## 新架构:Oracle 集中计算 + NAS 代理展示 ### Oracle 端 (fam-edge) - 新增 frame_service: ffmpeg 视频抽帧 + VLM 人物定位裁剪头像(磁盘缓存) - 新增 /api/oracle/frame: 按 video_id+ts 抽帧返回 jpeg(带 token) - 新增 /api/oracle/avatar: 按 label 生成人物头像(VLM 定位人物 + 兜底整帧居中) - 新增 person_identifier: 人物身份识别模块 - Gemini 适配器支持 flash/flash-lite 双模型切换,429 自动降级 - frame_service VLM 全模型 429 时进入 10 分钟熔断,避免每次请求白打配额 - 兜底头像不落缓存,配额恢复后自动重试 VLM 精确定位 ### 人物合并硬规则校验(框架级修复) - person_service: LLM 合并结果落库前加硬冲突检测 - 性别冲突 → 绝不合并 - 年龄档跨未成年/成年 → 绝不合并(防止把爷爷/宝宝并进同一人) - oracle_db: upsert_person 入口剥离括号后缀(人物A(别名:人物B) → 人物A),消灭垃圾人物行 - 修复 set_canonical 丢弃 source 参数的 bug(旧代码硬编码 'manual' 导致错误合并被永久固化) - get_events_for_label: 只提取该身份组的特征文本,头像定位更精准 ### NAS 端 (fam-core) - 新增 img_proxy: /api/proxy/frame 和 /api/proxy/avatar 代理 Oracle 图片 - app.py 注册 img_bp 蓝图 - oracle_sync / db_layer / member_manager 同步人物表 ### UI 端 (fam-ui) - 事件时间轴: 每条事件卡片加时间点缩略帧 - 人物管理: 每人卡片加头像(150x150 圆角) - parse_persons: 剥离括号备注,与 Oracle 归一化一致 - 新增 EventItem 组件、Timeline 页改造 - Chat / ServiceStatus 页相应调整 ### 数据库 - scripts/ddl.sql: 同步表结构更新 - Oracle people 表: features_json / display_uid / source 字段完善
111 lines
4.7 KiB
Vue
111 lines
4.7 KiB
Vue
<script setup>
|
||
import { computed, ref } from 'vue'
|
||
import { api, 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 emit = defineEmits(['corrected'])
|
||
|
||
// 闭集人物识别只有这 4 个真实成员,纠错时二选/四选一,不是自由填字符串
|
||
const CANONICAL_NAMES = ['爷爷', '爸爸', '媳妇', '汤圆']
|
||
const fixingPerson = ref(null) // 当前正在纠错的人物名(打开选择器)
|
||
const fixingBusy = ref(false)
|
||
const fixError = ref('')
|
||
|
||
function toggleFix(name) {
|
||
fixError.value = ''
|
||
fixingPerson.value = fixingPerson.value === name ? null : name
|
||
}
|
||
|
||
async function correctPerson(oldName, newName) {
|
||
if (oldName === newName) { fixingPerson.value = null; return }
|
||
fixingBusy.value = true
|
||
fixError.value = ''
|
||
try {
|
||
await api.identityCorrect(props.videoId, oldName, newName)
|
||
fixingPerson.value = null
|
||
emit('corrected')
|
||
} catch (e) {
|
||
fixError.value = e.message
|
||
} finally {
|
||
fixingBusy.value = false
|
||
}
|
||
}
|
||
|
||
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 items-start gap-2">
|
||
<Badge v-if="isAttention" tone="danger">⚠ 需关注</Badge>
|
||
<div v-for="p in persons" :key="p" class="relative">
|
||
<button type="button" class="group inline-flex items-center gap-1" @click="toggleFix(p)" title="点击纠正这个人是谁">
|
||
<Badge tone="accent">{{ p }}</Badge>
|
||
<span class="text-[10px] text-text-faint opacity-0 transition-opacity group-hover:opacity-100">✎</span>
|
||
</button>
|
||
<div v-if="fixingPerson === p"
|
||
class="absolute left-0 top-full z-10 mt-1 flex gap-1.5 rounded-lg border border-border-hi bg-panel p-2 shadow-[var(--shadow-card)]">
|
||
<button v-for="name in CANONICAL_NAMES" :key="name" :disabled="fixingBusy"
|
||
@click="correctPerson(p, name)"
|
||
class="rounded-md border px-2 py-1 text-xs disabled:opacity-50"
|
||
:class="name === p
|
||
? 'border-accent/50 bg-accent/15 text-accent-bright'
|
||
: 'border-border bg-panel-2 text-text-dim hover:border-accent/40 hover:text-white'">
|
||
{{ name }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="fixError" class="mb-2 text-xs text-danger">纠错失败:{{ fixError }}</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>
|