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

24
fam-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

5
fam-ui/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

View File

@@ -1,16 +0,0 @@
# FAM-UI 配置文件 (NAS 端) - 新架构 v22026-08-21
# NAS 仅作管理后台,前端读本地 MariaDB 同步镜像;视频缩略图经 Oracle 带 token 接口获取。
core_url: "http://127.0.0.1:8000"
# 甲骨文 FAM-Edge 地址(视频缩略图接口,与同步同 token
oracle_url: "http://129.146.203.203:5000"
oracle_token: "${ORACLE_SYNC_TOKEN}"
database:
host: "127.0.0.1"
port: 3306
user: "root"
password: "iLoveJava5!"
database: "sentinel_home_ai"
unix_socket: "/run/mysqld/mysqld10.sock"

View File

@@ -1,11 +0,0 @@
# FAM-UI 配置文件 (NAS 端)
# 复制此文件为 config.yaml 并修改实际值
core_url: "http://127.0.0.1:8000" # FAM-Core 地址
database:
host: "127.0.0.1"
port: 3306
user: "root"
password: ""
database: "sentinel_home_ai"

13
fam-ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22%3E%3Ctext y=%22.9em%22 font-size=%2290%22%3E%F0%9F%8F%A0%3C/text%3E%3C/svg%3E" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>家庭智能监控</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

1671
fam-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
fam-ui/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "fam-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.40",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^6.0.8",
"tailwindcss": "^4.3.3",
"vite": "^8.2.0"
}
}

View File

@@ -1,5 +0,0 @@
streamlit>=1.30.0
PyMySQL>=1.1.0
pandas>=2.1.0
requests>=2.31.0
PyYAML>=6.0

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>

112
fam-ui/src/api.js Normal file
View File

