feat: enhance brand knowledge uploads

This commit is contained in:
江林铖 2026-09-09 13:28:52 +08:00
parent bdc7458038
commit 12f2baf3bc
10 changed files with 301 additions and 205 deletions

View File

@ -0,0 +1,52 @@
import { readFile, stat } from 'fs/promises'
import path from 'path'
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { assertBrandCenterDashboardAccess, BrandCenterDashboardAccessError } from '@/lib/brand-center-dashboard-access'
import { prisma } from '@/lib/prisma'
import { getUploadRootDir, resolveStoredUploadPath } from '@/lib/upload-paths'
import { isPathFreeOfSymbolicLinks, isPathInAllowedDirs } from '@/lib/path-security'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
export async function GET(_request: Request, context: { params: { id: string } }) {
try {
const session = await getServerSession(authOptions)
await assertBrandCenterDashboardAccess(session)
const item = await prisma.brandKnowledge.findUnique({
where: { id: context.params.id },
select: { previewImagePath: true, previewImageMimeType: true, previewImageSize: true },
})
if (!item?.previewImagePath) return NextResponse.json({ success: false, error: '预览图不存在' }, { status: 404, headers: PRIVATE_HEADERS })
const uploadRoot = path.resolve(getUploadRootDir())
const filePath = path.resolve(resolveStoredUploadPath(item.previewImagePath))
if (!isPathInAllowedDirs(filePath, [uploadRoot]) || !(await isPathFreeOfSymbolicLinks(uploadRoot, filePath))) {
return NextResponse.json({ success: false, error: '预览图路径不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const fileStats = await stat(filePath)
if (!fileStats.isFile() || (item.previewImageSize != null && fileStats.size !== Number(item.previewImageSize))) {
return NextResponse.json({ success: false, error: '预览图不可用' }, { status: 404, headers: PRIVATE_HEADERS })
}
const body = await readFile(filePath)
return new NextResponse(new Uint8Array(body), {
status: 200,
headers: {
...PRIVATE_HEADERS,
'Content-Type': item.previewImageMimeType || 'image/webp',
'Content-Length': String(fileStats.size),
'Content-Disposition': 'inline',
},
})
} catch (error) {
if (error instanceof BrandCenterDashboardAccessError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status, headers: PRIVATE_HEADERS })
}
console.error('[department-dashboard] 品牌知识预览图读取失败:', error)
return NextResponse.json({ success: false, error: '品牌知识预览图读取失败,请稍后重试' }, { status: 500, headers: PRIVATE_HEADERS })
}
}

View File

@ -10,15 +10,19 @@ import { getUploadRootDir } from '@/lib/upload-paths'
import { ensureSafeDirectoryWithinRoot, isPathFreeOfSymbolicLinks, normalizeSafeFileName, resolveStrictChildPath } from '@/lib/path-security'
import { assertBoundedMultipartHeaders, assertMultipartFiles } from '@/lib/multipart-request-guard'
import { boundedMultipartRequestLimiter, getBoundedMultipartFailure } from '@/lib/multipart-route-limits'
import { validateBrandImage, MAX_BRAND_IMAGE_BYTES } from '@/lib/brand-image-validation'
import { BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS } from '@/lib/brand-knowledge-file-types'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const MAX_FILE_SIZE = 50 * 1024 * 1024
const MAX_REQUEST_SIZE = 52 * 1024 * 1024
const MAX_REQUEST_SIZE = 58 * 1024 * 1024
const MAX_PREVIEW_IMAGE_SIZE = MAX_BRAND_IMAGE_BYTES
const KNOWLEDGE_TYPES = new Set(['品牌规范', '产品知识', '内容规范', '流程文档'])
const ALLOWED_EXTENSIONS = new Set(['.pdf', '.doc', '.docx', '.ppt', '.pptx', '.xls', '.xlsx', '.csv', '.md', '.txt'])
// The source file stays independent from Material/MaterialAsset rows.
const ALLOWED_EXTENSIONS: ReadonlySet<string> = new Set(BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS)
const PRIVATE_HEADERS = { 'Cache-Control': 'private, no-store, max-age=0' }
function accessErrorResponse(error: unknown) {
@ -37,9 +41,14 @@ function serializeKnowledge(item: {
title: string
knowledgeType: string
brand: string
productCategoryName: string
fileName: string
mimeType: string
fileSize: bigint
previewImageName: string | null
previewImagePath: string | null
previewImageMimeType: string | null
previewImageSize: bigint | null
createdAt: Date
uploader: { realName: string | null; username: string }
}) {
@ -48,12 +57,15 @@ function serializeKnowledge(item: {
title: item.title,
knowledgeType: item.knowledgeType,
brand: item.brand,
productCategoryName: item.productCategoryName,
fileName: item.fileName,
mimeType: item.mimeType,
fileSize: Number(item.fileSize),
createdAt: item.createdAt.toISOString(),
uploadedBy: item.uploader.realName || item.uploader.username,
downloadUrl: `/api/department-dashboard/brand-knowledge/${item.id}/file`,
previewUrl: item.previewImagePath ? `/api/department-dashboard/brand-knowledge/${item.id}/preview` : null,
previewImageName: item.previewImageName,
}
}
@ -72,6 +84,7 @@ export async function GET(request: Request) {
{ title: { contains: query, mode: 'insensitive' } },
{ fileName: { contains: query, mode: 'insensitive' } },
{ brand: { contains: query, mode: 'insensitive' } },
{ productCategoryName: { contains: query, mode: 'insensitive' } },
],
} : {}),
},
@ -82,9 +95,14 @@ export async function GET(request: Request) {
title: true,
knowledgeType: true,
brand: true,
productCategoryName: true,
fileName: true,
mimeType: true,
fileSize: true,
previewImageName: true,
previewImagePath: true,
previewImageMimeType: true,
previewImageSize: true,
createdAt: true,
uploader: { select: { realName: true, username: true } },
},
@ -100,7 +118,9 @@ export async function GET(request: Request) {
export async function POST(request: Request) {
let storedPath: string | null = null
let storedPreviewPath: string | null = null
let temporaryPath: string | null = null
let temporaryPreviewPath: string | null = null
let releaseRequestSlot: (() => void) | null = null
try {
const session = await getServerSession(authOptions)
@ -112,25 +132,54 @@ export async function POST(request: Request) {
releaseRequestSlot = await boundedMultipartRequestLimiter.acquire()
const formData = await request.formData()
assertMultipartFiles(formData, {
fields: { file: { maxCount: 1, maxFileBytes: MAX_FILE_SIZE } },
maxFiles: 1,
maxTotalBytes: MAX_FILE_SIZE,
fields: {
file: { maxCount: 1, maxFileBytes: MAX_FILE_SIZE },
previewImage: {
maxCount: 1,
maxFileBytes: MAX_PREVIEW_IMAGE_SIZE,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
},
},
maxFiles: 2,
maxTotalBytes: MAX_FILE_SIZE + MAX_PREVIEW_IMAGE_SIZE,
})
const file = formData.get('file')
const previewImage = formData.get('previewImage')
if (!(file instanceof File)) {
return NextResponse.json({ success: false, error: '请选择知识文件' }, { status: 400, headers: PRIVATE_HEADERS })
}
if (previewImage !== null && !(previewImage instanceof File)) {
return NextResponse.json({ success: false, error: '预览图文件无效' }, { status: 400, headers: PRIVATE_HEADERS })
}
const title = normalizeText(formData.get('title'), 200)
const knowledgeType = normalizeText(formData.get('knowledgeType'), 30)
const brand = normalizeText(formData.get('brand'), 40)
const productCategoryName = normalizeText(formData.get('productCategoryName'), 80)
if (!title) return NextResponse.json({ success: false, error: '请填写知识标题' }, { status: 400, headers: PRIVATE_HEADERS })
if (!KNOWLEDGE_TYPES.has(knowledgeType)) return NextResponse.json({ success: false, error: '知识类型不合法' }, { status: 400, headers: PRIVATE_HEADERS })
if (file.size <= 0 || file.size > MAX_FILE_SIZE) return NextResponse.json({ success: false, error: '文件大小需在 1B 至 50MB 之间' }, { status: 400, headers: PRIVATE_HEADERS })
let validatedPreview: Awaited<ReturnType<typeof validateBrandImage>> | null = null
let previewImageName: string | null = null
if (previewImage instanceof File) {
if (previewImage.size <= 0 || previewImage.size > MAX_PREVIEW_IMAGE_SIZE) {
return NextResponse.json({ success: false, error: '预览图需为 1B 至 5MB 之间的 PNG、JPEG 或 WebP 图片' }, { status: 400, headers: PRIVATE_HEADERS })
}
try {
validatedPreview = await validateBrandImage(Buffer.from(await previewImage.arrayBuffer()), previewImage.type)
} catch (error) {
return NextResponse.json(
{ success: false, error: error instanceof Error ? error.message : '预览图无效,请上传 PNG、JPEG 或 WebP 图片' },
{ status: 400, headers: PRIVATE_HEADERS },
)
}
previewImageName = normalizeSafeFileName(previewImage.name) || `preview.${validatedPreview.extension}`
}
const safeOriginalName = normalizeSafeFileName(file.name)
if (!safeOriginalName) return NextResponse.json({ success: false, error: '文件名不合法' }, { status: 400, headers: PRIVATE_HEADERS })
const extension = path.extname(safeOriginalName).toLowerCase()
if (!ALLOWED_EXTENSIONS.has(extension)) return NextResponse.json({ success: false, error: '仅支持 PDF、Office、表格、Markdown 和文本文件' }, { status: 400, headers: PRIVATE_HEADERS })
if (!ALLOWED_EXTENSIONS.has(extension)) return NextResponse.json({ success: false, error: '文件格式不支持,请参考素材资产库支持的格式' }, { status: 400, headers: PRIVATE_HEADERS })
const uploadRoot = getUploadRootDir()
const knowledgeDirectory = await ensureSafeDirectoryWithinRoot(uploadRoot, 'brand-knowledge')
@ -145,15 +194,34 @@ export async function POST(request: Request) {
temporaryPath = null
storedPath = `uploads/brand-knowledge/${storageName}`
let previewStorageName: string | null = null
if (previewImage instanceof File && validatedPreview) {
previewStorageName = `${randomUUID()}.${validatedPreview.extension}`
const previewDestination = resolveStrictChildPath(knowledgeDirectory, previewStorageName)
if (!previewDestination || !(await isPathFreeOfSymbolicLinks(uploadRoot, previewDestination, { allowMissingLeaf: true }))) {
throw new Error('品牌知识预览图路径不可用')
}
temporaryPreviewPath = `${previewDestination}.uploading`
await writeFile(temporaryPreviewPath, Buffer.from(await previewImage.arrayBuffer()), { flag: 'wx' })
await rename(temporaryPreviewPath, previewDestination)
temporaryPreviewPath = null
storedPreviewPath = `uploads/brand-knowledge/${previewStorageName}`
}
const created = await prisma.brandKnowledge.create({
data: {
title,
knowledgeType,
brand,
productCategoryName,
fileName: safeOriginalName,
filePath: storedPath,
mimeType: file.type || 'application/octet-stream',
fileSize: BigInt(file.size),
previewImageName,
previewImagePath: storedPreviewPath,
previewImageMimeType: validatedPreview?.contentType || null,
previewImageSize: previewImage instanceof File ? BigInt(previewImage.size) : null,
uploadedBy: userId,
},
select: {
@ -161,19 +229,28 @@ export async function POST(request: Request) {
title: true,
knowledgeType: true,
brand: true,
productCategoryName: true,
fileName: true,
mimeType: true,
fileSize: true,
previewImageName: true,
previewImagePath: true,
previewImageMimeType: true,
previewImageSize: true,
createdAt: true,
uploader: { select: { realName: true, username: true } },
},
})
storedPath = null
storedPreviewPath = null
return NextResponse.json({ success: true, data: serializeKnowledge(created) }, { status: 201, headers: PRIVATE_HEADERS })
} catch (error) {
if (temporaryPath) {
await unlink(temporaryPath).catch(() => undefined)
}
if (temporaryPreviewPath) {
await unlink(temporaryPreviewPath).catch(() => undefined)
}
if (storedPath) {
try {
const absolute = path.join(getUploadRootDir(), storedPath.replace(/^uploads\//, ''))
@ -182,6 +259,14 @@ export async function POST(request: Request) {
// 数据库写入失败时尽力清理孤立文件,不能覆盖原始错误。
}
}
if (storedPreviewPath) {
try {
const absolute = path.join(getUploadRootDir(), storedPreviewPath.replace(/^uploads\//, ''))
await unlink(absolute)
} catch {
// Keep the original error if cleanup of an orphan preview fails.
}
}
const response = accessErrorResponse(error)
if (response) return response
const boundedFailure = getBoundedMultipartFailure(error)

View File

@ -7,7 +7,16 @@ jest.mock('next-auth/react', () => ({
}))
describe('BrandKnowledgeDashboard', () => {
beforeEach(() => { global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true, data: [] }) } as Response) })
beforeEach(() => {
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:test-preview') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() })
global.fetch = jest.fn().mockImplementation((input: RequestInfo | URL) => {
if (String(input) === '/api/product-categories') {
return Promise.resolve({ ok: true, json: async () => [{ id: 'category-1', name: '床类' }] } as Response)
}
return Promise.resolve({ ok: true, json: async () => ({ success: true, data: [] }) } as Response)
})
})
afterEach(() => { jest.restoreAllMocks() })
it('shows the real upload entry and empty state', async () => {
@ -33,7 +42,15 @@ describe('BrandKnowledgeDashboard', () => {
it('submits a selected knowledge file to the independent API', async () => {
const fetchMock = global.fetch as jest.Mock
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, data: [] }) } as Response).mockResolvedValueOnce({ ok: true, json: async () => ({ success: true, data: {} }) } as Response)
fetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (String(input) === '/api/product-categories') {
return Promise.resolve({ ok: true, json: async () => [{ id: 'category-1', name: '床类' }] } as Response)
}
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: async () => ({ success: true, data: {} }) } as Response)
}
return Promise.resolve({ ok: true, json: async () => ({ success: true, data: [] }) } as Response)
})
render(<BrandKnowledgeDashboard />)
const uploadButton = screen.getByRole('button', { name: '上传知识' })
@ -43,12 +60,23 @@ describe('BrandKnowledgeDashboard', () => {
expect(screen.getByRole('button', { name: '提交知识' })).toBeDisabled()
fireEvent.change(screen.getByLabelText('知识标题'), { target: { value: '品牌手册' } })
await waitFor(() => expect(screen.getByRole('option', { name: '床类' })).toBeInTheDocument())
fireEvent.change(screen.getByLabelText(/品类/), { target: { value: '床类' } })
const knowledgeFile = new File(['品牌资料'], '品牌手册.pdf', { type: 'application/pdf' })
const previewFile = new File(['fake image'], '预览图.png', { type: 'image/png' })
fireEvent.change(screen.getByLabelText('选择知识文件'), { target: { files: [knowledgeFile] } })
fireEvent.change(screen.getByLabelText('选择预览图片'), { target: { files: [previewFile] } })
expect(screen.getByText('品牌手册.pdf')).toBeInTheDocument()
expect(screen.getByText('预览图.png')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '提交知识' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '提交知识' }))
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('/api/department-dashboard/brand-knowledge', expect.objectContaining({ method: 'POST', body: expect.any(FormData) })))
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith('/api/department-dashboard/brand-knowledge', expect.objectContaining({ method: 'POST', body: expect.any(FormData) })))
const postCall = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST')
expect(postCall).toBeDefined()
const submittedFormData = postCall?.[1]?.body as FormData
expect(submittedFormData.get('productCategoryName')).toBe('床类')
expect(submittedFormData.get('file')).toBe(knowledgeFile)
expect(submittedFormData.get('previewImage')).toBe(previewFile)
fireEvent.click(screen.getByRole('button', { name: '关闭上传知识窗口' }))
expect(screen.queryByRole('dialog', { name: '上传知识' })).not.toBeInTheDocument()

View File

@ -1,12 +1,13 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Image from 'next/image'
import { BookOpen, ChevronDown, Download, FileText, Search, Upload } from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import BrandKnowledgeUploadDialog from './BrandKnowledgeUploadDialog'
const KNOWLEDGE_TYPES = ['全部', '品牌规范', '产品知识', '内容规范', '流程文档']
type BrandKnowledgeItem = { id: string; title: string; knowledgeType: string; brand: string; fileName: string; mimeType: string; fileSize: number; createdAt: string; uploadedBy: string; downloadUrl: string }
type BrandKnowledgeItem = { id: string; title: string; knowledgeType: string; brand: string; productCategoryName: string; fileName: string; mimeType: string; fileSize: number; createdAt: string; uploadedBy: string; downloadUrl: string; previewUrl: string | null; previewImageName: string | null }
function formatFileSize(size: number) { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`; if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`; return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB` }
function formatDate(value: string) { const date = new Date(value); return Number.isNaN(date.getTime()) ? value : new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).format(date) }
@ -28,7 +29,7 @@ export default function BrandKnowledgeDashboard() {
}, [])
useEffect(() => { void loadKnowledge() }, [loadKnowledge])
const visibleItems = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase(); return items.filter((item) => (selectedType === '全部' || item.knowledgeType === selectedType) && (!keyword || [item.title, item.fileName, item.brand, item.uploadedBy].some((value) => value.toLowerCase().includes(keyword)))) }, [items, searchKeyword, selectedType])
const visibleItems = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase(); return items.filter((item) => (selectedType === '全部' || item.knowledgeType === selectedType) && (!keyword || [item.title, item.fileName, item.brand, item.productCategoryName, item.uploadedBy].some((value) => value.toLowerCase().includes(keyword)))) }, [items, searchKeyword, selectedType])
const emptyMessage = useMemo(() => {
if (loadError) return loadError
@ -121,7 +122,7 @@ export default function BrandKnowledgeDashboard() {
</button>
</div>
{isLoading ? <div className="border-t border-[#e2e8f0] py-24 text-center text-sm text-[#66758b]" role="status">正在读取知识内容…</div> : visibleItems.length > 0 ? <ul className="mt-4 grid gap-3 md:grid-cols-2" aria-label="品牌知识内容列表">{visibleItems.map((item) => <li key={item.id} className="rounded-xl border border-[#e2e8f0] bg-white p-5 shadow-[0_6px_18px_rgba(16,24,40,0.04)]"><div className="flex items-start justify-between gap-4"><div className="flex min-w-0 gap-3"><span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#eef6ff] text-[#247fd3]"><FileText className="h-5 w-5" aria-hidden="true" /></span><div className="min-w-0"><h3 className="truncate font-bold text-[#101828]" title={item.title}>{item.title}</h3><p className="mt-1 truncate text-sm text-[#66758b]" title={item.fileName}>{item.fileName}</p></div></div><a href={item.downloadUrl} className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-[#247fd3] hover:bg-[#eef6ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" aria-label={`下载${item.title}`} title="下载"><Download className="h-4 w-4" aria-hidden="true" /></a></div><div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-[#66758b]"><span className="rounded-full bg-[#f0fbf6] px-2.5 py-1 font-semibold text-[#087052]">{item.knowledgeType}</span>{item.brand && item.brand !== '未关联品牌' && <span className="rounded-full bg-[#f5f7fa] px-2.5 py-1">{item.brand}</span>}<span>{formatFileSize(item.fileSize)}</span><span>上传人:{item.uploadedBy}</span><span>{formatDate(item.createdAt)}</span></div></li>)}</ul> : <div className="border-t border-[#e2e8f0] py-24 text-center">
{isLoading ? <div className="border-t border-[#e2e8f0] py-24 text-center text-sm text-[#66758b]" role="status">正在读取知识内容…</div> : visibleItems.length > 0 ? <ul className="mt-4 grid gap-3 md:grid-cols-2" aria-label="品牌知识内容列表">{visibleItems.map((item) => <li key={item.id} className="rounded-xl border border-[#e2e8f0] bg-white p-5 shadow-[0_6px_18px_rgba(16,24,40,0.04)]"><div className="flex items-start justify-between gap-4"><div className="flex min-w-0 gap-3"><div className="relative h-16 w-20 shrink-0 overflow-hidden rounded-lg border border-[#e2e8f0] bg-[#f8fafc]">{item.previewUrl ? <Image src={item.previewUrl} alt={`预览图:${item.title}`} fill unoptimized className="object-cover" /> : <span className="flex h-full w-full items-center justify-center text-[#247fd3]"><FileText className="h-5 w-5" aria-hidden="true" /></span>}</div><div className="min-w-0"><h3 className="truncate font-bold text-[#101828]" title={item.title}>{item.title}</h3><p className="mt-1 truncate text-sm text-[#66758b]" title={item.fileName}>{item.fileName}</p></div></div><a href={item.downloadUrl} className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-[#247fd3] hover:bg-[#eef6ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]" aria-label={`下载${item.title}`} title="下载"><Download className="h-4 w-4" aria-hidden="true" /></a></div><div className="mt-4 flex flex-wrap items-center gap-2 text-xs text-[#66758b]"><span className="rounded-full bg-[#f0fbf6] px-2.5 py-1 font-semibold text-[#087052]">{item.knowledgeType}</span>{item.brand && item.brand !== '未关联品牌' && <span className="rounded-full bg-[#f5f7fa] px-2.5 py-1">{item.brand}</span>}{item.productCategoryName && <span className="rounded-full bg-[#fff7e8] px-2.5 py-1 text-[#9a4d08]">{item.productCategoryName}</span>}<span>{formatFileSize(item.fileSize)}</span><span>上传人:{item.uploadedBy}</span><span>{formatDate(item.createdAt)}</span></div></li>)}</ul> : <div className="border-t border-[#e2e8f0] py-24 text-center">
<BookOpen className="mx-auto h-8 w-8 text-[#aab5c3]" aria-hidden="true" />
<p className="mt-4 text-base font-semibold text-[#4f6077]">{emptyMessage}</p>
<p className="mt-2 text-sm text-[#8a8f97]">点击右上角“上传知识”,即可将文件保存到品牌知识库。</p>

View File

@ -1,10 +1,21 @@
'use client'
import Image from 'next/image'
import { useEffect, useRef, useState, type DragEvent, type RefObject } from 'react'
import { AlertCircle, FileText, FolderOpen, Upload, X } from 'lucide-react'
import { AlertCircle, FileText, FolderOpen, ImagePlus, Upload, X } from 'lucide-react'
import {
BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT,
BRAND_KNOWLEDGE_MAIN_FILE_LABEL,
BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT,
} from '@/lib/brand-knowledge-file-types'
const KNOWLEDGE_TYPE_OPTIONS = ['品牌规范', '产品知识', '内容规范', '流程文档']
const BRAND_OPTIONS = ['未关联品牌', '亿家达', '卓禾', '欧意朗', '顾家', '索菲亚', '朴美']
const MAX_MAIN_FILE_SIZE = 50 * 1024 * 1024
const MAX_PREVIEW_SIZE = 5 * 1024 * 1024
const PREVIEW_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp'])
interface ProductCategory { id: string; name: string }
interface BrandKnowledgeUploadDialogProps {
onClose: () => void
@ -18,18 +29,29 @@ function formatFileSize(fileSize: number) {
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function extensionOf(file: File) {
return file.name.split('.').pop()?.toLowerCase() || ''
}
export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, returnFocusRef }: BrandKnowledgeUploadDialogProps) {
const [knowledgeType, setKnowledgeType] = useState(KNOWLEDGE_TYPE_OPTIONS[0])
const [title, setTitle] = useState('')
const [brand, setBrand] = useState(BRAND_OPTIONS[0])
const [productCategoryName, setProductCategoryName] = useState('')
const [productCategories, setProductCategories] = useState<ProductCategory[]>([])
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [previewImage, setPreviewImage] = useState<File | null>(null)
const [previewUrl, setPreviewUrl] = useState('')
const [isDragging, setIsDragging] = useState(false)
const [isPreviewDragging, setIsPreviewDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const dialogRef = useRef<HTMLDivElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const previewInputRef = useRef<HTMLInputElement>(null)
const dragDepthRef = useRef(0)
const previewDragDepthRef = useRef(0)
useEffect(() => {
const previousOverflow = document.body.style.overflow
@ -43,29 +65,18 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
onClose()
return
}
if (event.key !== 'Tab' || !dialogRef.current) return
const focusableElements = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
),
)
const focusableElements = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'))
if (focusableElements.length === 0) return
const firstElement = focusableElements[0]
const lastElement = focusableElements[focusableElements.length - 1]
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault()
lastElement.focus()
event.preventDefault(); lastElement.focus()
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault()
firstElement.focus()
event.preventDefault(); firstElement.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', handleKeyDown)
@ -73,32 +84,63 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
}
}, [onClose, returnFocusRef])
useEffect(() => {
let cancelled = false
void fetch('/api/product-categories', { cache: 'no-store' })
.then((response) => response.ok ? response.json() : [])
.then((payload) => {
if (!cancelled && Array.isArray(payload)) setProductCategories(payload.filter((item): item is ProductCategory => typeof item?.id === 'string' && typeof item?.name === 'string'))
})
.catch(() => undefined)
return () => { cancelled = true }
}, [])
useEffect(() => () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
}, [previewUrl])
const setFile = (file: File | undefined) => {
if (file) setSelectedFile(file)
}
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current += 1
setIsDragging(true)
}
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current -= 1
if (dragDepthRef.current <= 0) {
dragDepthRef.current = 0
setIsDragging(false)
if (!file) return
if (!BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT.split(',').includes(`.${extensionOf(file)}`)) {
setErrorMessage(`主文件格式不支持,请上传${BRAND_KNOWLEDGE_MAIN_FILE_LABEL}`)
return
}
if (file.size <= 0 || file.size > MAX_MAIN_FILE_SIZE) {
setErrorMessage('主文件需为 1B 至 50MB 之间的有效文件')
return
}
setErrorMessage('')
setSelectedFile(file)
}
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault()
dragDepthRef.current = 0
setIsDragging(false)
setFile(event.dataTransfer.files[0])
const setPreview = (file: File | undefined) => {
if (!file) return
const extension = extensionOf(file)
if (!PREVIEW_EXTENSIONS.has(extension) || (file.type && !['image/jpeg', 'image/png', 'image/webp'].includes(file.type))) {
setErrorMessage('预览图仅支持 JPG、PNG 或 WebP 图片')
return
}
if (file.size <= 0 || file.size > MAX_PREVIEW_SIZE) {
setErrorMessage('预览图需为 1B 至 5MB 之间的有效图片')
return
}
if (previewUrl) URL.revokeObjectURL(previewUrl)
setPreviewImage(file)
setPreviewUrl(URL.createObjectURL(file))
setErrorMessage('')
}
const removePreview = () => {
if (previewUrl) URL.revokeObjectURL(previewUrl)
setPreviewImage(null)
setPreviewUrl('')
}
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragDepthRef.current += 1; setIsDragging(true) }
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); dragDepthRef.current -= 1; if (dragDepthRef.current <= 0) { dragDepthRef.current = 0; setIsDragging(false) } }
const handlePreviewDragEnter = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); previewDragDepthRef.current += 1; setIsPreviewDragging(true) }
const handlePreviewDragLeave = (event: DragEvent<HTMLDivElement>) => { event.preventDefault(); previewDragDepthRef.current -= 1; if (previewDragDepthRef.current <= 0) { previewDragDepthRef.current = 0; setIsPreviewDragging(false) } }
const handleSubmit = async () => {
if (isUploading || !selectedFile || !title.trim()) return
setIsUploading(true)
@ -108,7 +150,9 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
formData.set('title', title.trim())
formData.set('knowledgeType', knowledgeType)
formData.set('brand', brand)
formData.set('productCategoryName', productCategoryName)
formData.set('file', selectedFile)
if (previewImage) formData.set('previewImage', previewImage)
const response = await fetch('/api/department-dashboard/brand-knowledge', { method: 'POST', body: formData })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识上传失败,请稍后重试')
@ -122,168 +166,22 @@ export default function BrandKnowledgeUploadDialog({ onClose, onUploaded, return
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-[#06192d]/45 p-4 sm:p-6"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose()
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="brand-knowledge-upload-title"
aria-describedby="brand-knowledge-upload-status"
className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white shadow-[0_24px_56px_rgba(6,25,45,0.22)] sm:max-h-[calc(100dvh-3rem)]"
>
<header className="shrink-0 border-b border-[#e2e8f0] px-5 py-5 sm:px-8 sm:py-6">
<div className="flex items-start justify-between gap-5">
<div className="min-w-0">
<h2 id="brand-knowledge-upload-title" className="text-xl font-bold text-[#101828] sm:text-2xl">上传知识</h2>
<p className="mt-2 text-sm leading-6 text-[#66758b]">选择知识类型、标题和文件,提交后会保存到品牌知识库。</p>
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
aria-label="关闭上传知识窗口"
title="关闭"
className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] transition-colors hover:bg-[#f2f4f7] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</header>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#06192d]/45 p-4 sm:p-6" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
<div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="brand-knowledge-upload-title" aria-describedby="brand-knowledge-upload-status" className="flex max-h-[calc(100dvh-2rem)] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-[#e2e8f0] bg-white shadow-[0_24px_56px_rgba(6,25,45,0.22)] sm:max-h-[calc(100dvh-3rem)]">
<header className="shrink-0 border-b border-[#e2e8f0] px-5 py-5 sm:px-8 sm:py-6"><div className="flex items-start justify-between gap-5"><div className="min-w-0"><h2 id="brand-knowledge-upload-title" className="text-xl font-bold text-[#101828] sm:text-2xl">上传知识</h2><p className="mt-2 text-sm leading-6 text-[#66758b]">补充知识类型、品牌、品类和预览图,提交后独立保存到品牌知识库。</p></div><button ref={closeButtonRef} type="button" onClick={onClose} aria-label="关闭上传知识窗口" title="关闭" className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-lg text-[#66758b] transition-colors hover:bg-[#f2f4f7] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><X className="h-5 w-5" aria-hidden="true" /></button></div></header>
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-5 py-6 sm:px-8">
<div id="brand-knowledge-upload-status" role="status" aria-live="polite" className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}>
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
<div>
<p className="font-semibold">{errorMessage || '文件将独立保存为品牌知识,不会写入素材资产库'}</p>
{!errorMessage && <p className="mt-1 leading-5">支持 PDF、Office 文档、表格、Markdown 和文本文件,单个文件不超过 50MB。</p>}
</div>
</div>
<div id="brand-knowledge-upload-status" role="status" aria-live="polite" className={`flex gap-3 rounded-lg px-4 py-3 text-sm ${errorMessage ? 'border border-[#f4c7c3] bg-[#fff2f0] text-[#b42318]' : 'border border-[#b8e5d5] bg-[#f0fbf6] text-[#087052]'}`}><AlertCircle className="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" /><div><p className="font-semibold">{errorMessage || '文件和预览图将独立保存,不会写入素材资产库'}</p>{!errorMessage && <p className="mt-1 leading-5">主文件支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL},单个主文件不超过 50MB;预览图支持 JPG、PNG、WebP,不超过 5MB。</p>}</div></div>
<div className="grid gap-5 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span>
<select
value={knowledgeType}
onChange={(event) => setKnowledgeType(event.currentTarget.value)}
className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"
>
{KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}
</select>
</label>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><select value={knowledgeType} onChange={(event) => setKnowledgeType(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={brand} onChange={(event) => setBrand(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20">{BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</select></label></div>
<div className="grid gap-5 sm:grid-cols-2"><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类 <span className="font-normal text-[#66758b]">(可选)</span></span><select value={productCategoryName} onChange={(event) => setProductCategoryName(event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"><option value="">不选择品类</option>{productCategories.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</select><span className="mt-2 block text-xs text-[#8a99ad]">品类列表与素材资产库保持一致</span></label><label className="block"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input type="text" value={title} onChange={(event) => setTitle(event.currentTarget.value)} placeholder="例如:索菲亚新品场景图文规范" className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors placeholder:text-[#8a99ad] focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20" /></label></div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌 <span className="font-normal text-[#66758b]">(可选)</span></span>
<select
value={brand}
onChange={(event) => setBrand(event.currentTarget.value)}
className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"
>
{BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}
</select>
</label>
</div>
<section aria-labelledby="brand-knowledge-preview-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-preview-label" className="text-sm font-semibold text-[#101828]">预览图片 <span className="font-normal text-[#66758b]">(可选)</span></h3>{previewImage && <button type="button" onClick={removePreview} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除图片</button>}</div><div onDragEnter={handlePreviewDragEnter} onDragOver={(event) => event.preventDefault()} onDragLeave={handlePreviewDragLeave} onDrop={(event) => { event.preventDefault(); previewDragDepthRef.current = 0; setIsPreviewDragging(false); setPreview(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-4 transition-colors ${isPreviewDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}><input ref={previewInputRef} type="file" aria-label="选择预览图片" accept={BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT} onChange={(event) => setPreview(event.currentTarget.files?.[0])} className="sr-only" />{previewImage && previewUrl ? <div className="flex items-center gap-4"><div className="relative h-28 w-40 shrink-0 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white"><Image src={previewUrl} alt={`预览图:${previewImage.name}`} fill unoptimized className="object-cover" /></div><div className="min-w-0"><p className="break-all text-sm font-semibold text-[#101828]">{previewImage.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(previewImage.size)} · 第一张图作为知识卡片预览</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-3 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换图片</button></div></div> : <div className="flex flex-col items-center py-4 text-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><ImagePlus className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放预览图片到这里,或选择图片</p><p className="mt-1 text-sm text-[#66758b]">支持 JPG、PNG、WebP,单张不超过 5MB</p><button type="button" onClick={() => previewInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择图片</button></div>}</div></section>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span>
<input
type="text"
value={title}
onChange={(event) => setTitle(event.currentTarget.value)}
placeholder="例如:索菲亚新品场景图文规范"
className="h-11 w-full rounded-lg border border-[#e2e8f0] bg-white px-3 text-sm text-[#101828] outline-none transition-colors placeholder:text-[#8a99ad] focus:border-[#1677ff] focus-visible:ring-2 focus-visible:ring-[#1677ff]/20"
/>
</label>
<section aria-labelledby="brand-knowledge-file-label">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 id="brand-knowledge-file-label" className="text-sm font-semibold text-[#101828]">知识文件</h3>
{selectedFile && (
<button
type="button"
onClick={() => setSelectedFile(null)}
className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2"
>
移除文件
</button>
)}
</div>
<div
onDragEnter={handleDragEnter}
onDragOver={(event) => event.preventDefault()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`rounded-xl border border-dashed p-6 text-center transition-colors sm:p-8 ${isDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}
>
<input
ref={fileInputRef}
id="brand-knowledge-file"
aria-label="选择知识文件"
type="file"
accept=".pdf,.doc,.docx,.ppt,.pptx,.xls,.xlsx,.csv,.md,.txt"
onChange={(event) => setFile(event.currentTarget.files?.[0])}
className="sr-only"
/>
{selectedFile ? (
<div className="flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#087052] shadow-sm">
<FileText className="h-5 w-5" aria-hidden="true" />
</span>
<p className="mt-3 max-w-full break-all text-sm font-semibold text-[#101828]">{selectedFile.name}</p>
<p className="mt-1 text-sm text-[#66758b]">{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<FolderOpen className="h-4 w-4" aria-hidden="true" />
更换文件
</button>
</div>
) : (
<div className="flex flex-col items-center">
<span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm">
<Upload className="h-5 w-5" aria-hidden="true" />
</span>
<p className="mt-3 text-sm font-semibold text-[#101828]">拖放文件到这里,或选择文件</p>
<p className="mt-1 text-sm text-[#66758b]">支持 PDF、Office 文档、表格、Markdown 和文本文件</p>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<FolderOpen className="h-4 w-4" aria-hidden="true" />
选择文件
</button>
</div>
)}
</div>
</section>
<section aria-labelledby="brand-knowledge-file-label"><div className="mb-2 flex items-center justify-between gap-3"><h3 id="brand-knowledge-file-label" className="text-sm font-semibold text-[#101828]">知识文件</h3>{selectedFile && <button type="button" onClick={() => setSelectedFile(null)} className="text-sm font-semibold text-[#b42318] hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">移除文件</button>}</div><div onDragEnter={handleDragEnter} onDragOver={(event) => event.preventDefault()} onDragLeave={handleDragLeave} onDrop={(event) => { event.preventDefault(); dragDepthRef.current = 0; setIsDragging(false); setFile(event.dataTransfer.files[0]) }} className={`rounded-xl border border-dashed p-6 text-center transition-colors sm:p-8 ${isDragging ? 'border-[#1677ff] bg-[#e6f4ff]' : 'border-[#cbd5e1] bg-[#f8fafc]'}`}><input ref={fileInputRef} id="brand-knowledge-file" aria-label="选择知识文件" type="file" accept={BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT} onChange={(event) => setFile(event.currentTarget.files?.[0])} className="sr-only" />{selectedFile ? <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#087052] shadow-sm"><FileText className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 max-w-full break-all text-sm font-semibold text-[#101828]">{selectedFile.name}</p><p className="mt-1 text-sm text-[#66758b]">{formatFileSize(selectedFile.size)} · 提交后保存到品牌知识库</p><button type="button" onClick={() => fileInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />更换文件</button></div> : <div className="flex flex-col items-center"><span className="flex h-10 w-10 items-center justify-center rounded-lg bg-white text-[#247fd3] shadow-sm"><Upload className="h-5 w-5" aria-hidden="true" /></span><p className="mt-3 text-sm font-semibold text-[#101828]">拖放文件到这里,或选择文件</p><p className="mt-1 max-w-xl text-sm text-[#66758b]">支持{BRAND_KNOWLEDGE_MAIN_FILE_LABEL}</p><button type="button" onClick={() => fileInputRef.current?.click()} className="mt-4 inline-flex min-h-10 items-center gap-2 rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><FolderOpen className="h-4 w-4" aria-hidden="true" />选择文件</button></div>}</div></section>
</div>
<footer className="shrink-0 flex flex-col-reverse gap-3 border-t border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-8">
<button
type="button"
onClick={onClose}
className="inline-flex min-h-11 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-5 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
取消
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={isUploading || !selectedFile || !title.trim()}
className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<Upload className="h-4 w-4" aria-hidden="true" />
{isUploading ? '上传中…' : '提交知识'}
</button>
</footer>
<footer className="shrink-0 flex flex-col-reverse gap-3 border-t border-[#e2e8f0] px-5 py-4 sm:flex-row sm:items-center sm:justify-end sm:px-8"><button type="button" onClick={onClose} className="inline-flex min-h-11 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-5 text-sm font-semibold text-[#101828] transition-colors hover:bg-[#f2f4f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">取消</button><button type="button" onClick={() => void handleSubmit()} disabled={isUploading || !selectedFile || !title.trim()} className="inline-flex min-h-11 items-center justify-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white transition-colors hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-55 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"><Upload className="h-4 w-4" aria-hidden="true" />{isUploading ? '上传中...' : '提交知识'}</button></footer>
</div>
</div>
)

View File

@ -0,0 +1,17 @@
// Brand knowledge keeps its own storage, but accepts the same source formats
// exposed by the material library upload flow.
export const BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS = [
'.pdf', '.doc', '.docx', '.ppt', '.pptx', '.xls', '.xlsx', '.csv', '.md', '.txt',
'.zip', '.rar', '.7z', '.psd', '.tif', '.tiff', '.psb',
'.mp4', '.mov', '.avi', '.mkv', '.mts', '.m4v',
'.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg',
'.jpg', '.jpeg', '.png', '.webp',
] as const
export const BRAND_KNOWLEDGE_MAIN_FILE_ACCEPT = BRAND_KNOWLEDGE_MAIN_FILE_EXTENSIONS.join(',')
export const BRAND_KNOWLEDGE_MAIN_FILE_LABEL =
'PDF、Office、表格、Markdown、文本、压缩包、PSD/TIF/PSB、音视频和图片文件'
export const BRAND_KNOWLEDGE_PREVIEW_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.webp'] as const
export const BRAND_KNOWLEDGE_PREVIEW_IMAGE_ACCEPT = BRAND_KNOWLEDGE_PREVIEW_IMAGE_EXTENSIONS.join(',')

View File

@ -0,0 +1,9 @@
ALTER TABLE "brand_knowledge"
ADD COLUMN "product_category_name" TEXT NOT NULL DEFAULT '',
ADD COLUMN "preview_image_name" TEXT,
ADD COLUMN "preview_image_path" TEXT,
ADD COLUMN "preview_image_mime_type" TEXT,
ADD COLUMN "preview_image_size" BIGINT;
CREATE INDEX "brand_knowledge_product_category_name_idx"
ON "brand_knowledge"("product_category_name");

View File

@ -169,10 +169,15 @@ model BrandKnowledge {
title String
knowledgeType String @map("knowledge_type")
brand String @default("")
productCategoryName String @default("") @map("product_category_name")
fileName String @map("file_name")
filePath String @map("file_path")
mimeType String @map("mime_type")
fileSize BigInt @map("file_size")
previewImageName String? @map("preview_image_name")
previewImagePath String? @map("preview_image_path")
previewImageMimeType String? @map("preview_image_mime_type")
previewImageSize BigInt? @map("preview_image_size")
uploadedBy String @map("uploaded_by")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -180,6 +185,7 @@ model BrandKnowledge {
@@index([knowledgeType])
@@index([brand])
@@index([productCategoryName])
@@index([createdAt])
@@index([uploadedBy])
@@map("brand_knowledge")

View File

@ -2007,7 +2007,7 @@ http {
# Keep this allowance scoped to the exact collection endpoint so list and
# upload requests work without widening unrelated dashboard routes.
location = /api/department-dashboard/brand-knowledge {
client_max_body_size 53m;
client_max_body_size 58m;
proxy_request_buffering off;
proxy_pass http://app_backend;
proxy_hide_header X-Material-Proxy-Target;

View File

@ -74,7 +74,7 @@ test('brand knowledge upload stays bounded in the route and stable proxy', () =>
assert.match(
deploySource,
/location = \/api\/department-dashboard\/brand-knowledge \{\n\s+client_max_body_size 53m;\n\s+proxy_request_buffering off;/,
/location = \/api\/department-dashboard\/brand-knowledge \{\n\s+client_max_body_size 58m;\n\s+proxy_request_buffering off;/,
)
const headerGuard = routeSource.indexOf('assertBoundedMultipartHeaders(')
@ -86,6 +86,6 @@ test('brand knowledge upload stays bounded in the route and stable proxy', () =>
assert.ok(parseBody > acquireSlot)
assert.ok(parsedFiles > parseBody)
assert.match(routeSource, /const MAX_FILE_SIZE = 50 \* 1024 \* 1024/)
assert.match(routeSource, /const MAX_REQUEST_SIZE = 52 \* 1024 \* 1024/)
assert.match(routeSource, /const MAX_REQUEST_SIZE = 58 \* 1024 \* 1024/)
assert.match(routeSource, /releaseRequestSlot\?\.\(\)/)
})