Files
momentry_studio/src/views/PersonDetailView.vue

856 lines
43 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 ms-ppl-detail-view">
<div class="ms-ppl-topbar">
<button class="ms-fm-btn" @click="router.back()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
返回
</button>
<button v-if="!isEditing" class="ms-ppl-edit-text-btn" @click="startEditing">
編輯
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style="margin-left:4px;">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div v-if="loading" class="loading-state">
<div class="spinner-lg"></div>
<p>Loading...</p>
</div>
<template v-else-if="person">
<div class="ms-ppl-detail-header">
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;">
<div class="ms-ppl-detail-avatar" :class="{ 'ms-ppl-avatar-editable': isEditing }" @click="isEditing && triggerAvatarUpload()">
<img v-if="profile" :src="profile" alt="" style="width:100%;height:100%;object-fit:cover;">
<svg v-else class="ms-silhouette" 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>
<div v-if="isEditing" class="ms-ppl-avatar-upload-hint">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="12" cy="13" r="4" stroke="currentColor" stroke-width="1.5"/>
</svg>
</div>
</div>
<input ref="fileInput" type="file" accept="image/jpeg,image/png" style="display:none" @change="handleAvatarUpload">
</div>
<div style="flex:1;min-width:0;">
<!-- View mode -->
<div v-if="!isEditing" id="msPplInfoView">
<div class="ms-ppl-detail-name-row">
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">{{ person.starred ? '★' : '☆' }}</button>
<div class="ms-ppl-view-box ms-ppl-view-name-box">{{ person.name || '—' }}</div>
</div>
<div class="ms-ppl-detail-aliases" v-if="person.metadata?.aliases?.length">
<span v-for="(a, i) in person.metadata.aliases" :key="i" class="ms-ppl-alias-chip">{{ a.name }}</span>
</div>
<div class="ms-ppl-edit-fields" style="margin-top:4px;">
<div class="ms-ppl-edit-field-row">
<span class="ms-ppl-edit-label">角色</span>
<div class="ms-ppl-view-box ms-ppl-view-field-box">{{ person.metadata?.role || '—' }}</div>
</div>
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
<span class="ms-ppl-edit-label">描述</span>
<div class="ms-ppl-view-box ms-ppl-view-notes-box">{{ person.metadata?.notes || '—' }}</div>
</div>
</div>
</div>
<!-- Edit mode -->
<div v-else id="msPplInfoEdit">
<div class="ms-ppl-edit-row ms-ppl-edit-name-row">
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">{{ person.starred ? '★' : '☆' }}</button>
<input v-model="editName" class="ms-ppl-edit-input ms-ppl-edit-name-input" placeholder="加入人名">
</div>
<div class="ms-ppl-edit-row ms-ppl-edit-alias-row">
<div class="ms-ppl-alias-wrap-inner">
<span v-for="(a, i) in editAliases" :key="i" class="ms-ppl-alias-tag">
{{ a.name }}
<button @click="removeAlias(i)">×</button>
</span>
</div>
<template v-if="showAliasInput">
<select v-model="aliasLocale" class="ms-ppl-alias-locale-select">
<option value="en">English</option>
<option value="zh-TW">繁體中文</option>
<option value="zh-CN">简体中文</option>
<option value="ja">日本語</option>
<option value="ko">한국어</option>
</select>
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="輸入別名後按 Enter" @keyup.enter="addAlias">
</template>
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">+ 別名</button>
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">收起</button>
</div>
<div class="ms-ppl-edit-fields">
<div class="ms-ppl-edit-field-row">
<label class="ms-ppl-edit-label">角色</label>
<input v-model="editRole" class="ms-ppl-edit-input ms-ppl-edit-field-input" placeholder="角色名稱">
</div>
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
<label class="ms-ppl-edit-label">描述</label>
<textarea v-model="editNotes" class="ms-ppl-edit-textarea ms-ppl-edit-field-input" placeholder="自訂描述"></textarea>
</div>
</div>
<div class="ms-ppl-edit-actions">
<button class="ms-fm-btn ms-fm-btn-primary" @click="saveEdit" :disabled="saving">{{ saving ? '儲存中...' : '✓ 儲存更改' }}</button>
<button class="ms-fm-btn" @click="cancelEditing">取消</button>
</div>
</div>
</div>
</div>
<!-- Trace strip (face_traces from get_traces) -->
<div class="ms-ppl-strip-wrap" :class="{ 'ms-ppl-edit-mode': isEditing }">
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="加入相同人物">+</button>
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage === 1" @click="prevFacePage">‹</button>
<div class="ms-ppl-face-strip">
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading traces...</div>
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No traces found</div>
<div v-for="(t, i) in paginatedTraces" :key="t.trace_id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: selectedTrace?.trace_id === t.trace_id }" @click="selectTrace(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)">
<div class="ms-ppl-strip-face-img">
<img v-if="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" :src="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;border-radius:8px;">
<div v-else style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#eef2ff;color:#6366f1;font-size:10px;font-weight:600;border-radius:8px;">T{{ t.trace_id }}</div>
</div>
<div class="ms-ppl-strip-trace-label">T{{ t.trace_id }} {{ t.face_id ? 'F' + t.face_id.slice(-4) : '' }}</div>
<button v-if="isEditing" class="ms-ppl-strip-remove-btn" @click="unbindFace(t)">×</button>
</div>
</div>
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage >= faceStripTotalPages" @click="nextFacePage">›</button>
<span v-if="faceStripTotalPages > 1" class="ms-ppl-strip-page">{{ faceStripPage }}/{{ faceStripTotalPages }}</span>
</div>
<!-- Trace detail card -->
<div v-if="selectedTrace" class="ms-ppl-face-card-detail">
<button class="ms-ppl-face-card-close" @click="selectedTrace = null">×</button>
<div class="ms-ppl-face-card-img-wrap">
<img v-if="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" :src="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:12px;">
<div v-else class="face-placeholder">?</div>
</div>
<div class="ms-ppl-face-card-info">
<div class="ms-ppl-face-card-file">trace_id: {{ selectedTrace.trace_id }}</div>
<div class="ms-ppl-face-card-frame">face_id: {{ selectedTrace.face_id || '—' }}</div>
<div class="ms-ppl-face-card-time">
{{ (selectedTrace.first_sec || 0).toFixed(1) }}s ~ {{ (selectedTrace.last_sec || 0).toFixed(1) }}s
<span class="ms-ppl-face-card-dur">({{ traceDuration(selectedTrace).toFixed(1) }}s)</span>
</div>
<div class="ms-ppl-face-card-fps" v-if="traceFps(selectedTrace)">
{{ traceFps(selectedTrace) }} fps · {{ selectedTrace.last_frame - selectedTrace.first_frame }} frames
</div>
<div class="ms-ppl-face-card-conf">#{{ selectedTrace.first_frame }}–#{{ selectedTrace.last_frame }} · {{ selectedTrace.file_uuid?.slice(0, 8) }}...</div>
<div class="ms-ppl-face-card-actions">
<button class="ms-fm-btn" @click="playTrace(selectedTrace)">▶ 播放</button>
<button v-if="isEditing && selectedTrace.face_id" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedTrace); selectedTrace = null">✕ 解綁</button>
</div>
</div>
</div>
<!-- Single segment card (entry point to player with all traces) -->
<div v-if="loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">Loading segments...</div>
<div v-else-if="loadingMoreTraces" class="ms-ppl-media-label" style="margin-bottom:20px;color:#9aa0a6;">Loading more segments...</div>
<div v-if="allSegCard" class="ms-ppl-media-item segment-card" @click="playAllSegments">
<div class="ms-ppl-media-thumb">
<img v-if="thumbs[allSegCard.thumbKey]" :src="thumbs[allSegCard.thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;">
<div class="ms-thumb-play-circle">
<svg width="20" height="20" viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20" fill="white"/></svg>
</div>
<span class="ms-ppl-media-dur">{{ allSegCard.duration.toFixed(1) }}s</span>
<span v-if="allSegCard.avg_confidence" class="ms-ppl-media-badge-conf">{{ (allSegCard.avg_confidence * 100).toFixed(0) }}%</span>
</div>
<div class="ms-ppl-media-info">
<div class="ms-ppl-media-title">{{ allSegCard.count }} 個片段 · {{ allTraces.length }} 個追蹤</div>
<div class="ms-ppl-media-sub">所有片段 · {{ formatTime(allSegCard.start) }}</div>
</div>
</div>
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No trace segments available for this person</div>
<!-- Tabs -->
<div v-show="activeTab === 'actions'">
<div class="ms-ppl-actions-section">
<div class="ms-ppl-actions-status">
<span class="ms-ppl-actions-label">狀態:</span>
<span class="ms-ppl-actions-value">{{ person.status || 'confirmed' }}</span>
<button v-if="person.status !== 'confirmed'" class="ms-fm-btn ms-fm-btn-primary" @click="updateStatus('confirmed')">✓ 確認</button>
<button v-if="person.status !== 'pending'" class="ms-fm-btn" @click="updateStatus('pending')">待定</button>
<button v-if="person.status !== 'skipped'" class="ms-fm-btn" @click="updateStatus('skipped')">略過</button>
</div>
</div>
<div class="ms-ppl-actions-section" style="margin-top:16px;">
<button class="ms-fm-btn ms-fm-btn-primary" @click="showCandidates = true" style="margin-bottom:8px;">+ 綁定人臉</button>
<button class="ms-fm-btn ms-fm-btn-blue" @click="showMerge = true" style="margin-bottom:8px;">⇄ 合併到其他人物</button>
</div>
<div class="ms-ppl-delete-zone">
<hr class="ms-ppl-delete-hr">
<button class="ms-fm-btn ms-ppl-delete-zone-btn" @click="confirmDelete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline>
<path d="M19 6l-1 14H6L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
刪除此人物
</button>
</div>
</div>
</template>
<div v-else class="empty">
Person not found: {{ route.params.uuid }}
<br><br>
<span style="font-size:12px;color:#9aa0a6;">People loaded: {{ peopleCount }}</span>
</div>
<div v-if="showCandidates" class="ms-modal-overlay show" @click.self="showCandidates = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showCandidates = false">×</button>
<h2 class="ms-ppl-section-title">Bind Face</h2>
<div class="ms-merge-grid">
<div v-for="c in candidates" :key="c.id" class="ms-merge-face-card" @click="bindCandidate(c)">
<div class="ms-merge-face-img">
<img v-if="faceThumbs[c.thumbKey]" :src="faceThumbs[c.thumbKey]" alt="">
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
</div>
<span class="ms-merge-face-name">{{ c.file_uuid?.slice(0, 8) }}... #{{ c.frame_number }}</span>
</div>
</div>
</div>
</div>
<div v-if="showMerge" class="ms-modal-overlay show" @click.self="showMerge = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showMerge = false">×</button>
<h2 class="ms-ppl-section-title">⇄ 合併到其他人物</h2>
<div class="ms-merge-search-wrap">
<span class="ms-merge-search-icon">🔍</span>
<input v-model="mergeSearchQuery" class="ms-merge-search-input" placeholder="搜尋人物名稱..." @input="onMergeSearch" />
</div>
<div class="ms-merge-grid">
<div v-for="r in mergeSearchResults" :key="r.identity_id" class="ms-merge-face-card" @click="confirmMergeTarget(r)">
<div class="ms-merge-face-img">
<svg viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
</svg>
</div>
<span class="ms-merge-face-name">{{ r.name }}</span>
</div>
<div v-if="!mergeSearchResults.length && mergeSearchQuery" class="ms-merge-face-card" style="opacity:0.5;cursor:default;width:100%;padding:20px;text-align:center;">
No results found
</div>
</div>
<div class="ms-merge-footer">
<button class="ms-fm-btn" @click="showMerge = false">取消</button>
</div>
</div>
</div>
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :all-traces="allTraces" :merged-segments="mergedSegments" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" @close="playing = false" />
<!-- Trace strip context menu -->
<div v-if="traceCtxMenu.show" class="ms-ctx-menu" :style="{ left: traceCtxMenu.x + 'px', top: traceCtxMenu.y + 'px', display: 'block' }" @click.stop @mousedown.stop @pointerdown.stop>
<button class="ms-ctx-item ms-ctx-danger" @click="traceCtxAction('unbind')">不是此人物</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiCall } from '@/api'
import { isTauri } from '@/api/config'
import { ensurePeople, peopleCache, peopleLoaded, profilesCache, loadProfile as storeLoadProfile, faceThumbsCache, loadFaceThumb as storeLoadFaceThumb, thumbnailsCache, loadThumbnail, invalidatePeople, invalidateProfile } from '@/store'
import VideoPlayer from '@/components/VideoPlayer.vue'
const route = useRoute()
const router = useRouter()
const person = ref<any>(null)
const loading = ref(true)
const loadingTraces = ref(true)
const profile = ref('')
const profileUuid = ref('')
const peopleCount = ref(0)
const allPeople = ref<any[]>([])
const allTraces = ref<any[]>([])
const mergedSegments = ref<any[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, traceIdx: 0, title: '' })
const showCandidates = ref(false)
const showMerge = ref(false)
const mergeSearchQuery = ref('')
const mergeSearchResults = ref<any[]>([])
const candidates = ref<any[]>([])
const thumbs = thumbnailsCache
const traceThumbs = thumbnailsCache
const faceThumbs = faceThumbsCache
const allSegCard = computed(() => {
const m = mergedSegments.value
if (!m.length) return null
const segs = [...m].sort((a, b) => a.start - b.start)
const first = segs[0]
const last = segs[segs.length - 1]
const firstTrace = allTraces.value[first._startIdx]
return {
thumbKey: first.thumbKey,
start: first.start,
end: last.end,
duration: last.end - first.start,
count: m.length,
avg_confidence: m.reduce((s, x) => s + (x.avg_confidence || 0), 0) / m.length,
firstTraceIdx: first._startIdx,
}
})
const isEditing = ref(false)
const editName = ref('')
const editRole = ref('')
const editNotes = ref('')
const editAliases = ref<{name: string, locale: string}[]>([])
const showAliasInput = ref(false)
const aliasLocale = ref('zh-TW')
const aliasName = ref('')
const saving = ref(false)
const avatarUploading = ref(false)
const activeTab = ref<'faces' | 'actions'>('faces')
const traceCtxMenu = ref({ show: false, x: 0, y: 0, trace: null as any })
const selectedTrace = ref<any>(null)
const fileInput = ref<HTMLInputElement | null>(null)
const loadingMoreTraces = ref(false)
const faceStripPage = ref(1)
const faceStripPerPage = 20
const paginatedTraces = computed(() => {
const start = (faceStripPage.value - 1) * faceStripPerPage
return allTraces.value.slice(start, start + faceStripPerPage)
})
const faceStripTotalPages = computed(() => Math.ceil(allTraces.value.length / faceStripPerPage))
function prevFacePage() { if (faceStripPage.value > 1) faceStripPage.value-- }
function nextFacePage() { if (faceStripPage.value < faceStripTotalPages.value) faceStripPage.value++ }
onMounted(async () => {
const uuid = route.params.uuid as string
try {
await ensurePeople()
peopleCount.value = peopleCache.value.length
allPeople.value = peopleCache.value
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
if (found) {
person.value = { ...found, status: found.status || 'confirmed' }
loadProfile(uuid)
loadMedia(uuid)
}
} catch (e) {
console.error('Failed to load person:', e)
} finally {
loading.value = false
}
loadCandidates()
document.addEventListener('click', closeTraceCtxMenu)
})
onUnmounted(() => {
document.removeEventListener('click', closeTraceCtxMenu)
})
function loadProfile(uuid: string) {
profileUuid.value = uuid
if (profilesCache.value[uuid]) {
profile.value = profilesCache.value[uuid]
} else {
storeLoadProfile(uuid)
}
}
watch(() => profileUuid.value && profilesCache.value[profileUuid.value], (val) => {
if (val && !profile.value) profile.value = val
})
function thumbKey(uuid: string, frame: number): string {
return `${uuid}:${frame}`
}
function selectTrace(t: any) {
selectedTrace.value = t
// Load thumbnail for this trace
loadThumb(t.file_uuid, t.first_frame || 0)
}
function playTrace(t: any) {
if (!t) return
const idx = allTraces.value.indexOf(t)
if (idx < 0) return
currentVideo.value = {
fileUuid: t.file_uuid || '',
startTime: t.first_sec || t.start_time || 0,
endTime: t.last_sec || t.end_time || 0,
traceIdx: idx,
title: `${person.value?.name} · T${t.trace_id}`,
}
playing.value = true
}
function showTraceCtxMenu(e: MouseEvent, t: any) {
e.preventDefault()
e.stopPropagation()
traceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, trace: { ...t } }
}
function closeTraceCtxMenu(e?: MouseEvent) {
traceCtxMenu.value.show = false
}
function traceCtxAction(action: string) {
const t = traceCtxMenu.value.trace
traceCtxMenu.value = { show: false, x: 0, y: 0, trace: null }
if (!t) {
console.error('traceCtxAction: no trace data')
return
}
if (!person.value) {
console.error('traceCtxAction: no person data')
return
}
if (action === 'unbind') {
unbindFace(t)
}
}
async function loadMedia(uuid: string) {
loadingTraces.value = true
try {
// Load all traces with pagination
const allItems: any[] = []
for (let page = 1; ; page++) {
const result: any = await apiCall('get_traces', { uuid, perPage: 100, page })
const items = result?.traces || []
if (!items.length) break
allItems.push(...items)
}
processTraces(allItems)
} catch (e) { console.error('Failed to load media:', e) }
finally { loadingTraces.value = false; loadingMoreTraces.value = false }
}
function processTraces(newData: any, append = false) {
const items = Array.isArray(newData) ? newData : []
if (!items.length) return
if (!append) {
allTraces.value = items.sort((a: any, b: any) => (a.first_sec || a.start_time || 0) - (b.first_sec || b.start_time || 0))
} else {
loadingMoreTraces.value = true
const existingIds = new Set(allTraces.value.map((t: any) => t.trace_id))
const fresh = items.filter((t: any) => !existingIds.has(t.trace_id))
if (!fresh.length) return
allTraces.value = [...allTraces.value, ...fresh].sort((a: any, b: any) =>
(a.first_sec || a.start_time || 0) - (b.first_sec || b.start_time || 0)
)
}
// Recompute merged segments
const merged: any[] = []
let cur: any = null
allTraces.value.forEach((item: any, idx: number) => {
const st = item.first_sec || item.start_time || 0
const en = item.last_sec || item.end_time || 0
const fu = item.file_uuid || ''
if (cur && fu === cur.file_uuid && (st - cur.end) < 30) {
cur.end = Math.max(cur.end, en)
cur.end_frame = Math.max(cur.end_frame, item.last_frame || 0)
cur.count++
cur._endIdx = idx
cur.total_confidence += item.avg_confidence || 0
} else {
if (cur) merged.push(cur)
cur = {
file_uuid: fu,
start_frame: item.first_frame || 0,
end_frame: item.last_frame || 0,
start: st,
end: en,
count: 1,
total_confidence: item.avg_confidence || 0,
_startIdx: idx,
_endIdx: idx,
thumbKey: thumbKey(fu, item.first_frame || 0),
}
}
})
if (cur) merged.push(cur)
merged.forEach((m: any) => {
m.avg_confidence = m.count > 1 ? m.total_confidence / m.count : m.total_confidence
delete m.total_confidence
})
mergedSegments.value = merged
if (append) loadingMoreTraces.value = false
// Load thumbnails for visible segments
merged.slice(0, 6).forEach((m: any) => loadThumb(m.file_uuid, m.start_frame))
}
function loadThumb(uuid: string, frame: number) {
const key = thumbKey(uuid, frame)
if (thumbnailsCache.value[key]) return
loadThumbnail(uuid, frame)
}
async function loadCandidates() {
try {
const result: any = await apiCall('get_face_candidates', { page: 1, perPage: 20 })
candidates.value = (Array.isArray(result) ? result : []).map((c: any) => ({
...c,
thumbKey: `cand_${c.id}`
}))
;(Array.isArray(result) ? result : []).forEach((c: any) => loadFaceCandidateThumb(c))
} catch (e) { console.error('Failed to load candidates:', e) }
}
async function loadFaceCandidateThumb(c: any) {
if (!c?.file_uuid) return
storeLoadFaceThumb(`cand_${c.id}`, c.file_uuid, c.frame_number || 0, c.bbox)
}
async function toggleStar() {
if (!person.value) return
const newVal = !person.value.starred
try {
await apiCall('update_identity_starred', { uuid: person.value.identity_uuid, starred: newVal })
person.value.starred = newVal
invalidatePeople()
} catch (e) {
console.error('Failed to update star:', e)
}
}
async function confirmDelete() {
if (!person.value || !confirm(`Delete "${person.value.name}"?`)) return
try {
await apiCall('delete_identity', { uuid: person.value.identity_uuid })
router.back()
} catch (e) { console.error('Failed to delete:', e) }
}
async function bindCandidate(c: any) {
if (!person.value) return
const fid = c.face_id || String(c.id)
try {
await apiCall('bind_face', { uuid: person.value.identity_uuid, faceId: fid, faceRowId: c.face_id ? undefined : c.id, fileUuid: c.file_uuid })
showCandidates.value = false
await refreshPerson()
} catch (e) { console.error('Bind failed:', e) }
}
function startEditing() {
if (!person.value) return
editName.value = person.value.name || ''
const meta = person.value.metadata || {}
editRole.value = meta.role || ''
editNotes.value = meta.notes || ''
editAliases.value = JSON.parse(JSON.stringify(meta.aliases || []))
isEditing.value = true
}
function cancelEditing() {
isEditing.value = false
}
async function saveEdit() {
if (!person.value || saving.value) return
saving.value = true
try {
const cleanAliases = editAliases.value.filter(a => a.name?.trim()).map(a => ({ name: a.name.trim(), locale: a.locale }))
const metadata = {
...(person.value.metadata || {}),
name: editName.value.trim(),
role: editRole.value.trim(),
notes: editNotes.value.trim(),
starred: person.value.starred,
aliases: cleanAliases,
}
await apiCall('update_identity', {
uuid: person.value.identity_uuid,
name: editName.value.trim(),
metadataJson: JSON.stringify(metadata),
})
isEditing.value = false
invalidatePeople()
await refreshPerson()
} catch (e) {
console.error('Failed to save:', e)
} finally {
saving.value = false
}
}
function addAlias() {
const name = aliasName.value.trim()
if (!name) return
editAliases.value.push({ name, locale: aliasLocale.value })
aliasName.value = ''
}
function removeAlias(index: number) {
editAliases.value.splice(index, 1)
}
async function handleAvatarUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file || !person.value) return
avatarUploading.value = true
try {
if (isTauri) {
const path = (file as any).path || file.name
await (window as any).__TAURI__.invoke('upload_profile_image', {
uuid: person.value.identity_uuid,
filePath: path,
})
} else {
await apiCall('upload_profile_image', {
uuid: person.value.identity_uuid,
file: file,
})
}
invalidateProfile(person.value.identity_uuid)
loadProfile(person.value.identity_uuid)
} catch (e) {
console.error('Avatar upload failed:', e)
} finally {
avatarUploading.value = false
target.value = ''
}
}
function triggerAvatarUpload() {
fileInput.value?.click()
}
async function unbindFace(t: any) {
if (!person.value) return
const uuid = person.value.identity_uuid
const fid = t.face_id || t.faceId || null
if (!fid) { alert('此 trace 沒有 face_id,無法解綁'); return }
if (!confirm(`解綁 trace T${t.trace_id}(face_id: ${fid})?`)) return
try {
await apiCall('unbind_face', {
uuid,
faceId: fid,
faceRowId: null,
fileUuid: t.file_uuid || t.fileUuid || '',
frameNumber: null,
})
// Refresh traces
await loadMedia(uuid)
invalidatePeople()
await ensurePeople()
allPeople.value = peopleCache.value
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
if (found) {
person.value = { ...found, status: found.status || 'confirmed' }
}
} catch (e) {
console.error('Unbind failed:', e)
alert('解綁失敗:' + (e instanceof Error ? e.message : String(e)))
}
}
async function updateStatus(status: string) {
if (!person.value) return
try {
await apiCall('update_identity_status', { uuid: person.value.identity_uuid, status })
person.value.status = status
await refreshPerson()
} catch (e) { console.error('Failed to update status:', e) }
}
function playAllSegments() {
const card = allSegCard.value
if (!card || !allTraces.value.length) return
const t = allTraces.value[0]
currentVideo.value = {
fileUuid: t.file_uuid || '',
startTime: t.first_sec || t.start_time || 0,
endTime: t.last_sec || t.end_time || 0,
traceIdx: 0,
title: `${person.value?.name} · ${card.count} 個片段`,
}
playing.value = true
}
function playMerged(m: any) {
const t = allTraces.value[m._startIdx]
if (!t) return
currentVideo.value = {
fileUuid: t.file_uuid || '',
startTime: t.first_sec || t.start_time || 0,
endTime: t.last_sec || t.end_time || 0,
traceIdx: m._startIdx,
title: `${person.value?.name} · #${m.start_frame}–#${m.end_frame}`,
}
playing.value = true
}
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 traceDuration(t: any): number {
if (!t) return 0
return (t.last_sec || 0) - (t.first_sec || 0)
}
function traceFps(t: any): number | null {
if (!t) return null
const frameDiff = (t.last_frame || 0) - (t.first_frame || 0)
const secDiff = traceDuration(t)
if (secDiff <= 0) return null
return Math.round(frameDiff / secDiff)
}
async function refreshPerson() {
invalidatePeople()
await ensurePeople()
allPeople.value = peopleCache.value
const uuid = person.value?.identity_uuid || (route.params.uuid as string)
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
if (found) {
person.value = { ...found, status: found.status || 'confirmed' }
}
}
async function onMergeSearch() {
clearTimeout(mergeSearchTimer)
if (!mergeSearchQuery.value.trim()) { mergeSearchResults.value = []; return }
mergeSearchTimer = setTimeout(() => {
const q = mergeSearchQuery.value.toLowerCase()
mergeSearchResults.value = allPeople.value
.filter((p: any) => p.identity_uuid !== person.value?.identity_uuid && p.name.toLowerCase().includes(q))
.slice(0, 20)
}, 200)
}
async function confirmMergeTarget(target: any) {
if (!person.value) return
if (!confirm(`合併「${person.value.name}」到「${target.name}」?`)) return
try {
await apiCall('merge_identities', { uuid: person.value.identity_uuid, intoUuid: target.identity_uuid })
showMerge.value = false
router.back()
} catch (e) { console.error('Merge failed:', e) }
}
let mergeSearchTimer: any
</script>
<style scoped>
.people-view { max-width: 1200px; padding-top: 20px; }
.ms-ppl-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; margin-top: 20px; }
.ms-ppl-edit-text-btn { border: none; background: transparent; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: #5f6368; cursor: pointer; outline: none; padding: 4px 10px; display: flex; align-items: center; gap: 4px; transition: color .15s; }
.ms-ppl-edit-text-btn:hover { color: #202124; }
.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); } }
.close-btn { position: absolute; top: 16px; right: 16px; }
.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; position: relative; }
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 20px; }
.ms-ppl-avatar-editable { cursor: pointer; }
.ms-ppl-avatar-upload-hint { position: absolute; inset: 0; border-radius: 20px; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .15s; pointer-events: none; }
.ms-ppl-avatar-editable:hover .ms-ppl-avatar-upload-hint { opacity: 1; }
.ms-ppl-avatar-upload-hint svg { color: #fff; }
.ms-silhouette { width: 100%; height: 100%; }
.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-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
.ms-ppl-edit-name-row { margin-bottom: 8px; }
.ms-ppl-edit-alias-row { padding-left: 32px; margin-bottom: 16px; gap: 6px; align-items: center; }
.ms-ppl-alias-wrap-inner { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.ms-ppl-alias-tag { display: inline-flex; align-items: center; gap: 5px; background: #e8eaed; border-radius: 999px; padding: 4px 10px; font-size: 12.5px; color: #202124; }
.ms-ppl-alias-tag button { border: none; background: transparent; cursor: pointer; color: #5f6368; font-size: 14px; line-height: 1; padding: 0; display: flex; align-items: center; }
.ms-ppl-alias-add-btn { border: 1.5px dashed #bbb; background: transparent; border-radius: 999px; padding: 4px 10px; font-size: 12px; color: #5f6368; cursor: pointer; outline: none; }
.ms-ppl-alias-add-btn:hover { border-color: #202124; color: #202124; }
.ms-ppl-alias-inline-wrap { display: flex; align-items: center; }
.ms-ppl-alias-inline-input { border: 1.5px solid #1a56db; border-radius: 999px; padding: 4px 12px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12.5px; color: #202124; outline: none; background: #fff; min-width: 140px; transition: border-color .15s; }
.ms-ppl-alias-inline-input:focus { border-color: #1a56db; }
.ms-ppl-alias-locale-select { border: 1.5px solid #1a56db; border-radius: 999px; padding: 4px 10px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; color: #202124; background: #fff; outline: none; cursor: pointer; height: 30px; }
.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-field-row--top { align-items: flex-start; }
.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-edit-input { border: 1.5px solid #e8eaed; border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; outline: none; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: border-color .15s, box-shadow .15s; min-width: 0; box-sizing: border-box; width: 100%; }
.ms-ppl-edit-input:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
.ms-ppl-edit-name-input { font-size: 16px !important; font-weight: 700 !important; flex: 1; min-width: 160px; }
.ms-ppl-edit-textarea { width: 100%; border: 1.5px solid #e8eaed; border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: #202124; outline: none; resize: vertical; min-height: 72px; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
.ms-ppl-edit-textarea:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
.ms-ppl-edit-actions { display: flex; gap: 8px; padding-left: 42px; }
.ms-ppl-edit-field-input { flex: 1; min-width: 0; }
.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; }
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: #3c4043; }
.ms-ppl-view-notes-box { flex: 1; min-width: 0; min-height: 24px; align-items: flex-start; color: #9aa0a6; font-size: 13px; white-space: pre-wrap; }
.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-clickable.selected { outline: 2px solid #1a56db; outline-offset: 2px; border-radius: 14px; }
.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-ppl-face-card-detail { display: flex; gap: 16px; align-items: flex-start; background: #fff; border: 1px solid #e0e0e0; border-radius: 16px; padding: 16px; margin: 12px 0; position: relative; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
.ms-ppl-face-card-close { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 20px; cursor: pointer; color: #9aa0a6; line-height: 1; padding: 2px 6px; border-radius: 6px; }
.ms-ppl-face-card-close:hover { background: #f3f4f6; color: #202124; }
.ms-ppl-face-card-img-wrap { width: 120px; height: 120px; border-radius: 12px; overflow: hidden; flex-shrink: 0; background: #e8eaed; }
.ms-ppl-face-card-info { flex: 1; min-width: 0; }
.ms-ppl-face-card-file { font-size: 13px; color: #5f6368; font-family: monospace; }
.ms-ppl-face-card-frame { font-size: 13px; color: #3c4043; margin-top: 2px; }
.ms-ppl-face-card-time { font-size: 13px; color: #3c4043; margin-top: 4px; }
.ms-ppl-face-card-dur { color: #9aa0a6; margin-left: 4px; }
.ms-ppl-face-card-fps { font-size: 12px; color: #5f6368; margin-top: 2px; }
.ms-ppl-face-card-conf { font-size: 12px; color: #9aa0a6; margin-top: 2px; }
.ms-ppl-face-card-actions { display: flex; gap: 8px; margin-top: 10px; }
.ms-ppl-strip-arrow { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid #d1d5db; background: #fff; font-size: 18px; display: grid; place-items: center; cursor: pointer; color: #5f6368; outline: none; flex-shrink: 0; transition: background .15s; }
.ms-ppl-strip-arrow:hover:not(:disabled) { background: #f3f4f6; color: #202124; }
.ms-ppl-strip-arrow:disabled { opacity: 0.35; cursor: default; }
.ms-ppl-strip-page { font-size: 11px; color: #9aa0a6; min-width: 32px; text-align: center; flex-shrink: 0; }
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
.ms-ppl-media-item { cursor: pointer; border-radius: 12px; overflow: visible; background: #f0f0f0; transition: transform .15s, box-shadow .15s; border: 1px solid #eee; }
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
.ms-ppl-media-thumb { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: #e8eaed; border-radius: 12px 12px 0 0; }
.ms-ppl-thumb-play-circle { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.18); border-radius: 12px 12px 0 0; }
.ms-ppl-thumb-play-circle svg { width: 38px; height: 38px; opacity: .7; transition: opacity .15s, transform .15s; }
.ms-ppl-media-item:hover .ms-ppl-thumb-play-circle svg { opacity: 1; transform: scale(1.08); }
.ms-ppl-media-dur { position: absolute; bottom: 5px; right: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
.ms-ppl-media-info { padding: 8px 10px 10px; background: #fff; }
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: #202124; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
.ms-ppl-media-sub { font-size: 10.5px; color: #9aa0a6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms-ppl-delete-zone { margin-top: 48px; padding-bottom: 32px; }
.ms-ppl-delete-hr { border: none; border-top: 1.5px solid #f3f4f6; margin: 0 0 20px; }
.ms-ppl-delete-zone-btn { color: #d93025; border-color: #fecaca; background: #fff; }
.ms-ppl-delete-zone-btn:hover { background: #fef2f2; border-color: #d93025; }
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
.ms-ppl-actions-section { display: flex; flex-direction: column; gap: 8px; }
.ms-ppl-actions-status { display: flex; align-items: center; gap: 10px; padding: 12px; background: #f8f9fa; border-radius: 10px; font-size: 13px; }
.ms-ppl-actions-label { color: #5f6368; font-weight: 500; }
.ms-ppl-actions-value { color: #202124; font-weight: 600; text-transform: capitalize; }
.ms-ppl-media-badge-conf { position: absolute; top: 5px; left: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
.segment-card { max-width: 320px; margin-bottom: 20px; }
/* Context menu */
.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: 140px; 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-item-disabled { padding: 8px 12px; font-size: 12px; color: #9aa0a6; cursor: default; }
/* Remove button for face strip */
.ms-ppl-strip-remove-btn { display: none; position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #fff; border: 1.5px solid #d1d5db; font-size: 11px; color: #5f6368; line-height: 1; cursor: pointer; outline: none; place-items: center; }
.ms-ppl-edit-mode .ms-ppl-strip-remove-btn { display: grid; }
</style>