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:
187
src/App.vue
187
src/App.vue
@@ -1,10 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isTauri } from '@/api/config'
|
||||
import AgentTip from './components/AgentTip.vue'
|
||||
import { loadUserBehavior } from '@/stores/agentStore'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { changeLanguage, getCurrentLanguage } from './locales'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const appZoom = ref(100)
|
||||
const loggedInUser = ref(localStorage.getItem('username') || '')
|
||||
const currentLanguage = ref(getCurrentLanguage())
|
||||
|
||||
const sidebarWidth = ref(240)
|
||||
const isDragging = ref(false)
|
||||
|
||||
function refreshUser() {
|
||||
const user = localStorage.getItem('username') || ''
|
||||
loggedInUser.value = user
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('expires_at')
|
||||
loggedInUser.value = ''
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
function onChangeLanguage() {
|
||||
changeLanguage(currentLanguage.value)
|
||||
}
|
||||
|
||||
let systemMedia: MediaQueryList | null = null
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey)) {
|
||||
@@ -24,77 +52,116 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
function startDrag(e: MouseEvent) {
|
||||
isDragging.value = true
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
}
|
||||
|
||||
function handleDrag(e: MouseEvent) {
|
||||
if (!isDragging.value) return
|
||||
const newWidth = e.clientX - 28
|
||||
sidebarWidth.value = Math.max(180, Math.min(400, newWidth))
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
isDragging.value = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
document.addEventListener('mousemove', handleDrag)
|
||||
document.addEventListener('mouseup', stopDrag)
|
||||
|
||||
refreshUser()
|
||||
loadUserBehavior()
|
||||
|
||||
if (isTauri) return
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const expiresAt = localStorage.getItem('expires_at')
|
||||
const currentPath = router.currentRoute.value.path
|
||||
|
||||
if (currentPath !== '/login') {
|
||||
if (!token || !expiresAt) {
|
||||
router.push('/login')
|
||||
} else {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
if (now >= expires) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('expires_at')
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('ms-login', () => {
|
||||
refreshUser()
|
||||
loadUserBehavior()
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
watch(() => router.currentRoute.value.path, () => {
|
||||
refreshUser()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.removeEventListener('mousemove', handleDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div id="app" class="ms-app" :data-current-login="'guest'">
|
||||
<aside class="ms-side">
|
||||
<aside class="ms-side" :style="{ width: sidebarWidth + 'px', minWidth: sidebarWidth + 'px' }">
|
||||
<div class="gs-logo">Workspace</div>
|
||||
<nav class="gs-nav">
|
||||
<router-link to="/search" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
|
||||
<span>Search</span>
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="M21 21l-4.35-4.35"/>
|
||||
</svg>
|
||||
<span>{{ t('nav.search') }}</span>
|
||||
</router-link>
|
||||
<router-link to="/library" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
|
||||
<span>Library</span>
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
<span>{{ t('nav.library') }}</span>
|
||||
</router-link>
|
||||
<router-link to="/people" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/People.png" alt="">
|
||||
<span>People</span>
|
||||
<router-link to="/face" class="gs-nav-item" active-class="active">
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="12" cy="10" r="4"/>
|
||||
<path d="M6 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/>
|
||||
</svg>
|
||||
<span>Face</span>
|
||||
</router-link>
|
||||
<router-link to="/admin" class="gs-nav-item" active-class="active">
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span>Admin</span>
|
||||
<span>{{ t('nav.admin') }}</span>
|
||||
</router-link>
|
||||
<router-link to="/client" class="gs-nav-item" active-class="active">
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span>Client</span>
|
||||
<span>{{ t('nav.client') }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="gs-divider"></div>
|
||||
<div class="gs-divider"></div>
|
||||
<div class="gs-footer">
|
||||
<div class="gs-theme-switcher">
|
||||
<button class="gs-theme-btn active">☀️</button>
|
||||
<button class="gs-theme-btn">🌙</button>
|
||||
<button class="gs-theme-btn">🌗</button>
|
||||
<div class="gs-lang-switcher">
|
||||
<select v-model="currentLanguage" @change="onChangeLanguage" class="gs-lang-select">
|
||||
<option value="en">English</option>
|
||||
<option value="zh-TW">繁體中文</option>
|
||||
<option value="auto">Auto Detect</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="gs-account-row">
|
||||
<template v-if="loggedInUser">
|
||||
<span class="gs-account-name">{{ loggedInUser }}</span>
|
||||
<button class="gs-logout-btn" @click="handleLogout" title="Logout">⏻ {{ t('common.logout') }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<router-link to="/login" class="gs-login-link">{{ t('common.login') }}</router-link>
|
||||
</template>
|
||||
</div>
|
||||
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="ms-main">
|
||||
<div class="ms-content"><router-view /></div>
|
||||
<div class="ms-divider" @mousedown="startDrag"></div>
|
||||
<main class="ms-main">
|
||||
<router-view />
|
||||
</main>
|
||||
<AgentTip v-if="loggedInUser" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -104,22 +171,28 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
|
||||
<style scoped>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
|
||||
#app { display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 28px; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
|
||||
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: #fff; border-radius: 18px; padding: 18px 14px; box-shadow: 0 16px 38px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
|
||||
.gs-logo { font-size: 15px; font-weight: 600; color: #202124; padding: 8px 12px 16px 12px; flex-shrink: 0; }
|
||||
#app, .ms-app { background: var(--background-color) !important; display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 0; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
|
||||
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: var(--card-background) !important; border-radius: 18px; padding: 18px 14px; box-shadow: var(--shadow); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
|
||||
.ms-divider { width: 28px; flex-shrink: 0; cursor: col-resize; display: flex; align-items: center; justify-content: center; }
|
||||
.ms-divider::after { content: ''; width: 4px; height: 40px; background: var(--border-color); border-radius: 2px; transition: background 0.2s, height 0.2s; }
|
||||
.ms-divider:hover::after { background: var(--primary-color); height: 60px; }
|
||||
.gs-logo { font-size: 15px; font-weight: 600; color: var(--text-primary); padding: 8px 12px 16px 12px; flex-shrink: 0; }
|
||||
.gs-nav { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
|
||||
.gs-nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 12px; border-radius: 10px; text-decoration: none; color: #3c4043; font-weight: 600; font-size: 18px; line-height: 1; background: transparent; box-sizing: border-box; }
|
||||
.gs-nav-item:hover { background: var(--ms-accent-soft); color: var(--ms-accent-text); }
|
||||
.gs-nav-item.active { background: var(--ms-accent-soft) !important; color: var(--ms-accent-text) !important; font-weight: 600; }
|
||||
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
|
||||
.gs-divider { height: 1px; background: #eee; margin: 14px 8px; flex-shrink: 0; }
|
||||
.gs-footer { margin-top: auto; padding: 10px 12px; border-radius: 12px; display: flex; align-items: center; gap: 10px; background: #f8f9fa; flex-shrink: 0; }
|
||||
.gs-theme-switcher { display: flex; gap: 5px; }
|
||||
.gs-theme-btn { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,.08); background: #f3f3f3; cursor: pointer; padding: 0; }
|
||||
.gs-theme-btn:hover { transform: scale(1.08); }
|
||||
.gs-theme-btn.active { box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,.16); }
|
||||
.gs-account-name { font-size: 12px; color: #5f6368; }
|
||||
.ms-main { flex: 1; min-width: 0; background: #fff; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; }
|
||||
.gs-nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 12px; border-radius: 10px; text-decoration: none; color: var(--text-primary) !important; font-weight: 500; font-size: 16px; line-height: 1; background: transparent !important; box-sizing: border-box; transition: all 0.15s; }
|
||||
.gs-nav-item:hover { background: var(--hover-background) !important; color: var(--primary-color) !important; }
|
||||
.gs-nav-item.active { background: var(--hover-background) !important; color: var(--primary-color) !important; font-weight: 600; }
|
||||
.gs-nav-icon { width: 22px; height: 22px; flex-shrink: 0; }
|
||||
.gs-divider { height: 1px; background: var(--border-light); margin: 14px 8px; flex-shrink: 0; }
|
||||
.gs-footer { margin-top: auto; padding: 10px 8px; border-radius: 12px; background: var(--hover-background); flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.gs-lang-switcher { width: 100%; }
|
||||
.gs-lang-select { width: 100%; padding: 6px 8px; border: 1px solid var(--border-color); border-radius: 6px; background: var(--card-background); font-size: 12px; font-family: inherit; color: var(--text-secondary); cursor: pointer; outline: none; }
|
||||
.gs-lang-select:focus { border-color: var(--text-primary); }
|
||||
.gs-account-row { display: flex; align-items: center; justify-content: center; gap: 8px; }
|
||||
.gs-account-name { font-size: 12px; color: var(--text-secondary); }
|
||||
.gs-logout-btn { background: none; border: 1px solid var(--border-color); cursor: pointer; font-size: 13px; color: var(--text-secondary); padding: 3px 8px; border-radius: 6px; transition: all .15s; margin-left: 6px; }
|
||||
.gs-logout-btn:hover { color: var(--danger-color); border-color: var(--danger-color); background: var(--danger-background); }
|
||||
.gs-login-link { font-size: 12px; color: var(--text-secondary); text-decoration: none; }
|
||||
.gs-login-link:hover { color: var(--text-primary); }
|
||||
.ms-main { flex: 1; min-width: 0; background: var(--card-background) !important; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; }
|
||||
.ms-content { max-width: 1200px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user