gongxiangp2/components/work-review/WorkReviewBoard.tsx

581 lines
25 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 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 (
<div className="min-h-screen bg-[#fafafa]">
<div className="h-16 border-b border-[#e7e8ea] bg-[#fafafa]" />
<main className="mx-auto max-w-[1180px] px-4 py-8 sm:px-6">
<div className="h-8 w-48 animate-pulse bg-[#eceef0]" />
<div className="mt-3 h-4 w-72 animate-pulse bg-[#f2f3f5]" />
<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 [deletionDialogProject, setDeletionDialogProject] = useState<WorkReviewProject | null>(null)
const [deletionBusy, setDeletionBusy] = useState(false)
const [deletionError, setDeletionError] = useState<string | 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 (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 <LoadingScreen />
}
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 (
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<WorkReviewHeader
name={me?.user.name || session.user?.name || '当前用户'}
avatar={me?.user.avatar || (session.user as any)?.avatar || null}
wide
secondaryAction={canResubmitFocusedProject ? {
label: '提交修改',
icon: 'add',
onClick: () => 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 })
},
}}
/>
<main className="mx-auto w-full max-w-[1600px] px-3 py-3 sm:px-5 sm:py-4">
<WorkReviewReviewerWorkspace
project={focusedProject}
onSubmitReview={(version, input) => submitReview(version.id, input)}
/>
</main>
{submissionDialog && (
<WorkReviewSubmissionDialog
key={`${submissionDialog.mode}-${submissionDialog.project?.id || 'new'}-${submissionDialog.project?.latestVersion || 0}`}
mode={submissionDialog.mode}
project={submissionDialog.project}
onClose={() => setSubmissionDialog(null)}
onSubmit={(input) => submissionDialog.mode === 'create'
? submitProject(input)
: submitVersion(submissionDialog.project!.id, input)}
/>
)}
{toast?.show && <ToastBubble message={toast.message} variant={toast.variant} topOffsetClass="top-20" zIndexClass="z-[1300]" />}
</div>
)
}
return (
<div className="min-h-screen bg-[#fafafa] text-[#1a1c1d]">
<WorkReviewHeader
name={me?.user.name || session.user?.name || '当前用户'}
avatar={me?.user.avatar || (session.user as any)?.avatar || null}
primaryAction={canSubmit ? {
label: '提交作品',
icon: 'add',
onClick: () => setSubmissionDialog({ mode: 'create' }),
} : undefined}
/>
<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}
canDelete={canDeleteProjects}
deleteBusy={deletionBusy && deletionDialogProject?.id === project.id}
onToggle={() => {
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 && (
<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
key={`${submissionDialog.mode}-${submissionDialog.project?.id || 'new'}-${submissionDialog.project?.latestVersion || 0}`}
mode={submissionDialog.mode}
project={submissionDialog.project}
onClose={() => setSubmissionDialog(null)}
onSubmit={(input) => submissionDialog.mode === 'create'
? submitProject(input)
: submitVersion(submissionDialog.project!.id, input)}
/>
)}
{deletionDialogProject && (
<WorkReviewDeleteConfirmationDialog
project={deletionDialogProject}
busy={deletionBusy}
error={deletionError}
onClose={() => {
if (deletionBusy) return
setDeletionDialogProject(null)
setDeletionError(null)
}}
onConfirm={() => void confirmDeleteProject()}
/>
)}
{toast?.show && <ToastBubble message={toast.message} variant={toast.variant} topOffsetClass="top-20" zIndexClass="z-[1300]" />}
</div>
)
}