Files
sentinel-home-ai/fam-ui/src/views/Stats.vue
ericwyuan 7579782daf 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>
2026-08-22 06:48:25 +08:00

86 lines
3.8 KiB
Vue

<script setup>
import { computed, onMounted, ref } from 'vue'
import { api, fmtDateOnly, fmtDateTime } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
const modelChart = ref([]) // [{label, value}]
const attention = ref([])
const syncStatus = ref(null)
const loadError = ref('')
const BAR_COLORS = ['#5b8cff', '#8b6bff', '#3ddc9b', '#f5b84e', '#4fd1e8', '#ff7373']
const maxVal = computed(() => Math.max(1, ...modelChart.value.map(m => m.value)))
async function load() {
loadError.value = ''
try {
const [ms, att, statusData] = await Promise.all([
api.modelStats(),
api.attentionEvents(),
api.status(),
])
const byProvider = {}
for (const row of ms.aggregate) {
const key = row.provider || 'unknown'
byProvider[key] = (byProvider[key] || 0) + (row.ok_cnt || 0) + (row.fail_cnt || 0)
}
modelChart.value = Object.entries(byProvider).map(([label, value]) => ({ label, value }))
attention.value = att.events
syncStatus.value = statusData.sync
} catch (e) {
loadError.value = e.message
}
}
onMounted(load)
</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="!modelChart.length" text="暂无统计数据" />
<div v-else class="mb-8 space-y-2.5">
<div v-for="(m, i) in modelChart" :key="m.label" class="flex items-center gap-3">
<div class="w-20 shrink-0 text-xs text-text-dim">{{ m.label }}</div>
<div class="h-6 flex-1 overflow-hidden rounded-md bg-panel-3">
<div class="h-full rounded-md transition-all" :style="{ width: `${(m.value / maxVal) * 100}%`, background: BAR_COLORS[i % BAR_COLORS.length] }"></div>
</div>
<div class="w-10 shrink-0 text-right font-mono text-xs tabular text-text-dim">{{ m.value }}</div>
</div>
</div>
<div class="mb-3 mt-6 text-[15px] font-bold text-[#f7f9fc]">关注事件统计</div>
<EmptyState v-if="!attention.length" text="暂无关注事件" />
<div v-else class="mb-8 overflow-hidden rounded-xl border border-border">
<table class="w-full 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>
</tr>
</thead>
<tbody>
<tr v-for="(a, i) in attention" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
<td class="px-4 py-2 font-mono tabular">{{ fmtDateOnly(a.date) || '?' }}</td>
<td class="px-4 py-2">{{ a.persons.join(', ') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="mb-3 mt-6 text-[15px] font-bold text-[#f7f9fc]">同步状态</div>
<div v-if="syncStatus" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
运行状态 <span :class="syncStatus.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ syncStatus.running ? '同步中' : '未运行' }}</span><br />
最近同步 <b class="text-[#ccd5e1]">{{ syncStatus.last_sync_at ? fmtDateTime(syncStatus.last_sync_at) : '—' }}</b><br />
本次增量 {{ syncStatus.last_count ? `视频+${syncStatus.last_count[0]} / 事件+${syncStatus.last_count[1]} / 人物+${syncStatus.last_count[2]}` : '—' }}<br />
游标 <b class="text-[#ccd5e1]">{{ syncStatus.cursor ? fmtDateTime(syncStatus.cursor) : '(全量)' }}</b><br />
周期 {{ syncStatus.interval_sec }}s
<div v-if="syncStatus.last_error" class="font-semibold text-danger"> {{ syncStatus.last_error }}</div>
</div>
<EmptyState v-else text="无法获取同步状态" />
</template>