feat(consignment): 支持复制寄卖模板配置与一二级分类折叠展示
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user