'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 && }
)
}