Files
order_site/apps/frontend/src/components/admin/ConsignmentTemplateCopyModal.tsx
T

405 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}