refactor: SearchView + PeopleView layout alignment with WordPress CSS

This commit is contained in:
2026-06-14 14:07:29 +08:00
parent 6c262acff0
commit a6a5add2f2
2 changed files with 143 additions and 102 deletions

View File

@@ -1,36 +1,54 @@
<template>
<div class="people-view">
<div class="toolbar">
<h1>People</h1>
<input v-model="searchQuery" class="search-input" placeholder="Search people..." @input="onSearch" />
</div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="filteredPeople.length === 0" class="empty">No results</div>
<div v-else class="grid">
<div v-for="p in filteredPeople" :key="p.identity_uuid" class="person-card" @click="selectPerson(p)">
<img v-if="profiles[p.identity_uuid]" class="avatar-img" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
<div v-else class="avatar" @vue:mounted="loadProfile(p.identity_uuid)">{{ p.name?.[0]?.toUpperCase() || '?' }}</div>
<p class="name">{{ p.name }}</p>
<span v-if="p.starred" class="starred"></span>
<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="selected" class="detail-modal" @click.self="selected = null">
<div class="detail-box">
<button class="close-btn" @click="selected = null">×</button>
<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)">
<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 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>
<div class="detail-header">
<img v-if="selectedProfile" class="detail-avatar-img" :src="selectedProfile" alt="">
<div v-else class="detail-avatar">{{ selected.name?.[0]?.toUpperCase() || '?' }}</div>
<div class="ms-ppl-face-img-wrap detail-avatar">
<img v-if="selectedProfile" :src="selectedProfile" alt="">
<svg v-else class="ms-silhouette" 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>
</div>
<div class="detail-info">
<div class="name-row">
<input v-if="editingName" v-model="editName" class="name-input" @keyup.enter="saveName" @blur="saveName" />
<h2 v-else @click="startEditName">{{ selected.name }}</h2>
<button class="edit-btn" @click="startEditName" title="Edit name"></button>
<button class="ms-fm-icon-btn edit-btn" @click="startEditName"></button>
</div>
<p class="uuid">{{ selected.identity_uuid }}</p>
<div class="detail-actions">
<button class="action-btn" @click="showCandidates = true">Bind Face</button>
<button class="action-btn" @click="showMerge = true">Merge</button>
<button class="action-btn delete" @click="confirmDelete">Delete</button>
<button class="ms-fm-btn" @click="showCandidates = true">Bind Face</button>
<button class="ms-fm-btn" @click="showMerge = true">Merge</button>
<button class="ms-fm-btn ms-fm-btn-danger" @click="confirmDelete">Delete</button>
</div>
</div>
</div>
@@ -44,38 +62,48 @@
<div class="face-placeholder">{{ Math.round(f.confidence * 100) }}%</div>
</div>
</div>
<div v-if="activeTab === 'traces'" class="trace-list">
<div v-for="t in traces.slice(0, 20)" :key="t.trace_id" class="trace-item" @click="playTrace(t)">
<div class="trace-thumb"></div>
<div class="trace-info">
<span class="trace-time">{{ formatTime(t.first_sec) }} - {{ formatTime(t.last_sec) }}</span>
<span class="trace-conf">{{ (t.avg_confidence * 100).toFixed(0) }}%</span>
<div v-if="activeTab === 'traces'" class="ms-ppl-media-grid">
<div v-for="t in traces.slice(0, 20)" :key="t.trace_id" class="ms-ppl-media-item" @click="playTrace(t)">
<div class="ms-ppl-media-thumb">
<div class="thumb-play"></div>
</div>
<div class="ms-ppl-media-info">
<div class="ms-ppl-media-title">{{ formatTime(t.first_sec) }} - {{ formatTime(t.last_sec) }}</div>
<div class="ms-ppl-media-sub">{{ (t.avg_confidence * 100).toFixed(0) }}% confidence</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="showCandidates" class="detail-modal" @click.self="showCandidates = false">
<div class="detail-box">
<button class="close-btn" @click="showCandidates = false">×</button>
<h2>Bind Face</h2>
<div class="candidates-list">
<div v-for="c in candidates" :key="c.id" class="candidate-item" @click="bindCandidate(c)">
<span>File: {{ c.file_uuid.slice(0, 12) }}... Frame: {{ c.frame_number }}</span>
<span class="conf">{{ (c.confidence * 100).toFixed(0) }}%</span>
<div v-if="showCandidates" class="ms-modal-overlay show" @click.self="showCandidates = false">
<div class="ms-modal ms-modal-merge">
<button class="ms-fm-icon-btn close-btn" @click="showCandidates = false">×</button>
<h2 class="ms-ppl-section-title">Bind Face</h2>
<div class="ms-merge-grid">
<div v-for="c in candidates" :key="c.id" class="ms-merge-face-card" @click="bindCandidate(c)">
<div class="ms-merge-face-img">
<img v-if="candidateThumbs[c.file_uuid]" :src="candidateThumbs[c.file_uuid]" alt="">
<div v-else class="face-placeholder" @vue:mounted="loadCandidateThumb(c.file_uuid)">{{ Math.round(c.confidence * 100) }}%</div>
</div>
<span class="ms-merge-face-name">{{ c.file_uuid.slice(0, 8) }}... #{{ c.frame_number }}</span>
</div>
</div>
</div>
</div>
<div v-if="showMerge" class="detail-modal" @click.self="showMerge = false">
<div class="detail-box">
<button class="close-btn" @click="showMerge = false">×</button>
<h2>Merge Identity</h2>
<div v-if="showMerge" class="ms-modal-overlay show" @click.self="showMerge = false">
<div class="ms-modal">
<button class="ms-fm-icon-btn close-btn" @click="showMerge = false">×</button>
<h2 class="ms-ppl-section-title">Merge Identity</h2>
<input v-model="mergeTarget" class="merge-input" placeholder="Target identity UUID" />
<button class="action-btn primary" :disabled="!mergeTarget" @click="confirmMerge">Merge</button>
<div class="ms-modal-actions">
<button class="ms-fm-btn ms-fm-btn-blue" :disabled="!mergeTarget" @click="confirmMerge">Merge</button>
</div>
</div>
</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>
@@ -104,6 +132,7 @@ const showCandidates = ref(false)
const showMerge = ref(false)
const mergeTarget = ref('')
const candidates = ref<any[]>([])
const candidateThumbs = ref<Record<string, string>>({})
const filteredPeople = computed(() => {
if (isSearching.value && searchResults.value.length) return searchResults.value
@@ -141,6 +170,11 @@ async function loadProfile(uuid: string) {
try { profiles.value[uuid] = await invoke('get_identity_profile', { uuid }) } catch {}
}
async function loadCandidateThumb(uuid: string) {
if (!uuid || candidateThumbs.value[uuid]) return
try { candidateThumbs.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 }) } catch {}
}
async function selectPerson(p: any) {
selected.value = p
selectedProfile.value = profiles.value[p.identity_uuid] || ''
@@ -215,50 +249,33 @@ watch(showCandidates, (v) => { if (v) loadCandidates() })
<style scoped>
.people-view { max-width: 1200px; }
.toolbar { display: flex; align-items: center; gap: 20px; margin-bottom: 20px; }
h1 { margin: 0; }
.search-input { padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px; width: 300px; }
.loading, .empty { text-align: center; padding: 40px; color: #6b7280; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: 16px; }
.person-card { background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; text-align: center; cursor: pointer; position: relative; }
.person-card:hover { border-color: #4f46e5; }
.avatar, .avatar-img { width: 56px; height: 56px; border-radius: 50%; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 600; margin: 0 auto 8px; object-fit: cover; }
.name { font-size: 0.75rem; color: #374151; margin: 0; }
.starred { position: absolute; top: 8px; right: 8px; font-size: 0.8rem; }
.detail-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.detail-box { background: #fff; border-radius: 20px; padding: 32px; width: min(700px, 90vw); max-height: 80vh; overflow-y: auto; position: relative; }
.close-btn { position: absolute; top: 16px; right: 16px; width: 32px; height: 32px; border-radius: 50%; background: #f3f4f6; border: none; font-size: 1.2rem; cursor: pointer; }
.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); } }
.close-btn { position: absolute; top: 16px; right: 16px; }
.detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
.detail-avatar, .detail-avatar-img { width: 80px; height: 80px; border-radius: 20px; background: #eef2ff; color: #4f46e5; display: flex; align-items: center; justify-content: center; font-size: 2rem; font-weight: 700; object-fit: cover; flex-shrink: 0; }
.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; }
.name-input { font-size: 1.25rem; font-weight: 700; border: 1px solid #d1d5db; border-radius: 6px; padding: 4px 8px; width: 200px; }
.edit-btn { background: none; border: none; cursor: pointer; font-size: 1rem; color: #6b7280; }
.uuid { color: #9ca3af; font-size: 0.8rem; font-family: monospace; margin: 4px 0 8px; }
.detail-actions { display: flex; gap: 8px; }
.action-btn { padding: 6px 14px; border-radius: 8px; border: 1px solid #d1d5db; background: #fff; cursor: pointer; font-size: 0.85rem; }
.action-btn.primary { background: #2563eb; color: #fff; border-color: #2563eb; }
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.action-btn.delete { color: #dc2626; border-color: #fca5a5; }
.action-btn.delete:hover { background: #fef2f2; }
.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: 1px solid #d1d5db; background: #fff; border-radius: 8px; cursor: pointer; }
.tab.active { background: #2563eb; color: #fff; border-color: #2563eb; }
.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: #e5e7eb; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.face-placeholder { font-size: 0.6rem; color: #6b7280; }
.trace-list { display: flex; flex-direction: column; gap: 8px; }
.trace-item { display: flex; align-items: center; gap: 12px; padding: 8px 12px; background: #f9fafb; border-radius: 8px; cursor: pointer; }
.trace-item:hover { background: #eef2ff; }
.trace-thumb { color: #4f46e5; }
.trace-info { display: flex; gap: 12px; flex: 1; }
.trace-time { color: #374151; font-size: 0.85rem; }
.trace-conf { color: #4f46e5; font-weight: 500; font-size: 0.85rem; }
.candidates-list { display: flex; flex-direction: column; gap: 8px; }
.candidate-item { display: flex; justify-content: space-between; padding: 8px 12px; background: #f9fafb; border-radius: 8px; cursor: pointer; }
.candidate-item:hover { background: #eef2ff; }
.conf { color: #4f46e5; font-weight: 500; }
.merge-input { width: 100%; padding: 10px; border: 1px solid #d1d5db; border-radius: 8px; margin-bottom: 12px; }
h2 { margin: 0 0 16px; }
.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; }
</style>

View File

@@ -3,27 +3,36 @@
<div class="search-header">
<h1>Search</h1>
<div class="mode-selector">
<button v-for="m in modes" :key="m.value"
<button v-for="m in modes" :key="m.value"
:class="['mode-btn', { active: mode === m.value }]"
@click="mode = m.value">
{{ m.label }}
</button>
</div>
</div>
<div class="search-bar">
<input v-model="query" @keyup.enter="search"
placeholder="Enter search query..."
<input v-model="query" @keyup.enter="search"
placeholder="Enter search query..."
:disabled="loading" />
<button @click="search" :disabled="loading || !query">
<button class="ms-fm-btn ms-fm-btn-primary" @click="search" :disabled="loading || !query">
<span v-if="loading" class="spinner"></span>
{{ loading ? 'Searching...' : 'Search' }}
</button>
</div>
<div v-if="loading && !results.length" class="loading-state">
<div class="spinner-lg"></div>
<p>Searching...</p>
</div>
<div class="results" v-if="results.length">
<div v-for="(r, i) in results" :key="i" class="result-card" @click="playVideo(r)">
<div class="result-thumb">
<div class="play-icon"></div>
<img v-if="thumbs[r.file_uuid]" :src="thumbs[r.file_uuid]" alt="" loading="lazy" @vue:mounted="loadThumb(r.file_uuid)">
<div v-else class="thumb-placeholder" @vue:mounted="loadThumb(r.file_uuid)">
<div class="play-icon"></div>
</div>
</div>
<div class="result-info">
<div class="result-meta">
@@ -55,8 +64,7 @@ import VideoPlayer from '../components/VideoPlayer.vue'
const modes = [
{ label: 'Keyword', value: 'keyword' },
{ label: 'Semantic', value: 'semantic' },
{ label: 'Agent', value: 'agent' }
{ label: 'Semantic', value: 'semantic' }
]
const mode = ref('semantic')
@@ -64,6 +72,7 @@ const query = ref('')
const results = ref<any[]>([])
const loading = ref(false)
const searched = ref(false)
const thumbs = ref<Record<string, string>>({})
const playing = ref(false)
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
@@ -83,6 +92,15 @@ async function search() {
}
}
async function loadThumb(uuid: string) {
if (!uuid || thumbs.value[uuid]) return
try {
thumbs.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 })
} catch {
// keep placeholder
}
}
function formatTime(sec: number): string {
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
@@ -105,22 +123,28 @@ function playVideo(r: any) {
.search-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
h1 { font-size: 1.5rem; }
.mode-selector { display: flex; gap: 8px; }
.mode-btn { padding: 8px 16px; border: 1px solid #d1d5db; background: #fff; border-radius: 8px; cursor: pointer; font-size: 0.85rem; }
.mode-btn.active { background: #4f46e5; color: #fff; border-color: #4f46e5; }
.mode-btn { padding: 8px 16px; border: 1.5px solid #d1d5db; background: #fff; border-radius: 10px; cursor: pointer; font-size: 0.85rem; font-weight: 500; transition: all 0.15s; }
.mode-btn:hover { background: #f3f4f6; }
.mode-btn.active { background: #202124; color: #fff; border-color: #202124; }
.search-bar { display: flex; gap: 10px; margin-bottom: 24px; }
.search-bar input { flex: 1; padding: 12px 16px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 1rem; }
.search-bar input { flex: 1; padding: 12px 16px; border: 1.5px solid #d1d5db; border-radius: 10px; font-size: 1rem; outline: none; transition: border-color 0.15s; }
.search-bar input:focus { border-color: #202124; }
.search-bar input:disabled { background: #f3f4f6; }
.search-bar button { padding: 12px 24px; background: #4f46e5; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; }
.search-bar button:disabled { opacity: 0.6; cursor: not-allowed; }
.result-card { display: flex; gap: 16px; background: #fff; border: 1px solid #e5e7eb; border-radius: 12px; padding: 16px; margin-bottom: 12px; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; }
.result-card:hover { border-color: #4f46e5; box-shadow: 0 4px 12px rgba(79,70,229,0.1); }
.result-thumb { width: 120px; height: 68px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.result-card { display: flex; gap: 16px; background: #fff; border: 1px solid #e8eaed; border-radius: 12px; padding: 16px; margin-bottom: 12px; cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; }
.result-card:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,0.1); }
.result-thumb { width: 120px; height: 68px; border-radius: 8px; overflow: hidden; flex-shrink: 0; background: #e8eaed; }
.result-thumb img { width: 100%; height: 100%; object-fit: cover; }
.thumb-placeholder { width: 100%; height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; }
.play-icon { color: #fff; font-size: 1.5rem; opacity: 0.8; }
.result-info { flex: 1; }
.result-info { flex: 1; min-width: 0; }
.result-meta { display: flex; gap: 12px; margin-bottom: 6px; }
.score { color: #4f46e5; font-weight: 600; font-size: 0.85rem; }
.time { color: #6b7280; font-size: 0.85rem; }
.summary { color: #374151; margin-bottom: 4px; }
.file-name { color: #9ca3af; font-size: 0.8rem; }
.no-results { text-align: center; padding: 40px; color: #6b7280; }
.score { color: #1a56db; font-weight: 600; font-size: 0.85rem; }
.time { color: #9aa0a6; font-size: 0.85rem; }
.summary { color: #3c4043; margin-bottom: 4px; line-height: 1.4; }
.file-name { color: #9aa0a6; font-size: 0.8rem; }
.no-results { text-align: center; padding: 40px; color: #5f6368; }
.loading-state { text-align: center; padding: 60px 0; color: #5f6368; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 6px; }
.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); } }
</style>