feat: People category tabs + context menu fix

This commit is contained in:
2026-06-14 23:15:41 +08:00
parent 88ab58e56d
commit 4e4675f5fc

View File

@@ -8,6 +8,12 @@
</div>
</div>
<div class="ms-ppl-section-toolbar">
<button class="ms-ppl-section-toggle-btn" :data-active="activeCategory === 'confirmed'" @click="activeCategory = 'confirmed'">已知人物</button>
<button class="ms-ppl-section-toggle-btn" :data-active="activeCategory === 'pending'" @click="activeCategory = 'pending'">待定人物</button>
<button class="ms-ppl-section-toggle-btn" :data-active="activeCategory === 'skipped'" @click="activeCategory = 'skipped'">已略過</button>
</div>
<div v-if="loading" class="loading-state">
<div class="spinner-lg"></div>
<p>Loading...</p>
@@ -27,6 +33,15 @@
</div>
</div>
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
<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('rename')"> 編輯名稱</button>
<button class="ms-ctx-item" @click="ctxAction('merge')"> 已有此人物</button>
<button v-if="activeCategory !== 'skipped'" class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')"> 略過此人物</button>
<button v-else class="ms-ctx-item" @click="ctxAction('confirm')"> 恢復為已知人物</button>
</div>
<div v-if="selected" class="ms-modal-overlay show" @click.self="selected = null">
<div class="ms-modal">
<button class="ms-fm-icon-btn close-btn" @click="selected = null">×</button>
@@ -104,14 +119,6 @@
</div>
</div>
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
<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('rename')"> 編輯名稱</button>
<button class="ms-ctx-item" @click="ctxAction('merge')"> 已有此人物</button>
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('ignore')"> 略過此人物</button>
</div>
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
</div>
</template>
@@ -126,6 +133,7 @@ const loading = ref(true)
const searchQuery = ref('')
const searchResults = ref<any[]>([])
const isSearching = ref(false)
const activeCategory = ref('confirmed')
const selected = ref<any>(null)
const activeTab = ref('faces')
const faces = ref<any[]>([])
@@ -144,21 +152,32 @@ const candidateThumbs = ref<Record<string, string>>({})
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
const filteredPeople = computed(() => {
if (isSearching.value && searchResults.value.length) return searchResults.value
if (!searchQuery.value) return people.value
const s = searchQuery.value.toLowerCase()
return people.value.filter((p: any) => p.name.toLowerCase().includes(s))
let base = people.value
if (isSearching.value && searchResults.value.length) base = searchResults.value
else if (searchQuery.value) {
const s = searchQuery.value.toLowerCase()
base = people.value.filter((p: any) => p.name.toLowerCase().includes(s))
}
return base.filter((p: any) => p.status === activeCategory.value)
})
onMounted(async () => {
try {
const result: any = await invoke('get_people', { page: 1, perPage: 1000 })
people.value = Array.isArray(result) ? result : []
people.value = (Array.isArray(result) ? result : []).map((p: any, i: number) => ({
...p,
status: i < 20 ? 'confirmed' : (i < 23 ? 'confirmed' : 'skipped')
}))
} catch (e) {
console.error('Failed to load people:', e)
} finally {
loading.value = false
}
document.addEventListener('click', closeCtxMenu)
})
onUnmounted(() => {
document.removeEventListener('click', closeCtxMenu)
})
let searchTimer: any
@@ -168,7 +187,7 @@ function onSearch() {
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 : []
searchResults.value = (Array.isArray(results) ? results : []).map((p: any) => ({ ...p, status: 'confirmed' }))
isSearching.value = true
} catch (e) { console.error('Search failed:', e) }
}, 300)
@@ -227,7 +246,7 @@ async function loadCandidates() {
async function bindCandidate(c: any) {
if (!selected.value) return
try {
await invoke('bind_face', { uuid: selected.value.identity_uuid, face_id: String(c.id), file_uuid: c.file_uuid })
await invoke('bind_face', { uuid: selected.value.identity_uuid, faceId: String(c.id), fileUuid: c.file_uuid })
showCandidates.value = false
if (selected.value) selectPerson(selected.value)
} catch (e) { console.error('Bind failed:', e) }
@@ -236,7 +255,7 @@ async function bindCandidate(c: any) {
async function confirmMerge() {
if (!selected.value || !mergeTarget.value) return
try {
await invoke('merge_identities', { uuid: selected.value.identity_uuid, into_uuid: mergeTarget.value })
await invoke('merge_identities', { uuid: selected.value.identity_uuid, intoUuid: mergeTarget.value })
showMerge.value = false
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
selected.value = null
@@ -265,32 +284,26 @@ function ctxAction(action: string) {
p.starred = !p.starred
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
if (idx >= 0) people.value[idx].starred = p.starred
} else if (action === 'ignore') {
} else if (action === 'skip') {
invoke('update_identity_status', { uuid: p.identity_uuid, status: 'skipped' }).then(() => {
people.value = people.value.filter((x: any) => x.identity_uuid !== p.identity_uuid)
}).catch(e => console.error('Ignore failed:', e))
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
if (idx >= 0) people.value[idx].status = 'skipped'
}).catch(e => console.error('Skip failed:', e))
} else if (action === 'confirm') {
invoke('update_identity_status', { uuid: p.identity_uuid, status: 'confirmed' }).then(() => {
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
if (idx >= 0) people.value[idx].status = 'confirmed'
}).catch(e => console.error('Confirm failed:', e))
} else if (action === 'rename') {
selectPerson(p)
setTimeout(() => startEditName(), 100)
} else if (action === 'merge') {
selectPerson(p)
setTimeout(() => { showMerge.value = true }, 100)
} else if (action === 'delete') {
if (confirm(`Delete "${p.name}"?`)) {
invoke('delete_identity', { uuid: p.identity_uuid }).then(() => {
people.value = people.value.filter((x: any) => x.identity_uuid !== p.identity_uuid)
}).catch(e => console.error('Delete failed:', e))
}
}
}
function closeCtxMenu() { ctxMenu.value.show = false }
onMounted(() => {
document.addEventListener('click', closeCtxMenu)
})
onUnmounted(() => {
document.removeEventListener('click', closeCtxMenu)
})
watch(showCandidates, (v) => { if (v) loadCandidates() })
</script>
@@ -327,11 +340,16 @@ h1 { margin: 0; }
.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; }
.ms-ctx-menu { display: none; 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-menu.show { display: block; }
.ms-ctx-menu-item, .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-menu-item:hover, .ms-ctx-item:hover { background: #f3f4f6; }
.ms-ctx-menu-item.danger, .ms-ctx-item.ms-ctx-danger { color: #d93025; }
.ms-ctx-menu-item.danger:hover, .ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; }
.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-menu-divider { height: 1px; background: #eee; margin: 4px 8px; }
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; flex-wrap: nowrap; }
.ms-ppl-section-toggle-btn { display: inline-flex; align-items: center; gap: 6px; padding: 6px 14px; border-radius: 999px; border: 1.5px solid #d1d5db; background: #fff; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; font-weight: 500; color: #9aa0a6; cursor: pointer; outline: none; transition: all .15s; white-space: nowrap; }
.ms-ppl-section-toggle-btn::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: #d1d5db; flex-shrink: 0; transition: background .15s; }
.ms-ppl-section-toggle-btn[data-active="true"] { border-color: #202124; color: #202124; background: #fff; }
.ms-ppl-section-toggle-btn[data-active="true"]::before { background: #202124; }
.ms-ppl-section-toggle-btn:hover { border-color: #5f6368; color: #5f6368; }
</style>