feat: restrict work review deletion to wuxie

This commit is contained in:
江林铖 2026-08-05 16:48:10 +08:00
parent f627344616
commit e7eb8d152e
11 changed files with 323 additions and 51 deletions

View File

@ -6,6 +6,7 @@ import {
WorkReviewApiError,
createWorkReviewProject,
createWorkReviewVersion,
deleteWorkReviewProject,
getWorkReviewBoard,
getWorkReviewReviewerOptions,
submitWorkReview,
@ -74,3 +75,16 @@ export async function POST(request: Request, context: RouteContext) {
return errorResponse(error)
}
}
export async function DELETE(_request: Request, context: RouteContext) {
try {
const session = await getServerSession(authOptions)
const path = getPath(context)
if (path[0] === 'projects' && path[1] && path.length === 2) {
return ok(await deleteWorkReviewProject(session, path[1]))
}
return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 })
} catch (error) {
return errorResponse(error)
}
}

View File

@ -8,6 +8,7 @@ import { useToast } from '@/hooks/useToast'
import {
createWorkReviewProject,
createWorkReviewVersion,
deleteWorkReviewProject,
getWorkReviewBoard,
submitWorkReview,
WorkReviewRequestError,
@ -19,7 +20,7 @@ import type {
WorkReviewStatus,
WorkReviewSubmissionInput,
} from '@/types/work-review'
import { WorkReviewSubmissionDialog } from './WorkReviewDialogs'
import { WorkReviewDeleteConfirmationDialog, WorkReviewSubmissionDialog } from './WorkReviewDialogs'
import { WorkReviewHeader } from './WorkReviewHeader'
import { WorkReviewProjectCard } from './WorkReviewProjectCard'
import { WorkReviewReviewerWorkspace } from './WorkReviewReviewerWorkspace'
@ -84,6 +85,9 @@ export default function WorkReviewBoard() {
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(() => {
@ -222,6 +226,41 @@ export default function WorkReviewBoard() {
}
}
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 {
@ -239,6 +278,7 @@ export default function WorkReviewBoard() {
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
@ -420,11 +460,17 @@ export default function WorkReviewBoard() {
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)
@ -513,6 +559,19 @@ export default function WorkReviewBoard() {
: 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>
)

View File

@ -147,6 +147,63 @@ function WorkReviewDialogShell({
)
}
export function WorkReviewDeleteConfirmationDialog({
project,
busy,
error,
onClose,
onConfirm,
}: {
project: WorkReviewProject
busy: boolean
error: string | null
onClose: () => void
onConfirm: () => void
}) {
return (
<WorkReviewDialogShell
title="删除作品"
subtitle="此操作会永久删除该作品的全部版本、评审意见和通知记录。"
busy={busy}
onClose={onClose}
widthClass="max-w-md"
footer={(
<div className="grid grid-cols-2 gap-3">
<button
type="button"
disabled={busy}
onClick={onClose}
className="min-h-11 rounded border border-[#cfd6e1] bg-white px-4 text-[13px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:cursor-not-allowed disabled:opacity-50"
>
取消
</button>
<button
type="button"
disabled={busy}
onClick={onConfirm}
className="min-h-11 rounded bg-[#b42318] px-4 text-[13px] font-bold text-white hover:bg-[#931f16] disabled:cursor-not-allowed disabled:opacity-55"
>
{busy ? '删除中…' : '确认删除'}
</button>
</div>
)}
>
<div className="rounded-lg border border-[#f0b8b8] bg-[#fff5f5] px-4 py-3">
<div className="text-[12px] font-semibold text-[#7a271a]">即将删除</div>
<div className="mt-1 break-words text-[15px] font-bold text-[#101828]">{project.title}</div>
<p className="mt-2 text-[12px] font-medium leading-5 text-[#93000a]">
删除后无法恢复,提交人如有需要只能重新发起作品评审。
</p>
</div>
{error && (
<div className="mt-3 rounded-lg border border-[#f0b8b8] bg-[#fff8f7] px-3 py-2.5 text-[12px] font-semibold leading-5 text-[#b42318]" role="alert">
{error}
</div>
)}
</WorkReviewDialogShell>
)
}
export function WorkReviewSubmissionDialog({
mode,
project,

View File

@ -109,13 +109,19 @@ function ReviewProgress({
export function WorkReviewProjectCard({
project,
expanded,
canDelete,
deleteBusy,
onToggle,
onDelete,
onResubmit,
onReview,
}: {
project: WorkReviewProject
expanded: boolean
canDelete: boolean
deleteBusy?: boolean
onToggle: () => void
onDelete: () => void
onResubmit: () => void
onReview: () => void
}) {
@ -151,53 +157,78 @@ export function WorkReviewProjectCard({
expanded ? 'border-[#9ebeff] shadow-[0_14px_38px_rgba(20,52,102,0.10)]' : 'border-[#e1e7ef] hover:shadow-[0_8px_24px_rgba(15,23,42,0.07)]'
}`}
>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="grid w-full gap-4 p-4 text-left sm:grid-cols-[132px_minmax(0,1fr)_auto] sm:items-center"
>
<div className="relative aspect-[4/3] overflow-hidden rounded-lg border border-[#e5eaf1] bg-[#f2f4f7] sm:h-[92px] sm:w-[132px]">
{cover ? (
<Image
src={proxyImageUrl(cover)}
alt={project.title}
fill
unoptimized
className="object-cover object-top"
/>
) : (
<span className="flex h-full items-center justify-center text-[12px] font-semibold text-[#98a2b3]">暂无图片</span>
<div className="grid grid-cols-[minmax(0,1fr)_40px] items-center gap-3 p-4">
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-label={`查看作品详情:${project.title}`}
aria-controls={`work-review-project-details-${project.id}`}
className="grid min-w-0 gap-4 rounded-lg text-left outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 sm:grid-cols-[132px_minmax(0,1fr)] sm:items-center"
>
<div className="relative aspect-[4/3] overflow-hidden rounded-lg border border-[#e5eaf1] bg-[#f2f4f7] sm:h-[92px] sm:w-[132px]">
{cover ? (
<Image
src={proxyImageUrl(cover)}
alt={project.title}
fill
unoptimized
className="object-cover object-top"
/>
) : (
<span className="flex h-full items-center justify-center text-[12px] font-semibold text-[#98a2b3]">暂无图片</span>
)}
<span className="absolute bottom-2 right-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
V{project.latestVersion}
</span>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="min-w-0 truncate text-[17px] font-bold text-[#101828]">{project.title}</h2>
<WorkReviewStatusBadge status={project.status} />
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] font-medium text-[#7b8494]">
<span>提交人:{project.submitter.name}</span>
<span>作品日期:{formatDateOnly(project.workDate)}</span>
<span>最近提交:{formatDateTime(latestVersion.submittedAt)}</span>
</div>
<div className="mt-2">
<ReviewProgress project={project} version={latestVersion} />
</div>
</div>
</button>
<div className="flex flex-col items-center gap-2">
{canDelete && (
<button
type="button"
disabled={deleteBusy}
onClick={onDelete}
className="flex min-h-9 w-10 items-center justify-center rounded border border-[#f1b4ae] bg-white text-[11px] font-bold text-[#b42318] hover:border-[#d92d20] hover:bg-[#fff5f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d92d20] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`删除作品:${project.title}`}
title="删除作品"
>
删除
</button>
)}
<span className="absolute bottom-2 right-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
V{project.latestVersion}
</span>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
aria-controls={`work-review-project-details-${project.id}`}
aria-label={`${expanded ? '收起' : '展开'}作品:${project.title}`}
className={`flex h-9 w-9 items-center justify-center rounded-full bg-[#f2f4f7] text-[18px] text-[#667085] transition-transform hover:bg-[#e8edf3] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0050cb] focus-visible:ring-offset-2 ${
expanded ? 'rotate-180' : ''
}`}
>
⌄
</button>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="min-w-0 truncate text-[17px] font-bold text-[#101828]">{project.title}</h2>
<WorkReviewStatusBadge status={project.status} />
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] font-medium text-[#7b8494]">
<span>提交人:{project.submitter.name}</span>
<span>作品日期:{formatDateOnly(project.workDate)}</span>
<span>最近提交:{formatDateTime(latestVersion.submittedAt)}</span>
</div>
<div className="mt-2">
<ReviewProgress project={project} version={latestVersion} />
</div>
</div>
<span className={`hidden h-9 w-9 items-center justify-center rounded-full bg-[#f2f4f7] text-[18px] text-[#667085] transition-transform sm:flex ${
expanded ? 'rotate-180' : ''
}`}>
⌄
</span>
</button>
</div>
{expanded && (
<div className="border-t border-[#e5eaf1] bg-[#fbfcfe] px-4 py-5 sm:px-5">
<div id={`work-review-project-details-${project.id}`} className="border-t border-[#e5eaf1] bg-[#fbfcfe] px-4 py-5 sm:px-5">
<div className="grid gap-5 lg:grid-cols-[170px_minmax(0,1fr)]">
<aside>
<div className="mb-2 text-[11px] font-bold uppercase tracking-[0.08em] text-[#7b8494]">版本记录</div>

View File

@ -1,15 +1,16 @@
/** @jest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import WorkReviewBoard from '@/components/work-review/WorkReviewBoard'
import { getWorkReviewBoard } from '@/lib/work-review-client'
import { deleteWorkReviewProject, getWorkReviewBoard } from '@/lib/work-review-client'
import type { WorkReviewBoardData } from '@/types/work-review'
const replace = jest.fn()
let searchParamsValue = 'project=project-1'
jest.mock('next/navigation', () => ({
useRouter: () => ({ replace }),
useSearchParams: () => new URLSearchParams('project=project-1'),
useSearchParams: () => new URLSearchParams(searchParamsValue),
}))
jest.mock('next-auth/react', () => ({
@ -26,6 +27,7 @@ jest.mock('@/lib/work-review-client', () => ({
getWorkReviewBoard: jest.fn(),
createWorkReviewProject: jest.fn(),
createWorkReviewVersion: jest.fn(),
deleteWorkReviewProject: jest.fn(),
submitWorkReview: jest.fn(),
WorkReviewRequestError: class WorkReviewRequestError extends Error {
status = 500
@ -47,6 +49,7 @@ const boardData: WorkReviewBoardData = {
},
permissions: {
canSubmit: true,
canDeleteProjects: true,
isSystemAdmin: false,
isManager: false,
isFinalReviewer: false,
@ -90,7 +93,9 @@ const boardData: WorkReviewBoardData = {
describe('WorkReviewBoard', () => {
beforeEach(() => {
replace.mockReset()
searchParamsValue = 'project=project-1'
;(getWorkReviewBoard as jest.Mock).mockResolvedValue(boardData)
;(deleteWorkReviewProject as jest.Mock).mockResolvedValue({ id: 'project-1', title: '普通双评审作品' })
})
test('opens the shared detail workspace for a visible submitter project without review permission', async () => {
@ -99,4 +104,17 @@ describe('WorkReviewBoard', () => {
await waitFor(() => expect(screen.getByTestId('shared-work-review-workspace')).toHaveTextContent('普通双评审作品'))
expect(screen.queryByText('作品与评审记录')).not.toBeInTheDocument()
})
test('asks for confirmation before deleting a project from the board', async () => {
searchParamsValue = ''
render(<WorkReviewBoard />)
const deleteButton = await screen.findByRole('button', { name: '删除作品:普通双评审作品' })
fireEvent.click(deleteButton)
expect(screen.getByRole('dialog', { name: '删除作品' })).toBeInTheDocument()
expect(deleteWorkReviewProject).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '确认删除' }))
await waitFor(() => expect(deleteWorkReviewProject).toHaveBeenCalledWith('project-1'))
})
})

View File

@ -1,4 +1,5 @@
import {
canDeleteWorkReviewProject,
canResubmitWorkReviewProject,
canSubmitWorkReview,
deriveCompletedWorkReviewStatus,
@ -20,6 +21,15 @@ describe('work review policy', () => {
expect(canSubmitWorkReview(null)).toBe(false)
})
test('only grants project deletion to the wuxie account', () => {
expect(canDeleteWorkReviewProject({ username: 'wuxie' })).toBe(true)
expect(canDeleteWorkReviewProject({ username: ' WUXIE ' })).toBe(true)
expect(canDeleteWorkReviewProject({ username: '无邪' })).toBe(true)
expect(canDeleteWorkReviewProject({ username: '无邪管理员' })).toBe(false)
expect(canDeleteWorkReviewProject({ username: 'admin' })).toBe(false)
expect(canDeleteWorkReviewProject(null)).toBe(false)
})
test('requires a distinct manual reviewer in addition to the fixed reviewer', () => {
expect(getWorkReviewReviewerSelectionError({
reviewerEmployeeId: null,

View File

@ -1,6 +1,6 @@
/** @jest-environment jsdom */
import { render, screen, within } from '@testing-library/react'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { WorkReviewProjectCard } from '@/components/work-review/WorkReviewProjectCard'
import type { WorkReviewProject } from '@/types/work-review'
@ -35,12 +35,14 @@ const project: WorkReviewProject = {
},
}
function renderCard(expanded: boolean) {
function renderCard(expanded: boolean, options?: { canDelete?: boolean; onDelete?: () => void; onToggle?: () => void }) {
return render(
<WorkReviewProjectCard
project={project}
expanded={expanded}
onToggle={jest.fn()}
canDelete={Boolean(options?.canDelete)}
onToggle={options?.onToggle || jest.fn()}
onDelete={options?.onDelete || jest.fn()}
onResubmit={jest.fn()}
onReview={jest.fn()}
/>,
@ -51,7 +53,7 @@ describe('WorkReviewProjectCard', () => {
test('keeps the collapsed summary consistent and moves the description into details', () => {
renderCard(false)
const summary = screen.getByRole('button', { name: /GBSFY-SJZ0024详情/ })
const summary = screen.getByRole('button', { name: '查看作品详情:GBSFY-SJZ0024详情' })
expect(within(summary).queryByText('测试')).not.toBeInTheDocument()
expect(screen.queryByText('作品说明')).not.toBeInTheDocument()
})
@ -62,4 +64,27 @@ describe('WorkReviewProjectCard', () => {
expect(screen.getByText('作品说明')).toBeInTheDocument()
expect(screen.getByText('测试')).toBeInTheDocument()
})
test('shows an independent delete action only when permission is granted', () => {
const onDelete = jest.fn()
const onToggle = jest.fn()
const { rerender } = renderCard(false, { onDelete, onToggle })
expect(screen.queryByRole('button', { name: '删除作品:GBSFY-SJZ0024详情' })).not.toBeInTheDocument()
rerender(
<WorkReviewProjectCard
project={project}
expanded={false}
canDelete
onToggle={onToggle}
onDelete={onDelete}
onResubmit={jest.fn()}
onReview={jest.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: '删除作品:GBSFY-SJZ0024详情' }))
expect(onDelete).toHaveBeenCalledTimes(1)
expect(onToggle).not.toHaveBeenCalled()
})
})

View File

@ -91,6 +91,12 @@ export function createWorkReviewVersion(projectId: string, input: WorkReviewSubm
})
}
export function deleteWorkReviewProject(projectId: string) {
return workReviewRequest<{ id: string; title: string }>(`/projects/${encodeURIComponent(projectId)}`, {
method: 'DELETE',
})
}
export function submitWorkReview(versionId: string, input: WorkReviewReviewInput) {
return workReviewRequest<WorkReviewProject>(`/versions/${encodeURIComponent(versionId)}/reviews`, {
method: 'POST',

View File

@ -175,6 +175,11 @@ export function canSubmitWorkReview(employee: unknown) {
return Boolean(employee)
}
export function canDeleteWorkReviewProject(user: { username?: unknown } | null | undefined) {
const username = String(user?.username ?? '').trim().toLowerCase()
return username === 'wuxie' || username === '无邪'
}
export function getWorkReviewReviewerSelectionError(input: {
reviewerEmployeeId: string | null
submitterEmployeeId: string

View File

@ -33,6 +33,7 @@ import {
getOwnedUploadSession,
} from '@/lib/upload-sessions'
import {
canDeleteWorkReviewProject,
canSubmitWorkReview,
deriveCompletedWorkReviewStatus,
deriveWorkReviewDecision,
@ -539,6 +540,7 @@ export async function getWorkReviewBoard(session: Session | null, params?: URLSe
employee: ctx.employee ? getEmployeeSnapshot(ctx.employee) : null,
permissions: {
canSubmit: ctx.canSubmit,
canDeleteProjects: canDeleteWorkReviewProject(ctx.user),
isSystemAdmin: ctx.isSystemAdmin,
isManager: Boolean(ctx.isLeadership),
isFinalReviewer: Boolean(
@ -557,6 +559,50 @@ export async function getWorkReviewBoard(session: Session | null, params?: URLSe
}
}
function getStoredWorkReviewAssetUrls(value: unknown) {
const payload = serializeWorkReviewImagePayload(value)
const groups = [...payload.imageGroups, ...payload.referenceImageGroups]
return Array.from(new Set(groups.flatMap((group) => [
group.previewUrl,
...group.slices.map((slice) => slice.url),
]).filter(Boolean)))
}
export async function deleteWorkReviewProject(session: Session | null, projectId: string) {
const ctx = await getContext(session)
if (!canDeleteWorkReviewProject(ctx.user)) {
throw new WorkReviewApiError('只有无邪可以删除作品过稿记录', 403)
}
const id = clean(projectId)
if (!id) throw new WorkReviewApiError('作品参数无效')
const project = await prisma.workReviewProject.findUnique({
where: { id },
select: {
id: true,
title: true,
versions: { select: { imageUrls: true } },
},
})
if (!project) throw new WorkReviewApiError('作品不存在', 404)
const result = await prisma.workReviewProject.deleteMany({ where: { id } })
if (result.count !== 1) {
throw new WorkReviewApiError('作品已被删除,请刷新看板', 409)
}
const assetUrls = Array.from(new Set(project.versions.flatMap((version) => (
getStoredWorkReviewAssetUrls(version.imageUrls)
))))
const cleanupResults = await Promise.allSettled(assetUrls.map((url) => deleteFromS3(url)))
const cleanupFailures = cleanupResults.filter((item) => item.status === 'rejected')
if (cleanupFailures.length > 0) {
console.error(`[work-review] 作品 ${id} 已删除,但有 ${cleanupFailures.length} 个图片对象清理失败`)
}
return { id: project.id, title: project.title }
}
export async function getWorkReviewReviewerOptions(session: Session | null) {
const ctx = await getContext(session)
if (!ctx.employee) {

View File

@ -125,6 +125,7 @@ export type WorkReviewMe = {
} | null
permissions: {
canSubmit: boolean
canDeleteProjects: boolean
isSystemAdmin: boolean
isManager: boolean
isFinalReviewer: boolean