feat: bind/unbind by id, video streaming proxy, unmute default, library refresh, hamburger click-outside

- Core API: bind/unbind accept id (integer PK) when face_id is null
- Tauri proxy: stream video responses instead of buffering for seek bar
- VideoPlayer: remove muted attribute, unmute by default
- LibraryView: call invalidateFiles() before ensureFiles() on register/unregister
- PeopleView: close sort panel on click-outside, not just hamburger toggle
- bind_face: prefer face_id, fallback to id when face_id is null
This commit is contained in:
2026-06-18 22:24:03 +08:00
parent 97af4da331
commit 7855983dc1
19 changed files with 5321 additions and 793 deletions

View File

@@ -1,11 +1,21 @@
<template>
<div class="people-view">
<div class="ms-ppl-toolbar">
<h1 class="ms-ppl-section-title">People</h1>
<div class="ms-ppl-search-wrap">
<span class="ms-ppl-search-icon">🔍</span>
<input v-model="searchQuery" class="ms-ppl-search-input" placeholder="Search people..." @input="onSearch" />
</div>
<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>
</button>
<button class="ms-fm-icon-btn" @click="refresh" title="重新整理" style="margin-left:8px;">⟳</button>
<div style="flex:1;"></div>
<button class="ms-ppl-section-toggle-btn" :class="{ active: showPending }" @click="showPending = !showPending">
待定人物 <span class="ms-ppl-toggle-dot" :class="{ on: showPending }"></span>
</button>
<button class="ms-ppl-section-toggle-btn" :class="{ active: showSkipped }" @click="showSkipped = !showSkipped">
已略過 <span class="ms-ppl-toggle-dot" :class="{ on: showSkipped }"></span>
</button>
<button class="ms-ppl-section-toggle-btn" :class="{ active: showUface }" @click="showUface = !showUface">
待定人臉 <span class="ms-ppl-toggle-dot" :class="{ on: showUface }"></span>
</button>
</div>
<div v-if="loading" class="loading-state">
@@ -13,234 +23,422 @@
<p>Loading...</p>
</div>
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0" class="empty">
No people found. people.value.length = {{ people.length }}
No people found.
</div>
<template v-else>
<!-- 已知人物 -->
<div v-if="confirmedPeople.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">({{ confirmedPeople.length }})</span></div>
<div style="flex:1;"></div>
<div class="ms-ppl-search-wrap">
<input v-model="knownSearch" class="ms-ppl-search-input" placeholder="搜尋已知人物">
</div>
<div style="position:relative;">
<button class="ms-fm-icon-btn" @click.prevent="showKnownSort = !showKnownSort; showPendingSort = false" title="排序與記錄">☰</button>
<div v-if="showKnownSort" class="ms-fm-sort-panel" @click.stop>
<div class="ms-fm-sort-section" v-if="lastCtxUuid">
<div class="ms-fm-sort-title">操作記錄 · {{ lastName }}</div>
<div class="ms-undo-row" style="margin-bottom:6px;">
<button class="ms-undo-btn" :disabled="!canUndoPerson" @click="doUndo" title="復原 (Ctrl+Z)">↩ 全部復原</button>
<button class="ms-undo-btn" :disabled="!canRedoPerson" @click="doRedo" title="重做 (Ctrl+Shift+Z)">↪ 全部重做</button>
<button class="ms-undo-btn ms-undo-btn-danger" @click="deleteFromMenu" title="刪除此人物">🗑</button>
</div>
<div v-if="actionHistory.length" class="ms-history-list">
<div v-for="a in actionHistory.slice(0, 8)" :key="a.timestamp + a.type" class="ms-history-item" :class="{ 'ms-history-item-undone': a.canRedo }">
<span class="ms-history-label">{{ a.label }}</span>
<span class="ms-history-time">{{ formatHistoryTime(a.timestamp) }}</span>
<span class="ms-history-actions">
<button v-if="a.canUndo && (a.type === 'patch' || a.type === 'bind')" class="ms-history-act-btn" @click="doUndoAction(a.type as 'patch' | 'bind')" title="復原此操作">↩</button>
<button v-if="a.canRedo && (a.type === 'patch' || a.type === 'bind')" class="ms-history-act-btn ms-history-act-redo" @click="doRedoAction(a.type as 'patch' | 'bind')" title="重做此操作">↪</button>
</span>
</div>
</div>
<div v-else class="ms-history-empty">尚無操作記錄</div>
</div>
<div class="ms-fm-sort-section">
<div class="ms-fm-sort-title">排序方式</div>
<label><input type="radio" v-model="knownSort" value="recent"> 最近瀏覽</label>
<label><input type="radio" v-model="knownSort" value="name_az"> 依檔名・由 A 到 Z</label>
<label><input type="radio" v-model="knownSort" value="name_za"> 依檔名・由 Z 到 A</label>
<label><input type="radio" v-model="knownSort" value="time_desc"> 依時間・由近到遠</label>
<label><input type="radio" v-model="knownSort" value="time_asc"> 依時間・由遠到近</label>
</div>
</div>
</div>
</div>
<div class="ms-ppl-face-grid">
<div v-for="p in confirmedPeople" :key="p.identityUuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
<div v-for="p in confirmedPeople" :key="p.identity_uuid" class="ms-ppl-face-card" :class="{ starred: p.starred }" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)" v-observe="() => enqueueProfileLoad(p.identity_uuid)">
<div class="ms-ppl-face-img-wrap">
<img v-if="profiles[p.identityUuid]" :src="profiles[p.identityUuid]" alt="" @vue:mounted="loadProfile(p.identityUuid)">
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identityUuid)" viewBox="0 0 120 120" fill="none">
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="">
<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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</span>
<span class="ms-ppl-card-star">⭐</span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
</div>
</div>
<hr class="ms-ppl-hr">
<hr v-if="confirmedPeople.length" class="ms-ppl-hr">
<!-- 待定人物 -->
<div v-if="pendingPeople.length" class="ms-ppl-section">
<div v-if="showPending && pendingPeople.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">({{ pendingPeople.length }})</span></div>
<div style="flex:1;"></div>
<div class="ms-ppl-search-wrap">
<input v-model="pendingSearch" class="ms-ppl-search-input" placeholder="搜尋待定人物">
</div>
<div style="position:relative;">
<button class="ms-fm-icon-btn" @click.prevent="showPendingSort = !showPendingSort; showKnownSort = false" title="排序與記錄">☰</button>
<div v-if="showPendingSort" class="ms-fm-sort-panel" @click.stop>
<div class="ms-fm-sort-section" v-if="lastCtxUuid">
<div class="ms-fm-sort-title">操作記錄 · {{ lastName }}</div>
<div class="ms-undo-row" style="margin-bottom:6px;">
<button class="ms-undo-btn" :disabled="!canUndoPerson" @click="doUndo" title="復原 (Ctrl+Z)">↩ 全部復原</button>
<button class="ms-undo-btn" :disabled="!canRedoPerson" @click="doRedo" title="重做 (Ctrl+Shift+Z)">↪ 全部重做</button>
<button class="ms-undo-btn ms-undo-btn-danger" @click="deleteFromMenu" title="刪除此人物">🗑</button>
</div>
<div v-if="actionHistory.length" class="ms-history-list">
<div v-for="a in actionHistory.slice(0, 8)" :key="a.timestamp + a.type" class="ms-history-item" :class="{ 'ms-history-item-undone': a.canRedo }">
<span class="ms-history-label">{{ a.label }}</span>
<span class="ms-history-time">{{ formatHistoryTime(a.timestamp) }}</span>
<span class="ms-history-actions">
<button v-if="a.canUndo && (a.type === 'patch' || a.type === 'bind')" class="ms-history-act-btn" @click="doUndoAction(a.type as 'patch' | 'bind')" title="復原此操作">↩</button>
<button v-if="a.canRedo && (a.type === 'patch' || a.type === 'bind')" class="ms-history-act-btn ms-history-act-redo" @click="doRedoAction(a.type as 'patch' | 'bind')" title="重做此操作">↪</button>
</span>
</div>
</div>
<div v-else class="ms-history-empty">尚無操作記錄</div>
</div>
<div class="ms-fm-sort-section">
<div class="ms-fm-sort-title">排序方式</div>
<label><input type="radio" v-model="pendingSort" value="recent"> 最近瀏覽</label>
<label><input type="radio" v-model="pendingSort" value="name_az"> 依檔名・由 A 到 Z</label>
<label><input type="radio" v-model="pendingSort" value="name_za"> 依檔名・由 Z 到 A</label>
<label><input type="radio" v-model="pendingSort" value="time_desc"> 依時間・由近到遠</label>
<label><input type="radio" v-model="pendingSort" value="time_asc"> 依時間・由遠到近</label>
</div>
</div>
</div>
</div>
<div class="ms-ppl-face-grid">
<div v-for="p in pendingPeople" :key="p.identityUuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
<div v-for="p in pendingPeople" :key="p.identity_uuid" class="ms-ppl-face-card" :class="{ starred: p.starred }" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)" v-observe="() => enqueueProfileLoad(p.identity_uuid)">
<div class="ms-ppl-face-img-wrap">
<img v-if="profiles[p.identityUuid]" :src="profiles[p.identityUuid]" alt="" @vue:mounted="loadProfile(p.identityUuid)">
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identityUuid)" viewBox="0 0 120 120" fill="none">
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="">
<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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</span>
<span class="ms-ppl-card-star">⭐</span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
</div>
</div>
<hr class="ms-ppl-hr">
<hr v-if="showPending && pendingPeople.length" class="ms-ppl-hr">
<!-- 已略過 -->
<div v-if="skippedPeople.length" class="ms-ppl-section">
<div v-if="showSkipped && skippedPeople.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title skipped-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="flex-shrink:0;">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"></circle>
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
</svg>
已略過:
已略過: <span class="ms-ppl-section-count">({{ skippedPeople.length }})</span>
</div>
</div>
<div class="ms-ppl-face-grid">
<div v-for="p in skippedPeople" :key="p.identityUuid" class="ms-ppl-face-card skipped-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
<div v-for="p in skippedPeople" :key="p.identity_uuid" class="ms-ppl-face-card ms-ppl-card-skipped" :class="{ starred: p.starred }" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)" v-observe="() => enqueueProfileLoad(p.identity_uuid)">
<div class="ms-ppl-face-img-wrap">
<img v-if="profiles[p.identityUuid]" :src="profiles[p.identityUuid]" alt="" @vue:mounted="loadProfile(p.identityUuid)">
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identityUuid)" viewBox="0 0 120 120" fill="none">
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="">
<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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</span>
<span class="ms-ppl-card-star">⭐</span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
</div>
</div>
<hr class="ms-ppl-hr">
<hr v-if="(showSkipped && skippedPeople.length) || (showPending && pendingPeople.length) || confirmedPeople.length" class="ms-ppl-hr">
<!-- 待定人臉 -->
<div v-if="faceCandidates.length" class="ms-ppl-section">
<div v-if="showUface && faceCandidates.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">待定人臉:</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="showAssignModal(c)">
<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 class="ms-ppl-face-img-wrap">
<img v-if="candidateThumbs[c.file_uuid]" :src="candidateThumbs[c.file_uuid]" alt="" @vue:mounted="loadCandidateThumb(c.file_uuid)">
<div v-else class="face-placeholder" @vue:mounted="loadCandidateThumb(c.file_uuid)">{{ Math.round(c.confidence * 100) }}%</div>
<img v-if="candidateThumbs[c.id]" :src="candidateThumbs[c.id]" alt="">
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
</div>
<span class="ms-ppl-face-name">{{ c.file_uuid.slice(0, 8) }}... #{{ c.frame_number }}</span>
<span class="ms-ppl-face-name">{{ (c.file_uuid || '').slice(0, 8) }}... #{{ c.frame_number }}</span>
</div>
</div>
</div>
</template>
<!-- Person context menu -->
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px', display: 'block' }">
<button class="ms-ctx-item" @click="ctxAction('star')">{{ ctxMenu.person?.starred ? '☆ 取消重要人物' : '★ 標為重要人物' }}</button>
<hr class="ms-ctx-menu-divider">
<button class="ms-ctx-item" @click="ctxAction('confirm')" v-if="ctxMenu.person?.status !== 'confirmed'">✓ 確認人物</button>
<button class="ms-ctx-item" @click="ctxAction('pending')" v-if="ctxMenu.person?.status !== 'pending'">待定</button>
<button class="ms-ctx-item" @click="ctxAction('rename')">✎ 編輯名稱</button>
<button class="ms-ctx-item" @click="ctxAction('merge')">⇄ 已有此人物</button>
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')">✕ 略過此人物</button>
<hr class="ms-ctx-menu-divider" v-if="ctxMenu.person">
<button class="ms-ctx-item ms-ctx-undo" :disabled="!canUndoPerson" @click="ctxAction('undo')">↩ 復原<template v-if="undoCounts[ctxMenu.person?.identity_uuid]?.patchUndo || undoCounts[ctxMenu.person?.identity_uuid]?.bindUndo"> ({{ (undoCounts[ctxMenu.person?.identity_uuid]?.patchUndo || 0) + (undoCounts[ctxMenu.person?.identity_uuid]?.bindUndo || 0) }})</template></button>
<button class="ms-ctx-item ms-ctx-redo" :disabled="!canRedoPerson" @click="ctxAction('redo')">↪ 重做<template v-if="undoCounts[ctxMenu.person?.identity_uuid]?.patchRedo || undoCounts[ctxMenu.person?.identity_uuid]?.bindRedo"> ({{ (undoCounts[ctxMenu.person?.identity_uuid]?.patchRedo || 0) + (undoCounts[ctxMenu.person?.identity_uuid]?.bindRedo || 0) }})</template></button>
<template v-if="ctxHistory.length">
<hr class="ms-ctx-menu-divider">
<div v-for="a in ctxHistory.slice(0, 5)" :key="a.timestamp + a.type" class="ms-ctx-history-item">
<span class="ms-ctx-history-label">{{ a.label }}</span>
<span class="ms-ctx-history-time">{{ formatHistoryTime(a.timestamp) }}</span>
<button v-if="a.canUndo && (a.type === 'patch' || a.type === 'bind')" class="ms-ctx-item ms-ctx-undo ms-ctx-history-btn" @click="ctxAction('undoAction', a.type)">↩</button>
<button v-if="a.canRedo && (a.type === 'patch' || a.type === 'bind')" class="ms-ctx-item ms-ctx-redo ms-ctx-history-btn" @click="ctxAction('redoAction', a.type)">↪</button>
</div>
</template>
<hr class="ms-ctx-menu-divider">
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('delete')">🗑 刪除此人物</button>
</div>
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
<!-- Face candidate context menu -->
<div v-if="faceCtxMenu.show" class="ms-ctx-menu" :style="{ left: faceCtxMenu.x + 'px', top: faceCtxMenu.y + 'px', display: 'block' }">
<button class="ms-ctx-item" @click="faceCtxAction('assign')">⇄ 指派給現有人物</button>
<button class="ms-ctx-item ms-ctx-danger" @click="faceCtxAction('skip')">✕ 略過此人臉</button>
</div>
<!-- Assign modal -->
<div v-if="assignModal.show" class="ms-modal-overlay show" @click.self="assignModal.show = false">
<div class="ms-modal ms-modal-assign">
<button class="ms-fm-icon-btn close-btn" @click="assignModal.show = false">×</button>
<div class="ms-assign-header">
<div class="ms-assign-trigger-face">
<img v-if="candidateThumbs[assignModal.candidate?.id]" :src="candidateThumbs[assignModal.candidate?.id]" alt="">
<div v-else class="face-placeholder">{{ Math.round(assignModal.candidate?.confidence * 100 || 0) }}%</div>
</div>
<div class="ms-assign-info">
<div class="ms-assign-title">指派給現有人物</div>
<div class="ms-assign-sub">{{ assignModal.candidate?.file_uuid?.slice(0,8) }}... #{{ assignModal.candidate?.frame_number }}</div>
</div>
</div>
<div class="ms-assign-search-wrap">
<span class="ms-assign-search-icon">🔍</span>
<input v-model="assignSearchQuery" class="ms-assign-search-input" placeholder="搜尋人物名稱...">
</div>
<div class="ms-assign-grid">
<div v-for="p in assignSearchResults" :key="p.identity_uuid" class="ms-assign-face-card" :class="{ selected: assignSelected?.identity_uuid === p.identity_uuid }" @click="assignSelected = p">
<div class="ms-assign-face-img">
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="">
<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>
<span class="ms-assign-face-name">{{ p.name }}</span>
</div>
<div v-if="!assignSearchResults.length && assignSearchQuery" class="ms-assign-empty">沒有找到符合的人物</div>
</div>
<div class="ms-assign-footer">
<button class="ms-fm-btn" @click="assignModal.show = false">取消</button>
<button class="ms-fm-btn ms-fm-btn-primary" @click="confirmAssign" :disabled="!assignSelected">確認指派</button>
</div>
</div>
</div>
<VideoPlayer v-if="playing" simple :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { invoke } from '@tauri-apps/api/core'
console.log('PeopleView script loaded')
import { apiCall } from '@/api'
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, invalidatePeople } from '@/store'
import { useUndoRedo } from '@/composables/useUndoRedo'
const router = useRouter()
import VideoPlayer from '../components/VideoPlayer.vue'
const people = ref<any[]>([])
const loading = ref(true)
const searchQuery = ref('')
const searchResults = ref<any[]>([])
const isSearching = ref(false)
const { counts: undoCounts, refreshCounts: refreshUndoCounts, undo, redo, bindUndo, bindRedo, canUndo, canRedo, refreshActionHistory, recentActions: actionHistory, undoAction, redoAction } = useUndoRedo()
const people = peopleCache
const loading = computed(() => !peopleLoaded.value)
const selected = ref<any>(null)
const faces = ref<any[]>([])
const traces = ref<any[]>([])
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
const profiles = ref<Record<string, string>>({})
const profiles = profilesCache
const showCandidates = ref(false)
const showMerge = ref(false)
const mergeTarget = ref('')
const candidates = ref<any[]>([])
const candidateThumbs = ref<Record<string, string>>({})
const faceCandidates = ref<any[]>([])
const candidateThumbs = faceThumbsCache
const faceCandidates = faceCandidatesCache
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
const faceCtxMenu = ref({ show: false, x: 0, y: 0, candidate: null as any })
const assignModal = ref({ show: false, candidate: null as any })
const assignSearchQuery = ref('')
const assignSelected = ref<any>(null)
// Section visibility toggles
const showPending = ref(true)
const showSkipped = ref(false)
const showUface = ref(true)
const starFilter = ref(false)
// Per-section search
const knownSearch = ref('')
const pendingSearch = ref('')
// Per-section sort
const knownSort = ref('recent')
const pendingSort = ref('name_az')
const showKnownSort = ref(false)
const showPendingSort = ref(false)
const lastCtxUuid = ref('')
const lastName = ref('')
const canUndoPerson = computed(() => canUndo(lastCtxUuid.value))
const canRedoPerson = computed(() => canRedo(lastCtxUuid.value))
const ctxHistory = computed(() => actionHistory.filter((a: any) => a.uuid === lastCtxUuid.value))
async function doUndoAction(type: 'patch' | 'bind') {
if (!lastCtxUuid.value) return
try {
await undoAction(lastCtxUuid.value, type)
} catch (e) { console.error('Undo action failed:', e) }
}
async function doRedoAction(type: 'patch' | 'bind') {
if (!lastCtxUuid.value) return
try {
await redoAction(lastCtxUuid.value, type)
} catch (e) { console.error('Redo action failed:', e) }
}
async function doUndo() {
if (!lastCtxUuid.value || !canUndoPerson.value) return
try {
await undo(lastCtxUuid.value)
} catch (e) { console.error('Undo failed:', e) }
}
async function doRedo() {
if (!lastCtxUuid.value || !canRedoPerson.value) return
try {
await redo(lastCtxUuid.value)
} catch (e) { console.error('Redo failed:', e) }
}
function sortPeople(list: any[], sortKey: string): any[] {
if (sortKey === 'name_az') return [...list].sort((a, b) => a.name.localeCompare(b.name))
if (sortKey === 'name_za') return [...list].sort((a, b) => b.name.localeCompare(a.name))
return list
}
const confirmedPeople = computed(() => {
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
return filtered.filter((p: any) => p.status === 'confirmed')
let base = people.value.filter((p: any) => p.status === 'confirmed')
if (starFilter.value) base = base.filter((p: any) => p.starred)
if (knownSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(knownSearch.value.toLowerCase()))
return sortPeople(base, knownSort.value)
})
const pendingPeople = computed(() => {
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
return filtered.filter((p: any) => p.status === 'pending')
let base = people.value.filter((p: any) => p.status === 'pending')
if (starFilter.value) base = base.filter((p: any) => p.starred)
if (pendingSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(pendingSearch.value.toLowerCase()))
return sortPeople(base, pendingSort.value)
})
const skippedPeople = computed(() => {
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
return filtered.filter((p: any) => p.status === 'skipped')
let base = people.value.filter((p: any) => p.status === 'skipped')
if (starFilter.value) base = base.filter((p: any) => p.starred)
return base
})
onMounted(async () => {
// Wait for Tauri to be ready
let tauri = (window as any).__TAURI_INTERNALS__ || (window as any).__TAURI__
let retries = 0
while (!tauri && retries < 20) {
await new Promise(r => setTimeout(r, 100))
tauri = (window as any).__TAURI_INTERNALS__ || (window as any).__TAURI__
retries++
const assignSearchResults = computed(() => {
let base = people.value.filter((p: any) => p.status === 'confirmed' || p.status === 'pending')
if (assignSearchQuery.value) {
base = base.filter((p: any) => p.name.toLowerCase().includes(assignSearchQuery.value.toLowerCase()))
}
if (!tauri) {
console.error('Tauri not available after waiting')
loading.value = false
return
}
try {
console.log('PeopleView: Tauri available, calling getPeople...')
const result = await invoke('get_people', { page: 1, perPage: 1000 })
console.log('PeopleView: getPeople raw result type:', typeof result)
console.log('PeopleView: getPeople raw result:', result)
console.log('PeopleView: isArray?', Array.isArray(result))
people.value = Array.isArray(result) ? result : []
console.log('PeopleView: people.value.length:', people.value.length)
if (people.value.length > 0) {
console.log('PeopleView: first person:', people.value[0])
return base.slice(0, 30)
})
async function refresh() {
ensurePeople().then(() => {
for (const p of people.value.slice(0, 30)) {
if (p.identity_uuid) loadProfile(p.identity_uuid)
}
} catch (e: any) {
console.error('Failed to load people:', e)
console.error('Error message:', e.message)
console.error('Error stack:', e.stack)
} finally {
loading.value = false
}
try {
const fc: any = await invoke('get_face_candidates', { page: 1, perPage: 100 })
faceCandidates.value = Array.isArray(fc) ? fc : []
} catch (e) {
console.error('Failed to load face candidates:', e)
}
})
ensureFaceCandidates().then(() => {
for (const c of faceCandidates.value.slice(0, 20)) {
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
}
})
}
onMounted(async () => {
await refresh()
document.addEventListener('click', closeCtxMenu)
document.addEventListener('click', closeFaceCtxMenu)
document.addEventListener('click', closeSortPanels)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('click', closeCtxMenu)
document.removeEventListener('click', closeFaceCtxMenu)
document.removeEventListener('click', closeSortPanels)
document.removeEventListener('keydown', handleKeydown)
})
let searchTimer: any
function onSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
if (!searchQuery.value.trim()) { isSearching.value = false; return }
try {
const results: any = await invoke('search_identities', { query: searchQuery.value, limit: 50 })
searchResults.value = Array.isArray(results) ? results : []
isSearching.value = true
} catch (e) { console.error('Search failed:', e) }
}, 300)
function closeSortPanels(e?: Event) {
const target = e?.target as HTMLElement | null
if (target?.closest('.ms-fm-icon-btn')) return
if (target?.closest('.ms-fm-sort-panel')) return
showKnownSort.value = false
showPendingSort.value = false
}
async function loadProfile(uuid: string) {
if (profiles.value[uuid]) return
try {
const result: string = await invoke('get_identity_profile', { uuid })
console.log('Profile loaded for:', uuid, result ? 'success' : 'empty')
profiles.value[uuid] = result
} catch (e) {
console.error('Profile load failed for:', uuid, e)
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
if (lastCtxUuid.value && canUndoPerson.value) {
e.preventDefault()
doUndo()
}
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'Z' || (e.key === 'z' && e.shiftKey))) {
if (lastCtxUuid.value && canRedoPerson.value) {
e.preventDefault()
doRedo()
}
}
}
async function loadCandidateThumb(uuid: string) {
if (!uuid || candidateThumbs.value[uuid]) return
try { candidateThumbs.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 }) } catch {}
function enqueueProfileLoad(uuid: string) {
loadProfile(uuid)
}
async function loadCandidateThumb(c: any) {
if (!c?.file_uuid || candidateThumbs.value[c.id]) return
loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
}
function selectPerson(p: any) {
const uuid = p.identityUuid || p.identityUuid
console.log('selectPerson called:', uuid, p.name)
const uuid = p.identity_uuid
router.push({ name: 'PersonDetail', params: { uuid } })
.catch(e => console.error('Router push failed:', e))
}
@@ -255,15 +453,15 @@ async function toggleStar() {
async function confirmDelete() {
if (!confirm(`Delete "${selected.value.name}"?`)) return
try {
await invoke('delete_identity', { uuid: selected.value.identity_uuid })
people.value = people.value.filter((p: any) => p.identityUuid !== selected.value.identity_uuid)
await apiCall('delete_identity', { uuid: selected.value.identity_uuid })
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
selected.value = null
} catch (e) { console.error('Failed to delete:', e) }
}
async function loadCandidates() {
try {
const result: any = await invoke('get_face_candidates', { page: 1, perPage: 50 })
const result: any = await apiCall('get_face_candidates', { page: 1, perPage: 50 })
candidates.value = Array.isArray(result) ? result : []
} catch (e) { console.error('Failed to load candidates:', e) }
}
@@ -271,7 +469,7 @@ async function loadCandidates() {
async function bindCandidate(c: any) {
if (!selected.value) return
try {
await invoke('bind_face', { uuid: selected.value.identity_uuid, faceId: String(c.id), fileUuid: c.file_uuid })
await apiCall('bind_face', { uuid: selected.value.identity_uuid, faceId: c.face_id || String(c.id), faceRowId: c.face_id ? undefined : c.id, fileUuid: c.file_uuid })
showCandidates.value = false
if (selected.value) selectPerson(selected.value)
} catch (e) { console.error('Bind failed:', e) }
@@ -280,53 +478,106 @@ async function bindCandidate(c: any) {
async function confirmMerge() {
if (!selected.value || !mergeTarget.value) return
try {
await invoke('merge_identities', { uuid: selected.value.identity_uuid, intoUuid: mergeTarget.value })
await apiCall('merge_identities', { uuid: selected.value.identity_uuid, intoUuid: mergeTarget.value })
showMerge.value = false
people.value = people.value.filter((p: any) => p.identityUuid !== selected.value.identity_uuid)
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
selected.value = null
} catch (e) { console.error('Merge failed:', e) }
}
function showAssignModal(c: any) {
alert(`Assign face ${c.id} to existing person - not yet implemented`)
}
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 playTrace(t: any) {
currentVideo.value = { fileUuid: t.file_uuid, startTime: t.first_sec, endTime: t.last_sec, title: `${selected.value?.name} - ${formatTime(t.first_sec)}-${formatTime(t.last_sec)}` }
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 formatHistoryTime(ts: string): string {
if (!ts) return ''
const d = new Date(ts)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '剛剛'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分鐘前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小時前`
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`
}
async function deleteFromMenu() {
if (!lastCtxUuid.value) return
const p = people.value.find((x: any) => x.identity_uuid === lastCtxUuid.value)
if (!p) return
if (!confirm(`刪除「${p.name}」?此操作無法復原。`)) return
try {
await apiCall('delete_identity', { uuid: lastCtxUuid.value })
people.value = people.value.filter((x: any) => x.identity_uuid !== lastCtxUuid.value)
invalidatePeople()
} catch (e) { console.error('Delete failed:', e) }
}
function showContextMenu(e: MouseEvent, p: any) {
e.preventDefault()
e.stopPropagation()
lastCtxUuid.value = p.identity_uuid
lastName.value = p.name
ctxMenu.value = { show: true, x: e.clientX, y: e.clientY, person: p }
refreshUndoCounts(p.identity_uuid)
refreshActionHistory(p.identity_uuid)
}
function ctxAction(action: string) {
async function ctxAction(action: string, extra?: any) {
const p = ctxMenu.value.person
if (!p) return
ctxMenu.value.show = false
const uuid = p.identity_uuid
lastCtxUuid.value = uuid
lastName.value = p.name
if (action === 'star') {
p.starred = !p.starred
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identityUuid)
if (idx >= 0) people.value[idx].starred = p.starred
const newVal = !p.starred
apiCall('update_identity_starred', { uuid, starred: newVal }).then(() => {
p.starred = newVal
const idx = people.value.findIndex((x: any) => x.identity_uuid === uuid)
if (idx >= 0) people.value[idx].starred = newVal
refreshUndoCounts(uuid)
}).catch(e => console.error('Star failed:', e))
} else if (action === 'skip') {
invoke('update_identity_status', { uuid: p.identityUuid, status: 'skipped' }).then(() => {
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identityUuid)
apiCall('update_identity_status', { uuid, status: 'skipped' }).then(() => {
const idx = people.value.findIndex((x: any) => x.identity_uuid === uuid)
if (idx >= 0) people.value[idx].status = 'skipped'
refreshUndoCounts(uuid)
}).catch(e => console.error('Skip failed:', e))
} else if (action === 'confirm') {
invoke('update_identity_status', { uuid: p.identityUuid, status: 'confirmed' }).then(() => {
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identityUuid)
apiCall('update_identity_status', { uuid, status: 'confirmed' }).then(() => {
const idx = people.value.findIndex((x: any) => x.identity_uuid === uuid)
if (idx >= 0) people.value[idx].status = 'confirmed'
refreshUndoCounts(uuid)
}).catch(e => console.error('Confirm failed:', e))
} else if (action === 'pending') {
apiCall('update_identity_status', { uuid, status: 'pending' }).then(() => {
const idx = people.value.findIndex((x: any) => x.identity_uuid === uuid)
if (idx >= 0) people.value[idx].status = 'pending'
refreshUndoCounts(uuid)
}).catch(e => console.error('Pending failed:', e))
} else if (action === 'rename' || action === 'merge') {
selectPerson(p)
} else if (action === 'undo') {
doUndo()
} else if (action === 'redo') {
doRedo()
} else if (action === 'undoAction') {
doUndoAction(extra as 'patch' | 'bind')
} else if (action === 'redoAction') {
doRedoAction(extra as 'patch' | 'bind')
} else if (action === 'delete') {
if (!confirm(`刪除「${p.name}」?此操作無法復原。`)) return
try {
await apiCall('delete_identity', { uuid })
people.value = people.value.filter((x: any) => x.identity_uuid !== uuid)
invalidatePeople()
} catch (e) { console.error('Delete failed:', e) }
}
}
@@ -335,80 +586,168 @@ function closeCtxMenu(e?: MouseEvent) {
ctxMenu.value.show = false
}
function showFaceCtxMenu(e: MouseEvent, c: any) {
e.preventDefault()
e.stopPropagation()
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: c }
}
function closeFaceCtxMenu(e?: MouseEvent) {
if (e && e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
faceCtxMenu.value.show = false
}
function faceCtxAction(action: string) {
const c = faceCtxMenu.value.candidate
if (!c) return
faceCtxMenu.value.show = false
if (action === 'assign') {
openAssignModal(c)
} else if (action === 'skip') {
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
}
}
function openAssignModal(c: any) {
assignModal.value = { show: true, candidate: c }
assignSearchQuery.value = ''
assignSelected.value = null
loadCandidateThumb(c)
}
async function confirmAssign() {
const c = assignModal.value.candidate
if (!c || !assignSelected.value) return
try {
await apiCall('bind_face', {
uuid: assignSelected.value.identity_uuid,
faceId: c.face_id || String(c.id),
faceRowId: c.face_id ? undefined : c.id,
fileUuid: c.file_uuid
})
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
assignModal.value.show = false
} catch (e) {
console.error('Bind failed:', e)
alert('指派失敗:' + e)
}
}
watch(showCandidates, (v) => { if (v) loadCandidates() })
</script>
<style scoped>
.ms-ppl-card-star { position: absolute; top: 6px; left: 6px; font-size: 16px; color: #f59e0b; text-shadow: 0 1px 3px rgba(0,0,0,.25); display: none; }
.ms-ppl-face-card.starred .ms-ppl-card-star, .ms-ppl-card-star.starred { display: block; }
.people-view { max-width: 1200px; }
h1 { margin: 0; }
.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); } }
.ms-ppl-detail-view { display: none; }
.ms-ppl-detail-view.show { display: block; }
.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; }
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 20px; }
.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-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-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-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; cursor: pointer; }
.ms-ppl-view-name-box:hover { text-decoration: underline; }
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: #3c4043; }
.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-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-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
.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; }
.ms-ppl-star-icon.starred { color: #f59e0b; }
.ms-fm-icon-btn { width: 34px; height: 34px; border: 1.5px solid #d1d5db; border-radius: 10px; background: #fff; cursor: pointer; display: grid; place-items: center; font-size: 16px; color: #5f6368; transition: border-color .15s, color .15s; }
.ms-fm-icon-btn:hover { border-color: #202124; color: #202124; }
.ms-ppl-section-toggle-btn { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1.5px solid #d1d5db; border-radius: 10px; background: #fff; cursor: pointer; font-size: 12.5px; font-family: inherit; color: #5f6368; transition: border-color .15s, color .15s, background .15s; }
.ms-ppl-section-toggle-btn.active { border-color: #202124; color: #202124; background: #f8f9fa; }
.ms-ppl-toggle-dot { width: 8px; height: 8px; border-radius: 50%; background: #d1d5db; transition: background .15s; }
.ms-ppl-toggle-dot.on { background: #1a56db; }
.ms-ppl-section { margin-bottom: 8px; }
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.ms-ppl-section-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 14px; font-weight: 600; color: #202124; margin: 0; display: flex; align-items: center; gap: 6px; }
.ms-ppl-section-count { font-weight: 400; color: #9aa0a6; font-size: 13px; }
.ms-ppl-hr { border: none; border-top: 1.5px solid #e8eaed; margin: 24px 0; }
.skipped-title { color: #9aa0a6; }
.ms-ppl-card-skipped .ms-ppl-face-img-wrap { filter: grayscale(0.6); opacity: 0.7; }
.ms-ppl-card-skipped .ms-ppl-face-name { color: #bdc1c6; }
.ms-ppl-face-grid { display: flex; flex-wrap: wrap; gap: 16px; }
.ms-ppl-face-card { width: 120px; cursor: pointer; border-radius: 12px; transition: transform .15s, box-shadow .15s; }
.ms-ppl-face-card:hover { transform: translateY(-3px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
.ms-ppl-face-card.starred .ms-ppl-card-star { display: block; }
.ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; background: #e8eaed; overflow: hidden; position: relative; }
.ms-ppl-face-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
.ms-ppl-card-star { display: none; position: absolute; top: 4px; right: 4px; font-size: 14px; line-height: 1; }
.ms-silhouette { width: 100%; height: 100%; }
.ms-ppl-face-name { display: block; text-align: center; font-size: 12px; color: #202124; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ms-uface-grid .ms-ppl-face-card { width: 120px; }
.ms-uface-grid .ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; }
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
.close-btn { position: absolute; top: 16px; right: 16px; }
.detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
.detail-avatar { width: 80px; height: 80px; border-radius: 16px; flex-shrink: 0; }
.detail-info { flex: 1; }
.name-row { display: flex; align-items: center; gap: 8px; }
.name-row h2 { margin: 0; cursor: pointer; font-size: 1.25rem; }
.name-row h2:hover { text-decoration: underline; }
.name-input { font-size: 1.25rem; font-weight: 700; border: 1.5px solid #d1d5db; border-radius: 8px; padding: 4px 8px; width: 200px; outline: none; }
.edit-btn { width: 28px; height: 28px; }
.uuid { color: #9aa0a6; font-size: 0.8rem; font-family: monospace; margin: 4px 0 8px; }
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tab { padding: 8px 16px; border: 1.5px solid #d1d5db; background: #fff; border-radius: 10px; cursor: pointer; font-size: 0.85rem; }
.tab.active { background: #202124; color: #fff; border-color: #202124; }
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
.face-thumb { width: 52px; height: 52px; border-radius: 8px; background: #e8eaed; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
.ms-ppl-media-item { cursor: pointer; }
.ms-ppl-media-thumb { position: relative; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; }
.thumb-play { color: #fff; font-size: 1.2rem; opacity: 0.8; }
.merge-input { width: 100%; padding: 10px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; margin-bottom: 16px; font-size: 0.9rem; outline: none; }
.merge-input:focus { border-color: #202124; }
.ms-modal-actions { display: flex; justify-content: flex-end; }
h2 { margin: 0 0 16px; font-size: 1rem; }
.ms-merge-grid { display: flex; flex-wrap: wrap; gap: 16px; max-height: 50vh; overflow-y: auto; }
/* Search input */
.ms-ppl-search-wrap { position: relative; }
.ms-ppl-search-input { padding: 6px 12px; border: 1.5px solid #d1d5db; border-radius: 8px; font-size: 12.5px; outline: none; width: 160px; font-family: inherit; transition: border-color .15s; }
.ms-ppl-search-input:focus { border-color: #202124; }
/* Sort panel */
.ms-fm-sort-panel { position: absolute; top: 100%; right: 0; z-index: 999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 12px 16px; min-width: 180px; margin-top: 4px; }
.ms-fm-sort-section { }
.ms-fm-sort-title { font-size: 11.5px; font-weight: 600; color: #9aa0a6; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
.ms-undo-row { display: flex; gap: 8px; }
.ms-undo-btn { display: flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1.5px solid #d1d5db; border-radius: 8px; background: #fff; cursor: pointer; font-size: 13px; font-family: inherit; color: #1a56db; transition: border-color .15s, background .15s; }
.ms-undo-btn:hover:not(:disabled) { border-color: #1a56db; background: #e8f0fe; }
.ms-undo-btn:disabled { color: #bdc1c6; cursor: not-allowed; opacity: 0.6; }
.ms-undo-btn-danger { color: #d93025; border-color: #f1c0b8; }
.ms-undo-btn-danger:hover:not(:disabled) { border-color: #d93025; background: #fce8e6; }
.ms-history-list { margin-top: 8px; max-height: 200px; overflow-y: auto; }
.ms-history-item { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 12px; color: #5f6368; border-bottom: 1px solid #f0f0f0; }
.ms-history-item:last-child { border-bottom: none; }
.ms-history-item-undone { opacity: 0.5; text-decoration: line-through; }
.ms-history-label { color: #3c4043; }
.ms-history-time { color: #9aa0a6; font-size: 11px; white-space: nowrap; margin-left: 8px; }
.ms-history-actions { display: flex; gap: 2px; margin-left: 4px; }
.ms-history-act-btn { background: none; border: 1px solid #d1d5db; border-radius: 4px; padding: 1px 5px; font-size: 12px; cursor: pointer; color: #1a56db; line-height: 1; }
.ms-history-act-btn:hover { background: #e8f0fe; border-color: #1a56db; }
.ms-history-act-redo { color: #1a56db; }
.ms-history-empty { font-size: 12px; color: #9aa0a6; padding: 4px 0; }
.ms-fm-sort-section label { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; color: #3c4043; cursor: pointer; }
.ms-fm-sort-section input[type="radio"] { margin: 0; accent-color: #202124; }
/* 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: 160px; 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.ms-ctx-undo { color: #1a56db; }
.ms-ctx-item.ms-ctx-redo { color: #1a56db; }
.ms-ctx-item.ms-ctx-undo:disabled, .ms-ctx-item.ms-ctx-redo:disabled { color: #bdc1c6; cursor: default; }
.ms-ctx-item.ms-ctx-undo:disabled:hover, .ms-ctx-item.ms-ctx-redo:disabled:hover { background: transparent; }
.ms-ctx-menu-divider { height: 1px; background: #eee; margin: 4px 8px; }
.ms-ppl-section { margin-bottom: 8px; }
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.ms-ppl-section-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 14px; font-weight: 600; color: #202124; margin: 0; display: flex; align-items: center; gap: 6px; }
.ms-ppl-hr { border: none; border-top: 1.5px solid #e8eaed; margin: 24px 0; }
.skipped-title { color: #9aa0a6; }
.skipped-card .ms-ppl-face-img-wrap { filter: grayscale(0.6); opacity: 0.7; }
.skipped-card .ms-ppl-face-name { color: #bdc1c6; }
.ms-ctx-history-item { display: flex; align-items: center; gap: 4px; padding: 3px 10px; font-size: 12px; color: #5f6368; }
.ms-ctx-history-label { flex: 1; color: #3c4043; font-size: 12px; }
.ms-ctx-history-time { color: #9aa0a6; font-size: 10px; white-space: nowrap; }
.ms-ctx-history-btn { padding: 2px 6px !important; font-size: 12px !important; min-width: 24px; }
/* Modal overlay */
.ms-modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 9998; align-items: center; justify-content: center; }
.ms-modal-overlay.show { display: flex; }
.close-btn { position: absolute; top: 16px; right: 16px; }
/* Assign modal */
.ms-modal-assign { max-width: 560px; width: 92%; padding: 28px 32px; text-align: left; max-height: 85vh; overflow-y: auto; margin-top: 48px; align-self: flex-start; background: #fff; border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,.2); position: relative; }
.ms-assign-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.ms-assign-trigger-face { width: 72px; height: 72px; border-radius: 14px; background: #e8eaed; flex-shrink: 0; overflow: hidden; }
.ms-assign-trigger-face img { width: 100%; height: 100%; object-fit: cover; }
.ms-assign-info { flex: 1; min-width: 0; }
.ms-assign-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: #202124; margin: 0 0 4px; }
.ms-assign-sub { font-size: 12px; color: #9aa0a6; }
.ms-assign-search-wrap { position: relative; margin-bottom: 16px; }
.ms-assign-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-size: 14px; color: #9aa0a6; }
.ms-assign-search-input { width: 100%; padding: 10px 14px 10px 36px; border: 1.5px solid #d1d5db; border-radius: 10px; font-size: 13px; outline: none; font-family: inherit; transition: border-color .15s; }
.ms-assign-search-input:focus { border-color: #202124; }
.ms-assign-grid { display: flex; flex-wrap: wrap; gap: 12px; min-height: 120px; max-height: 45vh; overflow-y: auto; padding: 4px 0; }
.ms-assign-face-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; width: 80px; padding: 8px; border-radius: 12px; transition: background .15s; }
.ms-assign-face-card:hover { background: #f3f4f6; }
.ms-assign-face-card.selected { background: #e8f0fe; }
.ms-assign-face-img { width: 64px; height: 64px; border-radius: 14px; overflow: hidden; background: #e8eaed; border: 2px solid transparent; transition: border-color .15s; }
.ms-assign-face-card.selected .ms-assign-face-img { border-color: #1a56db; }
.ms-assign-face-img img { width: 100%; height: 100%; object-fit: cover; }
.ms-assign-face-name { font-size: 11px; color: #202124; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 72px; }
.ms-assign-empty { width: 100%; padding: 32px 0; text-align: center; color: #9aa0a6; font-size: 13px; }
.ms-assign-footer { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
.ms-fm-btn-primary { background: #202124; color: #fff; border-color: #202124; }
.ms-fm-btn-primary:hover { background: #3c4043; }
.ms-fm-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
</style>