feat: add work review revision submission flow

This commit is contained in:
江林铖 2026-08-01 17:22:00 +08:00
parent f5ea657784
commit 50ea94ce3c
8 changed files with 259 additions and 24 deletions

View File

@ -241,23 +241,29 @@ export default function WorkReviewBoard() {
const requestedProject = expandedProjectId
? data?.items.find((item) => item.id === expandedProjectId)
: null
const reviewerCandidate = requestedProject
const focusedCandidate = requestedProject
const reviewerEmployeeId = me?.employee?.id
const reviewerFocusProject = reviewerCandidate && (
reviewerCandidate.permissions.pendingReviewRoles.length > 0
const focusedProject = focusedCandidate && (
focusedCandidate.permissions.canResubmit
|| focusedCandidate.permissions.pendingReviewRoles.length > 0
|| Boolean(reviewerEmployeeId && (
reviewerCandidate.manager.id === reviewerEmployeeId
|| reviewerCandidate.finalReviewer.id === reviewerEmployeeId
focusedCandidate.manager.id === reviewerEmployeeId
|| focusedCandidate.finalReviewer.id === reviewerEmployeeId
))
) ? reviewerCandidate : null
) ? focusedCandidate : null
if (reviewerFocusProject) {
if (focusedProject) {
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}
wide
secondaryAction={focusedProject.permissions.canResubmit ? {
label: '提交修改',
icon: 'add',
onClick: () => setSubmissionDialog({ mode: 'resubmit', project: focusedProject }),
} : undefined}
primaryAction={{
label: '返回看板',
icon: 'back',
@ -275,13 +281,14 @@ export default function WorkReviewBoard() {
/>
<main className="mx-auto w-full max-w-[1600px] px-3 py-3 sm:px-5 sm:py-4">
<WorkReviewReviewerWorkspace
project={reviewerFocusProject}
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)}
@ -500,6 +507,7 @@ export default function WorkReviewBoard() {
{submissionDialog && (
<WorkReviewSubmissionDialog
key={`${submissionDialog.mode}-${submissionDialog.project?.id || 'new'}-${submissionDialog.project?.latestVersion || 0}`}
mode={submissionDialog.mode}
project={submissionDialog.project}
onClose={() => setSubmissionDialog(null)}

View File

@ -62,6 +62,7 @@ function WorkReviewDialogShell({
}) {
const [ready, setReady] = useState(false)
const titleId = useId()
const subtitleId = useId()
const dialogRef = useRef<HTMLDivElement | null>(null)
const closeRef = useRef<HTMLButtonElement | null>(null)
@ -117,6 +118,7 @@ function WorkReviewDialogShell({
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={subtitle ? subtitleId : undefined}
aria-busy={busy}
tabIndex={-1}
className={`flex max-h-[94dvh] w-full flex-col overflow-hidden rounded-t-2xl bg-white text-[#101828] shadow-2xl sm:max-h-[calc(100dvh-40px)] sm:rounded-xl ${widthClass}`}
@ -124,7 +126,7 @@ function WorkReviewDialogShell({
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-[#e4e7ec] px-4 py-4 sm:px-5">
<div className="min-w-0">
<h2 id={titleId} className="text-[18px] font-bold">{title}</h2>
{subtitle && <p className="mt-1 text-[12px] font-medium leading-5 text-[#667085]">{subtitle}</p>}
{subtitle && <p id={subtitleId} className="mt-1 text-[12px] font-medium leading-5 text-[#667085]">{subtitle}</p>}
</div>
<button
ref={closeRef}
@ -138,7 +140,7 @@ function WorkReviewDialogShell({
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">{children}</div>
<div className="shrink-0 border-t border-[#e4e7ec] bg-white px-4 py-4 sm:px-5">{footer}</div>
<div className="shrink-0 border-t border-[#e4e7ec] bg-white px-4 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-5">{footer}</div>
</div>
</div>,
document.body,
@ -170,7 +172,9 @@ export function WorkReviewSubmissionDialog({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<{ message: string; percent?: number } | null>(null)
const submitLockRef = useRef(false)
const isCreate = mode === 'create'
const nextVersion = Number(project?.latestVersion || 0) + 1
useEffect(() => {
if (!isCreate) return
@ -195,6 +199,7 @@ export function WorkReviewSubmissionDialog({
}, [isCreate, reviewerLoadKey])
const submit = async () => {
if (submitLockRef.current) return
if (isCreate && !title.trim()) {
setError('请填写作品名称')
return
@ -216,6 +221,7 @@ export function WorkReviewSubmissionDialog({
return
}
submitLockRef.current = true
setBusy(true)
setError(null)
setProgress({ message: '正在准备图片' })
@ -245,24 +251,28 @@ export function WorkReviewSubmissionDialog({
workDate,
reviewerEmployeeId,
}
: {}),
: {
expectedLatestVersion: project?.latestVersion,
}),
submissionNote: submissionNote.trim(),
processedImages,
processedReferenceImages,
})
} catch (submitError) {
setProgress(null)
setError(submitError instanceof Error ? submitError.message : '提交失败,请重试')
} finally {
submitLockRef.current = false
setBusy(false)
}
}
return (
<WorkReviewDialogShell
title={isCreate ? '提交新作品' : `提交 ${project?.title || '作品'} 的新版本`}
title={isCreate ? '提交新作品' : `提交作品修改 V${nextVersion}`}
subtitle={isCreate
? '手动选择一名评审人,并由元泽共同进行逐图评审'
: `将创建 V${Number(project?.latestVersion || 0) + 1},历史图片和评审意见会完整保留`}
: `沿用首次提交的作品名称和评审人;V${nextVersion} 提交后,历史图片与评审意见会完整保留`}
busy={busy}
onClose={onClose}
widthClass="max-w-4xl"
@ -274,7 +284,14 @@ export function WorkReviewSubmissionDialog({
<span>{progress.message}</span>
{typeof progress.percent === 'number' && <span>{progress.percent}%</span>}
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[#e8eef6]">
<div
className="h-1.5 overflow-hidden rounded-full bg-[#e8eef6]"
role="progressbar"
aria-label={progress.message}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={typeof progress.percent === 'number' ? progress.percent : undefined}
>
<div
className={`h-full rounded-full bg-[#0050cb] transition-[width] ${typeof progress.percent !== 'number' ? 'w-2/3 animate-pulse' : ''}`}
style={typeof progress.percent === 'number' ? { width: `${progress.percent}%` } : undefined}
@ -297,7 +314,7 @@ export function WorkReviewSubmissionDialog({
onClick={() => void submit()}
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white hover:bg-[#003fa3] disabled:cursor-not-allowed disabled:bg-[#9bbcec]"
>
{busy ? '正在提交...' : isCreate ? '提交并通知评审人' : '提交新版本'}
{busy ? '正在提交...' : isCreate ? '提交并通知评审人' : `提交修改 V${nextVersion}`}
</button>
</div>
</div>
@ -369,9 +386,25 @@ export function WorkReviewSubmissionDialog({
</label>
</>
) : (
<div className="rounded-lg border border-[#f3d29b] bg-[#fffaf0] px-4 py-3 text-[12px] font-medium leading-5 text-[#8a5200]">
请确认已处理当前版本中的指定评审人和元泽评审建议。提交后,两位评审人会收到新一轮通知。
</div>
<>
<dl className="grid gap-x-5 gap-y-4 rounded-lg border border-[#dfe6f0] bg-[#f8fafc] px-4 py-4 sm:grid-cols-3" aria-label={`V${nextVersion} 提交信息`}>
<div className="min-w-0">
<dt className="text-[11px] font-bold text-[#667085]">作品名称</dt>
<dd className="mt-1 break-words text-[13px] font-bold text-[#101828]">{project?.title || '-'}</dd>
</div>
<div className="min-w-0">
<dt className="text-[11px] font-bold text-[#667085]">评审人</dt>
<dd className="mt-1 break-words text-[13px] font-bold text-[#101828]">{project?.manager.name || '-'}</dd>
</div>
<div className="min-w-0">
<dt className="text-[11px] font-bold text-[#667085]">修改提交日期</dt>
<dd className="mt-1 text-[13px] font-bold tabular-nums text-[#101828]">{workDate}</dd>
</div>
</dl>
<div className="rounded-lg border border-[#f3d29b] bg-[#fffaf0] px-4 py-3 text-[12px] font-medium leading-5 text-[#8a5200]">
请确认已处理 V{project?.latestVersion || 1} 中的评审建议。提交后,原评审人与元泽会收到 V{nextVersion} 的新一轮评审通知。
</div>
</>
)}
<label className="grid gap-2">

View File

@ -6,11 +6,17 @@ import Link from 'next/link'
export function WorkReviewHeader({
name,
avatar,
secondaryAction,
primaryAction,
wide = false,
}: {
name: string
avatar?: string | null
secondaryAction?: {
label: string
onClick: () => void
icon: 'add' | 'back'
}
primaryAction?: {
label: string
onClick: () => void
@ -22,7 +28,7 @@ export function WorkReviewHeader({
return (
<header className="sticky top-0 z-30 bg-[#1f2937] text-white shadow-sm">
<div className={`mx-auto flex min-h-[64px] w-full items-center justify-between gap-4 px-4 py-3 sm:px-6 ${wide ? 'max-w-[1600px]' : 'max-w-[1280px]'}`}>
<div className={`mx-auto flex min-h-[64px] w-full flex-wrap items-center justify-between gap-x-4 gap-y-2 px-4 py-3 sm:flex-nowrap sm:px-6 ${wide ? 'max-w-[1600px]' : 'max-w-[1280px]'}`}>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/15 text-[12px] font-bold">
{avatar ? (
@ -42,12 +48,28 @@ export function WorkReviewHeader({
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className={`flex shrink-0 items-center gap-2 ${secondaryAction ? 'w-full sm:w-auto' : ''}`}>
{secondaryAction && (
<button
type="button"
onClick={secondaryAction.onClick}
className="inline-flex min-h-10 flex-1 items-center justify-center rounded bg-[#0050cb] px-3 text-[12px] font-bold text-white transition-colors hover:bg-[#003fa3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#1f2937] sm:min-h-9 sm:flex-none sm:px-4 sm:text-[13px]"
>
<span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true">
{secondaryAction.icon === 'add' ? '+' : '←'}
</span>
{secondaryAction.label}
</button>
)}
{primaryAction && (
<button
type="button"
onClick={primaryAction.onClick}
className="inline-flex min-h-9 items-center justify-center rounded bg-[#0050cb] px-3 text-[12px] font-bold text-white transition-colors hover:bg-[#003fa3] sm:px-4 sm:text-[13px]"
className={`inline-flex items-center justify-center rounded px-3 text-[12px] font-bold text-white transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#1f2937] sm:min-h-9 sm:px-4 sm:text-[13px] ${
secondaryAction
? 'min-h-10 flex-1 border border-white/25 bg-white/10 hover:bg-white/15 sm:flex-none'
: 'min-h-9 bg-[#0050cb] hover:bg-[#003fa3]'
}`}
>
<span className="mr-1 text-[17px] font-light leading-none" aria-hidden="true">
{primaryAction.icon === 'add' ? '+' : '←'}
@ -57,7 +79,7 @@ export function WorkReviewHeader({
)}
<Link
href="/"
className="inline-flex min-h-9 items-center justify-center rounded px-2 text-[12px] font-semibold text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:px-3 sm:text-[13px]"
className="inline-flex min-h-10 items-center justify-center rounded px-2 text-[12px] font-semibold text-white/80 transition-colors hover:bg-white/10 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white sm:min-h-9 sm:px-3 sm:text-[13px]"
>
返回素材库
</Link>

View File

@ -935,7 +935,13 @@ export function WorkReviewReviewerWorkspace({
</>
) : (
<div className="flex min-h-11 items-center justify-center text-center text-[12px] font-semibold text-[#667085]">
{isLatestSelected ? '你的本轮评审已提交;两位评审完成后系统会通知发起人' : '历史版本仅供查看'}
{!isLatestSelected
? '历史版本仅供查看'
: project.permissions.canResubmit
? '当前版本需要修改,请通过顶部“提交修改”上传新版本'
: project.status === 'approved'
? '作品评审已通过,本作品已完结'
: '你的本轮评审已提交;两位评审完成后系统会通知发起人'}
</div>
)}
<div className="sr-only" aria-live="polite">{liveMessage}</div>

View File

@ -0,0 +1,111 @@
/** @jest-environment jsdom */
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { WorkReviewSubmissionDialog } from '@/components/work-review/WorkReviewDialogs'
import { WorkReviewHeader } from '@/components/work-review/WorkReviewHeader'
import { uploadWorkReviewImages } from '@/lib/work-review-client'
import type { WorkReviewProject } from '@/types/work-review'
jest.mock('@/lib/work-review-client', () => ({
getWorkReviewReviewerOptions: jest.fn(),
uploadWorkReviewImages: jest.fn(),
}))
const project: WorkReviewProject = {
id: 'project-1',
title: '夏季新品主视觉',
description: '第一版说明',
workDate: '2026-07-30',
status: 'revision_required',
latestVersion: 1,
submitter: { id: 'submitter-1', name: '提交人' },
manager: { id: 'manager-1', name: '清云' },
finalReviewer: { id: 'final-1', name: '元泽' },
createdAt: '2026-07-30T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z',
versions: [],
permissions: { canResubmit: true, pendingReviewRoles: [] },
}
describe('作品修改版本入口', () => {
beforeAll(() => {
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:preview') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() })
})
test('顶部按提交修改、返回看板的顺序展示操作', () => {
render(
<WorkReviewHeader
name="提交人"
secondaryAction={{ label: '提交修改', icon: 'add', onClick: jest.fn() }}
primaryAction={{ label: '返回看板', icon: 'back', onClick: jest.fn() }}
/>,
)
expect(screen.getAllByRole('button').map((button) => button.textContent?.trim())).toEqual([
'+提交修改',
'←返回看板',
])
})
test('V2 弹窗显示首版名称、原评审人与当天修改日期,并携带基准版本提交', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn().mockResolvedValue(undefined)
jest.mocked(uploadWorkReviewImages).mockResolvedValue([{
uploadId: 'upload-1',
mimeType: 'image/webp',
width: 100,
height: 100,
kind: 'slice',
sourceName: '修改稿.png',
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
}])
render(
<WorkReviewSubmissionDialog
mode="resubmit"
project={project}
onClose={jest.fn()}
onSubmit={onSubmit}
/>,
)
expect(await screen.findByRole('heading', { name: '提交作品修改 V2' })).toBeInTheDocument()
const summary = screen.getByLabelText('V2 提交信息')
expect(within(summary).getByText('夏季新品主视觉')).toBeInTheDocument()
expect(within(summary).getByText('清云')).toBeInTheDocument()
const now = new Date()
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000)
expect(within(summary).getByText(local.toISOString().slice(0, 10))).toBeInTheDocument()
expect(screen.queryByRole('combobox')).not.toBeInTheDocument()
const file = new File(['image'], '修改稿.png', { type: 'image/png' })
fireEvent.change(screen.getByLabelText('选择作品图片'), { target: { files: [file] } })
await user.click(screen.getByRole('button', { name: '提交修改 V2' }))
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
expectedLatestVersion: 1,
submissionNote: '',
processedImages: [expect.objectContaining({ uploadId: 'upload-1' })],
processedReferenceImages: [],
}))
})
test('版本号随最新版本递增', async () => {
render(
<WorkReviewSubmissionDialog
mode="resubmit"
project={{ ...project, latestVersion: 3 }}
onClose={jest.fn()}
onSubmit={jest.fn()}
/>,
)
expect(await screen.findByRole('heading', { name: '提交作品修改 V4' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '提交修改 V4' })).toBeInTheDocument()
})
})

View File

@ -199,4 +199,39 @@ describe('WorkReviewReviewerWorkspace', () => {
expect(screen.queryByRole('button', { name: '作品图' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '参考图' })).not.toBeInTheDocument()
})
test('guides the submitter to upload a revision only when changes are required', () => {
render(
<WorkReviewReviewerWorkspace
project={{
...project,
status: 'revision_required',
versions: [{ ...project.versions[0], status: 'revision_required' }],
permissions: { canResubmit: true, pendingReviewRoles: [] },
}}
onSubmitReview={jest.fn()}
/>,
)
expect(screen.getByText('当前版本需要修改,请通过顶部“提交修改”上传新版本')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '通过' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '提交给修改人' })).not.toBeInTheDocument()
})
test('treats an approved result as the completed end state', () => {
render(
<WorkReviewReviewerWorkspace
project={{
...project,
status: 'approved',
versions: [{ ...project.versions[0], status: 'approved', completedAt: '2026-08-01T08:00:00.000Z' }],
permissions: { canResubmit: false, pendingReviewRoles: [] },
}}
onSubmitReview={jest.fn()}
/>,
)
expect(screen.getByText('作品评审已通过,本作品已完结')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '提交给修改人' })).not.toBeInTheDocument()
})
})

View File

@ -738,13 +738,22 @@ async function withOptionalStoredImages<T>(input: {
async function queueSubmittedNotifications(project: any, version: any) {
const actionUrl = getWorkReviewActionUrl(project.id)
const isInitialVersion = Number(version.version) === 1
const submittedDate = isInitialVersion
? new Date(project.workDate).toISOString().slice(0, 10)
: new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(version.submittedAt || Date.now()))
const content = [
'### ◆ 作品待评审',
'',
`**${escapeDingtalkMarkdown(project.title)}**`,
'',
`**提交人:** ${escapeDingtalkMarkdown(project.submitterName)} `,
`**作品日期:** ${new Date(project.workDate).toISOString().slice(0, 10)} `,
`**${isInitialVersion ? '作品日期' : '修改提交日期'}:** ${submittedDate} `,
`**版本:** V${version.version} `,
'',
'> 请逐张查看作品图片并填写评审修改建议。',
@ -891,12 +900,19 @@ export async function createWorkReviewProject(session: Session | null, body: any
export async function createWorkReviewVersion(session: Session | null, projectId: string, body: any) {
const ctx = await getContext(session)
ensureCanSubmit(ctx)
const expectedLatestVersion = Number(body?.expectedLatestVersion)
if (!Number.isInteger(expectedLatestVersion) || expectedLatestVersion < 1) {
throw new WorkReviewApiError('修改版本信息无效,请刷新后重试')
}
const existing = await prisma.workReviewProject.findUnique({
where: { id: projectId },
include: { versions: { orderBy: { version: 'desc' }, take: 1 } },
})
if (!existing) throw new WorkReviewApiError('作品不存在', 404)
if (existing.submitterUserId !== ctx.user.id) throw new WorkReviewApiError('只能重新提交自己的作品', 403)
if (existing.latestVersion !== expectedLatestVersion) {
throw new WorkReviewApiError('作品版本已更新,请刷新后重新提交', 409)
}
if (existing.status !== VERSION_STATUS.REVISION_REQUIRED) {
throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409)
}
@ -926,6 +942,9 @@ export async function createWorkReviewVersion(session: Session | null, projectId
if (locked[0].status !== VERSION_STATUS.REVISION_REQUIRED) {
throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409)
}
if (locked[0].latest_version !== expectedLatestVersion) {
throw new WorkReviewApiError('作品版本已更新,请刷新后重新提交', 409)
}
const version = locked[0].latest_version + 1
await tx.workReviewVersion.create({
data: {

View File

@ -149,6 +149,7 @@ export type WorkReviewSubmissionInput = {
description?: string
workDate?: string
reviewerEmployeeId?: string
expectedLatestVersion?: number
submissionNote?: string
processedImages: WorkReviewProcessedImage[]
processedReferenceImages: WorkReviewProcessedImage[]