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
@@ -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>
)
}