[功能] 人物管理按运动视频重设计 - 人物卡新增「运动片段」区块(缩略图/时间/摘要/事件数,点击跳时间轴定位); 新增 GET /api/ui/people/clips 后端接口(按 label/canonical_name 查关联运动片段,含 first_ts/clip_events); Timeline 支持 ?video= 定位
This commit is contained in:
@@ -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_json(JSON_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]:
|
||||||
"""问答上下文:某人在某天的事件。
|
"""问答上下文:某人在某天的事件。
|
||||||
|
|
||||||
|
|||||||
@@ -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 字段。"""
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user