Files
projectAIpopular/webapp/src/views/MetricsView.vue
T
tzt 501058243b feat(v3): T19-T20 模型池设置 UI + 智能体页 + 指标分账卡
- 设置页「模型池」区块:三角色指派下拉、条目表格(档位徽标/单价/启用/测试/编辑/删除)、
  内联添加表单(模型列表探测 datalist,api_key 留空保留原值)
- 新增「🤖 智能体」页 /agent:任务输入 + 池模型选择、工具调用时间轴
  (round/tool_call/tool_result/usage/final)、左侧工作区文件浏览与预览
- 指标页「按模型分账」卡片(v2.by_model 表格)
- 修复:SSE final 后 EventSource 自动重连回放导致的事件重复渲染(终态即关闭+回放拦截)
2026-09-01 08:51:52 +08:00

210 lines
6.3 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="metrics-view">
<header class="metrics-header">
<h2>系统指标</h2>
<button class="refresh" @click="load">🔄 刷新</button>
</header>
<div v-if="loading" class="loading">加载中</div>
<div v-else-if="error" class="error">{{ error }}</div>
<template v-else-if="data">
<!-- 卡片网格 -->
<div class="card-grid">
<div class="metric-card">
<h3>路由器v1</h3>
<div class="kv-list">
<template v-for="(v, k) in data.router" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
</template>
</div>
</div>
<div class="metric-card">
<h3>缓存</h3>
<div class="kv-list">
<template v-for="(v, k) in data.cache" :key="k">
<span>{{ k }}</span><b>{{ v }}</b>
</template>
</div>
</div>
<div v-if="data.v2" class="metric-card highlight">
<h3>协作管线v2</h3>
<div class="kv-list">
<template v-for="(v, k) in data.v2" :key="k">
<span v-if="k !== 'by_model'">{{ k }}</span>
<b v-if="k !== 'by_model'">{{ v }}</b>
</template>
</div>
</div>
<div v-if="byModel && Object.keys(byModel).length" class="metric-card">
<h3>按模型分账token / 成本</h3>
<table class="by-model">
<thead>
<tr><th>模型</th><th>次数</th><th></th><th></th><th>成本 $</th></tr>
</thead>
<tbody>
<tr v-for="(b, m) in byModel" :key="m">
<td class="mono">{{ m }}</td>
<td>{{ b.requests }}</td>
<td>{{ b.input_tokens }}</td>
<td>{{ b.output_tokens }}</td>
<td>{{ b.cost_est_usd }}</td>
</tr>
</tbody>
</table>
<p class="hint">单价来自模型池条目$/1M tokens);经典设置下的模型成本不计入</p>
</div>
<div v-if="data.review" class="metric-card review-card">
<h3>人工检验</h3>
<div class="review-stats">
<div class="stat-item">
<span class="stat-num">{{ data.review.pending }}</span>
<span class="stat-label">待审核</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ data.review.total }}</span>
<span class="stat-label">总提交</span>
</div>
</div>
<div v-if="data.review.total > 0" class="progress-wrap">
<div
class="reviewed-bar"
:style="{
width: `${((data.review.total - data.review.pending) / data.review.total) * 100}%`,
}"
/>
</div>
<p class="review-rate">
通过率
{{ (((data.review.total - data.review.pending) / data.review.total) * 100).toFixed(1) }}%
</p>
</div>
</div>
<!-- 原始 JSON -->
<details class="raw-json">
<summary>原始 JSON</summary>
<pre>{{ JSON.stringify(data, null, 2) }}</pre>
</details>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { getMetrics } from '@/api'
import type { Metrics } from '@/types'
const data = ref<Metrics | null>(null)
const loading = ref(false)
const error = ref('')
interface ModelBucket {
requests: number
input_tokens: number
output_tokens: number
cost_est_usd: number
}
const byModel = computed(() => {
const v2 = data.value?.v2 as Record<string, unknown> | undefined
return (v2?.by_model as Record<string, ModelBucket>) || null
})
async function load() {
loading.value = true
error.value = ''
try {
data.value = await getMetrics()
} catch (e: unknown) {
// 网络超时或服务端错误:显示友好错误而不是无限 loading
error.value = e instanceof Error ? e.message : '指标加载失败,请检查后端服务'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.metrics-view { padding: 20px 24px; height: 100%; overflow-y: auto; }
.by-model { width: 100%; border-collapse: collapse; font-size: 12px; }
.by-model th, .by-model td { text-align: left; padding: 4px 8px; border-bottom: 1px solid #f3f4f6; }
.by-model th { color: #6b7280; font-weight: 600; }
.by-model td.mono { font-family: ui-monospace, Consolas, monospace; }
.hint { color: #9ca3af; font-size: 11px; margin-top: 8px; }
.metrics-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.metrics-header h2 { margin: 0; font-size: 20px; }
.refresh { padding: 6px 14px; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; background: #fff; }
.loading, .error { text-align: center; padding: 40px; color: #9ca3af; }
.error { color: #dc2626; }
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.metric-card {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 10px;
padding: 16px;
}
.metric-card.highlight { border-color: #2563eb; background: #eff6ff; }
.metric-card h3 { margin: 0 0 12px; font-size: 14px; color: #374151; }
.kv-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
font-size: 13px;
}
.kv-list span { color: #6b7280; }
.kv-list b { color: #111; text-align: right; }
.review-card { grid-column: span 2; }
.review-stats { display: flex; gap: 24px; margin-bottom: 12px; }
.stat-item { display: flex; flex-direction: column; align-items: center; }
.stat-num { font-size: 28px; font-weight: 700; color: #2563eb; }
.stat-label { font-size: 12px; color: #6b7280; }
.progress-wrap {
height: 8px;
background: #e5e7eb;
border-radius: 99px;
overflow: hidden;
margin-bottom: 6px;
}
.reviewed-bar { height: 100%; background: #16a34a; transition: width 0.5s ease; }
.review-rate { font-size: 13px; color: #6b7280; margin: 0; }
.raw-json {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
}
.raw-json summary { padding: 10px 14px; cursor: pointer; font-size: 13px; color: #6b7280; }
.raw-json pre {
padding: 10px 14px;
margin: 0;
font-size: 12px;
white-space: pre-wrap;
border-top: 1px solid #e5e7eb;
}
</style>