feat: show submitter first-pass rates

This commit is contained in:
江林铖 2026-09-21 17:49:40 +08:00
parent 3bc36d141e
commit ad9c07414a
5 changed files with 238 additions and 29 deletions

View File

@ -306,6 +306,29 @@ describe('CoreMetricDetail', () => {
statusBreakdown: { approved: 1, revisionRequired: 1, pendingReview: 1, unknown: 0 }, statusBreakdown: { approved: 1, revisionRequired: 1, pendingReview: 1, unknown: 0 },
readAt: '2026-08-14T02:00:00.000Z', readAt: '2026-08-14T02:00:00.000Z',
}, },
submitterRates: [
{
submitterName: '林一',
totalUploads: 1,
totalApproved: 1,
firstPassApproved: 1,
rate: 100,
},
{
submitterName: '林二',
totalUploads: 1,
totalApproved: 1,
firstPassApproved: 0,
rate: 0,
},
{
submitterName: '林三',
totalUploads: 1,
totalApproved: 0,
firstPassApproved: 0,
rate: null,
},
],
items: [ items: [
{ {
id: 'approved-v1', id: 'approved-v1',
@ -363,7 +386,13 @@ describe('CoreMetricDetail', () => {
) )
expect(screen.getByRole('heading', { level: 1, name: '首轮通过' })).toBeInTheDocument() expect(screen.getByRole('heading', { level: 1, name: '首轮通过' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: '各提交人的首轮通过率' })).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 2, name: '所有作品明细' })).toBeInTheDocument() expect(screen.getByRole('heading', { level: 2, name: '所有作品明细' })).toBeInTheDocument()
const submitterTable = screen.getByRole('table', { name: '各提交人的首轮通过率,按通过率从高到低排列' })
expect(within(submitterTable).getAllByRole('rowheader').map((cell) => cell.textContent)).toEqual(['林一', '林二', '林三'])
expect(within(submitterTable).getByText('100%')).toBeInTheDocument()
expect(within(submitterTable).getByText('0%')).toBeInTheDocument()
expect(within(submitterTable).getByText('—')).toBeInTheDocument()
expect(screen.getByText(/全部已上传且包含 V1 的作品/)).toBeInTheDocument() expect(screen.getByText(/全部已上传且包含 V1 的作品/)).toBeInTheDocument()
expect(screen.getByText('首轮通过率 = V1 审核通过作品数 ÷ 所有已通过作品数 × 100%')).toBeInTheDocument() expect(screen.getByText('首轮通过率 = V1 审核通过作品数 ÷ 所有已通过作品数 × 100%')).toBeInTheDocument()
expect(screen.getByText('当前口径:1 ÷ 2 = 50%;共读取 3 个作品')).toBeInTheDocument() expect(screen.getByText('当前口径:1 ÷ 2 = 50%;共读取 3 个作品')).toBeInTheDocument()

View File

@ -115,11 +115,67 @@ function FirstPassStatusBadge({ status }: { status: FirstPassReviewProjectListIt
} }
function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<CoreMetricDetailProps, 'metric' | 'firstPassPage'>> & Pick<CoreMetricDetailProps, 'query'>) { function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<CoreMetricDetailProps, 'metric' | 'firstPassPage'>> & Pick<CoreMetricDetailProps, 'query'>) {
const { items, pagination, summary } = firstPassPage const { items, pagination, summary, submitterRates } = firstPassPage
return ( return (
<section aria-labelledby="first-pass-list-title" className="mt-10"> <div className="mt-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between"> <section aria-labelledby="first-pass-submitter-rates-title">
<h2 id="first-pass-submitter-rates-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">各提交人的首轮通过率</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600">
按提交人汇总 V1 审核通过作品数 ÷ 当前已通过作品数,按通过率从高到低排列。
</p>
{submitterRates.length === 0 ? (
<div role="status" className="mt-5 rounded-lg border border-[#e2e8f0] bg-white px-5 py-10 text-center text-sm text-slate-600">
暂无可统计的提交人数据。
</div>
) : (
<>
<div className="mt-5 hidden overflow-x-auto rounded-lg border border-[#e2e8f0] bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9] focus-visible:ring-offset-2 md:block" tabIndex={0} aria-label="各提交人首轮通过率横向滚动表格">
<table className="min-w-[720px] w-full table-fixed text-left text-sm">
<caption className="sr-only">各提交人的首轮通过率,按通过率从高到低排列</caption>
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
<th scope="col" className="w-[32%] px-5 py-3.5">提交人</th>
<th scope="col" className="w-[20%] px-4 py-3.5">首轮通过率</th>
<th scope="col" className="w-[16%] px-4 py-3.5">V1 通过</th>
<th scope="col" className="w-[16%] px-4 py-3.5">当前已通过</th>
<th scope="col" className="w-[16%] px-5 py-3.5">V1 作品数</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-200">
{submitterRates.map((submitter) => (
<tr key={submitter.submitterName} className="transition-colors hover:bg-slate-50">
<th scope="row" className="break-words px-5 py-4 font-bold text-slate-950">{submitter.submitterName}</th>
<td className="px-4 py-4 text-lg font-bold tabular-nums text-[#176fc1]">{formatRate(submitter.rate)}</td>
<td className="px-4 py-4 tabular-nums text-slate-700">{submitter.firstPassApproved}</td>
<td className="px-4 py-4 tabular-nums text-slate-700">{submitter.totalApproved}</td>
<td className="px-5 py-4 tabular-nums text-slate-700">{submitter.totalUploads}</td>
</tr>
))}
</tbody>
</table>
</div>
<ul className="mt-5 divide-y divide-slate-200 overflow-hidden rounded-lg border border-[#e2e8f0] bg-white md:hidden" aria-label="各提交人首轮通过率列表">
{submitterRates.map((submitter) => (
<li key={submitter.submitterName} className="p-5">
<div className="flex min-w-0 items-start justify-between gap-4">
<h3 className="min-w-0 break-words font-bold text-slate-950">{submitter.submitterName}</h3>
<p className="shrink-0 text-lg font-bold tabular-nums text-[#176fc1]">{formatRate(submitter.rate)}</p>
</div>
<p className="mt-2 text-xs tabular-nums leading-5 text-slate-500">
V1 通过 {submitter.firstPassApproved} · 当前已通过 {submitter.totalApproved} · V1 作品 {submitter.totalUploads}
</p>
</li>
))}
</ul>
</>
)}
</section>
<section aria-labelledby="first-pass-list-title" className="mt-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div> <div>
<h2 id="first-pass-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">所有作品明细</h2> <h2 id="first-pass-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">所有作品明细</h2>
<p className="mt-1 text-sm leading-6 text-slate-600"> <p className="mt-1 text-sm leading-6 text-slate-600">
@ -264,7 +320,8 @@ function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<Co
</div> </div>
</nav> </nav>
)} )}
</section> </section>
</div>
) )
} }

