533 lines
32 KiB
TypeScript
533 lines
32 KiB
TypeScript
'use client'
|
||
|
||
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-contain object-center"
|
||
sizes="(max-width: 639px) 100vw, (max-width: 1279px) 50vw, 33vw"
|
||
/>
|
||
) : (
|
||
<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('')
|
||
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) => setMetadata((value) => ({ ...value, 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) => setMetadata((value) => ({ ...value, 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) => setMetadata((value) => ({ ...value, 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) => setMetadata((value) => ({ ...value, 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 [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 || '品牌知识列表读取失败'); setItems(Array.isArray(payload.data) ? payload.data : []); 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))
|
||
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.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-labelledby="brand-knowledge-content-title">
|
||
<div className="flex flex-col items-start justify-between gap-3 border-b border-[#dfe4ea] pb-4 sm:flex-row sm:items-center sm:gap-4">
|
||
<div>
|
||
<h2 id="brand-knowledge-content-title" className="text-lg font-bold text-[#101828]">知识内容</h2>
|
||
<p className="mt-1 text-sm text-[#8a8f97]">人员上传的知识文件将按类型保存,并可在此下载</p>
|
||
</div>
|
||
<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 xl:grid-cols-3" 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>
|
||
)
|
||
}
|