gongxiangp2/components/department-dashboard/BrandKnowledgeDashboard.tsx
2026-09-14 16:45:42 +08:00

326 lines
18 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Image from 'next/image'
import Link from 'next/link'
import { BookOpen, ChevronDown, Download, FileText, Heart, Search, Share2, Upload } from 'lucide-react'
import DepartmentShell from './DepartmentShell'
import BrandKnowledgeUploadDialog from './BrandKnowledgeUploadDialog'
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; downloadCount: number; createdAt: string; uploadedBy: string; downloadUrl: string; previewUrl: string | null; previewImageName: string | null; likeCount: number; isLiked: boolean; isFavorited: boolean }
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, onNotice, onLike, onFavorite }: {
item: BrandKnowledgeItem
onNotice: (message: string) => 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 () => {
onNotice(await copyText(item.id) ? '作品ID已复制' : '作品ID复制失败,请手动复制')
}
const handleShare = async () => {
const url = new URL(`/workspaces/assets/brand-knowledge/${encodeURIComponent(item.id)}`, window.location.origin)
onNotice(await copyText(url.toString()) ? '分享链接已复制' : '分享链接复制失败,请手动复制')
}
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 : '操作失败,请稍后重试')
} 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-[16px] font-semibold tracking-[-0.03em] 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-[12px] 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-[11px] tracking-[0.04em] 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-[13px]">
<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={() => void handleShare()}
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-[12px] tabular-nums tracking-[0.06em]">{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>
</div>
</div>
</article>
</li>
)
}
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 [notice, setNotice] = useState('')
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 : []) }
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 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">
<header className="mb-8 flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#e9f8f2] text-[#087052]">
<BookOpen className="h-5 w-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<h1 className="truncate text-2xl font-bold tracking-[-0.03em] text-[#101828] sm:text-3xl">品牌知识库</h1>
<p className="mt-1 truncate text-sm text-[#8a99ad]">品牌中心部门工作台</p>
</div>
</div>
<span className="inline-flex shrink-0 items-center gap-2 rounded-lg border border-[#b8e5d5] bg-[#f0fbf6] px-3 py-2 text-xs font-semibold text-[#087052]">
<span className="h-2 w-2 rounded-full bg-[#31d0aa]" aria-hidden="true" />
可上传
</span>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-[#66758b]" role="status" aria-live="polite">{notice}</p>
<button
ref={uploadButtonRef}
type="button"
onClick={() => { setNotice(''); setIsUploadDialogOpen(true) }}
className="inline-flex min-h-11 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>
</header>
<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 items-center justify-between gap-4 border-b border-[#dfe4ea] pb-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 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} onNotice={setNotice} 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 () => { setNotice('知识文件已上传到品牌知识库'); await loadKnowledge() }}
returnFocusRef={uploadButtonRef}
/>
)}
</DepartmentShell>
)
}