feat(基础资料库): 移除系统名称、优化花色筛选和表单布局

- 全栈移除 SubstrateSystemNameOption(字典管理、表单、API、数据库)
- 花色改为单选 + 自动搜索下拉,排除五金油漆/漆面颜色/五金漆面色
- 基材名称自动生成格式:尺寸+环保等级+材质+花色+贴面
- 材质改为纯下拉选择,贴面始终显示
- 花色框体与 Select 组件宽度/高度统一
- 厚度规格字典只保留厚度数值+排序值
This commit is contained in:
Codex 2026-04-11 18:05:17 +08:00
parent c1606f9af6
commit 8d6eed4583
11 changed files with 211 additions and 238 deletions

View File

@ -446,8 +446,8 @@ model Substrate {
thicknessSpec SubstrateThicknessSpec @relation(fields: [thicknessSpecId], references: [id])
surfaceOptionId String?
surfaceOption SubstrateSurfaceOption? @relation(fields: [surfaceOptionId], references: [id])
systemNameOptionId String
systemNameOption SubstrateSystemNameOption @relation(fields: [systemNameOptionId], references: [id])
systemNameOptionId String?
systemNameOption SubstrateSystemNameOption? @relation(fields: [systemNameOptionId], references: [id])
suggestedPrice Decimal? @db.Decimal(10, 2)
weightKg Float?
metadata Json?

View File

@ -49,8 +49,7 @@ export async function DELETE(request: NextRequest, context: RouteParams) {
type !== 'CATEGORY' &&
type !== 'ENVIRONMENT_GRADE' &&
type !== 'THICKNESS_SPEC' &&
type !== 'SURFACE_OPTION' &&
type !== 'SYSTEM_NAME_OPTION'
type !== 'SURFACE_OPTION'
) {
return jsonError(ErrorCode.VALIDATION_FAILED, '缺少有效的字典类型', 400, ctx.requestId)
}

View File

@ -104,7 +104,6 @@ export const substrateCreateSchema = z.object({
environmentGradeId: z.string().trim().min(1),
thicknessSpecId: z.string().trim().min(1),
surfaceOptionId: z.string().trim().min(1).optional().nullable(),
systemNameOptionId: z.string().trim().min(1),
suggestedPrice: z.number().nonnegative().optional().nullable(),
weightKg: z.number().nonnegative().optional().nullable(),
finishIds: z.array(z.string().trim().min(1)).optional(),
@ -118,7 +117,6 @@ export const substrateUpdateSchema = z.object({
environmentGradeId: z.string().trim().min(1).optional(),
thicknessSpecId: z.string().trim().min(1).optional(),
surfaceOptionId: z.string().trim().min(1).optional().nullable(),
systemNameOptionId: z.string().trim().min(1).optional(),
suggestedPrice: z.number().nonnegative().optional().nullable(),
weightKg: z.number().nonnegative().optional().nullable(),
finishIds: z.array(z.string().trim().min(1)).optional(),
@ -131,7 +129,6 @@ export const substrateDictionaryTypeSchema = z.enum([
'ENVIRONMENT_GRADE',
'THICKNESS_SPEC',
'SURFACE_OPTION',
'SYSTEM_NAME_OPTION',
])
export const substrateDictionaryCreateSchema = z.object({

View File

@ -54,7 +54,6 @@ const dictionaries: SubstrateDictionaryBundleDTO = {
environmentGrades: [{ id: 'grade-e1', type: 'ENVIRONMENT_GRADE', name: 'E1', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
thicknessSpecs: [{ id: 'th-18', type: 'THICKNESS_SPEC', name: '18', sortOrder: 0, isActive: true, thicknessMm: 18, sheetLengthMm: 2440, sheetWidthMm: 1220, createdAt: new Date(), updatedAt: new Date() }],
surfaceOptions: [{ id: 'surface-double', type: 'SURFACE_OPTION', name: '双贴', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
systemNameOptions: [{ id: 'sys-18e1', type: 'SYSTEM_NAME_OPTION', name: '18E1级刨花板', sortOrder: 0, isActive: true, createdAt: new Date(), updatedAt: new Date() }],
}
function renderClient() {
@ -76,8 +75,6 @@ function renderClient() {
sheetWidthMm: 1220,
surfaceOptionId: 'surface-double',
surfaceOptionName: '双贴',
systemNameOptionId: 'sys-18e1',
systemNameOptionName: '18E1级刨花板',
suggestedPrice: 180,
weightKg: null,
finishes: [],
@ -118,8 +115,6 @@ describe('MaterialLibraryClient', () => {
sheetWidthMm: 1220,
surfaceOptionId: 'surface-double',
surfaceOptionName: '双贴',
systemNameOptionId: 'sys-18e1',
systemNameOptionName: '18E1级刨花板',
suggestedPrice: 200,
weightKg: null,
finishes: [],
@ -145,7 +140,6 @@ describe('MaterialLibraryClient', () => {
environmentGradeId: 'grade-e1',
thicknessSpecId: 'th-18',
surfaceOptionId: 'surface-double',
systemNameOptionId: 'sys-18e1',
suggestedPrice: 200,
}),
)

View File

@ -4,6 +4,7 @@ import { startTransition, useMemo, useRef, useState } from 'react'
import {
ArrowLeft,
Check,
ChevronDown,
ImagePlus,
Package2,
Pencil,
@ -72,7 +73,6 @@ type SubstrateFormState = {
environmentGradeId: string
thicknessSpecId: string
surfaceOptionId: string
systemNameOptionId: string
suggestedPrice: string
weightKg: string
finishIds: string[]
@ -144,7 +144,6 @@ function emptySubstrateForm(dict: SubstrateDictionaryBundleDTO): SubstrateFormSt
environmentGradeId: dict.environmentGrades[0]?.id ?? '',
thicknessSpecId: dict.thicknessSpecs[0]?.id ?? '',
surfaceOptionId: dict.surfaceOptions[0]?.id ?? '',
systemNameOptionId: dict.systemNameOptions[0]?.id ?? '',
suggestedPrice: '',
weightKg: '',
finishIds: [],
@ -170,8 +169,6 @@ function dictionaryOptionsByType(bundle: SubstrateDictionaryBundleDTO, type: Sub
return bundle.thicknessSpecs
case 'SURFACE_OPTION':
return bundle.surfaceOptions
case 'SYSTEM_NAME_OPTION':
return bundle.systemNameOptions
}
}
@ -185,8 +182,6 @@ function dictionaryLabel(type: SubstrateDictionaryType) {
return '厚度规格'
case 'SURFACE_OPTION':
return '贴面类型'
case 'SYSTEM_NAME_OPTION':
return '系统名称'
}
}
@ -225,7 +220,8 @@ export default function MaterialLibraryClient({
const [substrateForm, setSubstrateForm] = useState<SubstrateFormState>(
emptySubstrateForm(initialSubstrateDictionaries),
)
const [finishPickerOpen, setFinishPickerOpen] = useState(false)
const [finishSearch, setFinishSearch] = useState('')
const [finishDropdownOpen, setFinishDropdownOpen] = useState(false)
// Dictionary management state
const [dictionaryModalOpen, setDictionaryModalOpen] = useState(false)
@ -241,7 +237,6 @@ export default function MaterialLibraryClient({
...dictionaries.environmentGrades,
...dictionaries.thicknessSpecs,
...dictionaries.surfaceOptions,
...dictionaries.systemNameOptions,
]
const map = new Map<string, { name: string }>()
for (const item of all) map.set(item.id, item)
@ -255,7 +250,6 @@ export default function MaterialLibraryClient({
ENVIRONMENT_GRADE: new Set(substrates.map((item) => item.environmentGradeId).filter(Boolean)),
THICKNESS_SPEC: new Set(substrates.map((item) => item.thicknessSpecId).filter(Boolean)),
SURFACE_OPTION: new Set(substrates.map((item) => item.surfaceOptionId).filter(Boolean)),
SYSTEM_NAME_OPTION: new Set(substrates.map((item) => item.systemNameOptionId).filter(Boolean)),
} as Record<SubstrateDictionaryType, Set<string>>
}, [substrates])
@ -284,14 +278,20 @@ export default function MaterialLibraryClient({
)
}, [substrates, searchText])
const isSolidWood = substrateForm.categoryId === 'cat-solid' || substrateForm.categoryText === '实木'
const isSolidWood = (() => {
const catName = dictById.get(substrateForm.categoryId)?.name ?? substrateForm.categoryText
if (!catName) return false
const solidKeywords = ['实木', '橡胶木', '橡木', '黑胡桃', '白蜡', '榉木', '桦木', '松木', '柞木']
return solidKeywords.some((kw) => catName.includes(kw))
})()
// Filter finishes by material type
// Filter finishes by material type, exclude 五金油漆/漆面颜色/五金漆面色 (these are for hardware accessories only)
const filteredFinishesForSubstrate = useMemo(() => {
const excludeCategories = ['五金油漆', '漆面颜色', '五金漆面色']
if (isSolidWood) {
return finishes.filter((f) => f.categoryName === '实木花色')
return finishes.filter((f) => f.categoryName === '实木花色' && !excludeCategories.includes(f.categoryName ?? ''))
}
return finishes.filter((f) => f.categoryName !== '实木花色')
return finishes.filter((f) => f.categoryName !== '实木花色' && !excludeCategories.includes(f.categoryName ?? ''))
}, [finishes, isSolidWood])
// Get the default finish category for inline creation
@ -302,20 +302,26 @@ export default function MaterialLibraryClient({
return categories.find((c) => c.name === '木纹贴面花色')?.id ?? categories[0]?.id ?? ''
}, [categories, isSolidWood])
const finishAutoSuggestions = useMemo(() => {
const kw = finishSearch.trim().toLowerCase()
if (!kw) return []
return filteredFinishesForSubstrate
.filter((f) => f.name.toLowerCase().includes(kw))
.slice(0, 8)
}, [finishSearch, filteredFinishesForSubstrate])
const previewSubstrateName = useMemo(() => {
const cat = substrateForm.categoryText.trim() || dictById.get(substrateForm.categoryId)?.name || ''
const cat = dictById.get(substrateForm.categoryId)?.name || ''
const env = dictById.get(substrateForm.environmentGradeId)?.name ?? ''
const thick = dictionaries.thicknessSpecs.find((t) => t.id === substrateForm.thicknessSpecId)
const surface = isSolidWood ? '' : (dictById.get(substrateForm.surfaceOptionId)?.name ?? '')
const finishNames = substrateForm.finishIds
.map((fid) => finishes.find((f) => f.id === fid)?.name)
.filter(Boolean) as string[]
const newFinish = substrateForm.newFinishName.trim()
if (newFinish) finishNames.push(newFinish)
const surface = dictById.get(substrateForm.surfaceOptionId)?.name ?? ''
const finishName = substrateForm.finishIds.length > 0
? finishes.find((f) => f.id === substrateForm.finishIds[0])?.name
: substrateForm.newFinishName.trim() || ''
if (!cat || !env || !thick) return ''
const thickness = thick.name.toUpperCase()
const finishPart = finishNames.length > 0 ? finishNames.join(' ') : ''
return [thickness, env, cat, finishPart, surface].filter(Boolean).join(' ')
const thickness = `${thick.thicknessMm ?? thick.name}`
const envPart = env.endsWith('级') ? env : `${env}级`
return [thickness, envPart, cat, finishName, surface].filter(Boolean).join(' ')
}, [substrateForm, dictById, dictionaries.thicknessSpecs, finishes, isSolidWood])
// Selected items
@ -371,7 +377,6 @@ export default function MaterialLibraryClient({
environmentGradeId: s.environmentGradeId,
thicknessSpecId: s.thicknessSpecId,
surfaceOptionId: s.surfaceOptionId ?? '',
systemNameOptionId: s.systemNameOptionId,
suggestedPrice: s.suggestedPrice != null ? String(s.suggestedPrice) : '',
weightKg: s.weightKg != null ? String(s.weightKg) : '',
finishIds: s.finishes.map((f) => f.id),
@ -420,7 +425,7 @@ export default function MaterialLibraryClient({
async function handleSaveSubstrate() {
const solidWood = substrateForm.categoryId === 'cat-solid' || substrateForm.categoryText.trim() === '实木'
if (!substrateForm.categoryText.trim() || !substrateForm.environmentGradeId || !substrateForm.thicknessSpecId || !substrateForm.systemNameOptionId) {
if (!substrateForm.categoryText.trim() || !substrateForm.environmentGradeId || !substrateForm.thicknessSpecId) {
toast.error('请补齐必填选项'); return
}
if (!solidWood && !substrateForm.surfaceOptionId) {
@ -470,7 +475,6 @@ export default function MaterialLibraryClient({
environmentGradeId: substrateForm.environmentGradeId,
thicknessSpecId: substrateForm.thicknessSpecId,
surfaceOptionId: solidWood ? undefined : substrateForm.surfaceOptionId,
systemNameOptionId: substrateForm.systemNameOptionId,
suggestedPrice: toNum(substrateForm.suggestedPrice),
weightKg: toNum(substrateForm.weightKg),
finishIds: finishIds.length > 0 ? finishIds : undefined,
@ -533,11 +537,29 @@ export default function MaterialLibraryClient({
}
async function handleSaveDictionary() {
if (!dictionaryForm.name.trim()) {
if (activeDictionaryType === 'THICKNESS_SPEC') {
if (!dictionaryForm.thicknessMm.trim() || !Number.isFinite(Number(dictionaryForm.thicknessMm))) {
toast.error('请填写有效的厚度数值')
return
}
} else if (!dictionaryForm.name.trim()) {
toast.error(`请填写${dictionaryLabel(activeDictionaryType)}名称`)
return
}
// Uniqueness check for non-THICKNESS_SPEC types
if (activeDictionaryType !== 'THICKNESS_SPEC') {
const trimmedName = dictionaryForm.name.trim()
const existingOptions = dictionaryOptionsByType(dictionaries, activeDictionaryType)
const duplicate = existingOptions.find(
(opt) => opt.name === trimmedName && opt.id !== editingDictionaryId,
)
if (duplicate) {
toast.error(`已存在同名「${trimmedName}」,不可重复创建`)
return
}
}
setSubmitting(true)
try {
if (dictionaryModalMode === 'create') {
@ -583,7 +605,6 @@ export default function MaterialLibraryClient({
environmentGrades: patch(current.environmentGrades),
thicknessSpecs: patch(current.thicknessSpecs),
surfaceOptions: patch(current.surfaceOptions),
systemNameOptions: patch(current.systemNameOptions),
}
})
setDictionaryModalOpen(false)
@ -619,7 +640,6 @@ export default function MaterialLibraryClient({
environmentGrades: current.environmentGrades.filter((item) => item.id !== deleteTarget.id),
thicknessSpecs: current.thicknessSpecs.filter((item) => item.id !== deleteTarget.id),
surfaceOptions: current.surfaceOptions.filter((item) => item.id !== deleteTarget.id),
systemNameOptions: current.systemNameOptions.filter((item) => item.id !== deleteTarget.id),
}))
setDeleteTarget(null)
toast.success(`${dictionaryLabel(deleteTarget.dictionaryType)}已删除`)
@ -820,122 +840,137 @@ export default function MaterialLibraryClient({
{/* Auto-generated name preview */}
<Input label="基材名称(自动生成)" value={previewSubstrateName} readOnly placeholder="按选项自动生成" />
<div className="grid grid-cols-2 gap-3">
<div>
<p className="mb-1.5 text-sm font-apple-medium text-apple-label-primary">材质</p>
<input
list="substrate-category-list"
value={substrateForm.categoryText}
onChange={(e) => {
const text = e.target.value
const match = dictionaries.categories.find((d) => d.name === text)
setSubstrateForm((c) => ({
...c,
categoryText: text,
categoryId: match ? match.id : '',
}))
}}
placeholder="选择或输入材质名称"
className="w-full rounded-apple-md border border-apple-separator bg-apple-bg-primary px-3 py-1.5 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary"
/>
<datalist id="substrate-category-list">
{dictionaries.categories.map((d) => (
<option key={d.id} value={d.name} />
))}
</datalist>
</div>
<Select label="材质" value={substrateForm.categoryId} onChange={(e) => {
const cat = dictionaries.categories.find((d) => d.id === e.target.value)
setSubstrateForm((c) => ({
...c,
categoryId: e.target.value,
categoryText: cat?.name ?? '',
}))
}} options={dictionaries.categories.map((d) => ({ value: d.id, label: d.name }))} />
<Select label="环保等级" value={substrateForm.environmentGradeId} onChange={(e) => setSubstrateForm((c) => ({ ...c, environmentGradeId: e.target.value }))} options={dictionaries.environmentGrades.map((d) => ({ value: d.id, label: d.name }))} />
<Select label="尺寸" value={substrateForm.thicknessSpecId} onChange={(e) => setSubstrateForm((c) => ({ ...c, thicknessSpecId: e.target.value }))} options={dictionaries.thicknessSpecs.map((d) => ({ value: d.id, label: `${d.thicknessMm}MM (${d.sheetLengthMm ?? 2440}×${d.sheetWidthMm ?? 1220})` }))} />
{!isSolidWood && <Select label="贴面" value={substrateForm.surfaceOptionId} onChange={(e) => setSubstrateForm((c) => ({ ...c, surfaceOptionId: e.target.value }))} options={dictionaries.surfaceOptions.map((d) => ({ value: d.id, label: d.name }))} />}
<Select label="贴面" value={substrateForm.surfaceOptionId} onChange={(e) => setSubstrateForm((c) => ({ ...c, surfaceOptionId: e.target.value }))} options={dictionaries.surfaceOptions.map((d) => ({ value: d.id, label: d.name }))} />
<Input label="售价" value={substrateForm.suggestedPrice} onChange={(e) => setSubstrateForm((c) => ({ ...c, suggestedPrice: e.target.value }))} placeholder="例如 180" />
<Input label="重量 (kg)" value={substrateForm.weightKg} onChange={(e) => setSubstrateForm((c) => ({ ...c, weightKg: e.target.value }))} placeholder="例如 32" />
</div>
{/* Finish multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<p className="text-sm font-apple-medium text-apple-label-primary">花色</p>
<div className="flex gap-2">
<Button variant="plain" size="small" onClick={() => setFinishPickerOpen(!finishPickerOpen)}>
{finishPickerOpen ? '收起' : '选择已有花色'}
</Button>
</div>
</div>
{/* Inline new finish input */}
<div className="flex items-center gap-2 mb-2">
<input
type="text"
value={substrateForm.newFinishName}
onChange={(e) => setSubstrateForm((c) => ({ ...c, newFinishName: e.target.value }))}
placeholder="输入新花色名称,保存时自动创建"
className="flex-1 rounded-apple-md border border-apple-separator bg-apple-bg-primary px-3 py-1.5 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary"
/>
{substrateForm.newFinishName.trim() && (
<button
type="button"
onClick={() => {
const name = substrateForm.newFinishName.trim()
setSubstrateForm((c) => ({ ...c, newFinishName: '' }))
// Check if already exists
const existing = filteredFinishesForSubstrate.find((f) => f.name === name)
if (existing) {
if (!substrateForm.finishIds.includes(existing.id)) {
setSubstrateForm((c) => ({ ...c, finishIds: [...c.finishIds, existing.id] }))
}
} else {
// Will be created on save via newFinishName
setSubstrateForm((c) => ({ ...c, newFinishName: name }))
}
}}
className="shrink-0 rounded-apple-md bg-apple-blue px-3 py-1.5 text-xs text-white hover:bg-apple-blue/90"
>
添加
</button>
{/* Finish single-select inside grid */}
<div className="space-y-2">
<label className="block text-xs font-apple-medium text-apple-label-secondary mb-1.5">花色</label>
<div className="relative">
<div
className={cn(
'w-full flex items-center justify-between border rounded-apple-md bg-apple-bg-secondary cursor-text h-11 px-4 text-sm',
finishDropdownOpen ? 'border-apple-blue border-2' : 'border-apple-separator hover:border-apple-label-tertiary',
)}
</div>
{/* Pending new finish badge */}
{substrateForm.newFinishName.trim() && (
<div className="flex flex-wrap gap-1 pb-2">
<span className="inline-flex items-center gap-1 rounded-full border border-apple-blue/30 bg-apple-blue/10 px-2 py-0.5 text-[11px] text-apple-blue">
+ {substrateForm.newFinishName.trim()}(新建)
<button type="button" onClick={() => setSubstrateForm((c) => ({ ...c, newFinishName: '' }))} className="hover:text-apple-red"><X className="h-3 w-3" /></button>
</span>
</div>
)}
{substrateForm.finishIds.length > 0 && (
<div className="flex flex-wrap gap-1 pb-2">
{substrateForm.finishIds.map((fid) => {
const f = finishes.find((x) => x.id === fid)
if (!f) return null
onClick={() => { setFinishDropdownOpen(true); setFinishSearch('') }}
>
{(() => {
const selectedFinish = substrateForm.finishIds.length > 0
? finishes.find((x) => x.id === substrateForm.finishIds[0])
: null
const pendingNew = substrateForm.newFinishName.trim()
if (pendingNew && !selectedFinish) {
return (
<span key={fid} className="inline-flex items-center gap-1 rounded-full border border-apple-separator bg-apple-bg-secondary px-2 py-0.5 text-[11px] text-apple-label-secondary">
{f.name}
<button type="button" onClick={() => setSubstrateForm((c) => ({ ...c, finishIds: c.finishIds.filter((id) => id !== fid) }))} className="text-apple-label-tertiary hover:text-apple-red"><X className="h-3 w-3" /></button>
<span className="flex flex-1 items-center gap-1.5 px-3 py-1.5 text-sm text-apple-blue overflow-hidden">
<Plus className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{pendingNew}(新建)</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setSubstrateForm((c) => ({ ...c, newFinishName: '' })) }} className="ml-1 shrink-0 hover:text-apple-red"><X className="h-3 w-3" /></button>
</span>
)
})}
</div>
)}
{finishPickerOpen && (
<div className="max-h-40 space-y-1 overflow-y-auto rounded-apple-md border border-apple-separator bg-apple-bg-primary p-2">
{filteredFinishesForSubstrate.length === 0 ? (
<p className="px-2 py-1 text-xs text-apple-label-tertiary">暂无花色,请在上方输入框新建</p>
) : (
filteredFinishesForSubstrate.map((f) => {
const selected = substrateForm.finishIds.includes(f.id)
return (
<button key={f.id} type="button" onClick={() => setSubstrateForm((c) => ({ ...c, finishIds: selected ? c.finishIds.filter((id) => id !== f.id) : [...c.finishIds, f.id] }))}
className={cn('flex w-full items-center gap-2 rounded-apple-sm px-2 py-1.5 text-left text-sm', selected ? 'bg-apple-blue/10 text-apple-blue' : 'text-apple-label-primary hover:bg-apple-bg-secondary')}>
<div className={cn('flex h-4 w-4 items-center justify-center rounded border', selected ? 'border-apple-blue bg-apple-blue text-white' : 'border-apple-separator')}>
{selected && <Check className="h-3 w-3" />}
</div>
{f.thumbnailUrl && <img src={f.thumbnailUrl} alt="" className="h-5 w-5 rounded-sm object-cover" />}
<span className="flex-1 truncate">{f.name}</span>
<span className="text-xs text-apple-label-tertiary">{f.code}</span>
}
if (selectedFinish) {
return (
<span className="flex flex-1 items-center gap-1.5 px-3 py-1.5 text-sm text-apple-label-primary overflow-hidden">
{selectedFinish.thumbnailUrl && <img src={selectedFinish.thumbnailUrl} alt="" className="h-4 w-4 shrink-0 rounded-sm object-cover" />}
<span className="truncate">{selectedFinish.name}</span>
<button type="button" onClick={(e) => { e.stopPropagation(); setSubstrateForm((c) => ({ ...c, finishIds: [] })) }} className="ml-1 shrink-0 text-apple-label-tertiary hover:text-apple-red"><X className="h-3.5 w-3.5" /></button>
</span>
)
}
return (
<input
type="text"
value={finishSearch}
onChange={(e) => { setFinishSearch(e.target.value); setFinishDropdownOpen(true) }}
onFocus={() => setFinishDropdownOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const name = finishSearch.trim()
if (!name) return
const existing = filteredFinishesForSubstrate.find((f) => f.name === name)
if (existing) {
setSubstrateForm((c) => ({ ...c, finishIds: [existing.id], newFinishName: '' }))
} else {
setSubstrateForm((c) => ({ ...c, finishIds: [], newFinishName: name }))
}
setFinishSearch('')
setFinishDropdownOpen(false)
}
}}
placeholder="输入花色名称搜索"
className="flex-1 bg-transparent px-3 py-1.5 text-sm text-apple-label-primary placeholder:text-apple-label-tertiary focus:outline-none"
/>
)
})()}
<button
type="button"
onClick={(e) => { e.stopPropagation(); setFinishDropdownOpen(!finishDropdownOpen); setFinishSearch('') }}
className="flex shrink-0 items-center justify-center px-2.5 py-1.5 text-apple-label-tertiary hover:text-apple-label-secondary"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
</div>
{/* Dropdown */}
{finishDropdownOpen && (
<div className="absolute left-0 z-10 mt-1 w-full max-h-60 overflow-y-auto rounded-apple-lg border border-apple-separator bg-apple-bg-primary/95 backdrop-blur-xl shadow-apple-lg">
<div className="py-1">
{finishSearch.trim() ? (
finishAutoSuggestions.length > 0 ? (
finishAutoSuggestions.map((f) => (
<button key={f.id} type="button" onClick={() => {
setSubstrateForm((c) => ({ ...c, finishIds: [f.id], newFinishName: '' }))
setFinishSearch('')
setFinishDropdownOpen(false)
}} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-apple-label-primary hover:bg-apple-bg-secondary">
{f.thumbnailUrl && <img src={f.thumbnailUrl} alt="" className="h-5 w-5 shrink-0 rounded-sm object-cover" />}
<span className="flex-1 truncate">{f.name}</span>
<span className="shrink-0 text-[11px] text-apple-label-tertiary">{f.code}</span>
</button>
))
) : (
<button type="button" onClick={() => {
const name = finishSearch.trim()
if (name) setSubstrateForm((c) => ({ ...c, finishIds: [], newFinishName: name }))
setFinishSearch('')
setFinishDropdownOpen(false)
}} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-apple-blue hover:bg-apple-blue/5">
<Plus className="h-3.5 w-3.5" />
<span>创建「{finishSearch.trim()}」</span>
</button>
)
})
)}
) : (
filteredFinishesForSubstrate.slice(0, 15).map((f) => {
const selected = substrateForm.finishIds.includes(f.id)
return (
<button key={f.id} type="button" onClick={() => {
setSubstrateForm((c) => ({ ...c, finishIds: selected ? [] : [f.id], newFinishName: '' }))
setFinishSearch('')
setFinishDropdownOpen(false)
}} className={cn('flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors', selected ? 'text-apple-blue bg-apple-blue/5' : 'text-apple-label-primary hover:bg-apple-bg-secondary')}>
{selected && <Check className="h-3.5 w-3.5 shrink-0" />}
{f.thumbnailUrl && <img src={f.thumbnailUrl} alt="" className="h-5 w-5 shrink-0 rounded-sm object-cover" />}
<span className="flex-1 truncate">{f.name}</span>
<span className="shrink-0 text-[11px] text-apple-label-tertiary">{f.code}</span>
</button>
)
})
)}
</div>
</div>
)}
</div>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button size="small" onClick={handleSaveSubstrate} loading={submitting}>
@ -1017,7 +1052,7 @@ export default function MaterialLibraryClient({
{/* Dictionary management buttons (substrate tab only) */}
{activeTab === 'substrate' && (
<div className="flex gap-2 overflow-x-auto whitespace-nowrap pb-1">
{(['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION', 'SYSTEM_NAME_OPTION'] as SubstrateDictionaryType[]).map((type) => (
{(['CATEGORY', 'ENVIRONMENT_GRADE', 'THICKNESS_SPEC', 'SURFACE_OPTION'] as SubstrateDictionaryType[]).map((type) => (
<Button key={type} variant="plain" size="small" onClick={() => openDictionaryModal(type)} className="shrink-0">
<Settings2 className="h-3.5 w-3.5" />
管理{dictionaryLabel(type)}
@ -1085,12 +1120,13 @@ export default function MaterialLibraryClient({
<div key={item.id} className="flex items-center gap-2 rounded-apple-sm border border-apple-separator/70 px-2 py-1.5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-apple-label-primary">
{item.name}
{item.type === 'THICKNESS_SPEC' && item.thicknessMm != null
? `${item.thicknessMm}MM`
: item.name}
{isLegacySeedOptionId(item.id) ? '(历史默认)' : ''}
</p>
<p className="text-[11px] text-apple-label-tertiary">
排序 {item.sortOrder}
{item.type === 'THICKNESS_SPEC' && item.thicknessMm != null ? ` · ${item.thicknessMm}mm` : ''}
</p>
</div>
<button type="button" onClick={() => openDictionaryModal(activeDictionaryType, item)} className="rounded-full p-1.5 text-apple-label-secondary hover:bg-apple-bg-secondary" title="编辑">
@ -1102,9 +1138,17 @@ export default function MaterialLibraryClient({
</div>
))}
</div>
<Input label="名称" value={dictionaryForm.name} onChange={(event) => setDictionaryForm((current) => ({ ...current, name: event.target.value }))} />
<Input label="排序值" value={dictionaryForm.sortOrder} onChange={(event) => setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))} />
{activeDictionaryType === 'THICKNESS_SPEC' ? <Input label="厚度 mm" value={dictionaryForm.thicknessMm} onChange={(event) => setDictionaryForm((current) => ({ ...current, thicknessMm: event.target.value }))} /> : null}
{activeDictionaryType === 'THICKNESS_SPEC' ? (
<>
<Input label="厚度(MM)" value={dictionaryForm.thicknessMm} onChange={(event) => setDictionaryForm((current) => ({ ...current, thicknessMm: event.target.value, name: event.target.value }))} placeholder="例如 12、15、18" />
<Input label="排序值" value={dictionaryForm.sortOrder} onChange={(event) => setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))} />
</>
) : (
<>
<Input label="名称" value={dictionaryForm.name} onChange={(event) => setDictionaryForm((current) => ({ ...current, name: event.target.value }))} />
<Input label="排序值" value={dictionaryForm.sortOrder} onChange={(event) => setDictionaryForm((current) => ({ ...current, sortOrder: event.target.value }))} />
</>
)}
</div>
</Modal>

View File

@ -15,17 +15,23 @@ function toFinitePositive(value: number | null | undefined): number | null {
}
export function buildSubstrateDisplayName(params: {
thicknessLabel: string
thicknessMm: number
environmentGradeLabel: string
systemNameLabel: string
categoryLabel: string
finishLabels?: string[]
surfaceLabel?: string
}): string {
const envLabel = params.environmentGradeLabel.trim()
const envWithSuffix = envLabel ? (envLabel.endsWith('级') ? envLabel : `${envLabel}级`) : ''
return [
params.thicknessLabel.trim(),
params.environmentGradeLabel.trim(),
params.systemNameLabel.trim(),
`${params.thicknessMm}`,
envWithSuffix,
params.categoryLabel.trim(),
...(params.finishLabels ?? []),
params.surfaceLabel?.trim() ?? '',
]
.filter(Boolean)
.join('')
.join(' ')
}
export function buildSubstrateSummaryLabel(params: {

View File

@ -40,7 +40,6 @@ export interface SubstrateWithRelations extends Substrate {
environmentGrade: { id: string; name: string }
thicknessSpec: { id: string; name: string; thicknessMm: number; sheetLengthMm: number; sheetWidthMm: number }
surfaceOption: { id: string; name: string } | null
systemNameOption: { id: string; name: string }
finishes: {
finish: {
id: string
@ -89,7 +88,6 @@ const substrateRelationInclude = {
},
},
surfaceOption: { select: { id: true, name: true } },
systemNameOption: { select: { id: true, name: true } },
finishes: {
select: {
finish: {

View File

@ -103,8 +103,6 @@ describe('assetCenterService.createSubstrate', () => {
},
surfaceOptionId: null,
surfaceOption: null,
systemNameOptionId: 'sys-solid',
systemNameOption: { id: 'sys-solid', name: '实木板' },
suggestedPrice: 320,
weightKg: 32,
metadata: null,
@ -124,7 +122,6 @@ describe('assetCenterService.createSubstrate', () => {
environmentGradeId: 'grade-e1',
thicknessSpecId: 'th-18',
surfaceOptionId: null,
systemNameOptionId: 'sys-solid',
suggestedPrice: 320,
weightKg: 32,
finishIds: ['finish-oak', 'finish-walnut'],

View File

@ -234,8 +234,6 @@ function toSubstrateDTO(record: SubstrateWithRelations): SubstrateDTO {
sheetWidthMm: record.thicknessSpec.sheetWidthMm,
surfaceOptionId: record.surfaceOptionId,
surfaceOptionName: record.surfaceOption?.name ?? null,
systemNameOptionId: record.systemNameOptionId,
systemNameOptionName: record.systemNameOption.name,
suggestedPrice: decimalToNumber(record.suggestedPrice),
weightKg: record.weightKg ?? null,
finishes: (record.finishes ?? []).map((item) => ({
@ -279,8 +277,6 @@ function getDictionaryLabel(type: SubstrateDictionaryType): string {
return '厚度规格'
case 'SURFACE_OPTION':
return '贴面类型'
case 'SYSTEM_NAME_OPTION':
return '系统名称'
}
}
@ -349,9 +345,8 @@ async function loadSubstrateParts(input: {
environmentGradeId: string
thicknessSpecId: string
surfaceOptionId: string | null
systemNameOptionId: string
}) {
const [category, environmentGrade, thicknessSpec, surfaceOption, systemNameOption] =
const [category, environmentGrade, thicknessSpec, surfaceOption] =
await Promise.all([
prisma.substrateCategory.findUnique({ where: { id: input.categoryId } }),
prisma.substrateEnvironmentGrade.findUnique({ where: { id: input.environmentGradeId } }),
@ -359,7 +354,6 @@ async function loadSubstrateParts(input: {
input.surfaceOptionId
? prisma.substrateSurfaceOption.findUnique({ where: { id: input.surfaceOptionId } })
: Promise.resolve(null),
prisma.substrateSystemNameOption.findUnique({ where: { id: input.systemNameOptionId } }),
])
if (!category) {
@ -374,16 +368,12 @@ async function loadSubstrateParts(input: {
if (input.surfaceOptionId && !surfaceOption) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '贴面类型不存在')
}
if (!systemNameOption) {
throw new AppError(ErrorCode.SUBSTRATE_NOT_FOUND, '系统名称不存在')
}
return {
category,
environmentGrade,
thicknessSpec,
surfaceOption,
systemNameOption,
}
}
@ -392,13 +382,12 @@ async function buildSubstratePayload(input: {
environmentGradeId: string
thicknessSpecId: string
surfaceOptionId: string | null
systemNameOptionId: string
}) {
const parts = await loadSubstrateParts(input)
const generatedName = buildSubstrateDisplayName({
thicknessLabel: parts.thicknessSpec.name,
thicknessMm: parts.thicknessSpec.thicknessMm,
environmentGradeLabel: parts.environmentGrade.name,
systemNameLabel: parts.systemNameOption.name,
categoryLabel: parts.category.name,
})
return {
@ -438,7 +427,6 @@ async function buildSubstratePayloadWithFinishes(input: {
environmentGradeId: string
thicknessSpecId: string
surfaceOptionId: string | null
systemNameOptionId: string
finishIds: string[]
}) {
const parts = await loadSubstrateParts(input)
@ -452,15 +440,13 @@ async function buildSubstratePayloadWithFinishes(input: {
: []
const finishPart = finishRows.map((item) => item.name).join(' ')
const generatedName = [
parts.thicknessSpec.name.toUpperCase(),
parts.environmentGrade.name,
parts.category.name,
finishPart,
parts.surfaceOption?.name ?? '',
]
.filter(Boolean)
.join(' ')
const generatedName = buildSubstrateDisplayName({
thicknessMm: parts.thicknessSpec.thicknessMm,
environmentGradeLabel: parts.environmentGrade.name,
categoryLabel: parts.category.name,
finishLabels: finishPart ? [finishPart] : [],
surfaceLabel: parts.surfaceOption?.name ?? '',
})
return {
generatedName,
@ -529,13 +515,12 @@ class AssetCenterService {
}
async getSubstrateDictionaryBundle(): Promise<SubstrateDictionaryBundleDTO> {
const [categories, environmentGrades, thicknessSpecs, surfaceOptions, systemNameOptions] =
const [categories, environmentGrades, thicknessSpecs, surfaceOptions] =
await Promise.all([
prisma.substrateCategory.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
prisma.substrateEnvironmentGrade.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
prisma.substrateThicknessSpec.findMany({ orderBy: [{ sortOrder: 'asc' }, { thicknessMm: 'asc' }] }),
prisma.substrateSurfaceOption.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
prisma.substrateSystemNameOption.findMany({ orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }] }),
])
return {
@ -543,7 +528,6 @@ class AssetCenterService {
environmentGrades: environmentGrades.map((item) => toDictionaryOptionDTO('ENVIRONMENT_GRADE', item)),
thicknessSpecs: thicknessSpecs.map((item) => toDictionaryOptionDTO('THICKNESS_SPEC', item)),
surfaceOptions: surfaceOptions.map((item) => toDictionaryOptionDTO('SURFACE_OPTION', item)),
systemNameOptions: systemNameOptions.map((item) => toDictionaryOptionDTO('SYSTEM_NAME_OPTION', item)),
}
}
@ -626,7 +610,6 @@ class AssetCenterService {
environmentGradeId: input.environmentGradeId,
thicknessSpecId: input.thicknessSpecId,
surfaceOptionId: input.surfaceOptionId ?? null,
systemNameOptionId: input.systemNameOptionId,
isActive: true,
},
include: {
@ -650,7 +633,6 @@ class AssetCenterService {
environmentGradeId: existing.environmentGradeId,
thicknessSpecId: existing.thicknessSpecId,
surfaceOptionId: existing.surfaceOptionId ?? null,
systemNameOptionId: existing.systemNameOptionId,
finishIds: mergedFinishIds,
})
@ -683,7 +665,6 @@ class AssetCenterService {
environmentGradeId: input.environmentGradeId,
thicknessSpecId: input.thicknessSpecId,
surfaceOptionId: input.surfaceOptionId ?? null,
systemNameOptionId: input.systemNameOptionId,
finishIds,
})
@ -698,7 +679,6 @@ class AssetCenterService {
surfaceOption: input.surfaceOptionId
? { connect: { id: input.surfaceOptionId } }
: undefined,
systemNameOption: { connect: { id: input.systemNameOptionId } },
suggestedPrice: input.suggestedPrice ?? null,
weightKg: input.weightKg ?? null,
finishes:
@ -720,7 +700,6 @@ class AssetCenterService {
select: { id: true, name: true, thicknessMm: true, sheetLengthMm: true, sheetWidthMm: true },
},
surfaceOption: { select: { id: true, name: true } },
systemNameOption: { select: { id: true, name: true } },
finishes: {
include: {
finish: {
@ -760,7 +739,6 @@ class AssetCenterService {
const nextThicknessSpecId = input.thicknessSpecId ?? existing.thicknessSpecId
const nextSurfaceOptionId =
input.surfaceOptionId !== undefined ? input.surfaceOptionId : existing.surfaceOptionId
const nextSystemNameOptionId = input.systemNameOptionId ?? existing.systemNameOptionId
const nextFinishIds =
input.finishIds !== undefined
? normalizeFinishIds(input.finishIds)
@ -774,13 +752,12 @@ class AssetCenterService {
environmentGradeId: nextEnvironmentGradeId,
thicknessSpecId: nextThicknessSpecId,
surfaceOptionId: nextSurfaceOptionId ?? null,
systemNameOptionId: nextSystemNameOptionId,
isActive: true,
},
select: { id: true },
})
if (duplicate) {
throw new AppError(ErrorCode.VALIDATION_FAILED, '已存在相同 5 字段规格的基材,请直接维护已有基材')
throw new AppError(ErrorCode.VALIDATION_FAILED, '已存在相同规格的基材,请直接维护已有基材')
}
const payload = await buildSubstratePayloadWithFinishes({
@ -788,7 +765,6 @@ class AssetCenterService {
environmentGradeId: nextEnvironmentGradeId,
thicknessSpecId: nextThicknessSpecId,
surfaceOptionId: nextSurfaceOptionId ?? null,
systemNameOptionId: nextSystemNameOptionId,
finishIds: nextFinishIds,
})
@ -815,10 +791,6 @@ class AssetCenterService {
: input.surfaceOptionId
? { connect: { id: input.surfaceOptionId } }
: { disconnect: true },
systemNameOption:
input.systemNameOptionId === undefined
? undefined
: { connect: { id: input.systemNameOptionId } },
suggestedPrice: input.suggestedPrice === undefined ? undefined : input.suggestedPrice,
weightKg: input.weightKg === undefined ? undefined : input.weightKg,
finishes:
@ -840,7 +812,6 @@ class AssetCenterService {
select: { id: true, name: true, thicknessMm: true, sheetLengthMm: true, sheetWidthMm: true },
},
surfaceOption: { select: { id: true, name: true } },
systemNameOption: { select: { id: true, name: true } },
finishes: {
include: {
finish: {
@ -913,11 +884,6 @@ class AssetCenterService {
'SURFACE_OPTION',
await prisma.substrateSurfaceOption.create({ data: dataBase }),
)
case 'SYSTEM_NAME_OPTION':
return toDictionaryOptionDTO(
'SYSTEM_NAME_OPTION',
await prisma.substrateSystemNameOption.create({ data: dataBase }),
)
}
} catch (error) {
if (
@ -988,18 +954,6 @@ class AssetCenterService {
},
}),
)
case 'SYSTEM_NAME_OPTION':
return toDictionaryOptionDTO(
'SYSTEM_NAME_OPTION',
await prisma.substrateSystemNameOption.update({
where: { id },
data: {
name: input.name?.trim(),
sortOrder: input.sortOrder,
isActive: input.isActive,
},
}),
)
}
} catch (error) {
if (
@ -1024,7 +978,6 @@ class AssetCenterService {
ENVIRONMENT_GRADE: { environmentGradeId: id },
THICKNESS_SPEC: { thicknessSpecId: id },
SURFACE_OPTION: { surfaceOptionId: id },
SYSTEM_NAME_OPTION: { systemNameOptionId: id },
}
const referenceCount = await prisma.substrate.count({ where: whereByType[type] })
if (referenceCount > 0) {
@ -1045,9 +998,6 @@ class AssetCenterService {
case 'SURFACE_OPTION':
await prisma.substrateSurfaceOption.delete({ where: { id } })
return
case 'SYSTEM_NAME_OPTION':
await prisma.substrateSystemNameOption.delete({ where: { id } })
return
}
} catch (error) {
if (

View File

@ -100,8 +100,6 @@ export interface SubstrateDTO {
sheetWidthMm: number
surfaceOptionId: string | null
surfaceOptionName: string | null
systemNameOptionId: string
systemNameOptionName: string
suggestedPrice: number | null
weightKg: number | null
finishes: {
@ -121,7 +119,6 @@ export type SubstrateDictionaryType =
| 'ENVIRONMENT_GRADE'
| 'THICKNESS_SPEC'
| 'SURFACE_OPTION'
| 'SYSTEM_NAME_OPTION'
export interface SubstrateDictionaryOptionDTO {
id: string
@ -141,7 +138,6 @@ export interface SubstrateDictionaryBundleDTO {
environmentGrades: SubstrateDictionaryOptionDTO[]
thicknessSpecs: SubstrateDictionaryOptionDTO[]
surfaceOptions: SubstrateDictionaryOptionDTO[]
systemNameOptions: SubstrateDictionaryOptionDTO[]
}
export interface CreateAssetFamilyInput {
@ -223,7 +219,6 @@ export interface CreateSubstrateInput {
environmentGradeId: string
thicknessSpecId: string
surfaceOptionId?: string | null
systemNameOptionId: string
suggestedPrice?: number | null
weightKg?: number | null
finishIds?: string[]
@ -238,7 +233,6 @@ export interface UpdateSubstrateInput {
environmentGradeId?: string
thicknessSpecId?: string
surfaceOptionId?: string | null
systemNameOptionId?: string
suggestedPrice?: number | null
weightKg?: number | null
finishIds?: string[]

View File

@ -341,7 +341,7 @@ async function ensureSubstrate(creatorId: string) {
return existing
}
const [category, environmentGrade, thicknessSpec, surfaceOption, systemNameOption] = await Promise.all([
const [category, environmentGrade, thicknessSpec, surfaceOption] = await Promise.all([
prisma.substrateCategory.upsert({
where: { name: '颗粒板' },
update: {},
@ -362,11 +362,6 @@ async function ensureSubstrate(creatorId: string) {
update: {},
create: { name: '双贴', sortOrder: 0, isActive: true },
}),
prisma.substrateSystemNameOption.upsert({
where: { name: '18E1级颗粒板' },
update: {},
create: { name: '18E1级颗粒板', sortOrder: 0, isActive: true },
}),
])
return assetCenterService.createSubstrate({
@ -376,7 +371,6 @@ async function ensureSubstrate(creatorId: string) {
environmentGradeId: environmentGrade.id,
thicknessSpecId: thicknessSpec.id,
surfaceOptionId: surfaceOption.id,
systemNameOptionId: systemNameOption.id,
suggestedPrice: 180,
metadata: {
seedKey: OPEN_PLATFORM_DEMO_SEED_MARKER,