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:
99
src/App.vue.backup_final
Normal file
99
src/App.vue.backup_final
Normal 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
33
src/api/params.ts
Normal 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
26
src/api/types.ts
Normal 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
216
src/components/AgentTip.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
12
src/composables/useTheme.ts
Normal file
12
src/composables/useTheme.ts
Normal 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
|
||||
}
|
||||
35
src/composables/useZIndex.ts
Normal file
35
src/composables/useZIndex.ts
Normal 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
273
src/locales/en.json
Normal 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
77
src/locales/index.ts
Normal 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
273
src/locales/zh-TW.json
Normal 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
19
src/main.ts.backup_local
Normal 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')
|
||||
28
src/stores/advancedMode.ts
Normal file
28
src/stores/advancedMode.ts
Normal 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
48
src/stores/agentStore.ts
Normal 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
60
src/utils/mockPoseData.ts
Normal 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
264
src/utils/poseRenderer.ts
Normal 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
|
||||
}
|
||||
85
src/views/AdminView.vue.backup_20260626_162155
Normal file
85
src/views/AdminView.vue.backup_20260626_162155
Normal 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>
|
||||
84
src/views/ClientView.vue.backup_20260626_162155
Normal file
84
src/views/ClientView.vue.backup_20260626_162155
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user