'use client' 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, getWorkReviewBoard, submitWorkReview, WorkReviewRequestError, } from '@/lib/work-review-client' import type { WorkReviewBoardData, WorkReviewProject, WorkReviewReviewInput, WorkReviewRole, WorkReviewStatus, WorkReviewSubmissionInput, WorkReviewVersion, } from '@/types/work-review' import { WorkReviewSubmissionDialog, WorkReviewReviewDialog } from './WorkReviewDialogs' import { WorkReviewHeader } from './WorkReviewHeader' import { WorkReviewProjectCard } from './WorkReviewProjectCard' 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 [reviewDialog, setReviewDialog] = useState<{ project: WorkReviewProject version: WorkReviewVersion role: WorkReviewRole } | null>(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 () => { 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 = deepLinkProjectRef.current const nextData = await getWorkReviewBoard({ query: debouncedQuery, status: statusFilter, project: deepLinkProject, 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) setReviewDialog(null) setExpandedProjectId(project.id) showToast(input.reviewerRole === 'manager' ? '直属主管评审已提交' : '元泽评审已提交') } 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) return (
setSubmissionDialog({ mode: 'create' })} />

作品与评审记录

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

{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 }) }} onResubmit={() => setSubmissionDialog({ mode: 'resubmit', project })} onReview={(version, role) => setReviewDialog({ project, version, role })} /> ))} {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)} /> )} {reviewDialog && ( setReviewDialog(null)} onSubmit={(input) => submitReview(reviewDialog.version.id, input)} /> )} {toast?.show && }
) }