[功能] 人物管理按运动视频重设计 - 人物卡新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳时间轴定位); 新增 GET /api/ui/people/clips 后端接口(按 label/canonical_name 查关联运动片段,含 first_ts/clip_events); Timeline 支持 ?video= 定位

This commit is contained in:
ericwyuan
2026-08-22 12:55:30 +08:00
parent ffcc292cce
commit 4cf4fc4bc2
5 changed files with 121 additions and 2 deletions

View File

@@ -35,6 +35,7 @@ export const api = {
videoDetail: (id) => request(`/api/ui/videos/${id}`),
stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`),
people: () => request('/api/ui/people'),
peopleClips: (label, limit = 10) => request(`/api/ui/people/clips?label=${encodeURIComponent(label)}&limit=${limit}`),
namedMembers: () => request('/api/ui/named-members'),
modelStats: () => request('/api/ui/model-stats'),
attentionEvents: () => request('/api/ui/attention-events'),

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api, fmtMonthDayTime } from '../api.js'
import Badge from './Badge.vue'
@@ -8,6 +9,7 @@ const props = defineProps({
allLabels: { type: Array, default: () => [] },
})
const emit = defineEmits(['named', 'merged'])
const router = useRouter()
const isNamed = computed(() => props.group.is_named)
const firstSeenStr = computed(() => {
@@ -77,6 +79,31 @@ async function doMerge(sourceLabel, target) {
busy.value = false
}
}
// 运动片段区块:某人物出现过的运动片段(按运动视频提取)
const clipsOpen = ref(false)
const clips = ref([])
const clipsLoading = ref(false)
const clipsError = ref('')
async function toggleClips() {
clipsOpen.value = !clipsOpen.value
if (!clipsOpen.value || clips.value.length || clipsLoading.value) return
clipsLoading.value = true
clipsError.value = ''
try {
const data = await api.peopleClips(props.group.display, 10)
clips.value = data.clips || []
} catch (e) {
clipsError.value = e.message
} finally {
clipsLoading.value = false
}
}
function goToClip(videoId) {
router.push({ path: '/timeline', query: { video: videoId } })
}
</script>
<template>
@@ -101,6 +128,28 @@ async function doMerge(sourceLabel, target) {
</div>
<div v-else class="mt-2 text-[11px] text-text-faint">特征待大模型补充下段视频分析时由 VLM 落库</div>
<button @click="toggleClips" class="mt-3 w-full rounded-lg border border-border bg-panel px-3 py-1.5 text-xs font-medium text-text-dim transition-colors hover:border-border-hi">
{{ clipsOpen ? '收起' : '查看' }}运动片段{{ clips.length ? `${clips.length}` : '' }}
</button>
<div v-if="clipsOpen" class="mt-2 space-y-2">
<div v-if="clipsLoading" class="py-2 text-center text-[11px] text-text-faint">加载中</div>
<div v-else-if="clipsError" class="py-2 text-center text-[11px] text-danger">{{ clipsError }}</div>
<div v-else-if="!clips.length" class="py-2 text-center text-[11px] text-text-faint">暂无该人物的运动片段</div>
<button v-for="c in clips" :key="c.video_id" @click="goToClip(c.video_id)"
class="flex w-full items-start gap-2.5 rounded-xl border border-border bg-panel-3 p-2 text-left transition-colors hover:border-border-hi">
<img v-if="c.first_ts" loading="lazy" alt="片段缩略图"
:src="`/api/proxy/frame?video_id=${c.video_id}&ts=${encodeURIComponent(c.first_ts)}&w=160`"
class="h-[54px] w-[96px] shrink-0 rounded-lg border border-border object-cover" />
<div class="min-w-0 flex-1">
<div class="text-[12px] font-medium text-text">{{ fmtMonthDayTime(c.event_start_time) }}
<span v-if="c.duration_sec" class="ml-1 font-normal text-text-mute">{{ Math.round(c.duration_sec) }}s</span>
</div>
<div class="mt-0.5 line-clamp-2 text-[11px] leading-snug text-text-dim">{{ c.summary_json || '(暂无摘要)' }}</div>
<div v-if="c.clip_events" class="mt-0.5 text-[10px] text-text-faint">{{ c.clip_events }} 个事件 · {{ c.camera_name || '未知' }}</div>
</div>
</button>
</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">

View File

@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { api, fmtDateOnly, fmtMonthDayTime, fmtDateTime, parseTs } from '../api.js'
import PageHeader from '../components/PageHeader.vue'
import StatCard from '../components/StatCard.vue'
@@ -8,11 +9,14 @@ import EventItem from '../components/EventItem.vue'
import Badge from '../components/Badge.vue'
import Spinner from '../components/Spinner.vue'
const route = useRoute()
const dateFilter = ref('')
const page = ref(0)
const videos = ref([])
const stats = ref(null)
const selectedId = ref(null)
// 支持 ?video=<id> 定位(人物卡/其他页跳转);非 null 时不因列表重置选中
const selectedId = ref(Number(route.query.video) || null)
const detail = ref(null)
const loadError = ref('')
const videosLoading = ref(true)
@@ -32,7 +36,8 @@ async function loadVideos() {
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)) {
// 仅当没有 query 定位且当前选中不在列表时,才默认选第一条
if (videos.value.length && !videos.value.some(v => v.id === selectedId.value) && !route.query.video) {
selectedId.value = videos.value[0].id
}
} catch (e) {