fix: m4mini deployment issues - duplicate script setup, auth redirect, Tauri bypass

This commit is contained in:
2026-06-26 20:33:35 +08:00
parent f915aaf794
commit 691b38fe96
14 changed files with 5185 additions and 219 deletions

View File

@@ -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; }