fix: m4mini deployment issues - duplicate script setup, auth redirect, Tauri bypass
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div class="people-view">
|
||||
<div class="ms-ppl-toolbar">
|
||||
<select v-model="selectedFileUuid" @change="onFileFilterChange" class="ms-ppl-file-select">
|
||||
<option value="">全部檔案</option>
|
||||
<option v-for="f in fileFilterList" :key="f.file_uuid" :value="f.file_uuid">{{ f.file_name }}</option>
|
||||
</select>
|
||||
<button class="ms-fm-btn ms-ppl-star-toggle-btn" @click="starFilter = !starFilter">
|
||||
<span class="ms-ppl-star-icon" :class="{ starred: starFilter }">★</span>
|
||||
<span>{{ starFilter ? '查看所有人物' : '查看重要人物' }}</span>
|
||||
@@ -22,7 +26,7 @@
|
||||
<div class="spinner-lg"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0" class="empty">
|
||||
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0 && unassignedTraces.length === 0" class="empty">
|
||||
No people found.
|
||||
</div>
|
||||
<template v-else>
|
||||
@@ -170,18 +174,18 @@
|
||||
|
||||
<hr v-if="(showSkipped && skippedPeople.length) || (showPending && pendingPeople.length) || confirmedPeople.length" class="ms-ppl-hr">
|
||||
|
||||
<!-- 待定人臉 -->
|
||||
<div v-if="showUface && faceCandidates.length" class="ms-ppl-section">
|
||||
<!-- 待定人臉 -->
|
||||
<div v-if="showUface && unassignedTraces.length" class="ms-ppl-section">
|
||||
<div class="ms-ppl-section-toolbar">
|
||||
<div class="ms-ppl-section-title">待定人臉:</div>
|
||||
<div class="ms-ppl-section-title">待定人臉: <span class="ms-ppl-section-count">({{ unassignedTraces.length }})</span></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="openAssignModal(c)" @contextmenu.prevent="showFaceCtxMenu($event, c)" v-observe="() => loadCandidateThumb(c)">
|
||||
<div v-for="t in unassignedTraces.slice(0, 100)" :key="`${t.trace_id}-${t.file_uuid}`" class="ms-ppl-face-card" @click="openTraceAssignModal(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)" v-observe="() => loadTraceThumb(t)">
|
||||
<div class="ms-ppl-face-img-wrap">
|
||||
<img v-if="candidateThumbs[c.id]" :src="candidateThumbs[c.id]" alt="">
|
||||
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
|
||||
<img v-if="faceThumbsCache[`${t.trace_id}-${t.file_uuid}`]" :src="faceThumbsCache[`${t.trace_id}-${t.file_uuid}`]" alt="">
|
||||
<div v-else class="face-placeholder">{{ t.frame_count }}帧</div>
|
||||
</div>
|
||||
<span class="ms-ppl-face-name">{{ (c.file_uuid || '').slice(0, 8) }}... #{{ c.frame_number }}</span>
|
||||
<span class="ms-ppl-face-name">{{ (t.file_uuid || '').slice(0, 8) }}... T{{ String(t.trace_id ?? '').slice(-4) || '?' }} ({{ t.frame_count }}帧)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,7 +326,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { apiCall } from '@/api'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, invalidatePeople } from '@/store'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, ensureUnassignedTraces, unassignedTracesCache, unassignedTracesLoaded, ensureFiles, filesCache, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, loadTraceThumb, invalidatePeople } from '@/store'
|
||||
import { useUndoRedo } from '@/composables/useUndoRedo'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -344,6 +348,16 @@ const mergeTarget = ref('')
|
||||
const candidates = ref<any[]>([])
|
||||
const candidateThumbs = faceThumbsCache
|
||||
const faceCandidates = faceCandidatesCache
|
||||
// File filter
|
||||
const selectedFileUuid = ref('')
|
||||
|
||||
const unassignedTraces = computed(() => {
|
||||
const all = unassignedTracesCache.value
|
||||
if (!selectedFileUuid.value) return all
|
||||
const filtered = all.filter((t: any) => t.file_uuid === selectedFileUuid.value)
|
||||
return filtered
|
||||
})
|
||||
|
||||
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 })
|
||||
@@ -351,6 +365,13 @@ 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: '' })
|
||||
const fileFilterList = computed(() => {
|
||||
const files = filesCache.value
|
||||
return files.filter((f: any) => f.isRegistered && f.status === 'completed').map((f: any) => ({
|
||||
file_uuid: f.file_uuid?.replace(/-/g, ''),
|
||||
file_name: f.file_name
|
||||
}))
|
||||
})
|
||||
|
||||
// Section visibility toggles
|
||||
const showPending = ref(true)
|
||||
@@ -418,9 +439,11 @@ const confirmedPeople = computed(() => {
|
||||
|
||||
const pendingPeople = computed(() => {
|
||||
let base = people.value.filter((p: any) => p.status === 'pending')
|
||||
if (selectedFileUuid.value) {
|
||||
base = base.filter((p: any) => (p.file_uuids || []).includes(selectedFileUuid.value))
|
||||
}
|
||||
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()))
|
||||
// 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
|
||||
@@ -445,9 +468,14 @@ const assignSearchResults = computed(() => {
|
||||
|
||||
async function refresh() {
|
||||
await ensurePeople()
|
||||
await ensureFiles()
|
||||
for (const p of people.value.slice(0, 30)) {
|
||||
if (p.identity_uuid) loadProfile(p.identity_uuid)
|
||||
}
|
||||
await ensureUnassignedTraces()
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
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)
|
||||
@@ -468,6 +496,22 @@ async function refresh() {
|
||||
traceCounts.value = counts
|
||||
}
|
||||
|
||||
async function onFileFilterChange() {
|
||||
if (selectedFileUuid.value) {
|
||||
unassignedTracesLoaded.value = false
|
||||
await ensureUnassignedTraces(selectedFileUuid.value)
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
} else {
|
||||
unassignedTracesLoaded.value = false
|
||||
await ensureUnassignedTraces()
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
document.addEventListener('click', closeCtxMenu)
|
||||
@@ -724,28 +768,22 @@ async function playFaceDetailVideo() {
|
||||
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) {
|
||||
@@ -761,6 +799,37 @@ function openAssignModal(c: any) {
|
||||
loadCandidateThumb(c)
|
||||
}
|
||||
|
||||
function openTraceAssignModal(t: any) {
|
||||
// Convert trace to candidate-like object for the assign modal
|
||||
const candidateLike = {
|
||||
id: t.trace_id,
|
||||
file_uuid: t.file_uuid,
|
||||
frame_number: t.best_face_frame || t.start_frame,
|
||||
confidence: t.best_face_confidence,
|
||||
bbox: t.best_face_bbox,
|
||||
trace_id: t.trace_id,
|
||||
frame_count: t.frame_count,
|
||||
}
|
||||
assignModal.value = { show: true, candidate: candidateLike }
|
||||
assignSearchQuery.value = ''
|
||||
assignSelected.value = null
|
||||
loadTraceThumb(t)
|
||||
}
|
||||
|
||||
function showTraceCtxMenu(e: MouseEvent, t: any) {
|
||||
e.preventDefault()
|
||||
const candidateLike = {
|
||||
id: t.trace_id,
|
||||
file_uuid: t.file_uuid,
|
||||
frame_number: t.best_face_frame || t.start_frame,
|
||||
confidence: t.best_face_confidence,
|
||||
bbox: t.best_face_bbox,
|
||||
trace_id: t.trace_id,
|
||||
frame_count: t.frame_count,
|
||||
}
|
||||
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: candidateLike }
|
||||
}
|
||||
|
||||
async function confirmAssign() {
|
||||
const c = assignModal.value.candidate
|
||||
if (!c || !assignSelected.value) return
|
||||
@@ -771,7 +840,12 @@ async function confirmAssign() {
|
||||
faceRowId: c.face_id ? undefined : c.id,
|
||||
fileUuid: c.file_uuid
|
||||
})
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
if (c.trace_id) {
|
||||
// Remove from unassigned traces if it's a trace
|
||||
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => t.trace_id !== c.trace_id)
|
||||
} else {
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
}
|
||||
assignModal.value.show = false
|
||||
} catch (e) {
|
||||
console.error('Bind failed:', e)
|
||||
@@ -790,6 +864,8 @@ h1 { margin: 0; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.ms-ppl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.ms-ppl-file-select { padding: 6px 16px; border: 1.5px solid #9aa0a6; border-radius: 10px; background: #fff; font-size: 13px; font-family: inherit; color: #202124; cursor: pointer; outline: none; min-width: 140px; }
|
||||
.ms-ppl-file-select:focus { border-color: #202124; }
|
||||
.ms-ppl-star-toggle-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; background: #fff; cursor: pointer; font-size: 13px; font-family: inherit; color: #202124; transition: border-color .15s; }
|
||||
.ms-ppl-star-toggle-btn:hover { border-color: #202124; }
|
||||
.ms-ppl-star-icon { font-size: 16px; color: #d1d5db; transition: color .15s; }
|
||||
|
||||
Reference in New Issue
Block a user