'use client' import Select from '@/components/ui/Select' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRouter, useSearchParams } from 'next/navigation' import { useSession } from 'next-auth/react' import ToastBubble from '@/components/ToastBubble' import { useToast } from '@/hooks/useToast' import { createWorkReviewProject, createWorkReviewVersion, deleteWorkReviewProject, getWorkReviewBoard, submitWorkReview, WorkReviewRequestError, } from '@/lib/work-review-client' import type { WorkReviewBoardData, WorkReviewProject, WorkReviewReviewInput, WorkReviewStatus, WorkReviewSubmissionInput, } from '@/types/work-review' import { WorkReviewDeleteConfirmationDialog, WorkReviewSubmissionDialog } from './WorkReviewDialogs' import { WorkReviewHeader } from './WorkReviewHeader' import { WorkReviewProjectCard } from './WorkReviewProjectCard' import { WorkReviewReviewerWorkspace } from './WorkReviewReviewerWorkspace' import { canResubmitWorkReviewProject } from '@/lib/work-review-policy' const statusOptions: Array<{ value: WorkReviewStatus | ''; label: string }> = [ { value: '', label: '全部状态' }, { value: 'pending_review', label: '评审中' }, { value: 'revision_required', label: '待修改' }, { value: 'approved', label: '已通过' }, ] function normalizeStatus(value: string | null): WorkReviewStatus | '' { return ['pending_review', 'revision_required', 'approved'].includes(String(value || '')) ? value as WorkReviewStatus : '' } function normalizePage(value: string | null) { return Math.max(1, Number(value || 1) || 1) } function LoadingScreen() { return (
{[0, 1, 2].map((item) =>
)}
{[0, 1, 2].map((item) =>
)}
) } export default function WorkReviewBoard() { const { data: session, status: sessionStatus } = useSession() const router = useRouter() const searchParams = useSearchParams() const initialQueryRef = useRef(searchParams.get('query') || '') const initialStatusRef = useRef(normalizeStatus(searchParams.get('status'))) const initialPageRef = useRef(normalizePage(searchParams.get('page'))) const initialProjectRef = useRef(searchParams.get('project') || '') const deepLinkProjectRef = useRef(initialProjectRef.current) const requestSequenceRef = useRef(0) const abortRef = useRef(null) const [query, setQuery] = useState(initialQueryRef.current) const [debouncedQuery, setDebouncedQuery] = useState(initialQueryRef.current) const [statusFilter, setStatusFilter] = useState(initialStatusRef.current) const [page, setPage] = useState(initialPageRef.current) const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [deepLinkedProjectMissing, setDeepLinkedProjectMissing] = useState(false) const [expandedProjectId, setExpandedProjectId] = useState(initialProjectRef.current) const [submissionDialog, setSubmissionDialog] = useState<{ mode: 'create' | 'resubmit' project?: WorkReviewProject } | null>(null) const [deletionDialogProject, setDeletionDialogProject] = useState(null) const [deletionBusy, setDeletionBusy] = useState(false) const [deletionError, setDeletionError] = useState(null) const { toast, showToast } = useToast() useEffect(() => { const timer = window.setTimeout(() => { setDebouncedQuery(query.trim()) setPage(1) }, 300) return () => window.clearTimeout(timer) }, [query]) useEffect(() => { if (sessionStatus !== 'unauthenticated') return const currentPath = typeof window === 'undefined' ? '/work-review' : `${window.location.pathname}${window.location.search}` router.replace(`/login?redirect=${encodeURIComponent(currentPath)}`) }, [router, sessionStatus]) const updateUrl = useCallback((input?: { project?: string; replaceProject?: boolean }) => { const params = new URLSearchParams() if (debouncedQuery) params.set('query', debouncedQuery) if (statusFilter) params.set('status', statusFilter) if (page > 1) params.set('page', String(page)) const projectId = input?.replaceProject ? input.project || '' : expandedProjectId if (projectId) params.set('project', projectId) const value = params.toString() router.replace(`/work-review${value ? `?${value}` : ''}`, { scroll: false }) }, [debouncedQuery, expandedProjectId, page, router, statusFilter]) useEffect(() => { if (sessionStatus !== 'authenticated') return updateUrl() }, [sessionStatus, updateUrl]) const load = useCallback(async (options?: { board?: boolean }) => { if (sessionStatus !== 'authenticated') return const sequence = requestSequenceRef.current + 1 requestSequenceRef.current = sequence abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller setLoading(true) setError(null) try { const deepLinkProject = options?.board ? '' : deepLinkProjectRef.current const nextData = await getWorkReviewBoard({ query: options?.board ? '' : debouncedQuery, status: options?.board ? '' : statusFilter, project: deepLinkProject, page: options?.board ? 1 : page, pageSize: 20, }, controller.signal) if (sequence !== requestSequenceRef.current) return if (deepLinkProject) { setDeepLinkedProjectMissing(!nextData.items.some((item) => item.id === deepLinkProject)) deepLinkProjectRef.current = '' } else { setDeepLinkedProjectMissing(false) } setData(nextData) } catch (loadError) { if (controller.signal.aborted || sequence !== requestSequenceRef.current) return if (loadError instanceof WorkReviewRequestError && loadError.status === 401) { const currentPath = `${window.location.pathname}${window.location.search}` router.replace(`/login?redirect=${encodeURIComponent(currentPath)}`) return } setError(loadError instanceof Error ? loadError.message : '看板加载失败') } finally { if (sequence === requestSequenceRef.current) setLoading(false) } }, [debouncedQuery, page, router, sessionStatus, statusFilter]) useEffect(() => { void load() return () => { requestSequenceRef.current += 1 abortRef.current?.abort() } }, [load]) useEffect(() => { if (!expandedProjectId || !data?.items.some((item) => item.id === expandedProjectId)) return const frame = window.requestAnimationFrame(() => { document.getElementById(`work-review-project-${expandedProjectId}`)?.scrollIntoView({ block: 'start', behavior: 'smooth', }) }) return () => window.cancelAnimationFrame(frame) }, [data?.items, expandedProjectId]) const replaceProject = useCallback((project: WorkReviewProject) => { setData((current) => current ? { ...current, items: current.items.map((item) => item.id === project.id ? project : item), } : current) }, []) const submitProject = async (input: WorkReviewSubmissionInput) => { const project = await createWorkReviewProject(input) setStatusFilter('') setQuery('') setDebouncedQuery('') setPage(1) setData((current) => current ? { ...current, items: [project, ...current.items.filter((item) => item.id !== project.id)].slice(0, current.pagination.pageSize), pagination: { ...current.pagination, page: 1, total: current.pagination.total + 1 }, } : current) setExpandedProjectId(project.id) setSubmissionDialog(null) showToast('作品已提交,评审通知正在发送') } const submitVersion = async (projectId: string, input: WorkReviewSubmissionInput) => { const project = await createWorkReviewVersion(projectId, input) replaceProject(project) setExpandedProjectId(project.id) setSubmissionDialog(null) showToast(`V${project.latestVersion} 已提交,评审通知正在发送`) } const submitReview = async (versionId: string, input: WorkReviewReviewInput) => { const project = await submitWorkReview(versionId, input) replaceProject(project) setExpandedProjectId(project.id) if (project.status === 'approved') { showToast('评审已完成,作品通过结果已通知发起人') } else if (project.status === 'revision_required') { showToast('评审已完成,修改意见已通知发起人') } else { showToast('评审意见已提交,等待另一位评审完成') } } const confirmDeleteProject = async () => { if (!deletionDialogProject || deletionBusy) return const project = deletionDialogProject setDeletionBusy(true) setDeletionError(null) try { await deleteWorkReviewProject(project.id) const shouldGoToPreviousPage = Boolean(data && data.items.length === 1 && page > 1) setData((current) => current ? { ...current, items: current.items.filter((item) => item.id !== project.id), pagination: { ...current.pagination, total: Math.max(0, current.pagination.total - 1), totalPages: Math.max(1, Math.ceil(Math.max(0, current.pagination.total - 1) / current.pagination.pageSize)), }, } : current) if (expandedProjectId === project.id) { setExpandedProjectId('') updateUrl({ project: '', replaceProject: true }) } setDeletionDialogProject(null) showToast(`作品「${project.title}」已删除`) if (shouldGoToPreviousPage) { setPage((current) => Math.max(1, current - 1)) } else { void load() } } catch (deleteError) { setDeletionError(deleteError instanceof Error ? deleteError.message : '删除失败,请重试') } finally { setDeletionBusy(false) } } const pageStats = useMemo(() => { const items = data?.items || [] return { action: items.filter((item) => item.permissions.canResubmit || item.permissions.pendingReviewRoles.length > 0).length, pending: items.filter((item) => item.status === 'pending_review').length, revision: items.filter((item) => item.status === 'revision_required').length, approved: items.filter((item) => item.status === 'approved').length, } }, [data?.items]) if (sessionStatus === 'loading' || (sessionStatus === 'authenticated' && !data && loading)) { return } if (sessionStatus === 'unauthenticated' || !session) return null const me = data?.me const canSubmit = Boolean(me?.permissions.canSubmit) const canDeleteProjects = Boolean(me?.permissions.canDeleteProjects) const requestedProject = expandedProjectId ? data?.items.find((item) => item.id === expandedProjectId) : null // 看板接口已经按当前用户做过可见范围过滤;所有可见作品都使用同一套详情工作区。 // 具体的评审、批注和提交修改按钮仍由项目 permissions 单独控制。 const focusedProject = requestedProject const canResubmitFocusedProject = focusedProject ? canResubmitWorkReviewProject(focusedProject) : false if (focusedProject) { return (
setSubmissionDialog({ mode: 'resubmit', project: focusedProject }), } : undefined} primaryAction={{ label: '返回看板', icon: 'back', onClick: () => { deepLinkProjectRef.current = '' setExpandedProjectId('') setQuery('') setDebouncedQuery('') setStatusFilter('') setPage(1) router.replace('/work-review', { scroll: false }) void load({ board: true }) }, }} />
submitReview(version.id, input)} />
{submissionDialog && ( setSubmissionDialog(null)} onSubmit={(input) => submissionDialog.mode === 'create' ? submitProject(input) : submitVersion(submissionDialog.project!.id, input)} /> )} {toast?.show && }
) } return (
setSubmissionDialog({ mode: 'create' }), } : undefined} />

