581 lines
25 KiB
TypeScript
581 lines
25 KiB
TypeScript
'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>
|
||
)
|
||
}
|