gongxiangp2/components/department-dashboard/BrandKnowledgeDashboard.tsx

544 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import Select from '@/components/ui/Select'
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { BookOpen, ChevronDown, Download, FileText, Heart, Pencil, Search, Share2, Trash2, Upload, X } from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import BrandKnowledgeUploadDialog from './BrandKnowledgeUploadDialog'
import ToastBubble from '@/components/ToastBubble'
import { useToast } from '@/hooks/useToast'
import { BRAND_KNOWLEDGE_BRAND_OPTIONS, 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; downloadCount: number; createdAt: string; uploadedBy: string; downloadUrl: string; previewUrl: string | null; previewImageName: string | null; likeCount: number; isLiked: boolean; isFavorited: boolean }
type BrandKnowledgeMetadata = Pick<BrandKnowledgeItem, 'title' | 'knowledgeType' | 'brand' | 'productCategoryName'>
type ProductCategory = { id: string; name: string }
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}` }
async function copyText(value: string) {
try {
await navigator.clipboard.writeText(value)
return true
} catch {
const textarea = document.createElement('textarea')
textarea.value = value
textarea.style.position = 'fixed'
textarea.style.left = '-999999px'
document.body.appendChild(textarea)
textarea.select()
try {
return document.execCommand('copy')
} catch {
return false
} finally {
document.body.removeChild(textarea)
}
}
}
function BrandKnowledgeCard({ item, canManage, onNotice, onShare, onEdit, onDelete, onLike, onFavorite }: {
item: BrandKnowledgeItem
canManage: boolean
onNotice: (message: string, variant?: 'success' | 'error') => void
onShare: (item: BrandKnowledgeItem) => void
onEdit: (item: BrandKnowledgeItem) => void
onDelete: (item: BrandKnowledgeItem) => void
onLike: (id: string, active: boolean) => Promise<void>
onFavorite: (id: string, active: boolean) => Promise<void>
}) {
const [busyAction, setBusyAction] = useState<'like' | 'favorite' | null>(null)
const details = [
formatFileSize(item.fileSize),
item.uploadedBy,
formatDate(item.createdAt),
item.knowledgeType,
item.brand && item.brand !== '未关联品牌' ? item.brand : '',
item.productCategoryName,
].filter(Boolean)
const handleCopyId = async () => {
const copied = await copyText(item.id)
onNotice(copied ? '作品ID已复制' : '作品ID复制失败,请手动复制', copied ? 'success' : 'error')
}
const handleEngagement = async (action: 'like' | 'favorite') => {
if (busyAction) return
setBusyAction(action)
try {
if (action === 'like') {
await onLike(item.id, !item.isLiked)
onNotice(item.isLiked ? '已取消点赞' : '点赞成功')
} else {
await onFavorite(item.id, !item.isFavorited)
onNotice(item.isFavorited ? '已取消收藏' : '收藏成功')
}
} catch (error) {
onNotice(error instanceof Error ? error.message : '操作失败,请稍后重试', 'error')
} finally {
setBusyAction(null)
}
}
return (
<li className="min-w-0">
<article className="bg-transparent">
<Link
href={`/workspaces/assets/brand-knowledge/${encodeURIComponent(item.id)}`}
aria-label={`查看${item.title}详情`}
className="relative block h-[400px] w-full overflow-hidden rounded-[12px] border border-[#e7e8ea] bg-white transition-colors hover:border-[#9ca3af] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
{item.previewUrl ? (
<Image
src={item.previewUrl}
alt={`预览图:${item.title}`}
fill
unoptimized
className="object-cover object-center"
sizes="(max-width: 639px) 100vw, (max-width: 1023px) 50vw, 25vw"
/>
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 bg-[#eceef0] px-6 text-center text-[#596579]">
<FileText className="h-8 w-8" aria-hidden="true" />
<span className="line-clamp-2 text-sm">暂无预览图</span>
</div>
)}
</Link>
<div className="pt-4">
<h3 className="truncate text-base font-semibold text-[#1a1c1d]" title={item.title}>
<Link href={`/workspaces/assets/brand-knowledge/${encodeURIComponent(item.id)}`} className="rounded-sm hover:text-[#1677ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">
{item.title}
</Link>
</h3>
<p className="mt-1.5 truncate text-xs leading-relaxed text-[#596579]" title={details.join(' · ')}>
{details.map((detail, index) => (
<span key={`${detail}-${index}`}>
{index > 0 && <span aria-hidden="true"> · </span>}
{detail}
</span>
))}
</p>
<button
type="button"
onClick={() => void handleCopyId()}
className="mt-1 block max-w-full truncate rounded-sm text-left text-xs text-[#596579] 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-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<a
href={item.downloadUrl}
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-3.5 w-3.5" strokeWidth={1.6} aria-hidden="true" />
下载
</a>
<button
type="button"
onClick={() => onShare(item)}
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="分享"
>
<Share2 className="h-3.5 w-3.5" strokeWidth={1.6} aria-hidden="true" />
分享
</button>
<button
type="button"
onClick={() => void handleEngagement('like')}
disabled={busyAction !== null}
aria-pressed={item.isLiked}
aria-label={`${item.isLiked ? '取消点赞' : '点赞'}${item.title}`}
className={`inline-flex min-h-10 items-center gap-1.5 rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60 ${item.isLiked ? 'text-[#1a1c1d]' : 'text-[#596579] hover:text-[#1a1c1d]'}`}
title={item.isLiked ? '取消点赞' : '点赞'}
>
点赞 <span className="font-en text-xs tabular-nums">{item.likeCount}</span>
</button>
<button
type="button"
onClick={() => void handleEngagement('favorite')}
disabled={busyAction !== null}
aria-pressed={item.isFavorited}
aria-label={`${item.isFavorited ? '取消收藏' : '收藏'}${item.title}`}
className={`inline-flex min-h-10 items-center gap-1.5 rounded-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60 ${item.isFavorited ? 'text-[#1a1c1d]' : 'text-[#596579] hover:text-[#1a1c1d]'}`}
title={item.isFavorited ? '取消收藏' : '收藏'}
>
<Heart className="h-3.5 w-3.5" fill={item.isFavorited ? 'currentColor' : 'none'} strokeWidth={1.6} aria-hidden="true" />
{item.isFavorited ? '已收藏' : '收藏'}
</button>
{canManage && (
<>
<button
type="button"
onClick={() => onEdit(item)}
className="inline-flex min-h-10 items-center gap-1.5 rounded-sm text-[#596579] transition-colors hover:text-[#1a1c1d] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
aria-label={`编辑${item.title}`}
title="编辑"
>
<Pencil className="h-3.5 w-3.5" strokeWidth={1.6} aria-hidden="true" />
编辑
</button>
<button
type="button"
onClick={() => onDelete(item)}
className="inline-flex min-h-10 items-center gap-1.5 rounded-sm text-[#b42318] transition-colors hover:text-[#8f1d15] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2"
aria-label={`删除${item.title}`}
title="删除"
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.6} aria-hidden="true" />
删除
</button>
</>
)}
</div>
</div>
</article>
</li>
)
}
function ModalFrame({ titleId, children, onClose }: {
titleId: string
children: ReactNode
onClose: () => void
}) {
const panelRef = useRef<HTMLDivElement>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const onCloseRef = useRef(onClose)
useEffect(() => { onCloseRef.current = onClose }, [onClose])
useEffect(() => {
const previousOverflow = document.body.style.overflow
const returnFocusElement = document.activeElement instanceof HTMLElement ? document.activeElement : null
document.body.style.overflow = 'hidden'
closeButtonRef.current?.focus()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onCloseRef.current()
return
}
if (event.key !== 'Tab' || !panelRef.current) return
const focusable = Array.from(panelRef.current.querySelectorAll<HTMLElement>('button:not([disabled]), input:not([disabled]), select:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'))
if (focusable.length === 0) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault(); last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first.focus()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.body.style.overflow = previousOverflow
document.removeEventListener('keydown', handleKeyDown)
returnFocusElement?.focus()
}
}, [])
return (
<div className="fixed inset-0 z-[180] flex items-center justify-center bg-[#06192d]/45 p-4" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose() }}>
<div ref={panelRef} role="dialog" aria-modal="true" aria-labelledby={titleId} className="relative w-full max-w-lg rounded-xl border border-[#e2e8f0] bg-white p-6 shadow-[0_24px_56px_rgba(6,25,45,0.22)] sm:p-7">
<button ref={closeButtonRef} type="button" onClick={onClose} aria-label="关闭窗口" title="关闭" className="absolute right-4 top-4 inline-flex h-10 w-10 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>
{children}
</div>
</div>
)
}
function ShareDialog({ item, onClose, onNotice }: {
item: BrandKnowledgeItem
onClose: () => void
onNotice: (message: string, variant?: 'success' | 'error') => void
}) {
const shareUrl = new URL(`/workspaces/assets/brand-knowledge/${encodeURIComponent(item.id)}`, window.location.origin).toString()
const handleCopy = async () => {
const copied = await copyText(shareUrl)
if (copied) {
onNotice('分享链接已复制到剪贴板!')
onClose()
} else {
onNotice('复制失败,请手动复制', 'error')
}
}
return (
<ModalFrame titleId="brand-knowledge-share-title" onClose={onClose}>
<h2 id="brand-knowledge-share-title" className="pr-12 text-lg font-semibold text-[#1a1c1d]">分享链接</h2>
<p className="mt-2 text-sm text-[#66758b]">复制“{item.title}”的详情链接,发送给同事查看。</p>
<div className="mt-4 break-all rounded-lg bg-[#f2f4f7] p-3 text-sm leading-6 text-[#344054]" data-testid="brand-knowledge-share-url">{shareUrl}</div>
<div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row">
<button type="button" onClick={onClose} className="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#344054] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">关闭</button>
<button type="button" onClick={() => void handleCopy()} className="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-[#247fd3] px-4 text-sm font-semibold text-white transition-colors hover:bg-[#176fc1] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">复制链接</button>
</div>
</ModalFrame>
)
}
function EditDialog({ item, onClose, onSave }: {
item: BrandKnowledgeItem
onClose: () => void
onSave: (metadata: BrandKnowledgeMetadata) => Promise<void>
}) {
const [metadata, setMetadata] = useState<BrandKnowledgeMetadata>({
title: item.title,
knowledgeType: item.knowledgeType,
brand: item.brand,
productCategoryName: item.productCategoryName,
})
const [categories, setCategories] = useState<ProductCategory[]>([])
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const updateMetadata = <Field extends keyof BrandKnowledgeMetadata>(
field: Field,
value: BrandKnowledgeMetadata[Field],
) => setMetadata((current) => ({ ...current, [field]: value }))
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)) setCategories(payload.filter((value): value is ProductCategory => typeof value?.id === 'string' && typeof value?.name === 'string'))
})
.catch(() => undefined)
return () => { cancelled = true }
}, [])
const submit = async () => {
if (!metadata.title.trim() || isSaving) return
setIsSaving(true); setError('')
try {
await onSave({ ...metadata, title: metadata.title.trim() })
onClose()
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : '品牌知识编辑失败,请稍后重试')
} finally {
setIsSaving(false)
}
}
const categoryOptions = metadata.productCategoryName && !categories.some((category) => category.name === metadata.productCategoryName)
? [{ id: 'current', name: metadata.productCategoryName }, ...categories]
: categories
return (
<ModalFrame titleId="brand-knowledge-edit-title" onClose={onClose}>
<h2 id="brand-knowledge-edit-title" className="pr-12 text-lg font-semibold text-[#1a1c1d]">编辑品牌知识</h2>
<p className="mt-2 text-sm text-[#66758b]">仅更新展示信息,原知识文件和预览图保持不变。</p>
{error && <p className="mt-4 rounded-lg border border-[#f4c7c3] bg-[#fff2f0] px-4 py-3 text-sm text-[#b42318]" role="alert">{error}</p>}
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2"><span className="mb-2 block text-sm font-semibold text-[#101828]">知识标题</span><input autoFocus value={metadata.title} onChange={(event) => updateMetadata('title', event.currentTarget.value)} maxLength={200} className="h-11 w-full rounded-lg border border-[#d0d5dd] px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20" /></label>
<label><span className="mb-2 block text-sm font-semibold text-[#101828]">知识类型</span><Select value={metadata.knowledgeType} onChange={(event) => updateMetadata('knowledgeType', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_TYPE_OPTIONS.map((option) => <option key={option}>{option}</option>)}</Select></label>
<label><span className="mb-2 block text-sm font-semibold text-[#101828]">关联品牌</span><Select value={metadata.brand} onChange={(event) => updateMetadata('brand', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20">{BRAND_KNOWLEDGE_BRAND_OPTIONS.map((option) => <option key={option}>{option}</option>)}</Select></label>
<label className="sm:col-span-2"><span className="mb-2 block text-sm font-semibold text-[#101828]">品类</span><Select value={metadata.productCategoryName} onChange={(event) => updateMetadata('productCategoryName', event.currentTarget.value)} className="h-11 w-full rounded-lg border border-[#d0d5dd] bg-white px-3 text-sm outline-none focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20"><option value="">不选择品类</option>{categoryOptions.map((category) => <option key={category.id} value={category.name}>{category.name}</option>)}</Select></label>
</div>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row">
<button type="button" onClick={onClose} disabled={isSaving} className="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#344054] hover:bg-[#f8fafc] disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">取消</button>
<button type="button" onClick={() => void submit()} disabled={isSaving || !metadata.title.trim()} className="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-[#247fd3] px-4 text-sm font-semibold text-white hover:bg-[#176fc1] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">{isSaving ? '保存中…' : '保存修改'}</button>
</div>
</ModalFrame>
)
}
function DeleteDialog({ item, onClose, onConfirm }: {
item: BrandKnowledgeItem
onClose: () => void
onConfirm: () => Promise<void>
}) {
const [isDeleting, setIsDeleting] = useState(false)
const [error, setError] = useState('')
const confirm = async () => {
if (isDeleting) return
setIsDeleting(true); setError('')
try {
await onConfirm()
onClose()
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : '品牌知识删除失败,请稍后重试')
} finally {
setIsDeleting(false)
}
}
return (
<ModalFrame titleId="brand-knowledge-delete-title" onClose={onClose}>
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-[#fff2f0] text-[#b42318]"><Trash2 className="h-5 w-5" aria-hidden="true" /></div>
<h2 id="brand-knowledge-delete-title" className="mt-4 text-lg font-semibold text-[#1a1c1d]">删除品牌知识</h2>
<p className="mt-2 text-sm leading-6 text-[#66758b]">确定删除“{item.title}”吗?内容会从知识库和详情链接中移除,原文件会保留以便后续恢复。</p>
{error && <p className="mt-4 rounded-lg border border-[#f4c7c3] bg-[#fff2f0] px-4 py-3 text-sm text-[#b42318]" role="alert">{error}</p>}
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row">
<button type="button" onClick={onClose} disabled={isDeleting} className="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border border-[#cbd5e1] bg-white px-4 text-sm font-semibold text-[#344054] hover:bg-[#f8fafc] disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">取消</button>
<button type="button" onClick={() => void confirm()} disabled={isDeleting} className="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg bg-[#b42318] px-4 text-sm font-semibold text-white hover:bg-[#8f1d15] disabled:cursor-wait disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#b42318] focus-visible:ring-offset-2">{isDeleting ? '删除中…' : '确认删除'}</button>
</div>
</ModalFrame>
)
}
export default function BrandKnowledgeDashboard() {
const [selectedType, setSelectedType] = useState('全部')
const [searchKeyword, setSearchKeyword] = useState('')
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false)
const [items, setItems] = useState<BrandKnowledgeItem[]>([])
const [totalCount, setTotalCount] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState('')
const [canManage, setCanManage] = useState(false)
const [shareItem, setShareItem] = useState<BrandKnowledgeItem | null>(null)
const [editItem, setEditItem] = useState<BrandKnowledgeItem | null>(null)
const [deleteItem, setDeleteItem] = useState<BrandKnowledgeItem | null>(null)
const { toast, showToast } = useToast()
const uploadButtonRef = useRef<HTMLButtonElement>(null)
const loadKnowledge = useCallback(async () => {
setIsLoading(true); setLoadError('')
try { const response = await fetch('/api/department-dashboard/brand-knowledge', { cache: 'no-store' }); const payload = await response.json().catch(() => null); if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识列表读取失败'); const nextItems = Array.isArray(payload.data) ? payload.data : []; setItems(nextItems); setTotalCount(Number.isSafeInteger(payload.total) && payload.total >= 0 ? payload.total : nextItems.length); setCanManage(payload.permissions?.canManage === true) }
catch (error) { setLoadError(error instanceof Error ? error.message : '品牌知识列表读取失败,请稍后重试') }
finally { setIsLoading(false) }
}, [])
useEffect(() => { void loadKnowledge() }, [loadKnowledge])
useEffect(() => {
const sharedKnowledgeId = new URLSearchParams(window.location.search).get('knowledge')?.trim()
if (sharedKnowledgeId) setSearchKeyword(sharedKnowledgeId)
}, [])
const setEngagement = useCallback(async (id: string, action: 'like' | 'favorite', active: boolean) => {
const response = await fetch(`/api/department-dashboard/brand-knowledge/${encodeURIComponent(id)}/${action}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active }),
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error || '操作失败,请稍后重试')
setItems((current) => current.map((item) => item.id === id ? {
...item,
...(action === 'like' ? { isLiked: payload.liked, likeCount: payload.likeCount } : { isFavorited: payload.isFavorited }),
} : item))
}, [])
const saveMetadata = useCallback(async (id: string, metadata: BrandKnowledgeMetadata) => {
const response = await fetch(`/api/department-dashboard/brand-knowledge/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
})
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识编辑失败,请稍后重试')
setItems((current) => current.map((item) => item.id === id ? { ...item, ...payload.data } : item))
showToast('品牌知识已更新', { variant: 'success' })
}, [showToast])
const deleteKnowledge = useCallback(async (id: string) => {
const response = await fetch(`/api/department-dashboard/brand-knowledge/${encodeURIComponent(id)}`, { method: 'DELETE' })
const payload = await response.json().catch(() => null)
if (!response.ok || !payload?.success) throw new Error(payload?.error || '品牌知识删除失败,请稍后重试')
setItems((current) => current.filter((item) => item.id !== id))
setTotalCount((current) => Math.max(0, current - 1))
showToast('品牌知识已删除,原文件已保留', { variant: 'success' })
}, [showToast])
const visibleItems = useMemo(() => { const keyword = searchKeyword.trim().toLowerCase(); return items.filter((item) => (selectedType === '全部' || item.knowledgeType === selectedType) && (!keyword || [item.id, item.title, item.knowledgeType, item.fileName, item.brand, item.productCategoryName, item.uploadedBy].some((value) => value.toLowerCase().includes(keyword)))) }, [items, searchKeyword, selectedType])
const emptyMessage = useMemo(() => {
if (loadError) return loadError
if (searchKeyword.trim()) return `没有找到与“${searchKeyword.trim()}”匹配的知识内容`
if (selectedType !== '全部') return `${selectedType}暂未上传内容`
return '品牌知识库暂未上传内容'
}, [loadError, searchKeyword, selectedType])
return (
<DepartmentShell>
<main className="min-h-[calc(100dvh-72px)] bg-[#f2f4f7] text-[#101828]">
<div className="mx-auto max-w-7xl px-4 pb-16 pt-6 sm:px-6 lg:px-8">
<div className="mb-6 flex min-h-11 items-center justify-end gap-4">
<button
ref={uploadButtonRef}
type="button"
onClick={() => setIsUploadDialogOpen(true)}
className="inline-flex min-h-11 shrink-0 items-center gap-2 rounded-lg bg-[#247fd3] px-5 text-sm font-bold text-white shadow-[0_8px_22px_rgba(36,127,211,0.16)] transition-colors hover:bg-[#176fc1] 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" />
上传知识
</button>
</div>
<section aria-label="品牌知识库筛选">
<label className="relative block">
<Search className="pointer-events-none absolute left-5 top-1/2 h-5 w-5 -translate-y-1/2 text-[#6b7075]" aria-hidden="true" />
<span className="sr-only">搜索品牌知识</span>
<input
type="search"
value={searchKeyword}
onChange={(event) => setSearchKeyword(event.currentTarget.value)}
placeholder="搜索品牌知识、产品、场景或关键词"
className="h-14 w-full rounded-lg border border-[#e2e8f0] bg-white pl-14 pr-5 text-base text-[#101828] outline-none transition-shadow placeholder:text-[#8a8f97] focus:border-[#1677ff] focus:ring-2 focus:ring-[#1677ff]/20"
/>
</label>
<nav className="mt-4 flex gap-7 overflow-x-auto border-b border-[#dfe4ea]" aria-label="知识类型">
{KNOWLEDGE_TYPES.map((type) => {
const active = selectedType === type
return (
<button
key={type}
type="button"
aria-pressed={active}
onClick={() => setSelectedType(type)}
className={`relative min-h-11 shrink-0 whitespace-nowrap px-0.5 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 ${active ? 'text-[#101828]' : 'text-[#6b7075] hover:text-[#101828]'}`}
>
{type}
{active && <span className="absolute inset-x-0 -bottom-px h-0.5 bg-[#101828]" aria-hidden="true" />}
</button>
)
})}
</nav>
</section>
<section className="mt-6" aria-label="品牌知识内容">
<div className="flex flex-wrap items-baseline justify-between gap-3 border-b border-[#dfe4ea] pb-4">
<p
className="text-sm text-[#86898e]"
aria-label={isLoading ? '上传数量加载中' : `共 ${totalCount} 条`}
aria-live="polite"
>
共 <span className="font-en text-base font-medium tabular-nums text-[#1a1c1d]">{isLoading ? '…' : totalCount.toLocaleString('zh-CN')}</span> 条
</p>
<button
type="button"
disabled
className="inline-flex min-h-11 items-center gap-1.5 text-sm font-semibold text-[#8a8f97]"
title="内容接入后开放排序"
>
最近更新
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</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 lg:grid-cols-4" aria-label="品牌知识内容列表">{visibleItems.map((item) => <BrandKnowledgeCard key={item.id} item={item} canManage={canManage} onNotice={(message, variant = 'success') => showToast(message, { variant })} onShare={setShareItem} onEdit={setEditItem} onDelete={setDeleteItem} onLike={(id, active) => setEngagement(id, 'like', active)} onFavorite={(id, active) => setEngagement(id, 'favorite', active)} />)}</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>
</div>}
</section>
</div>
</main>
{isUploadDialogOpen && (
<BrandKnowledgeUploadDialog
onClose={() => setIsUploadDialogOpen(false)}
onUploaded={async () => { showToast('知识文件已上传到品牌知识库'); await loadKnowledge() }}
returnFocusRef={uploadButtonRef}
/>
)}
{shareItem && <ShareDialog item={shareItem} onClose={() => setShareItem(null)} onNotice={(message, variant = 'success') => showToast(message, { variant })} />}
{editItem && <EditDialog item={editItem} onClose={() => setEditItem(null)} onSave={(metadata) => saveMetadata(editItem.id, metadata)} />}
{deleteItem && <DeleteDialog item={deleteItem} onClose={() => setDeleteItem(null)} onConfirm={() => deleteKnowledge(deleteItem.id)} />}
{toast?.show && <div role="status" aria-live="polite"><ToastBubble message={toast.message} variant={toast.variant} /></div>}
</DepartmentShell>
)
}