作品与评审记录

同一作品的所有提交版本与评审意见会归档在一张卡片中

{data && (
共 {data.pagination.total} 个作品
)}
{[ { label: '待我操作', value: pageStats.action, tone: 'text-[#0052d9]', filter: '' as const }, { label: '评审中', value: pageStats.pending, tone: 'text-[#0052d9]', filter: 'pending_review' as const }, { label: '待修改', value: pageStats.revision, tone: 'text-[#8a5200]', filter: 'revision_required' as const }, { label: '已通过', value: pageStats.approved, tone: 'text-[#087a3c]', filter: 'approved' as const }, ].map((item) => ( ))}
{deepLinkedProjectMissing && (
通知对应的作品不在当前结果页中。请清除筛选后搜索作品名称;如仍未找到,请联系管理员确认访问权限。
)} {error && (
看板加载失败

{error}

)} {data && !error && (
{data.items.map((project) => ( { const nextId = expandedProjectId === project.id ? '' : project.id setExpandedProjectId(nextId) updateUrl({ project: nextId, replaceProject: true }) }} onDelete={() => { setDeletionError(null) setDeletionDialogProject(project) }} onResubmit={() => setSubmissionDialog({ mode: 'resubmit', project })} onReview={() => { setExpandedProjectId(project.id) updateUrl({ project: project.id, replaceProject: true }) }} /> ))} {data.items.length === 0 && (
0

{debouncedQuery || statusFilter ? '没有符合条件的作品' : '还没有作品记录'}

{debouncedQuery || statusFilter ? '可以调整搜索词或状态筛选' : canSubmit ? '提交第一份作品,开始评审流程' : '有作品分配给你后会显示在这里'}

{(debouncedQuery || statusFilter) ? ( ) : canSubmit ? ( ) : null}
)}
)} {data && data.pagination.totalPages > 1 && ( )}
{submissionDialog && ( setSubmissionDialog(null)} onSubmit={(input) => submissionDialog.mode === 'create' ? submitProject(input) : submitVersion(submissionDialog.project!.id, input)} /> )} {deletionDialogProject && ( { if (deletionBusy) return setDeletionDialogProject(null) setDeletionError(null) }} onConfirm={() => void confirmDeleteProject()} /> )} {toast?.show && }
) }