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 },
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: [
{
id: 'approved-v1',
@ -363,7 +386,13 @@ describe('CoreMetricDetail', () => {
)
expect(screen.getByRole('heading', { level: 1, 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 审核通过作品数 ÷ 所有已通过作品数 × 100%')).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'>) {
const { items, pagination, summary } = firstPassPage
const { items, pagination, summary, submitterRates } = firstPassPage
return (
<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 className="mt-10">
<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>
<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">
@ -264,7 +320,8 @@ function FirstPassProjectList({ metric, query, firstPassPage }: Required<Pick<Co
</div>
</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 }) => (
JSON.stringify(where).includes('"status":"approved"') ? Promise.resolve(5) : Promise.resolve(1)
))
;(prisma.workReviewProject.findMany as jest.Mock).mockResolvedValue([{
id: 'project-1',
title: '品牌主视觉',
brand: null,
description: '第一版视觉作品',
status: 'revision_required',
latestVersion: 2,
submitterName: '提交人',
managerName: '主管',
finalReviewerName: '元泽',
workDate: new Date('2026-08-28T00:00:00.000Z'),
createdAt: new Date('2026-08-28T01:00:00.000Z'),
updatedAt: new Date('2026-08-30T01:00:00.000Z'),
versions: [{
status: 'approved',
submittedAt: new Date('2026-08-28T01:00:00.000Z'),
completedAt: new Date('2026-08-28T03:00:00.000Z'),
reviews: [{
reviewerRole: 'manager',
reviewerName: '主管',
decision: 'approved',
reviewedAt: new Date('2026-08-28T02:00:00.000Z'),
;(prisma.workReviewProject.findMany as jest.Mock)
.mockResolvedValueOnce([{
id: 'project-1',
title: '品牌主视觉',
brand: null,
description: '第一版视觉作品',
status: 'revision_required',
latestVersion: 2,
submitterName: '提交人',
managerName: '主管',
finalReviewerName: '元泽',
workDate: new Date('2026-08-28T00:00:00.000Z'),
createdAt: new Date('2026-08-28T01:00:00.000Z'),
updatedAt: new Date('2026-08-30T01:00:00.000Z'),
versions: [{
status: 'approved',
submittedAt: new Date('2026-08-28T01:00:00.000Z'),
completedAt: new Date('2026-08-28T03:00:00.000Z'),
reviews: [{
reviewerRole: 'manager',
reviewerName: '主管',
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 () => {
@ -138,6 +145,23 @@ describe('department dashboard first-pass review details', () => {
expect(prisma.workReviewProject.findMany).not.toHaveBeenCalledWith(expect.objectContaining({
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({
totalUploads: 3,
@ -145,6 +169,29 @@ describe('department dashboard first-pass review details', () => {
firstPassApproved: 2,
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([{
id: 'project-1',
title: '品牌主视觉',

View File

@ -23,6 +23,20 @@ export type FirstPassReviewSummary = {
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 {
if (status === 'approved' || status === 'revision_required' || status === 'pending_review') {
return status
@ -71,3 +85,45 @@ export function buildFirstPassReviewSummary(
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,
} from '@/lib/work-review-policy'
import {
buildFirstPassSubmitterRates,
buildFirstPassReviewSummary,
normalizeFirstPassReviewStatus,
type FirstPassReviewStatus,
type FirstPassReviewSummary,
type FirstPassSubmitterRate,
} from '@/lib/work-review-metrics'
import { buildDepartmentDashboardEmployeeWhere } from '@/lib/department-dashboard-scope'
@ -604,6 +606,7 @@ export type FirstPassReviewProjectListItem = {
export type FirstPassReviewProjectPage = {
summary: FirstPassReviewSummary
submitterRates: FirstPassSubmitterRate[]
items: FirstPassReviewProjectListItem[]
pagination: {
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.
// The denominator is every approved project in the complete department scope.
getFirstPassReviewSummaryForProjectWhere(scopeWhere),
@ -747,10 +750,27 @@ export async function getFirstPassReviewProjects(
skip: (page - 1) * 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 {
summary,
submitterRates: buildFirstPassSubmitterRates(submitterProjects.map((project) => ({
submitterName: project.submitterName,
currentStatus: project.status,
firstVersionStatus: project.versions[0]?.status || null,
}))),
items: projects.map((project) => {
const firstVersion = project.versions[0]
return {