feat: ASRX hybrid pipeline, identity history, worker fixes, checkpoint system
This commit is contained in:
174
portal/src/components/Face3DViewer.vue
Normal file
174
portal/src/components/Face3DViewer.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div ref="container" class="w-full h-full min-h-[300px] bg-gray-900 rounded-lg overflow-hidden"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
|
||||
const props = defineProps<{
|
||||
landmarks: number[][] // 468 x [x, y, z]
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLElement>()
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.PerspectiveCamera | null = null
|
||||
let controls: OrbitControls | null = null
|
||||
let animId: number
|
||||
let objects: THREE.Object3D[] = []
|
||||
|
||||
function disposeScene() {
|
||||
cancelAnimationFrame(animId)
|
||||
for (const obj of objects) {
|
||||
scene?.remove(obj)
|
||||
if (obj instanceof THREE.Mesh) {
|
||||
obj.geometry?.dispose()
|
||||
if (Array.isArray(obj.material)) obj.material.forEach(m => m.dispose())
|
||||
else obj.material?.dispose()
|
||||
}
|
||||
if (obj instanceof THREE.Points) {
|
||||
obj.geometry?.dispose()
|
||||
if (obj.material) obj.material.dispose()
|
||||
}
|
||||
}
|
||||
objects = []
|
||||
controls?.dispose()
|
||||
controls = null
|
||||
if (renderer) {
|
||||
renderer.dispose()
|
||||
renderer = null
|
||||
}
|
||||
scene = null
|
||||
camera = null
|
||||
}
|
||||
|
||||
const FACES_TRI = [
|
||||
// Eyes
|
||||
[33, 133, 7], [33, 7, 163], [160, 159, 158], [159, 158, 157],
|
||||
// Nose
|
||||
[168, 6, 197], [6, 197, 195], [197, 195, 5],
|
||||
// Mouth outer
|
||||
[61, 146, 91], [91, 181, 84], [84, 17, 314], [314, 405, 321],
|
||||
// Mouth inner
|
||||
[78, 95, 88], [95, 88, 178], [87, 14, 317], [14, 317, 402],
|
||||
// Jaw
|
||||
[10, 338, 297], [297, 332, 284], [284, 251, 389],
|
||||
// Left eye brow
|
||||
[46, 53, 52], [53, 52, 65],
|
||||
// Right eye brow
|
||||
[276, 283, 282], [283, 282, 295],
|
||||
// Face oval
|
||||
[10, 338, 297], [297, 332, 284], [284, 251, 389], [389, 356, 454],
|
||||
[454, 323, 361], [361, 288, 397], [397, 365, 379], [379, 378, 400],
|
||||
[400, 377, 152], [152, 148, 176], [176, 149, 150], [150, 136, 172],
|
||||
[172, 58, 132], [132, 93, 234], [234, 127, 162], [162, 21, 54],
|
||||
[54, 103, 67], [67, 109, 10]
|
||||
]
|
||||
|
||||
function buildMesh(pts: number[][]): THREE.BufferGeometry {
|
||||
const verts = new Float32Array(pts.length * 3)
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
verts[i * 3] = (pts[i][0] - 0.5) * 2
|
||||
verts[i * 3 + 1] = -(pts[i][1] - 0.5) * 2
|
||||
verts[i * 3 + 2] = pts[i][2] * 2
|
||||
}
|
||||
const indices: number[] = []
|
||||
for (const tri of FACES_TRI) {
|
||||
if (tri.every(i => i < pts.length)) indices.push(...tri)
|
||||
}
|
||||
const geo = new THREE.BufferGeometry()
|
||||
geo.setAttribute('position', new THREE.BufferAttribute(verts, 3))
|
||||
geo.setIndex(indices)
|
||||
geo.computeVertexNormals()
|
||||
return geo
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!container.value) return
|
||||
|
||||
// Dispose previous scene if re-initializing
|
||||
disposeScene()
|
||||
|
||||
const rect = container.value.getBoundingClientRect()
|
||||
const w = rect.width || 400, h = rect.height || 300
|
||||
|
||||
scene = new THREE.Scene()
|
||||
camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10)
|
||||
camera.position.set(0, 0, 2.5)
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||
renderer.setSize(w, h)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
container.value.appendChild(renderer.domElement)
|
||||
|
||||
controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.enableDamping = true
|
||||
controls.dampingFactor = 0.05
|
||||
controls.autoRotate = true
|
||||
controls.autoRotateSpeed = 2
|
||||
|
||||
// Mesh
|
||||
if (props.landmarks?.length) {
|
||||
const geo = buildMesh(props.landmarks)
|
||||
const mat = new THREE.MeshPhongMaterial({
|
||||
color: 0x4488ff,
|
||||
flatShading: false,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
const mesh = new THREE.Mesh(geo, mat)
|
||||
scene.add(mesh)
|
||||
objects.push(mesh)
|
||||
|
||||
// Points
|
||||
const ptGeo = new THREE.BufferGeometry()
|
||||
ptGeo.setAttribute('position', geo.getAttribute('position')!)
|
||||
const ptMat = new THREE.PointsMaterial({ color: 0x88bbff, size: 0.008 })
|
||||
const points = new THREE.Points(ptGeo, ptMat)
|
||||
scene.add(points)
|
||||
objects.push(points)
|
||||
}
|
||||
|
||||
// Lights
|
||||
const ambient = new THREE.AmbientLight(0x404060)
|
||||
scene.add(ambient)
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 1)
|
||||
dir.position.set(1, 1, 1)
|
||||
scene.add(dir)
|
||||
const dir2 = new THREE.DirectionalLight(0x8888ff, 0.5)
|
||||
dir2.position.set(-1, -1, 0.5)
|
||||
scene.add(dir2)
|
||||
|
||||
// Resize observer
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!container.value || !renderer || !camera) return
|
||||
const r = container.value.getBoundingClientRect()
|
||||
const w = r.width || 400, h = r.height || 300
|
||||
renderer.setSize(w, h)
|
||||
camera.aspect = w / h
|
||||
camera.updateProjectionMatrix()
|
||||
})
|
||||
resizeObserver.observe(container.value)
|
||||
;(container.value as any).__resizeObserver = resizeObserver
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animId = requestAnimationFrame(animate)
|
||||
controls?.update()
|
||||
if (renderer && scene && camera) renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
onMounted(() => init())
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animId)
|
||||
if ((container.value as any)?.__resizeObserver) {
|
||||
(container.value as any).__resizeObserver.disconnect()
|
||||
}
|
||||
disposeScene()
|
||||
})
|
||||
</script>
|
||||
80
portal/src/components/IdentitySwimlane.vue
Normal file
80
portal/src/components/IdentitySwimlane.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-300">身分泳道圖 V2</h3>
|
||||
<span class="text-xs text-gray-500">{{ identities.length }} identities</span>
|
||||
</div>
|
||||
<div class="relative overflow-x-auto" ref="scrollRef">
|
||||
<svg :width="svgW" :height="rowH * identities.length + 30" class="block">
|
||||
<!-- time axis -->
|
||||
<line x1="80" :y1="rowH * identities.length + 5" :x2="svgW" :y2="rowH * identities.length + 5" stroke="#4b5563" stroke-width="1" />
|
||||
<g v-for="t in ticks" :key="t">
|
||||
<line :x1="xPos(t)" :y1="rowH * identities.length + 1" :x2="xPos(t)" :y2="rowH * identities.length + 5" stroke="#6b7280" stroke-width="1" />
|
||||
<text :x="xPos(t)" :y="rowH * identities.length + 16" fill="#9ca3af" font-size="9" text-anchor="middle">{{ t }}s</text>
|
||||
</g>
|
||||
<!-- swimlanes -->
|
||||
<g v-for="(ident, i) in identities" :key="ident.name">
|
||||
<text x="4" :y="rowH * i + rowH / 2 + 5" fill="#d1d5db" font-size="11" class="select-none">{{ ident.name }}</text>
|
||||
<rect x="78" :y="rowH * i + 4" width="2" :height="rowH - 8" fill="#374151" rx="2" />
|
||||
<rect
|
||||
v-for="seg in ident.segments" :key="seg.start"
|
||||
:x="xPos(seg.start)" :y="rowH * i + 6"
|
||||
:width="Math.max(2, xPos(seg.end) - xPos(seg.start))"
|
||||
:height="rowH - 12"
|
||||
:fill="ident.color"
|
||||
:opacity="0.7"
|
||||
rx="3"
|
||||
class="cursor-pointer hover:opacity-100"
|
||||
@click="$emit('selectTrace', seg.trace_id)"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
identities: SwimlaneIdentity[]
|
||||
totalDuration: number
|
||||
}>()
|
||||
|
||||
defineEmits<{ selectTrace: [traceId: number] }>()
|
||||
|
||||
export interface SwimlaneSegment {
|
||||
trace_id: number
|
||||
start: number
|
||||
end: number
|
||||
face_count: number
|
||||
}
|
||||
|
||||
export interface SwimlaneIdentity {
|
||||
name: string
|
||||
color: string
|
||||
segments: SwimlaneSegment[]
|
||||
}
|
||||
|
||||
const rowH = 28
|
||||
const labelW = 80
|
||||
const padR = 20
|
||||
|
||||
const svgW = computed(() => {
|
||||
const dur = props.totalDuration || 6000
|
||||
return Math.max(500, labelW + dur / 8)
|
||||
})
|
||||
|
||||
function xPos(sec: number): number {
|
||||
const dur = props.totalDuration || 6000
|
||||
return labelW + (sec / dur) * (svgW.value - labelW - padR)
|
||||
}
|
||||
|
||||
const ticks = computed(() => {
|
||||
const dur = props.totalDuration || 6000
|
||||
const step = Math.max(30, Math.round(dur / 6 / 30) * 30)
|
||||
const tks: number[] = []
|
||||
for (let t = 0; t <= dur; t += step) tks.push(t)
|
||||
return tks
|
||||
})
|
||||
</script>
|
||||
33
portal/src/components/ServiceStatusCard.vue
Normal file
33
portal/src/components/ServiceStatusCard.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div :class="['rounded-lg p-3 border', bgColor]">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold">{{ name }}</span>
|
||||
<span :class="statusColor">{{ status === 'ok' ? '●' : '○' }}</span>
|
||||
</div>
|
||||
<div v-if="latency" class="text-xs text-gray-400 mt-1">{{ latency }}ms</div>
|
||||
<div v-if="error" class="text-xs text-red-400 mt-1 truncate">{{ error }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
name?: string
|
||||
status?: string
|
||||
latency?: number | null
|
||||
error?: string | null
|
||||
}>()
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (props.status === 'ok') return 'text-green-400'
|
||||
if (props.status === 'degraded') return 'text-yellow-400'
|
||||
return 'text-red-400'
|
||||
})
|
||||
|
||||
const bgColor = computed(() => {
|
||||
if (props.status === 'ok') return 'bg-green-900/20 border-green-700'
|
||||
if (props.status === 'degraded') return 'bg-yellow-900/20 border-yellow-700'
|
||||
return 'bg-red-900/20 border-red-700'
|
||||
})
|
||||
</script>
|
||||
354
portal/src/components/SpaceTimeCube.vue
Normal file
354
portal/src/components/SpaceTimeCube.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold mb-4 text-blue-400">V5: 3D Space-Time Cube</h3>
|
||||
<div class="text-xs text-gray-500 mb-3 flex gap-2 items-center">
|
||||
<span>X/Y = 畫面位置</span>
|
||||
<span>Z = 深度(bbox 大小)</span>
|
||||
<span>T = 時間</span>
|
||||
</div>
|
||||
|
||||
<!-- Trace selector -->
|
||||
<div class="flex gap-2 mb-3">
|
||||
<select v-model="selectedTraceId"
|
||||
class="bg-gray-700 text-white px-3 py-1.5 rounded text-sm flex-1">
|
||||
<option :value="null" disabled>選擇 Trace</option>
|
||||
<option v-for="t in traceOptions" :key="t.id"
|
||||
:value="t.id">{{ t.label }}</option>
|
||||
</select>
|
||||
<button @click="loadData"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-4 py-1.5 rounded text-sm"
|
||||
:disabled="!selectedTraceId || loading">
|
||||
{{ loading ? '載入中...' : '載入' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="container" class="w-full h-[400px] bg-gray-900 rounded-lg overflow-hidden"></div>
|
||||
|
||||
<div class="text-xs text-gray-500 mt-2 flex gap-4">
|
||||
<span>🖱 拖曳旋轉</span>
|
||||
<span>🔍 滾輪縮放</span>
|
||||
<span v-if="faceCount">{{ faceCount }} 個檢測點</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
import { httpFetch, getCurrentConfig } from '@/api/client'
|
||||
|
||||
const props = defineProps<{
|
||||
fileUuid: string
|
||||
traces?: any[]
|
||||
frameWidth?: number
|
||||
frameHeight?: number
|
||||
}>()
|
||||
|
||||
const container = ref<HTMLElement>()
|
||||
const selectedTraceId = ref<number | null>(null)
|
||||
const loading = ref(false)
|
||||
const faceCount = ref(0)
|
||||
|
||||
const traceOptions = computed(() => {
|
||||
return (props.traces || []).map((t: any) => ({
|
||||
id: t.trace_id,
|
||||
label: `#${t.trace_id} (${t.face_count} faces, ${(t.duration_sec || 0).toFixed(1)}s)`
|
||||
}))
|
||||
})
|
||||
|
||||
let renderer: THREE.WebGLRenderer | null = null
|
||||
let scene: THREE.Scene | null = null
|
||||
let camera: THREE.PerspectiveCamera | null = null
|
||||
let controls: OrbitControls | null = null
|
||||
let animId: number
|
||||
let objects: THREE.Object3D[] = []
|
||||
|
||||
function disposeScene() {
|
||||
cancelAnimationFrame(animId)
|
||||
for (const obj of objects) {
|
||||
scene?.remove(obj)
|
||||
if (obj instanceof THREE.Mesh || obj instanceof THREE.Points || obj instanceof THREE.Line) {
|
||||
obj.geometry?.dispose()
|
||||
const mat = (obj as any).material
|
||||
if (mat) {
|
||||
if (Array.isArray(mat)) mat.forEach((m: any) => m.dispose())
|
||||
else mat.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
objects = []
|
||||
controls?.dispose()
|
||||
controls = null
|
||||
if (renderer) {
|
||||
renderer.dispose()
|
||||
renderer = null
|
||||
}
|
||||
scene = null
|
||||
camera = null
|
||||
}
|
||||
|
||||
type FacePoint = {
|
||||
frame: number
|
||||
t: number
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
z: number
|
||||
}
|
||||
|
||||
function loadData() {
|
||||
if (!selectedTraceId.value) return
|
||||
loading.value = true
|
||||
|
||||
const config = getCurrentConfig()
|
||||
httpFetch(`${config.api_base_url}/api/v1/file/${props.fileUuid}/trace/${selectedTraceId.value}/faces?interpolate=true&limit=2000&dimension=3d`)
|
||||
.then((res: any) => {
|
||||
const faces = res?.faces || []
|
||||
const fw = props.frameWidth || 1920
|
||||
const fh = props.frameHeight || 1080
|
||||
|
||||
const points: FacePoint[] = faces.map((f: any) => {
|
||||
const w = f.width || 1
|
||||
const h = f.height || 1
|
||||
const areaPct = (w * h) / (fw * fh)
|
||||
const z = f.z_rel !== undefined && f.z_rel !== null
|
||||
? f.z_rel
|
||||
: 1.0 - Math.min(areaPct * 50, 1.0)
|
||||
return {
|
||||
frame: f.start_frame || 0,
|
||||
t: f.start_time || 0,
|
||||
x: f.x || 0,
|
||||
y: f.y || 0,
|
||||
w,
|
||||
h,
|
||||
z
|
||||
}
|
||||
})
|
||||
faceCount.value = points.length
|
||||
buildScene(points)
|
||||
})
|
||||
.catch((err: any) => {
|
||||
console.error('Failed to load trace faces:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function buildScene(points: FacePoint[]) {
|
||||
if (!container.value) return
|
||||
disposeScene()
|
||||
|
||||
// Normalize coordinates to [-1, 1] range
|
||||
const fw = props.frameWidth || 1920
|
||||
const fh = props.frameHeight || 1080
|
||||
const maxT = points.length > 0 ? points[points.length - 1].t : 100
|
||||
|
||||
const vertexData = points.map(p => ({
|
||||
x: (p.x / fw) * 2 - 1,
|
||||
y: -((p.y / fh) * 2 - 1),
|
||||
z: p.z * 2 - 1,
|
||||
t: (p.t / maxT) * 2 - 1
|
||||
}))
|
||||
|
||||
const rect = container.value.getBoundingClientRect()
|
||||
const w = rect.width || 600, h = rect.height || 400
|
||||
|
||||
scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(0x111827)
|
||||
|
||||
camera = new THREE.PerspectiveCamera(50, w / h, 0.1, 10)
|
||||
camera.position.set(2.5, 1.8, 3)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||
renderer.setSize(w, h)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
container.value.appendChild(renderer.domElement)
|
||||
|
||||
controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.enableDamping = true
|
||||
controls.dampingFactor = 0.08
|
||||
controls.target.set(0, 0, 0)
|
||||
controls.update()
|
||||
|
||||
// ---- Axes helper with labels ----
|
||||
const axesLen = 1.2
|
||||
const axesMat = (color: number) => new THREE.LineBasicMaterial({ color })
|
||||
|
||||
// X axis (red) — screen x
|
||||
const xLine = new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
|
||||
new THREE.Vector3(axesLen, -axesLen, -axesLen)
|
||||
]),
|
||||
axesMat(0xff4444)
|
||||
)
|
||||
scene.add(xLine)
|
||||
objects.push(xLine)
|
||||
|
||||
// Y axis (green) — screen y
|
||||
const yLine = new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
|
||||
new THREE.Vector3(-axesLen, axesLen, -axesLen)
|
||||
]),
|
||||
axesMat(0x44ff44)
|
||||
)
|
||||
scene.add(yLine)
|
||||
objects.push(yLine)
|
||||
|
||||
// Z axis (blue) — depth
|
||||
const zLine = new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
|
||||
new THREE.Vector3(-axesLen, -axesLen, axesLen)
|
||||
]),
|
||||
axesMat(0x4488ff)
|
||||
)
|
||||
scene.add(zLine)
|
||||
objects.push(zLine)
|
||||
|
||||
// T axis (yellow) — time (at an angle for 3D effect)
|
||||
const tLine = new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(-axesLen, -axesLen, -axesLen),
|
||||
new THREE.Vector3(axesLen, axesLen, axesLen)
|
||||
]),
|
||||
axesMat(0xffdd44)
|
||||
)
|
||||
scene.add(tLine)
|
||||
objects.push(tLine)
|
||||
|
||||
// ---- Cube wireframe ----
|
||||
const cubeSize = axesLen * 2
|
||||
const cubeGeo = new THREE.BoxGeometry(cubeSize, cubeSize, cubeSize)
|
||||
const cubeWire = new THREE.LineSegments(
|
||||
new THREE.EdgesGeometry(cubeGeo),
|
||||
new THREE.LineBasicMaterial({ color: 0x444466, transparent: true, opacity: 0.3 })
|
||||
)
|
||||
cubeWire.position.set(0, 0, 0)
|
||||
scene.add(cubeWire)
|
||||
objects.push(cubeWire)
|
||||
|
||||
// ---- Points: color by time (t) ----
|
||||
if (vertexData.length > 0) {
|
||||
const positions = new Float32Array(vertexData.length * 3)
|
||||
const colors = new Float32Array(vertexData.length * 3)
|
||||
const color = new THREE.Color()
|
||||
|
||||
for (let i = 0; i < vertexData.length; i++) {
|
||||
const p = vertexData[i]
|
||||
// Position: (x, y, z) with time as movement along diagonal
|
||||
positions[i * 3] = p.x
|
||||
positions[i * 3 + 1] = p.y
|
||||
positions[i * 3 + 2] = p.t * 0.5 // compress time a bit
|
||||
|
||||
// Color gradient: blue (early) → cyan → yellow → red (late)
|
||||
const tNorm = (p.t + 1) / 2 // 0..1
|
||||
color.setHSL(0.6 - tNorm * 0.6, 0.9, 0.5)
|
||||
colors[i * 3] = color.r
|
||||
colors[i * 3 + 1] = color.g
|
||||
colors[i * 3 + 2] = color.b
|
||||
}
|
||||
|
||||
const ptGeo = new THREE.BufferGeometry()
|
||||
ptGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
ptGeo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
|
||||
|
||||
const ptMat = new THREE.PointsMaterial({
|
||||
size: 0.03,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
sizeAttenuation: true
|
||||
})
|
||||
const pointsObj = new THREE.Points(ptGeo, ptMat)
|
||||
scene.add(pointsObj)
|
||||
objects.push(pointsObj)
|
||||
|
||||
// ---- Trajectory line ----
|
||||
const linePositions = new Float32Array(vertexData.length * 3)
|
||||
for (let i = 0; i < vertexData.length; i++) {
|
||||
const p = vertexData[i]
|
||||
linePositions[i * 3] = p.x
|
||||
linePositions[i * 3 + 1] = p.y
|
||||
linePositions[i * 3 + 2] = p.t * 0.5
|
||||
}
|
||||
const lineGeo = new THREE.BufferGeometry()
|
||||
lineGeo.setAttribute('position', new THREE.BufferAttribute(linePositions, 3))
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: 0x88ccff,
|
||||
transparent: true,
|
||||
opacity: 0.35
|
||||
})
|
||||
const line = new THREE.Line(lineGeo, lineMat)
|
||||
scene.add(line)
|
||||
objects.push(line)
|
||||
}
|
||||
|
||||
// ---- Lights ----
|
||||
const ambient = new THREE.AmbientLight(0x404060)
|
||||
scene.add(ambient)
|
||||
const dir = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||
dir.position.set(1, 2, 1)
|
||||
scene.add(dir)
|
||||
|
||||
// ---- Grid helper (subtle) ----
|
||||
const gridHelper = new THREE.GridHelper(2.5, 10, 0x444466, 0x333355)
|
||||
gridHelper.position.y = -axesLen - 0.05
|
||||
scene.add(gridHelper)
|
||||
objects.push(gridHelper)
|
||||
|
||||
// Resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (!container.value || !renderer || !camera) return
|
||||
const r = container.value.getBoundingClientRect()
|
||||
const rw = r.width || 600, rh = r.height || 400
|
||||
renderer.setSize(rw, rh)
|
||||
camera.aspect = rw / rh
|
||||
camera.updateProjectionMatrix()
|
||||
})
|
||||
resizeObserver.observe(container.value)
|
||||
;(container.value as any).__resizeObserver = resizeObserver
|
||||
|
||||
animate()
|
||||
|
||||
// Notify demo runner via callback URL if present
|
||||
const cb = new URLSearchParams(window.location.search).get("_callback")
|
||||
if (cb) {
|
||||
fetch(cb, { mode: "no-cors" }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function animate() {
|
||||
animId = requestAnimationFrame(animate)
|
||||
controls?.update()
|
||||
if (renderer && scene && camera) renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
tryAutoLoad()
|
||||
})
|
||||
|
||||
watch(() => props.traces, () => {
|
||||
tryAutoLoad()
|
||||
}, { deep: false })
|
||||
|
||||
function tryAutoLoad() {
|
||||
if (props.traces?.length && !selectedTraceId.value && !loading.value) {
|
||||
selectedTraceId.value = props.traces[0].trace_id
|
||||
loadData()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animId)
|
||||
if ((container.value as any)?.__resizeObserver) {
|
||||
(container.value as any).__resizeObserver.disconnect()
|
||||
}
|
||||
disposeScene()
|
||||
})
|
||||
</script>
|
||||
41
portal/src/components/TraceDurationHistogram.vue
Normal file
41
portal/src/components/TraceDurationHistogram.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-300">持續時間分布圖 V3</h3>
|
||||
<span class="text-xs text-gray-500">{{ traces.length }} traces</span>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded p-4" v-if="bars.length">
|
||||
<svg :width="barW * bars.length + 4" :height="maxH + 30" class="block">
|
||||
<g v-for="(b, i) in bars" :key="i">
|
||||
<rect :x="i * barW + 2" :y="maxH - b.h" :width="barW - 4" :height="b.h" fill="#4488ff" :opacity="0.6 + 0.4 * b.r" rx="2" />
|
||||
<text :x="i * barW + barW / 2" :y="maxH - b.h - 4" fill="#9ca3af" font-size="9" text-anchor="middle" v-if="b.c > 0">{{ b.c }}</text>
|
||||
<text :x="i * barW + barW / 2" :y="maxH + 16" fill="#6b7280" font-size="8" text-anchor="middle">{{ b.l }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="text-center text-gray-500 text-sm py-8">no data</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ traces: any[] }>()
|
||||
const barW = 44
|
||||
const maxBars = 20
|
||||
const maxH = 200
|
||||
|
||||
const bars = computed(() => {
|
||||
const d = props.traces.map((t: any) => t.duration_sec).filter((x: number) => x > 0)
|
||||
if (!d.length) return []
|
||||
const mx = Math.ceil(Math.max(...d))
|
||||
const st = Math.max(1, Math.ceil(mx / maxBars))
|
||||
const bins: { l: string; c: number; r: number; h: number }[] = []
|
||||
for (let s = 0; s <= mx; s += st) {
|
||||
bins.push({ l: `${s}-${s + st}s`, c: d.filter((x: number) => x >= s && x < s + st).length, r: 0, h: 0 })
|
||||
}
|
||||
const mc = Math.max(...bins.map(b => b.c), 1)
|
||||
bins.forEach(b => { b.r = b.c / mc; b.h = Math.max(4, b.r * maxH) })
|
||||
return bins
|
||||
})
|
||||
</script>
|
||||
63
portal/src/components/TraceSimilarityMatrix.vue
Normal file
63
portal/src/components/TraceSimilarityMatrix.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-300">相似度矩陣 V4</h3>
|
||||
<span class="text-xs text-gray-500">{{ traces.length }} traces</span>
|
||||
</div>
|
||||
<div class="bg-gray-900 rounded p-4 overflow-x-auto" v-if="matrix.length">
|
||||
<svg :width="cellS * matrix.length + 60" :height="cellS * matrix.length + 40" class="block">
|
||||
<!-- labels -->
|
||||
<text v-for="(_, i) in matrix" :key="'l'+i"
|
||||
:x="cellS * i + cellS / 2 + 50" :y="14" fill="#9ca3af" font-size="7" text-anchor="end"
|
||||
transform="rotate(-60, 10, 10)">{{ traces[i]?.trace_id }}</text>
|
||||
<text v-for="(_, i) in matrix" :key="'r'+i"
|
||||
:x="44" :y="cellS * i + cellS / 2 + 24" fill="#9ca3af" font-size="7">{{ traces[i]?.trace_id }}</text>
|
||||
<!-- cells -->
|
||||
<g v-for="(row, i) in matrix" :key="i">
|
||||
<rect v-for="(v, j) in row" :key="j"
|
||||
:x="cellS * j + 50" :y="cellS * i + 20"
|
||||
:width="cellS" :height="cellS"
|
||||
:fill="color(v)" stroke="#374151" stroke-width="0.5" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="text-center text-gray-500 text-sm py-8">no data</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ traces: any[] }>()
|
||||
const cellS = 14
|
||||
const maxN = 40
|
||||
|
||||
const matrix = computed(() => {
|
||||
const t = props.traces.slice(0, maxN)
|
||||
if (t.length < 2) return []
|
||||
const durs = t.map(x => x.duration_sec || 0)
|
||||
const cnt = t.map(x => x.face_count || 0)
|
||||
const maxDur = Math.max(...durs, 1)
|
||||
const maxCnt = Math.max(...cnt, 1)
|
||||
const m: number[][] = []
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
const row: number[] = []
|
||||
for (let j = 0; j < t.length; j++) {
|
||||
if (i === j) { row.push(1); continue }
|
||||
// Simple similarity: duration + face_count proximity
|
||||
const durSim = 1 - Math.abs(durs[i] - durs[j]) / maxDur
|
||||
const cntSim = 1 - Math.abs(cnt[i] - cnt[j]) / maxCnt
|
||||
row.push((durSim + cntSim) / 2)
|
||||
}
|
||||
m.push(row)
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
function color(v: number): string {
|
||||
if (v > 0.85) return 'rgba(68, 255, 68, 0.8)' // bright green = similar
|
||||
if (v > 0.7) return 'rgba(68, 200, 68, 0.6)'
|
||||
if (v > 0.5) return 'rgba(100, 100, 100, 0.4)'
|
||||
return 'rgba(40, 40, 50, 0.3)' // dark = dissimilar
|
||||
}
|
||||
</script>
|
||||
86
portal/src/components/TraceThumbnailTimeline.vue
Normal file
86
portal/src/components/TraceThumbnailTimeline.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-300">臉孔縮圖時間軸 V1</h3>
|
||||
<span class="text-xs text-gray-500">{{ traces.length }} traces</span>
|
||||
</div>
|
||||
<div class="relative overflow-x-auto py-4" ref="scrollRef">
|
||||
<svg :width="svgW" :height="80" class="block">
|
||||
<!-- time axis -->
|
||||
<line x1="0" y1="70" :x2="svgW" y2="70" stroke="#4b5563" stroke-width="1" />
|
||||
<!-- time ticks -->
|
||||
<g v-for="t in ticks" :key="t">
|
||||
<line :x1="xPos(t)" y1="66" :x2="xPos(t)" y2="70" stroke="#6b7280" stroke-width="1" />
|
||||
<text :x="xPos(t)" y="78" fill="#9ca3af" font-size="9" text-anchor="middle">{{ t }}s</text>
|
||||
</g>
|
||||
<!-- trace thumbnails -->
|
||||
<g v-for="(tr, idx) in topTraces" :key="tr.trace_id">
|
||||
<image
|
||||
:x="thumbX(tr, idx)" :y="thumbY(idx)"
|
||||
:width="thumbSize" :height="thumbSize"
|
||||
:href="thumbUrl(tr)"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
class="cursor-pointer hover:opacity-80"
|
||||
@click="$emit('select', tr.trace_id)"
|
||||
/>
|
||||
<text
|
||||
:x="thumbX(tr, idx) + thumbSize / 2" :y="thumbY(idx) + thumbSize + 10"
|
||||
fill="#9ca3af" font-size="8" text-anchor="middle"
|
||||
>#{{ tr.trace_id }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { getCurrentConfig } from '@/api/client'
|
||||
|
||||
const props = defineProps<{
|
||||
fileUuid: string
|
||||
traces: any[]
|
||||
totalDuration: number
|
||||
}>()
|
||||
|
||||
defineEmits<{ select: [traceId: number] }>()
|
||||
|
||||
const config = getCurrentConfig()
|
||||
const thumbSize = 40
|
||||
const maxTraces = 15
|
||||
const timelinePad = 60
|
||||
|
||||
const topTraces = computed(() =>
|
||||
[...props.traces].sort((a, b) => b.face_count - a.face_count).slice(0, maxTraces)
|
||||
)
|
||||
|
||||
const svgW = computed(() => {
|
||||
const dur = props.totalDuration || 6000
|
||||
return Math.max(600, timelinePad * 2 + dur / 10)
|
||||
})
|
||||
|
||||
function xPos(sec: number): number {
|
||||
const dur = props.totalDuration || 6000
|
||||
return timelinePad + (sec / dur) * (svgW.value - timelinePad * 2)
|
||||
}
|
||||
|
||||
function thumbY(_index: number): number {
|
||||
return 15 + (_index % 3) * (thumbSize + 8)
|
||||
}
|
||||
|
||||
function thumbX(tr: any, _index: number): number {
|
||||
return xPos((tr.first_sec + tr.last_sec) / 2) - thumbSize / 2
|
||||
}
|
||||
|
||||
function thumbUrl(tr: any): string {
|
||||
return `${config.api_base_url}/api/v1/file/${props.fileUuid}/thumbnail?frame=${tr.first_frame}`
|
||||
}
|
||||
|
||||
const ticks = computed(() => {
|
||||
const dur = props.totalDuration || 6000
|
||||
const step = Math.max(30, Math.round(dur / 8 / 30) * 30)
|
||||
const tks: number[] = []
|
||||
for (let t = 0; t <= dur; t += step) tks.push(t)
|
||||
return tks
|
||||
})
|
||||
</script>
|
||||
16
portal/src/test-setup.ts
Normal file
16
portal/src/test-setup.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { vi } from 'vitest'
|
||||
|
||||
const mockStorage = new Map<string, string>()
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: {
|
||||
getItem: (key: string) => mockStorage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => mockStorage.set(key, String(value)),
|
||||
removeItem: (key: string) => mockStorage.delete(key),
|
||||
clear: () => mockStorage.clear(),
|
||||
get length() { return mockStorage.size },
|
||||
key: (index: number) => [...mockStorage.keys()][index] ?? null,
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
184
portal/src/views/LoginView.test.ts
Normal file
184
portal/src/views/LoginView.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
const mockReplace = vi.fn()
|
||||
const mockUseRoute = vi.fn(() => ({
|
||||
query: { username: '', password: '' },
|
||||
path: '/login',
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush, replace: mockReplace }),
|
||||
useRoute: mockUseRoute,
|
||||
}))
|
||||
|
||||
const mockHttpFetch = vi.fn()
|
||||
vi.mock('@/api/client', () => ({
|
||||
httpFetch: mockHttpFetch,
|
||||
getCurrentConfig: () => ({
|
||||
api_base_url: 'http://localhost:3003',
|
||||
api_key: '',
|
||||
timeout_secs: 30,
|
||||
}),
|
||||
saveConfig: vi.fn(),
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
mockUseRoute.mockReturnValue({
|
||||
query: { username: '', password: '' },
|
||||
path: '/login',
|
||||
})
|
||||
})
|
||||
|
||||
async function createWrapper() {
|
||||
const { default: LoginView } = await import('./LoginView.vue')
|
||||
return mount(LoginView, {
|
||||
attachTo: document.body,
|
||||
})
|
||||
}
|
||||
|
||||
describe('LoginView', () => {
|
||||
it('renders login form', async () => {
|
||||
const wrapper = await createWrapper()
|
||||
expect(wrapper.find('h1').text()).toBe('Momentry')
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
||||
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Login')
|
||||
})
|
||||
|
||||
it('updates username and password on input', async () => {
|
||||
const wrapper = await createWrapper()
|
||||
const usernameInput = wrapper.find('input[type="text"]')
|
||||
const passwordInput = wrapper.find('input[type="password"]')
|
||||
|
||||
await usernameInput.setValue('demo')
|
||||
await passwordInput.setValue('secret')
|
||||
|
||||
expect((usernameInput.element as HTMLInputElement).value).toBe('demo')
|
||||
expect((passwordInput.element as HTMLInputElement).value).toBe('secret')
|
||||
})
|
||||
|
||||
it('toggles password visibility', async () => {
|
||||
const wrapper = await createWrapper()
|
||||
const toggleBtn = wrapper.find('button[type="button"]')
|
||||
const passwordInput = wrapper.find('input[type="password"]')
|
||||
|
||||
expect(passwordInput.attributes('type')).toBe('password')
|
||||
await toggleBtn.trigger('click')
|
||||
expect(passwordInput.attributes('type')).toBe('text')
|
||||
await toggleBtn.trigger('click')
|
||||
expect(passwordInput.attributes('type')).toBe('password')
|
||||
})
|
||||
|
||||
it('shows loading state on submit', async () => {
|
||||
mockHttpFetch.mockImplementation(() => new Promise(() => {}))
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('demo')
|
||||
await wrapper.find('input[type="password"]').setValue('demo')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').text()).toBe('Logging in...')
|
||||
expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows error on login failure with message', async () => {
|
||||
mockHttpFetch.mockResolvedValue({ success: false, message: 'Account disabled' })
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('demo')
|
||||
await wrapper.find('input[type="password"]').setValue('demo')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Account disabled')
|
||||
})
|
||||
|
||||
it('shows generic error for 401', async () => {
|
||||
mockHttpFetch.mockRejectedValue(new Error('401 Unauthorized'))
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('bad')
|
||||
await wrapper.find('input[type="password"]').setValue('creds')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Invalid username or password')
|
||||
})
|
||||
|
||||
it('shows connection error on network failure', async () => {
|
||||
mockHttpFetch.mockRejectedValue(new Error('NetworkError'))
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('demo')
|
||||
await wrapper.find('input[type="password"]').setValue('demo')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.text()).toContain('Connection error')
|
||||
})
|
||||
|
||||
it('redirects on successful login', async () => {
|
||||
mockHttpFetch.mockResolvedValue({
|
||||
success: true,
|
||||
api_key: 'test_key_123',
|
||||
user: { name: 'demo', role: 'admin' },
|
||||
})
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('admin')
|
||||
await wrapper.find('input[type="password"]').setValue('admin')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(localStorage.getItem('momentry_user')).toBe(
|
||||
JSON.stringify({ name: 'demo', role: 'admin' }),
|
||||
)
|
||||
expect(localStorage.getItem('momentry_api_key')).toBe('test_key_123')
|
||||
expect(mockPush).toHaveBeenCalledWith('/home')
|
||||
})
|
||||
|
||||
it('redirects to original redirect path after login', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
query: { redirect: '/search?q=test' },
|
||||
path: '/login',
|
||||
})
|
||||
mockHttpFetch.mockResolvedValue({
|
||||
success: true,
|
||||
api_key: 'key',
|
||||
user: { name: 'demo' },
|
||||
})
|
||||
const wrapper = await createWrapper()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('demo')
|
||||
await wrapper.find('input[type="password"]').setValue('demo')
|
||||
await wrapper.find('form').trigger('submit.prevent')
|
||||
await nextTick()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/search?q=test')
|
||||
})
|
||||
|
||||
it('auto-submits when query params are present', async () => {
|
||||
mockUseRoute.mockReturnValue({
|
||||
query: { username: 'auto', password: 'login' },
|
||||
path: '/login',
|
||||
})
|
||||
mockHttpFetch.mockResolvedValue({
|
||||
success: true,
|
||||
api_key: 'auto_key',
|
||||
user: { name: 'auto_user' },
|
||||
})
|
||||
|
||||
await createWrapper()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
|
||||
expect(mockHttpFetch).toHaveBeenCalled()
|
||||
expect(localStorage.getItem('momentry_api_key')).toBe('auto_key')
|
||||
})
|
||||
})
|
||||
11
portal/src/views/NotFoundView.vue
Normal file
11
portal/src/views/NotFoundView.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-[60vh] text-center">
|
||||
<div class="text-8xl font-bold text-gray-600 mb-4">404</div>
|
||||
<h2 class="text-2xl font-semibold text-gray-300 mb-2">頁面不存在</h2>
|
||||
<p class="text-gray-500 mb-8">您要尋找的頁面不存在或已被移除</p>
|
||||
<router-link to="/home"
|
||||
class="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2 rounded-lg transition">
|
||||
回到首頁
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
370
portal/src/views/PipelineProgressView.vue
Normal file
370
portal/src/views/PipelineProgressView.vue
Normal file
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 text-gray-100 p-6">
|
||||
|
||||
<div v-if="loading" class="text-center py-12"><p class="text-gray-400">載入中...</p></div>
|
||||
<div v-else-if="error" class="bg-red-900/50 border border-red-700 rounded p-4 mb-4">
|
||||
<p class="text-red-300">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- ═══ 頂部:標題 + 篩選 + 搜尋 ═══ -->
|
||||
<div class="flex flex-wrap items-center justify-between mb-4 gap-3">
|
||||
<h1 class="text-2xl font-bold">📋 檔案歷程</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 狀態篩選 -->
|
||||
<button v-for="f in filterOptions" :key="f.key"
|
||||
@click="activeFilter = f.key"
|
||||
class="px-3 py-1 rounded text-sm transition"
|
||||
:class="activeFilter === f.key ? 'bg-blue-700 text-white' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'">
|
||||
{{ f.label }}
|
||||
</button>
|
||||
<!-- 搜尋 -->
|
||||
<input v-model="searchQuery" placeholder="搜尋 UUID 或檔名..."
|
||||
class="bg-gray-700 border border-gray-600 rounded px-3 py-1.5 text-sm w-48 focus:border-blue-500 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Job 清單(摺疊) ═══ -->
|
||||
<div class="bg-gray-800 rounded-lg mb-4 overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700 text-xs">
|
||||
<th class="text-left py-2 px-3 w-12">#</th>
|
||||
<th class="text-left py-2">檔案名稱</th>
|
||||
<th class="text-left py-2 w-16">狀態</th>
|
||||
<th class="text-left py-2 w-20">時間</th>
|
||||
<th class="text-left py-2 w-16">進度</th>
|
||||
<th class="text-left py-2 w-12"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in filteredJobs" :key="job.id"
|
||||
@click="selectedId = job.id"
|
||||
class="border-b border-gray-700/30 cursor-pointer transition"
|
||||
:class="selectedId === job.id ? 'bg-blue-900/30' : 'hover:bg-gray-700/30'">
|
||||
<td class="py-2 px-3 font-mono text-xs text-gray-500">{{ job.id }}</td>
|
||||
<td class="py-2 truncate max-w-64">{{ job.file_name || '未知' }}</td>
|
||||
<td class="py-2"><span :class="statusBadge(job.status)" class="px-2 py-0.5 rounded text-xs">{{ job.status }}</span></td>
|
||||
<td class="py-2 font-mono text-xs text-gray-400">{{ job.createdAt || '-' }}</td>
|
||||
<td class="py-2">{{ completedCount(job) }}/{{ job.processorList?.length || 0 }}</td>
|
||||
<td class="py-2 text-xs text-gray-500">{{ selectedId === job.id ? '◀' : '▶' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 選中的 Job 詳細資料 ═══ -->
|
||||
<div v-if="selectedJob">
|
||||
<!-- ① 檔案基本資料 -->
|
||||
<div class="bg-gray-800 rounded-lg p-5 mb-4">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
{{ selectedJob.file_name || '未知檔案' }}
|
||||
<span :class="statusBadge(selectedJob.status)" class="px-2 py-0.5 rounded text-xs">{{ selectedJob.status }}</span>
|
||||
</h2>
|
||||
<p class="text-gray-400 text-xs mt-1 font-mono">UUID: {{ selectedJob.uuid || '-' }}</p>
|
||||
</div>
|
||||
<div class="text-right text-xs text-gray-500">
|
||||
<div>Job #{{ selectedJob.id }}</div>
|
||||
<div v-if="selectedJob.metadata && selectedJob.metadata['duration']">{{ Math.round(selectedJob.metadata['duration']/60) }}min</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedJob.metadata" class="grid grid-cols-4 gap-3 text-sm bg-gray-900/50 rounded p-3 mb-3">
|
||||
<div><span class="text-gray-500">長度</span><br>{{ selectedJob.metadata['duration'] ? Math.round(selectedJob.metadata['duration']) + 's' : '-' }}</div>
|
||||
<div><span class="text-gray-500">解析度</span><br>{{ selectedJob.metadata['width'] || '?' }}x{{ selectedJob.metadata['height'] || '?' }}</div>
|
||||
<div><span class="text-gray-500">FPS</span><br>{{ selectedJob.metadata['fps'] || '?' }}</div>
|
||||
<div><span class="text-gray-500">總幀數</span><br>{{ selectedJob.metadata['total_frames'] || '?' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 flex-wrap mb-3" v-if="selectedJob.uuid">
|
||||
<a :href="baseURL + '/api/v1/file/' + selectedJob.uuid + '/video'" target="_blank" class="px-3 py-1 bg-blue-700 hover:bg-blue-600 rounded text-xs">🎬 串流</a>
|
||||
<a :href="baseURL + '/api/v1/file/' + selectedJob.uuid + '/thumbnail?frame=0'" target="_blank" class="px-3 py-1 bg-green-700 hover:bg-green-600 rounded text-xs">🖼️ 縮圖</a>
|
||||
<router-link :to="'/search?uuid=' + selectedJob.uuid" class="px-3 py-1 bg-purple-700 hover:bg-purple-600 rounded text-xs">🔍 搜尋</router-link>
|
||||
</div>
|
||||
|
||||
<!-- ② 時間軸 -->
|
||||
<div v-if="selectedJob.timeline && selectedJob.timeline.length" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">⏱️ 處理時間軸</h3>
|
||||
<div class="relative h-8 bg-gray-900 rounded overflow-hidden">
|
||||
<div v-for="(seg, i) in selectedJob.timeline" :key="i"
|
||||
:title="seg.label + ': ' + seg.duration"
|
||||
class="absolute h-full flex items-center justify-center text-xs font-bold text-white truncate"
|
||||
:style="{ left: seg.left + '%', width: seg.width + '%', background: seg.color }">
|
||||
{{ seg.width > 8 ? seg.label : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-1 text-xs text-gray-500 flex-wrap">
|
||||
<span v-for="(seg, i) in selectedJob.timeline" :key="'l'+i"><span :style="{ color: seg.color }">●</span> {{ seg.label }} ({{ seg.duration }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ③ Processors -->
|
||||
<table class="w-full text-sm mb-3">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700">
|
||||
<th class="text-left py-2 w-20">Proc</th>
|
||||
<th class="text-left py-2 w-10">St</th>
|
||||
<th class="text-left py-2 w-14">Start</th>
|
||||
<th class="text-left py-2 w-14">End</th>
|
||||
<th class="text-left py-2 w-16">耗時</th>
|
||||
<th class="text-right py-2">已產出</th>
|
||||
<th class="text-right py-2">已處理</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="p in selectedJob.processorList" :key="p.name" class="border-b border-gray-700/50 hover:bg-gray-700/30">
|
||||
<td class="py-1.5 font-mono text-sm">{{ p.name }}</td>
|
||||
<td class="py-1.5">{{ statusIcon(p.status) }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.start }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.end }}</td>
|
||||
<td class="py-1.5 font-mono text-xs text-gray-400">{{ p.duration || '-' }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-sm">{{ p.chunks ?? '-' }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-sm">{{ p.frames ?? '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="text-xs text-gray-500 mb-3">已處理 {{ completedCount(selectedJob) }}/{{ selectedJob.processorList?.length || 0 }}</div>
|
||||
|
||||
<!-- ④ Post-Processing -->
|
||||
<div v-if="selectedJob.postProcessing" class="mb-4">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">⚙️ Post-Processing</h3>
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-gray-400 border-b border-gray-700">
|
||||
<th class="text-left py-2">Stage</th>
|
||||
<th class="text-left py-2 w-10">St</th>
|
||||
<th class="text-right py-2 w-16">已產出</th>
|
||||
<th class="text-left py-2 pl-4">依賴進度狀態</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="pp in selectedJob.postProcessing" :key="pp.stage" class="border-b border-gray-700/50 hover:bg-gray-700/30">
|
||||
<td class="py-1.5 text-sm">{{ pp.stage }}</td>
|
||||
<td class="py-1.5">{{ statusIcon(pp.status) }}</td>
|
||||
<td class="py-1.5 text-right font-mono text-xs text-gray-400">{{ pp.output || '-' }}</td>
|
||||
<td class="py-1.5 pl-4 font-mono text-xs text-gray-400">{{ pp.deps }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ⑤ Resources -->
|
||||
<div v-if="selectedJob.processorList.some(p => p.version)" class="mb-2">
|
||||
<h3 class="text-sm font-semibold text-gray-300 mb-2">🔧 Resources</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
|
||||
<div v-for="p in selectedJob.processorList.filter(p => p.version)" :key="p.name" class="bg-gray-900/50 rounded p-2 text-xs">
|
||||
<div class="text-gray-400">{{ p.name }}</div>
|
||||
<div class="font-mono text-gray-300 truncate">{{ p.version }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 無匹配 -->
|
||||
<div v-if="filteredJobs.length === 0" class="text-center py-12 text-gray-500">無符合條件的檔案記錄</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { httpFetch } from '@/api/client'
|
||||
|
||||
interface ProcessorInfo {
|
||||
name: string; status: string; start: string; end: string; duration: string
|
||||
chunks: number; frames: number; version: string
|
||||
}
|
||||
interface PostProcessInfo { stage: string; status: string; output: string; deps: string }
|
||||
interface TimelineSeg { label: string; left: number; width: number; color: string; duration: string }
|
||||
interface JobInfo {
|
||||
id: number; uuid: string; status: string; file_name: string; createdAt: string
|
||||
metadata: any
|
||||
timeline: TimelineSeg[]
|
||||
processorList: ProcessorInfo[]
|
||||
postProcessing: PostProcessInfo[]
|
||||
}
|
||||
|
||||
const baseURL = JSON.parse(localStorage.getItem('portal_config') || '{}').api_base_url || 'http://127.0.0.1:3003'
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const jobs = ref<JobInfo[]>([])
|
||||
const activeFilter = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const selectedId = ref<number | null>(null)
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const filterOptions = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'running', label: '⏳ Running' },
|
||||
{ key: 'completed', label: '✅ Completed' },
|
||||
{ key: 'failed', label: '❌ Failed' },
|
||||
]
|
||||
|
||||
const filteredJobs = computed(() => {
|
||||
let list = jobs.value
|
||||
if (activeFilter.value !== 'all') {
|
||||
list = list.filter(j => j.status === activeFilter.value)
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
list = list.filter(j =>
|
||||
(j.file_name && j.file_name.toLowerCase().includes(q)) ||
|
||||
(j.uuid && j.uuid.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const selectedJob = computed(() => {
|
||||
return jobs.value.find(j => j.id === selectedId.value) || null
|
||||
})
|
||||
|
||||
const procColors: Record<string, string> = {
|
||||
cut: '#3b82f6', face: '#10b981', ocr: '#f59e0b',
|
||||
pose: '#8b5cf6', yolo: '#ef4444', asr: '#06b6d4', asrx: '#ec4899'
|
||||
}
|
||||
|
||||
function statusIcon(st: string): string {
|
||||
return ({ completed: '✅', running: '⏳', pending: '⬜', failed: '❌', skipped: '⏭️' })[st] || '⬜'
|
||||
}
|
||||
function statusBadge(st: string): string {
|
||||
return ({
|
||||
completed: 'bg-green-700 text-green-200', running: 'bg-blue-700 text-blue-200',
|
||||
failed: 'bg-red-700 text-red-200'
|
||||
})[st] || 'bg-gray-600 text-gray-300'
|
||||
}
|
||||
function completedCount(job: JobInfo): number {
|
||||
return job.processorList?.filter(p => p.status === 'completed').length || 0
|
||||
}
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '-'
|
||||
try { return new Date(iso).toTimeString().substring(0, 5) }
|
||||
catch { return iso.substring(11, 16) }
|
||||
}
|
||||
function formatDuration(secs: number): string {
|
||||
if (!secs || secs <= 0) return '-'
|
||||
if (secs < 60) return Math.round(secs) + 's'
|
||||
return Math.floor(secs / 60) + 'm ' + Math.round(secs % 60) + 's'
|
||||
}
|
||||
function formatDateTime(iso: string): string {
|
||||
if (!iso) return '-'
|
||||
try { return new Date(iso).toLocaleString('zh-TW', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) }
|
||||
catch { return iso.substring(5, 16) }
|
||||
}
|
||||
|
||||
async function loadJobs() {
|
||||
try {
|
||||
const resp: any = await httpFetch(`${baseURL}/api/v1/jobs`)
|
||||
const rawJobs = resp?.jobs || []
|
||||
const result: JobInfo[] = []
|
||||
|
||||
for (const j of (Array.isArray(rawJobs) ? rawJobs : []).slice(-10)) {
|
||||
const jobId = j.id
|
||||
const uuid = j.uuid || ''
|
||||
let processors: ProcessorInfo[] = []
|
||||
let postProcessing: PostProcessInfo[] = []
|
||||
let fileName = ''
|
||||
let fileMeta: Record<string, any> | null = null
|
||||
let timeline: TimelineSeg[] = []
|
||||
|
||||
if (uuid) {
|
||||
try {
|
||||
// Fetch file probe
|
||||
const probe: any = await httpFetch(`${baseURL}/api/v1/file/${uuid}/probe`)
|
||||
fileMeta = probe || null
|
||||
fileName = probe?.file_name || fileName
|
||||
|
||||
// Fetch progress
|
||||
const prog: any = await httpFetch(`${baseURL}/api/v1/progress/${uuid}`)
|
||||
fileName = prog?.file_name || fileName
|
||||
|
||||
const procMap: Record<string, any> = {}
|
||||
for (const p of (prog?.processors || [])) procMap[p.name] = p
|
||||
|
||||
const procOrder = ['cut', 'face', 'ocr', 'pose', 'yolo', 'asr', 'asrx']
|
||||
const parsed: { name: string; start: number; end: number; status: string }[] = []
|
||||
|
||||
for (const name of procOrder) {
|
||||
const p = procMap[name] || { status: 'pending' }
|
||||
const startStr = p.started_at || ''
|
||||
const endStr = p.completed_at || ''
|
||||
const startMs = startStr ? new Date(startStr).getTime() : 0
|
||||
const endMs = endStr ? new Date(endStr).getTime() : (startMs || 0)
|
||||
const dur = (endMs && endMs >= startMs) ? (endMs - startMs) / 1000 : 0
|
||||
|
||||
processors.push({
|
||||
name, status: p.status,
|
||||
start: formatTime(startStr),
|
||||
end: formatTime(endStr),
|
||||
duration: formatDuration(dur),
|
||||
chunks: p.chunks_produced ?? 0,
|
||||
frames: p.frames_processed ?? 0,
|
||||
version: p.version || ''
|
||||
})
|
||||
if (startMs && startStr) {
|
||||
parsed.push({ name, start: startMs, end: endMs || Date.now(), status: p.status })
|
||||
}
|
||||
}
|
||||
|
||||
// Build timeline
|
||||
if (parsed.length > 0) {
|
||||
const minT = Math.min(...parsed.map(p => p.start))
|
||||
const maxT = Math.max(...parsed.map(p => p.end === Date.now() ? Date.now() : p.end))
|
||||
const range = maxT - minT || 1
|
||||
for (const p of parsed) {
|
||||
timeline.push({
|
||||
label: p.name,
|
||||
left: ((p.start - minT) / range) * 100,
|
||||
width: Math.max(((p.end - p.start) / range) * 100, 3),
|
||||
color: procColors[p.name] || '#6b7280',
|
||||
duration: formatDuration((p.end - p.start) / 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Post-processing deps
|
||||
const allDone = processors.every(p => p.status === 'completed')
|
||||
const S = (n: string) => statusIcon(procMap[n]?.status || 'pending')
|
||||
postProcessing = [
|
||||
{ stage: 'Rule 1 chunks', status: allDone ? 'running' : 'pending', output: '-', deps: `ASR${S('asr')} + ASRX${S('asrx')}` },
|
||||
{ stage: 'face_trace', status: allDone ? 'running' : 'pending', output: '-', deps: `cut${S('cut')} face${S('face')} ocr${S('ocr')} pose${S('pose')} yolo${S('yolo')} asr${S('asr')} asrx${S('asrx')}` },
|
||||
{ stage: 'Qdrant face sync', status: 'pending', output: '-', deps: 'face_trace⬜' },
|
||||
{ stage: 'Qdrant voice', status: 'pending', output: '-', deps: `ASRX${S('asrx')} (inline)` },
|
||||
{ stage: 'ANE vectorize', status: 'pending', output: '-', deps: 'Rule 1 chunks⬜' },
|
||||
{ stage: '5W1H Agent', status: 'pending', output: '-', deps: 'Rule 1⬜ + Rule 3⬜' },
|
||||
]
|
||||
} catch (e) { console.warn(`skip ${uuid}:`, e) }
|
||||
}
|
||||
|
||||
result.push({
|
||||
id: jobId, uuid, status: j.status || 'unknown', file_name: fileName,
|
||||
createdAt: j.created_at ? formatDateTime(j.created_at) : '',
|
||||
metadata: fileMeta, timeline, processorList: processors, postProcessing
|
||||
})
|
||||
}
|
||||
jobs.value = result.reverse()
|
||||
if (result.length > 0 && selectedId.value === null) {
|
||||
selectedId.value = result[result.length - 1].id
|
||||
}
|
||||
|
||||
// Auto refresh if any job is running
|
||||
const hasRunning = result.some(j => j.status === 'running')
|
||||
if (hasRunning && !refreshTimer) {
|
||||
refreshTimer = setInterval(loadJobs, 15000)
|
||||
} else if (!hasRunning && refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '載入失敗'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadJobs)
|
||||
onUnmounted(() => { if (refreshTimer) clearInterval(refreshTimer) })
|
||||
</script>
|
||||
85
portal/src/views/TraceDetailView.vue
Normal file
85
portal/src/views/TraceDetailView.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<button @click="$router.back()" class="text-gray-400 hover:text-white text-lg">← 返回</button>
|
||||
<h2 class="text-2xl font-bold">Trace #{{ traceId }}</h2>
|
||||
<span class="text-gray-400 text-sm">{{ fileUuid?.substring(0, 12) }}...</span>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-12"><div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500 mx-auto"></div></div>
|
||||
|
||||
<div v-else-if="trace" class="grid gap-6">
|
||||
<!-- Summary -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div><span class="text-xs text-gray-500">DETECTIONS</span><p class="text-white text-lg font-semibold">{{ trace.face_count }}</p></div>
|
||||
<div><span class="text-xs text-gray-500">DURATION</span><p class="text-white text-lg font-semibold">{{ trace.duration_sec?.toFixed(1) }}s</p></div>
|
||||
<div><span class="text-xs text-gray-500">CONFIDENCE</span><p class="text-white text-lg font-semibold">{{ (trace.avg_confidence * 100).toFixed(0) }}%</p></div>
|
||||
<div><span class="text-xs text-gray-500">TIME</span><p class="text-white text-lg font-semibold">{{ trace.first_sec?.toFixed(0) }}s - {{ trace.last_sec?.toFixed(0) }}s</p></div>
|
||||
</div>
|
||||
|
||||
<!-- Video -->
|
||||
<div class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden">
|
||||
<video controls autoplay class="w-full" @error="videoError = true">
|
||||
<source :src="videoUrl" type="video/mp4" />
|
||||
</video>
|
||||
<div v-if="videoError" class="p-4 text-center text-gray-500">Video unavailable</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Faces -->
|
||||
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||
<h3 class="text-lg font-semibold mb-4 text-blue-400">Recent Detections</h3>
|
||||
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
|
||||
<div v-for="f in recentFaces" :key="f.id" class="bg-gray-900 rounded overflow-hidden">
|
||||
<img :src="thumbUrl(f)" class="w-full aspect-square object-cover" loading="lazy" @error="e => (e.target as HTMLElement).style.display='none'" />
|
||||
<div class="p-1 text-[9px] text-gray-400 truncate">#{{ f.start_frame }}<br/>{{ (f.confidence * 100).toFixed(0) }}%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getCurrentConfig, httpFetch } from '@/api/client'
|
||||
|
||||
const route = useRoute()
|
||||
const fileUuid = route.params.file_uuid as string
|
||||
const traceId = route.params.trace_id as string
|
||||
const config = getCurrentConfig()
|
||||
|
||||
const trace = ref<any>(null)
|
||||
const faces = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const videoError = ref(false)
|
||||
|
||||
const videoUrl = computed(() =>
|
||||
`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/video?padding=1`
|
||||
)
|
||||
|
||||
const recentFaces = computed(() => faces.value.slice(0, 40))
|
||||
|
||||
function thumbUrl(f: any): string {
|
||||
return `${config.api_base_url}/api/v1/file/${fileUuid}/thumbnail?frame=${f.start_frame}&x=${f.x}&y=${f.y}&w=${f.width}&h=${f.height}`
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const traces = await httpFetch<any>(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ limit: 500 })
|
||||
})
|
||||
trace.value = (traces.traces || []).find((t: any) => String(t.trace_id) === traceId)
|
||||
|
||||
const faceData = await httpFetch<any>(`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/faces?limit=50`)
|
||||
faces.value = faceData.faces || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load trace:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadData())
|
||||
</script>
|
||||
64
portal/src/views/TraceVizView.vue
Normal file
64
portal/src/views/TraceVizView.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-900 text-white p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-bold text-blue-400">V5: 3D Space-Time Cube</h2>
|
||||
<button @click="goBack" class="text-sm bg-gray-700 hover:bg-gray-600 px-3 py-1 rounded">← 返回</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mb-3 flex gap-4">
|
||||
<span>X = 畫面水平位置(紅軸)</span>
|
||||
<span>Y = 畫面垂直位置(綠軸)</span>
|
||||
<span>Z = 深度 - bbox 面積(藍軸)</span>
|
||||
<span>T = 時間 - 顏色漸層藍→紅</span>
|
||||
</div>
|
||||
<div class="h-[calc(100vh-140px)]">
|
||||
<SpaceTimeCube
|
||||
:file-uuid="fileUuid"
|
||||
:traces="allTraces"
|
||||
:frame-width="1920"
|
||||
:frame-height="1080"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { getCurrentConfig } from '@/api/client'
|
||||
import SpaceTimeCube from '@/components/SpaceTimeCube.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const fileUuid = route.params.file_uuid as string
|
||||
const allTraces = ref<any[]>([])
|
||||
|
||||
// Auto-configure from query params (for demo)
|
||||
const keyParam = route.query.key as string
|
||||
const baseParam = route.query.base as string
|
||||
if (keyParam && baseParam) {
|
||||
const existing = JSON.parse(localStorage.getItem('portal_config') || '{}')
|
||||
existing.api_key = keyParam
|
||||
existing.api_base_url = baseParam
|
||||
localStorage.setItem('portal_config', JSON.stringify(existing))
|
||||
}
|
||||
|
||||
const goBack = () => router.back()
|
||||
|
||||
onMounted(async () => {
|
||||
const config = getCurrentConfig()
|
||||
try {
|
||||
const resp = await fetch(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(config.api_key ? { 'X-API-Key': config.api_key } : {})
|
||||
},
|
||||
body: JSON.stringify({ sort_by: 'face_count', limit: 200, min_faces: 1 })
|
||||
})
|
||||
const data = await resp.json()
|
||||
allTraces.value = data.traces || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load traces:', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user