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:
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>
|
||||
Reference in New Issue
Block a user