fix: resolve TypeScript compilation errors

Fixes:
- VideoPlayer.vue: restore allTags, filteredMarks, marksByTag (removed by mistake)
- PeopleView.vue: fix frameTime scope in playFaceDetailVideo catch block
- Both errors caused white screen on build failure

Root cause:
1. visibleTags duplicate declaration (old + new logic)
2. frameTime undefined in catch block (scope issue)

Solution:
1. Keep allTags/filteredMarks/marksByTag (needed in template)
2. Keep visibleTags (new logic for processor toggles)
3. Move frameTime calculation to correct scope in catch block
This commit is contained in:
2026-07-24 23:33:40 +08:00
parent 48975ef247
commit cb7e1ba0ba
37 changed files with 6875 additions and 205 deletions

99
src/App.vue.backup_final Normal file
View File

@@ -0,0 +1,99 @@
<template>
<div id="app" class="ms-app" :data-current-login="'guest'">
<aside class="ms-side">
<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>
</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>
</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>
<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>
</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>
</router-link>
</nav>
<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>
<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>
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
const appZoom = ref(100)
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey)) {
if (e.key === '=' || e.key === '+') {
e.preventDefault()
appZoom.value = Math.min(appZoom.value + 10, 300)
document.documentElement.style.zoom = appZoom.value + '%'
} else if (e.key === '-') {
e.preventDefault()
appZoom.value = Math.max(appZoom.value - 10, 50)
document.documentElement.style.zoom = appZoom.value + '%'
} else if (e.key === '0') {
e.preventDefault()
appZoom.value = 100
document.documentElement.style.zoom = '100%'
}
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<style>
@import './assets/wordpress-exact.css';
</style>
<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; }
.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; }
.ms-content { max-width: 1200px; }
</style>

33
src/api/params.ts Normal file
View File

@@ -0,0 +1,33 @@
export const PARAM_MAP: Record<string, string> = {
pageSize: 'page_size',
perPage: 'per_page',
fileUuid: 'file_uuid',
startTime: 'start_time',
endTime: 'end_time',
startFrame: 'start_frame',
endFrame: 'end_frame',
bboxX: 'bbox_x',
bboxY: 'bbox_y',
bboxW: 'bbox_w',
bboxH: 'bbox_h',
conversationId: 'conversation_id',
historyId: 'history_id',
mergeId: 'merge_id',
traceId: 'trace_id',
faceId: 'face_id',
}
export function toSnakeCase(key: string): string {
if (PARAM_MAP[key]) return PARAM_MAP[key]
return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
}
export function mapParams(params: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
result[toSnakeCase(key)] = value
}
}
return result
}

26
src/api/types.ts Normal file
View File

@@ -0,0 +1,26 @@
export interface PoseKeypoint {
name: string
x: number
y: number
confidence?: number
}
export type PoseClass = 'standing' | 'sitting' | 'kneeling' | 'lying' | 'prone' | 'unknown'
export interface PoseResponse {
frame: number
keypoints: PoseKeypoint[]
pose_class: PoseClass
confidence?: number
}
export interface DominantColor {
rgb: [number, number, number]
percentage?: number
}
export interface AppearanceResponse {
frame: number
dominant_colors: DominantColor[]
hsv_histogram?: number[][][]
}

216
src/components/AgentTip.vue Normal file
View File

