[功能] 人物管理按运动视频重设计 - 人物卡新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳时间轴定位); 新增 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

@@ -203,6 +203,54 @@ def get_sync_events_for_video(video_id: int) -> List[Dict]:
conn.close() conn.close()
def get_sync_people_clips(label: str, limit: int = 10) -> List[Dict]:
"""某人物label 或 canonical_name出现过的运动片段列表。
匹配label 本身 + 其 canonical_name 下全部 label命中 sync_events 的
person_list_jsonJSON_CONTAINS→ 关联 video优先运动片段 motion_
返回按 event_start_time 倒序,含 first_ts该人物在片段内最早事件 ts
前端缩略图定位用)与 clip_events片段内该人物事件数
"""
conn = get_conn()
try:
cur = conn.cursor(pymysql.cursors.DictCursor)
labels = {label}
cur.execute(
"SELECT label, canonical_name FROM sync_people WHERE label=%s OR canonical_name=%s",
(label, label))
for r in cur.fetchall():
cn = r.get('canonical_name')
if cn:
cur.execute("SELECT label FROM sync_people WHERE canonical_name=%s", (cn,))
labels.update(x['label'] for x in cur.fetchall())
clips = []
seen = set()
for lb in sorted(labels):
cur.execute(
"""SELECT DISTINCT v.id AS video_id, v.filename, v.event_start_time,
v.duration_sec, v.summary_json, v.camera_name,
(SELECT MIN(e2.ts) FROM sync_events e2
WHERE e2.video_id=v.id AND e2.person_list_json IS NOT NULL
AND JSON_CONTAINS(e2.person_list_json, JSON_QUOTE(%s), '$')) AS first_ts,
(SELECT COUNT(*) FROM sync_events e3
WHERE e3.video_id=v.id AND e3.person_list_json IS NOT NULL
AND JSON_CONTAINS(e3.person_list_json, JSON_QUOTE(%s), '$')) AS clip_events
FROM sync_events e
JOIN sync_videos v ON e.video_id=v.id
WHERE e.person_list_json IS NOT NULL
AND JSON_CONTAINS(e.person_list_json, JSON_QUOTE(%s), '$')
AND v.status='done'""",
(lb, lb, lb))
for row in cur.fetchall():
if row['video_id'] not in seen:
seen.add(row['video_id'])
clips.append(row)
clips.sort(key=lambda x: x.get('event_start_time') or '', reverse=True)
return clips[:int(limit)]
finally:
conn.close()
def query_sync_events_for_person_date(person: str, date_str: str) -> List[Dict]: def query_sync_events_for_person_date(person: str, date_str: str) -> List[Dict]:
"""问答上下文:某人在某天的事件。 """问答上下文:某人在某天的事件。

View File

@@ -104,6 +104,22 @@ def people():
return jsonify({"groups": _ser(out), "all_labels": all_labels}), 200 return jsonify({"groups": _ser(out), "all_labels": all_labels}), 200
@ui_bp.route('/api/ui/people/clips', methods=['GET'])
def people_clips():
"""某人物出现过的运动片段列表(人物卡「运动片段」区块)。
按 label/canonical_name 匹配 sync_events.person_list_json → 关联视频
(运动片段优先)。返回片段 video_id/filename/event_start_time/duration/
summary/camera_name + first_ts缩略图定位+ clip_events片段内事件数
"""
label = request.args.get('label') or ''
if not label:
return jsonify({"error": "缺少 label 参数"}), 400
limit = min(20, request.args.get('limit', 10, type=int) or 10)
clips = db_layer.get_sync_people_clips(label, limit)
return jsonify({"label": label, "clips": _ser(clips)}), 200
@ui_bp.route('/api/ui/attention-events', methods=['GET']) @ui_bp.route('/api/ui/attention-events', methods=['GET'])
def attention_events(): def attention_events():
"""需关注事件列表(统计图表页),已按人物去重规则清洗好 people 字段。""" """需关注事件列表(统计图表页),已按人物去重规则清洗好 people 字段。"""

View File

@@ -35,6 +35,7 @@ export const api = {
videoDetail: (id) => request(`/api/ui/videos/${id}`), videoDetail: (id) => request(`/api/ui/videos/${id}`),
stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`), stats: (date) => request(`/api/ui/stats${date ? '?date=' + date : ''}`),
people: () => request('/api/ui/people'), 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'), namedMembers: () => request('/api/ui/named-members'),
modelStats: () => request('/api/ui/model-stats'), modelStats: () => request('/api/ui/model-stats'),
attentionEvents: () => request('/api/ui/attention-events'), attentionEvents: () => request('/api/ui/attention-events'),

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api, fmtMonthDayTime } from '../api.js' import { api, fmtMonthDayTime } from '../api.js'
import Badge from './Badge.vue' import Badge from './Badge.vue'
@@ -8,6 +9,7 @@ const props = defineProps({
allLabels: { type: Array, default: () => [] }, allLabels: { type: Array, default: () => [] },
}) })
const emit = defineEmits(['named', 'merged']) const emit = defineEmits(['named', 'merged'])
const router = useRouter()
const isNamed = computed(() => props.group.is_named) const isNamed = computed(() => props.group.is_named)
const firstSeenStr = computed(() => { const firstSeenStr = computed(() => {
@@ -77,6 +79,31 @@ async function doMerge(sourceLabel, target) {
busy.value = false 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> </script>
<template> <template>
@@ -101,6 +128,28 @@ async function doMerge(sourceLabel, target) {
</div> </div>
<div v-else class="mt-2 text-[11px] text-text-faint">特征待大模型补充下段视频分析时由 VLM 落库</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> <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-if="!isNamed" class="mt-3 space-y-2.5">

View File

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