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