feat: People category tabs + context menu fix
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user