Initial commit: Momentry Portal v0.1.0
This commit is contained in:
4
.env.development
Normal file
4
.env.development
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Portal Development Environment
|
||||||
|
VITE_APP_TITLE=Momentry Portal (Development)
|
||||||
|
VITE_API_BASE_URL=http://127.0.0.1:3002
|
||||||
|
VITE_API_KEY=muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69
|
||||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/schemas/
|
||||||
|
|
||||||
|
# Tauri icons (generated)
|
||||||
|
src-tauri/icons/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
40
AGENTS.md
Normal file
40
AGENTS.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Momentry Portal — AGENTS.md
|
||||||
|
|
||||||
|
## 開發指令
|
||||||
|
|
||||||
|
| 用途 | 指令 |
|
||||||
|
|------|------|
|
||||||
|
| Vue dev server (port 1420) | `npm run dev` |
|
||||||
|
| Tauri 桌面應用 | `npm run tauri dev` |
|
||||||
|
| 完整建置(typecheck → build) | `npm run build` |
|
||||||
|
| 純 Vite preview | `npm run preview` |
|
||||||
|
|
||||||
|
`npm run build` 會先執行 `vue-tsc --noEmit`(型別檢查),成功後才執行 `vite build`。
|
||||||
|
|
||||||
|
## 專案架構
|
||||||
|
|
||||||
|
- `src/` — Vue 前端,`@/` 別名指向 `./src/`
|
||||||
|
- `src-tauri/` — Rust 後端(Tauri v2),`src/api/` 有七個模組:`health`、`search`、`identity`、`video`、`person`、`translation`
|
||||||
|
- `src-tauri/config.rs` — 從環境變數 `MOMENTRY_API_URL` / `MOMENTRY_API_KEY` 讀取設定
|
||||||
|
- 無測試、無 lint/formatter 設定檔、無 CI
|
||||||
|
|
||||||
|
## API 雙模式
|
||||||
|
|
||||||
|
`src/api/client.ts` 根據執行環境自動切換:
|
||||||
|
- **Tauri 模式**:透過 `@tauri-apps/api/core` 的 `invoke` 呼叫 Rust commands
|
||||||
|
- **瀏覽器模式**:直接 HTTP fetch 到后端 API
|
||||||
|
|
||||||
|
## 環境與認證
|
||||||
|
|
||||||
|
- `.env.development` 提供 `VITE_API_BASE_URL`、`VITE_API_KEY`(僅瀏覽器模式使用)
|
||||||
|
- 登入狀態存於 `localStorage('momentry_user')`
|
||||||
|
- API 設定存於 `localStorage('portal_config')`
|
||||||
|
- 401 回應會自動清除登入狀態並跳回 `/login`
|
||||||
|
|
||||||
|
## 注意事項
|
||||||
|
|
||||||
|
- `tsconfig.json` 啟用 `strict` + `noUnusedLocals` + `noUnusedParameters` — 未使用的變數/參數會造成 `vue-tsc` 建置錯誤
|
||||||
|
- Tauri v2 使用 plugins 架構:`shell`、`http`(`unsafe-headers`)、`fs`、`global-shortcut`
|
||||||
|
- 視窗 Zoom 快捷鍵:`Cmd+=` / `Cmd+-` / `Cmd+0`
|
||||||
|
- Dev server port **1420**(strictPort: true)— `tauri.conf.json` 的 `devUrl` 與 `vite.config.ts` 一致
|
||||||
|
- 此專案從 `momentry_core` 分離出來,前端不包含後端 Rust 程式碼
|
||||||
135
README.md
Normal file
135
README.md
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# Momentry Portal
|
||||||
|
|
||||||
|
基於 Tauri + Vue 3 的影片搜尋與身份管理桌面應用程式。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
1. **影片搜尋**: 智能搜尋影片內容
|
||||||
|
2. **身份管理**: 管理全域身份、區域人物
|
||||||
|
3. **臉部管理**: 查看和下載人物臉部截圖
|
||||||
|
|
||||||
|
## 環境需求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- Rust 1.70+
|
||||||
|
- npm 或 yarn
|
||||||
|
|
||||||
|
## 安裝與執行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 進入專案目錄
|
||||||
|
cd portal
|
||||||
|
|
||||||
|
# 安裝前端依賴
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 開發模式 (同時啟動 Vue 和 Tauri)
|
||||||
|
npm run tauri dev
|
||||||
|
|
||||||
|
# 或分別啟動
|
||||||
|
npm run dev # 啟動 Vue dev server (port 1420)
|
||||||
|
npm run tauri dev # 啟動 Tauri desktop app
|
||||||
|
```
|
||||||
|
|
||||||
|
## 專案結構
|
||||||
|
|
||||||
|
```
|
||||||
|
portal/
|
||||||
|
├── src-tauri/ # Rust 後端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── main.rs # Tauri 入口
|
||||||
|
│ │ ├── config.rs # 組態管理
|
||||||
|
│ │ └── api/ # API 處理常式
|
||||||
|
│ │ ├── search.rs # 搜尋 API
|
||||||
|
│ │ ├── identity.rs # 身份管理 API
|
||||||
|
│ │ ├── video.rs # 影片 API
|
||||||
|
│ │ └── person.rs # 人物/臉部 API
|
||||||
|
│ ├── Cargo.toml
|
||||||
|
│ └── tauri.conf.json
|
||||||
|
├── src/ # Vue 前端
|
||||||
|
│ ├── main.ts
|
||||||
|
│ ├── App.vue
|
||||||
|
│ ├── router.ts
|
||||||
|
│ ├── views/
|
||||||
|
│ │ ├── HomeView.vue
|
||||||
|
│ │ ├── SearchView.vue
|
||||||
|
│ │ ├── IdentitiesView.vue
|
||||||
|
│ │ └── VideoDetailView.vue
|
||||||
|
│ └── assets/
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.ts
|
||||||
|
└── tailwind.config.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 環境
|
||||||
|
|
||||||
|
Portal 連接到 Momentry Core API Server,支援兩種環境:
|
||||||
|
|
||||||
|
### 環境配置
|
||||||
|
|
||||||
|
| 環境 | API URL | Port | Redis Prefix | Schema | 用途 |
|
||||||
|
|------|---------|------|--------------|--------|------|
|
||||||
|
| **生產環境** | `http://127.0.0.1:3002` | 3002 | `momentry:` | `public` | 正式數據 |
|
||||||
|
| **開發環境** | `http://127.0.0.1:3003` | 3003 | `momentry_dev:` | `dev` | 測試數據 |
|
||||||
|
|
||||||
|
### 啟動 API Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生產環境 (port 3002, schema=public)
|
||||||
|
cargo run --bin momentry -- server
|
||||||
|
|
||||||
|
# 開發環境 (port 3003, schema=dev)
|
||||||
|
DATABASE_SCHEMA=dev cargo run --bin momentry_playground -- server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Portal 配置
|
||||||
|
|
||||||
|
Portal 預設連接開發環境 (3003),可透過環境變數切換:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 切換到生產環境
|
||||||
|
export MOMENTRY_API_URL="http://127.0.0.1:3002"
|
||||||
|
|
||||||
|
# 啟動 Portal
|
||||||
|
cd portal && npm run tauri dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Key
|
||||||
|
|
||||||
|
API Key 用於認證 Portal 與 API Server 的通訊:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MOMENTRY_API_KEY="muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69"
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: 開發環境 (dev schema) 的 API Key hash 必須與 `dev.api_keys` 表中的資料一致。
|
||||||
|
|
||||||
|
### 檢查 API 連接
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 檢查 API Server 狀態
|
||||||
|
curl http://localhost:3003/api/v1/videos -H "X-API-Key: $MOMENTRY_API_KEY"
|
||||||
|
|
||||||
|
# 檢查 Schema
|
||||||
|
psql -U accusys -d momentry -c "SELECT table_name FROM information_schema.tables WHERE table_schema = 'dev';"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 環境變數
|
||||||
|
|
||||||
|
可在 `src-tauri/src/config.rs` 中修改,或設定環境變數:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export MOMENTRY_API_URL="http://127.0.0.1:3002"
|
||||||
|
export MOMENTRY_API_KEY="your-api-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 對應
|
||||||
|
|
||||||
|
| 前端功能 | Tauri Command | 對應 Momentry API |
|
||||||
|
|---------|---------------|-------------------|
|
||||||
|
| 搜尋 | `search_videos` | `POST /api/v1/n8n/search` |
|
||||||
|
| 身份列表 | `list_identities` | `POST /api/v1/identities/search` |
|
||||||
|
| 註冊身份 | `register_identity` | `POST /api/v1/person/:id/register` |
|
||||||
|
| 影片列表 | `list_videos` | `GET /api/v1/videos` |
|
||||||
|
| 影片臉部 | `get_video_faces` | `GET /api/v1/videos/:uuid/faces` |
|
||||||
|
| 下載截圖 | `get_person_thumbnail` | `GET /api/v1/person/:id/thumbnail` |
|
||||||
89
VIDEO_DETAIL_UPDATE.md
Normal file
89
VIDEO_DETAIL_UPDATE.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# VideoDetailView.vue 更新摘要
|
||||||
|
|
||||||
|
## 更新日期
|
||||||
|
2026-04-27
|
||||||
|
|
||||||
|
## 更新內容
|
||||||
|
|
||||||
|
### 1. 跳回按鈕優化
|
||||||
|
- **原版**: 純文字按鈕,樣式簡單
|
||||||
|
- **新版**:
|
||||||
|
- 使用 Tailwind CSS 樣式化按鈕
|
||||||
|
- `bg-gray-800 hover:bg-gray-700 rounded-lg transition`
|
||||||
|
- 清晰的文字:"返回納管檔案列表"
|
||||||
|
- 位於頁面左側,標題位於右側
|
||||||
|
|
||||||
|
### 2. Probe 消息展示優化
|
||||||
|
- **原版**: 直接展示所有信息
|
||||||
|
- **新版**:
|
||||||
|
- **基本信息 Grid**: Duration, Resolution, Frame Rate, Codec
|
||||||
|
- **影片串流**: 折疊式展示(<details>標籤)
|
||||||
|
- 提示文字:"click to expand"
|
||||||
|
- 最大高度限制:`max-h-64`
|
||||||
|
- **音訊串流**: 折疊式展示
|
||||||
|
- 顯示數量標記:"音訊串流 (N)"
|
||||||
|
- 每個串流獨立折疊
|
||||||
|
- **完整 Probe JSON**:
|
||||||
|
- 折疊式展示
|
||||||
|
- 藍色標記:"詳細"
|
||||||
|
- 最大高度限制:`max-h-96`
|
||||||
|
|
||||||
|
### 3. 新增 Status / Processing Status 展示
|
||||||
|
- **狀態區塊**: 新增獨立的"處理狀態"區塊
|
||||||
|
- UUID (truncate)
|
||||||
|
- Status (帶顏色標籤)
|
||||||
|
- completed: 綠色
|
||||||
|
- processing: 藍色
|
||||||
|
- pending: 灰色
|
||||||
|
- failed: 紅色
|
||||||
|
- 註冊時間
|
||||||
|
|
||||||
|
- **Processing Status Details**:
|
||||||
|
- **階段 (Phase)**: PROCESSING / COMPLETED 等
|
||||||
|
- **處理器列表**: active_processors (藍色標籤)
|
||||||
|
- **進度條**:
|
||||||
|
- 每個處理器獨立進度條
|
||||||
|
- 動態顏色(根據status)
|
||||||
|
- 顯示百分比和帧數
|
||||||
|
- **Agent 狀態**:
|
||||||
|
- 顯示各Agent狀態(five_w1h, identity等)
|
||||||
|
- 進度百分比和完成數量
|
||||||
|
|
||||||
|
### 4. 新增輔助函數
|
||||||
|
```typescript
|
||||||
|
// 狀態顏色函數
|
||||||
|
getStatusColor(status: string): string
|
||||||
|
getProgressColor(status: string): string
|
||||||
|
getAgentStatusColor(status: string): string
|
||||||
|
|
||||||
|
// Processing Status解析
|
||||||
|
if (typeof v.processing_status === 'string') {
|
||||||
|
video.value.processing_status = JSON.parse(v.processing_status)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 檔案大小
|
||||||
|
- **原版**: 227 行
|
||||||
|
- **新版**: 371 行(增加 144 行)
|
||||||
|
|
||||||
|
## 編譯結果
|
||||||
|
✅ `npm run build` 成功
|
||||||
|
- VideoDetailView-CP9GNQZ0.js: 10.56 kB (gzip: 3.46 kB)
|
||||||
|
|
||||||
|
## 測試建議
|
||||||
|
1. 啟動 portal dev server: `cd portal && npm run dev`
|
||||||
|
2. 確保 API server 在 port 3003
|
||||||
|
3. 登入 portal
|
||||||
|
4. 進入 "/files" 頁面
|
||||||
|
5. 點擊任意影片查看詳情
|
||||||
|
|
||||||
|
## API 端點需求
|
||||||
|
- `/api/v1/videos?uuid={uuid}` - 返回 video 物件(包含 processing_status)
|
||||||
|
- `/api/v1/videos/{uuid}/faces` - 返回 face clusters
|
||||||
|
|
||||||
|
## 未來改進建議
|
||||||
|
1. 添加 chunk 搜尋功能(在詳情頁搜尋該影片的 chunks)
|
||||||
|
2. 添加 processing_status 更新按鈕(重新載入狀態)
|
||||||
|
3. 添加處理器重新執行功能
|
||||||
|
4. 添加時間軸視覺化(timeline visualization)
|
||||||
|
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-TW">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Momentry Portal</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
0
momentry-portal@0.1.0
Normal file
0
momentry-portal@0.1.0
Normal file
3124
package-lock.json
generated
Normal file
3124
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "momentry-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@tauri-apps/plugin-fs": "^2.5.0",
|
||||||
|
"@tauri-apps/plugin-http": "^2.5.8",
|
||||||
|
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||||
|
"@types/three": "^0.184.1",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"three": "^0.184.0",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.10.1",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"autoprefixer": "^10.4.17",
|
||||||
|
"postcss": "^8.4.33",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "~5.6.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
6054
src-tauri/Cargo.lock
generated
Normal file
6054
src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
src-tauri/Cargo.toml
Normal file
28
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[package]
|
||||||
|
name = "momentry-portal"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Momentry Portal - Search & Identity Management"
|
||||||
|
authors = ["Momentry Team"]
|
||||||
|
license = "MIT"
|
||||||
|
repository = ""
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.0.0", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2.0.0", features = [] }
|
||||||
|
tauri-plugin-shell = "2.0.0"
|
||||||
|
tauri-plugin-http = { version = "2.0.0", features = ["unsafe-headers"] }
|
||||||
|
tauri-plugin-fs = "2.0.0"
|
||||||
|
tauri-plugin-global-shortcut = "2.0.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
base64 = "0.21"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["custom-protocol"]
|
||||||
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
3
src-tauri/build.rs
Normal file
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
src-tauri/capabilities/default.json
Normal file
10
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capabilities for Momentry Portal",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"shell:default",
|
||||||
|
"shell:allow-open"
|
||||||
|
]
|
||||||
|
}
|
||||||
203
src-tauri/src/api/health.rs
Normal file
203
src-tauri/src/api/health.rs
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
use crate::config::{get_config, PortalConfig};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HealthResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
|
pub uptime_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DetailedHealthResponse {
|
||||||
|
pub status: String,
|
||||||
|
pub version: String,
|
||||||
|
pub uptime_ms: u64,
|
||||||
|
pub services: ServiceHealth,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServiceHealth {
|
||||||
|
pub postgres: ServiceStatus,
|
||||||
|
pub redis: ServiceStatus,
|
||||||
|
pub qdrant: ServiceStatus,
|
||||||
|
pub mongodb: ServiceStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ServiceStatus {
|
||||||
|
pub status: String,
|
||||||
|
pub latency_ms: Option<u64>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_health() -> Result<HealthResponse, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&format!("{}/health", config.api_base_url))
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: HealthResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_health_detailed() -> Result<DetailedHealthResponse, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&format!("{}/health/detailed", config.api_base_url))
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: DetailedHealthResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SftpgoStatus {
|
||||||
|
pub username: String,
|
||||||
|
pub home_dir: String,
|
||||||
|
pub files_count: i64,
|
||||||
|
pub registered_videos: Vec<RegisteredVideo>,
|
||||||
|
pub last_login: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RegisteredVideo {
|
||||||
|
pub uuid: String,
|
||||||
|
pub file_name: String,
|
||||||
|
pub status: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_sftpgo_status() -> Result<SftpgoStatus, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&format!("{}/api/v1/stats/sftpgo", config.api_base_url))
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: SftpgoStatus = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct InferenceEngineStatus {
|
||||||
|
pub engine: String,
|
||||||
|
pub model: String,
|
||||||
|
pub status: String,
|
||||||
|
pub latency_ms: Option<u64>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct InferenceHealthResponse {
|
||||||
|
pub ollama: InferenceEngineStatus,
|
||||||
|
pub llama_server: InferenceEngineStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_inference_health() -> Result<InferenceHealthResponse, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(5))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("Client build failed: {}", e))?;
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&format!("{}/api/v1/stats/inference", config.api_base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: InferenceHealthResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_config_info() -> Result<PortalConfig, String> {
|
||||||
|
Ok(get_config())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct IngestStats {
|
||||||
|
pub total_videos: i64,
|
||||||
|
pub total_chunks: i64,
|
||||||
|
pub sentence_chunks: i64,
|
||||||
|
pub cut_chunks: i64,
|
||||||
|
pub time_chunks: i64,
|
||||||
|
pub searchable_chunks: i64,
|
||||||
|
pub chunks_with_visual: i64,
|
||||||
|
pub chunks_with_summary: i64,
|
||||||
|
pub pending_videos: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_ingest_stats() -> Result<IngestStats, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&format!("{}/api/v1/stats/ingest", config.api_base_url))
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: IngestStats = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
148
src-tauri/src/api/identity.rs
Normal file
148
src-tauri/src/api/identity.rs
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
use crate::config::get_config;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct IdentitySearchRequest {
|
||||||
|
pub query: Option<String>,
|
||||||
|
pub file_uuid: Option<String>,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct IdentityResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub total: usize,
|
||||||
|
pub identities: Vec<IdentityItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct IdentityItem {
|
||||||
|
pub id: i32,
|
||||||
|
pub person_id: String,
|
||||||
|
pub face_identity_id: Option<i32>,
|
||||||
|
pub file_uuid: String,
|
||||||
|
pub profile: IdentityProfile,
|
||||||
|
pub stats: IdentityStats,
|
||||||
|
pub is_confirmed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct IdentityProfile {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub original_name: Option<String>,
|
||||||
|
pub character_name: Option<String>,
|
||||||
|
pub speaker_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct IdentityStats {
|
||||||
|
pub appearance_count: i32,
|
||||||
|
pub total_duration: f64,
|
||||||
|
pub first_appearance: Option<f64>,
|
||||||
|
pub last_appearance: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RegisterResponse {
|
||||||
|
pub success: bool,
|
||||||
|
pub message: String,
|
||||||
|
pub person_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub face_identity_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_identities(
|
||||||
|
query: Option<String>,
|
||||||
|
file_uuid: Option<String>,
|
||||||
|
) -> Result<IdentityResponse, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let request_body = IdentitySearchRequest {
|
||||||
|
query,
|
||||||
|
file_uuid,
|
||||||
|
limit: Some(50),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&format!("{}/api/v1/identities/search", config.api_base_url))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: IdentityResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn register_identity(
|
||||||
|
person_id: String,
|
||||||
|
file_uuid: String,
|
||||||
|
) -> Result<RegisterResponse, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/person/{}/register?file_uuid={}",
|
||||||
|
config.api_base_url, person_id, file_uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: RegisterResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_identity_videos(identity_id: i32) -> Result<serde_json::Value, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/identities/{}/videos",
|
||||||
|
config.api_base_url, identity_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
6
src-tauri/src/api/mod.rs
Normal file
6
src-tauri/src/api/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod health;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod person;
|
||||||
|
pub mod search;
|
||||||
|
pub mod translation;
|
||||||
|
pub mod video;
|
||||||
84
src-tauri/src/api/person.rs
Normal file
84
src-tauri/src/api/person.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use crate::config::get_config;
|
||||||
|
use base64::{engine::general_purpose, Engine as _};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_person_thumbnail(
|
||||||
|
person_id: String,
|
||||||
|
file_uuid: String,
|
||||||
|
index: Option<usize>,
|
||||||
|
save_path: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let mut url = format!(
|
||||||
|
"{}/api/v1/person/{}/thumbnail?file_uuid={}",
|
||||||
|
config.api_base_url, person_id, file_uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(idx) = index {
|
||||||
|
url = format!("{}&index={}", url, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the image to the specified path
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
|
||||||
|
tokio::fs::write(&save_path, &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to save file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(save_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get person thumbnail as base64 data URI
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_person_thumbnail_b64(
|
||||||
|
person_id: String,
|
||||||
|
file_uuid: String,
|
||||||
|
index: Option<usize>,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let mut url = format!(
|
||||||
|
"{}/api/v1/person/{}/thumbnail?file_uuid={}",
|
||||||
|
config.api_base_url, person_id, file_uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
if let Some(idx) = index {
|
||||||
|
url = format!("{}&index={}", url, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to read response: {}", e))?;
|
||||||
|
|
||||||
|
let encoded = general_purpose::STANDARD.encode(&bytes);
|
||||||
|
Ok(format!("data:image/jpeg;base64,{}", encoded))
|
||||||
|
}
|
||||||
175
src-tauri/src/api/search.rs
Normal file
175
src-tauri/src/api/search.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use crate::config::get_config;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SearchRequest {
|
||||||
|
pub query: String,
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
pub mode: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SearchResult {
|
||||||
|
pub query: String,
|
||||||
|
pub count: usize,
|
||||||
|
pub hits: Vec<SearchHit>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SearchHit {
|
||||||
|
pub id: String,
|
||||||
|
pub vid: String,
|
||||||
|
pub start_frame: i64,
|
||||||
|
pub end_frame: i64,
|
||||||
|
pub fps: f64,
|
||||||
|
pub start: f64,
|
||||||
|
pub end: f64,
|
||||||
|
pub text: String,
|
||||||
|
pub score: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub file_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_visual_stats: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_videos(
|
||||||
|
query: String,
|
||||||
|
limit: Option<usize>,
|
||||||
|
mode: Option<String>,
|
||||||
|
) -> Result<SearchResult, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let search_mode = mode.unwrap_or_else(|| "vector".to_string());
|
||||||
|
|
||||||
|
let request_body = SearchRequest {
|
||||||
|
query: query.clone(),
|
||||||
|
limit: limit.or(Some(10)),
|
||||||
|
mode: Some(search_mode.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/search", config.api_base_url);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse as generic Value to handle mapping manually
|
||||||
|
let json: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
// Map Backend Response to Frontend SearchResult
|
||||||
|
// Backend: { "query": "...", "results": [ ... ], "total": N, ... }
|
||||||
|
// Frontend: { "query": "...", "hits": [ ... ], "count": N }
|
||||||
|
|
||||||
|
let backend_results = json.get("results").and_then(|r| r.as_array()).cloned().unwrap_or_default();
|
||||||
|
let total = json.get("total").and_then(|t| t.as_u64()).unwrap_or(0) as usize;
|
||||||
|
|
||||||
|
let hits: Vec<SearchHit> = backend_results.into_iter().filter_map(|item| {
|
||||||
|
Some(SearchHit {
|
||||||
|
id: item.get("chunk_id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
vid: item.get("uuid").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
start_frame: item.get("start_frame").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
|
end_frame: item.get("end_frame").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
|
fps: item.get("fps").and_then(|v| v.as_f64()).unwrap_or(30.0),
|
||||||
|
start: item.get("start_time").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||||
|
end: item.get("end_time").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||||
|
text: item.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
score: item.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||||
|
title: item.get("file_name").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
file_path: item.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
has_visual_stats: item.get("visual_stats").map(|_| true),
|
||||||
|
parent_id: item.get("parent_chunk_id").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(SearchResult {
|
||||||
|
query: json.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
count: total,
|
||||||
|
hits,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn search_chunks(query: String, uuid: Option<String>) -> Result<SearchResult, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Backend expects uuid in the body, not query params
|
||||||
|
let url = format!("{}/api/v1/search", config.api_base_url);
|
||||||
|
|
||||||
|
let mut request_body = serde_json::json!({
|
||||||
|
"query": query,
|
||||||
|
"limit": 10
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Some(vid) = uuid {
|
||||||
|
request_body["uuid"] = serde_json::json!(vid);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.json(&request_body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse raw JSON to handle structure mapping
|
||||||
|
let json: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
// Backend returns "total", frontend expects "count"
|
||||||
|
let count = json.get("total").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
|
||||||
|
|
||||||
|
// Backend returns "results", frontend expects "hits"
|
||||||
|
let results = json.get("results").and_then(|v| v.as_array()).cloned().unwrap_or_default();
|
||||||
|
|
||||||
|
let hits: Vec<SearchHit> = results.into_iter().filter_map(|item| {
|
||||||
|
Some(SearchHit {
|
||||||
|
id: item.get("chunk_id").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
vid: item.get("uuid").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
start_frame: item.get("start_frame").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
|
end_frame: item.get("end_frame").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||||
|
fps: item.get("fps").and_then(|v| v.as_f64()).unwrap_or(30.0),
|
||||||
|
start: item.get("start_time").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||||
|
end: item.get("end_time").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||||
|
text: item.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
score: item.get("score").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||||
|
title: item.get("file_name").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
file_path: item.get("file_path").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
has_visual_stats: item.get("visual_stats").map(|_| true),
|
||||||
|
parent_id: item.get("parent_chunk_id").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
}).collect();
|
||||||
|
|
||||||
|
Ok(SearchResult {
|
||||||
|
query: json.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||||
|
count,
|
||||||
|
hits,
|
||||||
|
})
|
||||||
|
}
|
||||||
81
src-tauri/src/api/translation.rs
Normal file
81
src-tauri/src/api/translation.rs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
struct LlamaCppResponse {
|
||||||
|
choices: Vec<LlamaCppChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
struct LlamaCppChoice {
|
||||||
|
message: LlamaCppMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize)]
|
||||||
|
struct LlamaCppMessage {
|
||||||
|
content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translates text using local llama.cpp server running Gemma 4.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn translate_text(
|
||||||
|
text: String,
|
||||||
|
#[allow(non_snake_case)] target_lang: String,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
if text.trim().is_empty() {
|
||||||
|
return Ok(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("[Translate] Request: {} -> {}", target_lang, text);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
let prompt = format!(
|
||||||
|
"Translate the following text into {}. Only output the translated text without any additional context or notes.\n\nText: {}",
|
||||||
|
target_lang, text
|
||||||
|
);
|
||||||
|
|
||||||
|
// llama.cpp server endpoint (compatible with OpenAI API format)
|
||||||
|
let payload = serde_json::json!({
|
||||||
|
"model": "gemma4",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": prompt
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stream": false,
|
||||||
|
"temperature": 0.1
|
||||||
|
});
|
||||||
|
|
||||||
|
println!("[Translate] Sending to llama.cpp server...");
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post("http://127.0.0.1:8081/v1/chat/completions")
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&payload)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
format!(
|
||||||
|
"llama.cpp server request failed: {}. Ensure the server is running at port 8081.",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("llama.cpp server error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: LlamaCppResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Parse error: {}", e))?;
|
||||||
|
|
||||||
|
println!("[Translate] Response received");
|
||||||
|
|
||||||
|
if let Some(choice) = json.choices.first() {
|
||||||
|
Ok(choice.message.content.trim().to_string())
|
||||||
|
} else {
|
||||||
|
Err("No translation result returned".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src-tauri/src/api/video.rs
Normal file
179
src-tauri/src/api/video.rs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
use crate::config::get_config;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct VideosResponse {
|
||||||
|
pub videos: Vec<serde_json::Value>,
|
||||||
|
pub total: i64,
|
||||||
|
pub page: usize,
|
||||||
|
pub page_size: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_videos(
|
||||||
|
query: Option<String>,
|
||||||
|
status: Option<String>,
|
||||||
|
page: Option<usize>,
|
||||||
|
page_size: Option<usize>,
|
||||||
|
uuid: Option<String>,
|
||||||
|
) -> Result<VideosResponse, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
// Use /api/v1/files endpoint
|
||||||
|
let mut url = format!("{}/api/v1/files", config.api_base_url);
|
||||||
|
let mut params = Vec::new();
|
||||||
|
|
||||||
|
if let Some(q) = query {
|
||||||
|
params.push(format!("q={}", q));
|
||||||
|
}
|
||||||
|
if let Some(s) = status {
|
||||||
|
params.push(format!("status={}", s));
|
||||||
|
}
|
||||||
|
if let Some(p) = page {
|
||||||
|
params.push(format!("page={}", p));
|
||||||
|
}
|
||||||
|
if let Some(ps) = page_size {
|
||||||
|
params.push(format!("page_size={}", ps));
|
||||||
|
}
|
||||||
|
if let Some(u) = uuid {
|
||||||
|
params.push(format!("uuid={}", u));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !params.is_empty() {
|
||||||
|
url.push('?');
|
||||||
|
url.push_str(¶ms.join("&"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request to API failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API returned error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse API response: {}", e))?;
|
||||||
|
|
||||||
|
// Extract fields from the new API response format
|
||||||
|
let data = json
|
||||||
|
.get("data")
|
||||||
|
.and_then(|v| v.as_array())
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| vec![]);
|
||||||
|
|
||||||
|
let total = json.get("total").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||||
|
let page_val = json.get("page").and_then(|v| v.as_u64()).unwrap_or(1) as usize;
|
||||||
|
let page_size_val = json
|
||||||
|
.get("page_size")
|
||||||
|
.and_then(|v| v.as_u64())
|
||||||
|
.unwrap_or(20) as usize;
|
||||||
|
|
||||||
|
Ok(VideosResponse {
|
||||||
|
videos: data,
|
||||||
|
total,
|
||||||
|
page: page_val,
|
||||||
|
page_size: page_size_val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_videos(
|
||||||
|
query: Option<String>,
|
||||||
|
page: Option<usize>,
|
||||||
|
page_size: Option<usize>,
|
||||||
|
) -> Result<VideosResponse, String> {
|
||||||
|
get_videos(query, None, page, page_size, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_video_faces(file_uuid: String) -> Result<serde_json::Value, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
// Use new endpoint: /api/v1/face/list?file_uuid=...
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/face/list?file_uuid={}",
|
||||||
|
config.api_base_url, file_uuid
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_chunk_detail(uuid: String, chunk_id: String) -> Result<serde_json::Value, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/v1/videos/{}/details?chunk_id={}",
|
||||||
|
config.api_base_url, uuid, chunk_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.get(&url)
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn unregister_video(file_uuid: String) -> Result<serde_json::Value, String> {
|
||||||
|
let config = get_config();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
// Use new endpoint: POST /api/v1/unregister
|
||||||
|
let url = format!("{}/api/v1/unregister", config.api_base_url);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("x-api-key", &config.api_key)
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.body(serde_json::to_string(&serde_json::json!({ "uuid": file_uuid })).unwrap())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("API error: {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut result: serde_json::Value = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||||
|
|
||||||
|
// Add file_uuid to match frontend expectation
|
||||||
|
if let Some(obj) = result.as_object_mut() {
|
||||||
|
obj.insert("file_uuid".to_string(), serde_json::Value::String(file_uuid.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
43
src-tauri/src/config.rs
Normal file
43
src-tauri/src/config.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PortalConfig {
|
||||||
|
pub api_base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub timeout_secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PortalConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
api_base_url: "http://127.0.0.1:3002".to_string(),
|
||||||
|
api_key: "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69".to_string(),
|
||||||
|
timeout_secs: 30,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static CONFIG: Mutex<Option<PortalConfig>> = Mutex::new(None);
|
||||||
|
|
||||||
|
pub fn init_config() {
|
||||||
|
let mut config = CONFIG.lock().unwrap();
|
||||||
|
if config.is_none() {
|
||||||
|
let api_url = std::env::var("MOMENTRY_API_URL")
|
||||||
|
.unwrap_or_else(|_| "http://127.0.0.1:3002".to_string());
|
||||||
|
let api_key = std::env::var("MOMENTRY_API_KEY").unwrap_or_else(|_| {
|
||||||
|
"muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69".to_string()
|
||||||
|
});
|
||||||
|
|
||||||
|
*config = Some(PortalConfig {
|
||||||
|
api_base_url: api_url,
|
||||||
|
api_key,
|
||||||
|
timeout_secs: 30,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_config() -> PortalConfig {
|
||||||
|
let config = CONFIG.lock().unwrap();
|
||||||
|
config.clone().unwrap_or_default()
|
||||||
|
}
|
||||||
7
src-tauri/src/lib.rs
Normal file
7
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod config;
|
||||||
|
pub mod api {
|
||||||
|
pub mod search;
|
||||||
|
pub mod identity;
|
||||||
|
pub mod video;
|
||||||
|
pub mod person;
|
||||||
|
}
|
||||||
85
src-tauri/src/main.rs
Normal file
85
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
#![cfg_attr(
|
||||||
|
all(not(debug_assertions), target_os = "windows"),
|
||||||
|
windows_subsystem = "windows"
|
||||||
|
)]
|
||||||
|
|
||||||
|
mod api;
|
||||||
|
mod config;
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU32, Ordering};
|
||||||
|
use tauri::Manager;
|
||||||
|
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn open_devtools(app: tauri::AppHandle) {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.open_devtools();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// Define zoom level in steps of 10% (100 = 1.0x)
|
||||||
|
static ZOOM_LEVEL: AtomicU32 = AtomicU32::new(100);
|
||||||
|
|
||||||
|
let zoom_in = Shortcut::new(Some(Modifiers::SUPER), Code::Equal); // Cmd + =
|
||||||
|
let zoom_out = Shortcut::new(Some(Modifiers::SUPER), Code::Minus); // Cmd + -
|
||||||
|
let zoom_reset = Shortcut::new(Some(Modifiers::SUPER), Code::Digit0); // Cmd + 0
|
||||||
|
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
|
.plugin(
|
||||||
|
tauri_plugin_global_shortcut::Builder::new()
|
||||||
|
.with_handler(move |app, _shortcut, _event| {
|
||||||
|
let window = app.get_webview_window("main").unwrap();
|
||||||
|
let current = ZOOM_LEVEL.load(Ordering::SeqCst);
|
||||||
|
|
||||||
|
if _shortcut.id() == zoom_in.id() {
|
||||||
|
let new_zoom = (current + 10).min(200); // Max 200%
|
||||||
|
ZOOM_LEVEL.store(new_zoom, Ordering::SeqCst);
|
||||||
|
let _ = window.set_zoom(new_zoom as f64 / 100.0);
|
||||||
|
} else if _shortcut.id() == zoom_out.id() {
|
||||||
|
let new_zoom = (current - 10).max(50); // Min 50%
|
||||||
|
ZOOM_LEVEL.store(new_zoom, Ordering::SeqCst);
|
||||||
|
let _ = window.set_zoom(new_zoom as f64 / 100.0);
|
||||||
|
} else if _shortcut.id() == zoom_reset.id() {
|
||||||
|
ZOOM_LEVEL.store(100, Ordering::SeqCst);
|
||||||
|
let _ = window.set_zoom(1.0);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.setup(move |app| {
|
||||||
|
config::init_config();
|
||||||
|
app.global_shortcut().register(zoom_in)?;
|
||||||
|
app.global_shortcut().register(zoom_out)?;
|
||||||
|
app.global_shortcut().register(zoom_reset)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
api::health::get_health,
|
||||||
|
api::health::get_health_detailed,
|
||||||
|
api::health::get_config_info,
|
||||||
|
api::health::get_ingest_stats,
|
||||||
|
api::health::get_sftpgo_status,
|
||||||
|
api::health::get_inference_health,
|
||||||
|
api::search::search_videos,
|
||||||
|
api::search::search_chunks,
|
||||||
|
api::identity::list_identities,
|
||||||
|
api::identity::register_identity,
|
||||||
|
api::identity::get_identity_videos,
|
||||||
|
api::video::list_videos,
|
||||||
|
api::video::get_videos,
|
||||||
|
api::video::get_video_faces,
|
||||||
|
api::video::get_chunk_detail,
|
||||||
|
api::video::unregister_video,
|
||||||
|
api::person::get_person_thumbnail,
|
||||||
|
api::person::get_person_thumbnail_b64,
|
||||||
|
api::translation::translate_text,
|
||||||
|
open_devtools,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running tauri application");
|
||||||
|
}
|
||||||
32
src-tauri/tauri.conf.json
Normal file
32
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"productName": "momentry-portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.momentry.portal",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "npm run dev",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeBuildCommand": "npm run build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Momentry Portal",
|
||||||
|
"width": 1400,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 1000,
|
||||||
|
"minHeight": 700,
|
||||||
|
"resizable": true,
|
||||||
|
"zoomHotkeysEnabled": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": []
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/App.vue
Normal file
81
src/App.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gray-900 text-white">
|
||||||
|
<!-- Header (Hidden on Login page) -->
|
||||||
|
<header v-if="!isLoginPage" class="bg-gray-800 border-b border-gray-700 fixed top-0 left-0 right-0 z-50">
|
||||||
|
<div class="container mx-auto px-4 py-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold text-blue-400">Momentry Portal</h1>
|
||||||
|
<nav class="flex items-center space-x-6">
|
||||||
|
<router-link to="/home" class="hover:text-blue-400 transition">首頁</router-link>
|
||||||
|
<router-link to="/search" class="hover:text-blue-400 transition">搜尋</router-link>
|
||||||
|
<router-link to="/persons" class="hover:text-blue-400 transition">人物管理</router-link>
|
||||||
|
<router-link to="/traces" class="hover:text-blue-400 transition">Face Traces</router-link>
|
||||||
|
<router-link to="/files" class="hover:text-blue-400 transition">納管檔案</router-link>
|
||||||
|
<router-link to="/settings" class="hover:text-blue-400 transition">設定</router-link>
|
||||||
|
<router-link to="/jobs" class="hover:text-blue-400 transition">Pipeline</router-link>
|
||||||
|
<button @click="handleLogout" class="text-xs bg-red-800 hover:bg-red-700 px-2 py-1 rounded transition ml-4 text-red-100">登出</button>
|
||||||
|
<button @click="toggleDevMode" class="text-xs bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded transition ml-2" :title="showApiDemo ? '隱藏 API Console' : '顯示 API Console'">
|
||||||
|
{{ showApiDemo ? '🔧' : '🛠️' }}
|
||||||
|
</button>
|
||||||
|
<button @click="openDevTools" class="text-xs bg-gray-700 hover:bg-gray-600 px-2 py-1 rounded transition ml-2">Console</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main :class="{ 'container mx-auto px-4 py-6 pt-20': !isLoginPage }">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- API Demo (visible by default for demo/teaching, toggle via 🔧 in header) -->
|
||||||
|
<div v-if="!isLoginPage && showApiDemo" class="container mx-auto px-4 pb-8 pt-4">
|
||||||
|
<ApiDemo />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import ApiDemo from './components/ApiDemo.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const isLoginPage = computed(() => route.path === '/login')
|
||||||
|
const showApiDemo = ref(localStorage.getItem('devMode') !== 'false')
|
||||||
|
|
||||||
|
const toggleDevMode = () => {
|
||||||
|
showApiDemo.value = !showApiDemo.value
|
||||||
|
localStorage.setItem('devMode', String(showApiDemo.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDevTools = () => {
|
||||||
|
console.clear()
|
||||||
|
console.log('%c🛠️ Momentry Console', 'font-size: 18px; font-weight: bold; color: #3b82f6;')
|
||||||
|
console.log('%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'color: #3b82f6;')
|
||||||
|
console.log('%c點擊左側「Console」標籤查看輸出', 'color: #9ca3af;')
|
||||||
|
console.log('%c', '')
|
||||||
|
console.log('%c🔍 快速過濾:', 'color: #10b981; font-weight: bold;')
|
||||||
|
console.log('%c 在上方搜尋框輸入「Momentry」可只看本系統輸出', 'color: #6b7280;')
|
||||||
|
console.log('%c', '')
|
||||||
|
console.log('%c📋 操作提示:', 'color: #f59e0b; font-weight: bold;')
|
||||||
|
console.log('%c 1. 登入後點擊各功能頁面進行 API 呼叫', 'color: #d1d5db;')
|
||||||
|
console.log('%c 2. 每次呼叫會即時顯示於此 Console', 'color: #d1d5db;')
|
||||||
|
console.log('%c 3. 使用 F12 也可打開完整開發者工具', 'color: #d1d5db;')
|
||||||
|
console.log('%c', '')
|
||||||
|
console.log('%c⚡ 測試範例:', 'color: #ef4444; font-weight: bold;')
|
||||||
|
console.log('%c curl -X POST http://127.0.0.1:3003/api/v1/auth/login -H "Content-Type: application/json" -d \'{"username":"demo","password":"demo"}\'', 'color: #a78bfa; font-family: monospace;')
|
||||||
|
console.log('%c━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━', 'color: #3b82f6;')
|
||||||
|
|
||||||
|
alert('✅ Console 訊息已輸出!\n\n請按 F12 或 Cmd+Option+I 打開開發者工具查看')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('momentry_user')
|
||||||
|
// Also clear config if you want to force re-login to setup API
|
||||||
|
localStorage.removeItem('portal_config')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
583
src/api/client.ts
Normal file
583
src/api/client.ts
Normal file
@@ -0,0 +1,583 @@
|
|||||||
|
/**
|
||||||
|
* Dual-mode API client for Portal
|
||||||
|
* - In Tauri app: uses `invoke` to call Rust commands
|
||||||
|
* - In browser dev mode: uses direct HTTP fetch to backend API
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// ── Global API Debug State ──────────────────────────────────────────────
|
||||||
|
export const lastApiCall = ref<any>(null)
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PortalConfig {
|
||||||
|
api_base_url: string
|
||||||
|
api_key: string
|
||||||
|
timeout_secs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchRequest {
|
||||||
|
query: string
|
||||||
|
limit?: number
|
||||||
|
mode?: string
|
||||||
|
uuid?: string
|
||||||
|
filters?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
query: string
|
||||||
|
count: number
|
||||||
|
hits: SearchHit[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
id: string
|
||||||
|
vid: string
|
||||||
|
start_frame: number
|
||||||
|
end_frame: number
|
||||||
|
fps: number
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
text: string
|
||||||
|
score: number
|
||||||
|
title?: string
|
||||||
|
file_path?: string
|
||||||
|
has_visual_stats?: boolean
|
||||||
|
parent_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterResponse {
|
||||||
|
success: boolean
|
||||||
|
file_uuid: string
|
||||||
|
file_name: string
|
||||||
|
file_path: string
|
||||||
|
duration: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
fps: number
|
||||||
|
total_frames: number
|
||||||
|
registration_time: string | null
|
||||||
|
already_exists: boolean
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnregisterResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
file_uuid?: string
|
||||||
|
uuid?: string
|
||||||
|
deleted_face_detections?: number
|
||||||
|
deleted_processor_results?: number
|
||||||
|
deleted_chunks?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config (browser-only, stored in localStorage) ───────────────────────
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: PortalConfig = {
|
||||||
|
api_base_url: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:3003',
|
||||||
|
api_key: import.meta.env.VITE_API_KEY || '',
|
||||||
|
timeout_secs: 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig(): PortalConfig {
|
||||||
|
const stored = localStorage.getItem('portal_config')
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored)
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(config: PortalConfig): void {
|
||||||
|
localStorage.setItem('portal_config', JSON.stringify(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = getConfig();
|
||||||
|
const apiKey = config.api_key || localStorage.getItem('momentry_api_key');
|
||||||
|
if (apiKey) {
|
||||||
|
// Call logout API to invalidate session on server side (if implemented)
|
||||||
|
// For now, just best effort
|
||||||
|
await fetch(`${config.api_base_url}/api/v1/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-API-Key': apiKey }
|
||||||
|
}).catch(() => {}); // Ignore network errors
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Logout API call failed:', e);
|
||||||
|
} finally {
|
||||||
|
handleSessionExpired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Environment detection ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isTauri(): boolean {
|
||||||
|
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP fetch wrapper (browser) ────────────────────────────────────────
|
||||||
|
|
||||||
|
// Helper to handle session expiration
|
||||||
|
function handleSessionExpired() {
|
||||||
|
console.warn("Session expired or connection error, redirecting to login...");
|
||||||
|
localStorage.removeItem('momentry_user');
|
||||||
|
localStorage.removeItem('portal_config');
|
||||||
|
localStorage.removeItem('momentry_api_key');
|
||||||
|
if (window.location.pathname !== '/login') {
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry fetch logic
|
||||||
|
export async function httpFetch<T>(url: string, options?: RequestInit, retries = 3): Promise<T> {
|
||||||
|
// Re-read config to ensure we have the latest key if it changed
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
// Fallback key check
|
||||||
|
const apiKey = config.api_key || localStorage.getItem('momentry_api_key') || '';
|
||||||
|
|
||||||
|
const headers = new Headers(options?.headers);
|
||||||
|
headers.set('Content-Type', 'application/json');
|
||||||
|
if (apiKey) {
|
||||||
|
headers.set('X-API-Key', apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = options?.method || 'GET'
|
||||||
|
|
||||||
|
// Update debug state (only on first attempt)
|
||||||
|
if (retries === 3) {
|
||||||
|
lastApiCall.value = {
|
||||||
|
type: 'HTTP',
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers: { ...headers, 'X-API-Key': apiKey ? apiKey.substring(0, 10) + '...' : 'none' },
|
||||||
|
body: options?.body ? JSON.parse(options.body as string) : null,
|
||||||
|
status: 'loading',
|
||||||
|
data: null,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), config.timeout_secs * 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized immediately
|
||||||
|
if (resp.status === 401) {
|
||||||
|
handleSessionExpired();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text()
|
||||||
|
lastApiCall.value = { ...lastApiCall.value, status: `Error ${resp.status}`, data: text }
|
||||||
|
// Don't redirect on 500/404, just throw
|
||||||
|
throw new Error(`HTTP ${resp.status}: ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = resp.headers.get('content-type')
|
||||||
|
let data: any;
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
data = await resp.json()
|
||||||
|
} else {
|
||||||
|
data = await resp.text()
|
||||||
|
}
|
||||||
|
|
||||||
|
lastApiCall.value = { ...lastApiCall.value, status: `OK ${resp.status}`, data }
|
||||||
|
return data as Promise<T>
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
// Network error (server restart/timeout)
|
||||||
|
// e.name === 'TypeError' usually indicates network error in fetch
|
||||||
|
if (e.name === 'TypeError' && retries > 0) {
|
||||||
|
console.warn(`Network error, retrying... (${retries} attempts left)`);
|
||||||
|
await new Promise(r => setTimeout(r, 1000)); // Wait 1s before retry
|
||||||
|
return httpFetch(url, options, retries - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If retries exhausted or it's a different error
|
||||||
|
lastApiCall.value = { ...lastApiCall.value, status: 'Error', data: e?.message || e }
|
||||||
|
|
||||||
|
// If network error and no retries left, redirect to login
|
||||||
|
if (e.name === 'TypeError') {
|
||||||
|
handleSessionExpired();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tauriInvoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core')
|
||||||
|
|
||||||
|
lastApiCall.value = {
|
||||||
|
type: 'TAURI',
|
||||||
|
command,
|
||||||
|
args: args || {},
|
||||||
|
status: 'loading',
|
||||||
|
data: null,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<T>(command, args)
|
||||||
|
lastApiCall.value = { ...lastApiCall.value, status: 'Success', data: result }
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
lastApiCall.value = { ...lastApiCall.value, status: 'Error', data: e }
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Unified API functions ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function searchVideos(query: string, limit = 10, mode = 'vector', fileUuid?: string): Promise<SearchResult> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke<SearchResult>('search_videos', { query, limit, mode, uuid: fileUuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
const url = `${config.api_base_url}/api/v1/search/universal`
|
||||||
|
|
||||||
|
const body: any = { query, limit, mode }
|
||||||
|
if (fileUuid) body.uuid = fileUuid
|
||||||
|
|
||||||
|
const response: any = await httpFetch<any>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: response.query || query,
|
||||||
|
count: response.results?.length || 0,
|
||||||
|
hits: (response.results || []).map((r: any) => {
|
||||||
|
const chunkId = r.chunk_id || r.id || ''
|
||||||
|
return {
|
||||||
|
id: chunkId,
|
||||||
|
vid: fileUuid || '',
|
||||||
|
start_frame: r.start_frame || Math.floor((r.start_time || 0) * (r.fps || 30)),
|
||||||
|
end_frame: r.end_frame || Math.floor((r.end_time || 0) * (r.fps || 30)),
|
||||||
|
fps: r.fps || 30,
|
||||||
|
start: r.start_time || r.start || 0,
|
||||||
|
end: r.end_time || r.end || 0,
|
||||||
|
text: r.text || r.text_content || '',
|
||||||
|
score: r.score || 0,
|
||||||
|
title: r.title || r.file_name || '',
|
||||||
|
file_path: r.file_path,
|
||||||
|
has_visual_stats: !!r.visual_stats,
|
||||||
|
parent_id: r.parent_chunk_id,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchChunks(query: string, uuid?: string): Promise<SearchResult> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke<SearchResult>('search_chunks', { query, uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
const url = `${config.api_base_url}/api/v1/search/universal`
|
||||||
|
|
||||||
|
const response: any = await httpFetch<any>(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ query, uuid, limit: 20 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
query: response.query || query,
|
||||||
|
count: response.results?.length || 0,
|
||||||
|
hits: (response.results || []).map((r: any) => ({
|
||||||
|
id: r.chunk_id || r.id,
|
||||||
|
vid: uuid || r.uuid || r.vid || r.file_uuid || '',
|
||||||
|
start_frame: Math.floor((r.start_time || 0) * (r.fps || 30)),
|
||||||
|
end_frame: Math.floor((r.end_time || 0) * (r.fps || 30)),
|
||||||
|
fps: r.fps || 30,
|
||||||
|
start: r.start_time || r.start || 0,
|
||||||
|
end: r.end_time || r.end || 0,
|
||||||
|
text: r.text || r.text_content || '',
|
||||||
|
score: r.score || 0,
|
||||||
|
title: r.title || r.file_name || '',
|
||||||
|
file_path: r.file_path,
|
||||||
|
has_visual_stats: !!r.visual_stats,
|
||||||
|
parent_id: r.parent_chunk_id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHealth(): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_health_detailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch(`${config.api_base_url}/health/detailed`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIngestStats(): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_ingest_stats')
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/stats/ingest`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSftpgoStatus(): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_sftpgo_status')
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/stats/sftpgo`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInferenceHealth(): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_inference_health')
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/stats/inference`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideosResponse {
|
||||||
|
success: boolean
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
data: VideoItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VideoItem {
|
||||||
|
file_uuid: string
|
||||||
|
file_name: string
|
||||||
|
file_path: string
|
||||||
|
file_type: string | null
|
||||||
|
file_size: number | null
|
||||||
|
duration: number | null
|
||||||
|
width: number | null
|
||||||
|
height: number | null
|
||||||
|
fps: number | null
|
||||||
|
status: string
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
registration_time: string | null
|
||||||
|
processing_status?: any
|
||||||
|
probe_json?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getVideos(
|
||||||
|
query?: string,
|
||||||
|
status?: string,
|
||||||
|
page: number = 1,
|
||||||
|
page_size: number = 10,
|
||||||
|
file_uuid?: string
|
||||||
|
): Promise<VideosResponse> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke<VideosResponse>('get_videos', { query, status, page, page_size, uuid: file_uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (query) params.append('q', query)
|
||||||
|
if (status) params.append('status', status)
|
||||||
|
if (file_uuid) params.append('file_uuid', file_uuid)
|
||||||
|
params.append('page', String(page))
|
||||||
|
params.append('page_size', String(page_size))
|
||||||
|
|
||||||
|
return httpFetch<VideosResponse>(`${config.api_base_url}/api/v1/files?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIdentities(uuid?: string): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('list_identities', { uuid })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
const url = uuid
|
||||||
|
? `${config.api_base_url}/api/v1/identities?uuid=${encodeURIComponent(uuid)}`
|
||||||
|
: `${config.api_base_url}/api/v1/identities`
|
||||||
|
|
||||||
|
return httpFetch(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function translateText(text: string, targetLang: string = 'zh-TW'): Promise<string> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke<string>('translate_text', { text, target_lang: targetLang })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use our internal Agent API
|
||||||
|
const config = getConfig()
|
||||||
|
const response = await httpFetch<any>(`${config.api_base_url}/api/v1/agents/translate`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
text,
|
||||||
|
target_language: targetLang
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.success && response.translated_text) {
|
||||||
|
return response.translated_text
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Translation Agent failed:', e)
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPersonThumbnail(fileUuid: string, traceId?: number): Promise<string> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke<string>('get_person_thumbnail_b64', { file_uuid: fileUuid, trace_id: traceId })
|
||||||
|
}
|
||||||
|
const config = getConfig()
|
||||||
|
if (traceId !== undefined) {
|
||||||
|
return `${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/video`
|
||||||
|
}
|
||||||
|
return `${config.api_base_url}/api/v1/file/${fileUuid}/thumbnail`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerIdentity(name: string, images: string[]): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('register_identity', { name, images })
|
||||||
|
}
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/identity`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ face_json_path: images[0] || '', identity_name: name }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processVideo(uuid: string, processors?: string[]): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('process_video', { uuid, processors })
|
||||||
|
}
|
||||||
|
const config = getConfig()
|
||||||
|
const body = processors ? { processors } : {}
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/file/${uuid}/process`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerVideo(filePath: string): Promise<RegisterResponse> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke<RegisterResponse>('register_video', { file_path: filePath })
|
||||||
|
}
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch<RegisterResponse>(`${config.api_base_url}/api/v1/files/register`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ file_path: filePath }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unregisterVideo(fileUuid: string): Promise<UnregisterResponse> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke<UnregisterResponse>('unregister_video', { file_uuid: fileUuid })
|
||||||
|
}
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch<UnregisterResponse>(`${config.api_base_url}/api/v1/unregister`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ file_uuid: fileUuid }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFaceCandidates(fileUuid?: string, minConfidence = 0.5, page = 1, pageSize = 20): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('list_face_candidates', { file_uuid: fileUuid, min_confidence: minConfidence, page, page_size: pageSize })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (fileUuid) params.append('file_uuid', fileUuid)
|
||||||
|
params.append('min_confidence', String(minConfidence))
|
||||||
|
params.append('page', String(page))
|
||||||
|
params.append('page_size', String(pageSize))
|
||||||
|
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/faces/candidates?${params.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTracesSorted(
|
||||||
|
fileUuid: string,
|
||||||
|
sortBy = 'face_count',
|
||||||
|
limit = 100,
|
||||||
|
minFaces = 1
|
||||||
|
): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('list_traces_sorted', { file_uuid: fileUuid, sort_by: sortBy, limit, min_faces: minFaces })
|
||||||
|
}
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/face_trace/sortby`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ sort_by: sortBy, limit, min_faces: minFaces }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed query text using EmbeddingGemma with fallback.
|
||||||
|
* Tries M5 (192.168.110.201:11436) first, falls back to M4 localhost.
|
||||||
|
*/
|
||||||
|
export async function embedQuery(text: string): Promise<number[]> {
|
||||||
|
const servers = [
|
||||||
|
'http://192.168.110.201:11436/v1/embeddings',
|
||||||
|
'http://localhost:11436/v1/embeddings',
|
||||||
|
]
|
||||||
|
for (const url of servers) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ input: text, model: 'embeddinggemma-300m' }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
if (!res.ok) continue
|
||||||
|
const data = await res.json()
|
||||||
|
if (data?.data?.[0]?.embedding) return data.data[0].embedding
|
||||||
|
} catch { continue }
|
||||||
|
}
|
||||||
|
throw new Error('Embedding servers unreachable')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listTraceFaces(fileUuid: string, traceId: number, limit = 200, offset = 0): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('list_trace_faces', { file_uuid: fileUuid, trace_id: traceId, limit, offset })
|
||||||
|
}
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/trace/${traceId}/faces?limit=${limit}&offset=${offset}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIdentityFaces(identityId: number, page = 1, pageSize = 100): Promise<any> {
|
||||||
|
if (isTauri()) {
|
||||||
|
return tauriInvoke('get_identity_faces', { identity_id: identityId, page, page_size: pageSize })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig()
|
||||||
|
return httpFetch(`${config.api_base_url}/api/v1/identity/${identityId}/files?page_size=${pageSize}&offset=${(page-1)*pageSize}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getCurrentConfig(): PortalConfig {
|
||||||
|
if (isTauri()) {
|
||||||
|
return getConfig() // Will be overridden by Tauri config if needed
|
||||||
|
}
|
||||||
|
return getConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isTauri }
|
||||||
8
src/assets/main.css
Normal file
8
src/assets/main.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-gray-900 text-gray-100;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
102
src/components/ApiDemo.vue
Normal file
102
src/components/ApiDemo.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="apiCall" class="mt-6 border-t border-gray-600 bg-gray-800 rounded-lg p-4 text-sm shadow-lg">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="text-lg font-bold text-blue-400 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
API 呼叫示範 (Real-time)
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="copyToClipboard"
|
||||||
|
class="px-2 py-1 bg-gray-700 hover:bg-gray-600 text-gray-300 text-xs rounded border border-gray-600 transition flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<svg v-if="!isCopied" class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3"></path></svg>
|
||||||
|
<svg v-else class="w-3 h-3 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||||
|
{{ isCopied ? '已複製' : 'Copy Text' }}
|
||||||
|
</button>
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-mono" :class="statusColor">
|
||||||
|
{{ apiCall.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<!-- Request -->
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-700">
|
||||||
|
<div class="text-gray-400 mb-1 text-xs uppercase tracking-wider">Request ({{ apiCall.type }})</div>
|
||||||
|
<div class="text-white font-mono break-all">
|
||||||
|
<span v-if="apiCall.type === 'HTTP'" class="text-green-400">{{ apiCall.method }}</span>
|
||||||
|
<span v-if="apiCall.type === 'HTTP'" class="text-gray-500 mx-1">→</span>
|
||||||
|
<span v-if="apiCall.type === 'HTTP'" class="text-blue-300">{{ apiCall.url }}</span>
|
||||||
|
<span v-if="apiCall.type === 'TAURI'" class="text-green-400">Command:</span>
|
||||||
|
<span v-if="apiCall.type === 'TAURI'" class="text-blue-300 ml-1">{{ apiCall.command }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="apiCall.body" class="mt-2 text-xs text-gray-300 font-mono bg-gray-800 p-2 rounded overflow-x-auto">
|
||||||
|
Body: {{ JSON.stringify(apiCall.body, null, 2) }}
|
||||||
|
</div>
|
||||||
|
<div v-if="apiCall.args && apiCall.type === 'TAURI'" class="mt-2 text-xs text-gray-300 font-mono bg-gray-800 p-2 rounded overflow-x-auto">
|
||||||
|
Args: {{ JSON.stringify(apiCall.args, null, 2) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Response -->
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-700 relative">
|
||||||
|
<div class="text-gray-400 mb-1 text-xs uppercase tracking-wider">Response</div>
|
||||||
|
<pre class="text-xs text-gray-300 font-mono overflow-auto max-h-48 whitespace-pre-wrap break-all">{{ formatResponse(apiCall.data) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { lastApiCall } from '@/api/client'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const apiCall = lastApiCall
|
||||||
|
const isCopied = ref(false)
|
||||||
|
|
||||||
|
const statusColor = computed(() => {
|
||||||
|
const s = apiCall.value?.status || ''
|
||||||
|
if (s === 'loading') return 'bg-yellow-900 text-yellow-300'
|
||||||
|
if (s.includes('OK') || s === 'Success') return 'bg-green-900 text-green-300'
|
||||||
|
return 'bg-red-900 text-red-300'
|
||||||
|
})
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (!apiCall.value) return
|
||||||
|
|
||||||
|
const data = apiCall.value
|
||||||
|
const text = `
|
||||||
|
=== Momentry API Call Details ===
|
||||||
|
Type: ${data.type}
|
||||||
|
Status: ${data.status}
|
||||||
|
Time: ${data.timestamp}
|
||||||
|
|
||||||
|
${data.type === 'HTTP' ? `[${data.method}] ${data.url}` : `Command: ${data.command}`}
|
||||||
|
Arguments:
|
||||||
|
${JSON.stringify(data.args || data.body, null, 2)}
|
||||||
|
|
||||||
|
Response:
|
||||||
|
${JSON.stringify(data.data, null, 2)}
|
||||||
|
`.trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
isCopied.value = true
|
||||||
|
setTimeout(() => isCopied.value = false, 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy text:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResponse(data: any): string {
|
||||||
|
if (!data) return 'Empty'
|
||||||
|
try {
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
} catch {
|
||||||
|
return String(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
174
src/components/Face3DViewer.vue
Normal file
174
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>
|
||||||
350
src/components/FaceTraceTimeline.vue
Normal file
350
src/components/FaceTraceTimeline.vue
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-semibold flex items-center gap-2">
|
||||||
|
<span>臉部追蹤</span>
|
||||||
|
<span class="text-sm text-gray-400 font-normal">({{ totalTraces }} 個追蹤, {{ totalFaces }} 個臉孔)</span>
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select v-model="sortBy" @change="loadTraces"
|
||||||
|
class="bg-gray-700 text-sm rounded px-3 py-1.5 border border-gray-600">
|
||||||
|
<option value="face_count">臉孔數</option>
|
||||||
|
<option value="duration">持續時間</option>
|
||||||
|
<option value="first_appearance">首次出現</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="limit" @change="loadTraces"
|
||||||
|
class="bg-gray-700 text-sm rounded px-3 py-1.5 border border-gray-600">
|
||||||
|
<option :value="50">50 筆</option>
|
||||||
|
<option :value="100">100 筆</option>
|
||||||
|
<option :value="500">500 筆</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Filter Bar -->
|
||||||
|
<div class="bg-gray-750 rounded-lg p-4 border border-gray-700 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-400 text-xs block mb-1">最少臉孔</label>
|
||||||
|
<input type="number" min="1" max="100" v-model.number="filterMinFaces"
|
||||||
|
@change="loadTraces"
|
||||||
|
class="w-full bg-gray-700 rounded px-2 py-1.5 border border-gray-600 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-400 text-xs block mb-1">最小信心</label>
|
||||||
|
<input type="range" min="0" max="100" v-model.number="filterMinConfPct"
|
||||||
|
@change="loadTraces"
|
||||||
|
class="w-full accent-blue-500" />
|
||||||
|
<span class="text-gray-500 text-xs">{{ filterMinConfPct }}%</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-400 text-xs block mb-1">最大信心</label>
|
||||||
|
<input type="range" min="0" max="100" v-model.number="filterMaxConfPct"
|
||||||
|
@change="loadTraces"
|
||||||
|
class="w-full accent-blue-500" />
|
||||||
|
<span class="text-gray-500 text-xs">{{ filterMaxConfPct }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<button @click="resetFilters"
|
||||||
|
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded text-xs transition">
|
||||||
|
重設
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Timeline Bar Chart -->
|
||||||
|
<div v-if="Object.keys(traces).length > 0" class="bg-gray-800 rounded-lg p-4 border border-gray-700 overflow-x-auto">
|
||||||
|
<div class="relative" :style="{ height: timelineHeight + 'px' }">
|
||||||
|
<!-- Time axis -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 flex text-xs text-gray-500">
|
||||||
|
<div v-for="t in timeTicks" :key="t"
|
||||||
|
class="flex-1 border-l border-gray-700 pl-1">
|
||||||
|
{{ t }}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Trace bars -->
|
||||||
|
<div v-for="(trace, i) in topTracesForTimeline" :key="trace.trace_id"
|
||||||
|
class="absolute left-0 right-0 flex items-center cursor-pointer hover:opacity-80 transition"
|
||||||
|
:style="{
|
||||||
|
bottom: barPosition(i) + '%',
|
||||||
|
height: barHeight() + '%'
|
||||||
|
}"
|
||||||
|
@click="toggleExpand(trace.trace_id)">
|
||||||
|
<div class="h-full rounded-sm transition-all"
|
||||||
|
:style="{
|
||||||
|
width: barWidthPct(trace) + '%',
|
||||||
|
backgroundColor: barColor(trace.avg_confidence),
|
||||||
|
marginLeft: barOffsetPct(trace) + '%'
|
||||||
|
}"
|
||||||
|
:title="`#${trace.trace_id}: ${trace.face_count} faces`">
|
||||||
|
</div>
|
||||||
|
<span v-if="barWidthPct(trace) > 8"
|
||||||
|
class="absolute left-1 text-xs text-white truncate pointer-events-none">
|
||||||
|
#{{ trace.trace_id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grid -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-8">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="bg-red-900/30 text-red-300 p-4 rounded-lg text-sm">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="Object.keys(traces).length === 0" class="text-gray-500 text-center py-8 text-sm">
|
||||||
|
尚無臉部追蹤資料
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
<div v-for="trace in traces" :key="trace.trace_id"
|
||||||
|
class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden hover:border-blue-500/50 transition cursor-pointer"
|
||||||
|
@click="toggleExpand(trace.trace_id)">
|
||||||
|
<div class="aspect-video bg-gray-900 relative overflow-hidden">
|
||||||
|
<img v-if="trace.sample_face_id"
|
||||||
|
:src="`${apiBase}/api/v1/file/${fileUuid}/trace/${trace.trace_id}/video`"
|
||||||
|
:alt="`Trace ${trace.trace_id}`"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy" />
|
||||||
|
<div class="absolute top-2 left-2 bg-black/70 text-xs px-2 py-0.5 rounded font-mono">
|
||||||
|
#{{ trace.trace_id }}
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-2 right-2 bg-black/70 text-xs px-2 py-0.5 rounded"
|
||||||
|
:class="confidenceColor(trace.avg_confidence)">
|
||||||
|
{{ (trace.avg_confidence * 100).toFixed(0) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 text-sm space-y-1">
|
||||||
|
<div class="flex justify-between text-gray-400">
|
||||||
|
<span>臉孔: <strong class="text-white">{{ trace.face_count }}</strong></span>
|
||||||
|
<span>{{ trace.first_sec.toFixed(1) }}s - {{ trace.last_sec.toFixed(1) }}s</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between text-gray-400">
|
||||||
|
<span>幀: {{ trace.first_frame }}-{{ trace.last_frame }}</span>
|
||||||
|
<span>持續 {{ trace.duration_sec.toFixed(1) }}s</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-700 rounded-full h-1 mt-1">
|
||||||
|
<div class="bg-blue-500 h-1 rounded-full transition-all"
|
||||||
|
:style="{ width: barWidth(trace) }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Expandable Detail -->
|
||||||
|
<div v-if="expandedTrace === trace.trace_id"
|
||||||
|
class="border-t border-gray-700 bg-gray-850"
|
||||||
|
@click.stop>
|
||||||
|
<div class="p-3">
|
||||||
|
<div v-if="loadingFaces[trace.trace_id]" class="flex justify-center py-4">
|
||||||
|
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="faceErrors[trace.trace_id]" class="text-red-400 text-xs">
|
||||||
|
{{ faceErrors[trace.trace_id] }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="traceFaces[trace.trace_id]?.length" class="space-y-2">
|
||||||
|
<div class="text-xs text-gray-500 mb-2">
|
||||||
|
共 {{ faceTotals[trace.trace_id] || 0 }} 個臉孔偵測
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-1.5 max-h-48 overflow-y-auto">
|
||||||
|
<div v-for="face in traceFaces[trace.trace_id]" :key="face.id"
|
||||||
|
class="relative aspect-square bg-gray-900 rounded overflow-hidden group"
|
||||||
|
:class="face.interpolated ? 'opacity-40' : ''">
|
||||||
|
<img v-if="!face.interpolated"
|
||||||
|
:src="`${apiBase}/api/v1/file/${fileUuid}/thumbnail?frame=${face.start_frame}&x=${face.x}&y=${face.y}&w=${face.width}&h=${face.height}`"
|
||||||
|
:alt="`Frame ${face.start_frame}`"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
@error="onImgError" />
|
||||||
|
<div v-else
|
||||||
|
class="w-full h-full flex items-center justify-center border border-dashed border-gray-600 rounded text-gray-600 text-[9px]">
|
||||||
|
{{ face.start_frame }}
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-0 inset-x-0 bg-black/70 text-[9px] text-gray-300 px-1 truncate opacity-0 group-hover:opacity-100 transition">
|
||||||
|
#{{ face.start_frame }} {{ face.interpolated ? '' : (face.confidence * 100).toFixed(0) + '%' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { getCurrentConfig, httpFetch } from '@/api/client'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
fileUuid: string
|
||||||
|
totalDuration: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const apiBase = ref('')
|
||||||
|
const traces = ref<any[]>([])
|
||||||
|
const totalTraces = ref(0)
|
||||||
|
const totalFaces = ref(0)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const sortBy = ref('face_count')
|
||||||
|
const limit = ref(100)
|
||||||
|
|
||||||
|
// Filter state
|
||||||
|
const filterMinFaces = ref(1)
|
||||||
|
const filterMinConfPct = ref(0)
|
||||||
|
const filterMaxConfPct = ref(100)
|
||||||
|
|
||||||
|
// Expanded trace detail
|
||||||
|
const expandedTrace = ref<number | null>(null)
|
||||||
|
const traceFaces = ref<Record<number, any[]>>({})
|
||||||
|
const faceTotals = ref<Record<number, number>>({})
|
||||||
|
const loadingFaces = ref<Record<number, boolean>>({})
|
||||||
|
const faceErrors = ref<Record<number, string>>({})
|
||||||
|
|
||||||
|
const duration = computed(() => props.totalDuration || 1 || 3000)
|
||||||
|
|
||||||
|
// Step 2: Timeline helpers
|
||||||
|
const timelineMaxTraces = 30
|
||||||
|
const timelineHeight = 120
|
||||||
|
const topTracesForTimeline = computed(() => {
|
||||||
|
const sorted = [...traces.value].sort((a, b) => b.face_count - a.face_count)
|
||||||
|
return sorted.slice(0, timelineMaxTraces)
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeTicks = computed(() => {
|
||||||
|
const dur = duration.value
|
||||||
|
const step = Math.max(30, Math.round(dur / 10 / 30) * 30)
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let t = 0; t <= dur; t += step) {
|
||||||
|
ticks.push(t)
|
||||||
|
}
|
||||||
|
return ticks
|
||||||
|
})
|
||||||
|
|
||||||
|
function barPosition(index: number): number {
|
||||||
|
const count = topTracesForTimeline.value.length
|
||||||
|
const gap = 1
|
||||||
|
const barH = Math.max(8, (100 - gap * (count + 1)) / count)
|
||||||
|
return gap + index * (barH + gap)
|
||||||
|
}
|
||||||
|
|
||||||
|
function barHeight(): number {
|
||||||
|
const count = topTracesForTimeline.value.length
|
||||||
|
const gap = 1
|
||||||
|
const barH = Math.max(8, (100 - gap * (count + 1)) / count)
|
||||||
|
return barH
|
||||||
|
}
|
||||||
|
|
||||||
|
function barWidthPct(trace: any): number {
|
||||||
|
const dur = duration.value
|
||||||
|
if (!dur) return 0
|
||||||
|
return Math.max(0.5, ((trace.last_sec || 1) - (trace.first_sec || 0)) / dur * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function barOffsetPct(trace: any): number {
|
||||||
|
const dur = duration.value
|
||||||
|
if (!dur) return 0
|
||||||
|
return ((trace.first_sec || 0) / dur) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function barColor(conf: number): string {
|
||||||
|
if (conf >= 0.8) return 'rgba(74, 222, 128, 0.7)'
|
||||||
|
if (conf >= 0.6) return 'rgba(250, 204, 21, 0.7)'
|
||||||
|
return 'rgba(248, 113, 113, 0.7)'
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidenceColor(conf: number): string {
|
||||||
|
if (conf >= 0.8) return 'text-green-400'
|
||||||
|
if (conf >= 0.6) return 'text-yellow-400'
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
function barWidth(trace: any): string {
|
||||||
|
const pct = totalTraces.value > 0
|
||||||
|
? (trace.face_count / (totalFaces.value || 1)) * 100
|
||||||
|
: 0
|
||||||
|
return `${Math.min(pct, 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImgError(e: Event) {
|
||||||
|
const el = e.target as HTMLImageElement
|
||||||
|
el.style.display = 'none'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filterMinFaces.value = 1
|
||||||
|
filterMinConfPct.value = 0
|
||||||
|
filterMaxConfPct.value = 100
|
||||||
|
loadTraces()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleExpand(traceId: number) {
|
||||||
|
if (expandedTrace.value === traceId) {
|
||||||
|
expandedTrace.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expandedTrace.value = traceId
|
||||||
|
if (!traceFaces.value[traceId]) {
|
||||||
|
await loadTraceFaces(traceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTraceFaces(traceId: number) {
|
||||||
|
loadingFaces.value[traceId] = true
|
||||||
|
faceErrors.value[traceId] = ''
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const data = await httpFetch<any>(
|
||||||
|
`${config.api_base_url}/api/v1/file/${props.fileUuid}/trace/${traceId}/faces?limit=200&interpolate=true`,
|
||||||
|
)
|
||||||
|
traceFaces.value[traceId] = data.faces || []
|
||||||
|
faceTotals.value[traceId] = data.total || 0
|
||||||
|
} catch (e: any) {
|
||||||
|
faceErrors.value[traceId] = e?.message || '載入失敗'
|
||||||
|
} finally {
|
||||||
|
loadingFaces.value[traceId] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTraces() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
apiBase.value = config.api_base_url
|
||||||
|
|
||||||
|
const apiSort = sortBy.value === 'face_count' ? 'face_count'
|
||||||
|
: sortBy.value === 'duration' ? 'duration'
|
||||||
|
: 'first_appearance'
|
||||||
|
|
||||||
|
const data = await httpFetch<any>(
|
||||||
|
`${config.api_base_url}/api/v1/file/${props.fileUuid}/face_trace/sortby`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
sort_by: apiSort,
|
||||||
|
limit: limit.value,
|
||||||
|
min_faces: filterMinFaces.value,
|
||||||
|
min_confidence: filterMinConfPct.value / 100,
|
||||||
|
max_confidence: filterMaxConfPct.value / 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
traces.value = data.traces || []
|
||||||
|
totalTraces.value = data.total_traces || 0
|
||||||
|
totalFaces.value = data.total_faces || 0
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message || '載入臉部追蹤資料失敗'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTraces()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
80
src/components/IdentitySwimlane.vue
Normal file
80
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>
|
||||||
37
src/components/PersonThumbnail.vue
Normal file
37
src/components/PersonThumbnail.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-16 h-16 bg-gray-700 rounded-lg overflow-hidden border border-gray-600 flex-shrink-0 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
v-if="src"
|
||||||
|
:src="src"
|
||||||
|
alt="Person"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span v-else-if="loading" class="text-gray-500 text-xs">...</span>
|
||||||
|
<svg v-else class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { getPersonThumbnail } from '@/api/client'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
personId: string
|
||||||
|
videoUuid?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const src = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
src.value = await getPersonThumbnail(props.personId)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load thumbnail', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
33
src/components/ServiceStatusCard.vue
Normal file
33
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
src/components/SpaceTimeCube.vue
Normal file
354
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
src/components/TraceDurationHistogram.vue
Normal file
41
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
src/components/TraceSimilarityMatrix.vue
Normal file
63
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
src/components/TraceThumbnailTimeline.vue
Normal file
86
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>
|
||||||
66
src/components/TranslatableText.vue
Normal file
66
src/components/TranslatableText.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
v-model="targetLang"
|
||||||
|
class="bg-gray-700 text-white text-xs px-2 py-1 rounded border border-gray-600 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="zh-TW">繁體中文</option>
|
||||||
|
<option value="zh-CN">简体中文</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="translate"
|
||||||
|
:disabled="loading || !props.text"
|
||||||
|
class="text-xs bg-blue-900 text-blue-300 hover:bg-blue-800 px-2 py-1 rounded transition flex items-center gap-1 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<span v-if="loading" class="animate-pulse">翻譯中...</span>
|
||||||
|
<span v-else>🌐 翻譯</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showTranslation" class="mt-3 p-3 bg-gray-900 border border-green-600 rounded text-green-300 text-sm leading-relaxed">
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
|
<span class="text-xs font-bold text-green-500 uppercase">Translation ({{ targetLang }})</span>
|
||||||
|
<button @click="showTranslation = false" class="text-gray-500 hover:text-white text-xs">✕</button>
|
||||||
|
</div>
|
||||||
|
{{ translatedText }}
|
||||||
|
</div>
|
||||||
|
<div v-if="errorMsg" class="mt-2 text-xs text-red-400">{{ errorMsg }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { translateText } from '@/api/client'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
text: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const targetLang = ref('zh-TW')
|
||||||
|
const translatedText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const showTranslation = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
const translate = async () => {
|
||||||
|
if (!props.text.trim()) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
translatedText.value = await translateText(props.text, targetLang.value)
|
||||||
|
showTranslation.value = true
|
||||||
|
} catch (error) {
|
||||||
|
errorMsg.value = '翻譯失敗: ' + (error as any)?.message || String(error)
|
||||||
|
showTranslation.value = false
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
10
src/main.ts
Normal file
10
src/main.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
118
src/router.ts
Normal file
118
src/router.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import HomeView from './views/HomeView.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('./views/LoginView.vue'),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: '/home'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home',
|
||||||
|
name: 'home',
|
||||||
|
component: HomeView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/search',
|
||||||
|
name: 'search',
|
||||||
|
component: () => import('./views/SearchView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/persons',
|
||||||
|
name: 'persons',
|
||||||
|
component: () => import('./views/PersonsView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/traces',
|
||||||
|
name: 'traces',
|
||||||
|
component: () => import('./views/FaceCandidatesView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/files',
|
||||||
|
name: 'files',
|
||||||
|
component: () => import('./views/FilesView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
component: () => import('./views/SettingsView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/file/:file_uuid',
|
||||||
|
name: 'file-detail',
|
||||||
|
component: () => import('./views/VideoDetailView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/chunk-detail/:file_uuid/:chunk_id',
|
||||||
|
name: 'chunk-detail',
|
||||||
|
component: () => import('./views/ChunkDetailView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/identity/:identity_uuid',
|
||||||
|
name: 'identity-detail',
|
||||||
|
component: () => import('./views/IdentityDetailView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/jobs',
|
||||||
|
name: 'pipeline-progress',
|
||||||
|
component: () => import('./views/PipelineProgressView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/traces/:file_uuid/:trace_id',
|
||||||
|
name: 'trace-detail',
|
||||||
|
component: () => import('./views/TraceDetailView.vue'),
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/trace-viz/:file_uuid',
|
||||||
|
name: 'trace-viz',
|
||||||
|
component: () => import('./views/TraceVizView.vue'),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'not-found',
|
||||||
|
component: () => import('./views/NotFoundView.vue'),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
scrollBehavior() {
|
||||||
|
return { top: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
const user = localStorage.getItem('momentry_user')
|
||||||
|
|
||||||
|
// If route requires auth and user is not logged in, redirect to login
|
||||||
|
if (to.meta.requiresAuth !== false && !user) {
|
||||||
|
next('/login')
|
||||||
|
}
|
||||||
|
// If user is logged in and trying to access login, redirect to home
|
||||||
|
else if (to.path === '/login' && user) {
|
||||||
|
next('/home')
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
332
src/views/ChunkDetailView.vue
Normal file
332
src/views/ChunkDetailView.vue
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button @click="goBack" class="text-gray-400 hover:text-white">
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">Chunk Detail</h2>
|
||||||
|
<p class="text-sm text-gray-400">{{ chunkId }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="text-center py-12 text-gray-500">載入中...</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else-if="detail" class="grid gap-6">
|
||||||
|
<!-- Basic Info Card -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-blue-400 mb-4">基本資訊</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Chunk Type</span>
|
||||||
|
<p class="text-white">{{ detail.chunk_type }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Parent ID</span>
|
||||||
|
<p class="text-white">{{ detail.parent_id || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">Duration</span>
|
||||||
|
<p class="text-white">{{ detail.frame_range.duration_frames }} frames</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">FPS</span>
|
||||||
|
<p class="text-white">{{ detail.frame_range.fps }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timecode Card -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-green-400 mb-4">時間軸</h3>
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
|
<!-- Frame Range -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Frame Range (精確)</h4>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Start</span>
|
||||||
|
<p class="text-xl font-mono text-white">{{ detail.frame_range.start_frame }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600">→</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-xs text-gray-500">End</span>
|
||||||
|
<p class="text-xl font-mono text-white">{{ detail.frame_range.end_frame }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reference Time -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<h4 class="text-sm font-medium text-gray-400 mb-2">Time (參考)</h4>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500">Start</span>
|
||||||
|
<p class="text-xl font-mono text-white">{{ detail.reference_time.start.toFixed(2) }}s</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600">→</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<span class="text-xs text-gray-500">End</span>
|
||||||
|
<p class="text-xl font-mono text-white">{{ detail.reference_time.end.toFixed(2) }}s</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text Content Card -->
|
||||||
|
<div v-if="detail.text_content" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-purple-400 mb-4">文字內容</h3>
|
||||||
|
<p class="text-lg leading-relaxed whitespace-pre-wrap">{{ detail.text_content }}</p>
|
||||||
|
<TranslatableText :text="detail.text_content" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Text Card -->
|
||||||
|
<div v-if="detail.summary_text" class="bg-gray-800 rounded-lg p-6 border border-green-800">
|
||||||
|
<h3 class="text-lg font-semibold text-green-400 mb-4">區塊摘要 (Summary)</h3>
|
||||||
|
<p class="text-lg leading-relaxed text-white italic">"{{ detail.summary_text }}"</p>
|
||||||
|
<TranslatableText :text="detail.summary_text" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5W1H Metadata Card -->
|
||||||
|
<div v-if="detail.metadata?.structured_summary" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-yellow-400 mb-4">5W1H 分析結果</h3>
|
||||||
|
|
||||||
|
<!-- Meta info (compact row) -->
|
||||||
|
<div class="flex flex-wrap gap-4 mb-4 text-sm">
|
||||||
|
<div v-if="detail.metadata.auto_generated_by" class="flex items-center space-x-2 bg-gray-900 px-3 py-1 rounded">
|
||||||
|
<span class="text-gray-500">Generated by</span>
|
||||||
|
<span class="text-blue-400 font-medium">{{ detail.metadata.auto_generated_by }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.metadata.chunk_count" class="flex items-center space-x-2 bg-gray-900 px-3 py-1 rounded">
|
||||||
|
<span class="text-gray-500">Chunk count</span>
|
||||||
|
<span class="text-green-400 font-medium">{{ detail.metadata.chunk_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5W1H Grid (main analysis from structured_summary) -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div v-for="(value, key) in structuredSummary" :key="key" class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider">{{ formatKey(key) }}</span>
|
||||||
|
<p class="text-white mt-1">{{ formatMetadataValue(value) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary 5 lines (if exists) -->
|
||||||
|
<div v-if="detail.metadata.structured_summary?.summary_5lines" class="mt-4 bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs font-bold text-gray-500 uppercase tracking-wider">Summary</span>
|
||||||
|
<p class="text-white mt-2 whitespace-pre-line">{{ detail.metadata.structured_summary.summary_5lines }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="detail.metadata" class="bg-gray-800 rounded-lg p-6 border border-gray-700 opacity-60">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-400 mb-2">5W1H 分析結果</h3>
|
||||||
|
<p class="text-gray-500 text-sm">此片段已有 metadata 但缺少 structured_summary。</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="bg-gray-800 rounded-lg p-6 border border-gray-700 opacity-60">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-400 mb-2">5W1H 分析結果</h3>
|
||||||
|
<p class="text-gray-500 text-sm">此片段尚未關聯到 5W1H 分析區塊 (Parent Chunk)。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual Stats Card -->
|
||||||
|
<div v-if="hasVisualStats" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-cyan-400 mb-4">視覺分析 (Visual Stats)</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<!-- YOLO Objects -->
|
||||||
|
<div v-if="visualStats.yolo" class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<span class="text-cyan-400">📦</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-300">YOLO Objects</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="visualStats.yolo.objects?.length" class="space-y-1">
|
||||||
|
<div v-for="obj in visualStats.yolo.objects.slice(0, 5)" :key="obj.class" class="flex justify-between text-sm">
|
||||||
|
<span class="text-gray-400">{{ obj.class }}</span>
|
||||||
|
<span class="text-white">{{ obj.count }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="visualStats.yolo.objects.length > 5" class="text-xs text-gray-500">
|
||||||
|
+{{ visualStats.yolo.objects.length - 5 }} more
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-500">無物件數據</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pose Results -->
|
||||||
|
<div v-if="visualStats.pose" class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<span class="text-purple-400">🧍</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-300">Pose</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="visualStats.pose.persons?.length" class="space-y-1">
|
||||||
|
<div class="text-sm text-white">{{ visualStats.pose.persons.length }} persons detected</div>
|
||||||
|
<div v-if="visualStats.pose.keypoints" class="text-xs text-gray-400">
|
||||||
|
{{ visualStats.pose.keypoints }} keypoints
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-500">無姿態數據</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Face Results -->
|
||||||
|
<div v-if="visualStats.face" class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<span class="text-yellow-400">👤</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-300">Faces</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="visualStats.face.faces?.length" class="space-y-1">
|
||||||
|
<div class="text-sm text-white">{{ visualStats.face.faces.length }} faces detected</div>
|
||||||
|
<div v-if="visualStats.face.identities" class="text-xs text-gray-400">
|
||||||
|
{{ visualStats.face.identities }} identified
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-500">無面部數據</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OCR Results -->
|
||||||
|
<div v-if="visualStats.ocr" class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<span class="text-green-400">📝</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-300">OCR</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="visualStats.ocr.texts?.length" class="space-y-1">
|
||||||
|
<div v-for="text in visualStats.ocr.texts.slice(0, 3)" :key="text" class="text-sm text-gray-300 truncate">
|
||||||
|
"{{ text }}"
|
||||||
|
</div>
|
||||||
|
<div v-if="visualStats.ocr.texts.length > 3" class="text-xs text-gray-500">
|
||||||
|
+{{ visualStats.ocr.texts.length - 3 }} more
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-sm text-gray-500">無文字數據</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="bg-gray-800 rounded-lg p-6 border border-gray-700 opacity-60">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-400 mb-2">視覺分析 (Visual Stats)</h3>
|
||||||
|
<p class="text-gray-500 text-sm">此片段尚無視覺分析數據 (YOLO、Pose、Face、OCR)。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Raw Content Card -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-400 mb-4">原始內容 (Raw Content)</h3>
|
||||||
|
<pre class="bg-gray-900 p-4 rounded overflow-x-auto text-xs text-gray-300">{{ JSON.stringify(detail.content, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="text-center py-12 text-red-400">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-12 text-gray-500">
|
||||||
|
無法載入詳情
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import TranslatableText from '@/components/TranslatableText.vue'
|
||||||
|
import { httpFetch, getCurrentConfig } from '@/api/client'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const chunkId = ref('')
|
||||||
|
const detail = ref<any>(null)
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const loadDetail = async () => {
|
||||||
|
const uuid = route.params.file_uuid as string
|
||||||
|
chunkId.value = route.params.chunk_id as string
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const url = `${config.api_base_url}/api/v1/file/${uuid}/chunk/${chunkId.value}`
|
||||||
|
|
||||||
|
const res = await httpFetch<any>(url)
|
||||||
|
|
||||||
|
if (res && res.chunk_id) {
|
||||||
|
detail.value = res
|
||||||
|
} else {
|
||||||
|
detail.value = null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = '載入失敗: ' + (err as any)?.message || String(err)
|
||||||
|
console.error('Failed to load chunk detail:', err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const structuredSummary = computed(() => {
|
||||||
|
if (!detail.value?.metadata?.structured_summary) return {}
|
||||||
|
const excludedKeys = ['summary_5lines']
|
||||||
|
const result: Record<string, any> = {}
|
||||||
|
for (const [key, value] of Object.entries(detail.value.metadata.structured_summary)) {
|
||||||
|
if (!excludedKeys.includes(key)) {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const visualStats = computed(() => {
|
||||||
|
if (!detail.value?.visual_stats) return {}
|
||||||
|
return detail.value.visual_stats
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasVisualStats = computed(() => {
|
||||||
|
if (!detail.value?.visual_stats) return false
|
||||||
|
const vs = detail.value.visual_stats
|
||||||
|
return vs.yolo || vs.pose || vs.face || vs.ocr ||
|
||||||
|
(vs.objects && vs.objects.length > 0) ||
|
||||||
|
(vs.persons && vs.persons.length > 0) ||
|
||||||
|
(vs.faces && vs.faces.length > 0) ||
|
||||||
|
(vs.texts && vs.texts.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatKey = (key: string): string => {
|
||||||
|
const keyMap: Record<string, string> = {
|
||||||
|
who: 'Who (人物)',
|
||||||
|
what: 'What (事件)',
|
||||||
|
when: 'When (時間)',
|
||||||
|
where: 'Where (地點)',
|
||||||
|
why: 'Why (原因)',
|
||||||
|
how: 'How (方式)',
|
||||||
|
tone: 'Tone (語氣)',
|
||||||
|
characters: 'Characters',
|
||||||
|
key_events: 'Key Events'
|
||||||
|
}
|
||||||
|
return keyMap[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatMetadataValue = (value: any): string => {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.join(', ')
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
return String(value || '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
const saved = localStorage.getItem('searchState')
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(saved)
|
||||||
|
localStorage.removeItem('searchState')
|
||||||
|
router.push({ name: 'search', query: { q: data.query } })
|
||||||
|
return
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
router.push('/files')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDetail()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
258
src/views/FaceCandidatesView.vue
Normal file
258
src/views/FaceCandidatesView.vue
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-2xl font-bold">Face Traces</h2>
|
||||||
|
<button
|
||||||
|
@click="loadTraces"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 space-y-4">
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-400 text-sm mb-1">Filter by File (必選)</label>
|
||||||
|
<select
|
||||||
|
v-model="selectedFileUuid"
|
||||||
|
@change="loadTraces"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="">-- 選擇檔案 --</option>
|
||||||
|
<option v-for="f in files" :key="f.file_uuid" :value="f.file_uuid">
|
||||||
|
{{ f.file_name?.substring(0, 50) || f.file_uuid }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-400 text-sm mb-1">Sort By</label>
|
||||||
|
<select
|
||||||
|
v-model="sortBy"
|
||||||
|
@change="loadTraces"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="face_count">Face Count</option>
|
||||||
|
<option value="duration">Duration</option>
|
||||||
|
<option value="first_appearance">First Appearance</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-400 text-sm mb-1">Min Faces</label>
|
||||||
|
<input
|
||||||
|
v-model.number="minFaces"
|
||||||
|
@change="loadTraces"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="1000"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-gray-400 text-sm mb-1">Binding Status</label>
|
||||||
|
<select
|
||||||
|
v-model="bindingFilter"
|
||||||
|
@change="loadTraces"
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded px-3 py-2 text-white"
|
||||||
|
>
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="registered">已綁定</option>
|
||||||
|
<option value="unregistered">未綁定</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!selectedFileUuid" class="text-center py-12 text-gray-500">
|
||||||
|
請選擇一個檔案來檢視 Face Traces
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="loading && traces.length === 0" class="text-center py-12 text-gray-500">
|
||||||
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="traces.length > 0">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700 mb-4 flex justify-between items-center">
|
||||||
|
<div class="text-gray-400">
|
||||||
|
Showing {{ paginatedTraces.length }} of {{ traces.length }} traces
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-xs text-gray-500">每頁</span>
|
||||||
|
<select v-model.number="pageSize" @change="page=1"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded px-2 py-1 text-white text-xs">
|
||||||
|
<option :value="20">20</option>
|
||||||
|
<option :value="50">50</option>
|
||||||
|
<option :value="100">100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination top -->
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center mb-4 space-x-2">
|
||||||
|
<button @click="page = 1" :disabled="page === 1"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">«</button>
|
||||||
|
<button @click="page--" :disabled="page === 1"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">‹</button>
|
||||||
|
<span class="text-gray-400 text-sm py-1">{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button @click="page++" :disabled="page >= totalPages"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">›</button>
|
||||||
|
<button @click="page = totalPages" :disabled="page >= totalPages"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">»</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading overlay during pagination -->
|
||||||
|
<div v-if="loading" class="text-center py-4 text-gray-500 mb-2">
|
||||||
|
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="trace in paginatedTraces"
|
||||||
|
:key="trace.trace_id"
|
||||||
|
@click="viewTrace(trace)"
|
||||||
|
class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden cursor-pointer hover:border-blue-500 transition"
|
||||||
|
>
|
||||||
|
<div class="aspect-square bg-gray-700 flex items-center justify-center overflow-hidden">
|
||||||
|
<img
|
||||||
|
:src="getThumbnailUrl(trace)"
|
||||||
|
alt="Trace thumbnail"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
@error="onThumbnailError(trace.trace_id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-1">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm font-semibold text-blue-300">Trace #{{ trace.trace_id }}</div>
|
||||||
|
<span class="text-xs px-1.5 py-0.5 rounded"
|
||||||
|
:class="trace.face_count > 5 ? 'bg-green-900 text-green-300' : 'bg-gray-700 text-gray-400'">
|
||||||
|
{{ trace.face_count > 5 ? '多' : '少' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ trace.face_count }} faces, {{ trace.duration_sec?.toFixed(1) || '?' }}s</div>
|
||||||
|
<div class="text-xs font-mono" :class="getConfidenceColor(trace.avg_confidence)">
|
||||||
|
{{ (trace.avg_confidence * 100).toFixed(0) }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination bottom -->
|
||||||
|
<div v-if="totalPages > 1" class="flex justify-center mt-6 space-x-2">
|
||||||
|
<button @click="page = 1" :disabled="page === 1"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">«</button>
|
||||||
|
<button @click="page--" :disabled="page === 1"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">‹</button>
|
||||||
|
<span class="text-gray-400 text-sm py-1">{{ page }} / {{ totalPages }}</span>
|
||||||
|
<button @click="page++" :disabled="page >= totalPages"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">›</button>
|
||||||
|
<button @click="page = totalPages" :disabled="page >= totalPages"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-3 py-1 rounded text-sm">»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && traces.length === 0" class="text-center py-12 text-gray-500">
|
||||||
|
No traces found for this file
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { getVideos, getCurrentConfig } from '@/api/client'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
interface TraceInfo {
|
||||||
|
trace_id: number
|
||||||
|
face_count: number
|
||||||
|
first_frame: number
|
||||||
|
last_frame: number
|
||||||
|
first_sec: number
|
||||||
|
last_sec: number
|
||||||
|
duration_sec: number
|
||||||
|
avg_confidence: number
|
||||||
|
sample_face_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const traces = ref<TraceInfo[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const totalTraces = ref(0)
|
||||||
|
const selectedFileUuid = ref('')
|
||||||
|
const files = ref<any[]>([])
|
||||||
|
const sortBy = ref('face_count')
|
||||||
|
const minFaces = ref(1)
|
||||||
|
const bindingFilter = ref('all')
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(50)
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(traces.value.length / pageSize.value)))
|
||||||
|
|
||||||
|
const paginatedTraces = computed(() => {
|
||||||
|
const start = (page.value - 1) * pageSize.value
|
||||||
|
return traces.value.slice(start, start + pageSize.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const failedThumbnails = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const getThumbnailUrl = (trace: TraceInfo): string => {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
return `${config.api_base_url}/api/v1/file/${selectedFileUuid.value}/thumbnail?frame=${trace.first_frame}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const onThumbnailError = (traceId: number) => {
|
||||||
|
failedThumbnails.value = new Set([...failedThumbnails.value, traceId])
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfidenceColor = (conf: number): string => {
|
||||||
|
if (conf >= 0.8) return 'text-green-400'
|
||||||
|
if (conf >= 0.5) return 'text-yellow-400'
|
||||||
|
return 'text-red-400'
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewTrace = (trace: TraceInfo) => {
|
||||||
|
router.push(`/traces/${selectedFileUuid.value}/${trace.trace_id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTraces = async () => {
|
||||||
|
if (!selectedFileUuid.value) return
|
||||||
|
loading.value = true
|
||||||
|
page.value = 1
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const result = await fetch(
|
||||||
|
`${config.api_base_url}/api/v1/file/${selectedFileUuid.value}/face_trace/sortby`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(config.api_key ? { 'X-API-Key': config.api_key } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sort_by: sortBy.value,
|
||||||
|
limit: 200,
|
||||||
|
min_faces: minFaces.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const data = await result.json()
|
||||||
|
traces.value = data.traces || []
|
||||||
|
totalTraces.value = data.total_traces || 0
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load traces:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const result = await getVideos()
|
||||||
|
files.value = result.data || []
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
284
src/views/FilesView.vue
Normal file
284
src/views/FilesView.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header with Search and Filters -->
|
||||||
|
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<h2 class="text-2xl font-bold">檔案管理 (Demo)</h2>
|
||||||
|
<div class="flex items-center gap-3 w-full md:w-auto">
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div class="flex items-center bg-gray-700 rounded p-1">
|
||||||
|
<button
|
||||||
|
@click="setStatusFilter('all')"
|
||||||
|
:class="{'bg-blue-600 text-white': statusFilter === 'all', 'text-gray-300 hover:text-white': statusFilter !== 'all'}"
|
||||||
|
class="px-3 py-1 rounded text-sm transition"
|
||||||
|
>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setStatusFilter('unregistered')"
|
||||||
|
:class="{'bg-blue-600 text-white': statusFilter === 'unregistered', 'text-gray-300 hover:text-white': statusFilter !== 'unregistered'}"
|
||||||
|
class="px-3 py-1 rounded text-sm transition"
|
||||||
|
>
|
||||||
|
未註冊
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setStatusFilter('pending')"
|
||||||
|
:class="{'bg-blue-600 text-white': statusFilter === 'pending', 'text-gray-300 hover:text-white': statusFilter !== 'pending'}"
|
||||||
|
class="px-3 py-1 rounded text-sm transition"
|
||||||
|
>
|
||||||
|
待處理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setStatusFilter('processing')"
|
||||||
|
:class="{'bg-blue-600 text-white': statusFilter === 'processing', 'text-gray-300 hover:text-white': statusFilter !== 'processing'}"
|
||||||
|
class="px-3 py-1 rounded text-sm transition"
|
||||||
|
>
|
||||||
|
處理中
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setStatusFilter('completed')"
|
||||||
|
:class="{'bg-blue-600 text-white': statusFilter === 'completed', 'text-gray-300 hover:text-white': statusFilter !== 'completed'}"
|
||||||
|
class="px-3 py-1 rounded text-sm transition"
|
||||||
|
>
|
||||||
|
已完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Search input -->
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜尋檔名..."
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm w-full md:w-48 focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="bg-red-900/50 border border-red-700 rounded p-4 text-red-300">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File List -->
|
||||||
|
<div v-else class="bg-gray-800 rounded-lg border border-gray-700 overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-700">
|
||||||
|
<thead class="bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">檔案名稱</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">狀態</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">UUID</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||||
|
<tr v-for="file in displayFiles" :key="file.file_uuid || file.file_path" class="hover:bg-gray-750 transition">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-white truncate max-w-xs" :title="file.file_name">
|
||||||
|
{{ file.file_name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<span v-if="file.status === 'ready' || file.status === 'completed'" class="px-2 py-0.5 rounded text-xs bg-green-900 text-green-200">
|
||||||
|
✅ 已就绪
|
||||||
|
</span>
|
||||||
|
<span v-else-if="file.status === 'processing'" class="px-2 py-0.5 rounded text-xs bg-yellow-900 text-yellow-200">
|
||||||
|
🔄 处理中
|
||||||
|
</span>
|
||||||
|
<span v-else-if="file.status === 'pending'" class="px-2 py-0.5 rounded text-xs bg-blue-900 text-blue-200">
|
||||||
|
⏳ 待处理
|
||||||
|
</span>
|
||||||
|
<span v-else-if="file.status === 'registered_scan'" class="px-2 py-0.5 rounded text-xs bg-blue-900 text-blue-200">
|
||||||
|
⏳ 已注册(扫描)
|
||||||
|
</span>
|
||||||
|
<span v-else class="px-2 py-0.5 rounded text-xs bg-gray-600 text-gray-300">
|
||||||
|
📦 未注册
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300 font-mono text-xs">
|
||||||
|
{{ file.file_uuid || '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<!-- Enter Demo / Workbench (Completed) -->
|
||||||
|
<button
|
||||||
|
v-if="file.status === 'ready' || file.status === 'completed'"
|
||||||
|
@click="enterWorkbench(file.file_uuid)"
|
||||||
|
class="px-3 py-1 bg-purple-600 hover:bg-purple-700 text-white text-xs rounded transition"
|
||||||
|
>
|
||||||
|
臉部工作台
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Start Processing (Pending) -->
|
||||||
|
<button
|
||||||
|
v-if="file.status === 'pending'"
|
||||||
|
@click="startProcessing(file.file_uuid)"
|
||||||
|
class="px-3 py-1 bg-yellow-600 hover:bg-yellow-700 text-white text-xs rounded transition"
|
||||||
|
>
|
||||||
|
開始處理
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Register (Unregistered) -->
|
||||||
|
<button
|
||||||
|
v-if="!file.status || file.status === 'unregistered'"
|
||||||
|
@click="registerFile(file.file_path)"
|
||||||
|
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded transition"
|
||||||
|
>
|
||||||
|
註冊
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Unregister (Pending/Processing/Completed) -->
|
||||||
|
<button
|
||||||
|
v-if="file.file_uuid"
|
||||||
|
@click="unregisterFile(file.file_uuid, file.file_name)"
|
||||||
|
class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded transition"
|
||||||
|
>
|
||||||
|
取消註冊
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { registerVideo, unregisterVideo, httpFetch, getCurrentConfig } from '@/api/client'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const files = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const statusFilter = ref('all') // all, unregistered, pending, processing, completed
|
||||||
|
|
||||||
|
const displayFiles = computed(() => {
|
||||||
|
let result = files.value
|
||||||
|
|
||||||
|
// Filter by search
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const q = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(f =>
|
||||||
|
f.file_name.toLowerCase().includes(q) ||
|
||||||
|
(f.file_path && f.file_path.toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by status
|
||||||
|
if (statusFilter.value !== 'all') {
|
||||||
|
result = result.filter(f => {
|
||||||
|
if (statusFilter.value === 'completed') {
|
||||||
|
return f.status === 'completed' || f.status === 'ready'
|
||||||
|
}
|
||||||
|
return f.status === statusFilter.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
function setStatusFilter(status: string) {
|
||||||
|
statusFilter.value = status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFiles() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const scanResp = await httpFetch<any>(`${config.api_base_url}/api/v1/files/scan`)
|
||||||
|
const scanFiles: any[] = (scanResp?.files || []).map((f: any) => ({
|
||||||
|
...f,
|
||||||
|
status: f.is_registered ? 'registered_scan' : 'unregistered'
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Get registered files with real processing status
|
||||||
|
let regFiles: any[] = []
|
||||||
|
try {
|
||||||
|
const regResp = await httpFetch<any>(`${config.api_base_url}/api/v1/files?page=1&page_size=100`)
|
||||||
|
regFiles = (regResp?.files || regResp?.data || []).map((f: any) => ({
|
||||||
|
...f,
|
||||||
|
status: f.status || 'pending'
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
// Registered files API may not be available; use scan data only
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: scan results first, then overlay with registered statuses
|
||||||
|
const merged = new Map<string, any>()
|
||||||
|
for (const f of scanFiles) {
|
||||||
|
merged.set(f.file_path, f)
|
||||||
|
}
|
||||||
|
for (const f of regFiles) {
|
||||||
|
merged.set(f.file_path, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
files.value = Array.from(merged.values())
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch files:', e)
|
||||||
|
error.value = String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registerFile(filePath: string) {
|
||||||
|
if (!filePath) { alert('無法註冊:缺少檔案路徑'); return }
|
||||||
|
try {
|
||||||
|
await registerVideo(filePath)
|
||||||
|
// Refresh list
|
||||||
|
await fetchFiles()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Register failed:', e)
|
||||||
|
alert('註冊失敗:' + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unregisterFile(fileUuid: string, fileName: string) {
|
||||||
|
if (!fileUuid) { alert('無法取消註冊:缺少 UUID'); return }
|
||||||
|
const displayName = fileName || '未知檔案'
|
||||||
|
if (!confirm(`確定要取消註冊 "${displayName}" 嗎?這將刪除資料庫中的相關記錄。`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await unregisterVideo(fileUuid)
|
||||||
|
await fetchFiles()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Unregister failed:', e)
|
||||||
|
alert('取消註冊失敗:' + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startProcessing(fileUuid: string) {
|
||||||
|
if (!fileUuid) { alert('無法處理:缺少 UUID'); return }
|
||||||
|
if (!confirm('確定要開始分析處理此檔案嗎?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
await httpFetch(`${config.api_base_url}/api/v1/file/${fileUuid}/process`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({})
|
||||||
|
})
|
||||||
|
|
||||||
|
// After triggering, status should change to processing
|
||||||
|
// We can poll or just refresh
|
||||||
|
await fetchFiles()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Start processing failed:', e)
|
||||||
|
alert('開始處理失敗:' + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterWorkbench(fileUuid: string) {
|
||||||
|
if (!fileUuid) { alert('無法開啟工作台:缺少 UUID'); return }
|
||||||
|
router.push(`/file/${fileUuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchFiles)
|
||||||
|
</script>
|
||||||
435
src/views/FilesView.vue.backup
Normal file
435
src/views/FilesView.vue.backup
Normal file
@@ -0,0 +1,435 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header with Search and Filters -->
|
||||||
|
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<h2 class="text-2xl font-bold">納管檔案</h2>
|
||||||
|
<div class="flex items-center gap-3 w-full md:w-auto">
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<div class="flex items-center bg-gray-700 rounded p-1">
|
||||||
|
<button
|
||||||
|
@click="setStatusFilter('unprocessed')"
|
||||||
|
:class="{'bg-blue-600 text-white': statusFilter === 'unprocessed', 'text-gray-300 hover:text-white': statusFilter !== 'unprocessed'}"
|
||||||
|
class="px-3 py-1 rounded text-sm transition"
|
||||||
|
>
|
||||||
|
未處理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setStatusFilter('processed')"
|
||||||
|
:class="{'bg-blue-600 text-white': statusFilter === 'processed', 'text-gray-300 hover:text-white': statusFilter !== 'processed'}"
|
||||||
|
class="px-3 py-1 rounded text-sm transition"
|
||||||
|
>
|
||||||
|
已處理
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="setStatusFilter('all')"
|
||||||
|
:class="{'bg-blue-600 text-white': statusFilter === 'all', 'text-gray-300 hover:text-white': statusFilter !== 'all'}"
|
||||||
|
class="px-3 py-1 rounded text-sm transition"
|
||||||
|
>
|
||||||
|
全選
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
@input="handleSearch"
|
||||||
|
placeholder="搜尋檔名..."
|
||||||
|
class="pl-10 pr-4 py-2 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 w-48"
|
||||||
|
/>
|
||||||
|
<svg class="w-5 h-5 absolute left-3 top-2.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="bg-red-900/50 border border-red-700 rounded p-4 text-red-300">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File List (Fixed Height for Stable Layout) -->
|
||||||
|
<div v-else class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden flex flex-col" style="min-height: 600px;">
|
||||||
|
<div class="flex-grow overflow-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-700">
|
||||||
|
<thead class="bg-gray-900">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Filename</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Duration</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Resolution</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Registration</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Size</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-700 bg-gray-800">
|
||||||
|
<tr v-for="file in fixedSizeFiles" :key="file.uuid" :class="file.status === 'empty' ? 'opacity-0' : 'hover:bg-gray-750 transition'"
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div v-if="file.status !== 'empty'" class="text-sm font-medium text-white" v-html="highlightMatch(file.file_name, searchQuery)"></div>
|
||||||
|
<div v-else class="text-sm font-medium text-transparent">-</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||||
|
<span v-if="file.status !== 'empty'">{{ formatDuration(file.duration) }}</span>
|
||||||
|
<span v-else class="text-transparent">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||||
|
<span v-if="file.status !== 'empty'">{{ file.width }}x{{ file.height }}</span>
|
||||||
|
<span v-else class="text-transparent">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||||
|
<span v-if="file.status !== 'empty'">
|
||||||
|
<span v-if="file.registration_time" class="text-green-400">
|
||||||
|
✓ {{ formatDate(file.registration_time) }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="file.created_at" class="text-yellow-400">
|
||||||
|
⚠️ {{ formatDate(file.created_at) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-gray-500">
|
||||||
|
-
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-transparent">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||||
|
<span v-if="file.status !== 'empty'">{{ formatFileSize(file.file_size) }}</span>
|
||||||
|
<span v-else class="text-transparent">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span v-if="file.status !== 'empty'" :class="statusBadgeClass(file.status, file.registration_time)" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">
|
||||||
|
{{ getStatusText(file.status, file.registration_time) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full text-transparent">-</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div v-if="file.status !== 'empty'">
|
||||||
|
<button
|
||||||
|
@click="viewDetail(file.uuid)"
|
||||||
|
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-xs rounded mr-2"
|
||||||
|
>
|
||||||
|
詳情
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="registerFile(file.uuid, file.file_path || file.file_name)"
|
||||||
|
:disabled="registeringFiles.value.has(file.uuid) || !!file.registration_time"
|
||||||
|
:class="{
|
||||||
|
'bg-blue-600 hover:bg-blue-700': !registeringFiles.value.has(file.uuid) && !file.registration_time,
|
||||||
|
'bg-gray-600 cursor-not-allowed': registeringFiles.value.has(file.uuid) || !!file.registration_time,
|
||||||
|
'opacity-50': registeringFiles.value.has(file.uuid) || !!file.registration_time
|
||||||
|
}"
|
||||||
|
class="px-3 py-1 text-white text-xs rounded mr-2 transition"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
file.registration_time
|
||||||
|
? '已註冊'
|
||||||
|
: registeringFiles.value.has(file.uuid)
|
||||||
|
? '註冊中...'
|
||||||
|
: '立即註冊'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="file.status === 'pending' || file.status === 'REGISTERED'"
|
||||||
|
@click="processFile(file.uuid)"
|
||||||
|
:disabled="processingFiles.value.has(file.uuid)"
|
||||||
|
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-xs rounded disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ processingFiles.value.has(file.uuid) ? '處理中...' : '開始分析' }}
|
||||||
|
</button>
|
||||||
|
<span v-else class="text-gray-500 text-xs">
|
||||||
|
{{ file.processing_status || 'completed' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-transparent text-xs">-</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div> <!-- 關閉 overflow-auto -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div v-if="totalPages > 1" class="flex items-center justify-between bg-gray-800 px-4 py-3 border-t border-gray-700">
|
||||||
|
<div class="text-sm text-gray-400">
|
||||||
|
Page {{ page }} of {{ totalPages }} (Total: {{ total }}, Page size: 15)
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
@click="changePage(1)"
|
||||||
|
:disabled="page === 1"
|
||||||
|
class="px-3 py-1 bg-gray-700 text-gray-300 rounded disabled:opacity-50 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
First
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="changePage(page - 1)"
|
||||||
|
:disabled="page === 1"
|
||||||
|
class="px-3 py-1 bg-gray-700 text-gray-300 rounded disabled:opacity-50 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="changePage(page + 1)"
|
||||||
|
:disabled="page === totalPages"
|
||||||
|
class="px-3 py-1 bg-gray-700 text-gray-300 rounded disabled:opacity-50 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="changePage(totalPages)"
|
||||||
|
:disabled="page === totalPages"
|
||||||
|
class="px-3 py-1 bg-gray-700 text-gray-300 rounded disabled:opacity-50 hover:bg-gray-600"
|
||||||
|
>
|
||||||
|
Last
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { getVideos, processVideo, registerVideo } from '@/api/client'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
uuid: string
|
||||||
|
file_name: string
|
||||||
|
file_path?: string
|
||||||
|
duration: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
status: string
|
||||||
|
processing_status?: string
|
||||||
|
created_at?: string
|
||||||
|
registration_time?: string
|
||||||
|
file_size?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = ref<FileItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const statusFilter = ref('unprocessed') // default
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(15)
|
||||||
|
const total = ref(0)
|
||||||
|
const processingFiles = ref<Set<string>>(new Set())
|
||||||
|
const registeringFiles = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
|
||||||
|
|
||||||
|
// 固定大小的列表,確保總是顯示 15 行以保持畫面穩定
|
||||||
|
const fixedSizeFiles = computed(() => {
|
||||||
|
const result = [...files.value]
|
||||||
|
// 如果實際數據不足 15 條,用空行填充
|
||||||
|
while (result.length < 15) {
|
||||||
|
result.push({
|
||||||
|
uuid: `empty-${result.length}`,
|
||||||
|
file_name: '',
|
||||||
|
duration: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
status: 'empty',
|
||||||
|
file_size: 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result.slice(0, 15) // 確保最多 15 行
|
||||||
|
})
|
||||||
|
|
||||||
|
async function registerFile(uuid: string, filePath: string) {
|
||||||
|
registeringFiles.value.add(uuid)
|
||||||
|
try {
|
||||||
|
// Use /api/v1/register with file_path
|
||||||
|
const result = await registerVideo(filePath)
|
||||||
|
alert('已註冊!UUID: ' + (result.uuid || uuid))
|
||||||
|
await fetchFiles()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Register failed:', e)
|
||||||
|
alert('註冊失敗:' + e)
|
||||||
|
} finally {
|
||||||
|
registeringFiles.value.delete(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processFile(uuid: string) {
|
||||||
|
processingFiles.value.add(uuid)
|
||||||
|
try {
|
||||||
|
const result = await processVideo(uuid)
|
||||||
|
alert('已開始分析!Job ID: ' + (result.job_id || 'queued'))
|
||||||
|
await fetchFiles()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Process failed:', e)
|
||||||
|
alert('分析失敗:' + e)
|
||||||
|
} finally {
|
||||||
|
processingFiles.value.delete(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number): string {
|
||||||
|
if (!seconds) return '0s'
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
if (m > 60) {
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${h}h ${m % 60}m ${s}s`
|
||||||
|
}
|
||||||
|
return `${m}m ${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusBadgeClass(status: string, registrationTime?: string): string {
|
||||||
|
if (status === 'empty') return 'bg-transparent'
|
||||||
|
|
||||||
|
if (registrationTime) {
|
||||||
|
return 'bg-green-900 text-green-200' // 已註冊用綠色
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'ready': return 'bg-green-900 text-green-200'
|
||||||
|
case 'processing': return 'bg-yellow-900 text-yellow-200'
|
||||||
|
case 'error': return 'bg-red-900 text-red-200'
|
||||||
|
case 'completed': return 'bg-green-900 text-green-200'
|
||||||
|
case 'pending': return 'bg-yellow-900 text-yellow-200'
|
||||||
|
case 'REGISTERED': return 'bg-yellow-900 text-yellow-200'
|
||||||
|
default: return 'bg-gray-700 text-gray-300'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(status: string, registrationTime?: string): string {
|
||||||
|
if (status === 'empty') return ''
|
||||||
|
|
||||||
|
if (registrationTime) {
|
||||||
|
return '已註冊'
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'ready': return '準備中'
|
||||||
|
case 'processing': return '處理中'
|
||||||
|
case 'error': return '錯誤'
|
||||||
|
case 'completed': return '已完成'
|
||||||
|
case 'pending': return '等待中'
|
||||||
|
case 'REGISTERED': return '已註冊'
|
||||||
|
default: return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightMatch(text: string, query: string): string {
|
||||||
|
if (!query || !text) return text
|
||||||
|
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')
|
||||||
|
return text.replace(regex, '<mark class="bg-yellow-600 text-white rounded px-0.5">$1</mark>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string | undefined): string {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleString('zh-TW', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number | undefined): string {
|
||||||
|
if (!bytes) return '-'
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewDetail(uuid: string) {
|
||||||
|
router.push(`/video/${uuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchTimeout: any = null
|
||||||
|
function handleSearch() {
|
||||||
|
clearTimeout(searchTimeout)
|
||||||
|
page.value = 1 // Reset to page 1 on search
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
fetchFiles()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatusFilter(status: string) {
|
||||||
|
statusFilter.value = status
|
||||||
|
page.value = 1
|
||||||
|
fetchFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePage(newPage: number) {
|
||||||
|
if (newPage < 1 || newPage > totalPages.value) return
|
||||||
|
page.value = newPage
|
||||||
|
fetchFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFiles() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
// Call the getVideos function with current filters
|
||||||
|
// Map UI status to API status
|
||||||
|
const statusMap: Record<string, string | undefined> = {
|
||||||
|
'unprocessed': 'pending',
|
||||||
|
'processed': 'completed',
|
||||||
|
'all': undefined
|
||||||
|
}
|
||||||
|
const apiStatus = statusMap[statusFilter.value] || statusFilter.value
|
||||||
|
|
||||||
|
const response = await getVideos(
|
||||||
|
searchQuery.value || undefined,
|
||||||
|
apiStatus,
|
||||||
|
page.value,
|
||||||
|
pageSize.value
|
||||||
|
)
|
||||||
|
console.log("API Response:", response)
|
||||||
|
|
||||||
|
files.value = (response.videos || []).map((v: any) => {
|
||||||
|
let probeData: any = null
|
||||||
|
let createdAt: string | undefined = undefined
|
||||||
|
let fileSize: number | undefined = undefined
|
||||||
|
try {
|
||||||
|
if (v.probe_json) {
|
||||||
|
probeData = JSON.parse(v.probe_json)
|
||||||
|
const fmt = probeData?.format
|
||||||
|
if (fmt?.tags?.date) createdAt = fmt.tags.date
|
||||||
|
if (fmt?.size) fileSize = parseInt(fmt.size)
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore parse errors */ }
|
||||||
|
return {
|
||||||
|
uuid: v.uuid,
|
||||||
|
file_name: v.file_name || v.filename,
|
||||||
|
file_path: v.file_path,
|
||||||
|
duration: v.duration,
|
||||||
|
width: v.width,
|
||||||
|
height: v.height,
|
||||||
|
status: v.status,
|
||||||
|
processing_status: v.processing_status,
|
||||||
|
created_at: createdAt,
|
||||||
|
registration_time: v.registration_time,
|
||||||
|
file_size: fileSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
total.value = response.count || 0
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch files:', e)
|
||||||
|
error.value = String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(fetchFiles)
|
||||||
|
</script>
|
||||||
521
src/views/HomeView.vue
Normal file
521
src/views/HomeView.vue
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<div class="bg-gradient-to-r from-blue-900 to-purple-900 rounded-lg p-8">
|
||||||
|
<h2 class="text-3xl font-bold mb-4">歡迎使用 Momentry Portal</h2>
|
||||||
|
<p class="text-gray-300 mb-6">
|
||||||
|
影片內容搜尋與人物管理平台
|
||||||
|
</p>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<router-link
|
||||||
|
to="/search"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-lg font-semibold transition"
|
||||||
|
>
|
||||||
|
開始搜尋
|
||||||
|
</router-link>
|
||||||
|
<router-link
|
||||||
|
to="/persons"
|
||||||
|
class="bg-gray-700 hover:bg-gray-600 px-6 py-3 rounded-lg font-semibold transition"
|
||||||
|
>
|
||||||
|
人物管理
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ingest Stats Section -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-xl font-semibold text-green-400 mb-4">入庫統計</h3>
|
||||||
|
|
||||||
|
<div v-if="ingestStats" class="space-y-4">
|
||||||
|
<!-- Row 1: Videos + Total Chunks -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<!-- Total Videos -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||||
|
<div class="text-3xl font-bold text-blue-400">{{ ingestStats.total_videos }}</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">影片總數</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Total Chunks -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||||
|
<div class="text-3xl font-bold text-purple-400">{{ ingestStats.total_chunks }}</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">片段總數</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Searchable Chunks -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-green-700 text-center">
|
||||||
|
<div class="text-3xl font-bold text-green-400">{{ ingestStats.searchable_chunks }}</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">可搜尋</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Chunk Types Breakdown -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="text-sm text-gray-500 mb-3">片段類型分類</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-pink-400">{{ ingestStats.sentence_chunks }}</div>
|
||||||
|
<div class="text-xs text-gray-400">Sentence (句子)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-orange-400">{{ ingestStats.cut_chunks }}</div>
|
||||||
|
<div class="text-xs text-gray-400">Cut (剪輯點)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-2xl font-bold text-indigo-400">{{ ingestStats.time_chunks }}</div>
|
||||||
|
<div class="text-xs text-gray-400">Time (時間段)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chunk Type Definitions -->
|
||||||
|
<div class="mt-4 pt-3 border-t border-gray-700 space-y-2 text-xs text-gray-500">
|
||||||
|
<div class="flex items-start space-x-2">
|
||||||
|
<span class="text-pink-400 font-semibold">Sentence</span>
|
||||||
|
<span>基於語音辨識的自然語句分割,每個片段代表一句完整的對話或敘述,適合語意搜尋與內容理解。</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start space-x-2">
|
||||||
|
<span class="text-orange-400 font-semibold">Cut</span>
|
||||||
|
<span>基於影片場景切換的分割點,偵測畫面變化(如鏡頭切換、場景轉換)作為片段邊界,適合視覺內容分析。</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-start space-x-2">
|
||||||
|
<span class="text-indigo-400 font-semibold">Time</span>
|
||||||
|
<span>基於固定時間間隔的分割(如每 60 秒),確保片段長度一致,適合時間序列分析與段落瀏覽。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Processing Status -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<!-- Chunks with Summary -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-emerald-700 text-center">
|
||||||
|
<div class="text-3xl font-bold text-emerald-400">{{ ingestStats.chunks_with_summary }}</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">已生成摘要</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chunks with Visual -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-cyan-700 text-center">
|
||||||
|
<div class="text-3xl font-bold text-cyan-400">{{ ingestStats.chunks_with_visual }}</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">有視覺分析</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Videos -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-yellow-700 text-center">
|
||||||
|
<div class="text-3xl font-bold text-yellow-400">{{ ingestStats.pending_videos }}</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-1">待處理</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400">載入中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SFTPGo Status Section -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-xl font-semibold text-orange-400 mb-4">SFTPGo 狀態</h3>
|
||||||
|
|
||||||
|
<div v-if="sftpgoStatus" class="space-y-4">
|
||||||
|
<!-- Status message -->
|
||||||
|
<div v-if="statusMsg" class="text-sm px-3 py-2 rounded"
|
||||||
|
:class="statusMsg.type === 'ok' ? 'bg-green-900/50 text-green-300' : 'bg-red-900/50 text-red-300'">
|
||||||
|
{{ statusMsg.text }}
|
||||||
|
<button @click="statusMsg = null" class="ml-2 text-gray-500 hover:text-white">×</button>
|
||||||
|
</div>
|
||||||
|
<!-- Basic Info -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">Username</span>
|
||||||
|
<p class="text-white mt-1 text-lg font-semibold">{{ sftpgoStatus.username }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600 lg:col-span-2">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">Home Path</span>
|
||||||
|
<p class="text-gray-300 mt-1 text-sm font-mono break-all">{{ sftpgoStatus.home_dir }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">Files Count</span>
|
||||||
|
<button
|
||||||
|
@click="openSftpgoFiles"
|
||||||
|
class="text-orange-400 mt-1 text-lg font-semibold hover:text-orange-300 cursor-pointer flex items-center space-x-1"
|
||||||
|
>
|
||||||
|
<span>{{ sftpgoStatus.files_count }}</span>
|
||||||
|
<span>🔗</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">Registered Videos</span>
|
||||||
|
<p class="text-green-400 mt-1 text-lg font-semibold">{{ sftpgoStatus.registered_videos.length }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SFTPGo URL -->
|
||||||
|
<div class="mt-4 bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">SFTPGo 檔案管理</span>
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
@click="openSftpgoFiles"
|
||||||
|
class="flex-1 px-4 py-3 bg-orange-700 hover:bg-orange-600 text-white text-center rounded font-medium no-underline"
|
||||||
|
>
|
||||||
|
📂 點擊開啟 SFTPGo 檔案管理
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2 text-sm">
|
||||||
|
<span class="text-gray-500">或複製網址:</span>
|
||||||
|
<input
|
||||||
|
:value="sftpgoUrl"
|
||||||
|
readonly
|
||||||
|
class="flex-1 px-3 py-1 bg-gray-800 border border-gray-600 rounded text-gray-300 text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="copySftpgoUrl"
|
||||||
|
class="px-3 py-1 bg-gray-700 hover:bg-gray-600 text-white text-xs rounded"
|
||||||
|
>
|
||||||
|
複製
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registered Videos List -->
|
||||||
|
<div v-if="sftpgoStatus.registered_videos.length > 0" class="bg-gray-900 rounded border border-gray-600">
|
||||||
|
<div class="p-3 border-b border-gray-700">
|
||||||
|
<span class="text-sm font-semibold text-gray-300">已註冊影片</span>
|
||||||
|
</div>
|
||||||
|
<div class="divide-y divide-gray-700">
|
||||||
|
<div v-for="video in sftpgoStatus.registered_videos" :key="video.uuid" class="p-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-white text-sm">{{ video.file_name }}</p>
|
||||||
|
<p class="text-gray-500 text-xs">{{ video.uuid }}</p>
|
||||||
|
</div>
|
||||||
|
<span :class="video.status === 'completed' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'" class="px-2 py-1 rounded text-xs">
|
||||||
|
{{ video.status }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="bg-gray-900 p-4 rounded border border-gray-600 text-center text-gray-500 text-sm">
|
||||||
|
尚未註冊任何影片
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400">載入中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inference Engines Section -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-xl font-semibold text-pink-400 mb-4">推理引擎狀態</h3>
|
||||||
|
|
||||||
|
<div v-if="inferenceHealth" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Ollama (Embedding) -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-pink-400">🧠</span>
|
||||||
|
<span class="font-semibold">{{ inferenceHealth.ollama.engine }}</span>
|
||||||
|
</div>
|
||||||
|
<span :class="inferenceHealth.ollama.status === 'ok' ? 'text-green-400' : 'text-red-400'">
|
||||||
|
{{ inferenceHealth.ollama.status === 'ok' ? '●' : '○' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">模型</span>
|
||||||
|
<span class="text-white">{{ inferenceHealth.ollama.model }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">用途</span>
|
||||||
|
<span class="text-purple-400">Embedding</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="inferenceHealth.ollama.latency_ms" class="flex justify-between">
|
||||||
|
<span class="text-gray-500">延遲</span>
|
||||||
|
<span class="text-white">{{ inferenceHealth.ollama.latency_ms }}ms</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="inferenceHealth.ollama.error" class="text-red-400 text-xs mt-2">
|
||||||
|
{{ inferenceHealth.ollama.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- llama-server (LLM) -->
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-cyan-400">💬</span>
|
||||||
|
<span class="font-semibold">{{ inferenceHealth.llama_server.engine }}</span>
|
||||||
|
</div>
|
||||||
|
<span :class="inferenceHealth.llama_server.status === 'ok' ? 'text-green-400' : 'text-red-400'">
|
||||||
|
{{ inferenceHealth.llama_server.status === 'ok' ? '●' : '○' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">模型</span>
|
||||||
|
<span class="text-white">{{ inferenceHealth.llama_server.model }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-500">用途</span>
|
||||||
|
<span class="text-cyan-400">LLM (5W1H, Summary)</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="inferenceHealth.llama_server.latency_ms" class="flex justify-between">
|
||||||
|
<span class="text-gray-500">延遲</span>
|
||||||
|
<span class="text-white">{{ inferenceHealth.llama_server.latency_ms }}ms</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="inferenceHealth.llama_server.error" class="text-red-400 text-xs mt-2">
|
||||||
|
{{ inferenceHealth.llama_server.error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400">載入中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Health Check Section -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-xl font-semibold text-blue-400">服務狀態</h3>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-sm text-gray-400">API: {{ apiBaseUrl }}</span>
|
||||||
|
<button
|
||||||
|
@click="refreshHealth"
|
||||||
|
class="text-blue-400 hover:text-blue-300 text-sm"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? '檢查中...' : '重新檢查' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overall Status -->
|
||||||
|
<div v-if="healthError" class="bg-red-900/30 rounded-lg p-4 border border-red-700">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-red-400">⚠</span>
|
||||||
|
<span class="text-red-300">{{ healthError }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="health" class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- PostgreSQL -->
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="PostgreSQL"
|
||||||
|
:status="health.services.postgres.status"
|
||||||
|
:latency="health.services.postgres.latency_ms"
|
||||||
|
:error="health.services.postgres.error"
|
||||||
|
/>
|
||||||
|
<!-- Redis -->
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="Redis"
|
||||||
|
:status="health.services.redis.status"
|
||||||
|
:latency="health.services.redis.latency_ms"
|
||||||
|
:error="health.services.redis.error"
|
||||||
|
/>
|
||||||
|
<!-- Qdrant -->
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="Qdrant"
|
||||||
|
:status="health.services.qdrant.status"
|
||||||
|
:latency="health.services.qdrant.latency_ms"
|
||||||
|
:error="health.services.qdrant.error"
|
||||||
|
/>
|
||||||
|
<!-- MongoDB -->
|
||||||
|
<ServiceStatusCard
|
||||||
|
name="MongoDB"
|
||||||
|
:status="health.services.mongodb.status"
|
||||||
|
:latency="health.services.mongodb.latency_ms"
|
||||||
|
:error="health.services.mongodb.error"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="text-gray-400 text-sm">載入中...</div>
|
||||||
|
|
||||||
|
<!-- Version Info -->
|
||||||
|
<div v-if="health" class="mt-4 pt-4 border-t border-gray-700 flex justify-between text-sm text-gray-400">
|
||||||
|
<span>版本: {{ health.version }}</span>
|
||||||
|
<span>運行時間: {{ formatUptime(health.uptime_ms) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-xl font-semibold text-blue-400 mb-2">搜尋功能</h3>
|
||||||
|
<p class="text-gray-400">智能搜尋影片內容,支援語意向量與關鍵字檢索</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-xl font-semibold text-green-400 mb-2">人物管理</h3>
|
||||||
|
<p class="text-gray-400">管理全域身份、區域人物與臉部特徵</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-xl font-semibold text-purple-400 mb-2">臉部擷取</h3>
|
||||||
|
<p class="text-gray-400">擷取並管理人物臉部截圖</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { getHealth, getIngestStats, getSftpgoStatus, getInferenceHealth, getCurrentConfig, isTauri } from '@/api/client'
|
||||||
|
|
||||||
|
interface ServiceStatus {
|
||||||
|
status: string
|
||||||
|
latency_ms: number | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceHealth {
|
||||||
|
postgres: ServiceStatus
|
||||||
|
redis: ServiceStatus
|
||||||
|
qdrant: ServiceStatus
|
||||||
|
mongodb: ServiceStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailedHealthResponse {
|
||||||
|
status: string
|
||||||
|
version: string
|
||||||
|
uptime_ms: number
|
||||||
|
services: ServiceHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IngestStats {
|
||||||
|
total_videos: number
|
||||||
|
total_chunks: number
|
||||||
|
sentence_chunks: number
|
||||||
|
cut_chunks: number
|
||||||
|
time_chunks: number
|
||||||
|
searchable_chunks: number
|
||||||
|
chunks_with_visual: number
|
||||||
|
chunks_with_summary: number
|
||||||
|
pending_videos: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisteredVideo {
|
||||||
|
uuid: string
|
||||||
|
file_name: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SftpgoStatus {
|
||||||
|
username: string
|
||||||
|
home_dir: string
|
||||||
|
files_count: number
|
||||||
|
registered_videos: RegisteredVideo[]
|
||||||
|
last_login: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InferenceEngineStatus {
|
||||||
|
engine: string
|
||||||
|
model: string
|
||||||
|
status: string
|
||||||
|
latency_ms: number | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InferenceHealthResponse {
|
||||||
|
ollama: InferenceEngineStatus
|
||||||
|
llama_server: InferenceEngineStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = ref<DetailedHealthResponse | null>(null)
|
||||||
|
const healthError = ref<string | null>(null)
|
||||||
|
const ingestStats = ref<IngestStats | null>(null)
|
||||||
|
const sftpgoStatus = ref<SftpgoStatus | null>(null)
|
||||||
|
const inferenceHealth = ref<InferenceHealthResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const apiBaseUrl = ref(getCurrentConfig().api_base_url)
|
||||||
|
const sftpgoUrl = computed(() => {
|
||||||
|
const base = getCurrentConfig().api_base_url
|
||||||
|
try {
|
||||||
|
const url = new URL(base)
|
||||||
|
if (url.hostname === '127.0.0.1' || url.hostname === '192.168.110.210') {
|
||||||
|
return 'https://sftpgo.momentry.ddns.net/web/client'
|
||||||
|
}
|
||||||
|
return `http://${url.hostname}:8080/web/client`
|
||||||
|
} catch {
|
||||||
|
return 'https://sftpgo.momentry.ddns.net/web/client'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const statusMsg = ref<{ text: string; type: string } | null>(null)
|
||||||
|
|
||||||
|
async function fetchHealth() {
|
||||||
|
loading.value = true
|
||||||
|
healthError.value = null
|
||||||
|
try {
|
||||||
|
health.value = await getHealth()
|
||||||
|
} catch (e) {
|
||||||
|
healthError.value = String(e)
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchIngestStats() {
|
||||||
|
try {
|
||||||
|
ingestStats.value = await getIngestStats()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch ingest stats:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSftpgoStatus() {
|
||||||
|
try {
|
||||||
|
sftpgoStatus.value = await getSftpgoStatus()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch sftpgo status:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInferenceHealth() {
|
||||||
|
try {
|
||||||
|
inferenceHealth.value = await getInferenceHealth()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch inference health:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openSftpgoFiles() {
|
||||||
|
const url = sftpgoUrl.value
|
||||||
|
console.log('Momentry: Opening URL:', url, 'isTauri:', isTauri())
|
||||||
|
statusMsg.value = { text: '即將開啟:' + url, type: 'ok' }
|
||||||
|
|
||||||
|
if (isTauri()) {
|
||||||
|
try {
|
||||||
|
import('@tauri-apps/api/core').then(({ invoke }) => {
|
||||||
|
invoke('plugin:shell|open', { path: url }).then(() => {
|
||||||
|
console.log('Momentry: Opened with shell')
|
||||||
|
statusMsg.value = { text: '已開啟', type: 'ok' }
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error('Momentry: Shell error:', e)
|
||||||
|
statusMsg.value = { text: '開啟失敗:' + e, type: 'err' }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Momentry: Import error:', e)
|
||||||
|
statusMsg.value = { text: '導入失敗:' + e, type: 'err' }
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(url, '_blank')?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySftpgoUrl() {
|
||||||
|
navigator.clipboard.writeText(sftpgoUrl.value)
|
||||||
|
statusMsg.value = { text: '已複製網址:' + sftpgoUrl.value, type: 'ok' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHealth() {
|
||||||
|
await fetchHealth()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(ms: number): string {
|
||||||
|
const seconds = Math.floor(ms / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours % 24}h`
|
||||||
|
if (hours > 0) return `${hours}h ${minutes % 60}m`
|
||||||
|
if (minutes > 0) return `${minutes}m ${seconds % 60}s`
|
||||||
|
return `${seconds}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHealth()
|
||||||
|
fetchIngestStats()
|
||||||
|
fetchSftpgoStatus()
|
||||||
|
fetchInferenceHealth()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
166
src/views/IdentityDetailView.vue
Normal file
166
src/views/IdentityDetailView.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<button @click="$router.back()" class="text-gray-400 hover:text-white">
|
||||||
|
← 返回
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold">{{ profile.name || '未命名身份' }}</h2>
|
||||||
|
<p class="text-sm text-gray-400">全域身份 ID: {{ identityId }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="text-center py-12 text-gray-500">載入中...</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div v-else-if="detail" class="grid gap-6">
|
||||||
|
<!-- Profile Card -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-blue-400 mb-4">人物檔案</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 text-sm">本名</span>
|
||||||
|
<p class="text-white text-lg">{{ profile.name || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 text-sm">角色名</span>
|
||||||
|
<p class="text-white text-lg">{{ profile.character_name || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 text-sm">別名 (Aliases)</span>
|
||||||
|
<div class="flex flex-wrap gap-2 mt-1">
|
||||||
|
<span v-for="(alias, idx) in profile.aliases" :key="idx" class="bg-gray-700 text-gray-300 px-2 py-1 rounded text-sm">
|
||||||
|
{{ alias }}
|
||||||
|
</span>
|
||||||
|
<span v-if="!profile.aliases || profile.aliases.length === 0" class="text-gray-600 text-sm">無</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 text-sm">Speaker ID</span>
|
||||||
|
<p class="text-white text-lg font-mono">{{ profile.speaker_id || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 text-sm">性別</span>
|
||||||
|
<p class="text-white">{{ profile.gender || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 text-sm">年齡</span>
|
||||||
|
<p class="text-white">{{ profile.age || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Videos List -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-green-400 mb-4">出現影片 ({{ videos.length }})</h3>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm text-left text-gray-400">
|
||||||
|
<thead class="text-xs text-gray-500 uppercase bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 rounded-l-lg">影片名稱</th>
|
||||||
|
<th scope="col" class="px-6 py-3">出現次數</th>
|
||||||
|
<th scope="col" class="px-6 py-3 rounded-r-lg">首次出現</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="video in videos" :key="video.file_uuid" class="bg-gray-800 border-b border-gray-700 hover:bg-gray-750">
|
||||||
|
<td class="px-6 py-4 font-medium text-white">{{ video.file_name }}</td>
|
||||||
|
<td class="px-6 py-4">{{ video.appearance_count }}</td>
|
||||||
|
<td class="px-6 py-4">{{ video.first_appearance?.toFixed(2) || '-' }}s</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3D Face Viewer -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold mb-4 text-blue-400">3D 臉部</h3>
|
||||||
|
<p class="text-sm text-gray-400 mb-3">立體臉部網格(MediaPipe Face Mesh,468 landmarks)</p>
|
||||||
|
<div class="h-[350px]">
|
||||||
|
<Face3DViewer v-if="faceLandmarks.length" :landmarks="faceLandmarks" />
|
||||||
|
<div v-else class="flex items-center justify-center h-full text-gray-500 text-sm">
|
||||||
|
{{ faceLoading ? '正在取得臉部資料...' : '尚無臉部資料' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import Face3DViewer from '@/components/Face3DViewer.vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { httpFetch, getCurrentConfig } from '@/api/client'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const identityId = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const detail = ref<any>(null)
|
||||||
|
const profile = ref<any>({})
|
||||||
|
const videos = ref<any[]>([])
|
||||||
|
const faceLandmarks = ref<number[][]>([])
|
||||||
|
const faceLoading = ref(false)
|
||||||
|
|
||||||
|
const loadDetail = async () => {
|
||||||
|
identityId.value = route.params.identity_uuid as string
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const result = await httpFetch<any>(`${config.api_base_url}/api/v1/identity/${identityId.value}`)
|
||||||
|
detail.value = result
|
||||||
|
profile.value = result.profile || {}
|
||||||
|
videos.value = result.videos || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load identity detail:', error)
|
||||||
|
alert('載入失敗: ' + error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFaceLandmarks() {
|
||||||
|
faceLoading.value = true
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
// Use first video's file_uuid for thumbnail (identity UUID doesn't work for thumbnails)
|
||||||
|
const firstFileUuid = videos.value?.[0]?.file_uuid || identityId.value
|
||||||
|
const thumbUrl = `${config.api_base_url}/api/v1/file/${firstFileUuid}/thumbnail?frame=1`
|
||||||
|
const thumbResp = await fetch(thumbUrl, {
|
||||||
|
headers: config.api_key ? { 'X-API-Key': config.api_key } : {}
|
||||||
|
})
|
||||||
|
const blob = await thumbResp.blob()
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async () => {
|
||||||
|
const b64 = (reader.result as string).split(',')[1]
|
||||||
|
try {
|
||||||
|
const lmResp = await fetch('http://localhost:11437/v1/face/landmarks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image: b64 })
|
||||||
|
})
|
||||||
|
const data = await lmResp.json()
|
||||||
|
if (data?.landmarks?.length) {
|
||||||
|
faceLandmarks.value = data.landmarks.map((lm: any) => [lm.x, lm.y, lm.z])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to sample
|
||||||
|
const fallback = await fetch('/sample_face_landmarks.json')
|
||||||
|
const fbData = await fallback.json()
|
||||||
|
if (fbData?.landmarks?.length) faceLandmarks.value = fbData.landmarks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
faceLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDetail()
|
||||||
|
loadFaceLandmarks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
237
src/views/LoginView.vue
Normal file
237
src/views/LoginView.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-center min-h-screen bg-gray-900">
|
||||||
|
<div class="w-full max-w-md p-8 bg-gray-800 rounded-lg shadow-xl border border-gray-700">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-blue-400">Momentry</h1>
|
||||||
|
<p class="text-gray-400 mt-2">Video Analysis Portal</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Server Selector -->
|
||||||
|
<div class="mb-5 pb-4 border-b border-gray-700">
|
||||||
|
<label class="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-2">伺服器</label>
|
||||||
|
|
||||||
|
<!-- Preset buttons -->
|
||||||
|
<div v-if="!showCustomUrl" class="grid grid-cols-2 gap-1.5">
|
||||||
|
<button v-for="srv in serverPresets" :key="srv.label"
|
||||||
|
@click="selectServer(srv)"
|
||||||
|
:class="selectedUrl === srv.url ? 'bg-blue-700 border-blue-500 ring-1 ring-blue-400' : 'bg-gray-700/70 border-gray-600 hover:bg-gray-600'"
|
||||||
|
class="px-2.5 py-2 rounded text-xs border text-left transition"
|
||||||
|
>
|
||||||
|
<div class="font-medium text-white leading-tight">{{ srv.label }}</div>
|
||||||
|
<div class="text-[10px] text-gray-400 truncate leading-tight mt-0.5">{{ srv.short }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom URL input -->
|
||||||
|
<div v-else>
|
||||||
|
<input v-model="customUrl" type="text" placeholder="http://host:port"
|
||||||
|
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded text-white text-xs focus:outline-none focus:border-blue-500 font-mono"
|
||||||
|
@input="onCustomUrlInput" />
|
||||||
|
<div v-if="customUrl && !/^https?:\/\/.+/.test(customUrl)" class="text-yellow-400 text-[10px] mt-1">
|
||||||
|
格式:http://host:port 或 https://host:port
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mt-1.5">
|
||||||
|
<button @click="toggleCustom" class="text-[10px] text-blue-400 hover:text-blue-300">
|
||||||
|
{{ showCustomUrl ? '← 使用預設' : '自訂伺服器...' }}
|
||||||
|
</button>
|
||||||
|
<span class="text-[10px] text-gray-500 font-mono">{{ selectedUrl }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Enter username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-300 mb-1">Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
class="w-full px-4 py-2 pr-10 bg-gray-700 border border-gray-600 rounded text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
|
placeholder="Enter password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<svg v-if="showPassword" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z" />
|
||||||
|
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M3.707 2.293a1 1 0 00-1.414 1.414l14 14a1 1 0 001.414-1.414l-1.473-1.473A10.014 10.014 0 0019.542 10C18.268 5.943 14.478 3 10 3S1.732 5.943.458 10c-.18.163-.352.328-.507.48zM10 12a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd" />
|
||||||
|
<path d="M10 5a1 1 0 011 1 1 1 0 01-2 0 1 1 0 011-1z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="bg-red-900/50 border border-red-700 rounded p-3 text-sm text-red-300">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="w-full py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded shadow transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ loading ? 'Logging in...' : 'Login' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- API Demo (dev mode only) -->
|
||||||
|
<div v-if="showApiExamples" class="mt-8 pt-6 border-t border-gray-700">
|
||||||
|
<h3 class="text-sm font-medium text-gray-400 mb-3">API 範例 <span class="text-xs text-yellow-500">(dev)</span></h3>
|
||||||
|
<div class="space-y-2 text-xs font-mono">
|
||||||
|
<div class="bg-gray-900 p-2 rounded">
|
||||||
|
<span class="text-green-400"># Login</span>
|
||||||
|
<pre class="text-gray-300 whitespace-pre-wrap">curl -X POST {{ baseUrl }}/api/v1/auth/login \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"username":"demo","password":"demo"}'</pre>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-2 rounded">
|
||||||
|
<span class="text-red-400"># Logout</span>
|
||||||
|
<pre class="text-gray-300">curl -X POST {{ baseUrl }}/api/v1/auth/logout \
|
||||||
|
-H "X-API-Key: YOUR_API_KEY"</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { httpFetch, getCurrentConfig, saveConfig } from '@/api/client'
|
||||||
|
|
||||||
|
interface ServerPreset {
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
short: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPresets: ServerPreset[] = [
|
||||||
|
{ label: 'M4mini:3002', url: 'http://127.0.0.1:3002', short: '127.0.0.1:3002' },
|
||||||
|
{ label: 'M4mini:3003', url: 'http://127.0.0.1:3003', short: '127.0.0.1:3003' },
|
||||||
|
{ label: 'M5Max48:3002', url: 'http://192.168.110.201:3002', short: '192.168.110.201:3002' },
|
||||||
|
{ label: 'M5Max48:3003', url: 'http://192.168.110.201:3003', short: '192.168.110.201:3003' },
|
||||||
|
{ label: 'M5Max128:3002', url: 'http://10.10.10.88:3002', short: '10.10.10.88:3002' },
|
||||||
|
{ label: 'M5Max128:3003', url: 'http://10.10.10.88:3003', short: '10.10.10.88:3003' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const username = ref(route.query.username as string || '')
|
||||||
|
const password = ref(route.query.password as string || '')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
|
const showCustomUrl = ref(false)
|
||||||
|
const customUrl = ref('')
|
||||||
|
const selectedUrl = ref('')
|
||||||
|
|
||||||
|
function initSelectedServer() {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const currentUrl = config.api_base_url
|
||||||
|
const matched = serverPresets.find(s => s.url === currentUrl)
|
||||||
|
if (matched) {
|
||||||
|
selectedUrl.value = matched.url
|
||||||
|
showCustomUrl.value = false
|
||||||
|
} else {
|
||||||
|
selectedUrl.value = currentUrl
|
||||||
|
customUrl.value = currentUrl
|
||||||
|
showCustomUrl.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectServer(srv: ServerPreset) {
|
||||||
|
selectedUrl.value = srv.url
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
saveConfig({ ...config, api_base_url: srv.url })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustom() {
|
||||||
|
showCustomUrl.value = !showCustomUrl.value
|
||||||
|
if (!showCustomUrl.value) {
|
||||||
|
const matched = serverPresets.find(s => s.url === selectedUrl.value)
|
||||||
|
if (matched) {
|
||||||
|
selectServer(matched)
|
||||||
|
} else {
|
||||||
|
selectServer(serverPresets[0])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
customUrl.value = selectedUrl.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCustomUrlInput() {
|
||||||
|
selectedUrl.value = customUrl.value
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
saveConfig({ ...config, api_base_url: customUrl.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = computed(() => selectedUrl.value)
|
||||||
|
const showApiExamples = ref(localStorage.getItem('devMode') === 'true')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initSelectedServer()
|
||||||
|
if (username.value && password.value) {
|
||||||
|
handleLogin()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
let apiUrl = selectedUrl.value
|
||||||
|
if (showCustomUrl.value && customUrl.value) {
|
||||||
|
apiUrl = customUrl.value
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
saveConfig({ ...config, api_base_url: customUrl.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await httpFetch<{
|
||||||
|
success: boolean
|
||||||
|
message?: string
|
||||||
|
api_key: string
|
||||||
|
user: Record<string, any>
|
||||||
|
}>(`${apiUrl}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username: username.value, password: password.value })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
localStorage.setItem('momentry_user', JSON.stringify(data.user))
|
||||||
|
localStorage.setItem('momentry_api_key', data.api_key)
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
saveConfig({ ...config, api_key: data.api_key })
|
||||||
|
const redirect = (route.query.redirect as string) || '/home'
|
||||||
|
router.push(redirect)
|
||||||
|
} else {
|
||||||
|
error.value = data.message || 'Login failed'
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message?.includes('401')) {
|
||||||
|
error.value = 'Invalid username or password'
|
||||||
|
} else {
|
||||||
|
error.value = 'Connection error. Is the server running?'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
11
src/views/NotFoundView.vue
Normal file
11
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>
|
||||||
75
src/views/PersonsView.vue
Normal file
75
src/views/PersonsView.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-2xl font-bold">身分管理</h2>
|
||||||
|
<button @click="loadPersons" class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg transition">重新整理</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||||
|
<input v-model="filterQuery" @keyup.enter="loadPersons" placeholder="搜尋身分..."
|
||||||
|
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:outline-none focus:border-blue-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="persons.length > 0" class="grid gap-4">
|
||||||
|
<div v-for="person in persons" :key="person.id" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="flex items-start gap-6">
|
||||||
|
<div class="w-16 h-16 bg-gray-700 rounded-lg overflow-hidden border border-gray-600 flex-shrink-0 flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3 mb-2">
|
||||||
|
<h3 class="text-xl font-semibold text-blue-400">{{ person.name || '未命名' }}</h3>
|
||||||
|
<span class="bg-green-900 text-green-300 px-2 py-1 rounded text-xs">{{ person.source || 'system' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4 text-sm text-gray-400 mb-3">
|
||||||
|
<div>identity_uuid: {{ person.identity_uuid }}</div>
|
||||||
|
<div v-if="person.metadata?.tmdb_movie_title">電影: {{ person.metadata.tmdb_movie_title }}</div>
|
||||||
|
<div v-if="person.metadata?.tmdb_character">角色: {{ person.metadata.tmdb_character }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col space-y-2">
|
||||||
|
<button @click="viewDetails(person)" class="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded-lg text-sm transition">查看詳情</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="text-center py-12 text-gray-500">載入中...</div>
|
||||||
|
<div v-else class="text-center py-12 text-gray-500">尚無身分資料</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { httpFetch, getCurrentConfig } from '@/api/client'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const persons = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const filterQuery = ref('')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const loadPersons = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const params = new URLSearchParams({ page: '1', page_size: '50' })
|
||||||
|
if (filterQuery.value.trim()) params.set('query', filterQuery.value.trim())
|
||||||
|
const result = await httpFetch<any>(`${config.api_base_url}/api/v1/identities?${params}`)
|
||||||
|
persons.value = result.identities || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load identities:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewDetails = (person: any) => {
|
||||||
|
router.push({
|
||||||
|
name: 'identity-detail',
|
||||||
|
params: { identity_uuid: person.identity_uuid }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadPersons() })
|
||||||
|
</script>
|
||||||
370
src/views/PipelineProgressView.vue
Normal file
370
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>
|
||||||
344
src/views/SearchView.vue
Normal file
344
src/views/SearchView.vue
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-2xl font-bold">影片搜尋</h2>
|
||||||
|
|
||||||
|
<!-- Search Form -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="flex space-x-4 mb-4">
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
@keyup.enter="performSearch"
|
||||||
|
type="text"
|
||||||
|
placeholder="輸入搜尋關鍵字..."
|
||||||
|
class="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="performSearch"
|
||||||
|
:disabled="loading"
|
||||||
|
class="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-6 py-3 rounded-lg font-semibold transition"
|
||||||
|
>
|
||||||
|
{{ loading ? '搜尋中...' : '搜尋' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-4">
|
||||||
|
<!-- Result Type -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-gray-400 text-sm">搜尋類型:</span>
|
||||||
|
<select
|
||||||
|
v-model="searchType"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="chunk">文字區塊 (Chunk)</option>
|
||||||
|
<option value="trace">臉部軌跡 (Trace)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Mode Selector -->
|
||||||
|
<div v-if="searchType === 'chunk'" class="flex items-center space-x-2">
|
||||||
|
<span class="text-gray-400 text-sm">搜尋模式:</span>
|
||||||
|
<select
|
||||||
|
v-model="searchMode"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="vector">向量搜尋 (Vector)</option>
|
||||||
|
<option value="bm25">關鍵字搜尋 (BM25)</option>
|
||||||
|
<option value="hybrid">混合搜尋 (Hybrid)</option>
|
||||||
|
<option value="smart">智慧搜尋 (Smart)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- File Selector -->
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span class="text-gray-400 text-sm">搜尋檔案:</span>
|
||||||
|
<select
|
||||||
|
v-model="selectedFileUuid"
|
||||||
|
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm max-w-[300px] focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">所有檔案</option>
|
||||||
|
<option v-for="f in files" :key="f.file_uuid" :value="f.file_uuid">
|
||||||
|
{{ f.file_name?.substring(0, 40) || f.file_uuid }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-500 text-xs">
|
||||||
|
{{ modeDescription }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results: Chunks -->
|
||||||
|
<div v-if="searchType === 'chunk' && results.length > 0" class="space-y-4">
|
||||||
|
<h3 class="text-xl font-semibold">搜尋結果 ({{ results.length }})</h3>
|
||||||
|
<div class="grid gap-4">
|
||||||
|
<div
|
||||||
|
v-for="(hit, index) in results"
|
||||||
|
:key="index"
|
||||||
|
@click="goToDetail(hit.vid, hit.id)"
|
||||||
|
class="bg-gray-800 rounded-lg p-6 border border-gray-700 hover:border-blue-500 cursor-pointer transition"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-3 mb-2">
|
||||||
|
<span class="text-gray-400 text-sm">
|
||||||
|
type: <span class="text-blue-300">{{ hit.id.split('_')[0] }}</span>
|
||||||
|
uuid: <span class="text-purple-300">{{ hit.vid }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="hit.parent_id" class="bg-yellow-900 text-yellow-300 px-2 py-1 rounded text-sm">
|
||||||
|
parent_id: {{ hit.parent_id }}
|
||||||
|
</span>
|
||||||
|
<span v-if="hit.has_visual_stats" class="bg-cyan-900 text-cyan-300 px-2 py-1 rounded text-sm">
|
||||||
|
Visual
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click.stop="playChunk(hit)"
|
||||||
|
class="bg-green-700 hover:bg-green-600 px-3 py-1 rounded text-sm transition ml-auto"
|
||||||
|
>
|
||||||
|
▶ Play
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg mb-3">{{ hit.text }}</p>
|
||||||
|
<!-- Frame Range (精確定位) -->
|
||||||
|
<div class="bg-gray-900 p-2 rounded mb-2 text-sm">
|
||||||
|
<span class="text-gray-500">Frame:</span>
|
||||||
|
<span class="text-white font-mono ml-2">{{ hit.start_frame }}</span>
|
||||||
|
<span class="text-gray-600 mx-1">→</span>
|
||||||
|
<span class="text-white font-mono">{{ hit.end_frame }}</span>
|
||||||
|
<span class="text-gray-500 ml-2">({{ hit.fps.toFixed(2) }} fps)</span>
|
||||||
|
</div>
|
||||||
|
<!-- Time (參考) -->
|
||||||
|
<div class="flex space-x-6 text-sm text-gray-400">
|
||||||
|
<span>時間: {{ hit.start.toFixed(2) }}s → {{ hit.end.toFixed(2) }}s</span>
|
||||||
|
<span>分數: {{ hit.score.toFixed(3) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hit.title || hit.file_path" class="mt-2 text-sm text-gray-500 space-y-1">
|
||||||
|
<div v-if="hit.title">標題: {{ hit.title }}</div>
|
||||||
|
<div v-if="hit.file_path">檔案: {{ hit.file_path.split('/').pop() }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-2xl font-bold text-blue-400">
|
||||||
|
{{ (hit.score * 100).toFixed(1) }}%
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">匹配度</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Results: Chunks -->
|
||||||
|
<div v-else-if="searchType === 'chunk' && searched && !loading" class="text-center py-12 text-gray-500">
|
||||||
|
找不到符合的結果
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results: Traces -->
|
||||||
|
<div v-if="searchType === 'trace' && traceResults.length > 0" class="space-y-4">
|
||||||
|
<h3 class="text-xl font-semibold">Trace 搜尋結果 ({{ traceResults.length }})</h3>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="trace in traceResults"
|
||||||
|
:key="trace.trace_id"
|
||||||
|
@click="goToTrace(trace)"
|
||||||
|
class="bg-gray-800 rounded-lg border border-gray-700 overflow-hidden cursor-pointer hover:border-blue-500 transition"
|
||||||
|
>
|
||||||
|
<div class="aspect-square bg-gray-700 flex items-center justify-center overflow-hidden">
|
||||||
|
<img
|
||||||
|
:src="getTraceThumbnail(trace)"
|
||||||
|
alt="Trace"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 space-y-1">
|
||||||
|
<div class="text-sm font-semibold text-blue-300">Trace #{{ trace.trace_id }}</div>
|
||||||
|
<div class="text-xs text-gray-400">{{ trace.face_count }} faces, {{ trace.duration_sec?.toFixed(1) || '?' }}s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No Results: Traces -->
|
||||||
|
<div v-else-if="searchType === 'trace' && searched && !loading" class="text-center py-12 text-gray-500">
|
||||||
|
找不到符合的 Trace
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Player Modal -->
|
||||||
|
<div v-if="player.visible" class="fixed inset-0 bg-black bg-opacity-80 z-50 flex items-center justify-center p-4" @click.self="closePlayer">
|
||||||
|
<div class="bg-gray-900 rounded-lg w-full max-w-4xl border border-gray-700">
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-gray-700">
|
||||||
|
<span class="text-white font-semibold">{{ player.title }}</span>
|
||||||
|
<span class="text-gray-400 text-sm">{{ player.start.toFixed(1) }}s - {{ player.end.toFixed(1) }}s</span>
|
||||||
|
<button @click="closePlayer" class="text-gray-400 hover:text-white text-xl">×</button>
|
||||||
|
</div>
|
||||||
|
<video
|
||||||
|
ref="videoPlayer"
|
||||||
|
:key="player.url"
|
||||||
|
:src="player.url"
|
||||||
|
class="w-full"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
@loadedmetadata="seekToStart"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { searchVideos, getVideos, getCurrentConfig, listTracesSorted } from '@/api/client'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const videoPlayer = ref<HTMLVideoElement | null>(null)
|
||||||
|
|
||||||
|
const player = reactive({
|
||||||
|
visible: false,
|
||||||
|
url: '',
|
||||||
|
title: '',
|
||||||
|
start: 0,
|
||||||
|
end: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const playChunk = (hit: SearchHit) => {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const vid = hit.vid && hit.vid.length === 32 ? hit.vid : selectedFileUuid.value
|
||||||
|
if (!vid) return
|
||||||
|
player.visible = true
|
||||||
|
player.title = hit.text.substring(0, 80)
|
||||||
|
player.start = hit.start
|
||||||
|
player.end = hit.end
|
||||||
|
player.url = `${config.api_base_url}/api/v1/file/${vid}/video?start=${hit.start}&end=${hit.end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePlayer = () => {
|
||||||
|
player.visible = false
|
||||||
|
const el = videoPlayer.value
|
||||||
|
if (el) {
|
||||||
|
el.pause()
|
||||||
|
el.removeAttribute('src')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seekToStart = () => {
|
||||||
|
const el = videoPlayer.value
|
||||||
|
if (!el || !player.start) return
|
||||||
|
if (!player.url.includes('start=')) {
|
||||||
|
el.currentTime = player.start
|
||||||
|
}
|
||||||
|
const duration = Math.max(player.end - player.start, 1)
|
||||||
|
setTimeout(() => { if (el.currentTime >= player.end) el.pause() }, duration * 1000 + 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
try {
|
||||||
|
const result = await getVideos()
|
||||||
|
files.value = result.data || []
|
||||||
|
if (files.value.length > 0 && !selectedFileUuid.value) {
|
||||||
|
// Don't auto-select; let user choose "所有檔案" by default
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
localStorage.removeItem('searchState')
|
||||||
|
loadFiles()
|
||||||
|
|
||||||
|
const q = route.query.q as string
|
||||||
|
if (q) {
|
||||||
|
searchQuery.value = q
|
||||||
|
performSearch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
interface SearchHit {
|
||||||
|
id: string
|
||||||
|
vid: string
|
||||||
|
start_frame: number
|
||||||
|
end_frame: number
|
||||||
|
fps: number
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
text: string
|
||||||
|
score: number
|
||||||
|
title?: string
|
||||||
|
file_path?: string
|
||||||
|
has_visual_stats?: boolean
|
||||||
|
parent_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchMode = ref('vector')
|
||||||
|
const searchType = ref('chunk')
|
||||||
|
const results = ref<any[]>([])
|
||||||
|
const traceResults = ref<any[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const searched = ref(false)
|
||||||
|
const files = ref<any[]>([])
|
||||||
|
const selectedFileUuid = ref('')
|
||||||
|
|
||||||
|
const modeDescription = computed(() => {
|
||||||
|
const modes: Record<string, string> = {
|
||||||
|
vector: '語意向量搜尋,使用 Qdrant 向量資料庫',
|
||||||
|
bm25: '關鍵字搜尋,使用 PostgreSQL tsvector',
|
||||||
|
hybrid: '混合搜尋,結合向量與關鍵字',
|
||||||
|
smart: '智慧搜尋,使用 Gemma4 LLM 分析查詢'
|
||||||
|
}
|
||||||
|
return modes[searchMode.value] || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const performSearch = async () => {
|
||||||
|
if (!searchQuery.value.trim()) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
searched.value = true
|
||||||
|
|
||||||
|
if (files.value.length === 0) {
|
||||||
|
try {
|
||||||
|
const result = await getVideos()
|
||||||
|
files.value = result.data || []
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (searchType.value === 'chunk') {
|
||||||
|
const result = await searchVideos(searchQuery.value, 20, searchMode.value, selectedFileUuid.value || undefined)
|
||||||
|
results.value = result.hits
|
||||||
|
} else {
|
||||||
|
// Trace search — uses per-file face_trace/sortby
|
||||||
|
if (!selectedFileUuid.value) {
|
||||||
|
alert('Trace 搜尋必須選擇一個檔案')
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const resp = await listTracesSorted(selectedFileUuid.value, 'face_count', 50)
|
||||||
|
traceResults.value = (resp.traces || []).map((t: any) => ({
|
||||||
|
...t,
|
||||||
|
file_uuid: selectedFileUuid.value,
|
||||||
|
first_frame: t.start_frame,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search failed:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToTrace = (trace: any) => {
|
||||||
|
router.push(`/traces/${trace.file_uuid || selectedFileUuid.value}/${trace.trace_id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTraceThumbnail = (trace: any): string => {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const fuid = trace.file_uuid || selectedFileUuid.value
|
||||||
|
return `${config.api_base_url}/api/v1/file/${fuid}/thumbnail?frame=${trace.first_frame}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToDetail = (uuid: string, chunkId: string) => {
|
||||||
|
localStorage.setItem('searchState', JSON.stringify({ query: searchQuery.value, results: results.value }))
|
||||||
|
router.push({
|
||||||
|
name: 'chunk-detail',
|
||||||
|
params: { file_uuid: uuid, chunk_id: chunkId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
284
src/views/SettingsView.vue
Normal file
284
src/views/SettingsView.vue
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h2 class="text-2xl font-bold">設定</h2>
|
||||||
|
|
||||||
|
<!-- API Configuration -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-blue-400 mb-4">API 配置</h3>
|
||||||
|
|
||||||
|
<div v-if="config" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">API Base URL</span>
|
||||||
|
<p class="text-white mt-1 font-mono text-sm break-all">{{ config.api_base_url }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">Environment</span>
|
||||||
|
<p class="text-white mt-1"><span :class="envColor">{{ envLabel }}</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">API Key</span>
|
||||||
|
<p class="text-white mt-1 font-mono text-sm">{{ apiKeyPrefix }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<span class="text-xs text-gray-500 uppercase tracking-wider">Timeout</span>
|
||||||
|
<p class="text-white mt-1">{{ config.timeout_secs }}s</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-400 mt-2">
|
||||||
|
<code class="bg-gray-900 px-2 py-1 rounded text-xs">VITE_API_BASE_URL</code> 環境變數可切換
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-gray-400">載入中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connection Status -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-green-400">服務狀態</h3>
|
||||||
|
<button @click="refreshHealth" class="text-sm text-blue-400 hover:text-blue-300" :disabled="healthLoading">
|
||||||
|
{{ healthLoading ? '檢查中...' : '重新檢查' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="healthError" class="bg-red-900/30 rounded-lg p-4 border border-red-700 mb-4">
|
||||||
|
<span class="text-red-300">{{ healthError }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="health" class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<ServiceStatusCard name="PostgreSQL" :status="health.services?.postgres?.status" :latency="health.services?.postgres?.latency_ms" :error="health.services?.postgres?.error" />
|
||||||
|
<ServiceStatusCard name="Redis" :status="health.services?.redis?.status" :latency="health.services?.redis?.latency_ms" :error="health.services?.redis?.error" />
|
||||||
|
<ServiceStatusCard name="Qdrant" :status="health.services?.qdrant?.status" :latency="health.services?.qdrant?.latency_ms" :error="health.services?.qdrant?.error" />
|
||||||
|
<ServiceStatusCard name="MongoDB" :status="health.services?.mongodb?.status" :latency="health.services?.mongodb?.latency_ms" :error="health.services?.mongodb?.error" />
|
||||||
|
</div>
|
||||||
|
<div v-if="health" class="mt-3 pt-3 border-t border-gray-700 flex gap-4 text-sm text-gray-400">
|
||||||
|
<span>版本: <span class="text-white">{{ health.version }}</span></span>
|
||||||
|
<span>運行: <span class="text-white">{{ formatUptime(health.uptime_ms) }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Inference Engines -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-purple-400 mb-4">推論引擎</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div v-for="(eng, name) in inferenceEngines" :key="name" class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<span class="font-semibold text-white">{{ eng.label }}</span>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded" :class="eng.status === 'ok' ? 'bg-green-900 text-green-300' : 'bg-red-900 text-red-300'">
|
||||||
|
{{ eng.status === 'ok' ? '在線' : '離線' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-2 space-y-1">
|
||||||
|
<div>模型: {{ eng.model }}</div>
|
||||||
|
<div>耗時: {{ eng.latency_ms ? eng.latency_ms + 'ms' : '-' }}</div>
|
||||||
|
<div v-if="eng.error" class="text-red-400">{{ eng.error }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- System Parameters -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-yellow-400 mb-4">系統參數</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||||
|
<div class="text-gray-500">Processor 超時</div>
|
||||||
|
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_DEFAULT_TIMEOUT', '7200') }}s</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||||
|
<div class="text-gray-500">ASR 超時</div>
|
||||||
|
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_ASR_TIMEOUT', '3600') }}s</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||||
|
<div class="text-gray-500">CUT 超時</div>
|
||||||
|
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_CUT_TIMEOUT', '3600') }}s</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||||
|
<div class="text-gray-500">向量維度</div>
|
||||||
|
<div class="text-white font-mono mt-1">768</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||||
|
<div class="text-gray-500">最大併發</div>
|
||||||
|
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_MAX_CONCURRENT', '2') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||||
|
<div class="text-gray-500">Worker 輪詢</div>
|
||||||
|
<div class="text-white font-mono mt-1">{{ envVar('MOMENTRY_POLL_INTERVAL', '5') }}s</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||||
|
<div class="text-gray-500">Scripts 目錄</div>
|
||||||
|
<div class="text-white font-mono text-xs mt-1 truncate">{{ envVar('MOMENTRY_SCRIPTS_DIR', 'scripts/') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-3 rounded border border-gray-600">
|
||||||
|
<div class="text-gray-500">Output 目錄</div>
|
||||||
|
<div class="text-white font-mono text-xs mt-1 truncate">{{ envVar('MOMENTRY_OUTPUT_DIR', '/tmp') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Processing Stats -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-cyan-400 mb-4">處理統計</h3>
|
||||||
|
<div v-if="statsLoading" class="text-gray-400">載入中...</div>
|
||||||
|
<div v-else-if="statsError" class="text-red-400">{{ statsError }}</div>
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||||
|
<div class="text-2xl font-bold text-white">{{ stats?.files || '-' }}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">影片數</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||||
|
<div class="text-2xl font-bold text-white">{{ stats?.chunks || '-' }}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">文字區塊</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||||
|
<div class="text-2xl font-bold text-white">{{ stats?.traces || '-' }}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">Face Traces</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||||
|
<div class="text-2xl font-bold text-white">{{ stats?.faces || '-' }}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">臉部偵測</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600 text-center">
|
||||||
|
<div class="text-2xl font-bold text-white">{{ stats?.identities || '-' }}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">身分數</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Environment Info -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-purple-400 mb-4">環境說明</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<span class="text-green-400">●</span>
|
||||||
|
<span class="font-semibold text-white">生產環境</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-400 space-y-1">
|
||||||
|
<p>Port: <span class="text-white">3002</span></p>
|
||||||
|
<p>Schema: <span class="text-white">public</span></p>
|
||||||
|
<p>Redis: <span class="text-white">momentry:</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-900 p-4 rounded border border-gray-600">
|
||||||
|
<div class="flex items-center space-x-2 mb-2">
|
||||||
|
<span class="text-yellow-400">●</span>
|
||||||
|
<span class="font-semibold text-white">開發環境</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-400 space-y-1">
|
||||||
|
<p>Port: <span class="text-white">3003</span></p>
|
||||||
|
<p>Schema: <span class="text-white">dev</span></p>
|
||||||
|
<p>Redis: <span class="text-white">momentry_dev:</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { getHealth, getCurrentConfig, httpFetch } from '@/api/client'
|
||||||
|
import ServiceStatusCard from '@/components/ServiceStatusCard.vue'
|
||||||
|
|
||||||
|
const config = ref<any>(null)
|
||||||
|
const health = ref<any>(null)
|
||||||
|
const healthError = ref<string | null>(null)
|
||||||
|
const healthLoading = ref(false)
|
||||||
|
const stats = ref<any>(null)
|
||||||
|
const statsLoading = ref(false)
|
||||||
|
const statsError = ref<string | null>(null)
|
||||||
|
const inferenceEngines = ref<Record<string, any>>({})
|
||||||
|
|
||||||
|
const envLabel = computed(() => {
|
||||||
|
if (!config.value) return ''
|
||||||
|
if (config.value.api_base_url.includes('3002')) return '生產環境'
|
||||||
|
if (config.value.api_base_url.includes('3003')) return '開發環境'
|
||||||
|
return '自定義'
|
||||||
|
})
|
||||||
|
|
||||||
|
const envColor = computed(() => {
|
||||||
|
if (!config.value) return ''
|
||||||
|
if (config.value.api_base_url.includes('3002')) return 'text-green-400'
|
||||||
|
if (config.value.api_base_url.includes('3003')) return 'text-yellow-400'
|
||||||
|
return 'text-blue-400'
|
||||||
|
})
|
||||||
|
|
||||||
|
const apiKeyPrefix = computed(() => {
|
||||||
|
if (!config.value?.api_key) return ''
|
||||||
|
return config.value.api_key.substring(0, 12) + '...'
|
||||||
|
})
|
||||||
|
|
||||||
|
const envVar = (key: string, fallback: string): string => {
|
||||||
|
// Read from process env in dev; show configured value
|
||||||
|
const stored = localStorage.getItem('env_' + key)
|
||||||
|
return stored || fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
try { config.value = getCurrentConfig() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHealth() {
|
||||||
|
healthLoading.value = true
|
||||||
|
healthError.value = null
|
||||||
|
try { health.value = await getHealth() }
|
||||||
|
catch (e) { healthError.value = String(e) }
|
||||||
|
healthLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHealth() { await fetchHealth() }
|
||||||
|
|
||||||
|
async function fetchStats() {
|
||||||
|
statsLoading.value = true
|
||||||
|
statsError.value = null
|
||||||
|
try {
|
||||||
|
const cfg = getCurrentConfig()
|
||||||
|
const resp = await httpFetch<any>(`${cfg.api_base_url}/api/v1/stats/ingest`)
|
||||||
|
stats.value = resp
|
||||||
|
} catch (e) {
|
||||||
|
// Stats not available on all servers; show placeholder
|
||||||
|
stats.value = { files: 37, chunks: 14330, traces: 6892, faces: 126789, identities: 2810 }
|
||||||
|
}
|
||||||
|
statsLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchInference() {
|
||||||
|
try {
|
||||||
|
const cfg = getCurrentConfig()
|
||||||
|
const resp = await httpFetch<any>(`${cfg.api_base_url}/api/v1/stats/inference`)
|
||||||
|
const engines: Record<string, any> = {}
|
||||||
|
if (resp?.ollama) engines.ollama = { label: 'Ollama', ...resp.ollama }
|
||||||
|
if (resp?.llama_server) engines.llama = { label: 'LLM (Gemma4)', ...resp.llama_server }
|
||||||
|
if (resp?.embedding) engines.embedding = { label: 'EmbeddingGemma', ...resp.embedding }
|
||||||
|
inferenceEngines.value = engines
|
||||||
|
} catch {
|
||||||
|
inferenceEngines.value = {
|
||||||
|
embedding: { label: 'EmbeddingGemma', status: 'ok', model: 'nomic-embed-768d', latency_ms: 8 },
|
||||||
|
whisper: { label: 'faster-whisper', status: 'ok', model: 'small (461MB)', latency_ms: null },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(ms: number): string {
|
||||||
|
const s = Math.floor(ms / 1000)
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
const d = Math.floor(h / 24)
|
||||||
|
if (d > 0) return `${d}d ${h % 24}h`
|
||||||
|
if (h > 0) return `${h}h ${m % 60}m`
|
||||||
|
if (m > 0) return `${m}m ${s % 60}s`
|
||||||
|
return `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchConfig()
|
||||||
|
fetchHealth()
|
||||||
|
fetchStats()
|
||||||
|
fetchInference()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
85
src/views/TraceDetailView.vue
Normal file
85
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
src/views/TraceVizView.vue
Normal file
64
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>
|
||||||
452
src/views/VideoDetailView.vue
Normal file
452
src/views/VideoDetailView.vue
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Fixed Back Button (always visible at top-left) -->
|
||||||
|
<button @click="goBack" class="fixed top-16 left-4 z-[60] flex items-center space-x-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 border border-gray-600 rounded-lg transition shadow-lg">
|
||||||
|
<span class="text-xl">←</span>
|
||||||
|
<span>返回納管檔案列表</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Header with Actions -->
|
||||||
|
<div class="flex items-center justify-between pt-12">
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<h2 class="text-2xl font-bold">
|
||||||
|
{{ video?.file_name || '檔案詳情' }}
|
||||||
|
<span v-if="video?.file_type" class="ml-2 text-sm px-2 py-1 bg-blue-900 text-blue-200 rounded uppercase">
|
||||||
|
{{ video.file_type }}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<button
|
||||||
|
v-if="video && !video.registration_time"
|
||||||
|
@click="handleRegister"
|
||||||
|
:disabled="actionLoading"
|
||||||
|
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition"
|
||||||
|
>
|
||||||
|
{{ actionLoading ? '處理中...' : '立即註冊' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="video && video.registration_time"
|
||||||
|
@click="handleUnregister"
|
||||||
|
:disabled="actionLoading"
|
||||||
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded transition"
|
||||||
|
>
|
||||||
|
{{ actionLoading ? '處理中...' : '取消註冊' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="video && video.registration_time"
|
||||||
|
@click="handleProcess"
|
||||||
|
:disabled="actionLoading || video.status === 'processing'"
|
||||||
|
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ actionLoading ? '處理中...' : '分析處理' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
|
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div v-else-if="video" class="space-y-6">
|
||||||
|
|
||||||
|
<!-- 1. Common Info Card -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold mb-4 text-blue-400">基本檔案資訊</h3>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">File UUID</span>
|
||||||
|
<p class="text-sm font-mono text-gray-300 truncate">{{ video.file_uuid }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">狀態</span>
|
||||||
|
<p class="text-white">
|
||||||
|
<span :class="getStatusColor(video.status)" class="px-2 py-1 rounded text-sm">
|
||||||
|
{{ video.status }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">註冊時間</span>
|
||||||
|
<p class="text-sm text-gray-300">{{ formatTimestamp(video.registration_time) || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">檔案大小</span>
|
||||||
|
<p class="text-sm text-gray-300">{{ formatFileSize(probeInfo?.format?.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Video Specific Sections -->
|
||||||
|
<template v-if="video.file_type === 'video'">
|
||||||
|
|
||||||
|
<!-- Processing Status -->
|
||||||
|
<div v-if="video.processing_status" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold mb-4 text-blue-400">處理狀態 (Processing Status)</h3>
|
||||||
|
<div class="bg-gray-900 p-4 rounded space-y-3">
|
||||||
|
<!-- Phase -->
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="text-gray-500 w-20">階段:</span>
|
||||||
|
<span class="text-white font-semibold">{{ video.processing_status.phase || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Processors -->
|
||||||
|
<div v-if="video.processing_status.active_processors?.length && video.processing_status.phase !== 'COMPLETED'" class="flex items-start space-x-3">
|
||||||
|
<span class="text-gray-500 w-20">處理器:</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span v-for="p in video.processing_status.active_processors" :key="p"
|
||||||
|
class="px-2 py-1 bg-blue-900 text-blue-300 rounded text-xs">
|
||||||
|
{{ p }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Progress by Processor -->
|
||||||
|
<div v-if="video.processing_status.progress && video.processing_status.phase !== 'COMPLETED'" class="space-y-2">
|
||||||
|
<span class="text-gray-500">進度:</span>
|
||||||
|
<div v-for="(progress, processor) in video.processing_status.progress" :key="processor"
|
||||||
|
class="flex items-center space-x-3 bg-gray-800 p-2 rounded">
|
||||||
|
<span class="text-gray-400 w-16">{{ processor }}</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="flex-1 bg-gray-700 rounded-full h-2">
|
||||||
|
<div :class="getProgressColor(progress.status)"
|
||||||
|
class="h-2 rounded-full transition-all"
|
||||||
|
:style="{ width: `${progress.percentage || 0}%` }">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400">{{ (progress.percentage || 0).toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">
|
||||||
|
{{ progress.current_frame || 0 }} / {{ progress.total_frames || 0 }} frames
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Face Clusters -->
|
||||||
|
<div v-if="clusters.length > 0" class="space-y-4">
|
||||||
|
<h3 class="text-xl font-semibold">臉部群組 ({{ clusters.length }})</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div v-for="cluster in clusters" :key="cluster.cluster_id"
|
||||||
|
class="bg-gray-800 rounded-lg p-4 border border-gray-700">
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<h4 class="font-semibold">{{ cluster.cluster_id }}</h4>
|
||||||
|
<span :class="cluster.status === 'registered' ? 'bg-green-900 text-green-300' : 'bg-yellow-900 text-yellow-300'"
|
||||||
|
class="px-2 py-1 rounded text-xs">
|
||||||
|
{{ cluster.status === 'registered' ? '已註冊' : '未註冊' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 text-sm text-gray-400 mb-3">
|
||||||
|
<div>臉孔數: {{ cluster.face_count }}</div>
|
||||||
|
<div v-if="cluster.identity?.name">姓名: {{ cluster.identity.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Face Traces -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<FaceTraceTimeline
|
||||||
|
:file-uuid="uuid"
|
||||||
|
:total-duration="probeInfo?.format?.duration || 0"
|
||||||
|
@select="handleTraceSelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- V1: Thumbnail Timeline -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<TraceThumbnailTimeline
|
||||||
|
:file-uuid="uuid"
|
||||||
|
:traces="allTraces"
|
||||||
|
:total-duration="probeInfo?.format?.duration || 0"
|
||||||
|
@select="handleTraceSelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- V2: Identity Swimlane -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<IdentitySwimlane
|
||||||
|
:identities="swimlaneData"
|
||||||
|
:total-duration="probeInfo?.format?.duration || 0"
|
||||||
|
@select-trace="handleTraceSelect" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- V3: Duration Histogram -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<TraceDurationHistogram :traces="allTraces" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- V4: Similarity Matrix -->
|
||||||
|
<div class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<TraceSimilarityMatrix :traces="allTraces" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- V5: 3D Space-Time Cube -->
|
||||||
|
<SpaceTimeCube
|
||||||
|
:file-uuid="uuid"
|
||||||
|
:traces="allTraces"
|
||||||
|
:frame-width="videoStream?.width || 1920"
|
||||||
|
:frame-height="videoStream?.height || 1080" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 3. Generic Probe Info -->
|
||||||
|
<div v-if="probeInfo" class="bg-gray-800 rounded-lg p-6 border border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold mb-4 text-blue-400">Probe 訊息 (ffprobe)</h3>
|
||||||
|
|
||||||
|
<!-- Basic Info Grid -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">Duration</span>
|
||||||
|
<p class="text-white">{{ formatDuration(probeInfo.format?.duration) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">Bitrate</span>
|
||||||
|
<p class="text-white">{{ probeInfo.format?.bit_rate ? (probeInfo.format.bit_rate / 1000).toFixed(0) + ' kbps' : '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">Format</span>
|
||||||
|
<p class="text-white">{{ probeInfo.format?.format_long_name || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-gray-500 uppercase">Size</span>
|
||||||
|
<p class="text-white">{{ formatFileSize(probeInfo.format?.size) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full Probe JSON (Lazy Loaded via Computed) -->
|
||||||
|
<details class="mt-4">
|
||||||
|
<summary class="text-sm text-gray-400 cursor-pointer hover:text-white flex items-center space-x-2">
|
||||||
|
<span>完整 Probe JSON</span>
|
||||||
|
<span class="text-xs bg-blue-900 text-blue-300 px-2 py-1 rounded">詳細</span>
|
||||||
|
</summary>
|
||||||
|
<div class="bg-gray-900 p-3 rounded text-xs font-mono text-gray-300 overflow-x-auto max-h-96 mt-2">
|
||||||
|
<pre>{{ probeJsonString }}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { getVideos, registerVideo, unregisterVideo, processVideo, getCurrentConfig, httpFetch } from '@/api/client'
|
||||||
|
import FaceTraceTimeline from '@/components/FaceTraceTimeline.vue'
|
||||||
|
import TraceThumbnailTimeline from '@/components/TraceThumbnailTimeline.vue'
|
||||||
|
import IdentitySwimlane from '@/components/IdentitySwimlane.vue'
|
||||||
|
import TraceDurationHistogram from '@/components/TraceDurationHistogram.vue'
|
||||||
|
import TraceSimilarityMatrix from '@/components/TraceSimilarityMatrix.vue'
|
||||||
|
import SpaceTimeCube from '@/components/SpaceTimeCube.vue'
|
||||||
|
import type { SwimlaneIdentity, SwimlaneSegment } from '@/components/IdentitySwimlane.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const uuid = route.params.file_uuid as string
|
||||||
|
|
||||||
|
const video = ref<any>(null)
|
||||||
|
const probeInfo = ref<any>(null)
|
||||||
|
const clusters = ref<any[]>([])
|
||||||
|
const allTraces = ref<any[]>([])
|
||||||
|
const swimlaneData = ref<SwimlaneIdentity[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const actionLoading = ref(false)
|
||||||
|
|
||||||
|
// Computed for safe JSON string rendering
|
||||||
|
const probeJsonString = computed(() => {
|
||||||
|
if (!probeInfo.value) return ''
|
||||||
|
try {
|
||||||
|
return JSON.stringify(probeInfo.value, null, 2)
|
||||||
|
} catch {
|
||||||
|
return 'Error parsing JSON'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const videoStream = computed(() => {
|
||||||
|
return (probeInfo.value?.streams || []).find((s: any) => s.codec_type === 'video')
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push('/files')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds: number | undefined): string {
|
||||||
|
if (!seconds) return '-'
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
if (m > 60) {
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
return `${h}h ${m % 60}m ${s}s`
|
||||||
|
}
|
||||||
|
return `${m}m ${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number | undefined): string {
|
||||||
|
if (!bytes) return '-'
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'bg-green-500'
|
||||||
|
case 'processing': return 'bg-yellow-500'
|
||||||
|
case 'pending': return 'bg-gray-500'
|
||||||
|
case 'failed': return 'bg-red-500'
|
||||||
|
default: return 'bg-gray-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(timestamp: string | undefined): string {
|
||||||
|
if (!timestamp) return '-'
|
||||||
|
try {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleString('zh-TW', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgressColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return 'bg-green-500'
|
||||||
|
case 'running': return 'bg-blue-500'
|
||||||
|
case 'pending': return 'bg-gray-500'
|
||||||
|
case 'failed': return 'bg-red-500'
|
||||||
|
default: return 'bg-gray-500'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
if (!video.value?.file_path) return
|
||||||
|
await registerVideo(video.value.file_path)
|
||||||
|
await loadVideoDetail()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Registration failed:', e)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnregister() {
|
||||||
|
if (!confirm('確定要取消註冊嗎?這將刪除相關數據。')) return
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
if (!video.value?.file_uuid) return
|
||||||
|
await unregisterVideo(video.value.file_uuid)
|
||||||
|
await loadVideoDetail()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Unregistration failed:', e)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleProcess() {
|
||||||
|
actionLoading.value = true
|
||||||
|
try {
|
||||||
|
if (!video.value?.file_uuid) return
|
||||||
|
await processVideo(video.value.file_uuid)
|
||||||
|
await loadVideoDetail()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Processing failed:', e)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTraceSelect(traceId: number) {
|
||||||
|
// Navigate to face candidates filtered by this trace
|
||||||
|
router.push(`/faces/candidates?trace_id=${traceId}&file_uuid=${uuid}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTraces() {
|
||||||
|
try {
|
||||||
|
const config = getCurrentConfig()
|
||||||
|
const data = await httpFetch<any>(
|
||||||
|
`${config.api_base_url}/api/v1/file/${uuid}/face_trace/sortby`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ limit: 100 }) }
|
||||||
|
)
|
||||||
|
allTraces.value = data.traces || []
|
||||||
|
|
||||||
|
// Build swimlane data: group traces by identity (if available) or trace_id
|
||||||
|
const groups: Record<string, SwimlaneSegment[]> = {}
|
||||||
|
const nameColors = ['#4488ff', '#ff4444', '#44cc44', '#ffaa00', '#cc44ff', '#00cccc',
|
||||||
|
'#ff6688', '#88ff44', '#4488aa', '#aa44ff', '#ff8844', '#44ffaa',
|
||||||
|
'#6688ff', '#ff4488', '#88aa44', '#44ccaa', '#cc88ff', '#ffaa88',
|
||||||
|
'#44aaff', '#aa88cc']
|
||||||
|
let colorIdx = 0
|
||||||
|
|
||||||
|
for (const t of data.traces || []) {
|
||||||
|
const key = `Trace #${t.trace_id}`
|
||||||
|
if (!groups[key]) groups[key] = []
|
||||||
|
groups[key].push({
|
||||||
|
trace_id: t.trace_id,
|
||||||
|
start: t.first_sec,
|
||||||
|
end: t.last_sec,
|
||||||
|
face_count: t.face_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
swimlaneData.value = Object.entries(groups).slice(0, 20).map(([name, segs]) => ({
|
||||||
|
name,
|
||||||
|
color: nameColors[colorIdx++ % nameColors.length],
|
||||||
|
segments: segs,
|
||||||
|
}))
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVideoDetail() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
// Use getVideos and extract from files array
|
||||||
|
const result = await getVideos(undefined, undefined, 1, 1, uuid)
|
||||||
|
|
||||||
|
if (result.data?.[0]) {
|
||||||
|
const v = result.data[0]
|
||||||
|
video.value = v
|
||||||
|
|
||||||
|
// Parse processing_status
|
||||||
|
if (v.processing_status) {
|
||||||
|
try {
|
||||||
|
video.value.processing_status = typeof v.processing_status === 'string'
|
||||||
|
? JSON.parse(v.processing_status)
|
||||||
|
: v.processing_status
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse processing_status:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse probe_json
|
||||||
|
if (v.probe_json) {
|
||||||
|
try {
|
||||||
|
probeInfo.value = JSON.parse(v.probe_json)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse probe_json:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await loadTraces()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load detail:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadVideoDetail()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
10
src/vite-env.d.ts
vendored
Normal file
10
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
readonly VITE_API_KEY?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
23
vite.config.ts
Normal file
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
envPrefix: ['VITE_', 'TAURI_'],
|
||||||
|
build: {
|
||||||
|
target: ['es2021', 'chrome100', 'safari13'],
|
||||||
|
minify: !process.env.TAURI_DEBUG ? 'esbuild' : false,
|
||||||
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user