Compare commits
4 Commits
7855983dc1
...
bf7fbce458
| Author | SHA1 | Date | |
|---|---|---|---|
| bf7fbce458 | |||
| 98a3d79701 | |||
| 691b38fe96 | |||
| f915aaf794 |
44
AGENTS.md
44
AGENTS.md
@@ -16,18 +16,19 @@ cargo tauri build # Full native app build (outputs to src-tauri/target/releas
|
||||
```
|
||||
|
||||
## Architecture
|
||||
- **All Core API calls go through Rust Tauri commands** — frontend NEVER contacts `localhost:3002` directly.
|
||||
- **Rust proxies to external API**: `CORE_API = http://localhost:3002` (hardcoded in `src-tauri/src/main.rs`). This service must be running for the app to function.
|
||||
- **API key**: Hardcoded in `src-tauri/src/main.rs` only (`API_KEY` constant).
|
||||
- **Rust entrypoint**: `src-tauri/src/main.rs` — 15 Tauri commands:
|
||||
- `search_llm_smart`, `get_files`, `get_people`, `get_faces`, `get_traces` (data APIs)
|
||||
- `get_thumbnail`, `get_identity_profile` (return base64 image strings)
|
||||
- `get_video_stream` (returns `file://` URL to temp MP4)
|
||||
- `update_identity_name`, `delete_identity` (identity management)
|
||||
- `search_identities`, `get_face_candidates` (search)
|
||||
- `merge_identities`, `bind_face`, `unbind_face` (identity ops)
|
||||
- **Dual-mode API**: Frontend uses `apiCall()` from `src/api/index.ts` which detects `window.__TAURI__` and dispatches to either Tauri IPC (`invoke`) or HTTP to proxy at `http://0.0.0.0:8888`.
|
||||
- **Rust Tauri commands**: `src-tauri/src/main.rs` — 22 Tauri commands:
|
||||
- Core API: `search_llm_smart`, `search_agents`, `get_files`, `get_people`, `get_faces`, `get_traces`, `get_file_info`
|
||||
- Media: `get_thumbnail`, `get_face_thumbnail`, `get_identity_profile`
|
||||
- Identity: `update_identity_name`, `update_identity_status`, `update_identity_starred`, `delete_identity`, `search_identities`, `get_face_candidates`, `merge_identities`, `bind_face`, `unbind_face`
|
||||
- File ops: `register_file`, `process_file`, `unregister_file`
|
||||
- Local SQLite: `get_search_history`, `save_search_history`, `rename_search_history`, `pin_search_history`, `delete_search_history`, `get_bookmarks`, `save_bookmark`, `delete_bookmark`
|
||||
- **Rust HTTP proxy** (`src-tauri/src/proxy.rs`): Axum on port 8888. Forwards `/api/v1/*` to Core API (localhost:3002) with API key injection. Handles `/api/v1/search-history` and `/api/v1/bookmarks` locally via `db::` functions. Handles `/api/v1/identity/{uuid}/profile` (reads local filesystem) and `/api/v1/face-thumbnail` (downloads frame + crops bbox) locally — these mirror the Tauri IPC commands that can't be proxied to Core API.
|
||||
- **Per-user SQLite**: `src-tauri/src/db.rs` — Project-root `data/users/demo.sqlite` (outside `src-tauri/` to avoid dev watcher rebuilds). WAL mode enabled. Tables: `search_history`, `bookmarks`, `app_users`. `init_db()` called on app setup.
|
||||
- **Frontend routes**: `/search`, `/library`, `/people` (redirect `/` → `/search`)
|
||||
- **Frontend entry**: `src/main.ts` → `src/App.vue` → `src/router/index.ts`
|
||||
- **Frontend entry**: `src/main.ts` → `App.vue` → `router/index.ts`
|
||||
- **Global directive**: `v-observe` registered in `main.ts` from `src/directives/vObserve.ts`
|
||||
- **Composables**: `src/composables/useSearchHistory.ts` — `useSearchHistory()` and `useBookmarks()` for SQLite-backed search history and bookmarks CRUD via `apiCall`.
|
||||
|
||||
## Key Details
|
||||
- **`withGlobalTauri: true`** in `tauri.conf.json` — `window.__TAURI__` is available in the webview.
|
||||
@@ -35,4 +36,23 @@ cargo tauri build # Full native app build (outputs to src-tauri/target/releas
|
||||
- **TypeScript is non-strict** (`strict: false` in `tsconfig.json`).
|
||||
- **CI/CD**: Shell scripts only (`ci-cd.sh`, `local-ci-cd.sh`, `setup-gitea-ci.sh`). No GitHub Actions.
|
||||
- **Full native build order**: `npm install` → `npm run build` → `cargo tauri build`
|
||||
- **Rust deps**: sqlx (postgres), reqwest, tokio, tauri-plugin-shell, base64
|
||||
- **Rust deps**: sqlx 0.8, rusqlite 0.32 (bundled), reqwest, tokio, tauri-plugin-shell, base64, chrono, uuid
|
||||
- **Core API**: `http://localhost:3002`, API key hardcoded in Rust only.
|
||||
- **Proxy**: `http://0.0.0.0:8888` — forwards `/api/v1/*` to Core API with auto-injected key; handles `/api/v1/search-history` and `/api/v1/bookmarks` locally.
|
||||
|
||||
## Patterns
|
||||
- **Search thumbnails**: Use `start_frame` for frame-accurate thumbnails. Cache key format: `${file_uuid}:${start_frame}`. Not a single `file_uuid` key because same file can appear in multiple search results at different time segments.
|
||||
- **Store thumbnail queue**: `src/store.ts` provides `loadThumbnail()`, `loadFaceThumb()`, `loadProfile()` with max 4 concurrent requests. Always use store functions for shared data across pages.
|
||||
- **Page perPage limit**: Core API hangs on `per_page >= 100`. Always use `perPage ≤ 20`. The Rust `get_people` command uses a special 2-page × 100 flow (separate from frontend).
|
||||
- **PersonDetailView**: Uses `ensurePeople()` from store instead of direct `get_people` call. Face thumbnails go through store's `loadFaceThumb()`. Faces/candidates use `perPage: 20`.
|
||||
- **v-observe directive**: Global directive (`v-observe`) registered in `main.ts`. Defined in `src/directives/vObserve.ts`. Uses `IntersectionObserver` with 200px rootMargin. Includes `requestAnimationFrame` fallback for elements already visible on mount.
|
||||
- **SearchView empty state**: When `messages.length === 0`, shows `.ms-hero` with "Momentry Studio" + "Turn Every Moment Into Intelligence" slogan. Chat area gets `.chat--empty` class (flex-column, justify-end) to position hero near the search bar. Once messages exist, hero disappears and `.ibar` stays fixed at bottom.
|
||||
- **No `console.log` in *.vue files**: All debug console.log statements have been removed. Use `console.error` only for actual errors.
|
||||
- **Search history & bookmarks**: Stored in per-user SQLite (`data/users/demo.sqlite`), not localStorage. History items have `id`, `query`, `title`, `chat_state` (JSON-serialized Vue messages), `mode`, `pinned`, timestamps. Max 30 items; pinned sorted first. Composable `useSearchHistory()` provides `loadHistory`, `saveToHistory`, `renameHistory`, `pinHistory`, `deleteHistory`, `restoreFromHistory`. Composable `useBookmarks()` provides `loadBookmarks`, `saveBookmark`, `deleteBookmark`.
|
||||
- **SearchView history panel**: Dropdown with ⋯ menu per item (Pin/Unpin, Rename, Delete). +New button clears chat. Click item → restores `chat_state` JSON or runs new search. Renames via modal dialog.
|
||||
- **SearchView bookmark panel**: Dropdown with items from SQLite. Click → runs search. Hover shows x button to delete.
|
||||
- **API call snake_case**: Frontend `apiCall()` sends `camelCase` args to Tauri IPC (auto-converted by Tauri) but `snake_case` in HTTP body. `buildHttpRequest()` converts arg names for HTTP mode (e.g., `chatState` → `chat_state`).
|
||||
- **HistoryItem ID format**: `h_{timestamp}_{random}` e.g. `h_1718000000_abc123`.
|
||||
- **SQLite WAL mode + path outside src-tauri**: Database at project-root `data/users/demo.sqlite` (not inside `src-tauri/`). WAL journal mode prevents `-journal` file creation that triggers `cargo tauri dev` watcher rebuilds. Default path `../data/users/demo.sqlite` resolved from `src-tauri/` cwd.
|
||||
- **Video streaming**: `get_video_stream` is NOT a Tauri command. Both Tauri and browser modes use the proxy URL directly (`http://localhost:8888/api/v1/file/{uuid}/video?...`). The `<video>` element streams from the proxy, never via IPC. Proxy streams `video/*` and `octet-stream` responses (doesn't buffer entire body).
|
||||
- **Face thumbnail & identity profile in browser mode**: `/api/v1/face-thumbnail` and `/api/v1/identity/{uuid}/profile` are handled locally by the proxy (same logic as Tauri IPC commands). Core API doesn't support bbox cropping for face thumbnails or local filesystem for profiles.
|
||||
@@ -301,6 +301,73 @@ id, face_id, file_uuid, frame_number, confidence, bbox, attributes
|
||||
|
||||
---
|
||||
|
||||
## 10. Unregister API
|
||||
|
||||
### POST /api/v1/unregister
|
||||
|
||||
**Auth**: Required
|
||||
|
||||
Delete a registered file from the system. Supports single file by UUID, or batch by directory + regex pattern.
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `file_uuid` | string | * | — | Single file UUID to delete |
|
||||
| `file_path` | string | * | — | Directory path (for batch delete) |
|
||||
| `pattern` | string | * | — | Regex pattern (requires `file_path`) |
|
||||
| `delete_output_files` | boolean | No | `true` | Also delete processor output JSON files |
|
||||
|
||||
**Response (200)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"file_uuid": "8703db16...",
|
||||
"message": "File ... unregistered successfully.",
|
||||
"deleted_face_detections": 0,
|
||||
"deleted_processor_results": 0,
|
||||
"deleted_chunks": 0,
|
||||
"deleted_qdrant_vectors": 1,
|
||||
"deleted_redis_keys": 1,
|
||||
"deleted_output_files": 10
|
||||
}
|
||||
```
|
||||
|
||||
**Error**: `400` (missing params), `404` (UUID not found), `401` (bad API key)
|
||||
|
||||
---
|
||||
|
||||
## 11. Process API
|
||||
|
||||
### POST /api/v1/file/{uuid}/process
|
||||
|
||||
Trigger processing pipeline for a registered file. Runs asynchronously.
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-------|------|----------|---------|-------------|
|
||||
| `processors` | string[] | No | all | Processors: `cut`, `asr`, `asrx`, `yolo`, `ocr`, `face`, `pose`, `visual_chunk`, `story`, `5w1h` |
|
||||
| `rules` | string[] | No | all | Rule names (currently unused) |
|
||||
|
||||
**Response (200)**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"job_id": 42,
|
||||
"file_uuid": "3a6c1865...",
|
||||
"status": "processing",
|
||||
"pids": [12345, 12346],
|
||||
"message": "Processing triggered for video.mp4"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/progress/{uuid}
|
||||
|
||||
Real-time processing progress.
|
||||
|
||||
### GET /api/v1/jobs
|
||||
|
||||
List all processing jobs.
|
||||
|
||||
---
|
||||
|
||||
## API Response Key 摘要
|
||||
|
||||
| 端點 | Response Key | 備註 |
|
||||
|
||||
3888
src-tauri/Cargo.lock
generated
3888
src-tauri/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ name = "momentry-proxy"
|
||||
path = "src/bin/proxy.rs"
|
||||
|
||||
[dependencies]
|
||||
dirs = "5"
|
||||
tauri = { version = "2", features = ["protocol-asset"] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -34,6 +35,8 @@ tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# MarkBase Core for Admin/Client GUI
|
||||
markbase-core = { path = "../../markbase/markbase-core" }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use dirs;
|
||||
use markbase_core::vfs::VfsBackend;
|
||||
mod proxy;
|
||||
mod db;
|
||||
|
||||
@@ -44,6 +46,9 @@ struct FileInfo {
|
||||
#[serde(rename = "isRegistered")]
|
||||
is_registered: bool,
|
||||
status: String,
|
||||
registration_time: Option<String>,
|
||||
#[serde(rename = "ingested")]
|
||||
ingested: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -53,6 +58,7 @@ struct PersonInfo {
|
||||
starred: bool,
|
||||
status: String,
|
||||
metadata: serde_json::Value,
|
||||
file_uuids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -76,6 +82,7 @@ struct TraceInfo {
|
||||
first_sec: f64,
|
||||
last_sec: f64,
|
||||
avg_confidence: f64,
|
||||
face_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -207,6 +214,7 @@ async fn get_files(args: GetFilesArgs) -> Result<Vec<FileInfo>, String> {
|
||||
let is_reg = f["is_registered"].as_bool().unwrap_or(false);
|
||||
let file_uuid = f["file_uuid"].as_str()
|
||||
.unwrap_or_else(|| f["file_path"].as_str().unwrap_or("")).to_string();
|
||||
let status = f["status"].as_str().unwrap_or("").to_string();
|
||||
Some(FileInfo {
|
||||
file_uuid,
|
||||
file_name: f["file_name"].as_str()?.to_string(),
|
||||
@@ -214,7 +222,9 @@ async fn get_files(args: GetFilesArgs) -> Result<Vec<FileInfo>, String> {
|
||||
file_size: f["file_size"].as_i64().unwrap_or(0),
|
||||
modified_time: f["modified_time"].as_str().unwrap_or("").to_string(),
|
||||
is_registered: is_reg,
|
||||
status: f["status"].as_str().unwrap_or("").to_string(),
|
||||
registration_time: f["registration_time"].as_str().map(|s| s.to_string()),
|
||||
ingested: status == "completed",
|
||||
status,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -276,6 +286,43 @@ async fn process_file(file_uuid: String, processors: Vec<String>) -> Result<Proc
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct IngestResult {
|
||||
success: bool,
|
||||
file_uuid: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn ingest_file(file_uuid: String) -> Result<IngestResult, String> {
|
||||
let client = get_client();
|
||||
let url = format!("{}/api/v1/file/{}/checkin?api_key={}", CORE_API, file_uuid, API_KEY);
|
||||
let resp = client.post(&url).send().await
|
||||
.map_err(|e| format!("Ingest (checkin) request failed: {}", e))?;
|
||||
let json: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("Ingest (checkin) parse failed: {}", e))?;
|
||||
Ok(IngestResult {
|
||||
success: json["success"].as_bool().unwrap_or(false),
|
||||
file_uuid,
|
||||
message: json["message"].as_str().unwrap_or("Checkin complete").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn checkout_file(file_uuid: String) -> Result<IngestResult, String> {
|
||||
let client = get_client();
|
||||
let url = format!("{}/api/v1/file/{}/checkout?api_key={}", CORE_API, file_uuid, API_KEY);
|
||||
let resp = client.post(&url).send().await
|
||||
.map_err(|e| format!("Checkout request failed: {}", e))?;
|
||||
let json: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("Checkout parse failed: {}", e))?;
|
||||
Ok(IngestResult {
|
||||
success: json["success"].as_bool().unwrap_or(false),
|
||||
file_uuid,
|
||||
message: json["message"].as_str().unwrap_or("Checkout complete").to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct UnregisterResult {
|
||||
success: bool,
|
||||
@@ -347,9 +394,10 @@ async fn get_people(_page: usize, _per_page: usize) -> Result<Vec<PersonInfo>, S
|
||||
people.push(PersonInfo {
|
||||
identity_uuid: uuid,
|
||||
name,
|
||||
starred: i["metadata"]["starred"].as_bool().unwrap_or(false),
|
||||
status: i["metadata"]["status"].as_str().unwrap_or("pending").to_string(),
|
||||
starred: i["starred"].as_bool().unwrap_or_else(|| i["metadata"]["starred"].as_bool().unwrap_or(false)),
|
||||
status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(),
|
||||
metadata: i["metadata"].clone(),
|
||||
file_uuids: i["file_uuids"].as_array().map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -404,77 +452,61 @@ async fn get_faces(uuid: String, per_page: usize) -> Result<Vec<FaceInfo>, Strin
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TracesResponse {
|
||||
total: i64,
|
||||
traces: Vec<TraceInfo>,
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_traces(uuid: String, per_page: usize) -> Result<Vec<TraceInfo>, String> {
|
||||
let page_size = per_page.min(20);
|
||||
let mut all_traces: Vec<TraceInfo> = vec![];
|
||||
let mut page = 1u32;
|
||||
let max_retries = 2;
|
||||
async fn get_traces(uuid: String, per_page: usize, page: Option<u32>) -> Result<TracesResponse, String> {
|
||||
let page_size = per_page.min(100);
|
||||
let page_num = page.unwrap_or(1);
|
||||
let t = std::time::Instant::now();
|
||||
eprintln!("[get_traces] --> {}", uuid);
|
||||
|
||||
for _page in 0..10 {
|
||||
let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}&page={}", CORE_API, uuid, API_KEY, page_size, page);
|
||||
|
||||
let mut response: Option<reqwest::Response> = None;
|
||||
for attempt in 0..=max_retries {
|
||||
if attempt > 0 {
|
||||
eprintln!("[get_traces] retry {} for page {}", attempt, page);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
match get_client().get(&url).send().await {
|
||||
Ok(r) => { response = Some(r); break; }
|
||||
Err(e) => {
|
||||
eprintln!("[get_traces] attempt {} failed on page {}: {}", attempt + 1, page, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let resp = match response {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
eprintln!("[get_traces] all retries exhausted on page {} — returning {} traces so far", page, all_traces.len());
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let json: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
eprintln!("[get_traces] parse error on page {}: {} — returning {} traces so far", page, e, all_traces.len());
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
let traces: Vec<TraceInfo> = json["traces"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
Some(TraceInfo {
|
||||
trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32,
|
||||
file_uuid: t["file_uuid"].as_str()?.to_string(),
|
||||
frame_count: t["frame_count"].as_i64().unwrap_or(0),
|
||||
first_frame: t["first_frame"].as_i64().unwrap_or(0),
|
||||
last_frame: t["last_frame"].as_i64().unwrap_or(0),
|
||||
first_sec: t["first_sec"].as_f64().unwrap_or(0.0),
|
||||
last_sec: t["last_sec"].as_f64().unwrap_or(0.0),
|
||||
avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0),
|
||||
})
|
||||
eprintln!("[get_traces] --> {} page={} page_size={}", uuid, page_num, page_size);
|
||||
|
||||
let url = format!("{}/api/v1/identity/{}/traces?api_key={}&page_size={}&page={}", CORE_API, uuid, API_KEY, page_size, page_num);
|
||||
|
||||
let resp = get_client().get(&url).send().await
|
||||
.map_err(|e| format!("Traces request failed: {}", e))?;
|
||||
|
||||
let json: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("Traces parse failed: {}", e))?;
|
||||
|
||||
let total = json["total"].as_i64().unwrap_or(0);
|
||||
|
||||
let traces: Vec<TraceInfo> = json["traces"]
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
Some(TraceInfo {
|
||||
trace_id: t["trace_id"].as_i64().unwrap_or(0) as i32,
|
||||
file_uuid: t["file_uuid"].as_str()?.to_string(),
|
||||
frame_count: t["frame_count"].as_i64().unwrap_or(0),
|
||||
first_frame: t["first_frame"].as_i64().unwrap_or(0),
|
||||
last_frame: t["last_frame"].as_i64().unwrap_or(0),
|
||||
first_sec: t["first_sec"].as_f64().unwrap_or(0.0),
|
||||
last_sec: t["last_sec"].as_f64().unwrap_or(0.0),
|
||||
avg_confidence: t["avg_confidence"].as_f64().unwrap_or(0.0),
|
||||
face_id: t["face_id"].as_str().map(String::from),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let count = traces.len();
|
||||
all_traces.extend(traces);
|
||||
|
||||
if count < page_size {
|
||||
break;
|
||||
}
|
||||
page += 1;
|
||||
}
|
||||
|
||||
eprintln!("[get_traces] <-- {} {} traces {:?}", uuid, all_traces.len(), t.elapsed());
|
||||
Ok(all_traces)
|
||||
})
|
||||
.collect();
|
||||
|
||||
eprintln!("[get_traces] <-- {} total={} {} traces {:?}", uuid, total, traces.len(), t.elapsed());
|
||||
Ok(TracesResponse { total, traces })
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_unassigned_traces(page: usize, per_page: usize) -> Result<serde_json::Value, String> {
|
||||
let page_size = per_page.min(100);
|
||||
let url = format!("{}/api/v1/traces/unassigned?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, page_size);
|
||||
let resp = get_client().get(&url).send().await
|
||||
.map_err(|e| format!("Unassigned traces request failed: {}", e))?;
|
||||
let json: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("Unassigned traces parse failed: {}", e))?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
@@ -720,6 +752,67 @@ async fn upload_profile_image(uuid: String, file_path: String) -> Result<(), Str
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn match_from_photo(identity_uuid: String, file_uuid: String, image_path: Option<String>) -> Result<serde_json::Value, String> {
|
||||
let client = get_client();
|
||||
let url = format!("{}/api/v1/agents/identity/match-from-photo?api_key={}", CORE_API, API_KEY);
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.text("identity_uuid", identity_uuid.replace('-', ""))
|
||||
.text("file_uuid", file_uuid);
|
||||
if let Some(path) = image_path {
|
||||
let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
|
||||
let part = reqwest::multipart::Part::bytes(file_bytes)
|
||||
.file_name("profile.jpg")
|
||||
.mime_str("image/jpeg")
|
||||
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&path).unwrap_or_default()).file_name("profile.jpg"));
|
||||
form = form.part("image", part);
|
||||
}
|
||||
let resp = client.post(&url)
|
||||
.multipart(form)
|
||||
.send().await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Match failed: {} {}", status, body));
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?;
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn set_profile_from_face(uuid: String, file_uuid: String, face_id: Option<String>, id: Option<i64>, trace_id: Option<i32>, frame_number: Option<i64>) -> Result<(), String> {
|
||||
let client = get_client();
|
||||
let url = format!("{}/api/v1/identity/{}/profile-image/from-face?api_key={}", CORE_API, uuid, API_KEY);
|
||||
let mut body = serde_json::json!({"file_uuid": file_uuid});
|
||||
if let Some(fid) = face_id {
|
||||
body["face_id"] = serde_json::json!(fid);
|
||||
} else if let Some(rid) = id {
|
||||
body["id"] = serde_json::json!(rid);
|
||||
} else if let Some(tid) = trace_id {
|
||||
body["trace_id"] = serde_json::json!(tid);
|
||||
if let Some(frame) = frame_number {
|
||||
body["frame_number"] = serde_json::json!(frame);
|
||||
}
|
||||
} else {
|
||||
return Err("Either face_id, id, or trace_id is required".to_string());
|
||||
}
|
||||
let resp = client.post(&url)
|
||||
.json(&body)
|
||||
.send().await
|
||||
.map_err(|e| format!("Request failed: {}", e))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body_text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("Set profile failed: {} {}", status, body_text));
|
||||
}
|
||||
let mut cache = PROFILE_CACHE.lock().unwrap();
|
||||
if let Some(c) = cache.as_mut() {
|
||||
c.pop(&uuid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn delete_identity(uuid: String) -> Result<(), String> {
|
||||
let client = get_client();
|
||||
@@ -733,6 +826,57 @@ async fn delete_identity(uuid: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CreateIdentityFromFaceResult {
|
||||
success: bool,
|
||||
identity_uuid: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn create_identity_from_face(name: String, _face_id: Option<String>, _face_row_id: Option<i64>, file_uuid: String) -> Result<CreateIdentityFromFaceResult, String> {
|
||||
let client = get_client();
|
||||
|
||||
// Use POST /api/v1/file/:file_uuid/pending-person to create pending identity
|
||||
let url = format!("{}/api/v1/file/{}/pending-person?api_key={}", CORE_API, file_uuid, API_KEY);
|
||||
let body = serde_json::json!({
|
||||
"name": name
|
||||
});
|
||||
|
||||
let resp = client.post(&url)
|
||||
.json(&body)
|
||||
.send().await
|
||||
.map_err(|e| format!("Create pending person request failed: {}", e))?;
|
||||
|
||||
let status = resp.status();
|
||||
let resp_json: serde_json::Value = resp.json().await
|
||||
.map_err(|e| format!("Parse response failed: {}", e))?;
|
||||
|
||||
eprintln!("[create_identity_from_face] status={} resp={}", status, resp_json);
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("Create pending person failed: {} - {}", status, resp_json["error"].as_str().unwrap_or("unknown error")));
|
||||
}
|
||||
|
||||
// identity_uuid is inside data object
|
||||
let data_obj = resp_json.get("data").unwrap_or(&resp_json);
|
||||
let new_uuid = data_obj.get("identity_uuid")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| resp_json.get("identity_uuid").and_then(|v| v.as_str()))
|
||||
.or_else(|| resp_json.get("uuid").and_then(|v| v.as_str()))
|
||||
.unwrap_or("");
|
||||
|
||||
if new_uuid.is_empty() {
|
||||
return Err("Create pending person failed: no uuid returned".to_string());
|
||||
}
|
||||
|
||||
Ok(CreateIdentityFromFaceResult {
|
||||
success: true,
|
||||
identity_uuid: new_uuid.to_string(),
|
||||
name,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn search_identities(query: String, limit: usize) -> Result<Vec<SearchIdentityResult>, String> {
|
||||
let url = format!("{}/api/v1/identities/search?api_key={}&q={}", CORE_API, API_KEY, query);
|
||||
@@ -806,7 +950,7 @@ async fn merge_identities(uuid: String, into_uuid: String) -> Result<(), String>
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn bind_face(uuid: String, face_id: Option<String>, face_row_id: Option<i64>, file_uuid: String) -> Result<(), String> {
|
||||
async fn bind_face(uuid: String, face_id: Option<String>, face_row_id: Option<i64>, file_uuid: String, expand_to_trace: Option<bool>) -> Result<(), String> {
|
||||
let client = get_client();
|
||||
let url = format!("{}/api/v1/identity/{}/bind?api_key={}", CORE_API, uuid, API_KEY);
|
||||
let mut body = serde_json::json!({"file_uuid": file_uuid});
|
||||
@@ -817,6 +961,9 @@ async fn bind_face(uuid: String, face_id: Option<String>, face_row_id: Option<i6
|
||||
} else {
|
||||
return Err("Either face_id or face_row_id is required".to_string());
|
||||
}
|
||||
if let Some(expand) = expand_to_trace {
|
||||
body["expand_to_trace"] = serde_json::json!(expand);
|
||||
}
|
||||
let resp = client.post(&url)
|
||||
.json(&body)
|
||||
.send().await
|
||||
@@ -976,6 +1123,107 @@ async fn merge_history(source_uuid: Option<String>, target_uuid: Option<String>,
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
|
||||
// === Admin Commands ===
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn list_admin_users() -> Result<serde_json::Value, String> {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
|
||||
let db_path = home.join("momentry/data/auth.sqlite");
|
||||
|
||||
let conn = rusqlite::Connection::open(&db_path)
|
||||
.map_err(|e| format!("Failed to open database: {}", e))?;
|
||||
|
||||
let mut stmt = conn.prepare("SELECT username, home_dir, status FROM sftpgo_users")
|
||||
.map_err(|e| format!("Failed to prepare query: {}", e))?;
|
||||
|
||||
let users = stmt.query_map([], |row| {
|
||||
Ok(serde_json::json!({
|
||||
"username": row.get::<_, String>(0)?,
|
||||
"homeDir": row.get::<_, String>(1)?,
|
||||
"status": row.get::<_, i32>(2)?
|
||||
}))
|
||||
}).map_err(|e| format!("Failed to query: {}", e))?;
|
||||
|
||||
let result: Vec<serde_json::Value> = users.filter_map(|u| u.ok()).collect();
|
||||
Ok(serde_json::Value::Array(result))
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn get_system_stats() -> Result<serde_json::Value, String> {
|
||||
let mut stats = serde_json::Map::new();
|
||||
stats.insert("cpu".to_string(), serde_json::Value::Number(25.into()));
|
||||
stats.insert("memory".to_string(), serde_json::Value::Number(60.into()));
|
||||
stats.insert("disk".to_string(), serde_json::Value::Number(45.into()));
|
||||
Ok(serde_json::Value::Object(stats))
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn list_shares() -> Result<serde_json::Value, String> {
|
||||
let shares = vec![
|
||||
serde_json::json!({"name": "SMB", "port": 4445, "status": "running"}),
|
||||
serde_json::json!({"name": "SFTP", "port": 2024, "status": "running"}),
|
||||
serde_json::json!({"name": "WebDAV", "port": 11438, "status": "running"}),
|
||||
];
|
||||
Ok(serde_json::Value::Array(shares))
|
||||
}
|
||||
|
||||
// === Client Commands ===
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn list_client_files(path: String) -> Result<serde_json::Value, String> {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
|
||||
let root = home.join("momentry/var/sftpgo/data/demo");
|
||||
let full_path = if path.is_empty() || path == "/" { root.clone() } else { root.join(&path) };
|
||||
|
||||
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
|
||||
let entries = vfs.read_dir(&full_path)
|
||||
.map_err(|e| format!("Failed to read dir: {}", e))?;
|
||||
|
||||
let files: Vec<serde_json::Value> = entries
|
||||
.into_iter()
|
||||
.filter_map(|e| {
|
||||
Some(serde_json::json!({
|
||||
"name": e.name,
|
||||
"path": e.long_name,
|
||||
"isDir": e.stat.is_dir,
|
||||
"size": e.stat.size
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(serde_json::Value::Array(files))
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn mkdir_client(path: String) -> Result<(), String> {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
|
||||
let root = home.join("momentry/var/sftpgo/data/demo");
|
||||
let full_path = root.join(&path);
|
||||
|
||||
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
|
||||
vfs.create_dir(&full_path, 0o755)
|
||||
.map_err(|e| format!("Failed to mkdir: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command(rename_all = "camelCase")]
|
||||
async fn rm_client(path: String) -> Result<(), String> {
|
||||
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
|
||||
let root = home.join("momentry/var/sftpgo/data/demo");
|
||||
let full_path = root.join(&path);
|
||||
|
||||
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
|
||||
if full_path.is_dir() {
|
||||
vfs.remove_dir(&full_path)
|
||||
.map_err(|e| format!("Failed to remove dir: {}", e))?;
|
||||
} else {
|
||||
vfs.remove_file(&full_path)
|
||||
.map_err(|e| format!("Failed to remove file: {}", e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
std::thread::spawn(|| {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
@@ -998,10 +1246,13 @@ fn main() {
|
||||
get_files,
|
||||
register_file,
|
||||
process_file,
|
||||
ingest_file,
|
||||
checkout_file,
|
||||
unregister_file,
|
||||
get_people,
|
||||
get_faces,
|
||||
get_traces,
|
||||
get_unassigned_traces,
|
||||
get_file_info,
|
||||
get_thumbnail,
|
||||
get_face_thumbnail,
|
||||
@@ -1011,7 +1262,10 @@ fn main() {
|
||||
update_identity_starred,
|
||||
update_identity,
|
||||
upload_profile_image,
|
||||
set_profile_from_face,
|
||||
match_from_photo,
|
||||
delete_identity,
|
||||
create_identity_from_face,
|
||||
search_identities,
|
||||
get_face_candidates,
|
||||
merge_identities,
|
||||
@@ -1033,7 +1287,13 @@ fn main() {
|
||||
db::delete_search_history,
|
||||
db::get_bookmarks,
|
||||
db::save_bookmark,
|
||||
db::delete_bookmark
|
||||
db::delete_bookmark,
|
||||
list_admin_users,
|
||||
get_system_stats,
|
||||
list_shares,
|
||||
list_client_files,
|
||||
mkdir_client,
|
||||
rm_client
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
109
src/App.vue
109
src/App.vue
@@ -1,40 +1,9 @@
|
||||
<template>
|
||||
<div id="app" class="ms-app" :data-current-login="'guest'">
|
||||
<aside class="ms-side">
|
||||
<div class="gs-logo">Workspace</div>
|
||||
<nav class="gs-nav">
|
||||
<router-link to="/search" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
|
||||
<span>Search</span>
|
||||
</router-link>
|
||||
<router-link to="/library" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
|
||||
<span>Library</span>
|
||||
</router-link>
|
||||
<router-link to="/people" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/People.png" alt="">
|
||||
<span>People</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="gs-divider"></div>
|
||||
<div class="gs-footer">
|
||||
<div class="gs-theme-switcher">
|
||||
<button class="gs-theme-btn active">☀️</button>
|
||||
<button class="gs-theme-btn">🌙</button>
|
||||
<button class="gs-theme-btn">🌗</button>
|
||||
</div>
|
||||
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="ms-main">
|
||||
<div class="ms-content"><router-view /></div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { isTauri } from '@/api/config'
|
||||
|
||||
const router = useRouter()
|
||||
const appZoom = ref(100)
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
@@ -55,9 +24,79 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', handleKeydown))
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
if (isTauri) return
|
||||
|
||||
const token = localStorage.getItem('token')
|
||||
const expiresAt = localStorage.getItem('expires_at')
|
||||
const currentPath = router.currentRoute.value.path
|
||||
|
||||
if (currentPath !== '/login') {
|
||||
if (!token || !expiresAt) {
|
||||
router.push('/login')
|
||||
} else {
|
||||
const now = new Date()
|
||||
const expires = new Date(expiresAt)
|
||||
if (now >= expires) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('expires_at')
|
||||
router.push('/login')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
||||
</script>
|
||||
<template>
|
||||
<div id="app" class="ms-app" :data-current-login="'guest'">
|
||||
<aside class="ms-side">
|
||||
<div class="gs-logo">Workspace</div>
|
||||
<nav class="gs-nav">
|
||||
<router-link to="/search" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
|
||||
<span>Search</span>
|
||||
</router-link>
|
||||
<router-link to="/library" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
|
||||
<span>Library</span>
|
||||
</router-link>
|
||||
<router-link to="/people" class="gs-nav-item" active-class="active">
|
||||
<img class="gs-nav-icon" src="/icons/People.png" alt="">
|
||||
<span>People</span>
|
||||
</router-link>
|
||||
<router-link to="/admin" class="gs-nav-item" active-class="active">
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
<span>Admin</span>
|
||||
</router-link>
|
||||
<router-link to="/client" class="gs-nav-item" active-class="active">
|
||||
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span>Client</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="gs-divider"></div>
|
||||
<div class="gs-footer">
|
||||
<div class="gs-theme-switcher">
|
||||
<button class="gs-theme-btn active">☀️</button>
|
||||
<button class="gs-theme-btn">🌙</button>
|
||||
<button class="gs-theme-btn">🌗</button>
|
||||
</div>
|
||||
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="ms-main">
|
||||
<div class="ms-content"><router-view /></div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import './assets/wordpress-exact.css';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const isTauri = typeof window !== 'undefined' && !!(window as any).__TAURI__
|
||||
|
||||
export function getApiBase(): string {
|
||||
if (isTauri) return ''
|
||||
if (isTauri) return 'http://localhost:8888'
|
||||
return localStorage.getItem('proxy_url') || window.location.origin
|
||||
}
|
||||
160
src/api/index.ts
160
src/api/index.ts
@@ -32,7 +32,16 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
|
||||
return httpCall('update_identity', { uuid: args.uuid, metadataJson: JSON.stringify(metadata) })
|
||||
}
|
||||
if (isTauri) {
|
||||
return invoke(cmd, args)
|
||||
try {
|
||||
const data = await invoke(cmd, args)
|
||||
return transformResponse(cmd, data)
|
||||
} catch (e: any) {
|
||||
console.error('[apiCall] invoke error:', typeof e, e)
|
||||
const errMsg = typeof e === 'string' ? e : (e?.message || String(e))
|
||||
console.error('[apiCall] falling back to HTTP for', cmd, '- error:', errMsg)
|
||||
const data = await httpCall(cmd, args)
|
||||
return transformResponse(cmd, data)
|
||||
}
|
||||
}
|
||||
const data = await httpCall(cmd, args)
|
||||
return transformResponse(cmd, data)
|
||||
@@ -110,11 +119,21 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
|
||||
return { url: `/api/v1/identity/${a.uuid}/faces?page_size=${a.perPage || 100}`, method: 'GET' }
|
||||
}
|
||||
case 'get_traces': {
|
||||
return { url: `/api/v1/identity/${a.uuid}/traces?page_size=${a.perPage || 50}`, method: 'GET' }
|
||||
let url = `/api/v1/identity/${a.uuid}/traces?page_size=${a.perPage || 50}`
|
||||
if (a.page) url += `&page=${a.page}`
|
||||
return { url, method: 'GET' }
|
||||
}
|
||||
case 'get_face_candidates': {
|
||||
return { url: `/api/v1/faces/candidates?page=${a.page || 1}&page_size=${a.perPage || 100}`, method: 'GET' }
|
||||
}
|
||||
case 'get_unassigned_traces': {
|
||||
let url = `/api/v1/traces/unassigned?page=${a.page || 1}&page_size=${a.perPage || 20}`
|
||||
if (a.fileUuid) url += `&file_uuid=${a.fileUuid}`
|
||||
return { url, method: 'GET' }
|
||||
}
|
||||
case 'get_pending_persons': {
|
||||
return { url: `/api/v1/file/${a.fileUuid}/pending-persons`, method: 'GET' }
|
||||
}
|
||||
case 'get_identity': {
|
||||
return { url: `/api/v1/identity/${a.uuid}`, method: 'GET' }
|
||||
}
|
||||
@@ -179,6 +198,11 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
|
||||
case 'delete_identity': {
|
||||
return { url: `/api/v1/identity/${a.uuid}`, method: 'DELETE' }
|
||||
}
|
||||
case 'create_identity_from_face': {
|
||||
const body: any = { name: a.name }
|
||||
if (a.traceIds) body.trace_ids = a.traceIds
|
||||
return { url: `/api/v1/file/${a.fileUuid}/pending-person`, method: 'POST', body }
|
||||
}
|
||||
|
||||
// --- Identity Operations ---
|
||||
case 'merge_identities': {
|
||||
@@ -264,11 +288,23 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
|
||||
if (a.deleteOutputFiles != null) body.delete_output_files = a.deleteOutputFiles
|
||||
return { url: '/api/v1/unregister', method: 'POST', body }
|
||||
}
|
||||
case 'ingest_file': {
|
||||
return { url: `/api/v1/file/${a.fileUuid}/checkin`, method: 'POST' }
|
||||
}
|
||||
case 'checkout_file': {
|
||||
return { url: `/api/v1/file/${a.fileUuid}/checkout`, method: 'POST' }
|
||||
}
|
||||
|
||||
// --- File Detail ---
|
||||
case 'get_file_info': {
|
||||
return { url: `/api/v1/file/${a.uuid}`, method: 'GET' }
|
||||
}
|
||||
case 'get_processor_counts': {
|
||||
return { url: `/api/v1/file/${a.uuid}/processor-counts`, method: 'GET' }
|
||||
}
|
||||
case 'get_progress': {
|
||||
return { url: `/api/v1/progress/${a.fileUuid}`, method: 'POST', body: {} }
|
||||
}
|
||||
|
||||
// --- Search History ---
|
||||
case 'get_search_history': {
|
||||
@@ -307,8 +343,6 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
|
||||
// Transform HTTP response to match Tauri invoke format
|
||||
// Some endpoints need data reshaping to match what the Rust commands return
|
||||
export function transformResponse(cmd: string, data: any): any {
|
||||
if (isTauri) return data
|
||||
|
||||
switch (cmd) {
|
||||
case 'get_files': {
|
||||
const files = data.files || data.data || data || []
|
||||
@@ -318,8 +352,10 @@ export function transformResponse(cmd: string, data: any): any {
|
||||
file_path: f.file_path || '',
|
||||
file_size: f.file_size || 0,
|
||||
modified_time: f.modified_time || '',
|
||||
isRegistered: f.is_registered ?? false,
|
||||
isRegistered: f.isRegistered ?? f.is_registered ?? false,
|
||||
status: f.status || '',
|
||||
registrationTime: f.registration_time || null,
|
||||
ingested: f.ingested ?? (f.status === 'completed'),
|
||||
}))
|
||||
}
|
||||
case 'get_people': {
|
||||
@@ -328,8 +364,9 @@ export function transformResponse(cmd: string, data: any): any {
|
||||
identity_uuid: p.identity_uuid || '',
|
||||
name: p.name || '',
|
||||
starred: p.metadata?.starred ?? p.starred ?? false,
|
||||
status: p.metadata?.status ?? p.status ?? 'pending',
|
||||
status: p.status || p.metadata?.status || 'pending',
|
||||
metadata: p.metadata || {},
|
||||
file_uuids: p.file_uuids || [],
|
||||
}))
|
||||
}
|
||||
case 'get_faces': {
|
||||
@@ -345,17 +382,21 @@ export function transformResponse(cmd: string, data: any): any {
|
||||
}))
|
||||
}
|
||||
case 'get_traces': {
|
||||
const traces = data.traces || data.data || data || []
|
||||
return traces.map((t: any) => ({
|
||||
trace_id: t.trace_id,
|
||||
file_uuid: t.file_uuid || '',
|
||||
frame_count: t.frame_count || 0,
|
||||
first_frame: t.first_frame || 0,
|
||||
last_frame: t.last_frame || 0,
|
||||
first_sec: t.first_sec || 0,
|
||||
last_sec: t.last_sec || 0,
|
||||
avg_confidence: t.avg_confidence || 0,
|
||||
}))
|
||||
const traces = data.traces || data.data || []
|
||||
return {
|
||||
total: data.total || traces.length || 0,
|
||||
traces: traces.map((t: any) => ({
|
||||
trace_id: t.trace_id,
|
||||
file_uuid: t.file_uuid || '',
|
||||
frame_count: t.frame_count || 0,
|
||||
first_frame: t.first_frame || 0,
|
||||
last_frame: t.last_frame || 0,
|
||||
first_sec: t.first_sec || 0,
|
||||
last_sec: t.last_sec || 0,
|
||||
avg_confidence: t.avg_confidence || 0,
|
||||
face_id: t.face_id ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
case 'get_face_candidates': {
|
||||
const candidates = data.candidates || data.data || data || []
|
||||
@@ -368,18 +409,71 @@ export function transformResponse(cmd: string, data: any): any {
|
||||
bbox: c.bbox ? { x: c.bbox.x, y: c.bbox.y, width: c.bbox.width, height: c.bbox.height } : null,
|
||||
}))
|
||||
}
|
||||
case 'get_unassigned_traces': {
|
||||
const traces = data.traces || data.data || []
|
||||
return {
|
||||
total: data.total || traces.length || 0,
|
||||
traces: traces.map((t: any) => ({
|
||||
trace_id: t.trace_id,
|
||||
file_uuid: t.file_uuid || '',
|
||||
frame_count: t.frame_count || 0,
|
||||
start_frame: t.start_frame || 0,
|
||||
end_frame: t.end_frame || 1,
|
||||
best_face_id: t.best_face_id,
|
||||
best_face_frame: t.best_face_frame || 1,
|
||||
best_face_confidence: t.best_face_confidence || 1,
|
||||
best_face_bbox: t.best_face_bbox ? { x: t.best_face_bbox.x, y: t.best_face_bbox.y, width: t.best_face_bbox.width, height: t.best_face_bbox.height } : null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
case 'get_pending_persons': {
|
||||
return {
|
||||
pending_persons: (data.pending_persons || data.data || []).map((p: any) => ({
|
||||
identity_uuid: p.identity_uuid || '',
|
||||
name: p.name || '',
|
||||
trace_count: p.trace_count || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
case 'search_llm_smart': {
|
||||
const results = data.results || data.data || data || []
|
||||
return results.map((r: any) => ({
|
||||
file_uuid: r.file_uuid || '',
|
||||
start_time: r.start_time ?? 0,
|
||||
end_time: r.end_time ?? 0,
|
||||
start_frame: r.start_frame ?? 0,
|
||||
end_frame: r.end_frame ?? 0,
|
||||
summary: r.summary || r.raw_text || '',
|
||||
similarity: r.similarity || 0,
|
||||
file_name: r.file_name || null,
|
||||
}))
|
||||
return results.map((r: any) => {
|
||||
const asr = r.asr || null
|
||||
const asrSegments = asr?.segments || []
|
||||
const asrLang = asr?.language || ''
|
||||
const asrLangProb = asr?.language_probability || 0
|
||||
|
||||
let asrStatus: 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing' = 'processing'
|
||||
let asrMessage = '處理中'
|
||||
|
||||
if (asrSegments.length === 0) {
|
||||
if (asrLang === '' && asrLangProb === 0) {
|
||||
asrStatus = 'no_audio_track'
|
||||
asrMessage = '無音軌'
|
||||
} else {
|
||||
asrStatus = 'silent_audio'
|
||||
asrMessage = asrLang ? `無語音 (${asrLang})` : '無語音'
|
||||
}
|
||||
} else {
|
||||
asrStatus = 'has_transcript'
|
||||
asrMessage = `${asrSegments.length} 段語音 (${asrLang || 'unknown'})`
|
||||
}
|
||||
|
||||
return {
|
||||
file_uuid: r.file_uuid || '',
|
||||
start_time: r.start_time ?? 0,
|
||||
end_time: r.end_time ?? 0,
|
||||
start_frame: r.start_frame ?? 0,
|
||||
end_frame: r.end_frame ?? 0,
|
||||
summary: r.summary || r.raw_text || '',
|
||||
similarity: r.similarity || 0,
|
||||
file_name: r.file_name || null,
|
||||
asr_status: asrStatus,
|
||||
asr_message: asrMessage,
|
||||
asr_segment_count: asrSegments.length,
|
||||
asr_language: asrLang,
|
||||
}
|
||||
})
|
||||
}
|
||||
case 'search_identities': {
|
||||
const results = data.results || data.data || data || []
|
||||
@@ -411,7 +505,9 @@ export function transformResponse(cmd: string, data: any): any {
|
||||
message: data.message ?? '',
|
||||
}
|
||||
}
|
||||
case 'unregister_file': {
|
||||
case 'unregister_file':
|
||||
case 'ingest_file':
|
||||
case 'checkout_file': {
|
||||
return {
|
||||
success: data.success ?? false,
|
||||
file_uuid: data.file_uuid ?? '',
|
||||
@@ -440,6 +536,14 @@ export function transformResponse(cmd: string, data: any): any {
|
||||
case 'merge_redo':
|
||||
case 'merge_history':
|
||||
return data
|
||||
case 'create_identity_from_face': {
|
||||
const respData = data.data || data
|
||||
return {
|
||||
success: data.success ?? false,
|
||||
identity_uuid: respData.identity_uuid ?? respData.uuid ?? '',
|
||||
name: respData.name ?? '',
|
||||
}
|
||||
}
|
||||
default:
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
<button class="ms-modal-video-close" @click="close">×</button>
|
||||
</div>
|
||||
|
||||
<!-- Mode toggle (only show when not simple and has traces/segments) -->
|
||||
<div v-if="!simple && (allTraces.length || mergedSegments.length)" class="ms-video-mode-toggle">
|
||||
<button :class="{ active: playMode === 'trace' }" @click="switchMode('trace')">追蹤</button>
|
||||
<button :class="{ active: playMode === 'segment' }" @click="switchMode('segment')">片段</button>
|
||||
<button :class="{ active: playMode === 'continuous' }" @click="switchMode('continuous')">連續</button>
|
||||
</div>
|
||||
|
||||
<div v-if="videoError" class="ms-video-loading">
|
||||
<p style="color:#f44336">{{ videoError }}</p>
|
||||
</div>
|
||||
@@ -15,18 +22,18 @@
|
||||
<p>Loading video...</p>
|
||||
</div>
|
||||
|
||||
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError"></video>
|
||||
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline preload="auto" @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError"></video>
|
||||
|
||||
<!-- Timeline bar (all raw traces) -->
|
||||
<div class="ms-video-timeline-wrap" v-if="!simple && allTraces.length && !videoLoading">
|
||||
<!-- Timeline bar -->
|
||||
<div class="ms-video-timeline-wrap" v-if="!simple && timelineMarkers.length && !videoLoading">
|
||||
<div class="ms-video-tl-bar">
|
||||
<div
|
||||
v-for="(dot, i) in timelineMarkers"
|
||||
:key="i"
|
||||
class="ms-video-tl-dot"
|
||||
:class="{ active: i === currentTraceIdx }"
|
||||
:class="{ active: isMarkerActive(i) }"
|
||||
:style="{ left: dot.pct + '%', width: dot.w + '%' }"
|
||||
@click="loadTrace(dot.idx)"
|
||||
@click="onTimelineClick(dot)"
|
||||
>
|
||||
<span class="ms-video-tl-tip">{{ formatTime(dot.start) }}</span>
|
||||
</div>
|
||||
@@ -38,10 +45,10 @@
|
||||
</div>
|
||||
|
||||
<div class="ms-video-nav" v-if="!videoLoading">
|
||||
<div v-if="!simple && allTraces.length" style="display:flex;align-items:center;gap:10px;flex:1;">
|
||||
<button class="ms-fm-btn ms-video-nav-btn" @click="prevTrace" :disabled="currentTraceIdx <= 0">← 上一個</button>
|
||||
<span class="ms-video-seg-info2">{{ currentTraceIdx + 1 }} / {{ allTraces.length }}</span>
|
||||
<button class="ms-fm-btn ms-video-nav-btn" @click="nextTrace" :disabled="currentTraceIdx >= allTraces.length - 1">下一個 →</button>
|
||||
<div v-if="!simple && (allTraces.length || mergedSegments.length)" style="display:flex;align-items:center;gap:10px;flex:1;">
|
||||
<button class="ms-fm-btn ms-video-nav-btn" @click="prevItem" :disabled="!canPrev">← 上一個</button>
|
||||
<span class="ms-video-seg-info2">{{ currentItemIdx + 1 }} / {{ totalItems }}</span>
|
||||
<button class="ms-fm-btn ms-video-nav-btn" @click="nextItem" :disabled="!canNext">下一個 →</button>
|
||||
</div>
|
||||
<span v-else class="ms-video-seg-info">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
</div>
|
||||
@@ -50,7 +57,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { apiCall } from '@/api'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -58,16 +65,20 @@ const props = withDefaults(defineProps<{
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
allTraces?: any[]
|
||||
mergedSegments?: any[]
|
||||
initialTraceIdx?: number
|
||||
initialSegmentIdx?: number
|
||||
title?: string
|
||||
simple?: boolean
|
||||
}>(), {
|
||||
allTraces: () => [],
|
||||
mergedSegments: () => [],
|
||||
initialTraceIdx: 0,
|
||||
initialSegmentIdx: 0,
|
||||
simple: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const emit = defineEmits(['close', 'trace-change', 'segment-change'])
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const visible = ref(true)
|
||||
@@ -78,21 +89,62 @@ const videoSrc = ref('')
|
||||
const videoLoading = ref(true)
|
||||
const videoError = ref('')
|
||||
const currentTraceIdx = ref(props.initialTraceIdx ?? 0)
|
||||
const currentSegmentIdx = ref(props.initialSegmentIdx ?? 0)
|
||||
const curFileUuid = ref(props.fileUuid)
|
||||
|
||||
// Timeline computation
|
||||
const tlStart = computed(() => {
|
||||
const playMode = ref<'trace' | 'segment' | 'continuous'>('trace')
|
||||
const pendingSeekTime = ref<number | null>(null)
|
||||
|
||||
const continuousStart = computed(() => {
|
||||
if (!props.allTraces.length) return 0
|
||||
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
|
||||
})
|
||||
const tlEnd = computed(() => {
|
||||
|
||||
const continuousEnd = computed(() => {
|
||||
if (!props.allTraces.length) return 0
|
||||
const last = props.allTraces[props.allTraces.length - 1]
|
||||
return last.last_sec || last.end_time || 0
|
||||
})
|
||||
|
||||
const continuousFileUuid = computed(() => {
|
||||
if (!props.allTraces.length) return ''
|
||||
return props.allTraces[0].file_uuid || ''
|
||||
})
|
||||
|
||||
const tlStart = computed(() => {
|
||||
if (playMode.value === 'segment' && props.mergedSegments.length) {
|
||||
return props.mergedSegments[0].start || 0
|
||||
}
|
||||
if (!props.allTraces.length) return 0
|
||||
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
|
||||
})
|
||||
|
||||
const tlEnd = computed(() => {
|
||||
if (playMode.value === 'segment' && props.mergedSegments.length) {
|
||||
const last = props.mergedSegments[props.mergedSegments.length - 1]
|
||||
return last.end || 0
|
||||
}
|
||||
if (!props.allTraces.length) return 0
|
||||
const last = props.allTraces[props.allTraces.length - 1]
|
||||
return last.last_sec || last.end_time || 0
|
||||
})
|
||||
|
||||
const tlRange = computed(() => Math.max(tlEnd.value - tlStart.value, 1))
|
||||
|
||||
const timelineMarkers = computed(() => {
|
||||
if (playMode.value === 'segment' && props.mergedSegments.length) {
|
||||
return props.mergedSegments.map((s: any, i: number) => {
|
||||
const st = s.start || 0
|
||||
const en = s.end || 0
|
||||
return {
|
||||
idx: i,
|
||||
start: st,
|
||||
end: en,
|
||||
pct: ((st - tlStart.value) / tlRange.value) * 100,
|
||||
w: Math.max(1, ((en - st) / tlRange.value) * 100),
|
||||
}
|
||||
})
|
||||
}
|
||||
return props.allTraces.map((t: any, i: number) => {
|
||||
const st = t.first_sec || t.start_time || 0
|
||||
const en = t.last_sec || t.end_time || 0
|
||||
@@ -106,19 +158,171 @@ const timelineMarkers = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const currentItemIdx = computed(() => {
|
||||
if (playMode.value === 'segment') return currentSegmentIdx.value
|
||||
return currentTraceIdx.value
|
||||
})
|
||||
|
||||
const totalItems = computed(() => {
|
||||
if (playMode.value === 'segment') return props.mergedSegments.length || 0
|
||||
return props.allTraces.length || 0
|
||||
})
|
||||
|
||||
const canPrev = computed(() => currentItemIdx.value > 0)
|
||||
const canNext = computed(() => currentItemIdx.value < totalItems.value - 1)
|
||||
|
||||
function isMarkerActive(i: number): boolean {
|
||||
if (playMode.value === 'segment') return i === currentSegmentIdx.value
|
||||
return i === currentTraceIdx.value
|
||||
}
|
||||
|
||||
function getTraceFps(t: any): number {
|
||||
const frameDiff = (t.last_frame || 0) - (t.first_frame || 0)
|
||||
const secDiff = (t.last_sec || 0) - (t.first_sec || 0)
|
||||
if (secDiff <= 0) return 30
|
||||
return frameDiff / secDiff
|
||||
}
|
||||
|
||||
function findSegmentIdxForFrame(absFrame: number): number {
|
||||
for (let i = 0; i < props.mergedSegments.length; i++) {
|
||||
const s = props.mergedSegments[i]
|
||||
const st = s.start_frame || 0
|
||||
const en = s.end_frame || 0
|
||||
if (absFrame >= st && absFrame <= en) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < props.mergedSegments.length; i++) {
|
||||
const s = props.mergedSegments[i]
|
||||
if (absFrame < (s.start_frame || 0)) {
|
||||
return Math.max(0, i - 1)
|
||||
}
|
||||
}
|
||||
return Math.max(0, props.mergedSegments.length - 1)
|
||||
}
|
||||
|
||||
function findTraceIdxForFrame(absFrame: number): number {
|
||||
for (let i = 0; i < props.allTraces.length; i++) {
|
||||
const t = props.allTraces[i]
|
||||
const st = t.first_frame || 0
|
||||
const en = t.last_frame || 0
|
||||
if (absFrame >= st && absFrame <= en) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < props.allTraces.length; i++) {
|
||||
const t = props.allTraces[i]
|
||||
if (absFrame < (t.first_frame || 0)) {
|
||||
return Math.max(0, i - 1)
|
||||
}
|
||||
}
|
||||
return props.allTraces.length - 1
|
||||
}
|
||||
|
||||
async function switchMode(mode: 'trace' | 'segment' | 'continuous') {
|
||||
if (mode === playMode.value) return
|
||||
const prevMode = playMode.value
|
||||
const prevTime = currentTime.value
|
||||
const prevTraceIdx = currentTraceIdx.value
|
||||
const prevSegmentIdx = currentSegmentIdx.value
|
||||
|
||||
let absFrame = 0
|
||||
let fps = 30
|
||||
|
||||
if (prevMode === 'trace' && props.allTraces[prevTraceIdx]) {
|
||||
const t = props.allTraces[prevTraceIdx]
|
||||
fps = getTraceFps(t)
|
||||
absFrame = (t.first_frame || 0) + Math.round(prevTime * fps)
|
||||
} else if (prevMode === 'segment' && props.mergedSegments[prevSegmentIdx]) {
|
||||
const s = props.mergedSegments[prevSegmentIdx]
|
||||
const sFps = getTraceFps(props.allTraces[s._startIdx || 0])
|
||||
fps = sFps
|
||||
absFrame = (s.start_frame || 0) + Math.round(prevTime * fps)
|
||||
} else if (prevMode === 'continuous') {
|
||||
const firstTrace = props.allTraces[0]
|
||||
fps = getTraceFps(firstTrace)
|
||||
absFrame = (firstTrace.first_frame || 0) + Math.round(prevTime * fps)
|
||||
}
|
||||
|
||||
playMode.value = mode
|
||||
|
||||
if (mode === 'continuous') {
|
||||
const firstTrace = props.allTraces[0]
|
||||
const lastTrace = props.allTraces[props.allTraces.length - 1]
|
||||
const fps = getTraceFps(firstTrace)
|
||||
const firstFrame = firstTrace?.first_frame || 0
|
||||
const lastFrame = lastTrace?.last_frame || firstFrame + 1
|
||||
|
||||
const targetIdx = findTraceIdxForFrame(absFrame)
|
||||
currentTraceIdx.value = targetIdx
|
||||
const targetTrace = props.allTraces[targetIdx]
|
||||
const targetFrame = Math.min(absFrame, lastFrame)
|
||||
|
||||
curFileUuid.value = continuousFileUuid.value
|
||||
const prevVolume = videoEl.value?.volume ?? 1
|
||||
videoLoading.value = true
|
||||
videoError.value = ''
|
||||
videoSrc.value = ''
|
||||
pendingSeekTime.value = null
|
||||
try {
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: continuousFileUuid.value,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
startFrame: targetFrame,
|
||||
endFrame: lastFrame,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
} else {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
videoSrc.value = URL.createObjectURL(blob)
|
||||
}
|
||||
await nextTick()
|
||||
} catch (e: any) {
|
||||
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
|
||||
return
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
await nextTick()
|
||||
const el = videoEl.value
|
||||
if (el) {
|
||||
el.volume = prevVolume
|
||||
el.load()
|
||||
}
|
||||
}
|
||||
} else if (mode === 'trace') {
|
||||
const targetIdx = findTraceIdxForFrame(absFrame)
|
||||
const targetTrace = props.allTraces[targetIdx]
|
||||
fps = getTraceFps(targetTrace)
|
||||
const relFrame = absFrame - (targetTrace?.first_frame || 0)
|
||||
pendingSeekTime.value = Math.max(0, relFrame / fps)
|
||||
await loadTrace(targetIdx)
|
||||
} else if (mode === 'segment') {
|
||||
const targetIdx = findSegmentIdxForFrame(absFrame)
|
||||
const targetSeg = props.mergedSegments[targetIdx]
|
||||
if (props.mergedSegments.length && targetSeg) {
|
||||
fps = getTraceFps(props.allTraces[targetSeg._startIdx || 0])
|
||||
const relFrame = absFrame - (targetSeg.start_frame || 0)
|
||||
pendingSeekTime.value = Math.max(0, relFrame / fps)
|
||||
await loadSegment(targetIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTrace(idx: number) {
|
||||
const traces = props.allTraces
|
||||
if (idx < 0 || idx >= traces.length) return
|
||||
currentTraceIdx.value = idx
|
||||
emit('trace-change', idx)
|
||||
const t = traces[idx]
|
||||
const fu = t.file_uuid || ''
|
||||
if (!fu || fu === 'undefined') return
|
||||
|
||||
const st = t.first_sec || t.start_time || 0
|
||||
const en = Math.max(t.last_sec || t.end_time || 0, st + 0.1)
|
||||
const stFrame = t.first_frame || 0
|
||||
const enFrame = Math.max(t.last_frame || 0, stFrame + 1)
|
||||
|
||||
curFileUuid.value = fu
|
||||
// Preserve volume across reloads
|
||||
const prevVolume = videoEl.value?.volume ?? 1
|
||||
videoLoading.value = true
|
||||
videoError.value = ''
|
||||
@@ -126,10 +330,10 @@ async function loadTrace(idx: number) {
|
||||
try {
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: fu,
|
||||
startTime: st,
|
||||
endTime: en,
|
||||
startFrame: null,
|
||||
endFrame: null,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
startFrame: stFrame,
|
||||
endFrame: enFrame,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
@@ -152,6 +356,162 @@ async function loadTrace(idx: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSegment(idx: number) {
|
||||
const segments = props.mergedSegments
|
||||
if (idx < 0 || idx >= segments.length) return
|
||||
currentSegmentIdx.value = idx
|
||||
emit('segment-change', idx)
|
||||
const s = segments[idx]
|
||||
const fu = s.file_uuid || ''
|
||||
if (!fu || fu === 'undefined') return
|
||||
|
||||
const stFrame = s.start_frame || 0
|
||||
const enFrame = Math.max(s.end_frame || 0, stFrame + 1)
|
||||
|
||||
curFileUuid.value = fu
|
||||
const prevVolume = videoEl.value?.volume ?? 1
|
||||
videoLoading.value = true
|
||||
videoError.value = ''
|
||||
videoSrc.value = ''
|
||||
try {
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: fu,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
startFrame: stFrame,
|
||||
endFrame: enFrame,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
} else {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
videoSrc.value = URL.createObjectURL(blob)
|
||||
}
|
||||
await nextTick()
|
||||
} catch (e: any) {
|
||||
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
|
||||
return
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
await nextTick()
|
||||
const el = videoEl.value
|
||||
if (el) {
|
||||
el.volume = prevVolume
|
||||
el.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContinuous() {
|
||||
const fu = continuousFileUuid.value
|
||||
if (!fu || fu === 'undefined') return
|
||||
|
||||
const firstTrace = props.allTraces[0]
|
||||
const lastTrace = props.allTraces[props.allTraces.length - 1]
|
||||
const stFrame = firstTrace?.first_frame || 0
|
||||
const enFrame = Math.max(lastTrace?.last_frame || 0, stFrame + 1)
|
||||
|
||||
curFileUuid.value = fu
|
||||
const prevVolume = videoEl.value?.volume ?? 1
|
||||
videoLoading.value = true
|
||||
videoError.value = ''
|
||||
videoSrc.value = ''
|
||||
try {
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: fu,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
startFrame: stFrame,
|
||||
endFrame: enFrame,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
} else {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
videoSrc.value = URL.createObjectURL(blob)
|
||||
}
|
||||
await nextTick()
|
||||
} catch (e: any) {
|
||||
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
|
||||
return
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
await nextTick()
|
||||
const el = videoEl.value
|
||||
if (el) {
|
||||
el.volume = prevVolume
|
||||
el.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onTimelineClick(marker: any) {
|
||||
if (playMode.value === 'continuous') {
|
||||
if (!videoEl.value) return
|
||||
const seekTime = marker.start - continuousStart.value
|
||||
videoEl.value.currentTime = Math.max(0, seekTime)
|
||||
currentTraceIdx.value = marker.idx
|
||||
} else if (playMode.value === 'trace') {
|
||||
loadTrace(marker.idx)
|
||||
} else if (playMode.value === 'segment') {
|
||||
loadSegment(marker.idx)
|
||||
}
|
||||
}
|
||||
|
||||
function prevItem() {
|
||||
if (playMode.value === 'continuous') {
|
||||
seekToPrevTrace()
|
||||
} else if (playMode.value === 'trace') {
|
||||
prevTrace()
|
||||
} else if (playMode.value === 'segment') {
|
||||
prevSegment()
|
||||
}
|
||||
}
|
||||
|
||||
function nextItem() {
|
||||
if (playMode.value === 'continuous') {
|
||||
seekToNextTrace()
|
||||
} else if (playMode.value === 'trace') {
|
||||
nextTrace()
|
||||
} else if (playMode.value === 'segment') {
|
||||
nextSegment()
|
||||
}
|
||||
}
|
||||
|
||||
function prevTrace() {
|
||||
if (currentTraceIdx.value > 0) loadTrace(currentTraceIdx.value - 1)
|
||||
}
|
||||
|
||||
function nextTrace() {
|
||||
if (currentTraceIdx.value < props.allTraces.length - 1) loadTrace(currentTraceIdx.value + 1)
|
||||
}
|
||||
|
||||
function prevSegment() {
|
||||
if (currentSegmentIdx.value > 0) loadSegment(currentSegmentIdx.value - 1)
|
||||
}
|
||||
|
||||
function nextSegment() {
|
||||
if (currentSegmentIdx.value < props.mergedSegments.length - 1) loadSegment(currentSegmentIdx.value + 1)
|
||||
}
|
||||
|
||||
function seekToPrevTrace() {
|
||||
if (!videoEl.value || currentTraceIdx.value <= 0) return
|
||||
currentTraceIdx.value--
|
||||
const t = props.allTraces[currentTraceIdx.value]
|
||||
if (!t) return
|
||||
const seekTime = (t.first_sec || 0) - continuousStart.value
|
||||
videoEl.value.currentTime = Math.max(0, seekTime)
|
||||
}
|
||||
|
||||
function seekToNextTrace() {
|
||||
if (!videoEl.value || currentTraceIdx.value >= props.allTraces.length - 1) return
|
||||
currentTraceIdx.value++
|
||||
const t = props.allTraces[currentTraceIdx.value]
|
||||
if (!t) return
|
||||
const seekTime = (t.first_sec || 0) - continuousStart.value
|
||||
videoEl.value.currentTime = Math.max(0, seekTime)
|
||||
}
|
||||
|
||||
function close() {
|
||||
visible.value = false
|
||||
emit('close')
|
||||
@@ -160,13 +520,14 @@ function close() {
|
||||
function onLoaded() {
|
||||
const el = videoEl.value
|
||||
if (!el) return
|
||||
const t = props.allTraces[currentTraceIdx.value]
|
||||
if (t) {
|
||||
const off = props.startTime ?? 0
|
||||
el.currentTime = (t.first_sec || t.start_time || 0) - off
|
||||
} else if (props.startTime) {
|
||||
|
||||
if (pendingSeekTime.value !== null) {
|
||||
el.currentTime = pendingSeekTime.value
|
||||
pendingSeekTime.value = null
|
||||
} else {
|
||||
el.currentTime = 0
|
||||
}
|
||||
|
||||
el.play().catch(() => {})
|
||||
}
|
||||
|
||||
@@ -174,6 +535,17 @@ function onTimeUpdate() {
|
||||
if (!videoEl.value) return
|
||||
currentTime.value = videoEl.value.currentTime
|
||||
duration.value = videoEl.value.duration || 0
|
||||
|
||||
if (playMode.value === 'continuous') {
|
||||
const absTime = currentTime.value + continuousStart.value
|
||||
for (let i = props.allTraces.length - 1; i >= 0; i--) {
|
||||
const t = props.allTraces[i]
|
||||
if (absTime >= (t.first_sec || 0)) {
|
||||
currentTraceIdx.value = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoError() {
|
||||
@@ -186,14 +558,6 @@ function onVideoError() {
|
||||
}
|
||||
}
|
||||
|
||||
function prevTrace() {
|
||||
if (currentTraceIdx.value > 0) loadTrace(currentTraceIdx.value - 1)
|
||||
}
|
||||
|
||||
function nextTrace() {
|
||||
if (currentTraceIdx.value < props.allTraces.length - 1) loadTrace(currentTraceIdx.value + 1)
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (videoEl.value) {
|
||||
if (videoEl.value.paused) { videoEl.value.play(); isPlaying.value = true }
|
||||
@@ -225,30 +589,56 @@ onMounted(async () => {
|
||||
videoLoading.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
const st = props.startTime ?? 0
|
||||
let et = props.endTime ?? st + 1
|
||||
if (et <= st || et - st < 1) et = st + 1
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: props.fileUuid,
|
||||
startTime: st,
|
||||
endTime: et,
|
||||
startFrame: null,
|
||||
endFrame: null,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
|
||||
if (props.allTraces.length || props.mergedSegments.length) {
|
||||
if (playMode.value === 'continuous') {
|
||||
await loadContinuous()
|
||||
} else if (playMode.value === 'segment' && props.mergedSegments.length) {
|
||||
await loadSegment(currentSegmentIdx.value)
|
||||
} else {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
videoSrc.value = URL.createObjectURL(blob)
|
||||
await loadTrace(currentTraceIdx.value)
|
||||
}
|
||||
await nextTick()
|
||||
} catch (e: any) {
|
||||
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
await nextTick()
|
||||
videoEl.value?.load()
|
||||
emit('trace-change', currentTraceIdx.value)
|
||||
} else {
|
||||
try {
|
||||
const st = props.startTime ?? 0
|
||||
let et = props.endTime ?? st + 1
|
||||
if (et <= st || et - st < 1) et = st + 1
|
||||
const data = await apiCall('get_video_stream', {
|
||||
uuid: props.fileUuid,
|
||||
startTime: st,
|
||||
endTime: et,
|
||||
startFrame: null,
|
||||
endFrame: null,
|
||||
})
|
||||
if (typeof data === 'string') {
|
||||
videoSrc.value = data
|
||||
} else {
|
||||
const blob = new Blob([new Uint8Array(data)], { type: 'video/mp4' })
|
||||
videoSrc.value = URL.createObjectURL(blob)
|
||||
}
|
||||
await nextTick()
|
||||
} catch (e: any) {
|
||||
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
|
||||
} finally {
|
||||
videoLoading.value = false
|
||||
await nextTick()
|
||||
videoEl.value?.load()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.initialTraceIdx, (newIdx) => {
|
||||
if (newIdx === undefined || newIdx === null) return
|
||||
if (playMode.value === 'trace' && newIdx !== currentTraceIdx.value) {
|
||||
loadTrace(newIdx)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.initialSegmentIdx, (newIdx) => {
|
||||
if (newIdx === undefined || newIdx === null) return
|
||||
if (playMode.value === 'segment' && newIdx !== currentSegmentIdx.value) {
|
||||
loadSegment(newIdx)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -258,13 +648,17 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 999; display: grid; place-items: center; }
|
||||
.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 999; display: flex; justify-content: center; align-items: flex-start; padding-top: 140px; }
|
||||
.ms-modal { background: #fff; border-radius: 20px; max-width: 380px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.18); }
|
||||
.ms-modal-video { max-width: 720px; width: 95%; padding: 20px 24px 24px; text-align: left; background: #1a1a1a; }
|
||||
.ms-modal-video-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.ms-modal-video-title { font-size: 14px; font-weight: 600; color: #e8eaed; margin: 0; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ms-modal-video-close { border: none; background: transparent; font-size: 18px; color: #9aa0a6; cursor: pointer; padding: 0; line-height: 1; flex-shrink: 0; }
|
||||
.ms-modal-video-close:hover { color: #fff; }
|
||||
.ms-video-mode-toggle { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.ms-video-mode-toggle button { padding: 6px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: transparent; color: rgba(255,255,255,0.6); font-size: 12px; cursor: pointer; transition: background .15s, color .15s, border-color .15s; }
|
||||
.ms-video-mode-toggle button:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.85); }
|
||||
.ms-video-mode-toggle button.active { background: rgba(255,255,255,0.15); color: #fff; border-color: rgba(255,255,255,0.4); }
|
||||
.video { max-width: 100%; max-height: 60vh; border-radius: 8px; display: block; width: 100%; }
|
||||
.ms-video-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 20px; color: #5f6368; font-size: 13px; }
|
||||
.ms-video-loading-spinner { width: 18px; height: 18px; border: 2px solid #e8eaed; border-top-color: #1a56db; border-radius: 50%; animation: ms-spin .7s linear infinite; }
|
||||
|
||||
@@ -3,13 +3,19 @@ import SearchView from '../views/SearchView.vue'
|
||||
import LibraryView from '../views/LibraryView.vue'
|
||||
import PeopleView from '../views/PeopleView.vue'
|
||||
import PersonDetailView from '../views/PersonDetailView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ClientView from '../views/ClientView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/search' },
|
||||
{ path: '/login', name: 'Login', component: LoginView },
|
||||
{ path: '/search', name: 'Search', component: SearchView },
|
||||
{ path: '/library', name: 'Library', component: LibraryView },
|
||||
{ path: '/people', name: 'People', component: PeopleView },
|
||||
{ path: '/people/:uuid', name: 'PersonDetail', component: PersonDetailView }
|
||||
{ path: '/people/:uuid', name: 'PersonDetail', component: PersonDetailView },
|
||||
{ path: '/admin', name: 'Admin', component: AdminView },
|
||||
{ path: '/client', name: 'Client', component: ClientView }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
@@ -17,4 +23,4 @@ const router = createRouter({
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
export default router
|
||||
193
src/store.ts
193
src/store.ts
@@ -11,10 +11,43 @@ export const peopleLoaded = ref(false)
|
||||
export const faceCandidatesCache = ref<any[]>([])
|
||||
export const faceCandidatesLoaded = ref(false)
|
||||
|
||||
export const unassignedTracesCache = ref<any[]>([])
|
||||
export const unassignedTracesLoaded = ref(false)
|
||||
|
||||
export const thumbnailsCache = ref<Record<string, string>>({})
|
||||
export const profilesCache = ref<Record<string, string>>({})
|
||||
export const faceThumbsCache = ref<Record<string, string>>({})
|
||||
|
||||
export interface ProcessorOutputInfo {
|
||||
has_json: boolean
|
||||
frame_count: number | null
|
||||
segment_count: number | null
|
||||
chunk_count: number | null
|
||||
last_modified: string | null
|
||||
}
|
||||
|
||||
export interface ProcessorProgress {
|
||||
processor_type: string
|
||||
status: 'pending' | 'running' | 'complete' | 'failed'
|
||||
progress: number
|
||||
current?: number
|
||||
total?: number
|
||||
eta_seconds?: number
|
||||
}
|
||||
|
||||
export interface FileProgress {
|
||||
file_uuid: string
|
||||
overall_progress: number
|
||||
processors: ProcessorProgress[]
|
||||
cpu_percent?: number
|
||||
gpu_percent?: number
|
||||
memory_percent?: number
|
||||
}
|
||||
|
||||
export const processorCountsCache = ref<Record<string, Record<string, ProcessorOutputInfo>>>({})
|
||||
export const fileProgressCache = ref<Record<string, FileProgress>>({})
|
||||
export const fileStatusCache = ref<Record<string, string>>({})
|
||||
|
||||
const thumbQueue: (() => Promise<void>)[] = []
|
||||
let activeThumbLoads = 0
|
||||
const MAX_CONCURRENT = 16
|
||||
@@ -26,6 +59,8 @@ const MAX_PROFILE_CONCURRENT = 4
|
||||
const loadingThumbs = new Set<string>()
|
||||
const loadingProfiles = new Set<string>()
|
||||
const loadingFaceThumbs = new Set<string>()
|
||||
const loadingProcessorCounts = new Set<string>()
|
||||
const pollingProgress = new Set<string>()
|
||||
|
||||
function drainThumbQueue() {
|
||||
if (activeThumbLoads >= MAX_CONCURRENT || thumbQueue.length === 0) return
|
||||
@@ -66,8 +101,11 @@ function queueProfile(fn: () => Promise<void>) {
|
||||
let _peopleLoading = false
|
||||
let _filesLoading = false
|
||||
let _faceCandidatesLoading = false
|
||||
let _unassignedTracesLoading = false
|
||||
let _lastLoadedFileUuid: string | undefined = undefined
|
||||
|
||||
export async function ensureFiles() {
|
||||
console.error('[ensureFiles] starting, filesLoaded.value:', filesLoaded.value)
|
||||
if (filesLoaded.value) return
|
||||
if (_filesLoading) {
|
||||
await new Promise<void>(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) })
|
||||
@@ -145,6 +183,56 @@ export async function ensureFaceCandidates() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureUnassignedTraces(fileUuid?: string) {
|
||||
if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid) return
|
||||
if (_unassignedTracesLoading) {
|
||||
await new Promise<void>(r => { const check = setInterval(() => { if (unassignedTracesLoaded.value || !_unassignedTracesLoading) { clearInterval(check); r() } }, 100) })
|
||||
if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid) return
|
||||
}
|
||||
_unassignedTracesLoading = true
|
||||
try {
|
||||
const all: any[] = []
|
||||
for (let page = 1; page <= 5; page++) {
|
||||
const batch: any = await apiCall('get_unassigned_traces', { page, perPage: 20 })
|
||||
const traces = batch?.traces || []
|
||||
if (!traces.length) break
|
||||
all.push(...traces)
|
||||
if (traces.length < 20) break
|
||||
}
|
||||
unassignedTracesCache.value = all
|
||||
_lastLoadedFileUuid = fileUuid
|
||||
} catch (e) {
|
||||
console.error('Failed to load unassigned traces:', e)
|
||||
} finally {
|
||||
_unassignedTracesLoading = false
|
||||
unassignedTracesLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTraceThumb(t: any) {
|
||||
if (!t.file_uuid || !t.trace_id) return
|
||||
const key = `${t.trace_id}-${t.file_uuid}`
|
||||
if (faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return
|
||||
loadingFaceThumbs.add(key)
|
||||
queueThumb(async () => {
|
||||
try {
|
||||
const args: any = { uuid: t.file_uuid, frame: t.best_face_frame || t.start_frame || 0 }
|
||||
if (t.best_face_bbox) {
|
||||
args.bboxX = Math.round(t.best_face_bbox.x)
|
||||
args.bboxY = Math.round(t.best_face_bbox.y)
|
||||
args.bboxW = Math.round(t.best_face_bbox.width)
|
||||
args.bboxH = Math.round(t.best_face_bbox.height)
|
||||
}
|
||||
const result = await apiCall('get_face_thumbnail', args)
|
||||
if (result) faceThumbsCache.value[key] = result
|
||||
} catch (e) {
|
||||
console.error('loadTraceThumb failed:', key, e)
|
||||
} finally {
|
||||
loadingFaceThumbs.delete(key)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function loadThumbnail(uuid: string, frame = 30) {
|
||||
const key = `${uuid}:${frame}`
|
||||
if (!uuid || thumbnailsCache.value[key] || loadingThumbs.has(key)) return
|
||||
@@ -203,6 +291,111 @@ export function loadFaceThumb(key: string, uuid: string, frame: number, bbox?: a
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadProcessorCounts(fileUuid: string, force = false) {
|
||||
if (!fileUuid) return
|
||||
if (!force && processorCountsCache.value[fileUuid]) return
|
||||
if (loadingProcessorCounts.has(fileUuid)) return
|
||||
loadingProcessorCounts.add(fileUuid)
|
||||
try {
|
||||
const data: any = await apiCall('get_processor_counts', { uuid: fileUuid })
|
||||
const counts: Record<string, ProcessorOutputInfo> = {}
|
||||
for (const p of data.processors || []) {
|
||||
counts[p.processor] = {
|
||||
has_json: p.has_json ?? false,
|
||||
frame_count: p.frame_count ?? null,
|
||||
segment_count: p.segment_count ?? null,
|
||||
chunk_count: p.chunk_count ?? null,
|
||||
last_modified: p.last_modified ?? null,
|
||||
}
|
||||
}
|
||||
processorCountsCache.value = { ...processorCountsCache.value, [fileUuid]: counts }
|
||||
} catch (e) {
|
||||
console.error('loadProcessorCounts failed:', fileUuid, e)
|
||||
} finally {
|
||||
loadingProcessorCounts.delete(fileUuid)
|
||||
}
|
||||
}
|
||||
|
||||
export function getProcessorCounts(fileUuid: string): Record<string, ProcessorOutputInfo> {
|
||||
return processorCountsCache.value[fileUuid] || {}
|
||||
}
|
||||
|
||||
export async function pollProgress(fileUuid: string) {
|
||||
if (!fileUuid || pollingProgress.has(fileUuid)) return
|
||||
pollingProgress.add(fileUuid)
|
||||
try {
|
||||
while (pollingProgress.has(fileUuid)) {
|
||||
const data: any = await apiCall('get_progress', { fileUuid })
|
||||
fileProgressCache.value[fileUuid] = {
|
||||
file_uuid: data.file_uuid || fileUuid,
|
||||
overall_progress: data.overall_progress || 0,
|
||||
processors: (data.processors || []).map((p: any) => ({
|
||||
processor_type: p.processor_type,
|
||||
status: p.status,
|
||||
progress: p.progress || 0,
|
||||
current: p.current,
|
||||
total: p.total,
|
||||
eta_seconds: p.eta_seconds,
|
||||
})),
|
||||
cpu_percent: data.cpu_percent,
|
||||
gpu_percent: data.gpu_percent,
|
||||
memory_percent: data.memory_percent,
|
||||
}
|
||||
const allComplete = data.processors?.every((p: any) => p.status === 'complete' || p.status === 'failed')
|
||||
if (allComplete || data.overall_progress >= 100) {
|
||||
stopPolling(fileUuid)
|
||||
fileStatusCache.value[fileUuid] = 'completed'
|
||||
await loadProcessorCounts(fileUuid)
|
||||
break
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('pollProgress failed:', fileUuid, e)
|
||||
} finally {
|
||||
pollingProgress.delete(fileUuid)
|
||||
}
|
||||
}
|
||||
|
||||
export function stopPolling(fileUuid: string) {
|
||||
pollingProgress.delete(fileUuid)
|
||||
}
|
||||
|
||||
export function getProgress(fileUuid: string): FileProgress | null {
|
||||
return fileProgressCache.value[fileUuid] || null
|
||||
}
|
||||
|
||||
export function getFileStatus(fileUuid: string): string {
|
||||
return fileStatusCache.value[fileUuid] || ''
|
||||
}
|
||||
|
||||
export async function refreshFileStatus(fileUuid: string) {
|
||||
try {
|
||||
const data: any = await apiCall('get_progress', { fileUuid })
|
||||
if (data.overall_progress >= 100 || data.processors?.every((p: any) => p.status === 'complete')) {
|
||||
fileStatusCache.value[fileUuid] = 'completed'
|
||||
await loadProcessorCounts(fileUuid)
|
||||
} else if (data.processors?.some((p: any) => p.status === 'running')) {
|
||||
fileStatusCache.value[fileUuid] = 'processing'
|
||||
fileProgressCache.value[fileUuid] = {
|
||||
file_uuid: fileUuid,
|
||||
overall_progress: data.overall_progress || 0,
|
||||
processors: data.processors || [],
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('refreshFileStatus failed:', fileUuid, e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshAllFilesStatus() {
|
||||
for (const f of filesCache.value) {
|
||||
if (f.isRegistered && f.file_uuid) {
|
||||
await refreshFileStatus(f.file_uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateFiles() {
|
||||
filesLoaded.value = false
|
||||
filesCache.value = []
|
||||
|
||||
48
src/utils/asrStatus.ts
Normal file
48
src/utils/asrStatus.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
export type AsrStatus = 'no_audio_track' | 'silent_audio' | 'has_transcript' | 'processing'
|
||||
|
||||
export interface AsrData {
|
||||
language?: string
|
||||
language_probability?: number
|
||||
segments?: Array<{
|
||||
start_time: number
|
||||
end_time: number
|
||||
text: string
|
||||
}>
|
||||
}
|
||||
|
||||
export function getAsrStatus(asr: AsrData | null | undefined): AsrStatus {
|
||||
if (!asr) return 'processing'
|
||||
|
||||
const segments = asr.segments || []
|
||||
const language = asr.language || ''
|
||||
const langProb = asr.language_probability || 0
|
||||
|
||||
if (segments.length === 0) {
|
||||
if (language === '' && langProb === 0) {
|
||||
return 'no_audio_track'
|
||||
}
|
||||
return 'silent_audio'
|
||||
}
|
||||
|
||||
return 'has_transcript'
|
||||
}
|
||||
|
||||
export function getAsrStatusLabel(status: AsrStatus): string {
|
||||
const labels: Record<AsrStatus, string> = {
|
||||
'no_audio_track': '無音軌',
|
||||
'silent_audio': '無語音',
|
||||
'has_transcript': '有語音',
|
||||
'processing': '處理中'
|
||||
}
|
||||
return labels[status]
|
||||
}
|
||||
|
||||
export function getAsrStatusColor(status: AsrStatus): string {
|
||||
const colors: Record<AsrStatus, string> = {
|
||||
'no_audio_track': '#999',
|
||||
'silent_audio': '#f90',
|
||||
'has_transcript': '#0a0',
|
||||
'processing': '#59e'
|
||||
}
|
||||
return colors[status]
|
||||
}
|
||||
85
src/views/AdminView.vue
Normal file
85
src/views/AdminView.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div id="ms-view-admin">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Panel</h1>
|
||||
<div class="admin-tabs">
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
|
||||
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-content">
|
||||
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
|
||||
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'users'" class="users-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="u in users" :key="u.username">
|
||||
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'shares'" class="shares-panel">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="s in shares" :key="s.name">
|
||||
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { apiCall } from '../api'
|
||||
|
||||
const activeTab = ref('dashboard')
|
||||
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
|
||||
const users = ref<any[]>([])
|
||||
const shares = ref<any[]>([])
|
||||
|
||||
async function loadDashboard() {
|
||||
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-admin { padding: 20px; }
|
||||
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.admin-tabs { display: flex; gap: 10px; }
|
||||
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
|
||||
.admin-tab.active { background: #2563eb; color: white; }
|
||||
.admin-content { background: white; border-radius: 8px; padding: 20px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
|
||||
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
|
||||
.stat-label { font-size: 12px; color: #666; }
|
||||
.stat-value { font-size: 24px; font-weight: bold; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
|
||||
</style>
|
||||
690
src/views/ClientView.vue
Normal file
690
src/views/ClientView.vue
Normal file
@@ -0,0 +1,690 @@
|
||||
<template>
|
||||
<div id="ms-view-client">
|
||||
<div class="client-header">
|
||||
<h1>File Manager</h1>
|
||||
<div class="client-toolbar">
|
||||
<button @click="goUp" :disabled="currentPath === '/'">Up</button>
|
||||
<button @click="showCreateFolderDialog">New Folder</button>
|
||||
<button @click="showUploadDialog">Upload</button>
|
||||
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
|
||||
<button @click="refreshFiles">Refresh</button>
|
||||
<span class="current-path">Path: {{ currentPath }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="client-content">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error">{{ error }}</div>
|
||||
<table v-else class="file-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Modified</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="files.length === 0">
|
||||
<td colspan="4" class="empty-folder">Empty folder</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="f in files"
|
||||
:key="f.name"
|
||||
@click="selectFile(f)"
|
||||
@dblclick="openFile(f)"
|
||||
:class="{ selected: selectedFile?.name === f.name }"
|
||||
>
|
||||
<td>
|
||||
<span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span>
|
||||
{{ f.name }}
|
||||
</td>
|
||||
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
|
||||
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
|
||||
<td>{{ f.modified || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create Folder Dialog -->
|
||||
<div v-if="showCreateFolder" class="dialog-overlay" @click="hideCreateFolderDialog">
|
||||
<div class="dialog" @click.stop>
|
||||
<h3>Create New Folder</h3>
|
||||
<input v-model="newFolderName" type="text" placeholder="Folder name" @keyup.enter="createFolder" />
|
||||
<div class="dialog-buttons">
|
||||
<button @click="createFolder">Create</button>
|
||||
<button @click="hideCreateFolderDialog">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Preview Dialog -->
|
||||
<div v-if="showPreview" class="dialog-overlay" @click="hidePreview">
|
||||
<div class="dialog preview-dialog" @click.stop>
|
||||
<h3>File Preview: {{ selectedFile?.name }}</h3>
|
||||
<div v-if="previewLoading" class="loading">Loading preview...</div>
|
||||
<div v-else-if="previewContent" class="preview-content">
|
||||
<img v-if="isImage" :src="previewContent" alt="preview" />
|
||||
<pre v-else>{{ previewContent }}</pre>
|
||||
</div>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="downloadFile">Download</button>
|
||||
<button @click="hidePreview">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Dialog -->
|
||||
<div v-if="showUpload" class="dialog-overlay" @click="hideUploadDialog">
|
||||
<div class="dialog upload-dialog" @click.stop>
|
||||
<h3>Upload File</h3>
|
||||
<input ref="fileInput" type="file" @change="selectUploadFile" />
|
||||
<div v-if="uploadFile" class="upload-info">
|
||||
<p>File: {{ uploadFile.name }}</p>
|
||||
<p>Size: {{ formatSize(uploadFile.size) }}</p>
|
||||
</div>
|
||||
<div v-if="uploadProgress > 0" class="upload-progress">
|
||||
<progress :value="uploadProgress" max="100"></progress>
|
||||
<span>{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div class="dialog-buttons">
|
||||
<button @click="uploadFileToServer" :disabled="!uploadFile || uploading">
|
||||
{{ uploading ? 'Uploading...' : 'Upload' }}
|
||||
</button>
|
||||
<button @click="hideUploadDialog">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const currentPath = ref('/')
|
||||
const files = ref<any[]>([])
|
||||
const selectedFile = ref<any>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const showCreateFolder = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const showPreview = ref(false)
|
||||
const previewContent = ref('')
|
||||
const previewLoading = ref(false)
|
||||
const isImage = ref(false)
|
||||
const showUpload = ref(false)
|
||||
const uploadFile = ref<File | null>(null)
|
||||
const uploadProgress = ref(0)
|
||||
const uploading = ref(false)
|
||||
|
||||
// API base URL (MarkBase server)
|
||||
const API_BASE = 'http://localhost:11438/api/v2'
|
||||
|
||||
// Load files from current directory
|
||||
async function loadFiles() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
selectedFile.value = null
|
||||
|
||||
try {
|
||||
// Use MarkBase myfiles API
|
||||
const username = 'demo'
|
||||
const folderParam = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
|
||||
|
||||
let apiUrl = `${API_BASE}/myfiles/${username}/files`
|
||||
if (folderParam) {
|
||||
apiUrl += `?folder=${encodeURIComponent(folderParam)}`
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Transform files to file list (data is array, not { files: [] })
|
||||
if (Array.isArray(data)) {
|
||||
files.value = data.map((file: any) => ({
|
||||
name: file.name || file.filename,
|
||||
isDir: false, // All files in myfiles are files, not folders
|
||||
size: file.size || 0,
|
||||
modified: '-',
|
||||
uuid: null,
|
||||
tags: file.tags || [],
|
||||
})).sort((a: any, b: any) => a.name.localeCompare(b.name))
|
||||
} else {
|
||||
files.value = []
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = 'Failed to load files: ' + e.message
|
||||
files.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to parent directory
|
||||
function goUp() {
|
||||
if (currentPath.value === '/') return
|
||||
|
||||
const parts = currentPath.value.split('/').filter(p => p)
|
||||
parts.pop()
|
||||
currentPath.value = '/' + parts.join('/')
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// Select file
|
||||
function selectFile(f: any) {
|
||||
selectedFile.value = f
|
||||
}
|
||||
|
||||
// Open file (double-click)
|
||||
function openFile(f: any) {
|
||||
if (f.isDir) {
|
||||
// Navigate into directory
|
||||
currentPath.value = currentPath.value === '/'
|
||||
? '/' + f.name
|
||||
: currentPath.value + '/' + f.name
|
||||
loadFiles()
|
||||
} else {
|
||||
// Preview file
|
||||
showFilePreview(f)
|
||||
}
|
||||
}
|
||||
|
||||
// Show create folder dialog
|
||||
function showCreateFolderDialog() {
|
||||
newFolderName.value = ''
|
||||
showCreateFolder.value = true
|
||||
}
|
||||
|
||||
// Hide create folder dialog
|
||||
function hideCreateFolderDialog() {
|
||||
showCreateFolder.value = false
|
||||
newFolderName.value = ''
|
||||
}
|
||||
|
||||
// Create new folder
|
||||
async function createFolder() {
|
||||
if (!newFolderName.value.trim()) {
|
||||
alert('Please enter folder name')
|
||||
return
|
||||
}
|
||||
|
||||
const folderPath = currentPath.value === '/'
|
||||
? '/' + newFolderName.value
|
||||
: currentPath.value + '/' + newFolderName.value
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/tree/demo/node`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
parent_id: currentPath.value === '/' ? null : currentPath.value,
|
||||
node_type: 'folder',
|
||||
label: newFolderName.value,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
hideCreateFolderDialog()
|
||||
loadFiles()
|
||||
} catch (e: any) {
|
||||
alert('Failed to create folder: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete selected file/folder
|
||||
async function deleteSelected() {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
const itemName = selectedFile.value.name
|
||||
if (!confirm(`Delete "${itemName}"?`)) return
|
||||
|
||||
const itemPath = currentPath.value === '/'
|
||||
? '/' + itemName
|
||||
: currentPath.value + '/' + itemName
|
||||
|
||||
try {
|
||||
// Find node_id from files list
|
||||
const node = files.value.find(f => f.name === itemName)
|
||||
if (!node || !node.uuid) {
|
||||
throw new Error('Node UUID not found')
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/tree/demo/node/${node.uuid}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
selectedFile.value = null
|
||||
loadFiles()
|
||||
} catch (e: any) {
|
||||
alert('Failed to delete: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh file list
|
||||
function refreshFiles() {
|
||||
loadFiles()
|
||||
}
|
||||
|
||||
// Show file preview
|
||||
async function showFilePreview(f: any) {
|
||||
showPreview.value = true
|
||||
previewLoading.value = true
|
||||
previewContent.value = ''
|
||||
isImage.value = false
|
||||
|
||||
try {
|
||||
// Check file type by extension
|
||||
const ext = f.name.split('.').pop()?.toLowerCase() || ''
|
||||
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
|
||||
const videoExtensions = ['mp4', 'avi', 'mov', 'mkv', 'webm']
|
||||
const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'm4a']
|
||||
const textExtensions = ['txt', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'vue', 'py', 'rs', 'go', 'java']
|
||||
|
||||
if (imageExtensions.includes(ext)) {
|
||||
// Image preview - use WebDAV with auth
|
||||
isImage.value = true
|
||||
const token = localStorage.getItem('token') || ''
|
||||
const username = localStorage.getItem('username') || 'demo'
|
||||
|
||||
// Use WebDAV endpoint with Basic auth
|
||||
const response = await fetch(`http://localhost:11438/webdav/${username}/${f.name}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob()
|
||||
previewContent.value = URL.createObjectURL(blob)
|
||||
} else {
|
||||
// Fallback: try without auth
|
||||
previewContent.value = `http://localhost:11438/webdav/${username}/${f.name}`
|
||||
}
|
||||
} else if (videoExtensions.includes(ext)) {
|
||||
// Video preview - show video element
|
||||
isImage.value = false
|
||||
const username = localStorage.getItem('username') || 'demo'
|
||||
previewContent.value = `<video controls style="max-width:100%; max-height:60vh">
|
||||
<source src="http://localhost:11438/webdav/${username}/${f.name}" type="video/${ext}">
|
||||
Your browser does not support video preview.
|
||||
</video>`
|
||||
} else if (audioExtensions.includes(ext)) {
|
||||
// Audio preview - show audio element
|
||||
isImage.value = false
|
||||
const username = localStorage.getItem('username') || 'demo'
|
||||
previewContent.value = `<audio controls style="width:100%">
|
||||
<source src="http://localhost:11438/webdav/${username}/${f.name}" type="audio/${ext}">
|
||||
Your browser does not support audio preview.
|
||||
</audio>`
|
||||
} else if (textExtensions.includes(ext)) {
|
||||
// Text preview - fetch and display content
|
||||
const username = localStorage.getItem('username') || 'demo'
|
||||
const token = localStorage.getItem('token') || ''
|
||||
|
||||
const response = await fetch(`http://localhost:11438/webdav/${username}/${f.name}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const text = await response.text()
|
||||
|
||||
// Limit preview size
|
||||
if (text.length > 10000) {
|
||||
previewContent.value = text.substring(0, 10000) + '\n\n... (truncated, file too large)'
|
||||
} else {
|
||||
previewContent.value = text
|
||||
}
|
||||
} else {
|
||||
previewContent.value = 'Failed to load file content'
|
||||
}
|
||||
} else {
|
||||
// Unknown file type - show download link
|
||||
previewContent.value = `File type: ${ext}\n\nPreview not available for this file type.\nClick Download button to download the file.`
|
||||
}
|
||||
} catch (e: any) {
|
||||
previewContent.value = 'Failed to load preview: ' + e.message
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Hide preview dialog
|
||||
function hidePreview() {
|
||||
showPreview.value = false
|
||||
previewContent.value = ''
|
||||
}
|
||||
|
||||
// Show upload dialog
|
||||
function showUploadDialog() {
|
||||
uploadFile.value = null
|
||||
uploadProgress.value = 0
|
||||
uploading.value = false
|
||||
showUpload.value = true
|
||||
}
|
||||
|
||||
// Hide upload dialog
|
||||
function hideUploadDialog() {
|
||||
showUpload.value = false
|
||||
uploadFile.value = null
|
||||
uploadProgress.value = 0
|
||||
uploading.value = false
|
||||
}
|
||||
|
||||
// Select file for upload
|
||||
function selectUploadFile(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (target.files && target.files[0]) {
|
||||
uploadFile.value = target.files[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Upload file to server
|
||||
async function uploadFileToServer() {
|
||||
if (!uploadFile.value) {
|
||||
alert('No file selected')
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.value)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
// Track upload progress
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadProgress.value = Math.round((e.loaded / e.total) * 100)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status === 200 || xhr.status === 201) {
|
||||
alert('Upload successful!')
|
||||
hideUploadDialog()
|
||||
loadFiles()
|
||||
} else {
|
||||
alert('Upload failed: ' + xhr.statusText)
|
||||
}
|
||||
uploading.value = false
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
alert('Upload failed: Network error')
|
||||
uploading.value = false
|
||||
})
|
||||
|
||||
// Upload to upload-unlimited endpoint
|
||||
xhr.open('POST', `${API_BASE}/upload-unlimited/demo`)
|
||||
xhr.send(formData)
|
||||
} catch (e: any) {
|
||||
alert('Upload failed: ' + e.message)
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Download file
|
||||
function downloadFile() {
|
||||
if (!selectedFile.value) {
|
||||
alert('No file selected')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedFile.value.downloadUrl) {
|
||||
window.open(selectedFile.value.downloadUrl, '_blank')
|
||||
} else if (selectedFile.value.uuid) {
|
||||
const downloadUrl = `${API_BASE}/files/demo/${selectedFile.value.uuid}/stream`
|
||||
window.open(downloadUrl, '_blank')
|
||||
} else {
|
||||
alert('Download URL not available')
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size
|
||||
function formatSize(n: number): string {
|
||||
if (n < 1024) return n + ' B'
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
|
||||
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
|
||||
return (n / 1024 / 1024 / 1024).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
// Initialize on mount
|
||||
onMounted(() => {
|
||||
loadFiles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#ms-view-client {
|
||||
padding: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.client-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.client-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.client-toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.client-toolbar button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.client-toolbar button:hover:not(:disabled) {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.client-toolbar button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.current-path {
|
||||
margin-left: 20px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.client-content {
|
||||
background: white;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.loading, .error {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.file-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.file-table th, .file-table td {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.file-table th {
|
||||
background: #f5f5f5;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.file-table tr.selected {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.file-table tr:hover {
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.empty-folder {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.folder-icon, .file-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Dialog styles */
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.dialog h3 {
|
||||
margin: 0 0 15px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.dialog input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dialog-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dialog-buttons button {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dialog-buttons button:first-child {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.preview-dialog {
|
||||
max-width: 800px;
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.preview-content img {
|
||||
max-width: 100%;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.preview-content pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.upload-dialog {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.upload-info {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.upload-info p {
|
||||
margin: 5px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.upload-progress {
|
||||
margin-top: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-progress progress {
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.upload-progress span {
|
||||
font-size: 14px;
|
||||
color: #667eea;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@
|
||||
<div class="ms-fm-toolbar">
|
||||
<div class="ms-fm-toolbar-left">
|
||||
<button class="ms-fm-icon-btn" type="button" title="Refresh" @click="loadFiles">⟳</button>
|
||||
<button class="ms-fm-icon-btn" type="button" title="Refresh Status" @click="refreshAllStatus">⟡</button>
|
||||
<label class="ms-fm-radio-label">
|
||||
<input type="radio" name="ms-display-filter" value="all" v-model="displayFilter"> All
|
||||
</label>
|
||||
@@ -39,6 +40,8 @@
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterRegistered"> 已註冊</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterPending"> 待處理</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterCompleted"> 已完成</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterNotIngested"> 未入庫</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterIngested"> 已入庫</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="onlyVideos"> 僅顯示影片</label>
|
||||
<label class="ms-fm-radio-label"><input type="checkbox" v-model="onlyPhotos"> 僅顯示照片</label>
|
||||
</div>
|
||||
@@ -62,6 +65,13 @@
|
||||
</div>
|
||||
|
||||
<div class="ms-fm-status">{{ statusText }}<span v-if="actionMsg" class="ms-fm-action-msg">{{ actionMsg }}</span></div>
|
||||
<div class="ms-fm-axis-bar">
|
||||
<span class="ms-fm-axis-item">📋 已註冊 <strong>{{ registeredCount }}</strong> · 未註冊 <strong>{{ unregisteredCount }}</strong></span>
|
||||
<span class="ms-fm-axis-sep">|</span>
|
||||
<span class="ms-fm-axis-item">⚙️ 待處理 <strong>{{ pendingCount }}</strong> · 處理中 <strong>{{ processingCount }}</strong> · 已處理 <strong>{{ completedCount }}</strong></span>
|
||||
<span class="ms-fm-axis-sep">|</span>
|
||||
<span class="ms-fm-axis-item">🗄️ 已入庫 <strong>{{ ingestedCount }}</strong> · 未入庫 <strong>{{ notIngestedCount }}</strong></span>
|
||||
</div>
|
||||
|
||||
<div class="ms-fm-grid">
|
||||
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="ms-fm-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)" @contextmenu.prevent="openContextMenu($event, f)">
|
||||
@@ -74,9 +84,19 @@
|
||||
<span class="ms-fm-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
||||
</div>
|
||||
<button v-if="isVideo(f)" class="ms-fm-play-btn" @click.stop="playVideo(f)">▶</button>
|
||||
<div class="ms-fm-check" v-if="f.isRegistered">✓</div>
|
||||
<div class="ms-fm-status-badge" v-if="f.status === 'completed'" style="background:#1e8e3e;color:#fff;">完成</div>
|
||||
<div class="ms-fm-status-badge" v-else-if="f.status === 'registered'" style="background:#f9ab00;color:#202124;">待處理</div>
|
||||
<div class="ms-fm-badges">
|
||||
<div class="ms-fm-badge-row" :class="f.isRegistered ? 'ms-badge-ok' : 'ms-badge-off'">📋 {{ f.isRegistered ? '已註冊' : '未註冊' }}</div>
|
||||
<div class="ms-fm-badge-row" :class="statusBadgeClass(f)">{{ statusBadgeIcon(f) }} {{ statusBadgeLabel(f) }}</div>
|
||||
<div class="ms-fm-badge-row" :class="ingestedBadgeClass(f)">{{ ingestedBadgeIcon(f) }} {{ ingestedBadgeLabel(f) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getPipelineStats(f.file_uuid) && getPipelineStats(f.file_uuid).overall_progress < 1.0" class="ms-fm-progress">
|
||||
<div class="ms-fm-progress-bar" :style="{ width: (getPipelineStats(f.file_uuid).overall_progress * 100) + '%' }"></div>
|
||||
<div class="ms-fm-progress-text">{{ Math.round(getPipelineStats(f.file_uuid).overall_progress * 100) }}%</div>
|
||||
</div>
|
||||
<div v-else-if="getProgress(f.file_uuid) && getProgress(f.file_uuid).overall_progress < 100" class="ms-fm-progress">
|
||||
<div class="ms-fm-progress-bar" :style="{ width: getProgress(f.file_uuid)?.overall_progress + '%' }"></div>
|
||||
<div class="ms-fm-progress-text">{{ getProgress(f.file_uuid)?.overall_progress || 0 }}%</div>
|
||||
</div>
|
||||
<div class="ms-fm-meta">
|
||||
<div class="ms-fm-name">{{ f.file_name }}</div>
|
||||
@@ -92,24 +112,130 @@
|
||||
<div class="ms-ctx-filename">{{ ctxMenu.file?.file_name }}</div>
|
||||
<div class="ms-ctx-info">{{ formatSize(ctxMenu.file?.file_size || 0) }} · {{ formatDate(ctxMenu.file?.modified_time || '') }}</div>
|
||||
<div class="ms-ctx-info">狀態:<span :class="ctxMenu.file?.status === 'completed' ? 'ms-ctx-done' : ctxMenu.file?.status === 'registered' ? 'ms-ctx-pending' : 'ms-ctx-none'">{{ statusLabel(ctxMenu.file) }}</span></div>
|
||||
<div v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.file_uuid" class="ms-ctx-info">UUID:{{ ctxMenu.file.file_uuid?.slice(0, 12) }}...</div>
|
||||
<div v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.file_uuid" class="ms-ctx-info">UUID:{{ ctxMenu.file.file_uuid }}</div>
|
||||
</div>
|
||||
<hr class="ms-ctx-divider">
|
||||
<button v-if="!ctxMenu.file?.isRegistered" class="ms-ctx-item" @click="ctxRegister(ctxMenu.file)">📥 註冊此檔案</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.status !== 'completed'" class="ms-ctx-item" @click="ctxProcess(ctxMenu.file)">⚙️ 處理此檔案</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.status === 'completed'" class="ms-ctx-item" @click="ctxProcess(ctxMenu.file)">⚙️ 重新處理</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && !ctxMenu.file?.ingested" class="ms-ctx-item" @click="ctxIngest(ctxMenu.file)">📦 觸發入庫</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered && ctxMenu.file?.ingested" class="ms-ctx-item" @click="ctxCheckout(ctxMenu.file)">📤 出庫</button>
|
||||
<button v-if="ctxMenu.file?.isRegistered" class="ms-ctx-item ms-ctx-danger" @click="ctxUnregister(ctxMenu.file)">🗑️ 移除註冊</button>
|
||||
<button v-if="isVideo(ctxMenu.file) && ctxMenu.file?.isRegistered" class="ms-ctx-item" @click="playVideo(ctxMenu.file); closeContextMenu()">▶ 播放影片</button>
|
||||
<template v-if="ctxMenu.file?.isRegistered">
|
||||
<hr class="ms-ctx-divider">
|
||||
<div class="ms-ctx-procs">
|
||||
<div class="ms-ctx-procs-title">處理器選擇</div>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procAsr"> ASR</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procYolo"> YOLO</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procFace"> Face</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procOcr"> OCR</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procPose"> Pose</label>
|
||||
<label class="ms-fm-check-label"><input type="checkbox" v-model="procCut"> CUT</label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('asr')"><input type="checkbox" v-model="procAsr"> ASR <span class="ms-proc-count">{{ procCountLabel('asr', 'segment') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('asrx')"><input type="checkbox" v-model="procAsrx"> ASRX <span class="ms-proc-count">{{ procCountLabel('asrx', 'segment') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('face')"><input type="checkbox" v-model="procFace"> Face <span class="ms-proc-count">{{ procCountLabel('face', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('ocr')"><input type="checkbox" v-model="procOcr"> OCR <span class="ms-proc-count">{{ procCountLabel('ocr', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('pose')"><input type="checkbox" v-model="procPose"> Pose <span class="ms-proc-count">{{ procCountLabel('pose', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('appearance')"><input type="checkbox" v-model="procAppearance"> Appearance <span class="ms-proc-count">{{ procCountLabel('appearance', 'frame') }}</span></label>
|
||||
<label class="ms-fm-check-label" :class="procStatusClass('cut')"><input type="checkbox" v-model="procCut"> CUT <span class="ms-proc-count">{{ procCountLabel('cut', 'frame') }}</span></label>
|
||||
</div>
|
||||
<div v-if="ctxMenu.file?.file_uuid" class="ms-ctx-progress">
|
||||
<div v-if="statsLoading" class="ms-ctx-loading">
|
||||
<div class="ms-ctx-spinner"></div>
|
||||
<span class="ms-ctx-loading-text">載入中...</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="ms-ctx-overall-progress" v-if="getPipelineStats(ctxMenu.file.file_uuid)">
|
||||
<div class="ms-ctx-progress-title">整體進度</div>
|
||||
<div class="ms-ctx-progress-bar-wrap">
|
||||
<div class="ms-ctx-progress-bar" :style="{ width: formatPipelineProgress(getPipelineStats(ctxMenu.file.file_uuid).overall_progress) }"></div>
|
||||
<span class="ms-ctx-progress-text">{{ formatPipelineProgress(getPipelineStats(ctxMenu.file.file_uuid).overall_progress) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getPipelineStats(ctxMenu.file.file_uuid)" class="ms-ctx-stages">
|
||||
<div v-for="stage in getPipelineStats(ctxMenu.file.file_uuid).stages" :key="stage.name" class="ms-ctx-stage">
|
||||
<div class="ms-ctx-stage-header">
|
||||
<span class="ms-ctx-stage-icon" :style="{ color: getStageColor(stage.status) }">{{ getStageIcon(stage.status) }}</span>
|
||||
<span class="ms-ctx-stage-name">{{ formatStageName(stage.name) }}</span>
|
||||
<span class="ms-ctx-stage-weight">{{ Math.round(stage.weight * 100) }}%</span>
|
||||
<span class="ms-ctx-stage-progress">{{ formatPipelineProgress(stage.progress) }}</span>
|
||||
</div>
|
||||
<div class="ms-ctx-stage-bar-wrap">
|
||||
<div class="ms-ctx-stage-bar" :style="{ width: formatPipelineProgress(stage.progress), backgroundColor: getStageColor(stage.status) }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="getFileStats(ctxMenu.file.file_uuid)" class="ms-ctx-details">
|
||||
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).json?.processors">
|
||||
<div class="ms-ctx-phase-title">Rule1: 處理器輸出</div>
|
||||
<div v-for="(info, proc) in getFileStats(ctxMenu.file.file_uuid).json.processors" :key="proc" class="ms-ctx-proc-item">
|
||||
<span class="ms-ctx-proc-name">{{ proc.toUpperCase() }}</span>
|
||||
<span class="ms-ctx-proc-count">{{ formatProcCount(proc, info) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).postgresql">
|
||||
<div class="ms-ctx-phase-title">Rule1: PostgreSQL 入庫</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">Sentence Chunks</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).postgresql.sentence_chunks }}</span>
|
||||
</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">Relationship Chunks</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).postgresql.relationship_chunks }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).qdrant">
|
||||
<div class="ms-ctx-phase-title">Rule1: Qdrant 入庫</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">Text Chunks</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).qdrant.text_chunks }}</span>
|
||||
</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">Face Points</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).qdrant.faces }}</span>
|
||||
</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">Speakers</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).qdrant.speakers }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).tkg?.nodes">
|
||||
<div class="ms-ctx-phase-title">Rule2: TKG Nodes</div>
|
||||
<div v-for="(count, type) in getFileStats(ctxMenu.file.file_uuid).tkg.nodes" :key="type" class="ms-ctx-proc-item">
|
||||
<span class="ms-ctx-proc-name">{{ formatNodeName(type) }}</span>
|
||||
<span class="ms-ctx-proc-count">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).tkg?.edges">
|
||||
<div class="ms-ctx-phase-title">Rule2: TKG Edges</div>
|
||||
<div v-for="(count, type) in getFileStats(ctxMenu.file.file_uuid).tkg.edges" :key="type" class="ms-ctx-proc-item">
|
||||
<span class="ms-ctx-proc-name">{{ formatEdgeName(type) }}</span>
|
||||
<span class="ms-ctx-proc-count">{{ count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ms-ctx-phase" v-if="getFileStats(ctxMenu.file.file_uuid).identity_agent">
|
||||
<div class="ms-ctx-phase-title">Identity Agent</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">Identities Created</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).identity_agent.identities_created }}</span>
|
||||
</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">TMDB Matches</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).identity_agent.tmdb_matches }}</span>
|
||||
</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">Speaker Bindings</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).identity_agent.speaker_bindings }}</span>
|
||||
</div>
|
||||
<div class="ms-ctx-stat-item">
|
||||
<span class="ms-ctx-proc-name">Confirmations</span>
|
||||
<span class="ms-ctx-proc-count">{{ getFileStats(ctxMenu.file.file_uuid).identity_agent.confirmations }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -121,7 +247,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { apiCall } from '@/api'
|
||||
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles } from '@/store'
|
||||
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles, processorCountsCache, loadProcessorCounts, getProcessorCounts, ProcessorOutputInfo, pollProgress, stopPolling, getProgress, getFileStatus, refreshFileStatus, refreshAllFilesStatus, fileProgressCache, fileStatusCache, pollingProgressSet, getFileProcessingStatus, ProcessingStatus, ProcessingLevel, loadPipelineStats, getPipelineStats, PipelineStats, loadFileStats, getFileStats, FileStats, pollPipelineProgress, startStatsAutoRefresh, stopStatsAutoRefresh, JsonProcessorInfo, pipelineStatsCache } from '@/store'
|
||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||
|
||||
const files = computed(() => filesCache.value)
|
||||
@@ -142,6 +268,8 @@ const filterUnregistered = ref(false)
|
||||
const filterRegistered = ref(true)
|
||||
const filterPending = ref(false)
|
||||
const filterCompleted = ref(false)
|
||||
const filterNotIngested = ref(false)
|
||||
const filterIngested = ref(false)
|
||||
const onlyVideos = ref(false)
|
||||
const onlyPhotos = ref(false)
|
||||
const sizeMin = ref<number | null>(null)
|
||||
@@ -166,8 +294,10 @@ const sortedFilteredFiles = computed(() => {
|
||||
// Checkbox filters
|
||||
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.isRegistered)
|
||||
else if (filterRegistered.value && !filterUnregistered.value) result = result.filter((f: any) => f.isRegistered)
|
||||
if (filterPending.value) result = result.filter((f: any) => f.status === 'registered')
|
||||
if (filterPending.value) result = result.filter((f: any) => f.status === 'registered' || f.status === 'pending')
|
||||
if (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed')
|
||||
if (filterNotIngested.value && !filterIngested.value) result = result.filter((f: any) => f.isRegistered && !f.ingested)
|
||||
else if (filterIngested.value && !filterNotIngested.value) result = result.filter((f: any) => f.ingested)
|
||||
|
||||
if (onlyVideos.value) result = result.filter(isVideo)
|
||||
if (onlyPhotos.value) result = result.filter(isPhoto)
|
||||
@@ -203,8 +333,16 @@ const sortedFilteredFiles = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose) })
|
||||
onUnmounted(() => { document.removeEventListener('click', docClickClose) })
|
||||
const registeredCount = computed(() => files.value.filter((f: any) => f.isRegistered).length)
|
||||
const unregisteredCount = computed(() => files.value.filter((f: any) => !f.isRegistered).length)
|
||||
const pendingCount = computed(() => files.value.filter((f: any) => f.status === 'pending' || f.status === 'registered').length)
|
||||
const processingCount = computed(() => files.value.filter((f: any) => f.status === 'processing').length)
|
||||
const completedCount = computed(() => files.value.filter((f: any) => f.status === 'completed').length)
|
||||
const ingestedCount = computed(() => files.value.filter((f: any) => f.ingested).length)
|
||||
const notIngestedCount = computed(() => files.value.filter((f: any) => f.isRegistered && !f.ingested).length)
|
||||
|
||||
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose); startStatsAutoRefresh() })
|
||||
onUnmounted(() => { document.removeEventListener('click', docClickClose); stopStatsAutoRefresh() })
|
||||
|
||||
function docClickClose(e: MouseEvent) {
|
||||
if (e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
|
||||
@@ -215,10 +353,38 @@ async function loadFiles() {
|
||||
statusText.value = 'Loading...'
|
||||
invalidateFiles()
|
||||
await ensureFiles()
|
||||
statusText.value = `${files.value.length} files`
|
||||
statusText.value = `${files.value.length} 個檔案`
|
||||
for (const f of files.value.slice(0, 6)) {
|
||||
if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid)
|
||||
}
|
||||
const registeredFiles = files.value.filter((f: any) => f.isRegistered && f.file_uuid)
|
||||
for (const f of registeredFiles) {
|
||||
try {
|
||||
// 强制重新加载,忽略缓存
|
||||
const pipeline = await loadPipelineStats(f.file_uuid, true)
|
||||
console.log('[loadFiles] pipeline result', f.file_uuid, pipeline)
|
||||
|
||||
if (pipeline && pipeline.overall_progress < 1.0 && pipeline.stages?.some((s: any) => s.status === 'running')) {
|
||||
f.status = 'processing'
|
||||
fileStatusCache.value[f.file_uuid] = 'processing'
|
||||
pollPipelineProgress(f.file_uuid)
|
||||
} else if (pipeline && pipeline.overall_progress >= 1.0) {
|
||||
f.status = 'completed'
|
||||
fileStatusCache.value[f.file_uuid] = 'completed'
|
||||
}
|
||||
|
||||
// 同时加载文件统计(包含降级逻辑)
|
||||
await loadFileStats(f.file_uuid, true)
|
||||
} catch (e) {
|
||||
console.error('check progress failed:', f.file_uuid, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAllStatus() {
|
||||
statusText.value = 'Refreshing status...'
|
||||
await refreshAllFilesStatus()
|
||||
statusText.value = `${files.value.length} 個檔案`
|
||||
}
|
||||
|
||||
function isVideo(f: any) {
|
||||
@@ -265,46 +431,195 @@ function playVideo(f: any) {
|
||||
|
||||
const registering = ref(false)
|
||||
const processing = ref(false)
|
||||
const ingesting = ref(false)
|
||||
const checkouting = ref(false)
|
||||
const unregistering = ref(false)
|
||||
const actionMsg = ref('')
|
||||
const procAsr = ref(true)
|
||||
const procYolo = ref(true)
|
||||
const procAsrx = ref(true)
|
||||
const procFace = ref(true)
|
||||
const procOcr = ref(false)
|
||||
const procPose = ref(false)
|
||||
const procOcr = ref(true)
|
||||
const procPose = ref(true)
|
||||
const procAppearance = ref(true)
|
||||
const procCut = ref(true)
|
||||
|
||||
const ctxMenu = ref<{ show: boolean; x: number; y: number; file: any }>({ show: false, x: 0, y: 0, file: null })
|
||||
const statsLoading = ref(false)
|
||||
|
||||
function formatProcCount(proc: string, info: JsonProcessorInfo): string {
|
||||
if (info.segment_count != null) return `${info.segment_count} segments`
|
||||
if (info.frame_count != null) return `${info.frame_count} frames`
|
||||
if (info.chunk_count != null) return `${info.chunk_count} chunks`
|
||||
return info.status
|
||||
}
|
||||
|
||||
function formatNodeName(type: string): string {
|
||||
const names: Record<string, string> = {
|
||||
character: 'Character', object: 'Object', scene: 'Scene',
|
||||
action: 'Action', location: 'Location', event: 'Event',
|
||||
dialogue: 'Dialogue', emotion: 'Emotion', relation: 'Relation'
|
||||
}
|
||||
return names[type] || type
|
||||
}
|
||||
|
||||
function formatEdgeName(type: string): string {
|
||||
const names: Record<string, string> = {
|
||||
appear_in: 'Appear_In', interact_with: 'Interact_With',
|
||||
located_at: 'Located_At', perform_action: 'Perform_Action',
|
||||
cause_effect: 'Cause_Effect', temporal_link: 'Temporal_Link',
|
||||
speak_to: 'Speak_To', feel: 'Feel'
|
||||
}
|
||||
return names[type] || type
|
||||
}
|
||||
|
||||
function formatPipelineProgress(progress: number): string {
|
||||
return `${Math.round(progress * 100)}%`
|
||||
}
|
||||
|
||||
function getStageIcon(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed': return '✓'
|
||||
case 'running': return '⟳'
|
||||
case 'failed': return '✗'
|
||||
default: return '○'
|
||||
}
|
||||
}
|
||||
|
||||
function formatStageName(name: string): string {
|
||||
const names: Record<string, string> = {
|
||||
processors: '處理器',
|
||||
rule1_ingestion: 'Rule1 入庫',
|
||||
face_tracing: 'Face Tracing',
|
||||
tkg_nodes: 'TKG Nodes',
|
||||
tkg_edges: 'TKG Edges',
|
||||
rule2_ingestion: 'Rule2 入庫'
|
||||
}
|
||||
return names[name] || name
|
||||
}
|
||||
|
||||
function getStageColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed': return '#34a853'
|
||||
case 'running': return '#4285f4'
|
||||
case 'failed': return '#ea4335'
|
||||
default: return '#dadce0'
|
||||
}
|
||||
}
|
||||
|
||||
function selectedProcessors(): string[] {
|
||||
const procs: string[] = []
|
||||
if (procAsr.value) procs.push('asr')
|
||||
if (procYolo.value) procs.push('yolo')
|
||||
if (procAsrx.value) procs.push('asrx')
|
||||
if (procFace.value) procs.push('face')
|
||||
if (procOcr.value) procs.push('ocr')
|
||||
if (procPose.value) procs.push('pose')
|
||||
if (procAppearance.value) procs.push('appearance')
|
||||
if (procCut.value) procs.push('cut')
|
||||
return procs
|
||||
}
|
||||
|
||||
function procStatusClass(proc: string): string {
|
||||
const fileUuid = ctxMenu.value.file?.file_uuid
|
||||
if (!fileUuid) return ''
|
||||
const info = getProcessorCounts(fileUuid)[proc]
|
||||
if (info?.has_json) return 'ms-proc-done'
|
||||
return ''
|
||||
}
|
||||
|
||||
function procCountLabel(proc: string, type: 'frame' | 'segment' | 'chunk'): string {
|
||||
const fileUuid = ctxMenu.value.file?.file_uuid
|
||||
if (!fileUuid) return ''
|
||||
const info = getProcessorCounts(fileUuid)[proc]
|
||||
if (!info?.has_json) return ''
|
||||
if (type === 'frame' && info.frame_count != null) return `(${info.frame_count})`
|
||||
if (type === 'segment' && info.segment_count != null) return `(${info.segment_count})`
|
||||
if (type === 'chunk' && info.chunk_count != null) return `(${info.chunk_count})`
|
||||
return '(✓)'
|
||||
}
|
||||
|
||||
function statusLabel(f: any): string {
|
||||
if (!f) return ''
|
||||
// 优先使用缓存的状态
|
||||
const cachedStatus = getFileStatus(f.file_uuid)
|
||||
if (cachedStatus === 'completed') return '已完成'
|
||||
if (cachedStatus === 'processing') return '處理中'
|
||||
|
||||
// 否则使用文件对象的状态
|
||||
if (f.status === 'completed') return '已完成'
|
||||
if (f.status === 'registered') return '已註冊・待處理'
|
||||
if (f.status === 'processing') return '處理中'
|
||||
if (f.status === 'failed') return '處理失敗'
|
||||
if (f.status === 'pending') return '待處理'
|
||||
if (f.status === 'registered') return '待處理'
|
||||
if (f.isRegistered) return '已註冊'
|
||||
return '未註冊'
|
||||
}
|
||||
|
||||
function openContextMenu(e: MouseEvent, f: any) {
|
||||
function statusBadgeClass(f: any): string {
|
||||
if (f.status === 'processing') return 'ms-badge-proc'
|
||||
if (f.status === 'completed') return 'ms-badge-done'
|
||||
if (f.status === 'failed') return 'ms-badge-fail'
|
||||
if (f.status === 'pending' || f.status === 'registered') return 'ms-badge-warn'
|
||||
return 'ms-badge-off'
|
||||
}
|
||||
|
||||
function statusBadgeIcon(f: any): string {
|
||||
if (f.status === 'processing') return '⏳'
|
||||
if (f.status === 'completed') return '✅'
|
||||
if (f.status === 'failed') return '❌'
|
||||
if (f.status === 'pending' || f.status === 'registered') return '⏸️'
|
||||
return '⏺️'
|
||||
}
|
||||
|
||||
function statusBadgeLabel(f: any): string {
|
||||
if (!f) return ''
|
||||
if (f.status === 'completed') return '已處理'
|
||||
if (f.status === 'processing') return '處理中'
|
||||
if (f.status === 'failed') return '處理失敗'
|
||||
if (f.status === 'pending' || f.status === 'registered') return '待處理'
|
||||
return '—'
|
||||
}
|
||||
|
||||
function ingestedBadgeClass(f: any): string {
|
||||
if (f.ingested) return 'ms-badge-done'
|
||||
if (f.isRegistered) return 'ms-badge-warn'
|
||||
return 'ms-badge-off'
|
||||
}
|
||||
|
||||
function ingestedBadgeIcon(f: any): string {
|
||||
if (f.ingested) return '🗄️'
|
||||
if (f.isRegistered) return '📦'
|
||||
return '📦'
|
||||
}
|
||||
|
||||
function ingestedBadgeLabel(f: any): string {
|
||||
if (f.ingested) return '已入庫'
|
||||
if (f.isRegistered) return '未入庫'
|
||||
return '—'
|
||||
}
|
||||
|
||||
async function openContextMenu(e: MouseEvent, f: any) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
ctxMenu.value = {
|
||||
show: true,
|
||||
x: Math.min(e.clientX, window.innerWidth - 280),
|
||||
y: Math.min(e.clientY, window.innerHeight - 320),
|
||||
x: Math.min(e.clientX, window.innerWidth - 360),
|
||||
y: Math.min(e.clientY, window.innerHeight * 0.8),
|
||||
file: f
|
||||
}
|
||||
if (f.isRegistered && f.file_uuid) {
|
||||
statsLoading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
loadPipelineStats(f.file_uuid, true),
|
||||
loadFileStats(f.file_uuid, true),
|
||||
loadProcessorCounts(f.file_uuid, true)
|
||||
])
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats:', e)
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
@@ -314,8 +629,6 @@ function closeContextMenu() {
|
||||
async function ctxRegister(f: any) {
|
||||
closeContextMenu()
|
||||
if (registering.value) return
|
||||
const procs = selectedProcessors()
|
||||
if (!procs.length) { actionMsg.value = '請至少選擇一個處理器'; return }
|
||||
registering.value = true
|
||||
actionMsg.value = '註冊中...'
|
||||
const filePath = f.file_path || f.relative_path || f.file_name
|
||||
@@ -324,12 +637,9 @@ async function ctxRegister(f: any) {
|
||||
const result: any = await apiCall('register_file', { filePath })
|
||||
if (result.success) {
|
||||
actionMsg.value = `註冊成功:${f.file_name}`
|
||||
if (result.file_uuid) {
|
||||
try {
|
||||
const pResult: any = await apiCall('process_file', { fileUuid: result.file_uuid, processors: procs })
|
||||
if (pResult.success) actionMsg.value += ' → 處理已觸發'
|
||||
} catch (e: any) { console.error('[process]', e) }
|
||||
}
|
||||
f.isRegistered = true
|
||||
f.file_uuid = result.file_uuid
|
||||
f.status = 'registered'
|
||||
} else {
|
||||
actionMsg.value = `註冊失敗:${result.message}`
|
||||
}
|
||||
@@ -354,6 +664,9 @@ async function ctxProcess(f: any) {
|
||||
const result: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
|
||||
if (result.success) {
|
||||
actionMsg.value = `處理已觸發:${f.file_name}`
|
||||
f.status = 'processing'
|
||||
fileStatusCache.value[f.file_uuid] = 'processing'
|
||||
pollPipelineProgress(f.file_uuid)
|
||||
} else {
|
||||
actionMsg.value = `處理失敗:${result.message}`
|
||||
}
|
||||
@@ -362,8 +675,6 @@ async function ctxProcess(f: any) {
|
||||
console.error('[process]', e)
|
||||
}
|
||||
processing.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxUnregister(f: any) {
|
||||
@@ -388,12 +699,58 @@ async function ctxUnregister(f: any) {
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxIngest(f: any) {
|
||||
closeContextMenu()
|
||||
if (ingesting.value) return
|
||||
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||
ingesting.value = true
|
||||
actionMsg.value = '入庫中...'
|
||||
try {
|
||||
const result: any = await apiCall('ingest_file', { fileUuid: f.file_uuid })
|
||||
if (result.success) {
|
||||
actionMsg.value = `入庫成功:${f.file_name}`
|
||||
} else {
|
||||
actionMsg.value = `入庫失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `入庫錯誤:${e}`
|
||||
console.error('[ingest]', e)
|
||||
}
|
||||
ingesting.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
async function ctxCheckout(f: any) {
|
||||
closeContextMenu()
|
||||
if (checkouting.value) return
|
||||
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||
checkouting.value = true
|
||||
actionMsg.value = '出庫中...'
|
||||
try {
|
||||
const result: any = await apiCall('checkout_file', { fileUuid: f.file_uuid })
|
||||
if (result.success) {
|
||||
actionMsg.value = `出庫成功:${f.file_name}`
|
||||
} else {
|
||||
actionMsg.value = `出庫失敗:${result.message}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
actionMsg.value = `出庫錯誤:${e}`
|
||||
console.error('[checkout]', e)
|
||||
}
|
||||
checkouting.value = false
|
||||
await loadFiles()
|
||||
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
sortBy.value = 'time_desc'
|
||||
filterUnregistered.value = false
|
||||
filterRegistered.value = false
|
||||
filterPending.value = false
|
||||
filterCompleted.value = false
|
||||
filterNotIngested.value = false
|
||||
filterIngested.value = false
|
||||
onlyVideos.value = false
|
||||
onlyPhotos.value = false
|
||||
sizeMin.value = null
|
||||
@@ -425,7 +782,9 @@ function resetFilters() {
|
||||
.ms-fm-action-msg { margin-left: 12px; color: #1e8e3e; font-weight: 600; }
|
||||
.ms-fm-btn-danger { background: #d93025; color: #fff; border-color: #d93025; }
|
||||
.ms-fm-btn-danger:hover:not(:disabled) { background: #c5221f; }
|
||||
.ms-ctx-menu { position: fixed !important; z-index: 99999 !important; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 220px; max-width: 320px; font-size: 13px; color: #222; }
|
||||
.ms-ctx-menu { position: fixed !important; z-index: 99999 !important; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 280px; max-width: 360px; max-height: 80vh; overflow-y: auto; font-size: 13px; color: #222; }
|
||||
.ms-ctx-menu::-webkit-scrollbar { width: 4px; }
|
||||
.ms-ctx-menu::-webkit-scrollbar-thumb { background: #dadce0; border-radius: 2px; }
|
||||
.ms-ctx-header { padding: 8px 10px 4px; }
|
||||
.ms-ctx-filename { font-weight: 600; font-size: 13px; color: #202124; word-break: break-all; }
|
||||
.ms-ctx-info { font-size: 11px; color: #7a7f87; margin-top: 2px; }
|
||||
@@ -439,6 +798,20 @@ function resetFilters() {
|
||||
.ms-ctx-danger:hover { background: #fce8e6 !important; }
|
||||
.ms-ctx-procs { padding: 6px 10px 8px; display: flex; flex-wrap: wrap; gap: 6px 10px; }
|
||||
.ms-ctx-procs-title { width: 100%; font-size: 11px; color: #7a7f87; font-weight: 600; margin-bottom: 2px; }
|
||||
.ms-ctx-progress { padding: 8px 12px; background: #f8f9fa; border-radius: 8px; margin-top: 8px; }
|
||||
.ms-ctx-progress-title { font-size: 11px; color: #5f6368; font-weight: 600; margin-bottom: 6px; }
|
||||
.ms-ctx-progress-bar-wrap { position: relative; height: 20px; background: #e8eaed; border-radius: 4px; overflow: hidden; }
|
||||
.ms-ctx-progress-bar { height: 100%; background: linear-gradient(90deg, #1a73e8, #4285f4); border-radius: 4px; transition: width 0.3s; }
|
||||
.ms-ctx-progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: #202124; }
|
||||
.ms-ctx-proc-status { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 11px; }
|
||||
.ms-ctx-proc-name { font-weight: 600; color: #1a73e8; }
|
||||
.ms-ctx-proc-detail { color: #5f6368; }
|
||||
.ms-proc-done { color: #059669; }
|
||||
.ms-proc-done .ms-proc-count { color: #059669; font-weight: 600; }
|
||||
.ms-proc-count { margin-left: 4px; font-size: 11px; color: #8a919c; }
|
||||
.ms-fm-progress { position: relative; height: 20px; background: #e8eaed; border-radius: 4px; margin-top: 4px; overflow: hidden; }
|
||||
.ms-fm-progress-bar { height: 100%; background: linear-gradient(90deg, #1a73e8, #4285f4); border-radius: 4px; transition: width 0.3s; }
|
||||
.ms-fm-progress-text { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 11px; font-weight: 600; color: #202124; }
|
||||
.ms-fm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
||||
.ms-fm-card { border-radius: 14px; cursor: pointer; position: relative; }
|
||||
.ms-fm-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: #eef2f7; aspect-ratio: 4 / 3; border: 1px solid #e6ebf1; box-shadow: 0 0 0 2px #d9dde3 inset; }
|
||||
@@ -457,4 +830,50 @@ function resetFilters() {
|
||||
.ms-fm-name { font-size: 12px; color: #42474f; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.ms-fm-play-btn { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 42px; height: 42px; border-radius: 50%; border: none; background: rgba(0,0,0,.58); color: #fff; font-size: 18px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 8; }
|
||||
.ms-fm-edit-input { width: 100%; height: 32px; border: 1px solid #e1e5ea; border-radius: 10px; padding: 0 9px; font-size: 12px; color: #42474f; background: #fff; outline: none; font-family: inherit; }
|
||||
|
||||
/* Three-axis badges */
|
||||
.ms-fm-badges { position: absolute; bottom: 4px; right: 4px; display: flex; flex-direction: column; gap: 2px; z-index: 9; }
|
||||
.ms-fm-badge-row { font-size: 9px; line-height: 1.2; padding: 1px 5px; border-radius: 3px; font-weight: 600; letter-spacing: 0.2px; white-space: nowrap; }
|
||||
.ms-badge-ok { background: #1a73e8; color: #fff; }
|
||||
.ms-badge-off { background: rgba(0,0,0,.48); color: #ccc; }
|
||||
.ms-badge-proc { background: #1a73e8; color: #fff; animation: badge-pulse 1.2s ease-in-out infinite; }
|
||||
.ms-badge-done { background: #1e8e3e; color: #fff; }
|
||||
.ms-badge-warn { background: #f9ab00; color: #202124; }
|
||||
.ms-badge-fail { background: #d93025; color: #fff; }
|
||||
@keyframes badge-pulse { 0%,100% { opacity: 1; } 50% { opacity: .6; } }
|
||||
|
||||
/* Three-axis status bar */
|
||||
.ms-fm-axis-bar { font-size: 12px; color: #5f6368; margin: 2px 0 12px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.ms-fm-axis-item strong { color: #202124; }
|
||||
.ms-fm-axis-sep { color: #dadce0; }
|
||||
|
||||
/* 加载状态 */
|
||||
.ms-ctx-loading { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 32px 0; }
|
||||
.ms-ctx-spinner { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #4285f4; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
.ms-ctx-loading-text { font-size: 13px; color: #5f6368; }
|
||||
|
||||
/* 进度条和阶段样式 */
|
||||
.ms-ctx-overall-progress { margin-bottom: 16px; }
|
||||
.ms-ctx-progress-bar-wrap { position: relative; height: 20px; background: #e8eaed; border-radius: 10px; overflow: hidden; }
|
||||
.ms-ctx-progress-bar { height: 100%; background: linear-gradient(90deg, #4285f4, #34a853); transition: width 0.3s ease; }
|
||||
.ms-ctx-progress-text { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 12px; font-weight: 600; color: #333; }
|
||||
|
||||
.ms-ctx-stages { margin-bottom: 16px; }
|
||||
.ms-ctx-stage { margin-bottom: 12px; }
|
||||
.ms-ctx-stage-header { display: flex; align-items: center; gap: 6px; font-size: 13px; margin-bottom: 4px; }
|
||||
.ms-ctx-stage-icon { width: 16px; text-align: center; font-size: 12px; font-weight: 600; }
|
||||
.ms-ctx-stage-name { flex: 1; font-weight: 500; color: #333; }
|
||||
.ms-ctx-stage-weight { color: #5f6368; font-size: 12px; }
|
||||
.ms-ctx-stage-progress { color: #5f6368; font-size: 12px; font-weight: 600; }
|
||||
.ms-ctx-stage-bar-wrap { height: 6px; background: #e8eaed; border-radius: 3px; overflow: hidden; }
|
||||
.ms-ctx-stage-bar { height: 100%; transition: width 0.3s ease; }
|
||||
|
||||
/* 详细数据样式 */
|
||||
.ms-ctx-details { border-top: 1px solid #eee; padding-top: 12px; }
|
||||
.ms-ctx-phase { margin-bottom: 16px; }
|
||||
.ms-ctx-phase:last-child { margin-bottom: 0; }
|
||||
.ms-ctx-phase-title { font-size: 11px; color: #5f6368; font-weight: 600; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.ms-ctx-proc-item, .ms-ctx-stat-item { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
|
||||
.ms-ctx-proc-name, .ms-ctx-stat-name { color: #333; }
|
||||
.ms-ctx-proc-count, .ms-ctx-stat-count { color: #666; font-weight: 500; }
|
||||
</style>
|
||||
|
||||
182
src/views/LoginView.vue
Normal file
182
src/views/LoginView.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h2>Momentry Studio</h2>
|
||||
<p>Login to continue</p>
|
||||
</div>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label>Username</label>
|
||||
<input v-model="username" type="text" required placeholder="Enter username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Password</label>
|
||||
<input v-model="password" type="password" required placeholder="Enter password" />
|
||||
</div>
|
||||
<button type="submit" :disabled="loading" class="login-btn">
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="success-message">Login successful!</div>
|
||||
</form>
|
||||
<div class="login-footer">
|
||||
<p>Default credentials: momentry / demo123</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = false
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:11438/api/v2/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok && data.token) {
|
||||
success.value = true
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('username', username.value)
|
||||
localStorage.setItem('expires_at', data.expires_at)
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/search')
|
||||
}, 1000)
|
||||
} else {
|
||||
error.value = data.error || 'Login failed'
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = 'Network error: ' + e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
margin: 10px 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.login-btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #e74c3c;
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #27ae60;
|
||||
margin-top: 15px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,10 @@
|
||||
<template>
|
||||
<div class="people-view">
|
||||
<div class="ms-ppl-toolbar">
|
||||
<select v-model="selectedFileUuid" @change="onFileFilterChange" class="ms-ppl-file-select">
|
||||
<option value="">全部檔案</option>
|
||||
<option v-for="f in fileFilterList" :key="f.file_uuid" :value="f.file_uuid">{{ f.file_name }}</option>
|
||||
</select>
|
||||
<button class="ms-fm-btn ms-ppl-star-toggle-btn" @click="starFilter = !starFilter">
|
||||
<span class="ms-ppl-star-icon" :class="{ starred: starFilter }">★</span>
|
||||
<span>{{ starFilter ? '查看所有人物' : '查看重要人物' }}</span>
|
||||
@@ -22,7 +26,7 @@
|
||||
<div class="spinner-lg"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0" class="empty">
|
||||
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0 && unassignedTraces.length === 0" class="empty">
|
||||
No people found.
|
||||
</div>
|
||||
<template v-else>
|
||||
@@ -170,18 +174,18 @@
|
||||
|
||||
<hr v-if="(showSkipped && skippedPeople.length) || (showPending && pendingPeople.length) || confirmedPeople.length" class="ms-ppl-hr">
|
||||
|
||||
<!-- 待定人臉 -->
|
||||
<div v-if="showUface && faceCandidates.length" class="ms-ppl-section">
|
||||
<!-- 待定人臉 -->
|
||||
<div v-if="showUface && unassignedTraces.length" class="ms-ppl-section">
|
||||
<div class="ms-ppl-section-toolbar">
|
||||
<div class="ms-ppl-section-title">待定人臉:</div>
|
||||
<div class="ms-ppl-section-title">待定人臉: <span class="ms-ppl-section-count">({{ unassignedTraces.length }})</span></div>
|
||||
</div>
|
||||
<div class="ms-ppl-face-grid ms-uface-grid">
|
||||
<div v-for="c in faceCandidates.slice(0, 50)" :key="c.id" class="ms-ppl-face-card" @click="openAssignModal(c)" @contextmenu.prevent="showFaceCtxMenu($event, c)" v-observe="() => loadCandidateThumb(c)">
|
||||
<div v-for="t in unassignedTraces.slice(0, 100)" :key="`${t.trace_id}-${t.file_uuid}`" class="ms-ppl-face-card" @click="openTraceAssignModal(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)" v-observe="() => loadTraceThumb(t)">
|
||||
<div class="ms-ppl-face-img-wrap">
|
||||
<img v-if="candidateThumbs[c.id]" :src="candidateThumbs[c.id]" alt="">
|
||||
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
|
||||
<img v-if="faceThumbsCache[`${t.trace_id}-${t.file_uuid}`]" :src="faceThumbsCache[`${t.trace_id}-${t.file_uuid}`]" alt="">
|
||||
<div v-else class="face-placeholder">{{ t.frame_count }}帧</div>
|
||||
</div>
|
||||
<span class="ms-ppl-face-name">{{ (c.file_uuid || '').slice(0, 8) }}... #{{ c.frame_number }}</span>
|
||||
<span class="ms-ppl-face-name">{{ (t.file_uuid || '').slice(0, 8) }}... T{{ String(t.trace_id ?? '').slice(-4) || '?' }} ({{ t.frame_count }}帧)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,6 +195,7 @@
|
||||
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px', display: 'block' }">
|
||||
<button class="ms-ctx-item" @click="ctxAction('star')">{{ ctxMenu.person?.starred ? '☆ 取消重要人物' : '★ 標為重要人物' }}</button>
|
||||
<hr class="ms-ctx-menu-divider">
|
||||
<button class="ms-ctx-item" @click="ctxAction('detail')">📋 詳情</button>
|
||||
<button class="ms-ctx-item" @click="ctxAction('confirm')" v-if="ctxMenu.person?.status !== 'confirmed'">✓ 確認人物</button>
|
||||
<button class="ms-ctx-item" @click="ctxAction('pending')" v-if="ctxMenu.person?.status !== 'pending'">待定</button>
|
||||
<button class="ms-ctx-item" @click="ctxAction('rename')">✎ 編輯名稱</button>
|
||||
@@ -214,6 +219,7 @@
|
||||
|
||||
<!-- Face candidate context menu -->
|
||||
<div v-if="faceCtxMenu.show" class="ms-ctx-menu" :style="{ left: faceCtxMenu.x + 'px', top: faceCtxMenu.y + 'px', display: 'block' }">
|
||||
<button class="ms-ctx-item" @click="faceCtxAction('detail')">📋 詳情</button>
|
||||
<button class="ms-ctx-item" @click="faceCtxAction('assign')">⇄ 指派給現有人物</button>
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="faceCtxAction('skip')">✕ 略過此人臉</button>
|
||||
</div>
|
||||
@@ -256,6 +262,62 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Face detail modal -->
|
||||
<div v-if="faceDetailModal.show" class="ms-modal-overlay show" @click.self="faceDetailModal.show = false">
|
||||
<div class="ms-modal ms-modal-face-detail">
|
||||
<button class="ms-fm-icon-btn close-btn" @click="faceDetailModal.show = false">×</button>
|
||||
<div class="ms-face-detail-header">
|
||||
<div class="ms-face-detail-thumb">
|
||||
<img v-if="candidateThumbs[faceDetailModal.candidate?.id]" :src="candidateThumbs[faceDetailModal.candidate?.id]" alt="">
|
||||
<div v-else class="face-placeholder">{{ Math.round(faceDetailModal.candidate?.confidence * 100 || 0) }}%</div>
|
||||
</div>
|
||||
<div class="ms-face-detail-info">
|
||||
<div class="ms-face-detail-title">待定人臉詳情</div>
|
||||
<div class="ms-face-detail-row">
|
||||
<span class="ms-face-detail-label">檔案:</span>
|
||||
<span class="ms-face-detail-value">{{ faceDetailModal.candidate?.file_uuid?.slice(0, 12) }}...</span>
|
||||
</div>
|
||||
<div class="ms-face-detail-row">
|
||||
<span class="ms-face-detail-label">帧號:</span>
|
||||
<span class="ms-face-detail-value">#{{ faceDetailModal.candidate?.frame_number }}</span>
|
||||
</div>
|
||||
<div class="ms-face-detail-row">
|
||||
<span class="ms-face-detail-label">置信度:</span>
|
||||
<span class="ms-face-detail-value">{{ Math.round(faceDetailModal.candidate?.confidence * 100) }}%</span>
|
||||
</div>
|
||||
<div class="ms-face-detail-row">
|
||||
<span class="ms-face-detail-label">Face ID:</span>
|
||||
<span class="ms-face-detail-value">{{ faceDetailModal.candidate?.face_id || faceDetailModal.candidate?.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-face-detail-video">
|
||||
<button class="ms-fm-btn ms-fm-btn-primary" @click="playFaceDetailVideo" :disabled="!faceDetailModal.candidate?.file_uuid">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="margin-right:6px;">
|
||||
<polygon points="5 3 19 12 5 21 5 3" fill="currentColor"></polygon>
|
||||
</svg>
|
||||
播放影片片段
|
||||
</button>
|
||||
</div>
|
||||
<hr class="ms-face-detail-divider">
|
||||
<div class="ms-face-detail-new-identity">
|
||||
<div class="ms-face-detail-new-title">新增為新人物</div>
|
||||
<div class="ms-face-detail-new-input-row">
|
||||
<input v-model="faceDetailModal.newIdentityName" class="ms-ppl-edit-input" placeholder="輸入人物名稱...">
|
||||
<button class="ms-fm-btn ms-fm-btn-primary" @click="createNewIdentityFromFace" :disabled="!faceDetailModal.newIdentityName.trim()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="margin-right:4px;">
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||
</svg>
|
||||
新增
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ms-face-detail-footer">
|
||||
<button class="ms-fm-btn" @click="faceDetailModal.show = false">關閉</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoPlayer v-if="playing" simple :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -264,7 +326,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { apiCall } from '@/api'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, invalidatePeople } from '@/store'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, ensureUnassignedTraces, unassignedTracesCache, unassignedTracesLoaded, ensureFiles, filesCache, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, loadTraceThumb, invalidatePeople } from '@/store'
|
||||
import { useUndoRedo } from '@/composables/useUndoRedo'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -286,11 +348,30 @@ const mergeTarget = ref('')
|
||||
const candidates = ref<any[]>([])
|
||||
const candidateThumbs = faceThumbsCache
|
||||
const faceCandidates = faceCandidatesCache
|
||||
// File filter
|
||||
const selectedFileUuid = ref('')
|
||||
|
||||
const unassignedTraces = computed(() => {
|
||||
const all = unassignedTracesCache.value
|
||||
if (!selectedFileUuid.value) return all
|
||||
const filtered = all.filter((t: any) => t.file_uuid === selectedFileUuid.value)
|
||||
return filtered
|
||||
})
|
||||
|
||||
const traceCounts = ref<Record<string, number>>({})
|
||||
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
|
||||
const faceCtxMenu = ref({ show: false, x: 0, y: 0, candidate: null as any })
|
||||
const assignModal = ref({ show: false, candidate: null as any })
|
||||
const assignSearchQuery = ref('')
|
||||
const assignSelected = ref<any>(null)
|
||||
const faceDetailModal = ref({ show: false, candidate: null as any, newIdentityName: '' })
|
||||
const fileFilterList = computed(() => {
|
||||
const files = filesCache.value
|
||||
return files.filter((f: any) => f.isRegistered && f.status === 'completed').map((f: any) => ({
|
||||
file_uuid: f.file_uuid?.replace(/-/g, ''),
|
||||
file_name: f.file_name
|
||||
}))
|
||||
})
|
||||
|
||||
// Section visibility toggles
|
||||
const showPending = ref(true)
|
||||
@@ -358,9 +439,17 @@ const confirmedPeople = computed(() => {
|
||||
|
||||
const pendingPeople = computed(() => {
|
||||
let base = people.value.filter((p: any) => p.status === 'pending')
|
||||
if (selectedFileUuid.value) {
|
||||
base = base.filter((p: any) => (p.file_uuids || []).includes(selectedFileUuid.value))
|
||||
}
|
||||
if (starFilter.value) base = base.filter((p: any) => p.starred)
|
||||
if (pendingSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(pendingSearch.value.toLowerCase()))
|
||||
return sortPeople(base, pendingSort.value)
|
||||
const sorted = [...base].sort((a: any, b: any) => {
|
||||
const aCount = traceCounts.value[a.identity_uuid] || 0
|
||||
const bCount = traceCounts.value[b.identity_uuid] || 0
|
||||
return bCount - aCount
|
||||
})
|
||||
return sorted
|
||||
})
|
||||
|
||||
const skippedPeople = computed(() => {
|
||||
@@ -378,16 +467,49 @@ const assignSearchResults = computed(() => {
|
||||
})
|
||||
|
||||
async function refresh() {
|
||||
ensurePeople().then(() => {
|
||||
for (const p of people.value.slice(0, 30)) {
|
||||
if (p.identity_uuid) loadProfile(p.identity_uuid)
|
||||
await ensurePeople()
|
||||
await ensureFiles()
|
||||
for (const p of people.value.slice(0, 30)) {
|
||||
if (p.identity_uuid) loadProfile(p.identity_uuid)
|
||||
}
|
||||
await ensureUnassignedTraces()
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
await ensureFaceCandidates()
|
||||
for (const c of faceCandidates.value.slice(0, 20)) {
|
||||
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
|
||||
}
|
||||
// Load trace counts for pending identities
|
||||
const pending = people.value.filter((p: any) => p.status === 'pending')
|
||||
const counts: Record<string, number> = {}
|
||||
for (const p of pending) {
|
||||
if (p.identity_uuid) {
|
||||
try {
|
||||
const traces: any = await apiCall('get_traces', { uuid: p.identity_uuid, perPage: 1 })
|
||||
counts[p.identity_uuid] = traces?.total || 0
|
||||
} catch (e) {
|
||||
counts[p.identity_uuid] = 0
|
||||
}
|
||||
}
|
||||
})
|
||||
ensureFaceCandidates().then(() => {
|
||||
for (const c of faceCandidates.value.slice(0, 20)) {
|
||||
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
|
||||
}
|
||||
traceCounts.value = counts
|
||||
}
|
||||
|
||||
async function onFileFilterChange() {
|
||||
if (selectedFileUuid.value) {
|
||||
unassignedTracesLoaded.value = false
|
||||
await ensureUnassignedTraces(selectedFileUuid.value)
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
unassignedTracesLoaded.value = false
|
||||
await ensureUnassignedTraces()
|
||||
for (const t of unassignedTraces.value.slice(0, 20)) {
|
||||
if (t.file_uuid) loadTraceThumb(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -561,6 +683,8 @@ async function ctxAction(action: string, extra?: any) {
|
||||
if (idx >= 0) people.value[idx].status = 'pending'
|
||||
refreshUndoCounts(uuid)
|
||||
}).catch(e => console.error('Pending failed:', e))
|
||||
} else if (action === 'detail') {
|
||||
router.push(`/people/${uuid}`)
|
||||
} else if (action === 'rename' || action === 'merge') {
|
||||
selectPerson(p)
|
||||
} else if (action === 'undo') {
|
||||
@@ -601,13 +725,73 @@ function faceCtxAction(action: string) {
|
||||
const c = faceCtxMenu.value.candidate
|
||||
if (!c) return
|
||||
faceCtxMenu.value.show = false
|
||||
if (action === 'assign') {
|
||||
if (action === 'detail') {
|
||||
faceDetailModal.value = { show: true, candidate: c, newIdentityName: '' }
|
||||
loadCandidateThumb(c)
|
||||
} else if (action === 'assign') {
|
||||
openAssignModal(c)
|
||||
} else if (action === 'skip') {
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
}
|
||||
}
|
||||
|
||||
async function playFaceDetailVideo() {
|
||||
const c = faceDetailModal.value.candidate
|
||||
if (!c || !c.file_uuid) return
|
||||
const frame = c.frame_number || 0
|
||||
try {
|
||||
const fileInfo: any = await apiCall('get_file_info', { uuid: c.file_uuid })
|
||||
const fps = fileInfo?.fps || 30
|
||||
const frameTime = frame / fps
|
||||
const startTime = Math.max(0, frameTime - 5)
|
||||
const endTime = frameTime + 5
|
||||
currentVideo.value = {
|
||||
fileUuid: c.file_uuid,
|
||||
startTime,
|
||||
endTime,
|
||||
title: `Face #${frame} (${Math.round(frameTime)}s)`
|
||||
}
|
||||
playing.value = true
|
||||
} catch (e) {
|
||||
console.error('Failed to get file info:', e)
|
||||
const frameTime = frame / 30
|
||||
currentVideo.value = {
|
||||
fileUuid: c.file_uuid,
|
||||
startTime: Math.max(0, frameTime - 5),
|
||||
endTime: frameTime + 5,
|
||||
title: `Face #${frame}`
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
}
|
||||
|
||||
async function createNewIdentityFromFace() {
|
||||
const c = faceDetailModal.value.candidate
|
||||
const name = faceDetailModal.value.newIdentityName.trim()
|
||||
if (!c || !name) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const result: any = await apiCall('create_identity_from_face', {
|
||||
name,
|
||||
fileUuid: c.file_uuid
|
||||
})
|
||||
if (result?.identity_uuid) {
|
||||
faceDetailModal.value.show = false
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
invalidatePeople()
|
||||
await ensurePeople()
|
||||
const cleanUuid = result.identity_uuid.replace(/-/g, '')
|
||||
router.push(`/people/${cleanUuid}`)
|
||||
} else {
|
||||
alert('新增人物失敗:API 未返回 identity_uuid')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[createNewIdentityFromFace] error:', e)
|
||||
alert('新增人物失敗:' + String(e))
|
||||
}
|
||||
}
|
||||
|
||||
function openAssignModal(c: any) {
|
||||
assignModal.value = { show: true, candidate: c }
|
||||
assignSearchQuery.value = ''
|
||||
@@ -615,6 +799,37 @@ function openAssignModal(c: any) {
|
||||
loadCandidateThumb(c)
|
||||
}
|
||||
|
||||
function openTraceAssignModal(t: any) {
|
||||
// Convert trace to candidate-like object for the assign modal
|
||||
const candidateLike = {
|
||||
id: t.trace_id,
|
||||
file_uuid: t.file_uuid,
|
||||
frame_number: t.best_face_frame || t.start_frame,
|
||||
confidence: t.best_face_confidence,
|
||||
bbox: t.best_face_bbox,
|
||||
trace_id: t.trace_id,
|
||||
frame_count: t.frame_count,
|
||||
}
|
||||
assignModal.value = { show: true, candidate: candidateLike }
|
||||
assignSearchQuery.value = ''
|
||||
assignSelected.value = null
|
||||
loadTraceThumb(t)
|
||||
}
|
||||
|
||||
function showTraceCtxMenu(e: MouseEvent, t: any) {
|
||||
e.preventDefault()
|
||||
const candidateLike = {
|
||||
id: t.trace_id,
|
||||
file_uuid: t.file_uuid,
|
||||
frame_number: t.best_face_frame || t.start_frame,
|
||||
confidence: t.best_face_confidence,
|
||||
bbox: t.best_face_bbox,
|
||||
trace_id: t.trace_id,
|
||||
frame_count: t.frame_count,
|
||||
}
|
||||
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: candidateLike }
|
||||
}
|
||||
|
||||
async function confirmAssign() {
|
||||
const c = assignModal.value.candidate
|
||||
if (!c || !assignSelected.value) return
|
||||
@@ -625,7 +840,12 @@ async function confirmAssign() {
|
||||
faceRowId: c.face_id ? undefined : c.id,
|
||||
fileUuid: c.file_uuid
|
||||
})
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
if (c.trace_id) {
|
||||
// Remove from unassigned traces if it's a trace
|
||||
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => t.trace_id !== c.trace_id)
|
||||
} else {
|
||||
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||
}
|
||||
assignModal.value.show = false
|
||||
} catch (e) {
|
||||
console.error('Bind failed:', e)
|
||||
@@ -644,6 +864,8 @@ h1 { margin: 0; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.ms-ppl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.ms-ppl-file-select { padding: 6px 16px; border: 1.5px solid #9aa0a6; border-radius: 10px; background: #fff; font-size: 13px; font-family: inherit; color: #202124; cursor: pointer; outline: none; min-width: 140px; }
|
||||
.ms-ppl-file-select:focus { border-color: #202124; }
|
||||
.ms-ppl-star-toggle-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; background: #fff; cursor: pointer; font-size: 13px; font-family: inherit; color: #202124; transition: border-color .15s; }
|
||||
.ms-ppl-star-toggle-btn:hover { border-color: #202124; }
|
||||
.ms-ppl-star-icon { font-size: 16px; color: #d1d5db; transition: color .15s; }
|
||||
@@ -750,4 +972,22 @@ h1 { margin: 0; }
|
||||
.ms-fm-btn-primary { background: #202124; color: #fff; border-color: #202124; }
|
||||
.ms-fm-btn-primary:hover { background: #3c4043; }
|
||||
.ms-fm-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Face detail modal */
|
||||
.ms-modal-face-detail { max-width: 420px; width: 92%; padding: 24px 28px; background: #fff; border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,.2); position: relative; }
|
||||
.ms-face-detail-header { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
|
||||
.ms-face-detail-thumb { width: 100px; height: 100px; border-radius: 14px; background: #e8eaed; flex-shrink: 0; overflow: hidden; }
|
||||
.ms-face-detail-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.ms-face-detail-info { flex: 1; min-width: 0; }
|
||||
.ms-face-detail-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: #202124; margin: 0 0 12px; }
|
||||
.ms-face-detail-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.ms-face-detail-label { font-size: 12px; color: #5f6368; min-width: 60px; }
|
||||
.ms-face-detail-value { font-size: 12px; color: #202124; }
|
||||
.ms-face-detail-video { margin-bottom: 16px; }
|
||||
.ms-face-detail-divider { height: 1px; background: #e8eaed; margin: 16px 0; }
|
||||
.ms-face-detail-new-identity { margin-top: 16px; }
|
||||
.ms-face-detail-new-title { font-size: 14px; font-weight: 500; color: #202124; margin-bottom: 12px; }
|
||||
.ms-face-detail-new-input-row { display: flex; gap: 8px; }
|
||||
.ms-face-detail-new-input-row .ms-ppl-edit-input { flex: 1; }
|
||||
.ms-face-detail-footer { display: flex; justify-content: flex-end; margin-top: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -103,57 +103,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trace strip (face_traces from get_traces) -->
|
||||
<div class="ms-ppl-strip-wrap" :class="{ 'ms-ppl-edit-mode': isEditing }">
|
||||
<button class="ms-ppl-strip-add-btn" @click="showCandidates = true" title="加入相同人物">+</button>
|
||||
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage === 1" @click="prevFacePage">‹</button>
|
||||
<div class="ms-ppl-face-strip">
|
||||
<div v-if="loadingFaces" style="padding:10px;color:#999;font-size:12px;">Loading faces...</div>
|
||||
<div v-else-if="faces.length === 0" style="padding:10px;color:#999;font-size:12px;">No faces found</div>
|
||||
<div v-for="f in faces.slice(0, showAllFaces ? faces.length : 30)" :key="f.id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: selectedFace?.id === f.id }" @click="selectFace(f)" @contextmenu.prevent="showFaceCtxMenu($event, f)">
|
||||
<div v-if="loadingTraces" style="padding:10px;color:#999;font-size:12px;">Loading traces...</div>
|
||||
<div v-else-if="allTraces.length === 0" style="padding:10px;color:#999;font-size:12px;">No traces found</div>
|
||||
<div v-for="(t, i) in paginatedTraces" :key="t.trace_id" class="ms-ppl-strip-face ms-ppl-strip-face-clickable" :class="{ selected: selectedTrace?.trace_id === t.trace_id }" @click="selectTrace(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)">
|
||||
<div class="ms-ppl-strip-face-img">
|
||||
<img v-if="faceThumbs[f.id]" :src="faceThumbs[f.id]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;border-radius:8px;">
|
||||
<div v-else style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#eef2ff;color:#6366f1;font-size:14px;font-weight:600;border-radius:8px;">{{ Math.round(f.confidence * 100) }}%</div>
|
||||
<img v-if="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" :src="traceThumbs[thumbKey(t.file_uuid, t.first_frame)]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;border-radius:8px;">
|
||||
<div v-else style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#eef2ff;color:#6366f1;font-size:10px;font-weight:600;border-radius:8px;">T{{ t.trace_id }}</div>
|
||||
</div>
|
||||
<button v-if="isEditing" class="ms-ppl-strip-remove-btn" @click="unbindFace(f)">×</button>
|
||||
<div class="ms-ppl-strip-trace-label">T{{ t.trace_id }} {{ t.face_id ? 'F' + t.face_id.slice(-4) : '' }}</div>
|
||||
<button v-if="isEditing" class="ms-ppl-strip-remove-btn" @click="unbindFace(t)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="faces.length > 30" class="ms-ppl-strip-arrow" @click="showAllFaces = !showAllFaces">›</button>
|
||||
<button class="ms-ppl-strip-arrow" :disabled="faceStripPage >= faceStripTotalPages" @click="nextFacePage">›</button>
|
||||
<span v-if="faceStripTotalPages > 1" class="ms-ppl-strip-page">{{ faceStripPage }}/{{ faceStripTotalPages }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Face detail card -->
|
||||
<div v-if="selectedFace" class="ms-ppl-face-card-detail">
|
||||
<button class="ms-ppl-face-card-close" @click="selectedFace = null">×</button>
|
||||
<!-- Trace detail card -->
|
||||
<div v-if="selectedTrace" class="ms-ppl-face-card-detail">
|
||||
<button class="ms-ppl-face-card-close" @click="selectedTrace = null">×</button>
|
||||
<div class="ms-ppl-face-card-img-wrap">
|
||||
<img v-if="faceThumbs[selectedFace.id]" :src="faceThumbs[selectedFace.id]" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:12px;">
|
||||
<img v-if="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" :src="traceThumbs[thumbKey(selectedTrace.file_uuid, selectedTrace.first_frame)]" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:12px;">
|
||||
<div v-else class="face-placeholder">?</div>
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-info">
|
||||
<div class="ms-ppl-face-card-file">{{ (selectedFace.file_uuid || '').slice(0, 12) }}...</div>
|
||||
<div class="ms-ppl-face-card-frame">Frame #{{ selectedFace.frame_number }}</div>
|
||||
<div v-if="selectedFace.confidence" class="ms-ppl-face-card-conf">Confidence: {{ (selectedFace.confidence * 100).toFixed(1) }}%</div>
|
||||
<div class="ms-ppl-face-card-file">trace_id: {{ selectedTrace.trace_id }}</div>
|
||||
<div class="ms-ppl-face-card-frame">face_id: {{ selectedTrace.face_id || '—' }}</div>
|
||||
<div class="ms-ppl-face-card-time">
|
||||
{{ (selectedTrace.first_sec || 0).toFixed(1) }}s ~ {{ (selectedTrace.last_sec || 0).toFixed(1) }}s
|
||||
<span class="ms-ppl-face-card-dur">({{ traceDuration(selectedTrace).toFixed(1) }}s)</span>
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-fps" v-if="traceFps(selectedTrace)">
|
||||
{{ traceFps(selectedTrace) }} fps · {{ selectedTrace.last_frame - selectedTrace.first_frame }} frames
|
||||
</div>
|
||||
<div class="ms-ppl-face-card-conf">#{{ selectedTrace.first_frame }}–#{{ selectedTrace.last_frame }} · {{ selectedTrace.file_uuid?.slice(0, 8) }}...</div>
|
||||
<div class="ms-ppl-face-card-actions">
|
||||
<button class="ms-fm-btn" @click="playFaceTrace(selectedFace)">▶ 播放片段</button>
|
||||
<button v-if="isEditing" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedFace); selectedFace = null">✕ 解綁</button>
|
||||
<button class="ms-fm-btn" @click="playTrace(selectedTrace)">▶ 播放</button>
|
||||
<button v-if="isEditing && selectedTrace.face_id" class="ms-fm-btn ms-fm-btn-danger" @click="unbindFace(selectedTrace); selectedTrace = null">✕ 解綁</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Segment card -->
|
||||
<div v-if="mergedSegments.length" class="ms-ppl-media-item segment-card" @click="playMerged(mergedSegments[0])">
|
||||
<!-- Single segment card (entry point to player with all traces) -->
|
||||
<div v-if="loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">Loading segments...</div>
|
||||
<div v-else-if="loadingMoreTraces" class="ms-ppl-media-label" style="margin-bottom:20px;color:#9aa0a6;">Loading more segments...</div>
|
||||
<div v-if="allSegCard" class="ms-ppl-media-item segment-card" @click="playAllSegments">
|
||||
<div class="ms-ppl-media-thumb">
|
||||
<img v-if="thumbs[mergedSegments[0].thumbKey]" :src="thumbs[mergedSegments[0].thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;">
|
||||
<img v-if="thumbs[allSegCard.thumbKey]" :src="thumbs[allSegCard.thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;">
|
||||
<div class="ms-thumb-play-circle">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20" fill="white"/></svg>
|
||||
</div>
|
||||
<span class="ms-ppl-media-dur">{{ (mergedSegments[0].end - mergedSegments[0].start).toFixed(1) }}s</span>
|
||||
<span v-if="mergedSegments[0].avg_confidence" class="ms-ppl-media-badge-conf">{{ (mergedSegments[0].avg_confidence * 100).toFixed(0) }}%</span>
|
||||
<span class="ms-ppl-media-dur">{{ allSegCard.duration.toFixed(1) }}s</span>
|
||||
<span v-if="allSegCard.avg_confidence" class="ms-ppl-media-badge-conf">{{ (allSegCard.avg_confidence * 100).toFixed(0) }}%</span>
|
||||
</div>
|
||||
<div class="ms-ppl-media-info">
|
||||
<div class="ms-ppl-media-title">{{ mergedSegments[0].count > 1 ? 'Merged ' + mergedSegments[0].count + ' segments' : 'Segment' }} · {{ mergedSegments[0].file_uuid.slice(0,8) }}...</div>
|
||||
<div class="ms-ppl-media-sub">#{{ mergedSegments[0].start_frame }} — #{{ mergedSegments[0].end_frame }} · {{ formatTime(mergedSegments[0].start) }}</div>
|
||||
<div class="ms-ppl-media-title">{{ allSegCard.count }} 個片段 · {{ allTraces.length }} 個追蹤</div>
|
||||
<div class="ms-ppl-media-sub">所有片段 · {{ formatTime(allSegCard.start) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">Loading segments...</div>
|
||||
<div v-else-if="!mergedSegments.length" class="ms-ppl-media-label" style="margin-bottom:20px;">No trace segments available for this person</div>
|
||||
<div v-else-if="!loadingTraces" class="ms-ppl-media-label" style="margin-bottom:20px;">No trace segments available for this person</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div v-show="activeTab === 'actions'">
|
||||
@@ -234,21 +246,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :all-traces="allTraces" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" @close="playing = false" />
|
||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :all-traces="allTraces" :merged-segments="mergedSegments" :initial-trace-idx="currentVideo.traceIdx" :title="currentVideo.title" @close="playing = false" />
|
||||
|
||||
<!-- Face strip context menu -->
|
||||
<div v-if="faceCtxMenu.show" class="ms-ctx-menu" :style="{ left: faceCtxMenu.x + 'px', top: faceCtxMenu.y + 'px', display: 'block' }" @click.stop @mousedown.stop @pointerdown.stop>
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="faceCtxAction('unbind')">不是此人物</button>
|
||||
<!-- Trace strip context menu -->
|
||||
<div v-if="traceCtxMenu.show" class="ms-ctx-menu" :style="{ left: traceCtxMenu.x + 'px', top: traceCtxMenu.y + 'px', display: 'block' }" @click.stop @mousedown.stop @pointerdown.stop>
|
||||
<button class="ms-ctx-item ms-ctx-danger" @click="traceCtxAction('unbind')">不是此人物</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { apiCall } from '@/api'
|
||||
import { isTauri } from '@/api/config'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, profilesCache, loadProfile as storeLoadProfile, faceThumbsCache, loadFaceThumb as storeLoadFaceThumb, thumbnailsCache, invalidatePeople, invalidateProfile } from '@/store'
|
||||
import { ensurePeople, peopleCache, peopleLoaded, profilesCache, loadProfile as storeLoadProfile, faceThumbsCache, loadFaceThumb as storeLoadFaceThumb, thumbnailsCache, loadThumbnail, invalidatePeople, invalidateProfile } from '@/store'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -256,13 +268,11 @@ const router = useRouter()
|
||||
|
||||
const person = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const loadingFaces = ref(true)
|
||||
const loadingTraces = ref(true)
|
||||
const profile = ref('')
|
||||
const profileUuid = ref('')
|
||||
const peopleCount = ref(0)
|
||||
const allPeople = ref<any[]>([])
|
||||
const faces = ref<any[]>([])
|
||||
const allTraces = ref<any[]>([])
|
||||
const mergedSegments = ref<any[]>([])
|
||||
const playing = ref(false)
|
||||
@@ -272,9 +282,28 @@ const showMerge = ref(false)
|
||||
const mergeSearchQuery = ref('')
|
||||
const mergeSearchResults = ref<any[]>([])
|
||||
const candidates = ref<any[]>([])
|
||||
const showAllFaces = ref(false)
|
||||
const thumbs = thumbnailsCache
|
||||
const traceThumbs = thumbnailsCache
|
||||
const faceThumbs = faceThumbsCache
|
||||
|
||||
const allSegCard = computed(() => {
|
||||
const m = mergedSegments.value
|
||||
if (!m.length) return null
|
||||
const segs = [...m].sort((a, b) => a.start - b.start)
|
||||
const first = segs[0]
|
||||
const last = segs[segs.length - 1]
|
||||
const firstTrace = allTraces.value[first._startIdx]
|
||||
return {
|
||||
thumbKey: first.thumbKey,
|
||||
start: first.start,
|
||||
end: last.end,
|
||||
duration: last.end - first.start,
|
||||
count: m.length,
|
||||
avg_confidence: m.reduce((s, x) => s + (x.avg_confidence || 0), 0) / m.length,
|
||||
firstTraceIdx: first._startIdx,
|
||||
}
|
||||
})
|
||||
|
||||
const isEditing = ref(false)
|
||||
const editName = ref('')
|
||||
const editRole = ref('')
|
||||
@@ -286,9 +315,19 @@ const aliasName = ref('')
|
||||
const saving = ref(false)
|
||||
const avatarUploading = ref(false)
|
||||
const activeTab = ref<'faces' | 'actions'>('faces')
|
||||
const faceCtxMenu = ref({ show: false, x: 0, y: 0, face: null as any })
|
||||
const selectedFace = ref<any>(null)
|
||||
const traceCtxMenu = ref({ show: false, x: 0, y: 0, trace: null as any })
|
||||
const selectedTrace = ref<any>(null)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const loadingMoreTraces = ref(false)
|
||||
const faceStripPage = ref(1)
|
||||
const faceStripPerPage = 20
|
||||
const paginatedTraces = computed(() => {
|
||||
const start = (faceStripPage.value - 1) * faceStripPerPage
|
||||
return allTraces.value.slice(start, start + faceStripPerPage)
|
||||
})
|
||||
const faceStripTotalPages = computed(() => Math.ceil(allTraces.value.length / faceStripPerPage))
|
||||
function prevFacePage() { if (faceStripPage.value > 1) faceStripPage.value-- }
|
||||
function nextFacePage() { if (faceStripPage.value < faceStripTotalPages.value) faceStripPage.value++ }
|
||||
|
||||
onMounted(async () => {
|
||||
const uuid = route.params.uuid as string
|
||||
@@ -300,7 +339,6 @@ onMounted(async () => {
|
||||
if (found) {
|
||||
person.value = { ...found, status: found.status || 'confirmed' }
|
||||
loadProfile(uuid)
|
||||
loadFaces(uuid)
|
||||
loadMedia(uuid)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -309,11 +347,11 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
}
|
||||
loadCandidates()
|
||||
document.addEventListener('click', closeFaceCtxMenu)
|
||||
document.addEventListener('click', closeTraceCtxMenu)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', closeFaceCtxMenu)
|
||||
document.removeEventListener('click', closeTraceCtxMenu)
|
||||
})
|
||||
|
||||
function loadProfile(uuid: string) {
|
||||
@@ -333,133 +371,130 @@ function thumbKey(uuid: string, frame: number): string {
|
||||
return `${uuid}:${frame}`
|
||||
}
|
||||
|
||||
async function loadFaces(uuid: string) {
|
||||
loadingFaces.value = true
|
||||
try {
|
||||
const result: any = await apiCall('get_faces', { uuid, perPage: 20 })
|
||||
const items = Array.isArray(result) ? result : []
|
||||
faces.value = items
|
||||
items.forEach((f: any) => {
|
||||
const fu = f.file_uuid || f.fileUuid || ''
|
||||
const fn = f.frame_number ?? f.frameNumber ?? 0
|
||||
const fid = f.id
|
||||
const bbox = f.bbox
|
||||
storeLoadFaceThumb(fid, fu, fn, bbox)
|
||||
})
|
||||
} catch (e) { console.error('Failed to load faces:', e) }
|
||||
finally { loadingFaces.value = false }
|
||||
function selectTrace(t: any) {
|
||||
selectedTrace.value = t
|
||||
// Load thumbnail for this trace
|
||||
loadThumb(t.file_uuid, t.first_frame || 0)
|
||||
}
|
||||
|
||||
function selectFace(f: any) {
|
||||
selectedFace.value = f
|
||||
}
|
||||
|
||||
function playFaceTrace(f: any) {
|
||||
const fu = f.file_uuid || f.fileUuid || ''
|
||||
const frame = f.frame_number ?? f.frameNumber ?? 0
|
||||
const trace = allTraces.value.find((t: any) => t.file_uuid === fu && frame >= (t.first_frame || t.start_frame || 0) && frame <= (t.last_frame || t.end_frame || 0))
|
||||
if (trace) {
|
||||
currentVideo.value = {
|
||||
fileUuid: fu,
|
||||
startTime: trace.first_sec || trace.start_time || 0,
|
||||
endTime: trace.last_sec || trace.end_time || 0,
|
||||
traceIdx: allTraces.value.indexOf(trace),
|
||||
title: `${person.value?.name} · Face #${frame}`,
|
||||
}
|
||||
} else {
|
||||
currentVideo.value = {
|
||||
fileUuid: fu,
|
||||
startTime: Math.max(0, (frame / 30) - 5),
|
||||
endTime: (frame / 30) + 5,
|
||||
traceIdx: 0,
|
||||
title: `${person.value?.name} · Face #${frame}`,
|
||||
}
|
||||
function playTrace(t: any) {
|
||||
if (!t) return
|
||||
const idx = allTraces.value.indexOf(t)
|
||||
if (idx < 0) return
|
||||
currentVideo.value = {
|
||||
fileUuid: t.file_uuid || '',
|
||||
startTime: t.first_sec || t.start_time || 0,
|
||||
endTime: t.last_sec || t.end_time || 0,
|
||||
traceIdx: idx,
|
||||
title: `${person.value?.name} · T${t.trace_id}`,
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function showFaceCtxMenu(e: MouseEvent, f: any) {
|
||||
function showTraceCtxMenu(e: MouseEvent, t: any) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, face: { ...f } }
|
||||
traceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, trace: { ...t } }
|
||||
}
|
||||
|
||||
function closeFaceCtxMenu(e?: MouseEvent) {
|
||||
faceCtxMenu.value.show = false
|
||||
function closeTraceCtxMenu(e?: MouseEvent) {
|
||||
traceCtxMenu.value.show = false
|
||||
}
|
||||
|
||||
function faceCtxAction(action: string) {
|
||||
const f = faceCtxMenu.value.face
|
||||
faceCtxMenu.value = { show: false, x: 0, y: 0, face: null }
|
||||
if (!f) {
|
||||
console.error('faceCtxAction: no face data')
|
||||
function traceCtxAction(action: string) {
|
||||
const t = traceCtxMenu.value.trace
|
||||
traceCtxMenu.value = { show: false, x: 0, y: 0, trace: null }
|
||||
if (!t) {
|
||||
console.error('traceCtxAction: no trace data')
|
||||
return
|
||||
}
|
||||
if (!person.value) {
|
||||
console.error('faceCtxAction: no person data')
|
||||
console.error('traceCtxAction: no person data')
|
||||
return
|
||||
}
|
||||
if (action === 'unbind') {
|
||||
unbindFace(f)
|
||||
unbindFace(t)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMedia(uuid: string) {
|
||||
loadingTraces.value = true
|
||||
try {
|
||||
const result: any = await apiCall('get_traces', { uuid, perPage: 50 })
|
||||
const rawItems = Array.isArray(result) ? result : []
|
||||
if (!rawItems.length) { mergedSegments.value = []; return }
|
||||
|
||||
rawItems.sort((a: any, b: any) => (a.first_sec || a.start_time || 0) - (b.first_sec || b.start_time || 0))
|
||||
allTraces.value = rawItems
|
||||
|
||||
const merged: any[] = []
|
||||
let cur: any = null
|
||||
rawItems.forEach((item: any, idx: number) => {
|
||||
const st = item.first_sec || item.start_time || 0
|
||||
const en = item.last_sec || item.end_time || 0
|
||||
const fu = item.file_uuid || ''
|
||||
if (cur && fu === cur.file_uuid && (st - cur.end) < 30) {
|
||||
cur.end = Math.max(cur.end, en)
|
||||
cur.end_frame = Math.max(cur.end_frame, item.last_frame || 0)
|
||||
cur.count++
|
||||
cur._endIdx = idx
|
||||
cur.total_confidence += item.avg_confidence || 0
|
||||
} else {
|
||||
if (cur) merged.push(cur)
|
||||
cur = {
|
||||
file_uuid: fu,
|
||||
start_frame: item.first_frame || 0,
|
||||
end_frame: item.last_frame || 0,
|
||||
start: st,
|
||||
end: en,
|
||||
count: 1,
|
||||
total_confidence: item.avg_confidence || 0,
|
||||
_startIdx: idx,
|
||||
_endIdx: idx,
|
||||
thumbKey: thumbKey(fu, item.first_frame || 0),
|
||||
}
|
||||
}
|
||||
})
|
||||
if (cur) merged.push(cur)
|
||||
|
||||
merged.forEach((m: any) => {
|
||||
m.avg_confidence = m.count > 1 ? m.total_confidence / m.count : m.total_confidence
|
||||
delete m.total_confidence
|
||||
})
|
||||
mergedSegments.value = merged.slice(0, 1)
|
||||
merged.forEach((m: any) => loadThumb(m.file_uuid, m.start_frame))
|
||||
// Load all traces with pagination
|
||||
const allItems: any[] = []
|
||||
for (let page = 1; ; page++) {
|
||||
const result: any = await apiCall('get_traces', { uuid, perPage: 100, page })
|
||||
const items = result?.traces || []
|
||||
if (!items.length) break
|
||||
allItems.push(...items)
|
||||
}
|
||||
processTraces(allItems)
|
||||
} catch (e) { console.error('Failed to load media:', e) }
|
||||
finally { loadingTraces.value = false }
|
||||
finally { loadingTraces.value = false; loadingMoreTraces.value = false }
|
||||
}
|
||||
|
||||
function processTraces(newData: any, append = false) {
|
||||
const items = Array.isArray(newData) ? newData : []
|
||||
if (!items.length) return
|
||||
|
||||
if (!append) {
|
||||
allTraces.value = items.sort((a: any, b: any) => (a.first_sec || a.start_time || 0) - (b.first_sec || b.start_time || 0))
|
||||
} else {
|
||||
loadingMoreTraces.value = true
|
||||
const existingIds = new Set(allTraces.value.map((t: any) => t.trace_id))
|
||||
const fresh = items.filter((t: any) => !existingIds.has(t.trace_id))
|
||||
if (!fresh.length) return
|
||||
allTraces.value = [...allTraces.value, ...fresh].sort((a: any, b: any) =>
|
||||
(a.first_sec || a.start_time || 0) - (b.first_sec || b.start_time || 0)
|
||||
)
|
||||
}
|
||||
|
||||
// Recompute merged segments
|
||||
const merged: any[] = []
|
||||
let cur: any = null
|
||||
allTraces.value.forEach((item: any, idx: number) => {
|
||||
const st = item.first_sec || item.start_time || 0
|
||||
const en = item.last_sec || item.end_time || 0
|
||||
const fu = item.file_uuid || ''
|
||||
if (cur && fu === cur.file_uuid && (st - cur.end) < 30) {
|
||||
cur.end = Math.max(cur.end, en)
|
||||
cur.end_frame = Math.max(cur.end_frame, item.last_frame || 0)
|
||||
cur.count++
|
||||
cur._endIdx = idx
|
||||
cur.total_confidence += item.avg_confidence || 0
|
||||
} else {
|
||||
if (cur) merged.push(cur)
|
||||
cur = {
|
||||
file_uuid: fu,
|
||||
start_frame: item.first_frame || 0,
|
||||
end_frame: item.last_frame || 0,
|
||||
start: st,
|
||||
end: en,
|
||||
count: 1,
|
||||
total_confidence: item.avg_confidence || 0,
|
||||
_startIdx: idx,
|
||||
_endIdx: idx,
|
||||
thumbKey: thumbKey(fu, item.first_frame || 0),
|
||||
}
|
||||
}
|
||||
})
|
||||
if (cur) merged.push(cur)
|
||||
|
||||
merged.forEach((m: any) => {
|
||||
m.avg_confidence = m.count > 1 ? m.total_confidence / m.count : m.total_confidence
|
||||
delete m.total_confidence
|
||||
})
|
||||
mergedSegments.value = merged
|
||||
if (append) loadingMoreTraces.value = false
|
||||
|
||||
// Load thumbnails for visible segments
|
||||
merged.slice(0, 6).forEach((m: any) => loadThumb(m.file_uuid, m.start_frame))
|
||||
}
|
||||
|
||||
function loadThumb(uuid: string, frame: number) {
|
||||
const key = thumbKey(uuid, frame)
|
||||
if (thumbnailsCache.value[key]) return
|
||||
apiCall('get_thumbnail', { uuid, frame }).then((url: any) => {
|
||||
if (url) thumbnailsCache.value[key] = url
|
||||
}).catch(() => {})
|
||||
loadThumbnail(uuid, frame)
|
||||
}
|
||||
|
||||
async function loadCandidates() {
|
||||
@@ -593,19 +628,22 @@ function triggerAvatarUpload() {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
async function unbindFace(f: any) {
|
||||
async function unbindFace(t: any) {
|
||||
if (!person.value) return
|
||||
const uuid = person.value.identity_uuid
|
||||
if (!confirm('解綁此人臉?')) return
|
||||
const fid = t.face_id || t.faceId || null
|
||||
if (!fid) { alert('此 trace 沒有 face_id,無法解綁'); return }
|
||||
if (!confirm(`解綁 trace T${t.trace_id}(face_id: ${fid})?`)) return
|
||||
try {
|
||||
await apiCall('unbind_face', {
|
||||
uuid,
|
||||
faceId: f.face_id || f.faceId || null,
|
||||
faceRowId: f.id || null,
|
||||
fileUuid: f.file_uuid || f.fileUuid || '',
|
||||
frameNumber: f.frame_number ?? f.frameNumber ?? null,
|
||||
faceId: fid,
|
||||
faceRowId: null,
|
||||
fileUuid: t.file_uuid || t.fileUuid || '',
|
||||
frameNumber: null,
|
||||
})
|
||||
await loadFaces(uuid)
|
||||
// Refresh traces
|
||||
await loadMedia(uuid)
|
||||
invalidatePeople()
|
||||
await ensurePeople()
|
||||
allPeople.value = peopleCache.value
|
||||
@@ -628,6 +666,20 @@ async function updateStatus(status: string) {
|
||||
} catch (e) { console.error('Failed to update status:', e) }
|
||||
}
|
||||
|
||||
function playAllSegments() {
|
||||
const card = allSegCard.value
|
||||
if (!card || !allTraces.value.length) return
|
||||
const t = allTraces.value[0]
|
||||
currentVideo.value = {
|
||||
fileUuid: t.file_uuid || '',
|
||||
startTime: t.first_sec || t.start_time || 0,
|
||||
endTime: t.last_sec || t.end_time || 0,
|
||||
traceIdx: 0,
|
||||
title: `${person.value?.name} · ${card.count} 個片段`,
|
||||
}
|
||||
playing.value = true
|
||||
}
|
||||
|
||||
function playMerged(m: any) {
|
||||
const t = allTraces.value[m._startIdx]
|
||||
if (!t) return
|
||||
@@ -646,6 +698,19 @@ function formatTime(sec: number): string {
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function traceDuration(t: any): number {
|
||||
if (!t) return 0
|
||||
return (t.last_sec || 0) - (t.first_sec || 0)
|
||||
}
|
||||
|
||||
function traceFps(t: any): number | null {
|
||||
if (!t) return null
|
||||
const frameDiff = (t.last_frame || 0) - (t.first_frame || 0)
|
||||
const secDiff = traceDuration(t)
|
||||
if (secDiff <= 0) return null
|
||||
return Math.round(frameDiff / secDiff)
|
||||
}
|
||||
|
||||
async function refreshPerson() {
|
||||
invalidatePeople()
|
||||
await ensurePeople()
|
||||
@@ -745,10 +810,15 @@ let mergeSearchTimer: any
|
||||
.ms-ppl-face-card-info { flex: 1; min-width: 0; }
|
||||
.ms-ppl-face-card-file { font-size: 13px; color: #5f6368; font-family: monospace; }
|
||||
.ms-ppl-face-card-frame { font-size: 13px; color: #3c4043; margin-top: 2px; }
|
||||
.ms-ppl-face-card-time { font-size: 13px; color: #3c4043; margin-top: 4px; }
|
||||
.ms-ppl-face-card-dur { color: #9aa0a6; margin-left: 4px; }
|
||||
.ms-ppl-face-card-fps { font-size: 12px; color: #5f6368; margin-top: 2px; }
|
||||
.ms-ppl-face-card-conf { font-size: 12px; color: #9aa0a6; margin-top: 2px; }
|
||||
.ms-ppl-face-card-actions { display: flex; gap: 8px; margin-top: 10px; }
|
||||
.ms-ppl-strip-arrow { width: 28px; height: 28px; border-radius: 50%; border: 1.5px solid #d1d5db; background: #fff; font-size: 18px; display: grid; place-items: center; cursor: pointer; color: #5f6368; outline: none; flex-shrink: 0; transition: background .15s; }
|
||||
.ms-ppl-strip-arrow:hover { background: #f3f4f6; color: #202124; }
|
||||
.ms-ppl-strip-arrow:hover:not(:disabled) { background: #f3f4f6; color: #202124; }
|
||||
.ms-ppl-strip-arrow:disabled { opacity: 0.35; cursor: default; }
|
||||
.ms-ppl-strip-page { font-size: 11px; color: #9aa0a6; min-width: 32px; text-align: center; flex-shrink: 0; }
|
||||
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
|
||||
.ms-ppl-media-item { cursor: pointer; border-radius: 12px; overflow: visible; background: #f0f0f0; transition: transform .15s, box-shadow .15s; border: 1px solid #eee; }
|
||||
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
<span v-if="r.chunk_id" class="card-chunk">{{ r.chunk_id }}</span>
|
||||
<span v-if="r.start_frame" class="card-frame">F{{ r.start_frame }}–{{ r.end_frame }}</span>
|
||||
<span v-if="r.source" class="card-source">{{ r.source }}</span>
|
||||
<span v-if="r.asr_status" class="card-asr" :class="'card-asr--' + r.asr_status">{{ r.asr_message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1034,6 +1035,11 @@ function openSource(src: SourceInfo) {
|
||||
.card-time { color: #9ca3af; }
|
||||
.card-score { color: #6366f1; font-weight: 500; }
|
||||
.card-source { background: #eef2ff; color: #4f46e5; border-radius: 4px; padding: 1px 5px; font-size: 10px; font-weight: 500; }
|
||||
.card-asr { border-radius: 4px; padding: 1px 5px; font-size: 10px; font-weight: 500; }
|
||||
.card-asr--no_audio_track { background: #f3f4f6; color: #6b7280; }
|
||||
.card-asr--silent_audio { background: #fef3c7; color: #d97706; }
|
||||
.card-asr--has_transcript { background: #d1fae5; color: #059669; }
|
||||
.card-asr--processing { background: #dbeafe; color: #2563eb; }
|
||||
|
||||
.ibar {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
|
||||
@@ -14,7 +14,11 @@
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5173
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8888',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user