Files
YG_FT/frontend/src/views/eval/EvalDetailView.vue
wuyongtao 4e5c43fad5 feat: 评测任务元数据增强与前端展示优化
后端:
- 新增评测指标标签生成(_build_eval_metric_label/_basic_metric_labels)
- eval 任务 payload 富化(_enrich_eval_payload): 解析模型名/数据集名/维度配置, 生成 metric_label
- 任务创建时解析 trained_models/模型名, metric 回退为 metric_label
- 运行时状态刷新增加 2s 节流, 避免频繁触发

前端:
- EvalTask 类型增加 dataset_id/metric_label
- 评测列表与详情展示 metric_label 指标标签, 模型名/指标超长 tooltip 省略
- 创建页改用 getComputeGpus 过滤空闲 GPU, 移除 getSystemInfo 依赖
2026-08-03 17:34:21 +08:00

745 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import PageCard from '@/components/PageCard.vue'
import ModelStatusTag from '@/components/ModelStatusTag.vue'
import { getEvalDetail } from '@/api/modules/eval'
import { usePolling } from '@/composables/usePolling'
import type { EvalSampleResult, EvalTaskDetail } from '@/types'
const route = useRoute()
const taskId = route.params.id as string
const loading = ref(true)
const loadError = ref('')
const detail = ref<EvalTaskDetail | null>(null)
const keyword = ref('')
const judgementFilter = ref('')
const currentPage = ref(1)
const pageSize = ref(10)
const ACTIVE_STATUSES = new Set(['pending', 'queued', 'running'])
const filteredSamples = computed(() => {
const normalizedKeyword = keyword.value.trim().toLowerCase()
return (detail.value?.samples || []).filter((sample) => {
const matchesJudgement = !judgementFilter.value || sample.judgement === judgementFilter.value
const searchableText = [
sample.input,
sample.reference_answer,
sample.model_output,
sample.evaluation_reason,
sample.error_type,
].filter(Boolean).join(' ').toLowerCase()
return matchesJudgement && (!normalizedKeyword || searchableText.includes(normalizedKeyword))
})
})
const paginatedSamples = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredSamples.value.slice(start, start + pageSize.value)
})
const completionRate = computed(() => {
const total = detail.value?.sample_count || 0
return total ? Math.round(((detail.value?.completed_count || 0) / total) * 100) : 0
})
const passRate = computed(() => {
const completed = detail.value?.completed_count || 0
return completed ? Math.round(((detail.value?.passed_count || 0) / completed) * 100) : 0
})
const overallScore = computed(() => formatScore(detail.value?.overall_score, detail.value?.overall_score_max))
const displayModelName = computed(() => detail.value?.model_name || String(detail.value?.model_id || '-'))
const displayMetric = computed(() => detail.value?.metric_label || detail.value?.metric || '-')
function formatDateTime(value?: string) {
if (!value) return '-'
const date = new Date(value)
return Number.isNaN(date.getTime())
? value
: date.toLocaleString('zh-CN', { hour12: false })
}
function formatScore(score?: number | null, maxScore?: number) {
if (score == null) return '-'
const decimals = Number.isInteger(score) ? 0 : 2
return `${score.toFixed(decimals)}${maxScore != null ? ` / ${maxScore}` : ''}`
}
function judgementTagType(judgement?: EvalSampleResult['judgement']) {
if (judgement === '正确') return 'success'
if (judgement === '部分正确') return 'warning'
if (judgement === '错误') return 'danger'
return 'info'
}
function resetPage() {
currentPage.value = 1
}
async function loadDetail(options: { silent?: boolean } = {}) {
if (!options.silent) loading.value = true
loadError.value = ''
try {
detail.value = await getEvalDetail(taskId)
} catch {
detail.value = null
loadError.value = '评测详情加载失败,请稍后重试。'
} finally {
if (!options.silent) loading.value = false
}
}
const { start: startPolling, stop: stopPolling } = usePolling(
async () => {
await loadDetail({ silent: true })
if (!ACTIVE_STATUSES.has(String(detail.value?.status || ''))) {
stopPolling()
}
},
5000,
{ immediate: false },
)
onMounted(async () => {
await loadDetail()
if (ACTIVE_STATUSES.has(String(detail.value?.status || ''))) {
startPolling()
}
})
onUnmounted(stopPolling)
</script>
<template>
<PageCard class="eval-detail-page" v-loading="loading">
<template #header>
<div class="page-heading">
<div class="heading-copy">
<div class="heading-row">
<h1>{{ detail?.eval_task_name || '评测任务详情' }}</h1>
<ModelStatusTag v-if="detail" :status="detail.status" />
</div>
<dl class="task-meta">
<div><dt>任务 ID</dt><dd>{{ detail?.id || taskId }}</dd></div>
<div><dt>评测模型</dt><dd>{{ displayModelName }}</dd></div>
<div><dt>测试集</dt><dd>{{ detail?.dataset || '-' }}</dd></div>
<div><dt>评测指标</dt><dd>{{ displayMetric }}</dd></div>
</dl>
</div>
</div>
</template>
<div v-if="loadError" class="load-state is-error" role="alert">
<i class="fa fa-exclamation-circle" aria-hidden="true" />
<h2>无法加载评测详情</h2>
<p>{{ loadError }}</p>
<el-button type="primary" @click="() => loadDetail()">重新加载</el-button>
</div>
<template v-else-if="detail">
<section class="overview-grid" aria-label="评测概览">
<div class="overview-item score-hero">
<span>综合得分</span>
<strong>{{ overallScore }}</strong>
<small>模型综合评分</small>
</div>
<div class="overview-item">
<span>样本通过率</span>
<strong>{{ detail.completed_count ? `${passRate}%` : '-' }}</strong>
<small>通过 {{ detail.passed_count || 0 }} / 已评测 {{ detail.completed_count }}</small>
</div>
<div class="overview-item">
<span>评测进度</span>
<strong>{{ detail.completed_count }} / {{ detail.sample_count }}</strong>
<el-progress :percentage="completionRate" :show-text="false" :stroke-width="5" />
</div>
<div class="overview-item overview-time">
<span>{{ detail.completed_time ? '完成时间' : '创建时间' }}</span>
<strong>{{ formatDateTime(detail.completed_time || detail.create_time) }}</strong>
<small>{{ detail.completed_time ? '评测任务已完成' : `当前进度 ${completionRate}%` }}</small>
</div>
</section>
<section class="overall-review" aria-labelledby="overall-review-title">
<div class="review-copy">
<div class="section-heading">
<div>
<h2 id="overall-review-title">综合评价</h2>
<p>基于全部已评测样本生成的总体结论</p>
</div>
<el-tag v-if="detail.evaluator_model" type="primary" size="small">
评审模型{{ detail.evaluator_model }}
</el-tag>
</div>
<p class="review-text">
{{ detail.overall_evaluation || (detail.status === 'running' ? '评测正在进行,综合评价将在样本完成后生成。' : '暂无综合评价。') }}
</p>
<div class="suggestion-block">
<h3>改进建议</h3>
<ul v-if="detail.improvement_suggestions?.length" class="improvement-list">
<li v-for="suggestion in detail.improvement_suggestions" :key="suggestion">
{{ suggestion }}
</li>
</ul>
<p v-else class="empty-copy">暂无改进建议</p>
</div>
</div>
</section>
<section v-if="detail.dimension_summary?.length" class="dimension-summary" aria-labelledby="dimension-title">
<div class="section-heading compact-heading">
<div>
<h2 id="dimension-title">指标表现</h2>
<p>查看各评测指标的得分与通过率</p>
</div>
</div>
<div class="dimension-grid">
<div v-for="dimension in detail.dimension_summary" :key="dimension.name" class="dimension-item">
<div class="dimension-label">
<strong>{{ dimension.name }}</strong>
<span>{{ formatScore(dimension.score, dimension.max_score) }}</span>
</div>
<el-progress :percentage="dimension.pass_rate" :stroke-width="7" />
</div>
</div>
</section>
<section class="sample-section" aria-labelledby="sample-results-title">
<div class="sample-toolbar">
<div>
<h2 id="sample-results-title">测试集样本评测结果</h2>
<p> {{ filteredSamples.length }} 条结果展开行可查看评分依据与子维度分数</p>
</div>
<div class="sample-filters" aria-label="样本筛选">
<el-input v-model="keyword" clearable placeholder="搜索问题、回答或评价" aria-label="搜索样本" @input="resetPage">
<template #prefix><i class="fa fa-search" aria-hidden="true" /></template>
</el-input>
<el-select v-model="judgementFilter" clearable placeholder="全部判定" aria-label="按判定筛选" @change="resetPage">
<el-option label="正确" value="正确" />
<el-option label="部分正确" value="部分正确" />
<el-option label="错误" value="错误" />
</el-select>
</div>
</div>
<el-table v-if="filteredSamples.length" class="sample-results-table" :data="paginatedSamples" row-key="id" table-layout="fixed">
<el-table-column type="expand" width="48">
<template #default="{ row }">
<div class="sample-detail-grid">
<div class="evaluation-reason">
<span>评分依据</span>
<p>{{ row.evaluation_reason || '暂无评分依据。' }}</p>
</div>
<div v-if="row.error_type" class="error-type">
<span>错误类型</span>
<el-tag type="danger" size="small">{{ row.error_type }}</el-tag>
</div>
<div v-if="row.dimension_scores?.length" class="sample-dimensions">
<span>子维度评分</span>
<div class="dimension-score-list">
<span v-for="item in row.dimension_scores" :key="item.name">
{{ item.name }} <strong>{{ formatScore(item.score, item.max_score) }}</strong>
</span>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="#" prop="index" width="56" align="center" />
<el-table-column label="问题 / 输入" min-width="180">
<template #default="{ row }"><p class="cell-copy">{{ row.input }}</p></template>
</el-table-column>
<el-table-column label="标准答案" min-width="170">
<template #default="{ row }"><p class="cell-copy">{{ row.reference_answer || '-' }}</p></template>
</el-table-column>
<el-table-column label="模型回答" min-width="190">
<template #default="{ row }"><p class="cell-copy">{{ row.model_output || '等待生成' }}</p></template>
</el-table-column>
<el-table-column label="得分" width="90" align="center">
<template #default="{ row }"><span class="sample-score">{{ formatScore(row.score, row.max_score) }}</span></template>
</el-table-column>
<el-table-column label="判定" width="96" align="center">
<template #default="{ row }">
<el-tag :type="judgementTagType(row.judgement)" size="small">
{{ row.judgement || (row.status === 'pending' ? '待评测' : '-') }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div v-else class="load-state sample-empty">
<i class="fa fa-list-alt" aria-hidden="true" />
<h3>{{ detail.samples.length ? '没有符合筛选条件的样本' : '暂无样本评测结果' }}</h3>
<p>{{ detail.status === 'running' ? '任务正在运行,结果生成后会显示在这里。' : '请调整筛选条件或稍后重试。' }}</p>
</div>
<el-pagination v-if="filteredSamples.length > pageSize" v-model:current-page="currentPage" v-model:page-size="pageSize" background layout="total, sizes, prev, pager, next" :page-sizes="[10, 20, 50]" :total="filteredSamples.length" aria-label="样本结果分页" />
</section>
</template>
</PageCard>
</template>
<style scoped lang="scss">
.eval-detail-page {
min-width: 0;
}
.page-heading,
.heading-row,
.section-heading,
.sample-toolbar,
.sample-filters,
.dimension-label {
display: flex;
align-items: center;
}
.heading-copy {
width: 100%;
min-width: 0;
}
.heading-row {
gap: 10px;
flex-wrap: wrap;
}
.heading-row h1 {
margin: 0;
color: #303133;
font-size: 18px;
font-weight: 600;
line-height: 1.4;
}
.task-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
margin: 10px 0 0;
}
.task-meta > div {
display: flex;
gap: 6px;
min-width: 0;
}
.task-meta dt,
.task-meta dd {
margin: 0;
font-size: 13px;
}
.task-meta dt {
color: #909399;
}
.task-meta dd {
color: #606266;
font-weight: 500;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.overview-item {
min-width: 0;
min-height: 92px;
padding: 16px 20px;
border-right: 1px solid #e4e7ed;
}
.overview-item:last-child {
border-right: 0;
}
.overview-item > span,
.evaluation-reason > span,
.error-type > span,
.sample-dimensions > span {
display: block;
margin-bottom: 8px;
color: #606266;
font-size: 13px;
font-weight: 500;
}
.overview-item > strong {
display: block;
color: #303133;
font-size: 22px;
font-weight: 600;
line-height: 1.3;
font-variant-numeric: tabular-nums;
}
.score-hero > strong {
color: var(--primary-color);
}
.overview-item small {
display: block;
margin-top: 5px;
color: #909399;
font-size: 12px;
}
.overview-item :deep(.el-progress) {
margin-top: 8px;
}
.overview-time > strong {
font-size: 14px;
font-weight: 500;
line-height: 1.6;
}
.overall-review {
margin-top: 16px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
}
.review-copy {
padding: 18px 20px;
}
.section-heading,
.sample-toolbar {
justify-content: space-between;
gap: 16px;
}
.section-heading h2,
.sample-toolbar h2 {
margin: 0;
color: #303133;
font-size: 16px;
font-weight: 600;
}
.section-heading p,
.sample-toolbar p {
margin: 5px 0 0;
color: #909399;
font-size: 13px;
}
.review-text {
max-width: 1100px;
margin: 16px 0;
color: #606266;
font-size: 14px;
line-height: 1.75;
}
.suggestion-block {
padding: 14px 16px;
border: 1px solid #ebeef5;
border-radius: 6px;
background: #f8f9fb;
}
.suggestion-block h3 {
margin: 0 0 8px;
color: #303133;
font-size: 14px;
font-weight: 600;
}
.improvement-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 24px;
margin: 0;
padding-left: 18px;
color: #606266;
font-size: 13px;
line-height: 1.6;
}
.dimension-summary,
.sample-section {
margin-top: 16px;
padding: 18px 20px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #fff;
}
.compact-heading {
align-items: center;
}
.dimension-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0;
margin-top: 16px;
border: 1px solid #ebeef5;
border-radius: 6px;
overflow: hidden;
}
.dimension-item {
min-width: 0;
padding: 14px 16px;
border-right: 1px solid #ebeef5;
background: #fafafa;
}
.dimension-item:last-child {
border-right: 0;
}
.dimension-label {
justify-content: space-between;
margin-bottom: 8px;
gap: 8px;
color: #606266;
font-size: 13px;
}
.dimension-label strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dimension-label span {
flex: 0 0 auto;
color: #303133;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sample-toolbar {
align-items: center;
margin-bottom: 14px;
}
.sample-filters {
flex: 0 1 450px;
justify-content: flex-end;
gap: 10px;
}
.sample-filters :deep(.el-input) {
width: min(260px, 100%);
}
.sample-filters :deep(.el-select) {
width: 130px;
}
.sample-results-table {
width: 100%;
border: 1px solid #ebeef5;
border-radius: 6px;
}
.cell-copy {
display: -webkit-box;
overflow: hidden;
margin: 0;
color: #606266;
font-size: 13px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.sample-score {
color: #303133;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.sample-detail-grid {
display: grid;
grid-template-columns: minmax(140px, 0.35fr) minmax(320px, 1fr);
gap: 14px 24px;
padding: 16px 48px;
background: #f7f8fa;
}
.evaluation-reason {
grid-column: 1 / -1;
}
.evaluation-reason p {
margin: 0;
color: #606266;
font-size: 13px;
line-height: 1.7;
}
.dimension-score-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.dimension-score-list > span {
color: #606266;
font-size: 12px;
}
.dimension-score-list > span:not(:last-child)::after {
margin-left: 8px;
color: #c0c4cc;
content: '/';
}
.dimension-score-list strong {
margin-left: 4px;
color: #303133;
font-variant-numeric: tabular-nums;
}
.load-state {
min-height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px;
color: #909399;
text-align: center;
}
.load-state > i {
margin-bottom: 12px;
color: #c0c4cc;
font-size: 30px;
}
.load-state h2,
.load-state h3 {
margin: 0;
color: #303133;
}
.load-state p,
.empty-copy {
margin: 8px 0 16px;
color: #909399;
font-size: 13px;
}
.load-state.is-error > i {
color: #ef4444;
}
.sample-empty {
min-height: 210px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
background: #fafafa;
}
@media (max-width: 1100px) {
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-item:nth-child(2) {
border-right: 0;
}
.overview-item:nth-child(-n + 2) {
border-bottom: 1px solid #e4e7ed;
}
.dimension-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dimension-item:nth-child(2) {
border-right: 0;
}
.dimension-item:nth-child(-n + 2) {
border-bottom: 1px solid #ebeef5;
}
.sample-toolbar {
align-items: stretch;
flex-direction: column;
}
.sample-filters {
flex: 1;
justify-content: flex-start;
}
.sample-detail-grid {
padding: 16px 24px;
}
}
@media (max-width: 720px) {
.task-meta {
flex-direction: column;
gap: 5px;
}
.overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.overview-item {
padding: 14px;
}
.review-copy,
.dimension-summary,
.sample-section {
padding: 16px;
}
.dimension-grid,
.improvement-list,
.sample-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
.dimension-item,
.dimension-item:nth-child(2) {
border-right: 0;
border-bottom: 1px solid #ebeef5;
}
.dimension-item:last-child {
border-bottom: 0;
}
.sample-filters {
flex-direction: column;
align-items: stretch;
}
.sample-filters :deep(.el-input),
.sample-filters :deep(.el-select) {
width: 100%;
}
.evaluation-reason {
grid-column: auto;
}
}
</style>