Library: - 添加 checkbox 和鼠标框选功能 - Unregistered 卡片添加快速注册按钮 - 状态标签移至左下方,人脸图标移至左侧中间 - Deep Scan 改为 unregister -> register -> process - 完整国际化(日语、繁体中文、英语) PeopleView: - 修复卡片不显示问题(使用 get_file_traces API) - 移除对 identity_id 字段的依赖 其他: - 添加日语语言包 - 添加 useDragSelect composable - 新增文档:Face Bbox Null Bug Report
940 lines
49 KiB
Vue
940 lines
49 KiB
Vue
<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>
|
||
Back
|
||
</button>
|
||
<button v-if="!isEditing" class="ms-ppl-edit-text-btn" @click="startEditing">
|
||
Edit
|
||
<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:contain;">
|
||
<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 style="text-align:center;margin-top:2px;">
|
||
<span class="ms-ppl-source-badge" :class="detailSourceClass">{{ detailSourceLabel }}</span>
|
||
</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">Role</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">Description</span>
|
||
<div class="ms-ppl-view-box ms-ppl-view-notes-box">{{ person.metadata?.notes || '—' }}</div>
|
||
</div>
|
||
<div class="ms-ppl-edit-field-row" v-if="person.file_uuids?.length">
|
||
<span class="ms-ppl-edit-label">Appears in</span>
|
||
<div class="ms-ppl-files-list">
|
||
<div v-for="fu in person.file_uuids" :key="fu" class="ms-ppl-file-chip" :title="fu">
|
||
<template v-if="isTmdb">
|
||
<span class="ms-keyword-label">Keyword</span>
|
||
</template>
|
||
{{ getFileName(fu) }}<span class="ms-ppl-file-chip-uuid">{{ fu }}</span>
|
||
</div>
|
||
</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="Enter name">
|
||
</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">{{ $t('language.en') }}</option>
|
||
<option value="zh-TW">{{ $t('language.zh-TW') }}</option>
|
||
<option value="zh-CN">{{ $t('language.zh-CN') }}</option>
|
||
<option value="ja">{{ $t('language.ja') }}</option>
|
||
<option value="ko">{{ $t('language.ko') }}</option>
|
||
</select>
|
||
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="Enter alias and press Enter" @keyup.enter="addAlias">
|
||
</template>
|
||
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">{{ $t('people.detail.add_alias') }}</button>
|
||
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">Collapse</button>
|
||
</div>
|
||
<div class="ms-ppl-edit-fields">
|
||
<div class="ms-ppl-edit-field-row">
|
||
<label class="ms-ppl-edit-label">Role</label>
|
||
<input v-model="editRole" class="ms-ppl-edit-input ms-ppl-edit-field-input" placeholder="Role name">
|
||
</div>
|
||
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
|
||
<label class="ms-ppl-edit-label">Description</label>
|
||
<textarea v-model="editNotes" class="ms-ppl-edit-textarea ms-ppl-edit-field-input" placeholder="Custom description"></textarea>
|
||
</div>
|
||
</div>
|
||
<div class="ms-ppl-edit-actions">
|
||
<button class="ms-fm-btn ms-fm-btn-primary" @click="saveEdit" :disabled="saving">{{ saving ? 'Saving...' : '✓ Save Changes' }}</button>
|
||
<button class="ms-fm-btn" @click="cancelEditing">Cancel</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="Add same person">+</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 FS...</div>
|
||
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No FS 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:contain;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" @click="showEnlargedFace = true" style="cursor:pointer;">
|
||
<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:contain;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-frame-range">
|
||
Frame: #{{ selectedTrace.first_frame }} ~ #{{ selectedTrace.last_frame }}
|
||
</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-actions">
|
||
<button class="ms-fm-btn" @click="playTrace(selectedTrace)">▶ Play</button>
|
||
<button v-if="isEditing && selectedTrace.face_id" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedTrace); selectedTrace = null">✕ Unbind</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Enlarged face image modal -->
|
||
<div v-if="showEnlargedFace && selectedTrace" class="ms-modal-overlay show" @click.self="showEnlargedFace = false" style="z-index:10000;">
|
||
<div class="ms-enlarged-face-modal">
|
||
<button class="ms-modal-video-close" @click="showEnlargedFace = false">×</button>
|
||
<img v-if="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" :src="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" alt="" style="max-width:90vw;max-height:90vh;object-fit:contain;border-radius:12px;">
|
||
</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:contain;">
|
||
<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 }} segments · {{ allTraces.length }} FS</div>
|
||
<div class="ms-ppl-media-sub">All segments · {{ formatTime(allSegCard.start) }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No FS 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">Status:</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')">✓ Confirm</button>
|
||
<button v-if="person.status !== 'pending'" class="ms-fm-btn" @click="updateStatus('pending')">Pending</button>
|
||
<button v-if="person.status !== 'skipped'" class="ms-fm-btn" @click="updateStatus('skipped')">Skip</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;">+ Bind Face</button>
|
||
<button class="ms-fm-btn ms-fm-btn-blue" @click="showMerge = true" style="margin-bottom:8px;">⇄ Merge to Other Person</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>
|
||
Delete This Person
|
||
</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">⇄ Merge to Other Person</h2>
|
||
<div class="ms-merge-search-wrap">
|
||
<span class="ms-merge-search-icon">🔍</span>
|
||
<input v-model="mergeSearchQuery" class="ms-merge-search-input" placeholder="Search person name..." @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">Cancel</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-frame="currentVideo.startFrame" :end-frame="currentVideo.endFrame" :all-traces="allTraces" :merged-segments="mergedSegments" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" :show-face="true" :show-asrx="false" :show-ocr="false" :show-pose="false" @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')">Not This Person</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, filesCache, ensureFiles } 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: '', startFrame: 0, endFrame: 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 isTmdb = computed(() => {
|
||
const p = person.value
|
||
return !!(p && (p.source === 'tmdb' || p.tmdb_id || p.metadata?.role))
|
||
})
|
||
const detailSourceLabel = computed(() => {
|
||
const p = person.value
|
||
if (!p) return ''
|
||
if (p.source === 'tmdb' || p.tmdb_id) return 'TMDB'
|
||
if (p.source === 'user') return 'User'
|
||
if (p.metadata?.role) return 'TMDB'
|
||
return 'Auto'
|
||
})
|
||
const detailSourceClass = computed(() => {
|
||
const p = person.value
|
||
if (!p) return ''
|
||
if (p.source === 'tmdb' || p.tmdb_id || p.metadata?.role) return 'ms-source-tmdb'
|
||
if (p.source === 'user') return 'ms-source-user'
|
||
return 'ms-source-auto'
|
||
})
|
||
|
||
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 showEnlargedFace = ref(false)
|
||
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++ }
|
||
|
||
function getFileName(fileUuid: string): string {
|
||
const f = filesCache.value.find((f: any) => f.file_uuid?.replace(/-/g, '') === fileUuid)
|
||
return f?.file_name || fileUuid
|
||
}
|
||
|
||
onMounted(async () => {
|
||
const uuid = route.params.uuid as string
|
||
try {
|
||
await Promise.all([ensurePeople(), ensureFiles()])
|
||
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' }
|
||
if (!person.value.file_uuids?.length) {
|
||
try {
|
||
const result: any = await apiCall('get_identity_files', { uuid, pageSize: 20 })
|
||
const files = result?.data || []
|
||
person.value.file_uuids = files.map((f: any) => f.file_uuid)
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
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
|
||
const fps = traceFps(t) || 24
|
||
const startFrame = Math.max(0, (t.first_frame || 0) - 10)
|
||
const endFrame = (t.last_frame || 0) + 10
|
||
currentVideo.value = {
|
||
fileUuid: t.file_uuid || '',
|
||
startFrame: startFrame,
|
||
endFrame: endFrame,
|
||
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() {
|
||
const fileUuid = person.value?.file_uuids?.[0]
|
||
if (!fileUuid) { candidates.value = []; return }
|
||
try {
|
||
const result: any = await apiCall('get_face_candidates', { page: 1, perPage: 20, fileUuid })
|
||
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('This trace has no face_id, cannot unbind'); return }
|
||
if (!confirm(`Unbind 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('Unbind failed: ' + (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 || '',
|
||
startFrame: t.first_frame || 0,
|
||
endFrame: t.last_frame || (t.first_frame || 0) + 30,
|
||
traceIdx: 0,
|
||
title: `F${t.first_frame || 0}-F${t.last_frame || 0} (${formatTime(t.first_sec || 0)}-${formatTime(t.last_sec || 0)})`,
|
||
}
|
||
playing.value = true
|
||
}
|
||
|
||
function playMerged(m: any) {
|
||
const t = allTraces.value[m._startIdx]
|
||
if (!t) return
|
||
currentVideo.value = {
|
||
fileUuid: t.file_uuid || '',
|
||
startFrame: t.first_frame || 0,
|
||
endFrame: t.last_frame || (t.first_frame || 0) + 30,
|
||
traceIdx: m._startIdx,
|
||
title: `F${t.first_frame || 0}-F${t.last_frame || 0} (${formatTime(t.first_sec || 0)}-${formatTime(t.last_sec || 0)})`,
|
||
}
|
||
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(`Merge "${person.value.name}" into "${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: var(--text-secondary); cursor: pointer; outline: none; padding: 4px 10px; display: flex; align-items: center; gap: 4px; transition: color .15s; }
|
||
.ms-ppl-edit-text-btn:hover { color: var(--text-primary); }
|
||
.loading-state, .empty { text-align: center; padding: 60px 0; color: var(--text-secondary); }
|
||
.spinner-lg { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--text-primary); 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: var(--border-light); flex-shrink: 0; overflow: hidden; position: relative; }
|
||
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: contain; 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: var(--text-secondary); transition: color .15s; padding: 0; flex-shrink: 0; }
|
||
.ms-ppl-star-btn.starred { color: var(--warning-color); }
|
||
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: var(--hover-background); border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: var(--text-secondary); }
|
||
.ms-keyword-label { display: inline-flex; align-items: center; background: rgba(var(--primary-color-rgb), 0.1); border-radius: 4px; padding: 1px 6px; margin-right: 4px; font-size: 10px; color: var(--primary-color); font-weight: 500; }
|
||
.ms-ppl-source-badge { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
|
||
.ms-source-tmdb { color: #1a73e8; }
|
||
.ms-source-user { color: #188038; }
|
||
.ms-source-auto { color: #9aa0a6; }
|
||
.ms-ppl-files-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||
.ms-ppl-file-chip { background: rgba(var(--primary-color-rgb), 0.1); border-radius: 4px; padding: 2px 8px; font-size: 11px; color: var(--primary-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; cursor: default; display: inline-flex; align-items: center; gap: 6px; }
|
||
.ms-ppl-file-chip-uuid { font-size: 9px; color: var(--text-secondary); font-family: monospace; }
|
||
.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: var(--hover-background); border-radius: 999px; padding: 4px 10px; font-size: 12.5px; color: var(--text-primary); }
|
||
.ms-ppl-alias-tag button { border: none; background: transparent; cursor: pointer; color: var(--text-secondary); font-size: 14px; line-height: 1; padding: 0; display: flex; align-items: center; }
|
||
.ms-ppl-alias-add-btn { border: 1.5px dashed var(--border-color); background: transparent; border-radius: 999px; padding: 4px 10px; font-size: 12px; color: var(--text-secondary); cursor: pointer; outline: none; }
|
||
.ms-ppl-alias-add-btn:hover { border-color: var(--text-primary); color: var(--text-primary); }
|
||
.ms-ppl-alias-inline-wrap { display: flex; align-items: center; }
|
||
.ms-ppl-alias-inline-input { border: 1.5px solid var(--primary-color); border-radius: 999px; padding: 4px 12px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12.5px; color: var(--text-primary); outline: none; background: var(--card-background); min-width: 140px; transition: border-color .15s; }
|
||
.ms-ppl-alias-inline-input:focus { border-color: var(--primary-color); }
|
||
.ms-ppl-alias-locale-select { border: 1.5px solid var(--primary-color); border-radius: 999px; padding: 4px 10px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; color: var(--text-primary); background: var(--card-background); 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: var(--text-primary); letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
|
||
.ms-ppl-edit-input { border: 1.5px solid var(--border-color); border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: var(--text-primary); outline: none; background: var(--card-background); box-shadow: var(--shadow); transition: border-color .15s, box-shadow .15s; min-width: 0; box-sizing: border-box; width: 100%; }
|
||
.ms-ppl-edit-input:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.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 var(--border-color); border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: var(--text-primary); outline: none; resize: vertical; min-height: 72px; background: var(--card-background); box-shadow: var(--shadow); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
|
||
.ms-ppl-edit-textarea:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.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: var(--text-primary); 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: var(--text-primary); }
|
||
.ms-ppl-view-notes-box { flex: 1; min-width: 0; min-height: 24px; align-items: flex-start; color: var(--text-secondary); 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 var(--border-color); background: var(--card-background); font-size: 20px; color: var(--text-secondary); 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: var(--text-primary); color: var(--text-primary); }
|
||
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 8px; scrollbar-width: thin; scrollbar-color: #b8bec6 transparent; flex: 1; min-width: 0; }
|
||
.ms-ppl-face-strip::-webkit-scrollbar { height: 8px; }
|
||
.ms-ppl-face-strip::-webkit-scrollbar-track { background: transparent; }
|
||
.ms-ppl-face-strip::-webkit-scrollbar-thumb { background: #b8bec6; border-radius: 999px; }
|
||
.ms-ppl-face-strip::-webkit-scrollbar-thumb:hover { background: #8a919b; }
|
||
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
|
||
.ms-ppl-strip-face-clickable.selected { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 14px; }
|
||
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: var(--border-light); overflow: hidden; transition: border-color .15s; }
|
||
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: var(--text-primary); }
|
||
.ms-ppl-face-card-detail { display: flex; gap: 16px; align-items: flex-start; background: var(--card-background); border: 1px solid var(--border-color); border-radius: 16px; padding: 16px; margin: 12px 0; position: relative; box-shadow: var(--shadow); }
|
||
.ms-ppl-face-card-close { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text-secondary); line-height: 1; padding: 2px 6px; border-radius: 6px; z-index: 10; }
|
||
.ms-ppl-face-card-close:hover { background: var(--hover-background); color: var(--text-primary); }
|
||
.ms-ppl-face-card-img-wrap { width: 120px; height: 120px; border-radius: 12px; overflow: hidden; flex-shrink: 0; background: var(--border-light); }
|
||
.ms-ppl-face-card-info { flex: 1; min-width: 0; padding-top: 4px; }
|
||
.ms-ppl-face-card-file { font-size: 13px; color: var(--text-secondary); font-family: monospace; }
|
||
.ms-ppl-face-card-frame { font-size: 13px; color: var(--text-primary); margin-top: 2px; }
|
||
.ms-ppl-face-card-frame-range { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||
.ms-ppl-face-card-time { font-size: 13px; color: var(--text-primary); margin-top: 4px; }
|
||
.ms-ppl-face-card-dur { color: var(--text-secondary); margin-left: 4px; }
|
||
.ms-ppl-face-card-fps { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||
.ms-ppl-face-card-conf { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||
.ms-ppl-face-card-actions { display: flex; gap: 8px; margin-top: 10px; }
|
||
.ms-enlarged-face-modal { display: flex; align-items: center; justify-content: center; background: var(--card-background); border-radius: 16px; padding: 20px; position: relative; max-width: 95vw; max-height: 95vh; }
|
||
.ms-enlarged-face-modal .ms-modal-video-close { position: absolute; top: 8px; right: 12px; background: none; border: none; font-size: 24px; cursor: pointer; color: var(--text-secondary); line-height: 1; }
|
||
.ms-ppl-strip-arrow { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid var(--border-color); background: var(--card-background); font-size: 18px; display: grid; place-items: center; cursor: pointer; color: var(--text-secondary); outline: none; flex-shrink: 0; transition: background .15s; }
|
||
.ms-ppl-strip-arrow:hover:not(:disabled) { background: var(--hover-background); color: var(--text-primary); }
|
||
.ms-ppl-strip-arrow:disabled { opacity: 0.35; cursor: default; }
|
||
.ms-ppl-strip-page { font-size: 11px; color: var(--text-secondary); min-width: 32px; text-align: center; flex-shrink: 0; }
|
||
.ms-ppl-media-label { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
|
||
.ms-ppl-media-item { cursor: pointer; border-radius: 12px; overflow: visible; background: var(--hover-background); transition: transform .15s, box-shadow .15s; border: 1px solid var(--border-color); }
|
||
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
|
||
.ms-ppl-media-thumb { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: var(--border-light); 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: var(--card-background); }
|
||
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
|
||
.ms-ppl-media-sub { font-size: 10.5px; color: var(--text-secondary); 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 var(--border-light); margin: 0 0 20px; }
|
||
.ms-ppl-delete-zone-btn { color: var(--danger-color); border-color: var(--danger-color); background: var(--card-background); }
|
||
.ms-ppl-delete-zone-btn:hover { background: var(--danger-background); border-color: var(--danger-color); }
|
||
.face-placeholder { font-size: 0.6rem; color: var(--text-secondary); }
|
||
.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: var(--hover-background); border-radius: 10px; font-size: 13px; }
|
||
.ms-ppl-actions-label { color: var(--text-secondary); font-weight: 500; }
|
||
.ms-ppl-actions-value { color: var(--text-primary); 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; }
|
||
|
||
@media (max-width: 767px) {
|
||
.people-view { padding-top: 8px; }
|
||
.ms-ppl-strip-wrap { gap: 6px; margin-bottom: 16px; }
|
||
.ms-ppl-strip-face-img { width: 40px; height: 40px; }
|
||
.ms-ppl-detail-name { font-size: 16px; }
|
||
.ms-ppl-detail-info { flex-direction: column; align-items: flex-start; }
|
||
}
|
||
</style> |