feat: People detail modal match WordPress design

This commit is contained in:
2026-06-15 00:34:43 +08:00
parent c511674e99
commit 1c63cb80f8

View File

@@ -26,7 +26,7 @@
<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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }"></span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
@@ -48,7 +48,7 @@
<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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }"></span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
@@ -76,7 +76,7 @@
<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>
<span class="ms-ppl-card-star" :class="{ starred: p.starred }"></span>
</div>
<span class="ms-ppl-face-name">{{ p.name }}</span>
</div>
@@ -93,51 +93,60 @@
<button v-if="ctxMenu.person?.status !== 'skipped'" class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')"> 略過此人物</button>
</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">
<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 v-if="selected" class="ms-ppl-view ms-ppl-detail-view" :class="{ show: !!selected }">
<div class="ms-ppl-detail-header">
<div class="ms-ppl-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 style="flex:1;min-width:0;">
<div class="ms-ppl-detail-name-row">
<button class="ms-ppl-star-btn" :class="{ starred: selected.starred }" @click="toggleStar"></button>
<div class="ms-ppl-view-box ms-ppl-view-name-box" @click="startEditName">{{ selected.name || '' }}</div>
<button class="ms-fm-btn ms-fm-btn-danger" style="margin-left:auto;padding:4px 10px;font-size:12px;" @click="confirmDelete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></polyline>
<path d="M19 6l-1 14H6L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
</svg>
刪除
</button>
</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="ms-fm-icon-btn edit-btn" @click="startEditName"></button>
</div>
<p class="uuid">{{ selected.identity_uuid }}</p>
<div class="detail-actions">
<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 class="ms-ppl-detail-aliases" v-if="selected.metadata?.aliases?.length">
<span v-for="(a, i) in selected.metadata.aliases" :key="i" class="ms-ppl-alias-chip">{{ a.name }}</span>
</div>
<div class="ms-ppl-edit-fields">
<div class="ms-ppl-edit-field-row">
<span class="ms-ppl-edit-label">角色</span>
<div class="ms-ppl-view-box ms-ppl-view-field-box">{{ selected.metadata?.role || '—' }}</div>
</div>
</div>
</div>
<div class="tabs">
<button :class="['tab', {active: activeTab==='faces'}]" @click="activeTab='faces'">Faces ({{ faces.length }})</button>
<button :class="['tab', {active: activeTab==='traces'}]" @click="activeTab='traces'">Traces ({{ traces.length }})</button>
</div>
<div class="tab-content">
<div v-if="activeTab === 'faces'" class="face-strip">
<div v-for="f in faces.slice(0, 20)" :key="f.id" class="face-thumb">
<div class="face-placeholder">{{ Math.round(f.confidence * 100) }}%</div>
<button class="ms-fm-icon-btn" style="position:absolute;top:12px;right:12px;" @click="selected = null">×</button>
</div>
<div class="ms-ppl-strip-wrap">
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="加入相同人物"></button>
<div class="ms-ppl-face-strip">
<div v-for="f in faces.slice(0, 20)" :key="f.id" class="ms-ppl-strip-face">
<div class="ms-ppl-strip-face-img">
<div class="face-placeholder" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;">{{ Math.round(f.confidence * 100) }}%</div>
</div>
</div>
<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 class="ms-ppl-media-label" v-if="traces.length">Media ({{ traces.length }})</div>
<div 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) }}%</div>
</div>
</div>
</div>
@@ -274,6 +283,11 @@ async function selectPerson(p: any) {
}
function startEditName() { editingName.value = true; editName.value = selected.value.name }
async function toggleStar() {
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 saveName() {
editingName.value = false
if (editName.value === selected.value.name) return
@@ -372,12 +386,38 @@ 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); z-index: 1; }
.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-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; }