@@ -0,0 +1,112 @@
// API 薄封装:生产环境同源相对路径;开发环境走 vite.config.js 的 /api 代理。
async function request(path, options = {}) {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json' },
...options,
})
let data = null
try {
data = await res.json()
} catch {
// 非 JSON 响应(如 404 空 body保持 data=null
}
if (!res.ok) {
const msg = (data && (data.error || data.message)) || `HTTP ${res.status}`
throw new Error(msg)
}
return data
}
/** 过滤掉 null/undefined/空字符串再拼 query string避免 URLSearchParams 把
* undefined 字面量转成字符串 "undefined" 传给后端。 */
function qs(params) {
const clean = Object.fromEntries(
Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== ''))
const s = new URLSearchParams(clean).toString()
return s ? '?' + s : ''
}
export const api = {
get: (path) => request(path),
post: (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }),
videos: (params = {}) => request(`/api/ui/videos${qs(params)}`),
videoDetail: (id) => request(`/api/ui/videos/${id}`),
stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`),
people: () => request('/api/ui/people'),
namedMembers: () => request('/api/ui/named-members'),
modelStats: () => request('/api/ui/model-stats'),
attentionEvents: () => request('/api/ui/attention-events'),
serviceStatus: () => request('/api/ui/service-status'),
chatAsk: (question, queried_person, queried_date) =>
request('/api/chat/ask', { method: 'POST', body: JSON.stringify({ question, queried_person, queried_date }) }),
chatHistory: (params = {}) => request(`/api/chat/history${qs(params)}`),
nameMember: (label, canonical_name) =>
request('/api/member/name', { method: 'POST', body: JSON.stringify({ label, canonical_name, named_by: 'UI管理员' }) }),
mergeMember: (source, target) =>
request('/api/member/merge', { method: 'POST', body: JSON.stringify({ source, target }) }),
status: () => request('/api/status'),
}
/** 剥离全角/半角括号备注(如 '人物A别名人物B' -> '人物A'),与后端归一化一致 */
export function cleanPerson(s) {
return String(s || '').replace(/[(][^()]*[)]/g, '').trim()
}
/** person_list_json字符串或数组-> 去重、去括号备注、去"无人"的名字数组 */
export function parsePersons(personListJson) {
if (!personListJson) return []
let items = personListJson
if (typeof items === 'string') {
try { items = JSON.parse(items) } catch { items = [items] }
}
if (!Array.isArray(items)) items = [items]
const out = new Set()
for (const it of items) {
const s = cleanPerson(it)
if (s && s !== '无人') out.add(s)
}
return [...out].sort()
}
/** 'YYYY-MM-DD HH:MM:SS' / 相对时间 'HH:MM:SS' -> Date 对象,失败返回 null */
export function parseTs(ts) {
if (!ts) return null
const s = String(ts)
let m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/)
if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6])
m = s.match(/^(\d{1,2}):(\d{2}):(\d{2})/)
if (m) {
const d = new Date(0)
d.setHours(+m[1], +m[2], +m[3])
return d
}
return null
}
const pad = (n) => String(n).padStart(2, '0')
export function fmtTime(ts) {
const d = parseTs(ts)
return d ? `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` : (String(ts || '').slice(0, 8) || '--:--')
}
export function fmtDateTime(ts) {
const d = parseTs(ts)
if (!d) return '—'
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
export function fmtMonthDayTime(ts) {
const d = parseTs(ts)
return d ? `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` : '--:--'
}
export function fmtDateOnly(ts) {
const d = parseTs(ts)
return d ? `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` : ''
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
<script setup>
defineProps({
tone: { type: String, default: 'accent' }, // accent | ok | warn | danger | info | violet | neutral
})
const toneClass = {
accent: 'bg-accent/15 text-accent-bright border-accent/30',
ok: 'bg-ok/12 text-ok border-ok/30',
warn: 'bg-warn/12 text-warn border-warn/30',
danger: 'bg-danger/14 text-danger border-danger/35',
info: 'bg-info/12 text-info border-info/30',
violet: 'bg-violet/12 text-violet border-violet/30',
neutral: 'bg-slate-400/12 text-slate-300 border-slate-400/28',
}
</script>
<template>
<span class="inline-block whitespace-nowrap rounded-full border px-2.5 py-0.5 text-[11px] font-semibold leading-relaxed" :class="toneClass[tone]">
<slot />
</span>
</template>

View File

@@ -0,0 +1,13 @@
<script setup>
defineProps({
icon: { type: String, default: '🗒' },
text: { type: String, required: true },
})
</script>
<template>
<div class="rounded-2xl border border-dashed border-border bg-[rgba(19,24,38,.4)] px-5 py-11 text-center text-sm text-text-mute">
<span class="mb-3 block text-3xl opacity-50">{{ icon }}</span>
{{ text }}
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup>
import { computed } from 'vue'
import { fmtTime, parsePersons } from '../api.js'
import Badge from './Badge.vue'
const props = defineProps({
event: { type: Object, required: true },
videoId: { type: [Number, String], required: true },
})
const timeLabel = computed(() => fmtTime(props.event.ts))
const persons = computed(() => parsePersons(props.event.person_list_json))
const isAttention = computed(() => !!props.event.is_attention_event)
const description = computed(() => props.event.description || '(无描述)')
const thumbUrl = computed(() => {
if (!props.videoId || !props.event.ts) return null
return `/api/proxy/frame?video_id=${props.videoId}&ts=${encodeURIComponent(props.event.ts)}&w=440`
})
const appearances = computed(() => {
const raw = props.event.person_appearances_json
if (!raw) return []
let list = raw
if (typeof list === 'string') {
try { list = JSON.parse(list) } catch { return [] }
}
if (!Array.isArray(list)) return []
return list
.filter(p => p && p.uid)
.map(p => {
const feats = p.features || {}
const bits = ['gender', 'clothing', 'face']
.map(k => (feats[k] || '').trim())
.filter(v => v && v.toLowerCase() !== 'unknown')
return { uid: p.uid, featStr: bits.length ? bits.join(' · ') : '无特征', action: (p.action || '').trim() }
})
})
</script>
<template>
<div class="grid grid-cols-[78px_1fr] gap-3.5 border-b border-border py-3 last:border-none">
<div class="pt-0.5 font-mono text-sm font-semibold text-[#ccd3e0] tabular">
{{ timeLabel }}
<span v-if="event.camera_name" class="mt-1 block font-sans text-[11px] font-medium text-text-mute">{{ event.camera_name }}</span>
</div>
<div class="rounded-xl border p-3.5 transition-colors" :class="isAttention ? 'border-danger/35' : 'border-border'"
style="background: linear-gradient(165deg, var(--color-panel-2), var(--color-panel));">
<div v-if="thumbUrl" class="mb-2.5 leading-none">
<img loading="lazy" alt="事件帧" :src="thumbUrl" class="block w-full rounded-lg border border-border" />
</div>
<div class="mb-2 flex flex-wrap gap-2">
<Badge v-if="isAttention" tone="danger"> 需关注</Badge>
<Badge v-for="p in persons" :key="p" tone="accent">{{ p }}</Badge>
</div>
<div v-if="appearances.length" class="mb-2">
<div v-for="a in appearances" :key="a.uid" class="my-1.5 rounded-lg border border-border bg-panel-3 px-2.5 py-1.5 text-xs">
<b class="text-accent-bright">{{ a.uid }}</b>
<span class="ml-2 text-text-mute">{{ a.featStr }}</span>
<span v-if="a.action" class="mt-1 block text-text-dim">{{ a.action }}</span>
</div>
</div>
<div class="text-sm leading-relaxed text-text">{{ description }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
defineProps({
icon: { type: String, required: true },
title: { type: String, required: true },
sub: { type: String, default: '' },
})
</script>
<template>
<div class="mb-6 flex items-center gap-3.5">
<div class="flex h-11 w-11 items-center justify-center rounded-xl border border-accent/30 bg-gradient-to-br from-accent/30 to-accent-2/20 text-xl shadow-[var(--shadow-card)]">
{{ icon }}
</div>
<div>
<div class="text-[22px] font-bold tracking-tight text-[#f7f9fc]">{{ title }}</div>
<div v-if="sub" class="mt-0.5 text-[12.5px] text-text-mute">{{ sub }}</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,129 @@
<script setup>
import { computed, ref } from 'vue'
import { api, fmtMonthDayTime } from '../api.js'
import Badge from './Badge.vue'
const props = defineProps({
group: { type: Object, required: true },
allLabels: { type: Array, default: () => [] },
})
const emit = defineEmits(['named', 'merged'])
const isNamed = computed(() => props.group.is_named)
const firstSeenStr = computed(() => {
const s = fmtMonthDayTime(props.group.first_seen)
return s === '--:--' ? '--' : s
})
const labelStr = computed(() => props.group.labels.join(' · '))
const avatarUrl = computed(() => `/api/proxy/avatar?label=${encodeURIComponent(props.group.display)}&w=150`)
const FEATURE_ROWS = [
['性别', 'gender'], ['年龄段', 'age_band'], ['身材', 'build'], ['发型', 'hair'],
['衣着', 'clothing'], ['面部', 'face'], ['辨识点', 'distinguishing'],
]
const features = computed(() => {
let feats = {}
if (props.group.features_json) {
try { feats = JSON.parse(props.group.features_json) } catch { feats = {} }
}
return FEATURE_ROWS
.map(([label, key]) => ({ label, value: (feats[key] || '').trim() }))
.filter(f => f.value)
})
const mergeTargets = computed(() => props.allLabels.filter(l => !props.group.labels.includes(l)))
// 命名/合并表单状态
const newNameByLabel = ref({})
const mergeTargetByLabel = ref({})
const mergeTargetSelf = ref('')
const busy = ref(false)
const errorMsg = ref('')
async function doName(label) {
const name = (newNameByLabel.value[label] || '').trim()
if (!name) { errorMsg.value = '请输入名字'; return }
busy.value = true; errorMsg.value = ''
try {
await api.nameMember(label, name)
emit('named')
} catch (e) {
errorMsg.value = e.message
} finally {
busy.value = false
}
}
async function doMergeFromLabel(sourceLabel) {
const target = mergeTargetByLabel.value[sourceLabel]
if (!target) return
await doMerge(sourceLabel, target)
}
async function doMergeSelf() {
if (!mergeTargetSelf.value) return
await doMerge(props.group.labels[0], mergeTargetSelf.value)
}
async function doMerge(sourceLabel, target) {
busy.value = true; errorMsg.value = ''
try {
await api.mergeMember(sourceLabel, target)
emit('merged')
} catch (e) {
errorMsg.value = e.message
} finally {
busy.value = false
}
}
</script>
<template>
<div class="mb-3 rounded-2xl border border-border bg-panel-2 p-5 shadow-[var(--shadow-card)]">
<div class="mb-2.5 leading-none">
<img loading="lazy" alt="人物头像" :src="avatarUrl"
class="h-[150px] w-[150px] rounded-2xl border border-border object-cover shadow-[var(--shadow-card)]" />
</div>
<div class="flex flex-wrap items-center gap-2 text-[17px] font-bold text-[#f7f9fc]">
{{ group.display }}
<Badge :tone="isNamed ? 'info' : 'warn'">{{ isNamed ? '已命名' : '未命名' }}</Badge>
<span v-if="group.display_uid && group.display_uid !== group.display" class="text-[11px] font-normal text-text-mute">UID: {{ group.display_uid }}</span>
</div>
<div class="mt-1.5 text-xs leading-relaxed text-text-dim">标识: {{ labelStr }}</div>
<div class="mt-1.5 text-[11px] text-text-mute">出现 <b class="text-[#ccd5e1]">{{ group.appearances }}</b> · 首次 {{ firstSeenStr }}</div>
<div v-if="features.length" class="mt-2 flex flex-wrap gap-1.5">
<span v-for="f in features" :key="f.label" class="rounded-lg border border-border bg-panel-3 px-2.5 py-0.5 text-[11px]">
<span class="text-text-mute">{{ f.label }}</span>
<b class="ml-1" :class="f.value.toLowerCase() === 'unknown' ? 'font-medium text-text-faint' : 'text-text'">{{ f.value }}</b>
</span>
</div>
<div v-else class="mt-2 text-[11px] text-text-faint">特征待大模型补充下段视频分析时由 VLM 落库</div>
<p v-if="errorMsg" class="mt-2 text-xs text-danger">{{ errorMsg }}</p>
<div v-if="!isNamed" class="mt-3 space-y-2.5">
<div v-for="lb in group.labels" :key="lb" class="flex flex-col gap-2 sm:flex-row">
<input v-model="newNameByLabel[lb]" :placeholder="`为「${lb}」起名`"
class="min-w-0 flex-1 rounded-lg border border-border bg-panel px-3 py-1.5 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
<div class="flex gap-2">
<button :disabled="busy" @click="doName(lb)"
class="shrink-0 whitespace-nowrap rounded-lg bg-gradient-to-br from-accent to-accent-2 px-4 py-1.5 text-sm font-semibold text-white shadow-[0_4px_16px_-2px_rgba(91,140,255,.4)] disabled:opacity-50">命名</button>
<select v-model="mergeTargetByLabel[lb]" @change="doMergeFromLabel(lb)"
class="min-w-0 flex-1 rounded-lg border border-border bg-panel px-2 py-1.5 text-xs text-text-dim sm:w-36 sm:flex-none">
<option value="">合并到</option>
<option v-for="t in mergeTargets" :key="t" :value="t">{{ t }}</option>
</select>
</div>
</div>
</div>
<div v-else class="mt-3">
<select v-model="mergeTargetSelf" @change="doMergeSelf"
class="w-full rounded-lg border border-border bg-panel px-2 py-1.5 text-xs text-text-dim">
<option value="">合并到其他身份</option>
<option v-for="t in mergeTargets" :key="t" :value="t">{{ t }}</option>
</select>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
defineProps({
icon: { type: String, required: true },
name: { type: String, required: true },
valueColor: { type: String, default: '#ccd5e1' },
})
</script>
<template>
<div class="min-w-[190px] flex-1 rounded-xl border border-border bg-panel-2 p-3.5 shadow-[var(--shadow-card)]">
<div class="text-xs font-medium text-text-mute">{{ icon }} {{ name }}</div>
<div class="mt-1.5 text-[13px] leading-snug" :style="{ color: valueColor }"><slot /></div>
<div class="mt-1 text-[11px] text-text-faint"><slot name="sub" /></div>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup>
defineProps({
value: { type: [String, Number], required: true },
label: { type: String, required: true },
tone: { type: String, default: 'default' }, // default | ok | warn | danger
})
const toneClass = {
default: 'text-[#f7f9fc]',
ok: 'text-ok',
warn: 'text-warn',
danger: 'text-danger',
}
</script>
<template>
<div class="flex-1 min-w-[130px] rounded-2xl border border-border bg-gradient-to-b from-panel-2 to-panel px-5 py-4 shadow-[var(--shadow-card)]">
<div class="font-mono text-[27px] font-semibold leading-tight tabular" :class="toneClass[tone]">{{ value }}</div>
<div class="mt-1 text-xs font-medium text-text-mute">{{ label }}</div>
</div>
</template>

View File

@@ -1,29 +0,0 @@
"""
配置加载器 (FAM-UI 复用)
"""
import os
import re
import yaml
def _resolve_env_vars(value):
if isinstance(value, str):
def replace_env(match):
return os.environ.get(match.group(1), match.group(0))
return re.sub(r'\$\{(\w+)\}', replace_env, value)
elif isinstance(value, dict):
return {k: _resolve_env_vars(v) for k, v in value.items()}
elif isinstance(value, list):
return [_resolve_env_vars(item) for item in value]
return value
def load_config(config_path=None):
if config_path is None:
config_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
'config', 'config.yaml'
)
with open(config_path, 'r', encoding='utf-8') as f:
raw = yaml.safe_load(f)
return _resolve_env_vars(raw)

6
fam-ui/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router.js'
createApp(App).use(router).mount('#app')

19
fam-ui/src/router.js Normal file
View File

@@ -0,0 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/', redirect: '/timeline' },
{ path: '/timeline', name: 'timeline', component: () => import('./views/Timeline.vue'), meta: { icon: '🕒', label: '事件时间轴' } },
{ path: '/chat', name: 'chat', component: () => import('./views/Chat.vue'), meta: { icon: '💬', label: 'AI 对话' } },
{ path: '/chat-history', name: 'chat-history', component: () => import('./views/ChatHistory.vue'), meta: { icon: '📝', label: '对话历史' } },
{ path: '/people', name: 'people', component: () => import('./views/People.vue'), meta: { icon: '👤', label: '人物管理' } },
{ path: '/stats', name: 'stats', component: () => import('./views/Stats.vue'), meta: { icon: '📈', label: '统计图表' } },
{ path: '/model-stats', name: 'model-stats', component: () => import('./views/ModelStats.vue'), meta: { icon: '🤖', label: '模型统计' } },
{ path: '/service-status', name: 'service-status', component: () => import('./views/ServiceStatus.vue'), meta: { icon: '🖥', label: '服务状态' } },
]
export const navItems = routes.filter(r => r.meta)
export default createRouter({
history: createWebHistory(),
routes,
})

52
fam-ui/src/style.css Normal file
View File

@@ -0,0 +1,52 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;600&display=swap');
@import "tailwindcss";
@theme {
--color-bg: #0a0d13;
--color-panel: #12161f;
--color-panel-2: #171c28;
--color-panel-3: #0d1119;
--color-border: #232a3a;
--color-border-hi: #384357;
--color-text: #e7ecf3;
--color-text-dim: #949fb3;
--color-text-mute: #67728a;
--color-text-faint: #454e60;
--color-accent: #5b8cff;
--color-accent-2: #8b6bff;
--color-accent-bright: #9db8ff;
--color-ok: #3ddc9b;
--color-warn: #f5b84e;
--color-danger: #ff7373;
--color-info: #4fd1e8;
--color-violet: #c893ff;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--shadow-card: 0 1px 2px rgba(0,0,0,.2), 0 8px 24px -12px rgba(0,0,0,.5);
}
html, body, #app { height: 100%; }
body {
background:
radial-gradient(1200px 500px at 15% -10%, rgb(91 140 255 / .07), transparent),
radial-gradient(900px 500px at 100% 0%, rgb(139 107 255 / .05), transparent),
var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
::selection { background: rgb(91 140 255 / .2); }
/* 滚动条:深色主题下默认滚动条太亮,统一细一点、低调一点 */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--color-border-hi); border-radius: 999px; border: 2px solid var(--color-bg); }
.tabular { font-variant-numeric: tabular-nums; }

103
fam-ui/src/views/Chat.vue Normal file
View File

@@ -0,0 +1,103 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { api } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
const named = ref([])
const queriedPerson = ref('')
const queriedDate = ref(new Date().toISOString().slice(0, 10))
const userQuestion = ref('')
const selectedQuick = ref('自定义')
const loading = ref(false)
const errorMsg = ref('')
const result = ref(null)
const quickQuestions = computed(() => [
`${queriedPerson.value}今天干嘛了?`,
`${queriedPerson.value}有没有发生什么需要注意的事情?`,
`今天${queriedPerson.value}的活动时间线是什么?`,
])
function applyQuick() {
if (selectedQuick.value !== '自定义') userQuestion.value = selectedQuick.value
}
onMounted(async () => {
try {
const data = await api.namedMembers()
named.value = data.members
if (named.value.length) queriedPerson.value = named.value[0]
} catch { /* 忽略:下拉留空即可 */ }
})
async function ask() {
errorMsg.value = ''
result.value = null
if (!userQuestion.value.trim()) { errorMsg.value = '请输入问题'; return }
if (!queriedPerson.value.trim()) { errorMsg.value = '请输入查询人物'; return }
loading.value = true
try {
const data = await api.chatAsk(userQuestion.value, queriedPerson.value, queriedDate.value)
result.value = { question: userQuestion.value, answer: data.answer, contextSummary: data.context_summary }
} catch (e) {
errorMsg.value = e.message
} finally {
loading.value = false
}
}
</script>
<template>
<PageHeader icon="💬" title="AI 对话" sub="基于同步事件上下文的智能问答" />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="mb-1.5 block text-xs font-medium text-text-dim">查询人物</label>
<input v-model="queriedPerson" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
</div>
<div>
<label class="mb-1.5 block text-xs font-medium text-text-dim">查询日期</label>
<input type="date" v-model="queriedDate" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
</div>
</div>
<div v-if="named.length" class="mt-4">
<label class="mb-1.5 block text-xs font-medium text-text-dim">快捷选择成员</label>
<select v-model="queriedPerson" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text-dim">
<option value="">不选</option>
<option v-for="n in named" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<div class="mt-4">
<label class="mb-1.5 block text-xs font-medium text-text-dim">快捷提问</label>
<select v-model="selectedQuick" @change="applyQuick" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text-dim">
<option>自定义</option>
<option v-for="q in quickQuestions" :key="q" :value="q">{{ q }}</option>
</select>
</div>
<div class="mt-4">
<label class="mb-1.5 block text-xs font-medium text-text-dim">你的问题</label>
<textarea v-model="userQuestion" rows="3" class="w-full rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15"></textarea>
</div>
<button :disabled="loading" @click="ask"
class="mt-4 rounded-xl bg-gradient-to-br from-accent to-accent-2 px-5 py-2.5 text-sm font-semibold text-white shadow-[0_4px_16px_-2px_rgba(91,140,255,.4)] disabled:opacity-50">
{{ loading ? 'AI 正在思考…' : '提问' }}
</button>
<p v-if="errorMsg" class="mt-3 text-sm text-danger">{{ errorMsg }}</p>
<div v-if="result" class="mt-6 space-y-3.5">
<div class="rounded-2xl border border-accent/30 bg-accent/15 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute"> 提问</div>
{{ result.question }}
</div>
<div class="rounded-2xl border border-border bg-panel-2 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">🤖 回答</div>
<div class="whitespace-pre-wrap">{{ result.answer }}</div>
</div>
<p v-if="result.contextSummary" class="text-xs text-text-faint">上下文: {{ result.contextSummary }}</p>
</div>
</template>

View File

@@ -0,0 +1,51 @@
<script setup>
import { onMounted, ref, watch } from 'vue'
import { api, fmtDateTime } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
const page = ref(0)
const pageSize = 20
const history = ref([])
const loadError = ref('')
async function load() {
loadError.value = ''
try {
const data = await api.chatHistory({ limit: pageSize, offset: page.value * pageSize })
history.value = data.history
} catch (e) {
loadError.value = e.message
history.value = []
}
}
watch(page, load)
onMounted(load)
</script>
<template>
<PageHeader icon="📝" title="对话历史" sub="历史问答记录" />
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
<EmptyState v-else-if="!history.length" icon="💬" text="暂无对话记录" />
<template v-else>
<div v-for="h in history" :key="h.chat_id" class="mb-3.5">
<div class="rounded-2xl border border-accent/30 bg-accent/15 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">👤 {{ h.queried_person || '未知' }} · {{ fmtDateTime(h.created_at) }}</div>
{{ h.user_question }}
</div>
<div class="mt-3.5 rounded-2xl border border-border bg-panel-2 p-4 text-sm leading-relaxed shadow-[var(--shadow-card)]">
<div class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-text-mute">🤖 回答</div>
<div class="whitespace-pre-wrap">{{ h.ai_answer || '' }}</div>
</div>
</div>
<div class="mt-4 grid grid-cols-3 items-center gap-2">
<button :disabled="page === 0" @click="page--" class="rounded-lg border border-border bg-panel-2 py-2 text-sm text-text-dim disabled:opacity-40"> 上一页</button>
<div class="text-center text-sm text-text-mute"> {{ page + 1 }} </div>
<button :disabled="history.length < pageSize" @click="page++" class="rounded-lg border border-border bg-panel-2 py-2 text-sm text-text-dim disabled:opacity-40">下一页 </button>
</div>
</template>
</template>

View 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>

View File

@@ -0,0 +1,46 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { api } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
import EmptyState from '../components/EmptyState.vue'
import PersonCard from '../components/PersonCard.vue'
const groups = ref([])
const allLabels = ref([])
const loadError = ref('')
async function load() {
loadError.value = ''
try {
const data = await api.people()
groups.value = data.groups
allLabels.value = data.all_labels
} catch (e) {
loadError.value = e.message
groups.value = []
}
}
const namedCount = computed(() => groups.value.filter(g => g.is_named).length)
const unnamedCount = computed(() => groups.value.length - namedCount.value)
onMounted(load)
</script>
<template>
<PageHeader icon="👤" title="人物管理" sub="所有出现的人物 · 命名与合并(回推甲骨文)" />
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
<EmptyState v-else-if="!groups.length" icon="👤" text="暂未发现任何人物(甲骨文尚未同步)" />
<template v-else>
<div class="mb-4 text-xs text-text-dim">
<b class="text-[#f7f9fc]">{{ groups.length }}</b> 个身份 ·
已命名 <b class="text-info">{{ namedCount }}</b> ·
未命名 <b class="text-warn">{{ unnamedCount }}</b>
</div>
<PersonCard v-for="g in groups" :key="g.display" :group="g" :all-labels="allLabels"
@named="load" @merged="load" />
</template>
</template>

View File

@@ -0,0 +1,115 @@
<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'
import StatCard from '../components/StatCard.vue'
import ServiceCard from '../components/ServiceCard.vue'
import Badge from '../components/Badge.vue'
const data = ref(null)
const oracleError = ref('')
const loading = ref(false)
async function load() {
loading.value = true
try {
data.value = await api.serviceStatus()
oracleError.value = data.value.oracle_error || ''
} catch (e) {
oracleError.value = e.message
} finally {
loading.value = false
}
}
onMounted(load)
const oracle = computed(() => data.value?.oracle || {})
const queue = computed(() => oracle.value.queue || {})
const dbInfo = computed(() => oracle.value.db || {})
const byStatus = computed(() => dbInfo.value.by_status || {})
const currentTxt = computed(() => {
const c = queue.value.current
return c ? `#${c.video_id} ${(c.filename || '').slice(-42)}` : '—'
})
const qs = computed(() => queue.value.stats || {})
const rclone = computed(() => oracle.value.rclone)
const person = computed(() => oracle.value.person)
const nasSync = computed(() => data.value?.nas_sync)
const modelCalls = computed(() => oracle.value.model_calls || [])
const activities = computed(() => oracle.value.activities || [])
const SVC_BADGE = {
queue: 'info',
rclone: 'ok',
person: 'violet',
}
</script>
<template>
<PageHeader icon="🖥" title="服务状态" sub="各服务实时动态 · 最近 7 天活动记录" />
<div class="mb-5 flex items-center gap-3">
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">🔄 刷新</button>
<span class="text-xs text-text-mute">点击刷新立即更新</span>
</div>
<p v-if="oracleError" class="mb-4 text-sm text-warn">{{ oracleError }}</p>
<EmptyState v-if="!data?.oracle && !data?.nas_sync" text="暂时无法获取服务状态" />
<template v-else>
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">各服务当前状态</div>
<div class="mb-3 flex flex-wrap gap-3.5">
<StatCard :value="queue.running ? '运行中' : '停止'" label="FAM-Edge 队列" />
<StatCard :value="queue.queued ?? 0" label="排队中" />
<StatCard :value="byStatus.done ?? 0" label="已完成" tone="ok" />
<StatCard :value="byStatus.pending ?? 0" label="待处理" tone="warn" />
<StatCard :value="byStatus.failed ?? 0" label="失败" tone="danger" />
</div>
<div class="mb-5 text-xs text-text-dim">
正在处理<b class="text-info">{{ currentTxt }}</b>
<span class="ml-3.5">生产者已入队 {{ qs.produced ?? 0 }} · 成功 {{ qs.consumed_ok ?? 0 }} · 失败 {{ qs.consumed_fail ?? 0 }}</span>
</div>
<div class="mb-8 flex flex-wrap gap-2.5">
<ServiceCard icon="🔄" name="rclone 同步" :value-color="rclone ? '#4ade80' : undefined">
{{ rclone ? (rclone.detail || '').slice(0, 70) : '暂无记录' }}
<template #sub>{{ rclone ? `最近 ${(rclone.ts || '').slice(0, 19)}` : '—' }}</template>
</ServiceCard>
<ServiceCard icon="👤" name="人物合并" :value-color="person ? '#c084fc' : undefined">
{{ person ? person.action : '暂无记录' }}
<template #sub>{{ person ? `${(person.ts || '').slice(0, 19)} · ${(person.detail || '').slice(0, 46)}` : '—' }}</template>
</ServiceCard>
<ServiceCard icon="📡" name="NAS 同步" :value-color="nasSync ? '#fbbf24' : undefined">
{{ nasSync ? `游标 ${(nasSync.cursor || '').slice(0, 19)}` : '不可达' }}
<template #sub>
{{ nasSync ? `最近 ${(nasSync.last_sync_at || '').slice(0, 19)} · 增量 V${nasSync.last_count?.[0] ?? 0} E${nasSync.last_count?.[1] ?? 0} P${nasSync.last_count?.[2] ?? 0} M${nasSync.last_count?.[3] ?? 0}` : '—' }}
</template>
</ServiceCard>
<ServiceCard icon="🧠" name="云端模型" :value-color="modelCalls.length ? '#4ade80' : undefined">
{{ modelCalls.length ? `最近:${modelCalls[0].model} ${modelCalls[0].success ? '✅' : '❌ ' + (modelCalls[0].error || '').slice(0, 30)}` : '暂无调用' }}
<template #sub v-if="modelCalls.length">
{{ (modelCalls[0].started_at || '').slice(0, 19) }} · 耗时 {{ (modelCalls[0].duration_sec || 0).toFixed(1) }}s ·
近5次 成功{{ modelCalls.filter(m => m.success).length }}/{{ modelCalls.length }}
</template>
</ServiceCard>
</div>
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">最近活动</div>
<EmptyState v-if="!activities.length" text="暂无活动记录(服务刚启动?)" />
<template v-else>
<div v-for="(a, i) in activities.slice(0, 50)" :key="i" class="mb-1 grid grid-cols-[110px_1fr] gap-3.5">
<div class="pt-1 font-mono text-xs text-[#ccd3e0] tabular">{{ (a.ts || '').slice(0, 19) }}</div>
<div class="rounded-lg border border-border bg-panel-2 px-2.5 py-1.5">
<Badge :tone="SVC_BADGE[a.service] || 'neutral'">{{ a.service }}</Badge>
<span class="mx-1.5 font-semibold text-text">{{ a.action }}</span>
<span class="text-xs text-text-dim">{{ a.detail || '' }}</span>
</div>
</div>
<p class="mt-2 text-xs text-text-mute"> {{ activities.length }} 条记录活动日志仅保留最近 7 </p>
</template>
</template>
</template>

