From 89a3d23874de4b4a4b54e7a4f62ef75239901588 Mon Sep 17 00:00:00 2001 From: zhaoxing <2558691827@qq.com> Date: Mon, 9 Nov 2026 16:00:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(consignment):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E5=AF=84=E5=8D=96=E6=A8=A1=E6=9D=BF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E4=B8=80=E4=BA=8C=E7=BA=A7=E5=88=86=E7=B1=BB?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../consignment-inventory-filter.test.ts | 12 + .../consignment-profile-service.ts | 15 + .../admin/ConsignmentTemplateCopyModal.tsx | 403 ++++++++++++++++++ .../admin/panels/ConsignmentConfigPanel.tsx | 92 +++- .../panels/ConsignmentInventoryPanel.tsx | 130 +++++- .../pages/worker/WorkerConsignmentPage.tsx | 187 +++++++- apps/frontend/src/types/consignment.ts | 8 + .../src/utils/consignment-category-tree.ts | 62 +++ .../tests/consignment-category-tree.test.ts | 58 +++ .../tests/consignment-matching-config.test.ts | 40 +- 10 files changed, 969 insertions(+), 38 deletions(-) create mode 100644 apps/frontend/src/components/admin/ConsignmentTemplateCopyModal.tsx create mode 100644 apps/frontend/src/utils/consignment-category-tree.ts create mode 100644 apps/frontend/tests/consignment-category-tree.test.ts diff --git a/apps/backend/src/services/worker-platform/consignment-inventory-filter.test.ts b/apps/backend/src/services/worker-platform/consignment-inventory-filter.test.ts index b605c3dc..711358df 100644 --- a/apps/backend/src/services/worker-platform/consignment-inventory-filter.test.ts +++ b/apps/backend/src/services/worker-platform/consignment-inventory-filter.test.ts @@ -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) diff --git a/apps/backend/src/services/worker-platform/consignment-profile-service.ts b/apps/backend/src/services/worker-platform/consignment-profile-service.ts index 9ac9d5d6..a6f9feb0 100644 --- a/apps/backend/src/services/worker-platform/consignment-profile-service.ts +++ b/apps/backend/src/services/worker-platform/consignment-profile-service.ts @@ -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 || '', diff --git a/apps/frontend/src/components/admin/ConsignmentTemplateCopyModal.tsx b/apps/frontend/src/components/admin/ConsignmentTemplateCopyModal.tsx new file mode 100644 index 00000000..add36c6c --- /dev/null +++ b/apps/frontend/src/components/admin/ConsignmentTemplateCopyModal.tsx @@ -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, +): 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() + 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() + 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('published') + const [selectedProfileId, setSelectedProfileId] = useState(null) + const [treeExpandedKeys, setTreeExpandedKeys] = useState>([]) + + 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( + () => + 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(() => { + 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 ( + { + if (!selectedOption?.config) return + onApply({ + profile: selectedOption.profile, + config: selectedOption.config, + version, + }) + }} + onCancel={onCancel} + > + + + 来源版本 + setVersion(value as ConsignmentTemplateCopyVersion)} + /> + + + + style={{ width: '100%' }} + placeholder={ + options.some((option) => !option.disabled) + ? '请选择要复制的寄卖配置' + : '没有可复制的寄卖配置' + } + value={selectedProfileId ?? undefined} + treeData={treeData} + treeDefaultExpandAll={false} + treeExpandedKeys={treeExpandedKeys} + onTreeExpand={(keys) => setTreeExpandedKeys(keys as Array)} + 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 ( + + {item.title} + {item.option ? 一级 : null} + + ) + } + if (!item.option) return item.title + return ( + + {item.option.label} + {item.kind === 'root' ? 一级 : 二级} + {item.option.fields.length === 0 ? 无字段 : null} + + ) + }} + onChange={(value) => { + if (typeof value === 'number' && value > 0) setSelectedProfileId(value) + }} + /> + + {version === 'draft' && selectedOption?.profile.status === 'published' ? ( + + ) : null} + + {mode === 'specs' && selectedOption?.config?.specSource === 'template' ? ( + + ) : null} + + {selectedOption && selectedFields.length > 0 ? ( +
+ + 将复制 {selectedFields.length} 个{mode === 'specs' ? '规格字段' : '联系方式字段'} + +
+ {selectedFields.map((field) => ( + + {field.label} · {field.detail} + + ))} +
+ {selectedOption.config ? ( + + 复制只替换当前区块,不影响价格、保证金、服务费、匹配和超时设置;应用后请点「保存草稿」。 + + ) : null} +
+ ) : ( + 0 + ? '该版本没有可复制的字段,请换一个配置或切换版本' + : version === 'published' + ? '没有可用的已发布配置,请切换到「当前草稿」' + : '没有可复制的字段' + } + /> + )} +
+
+ ) +} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index eb2e14d6..1c310b12 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -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(null) const [categoryKeyword, setCategoryKeyword] = useState('') @@ -589,6 +594,7 @@ export default function ConsignmentConfigPanel() { const [specDrafts, setSpecDrafts] = useState([]) const [intakeDrafts, setIntakeDrafts] = useState([]) const [specSource, setSpecSource] = useState<'manual' | 'template'>('manual') + const [copyTemplateMode, setCopyTemplateMode] = useState(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(() => { @@ -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="初始为空;字段随配置版本发布,并写入提报与库存快照" /> + + + + + 默认字段与旧发布页一致;手机号字段会校验 11 @@ -2051,6 +2113,16 @@ export default function ConsignmentConfigPanel() { + setCopyTemplateMode(null)} + onApply={handleCopyTemplate} + /> + ('in_stock') const [categoryId, setCategoryId] = useState(null) + const [categoryExpandedKeys, setCategoryExpandedKeys] = useState>([]) const [specFilters, setSpecFilters] = useState>({}) const [allowMatching, setAllowMatching] = useState('') 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(() => { + 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() { {/* 寄卖分类:选项与规格筛选项全部来自该类别的已发布寄卖配置 */} - 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)} + 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 ( + + {item.title} + 一级 + + ) + } + if (item.kind === 'root') { + return ( + + {item.title} + 一级 + + ) + } + return ( + + {item.title} + 二级 + + ) + }} + onChange={(value) => { + if (typeof value === 'number' && value > 0) handleProfileChange(value) + }} /> {selectedProfile?.tabDescription ? ( {selectedProfile.tabDescription} diff --git a/apps/frontend/src/types/consignment.ts b/apps/frontend/src/types/consignment.ts index 1cb8b3a2..a1e0d708 100644 --- a/apps/frontend/src/types/consignment.ts +++ b/apps/frontend/src/types/consignment.ts @@ -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 diff --git a/apps/frontend/src/utils/consignment-category-tree.ts b/apps/frontend/src/utils/consignment-category-tree.ts new file mode 100644 index 00000000..64afcc95 --- /dev/null +++ b/apps/frontend/src/utils/consignment-category-tree.ts @@ -0,0 +1,62 @@ +export type ConsignmentCategoryGroup = { + /** 一级分类 ID;只有二级配置时这里是一级分类 ID(来自子项的 parentId) */ + groupCategoryId: number + /** 一级分类名称 */ + groupName: string + /** 一级分类自己的配置/筛选项;没有则为 null */ + root: T | null + /** 该一级下的二级项 */ + children: T[] +} + +/** + * 把「一级/二级」平铺数据聚合成可折叠树。保持输入顺序: + * 一级先出现,二级按输入顺序挂在对应一级下面;只有二级、没有一级的数据 + * 会创建一个无 root 的分组,避免历史配置丢失。 + */ +export function buildConsignmentCategoryGroups(input: { + items: T[] + getCategoryId: (item: T) => number + getParentId: (item: T) => number | null | undefined + getParentName?: (item: T) => string | undefined + getFallbackName: (item: T) => string +}): ConsignmentCategoryGroup[] { + const groups = new Map>() + 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)!) +} diff --git a/apps/frontend/tests/consignment-category-tree.test.ts b/apps/frontend/tests/consignment-category-tree.test.ts new file mode 100644 index 00000000..b5fd54b8 --- /dev/null +++ b/apps/frontend/tests/consignment-category-tree.test.ts @@ -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, []) +}) diff --git a/apps/frontend/tests/consignment-matching-config.test.ts b/apps/frontend/tests/consignment-matching-config.test.ts index ba93e425..baf338e2 100644 --- a/apps/frontend/tests/consignment-matching-config.test.ts +++ b/apps/frontend/tests/consignment-matching-config.test.ts @@ -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('继承'), '继承型二级分类必须有继承标记') +})