389 lines
20 KiB
Vue
389 lines
20 KiB
Vue
<template>
|
||
<div class="people-view">
|
||
<div class="ms-ppl-toolbar">
|
||
<h1 class="ms-ppl-section-title">People</h1>
|
||
<div class="ms-ppl-search-wrap">
|
||
<span class="ms-ppl-search-icon">🔍</span>
|
||
<input v-model="searchQuery" class="ms-ppl-search-input" placeholder="Search people..." @input="onSearch" />
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="loading" class="loading-state">
|
||
<div class="spinner-lg"></div>
|
||
<p>Loading...</p>
|
||
</div>
|
||
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0" class="empty">
|
||
No people found. people.value.length = {{ people.length }}
|
||
</div>
|
||
<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 class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</span>
|
||
</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 class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</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 class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</span>
|
||
</div>
|
||
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="ms-ppl-hr">
|
||
|
||
<!-- 待定人臉 -->
|
||
<div v-if="faceCandidates.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 ms-uface-grid">
|
||
<div v-for="c in faceCandidates.slice(0, 50)" :key="c.id" class="ms-ppl-face-card" @click="showAssignModal(c)">
|
||
<div class="ms-ppl-face-img-wrap">
|
||
<img v-if="candidateThumbs[c.file_uuid]" :src="candidateThumbs[c.file_uuid]" alt="" @vue:mounted="loadCandidateThumb(c.file_uuid)">
|
||
<div v-else class="face-placeholder" @vue:mounted="loadCandidateThumb(c.file_uuid)">{{ Math.round(c.confidence * 100) }}%</div>
|
||
</div>
|
||
<span class="ms-ppl-face-name">{{ c.file_uuid.slice(0, 8) }}... #{{ c.frame_number }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px', display: 'block' }">
|
||
<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('skip')">✕ 略過此人物</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>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { invoke } from '@tauri-apps/api/core'
|
||
|
||
console.log('PeopleView script loaded')
|
||
|
||
const router = useRouter()
|
||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||
|
||
const people = ref<any[]>([])
|
||
const loading = ref(true)
|
||
const searchQuery = ref('')
|
||
const searchResults = ref<any[]>([])
|
||
const isSearching = ref(false)
|
||
const selected = ref<any>(null)
|
||
const faces = ref<any[]>([])
|
||
const traces = ref<any[]>([])
|
||
const playing = ref(false)
|
||
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
||
const profiles = ref<Record<string, string>>({})
|
||
const showCandidates = ref(false)
|
||
const showMerge = ref(false)
|
||
const mergeTarget = ref('')
|
||
const candidates = ref<any[]>([])
|
||
const candidateThumbs = ref<Record<string, string>>({})
|
||
const faceCandidates = ref<any[]>([])
|
||
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
|
||
|
||
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 {
|
||
console.log('PeopleView: calling getPeople...')
|
||
const result: any = await invoke('getPeople', { page: 1, perPage: 1000 })
|
||
console.log('PeopleView: getPeople result:', Array.isArray(result) ? result.length : typeof result)
|
||
people.value = Array.isArray(result) ? result : []
|
||
console.log('PeopleView: people.value length:', people.value.length)
|
||
if (people.value.length > 0) {
|
||
console.log('PeopleView: first person:', JSON.stringify(people.value[0]))
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load people:', e)
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
try {
|
||
const fc: any = await invoke('getFaceCandidates', { page: 1, perPage: 100 })
|
||
faceCandidates.value = Array.isArray(fc) ? fc : []
|
||
} catch (e) {
|
||
console.error('Failed to load face candidates:', e)
|
||
}
|
||
document.addEventListener('click', closeCtxMenu)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
document.removeEventListener('click', closeCtxMenu)
|
||
})
|
||
|
||
let searchTimer: any
|
||
function onSearch() {
|
||
clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(async () => {
|
||
if (!searchQuery.value.trim()) { isSearching.value = false; return }
|
||
try {
|
||
const results: any = await invoke('searchIdentities', { query: searchQuery.value, limit: 50 })
|
||
searchResults.value = Array.isArray(results) ? results : []
|
||
isSearching.value = true
|
||
} catch (e) { console.error('Search failed:', e) }
|
||
}, 300)
|
||
}
|
||
|
||
async function loadProfile(uuid: string) {
|
||
if (profiles.value[uuid]) return
|
||
try { profiles.value[uuid] = await invoke('getIdentityProfile', { uuid }) } catch {}
|
||
}
|
||
|
||
async function loadCandidateThumb(uuid: string) {
|
||
if (!uuid || candidateThumbs.value[uuid]) return
|
||
try { candidateThumbs.value[uuid] = await invoke('getThumbnail', { uuid, frame: 30 }) } catch {}
|
||
}
|
||
|
||
function selectPerson(p: any) {
|
||
router.push({ name: 'PersonDetail', params: { uuid: p.identity_uuid } })
|
||
}
|
||
|
||
async function toggleStar() {
|
||
if (!selected.value) return
|
||
selected.value.starred = !selected.value.starred
|
||
const idx = people.value.findIndex((x: any) => x.identity_uuid === selected.value.identity_uuid)
|
||
if (idx >= 0) people.value[idx].starred = selected.value.starred
|
||
}
|
||
|
||
async function confirmDelete() {
|
||
if (!confirm(`Delete "${selected.value.name}"?`)) return
|
||
try {
|
||
await invoke('deleteIdentity', { uuid: selected.value.identity_uuid })
|
||
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
|
||
selected.value = null
|
||
} catch (e) { console.error('Failed to delete:', e) }
|
||
}
|
||
|
||
async function loadCandidates() {
|
||
try {
|
||
const result: any = await invoke('getFaceCandidates', { page: 1, perPage: 50 })
|
||
candidates.value = Array.isArray(result) ? result : []
|
||
} catch (e) { console.error('Failed to load candidates:', e) }
|
||
}
|
||
|
||
async function bindCandidate(c: any) {
|
||
if (!selected.value) return
|
||
try {
|
||
await invoke('bindFace', { 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) }
|
||
}
|
||
|
||
async function confirmMerge() {
|
||
if (!selected.value || !mergeTarget.value) return
|
||
try {
|
||
await invoke('mergeIdentities', { 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
|
||
} catch (e) { console.error('Merge failed:', e) }
|
||
}
|
||
|
||
function showAssignModal(c: any) {
|
||
alert(`Assign face ${c.id} to existing person - not yet implemented`)
|
||
}
|
||
|
||
function formatTime(sec: number): string {
|
||
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
|
||
return `${m}:${s.toString().padStart(2, '0')}`
|
||
}
|
||
|
||
function playTrace(t: any) {
|
||
currentVideo.value = { fileUuid: t.file_uuid, startTime: t.first_sec, endTime: t.last_sec, title: `${selected.value?.name} - ${formatTime(t.first_sec)}-${formatTime(t.last_sec)}` }
|
||
playing.value = true
|
||
}
|
||
|
||
function showContextMenu(e: MouseEvent, p: any) {
|
||
e.preventDefault()
|
||
e.stopPropagation()
|
||
ctxMenu.value = { show: true, x: e.clientX, y: e.clientY, person: p }
|
||
}
|
||
|
||
function ctxAction(action: string) {
|
||
const p = ctxMenu.value.person
|
||
if (!p) return
|
||
ctxMenu.value.show = false
|
||
if (action === 'star') {
|
||
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 === 'skip') {
|
||
invoke('updateIdentityStatus', { uuid: p.identity_uuid, status: 'skipped' }).then(() => {
|
||
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('updateIdentityStatus', { 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' || action === 'merge') {
|
||
selectPerson(p)
|
||
}
|
||
}
|
||
|
||
function closeCtxMenu(e?: MouseEvent) {
|
||
if (e && e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
|
||
ctxMenu.value.show = false
|
||
}
|
||
|
||
watch(showCandidates, (v) => { if (v) loadCandidates() })
|
||
</script>
|
||
|
||
<style scoped>
|
||
.ms-ppl-card-star { position: absolute; top: 6px; left: 6px; font-size: 16px; color: #f59e0b; text-shadow: 0 1px 3px rgba(0,0,0,.25); display: none; }
|
||
.ms-ppl-face-card.starred .ms-ppl-card-star, .ms-ppl-card-star.starred { display: block; }
|
||
.people-view { max-width: 1200px; }
|
||
h1 { margin: 0; }
|
||
.loading-state, .empty { text-align: center; padding: 60px 0; color: #5f6368; }
|
||
.spinner-lg { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #202124; border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.ms-ppl-detail-view { display: none; }
|
||
.ms-ppl-detail-view.show { display: block; }
|
||
.ms-ppl-detail-header { display: flex; align-items: flex-start; gap: 22px; margin-bottom: 28px; position: relative; margin-top: 20px; }
|
||
.ms-ppl-detail-avatar { width: 120px; height: 120px; border-radius: 20px; background: #e0e0e0; flex-shrink: 0; overflow: hidden; }
|
||
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 20px; }
|
||
.ms-ppl-detail-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
||
.ms-ppl-star-btn { font-size: 20px; background: transparent; border: none; cursor: pointer; outline: none; line-height: 1; color: #d1d5db; transition: color .15s; padding: 0; flex-shrink: 0; }
|
||
.ms-ppl-star-btn.starred { color: #f59e0b; }
|
||
.ms-ppl-detail-aliases { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }
|
||
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: #f0f0f0; border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: #5f6368; }
|
||
.ms-ppl-edit-fields { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
||
.ms-ppl-edit-field-row { display: flex; align-items: center; gap: 12px; }
|
||
.ms-ppl-edit-label { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; font-weight: 700; color: #202124; letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
|
||
.ms-ppl-view-box { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; min-height: 24px; display: flex; align-items: center; word-break: break-word; padding: 2px 0; }
|
||
.ms-ppl-view-name-box { font-size: 20px; font-weight: 700; flex: 1; min-width: 0; cursor: pointer; }
|
||
.ms-ppl-view-name-box:hover { text-decoration: underline; }
|
||
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: #3c4043; }
|
||
.ms-ppl-strip-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
|
||
.ms-ppl-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed #bdc1c6; background: #fff; font-size: 20px; color: #bdc1c6; display: grid; place-items: center; cursor: pointer; outline: none; flex-shrink: 0; transition: border-color .15s, color .15s; }
|
||
.ms-ppl-strip-add-btn:hover { border-color: #202124; color: #202124; }
|
||
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
|
||
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
|
||
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: #e8eaed; overflow: hidden; transition: border-color .15s; }
|
||
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: #202124; }
|
||
.ms-uface-grid .ms-ppl-face-card { width: 120px; }
|
||
.ms-uface-grid .ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; }
|
||
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
|
||
.close-btn { position: absolute; top: 16px; right: 16px; }
|
||
.detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
|
||
.detail-avatar { width: 80px; height: 80px; border-radius: 16px; flex-shrink: 0; }
|
||
.detail-info { flex: 1; }
|
||
.name-row { display: flex; align-items: center; gap: 8px; }
|
||
.name-row h2 { margin: 0; cursor: pointer; font-size: 1.25rem; }
|
||
.name-row h2:hover { text-decoration: underline; }
|
||
.name-input { font-size: 1.25rem; font-weight: 700; border: 1.5px solid #d1d5db; border-radius: 8px; padding: 4px 8px; width: 200px; outline: none; }
|
||
.edit-btn { width: 28px; height: 28px; }
|
||
.uuid { color: #9aa0a6; font-size: 0.8rem; font-family: monospace; margin: 4px 0 8px; }
|
||
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
||
.tab { padding: 8px 16px; border: 1.5px solid #d1d5db; background: #fff; border-radius: 10px; cursor: pointer; font-size: 0.85rem; }
|
||
.tab.active { background: #202124; color: #fff; border-color: #202124; }
|
||
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
|
||
.face-thumb { width: 52px; height: 52px; border-radius: 8px; background: #e8eaed; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
|
||
.ms-ppl-media-item { cursor: pointer; }
|
||
.ms-ppl-media-thumb { position: relative; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; }
|
||
.thumb-play { color: #fff; font-size: 1.2rem; opacity: 0.8; }
|
||
.merge-input { width: 100%; padding: 10px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; margin-bottom: 16px; font-size: 0.9rem; outline: none; }
|
||
.merge-input:focus { border-color: #202124; }
|
||
.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 { 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 { 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>
|