View File

@@ -0,0 +1,85 @@
<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>

View File

@@ -0,0 +1,141 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { api, fmtDateOnly, fmtMonthDayTime, fmtDateTime, parseTs } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
import StatCard from '../components/StatCard.vue'
import EmptyState from '../components/EmptyState.vue'
import EventItem from '../components/EventItem.vue'
import Badge from '../components/Badge.vue'
const dateFilter = ref('')
const page = ref(0)
const videos = ref([])
const stats = ref(null)
const selectedId = ref(null)
const detail = ref(null)
const loadError = ref('')
async function loadStats() {
try {
stats.value = await api.stats(dateFilter.value || undefined)
} catch {
stats.value = null
}
}
async function loadVideos() {
loadError.value = ''
try {
const data = await api.videos({ date: dateFilter.value || undefined, page: page.value })
videos.value = data.videos
if (videos.value.length && !videos.value.some(v => v.id === selectedId.value)) {
selectedId.value = videos.value[0].id
}
} catch (e) {
loadError.value = e.message
videos.value = []
}
}
async function loadDetail(id) {
if (!id) { detail.value = null; return }
try {
detail.value = await api.videoDetail(id)
} catch (e) {
detail.value = null
}
}
watch(dateFilter, () => { page.value = 0; loadStats(); loadVideos() })
watch(page, loadVideos)
watch(selectedId, (id) => loadDetail(id))
onMounted(() => { loadStats(); loadVideos() })
function selectVideo(id) {
selectedId.value = id
}
function summaryShort(s) {
s = (s || '').trim()
if (!s) return '暂无摘要'
return s.length <= 28 ? s : s.slice(0, 28) + '…'
}
const rangeStr = computed(() => {
if (!detail.value) return ''
const v = detail.value.video
const start = parseTs(v.event_start_time)
const proc = parseTs(v.processed_at)
const main = start || proc
if (!main) return ''
let s = fmtDateTime(v.event_start_time || v.processed_at)
if (start && proc && (proc - start) / 1000 > 60) {
s += `(分析于 ${fmtMonthDayTime(v.processed_at)}`
}
return s
})
const modelBadges = computed(() => {
const provider = detail.value?.video?.compute_provider || ''
return provider ? String(provider).split(',').map(p => p.trim()).filter(Boolean) : []
})
</script>
<template>
<PageHeader icon="🕒" title="事件时间轴" sub="视频会话 · 时间点 · 信息摘要" />
<div class="mb-5">
<label class="mb-1.5 block text-xs font-medium text-text-dim">日期</label>
<input type="date" v-model="dateFilter" class="rounded-lg border border-border bg-panel px-3 py-2 text-sm text-text outline-none focus:border-accent focus:ring-2 focus:ring-accent/15" />
</div>
<div v-if="stats" class="mb-6 flex flex-wrap gap-3.5">
<StatCard :value="stats.videos ?? 0" label="视频会话" />
<StatCard :value="stats.events ?? 0" label="事件数" />
<StatCard :value="stats.people ?? 0" label="出现人物" tone="ok" />
<StatCard :value="stats.attention ?? 0" label="需关注" tone="warn" />
</div>
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
<EmptyState v-else-if="!videos.length" icon="🗓" text="该日期暂无监控会话(甲骨文尚未同步数据?)" />
<div v-else class="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_2.35fr]">
<div>
<div class="mb-2.5 text-[13px] font-semibold text-text-dim">视频会话 · {{ videos.length }} </div>
<button v-for="v in videos" :key="v.id" @click="selectVideo(v.id)"
class="mb-1 block w-full rounded-xl border px-3.5 py-2.5 text-left text-sm transition-colors"
:class="v.id === selectedId
? 'border-transparent bg-gradient-to-br from-accent to-accent-2 font-semibold text-white shadow-[0_4px_16px_-4px_rgba(91,140,255,.4)]'
: 'border-border bg-panel-2 text-text-dim hover:border-border-hi'">
<span v-if="v.id === selectedId"> </span>{{ fmtMonthDayTime(v.event_start_time || v.processed_at) }} · {{ v.camera_name || '未知' }} · {{ v.event_count }}事件
<div class="mt-0.5 text-[11px] font-normal opacity-80">{{ fmtDateOnly(v.event_start_time || v.processed_at) }} · {{ summaryShort(v.summary_json) }}</div>
</button>
<div class="mt-3 grid grid-cols-2 gap-2">
<button :disabled="page === 0" @click="page--"
class="rounded-lg border border-border bg-panel-2 py-2 text-sm text-text-dim disabled:opacity-40"> 上一页</button>
<button :disabled="videos.length < 15" @click="page++"
class="rounded-lg border border-border bg-panel-2 py-2 text-sm text-text-dim disabled:opacity-40">下一页 </button>
</div>
</div>
<div v-if="detail">
<div class="mb-6 rounded-2xl border border-border-hi p-5 shadow-[var(--shadow-card)]"
style="background: linear-gradient(135deg, rgb(91 140 255 / .1), rgb(139 107 255 / .06));">
<div class="flex flex-wrap items-center gap-2.5 text-lg font-bold text-[#f7f9fc]">
<span>{{ detail.video.camera_name || '未知摄像头' }}</span>
<Badge tone="ok">会话 #{{ detail.video.id }}</Badge>
<Badge v-for="m in modelBadges" :key="m" tone="neutral">{{ m }}</Badge>
</div>
<div class="mt-1.5 font-mono text-[13px] text-text-dim tabular"> {{ rangeStr }} · 文件 {{ detail.video.filename }}</div>
<div class="mt-2.5 whitespace-pre-wrap text-sm leading-relaxed text-[#d6dce6]">{{ detail.video.summary_json || '暂无全局摘要' }}</div>
</div>
<EmptyState v-if="!detail.events.length" icon="🎞" text="该会话暂无时间点事件" />
<div v-else>
<EventItem v-for="ev in detail.events" :key="ev.id" :event="ev" :video-id="detail.video.id" />
</div>
</div>
</div>
</template>

17
fam-ui/vite.config.js Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// 开发环境代理 /api 到 NAS 上的 fam-core方便本地直接联调真实数据。
// 生产环境由 fam-core 的 Flask 同源提供,不需要这个代理。
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
proxy: {
'/api': {
target: 'http://192.168.50.64:8000',
changeOrigin: true,
},
},
},
})