feat: People sections as chapters (known/pending/skipped) instead of tabs
This commit is contained in:
@@ -8,37 +8,88 @@
|
||||
</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>
|
||||
</div>
|
||||
<div v-else-if="filteredPeople.length === 0" class="empty">No results</div>
|
||||
<div v-else class="ms-ppl-face-grid">
|
||||
<div v-for="p in filteredPeople" :key="p.identity_uuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
|
||||
<div class="ms-ppl-face-img-wrap">
|
||||
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
|
||||
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" 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 v-if="p.starred" class="ms-ppl-card-star">⭐</span>
|
||||
<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>
|
||||
<div class="ms-ppl-face-grid">
|
||||
<div v-for="p in confirmedPeople" :key="p.identity_uuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
|
||||
<div class="ms-ppl-face-img-wrap">
|
||||
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
|
||||
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" 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 v-if="p.starred" class="ms-ppl-card-star">⭐</span>
|
||||
</div>
|
||||
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="ms-ppl-hr">
|
||||
|
||||
<!-- 待定人物 -->
|
||||
<div v-if="pendingPeople.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">
|
||||
<div v-for="p in pendingPeople" :key="p.identity_uuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
|
||||
<div class="ms-ppl-face-img-wrap">
|
||||
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
|
||||
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" 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 v-if="p.starred" class="ms-ppl-card-star">⭐</span>
|
||||
</div>
|
||||
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="ms-ppl-hr">
|
||||
|
||||
<!-- 已略過 -->
|
||||
<div v-if="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>
|
||||
已略過:
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-ppl-face-grid">
|
||||
<div v-for="p in skippedPeople" :key="p.identity_uuid" class="ms-ppl-face-card skipped-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
|
||||
<div class="ms-ppl-face-img-wrap">
|
||||
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
|
||||
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" 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 v-if="p.starred" class="ms-ppl-card-star">⭐</span>
|
||||
</div>
|
||||
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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-if="ctxMenu.person?.status !== 'skipped'" class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')">✕ 略過此人物</button>
|
||||
<button v-else class="ms-ctx-item" @click="ctxAction('confirm')">✓ 恢復為已知人物</button>
|
||||
</div>
|
||||
|
||||
@@ -133,7 +184,6 @@ 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[]>([])
|
||||
@@ -151,23 +201,28 @@ const candidates = ref<any[]>([])
|
||||
const candidateThumbs = ref<Record<string, string>>({})
|
||||
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
|
||||
|
||||
const filteredPeople = computed(() => {
|
||||
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)
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const result: any = await invoke('get_people', { page: 1, perPage: 1000 })
|
||||
people.value = (Array.isArray(result) ? result : []).map((p: any, i: number) => ({
|
||||
...p,
|
||||
status: i < 20 ? 'confirmed' : (i < 23 ? 'confirmed' : 'skipped')
|
||||
}))
|
||||
people.value = Array.isArray(result) ? result : []
|
||||
} catch (e) {
|
||||
console.error('Failed to load people:', e)
|
||||
} finally {
|
||||
@@ -187,7 +242,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 : []).map((p: any) => ({ ...p, status: 'confirmed' }))
|
||||
searchResults.value = Array.isArray(results) ? results : []
|
||||
isSearching.value = true
|
||||
} catch (e) { console.error('Search failed:', e) }
|
||||
}, 300)
|
||||
@@ -346,10 +401,11 @@ h2 { margin: 0 0 16px; font-size: 1rem; }
|
||||
.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; }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user