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

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>