feat: 包材列表对齐修复、操作精简、图片上传功能

- 所有列统一 flex items-center 垂直居中对齐
- 表头图片列加上"图片"文字
- 操作列精简为编辑/复制/删除三个图标按钮
- 图片列支持点击上传(JPG/PNG/WebP,最大10MB)
- 新增 /api/asset-center/upload-image 上传接口
This commit is contained in:
Codex 2026-04-03 20:01:50 +08:00
parent 72f866de12
commit f83c587794
2 changed files with 121 additions and 37 deletions

View File

@ -0,0 +1,56 @@
/**
* 包材图片上传 API
* POST /api/asset-center/upload-image
*/
import { NextRequest } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import path from 'path'
import { handleRequest, jsonSuccess, jsonError } from '@/server/core/response'
import { ErrorCode } from '@/server/core/errors'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
export async function POST(request: NextRequest) {
return handleRequest(request, async ({ requestId }) => {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return jsonError(ErrorCode.VALIDATION_REQUIRED_FIELD, '请选择要上传的图片', 400, requestId)
}
if (!ALLOWED_TYPES.includes(file.type)) {
return jsonError(ErrorCode.FILE_TYPE_NOT_ALLOWED, '仅支持 JPG、PNG、WebP 格式', 400, requestId)
}
if (file.size > MAX_FILE_SIZE) {
return jsonError(ErrorCode.FILE_TOO_LARGE, '图片大小不能超过 10MB', 400, requestId)
}
const uploadDir = path.join(process.cwd(), 'public', 'packaging-images')
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true })
}
const ext = path.extname(file.name) || '.jpg'
const timestamp = Date.now()
const random = Math.random().toString(36).slice(2, 8)
const fileName = `${timestamp}_${random}${ext}`
const filePath = path.join(uploadDir, fileName)
const bytes = await file.arrayBuffer()
await writeFile(filePath, Buffer.from(bytes))
const publicUrl = `/packaging-images/${fileName}`
return jsonSuccess({
url: publicUrl,
fileName,
size: file.size,
}, 201, requestId)
}, { requireAuth: true })
}

View File

@ -19,8 +19,6 @@ import {
Package2,
Pencil,
Plus,
Power,
PowerOff,
ToggleLeft,
ToggleRight,
Trash2,
@ -777,6 +775,33 @@ export default function PackagingMaterialsClient({
}
}
async function handleUploadImage(variant: AssetVariantDTO, file: File) {
try {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/asset-center/upload-image', {
method: 'POST',
body: formData,
})
const result = await response.json()
if (!response.ok || !result.data?.url) {
throw new Error(result.message || '上传失败')
}
const updatedVariant = await updateAssetVariant(variant.id, {
thumbnailUrl: result.data.url,
})
setVariants((previous) =>
previous.map((item) => (item.id === updatedVariant.id ? updatedVariant : item)),
)
} catch (error) {
setDetailError(error instanceof Error ? error.message : '图片上传失败')
}
}
const selectedFamilyCanDelete =
selectedFamily != null && (familyVariantCountMap.get(selectedFamily.id) ?? 0) === 0
@ -1006,8 +1031,8 @@ export default function PackagingMaterialsClient({
</div>
<div className="overflow-hidden rounded-[24px] border border-black/5">
<div className="grid grid-cols-[48px_1.4fr_1fr_0.9fr_1fr_0.8fr_0.7fr_auto] gap-2 bg-apple-bg-secondary/65 px-4 py-3 text-xs font-apple-medium uppercase tracking-[0.18em] text-apple-label-tertiary">
<span></span>
<div className="grid grid-cols-[48px_1.4fr_1fr_0.9fr_1fr_0.8fr_0.7fr_auto] items-center gap-2 bg-apple-bg-secondary/65 px-4 py-3 text-xs font-apple-medium uppercase tracking-[0.18em] text-apple-label-tertiary">
<span>图片</span>
<span>名称</span>
<span>编码</span>
<span>模板类型</span>
@ -1072,32 +1097,47 @@ export default function PackagingMaterialsClient({
}
}}
>
<div className="flex items-center justify-center">
{variant.thumbnailUrl ? (
<img
src={variant.thumbnailUrl}
alt={variant.name}
className="h-9 w-9 rounded-apple-md border border-black/5 object-cover"
<div
className="flex items-center justify-center"
onClick={(event) => event.stopPropagation()}
>
<label className="relative cursor-pointer">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void handleUploadImage(variant, file)
event.target.value = ''
}}
/>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-apple-md border border-black/5 bg-apple-bg-secondary/60">
<ImageIcon className="h-4 w-4 text-apple-label-tertiary" />
</div>
)}
{variant.thumbnailUrl ? (
<img
src={variant.thumbnailUrl}
alt={variant.name}
className="h-9 w-9 rounded-apple-md border border-black/5 object-cover transition hover:opacity-80"
/>
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-apple-md border border-dashed border-black/15 bg-apple-bg-secondary/60 transition hover:border-apple-blue/40 hover:bg-apple-blue/5">
<ImageIcon className="h-4 w-4 text-apple-label-tertiary" />
</div>
)}
</label>
</div>
<div className="min-w-0">
<p className="truncate text-sm font-apple-semibold">{variant.name}</p>
<div className="min-w-0 flex items-center">
<span className="truncate text-sm font-apple-semibold">{variant.name}</span>
</div>
<div className="min-w-0 text-sm text-apple-label-secondary">
<span className="block truncate font-mono text-xs">{variant.code}</span>
<div className="min-w-0 flex items-center text-apple-label-secondary">
<span className="truncate font-mono text-xs">{variant.code}</span>
</div>
<div className="min-w-0 text-sm text-apple-label-secondary">
<span className="block truncate">{template?.label ?? '未设置'}</span>
<div className="min-w-0 flex items-center text-apple-label-secondary">
<span className="truncate text-sm">{template?.label ?? '未设置'}</span>
</div>
<div className="min-w-0 text-sm text-apple-label-secondary">
<span className="block truncate font-mono text-xs">{specLabel}</span>
<div className="min-w-0 flex items-center text-apple-label-secondary">
<span className="truncate font-mono text-xs">{specLabel}</span>
</div>
<div className="min-w-0 text-sm">
<div className="min-w-0 flex items-center text-sm">
{variant.costPrice != null ? (
<span className="font-apple-medium">¥ {variant.costPrice}</span>
) : rowComputedCost != null ? (
@ -1106,7 +1146,7 @@ export default function PackagingMaterialsClient({
<span className="text-xs text-apple-label-tertiary">缺成本</span>
)}
</div>
<div>
<div className="flex items-center">
<Badge type={getStatusTone(variant.status)} variant="subtle" size="small">
{getStatusLabel(variant.status)}
</Badge>
@ -1131,18 +1171,6 @@ export default function PackagingMaterialsClient({
>
<Copy className="h-4 w-4" />
</button>
<button
type="button"
aria-label={variant.status === 'AVAILABLE' ? '停用' : '启用'}
className="rounded-apple-md p-1.5 text-apple-label-secondary transition hover:bg-apple-bg-secondary hover:text-apple-label-primary"
onClick={() => void handleToggleVariantStatus(variant)}
>
{variant.status === 'AVAILABLE' ? (
<PowerOff className="h-4 w-4" />
) : (
<Power className="h-4 w-4" />
)}
</button>
<button
type="button"
aria-label="删除"