refactor(fam-ui): 重构第三阶段 - Streamlit 换成 Vue3+Vite+Tailwind 完全重写
范围变更:原计划是给 Streamlit 界面做视觉美化,用户中途要求换新框架完全重新实现。 最终方案:Vue 3 + Vite + Tailwind CSS v4,本地 npm run build 出静态文件,NAS 不装 Node.js,由 fam-core 的 Flask 直接提供(send_from_directory),原来独立跑在 :8501 的 Streamlit 进程整个退休,前端和 API 合并到 fam-core 的 :8000 一个进程。 fam-core 新增只读 API(ui_api.py):全部包装 db_layer.py 里已有的查询函数,没有 新写查询逻辑(除了下面两处真实缺口)。新增 static_app.py 做 SPA 静态文件服务 (assets 直出 + 非 API 路径回退 index.html 供前端路由接管),app.py 注册顺序上 必须排在其他 /api/* 蓝图之后。 顺带补的两个功能缺口(旧 Streamlit 版本自己绕开 db_layer 写了裸 SQL 才有的功能, db_layer 本身不支持): - get_chat_history 补 offset 参数(分页) - 新增 get_attention_events(统计图表页"关注事件"表格) fam-ui 完全重写为 Vue 3 项目:7 个页面 1:1 迁移功能(事件时间轴/AI对话/对话历史/ 人物管理/统计图表/模型统计/服务状态),深色主题设计系统(Inter+JetBrains Mono 字体、蓝紫渐变强调色、语义色 token)。用本地 npm run dev 代理到真实 NAS 数据做 了完整联调,过程中发现并修了两个真 bug: - URLSearchParams 把 undefined 转成字符串 "undefined" 传给后端,导致日期筛选失效 - MariaDB SUM() 返回 Decimal,Flask 默认序列化成字符串,前端字符串拼接出乱码数字 (ui_api.py::_ser 统一转 int/float 修复) 移动端 H5 补了响应式:原来的固定宽度侧边栏 + flex-wrap 导航在手机宽度下会把每个 按钮挤到文字逐字换行;改成 lg 以上桌面侧边栏、lg 以下移动端顶栏 + 横向可滑动导航 胶囊;人物管理页命名表单(输入框+命名按钮+合并下拉)在窄屏下改为纵向堆叠。 已部署 NAS 并验证:curl 确认 index.html/assets/深层路由/API 路由全部 200; Browser 工具在桌面宽度和手机宽度下把 7 个页面点了一遍(含真实提问一次 AI 对话、 人物头像/事件缩略图加载、命名合并表单),旧 Streamlit 进程已停止。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
107
fam-ui/src/views/ModelStats.vue
Normal file
107
fam-ui/src/views/ModelStats.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { api, fmtDateTime } from '../api.js'
|
||||
import PageHeader from '../components/PageHeader.vue'
|
||||
import EmptyState from '../components/EmptyState.vue'
|
||||
|
||||
const aggregate = ref([])
|
||||
const calls = ref([])
|
||||
const loadError = ref('')
|
||||
|
||||
const aggRows = computed(() => aggregate.value.map(r => {
|
||||
const total = (r.ok_cnt || 0) + (r.fail_cnt || 0)
|
||||
const rate = total ? ((r.ok_cnt || 0) / total * 100) : 0
|
||||
return {
|
||||
model: `${r.provider} / ${r.model}`,
|
||||
ok: r.ok_cnt || 0,
|
||||
fail: r.fail_cnt || 0,
|
||||
rate: rate.toFixed(1),
|
||||
avgDur: r.avg_duration,
|
||||
lastCall: r.last_call ? fmtDateTime(r.last_call) : '—',
|
||||
}
|
||||
}))
|
||||
|
||||
const callRows = computed(() => calls.value.map(c => ({
|
||||
time: c.started_at ? fmtDateTime(c.started_at) : '—',
|
||||
model: `${c.provider} / ${c.model}`,
|
||||
duration: (c.duration_sec || 0).toFixed(1),
|
||||
success: !!c.success,
|
||||
error: (c.error || '').slice(0, 60),
|
||||
filename: (c.filename || '').slice(0, 40),
|
||||
})))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await api.modelStats()
|
||||
aggregate.value = data.aggregate
|
||||
calls.value = data.recent_calls
|
||||
} catch (e) {
|
||||
loadError.value = e.message
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHeader icon="🤖" title="云端模型统计" sub="请求时间 · 耗时 · 成功/失败 · 失败原因" />
|
||||
|
||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||
|
||||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">按模型聚合</div>
|
||||
<EmptyState v-if="!aggRows.length" text="暂无模型调用记录(视频处理中或尚未同步)" />
|
||||
<div v-else class="mb-8 overflow-x-auto rounded-xl border border-border">
|
||||
<table class="w-full whitespace-nowrap text-sm">
|
||||
<thead>
|
||||
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||
<th class="px-4 py-2 font-medium">模型</th>
|
||||
<th class="px-4 py-2 font-medium">成功</th>
|
||||
<th class="px-4 py-2 font-medium">失败</th>
|
||||
<th class="px-4 py-2 font-medium">成功率%</th>
|
||||
<th class="px-4 py-2 font-medium">平均耗时s</th>
|
||||
<th class="px-4 py-2 font-medium">最后调用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(r, i) in aggRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||
<td class="px-4 py-2 font-medium text-text">{{ r.model }}</td>
|
||||
<td class="px-4 py-2 text-ok">{{ r.ok }}</td>
|
||||
<td class="px-4 py-2 text-danger">{{ r.fail }}</td>
|
||||
<td class="px-4 py-2 font-mono tabular">{{ r.rate }}</td>
|
||||
<td class="px-4 py-2 font-mono tabular">{{ r.avgDur }}</td>
|
||||
<td class="px-4 py-2 font-mono tabular">{{ r.lastCall }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">最近调用明细</div>
|
||||
<EmptyState v-if="!callRows.length" text="暂无调用明细" />
|
||||
<div v-else class="mb-4 max-h-[520px] overflow-auto rounded-xl border border-border">
|
||||
<table class="w-full whitespace-nowrap text-sm">
|
||||
<thead class="sticky top-0">
|
||||
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||
<th class="px-4 py-2 font-medium">请求时间</th>
|
||||
<th class="px-4 py-2 font-medium">模型</th>
|
||||
<th class="px-4 py-2 font-medium">耗时s</th>
|
||||
<th class="px-4 py-2 font-medium">状态</th>
|
||||
<th class="px-4 py-2 font-medium">失败原因</th>
|
||||
<th class="px-4 py-2 font-medium">视频</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(c, i) in callRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||
<td class="px-4 py-2 font-mono tabular">{{ c.time }}</td>
|
||||
<td class="px-4 py-2">{{ c.model }}</td>
|
||||
<td class="px-4 py-2 font-mono tabular">{{ c.duration }}</td>
|
||||
<td class="px-4 py-2" :class="c.success ? 'text-ok' : 'text-danger'">{{ c.success ? '✅ 成功' : '❌ 失败' }}</td>
|
||||
<td class="px-4 py-2 text-text-faint">{{ c.error }}</td>
|
||||
<td class="px-4 py-2 text-text-faint">{{ c.filename }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-text-mute">
|
||||
统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。
|
||||
失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。
|
||||
</p>
|
||||
</template>
|
||||
Reference in New Issue
Block a user