View File

@ -45,31 +45,38 @@ describe('department dashboard first-pass review details', () => {
;(prisma.workReviewProject.count as jest.Mock).mockImplementation(({ where }) => ( ;(prisma.workReviewProject.count as jest.Mock).mockImplementation(({ where }) => (
JSON.stringify(where).includes('"status":"approved"') ? Promise.resolve(5) : Promise.resolve(1) JSON.stringify(where).includes('"status":"approved"') ? Promise.resolve(5) : Promise.resolve(1)
)) ))
;(prisma.workReviewProject.findMany as jest.Mock).mockResolvedValue([{ ;(prisma.workReviewProject.findMany as jest.Mock)
id: 'project-1', .mockResolvedValueOnce([{
title: '品牌主视觉', id: 'project-1',
brand: null, title: '品牌主视觉',
description: '第一版视觉作品', brand: null,
status: 'revision_required', description: '第一版视觉作品',
latestVersion: 2, status: 'revision_required',
submitterName: '提交人', latestVersion: 2,
managerName: '主管', submitterName: '提交人',
finalReviewerName: '元泽', managerName: '主管',
workDate: new Date('2026-08-28T00:00:00.000Z'), finalReviewerName: '元泽',
createdAt: new Date('2026-08-28T01:00:00.000Z'), workDate: new Date('2026-08-28T00:00:00.000Z'),
updatedAt: new Date('2026-08-30T01:00:00.000Z'), createdAt: new Date('2026-08-28T01:00:00.000Z'),
versions: [{ updatedAt: new Date('2026-08-30T01:00:00.000Z'),
status: 'approved', versions: [{
submittedAt: new Date('2026-08-28T01:00:00.000Z'), status: 'approved',
completedAt: new Date('2026-08-28T03:00:00.000Z'), submittedAt: new Date('2026-08-28T01:00:00.000Z'),
reviews: [{ completedAt: new Date('2026-08-28T03:00:00.000Z'),
reviewerRole: 'manager', reviews: [{
reviewerName: '主管', reviewerRole: 'manager',
decision: 'approved', reviewerName: '主管',
reviewedAt: new Date('2026-08-28T02:00:00.000Z'), decision: 'approved',
reviewedAt: new Date('2026-08-28T02:00:00.000Z'),
}],
}], }],
}], }])
}]) .mockResolvedValueOnce([
{ submitterName: '林二', status: 'approved', versions: [{ status: 'revision_required' }] },
{ submitterName: '林一', status: 'approved', versions: [{ status: 'approved' }] },
{ submitterName: '林一', status: 'approved', versions: [] },
{ submitterName: '提交人', status: 'revision_required', versions: [{ status: 'approved' }] },
])
}) })
test('summary keeps the full brand-center V1 population while search narrows only detail rows', async () => { test('summary keeps the full brand-center V1 population while search narrows only detail rows', async () => {
@ -138,6 +145,23 @@ describe('department dashboard first-pass review details', () => {
expect(prisma.workReviewProject.findMany).not.toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.workReviewProject.findMany).not.toHaveBeenCalledWith(expect.objectContaining({
select: expect.objectContaining({ imageUrls: true }), select: expect.objectContaining({ imageUrls: true }),
})) }))
expect(prisma.workReviewProject.findMany).toHaveBeenNthCalledWith(2, {
where: {
AND: [
{},
{ versions: { some: { version: 1 } } },
],
},
select: {
submitterName: true,
status: true,
versions: {
where: { version: 1 },
select: { status: true },
take: 1,
},
},
})
expect(result.summary).toMatchObject({ expect(result.summary).toMatchObject({
totalUploads: 3, totalUploads: 3,
@ -145,6 +169,29 @@ describe('department dashboard first-pass review details', () => {
firstPassApproved: 2, firstPassApproved: 2,
rate: 40, rate: 40,
}) })
expect(result.submitterRates).toEqual([
{
submitterName: '林一',
totalUploads: 1,
totalApproved: 2,
firstPassApproved: 1,
rate: 50,
},
{
submitterName: '林二',
totalUploads: 1,
totalApproved: 1,
firstPassApproved: 0,
rate: 0,
},
{
submitterName: '提交人',
totalUploads: 1,
totalApproved: 0,
firstPassApproved: 1,
rate: null,
},
])
expect(result.items).toEqual([{ expect(result.items).toEqual([{
id: 'project-1', id: 'project-1',
title: '品牌主视觉', title: '品牌主视觉',

View File

@ -23,6 +23,20 @@ export type FirstPassReviewSummary = {
syncRowsChecked?: number syncRowsChecked?: number
} }
export type FirstPassSubmitterRate = {
submitterName: string
totalUploads: number
totalApproved: number
firstPassApproved: number
rate: number | null
}
export type FirstPassSubmitterProject = {
submitterName?: string | null
currentStatus: string
firstVersionStatus?: string | null
}
export function normalizeFirstPassReviewStatus(status: string): FirstPassReviewStatus { export function normalizeFirstPassReviewStatus(status: string): FirstPassReviewStatus {
if (status === 'approved' || status === 'revision_required' || status === 'pending_review') { if (status === 'approved' || status === 'revision_required' || status === 'pending_review') {
return status return status
@ -71,3 +85,45 @@ export function buildFirstPassReviewSummary(
readAt: readAt.toISOString(), readAt: readAt.toISOString(),
} }
} }
/**
* 按提交人复用总指标口径:V1 审核通过作品数 / 当前已通过作品数。
* 调用方应只传入包含 V1 的作品,与页面明细的统计范围保持一致。
*/
export function buildFirstPassSubmitterRates(
projects: FirstPassSubmitterProject[],
): FirstPassSubmitterRate[] {
const ratesBySubmitter = new Map<string, Omit<FirstPassSubmitterRate, 'rate'>>()
for (const project of projects) {
const submitterName = project.submitterName?.trim() || '未填写提交人'
const current = ratesBySubmitter.get(submitterName) || {
submitterName,
totalUploads: 0,
totalApproved: 0,
firstPassApproved: 0,
}
if (project.firstVersionStatus) {
current.totalUploads += 1
if (normalizeFirstPassReviewStatus(project.firstVersionStatus) === 'approved') {
current.firstPassApproved += 1
}
}
if (project.currentStatus === 'approved') current.totalApproved += 1
ratesBySubmitter.set(submitterName, current)
}
return Array.from(ratesBySubmitter.values(), (item) => ({
...item,
rate: item.totalApproved === 0
? null
: Math.round((item.firstPassApproved / item.totalApproved) * 1000) / 10,
})).sort((left, right) => {
if (left.rate === null && right.rate !== null) return 1
if (left.rate !== null && right.rate === null) return -1
if (left.rate !== right.rate) return (right.rate || 0) - (left.rate || 0)
if (left.totalApproved !== right.totalApproved) return right.totalApproved - left.totalApproved
return left.submitterName.localeCompare(right.submitterName, 'zh-CN')
})
}

View File

@ -57,10 +57,12 @@ import {
type NormalizedWorkReviewAnnotation, type NormalizedWorkReviewAnnotation,
} from '@/lib/work-review-policy' } from '@/lib/work-review-policy'
import { import {
buildFirstPassSubmitterRates,
buildFirstPassReviewSummary, buildFirstPassReviewSummary,
normalizeFirstPassReviewStatus, normalizeFirstPassReviewStatus,
type FirstPassReviewStatus, type FirstPassReviewStatus,
type FirstPassReviewSummary, type FirstPassReviewSummary,
type FirstPassSubmitterRate,
} from '@/lib/work-review-metrics' } from '@/lib/work-review-metrics'
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope' import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
@ -604,6 +606,7 @@ export type FirstPassReviewProjectListItem = {
export type FirstPassReviewProjectPage = { export type FirstPassReviewProjectPage = {
summary: FirstPassReviewSummary summary: FirstPassReviewSummary
submitterRates: FirstPassSubmitterRate[]
items: FirstPassReviewProjectListItem[] items: FirstPassReviewProjectListItem[]
pagination: { pagination: {
page: number page: number
@ -704,7 +707,7 @@ export async function getFirstPassReviewProjects(
], ],
} }
} }
const [summary, total, projects] = await Promise.all([ const [summary, total, projects, submitterProjects] = await Promise.all([
// Searching and detail-only V1 filters must not change the headline rate. // Searching and detail-only V1 filters must not change the headline rate.
// The denominator is every approved project in the complete department scope. // The denominator is every approved project in the complete department scope.
getFirstPassReviewSummaryForProjectWhere(scopeWhere), getFirstPassReviewSummaryForProjectWhere(scopeWhere),
@ -747,10 +750,27 @@ export async function getFirstPassReviewProjects(
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}), }),
prisma.workReviewProject.findMany({
where: { AND: [scopeWhere, { versions: { some: { version: 1 } } }] },
select: {
submitterName: true,
status: true,
versions: {
where: { version: 1 },
select: { status: true },
take: 1,
},
},
}),
]) ])
return { return {
summary, summary,
submitterRates: buildFirstPassSubmitterRates(submitterProjects.map((project) => ({
submitterName: project.submitterName,
currentStatus: project.status,
firstVersionStatus: project.versions[0]?.status || null,
}))),
items: projects.map((project) => { items: projects.map((project) => {
const firstVersion = project.versions[0] const firstVersion = project.versions[0]
return { return {