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:
ericwyuan
2026-08-22 06:48:25 +08:00
parent e56d362d73
commit 7579782daf
36 changed files with 3176 additions and 1306 deletions

65
fam-ui/src/App.vue Normal file
View File

@@ -0,0 +1,65 @@
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { api, fmtDateTime } from './api.js'
import { navItems } from './router.js'
const route = useRoute()
const sync = ref(null)
async function refreshStatus() {
try {
const data = await api.status()
sync.value = data.sync
} catch {
sync.value = null
}
}
let timer = null
onMounted(() => {
refreshStatus()
timer = setInterval(refreshStatus, 30000)
})
onUnmounted(() => clearInterval(timer))
</script>
<template>
<div class="flex min-h-screen flex-col lg:flex-row">
<!-- 桌面端左侧栏品牌 + 同步状态lg 以下隐藏改用下面的移动端顶栏 -->
<aside class="hidden w-64 shrink-0 border-r border-border bg-[#090c12] px-4 py-6 lg:block">
<div class="mb-6">
<div class="text-[17px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
<div class="mt-1 text-[11px] text-text-mute">SENTINEL HOME AI · 管理后台</div>
</div>
<div v-if="sync" class="rounded-xl border border-border bg-panel-2 px-4 py-3 text-xs leading-loose text-text-dim">
<b class="text-[#f7f9fc]">同步状态</b><br />
状态 <span :class="sync.running ? 'font-semibold text-ok' : 'font-semibold text-danger'">{{ sync.running ? '同步中' : '未运行' }}</span><br />
最近 <b class="text-[#ccd5e1]">{{ sync.last_sync_at ? fmtDateTime(sync.last_sync_at) : '—' }}</b><br />
本次增量 {{ sync.last_count ? `视频+${sync.last_count[0]} / 事件+${sync.last_count[1]} / 人物+${sync.last_count[2]}` : '—' }}<br />
游标 {{ sync.cursor ? fmtDateTime(sync.cursor) : '(全量)' }}
<div v-if="sync.last_error" class="font-semibold text-danger"> {{ sync.last_error }}</div>
</div>
</aside>
<!-- 移动端顶栏品牌 + 简要同步指示灯lg 以上隐藏用左侧栏代替 -->
<header class="flex items-center justify-between border-b border-border bg-[#090c12] px-4 py-3 lg:hidden">
<div class="text-[15px] font-bold text-[#f7f9fc]">🏠 家庭智能监控</div>
<span v-if="sync" class="flex items-center gap-1.5 text-xs font-medium" :class="sync.running ? 'text-ok' : 'text-danger'">
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ sync.running ? '同步中' : '未运行' }}
</span>
</header>
<div class="min-w-0 flex-1 px-4 py-4 sm:px-8 sm:py-6">
<!-- 导航宽屏时换行排列的胶囊组窄屏时横向可滑动避免每个按钮被挤到文字逐字换行 -->
<nav class="mb-6 flex gap-1.5 overflow-x-auto rounded-2xl border border-border bg-panel-2 p-1.5 lg:mb-7 lg:flex-wrap lg:overflow-visible">
<router-link v-for="item in navItems" :key="item.name" :to="item.path"
class="shrink-0 whitespace-nowrap rounded-xl px-3.5 py-2 text-sm font-medium text-text-dim transition-colors hover:text-text"
:class="route.name === item.name ? 'bg-gradient-to-br from-accent to-accent-2 text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.45)]' : ''">
{{ item.meta.icon }} {{ item.meta.label }}
</router-link>
</nav>
<router-view />
</div>
</div>
</template>