326 lines
18 KiB
TypeScript
326 lines
18 KiB
TypeScript
'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>
|
||
)
|
||
}
|