优化视觉评分结果分组展示
This commit is contained in:
parent
9ad759c694
commit
ca9fbdb6f5
@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
50
lib/__tests__/performance-survey-result-grouping.test.ts
Normal file
50
lib/__tests__/performance-survey-result-grouping.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
39
lib/performance-survey-result-grouping.ts
Normal file
39
lib/performance-survey-result-grouping.ts
Normal 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,
|
||||
})),
|
||||
}))
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user