ForcePilot/web/src/components/RAGEvaluationTab.vue
2026-06-05 00:35:31 +08:00

2156 lines
56 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.

<template>
<div class="rag-evaluation-container">
<!-- 评估结果区域 -->
<div class="evaluation-results">
<template v-if="!selectedDataset">
<ResourceEmptyState
class="rag-evaluation-empty"
title="暂无可用评估基准"
description="先上传或生成评估基准,再运行 RAG 评估。"
:icon="BarChart3"
>
<template #actions>
<a-button type="primary" class="lucide-icon-btn" @click="$emit('switch-to-benchmarks')">
<ClipboardList :size="16" />
前往基准管理
</a-button>
</template>
</ResourceEmptyState>
</template>
<template v-else>
<div class="last-evaluation-section">
<div class="section-header">
<h4 class="section-title">最后一次评估</h4>
<a-dropdown
v-model:open="evaluationDropdownOpen"
:trigger="['click']"
:disabled="availableDatasets.length === 0"
placement="bottomRight"
>
<a-button
type="primary"
:loading="startingEvaluation"
:disabled="availableDatasets.length === 0"
class="lucide-icon-btn"
>
开始评估
<ChevronDown :size="14" />
</a-button>
<template #overlay>
<div class="evaluation-start-dropdown" @click.stop>
<div class="dropdown-header">
<div class="dropdown-title">配置本次评估</div>
<div class="dropdown-subtitle">选择评估基准与可选模型后开始评估</div>
</div>
<div class="dropdown-model-fields">
<a-form-item label="评估名称">
<a-input
v-model:value="configForm.name"
placeholder="请输入评估名称"
:maxlength="100"
show-count
/>
</a-form-item>
<a-form-item label="评估基准">
<div class="dropdown-benchmark-row">
<a-select
v-model:value="selectedDatasetId"
placeholder="请选择评估基准"
style="width: 100%"
:loading="datasetsLoading"
@change="onDatasetChanged"
>
<a-select-option
v-for="benchmark in availableDatasets"
:key="benchmark.dataset_id"
:value="benchmark.dataset_id"
>
{{ benchmark.name }} ({{ benchmark.item_count }} 个问题)
</a-select-option>
</a-select>
<a-button
size="middle"
:loading="datasetsLoading"
:icon="h(RefreshCw, { size: 16 })"
class="refresh-benchmarks-btn lucide-icon-btn"
title="刷新评估基准列表"
@click="() => loadDatasets(true)"
/>
</div>
</a-form-item>
<a-form-item
:label="
selectedDataset?.has_gold_answers
? '答案生成模型(可选)'
: '答案生成模型(当前基准无需)'
"
>
<ModelSelectorComponent
v-model:model_spec="configForm.answer_llm"
size="small"
displayName="mini"
:disabled="!selectedDataset || !selectedDataset.has_gold_answers"
@select-model="(value) => (configForm.answer_llm = value)"
style="width: 100%"
/>
</a-form-item>
<a-form-item
:label="
selectedDataset?.has_gold_answers
? '答案评判模型(可选)'
: '答案评判模型(当前基准无需)'
"
>
<ModelSelectorComponent
v-model:model_spec="configForm.judge_llm"
size="small"
displayName="mini"
:disabled="!selectedDataset || !selectedDataset.has_gold_answers"
@select-model="(value) => (configForm.judge_llm = value)"
style="width: 100%"
/>
</a-form-item>
</div>
<div class="dropdown-hint">
{{ evaluationStartHint }}
</div>
<a-button
type="primary"
block
:loading="startingEvaluation"
:disabled="!selectedDataset"
@click="startEvaluation"
>
开始评估
</a-button>
</div>
</template>
</a-dropdown>
</div>
<div v-if="latestEvaluation" class="last-evaluation-card">
<div class="last-evaluation-main">
<div class="score-ring" :class="getLatestRingClass(latestEvaluation)">
{{ formatLatestRingValue(latestEvaluation) }}
</div>
<div class="last-evaluation-info">
<div class="last-evaluation-title">
{{ getRunName(latestEvaluation) }}
<a-tag :color="getStatusColor(latestEvaluation.status)" :bordered="false">
{{ getStatusText(latestEvaluation.status) }}
</a-tag>
</div>
<div class="last-evaluation-meta">
{{ getDatasetName(latestEvaluation.dataset_id) }} ·
{{ formatTime(latestEvaluation.started_at) }} ·
<button
v-if="latestEvaluation.status === 'completed'"
type="button"
class="run-id-link"
@click="viewResults(latestEvaluation.run_id)"
>
Run {{ latestEvaluation.run_id }}
</button>
<span v-else>Run {{ latestEvaluation.run_id }}</span>
</div>
<div v-if="latestEvaluation.status === 'running'" class="last-evaluation-progress">
<a-progress
:percent="getRunProgress(latestEvaluation)"
size="small"
status="active"
:show-info="false"
/>
<span class="run-progress-message">{{
getRunProgressMessage(latestEvaluation)
}}</span>
</div>
</div>
</div>
<div class="last-evaluation-metrics">
<div class="metric-card">
<span class="metric-label">Recall@10</span>
<strong :style="{ color: getMetricColor(getRecall10(latestEvaluation)) }">
{{ formatMetricValue(getRecall10(latestEvaluation)) }}
</strong>
</div>
<div class="metric-card">
<span class="metric-label">耗时</span>
<strong>{{ formatRunDuration(latestEvaluation) }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">数据量</span>
<strong>{{ formatRunItems(latestEvaluation) }}</strong>
</div>
<div class="metric-card">
<span class="metric-label">完成率</span>
<strong>{{ formatCompletionRate(latestEvaluation) }}</strong>
</div>
</div>
</div>
<div v-else class="last-evaluation-empty">
暂无评估记录,开始评估后会在这里展示最近一次结果。
</div>
</div>
<div class="history-section">
<div class="section-header">
<h4 class="section-title">历史评估记录</h4>
<a-button
type="text"
size="small"
:loading="refreshingHistory"
@click="refreshHistory"
:icon="h(RefreshCw, { size: 14 })"
class="refresh-btn lucide-icon-btn"
>
刷新
</a-button>
</div>
<a-table
class="history-table"
:columns="historyColumns"
:data-source="evaluationHistory"
:pagination="{ pageSize: 10, showSizeChanger: false }"
row-key="run_id"
size="small"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'actions'">
<a-space>
<a
v-if="record.status === 'completed'"
href=""
class="history-action-link"
@click.prevent="viewResults(record.run_id)"
>
查看
</a>
<a-popconfirm
title="确定要删除这条评估记录吗?"
description="删除后将无法恢复"
@confirm="deleteEvaluationRecord(record.run_id)"
ok-text="确定"
cancel-text="取消"
>
<a
href=""
class="history-action-link history-action-link-danger"
@click.prevent
>
删除
</a>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
</template>
</div>
</div>
<Teleport to="body">
<div v-if="resultModalVisible" class="evaluation-detail-overlay">
<div class="evaluation-detail-panel">
<div class="evaluation-detail-titlebar">
<div class="evaluation-detail-title">评估结果 - {{ getRunName(selectedResult) }}</div>
<a-button
type="text"
size="small"
class="lucide-icon-btn"
title="关闭"
@click="resultModalVisible = false"
>
<X :size="16" />
</a-button>
</div>
<div v-if="resultsLoading" class="loading-container">
<a-spin size="large" />
<p style="margin-top: 16px; color: var(--gray-600)">正在加载评估结果...</p>
</div>
<div v-else-if="selectedResult && detailedResults.length > 0" class="result-detail-content">
<div class="result-summary-bar">
<div class="summary-items">
<span class="summary-item summary-run-id" :title="selectedResult.run_id">
运行ID{{ selectedResult.run_id }}
</span>
<span class="summary-item">
状态:
<a-tag :color="getStatusColor(selectedResult.status)">
{{ getStatusText(selectedResult.status) }}
</a-tag>
</span>
<span class="summary-item">
总体评分:
<a-tag
v-if="selectedResult.overall_score != null"
:color="getScoreTagColor(selectedResult.overall_score)"
>
{{ (selectedResult.overall_score * 100).toFixed(1) }}%
</a-tag>
<span v-else>-</span>
</span>
<span class="summary-item">总问题数:{{ selectedResult.total_items }}</span>
<span class="summary-item">完成数:{{ selectedResult.completed_items }}</span>
<span class="summary-item">
总耗时:{{
evaluationStats.totalDuration
? formatDuration(evaluationStats.totalDuration)
: '-'
}}
</span>
</div>
<a-button
type="default"
size="small"
@click="toggleErrorOnly"
:class="{ 'error-only-active': showErrorsOnly }"
>
{{ showErrorsOnly ? '显示全部' : '仅查看错误' }}
</a-button>
</div>
<div class="result-metrics-bar">
<div class="metric-items">
<span class="result-count">
{{
showErrorsOnly
? `仅显示错误结果,共 ${paginationTotal} 条`
: `显示全部结果,共 ${paginationTotal} 条`
}}
</span>
<template v-if="Object.keys(evaluationStats.retrievalMetrics || {}).length > 0">
<span
v-for="(value, key) in evaluationStats.retrievalMetrics"
:key="key"
class="compact-metric"
>
{{ getMetricTitle(key) }}<strong :style="{ color: getScoreColor(value) }">
{{ formatMetricValue(value) }}
</strong>
</span>
</template>
<span v-if="evaluationStats.totalQuestions" class="compact-metric">
答案准确率:<strong
:style="{ color: getScoreColor(evaluationStats.answerAccuracy) }"
>
{{ (evaluationStats.answerAccuracy * 100).toFixed(1) }}%
</strong>
</span>
</div>
<a-switch
v-model:checked="resultAutoWrap"
checked-children="换行"
un-checked-children="不换行"
/>
</div>
<a-table
:columns="resultColumns"
:data-source="detailedResults"
:pagination="{
current: currentPage,
pageSize: pageSize,
total: paginationTotal,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
showQuickJumper: true,
showTotal: (total, range) => `第 ${range[0]}-${range[1]} 条,共 ${total} 条`,
onChange: handlePageChange,
onShowSizeChange: handlePageSizeChange
}"
:scroll="{ x: resultTableScrollX, y: 'calc(100dvh - 254px)' }"
:class="{ 'table-nowrap': !resultAutoWrap }"
size="small"
:loading="resultsLoading"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'query'">
<div class="query-text" :title="record.query">{{ record.query }}</div>
</template>
<template v-else-if="column.key === 'generated_answer'">
<div class="answer-text" :title="record.generated_answer">
{{ record.generated_answer || '-' }}
</div>
</template>
<template v-else-if="column.key === 'retrieval_score'">
<div
v-if="
record.metrics &&
Object.keys(record.metrics).some(
(k) =>
k.startsWith('recall') ||
k.startsWith('precision') ||
k === 'map' ||
k === 'ndcg'
)
"
class="retrieval-metrics"
>
<template v-for="(val, key) in record.metrics" :key="key">
<span
v-if="
key.startsWith('recall') ||
key.startsWith('precision') ||
key === 'map' ||
key === 'ndcg'
"
class="metric-content"
:class="`metric-${getMetricType(key)}`"
>
<span class="metric-name">{{ getMetricShortName(key) }}</span>
<span class="metric-value">{{ formatMetricValue(val) }}</span>
</span>
</template>
</div>
<span v-else class="no-metrics">-</span>
</template>
<template v-else-if="column.key === 'answer_score'">
<div
v-if="record.metrics && record.metrics.score !== undefined"
class="answer-judgement"
>
<a-tag :color="record.metrics.score > 0.5 ? 'green' : 'red'">
{{ record.metrics.score === 1.0 ? '正确' : '错误' }}
</a-tag>
<div
v-if="record.metrics.reasoning"
class="answer-reasoning"
:title="record.metrics.reasoning"
>
{{ record.metrics.reasoning }}
</div>
</div>
<span v-else>-</span>
</template>
</template>
</a-table>
</div>
<div v-else-if="selectedResult" class="empty-results">
<a-empty description="暂无详细结果数据">
<a-button @click="viewDetails(selectedResult)">查看基本信息</a-button>
</a-empty>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, watch, h } from 'vue'
import { message } from 'ant-design-vue'
import { evaluationApi } from '@/apis/knowledge_api'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue'
import { BarChart3, ChevronDown, ClipboardList, RefreshCw, X } from 'lucide-vue-next'
import ResourceEmptyState from '@/components/shared/ResourceEmptyState.vue'
import { useTaskerStore } from '@/stores/tasker'
const props = defineProps({
kbId: {
type: String,
required: true
}
})
defineEmits(['switch-to-benchmarks'])
// 使用任务中心 store
const taskerStore = useTaskerStore()
// 状态
const selectedDatasetId = ref(null)
const selectedDataset = ref(null)
const datasetsLoading = ref(false)
const availableDatasets = ref([])
const startingEvaluation = ref(false)
const evaluationDropdownOpen = ref(false)
const evaluationHistory = ref([])
const resultModalVisible = ref(false)
const selectedResult = ref(null)
const detailedResults = ref([])
const evaluationStats = ref({})
const resultsLoading = ref(false)
const refreshingHistory = ref(false)
const showErrorsOnly = ref(false)
const resultAutoWrap = ref(false)
const currentPage = ref(1)
const pageSize = ref(50)
const paginationTotal = ref(0)
const paginationTotalPages = ref(0)
let evaluationRefreshTimer = null
let stopColumnResize = null
const resultColumnWidths = reactive({
query: 360,
generated_answer: 420,
retrieval_score: 260,
answer_score: 320
})
const isDatasetCompleted = (dataset) =>
(dataset?.build_metadata?.status || 'completed') === 'completed'
const latestEvaluation = computed(() => evaluationHistory.value[0] || null)
const createDefaultNameHash = () => {
const cryptoApi = globalThis.crypto
if (cryptoApi?.getRandomValues) {
const values = new Uint32Array(1)
cryptoApi.getRandomValues(values)
return values[0].toString(16).padStart(6, '0').slice(0, 6)
}
return Math.floor(Math.random() * 0xffffff)
.toString(16)
.padStart(6, '0')
}
const buildDefaultEvaluationName = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `eval-${year}${month}${day}-${createDefaultNameHash()}`
}
// 评估配置表单(使用知识库默认配置)
const configForm = reactive({
name: buildDefaultEvaluationName(),
answer_llm: '', // 答案生成模型
judge_llm: '' // 评判模型
})
const evaluationStartHint = computed(() => {
if (!selectedDataset.value?.has_gold_answers) return '当前基准只会执行检索评估,无需选择模型。'
if (!configForm.answer_llm && !configForm.judge_llm) return '不选择模型时,将仅执行检索评估。'
if (configForm.answer_llm && configForm.judge_llm) return '将执行检索评估与答案评估。'
return '答案生成模型和答案评判模型需要同时选择。'
})
// 表格列定义
const resultColumns = computed(() => {
const columns = [
{
title: '问题',
dataIndex: 'query',
key: 'query',
width: resultColumnWidths.query
},
{
title: '生成答案',
key: 'generated_answer',
width: resultColumnWidths.generated_answer
},
{
title: '答案评判',
key: 'answer_score',
width: resultColumnWidths.answer_score
}
]
// 检查是否有检索指标数据
const hasRetrievalMetrics = detailedResults.value.some((item) => {
if (!item.metrics) return false
return Object.keys(item.metrics).some(
(key) =>
key.startsWith('recall') || key.startsWith('precision') || key === 'map' || key === 'ndcg'
)
})
// 如果有检索指标数据,添加检索指标列
if (hasRetrievalMetrics) {
columns.splice(2, 0, {
title: '检索指标',
key: 'retrieval_score',
width: resultColumnWidths.retrieval_score
})
}
return columns.map(withResizableTitle)
})
const resultTableScrollX = computed(() =>
resultColumns.value.reduce((total, column) => total + Number(column.width || 0), 0)
)
const startColumnResize = (event, key, minWidth = 120) => {
stopColumnResize?.()
const startX = event.clientX
const startWidth = resultColumnWidths[key]
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
const onMouseMove = (moveEvent) => {
resultColumnWidths[key] = Math.max(minWidth, startWidth + moveEvent.clientX - startX)
}
const onMouseUp = () => {
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
stopColumnResize = null
}
stopColumnResize = onMouseUp
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
}
const withResizableTitle = (column) => ({
...column,
customHeaderCell: () => ({
class: 'resizable-table-header'
}),
title: h('div', { class: 'resizable-table-title' }, [
h('span', { class: 'resizable-table-title-text' }, column.title),
h('span', {
class: 'column-resize-handle',
onMousedown: (event) => {
event.preventDefault()
event.stopPropagation()
startColumnResize(event, column.key)
}
})
])
})
const historyColumns = [
{
title: '评估名称',
dataIndex: 'name',
key: 'name',
width: 180,
ellipsis: true,
customRender: ({ record }) => getRunName(record)
},
{
title: '评估基准',
key: 'dataset_name',
width: 180,
ellipsis: true,
customRender: ({ record }) => getDatasetName(record.dataset_id)
},
{
title: '数据量',
key: 'items',
width: 92,
customRender: ({ record }) => formatRunItems(record)
},
{
title: '耗时',
key: 'duration',
width: 100,
customRender: ({ record }) => formatRunDuration(record)
},
{
title: 'Recall@10',
key: 'recall_10',
width: 100,
customRender: ({ record }) => renderMetricTag(getRecall10(record), record.status)
},
{
title: '综合评分',
key: 'overall_score',
width: 100,
customRender: ({ record }) => renderMetricTag(record.overall_score, record.status, 0)
},
{
title: '状态',
key: 'status',
width: 86,
customRender: ({ record }) =>
h('a-tag', { color: getStatusColor(record.status) }, getStatusText(record.status))
},
{
title: '操作',
key: 'actions',
width: 120
}
]
// 切换错误显示模式
const toggleErrorOnly = async () => {
resultsLoading.value = true
showErrorsOnly.value = !showErrorsOnly.value
currentPage.value = 1 // 切换模式时重置到第一页
// 立即加载新的分页数据
await loadResultsWithPagination()
}
// 处理分页变化
const handlePageChange = (page, size) => {
currentPage.value = page
if (size !== pageSize.value) {
pageSize.value = size
}
loadResultsWithPagination()
}
// 处理页面大小变化
const handlePageSizeChange = (current, size) => {
currentPage.value = 1
pageSize.value = size
loadResultsWithPagination()
}
// 加载分页结果
const loadResultsWithPagination = async () => {
if (!selectedResult.value) return
try {
resultsLoading.value = true
const response = await evaluationApi.getRunResults(props.kbId, selectedResult.value.run_id, {
page: currentPage.value,
pageSize: pageSize.value,
errorOnly: showErrorsOnly.value
})
if (response.message === 'success' && response.data) {
const resultData = response.data
// 更新详细结果
detailedResults.value = resultData.items || []
// 更新分页信息
paginationTotal.value = resultData.pagination.total
paginationTotalPages.value = resultData.pagination.total_pages
// 更新统计信息
// 如果是过滤模式,需要基于过滤后的总数计算统计
if (showErrorsOnly.value) {
// 在过滤模式下,只计算当前页的统计(避免重复计算)
evaluationStats.value = {
...evaluationStats.value,
totalQuestions: paginationTotal.value
// 可以在这里添加其他基于过滤后数据的统计
}
} else if (currentPage.value === 1) {
// 非过滤模式且是第一页时,才计算完整统计
evaluationStats.value = calculateEvaluationStats(detailedResults.value)
}
// 更新其他基本信息(保持原有的信息不变)
if (resultData.started_at && resultData.completed_at) {
const startTime = new Date(resultData.started_at)
const endTime = new Date(resultData.completed_at)
evaluationStats.value.totalDuration = (endTime - startTime) / 1000
}
}
} catch (error) {
console.error('加载评估结果失败:', error)
message.error('加载评估结果失败')
} finally {
resultsLoading.value = false
}
}
// 加载基准列表
const loadDatasets = async (showSuccessMessage = false) => {
if (!props.kbId) return
datasetsLoading.value = true
try {
const response = await evaluationApi.listDatasets(props.kbId)
if (response && response.message === 'success' && Array.isArray(response.data)) {
const completedDatasets = response.data.filter(isDatasetCompleted)
availableDatasets.value = completedDatasets
// 如果没有选中的基准,且有可用基准,默认选中第一个
if (!selectedDatasetId.value && completedDatasets.length > 0) {
selectedDatasetId.value = completedDatasets[0].dataset_id
selectedDataset.value = completedDatasets[0]
} else if (selectedDatasetId.value) {
// 如果之前有选中的基准,重新验证其有效性
const exists = completedDatasets.some((b) => b.dataset_id === selectedDatasetId.value)
if (!exists) {
selectedDatasetId.value = null
selectedDataset.value = null
} else {
// 更新选中的基准对象
selectedDataset.value = completedDatasets.find(
(b) => b.dataset_id === selectedDatasetId.value
)
}
}
// 如果是手动刷新,显示成功提示
if (showSuccessMessage) {
message.success(`已刷新,找到 ${completedDatasets.length} 个可评估基准`)
}
} else {
console.error('响应格式不符合预期:', response)
message.error('基准数据格式错误')
}
} catch (error) {
console.error('加载评估基准失败:', error)
message.error('加载评估基准失败')
} finally {
datasetsLoading.value = false
}
}
// 下拉框选择变化
const onDatasetChanged = (datasetId) => {
const benchmark = availableDatasets.value.find((b) => b.dataset_id === datasetId)
selectedDataset.value = benchmark || null
if (!selectedDataset.value?.has_gold_answers) {
configForm.answer_llm = ''
configForm.judge_llm = ''
}
}
const hasRunningEvaluation = () =>
evaluationHistory.value.some((record) => record.status === 'running')
const stopEvaluationRefresh = () => {
if (evaluationRefreshTimer) {
window.clearInterval(evaluationRefreshTimer)
evaluationRefreshTimer = null
}
}
const syncEvaluationRefresh = () => {
if (!hasRunningEvaluation()) {
stopEvaluationRefresh()
return
}
if (!evaluationRefreshTimer) {
evaluationRefreshTimer = window.setInterval(() => loadEvaluationHistory(true), 3000)
}
}
// 刷新历史评估记录
const refreshHistory = async () => {
refreshingHistory.value = true
try {
await loadEvaluationHistory()
message.success('历史记录已刷新')
} catch (error) {
console.error('刷新历史记录失败:', error)
message.error('刷新历史记录失败')
} finally {
refreshingHistory.value = false
}
}
// 开始评估
const startEvaluation = async () => {
if (!selectedDataset.value) {
message.error('请先选择评估基准')
return
}
const answerModel = selectedDataset.value.has_gold_answers ? configForm.answer_llm : ''
const judgeModel = selectedDataset.value.has_gold_answers ? configForm.judge_llm : ''
const hasAnswerModel = !!answerModel
const hasJudgeModel = !!judgeModel
const runName = configForm.name.trim()
if (hasAnswerModel !== hasJudgeModel) {
message.warning('生成模型和评估模型必须同时选择或者同时不选择')
return
}
if (!runName) {
message.warning('请输入评估名称')
return
}
startingEvaluation.value = true
const params = {
dataset_id: selectedDataset.value.dataset_id,
name: runName,
model_config: {
answer_llm: answerModel,
judge_llm: judgeModel
}
}
try {
const response = await evaluationApi.runEvaluation(props.kbId, params)
if (response.message === 'success') {
message.success('评估任务已开始')
evaluationDropdownOpen.value = false
configForm.name = buildDefaultEvaluationName()
loadEvaluationHistory()
taskerStore.loadTasks()
} else {
message.error(response.message || '启动评估失败')
}
} catch (error) {
console.error('启动评估失败:', error)
message.error('启动评估失败')
} finally {
startingEvaluation.value = false
}
}
// 加载评估历史
const loadEvaluationHistory = async (silent = false) => {
try {
const response = await evaluationApi.listRuns(props.kbId)
if (response.message === 'success') {
evaluationHistory.value = response.data || []
}
} catch (error) {
console.error('加载评估历史失败:', error)
if (!silent) message.error('加载评估历史失败')
} finally {
syncEvaluationRefresh()
}
}
// 计算评估统计信息
const calculateEvaluationStats = (results) => {
if (!results || results.length === 0) {
return {}
}
const stats = {
totalQuestions: results.length,
retrievalMetrics: {},
answerAccuracy: 0,
correctAnswers: 0,
averageResponseTime: 0,
totalResponseTime: 0
}
const metricSums = {}
const metricCounts = {}
results.forEach((item) => {
// 答案准确率
if (item.metrics && item.metrics.score !== undefined) {
if (item.metrics.score > 0.5) {
stats.correctAnswers++
}
}
// 检索指标统计
if (item.metrics) {
Object.keys(item.metrics).forEach((key) => {
if (
key.startsWith('recall') ||
key.startsWith('precision') ||
key === 'map' ||
key === 'ndcg'
) {
if (!metricSums[key]) {
metricSums[key] = 0
metricCounts[key] = 0
}
metricSums[key] += item.metrics[key]
metricCounts[key]++
}
})
}
})
// 计算平均值
Object.keys(metricSums).forEach((key) => {
stats.retrievalMetrics[key] = metricSums[key] / metricCounts[key]
})
// 计算答案准确率
stats.answerAccuracy = stats.totalQuestions > 0 ? stats.correctAnswers / stats.totalQuestions : 0
return stats
}
// 查看结果
const viewResults = async (runId) => {
try {
resultsLoading.value = true
// 重置分页状态
currentPage.value = 1
showErrorsOnly.value = false
// 先获取基本信息(不分页)
const response = await evaluationApi.getRunResults(props.kbId, runId)
if (response.message === 'success' && response.data) {
const resultData = response.data
// 从历史记录中找到对应的任务信息如果没有则使用API返回的数据
selectedResult.value = evaluationHistory.value.find((r) => r.run_id === runId) || {
run_id: resultData.run_id,
name: resultData.name,
status: resultData.status,
started_at: resultData.started_at,
completed_at: resultData.completed_at,
total_items: resultData.total_items || 0,
completed_items: resultData.completed_items || 0,
overall_score: resultData.overall_score,
retrieval_config: resultData.retrieval_config
}
// 如果是从历史记录获取的,确保也有 retrieval_config
if (selectedResult.value && !selectedResult.value.retrieval_config) {
selectedResult.value.retrieval_config = resultData.retrieval_config
}
// 打开模态框
resultModalVisible.value = true
// 加载分页数据
await loadResultsWithPagination()
} else {
message.error('获取评估结果失败:数据格式错误')
}
} catch (error) {
console.error('获取评估结果失败:', error)
message.error('获取评估结果失败')
} finally {
resultsLoading.value = false
}
}
// 删除评估记录
const deleteEvaluationRecord = async (runId) => {
try {
// 找到对应的记录并设置loading状态
const record = evaluationHistory.value.find((r) => r.run_id === runId)
if (record) {
record.deleting = true
}
const response = await evaluationApi.deleteRun(props.kbId, runId)
if (response.message === 'success') {
message.success('删除成功')
// 重新加载评估历史
await loadEvaluationHistory()
}
} catch (error) {
console.error('删除评估记录失败:', error)
message.error('删除评估记录失败')
} finally {
// 清除loading状态
const record = evaluationHistory.value.find((r) => r.run_id === runId)
if (record) {
record.deleting = false
}
}
}
const formatTime = (timeStr) => {
if (!timeStr) return '-'
const date = new Date(timeStr)
return date.toLocaleString('zh-CN')
}
const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value)
const getDatasetName = (datasetId) => {
const benchmark = availableDatasets.value.find((b) => b.dataset_id === datasetId)
return benchmark ? benchmark.name : datasetId?.slice(0, 8) || '-'
}
const getRunName = (record) =>
record?.name || record?.run_name || record?.run_id?.slice(0, 8) || '-'
const getRecall10 = (record) => record?.metrics?.['recall@10']
const formatPercent = (value, digits = 1) => {
if (!isFiniteNumber(value)) return '-'
return `${(value * 100).toFixed(digits)}%`
}
const formatRunItems = (record) => `${record?.completed_items || 0}/${record?.total_items || 0}`
const formatCompletionRate = (record) => {
const total = Number(record?.total_items || 0)
if (!total) return '-'
return formatPercent(Number(record?.completed_items || 0) / total, 0)
}
const formatRunDuration = (record) => {
if (record?.status === 'running') return '进行中'
if (!record?.started_at || !record?.completed_at) return '-'
const duration = (new Date(record.completed_at) - new Date(record.started_at)) / 1000
return Number.isFinite(duration) && duration >= 0 ? formatDuration(duration) : '-'
}
const getRunProgress = (record) => {
if (isFiniteNumber(record?.progress))
return Math.max(0, Math.min(Math.round(record.progress), 100))
const total = Number(record?.total_items || 0)
if (!total) return 0
const completed = Number(record?.completed_items || 0)
return Math.max(0, Math.min(Math.round((completed / total) * 100), 100))
}
const getRunProgressMessage = (record) => {
if (record?.message) return record.message
const total = Number(record?.total_items || 0)
if (total) return `评估 ${Number(record?.completed_items || 0)}/${total}`
return '评估进行中'
}
const formatLatestRingValue = (record) =>
record?.status === 'running'
? `${getRunProgress(record)}%`
: formatPercent(record?.overall_score, 0)
const getLatestRingClass = (record) =>
record?.status === 'running' ? 'score-running' : getScoreLevelClass(record?.overall_score)
const getMetricColor = (value) => (isFiniteNumber(value) ? getScoreColor(value) : undefined)
const renderMetricTag = (value, status, digits = 3) => {
if (isFiniteNumber(value)) {
return h(
'a-tag',
{ color: getScoreTagColor(value) },
digits === 0 ? formatPercent(value, 0) : formatMetricValue(value)
)
}
if (status === 'running') return h('a-tag', { color: 'processing' }, '计算中')
if (status === 'completed') return h('a-tag', { color: 'default' }, '无数据')
return h('span', '-')
}
const getScoreLevelClass = (score) => {
if (!isFiniteNumber(score)) return 'score-empty'
if (score >= 0.8) return 'score-high'
if (score >= 0.6) return 'score-medium'
return 'score-low'
}
const getScoreColor = (score) => {
if (score >= 0.8) return 'var(--color-success-500)'
if (score >= 0.6) return 'var(--color-warning-500)'
return 'var(--color-error-500)'
}
const getScoreTagColor = (score) => {
if (score >= 0.8) return 'success'
if (score >= 0.6) return 'warning'
return 'error'
}
const getStatusColor = (status) => {
const colors = {
running: 'blue',
completed: 'green',
failed: 'red',
paused: 'orange'
}
return colors[status] || 'default'
}
const getStatusText = (status) => {
const texts = {
running: '运行中',
completed: '已完成',
failed: '失败',
paused: '已暂停'
}
return texts[status] || status
}
const getMetricTitle = (key) => {
const titles = {
precision: '精确率',
recall: '召回率',
map: '平均精度',
ndcg: 'NDCG',
bleu: 'BLEU分数',
rouge: 'ROUGE分数',
answer_correctness: '答案准确性',
score: '评分',
reasoning: '理由',
overall_score: '综合评分'
}
// 处理 recall@k
if (key.startsWith('recall@')) return `召回率 (${key.split('@')[1]})`
if (key.startsWith('precision@')) return `精确率 (${key.split('@')[1]})`
return titles[key] || key
}
// 获取指标类型
const getMetricType = (key) => {
if (key.startsWith('recall')) return 'recall'
if (key.startsWith('precision')) return 'precision'
if (key === 'map') return 'map'
if (key === 'ndcg') return 'ndcg'
return 'default'
}
// 获取指标短名称
const getMetricShortName = (key) => {
if (key.startsWith('recall@')) return `R@${key.split('@')[1]}`
if (key.startsWith('precision@')) return `P@${key.split('@')[1]}`
if (key === 'precision') return 'Precision'
if (key === 'recall') return 'Recall'
if (key === 'map') return 'MAP'
if (key === 'ndcg') return 'NDCG'
return key
}
// 格式化指标值
const formatMetricValue = (val) => {
if (typeof val !== 'number') return '-'
return val.toFixed(3)
}
// 格式化持续时间
const formatDuration = (seconds) => {
if (seconds < 60) {
return `${Math.round(seconds)}`
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.round(seconds % 60)
return `${minutes}${remainingSeconds}`
} else {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return `${hours}小时${minutes}`
}
}
watch(evaluationHistory, syncEvaluationRefresh, { deep: true })
watch(evaluationDropdownOpen, (open) => {
if (open) {
configForm.name = buildDefaultEvaluationName()
}
})
// 组件挂载时加载数据
onMounted(() => {
loadDatasets()
loadEvaluationHistory()
})
onUnmounted(() => {
stopEvaluationRefresh()
stopColumnResize?.()
})
</script>
<style lang="less" scoped>
.rag-evaluation-container {
height: 100%;
display: flex;
flex-direction: column;
}
:global(.evaluation-start-dropdown) {
width: 420px;
padding: 14px;
border: 1px solid var(--gray-200);
border-radius: 10px;
background: var(--color-bg-container);
box-shadow: 0 10px 30px var(--shadow-3);
}
:global(.evaluation-start-dropdown .dropdown-header) {
padding-bottom: 10px;
margin-bottom: 12px;
border-bottom: 1px solid var(--gray-150);
}
:global(.evaluation-start-dropdown .dropdown-title) {
margin-bottom: 4px;
font-size: 14px;
font-weight: 600;
color: var(--gray-1000);
}
:global(.evaluation-start-dropdown .dropdown-subtitle) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--color-text-secondary);
}
:global(.evaluation-start-dropdown .dropdown-model-fields) {
display: flex;
flex-direction: column;
gap: 12px;
}
:global(.evaluation-start-dropdown .dropdown-benchmark-row) {
display: flex;
align-items: center;
gap: 8px;
}
:global(.evaluation-start-dropdown .refresh-benchmarks-btn) {
flex: 0 0 32px;
color: var(--gray-600);
}
:global(.evaluation-start-dropdown .ant-form-item) {
margin-bottom: 0;
}
:global(.evaluation-start-dropdown .ant-form-item-label) {
padding-bottom: 4px;
font-weight: 500;
}
:global(.evaluation-start-dropdown .dropdown-hint) {
margin: 12px 0;
padding: 8px 10px;
border-radius: 6px;
background: var(--color-info-50);
color: var(--color-info-700);
font-size: 12px;
line-height: 1.5;
}
// 评估内容区域
.evaluation-content {
flex: 1;
overflow: hidden;
min-height: 0;
padding: 10px 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
// 评估结果区域
.evaluation-results {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
overflow: auto;
margin-top: 0;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(0, 0, 0, 0.2);
border-radius: 3px;
&:hover {
background-color: rgba(0, 0, 0, 0.3);
}
}
}
.rag-evaluation-empty {
flex: 1;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: flex-start;
.progress-stats {
flex: 1;
margin-right: 24px;
min-width: 300px;
.ant-statistic {
margin-bottom: 12px;
.ant-statistic-title {
font-size: 13px;
color: var(--gray-600);
}
.ant-statistic-content {
font-size: 18px;
font-weight: 500;
}
}
}
.progress-actions {
flex-shrink: 0;
padding-top: 24px;
}
}
.query-text {
font-size: 12px;
line-height: 1.5;
word-wrap: break-word;
white-space: normal;
overflow: visible;
}
.answer-text {
font-size: 12px;
line-height: 1.5;
word-wrap: break-word;
white-space: normal;
overflow: visible;
color: var(--gray-700);
}
.log-time {
color: var(--gray-500);
margin-left: 8px;
font-size: 12px;
}
// 优化表格样式
:deep(.ant-table) {
overflow: auto;
.ant-table-tbody > tr > td {
padding: 12px 12px;
vertical-align: top;
}
.ant-table-thead > tr > th {
padding: 8px 12px;
font-weight: 500;
background-color: var(--gray-50);
}
}
// 优化卡片间距
:deep(.ant-card) {
.ant-card-head {
padding: 8px 16px;
min-height: 40px;
.ant-card-head-title {
font-size: 14px;
font-weight: 500;
padding: 4px 0;
}
}
}
// 优化时间线样式
:deep(.ant-timeline) {
.ant-timeline-item-content {
margin-left: 20px;
padding-bottom: 12px;
}
}
// 优化描述列表样式
:deep(.ant-descriptions) {
.ant-descriptions-item-label {
font-size: 13px;
font-weight: 500;
color: var(--gray-600);
}
.ant-descriptions-item-content {
font-size: 13px;
}
}
// 优化表单项间距
:deep(.ant-form) {
.ant-form-item {
margin-bottom: 16px;
}
}
:deep(.ant-form-inline) {
.ant-form-item {
margin-right: 24px;
margin-bottom: 16px;
&:last-child {
margin-right: 0;
}
}
}
// 优化统计数字样式
:deep(.ant-row) {
.ant-col {
.ant-statistic {
padding: 12px;
border: 1px solid var(--gray-200);
border-radius: 6px;
text-align: center;
transition: all 0.3s;
&:hover {
border-color: var(--gray-300);
box-shadow: 0 2px 4px var(--shadow-1);
}
}
}
}
// 检索指标样式
.retrieval-metrics {
display: inline-flex;
flex-wrap: wrap;
gap: 4px 10px;
align-items: center;
max-width: 100%;
}
.metric-content {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 12px;
line-height: 1.45;
font-weight: 500;
white-space: nowrap;
color: var(--gray-650);
&.metric-recall {
.metric-value {
color: var(--color-info-700);
}
}
&.metric-precision {
.metric-value {
color: var(--color-success-700);
}
}
&.metric-map,
&.metric-ndcg {
.metric-value {
color: var(--color-accent-700);
}
}
}
.metric-name {
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0;
color: var(--gray-550);
}
.metric-value {
font-weight: 700;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
color: var(--gray-900);
font-variant-numeric: tabular-nums;
}
.no-metrics {
color: var(--gray-400);
font-style: italic;
}
.answer-judgement {
display: flex;
align-items: flex-start;
gap: 6px;
:deep(.ant-tag) {
flex-shrink: 0;
margin-inline-end: 0;
}
}
// 答案推理样式
.answer-reasoning {
font-size: 12px;
color: var(--gray-600);
line-height: 1.4;
cursor: pointer;
word-wrap: break-word;
white-space: normal;
overflow: visible;
&:hover {
color: var(--gray-800);
}
}
// 加载和空状态样式
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
}
.empty-results {
padding: 40px 0;
text-align: center;
}
:global(.evaluation-detail-overlay) {
position: fixed;
inset: 0;
z-index: 1000;
width: 100vw;
height: 100dvh;
padding: 12px;
box-sizing: border-box;
background: var(--dark-25);
overflow: hidden;
}
:global(.evaluation-detail-panel) {
width: 100%;
height: calc(100dvh - 24px);
max-height: calc(100dvh - 24px);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 12px;
background: var(--color-bg-container);
box-shadow: 0 12px 32px var(--shadow-4);
}
:global(.evaluation-detail-titlebar) {
height: 44px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 14px 0 16px;
border-bottom: 1px solid var(--gray-150);
}
:global(.evaluation-detail-title) {
min-width: 0;
font-size: 14px;
font-weight: 600;
color: var(--gray-1000);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-detail-content {
flex: 1;
height: auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px 16px;
:deep(.ant-table-wrapper) {
flex: 1;
min-height: 0;
}
:deep(.ant-table) {
.ant-table-thead > tr > th {
position: relative;
padding: 6px 10px;
font-size: 12px;
}
.ant-table-tbody > tr > td {
padding: 6px 10px;
font-size: 12px;
}
.ant-table-pagination {
margin: 10px 0 0;
}
}
}
.result-summary-bar {
flex-shrink: 0;
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding-bottom: 8px;
border-bottom: 1px solid var(--gray-150);
}
.summary-items {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
align-items: center;
font-size: 12px;
}
.summary-item {
color: var(--gray-700);
white-space: nowrap;
}
.summary-run-id {
max-width: 360px;
overflow: hidden;
text-overflow: ellipsis;
}
.result-metrics-bar {
flex-shrink: 0;
display: flex;
justify-content: space-between;
gap: 8px 14px;
align-items: center;
padding: 8px 0;
font-size: 12px;
color: var(--gray-700);
}
.metric-items {
min-width: 0;
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
align-items: center;
}
.result-count {
color: var(--color-text-secondary);
}
.compact-metric {
white-space: nowrap;
}
:deep(.table-nowrap) {
.ant-table-cell {
white-space: nowrap;
overflow: hidden;
}
.answer-judgement {
width: 100%;
max-width: 100%;
align-items: center;
}
.query-text,
.answer-text,
.answer-reasoning {
display: block;
width: 100%;
max-width: 100%;
white-space: nowrap;
word-wrap: normal;
overflow: hidden;
text-overflow: clip;
}
}
:deep(.resizable-table-header) {
position: relative;
.ant-table-column-title {
display: block;
width: 100%;
}
}
:deep(.resizable-table-title) {
position: relative;
display: flex;
align-items: center;
min-height: 20px;
padding-right: 14px;
}
:deep(.resizable-table-title-text) {
overflow: hidden;
text-overflow: ellipsis;
}
:deep(.column-resize-handle) {
position: absolute;
top: -6px;
right: -16px;
bottom: -6px;
width: 14px;
cursor: col-resize;
z-index: 2;
&::after {
content: '';
position: absolute;
top: 7px;
bottom: 7px;
right: 2px;
width: 1px;
background: transparent;
}
&:hover::after {
background: var(--color-primary-400);
}
}
// 评估报告样式
.evaluation-report {
margin-bottom: 20px;
.report-metric {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--gray-100);
&:last-child {
border-bottom: none;
}
.metric-label {
font-size: 14px;
padding-right: 18px;
color: var(--gray-700);
}
.metric-value {
font-size: 14px;
font-weight: 600;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
}
.accuracy-stats {
.accuracy-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid var(--gray-100);
&:last-child {
border-bottom: none;
}
.accuracy-label {
font-size: 14px;
color: var(--gray-700);
}
.accuracy-value {
font-size: 16px;
font-weight: 600;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
}
}
:deep(.ant-card) {
.ant-card-head {
border-bottom: 1px solid var(--gray-200);
.ant-card-head-title {
font-size: 14px;
font-weight: 500;
}
}
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.section-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--gray-800);
}
}
.last-evaluation-section {
padding: 14px 16px;
background: var(--gray-0);
border: 1px solid var(--gray-200);
border-radius: 10px;
}
.last-evaluation-card {
display: grid;
grid-template-columns: minmax(260px, 1.1fr) minmax(360px, 1.8fr);
gap: 16px;
align-items: stretch;
}
.last-evaluation-main {
display: flex;
gap: 14px;
align-items: center;
min-width: 0;
}
.score-ring {
width: 64px;
height: 64px;
flex: 0 0 64px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
background: var(--gray-50);
color: var(--gray-700);
border: 6px solid var(--gray-200);
&.score-high {
color: var(--color-success-700);
border-color: var(--color-success-100);
background: var(--color-success-50);
}
&.score-medium {
color: var(--color-warning-700);
border-color: var(--color-warning-100);
background: var(--color-warning-50);
}
&.score-low {
color: var(--color-error-700);
border-color: var(--color-error-100);
background: var(--color-error-50);
}
&.score-running {
color: var(--color-primary-700);
border-color: var(--color-primary-100);
background: var(--color-primary-50);
}
}
.last-evaluation-info {
min-width: 0;
}
.last-evaluation-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
margin-bottom: 6px;
font-size: 15px;
font-weight: 600;
color: var(--gray-1000);
}
.last-evaluation-meta {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--color-text-secondary);
}
.last-evaluation-progress {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
:deep(.ant-progress) {
width: 160px;
line-height: 1;
}
}
.run-progress-message {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: var(--color-text-secondary);
}
.run-id-link {
padding: 0;
border: 0;
background: transparent;
color: var(--color-primary-700);
cursor: pointer;
font: inherit;
&:hover {
color: var(--color-primary-900);
text-decoration: underline;
}
}
.last-evaluation-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.metric-card {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--gray-150);
border-radius: 8px;
background: var(--gray-10);
.metric-label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: var(--color-text-secondary);
}
strong {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 16px;
color: var(--gray-1000);
font-variant-numeric: tabular-nums;
}
}
.last-evaluation-empty {
padding: 18px;
border: 1px dashed var(--gray-200);
border-radius: 8px;
background: var(--gray-10);
color: var(--color-text-secondary);
text-align: center;
font-size: 13px;
}
@media (max-width: 960px) {
.last-evaluation-card {
grid-template-columns: 1fr;
}
.last-evaluation-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
// 历史评估记录区域
.history-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.section-title {
margin: 0;
font-size: 14px;
font-weight: 500;
color: var(--gray-700);
}
.refresh-btn {
color: var(--gray-600);
border: none;
box-shadow: none;
padding: 4px 8px;
height: auto;
font-size: 13px;
&:hover {
color: var(--color-primary-600);
background-color: var(--color-primary-50);
}
&:active {
color: var(--color-primary-700);
background-color: var(--color-primary-100);
}
.anticon {
font-size: 14px;
}
}
}
:deep(.ant-table) {
border: 1px solid var(--gray-100);
}
.history-action-link {
color: var(--color-primary-500);
font-size: 13px;
text-decoration: none;
cursor: pointer;
&:hover {
color: var(--color-primary-700);
}
}
.history-action-link-danger {
color: var(--color-error-700);
&:hover {
color: var(--color-error-900);
}
}
}
// JSON 查看器样式
.json-viewer-container {
max-height: 400px;
overflow: auto;
.json-viewer {
margin: 0;
padding: 0;
font-family: 'SF Mono', 'Monaco', 'Consolas', 'Menlo', monospace;
font-size: 13px;
line-height: 1.5;
color: var(--gray-800);
white-space: pre-wrap;
word-wrap: break-word;
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: 6px;
padding: 12px;
}
}
// 仅查看错误按钮样式
.error-only-active {
background-color: var(--color-error-500) !important;
border-color: var(--color-error-500) !important;
color: white !important;
&:hover {
background-color: var(--color-error-600) !important;
border-color: var(--color-error-600) !important;
}
&:focus {
background-color: var(--color-error-500) !important;
border-color: var(--color-error-500) !important;
}
}
</style>