feat: restrict work review deletion to wuxie
This commit is contained in:
parent
f627344616
commit
e7eb8d152e
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -125,6 +125,7 @@ export type WorkReviewMe = {
|
||||
} | null
|
||||
permissions: {
|
||||
canSubmit: boolean
|
||||
canDeleteProjects: boolean
|
||||
isSystemAdmin: boolean
|
||||
isManager: boolean
|
||||
isFinalReviewer: boolean
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user