fix: add temporary single-review test flow

This commit is contained in:
江林铖 2026-08-01 18:34:51 +08:00
parent 50ea94ce3c
commit 878906c52f
10 changed files with 194 additions and 43 deletions

View File

@ -319,7 +319,7 @@ export default function WorkReviewBoard() {
<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 && (
@ -443,7 +443,7 @@ export default function WorkReviewBoard() {
{debouncedQuery || statusFilter ? '没有符合条件的作品' : '还没有作品记录'}
</h2>
<p className="mt-1.5 text-[12px] font-medium text-[#7b8494]">
{debouncedQuery || statusFilter ? '可以调整搜索词或状态筛选' : canSubmit ? '提交第一份作品,开始双评审流程' : '有作品分配给你后会显示在这里'}
{debouncedQuery || statusFilter ? '可以调整搜索词或状态筛选' : canSubmit ? '提交第一份作品,开始评审流程' : '有作品分配给你后会显示在这里'}
</p>
{(debouncedQuery || statusFilter) ? (
<button

View File

@ -402,7 +402,9 @@ export function WorkReviewSubmissionDialog({
</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} 的新一轮评审通知。
请确认已处理 V{project?.latestVersion || 1} 中的评审建议。提交后,{project?.reviewMode === 'single_reviewer_test'
? '原评审人会收到'
: '原评审人与元泽会收到'} V{nextVersion} 的新一轮评审通知。
</div>
</>
)}

View File

@ -49,10 +49,20 @@ function getVersionReview(version: WorkReviewVersion, role: WorkReviewRole) {
return version.reviews.find((review) => review.reviewerRole === role) || null
}
function getVersionAnnotations(version: WorkReviewVersion): WorkReviewDisplayAnnotation[][] {
function getRequiredReviewRoles(project: WorkReviewProject): WorkReviewRole[] {
return project.requiredReviewRoles?.length
? project.requiredReviewRoles
: ['manager', 'final_reviewer']
}
function getVersionAnnotations(
version: WorkReviewVersion,
requiredRoles: WorkReviewRole[],
): WorkReviewDisplayAnnotation[][] {
const imageCount = version.imageGroups?.length || version.imageUrls?.length || 0
const result = Array.from({ length: imageCount }, () => [] as WorkReviewDisplayAnnotation[])
for (const review of version.reviews || []) {
if (!requiredRoles.includes(review.reviewerRole)) continue
for (const imageComment of review.imageComments || []) {
if (!result[imageComment.imageIndex]) continue
result[imageComment.imageIndex].push(...(imageComment.annotations || []).map((annotation, index) => ({
@ -73,10 +83,12 @@ function ReviewProgress({
project: WorkReviewProject
version: WorkReviewVersion
}) {
const roles: Array<{ role: WorkReviewRole; label: string; name: string }> = [
const requiredRoles = getRequiredReviewRoles(project)
const allRoles: Array<{ role: WorkReviewRole; label: string; name: string }> = [
{ role: 'manager', label: '评审人', name: project.manager.name },
{ role: 'final_reviewer', label: '元泽', name: project.finalReviewer.name },
]
const roles = allRoles.filter((item) => requiredRoles.includes(item.role))
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
@ -121,9 +133,11 @@ export function WorkReviewProjectCard({
)
const cover = latestVersion?.imageGroups?.[0]?.previewUrl || latestVersion?.imageUrls?.[0] || ''
const isLatestSelected = selectedVersion?.id === latestVersion?.id
const requiredReviewRoles = getRequiredReviewRoles(project)
const singleReviewerTest = project.reviewMode === 'single_reviewer_test'
const annotationsByImageIndex = useMemo(
() => selectedVersion ? getVersionAnnotations(selectedVersion) : [],
[selectedVersion],
() => selectedVersion ? getVersionAnnotations(selectedVersion, requiredReviewRoles) : [],
[requiredReviewRoles, selectedVersion],
)
if (!latestVersion || !selectedVersion) return null
@ -270,30 +284,34 @@ export function WorkReviewProjectCard({
<div className="mt-6">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-[15px] font-bold text-[#101828]">双评审意见</h3>
<h3 className="text-[15px] font-bold text-[#101828]">{singleReviewerTest ? '评审意见' : '双评审意见'}</h3>
<ReviewProgress project={project} version={selectedVersion} />
</div>
<div className="grid gap-3 xl:grid-cols-2">
<ReviewPanel
label="指定评审人评审"
reviewerName={project.manager.name}
review={getVersionReview(selectedVersion, 'manager')}
canReview={Boolean(
isLatestSelected
&& project.permissions.pendingReviewRoles.includes('manager')
)}
onReview={onReview}
/>
<ReviewPanel
label="元泽评审"
reviewerName={project.finalReviewer.name}
review={getVersionReview(selectedVersion, 'final_reviewer')}
canReview={Boolean(
isLatestSelected
&& project.permissions.pendingReviewRoles.includes('final_reviewer')
)}
onReview={onReview}
/>
<div className={`grid gap-3 ${singleReviewerTest ? '' : 'xl:grid-cols-2'}`}>
{requiredReviewRoles.includes('manager') && (
<ReviewPanel
label="指定评审人评审"
reviewerName={project.manager.name}
review={getVersionReview(selectedVersion, 'manager')}
canReview={Boolean(
isLatestSelected
&& project.permissions.pendingReviewRoles.includes('manager')
)}
onReview={onReview}
/>
)}
{requiredReviewRoles.includes('final_reviewer') && (
<ReviewPanel
label="元泽评审"
reviewerName={project.finalReviewer.name}
review={getVersionReview(selectedVersion, 'final_reviewer')}
canReview={Boolean(
isLatestSelected
&& project.permissions.pendingReviewRoles.includes('final_reviewer')
)}
onReview={onReview}
/>
)}
</div>
</div>
</div>

View File

@ -7,6 +7,7 @@ import type {
WorkReviewImageGroup,
WorkReviewProject,
WorkReviewReviewInput,
WorkReviewRole,
WorkReviewVersion,
} from '@/types/work-review'
import {
@ -404,6 +405,13 @@ export function WorkReviewReviewerWorkspace({
if (!selectedVersion || !latestVersion) return null
const isLatestSelected = selectedVersion.id === latestVersion.id
const requiredReviewRoles: WorkReviewRole[] = project.requiredReviewRoles?.length
? project.requiredReviewRoles
: ['manager', 'final_reviewer']
const singleReviewerTest = project.reviewMode === 'single_reviewer_test'
const relevantReviews = selectedVersion.reviews.filter((review) => (
requiredReviewRoles.includes(review.reviewerRole)
))
const pendingRole = isLatestSelected ? project.permissions.pendingReviewRoles[0] : undefined
const currentDrafts = draftAnnotations[activeWorkImageIndex] || []
const selectedDraft = selectedAnnotation
@ -413,13 +421,13 @@ export function WorkReviewReviewerWorkspace({
? draftAnnotations[positionEditableAnnotation.imageIndex]?.find((item) => item.id === positionEditableAnnotation.id)
: undefined
const totalDraftCount = draftAnnotations.reduce((sum, annotations) => sum + annotations.length, 0)
const totalSubmittedAnnotationCount = selectedVersion.reviews.reduce((reviewSum, review) => (
const totalSubmittedAnnotationCount = relevantReviews.reduce((reviewSum, review) => (
reviewSum + (review.imageComments || []).reduce((imageSum, imageComment) => (
imageSum + (imageComment.annotations?.length || 0)
), 0)
), 0)
const totalAnnotationCount = totalSubmittedAnnotationCount + totalDraftCount
const submittedAnnotations: WorkReviewDisplayAnnotation[] = selectedVersion.reviews.flatMap((review) => {
const submittedAnnotations: WorkReviewDisplayAnnotation[] = relevantReviews.flatMap((review) => {
const imageComment = (review.imageComments || []).find((item) => item.imageIndex === activeWorkImageIndex)
return (imageComment?.annotations || []).map((annotation, index) => ({
annotation,
@ -627,6 +635,11 @@ export function WorkReviewReviewerWorkspace({
return (
<div className="flex min-h-0 flex-col">
{singleReviewerTest && (
<div className="mb-3 rounded border border-[#f3d29b] bg-[#fffaf0] px-3 py-2 text-[12px] font-semibold leading-5 text-[#8a5200]" role="status">
测试模式:当前作品由指定评审人单独完成评审,已跳过元泽。
</div>
)}
<nav
className="mb-3 flex min-h-12 items-center gap-2 overflow-x-auto border-b border-[#dfe5ee] bg-white px-1 pb-2"
aria-label="选择作品版本"
@ -941,7 +954,9 @@ export function WorkReviewReviewerWorkspace({
? '当前版本需要修改,请通过顶部“提交修改”上传新版本'
: project.status === 'approved'
? '作品评审已通过,本作品已完结'
: '你的本轮评审已提交;两位评审完成后系统会通知发起人'}
: singleReviewerTest
? '当前测试流程等待指定评审人完成'
: '你的本轮评审已提交;两位评审完成后系统会通知发起人'}
</div>
)}
<div className="sr-only" aria-live="polite">{liveMessage}</div>

View File

@ -100,6 +100,15 @@ describe('work review policy', () => {
])).toBe('revision_required')
})
test('completes a manager-only test round immediately after the manager review', () => {
expect(deriveCompletedWorkReviewStatus([
{ reviewerRole: 'manager', decision: 'approved' },
], ['manager'])).toBe('approved')
expect(deriveCompletedWorkReviewStatus([
{ reviewerRole: 'manager', decision: 'revision_required' },
], ['manager'])).toBe('revision_required')
})
test('normalizes image annotations without storing viewport pixels', () => {
expect(validateWorkReviewAnnotations([
{ id: 'point-1', x: 0.123456789, y: 0.75, comment: ' 调整人物位置 ' },

View File

@ -234,4 +234,38 @@ describe('WorkReviewReviewerWorkspace', () => {
expect(screen.getByText('作品评审已通过,本作品已完结')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: '提交给修改人' })).not.toBeInTheDocument()
})
test('shows the single-review test mode and hides skipped final-reviewer annotations', () => {
render(
<WorkReviewReviewerWorkspace
project={{
...project,
reviewMode: 'single_reviewer_test',
requiredReviewRoles: ['manager'],
versions: [{
...project.versions[0],
reviews: [{
id: 'skipped-review',
reviewerRole: 'final_reviewer',
reviewerName: '元泽',
decision: 'revision_required',
imageComments: [{
imageIndex: 0,
decision: 'revision_required',
comment: '不应显示的历史意见',
annotations: [{ id: 'skipped-point', x: 0.5, y: 0.5, comment: '跳过此标注' }],
}],
generalComment: null,
reviewedAt: '2026-08-01T08:00:00.000Z',
}],
}],
}}
onSubmitReview={jest.fn()}
/>,
)
expect(screen.getByRole('status')).toHaveTextContent('已跳过元泽')
expect(screen.queryByText('跳过此标注')).not.toBeInTheDocument()
expect(screen.queryByText('不应显示的历史意见')).not.toBeInTheDocument()
})
})

View File

@ -99,6 +99,9 @@ const OPTIONAL_ENV_VARS = [
'PERFORMANCE_SURVEY_DAILY_SYNC_INITIAL_DELAY_SECONDS',
'PERFORMANCE_SURVEY_DAILY_SYNC_LIMIT',
'DOWNLOAD_TOKEN_SECRET',
'WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID',
'WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID',
'WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT',
'DISABLE_CACHE',
'ADMIN_API_KEY', // 管理员API密钥(用于外部API调用验证)
]
@ -179,6 +182,28 @@ export function validateEnvFormats(): void {
}
}
const singleReviewVars = [
'WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID',
'WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID',
'WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT',
] as const
const configuredSingleReviewVars = singleReviewVars.filter((name) => Boolean(process.env[name]?.trim()))
if (configuredSingleReviewVars.length > 0 && configuredSingleReviewVars.length !== singleReviewVars.length) {
errors.push(`作品过稿单评审测试配置必须同时设置:${singleReviewVars.join(', ')}`)
}
if (configuredSingleReviewVars.length === singleReviewVars.length) {
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
if (!uuidPattern.test(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID!.trim())) {
errors.push('WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID 必须是有效 UUID')
}
if (!uuidPattern.test(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID!.trim())) {
errors.push('WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID 必须是有效 UUID')
}
if (!Number.isFinite(Date.parse(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT!.trim()))) {
errors.push('WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT 必须是有效日期时间')
}
}
if (errors.length > 0) {
const errorList = errors.map(e => ` - ${e}`).join('\n')
throw new Error(

View File

@ -165,9 +165,11 @@ export function deriveCompletedWorkReviewStatus(
reviewerRole: string
decision: WorkReviewDecision
}>,
requiredRoles: ReadonlyArray<WorkReviewReviewerRole> = ['manager', 'final_reviewer'],
): WorkReviewDecision | null {
const manager = reviews.find((review) => review.reviewerRole === 'manager')
const finalReviewer = reviews.find((review) => review.reviewerRole === 'final_reviewer')
if (!manager || !finalReviewer) return null
return deriveWorkReviewDecision([manager, finalReviewer])
const requiredReviews = requiredRoles.map((role) => (
reviews.find((review) => review.reviewerRole === role)
))
if (requiredReviews.some((review) => !review)) return null
return deriveWorkReviewDecision(requiredReviews as Array<{ decision: WorkReviewDecision }>)
}

View File

@ -135,6 +135,33 @@ function clean(value: unknown) {
return text || null
}
type WorkReviewReviewMode = 'dual' | 'single_reviewer_test'
function getSingleReviewerTestConfig() {
const projectId = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID)
const managerEmployeeId = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID)
const expiresAt = clean(process.env.WORK_REVIEW_SINGLE_REVIEW_TEST_EXPIRES_AT)
if (!projectId || !managerEmployeeId || !expiresAt) return null
const expiresAtMs = Date.parse(expiresAt)
if (!Number.isFinite(expiresAtMs) || Date.now() >= expiresAtMs) return null
return { projectId, managerEmployeeId, expiresAt }
}
function getWorkReviewReviewMode(project: { id: string; managerEmployeeId: string }): WorkReviewReviewMode {
const config = getSingleReviewerTestConfig()
return config
&& project.id === config.projectId
&& project.managerEmployeeId === config.managerEmployeeId
? 'single_reviewer_test'
: 'dual'
}
function getRequiredReviewRoles(project: { id: string; managerEmployeeId: string }): ReviewRole[] {
return getWorkReviewReviewMode(project) === 'single_reviewer_test'
? [REVIEW_ROLES.MANAGER]
: [REVIEW_ROLES.MANAGER, REVIEW_ROLES.FINAL_REVIEWER]
}
function escapeDingtalkMarkdown(value: unknown) {
return String(value ?? '')
.replace(/\\/g, '\\\\')
@ -385,12 +412,14 @@ function getPendingRoles(
version: any,
) {
const submittedRoles = new Set((version?.reviews || []).map((review: any) => review.reviewerRole))
return [REVIEW_ROLES.MANAGER, REVIEW_ROLES.FINAL_REVIEWER].filter((role) => (
return getRequiredReviewRoles(project).filter((role) => (
!submittedRoles.has(role) && canReviewRole(ctx, project, role)
))
}
function serializeProject(ctx: Awaited<ReturnType<typeof getContext>>, project: any) {
const reviewMode = getWorkReviewReviewMode(project)
const requiredReviewRoles = getRequiredReviewRoles(project)
const versions = [...(project.versions || [])]
.sort((left, right) => right.version - left.version)
.map((version) => {
@ -407,6 +436,7 @@ function serializeProject(ctx: Awaited<ReturnType<typeof getContext>>, project:
referenceImageUrls: images.referenceImageUrls,
referenceImageGroups: images.referenceImageGroups,
reviews: [...(version.reviews || [])]
.filter((review) => requiredReviewRoles.includes(review.reviewerRole))
.sort((left, right) => left.reviewerRole.localeCompare(right.reviewerRole))
.map((review) => ({
id: review.id,
@ -443,6 +473,8 @@ function serializeProject(ctx: Awaited<ReturnType<typeof getContext>>, project:
},
createdAt: project.createdAt,
updatedAt: project.updatedAt,
reviewMode,
requiredReviewRoles,
versions,
permissions: {
canResubmit: Boolean(
@ -759,7 +791,7 @@ async function queueSubmittedNotifications(project: any, version: any) {
'> 请逐张查看作品图片并填写评审修改建议。',
].join('\n')
return Promise.all([
const notifications = [
queueWorkReviewNotification({
projectId: project.id,
versionId: version.id,
@ -773,7 +805,9 @@ async function queueSubmittedNotifications(project: any, version: any) {
content,
actionUrl,
}),
queueWorkReviewNotification({
]
if (getRequiredReviewRoles(project).includes(REVIEW_ROLES.FINAL_REVIEWER)) {
notifications.push(queueWorkReviewNotification({
projectId: project.id,
versionId: version.id,
recipientEmployeeId: project.finalReviewerEmployeeId,
@ -785,12 +819,14 @@ async function queueSubmittedNotifications(project: any, version: any) {
title: '作品过稿看板',
content,
actionUrl,
}),
])
}))
}
return Promise.all(notifications)
}
async function queueCompletedNotification(project: any, version: any) {
const approved = version.status === VERSION_STATUS.APPROVED
const singleReviewerTest = getWorkReviewReviewMode(project) === 'single_reviewer_test'
return queueWorkReviewNotification({
projectId: project.id,
versionId: version.id,
@ -807,7 +843,9 @@ async function queueCompletedNotification(project: any, version: any) {
`**${escapeDingtalkMarkdown(project.title)}**`,
'',
`**版本:** V${version.version} `,
`**评审结果:** ${approved ? '指定评审人与元泽均已通过' : '请按评审建议修改后再次提交'} `,
`**评审结果:** ${approved
? (singleReviewerTest ? '指定评审人已通过' : '指定评审人与元泽均已通过')
: '请按评审建议修改后再次提交'} `,
'',
approved ? '> 本轮作品已完成过稿。' : '> 点击下方按钮查看每张图的修改建议。',
].join('\n'),
@ -1038,6 +1076,10 @@ export async function submitWorkReview(session: Session | null, versionId: strin
if (version.status !== VERSION_STATUS.PENDING_REVIEW) {
throw new WorkReviewApiError('该版本评审已经结束', 409)
}
const requiredReviewRoles = getRequiredReviewRoles(version.project)
if (!requiredReviewRoles.includes(role)) {
throw new WorkReviewApiError('当前测试流程已跳过元泽评审', 409)
}
if (!canReviewRole(ctx, version.project, role)) {
throw new WorkReviewApiError(role === REVIEW_ROLES.MANAGER ? '无权填写指定评审人区域' : '无权填写元泽评审', 403)
}
@ -1084,7 +1126,7 @@ export async function submitWorkReview(session: Session | null, versionId: strin
const finalStatus = deriveCompletedWorkReviewStatus(reviews as Array<{
reviewerRole: string
decision: 'approved' | 'revision_required'
}>)
}>, requiredReviewRoles)
if (!finalStatus) return { completed: false, status: VERSION_STATUS.PENDING_REVIEW }
const completedAt = new Date()
await tx.workReviewVersion.update({

View File

@ -2,6 +2,8 @@ export type WorkReviewStatus = 'pending_review' | 'revision_required' | 'approve
export type WorkReviewRole = 'manager' | 'final_reviewer'
export type WorkReviewReviewMode = 'dual' | 'single_reviewer_test'
export type WorkReviewDecision = 'approved' | 'revision_required'
export type WorkReviewImageAnnotation = {
@ -89,6 +91,8 @@ export type WorkReviewProject = {
finalReviewer: WorkReviewPerson
createdAt: string
updatedAt: string
reviewMode?: WorkReviewReviewMode
requiredReviewRoles?: WorkReviewRole[]
versions: WorkReviewVersion[]
permissions: {
canResubmit: boolean