feat: Person detail as independent page with route
This commit is contained in:
@@ -2,12 +2,14 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import SearchView from '../views/SearchView.vue'
|
||||
import LibraryView from '../views/LibraryView.vue'
|
||||
import PeopleView from '../views/PeopleView.vue'
|
||||
import PersonDetailView from '../views/PersonDetailView.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/search' },
|
||||
{ path: '/search', name: 'Search', component: SearchView },
|
||||
{ path: '/library', name: 'Library', component: LibraryView },
|
||||
{ path: '/people', name: 'People', component: PeopleView }
|
||||
{ path: '/people', name: 'People', component: PeopleView },
|
||||
{ path: '/people/:uuid', name: 'PersonDetail', component: PersonDetailView }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
@@ -110,99 +110,16 @@
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')">✕ 略過此人物</button>
|
||||
</div>
|
||||
|
||||
<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="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>
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<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="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" />
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const router = useRouter()
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
const people = ref<any[]>([])
|
||||
@@ -211,15 +128,11 @@ const searchQuery = ref('')
|
||||
const searchResults = ref<any[]>([])
|
||||
const isSearching = ref(false)
|
||||
const selected = ref<any>(null)
|
||||
const activeTab = ref('faces')
|
||||
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 selectedProfile = ref<string>('')
|
||||
const editingName = ref(false)
|
||||
const editName = ref('')
|
||||
const showCandidates = ref(false)
|
||||
const showMerge = ref(false)
|
||||
const mergeTarget = ref('')
|
||||
@@ -294,34 +207,16 @@ async function loadCandidateThumb(uuid: string) {
|
||||
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] || ''
|
||||
activeTab.value = 'faces'
|
||||
try {
|
||||
const fResult: any = await invoke('get_faces', { uuid: p.identity_uuid, perPage: 100 })
|
||||
const tResult: any = await invoke('get_traces', { uuid: p.identity_uuid, perPage: 100 })
|
||||
faces.value = Array.isArray(fResult) ? fResult : []
|
||||
traces.value = Array.isArray(tResult) ? tResult : []
|
||||
} catch (e) { console.error('Failed to load details:', e) }
|
||||
function selectPerson(p: any) {
|
||||
router.push({ name: 'PersonDetail', params: { uuid: p.identity_uuid } })
|
||||
}
|
||||
|
||||
function startEditName() { editingName.value = true; editName.value = selected.value.name }
|
||||
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 saveName() {
|
||||
editingName.value = false
|
||||
if (editName.value === selected.value.name) return
|
||||
try {
|
||||
await invoke('update_identity_name', { uuid: selected.value.identity_uuid, name: editName.value })
|
||||
selected.value.name = editName.value
|
||||
const idx = people.value.findIndex((p: any) => p.identity_uuid === selected.value.identity_uuid)
|
||||
if (idx >= 0) people.value[idx].name = editName.value
|
||||
} catch (e) { console.error('Failed to update name:', e) }
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!confirm(`Delete "${selected.value.name}"?`)) return
|
||||
@@ -396,12 +291,8 @@ function ctxAction(action: string) {
|
||||
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') {
|
||||
} else if (action === 'rename' || action === 'merge') {
|
||||
selectPerson(p)
|
||||
setTimeout(() => startEditName(), 100)
|
||||
} else if (action === 'merge') {
|
||||
selectPerson(p)
|
||||
setTimeout(() => { showMerge.value = true }, 100)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
245
src/views/PersonDetailView.vue
Normal file
245
src/views/PersonDetailView.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div class="people-view ms-ppl-detail-view">
|
||||
<div class="ms-ppl-topbar">
|
||||
<button class="ms-fm-btn" @click="router.back()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
返回
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner-lg"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<template v-else-if="person">
|
||||
<div class="ms-ppl-detail-header">
|
||||
<div class="ms-ppl-detail-avatar">
|
||||
<img v-if="profile" :src="profile" 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: person.starred }" @click="toggleStar">☆</button>
|
||||
<div class="ms-ppl-view-box ms-ppl-view-name-box">{{ person.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="ms-ppl-detail-aliases" v-if="person.metadata?.aliases?.length">
|
||||
<span v-for="(a, i) in person.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">{{ person.metadata?.role || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
</template>
|
||||
<div v-else class="empty">Person not found</div>
|
||||
|
||||
<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="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" />
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const person = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const profile = ref('')
|
||||
const faces = ref<any[]>([])
|
||||
const traces = ref<any[]>([])
|
||||
const playing = ref(false)
|
||||
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
||||
const showCandidates = ref(false)
|
||||
const showMerge = ref(false)
|
||||
const mergeTarget = ref('')
|
||||
const candidates = ref<any[]>([])
|
||||
const candidateThumbs = ref<Record<string, string>>({})
|
||||
|
||||
onMounted(async () => {
|
||||
const uuid = route.params.uuid as string
|
||||
try {
|
||||
const people: any = await invoke('getPeople', { page: 1, perPage: 1000 })
|
||||
const found = (Array.isArray(people) ? people : []).find((p: any) => p.identity_uuid === uuid)
|
||||
if (found) {
|
||||
person.value = { ...found, status: found.status || 'confirmed' }
|
||||
await loadProfile(uuid)
|
||||
const fResult: any = await invoke('getFaces', { uuid, perPage: 100 })
|
||||
const tResult: any = await invoke('getTraces', { uuid, perPage: 100 })
|
||||
faces.value = Array.isArray(fResult) ? fResult : []
|
||||
traces.value = Array.isArray(tResult) ? tResult : []
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load person:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function loadProfile(uuid: string) {
|
||||
try { profile.value = 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 toggleStar() {
|
||||
if (!person.value) return
|
||||
person.value.starred = !person.value.starred
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!person.value || !confirm(`Delete "${person.value.name}"?`)) return
|
||||
try {
|
||||
await invoke('delete_identity', { uuid: person.value.identity_uuid })
|
||||
router.back()
|
||||
} catch (e) { console.error('Failed to delete:', e) }
|
||||
}
|
||||
|
||||
async function bindCandidate(c: any) {
|
||||
if (!person.value) return
|
||||
try {
|
||||
await invoke('bind_face', { uuid: person.value.identity_uuid, faceId: String(c.id), fileUuid: c.file_uuid })
|
||||
showCandidates.value = false
|
||||
const fResult: any = await invoke('getFaces', { uuid: person.value.identity_uuid, perPage: 100 })
|
||||
faces.value = Array.isArray(fResult) ? fResult : []
|
||||
} catch (e) { console.error('Bind failed:', e) }
|
||||
}
|
||||
|
||||
async function confirmMerge() {
|
||||
if (!person.value || !mergeTarget.value) return
|
||||
try {
|
||||
await invoke('merge_identities', { uuid: person.value.identity_uuid, intoUuid: mergeTarget.value })
|
||||
router.back()
|
||||
} catch (e) { console.error('Merge failed:', e) }
|
||||
}
|
||||
|
||||
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: `${person.value?.name} - ${formatTime(t.first_sec)}-${formatTime(t.last_sec)}` }
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function showAssignModal(c: any) {
|
||||
alert(`Assign face ${c.id} to existing person - not yet implemented`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.people-view { max-width: 1200px; padding-top: 20px; }
|
||||
.ms-ppl-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; margin-top: 20px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.ms-ppl-media-item { cursor: pointer; border-radius: 12px; overflow: visible; background: #f0f0f0; transition: transform .15s, box-shadow .15s; border: 1px solid #eee; }
|
||||
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
|
||||
.ms-ppl-media-thumb { position: relative; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; width: 100%; aspect-ratio: 16/9; overflow: hidden; border-radius: 12px 12px 0 0; }
|
||||
.thumb-play { color: #fff; font-size: 1.2rem; opacity: 0.8; }
|
||||
.ms-ppl-media-info { padding: 8px 10px 10px; background: #fff; }
|
||||
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: #202124; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
|
||||
.ms-ppl-media-sub { font-size: 10.5px; color: #9aa0a6; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
|
||||
.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>
|
||||
Reference in New Issue
Block a user