Files
momentry_studio/src/views/PeopleView.vue

389 lines
20 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">
<div class="ms-ppl-toolbar">
<h1 class="ms-ppl-section-title">People</h1>
<div class="ms-ppl-search-wrap">
<span class="ms-ppl-search-icon">🔍</span>
<input v-model="searchQuery" class="ms-ppl-search-input" placeholder="Search people..." @input="onSearch" />
</div>
</div>
<div v-if="loading" class="loading-state">
<div class="spinner-lg"></div>
<p>Loading...</p>
</div>
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0" class="empty">
No people found. people.value.length = {{ people.length }}
</div>
<template v-else>
<!-- 已知人物 -->
<div v-if="confirmedPeople.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">已知人物</div>
</div>
<div class="ms-ppl-face-grid">
<div v-for="p in confirmedPeople" :key="p.identity_uuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
<div class="ms-ppl-face-img-wrap">
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" 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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }"></span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
</div>
</div>
<hr class="ms-ppl-hr">
<!-- 待定人物 -->
<div v-if="pendingPeople.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">待定人物</div>
</div>
<div class="ms-ppl-face-grid">
<div v-for="p in pendingPeople" :key="p.identity_uuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
<div class="ms-ppl-face-img-wrap">
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" 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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }"></span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
</div>
</div>
<hr class="ms-ppl-hr">
<!-- 已略過 -->
<div v-if="skippedPeople.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title skipped-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="flex-shrink:0;">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"></circle>
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
</svg>
已略過
</div>
</div>
<div class="ms-ppl-face-grid">
<div v-for="p in skippedPeople" :key="p.identity_uuid" class="ms-ppl-face-card skipped-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
<div class="ms-ppl-face-img-wrap">
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" 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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }"></span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
</div>
</div>
<hr class="ms-ppl-hr">
<!-- 待定人臉 -->
<div v-if="faceCandidates.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">待定人臉</div>
</div>
<div class="ms-ppl-face-grid ms-uface-grid">
<div v-for="c in faceCandidates.slice(0, 50)" :key="c.id" class="ms-ppl-face-card" @click="showAssignModal(c)">
<div class="ms-ppl-face-img-wrap">
<img v-if="candidateThumbs[c.file_uuid]" :src="candidateThumbs[c.file_uuid]" alt="" @vue:mounted="loadCandidateThumb(c.file_uuid)">
<div v-else class="face-placeholder" @vue:mounted="loadCandidateThumb(c.file_uuid)">{{ Math.round(c.confidence * 100) }}%</div>
</div>
<span class="ms-ppl-face-name">{{ c.file_uuid.slice(0, 8) }}... #{{ c.frame_number }}</span>
</div>
</div>
</div>
</template>
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px', display: 'block' }">
<button class="ms-ctx-item" @click="ctxAction('star')">{{ ctxMenu.person?.starred ? ' 取消重要人物' : ' 標為重要人物' }}</button>
<hr class="ms-ctx-menu-divider">
<button class="ms-ctx-item" @click="ctxAction('rename')"> 編輯名稱</button>
<button class="ms-ctx-item" @click="ctxAction('merge')"> 已有此人物</button>
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')"> 略過此人物</button>
</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'
console.log('PeopleView script loaded')
const router = useRouter()
import VideoPlayer from '../components/VideoPlayer.vue'
const people = ref<any[]>([])
const loading = ref(true)
const searchQuery = ref('')
const searchResults = ref<any[]>([])
const isSearching = ref(false)
const selected = ref<any>(null)
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 showCandidates = ref(false)
const showMerge = ref(false)
const mergeTarget = ref('')
const candidates = ref<any[]>([])
const candidateThumbs = ref<Record<string, string>>({})
const faceCandidates = ref<any[]>([])
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
const confirmedPeople = computed(() => {
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
return filtered.filter((p: any) => p.status === 'confirmed')
})
const pendingPeople = computed(() => {
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
return filtered.filter((p: any) => p.status === 'pending')
})
const skippedPeople = computed(() => {
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
return filtered.filter((p: any) => p.status === 'skipped')
})
onMounted(async () => {
try {
console.log('PeopleView: calling getPeople...')
const result: any = await invoke('getPeople', { page: 1, perPage: 1000 })
console.log('PeopleView: getPeople result:', Array.isArray(result) ? result.length : typeof result)
people.value = Array.isArray(result) ? result : []
console.log('PeopleView: people.value length:', people.value.length)
if (people.value.length > 0) {
console.log('PeopleView: first person:', JSON.stringify(people.value[0]))
}
} catch (e) {
console.error('Failed to load people:', e)
} finally {
loading.value = false
}
try {
const fc: any = await invoke('getFaceCandidates', { page: 1, perPage: 100 })
faceCandidates.value = Array.isArray(fc) ? fc : []
} catch (e) {
console.error('Failed to load face candidates:', e)
}
document.addEventListener('click', closeCtxMenu)
})
onUnmounted(() => {
document.removeEventListener('click', closeCtxMenu)
})
let searchTimer: any
function onSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
if (!searchQuery.value.trim()) { isSearching.value = false; return }
try {
const results: any = await invoke('searchIdentities', { 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('getIdentityProfile', { uuid }) } catch {}
}
async function loadCandidateThumb(uuid: string) {
if (!uuid || candidateThumbs.value[uuid]) return
try { candidateThumbs.value[uuid] = await invoke('getThumbnail', { uuid, frame: 30 }) } catch {}
}
function selectPerson(p: any) {
router.push({ name: 'PersonDetail', params: { uuid: p.identity_uuid } })
}
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 confirmDelete() {
if (!confirm(`Delete "${selected.value.name}"?`)) return
try {
await invoke('deleteIdentity', { 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) }
}
async function loadCandidates() {
try {
const result: any = await invoke('getFaceCandidates', { page: 1, perPage: 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('bindFace', { uuid: selected.value.identity_uuid, faceId: String(c.id), fileUuid: 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('mergeIdentities', { uuid: selected.value.identity_uuid, intoUuid: 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 showAssignModal(c: any) {
alert(`Assign face ${c.id} to existing person - not yet implemented`)
}
function formatTime(sec: number): string {
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)}` }
playing.value = true
}
function showContextMenu(e: MouseEvent, p: any) {
e.preventDefault()
e.stopPropagation()
ctxMenu.value = { show: true, x: e.clientX, y: e.clientY, person: p }
}
function ctxAction(action: string) {
const p = ctxMenu.value.person
if (!p) return
ctxMenu.value.show = false
if (action === 'star') {
p.starred = !p.starred
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
if (idx >= 0) people.value[idx].starred = p.starred
} else if (action === 'skip') {
invoke('updateIdentityStatus', { uuid: p.identity_uuid, status: 'skipped' }).then(() => {
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
if (idx >= 0) people.value[idx].status = 'skipped'
}).catch(e => console.error('Skip failed:', e))
} else if (action === 'confirm') {
invoke('updateIdentityStatus', { uuid: p.identity_uuid, status: 'confirmed' }).then(() => {
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' || action === 'merge') {
selectPerson(p)
}
}
function closeCtxMenu(e?: MouseEvent) {
if (e && e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
ctxMenu.value.show = false
}
watch(showCandidates, (v) => { if (v) loadCandidates() })
</script>
<style scoped>
.ms-ppl-card-star { position: absolute; top: 6px; left: 6px; font-size: 16px; color: #f59e0b; text-shadow: 0 1px 3px rgba(0,0,0,.25); display: none; }
.ms-ppl-face-card.starred .ms-ppl-card-star, .ms-ppl-card-star.starred { display: block; }
.people-view { max-width: 1200px; }
h1 { margin: 0; }
.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); } }
.ms-ppl-detail-view { display: none; }
.ms-ppl-detail-view.show { display: block; }
.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-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; cursor: pointer; }
.ms-ppl-view-name-box:hover { text-decoration: underline; }
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: #3c4043; }
.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-uface-grid .ms-ppl-face-card { width: 120px; }
.ms-uface-grid .ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; }
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
.close-btn { position: absolute; top: 16px; right: 16px; }
.detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
.detail-avatar { width: 80px; height: 80px; border-radius: 16px; flex-shrink: 0; }
.detail-info { flex: 1; }
.name-row { display: flex; align-items: center; gap: 8px; }
.name-row h2 { margin: 0; cursor: pointer; font-size: 1.25rem; }
.name-row h2:hover { text-decoration: underline; }
.name-input { font-size: 1.25rem; font-weight: 700; border: 1.5px solid #d1d5db; border-radius: 8px; padding: 4px 8px; width: 200px; outline: none; }
.edit-btn { width: 28px; height: 28px; }
.uuid { color: #9aa0a6; font-size: 0.8rem; font-family: monospace; margin: 4px 0 8px; }
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tab { padding: 8px 16px; border: 1.5px solid #d1d5db; background: #fff; border-radius: 10px; cursor: pointer; font-size: 0.85rem; }
.tab.active { background: #202124; color: #fff; border-color: #202124; }
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
.face-thumb { width: 52px; height: 52px; border-radius: 8px; background: #e8eaed; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
.ms-ppl-media-item { cursor: pointer; }
.ms-ppl-media-thumb { position: relative; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; }
.thumb-play { color: #fff; font-size: 1.2rem; opacity: 0.8; }
.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-ctx-menu { position: fixed; z-index: 99999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 160px; font-size: 13px; color: #222; }
.ms-ctx-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-radius: 8px; border: none; background: transparent; width: 100%; text-align: left; font-size: 13px; color: #222; font-family: inherit; }
.ms-ctx-item:hover { background: #f3f4f6; }
.ms-ctx-item.ms-ctx-danger { color: #d93025; }
.ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; }
.ms-ctx-menu-divider { height: 1px; background: #eee; margin: 4px 8px; }
.ms-ppl-section { margin-bottom: 8px; }
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.ms-ppl-section-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 14px; font-weight: 600; color: #202124; margin: 0; display: flex; align-items: center; gap: 6px; }
.ms-ppl-hr { border: none; border-top: 1.5px solid #e8eaed; margin: 24px 0; }
.skipped-title { color: #9aa0a6; }
.skipped-card .ms-ppl-face-img-wrap { filter: grayscale(0.6); opacity: 0.7; }
.skipped-card .ms-ppl-face-name { color: #bdc1c6; }
</style>