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:
24
fam-ui/.gitignore
vendored
Normal file
24
fam-ui/.gitignore
vendored
Normal 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
5
fam-ui/README.md
Normal 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).
|
||||
@@ -1,16 +0,0 @@
|
||||
# FAM-UI 配置文件 (NAS 端) - 新架构 v2(2026-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"
|
||||
@@ -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
13
fam-ui/index.html
Normal 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
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
21
fam-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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
65
fam-ui/src/App.vue
Normal 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
112
fam-ui/src/api.js
Normal 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())}` : ''
|
||||
}
|
||||
1239
fam-ui/src/app.py
1239
fam-ui/src/app.py
File diff suppressed because it is too large
Load Diff
21
fam-ui/src/components/Badge.vue
Normal file
21
fam-ui/src/components/Badge.vue
Normal 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>
|
||||
13
fam-ui/src/components/EmptyState.vue
Normal file
13
fam-ui/src/components/EmptyState.vue
Normal 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>
|
||||
66
fam-ui/src/components/EventItem.vue
Normal file
66
fam-ui/src/components/EventItem.vue
Normal 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>
|
||||
19
fam-ui/src/components/PageHeader.vue
Normal file
19
fam-ui/src/components/PageHeader.vue
Normal 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>
|
||||
129
fam-ui/src/components/PersonCard.vue
Normal file
129
fam-ui/src/components/PersonCard.vue
Normal 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>
|
||||
15
fam-ui/src/components/ServiceCard.vue
Normal file
15
fam-ui/src/components/ServiceCard.vue
Normal 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>
|
||||
21
fam-ui/src/components/StatCard.vue
Normal file
21
fam-ui/src/components/StatCard.vue
Normal 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>
|
||||
@@ -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
6
fam-ui/src/main.js
Normal 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
19
fam-ui/src/router.js
Normal 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
52
fam-ui/src/style.css
Normal 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
103
fam-ui/src/views/Chat.vue
Normal 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>
|
||||
51
fam-ui/src/views/ChatHistory.vue
Normal file
51
fam-ui/src/views/ChatHistory.vue
Normal 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>
|
||||
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>
|
||||
46
fam-ui/src/views/People.vue
Normal file
46
fam-ui/src/views/People.vue
Normal 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>
|
||||
115
fam-ui/src/views/ServiceStatus.vue
Normal file
115
fam-ui/src/views/ServiceStatus.vue
Normal 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>
|
||||
85
fam-ui/src/views/Stats.vue
Normal file
85
fam-ui/src/views/Stats.vue
Normal 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>
|
||||
141
fam-ui/src/views/Timeline.vue
Normal file
141
fam-ui/src/views/Timeline.vue
Normal 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
17
fam-ui/vite.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user