From 55a3e0df9c7e8e4dd49434c9ee7cdee8896491af Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 25 Oct 2026 13:55:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor(admin-ui):=20=E6=88=90=E6=9C=AC?= =?UTF-8?q?=E4=B8=AD=E5=BF=83=E5=AF=BC=E8=88=AA=E6=94=B6=E6=95=9B=E4=B8=BA?= =?UTF-8?q?=204=20=E4=B8=AA=E5=9B=BA=E5=AE=9A=20Tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原结构是「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 通过。 --- .../src/pages/admin/AdminCostCenterPage.tsx | 13 +- .../admin/cost-center/CostCenterContext.ts | 51 +---- .../admin/cost-center/CostCenterShell.tsx | 201 +++++++++--------- .../admin/cost-center/CostConfigPanel.tsx | 73 +++++++ docs/README.md | 2 +- docs/成本中心API契约.md | 3 +- 6 files changed, 188 insertions(+), 155 deletions(-) create mode 100644 apps/frontend/src/pages/admin/cost-center/CostConfigPanel.tsx diff --git a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx index 873fc257..6501a0da 100644 --- a/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx +++ b/apps/frontend/src/pages/admin/AdminCostCenterPage.tsx @@ -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 成本解析弹窗(异常行 / 订单详情进入) */ diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts b/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts index b9a50294..81850d8e 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterContext.ts @@ -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 - costModelCounts?: Record - } 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 } diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx index 43b3360a..037d5ca5 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx @@ -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 = { - 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( 'admin-cost-center:scope', 'all', - (value): value is CostScope => SCOPES.some((item) => item.key === value), + isCostScope, + ) + const [activeTab, setActiveTab] = usePersistedState( + 'admin-cost-center:tab', + 'products', + isCostCenterTab, ) - const [activeTab, setActiveTab] = usePersistedState('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 ( @@ -147,12 +135,20 @@ export default function CostCenterShell() { } /> + + 统计口径 + {scopeLabel} + {scope !== 'all' ? ( + (跟随上方来源筛选变化) + ) : null} + + {[ ['标准商品', 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() { ))} - - { - 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, + + + setScope(value as CostScope)} + options={SCOPES.map((item) => ({ + value: item.key, label: ( - + {item.label} - {Number(productCount || 0)} 商品 - + + {Number( + item.key === 'all' + ? (overview?.productTotalCount ?? overview?.productCount ?? 0) + : (overview?.productSourceCounts?.[item.key] ?? 0), + )} + + ), - children: {item.hint}, - } - })} - /> + }))} + /> + {scopeHint} + {!canConfig ? ( @@ -202,13 +190,13 @@ export default function CostCenterShell() { type="info" showIcon message="当前账号为只读权限" - description="可以查看商品、映射、发单价格和成本异常;新增/修改需要 finance.config 权限。" + description="可以查看商品、映射、成本配置和成本异常;新增/修改需要 finance.config 权限。" /> ) : null} setActiveTab(key as CostCenterTab)} items={[ { key: 'products', @@ -218,22 +206,25 @@ export default function CostCenterShell() { { key: 'mappings', label: '渠道映射', - children: , + children: mappingsApplicable ? ( + + ) : ( + 归一,不经过渠道映射;要核对采购价请到「成本配置 → 自动发货采购价」。' + : '人工客服的订单项按内部 SKU 归一(man: 前缀),不走渠道映射;要配置消耗积分请到「成本配置 → 人工客服 SKU」。' + } + /> + ), }, { - key: 'affiliate-skus', - label: '自动发货 SKU', - children: , - }, - { - key: 'manual-skus', - label: '人工 SKU 配置', - children: , - }, - { - key: 'prices', - label: '发单价格', - children: , + key: 'cost', + label: '成本配置', + children: , }, { key: 'exceptions', @@ -249,7 +240,7 @@ export default function CostCenterShell() { ), children: , }, - ].filter((item) => visibleTabKeys.includes(item.key))} + ]} /> diff --git a/apps/frontend/src/pages/admin/cost-center/CostConfigPanel.tsx b/apps/frontend/src/pages/admin/cost-center/CostConfigPanel.tsx new file mode 100644 index 00000000..82bce6e4 --- /dev/null +++ b/apps/frontend/src/pages/admin/cost-center/CostConfigPanel.tsx @@ -0,0 +1,73 @@ +/** + * 成本配置:按来源展示「成本怎么算」。 + * + * 三个来源的成本形态完全不同,所以这里不做统一表格,而是按来源切换: + * 自动发货 → 上游 SKU 采购价(按 aff: 归一) + * 接单平台 → 接单模板发单金额(只读核对,改价去接单模板页) + * 人工客服 → 内部 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 + title: string + description: string +}> = [ + { + key: 'affiliate_dash', + title: '自动发货采购价', + description: '按上游货源 SKU 配采购成本,订单项直接按 aff: 归一,不走渠道映射。', + }, + { + 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 + if (scope === 'work_order') return + if (scope === 'manual') return + + return ( + + + 成本配置按来源分开维护,选择一个来源查看;也可以在页面顶部直接切换来源。 + + + {CONFIG_ENTRIES.map((entry) => ( + + setScope(entry.key)}> + 去配置 + + } + > + {entry.description} + + + ))} + + + ) +} diff --git a/docs/README.md b/docs/README.md index bfeab7f6..932af312 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | 现行说明(未实施) | 影子总账是什么、和钱包流水的区别、对账与转正标准 | diff --git a/docs/成本中心API契约.md b/docs/成本中心API契约.md index 874b1c1b..8176731e 100644 --- a/docs/成本中心API契约.md +++ b/docs/成本中心API契约.md @@ -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 |