feat: pending identity sorting, face detail modal, video player fps, processor counts

This commit is contained in:
2026-06-24 02:02:33 +08:00
parent 7855983dc1
commit f915aaf794
9 changed files with 1479 additions and 324 deletions

View File

@@ -191,6 +191,7 @@
<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('detail')">📋 詳情</button>
<button class="ms-ctx-item" @click="ctxAction('confirm')" v-if="ctxMenu.person?.status !== 'confirmed'">✓ 確認人物</button>
<button class="ms-ctx-item" @click="ctxAction('pending')" v-if="ctxMenu.person?.status !== 'pending'">待定</button>
<button class="ms-ctx-item" @click="ctxAction('rename')">✎ 編輯名稱</button>
@@ -214,6 +215,7 @@
<!-- Face candidate context menu -->
<div v-if="faceCtxMenu.show" class="ms-ctx-menu" :style="{ left: faceCtxMenu.x + 'px', top: faceCtxMenu.y + 'px', display: 'block' }">
<button class="ms-ctx-item" @click="faceCtxAction('detail')">📋 詳情</button>
<button class="ms-ctx-item" @click="faceCtxAction('assign')">⇄ 指派給現有人物</button>
<button class="ms-ctx-item ms-ctx-danger" @click="faceCtxAction('skip')">✕ 略過此人臉</button>
</div>
@@ -256,6 +258,62 @@
</div>
</div>
<!-- Face detail modal -->
<div v-if="faceDetailModal.show" class="ms-modal-overlay show" @click.self="faceDetailModal.show = false">
<div class="ms-modal ms-modal-face-detail">
<button class="ms-fm-icon-btn close-btn" @click="faceDetailModal.show = false">×</button>
<div class="ms-face-detail-header">
<div class="ms-face-detail-thumb">
<img v-if="candidateThumbs[faceDetailModal.candidate?.id]" :src="candidateThumbs[faceDetailModal.candidate?.id]" alt="">
<div v-else class="face-placeholder">{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%</div>
</div>
<div class="ms-face-detail-info">
<div class="ms-face-detail-title">待定人臉詳情</div>
<div class="ms-face-detail-row">
<span class="ms-face-detail-label">檔案:</span>
<span class="ms-face-detail-value">{{ faceDetailModal.candidate?.file_uuid?.slice(0, 12) }}...</span>
</div>
<div class="ms-face-detail-row">
<span class="ms-face-detail-label">帧號:</span>
<span class="ms-face-detail-value">#{{ faceDetailModal.candidate?.frame_number }}</span>
</div>
<div class="ms-face-detail-row">
<span class="ms-face-detail-label">置信度:</span>
<span class="ms-face-detail-value">{{ Math.round(faceDetailModal.candidate?.confidence * 100) }}%</span>
</div>
<div class="ms-face-detail-row">
<span class="ms-face-detail-label">Face ID:</span>
<span class="ms-face-detail-value">{{ faceDetailModal.candidate?.face_id || faceDetailModal.candidate?.id }}</span>
</div>
</div>
</div>
<div class="ms-face-detail-video">
<button class="ms-fm-btn ms-fm-btn-primary" @click="playFaceDetailVideo" :disabled="!faceDetailModal.candidate?.file_uuid">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="margin-right:6px;">
<polygon points="5 3 19 12 5 21 5 3" fill="currentColor"></polygon>
</svg>
播放影片片段
</button>
</div>
<hr class="ms-face-detail-divider">
<div class="ms-face-detail-new-identity">
<div class="ms-face-detail-new-title">新增為新人物</div>
<div class="ms-face-detail-new-input-row">
<input v-model="faceDetailModal.newIdentityName" class="ms-ppl-edit-input" placeholder="輸入人物名稱...">
<button class="ms-fm-btn ms-fm-btn-primary" @click="createNewIdentityFromFace" :disabled="!faceDetailModal.newIdentityName.trim()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="margin-right:4px;">
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
新增
</button>
</div>
</div>
<div class="ms-face-detail-footer">
<button class="ms-fm-btn" @click="faceDetailModal.show = false">關閉</button>
</div>
</div>
</div>
<VideoPlayer v-if="playing" simple :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
</div>
</template>
@@ -286,11 +344,13 @@ const mergeTarget = ref('')
const candidates = ref<any[]>([])
const candidateThumbs = faceThumbsCache
const faceCandidates = faceCandidatesCache
const traceCounts = ref<Record<string, number>>({})
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
const faceCtxMenu = ref({ show: false, x: 0, y: 0, candidate: null as any })
const assignModal = ref({ show: false, candidate: null as any })
const assignSearchQuery = ref('')
const assignSelected = ref<any>(null)
const faceDetailModal = ref({ show: false, candidate: null as any, newIdentityName: '' })
// Section visibility toggles
const showPending = ref(true)
@@ -360,7 +420,13 @@ const pendingPeople = computed(() => {
let base = people.value.filter((p: any) => p.status === 'pending')
if (starFilter.value) base = base.filter((p: any) => p.starred)
if (pendingSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(pendingSearch.value.toLowerCase()))
return sortPeople(base, pendingSort.value)
// Sort by trace count (descending) - more traces = more suggestions
const sorted = [...base].sort((a: any, b: any) => {
const aCount = traceCounts.value[a.identity_uuid] || 0
const bCount = traceCounts.value[b.identity_uuid] || 0
return bCount - aCount
})
return sorted
})
const skippedPeople = computed(() => {
@@ -378,16 +444,28 @@ const assignSearchResults = computed(() => {
})
async function refresh() {
ensurePeople().then(() => {
for (const p of people.value.slice(0, 30)) {
if (p.identity_uuid) loadProfile(p.identity_uuid)
await ensurePeople()
for (const p of people.value.slice(0, 30)) {
if (p.identity_uuid) loadProfile(p.identity_uuid)
}
await ensureFaceCandidates()
for (const c of faceCandidates.value.slice(0, 20)) {
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
}
// Load trace counts for pending identities
const pending = people.value.filter((p: any) => p.status === 'pending')
const counts: Record<string, number> = {}
for (const p of pending) {
if (p.identity_uuid) {
try {
const traces: any = await apiCall('get_traces', { uuid: p.identity_uuid, perPage: 1 })
counts[p.identity_uuid] = traces?.total || 0
} catch (e) {
counts[p.identity_uuid] = 0
}
}
})
ensureFaceCandidates().then(() => {
for (const c of faceCandidates.value.slice(0, 20)) {
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
}
})
}
traceCounts.value = counts
}
onMounted(async () => {
@@ -561,6 +639,8 @@ async function ctxAction(action: string, extra?: any) {
if (idx >= 0) people.value[idx].status = 'pending'
refreshUndoCounts(uuid)
}).catch(e => console.error('Pending failed:', e))
} else if (action === 'detail') {
router.push(`/people/${uuid}`)
} else if (action === 'rename' || action === 'merge') {
selectPerson(p)
} else if (action === 'undo') {
@@ -601,13 +681,79 @@ function faceCtxAction(action: string) {
const c = faceCtxMenu.value.candidate
if (!c) return
faceCtxMenu.value.show = false
if (action === 'assign') {
if (action === 'detail') {
faceDetailModal.value = { show: true, candidate: c, newIdentityName: '' }
loadCandidateThumb(c)
} else if (action === 'assign') {
openAssignModal(c)
} else if (action === 'skip') {
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
}
}
async function playFaceDetailVideo() {
const c = faceDetailModal.value.candidate
if (!c || !c.file_uuid) return
const frame = c.frame_number || 0
try {
const fileInfo: any = await apiCall('get_file_info', { uuid: c.file_uuid })
const fps = fileInfo?.fps || 30
const frameTime = frame / fps
const startTime = Math.max(0, frameTime - 5)
const endTime = frameTime + 5
currentVideo.value = {
fileUuid: c.file_uuid,
startTime,
endTime,
title: `Face #${frame} (${Math.round(frameTime)}s)`
}
playing.value = true
} catch (e) {
console.error('Failed to get file info:', e)
const frameTime = frame / 30
currentVideo.value = {
fileUuid: c.file_uuid,
startTime: Math.max(0, frameTime - 5),
endTime: frameTime + 5,
title: `Face #${frame}`
}
playing.value = true
}
}
async function createNewIdentityFromFace() {
const c = faceDetailModal.value.candidate
const name = faceDetailModal.value.newIdentityName.trim()
console.log('[createNewIdentityFromFace] candidate:', c, 'name:', name)
if (!c || !name) {
console.log('[createNewIdentityFromFace] missing candidate or name')
return
}
try {
console.log('[createNewIdentityFromFace] calling API with fileUuid:', c.file_uuid)
const result: any = await apiCall('create_identity_from_face', {
name,
fileUuid: c.file_uuid
})
console.log('[createNewIdentityFromFace] result:', result)
if (result?.identity_uuid) {
faceDetailModal.value.show = false
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
invalidatePeople()
await ensurePeople()
// Remove hyphens from uuid for consistent format
const cleanUuid = result.identity_uuid.replace(/-/g, '')
router.push(`/people/${cleanUuid}`)
} else {
console.log('[createNewIdentityFromFace] no identity_uuid in result')
alert('新增人物失敗:API 未返回 identity_uuid')
}
} catch (e) {
console.error('[createNewIdentityFromFace] error:', e)
alert('新增人物失敗:' + String(e))
}
}
function openAssignModal(c: any) {
assignModal.value = { show: true, candidate: c }
assignSearchQuery.value = ''
@@ -750,4 +896,22 @@ h1 { margin: 0; }
.ms-fm-btn-primary { background: #202124; color: #fff; border-color: #202124; }
.ms-fm-btn-primary:hover { background: #3c4043; }
.ms-fm-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
/* Face detail modal */
.ms-modal-face-detail { max-width: 420px; width: 92%; padding: 24px 28px; background: #fff; border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,.2); position: relative; }
.ms-face-detail-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
.ms-face-detail-thumb { width: 100px; height: 100px; border-radius: 14px; background: #e8eaed; flex-shrink: 0; overflow: hidden; }
.ms-face-detail-thumb img { width: 100%; height: 100%; object-fit: cover; }
.ms-face-detail-info { flex: 1; min-width: 0; }
.ms-face-detail-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: #202124; margin: 0 0 12px; }
.ms-face-detail-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.ms-face-detail-label { font-size: 12px; color: #5f6368; min-width: 60px; }
.ms-face-detail-value { font-size: 12px; color: #202124; }
.ms-face-detail-video { margin-bottom: 16px; }
.ms-face-detail-divider { height: 1px; background: #e8eaed; margin: 16px 0; }
.ms-face-detail-new-identity { margin-top: 16px; }
.ms-face-detail-new-title { font-size: 14px; font-weight: 500; color: #202124; margin-bottom: 12px; }
.ms-face-detail-new-input-row { display: flex; gap: 8px; }
.ms-face-detail-new-input-row .ms-ppl-edit-input { flex: 1; }
.ms-face-detail-footer { display: flex; justify-content: flex-end; margin-top: 20px; }
</style>