feat: People sections as chapters (known/pending/skipped) instead of tabs

This commit is contained in:
2026-06-14 23:24:22 +08:00
parent 4e4675f5fc
commit 22a63de6d7

View File

@@ -8,37 +8,88 @@
</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>
</div> </div>
<div v-else-if="filteredPeople.length === 0" class="empty">No results</div> <template v-else>
<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 v-if="confirmedPeople.length" class="ms-ppl-section">
<div class="ms-ppl-face-img-wrap"> <div class="ms-ppl-section-toolbar">
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)"> <div class="ms-ppl-section-title">已知人物</div>
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" viewBox="0 0 120 120" fill="none"> </div>
<circle cx="60" cy="45" r="25" fill="#d1d5db"/> <div class="ms-ppl-face-grid">
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/> <div v-for="p in confirmedPeople" :key="p.identity_uuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
</svg> <div class="ms-ppl-face-img-wrap">
<span v-if="p.starred" class="ms-ppl-card-star"></span> <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>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div> </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' }"> <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> <button class="ms-ctx-item" @click="ctxAction('star')">{{ ctxMenu.person?.starred ? ' 取消重要人物' : ' 標為重要人物' }}</button>
<hr class="ms-ctx-menu-divider"> <hr class="ms-ctx-menu-divider">
<button class="ms-ctx-item" @click="ctxAction('rename')"> 編輯名稱</button> <button class="ms-ctx-item" @click="ctxAction('rename')"> 編輯名稱</button>
<button class="ms-ctx-item" @click="ctxAction('merge')"> 已有此人物</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> <button v-else class="ms-ctx-item" @click="ctxAction('confirm')"> 恢復為已知人物</button>
</div> </div>
@@ -133,7 +184,6 @@ 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[]>([])
@@ -151,23 +201,28 @@ const candidates = ref<any[]>([])
const candidateThumbs = ref<Record<string, string>>({}) 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 confirmedPeople = computed(() => {
let base = people.value const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
if (isSearching.value && searchResults.value.length) base = searchResults.value const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
else if (searchQuery.value) { return filtered.filter((p: any) => p.status === 'confirmed')
const s = searchQuery.value.toLowerCase() })
base = people.value.filter((p: any) => p.name.toLowerCase().includes(s))
} const pendingPeople = computed(() => {
return base.filter((p: any) => p.status === activeCategory.value) 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 () => { 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 : []).map((p: any, i: number) => ({ people.value = Array.isArray(result) ? result : []
...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 {
@@ -187,7 +242,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 : []).map((p: any) => ({ ...p, status: 'confirmed' })) searchResults.value = Array.isArray(results) ? results : []
isSearching.value = true isSearching.value = true
} catch (e) { console.error('Search failed:', e) } } catch (e) { console.error('Search failed:', e) }
}, 300) }, 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 { color: #d93025; }
.ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; } .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 { margin-bottom: 8px; }
.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-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.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-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-toggle-btn[data-active="true"] { border-color: #202124; color: #202124; background: #fff; } .ms-ppl-hr { border: none; border-top: 1.5px solid #e8eaed; margin: 24px 0; }
.ms-ppl-section-toggle-btn[data-active="true"]::before { background: #202124; } .skipped-title { color: #9aa0a6; }
.ms-ppl-section-toggle-btn:hover { border-color: #5f6368; color: #5f6368; } .skipped-card .ms-ppl-face-img-wrap { filter: grayscale(0.6); opacity: 0.7; }
.skipped-card .ms-ppl-face-name { color: #bdc1c6; }
</style> </style>