feat: align knowledge metadata with material cards

This commit is contained in:
江林铖 2026-09-14 11:57:44 +08:00
parent 61dade05ed
commit f5cb86e875
2 changed files with 62 additions and 34 deletions

View File

@ -11,6 +11,7 @@ describe('BrandKnowledgeDashboard', () => {
beforeEach(() => {
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:test-preview') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() })
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: jest.fn().mockResolvedValue(undefined) } })
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)
@ -69,13 +70,19 @@ describe('BrandKnowledgeDashboard', () => {
expect(await screen.findByRole('img', { name: '预览图:索菲亚图像影像规范' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 3, name: '索菲亚图像影像规范' })).toBeInTheDocument()
expect(screen.getByText(/637\.9 MB/)).toBeInTheDocument()
expect(screen.getByText(/上传人:无邪/)).toBeInTheDocument()
expect(screen.getByText('索菲亚图像影像规范.rar')).toBeInTheDocument()
expect(screen.getByText(/637\.90 MB/)).toBeInTheDocument()
expect(screen.getByText(/无邪/)).toBeInTheDocument()
expect(screen.getByText(/2026\.09\.12/)).toBeInTheDocument()
const knowledgeList = screen.getByRole('list', { name: '品牌知识内容列表' })
expect(within(knowledgeList).getByText('影像规范')).toBeInTheDocument()
expect(within(knowledgeList).getByText('索菲亚')).toBeInTheDocument()
expect(within(knowledgeList).getByText('衣柜')).toBeInTheDocument()
expect(screen.queryByText('索菲亚图像影像规范.rar')).not.toBeInTheDocument()
const copyIdButton = screen.getByRole('button', { name: '复制作品ID knowledge-1' })
expect(copyIdButton).toHaveTextContent('作品ID knowledge-1')
fireEvent.click(copyIdButton)
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith('knowledge-1'))
expect(screen.getByRole('status')).toHaveTextContent('作品ID已复制')
expect(screen.getByRole('link', { name: '下载索菲亚图像影像规范' })).toHaveAttribute(
'href',
'/api/department-dashboard/brand-knowledge/knowledge-1/file',

View File

@ -9,71 +9,92 @@ import { BRAND_KNOWLEDGE_TYPE_OPTIONS } from '@/lib/brand-knowledge-file-types'
const KNOWLEDGE_TYPES = ['全部', ...BRAND_KNOWLEDGE_TYPE_OPTIONS]
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) }
function formatFileSize(size: number) { if (size < 1024) return `${size} B`; if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`; if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(2)} MB`; return `${(size / (1024 * 1024 * 1024)).toFixed(2)} GB` }
function formatDate(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; const parts = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(date); const values = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${values.year}.${values.month}.${values.day}` }
function BrandKnowledgeCard({ item }: { item: BrandKnowledgeItem }) {
function BrandKnowledgeCard({ item, onNotice }: { item: BrandKnowledgeItem; onNotice: (message: string) => void }) {
const details = [
formatFileSize(item.fileSize),
item.uploadedBy ? `上传人:${item.uploadedBy}` : '',
item.uploadedBy,
formatDate(item.createdAt),
item.knowledgeType,
item.brand && item.brand !== '未关联品牌' ? item.brand : '',
item.productCategoryName,
].filter(Boolean)
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(item.id)
onNotice('作品ID已复制')
} catch {
const textarea = document.createElement('textarea')
textarea.value = item.id
textarea.style.position = 'fixed'
textarea.style.left = '-999999px'
document.body.appendChild(textarea)
textarea.select()
try {
const copied = document.execCommand('copy')
onNotice(copied ? '作品ID已复制' : '作品ID复制失败,请手动复制')
} catch {
onNotice('作品ID复制失败,请手动复制')
} finally {
document.body.removeChild(textarea)
}
}
}
return (
<li className="min-w-0">
<article>
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-[#e9edf2]">
<article className="bg-transparent">
<div className="relative h-[400px] w-full overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-[#f3f4f5]">
{item.previewUrl ? (
<Image
src={item.previewUrl}
alt={`预览图:${item.title}`}
fill
unoptimized
className="object-contain"
sizes="(max-width: 767px) 100vw, (max-width: 1279px) 50vw, 33vw"
className="object-cover"
sizes="(max-width: 639px) 100vw, (max-width: 1279px) 50vw, 33vw"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-[#66758b]">
<FileText className="h-8 w-8 text-[#247fd3]" aria-hidden="true" />
<div className="flex h-full flex-col items-center justify-center gap-3 bg-[#eceef0] px-6 text-center text-[#86898e]">
<FileText className="h-8 w-8" aria-hidden="true" />
<span className="line-clamp-2 text-sm">暂无预览图</span>
</div>
)}
</div>
<div className="pt-4">
<h3 className="line-clamp-2 text-base font-bold leading-6 text-[#101828]" title={item.title}>
<h3 className="truncate text-[16px] font-semibold tracking-[-0.03em] text-[#1a1c1d]" title={item.title}>
{item.title}
</h3>
<p className="mt-1.5 line-clamp-2 text-xs leading-5 text-[#66758b]" title={details.join(' · ')}>
<p className="mt-1.5 truncate text-[12px] leading-relaxed text-[#86898e]" title={details.join(' · ')}>
{details.map((detail, index) => (
<span key={detail}>
<span key={`${detail}-${index}`}>
{index > 0 && <span aria-hidden="true"> · </span>}
{detail}
</span>
))}
</p>
<p className="mt-1 truncate text-xs leading-5 text-[#8a8f97]" title={item.fileName}>
{item.fileName}
</p>
<button
type="button"
onClick={() => void handleCopyId()}
className="mt-1 block max-w-full truncate rounded-sm text-left text-[11px] tracking-[0.04em] text-[#86898e] transition-colors hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
title="点击复制完整作品ID"
aria-label={`复制作品ID ${item.id}`}
>
<span className="font-en tabular-nums">作品ID {item.id}</span>
</button>
<div className="mt-2 flex min-h-6 flex-wrap items-center gap-x-2 gap-y-1 text-xs text-[#66758b]">
<span className="font-semibold text-[#087052]">{item.knowledgeType}</span>
{item.brand && item.brand !== '未关联品牌' && (
<><span aria-hidden="true" className="text-[#c3cbd6]">/</span><span>{item.brand}</span></>
)}
{item.productCategoryName && (
<><span aria-hidden="true" className="text-[#c3cbd6]">/</span><span>{item.productCategoryName}</span></>
)}
</div>
<div className="mt-3 border-t border-[#e2e8f0] pt-3">
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[13px]">
<a
href={item.downloadUrl}
className="inline-flex min-h-10 items-center gap-2 rounded-sm text-sm font-semibold text-[#101828] transition-colors hover:text-[#247fd3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
className="inline-flex min-h-10 items-center gap-1.5 rounded-sm text-[#1a1c1d] transition-colors hover:text-[#5a66b8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
aria-label={`下载${item.title}`}
title="下载"
>
<Download className="h-4 w-4" aria-hidden="true" />
<Download className="h-3.5 w-3.5" strokeWidth={1.6} aria-hidden="true" />
下载
</a>
</div>
@ -101,7 +122,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.productCategoryName, 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.id, 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
@ -194,7 +215,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-5 grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 xl:grid-cols-3" aria-label="品牌知识内容列表">{visibleItems.map((item) => <BrandKnowledgeCard key={item.id} item={item} />)}</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-5 grid grid-cols-1 gap-x-6 gap-y-10 sm:grid-cols-2 xl:grid-cols-3" aria-label="品牌知识内容列表">{visibleItems.map((item) => <BrandKnowledgeCard key={item.id} item={item} onNotice={setNotice} />)}</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>