feat: People category tabs + context menu fix
This commit is contained in:
@@ -8,6 +8,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 v-if="loading" class="loading-state">
|
||||||
<div class="spinner-lg"></div>
|
<div class="spinner-lg"></div>
|
||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
@@ -27,6 +33,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 v-if="selected" class="ms-modal-overlay show" @click.self="selected = null">
|
||||||
<div class="ms-modal">
|
<div class="ms-modal">
|
||||||
<button class="ms-fm-icon-btn close-btn" @click="selected = null">×</button>
|
<button class="ms-fm-icon-btn close-btn" @click="selected = null">×</button>
|
||||||
@@ -104,14 +119,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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" />
|
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -126,6 +133,7 @@ const loading = ref(true)
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const searchResults = ref<any[]>([])
|
const searchResults = ref<any[]>([])
|
||||||
const isSearching = ref(false)
|
const isSearching = ref(false)
|
||||||
|
const activeCategory = ref('confirmed')
|
||||||
const selected = ref<any>(null)
|
const selected = ref<any>(null)
|
||||||
const activeTab = ref('faces')
|
const activeTab = ref('faces')
|
||||||
const faces = ref<any[]>([])
|
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 ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
|
||||||
|
|
||||||
const filteredPeople = computed(() => {
|
const filteredPeople = computed(() => {
|
||||||
if (isSearching.value && searchResults.value.length) return searchResults.value
|
let base = people.value
|
||||||
if (!searchQuery.value) return people.value
|
if (isSearching.value && searchResults.value.length) base = searchResults.value
|
||||||
const s = searchQuery.value.toLowerCase()
|
else if (searchQuery.value) {
|
||||||
return people.value.filter((p: any) => p.name.toLowerCase().includes(s))
|
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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const result: any = await invoke('get_people', { page: 1, perPage: 1000 })
|
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) {
|
} catch (e) {
|
||||||
console.error('Failed to load people:', e)
|
console.error('Failed to load people:', e)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
document.addEventListener('click', closeCtxMenu)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', closeCtxMenu)
|
||||||
})
|
})
|
||||||
|
|
||||||
let searchTimer: any
|
let searchTimer: any
|
||||||
@@ -168,7 +187,7 @@ function onSearch() {
|
|||||||
if (!searchQuery.value.trim()) { isSearching.value = false; return }
|
if (!searchQuery.value.trim()) { isSearching.value = false; return }
|
||||||
try {
|
try {
|
||||||
const results: any = await invoke('search_identities', { query: searchQuery.value, limit: 50 })
|
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
|
isSearching.value = true
|
||||||
} catch (e) { console.error('Search failed:', e) }
|
} catch (e) { console.error('Search failed:', e) }
|
||||||
}, 300)
|
}, 300)
|
||||||
@@ -227,7 +246,7 @@ async function loadCandidates() {
|
|||||||
async function bindCandidate(c: any) {
|
async function bindCandidate(c: any) {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
try {
|
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
|
showCandidates.value = false
|
||||||
if (selected.value) selectPerson(selected.value)
|
if (selected.value) selectPerson(selected.value)
|
||||||
} catch (e) { console.error('Bind failed:', e) }
|
} catch (e) { console.error('Bind failed:', e) }
|
||||||
@@ -236,7 +255,7 @@ async function bindCandidate(c: any) {
|
|||||||
async function confirmMerge() {
|
async function confirmMerge() {
|
||||||
if (!selected.value || !mergeTarget.value) return
|
if (!selected.value || !mergeTarget.value) return
|
||||||
try {
|
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
|
showMerge.value = false
|
||||||
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
|
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
|
||||||
selected.value = null
|
selected.value = null
|
||||||
@@ -265,32 +284,26 @@ function ctxAction(action: string) {
|
|||||||
p.starred = !p.starred
|
p.starred = !p.starred
|
||||||
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
|
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
|
||||||
if (idx >= 0) people.value[idx].starred = p.starred
|
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(() => {
|
invoke('update_identity_status', { uuid: p.identity_uuid, status: 'skipped' }).then(() => {
|
||||||
people.value = people.value.filter((x: any) => x.identity_uuid !== p.identity_uuid)
|
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identity_uuid)
|
||||||
}).catch(e => console.error('Ignore failed:', e))
|
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') {
|
} else if (action === 'rename') {
|
||||||
selectPerson(p)
|
selectPerson(p)
|
||||||
setTimeout(() => startEditName(), 100)
|
setTimeout(() => startEditName(), 100)
|
||||||
} else if (action === 'merge') {
|
} else if (action === 'merge') {
|
||||||
selectPerson(p)
|
selectPerson(p)
|
||||||
setTimeout(() => { showMerge.value = true }, 100)
|
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 }
|
function closeCtxMenu() { ctxMenu.value.show = false }
|
||||||
onMounted(() => {
|
|
||||||
document.addEventListener('click', closeCtxMenu)
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
|
||||||
document.removeEventListener('click', closeCtxMenu)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(showCandidates, (v) => { if (v) loadCandidates() })
|
watch(showCandidates, (v) => { if (v) loadCandidates() })
|
||||||
</script>
|
</script>
|
||||||
@@ -327,11 +340,16 @@ h1 { margin: 0; }
|
|||||||
.ms-modal-actions { display: flex; justify-content: flex-end; }
|
.ms-modal-actions { display: flex; justify-content: flex-end; }
|
||||||
h2 { margin: 0 0 16px; font-size: 1rem; }
|
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-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 { 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-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, .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-menu-item:hover, .ms-ctx-item:hover { background: #f3f4f6; }
|
.ms-ctx-item.ms-ctx-danger { color: #d93025; }
|
||||||
.ms-ctx-menu-item.danger, .ms-ctx-item.ms-ctx-danger { color: #d93025; }
|
.ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; }
|
||||||
.ms-ctx-menu-item.danger:hover, .ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; }
|
|
||||||
.ms-ctx-menu-divider { height: 1px; background: #eee; margin: 4px 8px; }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user