diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index e9760e96..556389e7 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -62,6 +62,11 @@ import { fetchAdminSession } from '@/services/admin/auth' import { resolveAdminPermission } from '@/utils/admin-auth' import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence' +import { + CONSIGNMENT_SPEC_TAG_COLORS, + resolveConsignmentSpecDisplayItems, + resolveConsignmentSpecText, +} from '@/utils/consignment-spec-display' import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec' import { formatDateTime } from '@/utils/date-time' @@ -301,14 +306,21 @@ export default function ConsignmentInventoryPanel() { ? Math.round(Number(values.depositAmount) * 100) : undefined const selectedWorker = workerOptions.find((w) => w.value === values.workerId) + // 有动态规格时规格值走 specValues(后端据此重算 spec_text),旧列不再写, + // 否则密钥这类只有「商品」的分类会被顺手写成 1钻0璀璨。 + const hasDynamicSpecs = Boolean(editingItem.specs?.length) await updateAdminConsignmentInventory(editingItem.id, { itemName: values.itemName, - system: values.system, - serverZone: values.serverZone, - series: values.series, - diamondLevel: Number(values.diamondLevel || 1), - brillianceCount: Number(values.brillianceCount || 0), + ...(hasDynamicSpecs + ? {} + : { + system: values.system, + serverZone: values.serverZone, + series: values.series, + diamondLevel: Number(values.diamondLevel || 1), + brillianceCount: Number(values.brillianceCount || 0), + }), costPriceFen, salePriceFen: values.salePrice !== undefined @@ -471,28 +483,46 @@ export default function ConsignmentInventoryPanel() { ), }, { - title: '和平精英规格', + title: '寄卖规格', key: 'specs', - width: 200, - render: (_, record) => ( - -
- - {record.system || '通用'} - - - {record.serverZone || '全区'} - -
-
- {record.series || '通用'} - {record.diamondLevel} 钻 - {record.brillianceCount > 0 ? ( - {record.brillianceCount} 璀璨 - ) : null} -
-
- ), + width: 220, + render: (_, record) => { + // 规格按该分类已发布的寄卖模板渲染;没有规格配置的历史库存才回退旧字段。 + const specItems = resolveConsignmentSpecDisplayItems(record.specs, record.specValues) + if (specItems.length > 0) { + return ( + + {specItems.map((item, index) => ( + + {item.label} {item.value} + + ))} + + ) + } + return ( + +
+ + {record.system || '通用'} + + + {record.serverZone || '全区'} + +
+
+ {record.series || '通用'} + {record.diamondLevel} 钻 + {record.brillianceCount > 0 ? ( + {record.brillianceCount} 璀璨 + ) : null} +
+
+ ) + }, }, { title: '收购价', @@ -1047,34 +1077,36 @@ export default function ConsignmentInventoryPanel() { - - - - {/* 后台全量选项:系统 苹果/安卓/通用,区服 微信/QQ/互通(互通=QQ/微信都能收) */} - - - - - - - - - - - - - - - - - - - + {editingItem?.specs?.length ? null : ( + + + + {/* 后台全量选项:系统 苹果/安卓/通用,区服 微信/QQ/互通(互通=QQ/微信都能收) */} + + + + + + + + + + + + + + + + + + + + )} {editingItem?.specs?.length ? ( <> @@ -1082,7 +1114,15 @@ export default function ConsignmentInventoryPanel() { {editingItem.specs.map((field) => ( - + {field.type === 'select' || field.type === 'radio' ? ( field.type === 'radio' ? ( - {viewingItem.system} / {viewingItem.serverZone} / {viewingItem.series} /{' '} - {viewingItem.diamondLevel}钻 ({viewingItem.brillianceCount}璀璨) + {resolveConsignmentSpecText(viewingItem)} diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx index fede7336..92ffaa1d 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -12,6 +12,7 @@ import { Card, Col, Descriptions, + Divider, Form, Input, InputNumber, @@ -43,6 +44,11 @@ import type { ConsignmentSubmission, GiftEvidenceReviewStatus } from '@/types/co import { resolveAdminPermission } from '@/utils/admin-auth' import { formatConsignmentContactSupporter } from '@/utils/consignment-contact' import { describeGiftEvidence, resolveGiftEvidenceBadge } from '@/utils/consignment-evidence' +import { + CONSIGNMENT_SPEC_TAG_COLORS, + resolveConsignmentSpecDisplayItems, + resolveConsignmentSpecText, +} from '@/utils/consignment-spec-display' import { CONSIGNMENT_SYSTEM_OPTIONS, CONSIGNMENT_ZONE_OPTIONS } from '@/utils/consignment-spec' import { formatDateTime } from '@/utils/date-time' @@ -154,6 +160,9 @@ export default function ConsignmentSubmissionsPanel() { ? Math.round(Number(values.depositAmount) * 100) : undefined const selectedWorker = workerOptions.find((w) => w.value === values.workerId) + // 有动态规格时规格值由 specValues 下发给后端推导 spec_text; + // 旧的 系统/区服/系列/钻级/璀璨 列只是历史兼容,不能再写(否则密钥类目会被写成 1钻0璀璨)。 + const hasDynamicSpecs = Boolean(approveModalItem.specs?.length) await approveAdminConsignmentSubmission(approveModalItem.id, { costPriceFen, @@ -161,11 +170,15 @@ export default function ConsignmentSubmissionsPanel() { shopName: values.shopName, inboundBatch: values.inboundBatch, itemName: values.itemName, - system: values.system, - serverZone: values.serverZone, - series: values.series, - diamondLevel: Number(values.diamondLevel || 1), - brillianceCount: Number(values.brillianceCount || 0), + ...(hasDynamicSpecs + ? {} + : { + system: values.system, + serverZone: values.serverZone, + series: values.series, + diamondLevel: Number(values.diamondLevel || 1), + brillianceCount: Number(values.brillianceCount || 0), + }), specValues: values.specValues && Object.keys(values.specValues).length ? values.specValues @@ -229,21 +242,38 @@ export default function ConsignmentSubmissionsPanel() { { title: '提报物品规格', key: 'specs', - width: 210, - render: (_, record) => ( - -
{record.itemName}
- - {record.system} - {record.serverZone} - {record.series} - {record.diamondLevel} 钻 - {record.brillianceCount > 0 ? ( - {record.brillianceCount} 璀璨 - ) : null} + width: 230, + render: (_, record) => { + // 规格按该分类已发布的寄卖模板渲染;没配置规格的历史提报才回退旧字段。 + const specItems = resolveConsignmentSpecDisplayItems(record.specs, record.specValues) + return ( + +
{record.itemName}
+ + {specItems.length > 0 ? ( + specItems.map((item, index) => ( + + {item.label} {item.value} + + )) + ) : ( + <> + {record.system} + {record.serverZone} + {record.series} + {record.diamondLevel} 钻 + {record.brillianceCount > 0 ? ( + {record.brillianceCount} 璀璨 + ) : null} + + )} +
-
- ), + ) + }, }, { title: '卖家报价', @@ -647,73 +677,85 @@ export default function ConsignmentSubmissionsPanel() {
- - - - - - - - - - - - - - - - - - - - - - + {approveModalItem?.specs?.length ? null : ( + + + + + + + + + + + + + + + + + + + + + + + )} {approveModalItem?.specs?.length ? ( - - {approveModalItem.specs.map((field) => ( - - - {field.type === 'select' || field.type === 'radio' ? ( - field.type === 'radio' ? ( - option.enabled !== false) - .map((option) => ({ label: option.label, value: option.value }))} + <> + + 规格(按寄卖分类模板) + + + {approveModalItem.specs.map((field) => ( + + + {field.type === 'select' || field.type === 'radio' ? ( + field.type === 'radio' ? ( + option.enabled !== false) + .map((option) => ({ label: option.label, value: option.value }))} + /> + ) : ( + + ) : field.type === 'textarea' ? ( + ) : ( - - ) : field.type === 'textarea' ? ( - - ) : ( - - )} - - - ))} - + + )} + + + ))} + + ) : null} {/* 分组二:履约与打手 */} @@ -888,9 +930,7 @@ export default function ConsignmentSubmissionsPanel() { {viewingSubmission.itemName} - {viewingSubmission.system} / {viewingSubmission.serverZone} /{' '} - {viewingSubmission.series} / {viewingSubmission.diamondLevel}钻 ( - {viewingSubmission.brillianceCount}璀璨) + {resolveConsignmentSpecText(viewingSubmission)} diff --git a/apps/frontend/src/utils/consignment-spec-display.ts b/apps/frontend/src/utils/consignment-spec-display.ts new file mode 100644 index 00000000..9c78e280 --- /dev/null +++ b/apps/frontend/src/utils/consignment-spec-display.ts @@ -0,0 +1,90 @@ +import type { ConsignmentSpecField, ConsignmentSpecValues } from '../types/consignment' + +export type ConsignmentSpecDisplayItem = { + key: string + label: string + value: string +} + +/** 旧版内置规格字段(没有动态规格配置的历史分类才会用到)。 */ +export type ConsignmentLegacySpecRecord = { + system?: string + serverZone?: string + series?: string + diamondLevel?: number | null + brillianceCount?: number | null +} + +/** 动态规格字段按模板顺序展示时的标签配色。 */ +export const CONSIGNMENT_SPEC_TAG_COLORS = [ + 'geekblue', + 'cyan', + 'orange', + 'gold', + 'magenta', + 'green', +] + +/** 选项值统一按字符串匹配(配置里 value 是字符串,历史数据可能是数字)。 */ +function formatSpecValue(field: ConsignmentSpecField, raw: unknown): string { + if (raw === undefined || raw === null) return '' + const text = String(raw).trim() + if (!text) return '' + if (field.type === 'select' || field.type === 'radio') { + const option = field.options?.find((item) => String(item.value) === text) + return option?.label || text + } + return text +} + +/** + * 按寄卖分类模板把 specValues 转成可展示的 {label, value}。 + * + * 顺序与后端 buildConsignmentSpecText 保持一致(sortOrder 升序,其次 key), + * 选项型字段把存储的选项值映射回选项名称(如 product=15 → 碎梦浮光珍宝礼盒)。 + */ +export function resolveConsignmentSpecDisplayItems( + specs: ConsignmentSpecField[] | null | undefined, + values: ConsignmentSpecValues | null | undefined, +): ConsignmentSpecDisplayItem[] { + if (!specs || specs.length === 0) return [] + return [...specs] + .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0) || a.key.localeCompare(b.key)) + .map((field) => ({ + key: field.key, + label: field.label, + value: formatSpecValue(field, values?.[field.key]), + })) + .filter((item) => item.value !== '') +} + +/** 旧版内置字段拼成一行(历史分类 & 历史数据兜底)。 */ +export function formatLegacyConsignmentSpec(record: ConsignmentLegacySpecRecord): string { + const parts = [ + record.system, + record.serverZone, + record.series, + record.diamondLevel ? `${record.diamondLevel}钻` : '', + record.brillianceCount ? `${record.brillianceCount}璀璨` : '', + ] + .map((part) => String(part ?? '').trim()) + .filter(Boolean) + return parts.join(' / ') +} + +/** + * 详情页规格文本:后端 specText 优先(已含选项名称),其次按模板现拼,最后回退旧字段。 + */ +export function resolveConsignmentSpecText( + input: { + specText?: string + specs?: ConsignmentSpecField[] | null + specValues?: ConsignmentSpecValues | null + } & ConsignmentLegacySpecRecord, +): string { + const specText = String(input.specText ?? '').trim() + if (specText) return specText + const items = resolveConsignmentSpecDisplayItems(input.specs, input.specValues) + if (items.length > 0) return items.map((item) => `${item.label} ${item.value}`).join(' / ') + return formatLegacyConsignmentSpec(input) || '-' +} diff --git a/apps/frontend/tests/consignment-admin-dynamic-specs.test.ts b/apps/frontend/tests/consignment-admin-dynamic-specs.test.ts new file mode 100644 index 00000000..7319a5fc --- /dev/null +++ b/apps/frontend/tests/consignment-admin-dynamic-specs.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +function readPanel(name: string) { + return readFileSync( + fileURLToPath(new URL(`../src/pages/admin/panels/${name}`, import.meta.url)), + 'utf8', + ) +} + +test('提报审核:规格按寄卖模板渲染,有模板时不再写死旧字段', () => { + const page = readPanel('ConsignmentSubmissionsPanel.tsx') + + assert.ok( + page.includes('resolveConsignmentSpecDisplayItems(record.specs, record.specValues)'), + '提报列表必须按模板字段渲染规格', + ) + assert.ok( + page.includes('resolveConsignmentSpecText(viewingSubmission)'), + '提报详情必须使用模板规格文本', + ) + assert.ok( + page.includes('{approveModalItem?.specs?.length ? null : ('), + '有模板规格时审核弹窗必须隐藏系统/区服/系列/钻级/璀璨旧字段', + ) + assert.ok( + page.includes('const hasDynamicSpecs = Boolean(approveModalItem.specs?.length)'), + '提交前必须判断该分类是否有模板规格', + ) + assert.ok(page.includes('...(hasDynamicSpecs'), '有模板规格时不得再提交旧规格字段') + assert.ok(page.includes('规格(按寄卖分类模板)'), '审核弹窗需要标明规格来自寄卖模板') + assert.ok(page.includes('请填写「'), '动态必填字段必须在表单层校验') +}) + +test('寄售仓库:列表/详情按模板渲染,编辑不再写死旧字段', () => { + const page = readPanel('ConsignmentInventoryPanel.tsx') + + assert.ok(!page.includes("title: '和平精英规格'"), '仓库列表列名不得写死和平精英') + assert.ok(page.includes("title: '寄卖规格'"), '仓库列表列名应为通用「寄卖规格」') + assert.ok( + page.includes('resolveConsignmentSpecDisplayItems(record.specs, record.specValues)'), + '仓库列表必须按模板字段渲染规格', + ) + assert.ok( + page.includes('resolveConsignmentSpecText(viewingItem)'), + '仓库详情必须使用模板规格文本', + ) + assert.ok( + page.includes('{editingItem?.specs?.length ? null : ('), + '有模板规格时编辑弹窗必须隐藏旧规格字段', + ) + assert.ok( + page.includes('const hasDynamicSpecs = Boolean(editingItem.specs?.length)'), + '保存前必须判断该分类是否有模板规格', + ) + assert.ok(page.includes('...(hasDynamicSpecs'), '有模板规格时不得再提交旧规格字段') + assert.ok(page.includes('请填写「'), '动态必填字段必须在表单层校验') +}) diff --git a/apps/frontend/tests/consignment-spec-display.test.ts b/apps/frontend/tests/consignment-spec-display.test.ts new file mode 100644 index 00000000..49484e62 --- /dev/null +++ b/apps/frontend/tests/consignment-spec-display.test.ts @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { ConsignmentSpecField } from '../src/types/consignment.ts' +import { + formatLegacyConsignmentSpec, + resolveConsignmentSpecDisplayItems, + resolveConsignmentSpecText, +} from '../src/utils/consignment-spec-display.ts' + +function makeSpec( + overrides: Partial & Pick, +): ConsignmentSpecField { + return { + type: 'text', + required: false, + options: [], + placeholder: '', + helpText: '', + matchMode: 'exact', + wildcardValues: [], + showInItemName: false, + sortOrder: 0, + ...overrides, + } +} + +const productSpec = makeSpec({ + key: 'product', + label: '商品', + type: 'select', + required: true, + sortOrder: 10, + options: [ + { label: '碎梦浮光珍宝礼盒', value: '15', enabled: true }, + { label: '指挥官密钥', value: '9', enabled: true }, + ], +}) + +const systemSpec = makeSpec({ + key: 'system', + label: '系统', + type: 'select', + sortOrder: 1, + options: [ + { label: '苹果', value: 'ios', enabled: true }, + { label: '安卓', value: 'android', enabled: true }, + ], +}) + +const levelSpec = makeSpec({ + key: 'diamondLevel', + label: '钻石等级', + type: 'number', + sortOrder: 2, +}) + +test('动态规格:选项值映射成选项名称(密钥分类 product=15)', () => { + const items = resolveConsignmentSpecDisplayItems([productSpec], { product: '15' }) + assert.deepEqual(items, [{ key: 'product', label: '商品', value: '碎梦浮光珍宝礼盒' }]) +}) + +test('动态规格:按模板 sortOrder 排序,空值不展示', () => { + const items = resolveConsignmentSpecDisplayItems([productSpec, levelSpec, systemSpec], { + product: '15', + system: 'ios', + diamondLevel: '', + }) + assert.deepEqual( + items.map((item) => item.key), + ['system', 'product'], + ) + assert.equal(items[0]?.value, '苹果') +}) + +test('动态规格:没有模板字段时返回空数组', () => { + assert.deepEqual(resolveConsignmentSpecDisplayItems([], { product: '15' }), []) + assert.deepEqual(resolveConsignmentSpecDisplayItems(undefined, undefined), []) +}) + +test('规格文本:后端 specText 优先', () => { + assert.equal( + resolveConsignmentSpecText({ + specText: '商品 碎梦浮光珍宝礼盒', + specs: [productSpec], + specValues: { product: '9' }, + }), + '商品 碎梦浮光珍宝礼盒', + ) +}) + +test('规格文本:无 specText 时按模板现拼', () => { + assert.equal( + resolveConsignmentSpecText({ specs: [productSpec], specValues: { product: '15' } }), + '商品 碎梦浮光珍宝礼盒', + ) +}) + +test('规格文本:没有模板规格时回退旧列,0 璀璨不展示', () => { + assert.equal( + resolveConsignmentSpecText({ + system: '苹果', + serverZone: '微信区', + series: '暗潮', + diamondLevel: 3, + brillianceCount: 2, + }), + '苹果 / 微信区 / 暗潮 / 3钻 / 2璀璨', + ) + assert.equal(formatLegacyConsignmentSpec({ diamondLevel: 1, brillianceCount: 0 }), '1钻') + assert.equal(resolveConsignmentSpecText({}), '-') +})