Merge branch 'main' into dev

This commit is contained in:
2026-11-03 10:33:10 +08:00
121 changed files with 5265 additions and 1629 deletions
@@ -1,5 +1,6 @@
import { Table as AntTable } from 'antd'
import type { TableProps } from 'antd'
import type { TablePaginationConfig, TableProps } from 'antd'
import { useEffect, useState } from 'react'
import { normalizePercentColumns } from '@/utils/percent-table-layout'
@@ -12,7 +13,56 @@ import { normalizePercentColumns } from '@/utils/percent-table-layout'
export default function PercentTable<RecordType extends object = Record<string, unknown>>(
props: TableProps<RecordType>,
) {
const { columns, scroll, tableLayout: _tableLayout, ...rest } = props
const { columns, scroll, tableLayout: _tableLayout, pagination, dataSource, ...rest } = props
const paginationConfig = pagination && typeof pagination === 'object' ? pagination : null
const paginationIsExternallyControlled = paginationConfig?.current !== undefined
// 本地分页也必须由组件持有 current;只交给 AntD 内部状态时,父级重渲染会丢失翻页。
const [localPagination, setLocalPagination] = useState(() => ({
current: 1,
pageSize: Number(paginationConfig?.defaultPageSize || paginationConfig?.pageSize || 10),
}))
useEffect(() => {
if (paginationIsExternallyControlled || !paginationConfig?.pageSize) return
const nextPageSize = Number(paginationConfig.pageSize)
if (!Number.isFinite(nextPageSize) || nextPageSize <= 0) return
setLocalPagination((current) =>
current.pageSize === nextPageSize ? current : { current: 1, pageSize: nextPageSize },
)
}, [paginationConfig?.pageSize, paginationIsExternallyControlled])
useEffect(() => {
if (paginationIsExternallyControlled || !paginationConfig) return
const total = Math.max(0, Number(paginationConfig.total ?? dataSource?.length ?? 0))
const maxPage = Math.max(1, Math.ceil(total / Math.max(1, localPagination.pageSize)))
if (localPagination.current > maxPage) {
setLocalPagination((current) => ({ ...current, current: maxPage }))
}
}, [
dataSource?.length,
localPagination.current,
localPagination.pageSize,
paginationConfig,
paginationIsExternallyControlled,
])
const resolvedPagination: TablePaginationConfig | false | undefined = paginationConfig
? paginationIsExternallyControlled
? paginationConfig
: {
...paginationConfig,
current: localPagination.current,
pageSize: localPagination.pageSize,
onChange: (nextPage, nextPageSize) => {
const normalizedPageSize = Math.max(1, Number(nextPageSize) || localPagination.pageSize)
setLocalPagination({
current: normalizedPageSize === localPagination.pageSize ? nextPage : 1,
pageSize: normalizedPageSize,
})
paginationConfig.onChange?.(nextPage, nextPageSize)
},
}
: pagination
const reservedPercent =
(props.rowSelection ? 4 : 0) +
(props.expandable && typeof props.expandable === 'object' ? 4 : 0)
@@ -24,7 +74,9 @@ export default function PercentTable<RecordType extends object = Record<string,
return (
<AntTable<RecordType>
{...rest}
dataSource={dataSource}
columns={normalizedColumns}
pagination={resolvedPagination}
tableLayout="fixed"
scroll={{ ...scrollConfig, x: '100%' }}
/>
@@ -3,6 +3,7 @@ import {
AccountBookOutlined,
ApartmentOutlined,
BellOutlined,
BranchesOutlined,
BugOutlined,
DashboardOutlined,
FileSearchOutlined,
@@ -182,6 +183,7 @@ export default function AdminLayout() {
const isFinanceKey = [
'/admin/sales-profit',
'/admin/cost-center',
'/admin/fulfillment-channel',
'/admin/fund',
'/admin/finance',
'/admin/finance/ledger',
@@ -198,6 +200,7 @@ export default function AdminLayout() {
const financeSubItems: MenuProps['items'] = [
{ key: '/admin/sales-profit', icon: <LineChartOutlined />, label: '销售毛利' },
{ key: '/admin/cost-center', icon: <ApartmentOutlined />, label: '商品与成本' },
{ key: '/admin/fulfillment-channel', icon: <BranchesOutlined />, label: '履约渠道' },
{ key: '/admin/fund', icon: <WalletOutlined />, label: '钱包管理' },
{ key: '/admin/finance', icon: <AccountBookOutlined />, label: '收支明细' },
{ key: '/admin/finance/ledger', icon: <AuditOutlined />, label: '总账与对账' },
@@ -846,6 +849,7 @@ function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
if (pathname.startsWith('/admin/sales-profit')) return '/admin/sales-profit'
if (pathname.startsWith('/admin/cost-center')) return '/admin/cost-center'
if (pathname.startsWith('/admin/fulfillment-channel')) return '/admin/fulfillment-channel'
if (pathname.startsWith('/admin/fund')) return '/admin/fund'
if (pathname.startsWith('/admin/finance/ledger')) return '/admin/finance/ledger'
if (pathname.startsWith('/admin/finance')) return '/admin/finance'
@@ -27,6 +27,7 @@ import type { Dayjs } from 'dayjs'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import PercentTable from '@/components/common/PercentTable'
import { buildAdminTablePagination } from '@/utils/admin-pagination'
import dayjs from '@/lib/dayjs'
import {
closeFinancePeriod,
@@ -509,16 +510,15 @@ export default function AdminFinanceLedgerPage() {
dataSource={entriesQuery.data?.data.items || []}
columns={entryColumns}
onRow={(row) => ({ onClick: () => setEntryDetail(row) })}
pagination={{
pagination={buildAdminTablePagination({
current: entryPage,
pageSize: entryPageSize,
total: entriesQuery.data?.data.pagination.total || 0,
showSizeChanger: true,
onChange: (page, size) => {
setEntryPage(page)
setEntryPageSize(size)
},
}}
})}
/>
</Card>
</Space>
@@ -566,16 +566,15 @@ export default function AdminFinanceLedgerPage() {
loading={reconciliationsQuery.isLoading}
dataSource={reconciliationsQuery.data?.data.items || []}
columns={reconColumns}
pagination={{
pagination={buildAdminTablePagination({
current: reconPage,
pageSize: reconPageSize,
total: reconciliationsQuery.data?.data.pagination.total || 0,
showSizeChanger: true,
onChange: (page, size) => {
setReconPage(page)
setReconPageSize(size)
},
}}
})}
/>
</Card>
),
@@ -6,7 +6,12 @@
* · 没有命中规则的新单落在「待分类」,收入进未分类桶,不产生成本噪音;
* · 第三方卡券是「只统计」渠道:不建任务、不发领取页。
*/
import { PlusOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons'
import {
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
ThunderboltOutlined,
} from '@ant-design/icons'
import {
Alert,
Button,
@@ -24,13 +29,13 @@ import {
message,
} from 'antd'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import PercentTable from '@/components/common/PercentTable'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
import {
assignChannelBySku,
deleteChannelRule,
deleteChannelRules,
fetchChannelExceptions,
fetchChannelRules,
fetchPendingChannelGroups,
@@ -43,6 +48,7 @@ import {
} from '@/services/admin'
import { formatRedeemAmount } from '@/utils/redeem'
import { formatDateTime } from '@/utils/date-time'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
const CHANNEL_OPTIONS = [
{ value: 'work_order', label: '接单平台' },
@@ -58,11 +64,7 @@ const CHANNEL_COLOR: Record<string, string> = {
manual: 'orange',
}
const MATCH_KEY_OPTIONS = [
{ value: 'sku_code', label: '平台 SKU' },
{ value: 'internal_sku', label: '内部 SKU' },
{ value: 'sku_name', label: 'SKU 名称' },
]
const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }]
export default function AdminFulfillmentChannelPage() {
return (
@@ -84,11 +86,13 @@ export default function AdminFulfillmentChannelPage() {
function PendingPanel() {
const queryClient = useQueryClient()
const [keyword, setKeyword] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [selections, setSelections] = useState<Record<string, string>>({})
const query = useQuery({
queryKey: ['fulfillment-pending', keyword],
queryFn: () => fetchPendingChannelGroups({ page: 1, pageSize: 100, keyword }),
queryKey: ['fulfillment-pending', keyword, page, pageSize],
queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword }),
})
const assignMutation = useMutation({
@@ -100,7 +104,13 @@ function PendingPanel() {
channel: selections[rowKey(row)] || '',
}),
onSuccess: (result) => {
message.success(`已指定 ${result.data.updated} 个订单项`)
if (result.data.carrierFailed > 0) {
message.warning(
`已指定 ${result.data.updated} 个订单项,${result.data.carrierFailed} 个因载体创建失败仍待处理`,
)
} else {
message.success(`已指定 ${result.data.updated} 个订单项`)
}
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: () => message.error('指定渠道失败'),
@@ -135,6 +145,7 @@ function PendingPanel() {
no_internal_sku: '无内部SKU',
no_channel_capability: '无渠道能力',
no_carrier: '无履约载体',
routing_conflict: '渠道冲突',
}
return value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-'
},
@@ -188,7 +199,10 @@ function PendingPanel() {
allowClear
placeholder="SKU / 商品名"
style={{ width: 220 }}
onSearch={(value) => setKeyword(value)}
onSearch={(value) => {
setKeyword(value)
setPage(1)
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
@@ -207,7 +221,7 @@ function PendingPanel() {
type="info"
showIcon
style={{ marginBottom: 12 }}
message="待分类订单的收入已经计入「未分类」桶,不会丢失;指定渠道后会自动进入商品归一与成本核算。"
message="指定渠道会写入该内部 SKU 的全局配置,并影响所有平台和店铺;系统会先确认载体创建成功,再进入商品归一与成本核算。"
/>
<PercentTable
rowKey={(row) => rowKey(row)}
@@ -215,7 +229,15 @@ function PendingPanel() {
loading={query.isFetching}
dataSource={query.data?.data?.items || []}
columns={columns}
pagination={{ pageSize: 20, showSizeChanger: false }}
pagination={buildAdminTablePagination({
current: query.data?.data?.page || page,
pageSize: query.data?.data?.pageSize || pageSize,
total: query.data?.data?.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
},
})}
/>
</Card>
)
@@ -224,12 +246,21 @@ function PendingPanel() {
function RulesPanel() {
const queryClient = useQueryClient()
const [open, setOpen] = useState(false)
const [selectedRuleIds, setSelectedRuleIds] = useState<number[]>([])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [form] = Form.useForm()
const query = useQuery({
queryKey: ['fulfillment-rules'],
queryFn: () => fetchChannelRules(),
queryKey: ['fulfillment-rules', page, pageSize],
queryFn: () => fetchChannelRules({ page, pageSize }),
})
const ruleCount = query.data?.data?.total || 0
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(ruleCount / pageSize))
if (page > maxPage) setPage(maxPage)
}, [page, pageSize, ruleCount])
const saveMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => saveChannelRule(values as never),
@@ -243,17 +274,33 @@ function RulesPanel() {
})
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteChannelRule(id),
onSuccess: () => {
message.success('规则已删除')
mutationFn: (ids: number[]) => deleteChannelRules(ids),
onSuccess: (result) => {
const missingHint = result.data.missingIds.length
? `,${result.data.missingIds.length} 条已不存在`
: ''
message.success(`已删除 ${result.data.deletedCount} 条渠道规则${missingHint}`)
setSelectedRuleIds([])
void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] })
void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] })
},
onError: () => message.error('删除规则失败'),
})
function confirmDeleteRules(ids: number[]) {
if (ids.length === 0) return
Modal.confirm({
title: ids.length === 1 ? '删除渠道规则' : `批量删除 ${ids.length} 条渠道规则`,
content: '删除后会立即影响新订单的渠道判定;历史订单和成本数据不会被删除。',
okText: '确认删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => deleteMutation.mutateAsync(ids),
})
}
const columns = [
{ title: '来源', dataIndex: 'provider', width: 120, render: (v: string) => v || '不限' },
{ title: '店铺', dataIndex: 'shop_id', width: 140, render: (v: string) => v || '不限' },
{ title: '作用范围', width: 110, render: () => <Tag color="blue">全局</Tag> },
{
title: '匹配键',
dataIndex: 'match_key_type',
@@ -286,7 +333,7 @@ function RulesPanel() {
danger
size="small"
loading={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(row.id)}
onClick={() => confirmDeleteRules([row.id])}
>
删除
</Button>
@@ -296,9 +343,18 @@ function RulesPanel() {
return (
<Card
title={`渠道规则(${query.data?.data?.length ?? 0})`}
title={`统一渠道配置(${ruleCount})`}
extra={
<Space>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRuleIds.length === 0}
loading={deleteMutation.isPending}
onClick={() => confirmDeleteRules(selectedRuleIds)}
>
批量删除{selectedRuleIds.length ? ` (${selectedRuleIds.length})` : ''}
</Button>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
</Button>
@@ -309,20 +365,32 @@ function RulesPanel() {
}
>
<Typography.Paragraph type="secondary">
接单平台由接单模板匹配决定,不在此表维护;这里负责自动发货、人工、第三方卡券三个渠道与运营强制覆盖。
同优先级命中多个渠道时订单保持待分类并进「配置冲突」。
配置按内部 SKU 全局生效,会同时影响所有平台和店铺。每条内部 SKU 只能有一个主渠道;
其他渠道请设置 90 以上优先级作为兜底。接单平台的具体模板由统一配置中的 workRuleKey 控制。
</Typography.Paragraph>
<PercentTable
rowKey="id"
size="small"
loading={query.isFetching}
dataSource={query.data?.data || []}
dataSource={query.data?.data?.items || []}
columns={columns}
pagination={{ pageSize: 20, showSizeChanger: false }}
rowSelection={{
selectedRowKeys: selectedRuleIds,
onChange: (keys) => setSelectedRuleIds(keys.map(Number)),
}}
pagination={buildAdminTablePagination({
current: query.data?.data?.page || page,
pageSize: query.data?.data?.pageSize || pageSize,
total: ruleCount,
onChange: (nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
},
})}
/>
<Modal
title="新增渠道规则"
title="新增统一渠道配置"
open={open}
onCancel={() => setOpen(false)}
onOk={() => form.submit()}
@@ -331,15 +399,13 @@ function RulesPanel() {
<Form
form={form}
layout="vertical"
initialValues={{ matchKeyType: 'sku_code', channel: 'third_party_voucher', priority: 10 }}
initialValues={{
matchKeyType: 'internal_sku',
channel: 'third_party_voucher',
priority: 10,
}}
onFinish={(values) => saveMutation.mutate(values)}
>
<Form.Item name="provider" label="来源(留空=不限)">
<Input placeholder="91kaquan / pdd / douyin_mashida" />
</Form.Item>
<Form.Item name="shopId" label="店铺(留空=不限)">
<Input />
</Form.Item>
<Form.Item name="matchKeyType" label="匹配键" rules={[{ required: true }]}>
<Select options={MATCH_KEY_OPTIONS} />
</Form.Item>
@@ -366,9 +432,11 @@ function RulesPanel() {
function ExceptionsPanel() {
const [reason, setReason] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const query = useQuery({
queryKey: ['fulfillment-exceptions', reason],
queryFn: () => fetchChannelExceptions({ reason, status: 'open', page: 1, pageSize: 100 }),
queryKey: ['fulfillment-exceptions', reason, page, pageSize],
queryFn: () => fetchChannelExceptions({ reason, status: 'open', page, pageSize }),
})
const REASON_OPTIONS = [
@@ -408,7 +476,10 @@ function ExceptionsPanel() {
style={{ width: 180 }}
value={reason}
options={REASON_OPTIONS}
onChange={setReason}
onChange={(value) => {
setReason(value)
setPage(1)
}}
/>
<Button icon={<ReloadOutlined />} onClick={() => void query.refetch()}>
刷新
@@ -422,7 +493,15 @@ function ExceptionsPanel() {
loading={query.isFetching}
dataSource={query.data?.data?.items || []}
columns={columns}
pagination={{ pageSize: 20, showSizeChanger: false }}
pagination={buildAdminTablePagination({
current: query.data?.data?.page || page,
pageSize: query.data?.data?.pageSize || pageSize,
total: query.data?.data?.total || 0,
onChange: (nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
},
})}
/>
</Card>
)
@@ -835,7 +835,7 @@ export default function AdminTaskDetailPage() {
type="secondary"
style={{ fontSize: 12, display: 'block', marginBottom: 6 }}
>
历史/备用 Token 链接:
任务领取链接(统一 H5 订单号):
</Typography.Text>
<Space wrap align="center">
{claimUrl ? (
@@ -861,7 +861,7 @@ export default function AdminTaskDetailPage() {
disabled={!claimUrl}
onClick={copyClaimUrl}
>
复制 Token 链接
复制领取链接
</Button>
</Space>
</div>
@@ -22,7 +22,7 @@ import {
Tag,
Typography,
} from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { keepPreviousData, useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect } from 'react'
import FinanceSubNav from '@/components/admin/FinanceSubNav'
@@ -97,6 +97,7 @@ export default function CostCenterShell() {
const overviewQuery = useQuery({
queryKey: ['admin-cost-center-overview', scope],
queryFn: () => fetchAdminCostCenterOverview({ scope }),
placeholderData: keepPreviousData,
})
const optionsQuery = useQuery({
queryKey: ['admin-cost-center-options'],
@@ -105,6 +106,9 @@ export default function CostCenterShell() {
const overview = overviewQuery.data?.data
const options = optionsQuery.data?.data
const costCenterFetching = useIsFetching({
predicate: (query) => String(query.queryKey[0] ?? '').startsWith('admin-cost-center'),
})
function refreshAll() {
// 按前缀失效:新增一个 admin-cost-center-* 查询不需要再回来改这里。
@@ -145,7 +149,7 @@ export default function CostCenterShell() {
size="small"
icon={<ReloadOutlined />}
onClick={refreshAll}
loading={overviewQuery.isFetching}
loading={costCenterFetching > 0}
>
刷新
</Button>
@@ -162,7 +166,7 @@ export default function CostCenterShell() {
].map(([label, value]) => (
<Col xs={12} md={8} xl={4} key={label}>
<Card>
<Statistic title={label} value={value as number} />
<Statistic title={label} value={overview ? (value as number) : '—'} />
</Card>
</Col>
))}
@@ -179,11 +183,13 @@ export default function CostCenterShell() {
<Space size={4}>
<span>{item.label}</span>
<Tag>
{Number(
item.key === 'all'
? (overview?.productTotalCount ?? overview?.productCount ?? 0)
: (overview?.productSourceCounts?.[item.key] ?? 0),
)}
{overview
? Number(
item.key === 'all'
? (overview.productTotalCount ?? overview.productCount ?? 0)
: (overview.productSourceCounts?.[item.key] ?? 0),
)
: '—'}
</Tag>
</Space>
),
@@ -5,6 +5,7 @@
* aff: 自动发货 / tpl: 接单模板 / wo: 工单证据 / man: 人工。
*/
import {
ClearOutlined,
DeleteOutlined,
DollarOutlined,
EditOutlined,
@@ -33,7 +34,7 @@ import {
} from 'antd'
import type { TableColumnsType } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef, useState } from 'react'
import type { Dayjs } from 'dayjs'
@@ -41,6 +42,8 @@ import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
import dayjs from '@/lib/dayjs'
import {
applyAdminTemplateProductBootstrap,
archiveAdminHistoricalManualProducts,
archiveAdminSupersededManualProducts,
createAdminProductCatalog,
deleteAdminProductCatalog,
fetchAdminProductCatalog,
@@ -54,6 +57,7 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi
import { useCostCenter } from './CostCenterContext'
import {
FULFILLMENT_MODE_LABELS,
PRODUCT_SOURCE_OPTIONS,
PRODUCT_SOURCE_TAG_COLORS,
labelOf,
@@ -93,7 +97,7 @@ export function ProductCatalogPanel() {
const queryClient = useQueryClient()
const { canConfig, productSourceFilter, refreshAll } = useCostCenter()
const [productForm] = Form.useForm<ProductFormValues>()
// 初始化/回填只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
// 历史修复只处理最近 30 天,避免很早以前的乱 SKU 再次进入商品与映射。
const [backfillRange, setBackfillRange] = useState<[Dayjs, Dayjs]>([
dayjs().subtract(29, 'day').startOf('day'),
dayjs().endOf('day'),
@@ -105,6 +109,8 @@ export function ProductCatalogPanel() {
const [productStatusUpdatingId, setProductStatusUpdatingId] = useState<number | null>(null)
const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false)
const [bootstrapApplying, setBootstrapApplying] = useState(false)
const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false)
const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false)
const bootstrapSyncOptionsRef = useRef<BootstrapSyncOptions>({ includeDisabledTemplates: false })
const [productPage, setProductPage] = useState(1)
const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
@@ -146,6 +152,7 @@ export function ProductCatalogPanel() {
page: productPage,
pageSize: productPageSize,
}),
placeholderData: keepPreviousData,
})
const products = productsQuery.data?.data.items || []
@@ -174,7 +181,7 @@ export function ProductCatalogPanel() {
const data = response.data
bootstrapSyncOptionsRef.current.includeDisabledTemplates = false
modal.confirm({
title: '从接单模板全量同步商品与映射',
title: '修复接单模板历史商品与映射',
width: 720,
content: (
<Space direction="vertical" size={8}>
@@ -273,7 +280,7 @@ export function ProductCatalogPanel() {
? `(含停用模板 ${applied.disabledTemplateCount} 个,已建为归档商品)`
: ''
message.success(
`已同步 ${applied.templateCount} 个模板商品:新建 ${applied.createdProductCount} 个、刷新 ${applied.updatedProductCount} 个、复用 ${applied.reusedProductCount} 个${disabledText};新增 ${applied.createdMappingCount} 条映射,同步 ${applied.templateCostVersionCount} 条模板发单金额,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
`已同步 ${applied.templateCount} 个模板商品:新建 ${applied.createdProductCount} 个、刷新 ${applied.updatedProductCount} 个、复用 ${applied.reusedProductCount} 个${disabledText};新增 ${applied.createdMappingCount} 条映射,回填 ${applied.mappedOrderItemCount} 个订单项${rebuildText}${conflictText}`,
)
refreshAll()
} catch (error) {
@@ -344,7 +351,6 @@ export function ProductCatalogPanel() {
: undefined,
effectiveScope: 'immediate',
changeReason: '在商品与成本中心统一调整价格',
syncOpenOrders: true,
syncSnapshots: true,
})
setPriceModalOpen(true)
@@ -355,7 +361,6 @@ export function ProductCatalogPanel() {
effectiveScope: 'immediate' | 'custom'
effectiveFromCustom?: string
changeReason?: string
syncOpenOrders?: boolean
}) {
if (!pricingProduct) return
const yuan = Number(values.amountYuan)
@@ -375,7 +380,7 @@ export function ProductCatalogPanel() {
amountFen,
effectiveFrom,
changeReason: values.changeReason || '在商品与成本中心统一调整价格',
syncOpenOrders: values.syncOpenOrders !== false,
syncOpenOrders: true,
})
const result = response.data
let extraHint = ''
@@ -383,6 +388,12 @@ export function ProductCatalogPanel() {
extraHint = `,已同步 ${result.syncedOrders.updatedCount} 笔工单大厅待抢单`
}
message.success(`标准价格配置成功(¥${(amountFen / 100).toFixed(2)})${extraHint}`)
if (pricingProduct.canonicalSku.startsWith('tpl:') && !pricingProduct.templateEnabled) {
message.info('成本已配置;请回接单模板启用该模板后开始自动接单')
}
void queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-product-rules'],
})
setPriceModalOpen(false)
refreshAll()
} catch (error) {
@@ -441,6 +452,76 @@ export function ProductCatalogPanel() {
}
}
async function archiveHistoricalManuals() {
setArchivingHistoricalManuals(true)
try {
const preview = (await archiveAdminHistoricalManualProducts()).data
if (preview.candidateCount === 0) {
message.info('没有需要归档的历史人工商品')
return
}
modal.confirm({
title: '归档历史人工商品',
content: `将归档 ${preview.candidateCount} 个仅被已归档订单引用的历史人工商品。不会删除订单、订单项、成本或工单数据,商品只是从当前目录隐藏。`,
okText: '确认归档',
cancelText: '取消',
onOk: async () => {
setArchivingHistoricalManuals(true)
try {
const result = (await archiveAdminHistoricalManualProducts({ execute: true })).data
message.success(`已归档 ${result.archivedCount} 个历史人工商品`)
await queryClient.invalidateQueries({ queryKey: ['admin-cost-center-products'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-overview'] })
void queryClient.invalidateQueries({ queryKey: ['admin-cost-center-options'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '归档历史人工商品失败')
} finally {
setArchivingHistoricalManuals(false)
}
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '读取历史人工商品失败')
} finally {
setArchivingHistoricalManuals(false)
}
}
async function archiveSupersededManuals() {
setArchivingSupersededManuals(true)
try {
const preview = (await archiveAdminSupersededManualProducts()).data
if (preview.candidateCount === 0) {
message.info('没有已被其他渠道接管的人工归一商品')
return
}
modal.confirm({
title: '归档跨渠道重复的人工归一商品',
content: `发现 ${preview.candidateCount} 个 man: 商品已有自动发货、接单平台或卡券主渠道。将归档这些旧商品,并把仍指向它们的人工兜底改指当前主商品;不会删除商品行、订单、订单项、成本版本或成本快照。`,
okText: '确认归档',
cancelText: '取消',
onOk: async () => {
setArchivingSupersededManuals(true)
try {
const result = (await archiveAdminSupersededManualProducts({ execute: true })).data
message.success(
`已归档 ${result.archivedCount} 个重复商品,修正 ${result.repointedChannelCount} 条人工兜底`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '归档跨渠道重复商品失败')
} finally {
setArchivingSupersededManuals(false)
}
},
})
} catch (error) {
message.error(error instanceof Error ? error.message : '读取跨渠道重复商品失败')
} finally {
setArchivingSupersededManuals(false)
}
}
function deleteProduct(item: ProductCatalogItem) {
const affiliateHint =
item.source === 'affiliate_dash'
@@ -498,6 +579,22 @@ export function ProductCatalogPanel() {
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
单位:{row.unitName || '件'}
</Typography.Text>
{row.canonicalSku.startsWith('tpl:') ? (
<Tag color={row.templateEnabled ? 'green' : 'orange'} style={{ marginInlineEnd: 0 }}>
{row.templateEnabled ? '模板已启用' : '模板待启用'}
</Tag>
) : null}
{row.templatePricingMode ? (
<Tag color="geekblue" style={{ marginInlineEnd: 0 }}>
{row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'}
</Tag>
) : null}
{row.activePrimaryChannel && row.activePrimaryChannel !== row.source ? (
<Tag color="purple" style={{ marginInlineEnd: 0 }}>
当前主渠道:
{FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel}
</Tag>
) : null}
</Space>
</div>
),
@@ -659,6 +756,24 @@ export function ProductCatalogPanel() {
extra={
canConfig ? (
<Space size={6} wrap>
{productSourceFilter === 'manual' ? (
<>
<Button
icon={<ClearOutlined />}
loading={archivingSupersededManuals}
onClick={() => void archiveSupersededManuals()}
>
归档跨渠道重复商品
</Button>
<Button
icon={<ClearOutlined />}
loading={archivingHistoricalManuals}
onClick={() => void archiveHistoricalManuals()}
>
归档纯历史人工商品
</Button>
</>
) : null}
{productSourceFilter === 'work_order' ? (
<>
<AdminRangePicker
@@ -673,7 +788,7 @@ export function ProductCatalogPanel() {
loading={bootstrapPreviewing || bootstrapApplying}
onClick={() => void runTemplateBootstrap()}
>
从接单模板初始化
修复历史商品/映射
</Button>
</>
) : null}
@@ -783,7 +898,7 @@ export function ProductCatalogPanel() {
size="small"
columns={productColumns}
dataSource={products}
loading={productsQuery.isLoading}
loading={productsQuery.isFetching}
rowClassName={(row) => (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')}
scroll={{ x: 1180 }}
pagination={buildAdminTablePagination({
@@ -902,16 +1017,38 @@ export function ProductCatalogPanel() {
</div>
</div>
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`当前计价方式:${
pricingProduct.templatePricingMode === 'unit' ? '按 SKU 按件' : '固定金额'
}`}
description="计价方式由接单模板统一设置;这里只维护对应金额。"
/>
) : null}
<Form.Item
name="amountYuan"
label={
pricingProduct.source === 'affiliate_dash' ? '采购单价(元)' : '标准结算单价(元)'
pricingProduct.source === 'affiliate_dash'
? '采购单价(元)'
: pricingProduct.canonicalSku.startsWith('tpl:')
? pricingProduct.templatePricingMode === 'unit'
? '每件接单成本(元)'
: '整单接单成本(元)'
: '标准结算单价(元)'
}
rules={[{ required: true, message: '请填写价格' }]}
extra={
pricingProduct.source === 'affiliate_dash'
? '上游每发货 1 件扣除的采购成本金额'
: '接单打手发单/验收的基础结算单价'
: pricingProduct.canonicalSku.startsWith('tpl:')
? pricingProduct.templatePricingMode === 'unit'
? `订单有 N 件时,本单接单成本 = 当前金额 × N`
: '无论订单件数,当前金额就是整单接单成本'
: '接单打手发单/验收的基础结算单价'
}
>
<InputNumber
@@ -936,8 +1073,13 @@ export function ProductCatalogPanel() {
只影响之后的新订单;历史订单的成本与成交价已固化,不会被改价覆盖
</div>
</Radio>
<Radio value="custom">
<Radio value="custom" disabled={pricingProduct.canonicalSku.startsWith('tpl:')}>
<strong>指定生效时间(不能早于当前时间)</strong>
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
<div style={{ color: '#cf1322', fontSize: 12, marginTop: 2 }}>
接单模板当前只支持立即生效,避免模板价格与成本版本不同步
</div>
) : null}
</Radio>
</Radio.Group>
</Form.Item>
@@ -963,17 +1105,6 @@ export function ProductCatalogPanel() {
<Input placeholder="例如:成本中心统一定价调整 / 上游采购成本调整" />
</Form.Item>
{pricingProduct.canonicalSku.startsWith('tpl:') ? (
<Form.Item name="syncOpenOrders" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox>
<strong>立即同步至接单大厅待抢单工单(推荐)</strong>
<div style={{ color: '#64748b', fontSize: 12 }}>
接单大厅中所有未领取的该模板工单将立即同步为新价格;已领取的工单保持原价不变。
</div>
</Checkbox>
</Form.Item>
) : null}
{pricingProduct.source === 'affiliate_dash' ? (
<Form.Item name="syncSnapshots" valuePropName="checked" style={{ marginBottom: 12 }}>
<Checkbox>
@@ -27,25 +27,25 @@ import {
setAdminProductPrice,
} from '@/services/admin'
import type { ProductCatalogItem } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { useCostCenter } from './CostCenterContext'
const PAGE_SIZE = 100
export default function VoucherSkuPanel() {
const queryClient = useQueryClient()
const { canConfig, refreshAll } = useCostCenter()
const [page] = useState(1)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [selected, setSelected] = useState<ProductCatalogItem | null>(null)
const [ensuringRules, setEnsuringRules] = useState(false)
const [form] = Form.useForm<{ amountFen: number; changeReason?: string }>()
const query = useQuery({
queryKey: ['cost-center-voucher-products', page],
queryKey: ['cost-center-voucher-products', page, pageSize],
queryFn: () =>
fetchAdminProductCatalog({
carrierType: 'third_party_voucher',
page,
pageSize: PAGE_SIZE,
pageSize,
}),
})
@@ -154,7 +154,15 @@ export default function VoucherSkuPanel() {
loading={query.isFetching}
dataSource={items}
columns={columns}
pagination={{ pageSize: 20, showSizeChanger: false }}
pagination={buildAdminTablePagination({
current: page,
pageSize,
total,
onChange: (nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
},
})}
/>
<Modal
@@ -1,18 +1,18 @@
/**
* 发单价格 Tab:只读展示「接单模板 → 发单金额」的当前生效版本。
* 发单价格 Tab:展示由标准商品成本自动投影出的接单模板当前生效版本。
*
* 接单平台的成本以打手报酬为主,金额由接单模板(work_product_rules)决定,
* 这里只做核对视图;改价请改模板后点「同步模板发单金额」。
* 这里只做核对视图;改价在「标准商品」中完成并自动同步。
* 人工与自动发货的成本分别在各自 SKU 配置里维护,不在这里重复配置。
*/
import { SearchOutlined, SyncOutlined } from '@ant-design/icons'
import { Alert, App, Button, Card, Input, Select, Space, Tag, Typography } from 'antd'
import { SearchOutlined } from '@ant-design/icons'
import { Alert, Card, Input, Select, Space, Tag, Typography } from 'antd'
import PercentTable from '@/components/common/PercentTable'
import type { TableColumnsType } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { useState } from 'react'
import { fetchAdminCostVersions, syncAdminWorkOrderTemplateCostVersions } from '@/services/admin'
import { fetchAdminCostVersions } from '@/services/admin'
import type { CostVersion } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
@@ -22,14 +22,12 @@ import { useCostCenter } from './CostCenterContext'
import { formatRate } from './shared'
export function WorkOrderPricePanel() {
const { message } = App.useApp()
const { canConfig, refreshAll } = useCostCenter()
const { canConfig } = useCostCenter()
const [keyword, setKeyword] = useState('')
const [status, setStatus] = useState('active')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [syncing, setSyncing] = useState(false)
const versionsQuery = useQuery({
queryKey: ['admin-cost-center-cost-versions', 'work_order', keyword, status, page, pageSize],
@@ -46,22 +44,6 @@ export function WorkOrderPricePanel() {
const versions = versionsQuery.data?.data.items || []
const total = versionsQuery.data?.data.pagination.total || 0
async function syncTemplateCosts() {
setSyncing(true)
try {
const response = await syncAdminWorkOrderTemplateCostVersions()
const result = response.data
message.success(
`已同步 ${result.activeVersionCount} 条模板发单金额(新建 ${result.createdCount},停用 ${result.retiredCount}),关联 ${result.linkedSnapshotCount} 条成本快照`,
)
refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '同步接单模板发单金额失败')
} finally {
setSyncing(false)
}
}
const columns: TableColumnsType<CostVersion> = [
{
title: '接单规则 / 模板',
@@ -176,24 +158,14 @@ export function WorkOrderPricePanel() {
<Tag color="blue">{total} 条</Tag>
</Space>
}
extra={
canConfig ? (
<Button
icon={<SyncOutlined />}
loading={syncing}
onClick={() => void syncTemplateCosts()}
>
同步模板发单金额
</Button>
) : null
}
extra={canConfig ? <Tag color="green">自动同步</Tag> : null}
>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="接单平台的成本只看这张表;改价请改接单模板,再点「同步模板发单金额」"
description="人工客服的成本来自「人工 SKU 配置」里的消耗积分,自动发货的成本在「自动发货 SKU」里维护,二者都不在这里配置;系统内置成本项只有人工履约、自动发货采购、接单平台报酬 3 条。"
message="接单平台成本的唯一写入口是【标准商品 → 配置成本】"
description="改价保存后会自动更新成本版本、接单模板运行价格和本表;未领取的大厅工单同步新价,已领取工单保持原价。人工客服和自动发货成本分别在各自 SKU 配置中维护。"
/>
<Space style={{ marginBottom: 16 }} wrap>
<Input
@@ -41,6 +41,7 @@ import {
import type { PlatformCreditAccount, PlatformCreditEntry, PlatformCreditOwner } from '@/types/admin'
import { hasAdminPermission } from '@/utils/admin-auth'
import { formatAdminDateTime } from '@/utils/admin-time'
import { buildAdminLocalTablePagination } from '@/utils/admin-pagination'
const PLATFORM_TAG_COLORS: Record<string, string> = {
nanhang: 'blue',
@@ -406,7 +407,7 @@ export function PlatformCreditPanel() {
dataSource={visibleAccounts}
loading={accountsQuery.isLoading}
scroll={{ x: 1080 }}
pagination={{ pageSize: 20, showSizeChanger: true }}
pagination={buildAdminLocalTablePagination()}
locale={{ emptyText: '还没有客服积分账户' }}
/>
@@ -29,6 +29,7 @@ import {
Typography,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { buildAdminTablePagination } from '@/utils/admin-pagination'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
@@ -880,18 +881,16 @@ export default function ConsignmentInventoryPanel() {
dataSource={items}
loading={inventoriesQuery.isLoading}
scroll={{ x: 1590 }}
pagination={{
pagination={buildAdminTablePagination({
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showTotal: (t) => `共 ${t} 件物品`,
onChange: (p, ps) => {
setPage(p)
setPageSize(ps)
},
}}
})}
/>
</Card>
@@ -26,6 +26,7 @@ import {
App,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { buildAdminTablePagination } from '@/utils/admin-pagination'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
@@ -474,17 +475,16 @@ export default function ConsignmentSubmissionsPanel() {
dataSource={submissions}
loading={submissionsQuery.isLoading}
scroll={{ x: 1490 }}
pagination={{
pagination={buildAdminTablePagination({
current: submissionPage,
pageSize: submissionPageSize,
total: submissionTotal,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条提报记录`,
onChange: (p, ps) => {
setSubmissionPage(p)
setSubmissionPageSize(ps)
},
}}
})}
/>
</Card>
@@ -44,7 +44,11 @@ import {
import type { KuaishouMatchSource, WorkProductMatchLog } from '@/types/worker-platform'
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
import { formatAdminDateTime } from '@/utils/admin-time'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import {
ADMIN_DEFAULT_PAGE_SIZE,
buildAdminLocalTablePagination,
buildAdminTablePagination,
} from '@/utils/admin-pagination'
import { formatMoney } from './shared'
export default function ProductMatchPanel() {
@@ -366,7 +370,7 @@ export default function ProductMatchPanel() {
loading={sourcesQuery.isLoading}
columns={sourceColumns}
dataSource={sources}
pagination={{ pageSize: 10, showSizeChanger: false }}
pagination={buildAdminLocalTablePagination()}
size="small"
/>
</div>
@@ -18,7 +18,6 @@ import {
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Radio,
Select,
@@ -42,7 +41,6 @@ import {
fetchAdminWorkProductRules,
reprocessAdminKuaishouWorkOrderMatches,
saveAdminWorkProductRule,
syncAdminWorkProductRulePrice,
} from '@/services/admin'
import type {
CollectField,
@@ -73,7 +71,6 @@ export default function ProductRulesPanel() {
const hasInitializedRuleSelection = useRef(false)
const [editingRule, setEditingRule] = useState<WorkProductRule | null>(null)
const [syncingPrice, setSyncingPrice] = useState(false)
const [exportingRules, setExportingRules] = useState(false)
const [activeMappingId, setActiveMappingId] = useState<number | 'new' | null>(null)
const [sourcePrefill, setSourcePrefill] = useState<SourcePrefill | null>(null)
@@ -296,9 +293,7 @@ export default function ProductRulesPanel() {
itemTitleMatchType: rule.match.itemTitleMatchType,
skuNickMatchType: rule.match.skuNickMatchType,
categoryId: rule.categoryId || undefined,
pricingMode: rule.unitPriceFen > 0 ? 'unit' : 'fixed',
rewardAmount: rule.rewardAmount / 100,
unitPrice: rule.unitPriceFen / 100,
pricingMode: rule.pricingMode || 'fixed',
sharingEnabled: rule.sharing?.enabled || false,
sharingAutoFromOrder: rule.sharing?.autoFromOrder || false,
sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined,
@@ -342,9 +337,6 @@ export default function ProductRulesPanel() {
itemTitleMatchType?: 'contains' | 'exact'
skuNickMatchType?: 'contains' | 'exact'
categoryId?: number
rewardAmount?: number
pricingMode?: string
unitPrice?: number
sharingEnabled?: boolean
sharingAutoFromOrder?: boolean
sharingTotalQuantity?: number
@@ -358,10 +350,10 @@ export default function ProductRulesPanel() {
fieldsText?: string
enabled?: boolean
autoCreate?: boolean
pricingMode?: 'fixed' | 'unit'
sortOrder?: number
}) {
try {
const unitMode = values.pricingMode === 'unit'
const isFriendGift = values.acceptanceMode === 'friend_gift'
if (isFriendGift && values.sharingEnabled === true) {
message.error('好友赠送验收模式不支持拼单,请先关闭拼单')
@@ -372,12 +364,11 @@ export default function ProductRulesPanel() {
? Number(values.sharingTotalQuantity || 0) > 0 ||
Number(values.sharingUnitReward || 0) > 0
: hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward)
const { pricingMode: _pricingMode, giftCooldownUnit, giftCooldownValue, ...payload } = values
const { giftCooldownUnit, giftCooldownValue, ...payload } = values
const res = await saveAdminWorkProductRule({
...payload,
ruleId: editingRule?.ruleId,
ruleKey: editingRule?.ruleKey || '',
unitPrice: unitMode ? Number(values.unitPrice || 0) : 0,
acceptanceMode: isFriendGift ? 'friend_gift' : 'standard',
giftCooldownHours:
isFriendGift && giftCooldownUnit !== 'minutes' ? Number(giftCooldownValue || 0) : 0,
@@ -385,10 +376,16 @@ export default function ProductRulesPanel() {
isFriendGift && giftCooldownUnit === 'minutes' ? Number(giftCooldownValue || 0) : 0,
sharingEnabled: isFriendGift ? false : values.sharingEnabled === true,
sharingAutoFromOrder:
unitMode && values.sharingEnabled === true && !hasManualSharingOverride,
values.pricingMode === 'unit' &&
values.sharingEnabled === true &&
!hasManualSharingOverride,
})
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
const savedRule = res.data?.rule
if (res.data?.costConfigurationRequired) {
message.warning('模板已保存为停用。请到【商品与成本中心】配置成本后再启用')
} else {
message.success(editingRule ? '接单模板已更新' : '接单模板已创建')
}
if (savedRule) {
editRule(savedRule)
} else {
@@ -401,42 +398,15 @@ export default function ProductRulesPanel() {
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-summary'],
}),
queryClient.invalidateQueries({
predicate: (query) => String(query.queryKey[0] || '').startsWith('admin-cost-center'),
}),
])
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
}
}
function confirmSyncRulePrice() {
if (!editingRule) return
Modal.confirm({
title: '同步模板价格',
content:
'将同步接单大厅中该模板的待抢单价格。部分拼单仅调整剩余份额价格,已加入份额报酬保持不变。',
okText: '确认同步',
cancelText: '取消',
onOk: async () => {
setSyncingPrice(true)
try {
const response = await syncAdminWorkProductRulePrice(editingRule.ruleId)
const result = response.data
message.success(
`已同步 ${result.updatedCount} 单:待抢单 ${result.updatedNormalCount} 单,部分拼单 ${result.updatedPartialSharingCount} 单`,
)
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'] }),
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }),
])
} catch (error) {
message.error(error instanceof Error ? error.message : '同步价格失败')
throw error
} finally {
setSyncingPrice(false)
}
},
})
}
async function toggleRuleEnabled(rule: WorkProductRule, enabled: boolean) {
try {
const hasManualSharingOverride = hasManualSharingInput(
@@ -450,11 +420,12 @@ export default function ProductRulesPanel() {
platform: rule.platform,
productName: rule.productName,
categoryId: rule.categoryId || undefined,
rewardAmount: rule.rewardAmount / 100,
unitPrice: rule.unitPriceFen / 100,
pricingMode: rule.pricingMode,
sharingEnabled: rule.sharing?.enabled || false,
sharingAutoFromOrder:
rule.unitPriceFen > 0 && rule.sharing?.enabled === true && !hasManualSharingOverride,
rule.pricingMode === 'unit' &&
rule.sharing?.enabled === true &&
!hasManualSharingOverride,
sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined,
sharingUnitReward: hasManualSharingOverride
? (rule.sharing?.unitReward || 0) / 100
@@ -473,6 +444,9 @@ export default function ProductRulesPanel() {
await queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-product-rules'],
})
await queryClient.invalidateQueries({
predicate: (query) => String(query.queryKey[0] || '').startsWith('admin-cost-center'),
})
} catch (error) {
message.error(error instanceof Error ? error.message : '修改状态失败')
}
@@ -782,19 +756,22 @@ export default function ProductRulesPanel() {
},
},
{
title: '接单金额',
title: '接单价格',
key: 'reward',
width: '20%',
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong style={{ color: '#3f8600', fontSize: 12 }}>
{row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)}
</Typography.Text>
{row.unitPriceFen > 0 ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
按件: {formatMoney(row.unitPriceFen)}
{(row.pricingMode === 'unit' ? row.unitPriceFen : row.rewardAmount) > 0 ? (
<Typography.Text strong style={{ color: '#3f8600', fontSize: 12 }}>
{row.pricingMode === 'unit'
? `${formatMoney(row.unitPriceFen)} / 件`
: formatMoney(row.rewardAmount)}
</Typography.Text>
) : null}
) : (
<Tag color="orange" style={{ width: 'fit-content', margin: 0 }}>
待成本配置
</Tag>
)}
</div>
),
},
@@ -891,11 +868,6 @@ export default function ProductRulesPanel() {
+ 新建模板
</Button>
) : null}
{editingRule ? (
<Button loading={syncingPrice} size="middle" onClick={confirmSyncRulePrice}>
同步价格
</Button>
) : null}
<Button type="primary" size="middle" htmlType="submit" form="product-rule-form">
{editingRule ? '保存模板' : '创建模板'}
</Button>
@@ -913,10 +885,9 @@ export default function ProductRulesPanel() {
matchType: 'contains',
itemTitleMatchType: 'exact',
skuNickMatchType: 'exact',
pricingMode: 'fixed',
rewardAmount: 25,
enabled: true,
enabled: false,
autoCreate: true,
pricingMode: 'fixed',
sharingEnabled: false,
acceptanceMode: 'standard',
giftCooldownUnit: 'hours',
@@ -926,23 +897,21 @@ export default function ProductRulesPanel() {
}}
onFinish={saveRule}
>
{editingRule ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="全系统标准价格已统一收敛至【商品与成本中心】"
description={
<span>
接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往
<a href="#/admin/cost-center" style={{ marginLeft: 4, fontWeight: 500 }}>
【商品与成本中心】
</a>
进行修改。
</span>
}
/>
) : null}
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="接单模板维护计价方式和履约规则"
description={
<span>
在这里选择“固定金额”或“按 SKU 按件”;具体金额与价格同步统一由
<a href="#/admin/cost-center" style={{ margin: '0 4px', fontWeight: 500 }}>
商品与成本中心
</a>
管理。新模板默认停用,配置成本并启用模板后自动出现在商品目录。
</span>
}
/>
{/* 基础信息 */}
<div className="form-grid-3col">
@@ -965,58 +934,27 @@ export default function ProductRulesPanel() {
</Form.Item>
</div>
{/* Row 2: 计价方式 + 单价/金额 + 任务时限 */}
{/* Row 2: 任务时限 */}
<div className="form-grid-3col">
<Form.Item label="计价方式" name="pricingMode">
<Form.Item label="任务时限" name="timeoutMinutes">
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
</Form.Item>
<Form.Item
label="计价方式"
name="pricingMode"
rules={[{ required: true, message: '请选择计价方式' }]}
extra="固定金额按整单;按 SKU 按件会把成本中心金额乘以订单件数"
>
<Radio.Group
optionType="button"
buttonStyle="solid"
className="full-width"
options={[
{ value: 'fixed', label: '固定金额' },
{ value: 'unit', label: '按件计价' },
{ value: 'unit', label: '按 SKU 按件' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue('pricingMode') === 'unit' ? (
<Form.Item
label="按件单价"
name="unitPrice"
rules={[{ required: true, message: '请填写单价' }]}
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
>
<InputNumber
min={0.01}
step={1}
addonAfter="元"
className="full-width"
disabled={Boolean(editingRule)}
/>
</Form.Item>
) : (
<Form.Item
label="接单金额"
name="rewardAmount"
extra={editingRule ? '标准单价由商品与成本中心统一管理' : undefined}
>
<InputNumber
min={0}
step={1}
addonAfter="元"
className="full-width"
disabled={Boolean(editingRule)}
/>
</Form.Item>
)
}
</Form.Item>
<Form.Item label="任务时限" name="timeoutMinutes">
<InputNumber min={0} step={5} addonAfter="分钟" className="full-width" />
</Form.Item>
</div>
{/* 验收模式:好友赠送模式与拼单互斥 */}
@@ -1126,11 +1064,19 @@ export default function ProductRulesPanel() {
const sharingEnabled = getFieldValue('sharingEnabled')
const unitMode = getFieldValue('pricingMode') === 'unit'
const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift'
const hasConfiguredPrice =
Number(editingRule?.unitPriceFen || 0) > 0 ||
Number(editingRule?.rewardAmount || 0) > 0
return (
<div className="switches-inline-group span-2">
<Form.Item label="启用模板" name="enabled" valuePropName="checked">
<Switch size="small" />
<Form.Item
label="启用模板"
name="enabled"
valuePropName="checked"
tooltip={!hasConfiguredPrice ? '请先在商品与成本中心配置成本' : undefined}
>
<Switch size="small" disabled={!hasConfiguredPrice} />
</Form.Item>
<Form.Item label="自动建单" name="autoCreate" valuePropName="checked">
<Switch size="small" />
@@ -1139,9 +1085,15 @@ export default function ProductRulesPanel() {
label="启用拼单"
name="sharingEnabled"
valuePropName="checked"
tooltip={friendGiftMode ? '好友赠送模式不支持拼单' : undefined}
tooltip={
friendGiftMode
? '好友赠送模式不支持拼单'
: !hasConfiguredPrice
? '请先在商品与成本中心配置成本'
: undefined
}
>
<Switch size="small" disabled={friendGiftMode} />
<Switch size="small" disabled={friendGiftMode || !hasConfiguredPrice} />
</Form.Item>
{sharingEnabled ? (
@@ -14,6 +14,7 @@ import {
Tag,
} from 'antd'
import PercentTable from '@/components/common/PercentTable'
import { buildAdminTablePagination } from '@/utils/admin-pagination'
import type { ColumnsType } from 'antd/es/table'
import { useEffect, useState } from 'react'
@@ -635,16 +636,15 @@ export default function RiskControlPanel() {
dataSource={bans?.items || []}
loading={bansQuery.isLoading}
locale={{ emptyText: '暂无封禁记录' }}
pagination={{
pagination={buildAdminTablePagination({
current: banPage,
pageSize: banPageSize,
total: bans?.total || 0,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setBanPage(nextPage)
setBanPageSize(nextPageSize)
},
}}
})}
/>
</Card>
@@ -690,16 +690,15 @@ export default function RiskControlPanel() {
columns={eventColumns}
dataSource={events?.items || []}
loading={eventsQuery.isLoading}
pagination={{
pagination={buildAdminTablePagination({
current: page,
pageSize,
total: events?.total || 0,
showSizeChanger: true,
onChange: (nextPage, nextPageSize) => {
setPage(nextPage)
setPageSize(nextPageSize)
},
}}
})}
/>
</Card>
</Space>
@@ -1697,7 +1697,7 @@ function AffiliateDashPlatformPanel() {
<PercentTable<SkuMappingRow>
rowKey={(row) => row.productNo}
size="small"
pagination={{ pageSize: 10, showSizeChanger: false }}
pagination={buildAdminLocalTablePagination()}
dataSource={builtinSkuRows}
columns={[
{ title: '91 中文编码', dataIndex: 'productNo', key: 'productNo' },
+27 -11
View File
@@ -64,17 +64,6 @@ export function applyAdminTemplateProductBootstrap(payload?: {
)
}
export function syncAdminWorkOrderTemplateCostVersions() {
return apiPost<{
ruleCount: number
unitPriceRuleCount: number
activeVersionCount: number
createdCount: number
retiredCount: number
linkedSnapshotCount: number
}>('/api/v1/admin/cost-center/work-order-template-costs/sync')
}
export function backfillAdminWorkOrderProductSkus(payload?: {
dateFrom?: string
dateTo?: string
@@ -107,6 +96,32 @@ export function createAdminProductCatalog(payload: Record<string, unknown>) {
return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload)
}
export function archiveAdminHistoricalManualProducts(payload: { execute?: boolean } = {}) {
return apiPost<{
dryRun: boolean
candidateCount: number
archivedCount: number
sample: Array<{ id: number; canonical_sku: string; canonical_name: string }>
}>('/api/v1/admin/cost-center/products/archive-historical-manual', payload)
}
export function archiveAdminSupersededManualProducts(payload: { execute?: boolean } = {}) {
return apiPost<{
dryRun: boolean
candidateCount: number
archivedCount: number
repointedChannelCount: number
sample: Array<{
id: number
canonical_sku: string
canonical_name: string
primary_channel: string
target_product_id: number
target_product_sku: string
}>
}>('/api/v1/admin/cost-center/products/archive-superseded-manual', payload)
}
export function syncAdminAffiliateDashProducts() {
return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>(
'/api/v1/admin/cost-center/affiliate-products/sync',
@@ -494,6 +509,7 @@ export function setAdminProductPrice(
costVersionId: number
amountFen: number
effectiveFrom: string
pricingMode?: 'fixed' | 'unit'
syncedOrders?: {
candidateCount: number
updatedCount: number
@@ -24,15 +24,20 @@ export type PendingChannelGroupListResult = {
/** 渠道规则(L1)。 */
export type ChannelRule = {
id: number
provider: string
shop_id: string
match_key_type: 'sku_code' | 'internal_sku' | 'sku_name'
match_key_type: 'internal_sku'
match_key: string
channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual'
priority: number
enabled: boolean
}
export type ChannelRuleListResult = {
items: ChannelRule[]
total: number
page: number
pageSize: number
}
export type ChannelException = {
id: number
reason: string
@@ -100,7 +105,10 @@ export function assignChannelBySku(payload: {
channel: string
note?: string
}) {
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload)
return apiPost<{ updated: number; carrierFailed: number; ruleCreated: boolean }>(
'/api/v1/admin/fulfillment/assign',
payload,
)
}
export function assignChannelToItems(payload: {
@@ -108,17 +116,32 @@ export function assignChannelToItems(payload: {
channel: string
note?: string
}) {
return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload)
return apiPost<{ updated: number; carrierFailed: number }>(
'/api/v1/admin/fulfillment/assign',
payload,
)
}
export function fetchChannelRules(params: { channel?: string } = {}) {
return apiGet<ChannelRule[]>('/api/v1/admin/fulfillment/rules', params)
export function switchChannelForItems(payload: {
orderItemIds: number[]
channel: string
note?: string
}) {
return apiPost<{
switched: number
failed: number
errors: Array<{ orderItemId: number; reason: string }>
}>('/api/v1/admin/fulfillment/switch', payload)
}
export function fetchChannelRules(
params: { channel?: string; page?: number; pageSize?: number } = {},
) {
return apiGet<ChannelRuleListResult>('/api/v1/admin/fulfillment/rules', params)
}
export function saveChannelRule(payload: {
id?: number
provider?: string
shopId?: string
matchKeyType: string
matchKey: string
channel: string
@@ -133,6 +156,14 @@ export function deleteChannelRule(ruleId: number) {
return apiDelete<{ deleted: boolean }>(`/api/v1/admin/fulfillment/rules/${ruleId}`)
}
export function deleteChannelRules(ids: number[]) {
return apiPost<{
deletedCount: number
deletedIds: number[]
missingIds: number[]
}>('/api/v1/admin/fulfillment/rules/batch-delete', { ids })
}
export function fetchPipelineStatus() {
return apiGet<FinancePipelineStatus[]>('/api/v1/admin/fulfillment/pipeline/status')
}
@@ -291,8 +291,7 @@ export function saveAdminWorkProductRule(payload: {
categoryId?: number
enabled?: boolean
autoCreate?: boolean
rewardAmount?: number
unitPrice?: number
pricingMode?: 'fixed' | 'unit'
sharingEnabled?: boolean
sharingAutoFromOrder?: boolean
sharingTotalQuantity?: number
@@ -306,7 +305,12 @@ export function saveAdminWorkProductRule(payload: {
fieldsText?: string
sortOrder?: number
}) {
return apiPost<{ rule: WorkProductRule }>('/api/v1/admin/worker-platform/product-rules', payload)
return apiPost<{
rule: WorkProductRule
productId?: number
productStatus?: 'active' | 'archived'
costConfigurationRequired?: boolean
}>('/api/v1/admin/worker-platform/product-rules', payload)
}
export function syncAdminWorkProductRulePrice(ruleId: number) {
+4 -3
View File
@@ -51,6 +51,10 @@ export interface ProductCatalogItem {
costVersionCount: number
activeCostAmountFen?: number | null
activeCostEffectiveFrom?: string | null
templatePricingMode?: 'fixed' | 'unit' | null
templateEnabled?: boolean | null
templateSharingEnabled?: boolean | null
activePrimaryChannel?: FulfillmentChannel | null
/** 自动发货履约任务数,实时取自 fulfillment_tasks。 */
affiliateTaskCount: number
affiliateLastFulfilledAt: string | null
@@ -231,9 +235,6 @@ export interface TemplateProductBootstrapResult {
resetOrderItemCount: number
mappingConflictCount: number
mappingConflictItemCount: number
templateCostVersionCount: number
templateCostCreatedCount: number
templateCostRetiredCount: number
}
export interface CostLine {
@@ -438,6 +438,7 @@ export type WorkProductRule = {
autoCreate: boolean
rewardAmount: number
unitPriceFen: number
pricingMode: 'fixed' | 'unit'
sharing?: WorkOrderSharingConfig
requirement: {
fields: CollectField[]
+40 -10
View File
@@ -87,22 +87,52 @@ export function describeMissingConfigFields(config: MissingConfigCheck): string[
/**
* 面板预览用的示例订单号(与真实 16 位平台订单号形态一致)。
*/
const CLAIM_LINK_PREVIEW_TOKEN = '2628301923197355'
const CLAIM_LINK_PREVIEW_ORDER_NO = '2628301923197355'
/**
* 拼一条领取链接预览:`claimLinkBase` 去尾斜杠 + '/' + token。
* 规则与后端拼装一致(后端用配置里的 `claimLinkBase`)。
* 基址为空返回空串(后端会回退到通用兑换链接)。
* 拼一条领取链接预览:`<claimLinkBase>/<订单号>`。
* 规则与后端一致,历史 `/#/claim`、`/claim` 基址也会归一化为 `/h5/订单号`。
* 基址为空返回空串。
*/
export function buildDouyinMashidaClaimLinkPreview(
claimLinkBase: string,
token = CLAIM_LINK_PREVIEW_TOKEN,
orderNo = CLAIM_LINK_PREVIEW_ORDER_NO,
): string {
const base = String(claimLinkBase ?? '')
.trim()
.replace(/\/+$/, '')
if (!base) return ''
return `${base}/${String(token ?? '').trim()}`
const base = normalizeH5ClaimBase(claimLinkBase)
const normalizedOrderNo = String(orderNo ?? '').trim()
if (!base || !normalizedOrderNo) return ''
return `${base}/${encodeURIComponent(normalizedOrderNo)}`
}
/** 将历史 `/#/claim`、`/claim` 基址归一化为同站点 `/h5`,与后端生成口径一致。 */
function normalizeH5ClaimBase(claimLinkBase: string): string {
const raw = String(claimLinkBase ?? '').trim()
if (!raw) return ''
let url: URL
try {
url = new URL(raw)
} catch {
return ''
}
const pathname = url.pathname.replace(/\/+$/, '')
const h5Match = pathname.match(/\/h5(?:\/|$)/i)
const claimMatch = pathname.match(/\/claim(?:\/|$)/i)
if (h5Match?.index !== undefined) {
url.pathname = `${pathname.slice(0, h5Match.index)}/h5`
} else if (claimMatch?.index !== undefined) {
url.pathname = `${pathname.slice(0, claimMatch.index)}/h5`
} else if (pathname && pathname !== '/') {
url.pathname = `${pathname}/h5`
} else {
url.pathname = '/h5'
}
url.search = ''
url.hash = ''
return url.toString().replace(/\/+$/, '')
}
/**