feat: pending identity sorting, face detail modal, video player fps, processor counts
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user