Files
momentry_studio/src/views/PersonDetailView.vue

336 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="people-view ms-ppl-detail-view">
<div class="ms-ppl-topbar">
<button class="ms-fm-btn" @click="router.back()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
返回
</button>
</div>
<div v-if="loading" class="loading-state">
<div class="spinner-lg"></div>
<p>Loading...</p>
</div>
<template v-else-if="person">
<div class="ms-ppl-detail-header">
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;">
<div class="ms-ppl-detail-avatar">
<img v-if="profile" :src="profile" alt="">
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
</svg>
</div>
</div>
<div style="flex:1;min-width:0;">
<div id="msPplInfoView">
<div class="ms-ppl-detail-name-row">
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar"></button>
<div class="ms-ppl-view-box ms-ppl-view-name-box">{{ person.name || '—' }}</div>
</div>
<div class="ms-ppl-detail-aliases" v-if="person.metadata?.aliases?.length">
<span v-for="(a, i) in person.metadata.aliases" :key="i" class="ms-ppl-alias-chip">{{ a.name }}</span>
</div>
<div class="ms-ppl-edit-fields" style="margin-top:4px;">
<div class="ms-ppl-edit-field-row">
<span class="ms-ppl-edit-label">角色</span>
<div class="ms-ppl-view-box ms-ppl-view-field-box">{{ person.metadata?.role || '—' }}</div>
</div>
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
<span class="ms-ppl-edit-label">描述</span>
<div class="ms-ppl-view-box ms-ppl-view-notes-box">{{ person.metadata?.notes || '—' }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="ms-ppl-strip-wrap">
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="加入相同人物"></button>
<div class="ms-ppl-face-strip">
<div v-for="f in faces.slice(0, 30)" :key="f.id" class="ms-ppl-strip-face">
<div class="ms-ppl-strip-face-img">
<img v-if="f.thumbUrl" :src="f.thumbUrl" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;border-radius:8px;">
<div v-else style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#eef2ff;color:#6366f1;font-size:14px;font-weight:600;border-radius:8px;">{{ Math.round(f.confidence * 100) }}%</div>
</div>
</div>
</div>
<button v-if="faces.length > 30" class="ms-ppl-strip-arrow" @click="showAllFaces = !showAllFaces"></button>
</div>
<div class="ms-ppl-media-label" v-if="mergedTraces.length">{{ mergedTraces.length }} segments</div>
<div class="ms-ppl-media-grid">
<div v-for="(m, i) in mergedTraces.slice(0, 20)" :key="i" class="ms-ppl-media-item" @click="playVideo(m.file_uuid, m.start, m.end)">
<div class="ms-ppl-media-thumb">
<img v-if="m.thumbUrl" :src="m.thumbUrl" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;" @error="handleThumbError">
<div class="ms-thumb-play-circle">
<svg width="20" height="20" viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20" fill="white"/></svg>
</div>
<span class="ms-ppl-media-dur">{{ (m.end - m.start).toFixed(1) }}s</span>
</div>
<div class="ms-ppl-media-info">
<div class="ms-ppl-media-title">{{ m.count > 1 ? 'Merged ' + m.count + ' segments' : 'Segment' }}</div>
<div class="ms-ppl-media-sub">{{ m.start.toFixed(1) }}s - {{ m.end.toFixed(1) }}s</div>
</div>
</div>
</div>
<div class="ms-ppl-delete-zone">
<hr class="ms-ppl-delete-hr">
<button class="ms-fm-btn ms-ppl-delete-zone-btn" @click="confirmDelete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline>
<path d="M19 6l-1 14H6L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
刪除此人物
</button>
</div>
</template>
<div v-else class="empty">
Person not found: {{ route.params.uuid }}
<br><br>
<span style="font-size:12px;color:#9aa0a6;">People loaded: {{ peopleCount }}</span>
</div>
<div v-if="showCandidates" class="ms-modal-overlay show" @click.self="showCandidates = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showCandidates = false">×</button>
<h2 class="ms-ppl-section-title">Bind Face</h2>
<div class="ms-merge-grid">
<div v-for="c in candidates" :key="c.id" class="ms-merge-face-card" @click="bindCandidate(c)">
<div class="ms-merge-face-img">
<img v-if="c.thumbUrl" :src="c.thumbUrl" alt="">
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
</div>
<span class="ms-merge-face-name">{{ c.file_uuid?.slice(0, 8) }}... #{{ c.frame_number }}</span>
</div>
</div>
</div>
</div>
<div v-if="showMerge" class="ms-modal-overlay show" @click.self="showMerge = false">
<div class="ms-modal">
<button class="ms-fm-icon-btn close-btn" @click="showMerge = false">×</button>
<h2 class="ms-ppl-section-title">Merge Identity</h2>
<input v-model="mergeTarget" class="merge-input" placeholder="Target identity UUID" />
<div class="ms-modal-actions">
<button class="ms-fm-btn ms-fm-btn-blue" :disabled="!mergeTarget" @click="confirmMerge">Merge</button>
</div>
</div>
</div>
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
import VideoPlayer from '../components/VideoPlayer.vue'
const route = useRoute()
const router = useRouter()
const person = ref<any>(null)
const loading = ref(true)
const profile = ref('')
const peopleCount = ref(0)
const faces = ref<any[]>([])
const traces = ref<any[]>([])
const mergedTraces = ref<any[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
const showCandidates = ref(false)
const showMerge = ref(false)
const mergeTarget = ref('')
const candidates = ref<any[]>([])
const showAllFaces = ref(false)
const CORE_API = 'http://localhost:3002'
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
onMounted(async () => {
const uuid = route.params.uuid as string
console.log('PersonDetailView mounted, uuid:', uuid)
try {
console.log('Calling getPeople with uuid:', uuid)
const people: any = await invoke('get_people', { page: 1, perPage: 1000 })
console.log('getPeople raw result:', JSON.stringify(people).slice(0, 200))
console.log('getPeople result count:', Array.isArray(people) ? people.length : 'not array')
peopleCount.value = Array.isArray(people) ? people.length : 0
const found = (Array.isArray(people) ? people : []).find((p: any) => p.identity_uuid === uuid)
console.log('Person found:', !!found, found?.name)
if (found) {
person.value = { ...found, status: found.status || 'confirmed' }
await loadProfile(uuid)
await loadFaces(uuid)
await loadMedia(uuid)
}
} catch (e) {
console.error('Failed to load person:', e)
} finally {
loading.value = false
}
loadCandidates()
})
async function loadProfile(uuid: string) {
try { profile.value = await invoke('get_identity_profile', { uuid }) } catch {}
}
async function loadFaces(uuid: string) {
try {
const result: any = await invoke('getFaces', { uuid, perPage: 1000 })
const items = Array.isArray(result) ? result : []
faces.value = items.map((f: any) => ({
...f,
thumbUrl: f.file_uuid ? `${CORE_API}/api/v1/file/${f.file_uuid}/thumbnail?api_key=${API_KEY}&frame=${f.frame_number || 0}` : ''
}))
} catch (e) { console.error('Failed to load faces:', e) }
}
async function loadMedia(uuid: string) {
try {
const result: any = await invoke('getTraces', { uuid, perPage: 1000 })
const rawItems = Array.isArray(result) ? result : []
rawItems.sort((a: any, b: any) => (a.first_sec || 0) - (b.first_sec || 0))
const merged: any[] = []
let cur: any = null
rawItems.forEach((item: any) => {
const st = item.first_sec || 0
const en = item.last_sec || 0
const fu = item.file_uuid || ''
if (cur && fu === cur.file_uuid && (st - cur.end) < 30) {
cur.end = Math.max(cur.end, en)
cur.count++
} else {
if (cur) merged.push(cur)
cur = { file_uuid: fu, start: st, end: en, count: 1, first_frame: item.first_frame || 0 }
}
})
if (cur) merged.push(cur)
mergedTraces.value = merged.map((m: any) => ({
...m,
thumbUrl: m.file_uuid ? `${CORE_API}/api/v1/file/${m.file_uuid}/thumbnail?api_key=${API_KEY}&frame=${m.first_frame || Math.floor(m.start * 24)}` : ''
}))
} catch (e) { console.error('Failed to load media:', e) }
}
async function loadCandidates() {
try {
const result: any = await invoke('get_face_candidates', { page: 1, perPage: 100 })
candidates.value = (Array.isArray(result) ? result : []).map((c: any) => ({
...c,
thumbUrl: c.file_uuid ? `${CORE_API}/api/v1/file/${c.file_uuid}/thumbnail?api_key=${API_KEY}&frame=${c.frame_number || 0}` : ''
}))
} catch (e) { console.error('Failed to load candidates:', e) }
}
function handleThumbError(e: Event) {
const img = e.target as HTMLImageElement
img.style.display = 'none'
}
async function toggleStar() {
if (!person.value) return
person.value.starred = !person.value.starred
}
async function confirmDelete() {
if (!person.value || !confirm(`Delete "${person.value.name}"?`)) return
try {
await invoke('delete_identity', { uuid: person.value.identity_uuid })
router.back()
} catch (e) { console.error('Failed to delete:', e) }
}
async function bindCandidate(c: any) {
if (!person.value) return
try {
await invoke('bind_face', { uuid: person.value.identity_uuid, faceId: String(c.id), fileUuid: c.file_uuid })
showCandidates.value = false
await loadFaces(person.value.identity_uuid)
} catch (e) { console.error('Bind failed:', e) }
}
async function confirmMerge() {
if (!person.value || !mergeTarget.value) return
try {
await invoke('merge_identities', { uuid: person.value.identity_uuid, intoUuid: mergeTarget.value })
router.back()
} catch (e) { console.error('Merge failed:', e) }
}
function playVideo(fileUuid: string, start: number, end: number) {
currentVideo.value = { fileUuid, startTime: start, endTime: end, title: `${person.value?.name} - ${start.toFixed(1)}s-${end.toFixed(1)}s` }
playing.value = true
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
return `${m}:${s.toString().padStart(2, '0')}`
}
</script>
<style scoped>
.people-view { max-width: 1200px; padding-top: 20px; }
.ms-ppl-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; margin-top: 20px; }
.loading-state, .empty { text-align: center; padding: 60px 0; color: #5f6368; }
.spinner-lg { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #202124; border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
@keyframes spin { to { transform: rotate(360deg); } }
.close-btn { position: absolute; top: 16px; right: 16px; }
.ms-ppl-detail-header { display: flex; align-items: flex-start; gap: 22px; margin-bottom: 28px; position: relative; margin-top: 20px; }
.ms-ppl-detail-avatar { width: 120px; height: 120px; border-radius: 20px; background: #e0e0e0; flex-shrink: 0; overflow: hidden; }
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 20px; }
.ms-ppl-detail-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.ms-ppl-star-btn { font-size: 20px; background: transparent; border: none; cursor: pointer; outline: none; line-height: 1; color: #d1d5db; transition: color .15s; padding: 0; flex-shrink: 0; }
.ms-ppl-star-btn.starred { color: #f59e0b; }
.ms-ppl-detail-aliases { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: #f0f0f0; border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: #5f6368; }
.ms-ppl-edit-fields { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
.ms-ppl-edit-field-row { display: flex; align-items: center; gap: 12px; }
.ms-ppl-edit-field-row--top { align-items: flex-start; }
.ms-ppl-edit-label { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; font-weight: 700; color: #202124; letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
.ms-ppl-view-box { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; min-height: 24px; display: flex; align-items: center; word-break: break-word; padding: 2px 0; }
.ms-ppl-view-name-box { font-size: 20px; font-weight: 700; flex: 1; min-width: 0; }
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: #3c4043; }
.ms-ppl-view-notes-box { flex: 1; min-width: 0; min-height: 24px; align-items: flex-start; color: #9aa0a6; font-size: 13px; white-space: pre-wrap; }
.ms-ppl-strip-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
.ms-ppl-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed #bdc1c6; background: #fff; font-size: 20px; color: #bdc1c6; display: grid; place-items: center; cursor: pointer; outline: none; flex-shrink: 0; transition: border-color .15s, color .15s; }
.ms-ppl-strip-add-btn:hover { border-color: #202124; color: #202124; }
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: #e8eaed; overflow: hidden; transition: border-color .15s; }
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: #202124; }
.ms-ppl-strip-arrow { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid #d1d5db; background: #fff; font-size: 18px; display: grid; place-items: center; cursor: pointer; color: #5f6368; outline: none; flex-shrink: 0; transition: background .15s; }
.ms-ppl-strip-arrow:hover { background: #f3f4f6; color: #202124; }
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
.ms-ppl-media-item { cursor: pointer; border-radius: 12px; overflow: visible; background: #f0f0f0; transition: transform .15s, box-shadow .15s; border: 1px solid #eee; }
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
.ms-ppl-media-thumb { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: #e8eaed; border-radius: 12px 12px 0 0; }
.ms-thumb-play-circle { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.18); border-radius: 12px 12px 0 0; }
.ms-thumb-play-circle svg { width: 38px; height: 38px; opacity: .7; transition: opacity .15s, transform .15s; }
.ms-ppl-media-item:hover .ms-thumb-play-circle svg { opacity: 1; transform: scale(1.08); }
.ms-ppl-media-dur { position: absolute; bottom: 5px; right: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
.ms-ppl-media-info { padding: 8px 10px 10px; background: #fff; }
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: #202124; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
.ms-ppl-media-sub { font-size: 10.5px; color: #9aa0a6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-ppl-delete-zone { margin-top: 48px; padding-bottom: 32px; }
.ms-ppl-delete-hr { border: none; border-top: 1.5px solid #f3f4f6; margin: 0 0 20px; }
.ms-ppl-delete-zone-btn { color: #d93025; border-color: #fecaca; background: #fff; }
.ms-ppl-delete-zone-btn:hover { background: #fef2f2; border-color: #d93025; }
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
.merge-input { width: 100%; padding: 10px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; margin-bottom: 16px; font-size: 0.9rem; outline: none; }
.merge-input:focus { border-color: #202124; }
.ms-modal-actions { display: flex; justify-content: flex-end; }
h2 { margin: 0 0 16px; font-size: 1rem; }
.ms-merge-grid { display: flex; flex-wrap: wrap; gap: 16px; max-height: 50vh; overflow-y: auto; }
.ms-silhouette { width: 100%; height: 100%; }
</style>