405 lines
13 KiB
TypeScript
405 lines
13 KiB
TypeScript
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(() => {
|
||
if (!open) return
|
||
setTreeExpandedKeys(treeGroupKeys)
|
||
}, [open, version, mode, treeGroupKeys])
|
||
|
||
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
|
||
treeExpandedKeys={treeExpandedKeys}
|
||
onTreeExpand={(keys) => setTreeExpandedKeys(keys as Array<string | number>)}
|
||
showSearch={{
|
||
treeNodeFilterProp: 'title',
|
||
onSearch: () => setTreeExpandedKeys(treeGroupKeys),
|
||
}}
|
||
onOpenChange={(open) => {
|
||
if (!open) setTreeExpandedKeys(treeGroupKeys)
|
||
}}
|
||
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>
|
||
)
|
||
}
|