feat: PeopleView complete UI - search, tabs, bind, merge, delete
This commit is contained in:
@@ -2,14 +2,16 @@
|
||||
<div class="people-view">
|
||||
<div class="toolbar">
|
||||
<h1>People</h1>
|
||||
<input v-model="search" class="search-input" placeholder="Search people..." />
|
||||
<input v-model="searchQuery" class="search-input" placeholder="Search people..." @input="onSearch" />
|
||||
</div>
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="filteredPeople.length === 0" class="empty">No results</div>
|
||||
<div v-else class="grid">
|
||||
<div v-for="p in filteredPeople" :key="p.identity_uuid" class="person-card" @click="selectPerson(p)">
|
||||
<img v-if="profiles[p.identity_uuid]" class="avatar-img" :src="profiles[p.identity_uuid]" alt="" loading="lazy" @vue:mounted="loadProfile(p.identity_uuid)">
|
||||
<img v-if="profiles[p.identity_uuid]" class="avatar-img" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
|
||||
<div v-else class="avatar" @vue:mounted="loadProfile(p.identity_uuid)">{{ p.name?.[0]?.toUpperCase() || '?' }}</div>
|
||||
<p class="name">{{ p.name }}</p>
|
||||
<span v-if="p.starred" class="starred">⭐</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selected" class="detail-modal" @click.self="selected = null">
|
||||
@@ -26,77 +28,94 @@
|
||||
</div>
|
||||
<p class="uuid">{{ selected.identity_uuid }}</p>
|
||||
<div class="detail-actions">
|
||||
<button class="action-btn" @click="showCandidates = true">Bind Face</button>
|
||||
<button class="action-btn" @click="showMerge = true">Merge</button>
|
||||
<button class="action-btn delete" @click="confirmDelete">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-content">
|
||||
<div class="section"><h3>Faces ({{ faces.length }})</h3>
|
||||
<div class="face-strip">
|
||||
<div v-for="f in faces.slice(0, 20)" :key="f.id" class="face-thumb">
|
||||
<div class="face-placeholder">{{ Math.round(f.confidence * 100) }}%</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button :class="['tab', {active: activeTab==='faces'}]" @click="activeTab='faces'">Faces ({{ faces.length }})</button>
|
||||
<button :class="['tab', {active: activeTab==='traces'}]" @click="activeTab='traces'">Traces ({{ traces.length }})</button>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div v-if="activeTab === 'faces'" class="face-strip">
|
||||
<div v-for="f in faces.slice(0, 20)" :key="f.id" class="face-thumb">
|
||||
<div class="face-placeholder">{{ Math.round(f.confidence * 100) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section"><h3>Traces ({{ traces.length }})</h3>
|
||||
<div class="trace-list">
|
||||
<div v-for="t in traces.slice(0, 20)" :key="t.trace_id" class="trace-item" @click="playTrace(t)">
|
||||
<div class="trace-thumb">▶</div>
|
||||
<div class="trace-info">
|
||||
<span class="trace-time">{{ formatTime(t.first_sec) }} - {{ formatTime(t.last_sec) }}</span>
|
||||
<span class="trace-conf">{{ (t.avg_confidence * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div v-if="activeTab === 'traces'" class="trace-list">
|
||||
<div v-for="t in traces.slice(0, 20)" :key="t.trace_id" class="trace-item" @click="playTrace(t)">
|
||||
<div class="trace-thumb">▶</div>
|
||||
<div class="trace-info">
|
||||
<span class="trace-time">{{ formatTime(t.first_sec) }} - {{ formatTime(t.last_sec) }}</span>
|
||||
<span class="trace-conf">{{ (t.avg_confidence * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 v-if="showCandidates" class="detail-modal" @click.self="showCandidates = false">
|
||||
<div class="detail-box">
|
||||
<button class="close-btn" @click="showCandidates = false">×</button>
|
||||
<h2>Bind Face</h2>
|
||||
<div class="candidates-list">
|
||||
<div v-for="c in candidates" :key="c.id" class="candidate-item" @click="bindCandidate(c)">
|
||||
<span>File: {{ c.file_uuid.slice(0, 12) }}... Frame: {{ c.frame_number }}</span>
|
||||
<span class="conf">{{ (c.confidence * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showMerge" class="detail-modal" @click.self="showMerge = false">
|
||||
<div class="detail-box">
|
||||
<button class="close-btn" @click="showMerge = false">×</button>
|
||||
<h2>Merge Identity</h2>
|
||||
<input v-model="mergeTarget" class="merge-input" placeholder="Target identity UUID" />
|
||||
<button class="action-btn primary" :disabled="!mergeTarget" @click="confirmMerge">Merge</button>
|
||||
</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 } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
const people = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
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('')
|
||||
const candidates = ref<any[]>([])
|
||||
|
||||
const filteredPeople = computed(() => {
|
||||
if (!search.value) return people.value
|
||||
const s = search.value.toLowerCase()
|
||||
if (isSearching.value && searchResults.value.length) return searchResults.value
|
||||
if (!searchQuery.value) return people.value
|
||||
const s = searchQuery.value.toLowerCase()
|
||||
return people.value.filter((p: any) => p.name.toLowerCase().includes(s))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('PeopleView mounted')
|
||||
try {
|
||||
console.log('Calling get_people...')
|
||||
const result: any = await invoke('get_people', { page: 1, per_page: 1000 })
|
||||
console.log('get_people result:', result)
|
||||
people.value = Array.isArray(result) ? result : []
|
||||
console.log('People count:', people.value.length)
|
||||
} catch (e) {
|
||||
console.error('Failed to load people:', e)
|
||||
} finally {
|
||||
@@ -104,33 +123,37 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
let searchTimer: any
|
||||
function onSearch() {
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(async () => {
|
||||
if (!searchQuery.value.trim()) { isSearching.value = false; return }
|
||||
try {
|
||||
const results: any = await invoke('search_identities', { query: searchQuery.value, limit: 50 })
|
||||
searchResults.value = Array.isArray(results) ? results : []
|
||||
isSearching.value = true
|
||||
} catch (e) { console.error('Search failed:', e) }
|
||||
}, 300)
|
||||
}
|
||||
|
||||
async function loadProfile(uuid: string) {
|
||||
if (profiles.value[uuid]) return
|
||||
try {
|
||||
profiles.value[uuid] = await invoke('get_identity_profile', { uuid })
|
||||
} catch {
|
||||
// keep default avatar
|
||||
}
|
||||
try { profiles.value[uuid] = await invoke('get_identity_profile', { uuid }) } 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, per_page: 100 })
|
||||
const tResult: any = await invoke('get_traces', { uuid: p.identity_uuid, per_page: 100 })
|
||||
faces.value = Array.isArray(fResult) ? fResult : []
|
||||
traces.value = Array.isArray(tResult) ? tResult : []
|
||||
} catch (e) {
|
||||
console.error('Failed to load details:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function startEditName() {
|
||||
editingName.value = true
|
||||
editName.value = selected.value.name
|
||||
} catch (e) { console.error('Failed to load details:', e) }
|
||||
}
|
||||
|
||||
function startEditName() { editingName.value = true; editName.value = selected.value.name }
|
||||
async function saveName() {
|
||||
editingName.value = false
|
||||
if (editName.value === selected.value.name) return
|
||||
@@ -139,9 +162,7 @@ async function saveName() {
|
||||
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)
|
||||
}
|
||||
} catch (e) { console.error('Failed to update name:', e) }
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
@@ -150,26 +171,46 @@ async function confirmDelete() {
|
||||
await invoke('delete_identity', { uuid: selected.value.identity_uuid })
|
||||
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
|
||||
selected.value = null
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e)
|
||||
}
|
||||
} catch (e) { console.error('Failed to delete:', e) }
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
try {
|
||||
const result: any = await invoke('get_face_candidates', { page: 1, per_page: 50 })
|
||||
candidates.value = Array.isArray(result) ? result : []
|
||||
} catch (e) { console.error('Failed to load candidates:', e) }
|
||||
}
|
||||
|
||||
async function bindCandidate(c: any) {
|
||||
if (!selected.value) return
|
||||
try {
|
||||
await invoke('bind_face', { uuid: selected.value.identity_uuid, face_id: String(c.id), file_uuid: c.file_uuid })
|
||||
showCandidates.value = false
|
||||
if (selected.value) selectPerson(selected.value)
|
||||
} catch (e) { console.error('Bind failed:', e) }
|
||||
}
|
||||
|
||||
async function confirmMerge() {
|
||||
if (!selected.value || !mergeTarget.value) return
|
||||
try {
|
||||
await invoke('merge_identities', { uuid: selected.value.identity_uuid, into_uuid: mergeTarget.value })
|
||||
showMerge.value = false
|
||||
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
|
||||
selected.value = null
|
||||
} catch (e) { console.error('Merge failed:', e) }
|
||||
}
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function playTrace(t: any) {
|
||||
currentVideo.value = {
|
||||
fileUuid: t.file_uuid,
|
||||
startTime: t.first_sec,
|
||||
endTime: t.last_sec,
|
||||
title: `${selected.value?.name} - ${formatTime(t.first_sec)}-${formatTime(t.last_sec)}`
|
||||
}
|
||||
currentVideo.value = { fileUuid: t.file_uuid, startTime: t.first_sec, endTime: t.last_sec, title: `${selected.value?.name} - ${formatTime(t.first_sec)}-${formatTime(t.last_sec)}` }
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
watch(showCandidates, (v) => { if (v) loadCandidates() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -177,12 +218,13 @@ function playTrace(t: any) {
|
||||
.toolbar { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
|
||||
h1 { margin: 0; }
|
||||
.search-input { padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px; width: 300px; }
|
||||
.loading { text-align: center; padding: 40px; color: #6b7280; }
|
||||
.loading, .empty { text-align: center; padding: 40px; color: #6b7280; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 16px; }
|
||||
.person-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; text-align: center; cursor: pointer; }
|
||||
.person-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; text-align: center; cursor: pointer; position: relative; }
|
||||
.person-card:hover { border-color: #4f46e5; }
|
||||
.avatar, .avatar-img { width: 56px; height: 56px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 600; margin: 0 auto 8px; object-fit: cover; }
|
||||
.name { font-size: 0.75rem; color: #374151; }
|
||||
.name { font-size: 0.75rem; color: #374151; margin: 0; }
|
||||
.starred { position: absolute; top: 8px; right: 8px; font-size: 0.8rem; }
|
||||
.detail-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||
.detail-box { background: #fff; border-radius: 20px; padding: 32px; width: min(700px, 90vw); max-height: 80vh; overflow-y: auto; position: relative; }
|
||||
.close-btn { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; border-radius: 50%; background: #f3f4f6; border: none; font-size: 1.2rem; cursor: pointer; }
|
||||
@@ -191,17 +233,18 @@ h1 { margin: 0; }
|
||||
.detail-info { flex: 1; }
|
||||
.name-row { display: flex; align-items: center; gap: 8px; }
|
||||
.name-row h2 { margin: 0; cursor: pointer; }
|
||||
.name-row h2:hover { text-decoration: underline; }
|
||||
.name-input { font-size: 1.25rem; font-weight: 700; border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; width: 200px; }
|
||||
.edit-btn { background: none; border: none; cursor: pointer; font-size: 1rem; color: #6b7280; padding: 4px; }
|
||||
.edit-btn:hover { color: #4f46e5; }
|
||||
.edit-btn { background: none; border: none; cursor: pointer; font-size: 1rem; color: #6b7280; }
|
||||
.uuid { color: #9ca3af; font-size: 0.8rem; font-family: monospace; margin: 4px 0 8px; }
|
||||
.detail-actions { display: flex; gap: 8px; }
|
||||
.action-btn { padding: 6px 14px; border-radius: 8px; border: 1px solid #d1d5db; background: #fff; cursor: pointer; font-size: 0.85rem; }
|
||||
.action-btn.primary { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.action-btn.delete { color: #dc2626; border-color: #fca5a5; }
|
||||
.action-btn.delete:hover { background: #fef2f2; }
|
||||
.section { margin-bottom: 24px; }
|
||||
.section h3 { margin-bottom: 12px; font-size: 1rem; }
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.tab { padding: 8px 16px; border: 1px solid #d1d5db; background: #fff; border-radius: 8px; cursor: pointer; }
|
||||
.tab.active { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
|
||||
.face-thumb { width: 52px; height: 52px; border-radius: 8px; background: #e5e7eb; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.face-placeholder { font-size: 0.6rem; color: #6b7280; }
|
||||
@@ -212,4 +255,10 @@ h1 { margin: 0; }
|
||||
.trace-info { display: flex; gap: 12px; flex: 1; }
|
||||
.trace-time { color: #374151; font-size: 0.85rem; }
|
||||
.trace-conf { color: #4f46e5; font-weight: 500; font-size: 0.85rem; }
|
||||
.candidates-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.candidate-item { display: flex; justify-content: space-between; padding: 8px 12px; background: #f9fafb; border-radius: 8px; cursor: pointer; }
|
||||
.candidate-item:hover { background: #eef2ff; }
|
||||
.conf { color: #4f46e5; font-weight: 500; }
|
||||
.merge-input { width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; margin-bottom: 12px; }
|
||||
h2 { margin: 0 0 16px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user