gongxiangp2/components/work-review/WorkReviewBoard.tsx

462 lines
20 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 { 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 (
<div className="min-h-screen bg-[#f6f8fb]">
<div className="h-16 bg-[#1f2937]" />
<main className="mx-auto max-w-[1180px] px-4 py-8 sm:px-6">
<div className="h-8 w-48 animate-pulse rounded bg-[#e6eaf0]" />
<div className="mt-3 h-4 w-72 animate-pulse rounded bg-[#edf0f4]" />
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{[0, 1, 2].map((item) => <div key={item} className="h-20 animate-pulse rounded-xl bg-white" />)}
</div>
<div className="mt-6 space-y-3">
{[0, 1, 2].map((item) => <div key={item} className="h-32 animate-pulse rounded-xl bg-white" />)}
</div>
</main>
</div>
)
}
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<AbortController | null>(null)
const [query, setQuery] = useState(initialQueryRef.current)
const [debouncedQuery, setDebouncedQuery] = useState(initialQueryRef.current)
const [statusFilter, setStatusFilter] = useState<WorkReviewStatus | ''>(initialStatusRef.current)
const [page, setPage] = useState(initialPageRef.current)
const [data, setData] = useState<WorkReviewBoardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 <LoadingScreen />
}
if (sessionStatus === 'unauthenticated' || !session) return null
const me = data?.me
const canSubmit = Boolean(me?.permissions.canSubmit)
return (
<div className="min-h-screen bg-[#f6f8fb] text-[#101828]">
<WorkReviewHeader
name={me?.user.name || session.user?.name || '当前用户'}
avatar={me?.user.avatar || (session.user as any)?.avatar || null}
canCreate={canSubmit}
onCreate={() => setSubmissionDialog({ mode: 'create' })}
/>
<main className="mx-auto w-full max-w-[1180px] px-4 py-7 pb-16 sm:px-6 sm:py-9">
<section className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-[24px] font-bold tracking-tight text-[#101828]">作品与评审记录</h1>
<p className="mt-1.5 text-[13px] font-medium text-[#667085]">
同一作品的所有提交版本与双评审意见会归档在一张卡片中
</p>
</div>
{data && (
<div className="text-[12px] font-semibold text-[#7b8494]">
共 {data.pagination.total} 个作品
</div>
)}
</section>
<section className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label="本页状态概览">
{[
{ 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) => (
<button
key={item.label}
type="button"
onClick={() => {
setStatusFilter(item.filter)
setPage(1)
setExpandedProjectId('')
}}
className={`rounded-xl border bg-white px-4 py-3 text-left transition-colors ${
statusFilter === item.filter && item.label !== '待我操作'
? 'border-[#8ab9ff] ring-1 ring-[#cfe0ff]'
: 'border-[#e1e7ef] hover:border-[#b8cae2]'
}`}
>
<div className={`text-[22px] font-bold leading-none ${item.tone}`}>{item.value}</div>
<div className="mt-1.5 text-[11px] font-semibold text-[#667085]">{item.label} <span className="font-normal text-[#98a2b3]">· 本页</span></div>
</button>
))}
</section>
<section className="mt-5 rounded-xl border border-[#e1e7ef] bg-white p-3 sm:p-4" aria-label="作品筛选">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_180px_auto]">
<label className="relative">
<span className="sr-only">搜索作品</span>
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#98a2b3]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索作品名称或提交人"
className="h-11 w-full rounded border border-[#d7dde7] bg-white pl-10 pr-3 text-[13px] outline-none placeholder:text-[#98a2b3] focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff]"
/>
</label>
<label>
<span className="sr-only">状态筛选</span>
<select
value={statusFilter}
onChange={(event) => {
setStatusFilter(normalizeStatus(event.target.value))
setPage(1)
setExpandedProjectId('')
}}
className="h-11 w-full rounded border border-[#d7dde7] bg-white px-3 text-[13px] font-semibold text-[#344054] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff]"
>
{statusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="h-11 rounded border border-[#d7dde7] bg-white px-4 text-[12px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:opacity-50"
>
{loading ? '刷新中...' : '刷新'}
</button>
</div>
</section>
{deepLinkedProjectMissing && (
<div className="mt-4 rounded-lg border border-[#f3d29b] bg-[#fffaf0] px-4 py-3 text-[12px] font-semibold leading-5 text-[#8a5200]">
通知对应的作品不在当前结果页中。请清除筛选后搜索作品名称;如仍未找到,请联系管理员确认访问权限。
</div>
)}
{error && (
<div className="mt-5 rounded-xl border border-[#f0b8b8] bg-[#fff5f5] px-4 py-4" role="alert">
<div className="text-[13px] font-bold text-[#b42318]">看板加载失败</div>
<p className="mt-1 text-[12px] font-medium leading-5 text-[#93000a]">{error}</p>
<button
type="button"
onClick={() => void load()}
className="mt-3 min-h-9 rounded bg-[#b42318] px-4 text-[12px] font-bold text-white hover:bg-[#931f16]"
>
重新加载
</button>
</div>
)}
{data && !error && (
<section className={`mt-5 space-y-3 transition-opacity ${loading ? 'opacity-55' : 'opacity-100'}`} aria-busy={loading}>
{data.items.map((project) => (
<WorkReviewProjectCard
key={project.id}
project={project}
expanded={expandedProjectId === project.id}
onToggle={() => {
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 && (
<div className="rounded-xl border border-dashed border-[#c8d1dd] bg-white px-5 py-14 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-[#eef3f8] text-[20px] font-bold text-[#7b8494]">0</div>
<h2 className="mt-4 text-[17px] font-bold text-[#101828]">
{debouncedQuery || statusFilter ? '没有符合条件的作品' : '还没有作品记录'}
</h2>
<p className="mt-1.5 text-[12px] font-medium text-[#7b8494]">
{debouncedQuery || statusFilter ? '可以调整搜索词或状态筛选' : canSubmit ? '提交第一份作品,开始双评审流程' : '有作品分配给你后会显示在这里'}
</p>
{(debouncedQuery || statusFilter) ? (
<button
type="button"
onClick={() => {
setQuery('')
setDebouncedQuery('')
setStatusFilter('')
setPage(1)
}}
className="mt-5 min-h-10 rounded border border-[#cfd6e1] px-4 text-[12px] font-bold text-[#344054] hover:bg-[#f7f8fa]"
>
清除筛选
</button>
) : canSubmit ? (
<button
type="button"
onClick={() => setSubmissionDialog({ mode: 'create' })}
className="mt-5 min-h-10 rounded bg-[#0050cb] px-4 text-[12px] font-bold text-white hover:bg-[#003fa3]"
>
提交作品
</button>
) : null}
</div>
)}
</section>
)}
{data && data.pagination.totalPages > 1 && (
<nav className="mt-6 flex items-center justify-center gap-3" aria-label="作品分页">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => {
setExpandedProjectId('')
setPage((current) => Math.max(1, current - 1))
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className="min-h-10 rounded border border-[#d7dde7] bg-white px-4 text-[12px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:cursor-not-allowed disabled:text-[#b6bdc8]"
>
上一页
</button>
<span className="min-w-[88px] text-center text-[12px] font-semibold text-[#667085]">
{data.pagination.page} / {data.pagination.totalPages}
</span>
<button
type="button"
disabled={page >= data.pagination.totalPages || loading}
onClick={() => {
setExpandedProjectId('')
setPage((current) => Math.min(data.pagination.totalPages, current + 1))
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className="min-h-10 rounded border border-[#d7dde7] bg-white px-4 text-[12px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:cursor-not-allowed disabled:text-[#b6bdc8]"
>
下一页
</button>
</nav>
)}
</main>
{submissionDialog && (
<WorkReviewSubmissionDialog
mode={submissionDialog.mode}
project={submissionDialog.project}
onClose={() => setSubmissionDialog(null)}
onSubmit={(input) => submissionDialog.mode === 'create'
? submitProject(input)
: submitVersion(submissionDialog.project!.id, input)}
/>
)}
{reviewDialog && (
<WorkReviewReviewDialog
project={reviewDialog.project}
version={reviewDialog.version}
role={reviewDialog.role}
onClose={() => setReviewDialog(null)}
onSubmit={(input) => submitReview(reviewDialog.version.id, input)}
/>
)}
{toast?.show && <ToastBubble message={toast.message} variant={toast.variant} topOffsetClass="top-20" zIndexClass="z-[1300]" />}
</div>
)
}