@@ -0,0 +1,216 @@
<template>
<transition name="slide-up">
<div class="agent-tip" v-if="showAgentTip">
<div class="agent-avatar">🤖</div>
<div class="agent-message">{{ agentTip }}</div>
<button class="agent-close" @click="showAgentTip = false">×</button>
</div>
</transition>
<transition name="fade">
<div class="discovery-tip" v-if="discoveryTip">
<span class="discovery-icon">✨</span>
<span class="discovery-message">{{ discoveryTip }}</span>
</div>
</transition>
</template>
<script setup lang="ts">
import { showAgentTip, agentTip, discoveryTip } from '@/stores/agentStore'
</script>
<style scoped>
.agent-tip {
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 20px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 12px;
max-width: 400px;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.3);
z-index: 9999;
animation: slideInUp 0.3s ease-out;
}
.agent-avatar {
font-size: 24px;
}
.agent-message {
flex: 1;
font-size: 14px;
line-height: 1.4;
}
.agent-close {
background: none;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
}
.agent-close:hover {
opacity: 1;
}
.discovery-tip {
position: fixed;
top: 80px;
right: 20px;
background: #f0f9ff;
border: 1px solid #0ea5e9;
color: #0c4a6e;
padding: 10px 16px;
border-radius: 8px;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
z-index: 9998;
animation: fadeIn 0.3s ease-out;
}
.discovery-icon {
font-size: 16px;
}
.welcome-guide {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.3s ease-out;
}
.guide-content {
background: white;
padding: 32px 40px;
border-radius: 16px;
max-width: 500px;
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.2);
}
.guide-content h2 {
margin: 0 0 12px 0;
font-size: 24px;
color: #1f2937;
}
.guide-content > p {
color: #6b7280;
margin-bottom: 24px;
}
.guide-items {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.guide-item {
display: flex;
align-items: flex-start;
gap: 16px;
}
.guide-icon {
font-size: 32px;
flex-shrink: 0;
}
.guide-content-text h4 {
margin: 0 0 4px 0;
font-size: 16px;
color: #1f2937;
}
.guide-content-text p {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.guide-actions {
display: flex;
justify-content: center;
}
.guide-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 32px;
border-radius: 8px;
font-size: 16px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.guide-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.slide-up-enter-active {
animation: slideInUp 0.3s ease-out;
}
.slide-up-leave-active {
animation: slideOutDown 0.3s ease-in;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
@keyframes slideInUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideOutDown {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(20px);
opacity: 0;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
</style>

View File

@@ -419,7 +419,6 @@ function getTagColor(tag: string): string {
}
const allTags = computed(() => [...new Set(marks.value.map(m => m.tag))])
const visibleTags = computed(() => tagFilter.value ? [tagFilter.value] : allTags.value)
const filteredMarks = computed(() => {
if (!tagFilter.value) return marks.value

View File

@@ -0,0 +1,12 @@
import { useDark, useToggle } from '@vueuse/core'
export const isDark = useDark({
selector: 'html',
attribute: 'data-theme',
valueDark: 'dark',
valueLight: 'light',
})
export const toggleDark = () => {
isDark.value = !isDark.value
}

View File

@@ -0,0 +1,35 @@
import { ref, computed } from 'vue'
let baseZIndex = 10000
const stack = ref<string[]>([])
export function useZIndex() {
const push = (id: string): number => {
if (!stack.value.includes(id)) {
stack.value.push(id)
}
return baseZIndex + stack.value.indexOf(id) * 10
}
const pop = (id: string) => {
const idx = stack.value.indexOf(id)
if (idx >= 0) {
stack.value.splice(idx, 1)
}
}
const current = computed(() => baseZIndex + stack.value.length * 10)
return { push, pop, current }
}
let globalZIndex = 10000
export function getNextZIndex(): number {
globalZIndex += 10
return globalZIndex
}
export function resetZIndex() {
globalZIndex = 10000
}

273
src/locales/en.json Normal file
View File

@@ -0,0 +1,273 @@
{
"common": {
"loading": "Loading...",
"error": "Error",
"success": "Success",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"save": "Save",
"edit": "Edit",
"search": "Search",
"back": "Back",
"close": "Close",
"play": "Play",
"logout": "Logout",
"login": "Login"
},
"library": {
"sort": {
"title": "Sort By",
"time_desc": "Time (Newest First)",
"time_asc": "Time (Oldest First)",
"size_desc": "Size (Largest First)",
"size_asc": "Size (Smallest First)",
"name_asc": "Name (A to Z)",
"name_desc": "Name (Z to A)"
},
"filter": {
"title": "Filter By",
"unregistered": "Unregistered",
"registered": "Registered",
"pending": "Pending",
"completed": "Completed",
"videos_only": "Videos Only",
"photos_only": "Photos Only"
},
"size": {
"title": "Size",
"min_mb": "Min MB",
"max_mb": "Max MB"
},
"duration": {
"title": "Video Duration",
"min_minutes": "Min Minutes",
"max_minutes": "Max Minutes"
},
"status": {
"files": "{count} files",
"registered": "Registered",
"unregistered": "Unregistered",
"pending": "Pending",
"processing": "Processing",
"completed": "Completed"
},
"thumbnail": {
"register_to_preview": "Register to see preview"
},
"context": {
"register_file": "Register File",
"process_file": "Process File",
"reprocess": "Reprocess",
"run_identity_agent": "Run Identity Agent",
"unregister": "Unregister",
"play_video": "Play Video",
"processor_selection": "Processor Selection",
"overall_progress": "Overall Progress",
"missing_file_path": "Missing file path",
"file_not_registered": "File not registered",
"select_processor": "Please select at least one processor",
"file_must_be_completed": "File must be completed first"
},
"pipeline": {
"processors": "Processors",
"rule1_ingestion": "Rule1 Ingestion",
"face_tracing": "Face Tracing",
"tkg_nodes": "TKG Nodes",
"tkg_edges": "TKG Edges",
"rule2_ingestion": "Rule2 Ingestion"
},
"phase": {
"processor_output": "Rule1: Processor Output",
"postgresql_ingestion": "Rule1: PostgreSQL Ingestion",
"qdrant_ingestion": "Rule1: Qdrant Ingestion"
},
"action": {
"registering": "Registering...",
"registered": "Registered: {name}",
"registration_failed": "Registration failed: {message}",
"registration_error": "Registration error: {error}",
"processing": "Processing...",
"processing_started": "Processing started: {name}",
"processing_failed": "Processing failed: {message}",
"processing_error": "Processing error: {error}",
"identity_agent_started": "Identity Agent started: {name}",
"identity_agent_failed": "Identity Agent failed: {message}",
"identity_agent_error": "Identity Agent error: {error}",
"removing": "Removing...",
"removed": "Removed: {name}",
"removal_failed": "Removal failed: {message}",
"removal_error": "Removal error: {error}",
"clear_filters": "Clear Filters"
},
"refresh": "Refresh",
"refresh_status": "Refresh Status"
},
"people": {
"toolbar": {
"all_files": "All Files",
"view_all_people": "View All People",
"view_starred_people": "View Starred People",
"refresh": "Refresh",
"pending_people": "People",
"skipped_people": "Skipped People",
"pending_faces": "Faces",
"face_group": "Face Group"
},
"section": {
"known_people": "Known People",
"pending_people": "People",
"skipped_people": "Skipped People",
"pending_faces": "Faces",
"face_group": "Face Group"
},
"sort": {
"title": "Sort By",
"recent": "Recently Viewed",
"file_name": "File Name",
"name_az": "Name (A to Z)",
"name_za": "Name (Z to A)",
"time_desc": "Time (Newest First)",
"time_asc": "Time (Oldest First)"
},
"search": {
"known_people": "Search known people",
"pending_people": "Search pending people",
"person_name": "Search person name..."
},
"action_history": {
"title": "Action History · {name}",
"undo_all": "Undo All",
"redo_all": "Redo All",
"delete_person": "Delete This Person",
"no_history": "No action history"
},
"expand": {
"collapse": "Collapse",
"expand": "Expand",
"view_faces": "View Faces",
"appears_in": "Appears in:",
"faces": "{count} faces",
"set_as_profile": "Set as Profile",
"no_face_data": "No face data"
},
"context": {
"star_person": "★ Mark as Important",
"unstar_person": "☆ Remove Important",
"details": "📋 Details",
"confirm_person": "✓ Confirm Person",
"pending": "Pending",
"edit_name": "✎ Edit Name",
"merge_person": "⇄ Already Have This Person",
"skip_person": "✕ Skip This Person",
"undo": "↩ Undo",
"redo": "↪ Redo",
"delete_person": "🗑 Delete This Person"
},
"face_context": {
"details": "📋 Details",
"assign_existing": "⇄ Assign to Existing Person",
"skip_face": "✕ Skip This Face"
},
"assign": {
"title": "Assign to Existing Person",
"search_placeholder": "Search person name...",
"no_results": "No matching people found",
"confirm_assign": "Confirm Assignment"
},
"face_detail": {
"title": "Pending Face Details",
"file": "File:",
"trace_id": "Trace ID:",
"frame_number": "Frame Number:",
"confidence": "Confidence:",
"face_id": "Face ID:",
"play_segment": "Play Video Segment",
"new_identity": "Create as New Person",
"enter_name": "Enter person name...",
"add": "Add",
"close": "Close"
},
"detail": {
"back": "Back",
"edit": "Edit",
"role": "Role",
"description": "Description",
"enter_name": "Enter name",
"enter_alias": "Enter alias and press Enter",
"add_alias": "+ Alias",
"collapse": "Collapse",
"role_name": "Role name",
"custom_description": "Custom description",
"saving": "Saving...",
"save_changes": "✓ Save Changes",
"cancel": "Cancel",
"add_same_person": "Add same person",
"play": "▶ Play",
"unbind": "✕ Unbind",
"segments": "{count} segments",
"traces": "{count} traces",
"all_segments": "All segments",
"mode_trace": "Trace",
"mode_segment": "Segment",
"mode_continuous": "Continuous",
"status": "Status:",
"confirm": "✓ Confirm",
"skip": "Skip",
"bind_face": "+ Bind Face",
"merge_other": "⇄ Merge to Other Person",
"delete_person": "Delete This Person",
"bind_face_title": "Bind Face",
"merge_title": "⇄ Merge to Other Person",
"unbind_trace": "Unbind trace T{trace_id} (face_id: {face_id})?",
"no_face_id": "This trace has no face_id, cannot unbind",
"unbind_failed": "Unbind failed: {error}",
"merge_confirm": "Merge \"{source}\" into \"{target}\"?",
"not_this_person": "Not This Person",
"delete_confirm": "Delete \"{name}\"?",
"delete_confirm_message": "Delete \"{name}\"? This action cannot be undone.",
"delete_failed": "Delete failed: {error}",
"create_identity_failed": "Failed to create person: API did not return identity_uuid",
"create_identity_error": "Failed to create person: {error}",
"assign_failed": "Assignment failed: {error}"
}
},
"agent": {
"welcome": {
"title": "👋 Welcome to Momentry Studio",
"intro": "I'll help you make better use of the system. Let me introduce the main features:",
"smart_search": {
"title": "Smart Search",
"desc": "Keyword, semantic, and people search modes"
},
"people_management": {
"title": "People Management",
"desc": "Identify people in videos, build people database"
},
"file_management": {
"title": "File Management",
"desc": "Manage and process your video and photo files"
},
"got_it": "Got it, start using"
}
},
"login": {
"title": "Momentry Studio",
"subtitle": "Login to continue",
"username": "Username",
"password": "Password",
"username_placeholder": "Enter username",
"password_placeholder": "Enter password",
"login_button": "Login",
"logging_in": "Logging in...",
"login_success": "Login successful!",
"default_credentials": "Default credentials: demo / demo123"
},
"nav": {
"search": "Search",
"library": "Library",
"people": "People",
"admin": "Admin",
"client": "Client"
}
}

77
src/locales/index.ts Normal file
View File

@@ -0,0 +1,77 @@
import { createI18n } from 'vue-i18n'
import en from './en.json'
import zhTW from './zh-TW.json'
export type MessageSchema = typeof en
const supportedLanguages = {
'en': en,
'zh-TW': zhTW,
}
function detectInitialLanguage(): string {
const saved = localStorage.getItem('app_language')
if (saved && saved in supportedLanguages) {
return saved
}
if (saved === 'auto') {
return detectOSLanguage()
}
return 'en'
}
function detectOSLanguage(): string {
let osLang = 'en'
try {
if ((window as any).__TAURI__) {
const locale = (window as any).__TAURI__?.os?.getLocale?.()
if (locale) osLang = locale
} else {
osLang = navigator.language || 'en'
}
} catch (e) {
console.error('Language detection failed:', e)
}
if (osLang.startsWith('zh-TW') || osLang.startsWith('zh-Hant')) {
return 'zh-TW'
} else if (osLang.startsWith('zh-CN') || osLang.startsWith('zh-Hans')) {
return 'zh-CN'
} else if (osLang.startsWith('ja')) {
return 'ja'
} else if (osLang.startsWith('ko')) {
return 'ko'
}
return 'en'
}
const i18n = createI18n<[MessageSchema], 'en' | 'zh-TW'>({
legacy: false,
locale: detectInitialLanguage(),
fallbackLocale: 'en',
messages: supportedLanguages as any,
missingWarn: false,
fallbackWarn: false,
})
export function changeLanguage(lang: string) {
if (lang === 'auto') {
localStorage.setItem('app_language', 'auto')
const detected = detectOSLanguage()
i18n.global.locale = detected as any
} else {
localStorage.setItem('app_language', lang)
i18n.global.locale = lang as any
}
}
export function getCurrentLanguage(): string {
return i18n.global.locale as string
}
export { detectOSLanguage }
export default i18n

273
src/locales/zh-TW.json Normal file
View File

@@ -0,0 +1,273 @@
{
"common": {
"loading": "載入中...",
"error": "錯誤",
"success": "成功",
"cancel": "取消",
"confirm": "確認",
"delete": "刪除",
"save": "儲存",
"edit": "編輯",
"search": "搜尋",
"back": "返回",
"close": "關閉",
"play": "播放",
"logout": "登出",
"login": "登入"
},
"library": {
"sort": {
"title": "排序方式",
"time_desc": "依時間・由近到遠",
"time_asc": "依時間・由遠到近",
"size_desc": "依大小・由大到小",
"size_asc": "依大小・由小到大",
"name_asc": "依檔名・由 A 到 Z",
"name_desc": "依檔名・由 Z 到 A"
},
"filter": {
"title": "過濾方式",
"unregistered": "未註冊",
"registered": "已註冊",
"pending": "待處理",
"completed": "已完成",
"videos_only": "僅顯示影片",
"photos_only": "僅顯示照片"
},
"size": {
"title": "大小",
"min_mb": "最小 MB",
"max_mb": "最大 MB"
},
"duration": {
"title": "影片時長",
"min_minutes": "最小分鐘",
"max_minutes": "最大分鐘"
},
"status": {
"files": "{count} 個檔案",
"registered": "已註冊",
"unregistered": "未註冊",
"pending": "等待中",
"processing": "處理中",
"completed": "已完成"
},
"thumbnail": {
"register_to_preview": "註冊以預覽"
},
"context": {
"register_file": "註冊此檔案",
"process_file": "處理此檔案",
"reprocess": "重新處理",
"run_identity_agent": "執行 Identity Agent",
"unregister": "移除註冊",
"play_video": "播放影片",
"processor_selection": "處理器選擇",
"overall_progress": "整體進度",
"missing_file_path": "缺少檔案路徑",
"file_not_registered": "檔案尚未註冊",
"select_processor": "請至少選擇一個處理器",
"file_must_be_completed": "檔案需先完成處理"
},
"pipeline": {
"processors": "處理器",
"rule1_ingestion": "Rule1 入庫",
"face_tracing": "Face Tracing",
"tkg_nodes": "TKG Nodes",
"tkg_edges": "TKG Edges",
"rule2_ingestion": "Rule2 入庫"
},
"phase": {
"processor_output": "Rule1: 處理器輸出",
"postgresql_ingestion": "Rule1: PostgreSQL 入庫",
"qdrant_ingestion": "Rule1: Qdrant 入庫"
},
"action": {
"registering": "註冊中...",
"registered": "註冊成功:{name}",
"registration_failed": "註冊失敗:{message}",
"registration_error": "註冊錯誤:{error}",
"processing": "處理中...",
"processing_started": "處理已觸發:{name}",
"processing_failed": "處理失敗:{message}",
"processing_error": "處理錯誤:{error}",
"identity_agent_started": "Identity Agent 已啟動:{name}",
"identity_agent_failed": "Identity Agent 失敗:{message}",
"identity_agent_error": "Identity Agent 錯誤:{error}",
"removing": "移除中...",
"removed": "移除成功:{name}",
"removal_failed": "移除失敗:{message}",
"removal_error": "移除錯誤:{error}",
"clear_filters": "清除篩選"
},
"refresh": "重新整理",
"refresh_status": "重新整理狀態"
},
"people": {
"toolbar": {
"all_files": "全部檔案",
"view_all_people": "查看所有人物",
"view_starred_people": "查看重要人物",
"refresh": "重新整理",
"pending_people": "人物",
"skipped_people": "已略過",
"pending_faces": "人臉",
"face_group": "人臉群組"
},
"section": {
"known_people": "已知人物",
"pending_people": "人物",
"skipped_people": "已略過",
"pending_faces": "人臉",
"face_group": "人臉群組"
},
"sort": {
"title": "排序方式",
"recent": "最近瀏覽",
"file_name": "依檔案名稱",
"name_az": "依檔名・由 A 到 Z",
"name_za": "依檔名・由 Z 到 A",
"time_desc": "依時間・由近到遠",
"time_asc": "依時間・由遠到近"
},
"search": {
"known_people": "搜尋已知人物",
"pending_people": "搜尋待定人物",
"person_name": "搜尋人物名稱..."
},
"action_history": {
"title": "操作記錄 · {name}",
"undo_all": "全部復原",
"redo_all": "全部重做",
"delete_person": "刪除此人物",
"no_history": "尚無操作記錄"
},
"expand": {
"collapse": "收起",
"expand": "展開",
"view_faces": "查看面孔",
"appears_in": "出現檔案:",
"faces": "{count} 面孔",
"set_as_profile": "設為大頭貼",
"no_face_data": "無面孔資料"
},
"context": {
"star_person": "★ 標為重要人物",
"unstar_person": "☆ 取消重要人物",
"details": "📋 詳情",
"confirm_person": "✓ 確認人物",
"pending": "待定",
"edit_name": "✎ 編輯名稱",
"merge_person": "⇄ 已有此人物",
"skip_person": "✕ 略過此人物",
"undo": "↩ 復原",
"redo": "↪ 重做",
"delete_person": "🗑 刪除此人物"
},
"face_context": {
"details": "📋 詳情",
"assign_existing": "⇄ 指派給現有人物",
"skip_face": "✕ 略過此人臉"
},
"assign": {
"title": "指派給現有人物",
"search_placeholder": "搜尋人物名稱...",
"no_results": "沒有找到符合的人物",
"confirm_assign": "確認指派"
},
"face_detail": {
"title": "待定人臉詳情",
"file": "檔案:",
"trace_id": "Trace ID:",
"frame_number": "帧號:",
"confidence": "置信度:",
"face_id": "Face ID:",
"play_segment": "播放影片片段",
"new_identity": "新增為新人物",
"enter_name": "輸入人物名稱...",
"add": "新增",
"close": "關閉"
},
"detail": {
"back": "返回",
"edit": "編輯",
"role": "角色",
"description": "描述",
"enter_name": "加入人名",
"enter_alias": "輸入別名後按 Enter",
"add_alias": "+ 別名",
"collapse": "收起",
"role_name": "角色名稱",
"custom_description": "自訂描述",
"saving": "儲存中...",
"save_changes": "✓ 儲存更改",
"cancel": "取消",
"add_same_person": "加入相同人物",
"play": "▶ 播放",
"unbind": "✕ 解綁",
"segments": "{count} 個片段",
"traces": "{count} 個追蹤",
"all_segments": "所有片段",
"mode_trace": "追蹤",
"mode_segment": "片段",
"mode_continuous": "連續",
"status": "狀態:",
"confirm": "✓ 確認",
"skip": "略過",
"bind_face": "+ 綁定人臉",
"merge_other": "⇄ 合併到其他人物",
"delete_person": "刪除此人物",
"bind_face_title": "Bind Face",
"merge_title": "⇄ 合併到其他人物",
"unbind_trace": "解綁 trace T{trace_id}(face_id: {face_id})?",
"no_face_id": "此 trace 沒有 face_id,無法解綁",
"unbind_failed": "解綁失敗:{error}",
"merge_confirm": "合併「{source}」到「{target}」?",
"not_this_person": "不是此人物",
"delete_confirm": "刪除「{name}」?",
"delete_confirm_message": "刪除「{name}」?此操作無法復原。",
"delete_failed": "刪除失敗:{error}",
"create_identity_failed": "新增人物失敗:API 未返回 identity_uuid",
"create_identity_error": "新增人物失敗:{error}",
"assign_failed": "指派失敗:{error}"
}
},
"agent": {
"welcome": {
"title": "👋 歡迎使用 Momentry Studio",
"intro": "我會幫助你更好地使用系統。讓我介紹一下主要功能:",
"smart_search": {
"title": "智能搜索",
"desc": "關鍵詞、語義、人物三種搜索模式"
},
"people_management": {
"title": "人物管理",
"desc": "識別視頻中的人物,建立人物庫"
},
"file_management": {
"title": "文件管理",
"desc": "管理和處理你的視頻、照片文件"
},
"got_it": "知道了,開始使用"
}
},
"login": {
"title": "Momentry Studio",
"subtitle": "Login to continue",
"username": "Username",
"password": "Password",
"username_placeholder": "Enter username",
"password_placeholder": "Enter password",
"login_button": "Login",
"logging_in": "Logging in...",
"login_success": "Login successful!",
"default_credentials": "Default credentials: demo / demo123"
},
"nav": {
"search": "Search",
"library": "Library",
"people": "People",
"admin": "Admin",
"client": "Client"
}
}

19
src/main.ts.backup_local Normal file
View File

@@ -0,0 +1,19 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import App from './App.vue'
import LoginView from './views/LoginView.vue'
import ClientView from './views/ClientView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/login' },
{ path: '/login', name: 'Login', component: LoginView },
{ path: '/client', name: 'Client', component: ClientView },
{ path: '/search', name: 'Search', component: { template: '<div>Search Page (Coming Soon)</div>' } },
],
})
const app = createApp(App)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,28 @@
// Advanced Mode Store - controls visibility of advanced features
import { ref, computed } from 'vue'
const ADVANCED_KEY = 'momentry_advanced_mode'
// Load initial state
const savedAdvanced = localStorage.getItem(ADVANCED_KEY)
const isAdvancedMode = ref(savedAdvanced === 'true')
export function useAdvancedMode() {
const isAdvanced = computed(() => isAdvancedMode.value)
function toggleAdvanced() {
isAdvancedMode.value = !isAdvancedMode.value
localStorage.setItem(ADVANCED_KEY, isAdvancedMode.value.toString())
}
function setAdvanced(value: boolean) {
isAdvancedMode.value = value
localStorage.setItem(ADVANCED_KEY, value.toString())
}
return {
isAdvanced,
toggleAdvanced,
setAdvanced
}
}

48
src/stores/agentStore.ts Normal file
View File

@@ -0,0 +1,48 @@
import { ref } from 'vue'
export const agentTip = ref<string>('')
export const showAgentTip = ref(false)
export const agentSuggestions = ref<string[]>([])
export const discoveryTip = ref<string>('')
export const userBehavior = ref({
hasUsedKeywordSearch: false,
hasUsedSemanticSearch: false,
hasUsedPeopleSearch: false,
hasUsedFileFilter: false,
hasUsedContextMenu: false,
hasViewedPersonDetails: false,
hasBindedFace: false,
hasUsedSearchHistory: false,
hasUsedBookmark: false
})
export function showTip(message: string, duration = 5000) {
agentTip.value = message
showAgentTip.value = true
setTimeout(() => {
showAgentTip.value = false
}, duration)
}
export function showDiscovery(message: string, duration = 8000) {
discoveryTip.value = message
setTimeout(() => {
discoveryTip.value = ''
}, duration)
}
export function trackBehavior(action: keyof typeof userBehavior.value) {
userBehavior.value[action] = true
localStorage.setItem('momentry_user_behavior', JSON.stringify(userBehavior.value))
}
export function loadUserBehavior() {
const saved = localStorage.getItem('momentry_user_behavior')
if (saved) {
try {
userBehavior.value = JSON.parse(saved)
} catch (e) {
console.error('Failed to load user behavior:', e)
}
}
}

60
src/utils/mockPoseData.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { PoseResponse, AppearanceResponse } from '@/api/types'
export const mockPose: PoseResponse = {
frame: 100,
keypoints: [
{ name: 'nose', x: 100, y: 50, confidence: 0.9 },
{ name: 'left_eye', x: 90, y: 45, confidence: 0.95 },
{ name: 'right_eye', x: 110, y: 45, confidence: 0.95 },
{ name: 'left_ear', x: 80, y: 50, confidence: 0.85 },
{ name: 'right_ear', x: 120, y: 50, confidence: 0.85 },
{ name: 'left_shoulder', x: 60, y: 100, confidence: 0.9 },
{ name: 'right_shoulder', x: 140, y: 100, confidence: 0.9 },
{ name: 'left_elbow', x: 50, y: 150, confidence: 0.85 },
{ name: 'right_elbow', x: 150, y: 150, confidence: 0.85 },
{ name: 'left_wrist', x: 45, y: 190, confidence: 0.8 },
{ name: 'right_wrist', x: 155, y: 190, confidence: 0.8 },
{ name: 'left_hip', x: 70, y: 200, confidence: 0.9 },
{ name: 'right_hip', x: 130, y: 200, confidence: 0.9 },
{ name: 'left_knee', x: 65, y: 280, confidence: 0.85 },
{ name: 'right_knee', x: 135, y: 280, confidence: 0.85 },
{ name: 'left_ankle', x: 60, y: 350, confidence: 0.8 },
{ name: 'right_ankle', x: 140, y: 350, confidence: 0.8 },
],
pose_class: 'standing',
confidence: 0.92
}
export const mockAppearance: AppearanceResponse = {
frame: 100,
dominant_colors: [
{ rgb: [255, 100, 50], percentage: 0.35 },
{ rgb: [50, 150, 200], percentage: 0.25 },
{ rgb: [100, 200, 100], percentage: 0.15 },
]
}
export const mockSittingPose: PoseResponse = {
frame: 200,
keypoints: [
{ name: 'nose', x: 100, y: 60, confidence: 0.88 },
{ name: 'left_eye', x: 90, y: 55, confidence: 0.92 },
{ name: 'right_eye', x: 110, y: 55, confidence: 0.92 },
{ name: 'left_ear', x: 80, y: 60, confidence: 0.82 },
{ name: 'right_ear', x: 120, y: 60, confidence: 0.82 },
{ name: 'left_shoulder', x: 60, y: 110, confidence: 0.88 },
{ name: 'right_shoulder', x: 140, y: 110, confidence: 0.88 },
{ name: 'left_elbow', x: 55, y: 140, confidence: 0.83 },
{ name: 'right_elbow', x: 145, y: 140, confidence: 0.83 },
{ name: 'left_wrist', x: 50, y: 160, confidence: 0.78 },
{ name: 'right_wrist', x: 150, y: 160, confidence: 0.78 },
{ name: 'left_hip', x: 70, y: 180, confidence: 0.88 },
{ name: 'right_hip', x: 130, y: 180, confidence: 0.88 },
{ name: 'left_knee', x: 90, y: 200, confidence: 0.83 },
{ name: 'right_knee', x: 110, y: 200, confidence: 0.83 },
{ name: 'left_ankle', x: 85, y: 240, confidence: 0.78 },
{ name: 'right_ankle', x: 115, y: 240, confidence: 0.78 },
],
pose_class: 'sitting',
confidence: 0.87
}

264
src/utils/poseRenderer.ts Normal file
View File

@@ -0,0 +1,264 @@
import type { PoseResponse, AppearanceResponse } from '@/api/types'
const SKELETON_CONNECTIONS: [string, string][] = [
['nose', 'left_eye'],
['nose', 'right_eye'],
['left_eye', 'left_ear'],
['right_eye', 'right_ear'],
['left_shoulder', 'right_shoulder'],
['left_shoulder', 'left_elbow'],
['left_elbow', 'left_wrist'],
['right_shoulder', 'right_elbow'],
['right_elbow', 'right_wrist'],
['left_shoulder', 'left_hip'],
['right_shoulder', 'right_hip'],
['left_hip', 'right_hip'],
['left_hip', 'left_knee'],
['left_knee', 'left_ankle'],
['right_hip', 'right_knee'],
['right_knee', 'right_ankle'],
]
function transformKeypoints(
keypoints: Array<{ name: string; x: number; y: number; confidence?: number }>,
canvasWidth: number,
canvasHeight: number
): Map<string, { x: number; y: number; confidence: number }> {
console.log('[transformKeypoints] input keypoints:', keypoints?.length)
const validKeypoints = keypoints.filter(kp => {
const conf = kp.confidence ?? 0
return conf > 0 && kp.x > 0 && kp.y > 0
})
console.log('[transformKeypoints] valid keypoints:', validKeypoints.length)
if (validKeypoints.length === 0) {
return new Map()
}
let minX = Infinity, maxX = -Infinity
let minY = Infinity, maxY = -Infinity
for (const kp of validKeypoints) {
minX = Math.min(minX, kp.x)
maxX = Math.max(maxX, kp.x)
minY = Math.min(minY, kp.y)
maxY = Math.max(maxY, kp.y)
}
const width = maxX - minX || 1
const height = maxY - minY || 1
console.log('[transformKeypoints] bounding box:', minX, minY, 'to', maxX, maxY)
console.log('[transformKeypoints] bbox size:', width, 'x', height)
const padding = 20
const availableWidth = canvasWidth - 2 * padding
const availableHeight = canvasHeight - 2 * padding
const scaleX = availableWidth / width
const scaleY = availableHeight / height
const scale = Math.min(scaleX, scaleY)
const scaledWidth = width * scale
const scaledHeight = height * scale
const offsetX = (canvasWidth - scaledWidth) / 2 - minX * scale
const offsetY = (canvasHeight - scaledHeight) / 2 - minY * scale
console.log('[transformKeypoints] scale:', scale, 'offset:', offsetX, offsetY)
const keypointMap = new Map<string, { x: number; y: number; confidence: number }>()
for (const kp of keypoints) {
const conf = kp.confidence ?? 0
keypointMap.set(kp.name, {
x: kp.x * scale + offsetX,
y: kp.y * scale + offsetY,
confidence: conf
})
}
console.log('[transformKeypoints] First 3 transformed:',
[...keypointMap.entries()].slice(0, 3).map(([k, v]) => ({ name: k, ...v })))
return keypointMap
}
export function drawPoseSkeleton(
canvas: HTMLCanvasElement,
pose: PoseResponse,
options?: {
lineWidth?: number
pointRadius?: number
lineColor?: string
pointColor?: string
videoWidth?: number
videoHeight?: number
noTransform?: boolean
}
) {
console.log('[drawPoseSkeleton] canvas size:', canvas.width, 'x', canvas.height)
console.log('[drawPoseSkeleton] pose:', pose)
const ctx = canvas.getContext('2d')
if (!ctx) {
console.error('[drawPoseSkeleton] No context')
return
}
const {
lineWidth = 2,
pointRadius = 4,
lineColor = '#00ff00',
pointColor = '#ff0000',
noTransform = false
} = options || {}
ctx.clearRect(0, 0, canvas.width, canvas.height)
// 不轉換,直接畫原始座標(用於 debug)
if (noTransform) {
console.log('[drawPoseSkeleton] Drawing raw keypoints (no transform)')
const validKeypoints = pose.keypoints.filter(kp => kp.confidence > 0 && kp.x > 0 && kp.y > 0)
if (validKeypoints.length === 0) {
ctx.fillStyle = '#9aa0a6'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('No valid keypoints', canvas.width / 2, canvas.height / 2)
return
}
// 找 bounding box
let minX = Infinity, maxX = -Infinity
let minY = Infinity, maxY = -Infinity
for (const kp of validKeypoints) {
minX = Math.min(minX, kp.x)
maxX = Math.max(maxX, kp.x)
minY = Math.min(minY, kp.y)
maxY = Math.max(maxY, kp.y)
}
console.log('[drawPoseSkeleton] Raw bbox:', minX, minY, 'to', maxX, maxY)
// 繪製
ctx.fillStyle = pointColor
for (const kp of validKeypoints) {
ctx.beginPath()
ctx.arc(kp.x, kp.y, pointRadius, 0, Math.PI * 2)
ctx.fill()
}
return
}
const keypointMap = transformKeypoints(pose.keypoints, canvas.width, canvas.height)
console.log('[drawPoseSkeleton] transformed keypoints:', keypointMap.size)
if (keypointMap.size === 0) {
console.log('[drawPoseSkeleton] No valid keypoints')
ctx.fillStyle = '#9aa0a6'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('No pose detected', canvas.width / 2, canvas.height / 2)
return
}
ctx.strokeStyle = lineColor
ctx.lineWidth = lineWidth
for (const [from, to] of SKELETON_CONNECTIONS) {
const fromPt = keypointMap.get(from)
const toPt = keypointMap.get(to)
if (fromPt && toPt && fromPt.confidence > 0 && toPt.confidence > 0) {
ctx.beginPath()
ctx.moveTo(fromPt.x, fromPt.y)
ctx.lineTo(toPt.x, toPt.y)
ctx.stroke()
}
}
ctx.fillStyle = pointColor
for (const kp of pose.keypoints) {
const transformed = keypointMap.get(kp.name)
if (transformed && transformed.confidence > 0) {
ctx.beginPath()
ctx.arc(transformed.x, transformed.y, pointRadius, 0, Math.PI * 2)
ctx.fill()
}
}
}
export function drawAppearanceOverlay(
canvas: HTMLCanvasElement,
appearance: AppearanceResponse,
pose?: PoseResponse
) {
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
if (!appearance.dominant_colors?.length) {
if (appearance.hsv_histogram && appearance.hsv_histogram.length > 0) {
ctx.fillStyle = '#f8f9fa'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#5f6368'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('HSV histogram', canvas.width / 2, canvas.height / 2 - 10)
ctx.fillText(`(${appearance.hsv_histogram.length} channels)`, canvas.width / 2, canvas.height / 2 + 10)
}
return
}
if (!pose) {
const swatchWidth = canvas.width / appearance.dominant_colors.length
appearance.dominant_colors.forEach((color, i) => {
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
ctx.fillRect(i * swatchWidth, 0, swatchWidth, canvas.height)
if (color.percentage) {
ctx.fillStyle = '#fff'
ctx.font = '12px sans-serif'
ctx.fillText(`${Math.round(color.percentage * 100)}%`, i * swatchWidth + 4, canvas.height - 8)
}
})
return
}
const keypointMap = transformKeypoints(pose.keypoints, canvas.width, canvas.height)
if (keypointMap.size === 0) return
ctx.globalAlpha = 0.4
const upperBodyPts = ['left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow']
const lowerBodyPts = ['left_hip', 'right_hip', 'left_knee', 'right_knee']
if (appearance.dominant_colors[0]) {
const color = appearance.dominant_colors[0]
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
for (const name of upperBodyPts) {
const pt = keypointMap.get(name)
if (pt && pt.confidence > 0) {
ctx.beginPath()
ctx.arc(pt.x, pt.y, 15, 0, Math.PI * 2)
ctx.fill()
}
}
}
if (appearance.dominant_colors[1]) {
const color = appearance.dominant_colors[1]
ctx.fillStyle = `rgb(${color.rgb.join(',')})`
for (const name of lowerBodyPts) {
const pt = keypointMap.get(name)
if (pt && pt.confidence > 0) {
ctx.beginPath()
ctx.arc(pt.x, pt.y, 15, 0, Math.PI * 2)
ctx.fill()
}
}
}
ctx.globalAlpha = 1.0
}

View File

@@ -0,0 +1,85 @@
<template>
<div id="ms-view-admin">
<div class="admin-header">
<h1>Admin Panel</h1>
<div class="admin-tabs">
<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>
</div>
</div>
<div class="admin-content">
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
<div class="stats-grid">
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
</div>
</div>
<div v-if="activeTab === 'users'" class="users-panel">
<table class="admin-table">
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="activeTab === 'shares'" class="shares-panel">
<table class="admin-table">
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
<tbody>
<tr v-for="s in shares" :key="s.name">
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { apiCall } from '../api'
const activeTab = ref('dashboard')
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
const users = ref<any[]>([])
const shares = ref<any[]>([])
async function loadDashboard() {
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
}
async function loadUsers() {
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
}
async function loadShares() {
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
}
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
</script>
<style scoped>
#ms-view-admin { padding: 20px; }
.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; }
.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; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
</style>

View File

@@ -0,0 +1,84 @@
<template>
<div id="ms-view-client">
<div class="client-header">
<h1>File Manager</h1>
<div class="client-toolbar">
<button @click="goUp">Up</button>
<button @click="createFolder">New Folder</button>
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
<span class="current-path">{{ currentPath }}</span>
</div>
</div>
<div class="client-content">
<table class="file-table">
<thead><tr><th>Name</th><th>Size</th><th>Type</th></tr></thead>
<tbody>
<tr v-for="f in files" :key="f.name" @click="selectFile(f)" :class="{ selected: selectedFile?.name === f.name }">
<td><span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span> {{ f.name }}</td>
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { apiCall } from '../api'
const currentPath = ref('/')
const files = ref<any[]>([])
const selectedFile = ref<any>(null)
async function loadFiles() {
try {
const data = await apiCall('list_client_files', { path: currentPath.value })
files.value = (data || []).sort((a: any, b: any) => a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1)
} catch (e) { console.error(e); files.value = [] }
}
function goUp() {
const parts = currentPath.value.split('/').filter(p => p)
parts.pop()
currentPath.value = '/' + parts.join('/')
loadFiles()
}
function selectFile(f: any) {
if (f.isDir) { currentPath.value = currentPath.value === '/' ? '/' + f.name : currentPath.value + '/' + f.name; loadFiles() }
else { selectedFile.value = f }
}
async function createFolder() {
const name = prompt('Folder name:')
if (!name) return
const path = currentPath.value === '/' ? '/' + name : currentPath.value + '/' + name
try { await apiCall('mkdir_client', { path }); loadFiles() } catch (e) { alert('Failed: ' + e) }
}
async function deleteSelected() {
if (!selectedFile.value || !confirm('Delete ' + selectedFile.value.name + '?')) return
const path = currentPath.value === '/' ? '/' + selectedFile.value.name : currentPath.value + '/' + selectedFile.value.name
try { await apiCall('rm_client', { path }); selectedFile.value = null; loadFiles() } catch (e) { alert('Failed: ' + e) }
}
function formatSize(n: number) { return n < 1024 ? n + 'B' : n < 1024*1024 ? (n/1024).toFixed(1) + 'KB' : (n/1024/1024).toFixed(1) + 'MB' }
onMounted(loadFiles)
</script>
<style scoped>
#ms-view-client { padding: 20px; }
.client-header { margin-bottom: 15px; }
.client-toolbar { display: flex; gap: 10px; align-items: center; margin-top: 10px; }
.client-toolbar button { padding: 6px 12px; border: 1px solid #ddd; background: white; cursor: pointer; border-radius: 4px; }
.client-toolbar button:disabled { opacity: 0.5; }
.current-path { margin-left: 20px; font-weight: bold; }
.file-table { width: 100%; border-collapse: collapse; }
.file-table th, .file-table td { padding: 8px; border: 1px solid #eee; }
.file-table tr.selected { background: #e3f2fd; }
.file-table tr:hover { background: #f5f5f5; cursor: pointer; }
</style>

View File

@@ -1353,26 +1353,29 @@ async function playFaceDetailVideo() {
const c = faceDetailModal.value.candidate
if (!c || !c.file_uuid) return
const frame = c.frame_number || 0
const defaultFps = 30
try {
const fileInfo: any = await apiCall('get_file_info', { uuid: c.file_uuid })
const fps = fileInfo?.fps || 30
const fps = fileInfo?.fps || defaultFps
const frameTime = frame / fps
const startTime = Math.max(0, frameTime - 5)
const endTime = frameTime + 5
currentVideo.value = {
currentVideo.value = {
fileUuid: c.file_uuid,
startFrame: startTime * fps,
endFrame: endTime * fps,
startFrame: Math.round(startTime * fps),
endFrame: Math.round(endTime * fps),
title: `F${Math.round(startTime * fps)}-F${Math.round(endTime * fps)} (${c.name})`
}
playing.value = true
} catch (e) {
console.error('Failed to get file info:', e)
const frameTime = frame / defaultFps
currentVideo.value = {
fileUuid: c.file_uuid,
startFrame: Math.round(Math.max(0, frameTime - 5) * 30),
endFrame: Math.round((frameTime + 5) * 30),
title: `F${Math.round(Math.max(0, frameTime - 5) * 30)}-F${Math.round((frameTime + 5) * 30)}`
startFrame: Math.round(Math.max(0, frameTime - 5) * defaultFps),
endFrame: Math.round((frameTime + 5) * defaultFps),
title: `F${Math.round(Math.max(0, frameTime - 5) * defaultFps)}-F${Math.round((frameTime + 5) * defaultFps)}`
}
playing.value = true
}