feat(consignment): 支持复制寄卖模板配置与一二级分类折叠展示

This commit is contained in:
2026-11-09 16:00:09 +08:00
parent 6867431a72
commit 89a3d23874
10 changed files with 969 additions and 38 deletions
@@ -170,6 +170,18 @@ test('寄售仓库筛选选项:只取已发布且分类启用的配置,并
assert.ok(body.includes('inventoryCountByCategoryId'), '无库存的继承子分类不应出现')
})
test('寄卖配置:后台返回父分类与发布快照,打手端返回父分类层级字段', async () => {
const source = await readFile(
new URL('./consignment-profile-service.ts', import.meta.url),
'utf8',
)
assert.ok(source.includes('pc.name AS category_parent_name'), '必须返回父分类名称')
assert.ok(source.includes('publishedConfig'), '必须返回已发布版本配置')
assert.ok(source.includes('categoryParentName'), '打手端必须返回父分类名称')
assert.ok(source.includes('categoryParentId'), '打手端必须返回父分类 ID')
})
test('规格字段 filterable:选项类默认开启,其它类型默认关闭,显式值优先', () => {
const [optionField] = normalizeConsignmentSpecs([buildSpec()])
assert.equal(optionField?.filterable, true)
@@ -125,7 +125,10 @@ export type ConsignmentProfileRow = {
created_at: string
updated_at: string
category_name?: string
category_key?: string
category_parent_id?: number | null
/** 父分类名称:打手端/复制模板弹窗按「一级 / 二级」展示 */
category_parent_name?: string | null
current_version_no?: number | null
/** 当前发布版本快照(consignment_profile_versions.config_json) */
current_version_config_json?: unknown
@@ -366,8 +369,10 @@ function mapProfile(row: ConsignmentProfileRow) {
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
categoryKey: row.category_key || '',
categoryName: row.category_name || '',
categoryParentId: row.category_parent_id ? Number(row.category_parent_id) : null,
categoryParentName: row.category_parent_name || '',
name: row.name,
title: row.title || '',
description: row.description || '',
@@ -376,6 +381,10 @@ function mapProfile(row: ConsignmentProfileRow) {
currentVersionId: row.current_version_id ? Number(row.current_version_id) : null,
currentVersionNo: row.current_version_no ? Number(row.current_version_no) : null,
config: normalizeConsignmentProfileConfig(row.config_json),
/** 当前发布版本快照;从未发布过为 null。复制模板弹窗的「已发布版本」用它。 */
publishedConfig: row.current_version_config_json
? normalizeConsignmentProfileConfig(row.current_version_config_json)
: null,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
@@ -385,11 +394,14 @@ const PROFILE_SELECT = `
SELECT
p.*,
c.name AS category_name,
c.category_key AS category_key,
c.parent_id AS category_parent_id,
pc.name AS category_parent_name,
v.version_no AS current_version_no,
v.config_json AS current_version_config_json
FROM consignment_profiles p
INNER JOIN work_categories c ON c.id = p.category_id
LEFT JOIN work_categories pc ON pc.id = c.parent_id
LEFT JOIN consignment_profile_versions v ON v.id = p.current_version_id
`
@@ -582,7 +594,10 @@ export async function listWorkerConsignmentProfiles(workerId?: number) {
return {
profileId: Number(row.id),
categoryId: Number(row.category_id),
categoryKey: row.category_key || '',
categoryName: row.category_name || '',
categoryParentId: row.category_parent_id ? Number(row.category_parent_id) : null,
categoryParentName: row.category_parent_name || '',
name: row.name,
title: row.title || row.name,
description: row.description || '',
@@ -0,0 +1,403 @@
import { Alert, Empty, Modal, Segmented, Space, Tag, TreeSelect, Typography } from 'antd'
import { useEffect, useMemo, useState } from 'react'
import type {
AdminConsignmentProfilesResponse,
ConsignmentProfile,
ConsignmentProfileConfig,
} from '@/types/consignment'
import { buildConsignmentCategoryGroups } from '@/utils/consignment-category-tree'
const { Text } = Typography
export type ConsignmentTemplateCopyMode = 'specs' | 'intake'
export type ConsignmentTemplateCopyVersion = 'published' | 'draft'
export type ConsignmentTemplateCopySelection = {
profile: ConsignmentProfile
config: ConsignmentProfileConfig
version: ConsignmentTemplateCopyVersion
}
type Category = AdminConsignmentProfilesResponse['categories'][number]
type TemplateOption = {
value: number
label: string
disabled: boolean
profile: ConsignmentProfile
config: ConsignmentProfileConfig | null
fields: Array<{ label: string; detail: string }>
}
type TemplateTreeNode = {
key: string
value: number
title: string
selectable: boolean
disabled?: boolean
kind: 'group' | 'root' | 'child'
option?: TemplateOption
groupCount?: number
children?: TemplateTreeNode[]
}
function resolveCategoryPath(
profile: ConsignmentProfile,
categoryNames: Map<number, string>,
): string {
const ownName =
profile.categoryName || categoryNames.get(profile.categoryId) || profile.name || '未命名分类'
if (!profile.categoryParentId) return ownName
const parentName =
profile.categoryParentName || categoryNames.get(profile.categoryParentId) || '上级分类'
return `${parentName} / ${ownName}`
}
function resolveTemplateConfig(
profile: ConsignmentProfile,
version: ConsignmentTemplateCopyVersion,
): ConsignmentProfileConfig | null {
return version === 'published' ? (profile.publishedConfig ?? null) : profile.config
}
function resolveTemplateFields(
config: ConsignmentProfileConfig,
mode: ConsignmentTemplateCopyMode,
): Array<{ label: string; detail: string }> {
if (mode === 'specs') {
return config.specs.map((spec) => ({
label: spec.label || spec.key,
detail:
spec.type === 'select' || spec.type === 'radio'
? `${spec.type} · ${spec.options.filter((option) => option.enabled !== false).length} 个选项`
: spec.type,
}))
}
return (config.intake?.fields ?? []).map((field) => ({
label: field.label || field.key,
detail: `${field.type}${field.required ? ' · 必填' : ''}`,
}))
}
/** 按一级分类聚合排序:一级在前,其二级紧随其后,孤立二级排在最后。 */
function orderProfilesByCategory(profiles: ConsignmentProfile[]): ConsignmentProfile[] {
const roots: ConsignmentProfile[] = []
const childrenByParent = new Map<number, ConsignmentProfile[]>()
for (const profile of profiles) {
if (profile.categoryParentId) {
const list = childrenByParent.get(profile.categoryParentId) || []
list.push(profile)
childrenByParent.set(profile.categoryParentId, list)
} else {
roots.push(profile)
}
}
const ordered: ConsignmentProfile[] = []
const included = new Set<number>()
for (const root of roots) {
ordered.push(root)
included.add(root.profileId)
for (const child of childrenByParent.get(root.categoryId) || []) {
if (included.has(child.profileId)) continue
ordered.push(child)
included.add(child.profileId)
}
}
// 只有二级配置、没有一级配置的历史数据:仍需可选,排到最后。
for (const profile of profiles) {
if (included.has(profile.profileId)) continue
ordered.push(profile)
included.add(profile.profileId)
}
return ordered
}
export default function ConsignmentTemplateCopyModal({
open,
mode,
currentCategoryId,
categories,
profiles,
onCancel,
onApply,
}: {
open: boolean
mode: ConsignmentTemplateCopyMode
currentCategoryId: number | null
categories: Category[]
profiles: ConsignmentProfile[]
onCancel: () => void
onApply: (selection: ConsignmentTemplateCopySelection) => void
}) {
const [version, setVersion] = useState<ConsignmentTemplateCopyVersion>('published')
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(null)
const [treeExpandedKeys, setTreeExpandedKeys] = useState<Array<string | number>>([])
const categoryNames = useMemo(
() => new Map(categories.map((category) => [category.categoryId, category.name])),
[categories],
)
const selectableProfiles = useMemo(
() =>
orderProfilesByCategory(
profiles.filter((profile) => profile.categoryId !== currentCategoryId),
),
[profiles, currentCategoryId],
)
const hasPublished = selectableProfiles.some((profile) => Boolean(profile.publishedConfig))
const options = useMemo<TemplateOption[]>(
() =>
selectableProfiles.map((profile) => {
const config = resolveTemplateConfig(profile, version)
const fields = config ? resolveTemplateFields(config, mode) : []
const versionLabel =
version === 'published'
? profile.currentVersionNo
? `v${profile.currentVersionNo}`
: '未发布'
: '草稿'
const statusSuffix = profile.status === 'archived' ? ' · 已归档' : ''
const ownName = profile.categoryName || profile.name || '未命名分类'
return {
value: profile.profileId,
label: `${ownName}(${versionLabel}${statusSuffix})`,
disabled: fields.length === 0,
profile,
config,
fields,
}
}),
[selectableProfiles, version, mode],
)
const treeData = useMemo<TemplateTreeNode[]>(() => {
const groups = buildConsignmentCategoryGroups({
items: options,
getCategoryId: (option) => option.profile.categoryId,
getParentId: (option) => option.profile.categoryParentId,
getParentName: (option) => option.profile.categoryParentName,
getFallbackName: (option) => resolveCategoryPath(option.profile, categoryNames),
})
return groups.flatMap((group) => {
const root = group.root
const rootLabel = root?.label || group.groupName || '未命名分类'
const childNodes: TemplateTreeNode[] = group.children.map((option) => ({
key: `child-${option.value}`,
value: option.value,
title: option.label,
selectable: true,
disabled: option.disabled,
kind: 'child',
option,
}))
if (root && childNodes.length === 0) {
return [
{
key: `root-${root.value}`,
value: root.value,
title: root.label,
selectable: true,
disabled: root.disabled,
kind: 'root',
option: root,
},
]
}
if (!root) {
if (childNodes.length === 0) return []
const groupNode: TemplateTreeNode = {
key: `group-${group.groupCategoryId}`,
value: -group.groupCategoryId,
title: rootLabel,
selectable: false,
kind: 'group',
groupCount: childNodes.length,
children: childNodes,
}
return [groupNode]
}
return [
{
key: `group-${group.groupCategoryId}`,
value: root.value,
title: root.label,
selectable: !root.disabled,
kind: 'group',
option: root,
groupCount: childNodes.length,
children: childNodes,
},
]
})
}, [options, categoryNames])
const treeGroupKeys = useMemo(
() => treeData.filter((node) => node.children?.length).map((node) => node.key),
[treeData],
)
useEffect(() => {
if (!open) return
setVersion(hasPublished ? 'published' : 'draft')
setSelectedProfileId(null)
}, [open, hasPublished])
useEffect(() => {
setTreeExpandedKeys([])
}, [open, version, mode])
useEffect(() => {
if (!open) return
if (
selectedProfileId !== null &&
options.some((option) => option.value === selectedProfileId && !option.disabled)
) {
return
}
setSelectedProfileId(options.find((option) => !option.disabled)?.value ?? null)
}, [open, options, selectedProfileId])
const selectedOption = options.find((option) => option.value === selectedProfileId) || null
const selectedFields = selectedOption?.fields || []
return (
<Modal
open={open}
width={680}
title={mode === 'specs' ? '复制规格字段模板' : '复制联系方式模板'}
okText="复制到当前配置"
cancelText="取消"
okButtonProps={{ disabled: !selectedOption || selectedFields.length === 0 }}
onOk={() => {
if (!selectedOption?.config) return
onApply({
profile: selectedOption.profile,
config: selectedOption.config,
version,
})
}}
onCancel={onCancel}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space size={12} wrap>
<Text type="secondary">来源版本</Text>
<Segmented
value={version}
options={[
{ label: '已发布版本', value: 'published', disabled: !hasPublished },
{ label: '当前草稿', value: 'draft' },
]}
onChange={(value) => setVersion(value as ConsignmentTemplateCopyVersion)}
/>
</Space>
<TreeSelect<number, TemplateTreeNode>
style={{ width: '100%' }}
placeholder={
options.some((option) => !option.disabled)
? '请选择要复制的寄卖配置'
: '没有可复制的寄卖配置'
}
value={selectedProfileId ?? undefined}
treeData={treeData}
treeDefaultExpandAll={false}
treeExpandedKeys={treeExpandedKeys}
onTreeExpand={(keys) => setTreeExpandedKeys(keys as Array<string | number>)}
showSearch={{
treeNodeFilterProp: 'title',
onSearch: (value) => setTreeExpandedKeys(value.trim() ? treeGroupKeys : []),
}}
onOpenChange={(open) => {
if (!open) setTreeExpandedKeys([])
}}
popupMatchSelectWidth={false}
listHeight={320}
treeTitleRender={(node) => {
const item = node as TemplateTreeNode
if (item.kind === 'group') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
{item.option ? <Tag color="blue">一级</Tag> : null}
</span>
)
}
if (!item.option) return item.title
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.option.label}</span>
{item.kind === 'root' ? <Tag color="blue">一级</Tag> : <Tag>二级</Tag>}
{item.option.fields.length === 0 ? <Tag>无字段</Tag> : null}
</span>
)
}}
onChange={(value) => {
if (typeof value === 'number' && value > 0) setSelectedProfileId(value)
}}
/>
{version === 'draft' && selectedOption?.profile.status === 'published' ? (
<Alert
type="info"
showIcon
message="当前复制的是后台保存的草稿,不是线上已发布版本;如需线上配置请切换为「已发布版本」。"
/>
) : null}
{mode === 'specs' && selectedOption?.config?.specSource === 'template' ? (
<Alert
type="warning"
showIcon
message="来源是模板驱动规格:复制后 ruleKey 仍指向来源分类的接单模板,价格会按来源模板计算。"
description="如果希望按当前分类的接单模板取价,请改用「从接单模板生成」。"
/>
) : null}
{selectedOption && selectedFields.length > 0 ? (
<div>
<Text strong>
将复制 {selectedFields.length} 个{mode === 'specs' ? '规格字段' : '联系方式字段'}
</Text>
<div
style={{
marginTop: 8,
display: 'flex',
flexWrap: 'wrap',
gap: 6,
maxHeight: 240,
overflowY: 'auto',
}}
>
{selectedFields.map((field) => (
<Tag key={`${field.label}-${field.detail}`}>
{field.label} · {field.detail}
</Tag>
))}
</div>
{selectedOption.config ? (
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
复制只替换当前区块,不影响价格、保证金、服务费、匹配和超时设置;应用后请点「保存草稿」。
</Text>
) : null}
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
options.length > 0
? '该版本没有可复制的字段,请换一个配置或切换版本'
: version === 'published'
? '没有可用的已发布配置,请切换到「当前草稿」'
: '没有可复制的字段'
}
/>
)}
</Space>
</Modal>
)
}
@@ -1,6 +1,7 @@
import {
ArrowDownOutlined,
ArrowUpOutlined,
CopyOutlined,
DeleteOutlined,
DownOutlined,
PlusOutlined,
@@ -34,6 +35,10 @@ import {
import { useEffect, useMemo, useState, type CSSProperties } from 'react'
import PercentTable from '@/components/common/PercentTable'
import ConsignmentTemplateCopyModal, {
type ConsignmentTemplateCopyMode,
type ConsignmentTemplateCopySelection,
} from '@/components/admin/ConsignmentTemplateCopyModal'
import {
archiveAdminConsignmentProfile,
fetchAdminConsignmentProfiles,
@@ -580,7 +585,7 @@ const fieldCellStyle: CSSProperties = {
}
export default function ConsignmentConfigPanel() {
const { message } = App.useApp()
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
const [categoryKeyword, setCategoryKeyword] = useState('')
@@ -589,6 +594,7 @@ export default function ConsignmentConfigPanel() {
const [specDrafts, setSpecDrafts] = useState<ConsignmentSpecField[]>([])
const [intakeDrafts, setIntakeDrafts] = useState<ConsignmentIntakeField[]>([])
const [specSource, setSpecSource] = useState<'manual' | 'template'>('manual')
const [copyTemplateMode, setCopyTemplateMode] = useState<ConsignmentTemplateCopyMode | null>(null)
const [proposalOpen, setProposalOpen] = useState(false)
// 按数量(规格)保证金:弹窗里选字段 + 配每个选项的每件保证金
const [depositSpecOpen, setDepositSpecOpen] = useState(false)
@@ -608,6 +614,7 @@ export default function ConsignmentConfigPanel() {
() => new Map(profiles.map((profile) => [profile.categoryId, profile])),
[profiles],
)
const hasCopyableProfiles = profiles.some((profile) => profile.categoryId !== selectedCategoryId)
const normalizedKeyword = categoryKeyword.trim().toLowerCase()
const categoryTree = useMemo<CategoryNode[]>(() => {
@@ -756,6 +763,43 @@ export default function ConsignmentConfigPanel() {
setIntakeDrafts(cloneIntakeFields(DEFAULT_CONSIGNMENT_INTAKE_FIELDS))
}
function handleCopyTemplate(selection: ConsignmentTemplateCopySelection) {
const mode = copyTemplateMode
if (!mode) return
const sourceLabel = [
selection.profile.categoryParentName,
selection.profile.categoryName || selection.profile.name,
]
.filter(Boolean)
.join(' / ')
const apply = () => {
if (mode === 'specs') {
setSpecDrafts(cloneSpecs(selection.config.specs))
setSpecSource(selection.config.specSource === 'template' ? 'template' : 'manual')
message.success(
`已复制「${sourceLabel}」的 ${selection.config.specs.length} 个规格字段,请保存草稿`,
)
} else {
const fields = selection.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
setIntakeDrafts(cloneIntakeFields(fields))
message.success(`已复制「${sourceLabel}」的 ${fields.length} 个联系方式字段,请保存草稿`)
}
setCopyTemplateMode(null)
}
const existingCount = mode === 'specs' ? specDrafts.length : intakeDrafts.length
if (existingCount > 0) {
modal.confirm({
title: '覆盖当前字段?',
content: `当前区块已有 ${existingCount} 个字段,复制模板会全部替换,未保存的修改将丢失。`,
okText: '覆盖并复制',
cancelText: '取消',
onOk: apply,
})
return
}
apply()
}
async function handleSave() {
if (!selectedCategoryId) return
const specs = specDrafts.map((spec) =>
@@ -1248,6 +1292,14 @@ export default function ConsignmentConfigPanel() {
description="初始为空;字段随配置版本发布,并写入提报与库存快照"
/>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!hasCopyableProfiles}
onClick={() => setCopyTemplateMode('specs')}
>
复制模板配置
</Button>
<Button
size="small"
icon={<ThunderboltOutlined />}
@@ -1437,15 +1489,25 @@ export default function ConsignmentConfigPanel() {
title="联系方式与游戏凭据"
description="打手发布页按这里配置的字段动态渲染;字段为空时该模块不显示"
/>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addIntakeField}
>
添加字段
</Button>
<Space size={8}>
<Button
size="small"
icon={<CopyOutlined />}
disabled={!hasCopyableProfiles}
onClick={() => setCopyTemplateMode('intake')}
>
复制模板配置
</Button>
<Button
type="primary"
ghost
size="small"
icon={<PlusOutlined />}
onClick={addIntakeField}
>
添加字段
</Button>
</Space>
</div>
<Text type="secondary">
默认字段与旧发布页一致;手机号字段会校验 11
@@ -2051,6 +2113,16 @@ export default function ConsignmentConfigPanel() {
</div>
</Form>
<ConsignmentTemplateCopyModal
open={copyTemplateMode !== null}
mode={copyTemplateMode ?? 'specs'}
currentCategoryId={selectedCategoryId}
categories={categories}
profiles={profiles}
onCancel={() => setCopyTemplateMode(null)}
onApply={handleCopyTemplate}
/>
<Modal
title="从接单模板生成规格字段"
open={proposalOpen}
@@ -29,6 +29,7 @@ import {
Switch,
Tag,
Tooltip,
TreeSelect,
Typography,
} from 'antd'
import { AdminRangePicker } from '@/components/admin/AdminDatePicker'
@@ -55,12 +56,14 @@ import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
import ConsignmentManualMatchModal from '@/components/admin/ConsignmentManualMatchModal'
import type {
ConsignmentInventory,
ConsignmentInventoryFilterCategory,
ConsignmentInventoryFilterField,
GiftEvidenceReviewStatus,
} from '@/types/consignment'
import { fetchAdminSession } from '@/services/admin/auth'
import { resolveAdminPermission } from '@/utils/admin-auth'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { buildConsignmentCategoryGroups } from '@/utils/consignment-category-tree'
import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence'
import {
CONSIGNMENT_SPEC_TAG_COLORS,
@@ -123,12 +126,24 @@ function resolveGiftEvidenceEmptyText(record: {
return '未上传赠送截图'
}
type InventoryFilterTreeNode = {
key: string
value: number
title: string
selectable: boolean
kind: 'group' | 'root' | 'child'
item?: ConsignmentInventoryFilterCategory
groupCount?: number
children?: InventoryFilterTreeNode[]
}
export default function ConsignmentInventoryPanel() {
const { message } = App.useApp()
// 筛选条件:分类与规格项全部来自「寄卖配置」已发布版本
const [status, setStatus] = useState<string>('in_stock')
const [categoryId, setCategoryId] = useState<number | null>(null)
const [categoryExpandedKeys, setCategoryExpandedKeys] = useState<Array<string | number>>([])
const [specFilters, setSpecFilters] = useState<Record<string, string>>({})
const [allowMatching, setAllowMatching] = useState<string>('')
const [inboundRange, setInboundRange] = useState<[string, string] | null>(null)
@@ -189,14 +204,68 @@ export default function ConsignmentInventoryPanel() {
() => filterOptionsQuery.data?.data.items || [],
[filterOptionsQuery.data],
)
const categoryOptions = useMemo(
() =>
filterCategories.map((item) => ({
// 一级分类直接显示分类名;二级分类带上父级,避免同名歧义
label: item.parentName ? `${item.parentName} / ${item.name}` : item.name,
const categoryTreeData = useMemo<InventoryFilterTreeNode[]>(() => {
const groups = buildConsignmentCategoryGroups({
items: filterCategories,
getCategoryId: (item) => item.categoryId,
getParentId: (item) => item.parentId,
getParentName: (item) => item.parentName,
getFallbackName: (item) => item.name,
})
return groups.flatMap((group) => {
const root = group.root
const rootLabel = root?.name || group.groupName || '未命名分类'
const childNodes: InventoryFilterTreeNode[] = group.children.map((item) => ({
key: `child-${item.categoryId}`,
value: item.categoryId,
})),
[filterCategories],
title: item.name,
selectable: true,
kind: 'child',
item,
}))
if (root && childNodes.length === 0) {
return [
{
key: `root-${root.categoryId}`,
value: root.categoryId,
title: rootLabel,
selectable: true,
kind: 'root',
item: root,
},
]
}
if (!root) {
if (childNodes.length === 0) return []
const groupNode: InventoryFilterTreeNode = {
key: `group-${group.groupCategoryId}`,
value: -group.groupCategoryId,
title: rootLabel,
selectable: false,
kind: 'group',
groupCount: childNodes.length,
children: childNodes,
}
return [groupNode]
}
return [
{
key: `group-${group.groupCategoryId}`,
value: root.categoryId,
title: rootLabel,
selectable: true,
kind: 'group',
item: root,
groupCount: childNodes.length,
children: childNodes,
},
]
})
}, [filterCategories])
const categoryGroupKeys = useMemo(
() => categoryTreeData.filter((node) => node.children?.length).map((node) => node.key),
[categoryTreeData],
)
// 默认「苏尔南手枪」;该分类未开启寄卖时回退到第一个已开启分类
@@ -893,14 +962,53 @@ export default function ConsignmentInventoryPanel() {
</Radio.Group>
{/* 寄卖分类:选项与规格筛选项全部来自该类别的已发布寄卖配置 */}
<Select
style={{ width: 200 }}
<TreeSelect<number, InventoryFilterTreeNode>
style={{ width: 240 }}
loading={filterOptionsQuery.isLoading}
value={categoryId ?? undefined}
placeholder="寄卖分类"
options={categoryOptions}
treeData={categoryTreeData}
treeDefaultExpandAll={false}
treeExpandedKeys={categoryExpandedKeys}
onTreeExpand={(keys) => setCategoryExpandedKeys(keys as Array<string | number>)}
showSearch={{
treeNodeFilterProp: 'title',
onSearch: (value) => setCategoryExpandedKeys(value.trim() ? categoryGroupKeys : []),
}}
onOpenChange={(open) => {
if (!open) setCategoryExpandedKeys([])
}}
popupMatchSelectWidth={false}
listHeight={320}
treeTitleRender={(node) => {
const item = node as InventoryFilterTreeNode
if (item.kind === 'group') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag color="blue">一级</Tag>
</span>
)
}
if (item.kind === 'root') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag color="blue">一级</Tag>
</span>
)
}
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag>二级</Tag>
{item.item?.source === 'inherited' ? <Tag color="blue">继承</Tag> : null}
</span>
)
}}
onChange={(value) => {
setCategoryId(Number(value))
if (typeof value !== 'number' || value <= 0) return
setCategoryId(value)
setSpecFilters({})
setPage(1)
}}
@@ -35,10 +35,11 @@ import {
Space,
Tabs,
Tag,
TreeSelect,
Typography,
Spin,
} from 'antd'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import ImageUpload from '@/components/files/ImageUpload'
import { WorkerContactModal } from '@/components/worker/WorkerContactModal'
@@ -62,6 +63,7 @@ import type {
} from '@/types/consignment'
import type { UploadedFile } from '@/types/worker-platform'
import { DEFAULT_CONSIGNMENT_INTAKE_FIELDS, buildSpecItemName } from '@/utils/consignment-spec'
import { buildConsignmentCategoryGroups } from '@/utils/consignment-category-tree'
import { formatConsignmentContactSupporter } from '@/utils/consignment-contact'
import { formatGiftCooldownRemaining } from '@/utils/consignment-cooldown'
import { resolveConsignmentDepositFen } from '@/utils/consignment-deposit'
@@ -119,12 +121,56 @@ const COOLDOWN_TICK_MS = 30_000
const RECORD_PAGE_SIZE = 10
const SEARCH_DEBOUNCE_MS = 300
/** 一级在前、其二级紧随其后;只有二级配置时按原顺序追加。 */
function orderWorkerProfiles(profiles: WorkerConsignmentProfile[]): WorkerConsignmentProfile[] {
const childrenByParent = new Map<number, WorkerConsignmentProfile[]>()
const roots: WorkerConsignmentProfile[] = []
for (const profile of profiles) {
if (profile.categoryParentId) {
const list = childrenByParent.get(profile.categoryParentId) || []
list.push(profile)
childrenByParent.set(profile.categoryParentId, list)
continue
}
roots.push(profile)
}
const ordered: WorkerConsignmentProfile[] = []
const included = new Set<number>()
for (const root of roots) {
ordered.push(root)
included.add(root.profileId)
for (const child of childrenByParent.get(root.categoryId) || []) {
if (included.has(child.profileId)) continue
ordered.push(child)
included.add(child.profileId)
}
}
for (const profile of profiles) {
if (included.has(profile.profileId)) continue
ordered.push(profile)
}
return ordered
}
type WorkerProfileTreeNode = {
key: string
value: number
title: string
selectable: boolean
kind: 'group' | 'root' | 'child'
profile?: WorkerConsignmentProfile
groupCount?: number
children?: WorkerProfileTreeNode[]
}
export default function WorkerConsignmentPage() {
const isMobile = useIsMobile()
const { message, modal } = App.useApp()
const [activeTab, setActiveTabState] = useState<'publish' | 'records'>(getInitialActiveTab)
/** 当前选中的寄卖配置(每个已发布配置一个 tab) */
const [selectedProfileId, setSelectedProfileId] = useState<number | null>(null)
const [profileExpandedKeys, setProfileExpandedKeys] = useState<Array<string | number>>([])
const [recordFilter, setRecordFilterState] = useState<RecordFilter>(getInitialRecordFilter)
/** 搜索框里的原始输入(立刻回显),防抖后才作为服务端查询条件 */
const [recordKeywordInput, setRecordKeywordInput] = useState('')
@@ -206,9 +252,78 @@ export default function WorkerConsignmentPage() {
queryFn: () => fetchWorkerConsignmentProfiles(),
})
const consignmentProfiles = consignmentProfilesQuery.data?.data.profiles || []
const orderedConsignmentProfiles = useMemo(
() => orderWorkerProfiles(consignmentProfiles),
[consignmentProfiles],
)
const profileTreeData = useMemo<WorkerProfileTreeNode[]>(() => {
const groups = buildConsignmentCategoryGroups({
items: orderedConsignmentProfiles,
getCategoryId: (profile) => profile.categoryId,
getParentId: (profile) => profile.categoryParentId,
getParentName: (profile) => profile.categoryParentName,
getFallbackName: (profile) => profile.tabTitle || profile.categoryName || profile.name,
})
return groups.flatMap((group) => {
const root = group.root
const rootLabel = root
? root.tabTitle || root.categoryName || root.name
: group.groupName || '未命名分类'
const childNodes: WorkerProfileTreeNode[] = group.children.map((child) => ({
key: `child-${child.profileId}`,
value: child.profileId,
title: child.tabTitle || child.categoryName || child.name,
selectable: true,
kind: 'child',
profile: child,
}))
if (root && childNodes.length === 0) {
return [
{
key: `root-${root.profileId}`,
value: root.profileId,
title: rootLabel,
selectable: true,
kind: 'root',
profile: root,
},
]
}
if (!root) {
if (childNodes.length === 0) return []
const groupNode: WorkerProfileTreeNode = {
key: `group-${group.groupCategoryId}`,
value: -group.groupCategoryId,
title: rootLabel,
selectable: false,
kind: 'group',
groupCount: childNodes.length,
children: childNodes,
}
return [groupNode]
}
return [
{
key: `group-${group.groupCategoryId}`,
value: root.profileId,
title: rootLabel,
selectable: true,
kind: 'group',
profile: root,
groupCount: childNodes.length,
children: childNodes,
},
]
})
}, [orderedConsignmentProfiles])
const profileGroupKeys = useMemo(
() => profileTreeData.filter((node) => node.children?.length).map((node) => node.key),
[profileTreeData],
)
const selectedProfile: WorkerConsignmentProfile | null =
consignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
consignmentProfiles[0] ||
orderedConsignmentProfiles.find((profile) => profile.profileId === selectedProfileId) ||
orderedConsignmentProfiles[0] ||
null
const selectedSpecs: ConsignmentSpecField[] = selectedProfile?.config.specs ?? []
// 「允许修改寄售数量」关闭时:数量固定 1、输入框禁用,价格/押金一律按 1 件计算。
@@ -229,15 +344,19 @@ export default function WorkerConsignmentPage() {
const intakeFields: ConsignmentIntakeField[] =
selectedProfile?.config.intake?.fields ?? DEFAULT_CONSIGNMENT_INTAKE_FIELDS
useEffect(() => {
if (selectedProfileId === null && consignmentProfiles.length > 0) {
// 默认苏尔南手枪;没有该分类时退回第一个已发布配置。
if (selectedProfileId === null && orderedConsignmentProfiles.length > 0) {
// 默认苏尔南手枪一级配置;没有该分类时退回第一个一级配置,再退回第一条。
const preferred =
consignmentProfiles.find((profile) =>
/苏尔南|苏里南/.test(`${profile.categoryName}${profile.tabTitle}${profile.name}`),
) || consignmentProfiles[0]
orderedConsignmentProfiles.find(
(profile) =>
!profile.categoryParentId &&
/苏尔南|苏里南/.test(`${profile.categoryName}${profile.tabTitle}${profile.name}`),
) ||
orderedConsignmentProfiles.find((profile) => !profile.categoryParentId) ||
orderedConsignmentProfiles[0]
if (preferred) setSelectedProfileId(preferred.profileId)
}
}, [consignmentProfiles, selectedProfileId])
}, [orderedConsignmentProfiles, selectedProfileId])
// 发布寄卖必填「对接客服」:只取启用名单,默认选第一个
const consignmentContactsQuery = useQuery({
@@ -769,15 +888,51 @@ export default function WorkerConsignmentPage() {
}}
>
<Typography.Text strong>寄卖分类</Typography.Text>
<Select
<TreeSelect<number, WorkerProfileTreeNode>
value={selectedProfile?.profileId}
style={{ minWidth: 220 }}
placeholder={consignmentProfiles.length === 0 ? '暂无可发布的寄卖分类' : '请选择寄卖分类'}
options={consignmentProfiles.map((profile) => ({
value: profile.profileId,
label: profile.tabTitle || profile.categoryName || profile.name,
}))}
onChange={(value) => handleProfileChange(Number(value))}
treeData={profileTreeData}
treeDefaultExpandAll={false}
treeExpandedKeys={profileExpandedKeys}
onTreeExpand={(keys) => setProfileExpandedKeys(keys as Array<string | number>)}
showSearch={{
treeNodeFilterProp: 'title',
onSearch: (value) => setProfileExpandedKeys(value.trim() ? profileGroupKeys : []),
}}
onOpenChange={(open) => {
if (!open) setProfileExpandedKeys([])
}}
placeholder={profileTreeData.length === 0 ? '暂无可发布的寄卖分类' : '请选择寄卖分类'}
popupMatchSelectWidth={false}
listHeight={320}
treeTitleRender={(node) => {
const item = node as WorkerProfileTreeNode
if (item.kind === 'group') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag color="blue">一级</Tag>
</span>
)
}
if (item.kind === 'root') {
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag color="blue">一级</Tag>
</span>
)
}
return (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ flex: 1 }}>{item.title}</span>
<Tag>二级</Tag>
</span>
)
}}
onChange={(value) => {
if (typeof value === 'number' && value > 0) handleProfileChange(value)
}}
/>
{selectedProfile?.tabDescription ? (
<Typography.Text type="secondary">{selectedProfile.tabDescription}</Typography.Text>
+8
View File
@@ -445,8 +445,10 @@ export type ConsignmentPricingConfig = ConsignmentProfileConfig['pricing']
export type ConsignmentProfile = {
profileId: number
categoryId: number
categoryKey?: string
categoryName?: string
categoryParentId?: number | null
categoryParentName?: string
name: string
title: string
description: string
@@ -454,7 +456,10 @@ export type ConsignmentProfile = {
status: 'draft' | 'published' | 'archived'
currentVersionId: number | null
currentVersionNo: number | null
/** 后台当前保存的配置(草稿优先) */
config: ConsignmentProfileConfig
/** 当前发布版本快照;从未发布过为 null。复制模板弹窗用。 */
publishedConfig?: ConsignmentProfileConfig | null
createdAt?: string
updatedAt?: string
}
@@ -503,7 +508,10 @@ export type ConsignmentInventoryFilterOptionsResponse = {
export type WorkerConsignmentProfile = {
profileId: number
categoryId: number
categoryKey: string
categoryName: string
categoryParentId: number | null
categoryParentName: string
name: string
title: string
description: string
@@ -0,0 +1,62 @@
export type ConsignmentCategoryGroup<T> = {
/** 一级分类 ID;只有二级配置时这里是一级分类 ID(来自子项的 parentId) */
groupCategoryId: number
/** 一级分类名称 */
groupName: string
/** 一级分类自己的配置/筛选项;没有则为 null */
root: T | null
/** 该一级下的二级项 */
children: T[]
}
/**
* 把「一级/二级」平铺数据聚合成可折叠树。保持输入顺序:
* 一级先出现,二级按输入顺序挂在对应一级下面;只有二级、没有一级的数据
* 会创建一个无 root 的分组,避免历史配置丢失。
*/
export function buildConsignmentCategoryGroups<T>(input: {
items: T[]
getCategoryId: (item: T) => number
getParentId: (item: T) => number | null | undefined
getParentName?: (item: T) => string | undefined
getFallbackName: (item: T) => string
}): ConsignmentCategoryGroup<T>[] {
const groups = new Map<number, ConsignmentCategoryGroup<T>>()
const order: number[] = []
for (const item of input.items) {
const parentId = input.getParentId(item)
if (parentId) continue
const groupCategoryId = input.getCategoryId(item)
if (groups.has(groupCategoryId)) {
groups.get(groupCategoryId)!.root = item
continue
}
groups.set(groupCategoryId, {
groupCategoryId,
groupName: input.getFallbackName(item),
root: item,
children: [],
})
order.push(groupCategoryId)
}
for (const item of input.items) {
const parentId = input.getParentId(item)
if (!parentId) continue
let group = groups.get(parentId)
if (!group) {
group = {
groupCategoryId: parentId,
groupName: input.getParentName?.(item) || input.getFallbackName(item),
root: null,
children: [],
}
groups.set(parentId, group)
order.push(parentId)
}
group.children.push(item)
}
return order.map((groupCategoryId) => groups.get(groupCategoryId)!)
}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildConsignmentCategoryGroups } from '../src/utils/consignment-category-tree.ts'
type Item = { id: number; parentId: number | null; parentName?: string; name: string }
function build(items: Item[]) {
return buildConsignmentCategoryGroups({
items,
getCategoryId: (item) => item.id,
getParentId: (item) => item.parentId,
getParentName: (item) => item.parentName,
getFallbackName: (item) => item.name,
})
}
test('分类树:一级在前、其二级紧随其后', () => {
const groups = build([
{ id: 1, parentId: null, name: '苏尔南手枪' },
{ id: 2, parentId: null, name: '密钥' },
{ id: 11, parentId: 1, parentName: '苏尔南手枪', name: '暗潮' },
{ id: 12, parentId: 1, parentName: '苏尔南手枪', name: '怒嚎' },
])
assert.deepEqual(
groups.map((group) => group.groupName),
['苏尔南手枪', '密钥'],
)
assert.deepEqual(
groups[0]?.children.map((child) => child.name),
['暗潮', '怒嚎'],
)
assert.equal(groups[1]?.root?.name, '密钥')
})
test('分类树:只有二级配置时创建一个无 root 的分组', () => {
const groups = build([
{ id: 11, parentId: 1, parentName: '苏尔南手枪', name: '暗潮' },
{ id: 12, parentId: 1, parentName: '苏尔南手枪', name: '怒嚎' },
])
assert.equal(groups.length, 1)
assert.equal(groups[0]?.groupName, '苏尔南手枪')
assert.equal(groups[0]?.root, null)
assert.deepEqual(
groups[0]?.children.map((child) => child.name),
['暗潮', '怒嚎'],
)
})
test('分类树:没有下级的一级保持为单节点', () => {
const groups = build([{ id: 2, parentId: null, name: '密钥' }])
assert.equal(groups.length, 1)
assert.equal(groups[0]?.root?.name, '密钥')
assert.deepEqual(groups[0]?.children, [])
})
@@ -47,7 +47,7 @@ test('寄卖配置面板:必须覆盖保证金/服务费/数量限制/匹配/
test('打手端:寄卖页必须按已发布配置动态生成 tab,并支持改价', () => {
const page = read('../src/pages/worker/WorkerConsignmentPage.tsx')
assert.ok(page.includes('fetchWorkerConsignmentProfiles'), '必须拉取已发布寄卖配置')
assert.ok(page.includes('consignmentProfiles.map'), '必须按配置生成 tab')
assert.ok(page.includes('profileTreeData'), '必须按配置生成 tab/分类树')
assert.ok(page.includes('修改售价'), '在售寄卖必须提供改价入口')
assert.ok(page.includes('updateWorkerConsignmentPrice'), '改价必须调用专用接口')
assert.ok(page.includes('priceDropSuggestion'), '必须展示降价建议')
@@ -135,3 +135,41 @@ test('寄卖配置:保证金支持按数量(规格),弹窗内配置选
assert.ok(panel.includes('specPricesYuan'), '选项价格必须写回配置')
assert.ok(panel.includes('填充全部选项'), '弹窗必须支持批量填充选项价格')
})
test('寄卖配置:规格字段与联系方式区块提供复制模板配置入口', () => {
const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx')
assert.ok(panel.includes('复制模板配置'), '两个区块都要有复制模板配置按钮')
assert.ok(panel.includes("setCopyTemplateMode('specs')"), '规格字段入口必须打开规格复制')
assert.ok(panel.includes("setCopyTemplateMode('intake')"), '联系方式入口必须打开凭据复制')
assert.ok(panel.includes('ConsignmentTemplateCopyModal'), '必须挂载复制模板弹窗')
const modal = read('../src/components/admin/ConsignmentTemplateCopyModal.tsx')
assert.ok(modal.includes('已发布版本'), '复制来源必须支持已发布版本')
assert.ok(modal.includes('当前草稿'), '复制来源必须支持当前草稿')
assert.ok(modal.includes('publishedConfig'), '已发布版本必须读取发布快照配置')
assert.ok(modal.includes('不影响价格、保证金、服务费、匹配和超时设置'), '复制只替换当前区块')
assert.ok(modal.includes('ruleKey'), '模板驱动规格必须提示 ruleKey 仍指向来源模板')
assert.ok(modal.includes('TreeSelect'), '来源选择必须使用折叠树')
assert.ok(modal.includes('treeDefaultExpandAll={false}'), '来源树默认必须折叠')
assert.ok(modal.includes('treeTitleRender'), '来源树必须自定义层级标题')
})
test('打手端:寄卖分类按一二级层级展示', () => {
const page = read('../src/pages/worker/WorkerConsignmentPage.tsx')
assert.ok(page.includes('categoryParentName'), '必须使用父分类名称')
assert.ok(page.includes('orderWorkerProfiles'), '必须按一级/二级排序')
assert.ok(page.includes('profileTreeData'), '必须构建一级/二级树')
assert.ok(page.includes('TreeSelect'), '必须使用可折叠的 TreeSelect')
assert.ok(page.includes('treeDefaultExpandAll={false}'), '默认必须折叠')
assert.ok(page.includes('treeTitleRender'), '必须自定义层级标题')
assert.ok(page.includes('二级'), '二级分类必须有层级标签')
})
test('寄售仓库:寄卖分类筛选按一二级层级展示', () => {
const panel = read('../src/pages/admin/panels/ConsignmentInventoryPanel.tsx')
assert.ok(panel.includes('categoryTreeData'), '必须构建一级/二级树')
assert.ok(panel.includes('TreeSelect'), '必须使用可折叠的 TreeSelect')
assert.ok(panel.includes('treeDefaultExpandAll={false}'), '默认必须折叠')
assert.ok(panel.includes('treeTitleRender'), '必须自定义层级标题')
assert.ok(panel.includes('继承'), '继承型二级分类必须有继承标记')
})