feat: Person detail as independent page with route

This commit is contained in:
2026-06-15 01:46:50 +08:00
parent 9a6abcbdcb
commit 488b4d5e2e
3 changed files with 255 additions and 117 deletions

View File

@@ -110,99 +110,16 @@
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')"> 略過此人物</button>
</div>
<div v-if="selected" class="ms-ppl-view ms-ppl-detail-view" :class="{ show: !!selected }">
<div class="ms-ppl-detail-header">
<div class="ms-ppl-detail-avatar">
<img v-if="selectedProfile" :src="selectedProfile" 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 style="flex:1;min-width:0;">
<div class="ms-ppl-detail-name-row">
<button class="ms-ppl-star-btn" :class="{ starred: selected.starred }" @click="toggleStar"></button>
<div class="ms-ppl-view-box ms-ppl-view-name-box" @click="startEditName">{{ selected.name || '' }}</div>
<button class="ms-fm-btn ms-fm-btn-danger" style="margin-left:auto;padding:4px 10px;font-size:12px;" @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"></polyline>
<path d="M19 6l-1 14H6L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
刪除
</button>
</div>
<div class="ms-ppl-detail-aliases" v-if="selected.metadata?.aliases?.length">
<span v-for="(a, i) in selected.metadata.aliases" :key="i" class="ms-ppl-alias-chip">{{ a.name }}</span>
</div>
<div class="ms-ppl-edit-fields">
<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">{{ selected.metadata?.role || '—' }}</div>
</div>
</div>
</div>
<button class="ms-fm-icon-btn" style="position:absolute;top:12px;right:12px;" @click="selected = null">×</button>
</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, 20)" :key="f.id" class="ms-ppl-strip-face">
<div class="ms-ppl-strip-face-img">
<div class="face-placeholder" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;">{{ Math.round(f.confidence * 100) }}%</div>
</div>
</div>
</div>
</div>
<div class="ms-ppl-media-label" v-if="traces.length">Media ({{ traces.length }})</div>
<div class="ms-ppl-media-grid">
<div v-for="t in traces.slice(0, 20)" :key="t.trace_id" class="ms-ppl-media-item" @click="playTrace(t)">
<div class="ms-ppl-media-thumb">
<div class="thumb-play"></div>
</div>
<div class="ms-ppl-media-info">
<div class="ms-ppl-media-title">{{ formatTime(t.first_sec) }} - {{ formatTime(t.last_sec) }}</div>
<div class="ms-ppl-media-sub">{{ (t.avg_confidence * 100).toFixed(0) }}%</div>
</div>
</div>
</div>
</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="candidateThumbs[c.file_uuid]" :src="candidateThumbs[c.file_uuid]" alt="">
<div v-else class="face-placeholder" @vue:mounted="loadCandidateThumb(c.file_uuid)">{{ 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, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
const router = useRouter()
import VideoPlayer from '../components/VideoPlayer.vue'
const people = ref<any[]>([])
@@ -211,15 +128,11 @@ const searchQuery = ref('')
const searchResults = ref<any[]>([])
const isSearching = ref(false)
const selected = ref<any>(null)
const activeTab = ref('faces')
const faces = ref<any[]>([])
const traces = ref<any[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
const profiles = ref<Record<string, string>>({})
const selectedProfile = ref<string>('')
const editingName = ref(false)
const editName = ref('')
const showCandidates = ref(false)
const showMerge = ref(false)
const mergeTarget = ref('')
@@ -294,34 +207,16 @@ async function loadCandidateThumb(uuid: string) {
try { candidateThumbs.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 }) } catch {}
}
async function selectPerson(p: any) {
selected.value = p
selectedProfile.value = profiles.value[p.identity_uuid] || ''
activeTab.value = 'faces'
try {
const fResult: any = await invoke('get_faces', { uuid: p.identity_uuid, perPage: 100 })
const tResult: any = await invoke('get_traces', { uuid: p.identity_uuid, perPage: 100 })
faces.value = Array.isArray(fResult) ? fResult : []
traces.value = Array.isArray(tResult) ? tResult : []
} catch (e) { console.error('Failed to load details:', e) }
function selectPerson(p: any) {
router.push({ name: 'PersonDetail', params: { uuid: p.identity_uuid } })
}
function startEditName() { editingName.value = true; editName.value = selected.value.name }
async function toggleStar() {
if (!selected.value) return
selected.value.starred = !selected.value.starred
const idx = people.value.findIndex((x: any) => x.identity_uuid === selected.value.identity_uuid)
if (idx >= 0) people.value[idx].starred = selected.value.starred
}
async function saveName() {
editingName.value = false
if (editName.value === selected.value.name) return
try {
await invoke('update_identity_name', { uuid: selected.value.identity_uuid, name: editName.value })
selected.value.name = editName.value
const idx = people.value.findIndex((p: any) => p.identity_uuid === selected.value.identity_uuid)
if (idx >= 0) people.value[idx].name = editName.value
} catch (e) { console.error('Failed to update name:', e) }
}
async function confirmDelete() {
if (!confirm(`Delete "${selected.value.name}"?`)) return
@@ -396,12 +291,8 @@ function ctxAction(action: string) {
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
if (idx >= 0) people.value[idx].status = 'confirmed'
}).catch(e => console.error('Confirm failed:', e))
} else if (action === 'rename') {
} else if (action === 'rename' || action === 'merge') {
selectPerson(p)
setTimeout(() => startEditName(), 100)
} else if (action === 'merge') {
selectPerson(p)
setTimeout(() => { showMerge.value = true }, 100)
}
}