diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx index 14256b11..e277e826 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentConfigPanel.tsx @@ -2,14 +2,18 @@ import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, + DownOutlined, PlusOutlined, ReloadOutlined, + RightOutlined, SaveOutlined, + SearchOutlined, SendOutlined, StopOutlined, } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { + Alert, App, Button, Card, @@ -22,6 +26,7 @@ import { Space, Switch, Tag, + Tooltip, Typography, } from 'antd' import { useEffect, useMemo, useState, type CSSProperties } from 'react' @@ -33,6 +38,7 @@ import { saveAdminConsignmentProfile, } from '@/services/admin/consignment' import type { + AdminConsignmentProfilesResponse, ConsignmentProfile, ConsignmentSpecField, ConsignmentSpecFieldType, @@ -42,6 +48,9 @@ import { SPEC_FIELD_TYPE_OPTIONS, isOptionSpecField } from '@/utils/consignment- const { Text, Title } = Typography +type Category = AdminConsignmentProfilesResponse['categories'][number] +type CategoryNode = { category: Category; children: Category[] } + type ConfigFormValues = { name: string title: string @@ -76,6 +85,11 @@ type ConfigFormValues = { priceDrop: { enabled: boolean; hours: number; mode: 'percent' | 'fixed'; value: number } } +type ProfileStatusInfo = { + label: string + tone: 'success' | 'warning' | 'inherited' | 'muted' +} + function yuan(fen: number | undefined): number { return Number(((Number(fen || 0) || 0) / 100).toFixed(2)) } @@ -233,24 +247,86 @@ function buildPayload(categoryId: number, values: ConfigFormValues, specs: Consi } } +function resolveProfileStatus(profile: ConsignmentProfile | undefined): ProfileStatusInfo { + if (!profile) return { label: '未配置', tone: 'muted' } + if (profile.status === 'published') { + return { label: `v${profile.currentVersionNo || 1}`, tone: 'success' } + } + if (profile.status === 'draft') { + return { + label: profile.currentVersionNo ? `草稿 · 线上 v${profile.currentVersionNo}` : '草稿', + tone: 'warning', + } + } + return { label: '已归档', tone: 'muted' } +} + +function resolveInheritedProfileStatus( + profile: ConsignmentProfile | undefined, + parentProfile: ConsignmentProfile | undefined, +): ProfileStatusInfo { + const parentPublished = + parentProfile?.status === 'published' && Boolean(parentProfile.currentVersionNo) + if (profile) { + if (profile.status === 'archived' && parentPublished) { + return { label: `已归档 · 继承 v${parentProfile!.currentVersionNo}`, tone: 'muted' } + } + if (profile.status !== 'published' && !profile.currentVersionNo && parentPublished) { + return { label: `草稿 · 继承 v${parentProfile!.currentVersionNo}`, tone: 'warning' } + } + return resolveProfileStatus(profile) + } + if (parentPublished) { + return { label: `继承 v${parentProfile!.currentVersionNo}`, tone: 'inherited' } + } + return { label: '未配置', tone: 'muted' } +} + +function statusTagColor(tone: ProfileStatusInfo['tone']): string { + if (tone === 'success') return 'green' + if (tone === 'warning') return 'orange' + if (tone === 'inherited') return 'blue' + return 'default' +} + +function ConsignmentSectionTitle({ + index, + title, + description, +}: { + index: string + title: string + description: string +}) { + return ( +
+ {index} + + {title} + {description} + +
+ ) +} + const gridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16, } -const stackStyle: CSSProperties = { - display: 'flex', - flexDirection: 'column', - gap: 16, +const fieldCellStyle: CSSProperties = { + display: 'grid', + gap: 4, + minWidth: 0, } -const cardStyle: CSSProperties = { borderRadius: 12 } - export default function ConsignmentConfigPanel() { const { message } = App.useApp() const queryClient = useQueryClient() const [selectedCategoryId, setSelectedCategoryId] = useState(null) + const [categoryKeyword, setCategoryKeyword] = useState('') + const [expandedCategoryIds, setExpandedCategoryIds] = useState>(() => new Set()) const [saving, setSaving] = useState(false) const [specDrafts, setSpecDrafts] = useState([]) const [form] = Form.useForm() @@ -260,19 +336,64 @@ export default function ConsignmentConfigPanel() { queryFn: () => fetchAdminConsignmentProfiles(), }) const data = profilesQuery.data?.data - const categories = useMemo( - () => (data?.categories || []).filter((category) => category.status === 'active'), - [data?.categories], - ) + const categories = data?.categories || [] const profiles = data?.profiles || [] const profileByCategoryId = useMemo( () => new Map(profiles.map((profile) => [profile.categoryId, profile])), [profiles], ) + + const normalizedKeyword = categoryKeyword.trim().toLowerCase() + const categoryTree = useMemo(() => { + const childrenByParent = new Map() + const roots: Category[] = [] + for (const category of categories) { + if (category.parentId) { + const children = childrenByParent.get(category.parentId) || [] + children.push(category) + childrenByParent.set(category.parentId, children) + } else { + roots.push(category) + } + } + const matches = (category: Category) => + !normalizedKeyword || + category.name.toLowerCase().includes(normalizedKeyword) || + category.categoryKey.toLowerCase().includes(normalizedKeyword) + + return roots.flatMap((category) => { + const children = childrenByParent.get(category.categoryId) || [] + if (!normalizedKeyword) return [{ category, children }] + if (matches(category)) return [{ category, children }] + const matchedChildren = children.filter(matches) + return matchedChildren.length > 0 ? [{ category, children: matchedChildren }] : [] + }) + }, [categories, normalizedKeyword]) + + const filteredCategoryCount = categoryTree.reduce( + (total, node) => total + 1 + node.children.length, + 0, + ) + const configuredCount = new Set( + profiles + .filter((profile) => profile.status !== 'archived') + .map((profile) => profile.categoryId), + ).size + const selectedCategory = categories.find((category) => category.categoryId === selectedCategoryId) const selectedProfile = selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : undefined + const parentCategory = selectedCategory?.parentId + ? categories.find((category) => category.categoryId === selectedCategory.parentId) + : undefined + const parentProfile = parentCategory + ? profileByCategoryId.get(parentCategory.categoryId) + : undefined + const runtimeInheritsParent = + (!selectedProfile || selectedProfile.status !== 'published') && + parentProfile?.status === 'published' && + Boolean(parentProfile.currentVersionNo) useEffect(() => { if (selectedCategoryId === null && categories.length > 0) { @@ -299,10 +420,32 @@ export default function ConsignmentConfigPanel() { await queryClient.invalidateQueries({ queryKey: ['admin-consignment-profiles'] }) } + function handleReset() { + if (selectedProfile) { + form.setFieldsValue(toFormValues(selectedProfile)) + setSpecDrafts(cloneSpecs(selectedProfile.config.specs)) + return + } + form.setFieldsValue({ + ...emptyFormValues(), + name: selectedCategory?.name ? `${selectedCategory.name}寄卖` : '寄卖配置', + tabTitle: selectedCategory?.name || '', + }) + setSpecDrafts([]) + } + async function handleSave() { if (!selectedCategoryId) return + const specs = specDrafts.map((spec) => + isOptionSpecField(spec) + ? { + ...spec, + options: spec.options.filter((option) => option.label.trim() || option.value.trim()), + } + : spec, + ) const keys = new Set() - for (const spec of specDrafts) { + for (const spec of specs) { if (!/^[a-zA-Z][a-zA-Z0-9_]{0,39}$/.test(spec.key)) { message.error(`字段 Key 不合法:${spec.key || '(空)'}`) return @@ -323,7 +466,7 @@ export default function ConsignmentConfigPanel() { const values = await form.validateFields() setSaving(true) try { - await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values, specDrafts)) + await saveAdminConsignmentProfile(buildPayload(selectedCategoryId, values, specs)) message.success('寄卖配置已保存') await refresh() } catch (error) { @@ -358,35 +501,38 @@ export default function ConsignmentConfigPanel() { function changeSpecType(index: number, type: ConsignmentSpecFieldType) { setSpecDrafts((current) => - current.map((spec, specIndex) => { - if (specIndex !== index) return spec - if (isOptionSpecField({ type }) && spec.options.length === 0) { - return { - ...spec, - type, - options: [ - { label: '选项1', value: '选项1', enabled: true }, - { label: '选项2', value: '选项2', enabled: true }, - ], - } - } - return { ...spec, type } - }), + current.map((spec, specIndex) => + specIndex === index + ? { ...spec, type, options: isOptionSpecField({ type }) ? spec.options : [] } + : spec, + ), ) } - function updateOption(index: number, optionIndex: number, patch: Partial) { + /** 选项按订单服务风格用逗号分隔:保留已有选项的 label/value,新增项直接以文本生成。 */ + function updateOptionText(index: number, text: string) { + const tokens = text.split(/[,,]/).map((token) => token.trim()) setSpecDrafts((current) => - current.map((spec, specIndex) => - specIndex === index - ? { - ...spec, - options: spec.options.map((option, currentOptionIndex) => - currentOptionIndex === optionIndex ? { ...option, ...patch } : option, - ), - } - : spec, - ), + current.map((spec, specIndex) => { + if (specIndex !== index) return spec + const currentOptions = spec.options.filter((option) => option.enabled !== false) + const usedOptionIndexes = new Set() + const options = tokens.map((token) => { + if (!token) return { label: '', value: '', enabled: true } + const optionIndex = currentOptions.findIndex( + (option, currentOptionIndex) => + !usedOptionIndexes.has(currentOptionIndex) && + (option.label === token || option.value === token), + ) + const matchedOption = optionIndex >= 0 ? currentOptions[optionIndex] : undefined + if (matchedOption) { + usedOptionIndexes.add(optionIndex) + return { ...matchedOption, enabled: true } + } + return { label: token, value: token, enabled: true } + }) + return { ...spec, options } + }), ) } @@ -414,130 +560,227 @@ export default function ConsignmentConfigPanel() { setSpecDrafts((current) => [...current, newCustomSpec(current)]) } - function addOption(index: number) { - setSpecDrafts((current) => - current.map((spec, specIndex) => { - if (specIndex !== index) return spec - const nextValue = `选项${spec.options.length + 1}` - return { - ...spec, - options: [...spec.options, { label: nextValue, value: nextValue, enabled: true }], - } - }), - ) - } - - function removeOption(index: number, optionIndex: number) { - setSpecDrafts((current) => - current.map((spec, specIndex) => - specIndex === index - ? { - ...spec, - options: spec.options.filter((_, currentIndex) => currentIndex !== optionIndex), - } - : spec, - ), - ) - } - - const typeLabelMap = useMemo( - () => new Map(SPEC_FIELD_TYPE_OPTIONS.map((item) => [item.value, item.label])), - [], - ) - const priceType = Form.useWatch(['pricing', 'priceType'], form) + const headerStatus: ProfileStatusInfo = selectedProfile + ? resolveProfileStatus(selectedProfile) + : runtimeInheritsParent + ? { + label: `继承「${parentCategory?.name || '父级'}」v${parentProfile?.currentVersionNo}`, + tone: 'inherited', + } + : { label: '尚未配置', tone: 'muted' } return ( - - 寄卖配置 - {profiles.filter((p) => p.status === 'published').length} 个已发布 - - } - extra={ - - } - > -
- -
-
- {/* 1. 基础信息 */} - -
+
+ {selectedCategory ? ( + <> +
+
+ + + {selectedCategory.name} + + + {selectedCategory.status === 'active' ? '启用' : '停用'} + + {headerStatus.label} + {selectedProfile && runtimeInheritsParent ? ( + + 线上继承「{parentCategory?.name || '父级'}」v + {parentProfile?.currentVersionNo} + + ) : null} + +
+ 分类标识:{selectedCategory.categoryKey} +
+
+ + + + + {selectedProfile ? ( + + ) : null} + +
+ + {runtimeInheritsParent ? ( + + ) : null} + + +
+
+ +
+
@@ -563,18 +806,15 @@ export default function ConsignmentConfigPanel() { > - +
- {/* 2. 规格字段 */} - - 规格字段 - {specDrafts.length} 个 - - } - extra={ +
+
+ - } - style={cardStyle} - > +
- 初始为空;字段随配置版本发布,并写入提报与库存快照。Key 为 - system/serverZone/series/diamondLevel/brillianceCount - 时会自动同步旧数据列并沿用旧匹配器。 + Key 为 system/serverZone/series/diamondLevel/brillianceCount + 时会自动同步旧数据列并沿用旧匹配器;下拉/单选的选项用逗号分隔填写。 {specDrafts.length === 0 ? ( ) : ( -
+
{specDrafts.map((spec, index) => ( - - 字段 {index + 1} - {spec.label || spec.key} - {typeLabelMap.get(spec.type)} - - } - extra={ - +
+
+
+ 字段 Key + + updateSpec(index, { key: event.target.value }) + } + /> +
+
+ 显示名称 + + updateSpec(index, { label: event.target.value }) + } + /> +
+
+ 类型 + - updateSpec(index, { key: event.target.value }) - } - /> - - - - updateSpec(index, { label: event.target.value }) - } - /> - - - - - +
+
+ 填写说明 - +
+ {isOptionSpecField(spec) ? ( + option.enabled !== false) + .map((option) => option.label || option.value) + .join(',')} + onChange={(event) => updateOptionText(index, event.target.value)} + /> + ) : null}
-
- +
+
+ 匹配方式 updateSpec(index, { wildcardValues: values }) } /> - - +
+
+ 进物品名 - -
- - {isOptionSpecField(spec) ? ( -
- 选项 -
- {spec.options.map((option, optionIndex) => ( - - - updateOption(index, optionIndex, { - label: event.target.value, - }) - } - /> - - updateOption(index, optionIndex, { - value: event.target.value, - }) - } - /> - - updateOption(index, optionIndex, { enabled: checked }) - } - /> -
-
- ) : null} - +
+
))}
)} -
+
- {/* 3. 价格配置 */} - +
+
+ +
- +
- {/* 4. 保证金 */} - +
+
+ +
- +
- {/* 5. 服务费 */} - +
+
+ +
- +
- {/* 6. 库存限制 */} - +
+
+ +
- +
- {/* 7. 匹配设置 */} - +
+
+ +
- +
- {/* 8. 超时与降价 */} - +
+
+ +
- -
+ -
- - - {selectedProfile ? ( - - ) : null} -
- - - ) : ( - - )} - -
- +
+ 保存只更新草稿,发布后才会影响新工单。 + + + + +
+ + + ) : ( + + )} + +
+
+ ) } diff --git a/apps/frontend/tests/consignment-matching-config.test.ts b/apps/frontend/tests/consignment-matching-config.test.ts index 4b5fa0a1..d88e6f10 100644 --- a/apps/frontend/tests/consignment-matching-config.test.ts +++ b/apps/frontend/tests/consignment-matching-config.test.ts @@ -62,9 +62,11 @@ test('工单页:必须挂载内部库存匹配弹窗,并支持状态 interna assert.ok(shared.includes("internal_matching: 'magenta'"), '状态配色必须包含内部匹配中') }) -test('寄卖配置面板:独立功能卡片、空字段初始状态与价格配置', () => { +test('寄卖配置面板:订单服务风格分类树、独立分区与价格配置', () => { const panel = read('../src/pages/admin/panels/ConsignmentConfigPanel.tsx') for (const label of [ + '搜索分类', + '个分类已配置', '规格字段', '添加字段', '基础信息', @@ -81,10 +83,19 @@ test('寄卖配置面板:独立功能卡片、空字段初始状态与价格 ]) { assert.ok(panel.includes(label), `寄卖配置面板缺少「${label}」`) } + assert.ok(panel.includes('order-service-category-group'), '分类必须按订单服务风格分组') + assert.ok(panel.includes('order-service-subcategory-list'), '二级分类必须缩进展示') + assert.ok(panel.includes('resolveInheritedProfileStatus'), '子分类必须展示继承父级状态') + assert.ok(panel.includes('order-service-save-bar'), '底部必须使用订单服务保存栏') + assert.ok(panel.includes('ConsignmentSectionTitle'), '配置分区必须使用编号标题') assert.ok(!panel.includes('DEFAULT_SPEC_FIELDS'), '面板不能预置任何内置字段') assert.ok(panel.includes('useState([])'), '规格字段初始必须为空') assert.ok(panel.includes('specDrafts.map'), '规格字段必须可动态增删/排序') assert.ok(panel.includes('SPEC_FIELD_TYPE_OPTIONS'), '字段类型必须支持 6 种选项') + assert.ok(panel.includes('addonBefore="选项"'), '下拉/单选项必须使用订单服务式逗号输入') + assert.ok(panel.includes('placeholder="选项1,选项2,选项3"'), '选项输入必须使用订单服务占位文案') + assert.ok(panel.includes('updateOptionText'), '选项编辑必须把逗号分隔文本解析为选项') + assert.ok(!panel.includes('添加选项'), '选项不再逐行编辑') }) test('打手端:动态规格 + 系统建议价 + 改价不得高于建议价', () => { diff --git a/apps/frontend/tests/consignment-spec-options.test.ts b/apps/frontend/tests/consignment-spec-options.test.ts index ed30c9be..ec51e315 100644 --- a/apps/frontend/tests/consignment-spec-options.test.ts +++ b/apps/frontend/tests/consignment-spec-options.test.ts @@ -20,7 +20,7 @@ test('后台全量选项:系统含通用,区服含互通', () => { assert.ok(source.includes("value: '互通'"), '区服选项必须包含「互通」') }) -test('苏尔南默认配置:取消代码内置字段,由迁移 131 提供五个单选字段', () => { +test('苏尔南默认配置:取消代码内置字段,由迁移 137 提供五个单选字段', () => { const utils = readFileSync( new URL('../src/utils/consignment-spec.ts', import.meta.url).pathname, 'utf8',