优化视觉评分结果分组展示

This commit is contained in:
chenpei 2026-07-13 20:21:16 +08:00
parent 9ad759c694
commit ca9fbdb6f5
4 changed files with 241 additions and 47 deletions

View File

@ -9,6 +9,7 @@ import { pinyin } from 'pinyin-pro'
import { createPortal } from 'react-dom'
import { sortPerformanceSurveyImagesByName } from '@/lib/performance-survey-image-order'
import { calculatePerformanceSurveyResponseProgress } from '@/lib/performance-survey-response-progress'
import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-grouping'
type ApiState<T> = {
loading: boolean
@ -1691,6 +1692,103 @@ function ScoreParticipantList({ items }: { items: any[] }) {
)
}
function VisualEvaluatorResultList({
responses,
workItems,
questions,
template,
}: {
responses: any[]
workItems: any[]
questions: Template['questions']
template?: Template
}) {
const evaluators = groupVisualScoresByEvaluator(responses, workItems)
if (evaluators.length === 0) {
return <div className="rounded-[12px] bg-[#f8fafc] px-4 py-4 text-sm text-[#667085]">暂无评分记录</div>
}
return (
<div className="grid gap-3">
{evaluators.map((evaluator) => (
<section key={evaluator.id} className="overflow-hidden rounded-[14px] border border-[#e5edf6] bg-white">
<div className="flex items-start justify-between gap-3 border-b border-[#e5edf6] bg-[#f8fafc] px-4 py-3.5">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-[#101828]">{evaluator.respondentName}</span>
{['visual_final', 'self_visual_final'].includes(String(evaluator.workflowRole || '')) && (
<span className="rounded-full bg-[#e8f0ff] px-2 py-0.5 text-[11px] font-semibold text-[#0050cb]">计入最终分</span>
)}
{['self', 'self_visual_final'].includes(String(evaluator.workflowRole || '')) && (
<span className="rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] font-semibold text-[#5f5e5e]">自评</span>
)}
{evaluator.workflowRole === 'manager' && (
<span className="rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] font-semibold text-[#5f5e5e]">主管评分</span>
)}
</div>
{evaluator.createdAt && <div className="mt-1 text-[11px] text-[#98a2b3]">提交于 {formatDate(evaluator.createdAt)}</div>}
</div>
<div className="shrink-0 text-right">
<div className="text-[18px] font-semibold text-[#0052d9]">{formatResultScore(evaluator.totalScore)}</div>
<div className="text-[11px] font-medium text-[#667085]">总分</div>
</div>
</div>
<div className="divide-y divide-[#edf1f6] px-4">
{evaluator.workItemScores.map((item, itemIndex) => {
const itemScore = item.score
const rawScores = itemScore?.scores && typeof itemScore.scores === 'object'
? itemScore.scores as Record<string, unknown>
: {}
const dimensions = questions
.map((question) => ({
question,
value: rawScores[question.id],
}))
.filter(({ value }) => value !== null && value !== undefined && value !== '')
return (
<div key={item.workItemId} className="py-3.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold text-[#667085]">作品 {itemIndex + 1}</div>
<div className="mt-0.5 break-words text-sm font-semibold text-[#101828]">{item.workItemTitle}</div>
</div>
<div className={`shrink-0 text-sm font-semibold ${itemScore ? 'text-[#0052d9]' : 'text-[#98a2b3]'}`}>
{itemScore ? `${formatResultScore(itemScore.totalScore)} 分` : '未评分'}
</div>
</div>
{dimensions.length > 0 && (
<div className="mt-3 grid gap-x-4 gap-y-2 rounded-[10px] bg-[#f8fafc] px-3 py-2.5 sm:grid-cols-2">
{dimensions.map(({ question, value }) => (
<div key={question.id} className="flex items-center justify-between gap-3 text-[12px]">
<span className="truncate text-[#667085]">{question.title}</span>
<span className="shrink-0 font-semibold text-[#344054]">
{formatResultScore(value)} / {getQuestionMaxScore(template, question)}
</span>
</div>
))}
</div>
)}
{itemScore?.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#475467]">{itemScore.comment}</p>}
</div>
)
})}
</div>
{evaluator.comment && (
<div className="border-t border-[#e5edf6] bg-[#fbfcfe] px-4 py-3">
<div className="text-[11px] font-semibold text-[#667085]">总体评语</div>
<p className="mt-1 whitespace-pre-wrap text-sm leading-6 text-[#344054]">{evaluator.comment}</p>
</div>
)}
</section>
))}
</div>
)
}
function MetricCard({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="rounded-[14px] border border-[#e5eaf2] bg-white px-4 py-4 shadow-[0_8px_18px_rgba(45,43,80,0.05)] dark:border-gray-700 dark:bg-gray-900">
@ -5170,6 +5268,7 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
{targets.map((target: any) => {
const aggregate = target.aggregate || {}
const workItems = Array.isArray(target.workItems) ? target.workItems : []
const responses = Array.isArray(target.responses) ? target.responses : []
const dimensionEntries = questions
.map((question: Template['questions'][number]) => ({
question,
@ -5250,47 +5349,39 @@ function SurveyResults({ surveyId }: { surveyId: string }) {
</span>
</div>
<ImagePreviewGrid images={item.imageGroups || item.imageUrls || []} variant="long" />
<ScoreParticipantList
items={(item.scores || []).map((score: any) => ({
...score,
name: score.evaluatorName,
score: score.totalScore,
countsTowardFinal: ['visual_final', 'self_visual_final'].includes(score.workflowRole),
}))}
/>
</div>
))}
</section>
)}
<div className="mt-5">
<h3 className="text-sm font-semibold text-[#101828]">{visualTemplate ? '全部评分记录' : '评价明细'}</h3>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-2">
{target.responses.map((response: any) => (
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-[#101828]">
{response.respondentName}
{visualTemplate && ['visual_final', 'self_visual_final'].includes(response.workflowRole) && (
<span className="ml-2 rounded-full bg-[#e8f0ff] px-2 py-0.5 text-[11px] text-[#0050cb]">计入最终分</span>
)}
{visualTemplate && ['self', 'self_visual_final'].includes(response.workflowRole) && (
<span className="ml-2 rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] text-[#5f5e5e]">自评</span>
)}
{visualTemplate && response.workflowRole === 'manager' && (
<span className="ml-2 rounded-full bg-[#f0f1f3] px-2 py-0.5 text-[11px] text-[#5f5e5e]">主管评分</span>
)}
</span>
<span className="text-sm font-semibold text-[#667085]">{formatResultScore(response.totalScore)} 分</span>
<section className="mt-5">
<h3 className="text-sm font-semibold text-[#101828]">{visualTemplate ? `评分人明细(${responses.length}人)` : '评价明细'}</h3>
<div className="mt-3">
{visualTemplate ? (
<VisualEvaluatorResultList
responses={responses}
workItems={workItems}
questions={questions}
template={template}
/>
) : (
<div className="grid gap-3 md:grid-cols-2">
{responses.map((response: any) => (
<div key={response.id} className="rounded-[12px] bg-[#f8fafc] px-4 py-3">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-semibold text-[#101828]">{response.respondentName}</span>
<span className="text-sm font-semibold text-[#667085]">{formatResultScore(response.totalScore)} 分</span>
</div>
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054]">{response.comment}</p>}
</div>
))}
{responses.length === 0 && (
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085]">暂无评价</div>
)}
</div>
{response.comment && <p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-[#344054]">{response.comment}</p>}
</div>
))}
{target.responses.length === 0 && (
<div className="rounded-[12px] bg-[#f8fafc] px-4 py-3 text-sm text-[#667085]">暂无评价</div>
)}
</div>
)}
</div>
</section>
</article>
)
})}

View File

@ -0,0 +1,50 @@
import { groupVisualScoresByEvaluator } from '@/lib/performance-survey-result-grouping'
describe('performance survey visual result grouping', () => {
const workItems = [1, 2, 3].map((index) => ({
id: `work-${index}`,
title: `作品 ${index}`,
scores: [
{ id: `score-a-${index}`, participantKey: 'participant-a', totalScore: index === 1 ? 0 : index * 10 },
{ id: `score-b-${index}`, participantKey: 'participant-b', totalScore: index * 10 + 1 },
],
}))
it('三个作品只生成一次作品数据,并按评分人汇总各自三项评分', () => {
const result = groupVisualScoresByEvaluator([
{ id: 'response-a', participantKey: 'participant-a', respondentName: '评分人A' },
{ id: 'response-b', participantKey: 'participant-b', respondentName: '评分人B' },
], workItems)
expect(result).toHaveLength(2)
expect(result[0].workItemScores).toHaveLength(3)
expect(result[0].workItemScores.map((item) => item.score?.id)).toEqual([
'score-a-1',
'score-a-2',
'score-a-3',
])
expect(result[1].workItemScores.map((item) => item.score?.id)).toEqual([
'score-b-1',
'score-b-2',
'score-b-3',
])
})
it('同名评分人依靠参与键区分,并保留合法的零分', () => {
const result = groupVisualScoresByEvaluator([
{ id: 'response-a', participantKey: 'participant-a', respondentName: '同名' },
{ id: 'response-b', participantKey: 'participant-b', respondentName: '同名' },
], workItems)
expect(result[0].workItemScores[0].score?.totalScore).toBe(0)
expect(result[1].workItemScores[0].score?.totalScore).toBe(11)
})
it('缺失的作品评分保持为空,不伪造成零分', () => {
const result = groupVisualScoresByEvaluator([
{ id: 'response-a', participantKey: 'participant-a' },
], [{ id: 'work-missing', title: '未评分作品', scores: [] }])
expect(result[0].workItemScores[0].score).toBeNull()
})
})

View File

@ -0,0 +1,39 @@
export type VisualEvaluatorResponse = {
id: string
participantKey?: string | null
respondentName?: string | null
totalScore?: unknown
comment?: string | null
workflowRole?: string | null
createdAt?: string | Date | null
}
export type VisualEvaluatorWorkItem = {
id: string
title?: string | null
scores?: Array<{
id: string
participantKey?: string | null
totalScore?: unknown
scores?: unknown
comment?: string | null
workflowRole?: string | null
createdAt?: string | Date | null
}>
}
export function groupVisualScoresByEvaluator(
responses: VisualEvaluatorResponse[],
workItems: VisualEvaluatorWorkItem[],
) {
return responses.map((response) => ({
...response,
workItemScores: workItems.map((workItem, index) => ({
workItemId: workItem.id,
workItemTitle: workItem.title || `作品 ${index + 1}`,
score: (workItem.scores || []).find(
(item) => Boolean(response.participantKey) && item.participantKey === response.participantKey,
) || null,
})),
}))
}

View File

@ -2631,6 +2631,15 @@ export async function getPerformanceSurveyResults(
counts: survey._count,
},
targets: survey.targets.map((target) => {
const participantByAssignmentId = new Map(
target.responses.map((response, index) => [
response.assignmentId,
{
key: `participant-${index + 1}`,
name: showRealRespondent ? response.respondentName : `匿名评价 ${index + 1}`,
},
]),
)
const aggregateResponses = isVisualCaseTemplate(template)
? target.responses.filter((response) => isFinalWorkflowRole(readWorkflowRole(response.assignment.evaluatorSnapshot)))
: target.responses
@ -2662,7 +2671,8 @@ export async function getPerformanceSurveyResults(
aggregate: visibleAggregate,
responses: target.responses.map((response, index) => ({
id: response.id,
respondentName: showRealRespondent ? response.respondentName : `匿名评价 ${index + 1}`,
participantKey: participantByAssignmentId.get(response.assignmentId)?.key || `participant-${index + 1}`,
respondentName: participantByAssignmentId.get(response.assignmentId)?.name || `匿名评价 ${index + 1}`,
scores: response.scores,
comment: response.comment,
totalScore: response.totalScore,
@ -2674,17 +2684,21 @@ export async function getPerformanceSurveyResults(
})),
workItems: target.workItems.map((item) => {
const images = serializePerformanceSurveyImagePayload(item.imageUrls)
const scores = item.scores.map((score, index) => ({
id: score.id,
evaluatorName: showRealRespondent ? score.evaluatorName : `匿名评价 ${index + 1}`,
scores: score.scores,
comment: score.comment,
totalScore: score.totalScore,
workflowRole: readWorkflowRole(
target.responses.find((response) => response.assignmentId === score.assignmentId)?.assignment.evaluatorSnapshot,
),
createdAt: score.createdAt,
}))
const scores = item.scores.map((score) => {
const participant = participantByAssignmentId.get(score.assignmentId)
return {
id: score.id,
participantKey: participant?.key || null,
evaluatorName: participant?.name || (showRealRespondent ? score.evaluatorName : '匿名评价'),
scores: score.scores,
comment: score.comment,
totalScore: score.totalScore,
workflowRole: readWorkflowRole(
target.responses.find((response) => response.assignmentId === score.assignmentId)?.assignment.evaluatorSnapshot,
),
createdAt: score.createdAt,
}
})
const aggregateScores = isVisualCaseTemplate(template)
? scores.filter((score) => isFinalWorkflowRole(score.workflowRole))
: scores