feat: frame-based positioning and mark system foundation
Core Changes: - Fix SearchView to use start_frame/end_frame directly (no time*fps conversion) - Add hard_delete support to delete_trace API - VideoPlayer: Main timeline + Mark system foundation - Proxy: Add local routes for auth, media, identity-matches, cluster-results - Add .gitignore to exclude build artifacts and dependencies Design Documents: - Multi-track Mark system design (.opencode/plans/) - Video editing positioning standards research Files Modified: - src/views/SearchView.vue: Frame positioning, ensureMinDuration (240 frames) - src/views/PeopleView.vue: batchDeleteGroups with hard_delete - src/api/index.ts: delete_trace with hard_delete body - src/components/VideoPlayer.vue: Timeline + Mark UI - src-tauri/src/proxy.rs: New local routes - AGENTS.md: Update documentation
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'settings' }" @click="activeTab = 'settings'">Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -41,6 +42,108 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'settings'" class="settings-panel">
|
||||
<div v-if="settingsError" class="settings-error">{{ settingsError }}</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3>Upload Path</h3>
|
||||
<p class="settings-desc">Base directory where uploaded files are stored (appended with /<username>).</p>
|
||||
<div class="settings-row">
|
||||
<input class="settings-input" v-model="uploadPath" placeholder="/Users/accusys/momentry/var/sftpgo/data" />
|
||||
<button class="settings-save-btn" @click="saveUploadPath" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
<div v-if="saveMessage" :class="['settings-msg', saveError ? 'error' : 'ok']">{{ saveMessage }}</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<h3>Service URLs</h3>
|
||||
<p class="settings-desc">Configure backend service endpoints.</p>
|
||||
|
||||
<div class="service-card">
|
||||
<div class="service-header">
|
||||
<span class="service-name">Core API</span>
|
||||
<span :class="['service-status', coreStatus]">{{ coreStatus }}</span>
|
||||
</div>
|
||||
<div class="service-row">
|
||||
<input class="settings-input" v-model="coreApiUrl" placeholder="http://localhost:3002" />
|
||||
<button class="settings-test-btn" @click="testCoreApi" :disabled="testingCore">{{ testingCore ? 'Testing...' : 'Test' }}</button>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="service-stop-btn" @click="stopService('core')" :disabled="coreActionLoading">Stop</button>
|
||||
<button class="service-start-btn" @click="startService('core')" :disabled="coreActionLoading">Start</button>
|
||||
<span v-if="coreActionLoading" class="action-loading">Processing...</span>
|
||||
<span v-if="coreActionResult" :class="['action-result', coreActionResultOk ? 'ok' : 'error']">{{ coreActionResult }}</span>
|
||||
</div>
|
||||
<div v-if="coreInfo" class="service-info">
|
||||
<span>Version: {{ coreInfo.version || 'N/A' }}</span>
|
||||
<span>Uptime: {{ formatUptime(coreInfo.uptime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-card">
|
||||
<div class="service-header">
|
||||
<span class="service-name">MarkBase</span>
|
||||
<span :class="['service-status', mbStatus]">{{ mbStatus }}</span>
|
||||
</div>
|
||||
<div class="service-row">
|
||||
<input class="settings-input" v-model="markbaseUrl" placeholder="http://localhost:11438" />
|
||||
<button class="settings-test-btn" @click="testMarkbase" :disabled="testingMb">{{ testingMb ? 'Testing...' : 'Test' }}</button>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="service-stop-btn" @click="stopService('markbase')" :disabled="mbActionLoading">Stop</button>
|
||||
<button class="service-start-btn" @click="startService('markbase')" :disabled="mbActionLoading">Start</button>
|
||||
<span v-if="mbActionLoading" class="action-loading">Processing...</span>
|
||||
<span v-if="mbActionResult" :class="['action-result', mbActionResultOk ? 'ok' : 'error']">{{ mbActionResult }}</span>
|
||||
</div>
|
||||
<div v-if="mbInfo" class="service-info">
|
||||
<span>Version: {{ mbInfo.version || 'N/A' }}</span>
|
||||
<span>Uptime: {{ formatUptime(mbInfo.uptime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-card">
|
||||
<div class="service-header">
|
||||
<span class="service-name">MarkBaseEngine</span>
|
||||
<span :class="['service-status', mbeStatus]">{{ mbeStatus }}</span>
|
||||
</div>
|
||||
<div class="service-row">
|
||||
<input class="settings-input" v-model="mbeUrl" placeholder="http://localhost:8080" />
|
||||
<button class="settings-test-btn" @click="testMbe" :disabled="testingMbe">{{ testingMbe ? 'Testing...' : 'Test' }}</button>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="service-stop-btn" @click="stopService('mbe')" :disabled="mbeActionLoading">Stop</button>
|
||||
<button class="service-start-btn" @click="startService('mbe')" :disabled="mbeActionLoading">Start</button>
|
||||
<span v-if="mbeActionLoading" class="action-loading">Processing...</span>
|
||||
<span v-if="mbeActionResult" :class="['action-result', mbeActionResultOk ? 'ok' : 'error']">{{ mbeActionResult }}</span>
|
||||
</div>
|
||||
<div v-if="mbeInfo" class="service-info">
|
||||
<span>Version: {{ mbeInfo.version || 'N/A' }}</span>
|
||||
<span>Uptime: {{ formatUptime(mbeInfo.uptime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="service-card">
|
||||
<div class="service-header">
|
||||
<span class="service-name">Proxy</span>
|
||||
<span :class="['service-status', proxyStatus]">{{ proxyStatus }}</span>
|
||||
</div>
|
||||
<div class="service-row">
|
||||
<input class="settings-input" v-model="proxyUrl" placeholder="http://localhost:8888" />
|
||||
<button class="settings-test-btn" @click="testProxy" :disabled="testingProxy">{{ testingProxy ? 'Testing...' : 'Test' }}</button>
|
||||
</div>
|
||||
<div class="service-actions">
|
||||
<button class="service-stop-btn" @click="stopService('proxy')" :disabled="proxyActionLoading">Stop</button>
|
||||
<button class="service-start-btn" @click="startService('proxy')" :disabled="proxyActionLoading">Start</button>
|
||||
<span v-if="proxyActionLoading" class="action-loading">Processing...</span>
|
||||
<span v-if="proxyActionResult" :class="['action-result', proxyActionResultOk ? 'ok' : 'error']">{{ proxyActionResult }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="settings-save-btn" @click="saveServiceUrls" style="margin-top: 12px;">Save URLs</button>
|
||||
<div v-if="urlSaveMsg" :class="['settings-msg', urlSaveError ? 'error' : 'ok']">{{ urlSaveMsg }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -54,6 +157,226 @@ const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
||||
const users = ref<any[]>([])
|
||||
const shares = ref<any[]>([])
|
||||
|
||||
// Settings
|
||||
const uploadPath = ref('')
|
||||
const saving = ref(false)
|
||||
const saveMessage = ref('')
|
||||
const saveError = ref(false)
|
||||
const settingsError = ref('')
|
||||
|
||||
const coreApiUrl = ref('http://localhost:3002')
|
||||
const markbaseUrl = ref('http://localhost:11438')
|
||||
const mbeUrl = ref('http://localhost:8080')
|
||||
const proxyUrl = ref('http://localhost:8888')
|
||||
const coreStatus = ref('unknown')
|
||||
const mbStatus = ref('unknown')
|
||||
const mbeStatus = ref('unknown')
|
||||
const proxyStatus = ref('unknown')
|
||||
const coreInfo = ref<any>(null)
|
||||
const mbInfo = ref<any>(null)
|
||||
const mbeInfo = ref<any>(null)
|
||||
const testingCore = ref(false)
|
||||
const testingMb = ref(false)
|
||||
const testingMbe = ref(false)
|
||||
const testingProxy = ref(false)
|
||||
const urlSaveMsg = ref('')
|
||||
const urlSaveError = ref(false)
|
||||
|
||||
// Service stop/start state
|
||||
const coreActionLoading = ref(false)
|
||||
const coreActionResult = ref('')
|
||||
const coreActionResultOk = ref(false)
|
||||
const mbActionLoading = ref(false)
|
||||
const mbActionResult = ref('')
|
||||
const mbActionResultOk = ref(false)
|
||||
const mbeActionLoading = ref(false)
|
||||
const mbeActionResult = ref('')
|
||||
const mbeActionResultOk = ref(false)
|
||||
const proxyActionLoading = ref(false)
|
||||
const proxyActionResult = ref('')
|
||||
const proxyActionResultOk = ref(false)
|
||||
|
||||
async function stopService(name: string) {
|
||||
const s = serviceState(name)
|
||||
s.loading.value = true
|
||||
s.result.value = ''
|
||||
try {
|
||||
const result = await apiCall('stop_service', { name })
|
||||
s.result.value = result === 'stopped' ? 'Service stopped' : result
|
||||
s.ok.value = true
|
||||
setTimeout(() => s.test(), 1000)
|
||||
} catch (e: any) {
|
||||
s.result.value = 'Error: ' + (e.message || e)
|
||||
s.ok.value = false
|
||||
} finally {
|
||||
s.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function startService(name: string) {
|
||||
const s = serviceState(name)
|
||||
s.loading.value = true
|
||||
s.result.value = ''
|
||||
try {
|
||||
const result = await apiCall('start_service', { name })
|
||||
const started = typeof result === 'string' ? result === 'started' : result?.result === 'started'
|
||||
s.result.value = started ? (name === 'mbe' ? 'Loading model...' : 'Service started') : JSON.stringify(result)
|
||||
s.ok.value = started
|
||||
// Poll up to 30s for the service to pass health check
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
await s.test()
|
||||
if (s.status.value === 'online') {
|
||||
s.result.value = 'Service started'
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
s.result.value = 'Error: ' + (e.message || e)
|
||||
s.ok.value = false
|
||||
} finally {
|
||||
s.loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function serviceState(name: string) {
|
||||
switch (name) {
|
||||
case 'core': return { loading: coreActionLoading, result: coreActionResult, ok: coreActionResultOk, status: coreStatus, test: testCoreApi }
|
||||
case 'markbase': return { loading: mbActionLoading, result: mbActionResult, ok: mbActionResultOk, status: mbStatus, test: testMarkbase }
|
||||
case 'proxy': return { loading: proxyActionLoading, result: proxyActionResult, ok: proxyActionResultOk, status: proxyStatus, test: testProxy }
|
||||
default: return { loading: mbeActionLoading, result: mbeActionResult, ok: mbeActionResultOk, status: mbeStatus, test: testMbe }
|
||||
}
|
||||
}
|
||||
|
||||
function loadServiceUrls() {
|
||||
const stored = localStorage.getItem('service_urls')
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||
const currentOrigin = window.location.origin
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const urls = JSON.parse(stored)
|
||||
coreApiUrl.value = urls.core || (isLocalhost ? 'http://localhost:3002' : currentOrigin)
|
||||
markbaseUrl.value = urls.markbase || (isLocalhost ? 'http://localhost:11438' : currentOrigin)
|
||||
mbeUrl.value = urls.mbe || (isLocalhost ? 'http://localhost:8080' : currentOrigin)
|
||||
proxyUrl.value = urls.proxy || (isLocalhost ? 'http://localhost:8888' : currentOrigin)
|
||||
} catch (e) {
|
||||
coreApiUrl.value = isLocalhost ? 'http://localhost:3002' : currentOrigin
|
||||
markbaseUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
|
||||
mbeUrl.value = isLocalhost ? 'http://localhost:8080' : currentOrigin
|
||||
proxyUrl.value = isLocalhost ? 'http://localhost:8888' : currentOrigin
|
||||
}
|
||||
} else {
|
||||
coreApiUrl.value = isLocalhost ? 'http://localhost:3002' : currentOrigin
|
||||
markbaseUrl.value = isLocalhost ? 'http://localhost:11438' : currentOrigin
|
||||
mbeUrl.value = isLocalhost ? 'http://localhost:8080' : currentOrigin
|
||||
proxyUrl.value = isLocalhost ? 'http://localhost:8888' : currentOrigin
|
||||
}
|
||||
}
|
||||
|
||||
function saveServiceUrls() {
|
||||
urlSaveMsg.value = ''
|
||||
urlSaveError.value = false
|
||||
try {
|
||||
localStorage.setItem('service_urls', JSON.stringify({
|
||||
core: coreApiUrl.value,
|
||||
markbase: markbaseUrl.value,
|
||||
mbe: mbeUrl.value,
|
||||
proxy: proxyUrl.value,
|
||||
}))
|
||||
;(window as any).__CORE_API_URL__ = coreApiUrl.value
|
||||
;(window as any).__MARKBASE_URL__ = markbaseUrl.value
|
||||
;(window as any).__MBE_URL__ = mbeUrl.value
|
||||
urlSaveMsg.value = 'Service URLs saved'
|
||||
} catch (e: any) {
|
||||
urlSaveError.value = true
|
||||
urlSaveMsg.value = 'Failed to save: ' + (e.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
async function testCoreApi() {
|
||||
testingCore.value = true
|
||||
coreInfo.value = null
|
||||
try {
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||
const healthUrl = isLocalhost ? `${coreApiUrl.value}/health` : `${coreApiUrl.value}/api/v1/health`
|
||||
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
coreStatus.value = 'online'
|
||||
coreInfo.value = { version: data.version, uptime: data.uptime_ms }
|
||||
} catch (e: any) {
|
||||
coreStatus.value = 'offline'
|
||||
coreInfo.value = null
|
||||
} finally {
|
||||
testingCore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testMarkbase() {
|
||||
testingMb.value = true
|
||||
mbInfo.value = null
|
||||
try {
|
||||
const resp = await fetch(`${markbaseUrl.value}/api/v2/health`, { signal: AbortSignal.timeout(5000) })
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
mbStatus.value = 'online'
|
||||
mbInfo.value = { version: data.version, uptime: data.uptime }
|
||||
} catch (e: any) {
|
||||
mbStatus.value = 'offline'
|
||||
mbInfo.value = null
|
||||
} finally {
|
||||
testingMb.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testMbe() {
|
||||
testingMbe.value = true
|
||||
mbeInfo.value = null
|
||||
try {
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||
const healthUrl = isLocalhost ? `${mbeUrl.value}/health` : `${mbeUrl.value}/v1/health`
|
||||
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const text = await resp.text()
|
||||
if (text === 'OK') {
|
||||
mbeStatus.value = 'online'
|
||||
mbeInfo.value = { version: 'MarkBaseEngine', uptime: 0 }
|
||||
} else {
|
||||
throw new Error('Unexpected response: ' + text)
|
||||
}
|
||||
} catch (e: any) {
|
||||
mbeStatus.value = 'offline'
|
||||
mbeInfo.value = null
|
||||
} finally {
|
||||
testingMbe.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testProxy() {
|
||||
testingProxy.value = true
|
||||
try {
|
||||
const isLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
||||
const healthUrl = isLocalhost ? `${proxyUrl.value}/health` : `${proxyUrl.value}/api/v1/health`
|
||||
const resp = await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })
|
||||
proxyStatus.value = resp.ok ? 'online' : 'offline'
|
||||
} catch {
|
||||
proxyStatus.value = 'offline'
|
||||
} finally {
|
||||
testingProxy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number | undefined): string {
|
||||
if (!seconds) return 'N/A'
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
|
||||
}
|
||||
@@ -66,20 +389,90 @@ async function loadShares() {
|
||||
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
|
||||
async function loadSettings() {
|
||||
settingsError.value = ''
|
||||
saveMessage.value = ''
|
||||
try {
|
||||
const resp = await fetch(`${markbaseUrl.value}/api/v2/config`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const cfg = await resp.json()
|
||||
uploadPath.value = cfg.server?.upload_path || ''
|
||||
} catch (e: any) {
|
||||
settingsError.value = 'Failed to load settings: ' + (e.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUploadPath() {
|
||||
saving.value = true
|
||||
saveMessage.value = ''
|
||||
saveError.value = false
|
||||
try {
|
||||
const resp = await fetch(`${markbaseUrl.value}/api/v2/config/edit?key=server.upload_path&value=${encodeURIComponent(uploadPath.value)}`, { method: 'POST' })
|
||||
const data = await resp.json()
|
||||
if (data.ok) {
|
||||
saveMessage.value = 'Upload path saved successfully'
|
||||
} else {
|
||||
saveError.value = true
|
||||
saveMessage.value = data.error || 'Save failed'
|
||||
}
|
||||
} catch (e: any) {
|
||||
saveError.value = true
|
||||
saveMessage.value = 'Error: ' + (e.message || e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadServiceUrls()
|
||||
await Promise.all([loadDashboard(), loadUsers(), loadShares(), loadSettings()])
|
||||
await Promise.all([testCoreApi(), testMarkbase(), testMbe(), testProxy()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-admin { padding: 20px; }
|
||||
#ms-view-admin { padding: 20px; color: var(--text-primary); }
|
||||
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.admin-tabs { display: flex; gap: 10px; }
|
||||
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
|
||||
.admin-tab.active { background: #2563eb; color: white; }
|
||||
.admin-content { background: white; border-radius: 8px; padding: 20px; }
|
||||
.admin-tab { padding: 8px 16px; border: none; background: var(--hover-background); color: var(--text-primary); cursor: pointer; border-radius: 4px; }
|
||||
.admin-tab.active { background: var(--primary-color); color: white; }
|
||||
.admin-content { background: var(--card-background); border-radius: 8px; padding: 20px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
|
||||
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-label { font-size: 12px; color: #666; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; }
|
||||
.stat-card { background: var(--hover-background); padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-label { font-size: 12px; color: var(--text-secondary); }
|
||||
.stat-value { font-size: 24px; font-weight: bold; color: var(--text-primary); }
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
.admin-table th, .admin-table td { padding: 10px; border: 1px solid var(--border-color); text-align: left; color: var(--text-primary); }
|
||||
.settings-panel { max-width: 600px; }
|
||||
.settings-group { margin-bottom: 24px; }
|
||||
.settings-group h3 { margin: 0 0 4px; font-size: 16px; color: var(--text-primary); }
|
||||
.settings-desc { margin: 0 0 10px; font-size: 13px; color: var(--text-secondary); }
|
||||
.settings-row { display: flex; gap: 8px; align-items: center; }
|
||||
.settings-input { flex: 1; padding: 8px 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 14px; background: var(--card-background); color: var(--text-primary); }
|
||||
.settings-save-btn { padding: 8px 16px; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.settings-save-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.settings-msg { margin-top: 8px; font-size: 13px; }
|
||||
.settings-msg.ok { color: var(--success-color); }
|
||||
.settings-msg.error { color: var(--danger-color); }
|
||||
.settings-error { background: var(--danger-background); color: var(--danger-color); padding: 10px; border-radius: 4px; margin-bottom: 16px; }
|
||||
.service-card { background: var(--hover-background); border-radius: 6px; padding: 12px; margin-top: 12px; }
|
||||
.service-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.service-name { font-weight: 600; font-size: 14px; color: var(--text-primary); }
|
||||
.service-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; text-transform: uppercase; }
|
||||
.service-status.online { background: rgba(var(--success-color), 0.2); color: var(--success-color); }
|
||||
.service-status.offline { background: var(--danger-background); color: var(--danger-color); }
|
||||
.service-status.unknown { background: var(--hover-background); color: var(--text-secondary); }
|
||||
.service-row { display: flex; gap: 8px; align-items: center; }
|
||||
.service-test-btn { padding: 6px 12px; background: var(--text-secondary); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||
.service-test-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.service-info { margin-top: 8px; font-size: 12px; color: var(--text-secondary); display: flex; gap: 16px; }
|
||||
.service-actions { display: flex; gap: 6px; align-items: center; margin-top: 8px; }
|
||||
.service-stop-btn { padding: 4px 12px; background: var(--danger-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.service-stop-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.service-start-btn { padding: 4px 12px; background: var(--success-color); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||
.service-start-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.action-loading { font-size: 12px; color: var(--text-secondary); }
|
||||
.action-result { font-size: 12px; }
|
||||
.action-result.ok { color: var(--success-color); }
|
||||
.action-result.error { color: var(--danger-color); }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,6 @@
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="success-message">Login successful!</div>
|
||||
</form>
|
||||
<div class="login-footer">
|
||||
<p>Default credentials: momentry / demo123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,6 +27,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isTauri } from '@/api/config'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
@@ -44,33 +42,57 @@ async function handleLogin() {
|
||||
success.value = false
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:11438/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
if (isTauri) {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const result: any = await invoke('login_user', {
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.token) {
|
||||
success.value = true
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('username', username.value)
|
||||
localStorage.setItem('expires_at', data.expires_at)
|
||||
password: password.value
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/search')
|
||||
}, 1000)
|
||||
if (result.success && result.token) {
|
||||
success.value = true
|
||||
localStorage.setItem('token', result.token)
|
||||
localStorage.setItem('username', username.value)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ms-login', { detail: { username: username.value } }))
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/search')
|
||||
}, 500)
|
||||
} else {
|
||||
error.value = result.error || 'Login failed'
|
||||
}
|
||||
} else {
|
||||
error.value = data.error || 'Login failed'
|
||||
const apiBase = localStorage.getItem('proxy_url') || ''
|
||||
const response = await fetch(apiBase + '/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.success && data.token) {
|
||||
success.value = true
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('username', username.value)
|
||||
|
||||
window.dispatchEvent(new CustomEvent('ms-login', { detail: { username: username.value } }))
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/search')
|
||||
}, 500)
|
||||
} else {
|
||||
error.value = data.error || 'Login failed'
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = 'Network error: ' + e.message
|
||||
error.value = 'Login error: ' + (e.message || 'Unknown error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -172,11 +194,4 @@ async function handleLogin() {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,10 +5,10 @@
|
||||
<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>
|
||||
返回
|
||||
Back
|
||||
</button>
|
||||
<button v-if="!isEditing" class="ms-ppl-edit-text-btn" @click="startEditing">
|
||||
編輯
|
||||
Edit
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style="margin-left:4px;">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
@@ -24,7 +24,7 @@
|
||||
<div class="ms-ppl-detail-header">
|
||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;">
|
||||
<div class="ms-ppl-detail-avatar" :class="{ 'ms-ppl-avatar-editable': isEditing }" @click="isEditing && triggerAvatarUpload()">
|
||||
<img v-if="profile" :src="profile" alt="" style="width:100%;height:100%;object-fit:cover;">
|
||||
<img v-if="profile" :src="profile" alt="" style="width:100%;height:100%;object-fit:contain;">
|
||||
<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"/>
|
||||
@@ -45,25 +45,39 @@
|
||||
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">{{ person.starred ? '★' : '☆' }}</button>
|
||||
<div class="ms-ppl-view-box ms-ppl-view-name-box">{{ person.name || '—' }}</div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:2px;">
|
||||
<span class="ms-ppl-source-badge" :class="detailSourceClass">{{ detailSourceLabel }}</span>
|
||||
</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" style="margin-top:4px;">
|
||||
<div class="ms-ppl-edit-field-row">
|
||||
<span class="ms-ppl-edit-label">角色</span>
|
||||
<span class="ms-ppl-edit-label">Role</span>
|
||||
<div class="ms-ppl-view-box ms-ppl-view-field-box">{{ person.metadata?.role || '—' }}</div>
|
||||
</div>
|
||||
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
|
||||
<span class="ms-ppl-edit-label">描述</span>
|
||||
<span class="ms-ppl-edit-label">Description</span>
|
||||
<div class="ms-ppl-view-box ms-ppl-view-notes-box">{{ person.metadata?.notes || '—' }}</div>
|
||||
</div>
|
||||
<div class="ms-ppl-edit-field-row" v-if="person.file_uuids?.length">
|
||||
<span class="ms-ppl-edit-label">Appears in</span>
|
||||
<div class="ms-ppl-files-list">
|
||||
<div v-for="fu in person.file_uuids" :key="fu" class="ms-ppl-file-chip" :title="fu">
|
||||
<template v-if="isTmdb">
|
||||
<span class="ms-keyword-label">Keyword</span>
|
||||
</template>
|
||||
{{ getFileName(fu) }}<span class="ms-ppl-file-chip-uuid">{{ fu }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Edit mode -->
|
||||
<div v-else id="msPplInfoEdit">
|
||||
<div class="ms-ppl-edit-row ms-ppl-edit-name-row">
|
||||
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">{{ person.starred ? '★' : '☆' }}</button>
|
||||
<input v-model="editName" class="ms-ppl-edit-input ms-ppl-edit-name-input" placeholder="加入人名">
|
||||
<input v-model="editName" class="ms-ppl-edit-input ms-ppl-edit-name-input" placeholder="Enter name">
|
||||
</div>
|
||||
<div class="ms-ppl-edit-row ms-ppl-edit-alias-row">
|
||||
<div class="ms-ppl-alias-wrap-inner">
|
||||
@@ -80,24 +94,24 @@
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
</select>
|
||||
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="輸入別名後按 Enter" @keyup.enter="addAlias">
|
||||
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="Enter alias and press Enter" @keyup.enter="addAlias">
|
||||
</template>
|
||||
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">+ 別名</button>
|
||||
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">收起</button>
|
||||
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">+ Alias</button>
|
||||
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">Collapse</button>
|
||||
</div>
|
||||
<div class="ms-ppl-edit-fields">
|
||||
<div class="ms-ppl-edit-field-row">
|
||||
<label class="ms-ppl-edit-label">角色</label>
|
||||
<input v-model="editRole" class="ms-ppl-edit-input ms-ppl-edit-field-input" placeholder="角色名稱">
|
||||
<label class="ms-ppl-edit-label">Role</label>
|
||||
<input v-model="editRole" class="ms-ppl-edit-input ms-ppl-edit-field-input" placeholder="Role name">
|
||||
</div>
|
||||
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
|
||||
<label class="ms-ppl-edit-label">描述</label>
|
||||
<textarea v-model="editNotes" class="ms-ppl-edit-textarea ms-ppl-edit-field-input" placeholder="自訂描述"></textarea>
|
||||
<label class="ms-ppl-edit-label">Description</label>
|
||||
<textarea v-model="editNotes" class="ms-ppl-edit-textarea ms-ppl-edit-field-input" placeholder="Custom description"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-ppl-edit-actions">
|
||||
<button class="ms-fm-btn ms-fm-btn-primary" @click="saveEdit" :disabled="saving">{{ saving ? '儲存中...' : '✓ 儲存更改' }}</button>
|
||||
<button class="ms-fm-btn" @click="cancelEditing">取消</button>
|
||||
<button class="ms-fm-btn ms-fm-btn-primary" @click="saveEdit" :disabled="saving">{{ saving ? 'Saving...' : '✓ Save Changes' }}</button>
|
||||
<button class="ms-fm-btn" @click="cancelEditing">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,14 +119,14 @@
|
||||
|
||||
<!-- Trace strip (face_traces from get_traces) -->
|
||||
<div class="ms-ppl-strip-wrap" :class="{ 'ms-ppl-edit-mode': isEditing }">
|
||||
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="加入相同人物">+</button>
|
||||
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="Add same person">+</button>
|
||||
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage === 1" @click="prevFacePage">‹</button>
|
||||
<div class="ms-ppl-face-strip">
|
||||
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading traces...</div>
|
||||
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No traces found</div>
|
||||
<div v-for="(t, i) in paginatedTraces" :key="t.trace_id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: selectedTrace?.trace_id === t.trace_id }" @click="selectTrace(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)">
|
||||
<div class="ms-ppl-strip-face-img">
|
||||
<img v-if="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" :src="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;border-radius:8px;">
|
||||
<img v-if="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" :src="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:contain;border-radius:8px;">
|
||||
<div v-else style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#eef2ff;color:#6366f1;font-size:10px;font-weight:600;border-radius:8px;">T{{ t.trace_id }}</div>
|
||||
</div>
|
||||
<div class="ms-ppl-strip-trace-label">T{{ t.trace_id }} {{ t.face_id ? 'F' + t.face_id.slice(-4) : '' }}</div>
|
||||
@@ -126,8 +140,8 @@
|
||||
<!-- Trace detail card -->
|
||||
<div v-if="selectedTrace" class="ms-ppl-face-card-detail">
|
||||
<button class="ms-ppl-face-card-close" @click="selectedTrace = null">×</button>
|
||||
<div class="ms-ppl-face-card-img-wrap">
|
||||
<img v-if="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" :src="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:12px;">
|
||||
<div class="ms-ppl-face-card-img-wrap" @click="showEnlargedFace = true" style="cursor:pointer;">
|
||||
<img v-if="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" :src="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" alt="" style="width:100%;height:100%;object-fit:contain;border-radius:12px;">
|
||||
<div v-else class="face-placeholder">?</div>
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-info">
|
||||
@@ -137,23 +151,33 @@
|
||||
{{ (selectedTrace.first_sec || 0).toFixed(1) }}s ~ {{ (selectedTrace.last_sec || 0).toFixed(1) }}s
|
||||
<span class="ms-ppl-face-card-dur">({{ traceDuration(selectedTrace).toFixed(1) }}s)</span>
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-frame-range">
|
||||
Frame: #{{ selectedTrace.first_frame }} ~ #{{ selectedTrace.last_frame }}
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-fps" v-if="traceFps(selectedTrace)">
|
||||
{{ traceFps(selectedTrace) }} fps · {{ selectedTrace.last_frame - selectedTrace.first_frame }} frames
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-conf">#{{ selectedTrace.first_frame }}–#{{ selectedTrace.last_frame }} · {{ selectedTrace.file_uuid?.slice(0, 8) }}...</div>
|
||||
<div class="ms-ppl-face-card-actions">
|
||||
<button class="ms-fm-btn" @click="playTrace(selectedTrace)">▶ 播放</button>
|
||||
<button v-if="isEditing && selectedTrace.face_id" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedTrace); selectedTrace = null">✕ 解綁</button>
|
||||
<button class="ms-fm-btn" @click="playTrace(selectedTrace)">▶ Play</button>
|
||||
<button v-if="isEditing && selectedTrace.face_id" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedTrace); selectedTrace = null">✕ Unbind</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Enlarged face image modal -->
|
||||
<div v-if="showEnlargedFace && selectedTrace" class="ms-modal-overlay show" @click.self="showEnlargedFace = false" style="z-index:10000;">
|
||||
<div class="ms-enlarged-face-modal">
|
||||
<button class="ms-modal-video-close" @click="showEnlargedFace = false">×</button>
|
||||
<img v-if="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" :src="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" alt="" style="max-width:90vw;max-height:90vh;object-fit:contain;border-radius:12px;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single segment card (entry point to player with all traces) -->
|
||||
<div v-if="loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">Loading segments...</div>
|
||||
<div v-else-if="loadingMoreTraces" class="ms-ppl-media-label" style="margin-bottom:20px;color:#9aa0a6;">Loading more segments...</div>
|
||||
<div v-if="allSegCard" class="ms-ppl-media-item segment-card" @click="playAllSegments">
|
||||
<div class="ms-ppl-media-thumb">
|
||||
<img v-if="thumbs[allSegCard.thumbKey]" :src="thumbs[allSegCard.thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;">
|
||||
<img v-if="thumbs[allSegCard.thumbKey]" :src="thumbs[allSegCard.thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:contain;">
|
||||
<div class="ms-thumb-play-circle">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20" fill="white"/></svg>
|
||||
</div>
|
||||
@@ -161,8 +185,8 @@
|
||||
<span v-if="allSegCard.avg_confidence" class="ms-ppl-media-badge-conf">{{ (allSegCard.avg_confidence * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="ms-ppl-media-info">
|
||||
<div class="ms-ppl-media-title">{{ allSegCard.count }} 個片段 · {{ allTraces.length }} 個追蹤</div>
|
||||
<div class="ms-ppl-media-sub">所有片段 · {{ formatTime(allSegCard.start) }}</div>
|
||||
<div class="ms-ppl-media-title">{{ allSegCard.count }} segments · {{ allTraces.length }} traces</div>
|
||||
<div class="ms-ppl-media-sub">All segments · {{ formatTime(allSegCard.start) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No trace segments available for this person</div>
|
||||
@@ -171,16 +195,16 @@
|
||||
<div v-show="activeTab === 'actions'">
|
||||
<div class="ms-ppl-actions-section">
|
||||
<div class="ms-ppl-actions-status">
|
||||
<span class="ms-ppl-actions-label">狀態:</span>
|
||||
<span class="ms-ppl-actions-label">Status:</span>
|
||||
<span class="ms-ppl-actions-value">{{ person.status || 'confirmed' }}</span>
|
||||
<button v-if="person.status !== 'confirmed'" class="ms-fm-btn ms-fm-btn-primary" @click="updateStatus('confirmed')">✓ 確認</button>
|
||||
<button v-if="person.status !== 'pending'" class="ms-fm-btn" @click="updateStatus('pending')">待定</button>
|
||||
<button v-if="person.status !== 'skipped'" class="ms-fm-btn" @click="updateStatus('skipped')">略過</button>
|
||||
<button v-if="person.status !== 'confirmed'" class="ms-fm-btn ms-fm-btn-primary" @click="updateStatus('confirmed')">✓ Confirm</button>
|
||||
<button v-if="person.status !== 'pending'" class="ms-fm-btn" @click="updateStatus('pending')">Pending</button>
|
||||
<button v-if="person.status !== 'skipped'" class="ms-fm-btn" @click="updateStatus('skipped')">Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-ppl-actions-section" style="margin-top:16px;">
|
||||
<button class="ms-fm-btn ms-fm-btn-primary" @click="showCandidates = true" style="margin-bottom:8px;">+ 綁定人臉</button>
|
||||
<button class="ms-fm-btn ms-fm-btn-blue" @click="showMerge = true" style="margin-bottom:8px;">⇄ 合併到其他人物</button>
|
||||
<button class="ms-fm-btn ms-fm-btn-primary" @click="showCandidates = true" style="margin-bottom:8px;">+ Bind Face</button>
|
||||
<button class="ms-fm-btn ms-fm-btn-blue" @click="showMerge = true" style="margin-bottom:8px;">⇄ Merge to Other Person</button>
|
||||
</div>
|
||||
|
||||
<div class="ms-ppl-delete-zone">
|
||||
@@ -191,7 +215,7 @@
|
||||
<path d="M19 6l-1 14H6L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
<path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
刪除此人物
|
||||
Delete This Person
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,10 +245,10 @@
|
||||
<div v-if="showMerge" class="ms-modal-overlay show" @click.self="showMerge = false">
|
||||
<div class="ms-modal ms-modal-merge">
|
||||
<button class="ms-fm-icon-btn close-btn" @click="showMerge = false">×</button>
|
||||
<h2 class="ms-ppl-section-title">⇄ 合併到其他人物</h2>
|
||||
<h2 class="ms-ppl-section-title">⇄ Merge to Other Person</h2>
|
||||
<div class="ms-merge-search-wrap">
|
||||
<span class="ms-merge-search-icon">🔍</span>
|
||||
<input v-model="mergeSearchQuery" class="ms-merge-search-input" placeholder="搜尋人物名稱..." @input="onMergeSearch" />
|
||||
<input v-model="mergeSearchQuery" class="ms-merge-search-input" placeholder="Search person name..." @input="onMergeSearch" />
|
||||
</div>
|
||||
<div class="ms-merge-grid">
|
||||
<div v-for="r in mergeSearchResults" :key="r.identity_id" class="ms-merge-face-card" @click="confirmMergeTarget(r)">
|
||||
@@ -241,7 +265,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-merge-footer">
|
||||
<button class="ms-fm-btn" @click="showMerge = false">取消</button>
|
||||
<button class="ms-fm-btn" @click="showMerge = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,7 +274,7 @@
|
||||
|
||||
<!-- Trace strip context menu -->
|
||||
<div v-if="traceCtxMenu.show" class="ms-ctx-menu" :style="{ left: traceCtxMenu.x + 'px', top: traceCtxMenu.y + 'px', display: 'block' }" @click.stop @mousedown.stop @pointerdown.stop>
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="traceCtxAction('unbind')">不是此人物</button>
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="traceCtxAction('unbind')">Not This Person</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -260,7 +284,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiCall } from '@/api'
|
||||
import { isTauri } from '@/api/config'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, profilesCache, loadProfile as storeLoadProfile, faceThumbsCache, loadFaceThumb as storeLoadFaceThumb, thumbnailsCache, loadThumbnail, invalidatePeople, invalidateProfile } from '@/store'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, profilesCache, loadProfile as storeLoadProfile, faceThumbsCache, loadFaceThumb as storeLoadFaceThumb, thumbnailsCache, loadThumbnail, invalidatePeople, invalidateProfile, filesCache, ensureFiles } from '@/store'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -286,6 +310,26 @@ const thumbs = thumbnailsCache
|
||||
const traceThumbs = thumbnailsCache
|
||||
const faceThumbs = faceThumbsCache
|
||||
|
||||
const isTmdb = computed(() => {
|
||||
const p = person.value
|
||||
return !!(p && (p.source === 'tmdb' || p.tmdb_id || p.metadata?.role))
|
||||
})
|
||||
const detailSourceLabel = computed(() => {
|
||||
const p = person.value
|
||||
if (!p) return ''
|
||||
if (p.source === 'tmdb' || p.tmdb_id) return 'TMDB'
|
||||
if (p.source === 'user') return 'User'
|
||||
if (p.metadata?.role) return 'TMDB'
|
||||
return 'Auto'
|
||||
})
|
||||
const detailSourceClass = computed(() => {
|
||||
const p = person.value
|
||||
if (!p) return ''
|
||||
if (p.source === 'tmdb' || p.tmdb_id || p.metadata?.role) return 'ms-source-tmdb'
|
||||
if (p.source === 'user') return 'ms-source-user'
|
||||
return 'ms-source-auto'
|
||||
})
|
||||
|
||||
const allSegCard = computed(() => {
|
||||
const m = mergedSegments.value
|
||||
if (!m.length) return null
|
||||
@@ -317,6 +361,7 @@ const avatarUploading = ref(false)
|
||||
const activeTab = ref<'faces' | 'actions'>('faces')
|
||||
const traceCtxMenu = ref({ show: false, x: 0, y: 0, trace: null as any })
|
||||
const selectedTrace = ref<any>(null)
|
||||
const showEnlargedFace = ref(false)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const loadingMoreTraces = ref(false)
|
||||
const faceStripPage = ref(1)
|
||||
@@ -329,15 +374,27 @@ const faceStripTotalPages = computed(() => Math.ceil(allTraces.value.length / fa
|
||||
function prevFacePage() { if (faceStripPage.value > 1) faceStripPage.value-- }
|
||||
function nextFacePage() { if (faceStripPage.value < faceStripTotalPages.value) faceStripPage.value++ }
|
||||
|
||||
function getFileName(fileUuid: string): string {
|
||||
const f = filesCache.value.find((f: any) => f.file_uuid?.replace(/-/g, '') === fileUuid)
|
||||
return f?.file_name || fileUuid
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const uuid = route.params.uuid as string
|
||||
try {
|
||||
await ensurePeople()
|
||||
await Promise.all([ensurePeople(), ensureFiles()])
|
||||
peopleCount.value = peopleCache.value.length
|
||||
allPeople.value = peopleCache.value
|
||||
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
|
||||
if (found) {
|
||||
person.value = { ...found, status: found.status || 'confirmed' }
|
||||
if (!person.value.file_uuids?.length) {
|
||||
try {
|
||||
const result: any = await apiCall('get_identity_files', { uuid, pageSize: 20 })
|
||||
const files = result?.data || []
|
||||
person.value.file_uuids = files.map((f: any) => f.file_uuid)
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
loadProfile(uuid)
|
||||
loadMedia(uuid)
|
||||
}
|
||||
@@ -381,10 +438,13 @@ function playTrace(t: any) {
|
||||
if (!t) return
|
||||
const idx = allTraces.value.indexOf(t)
|
||||
if (idx < 0) return
|
||||
const fps = traceFps(t) || 24
|
||||
const startFrame = Math.max(0, (t.first_frame || 0) - 10)
|
||||
const endFrame = (t.last_frame || 0) + 10
|
||||
currentVideo.value = {
|
||||
fileUuid: t.file_uuid || '',
|
||||
startTime: t.first_sec || t.start_time || 0,
|
||||
endTime: t.last_sec || t.end_time || 0,
|
||||
startTime: startFrame / fps,
|
||||
endTime: endFrame / fps,
|
||||
traceIdx: idx,
|
||||
title: `${person.value?.name} · T${t.trace_id}`,
|
||||
}
|
||||
@@ -498,8 +558,10 @@ function loadThumb(uuid: string, frame: number) {
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
const fileUuid = person.value?.file_uuids?.[0]
|
||||
if (!fileUuid) { candidates.value = []; return }
|
||||
try {
|
||||
const result: any = await apiCall('get_face_candidates', { page: 1, perPage: 20 })
|
||||
const result: any = await apiCall('get_face_candidates', { page: 1, perPage: 20, fileUuid })
|
||||
candidates.value = (Array.isArray(result) ? result : []).map((c: any) => ({
|
||||
...c,
|
||||
thumbKey: `cand_${c.id}`
|
||||
@@ -632,8 +694,8 @@ async function unbindFace(t: any) {
|
||||
if (!person.value) return
|
||||
const uuid = person.value.identity_uuid
|
||||
const fid = t.face_id || t.faceId || null
|
||||
if (!fid) { alert('此 trace 沒有 face_id,無法解綁'); return }
|
||||
if (!confirm(`解綁 trace T${t.trace_id}(face_id: ${fid})?`)) return
|
||||
if (!fid) { alert('This trace has no face_id, cannot unbind'); return }
|
||||
if (!confirm(`Unbind trace T${t.trace_id} (face_id: ${fid})?`)) return
|
||||
try {
|
||||
await apiCall('unbind_face', {
|
||||
uuid,
|
||||
@@ -653,7 +715,7 @@ async function unbindFace(t: any) {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Unbind failed:', e)
|
||||
alert('解綁失敗:' + (e instanceof Error ? e.message : String(e)))
|
||||
alert('Unbind failed: ' + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -675,7 +737,7 @@ function playAllSegments() {
|
||||
startTime: t.first_sec || t.start_time || 0,
|
||||
endTime: t.last_sec || t.end_time || 0,
|
||||
traceIdx: 0,
|
||||
title: `${person.value?.name} · ${card.count} 個片段`,
|
||||
title: `${person.value?.name} · ${card.count} segments`,
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
@@ -735,7 +797,7 @@ async function onMergeSearch() {
|
||||
|
||||
async function confirmMergeTarget(target: any) {
|
||||
if (!person.value) return
|
||||
if (!confirm(`合併「${person.value.name}」到「${target.name}」?`)) return
|
||||
if (!confirm(`Merge "${person.value.name}" into "${target.name}"?`)) return
|
||||
try {
|
||||
await apiCall('merge_identities', { uuid: person.value.identity_uuid, intoUuid: target.identity_uuid })
|
||||
showMerge.value = false
|
||||
@@ -749,96 +811,106 @@ let mergeSearchTimer: any
|
||||
<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; }
|
||||
.ms-ppl-edit-text-btn { border: none; background: transparent; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: #5f6368; cursor: pointer; outline: none; padding: 4px 10px; display: flex; align-items: center; gap: 4px; transition: color .15s; }
|
||||
.ms-ppl-edit-text-btn:hover { color: #202124; }
|
||||
.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; }
|
||||
.ms-ppl-edit-text-btn { border: none; background: transparent; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: var(--text-secondary); cursor: pointer; outline: none; padding: 4px 10px; display: flex; align-items: center; gap: 4px; transition: color .15s; }
|
||||
.ms-ppl-edit-text-btn:hover { color: var(--text-primary); }
|
||||
.loading-state, .empty { text-align: center; padding: 60px 0; color: var(--text-secondary); }
|
||||
.spinner-lg { width: 24px; height: 24px; border: 3px solid var(--border-light); border-top-color: var(--text-primary); 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; position: relative; }
|
||||
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 20px; }
|
||||
.ms-ppl-detail-avatar { width: 120px; height: 120px; border-radius: 20px; background: var(--border-light); flex-shrink: 0; overflow: hidden; position: relative; }
|
||||
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: contain; border-radius: 20px; }
|
||||
.ms-ppl-avatar-editable { cursor: pointer; }
|
||||
.ms-ppl-avatar-upload-hint { position: absolute; inset: 0; border-radius: 20px; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .15s; pointer-events: none; }
|
||||
.ms-ppl-avatar-editable:hover .ms-ppl-avatar-upload-hint { opacity: 1; }
|
||||
.ms-ppl-avatar-upload-hint svg { color: #fff; }
|
||||
.ms-silhouette { width: 100%; height: 100%; }
|
||||
.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-star-btn { font-size: 20px; background: transparent; border: none; cursor: pointer; outline: none; line-height: 1; color: var(--text-secondary); transition: color .15s; padding: 0; flex-shrink: 0; }
|
||||
.ms-ppl-star-btn.starred { color: var(--warning-color); }
|
||||
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: var(--hover-background); border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: var(--text-secondary); }
|
||||
.ms-keyword-label { display: inline-flex; align-items: center; background: rgba(var(--primary-color-rgb), 0.1); border-radius: 4px; padding: 1px 6px; margin-right: 4px; font-size: 10px; color: var(--primary-color); font-weight: 500; }
|
||||
.ms-ppl-source-badge { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; }
|
||||
.ms-source-tmdb { color: #1a73e8; }
|
||||
.ms-source-user { color: #188038; }
|
||||
.ms-source-auto { color: #9aa0a6; }
|
||||
.ms-ppl-files-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.ms-ppl-file-chip { background: rgba(var(--primary-color-rgb), 0.1); border-radius: 4px; padding: 2px 8px; font-size: 11px; color: var(--primary-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; cursor: default; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.ms-ppl-file-chip-uuid { font-size: 9px; color: var(--text-secondary); font-family: monospace; }
|
||||
.ms-ppl-edit-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.ms-ppl-edit-name-row { margin-bottom: 8px; }
|
||||
.ms-ppl-edit-alias-row { padding-left: 32px; margin-bottom: 16px; gap: 6px; align-items: center; }
|
||||
.ms-ppl-alias-wrap-inner { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||
.ms-ppl-alias-tag { display: inline-flex; align-items: center; gap: 5px; background: #e8eaed; border-radius: 999px; padding: 4px 10px; font-size: 12.5px; color: #202124; }
|
||||
.ms-ppl-alias-tag button { border: none; background: transparent; cursor: pointer; color: #5f6368; font-size: 14px; line-height: 1; padding: 0; display: flex; align-items: center; }
|
||||
.ms-ppl-alias-add-btn { border: 1.5px dashed #bbb; background: transparent; border-radius: 999px; padding: 4px 10px; font-size: 12px; color: #5f6368; cursor: pointer; outline: none; }
|
||||
.ms-ppl-alias-add-btn:hover { border-color: #202124; color: #202124; }
|
||||
.ms-ppl-alias-tag { display: inline-flex; align-items: center; gap: 5px; background: var(--hover-background); border-radius: 999px; padding: 4px 10px; font-size: 12.5px; color: var(--text-primary); }
|
||||
.ms-ppl-alias-tag button { border: none; background: transparent; cursor: pointer; color: var(--text-secondary); font-size: 14px; line-height: 1; padding: 0; display: flex; align-items: center; }
|
||||
.ms-ppl-alias-add-btn { border: 1.5px dashed var(--border-color); background: transparent; border-radius: 999px; padding: 4px 10px; font-size: 12px; color: var(--text-secondary); cursor: pointer; outline: none; }
|
||||
.ms-ppl-alias-add-btn:hover { border-color: var(--text-primary); color: var(--text-primary); }
|
||||
.ms-ppl-alias-inline-wrap { display: flex; align-items: center; }
|
||||
.ms-ppl-alias-inline-input { border: 1.5px solid #1a56db; border-radius: 999px; padding: 4px 12px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12.5px; color: #202124; outline: none; background: #fff; min-width: 140px; transition: border-color .15s; }
|
||||
.ms-ppl-alias-inline-input:focus { border-color: #1a56db; }
|
||||
.ms-ppl-alias-locale-select { border: 1.5px solid #1a56db; border-radius: 999px; padding: 4px 10px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; color: #202124; background: #fff; outline: none; cursor: pointer; height: 30px; }
|
||||
.ms-ppl-alias-inline-input { border: 1.5px solid var(--primary-color); border-radius: 999px; padding: 4px 12px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12.5px; color: var(--text-primary); outline: none; background: var(--card-background); min-width: 140px; transition: border-color .15s; }
|
||||
.ms-ppl-alias-inline-input:focus { border-color: var(--primary-color); }
|
||||
.ms-ppl-alias-locale-select { border: 1.5px solid var(--primary-color); border-radius: 999px; padding: 4px 10px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; color: var(--text-primary); background: var(--card-background); outline: none; cursor: pointer; height: 30px; }
|
||||
.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-field-row--top { align-items: flex-start; }
|
||||
.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-edit-input { border: 1.5px solid #e8eaed; border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; outline: none; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: border-color .15s, box-shadow .15s; min-width: 0; box-sizing: border-box; width: 100%; }
|
||||
.ms-ppl-edit-input:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
|
||||
.ms-ppl-edit-label { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; font-weight: 700; color: var(--text-primary); letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
|
||||
.ms-ppl-edit-input { border: 1.5px solid var(--border-color); border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: var(--text-primary); outline: none; background: var(--card-background); box-shadow: var(--shadow); transition: border-color .15s, box-shadow .15s; min-width: 0; box-sizing: border-box; width: 100%; }
|
||||
.ms-ppl-edit-input:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.1); }
|
||||
.ms-ppl-edit-name-input { font-size: 16px !important; font-weight: 700 !important; flex: 1; min-width: 160px; }
|
||||
.ms-ppl-edit-textarea { width: 100%; border: 1.5px solid #e8eaed; border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: #202124; outline: none; resize: vertical; min-height: 72px; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
|
||||
.ms-ppl-edit-textarea:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
|
||||
.ms-ppl-edit-textarea { width: 100%; border: 1.5px solid var(--border-color); border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: var(--text-primary); outline: none; resize: vertical; min-height: 72px; background: var(--card-background); box-shadow: var(--shadow); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
|
||||
.ms-ppl-edit-textarea:focus { border-color: var(--primary-color); box-shadow: 0 0 0 3px rgba(var(--primary-color-rgb), 0.1); }
|
||||
.ms-ppl-edit-actions { display: flex; gap: 8px; padding-left: 42px; }
|
||||
.ms-ppl-edit-field-input { flex: 1; min-width: 0; }
|
||||
.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-box { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: var(--text-primary); 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-view-notes-box { flex: 1; min-width: 0; min-height: 24px; align-items: flex-start; color: #9aa0a6; font-size: 13px; white-space: pre-wrap; }
|
||||
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: var(--text-primary); }
|
||||
.ms-ppl-view-notes-box { flex: 1; min-width: 0; min-height: 24px; align-items: flex-start; color: var(--text-secondary); font-size: 13px; white-space: pre-wrap; }
|
||||
.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-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed var(--border-color); background: var(--card-background); font-size: 20px; color: var(--text-secondary); 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: var(--text-primary); color: var(--text-primary); }
|
||||
.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-clickable.selected { outline: 2px solid #1a56db; outline-offset: 2px; border-radius: 14px; }
|
||||
.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-face-card-detail { display: flex; gap: 16px; align-items: flex-start; background: #fff; border: 1px solid #e0e0e0; border-radius: 16px; padding: 16px; margin: 12px 0; position: relative; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
|
||||
.ms-ppl-face-card-close { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 20px; cursor: pointer; color: #9aa0a6; line-height: 1; padding: 2px 6px; border-radius: 6px; }
|
||||
.ms-ppl-face-card-close:hover { background: #f3f4f6; color: #202124; }
|
||||
.ms-ppl-face-card-img-wrap { width: 120px; height: 120px; border-radius: 12px; overflow: hidden; flex-shrink: 0; background: #e8eaed; }
|
||||
.ms-ppl-face-card-info { flex: 1; min-width: 0; }
|
||||
.ms-ppl-face-card-file { font-size: 13px; color: #5f6368; font-family: monospace; }
|
||||
.ms-ppl-face-card-frame { font-size: 13px; color: #3c4043; margin-top: 2px; }
|
||||
.ms-ppl-face-card-time { font-size: 13px; color: #3c4043; margin-top: 4px; }
|
||||
.ms-ppl-face-card-dur { color: #9aa0a6; margin-left: 4px; }
|
||||
.ms-ppl-face-card-fps { font-size: 12px; color: #5f6368; margin-top: 2px; }
|
||||
.ms-ppl-face-card-conf { font-size: 12px; color: #9aa0a6; margin-top: 2px; }
|
||||
.ms-ppl-strip-face-clickable.selected { outline: 2px solid var(--primary-color); outline-offset: 2px; border-radius: 14px; }
|
||||
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: var(--border-light); overflow: hidden; transition: border-color .15s; }
|
||||
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: var(--text-primary); }
|
||||
.ms-ppl-face-card-detail { display: flex; gap: 16px; align-items: flex-start; background: var(--card-background); border: 1px solid var(--border-color); border-radius: 16px; padding: 16px; margin: 12px 0; position: relative; box-shadow: var(--shadow); }
|
||||
.ms-ppl-face-card-close { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 20px; cursor: pointer; color: var(--text-secondary); line-height: 1; padding: 2px 6px; border-radius: 6px; z-index: 10; }
|
||||
.ms-ppl-face-card-close:hover { background: var(--hover-background); color: var(--text-primary); }
|
||||
.ms-ppl-face-card-img-wrap { width: 120px; height: 120px; border-radius: 12px; overflow: hidden; flex-shrink: 0; background: var(--border-light); }
|
||||
.ms-ppl-face-card-info { flex: 1; min-width: 0; padding-top: 4px; }
|
||||
.ms-ppl-face-card-file { font-size: 13px; color: var(--text-secondary); font-family: monospace; }
|
||||
.ms-ppl-face-card-frame { font-size: 13px; color: var(--text-primary); margin-top: 2px; }
|
||||
.ms-ppl-face-card-frame-range { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.ms-ppl-face-card-time { font-size: 13px; color: var(--text-primary); margin-top: 4px; }
|
||||
.ms-ppl-face-card-dur { color: var(--text-secondary); margin-left: 4px; }
|
||||
.ms-ppl-face-card-fps { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.ms-ppl-face-card-conf { font-size: 12px; color: var(--text-secondary); margin-top: 2px; }
|
||||
.ms-ppl-face-card-actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.ms-ppl-strip-arrow { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid #d1d5db; background: #fff; font-size: 18px; display: grid; place-items: center; cursor: pointer; color: #5f6368; outline: none; flex-shrink: 0; transition: background .15s; }
|
||||
.ms-ppl-strip-arrow:hover:not(:disabled) { background: #f3f4f6; color: #202124; }
|
||||
.ms-enlarged-face-modal { display: flex; align-items: center; justify-content: center; background: var(--card-background); border-radius: 16px; padding: 20px; position: relative; max-width: 95vw; max-height: 95vh; }
|
||||
.ms-enlarged-face-modal .ms-modal-video-close { position: absolute; top: 8px; right: 12px; background: none; border: none; font-size: 24px; cursor: pointer; color: var(--text-secondary); line-height: 1; }
|
||||
.ms-ppl-strip-arrow { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid var(--border-color); background: var(--card-background); font-size: 18px; display: grid; place-items: center; cursor: pointer; color: var(--text-secondary); outline: none; flex-shrink: 0; transition: background .15s; }
|
||||
.ms-ppl-strip-arrow:hover:not(:disabled) { background: var(--hover-background); color: var(--text-primary); }
|
||||
.ms-ppl-strip-arrow:disabled { opacity: 0.35; cursor: default; }
|
||||
.ms-ppl-strip-page { font-size: 11px; color: #9aa0a6; min-width: 32px; text-align: center; flex-shrink: 0; }
|
||||
.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; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: #e8eaed; border-radius: 12px 12px 0 0; }
|
||||
.ms-ppl-strip-page { font-size: 11px; color: var(--text-secondary); min-width: 32px; text-align: center; flex-shrink: 0; }
|
||||
.ms-ppl-media-label { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; }
|
||||
.ms-ppl-media-item { cursor: pointer; border-radius: 12px; overflow: visible; background: var(--hover-background); transition: transform .15s, box-shadow .15s; border: 1px solid var(--border-color); }
|
||||
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
|
||||
.ms-ppl-media-thumb { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: var(--border-light); border-radius: 12px 12px 0 0; }
|
||||
.ms-ppl-thumb-play-circle { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.18); border-radius: 12px 12px 0 0; }
|
||||
.ms-ppl-thumb-play-circle svg { width: 38px; height: 38px; opacity: .7; transition: opacity .15s, transform .15s; }
|
||||
.ms-ppl-media-item:hover .ms-ppl-thumb-play-circle svg { opacity: 1; transform: scale(1.08); }
|
||||
.ms-ppl-media-dur { position: absolute; bottom: 5px; right: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
|
||||
.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; }
|
||||
.ms-ppl-media-info { padding: 8px 10px 10px; background: var(--card-background); }
|
||||
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
|
||||
.ms-ppl-media-sub { font-size: 10.5px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ms-ppl-delete-zone { margin-top: 48px; padding-bottom: 32px; }
|
||||
.ms-ppl-delete-hr { border: none; border-top: 1.5px solid #f3f4f6; margin: 0 0 20px; }
|
||||
.ms-ppl-delete-zone-btn { color: #d93025; border-color: #fecaca; background: #fff; }
|
||||
.ms-ppl-delete-zone-btn:hover { background: #fef2f2; border-color: #d93025; }
|
||||
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
|
||||
.ms-ppl-delete-hr { border: none; border-top: 1.5px solid var(--border-light); margin: 0 0 20px; }
|
||||
.ms-ppl-delete-zone-btn { color: var(--danger-color); border-color: var(--danger-color); background: var(--card-background); }
|
||||
.ms-ppl-delete-zone-btn:hover { background: var(--danger-background); border-color: var(--danger-color); }
|
||||
.face-placeholder { font-size: 0.6rem; color: var(--text-secondary); }
|
||||
.ms-ppl-actions-section { display: flex; flex-direction: column; gap: 8px; }
|
||||
.ms-ppl-actions-status { display: flex; align-items: center; gap: 10px; padding: 12px; background: #f8f9fa; border-radius: 10px; font-size: 13px; }
|
||||
.ms-ppl-actions-label { color: #5f6368; font-weight: 500; }
|
||||
.ms-ppl-actions-value { color: #202124; font-weight: 600; text-transform: capitalize; }
|
||||
.ms-ppl-actions-status { display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--hover-background); border-radius: 10px; font-size: 13px; }
|
||||
.ms-ppl-actions-label { color: var(--text-secondary); font-weight: 500; }
|
||||
.ms-ppl-actions-value { color: var(--text-primary); font-weight: 600; text-transform: capitalize; }
|
||||
.ms-ppl-media-badge-conf { position: absolute; top: 5px; left: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
|
||||
.segment-card { max-width: 320px; margin-bottom: 20px; }
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user