feat: bind/unbind by id, video streaming proxy, unmute default, library refresh, hamburger click-outside
- Core API: bind/unbind accept id (integer PK) when face_id is null - Tauri proxy: stream video responses instead of buffering for seek bar - VideoPlayer: remove muted attribute, unmute by default - LibraryView: call invalidateFiles() before ensureFiles() on register/unregister - PeopleView: close sort panel on click-outside, not just hamburger toggle - bind_face: prefer face_id, fallback to id when face_id is null
This commit is contained in:
@@ -7,16 +7,33 @@ license = "MIT"
|
|||||||
repository = ""
|
repository = ""
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.77.2"
|
||||||
|
default-run = "momentry-studio"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "momentry-studio"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "momentry-proxy"
|
||||||
|
path = "src/bin/proxy.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = [] }
|
tauri = { version = "2", features = ["protocol-asset"] }
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "json"] }
|
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "postgres", "json"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json", "stream", "multipart"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
lru = "0.12"
|
||||||
|
futures = "0.3"
|
||||||
|
image = { version = "0.24", default-features = false, features = ["jpeg"] }
|
||||||
|
axum = "0.7"
|
||||||
|
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"] }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", features = [] }
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|||||||
11
src-tauri/src/bin/proxy.rs
Normal file
11
src-tauri/src/bin/proxy.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
// Standalone proxy binary for testing/development
|
||||||
|
// Re-exports the proxy module from main crate
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// We can't import from the main crate as a library easily,
|
||||||
|
// so instead we'll just call through the Tauri binary mechanism.
|
||||||
|
// For now, the full Tauri binary must be used to run the proxy.
|
||||||
|
eprintln!("Use `cargo tauri dev` to start the full application with proxy.");
|
||||||
|
eprintln!("For standalone proxy testing, the proxy runs as part of the Tauri binary.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
238
src-tauri/src/db.rs
Normal file
238
src-tauri/src/db.rs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
use rusqlite::Connection;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
const APP_TABLES: &str = "
|
||||||
|
CREATE TABLE IF NOT EXISTS search_history (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
query TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
chat_state TEXT,
|
||||||
|
mode TEXT DEFAULT 'keyword',
|
||||||
|
pinned INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
history_id TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT,
|
||||||
|
role TEXT DEFAULT 'user',
|
||||||
|
created_at TEXT DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_search_history_sort ON search_history(pinned DESC, updated_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bookmarks_created ON bookmarks(created_at DESC);
|
||||||
|
";
|
||||||
|
|
||||||
|
static DB_CONN: Mutex<Option<Connection>> = Mutex::new(None);
|
||||||
|
|
||||||
|
fn db_path() -> PathBuf {
|
||||||
|
let base = std::env::var("MOMENTRY_DATA_DIR").unwrap_or_else(|_| "../data/users".to_string());
|
||||||
|
std::fs::create_dir_all(&base).ok();
|
||||||
|
PathBuf::from(base).join("demo.sqlite")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_db() -> Result<(), String> {
|
||||||
|
let path = db_path();
|
||||||
|
let conn = Connection::open(&path).map_err(|e| format!("Failed to open db: {}", e))?;
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode error: {}", e))?;
|
||||||
|
conn.execute_batch(APP_TABLES).map_err(|e| format!("Failed to create tables: {}", e))?;
|
||||||
|
|
||||||
|
let default_id = "demo";
|
||||||
|
let default_user = "demo";
|
||||||
|
let count: i64 = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT COUNT(*) FROM app_users WHERE id = ?1",
|
||||||
|
[default_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0);
|
||||||
|
if count == 0 {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO app_users (id, username, display_name, role) VALUES (?1, ?2, ?3, 'admin')",
|
||||||
|
rusqlite::params![default_id, default_user, "Demo User"],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Failed to insert default user: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lock = DB_CONN.lock().map_err(|e| format!("DB lock error: {}", e))?;
|
||||||
|
*lock = Some(conn);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_conn() -> Result<Connection, String> {
|
||||||
|
let path = db_path();
|
||||||
|
let conn = Connection::open(&path).map_err(|e| format!("Failed to open db: {}", e))?;
|
||||||
|
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode error: {}", e))?;
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct HistoryItem {
|
||||||
|
pub id: String,
|
||||||
|
pub query: String,
|
||||||
|
pub title: String,
|
||||||
|
pub chat_state: Option<String>,
|
||||||
|
pub mode: Option<String>,
|
||||||
|
pub pinned: bool,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct BookmarkItem {
|
||||||
|
pub id: i64,
|
||||||
|
pub label: String,
|
||||||
|
pub history_id: Option<String>,
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub fn get_search_history(limit: Option<u32>) -> Result<Vec<HistoryItem>, String> {
|
||||||
|
let limit = limit.unwrap_or(30).min(30);
|
||||||
|
let conn = get_conn()?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id, query, title, chat_state, mode, pinned, created_at, updated_at FROM search_history ORDER BY pinned DESC, updated_at DESC LIMIT ?1")
|
||||||
|
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||||
|
let items = stmt
|
||||||
|
.query_map([limit], |row| {
|
||||||
|
Ok(HistoryItem {
|
||||||
|
id: row.get(0)?,
|
||||||
|
query: row.get(1)?,
|
||||||
|
title: row.get(2)?,
|
||||||
|
chat_state: row.get(3)?,
|
||||||
|
mode: row.get(4)?,
|
||||||
|
pinned: row.get::<_, i64>(5)? != 0,
|
||||||
|
created_at: row.get(6)?,
|
||||||
|
updated_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Query error: {}", e))?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub fn save_search_history(
|
||||||
|
id: String,
|
||||||
|
query: String,
|
||||||
|
title: String,
|
||||||
|
chat_state: Option<String>,
|
||||||
|
mode: Option<String>,
|
||||||
|
) -> Result<HistoryItem, String> {
|
||||||
|
let conn = get_conn()?;
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
let effective_mode = mode.unwrap_or_else(|| "keyword".to_string());
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO search_history (id, query, title, chat_state, mode, pinned, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, (SELECT COALESCE((SELECT pinned FROM search_history WHERE id = ?1), 0)), ?6)",
|
||||||
|
rusqlite::params![id, query, title, chat_state, effective_mode, now],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Insert error: {}", e))?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM search_history WHERE id NOT IN (SELECT id FROM search_history ORDER BY pinned DESC, updated_at DESC LIMIT 30)",
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
|
||||||
|
Ok(HistoryItem {
|
||||||
|
id,
|
||||||
|
query: query.clone(),
|
||||||
|
title: title.clone(),
|
||||||
|
chat_state,
|
||||||
|
mode: Some(effective_mode),
|
||||||
|
pinned: false,
|
||||||
|
created_at: None,
|
||||||
|
updated_at: Some(now),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub fn rename_search_history(id: String, title: String) -> Result<(), String> {
|
||||||
|
let conn = get_conn()?;
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE search_history SET title = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
rusqlite::params![title, now, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Rename error: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub fn pin_search_history(id: String, pinned: bool) -> Result<(), String> {
|
||||||
|
let conn = get_conn()?;
|
||||||
|
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE search_history SET pinned = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
rusqlite::params![pinned as i64, now, id],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Pin error: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub fn delete_search_history(id: String) -> Result<(), String> {
|
||||||
|
let conn = get_conn()?;
|
||||||
|
conn.execute("DELETE FROM search_history WHERE id = ?1", [id])
|
||||||
|
.map_err(|e| format!("Delete error: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub fn get_bookmarks() -> Result<Vec<BookmarkItem>, String> {
|
||||||
|
let conn = get_conn()?;
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id, label, history_id, created_at FROM bookmarks ORDER BY created_at DESC")
|
||||||
|
.map_err(|e| format!("Prepare error: {}", e))?;
|
||||||
|
let items = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(BookmarkItem {
|
||||||
|
id: row.get(0)?,
|
||||||
|
label: row.get(1)?,
|
||||||
|
history_id: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("Query error: {}", e))?
|
||||||
|
.filter_map(|r| r.ok())
|
||||||
|
.collect();
|
||||||
|
Ok(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub fn save_bookmark(label: String, history_id: Option<String>) -> Result<BookmarkItem, String> {
|
||||||
|
let conn = get_conn()?;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO bookmarks (label, history_id) VALUES (?1, ?2)",
|
||||||
|
rusqlite::params![label, history_id],
|
||||||
|
)
|
||||||
|
.map_err(|e| format!("Insert error: {}", e))?;
|
||||||
|
let id = conn.last_insert_rowid();
|
||||||
|
Ok(BookmarkItem {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
history_id,
|
||||||
|
created_at: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command(rename_all = "camelCase")]
|
||||||
|
pub fn delete_bookmark(id: i64) -> Result<(), String> {
|
||||||
|
let conn = get_conn()?;
|
||||||
|
conn.execute("DELETE FROM bookmarks WHERE id = ?1", [id])
|
||||||
|
.map_err(|e| format!("Delete error: {}", e))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
558
src-tauri/src/proxy.rs
Normal file
558
src-tauri/src/proxy.rs
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
use axum::{
|
||||||
|
body::Body,
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderValue, StatusCode},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
|
use crate::db;
|
||||||
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
use image::GenericImageView;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::num::NonZeroUsize;
|
||||||
|
use lru::LruCache;
|
||||||
|
|
||||||
|
const CORE_API: &str = "http://localhost:3002";
|
||||||
|
const API_KEY: &str = "muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69";
|
||||||
|
const DIST_DIR: &str = "../dist";
|
||||||
|
const PROFILE_DIRS: [&str; 4] = [
|
||||||
|
"/Users/accusys/momentry/output/identities",
|
||||||
|
"/Users/accusys/momentry/output_dev/identities",
|
||||||
|
"/Volumes/external/momentry/output/identities",
|
||||||
|
"/Volumes/external/momentry/output_dev/identities",
|
||||||
|
];
|
||||||
|
|
||||||
|
static FACE_THUMB_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||||
|
static PROFILE_PROXY_CACHE: Mutex<Option<LruCache<String, String>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
fn get_face_thumb_cache() -> std::sync::MutexGuard<'static, Option<LruCache<String, String>>> {
|
||||||
|
let mut guard = FACE_THUMB_CACHE.lock().unwrap();
|
||||||
|
if guard.is_none() {
|
||||||
|
*guard = Some(LruCache::new(NonZeroUsize::new(500).unwrap()));
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profile_proxy_cache() -> std::sync::MutexGuard<'static, Option<LruCache<String, String>>> {
|
||||||
|
let mut guard = PROFILE_PROXY_CACHE.lock().unwrap();
|
||||||
|
if guard.is_none() {
|
||||||
|
*guard = Some(LruCache::new(NonZeroUsize::new(300).unwrap()));
|
||||||
|
}
|
||||||
|
guard
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ProxyState {
|
||||||
|
pub client: reqwest::Client,
|
||||||
|
pub serve_static: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_proxy_server() {
|
||||||
|
let serve_static = std::env::var("SERVE_STATIC").is_ok();
|
||||||
|
let state = ProxyState {
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(10))
|
||||||
|
.timeout(std::time::Duration::from_secs(600))
|
||||||
|
.pool_max_idle_per_host(10)
|
||||||
|
.build()
|
||||||
|
.unwrap(),
|
||||||
|
serve_static,
|
||||||
|
};
|
||||||
|
|
||||||
|
let cors = CorsLayer::new()
|
||||||
|
.allow_origin(Any)
|
||||||
|
.allow_methods(Any)
|
||||||
|
.allow_headers(Any);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", axum::routing::get(health_handler))
|
||||||
|
.route("/api/v1/face-thumbnail", axum::routing::get(get_face_thumbnail_handler))
|
||||||
|
.fallback(fallback_handler)
|
||||||
|
.layer(cors)
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = match tokio::net::TcpListener::bind("0.0.0.0:8888").await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[proxy] Failed to bind port 8888: {}. Proxy will not be available.", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eprintln!("[proxy] HTTP server listening on http://0.0.0.0:8888 (serve_static={})", serve_static);
|
||||||
|
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
eprintln!("[proxy] Server error: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_handler() -> &'static str {
|
||||||
|
"ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fallback_handler(State(state): State<ProxyState>, req: axum::extract::Request) -> Response {
|
||||||
|
let path = req.uri().path().to_string();
|
||||||
|
let method = req.method().clone();
|
||||||
|
|
||||||
|
// Handle identity profile GET locally
|
||||||
|
if method == axum::http::Method::GET {
|
||||||
|
if let Some(rest) = path.strip_prefix("/api/v1/identity/") {
|
||||||
|
if rest.ends_with("/profile") {
|
||||||
|
let uuid = rest.trim_end_matches("/profile").to_string();
|
||||||
|
return get_identity_profile_handler_inner(uuid).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle identity profile-image POST: proxy to Core API + invalidate cache
|
||||||
|
if method == axum::http::Method::POST {
|
||||||
|
if let Some(rest) = path.strip_prefix("/api/v1/identity/") {
|
||||||
|
if rest.ends_with("/profile-image") {
|
||||||
|
let uuid = rest.trim_end_matches("/profile-image").to_string();
|
||||||
|
let response = proxy_api(state, req).await;
|
||||||
|
// Invalidate profile cache on successful upload
|
||||||
|
let status = response.status();
|
||||||
|
if status == StatusCode::OK || status == StatusCode::CREATED {
|
||||||
|
let mut cache = get_profile_proxy_cache();
|
||||||
|
if let Some(c) = cache.as_mut() { c.pop(&uuid); }
|
||||||
|
eprintln!("[proxy] PROFILE cache invalidated for {}", uuid);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle search-history CRUD locally
|
||||||
|
if path.starts_with("/api/v1/search-history") || path.starts_with("/api/v1/bookmarks") {
|
||||||
|
return handle_local_api(req).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.starts_with("/api/") {
|
||||||
|
return proxy_api(state, req).await;
|
||||||
|
} else if state.serve_static {
|
||||||
|
return serve_spa(&path).await;
|
||||||
|
} else {
|
||||||
|
return (StatusCode::NOT_FOUND, "Not found").into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_local_api(req: axum::extract::Request) -> Response {
|
||||||
|
let path = req.uri().path().to_string();
|
||||||
|
let method = req.method().clone();
|
||||||
|
let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await.unwrap_or_default();
|
||||||
|
|
||||||
|
if method == axum::http::Method::GET && path == "/api/v1/search-history" {
|
||||||
|
return get_search_history_handler_inner();
|
||||||
|
}
|
||||||
|
if method == axum::http::Method::POST && path == "/api/v1/search-history" {
|
||||||
|
return save_search_history_handler_inner(body_bytes);
|
||||||
|
}
|
||||||
|
if method == axum::http::Method::PATCH {
|
||||||
|
if let Some(rest) = path.strip_prefix("/api/v1/search-history/") {
|
||||||
|
if let Some(id) = rest.strip_suffix("/rename") {
|
||||||
|
return rename_search_history_handler_inner(id.to_string(), body_bytes);
|
||||||
|
}
|
||||||
|
if let Some(id) = rest.strip_suffix("/pin") {
|
||||||
|
return pin_search_history_handler_inner(id.to_string(), body_bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if method == axum::http::Method::DELETE {
|
||||||
|
if let Some(id) = path.strip_prefix("/api/v1/search-history/") {
|
||||||
|
return delete_search_history_handler_inner(id.to_string());
|
||||||
|
}
|
||||||
|
if let Some(id_str) = path.strip_prefix("/api/v1/bookmarks/") {
|
||||||
|
if let Ok(id) = id_str.parse::<i64>() {
|
||||||
|
return delete_bookmark_handler_inner(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if method == axum::http::Method::GET && path == "/api/v1/bookmarks" {
|
||||||
|
return get_bookmarks_handler_inner();
|
||||||
|
}
|
||||||
|
if method == axum::http::Method::POST && path == "/api/v1/bookmarks" {
|
||||||
|
return save_bookmark_handler_inner(body_bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
(StatusCode::NOT_FOUND, "Not found").into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn proxy_api(state: ProxyState, req: axum::extract::Request) -> Response {
|
||||||
|
let path = req.uri().path().to_string();
|
||||||
|
let method = req.method().clone();
|
||||||
|
let headers = req.headers().clone();
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
|
||||||
|
let query_str = req.uri().query().unwrap_or("").to_string();
|
||||||
|
let mut url = format!("{}{}?api_key={}", CORE_API, path, API_KEY);
|
||||||
|
for pair in query_str.split('&') {
|
||||||
|
if pair.is_empty() || pair.starts_with("api_key=") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
url.push_str(&format!("&{}", pair));
|
||||||
|
}
|
||||||
|
|
||||||
|
let range_hdr = headers.get("range").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
||||||
|
eprintln!("[proxy] --> {} {} (core api) range={}", method, path, range_hdr);
|
||||||
|
|
||||||
|
let reqwest_method = match method.as_str() {
|
||||||
|
"POST" => reqwest::Method::POST,
|
||||||
|
"PUT" => reqwest::Method::PUT,
|
||||||
|
"PATCH" => reqwest::Method::PATCH,
|
||||||
|
"DELETE" => reqwest::Method::DELETE,
|
||||||
|
"HEAD" => reqwest::Method::HEAD,
|
||||||
|
"OPTIONS" => reqwest::Method::OPTIONS,
|
||||||
|
_ => reqwest::Method::GET,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut req_builder = state.client.request(reqwest_method, &url);
|
||||||
|
|
||||||
|
for (name, value) in headers.iter() {
|
||||||
|
let name_str = name.as_str();
|
||||||
|
if name_str == "host" || name_str == "origin" || name_str == "referer" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(v) = String::from_utf8(value.as_bytes().to_vec()) {
|
||||||
|
req_builder = req_builder.header(name_str, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024).await.unwrap_or_default();
|
||||||
|
if !body_bytes.is_empty() {
|
||||||
|
if method == axum::http::Method::POST && path.contains("/unbind") {
|
||||||
|
eprintln!("[proxy] UNBIND body: {}", String::from_utf8_lossy(&body_bytes));
|
||||||
|
}
|
||||||
|
req_builder = req_builder.body(body_bytes.to_vec());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a video request (stream instead of buffer)
|
||||||
|
let is_video_request = path.contains("/video") || headers.contains_key("range");
|
||||||
|
|
||||||
|
match req_builder.send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||||
|
let resp_headers = resp.headers().clone();
|
||||||
|
|
||||||
|
// Check if response is video content
|
||||||
|
let content_type = resp_headers.get("content-type")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
let should_stream = is_video_request
|
||||||
|
|| content_type.starts_with("video/")
|
||||||
|
|| content_type == "application/octet-stream";
|
||||||
|
|
||||||
|
if should_stream {
|
||||||
|
eprintln!("[proxy] <-- {} {} {} streaming {}ms cl={} ar={} cr={}",
|
||||||
|
method, path, status.as_u16(), start.elapsed().as_millis(),
|
||||||
|
resp_headers.get("content-length").and_then(|v| v.to_str().ok()).unwrap_or("-"),
|
||||||
|
resp_headers.get("accept-ranges").and_then(|v| v.to_str().ok()).unwrap_or("-"),
|
||||||
|
resp_headers.get("content-range").and_then(|v| v.to_str().ok()).unwrap_or("-"));
|
||||||
|
|
||||||
|
let mut builder = axum::http::Response::builder().status(status);
|
||||||
|
for (name, value) in resp_headers.iter() {
|
||||||
|
let name_str = name.as_str();
|
||||||
|
if name_str == "transfer-encoding" || name_str == "content-encoding" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) {
|
||||||
|
builder = builder.header(name_str, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stream the body directly
|
||||||
|
let stream_body = Body::from_stream(resp.bytes_stream());
|
||||||
|
return match builder.body(stream_body) {
|
||||||
|
Ok(r) => r.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[proxy] Error building streaming response: {}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Proxy error").into_response()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-video: buffer entire response
|
||||||
|
let resp_bytes = match resp.bytes().await {
|
||||||
|
Ok(b) => b.to_vec(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[proxy] Error reading response body: {}", e);
|
||||||
|
return (StatusCode::BAD_GATEWAY, "Error reading response").into_response();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let size = resp_bytes.len();
|
||||||
|
if method == axum::http::Method::POST && path.contains("/unbind") {
|
||||||
|
eprintln!("[proxy] UNBIND response: {} {}", status.as_u16(), String::from_utf8_lossy(&resp_bytes));
|
||||||
|
}
|
||||||
|
let mut builder = axum::http::Response::builder().status(status);
|
||||||
|
for (name, value) in resp_headers.iter() {
|
||||||
|
let name_str = name.as_str();
|
||||||
|
if name_str == "transfer-encoding" || name_str == "content-encoding" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(v) = HeaderValue::from_bytes(value.as_bytes()) {
|
||||||
|
builder = builder.header(name_str, v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let accept_ranges = resp_headers.get("accept-ranges").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
||||||
|
let content_range = resp_headers.get("content-range").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
||||||
|
let content_length = resp_headers.get("content-length").and_then(|v| v.to_str().ok()).unwrap_or("-");
|
||||||
|
eprintln!("[proxy] <-- {} {} {} {} {}ms cl={} ar={} cr={}", method, path, status.as_u16(), size, start.elapsed().as_millis(), content_length, accept_ranges, content_range);
|
||||||
|
match builder.body(Body::from(resp_bytes)) {
|
||||||
|
Ok(r) => r.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[proxy] Error building response: {}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Proxy error").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[proxy] <-- {} {} ERR {}ms: {}", method, path, start.elapsed().as_millis(), e);
|
||||||
|
(StatusCode::BAD_GATEWAY, format!("Proxy error: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_spa(path: &str) -> Response {
|
||||||
|
let file_path = path.trim_start_matches('/');
|
||||||
|
|
||||||
|
if file_path.is_empty() || file_path == "index.html" {
|
||||||
|
return serve_file(DIST_DIR, "index.html").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let safe_path = file_path.replace("..", "");
|
||||||
|
if tokio::fs::try_exists(format!("{}/{}", DIST_DIR, safe_path)).await.unwrap_or(false) {
|
||||||
|
return serve_file(DIST_DIR, &safe_path).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
serve_file(DIST_DIR, "index.html").await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_type_for(path: &str) -> &'static str {
|
||||||
|
match path.rsplit('.').next() {
|
||||||
|
Some("js") => "application/javascript; charset=utf-8",
|
||||||
|
Some("css") => "text/css; charset=utf-8",
|
||||||
|
Some("html") => "text/html; charset=utf-8",
|
||||||
|
Some("json") => "application/json",
|
||||||
|
Some("png") => "image/png",
|
||||||
|
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||||
|
Some("svg") => "image/svg+xml",
|
||||||
|
Some("ico") => "image/x-icon",
|
||||||
|
Some("woff") => "font/woff",
|
||||||
|
Some("woff2") => "font/woff2",
|
||||||
|
Some("map") => "application/json",
|
||||||
|
_ => "application/octet-stream",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_file(dir: &str, name: &str) -> Response {
|
||||||
|
match tokio::fs::read(format!("{}/{}", dir, name)).await {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let ct = content_type_for(name);
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[(axum::http::header::CONTENT_TYPE, HeaderValue::from_static(ct))],
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(_) => (StatusCode::NOT_FOUND, "Not found").into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Local SQLite API handlers =====
|
||||||
|
|
||||||
|
fn get_search_history_handler_inner() -> Response {
|
||||||
|
match db::get_search_history(Some(30)) {
|
||||||
|
Ok(items) => axum::Json(items).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_search_history_handler_inner(body: axum::body::Bytes) -> Response {
|
||||||
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
let id = req.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let query = req.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let title = req.get("title").and_then(|v| v.as_str()).unwrap_or(&query).to_string();
|
||||||
|
let chat_state = req.get("chat_state").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
let mode = req.get("mode").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
match db::save_search_history(id, query, title, chat_state, mode) {
|
||||||
|
Ok(item) => axum::Json(item).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rename_search_history_handler_inner(id: String, body: axum::body::Bytes) -> Response {
|
||||||
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(),
|
||||||
|
};
|
||||||
|
let title = req.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
match db::rename_search_history(id, title) {
|
||||||
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pin_search_history_handler_inner(id: String, body: axum::body::Bytes) -> Response {
|
||||||
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response(),
|
||||||
|
};
|
||||||
|
let pinned = req.get("pinned").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
match db::pin_search_history(id, pinned) {
|
||||||
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_search_history_handler_inner(id: String) -> Response {
|
||||||
|
match db::delete_search_history(id) {
|
||||||
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_bookmarks_handler_inner() -> Response {
|
||||||
|
match db::get_bookmarks() {
|
||||||
|
Ok(items) => axum::Json(items).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_bookmark_handler_inner(body: axum::body::Bytes) -> Response {
|
||||||
|
let req: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return (StatusCode::BAD_REQUEST, format!("Invalid JSON: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
let label = req.get("label").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||||
|
let history_id = req.get("history_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
match db::save_bookmark(label, history_id) {
|
||||||
|
Ok(item) => axum::Json(item).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_bookmark_handler_inner(id: i64) -> Response {
|
||||||
|
match db::delete_bookmark(id) {
|
||||||
|
Ok(()) => axum::Json(serde_json::json!({"success": true})).into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_identity_profile_handler_inner(uuid: String) -> Response {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let no_dash = uuid.replace('-', "");
|
||||||
|
eprintln!("[proxy] PROFILE handler called for {} (no_dash={})", uuid, no_dash);
|
||||||
|
{
|
||||||
|
let mut cache = get_profile_proxy_cache();
|
||||||
|
if let Some(c) = cache.as_mut() {
|
||||||
|
if let Some(val) = c.get(&uuid) {
|
||||||
|
eprintln!("[proxy] <-- PROFILE {} 200 (cached) {}ms", uuid, start.elapsed().as_millis());
|
||||||
|
return ([("content-type", "text/plain")], val.clone()).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = (|| -> Option<String> {
|
||||||
|
for dir in &PROFILE_DIRS {
|
||||||
|
for candidate in [&no_dash, &uuid] {
|
||||||
|
let path = format!("{}/{}/profile.jpg", dir, candidate);
|
||||||
|
if std::path::Path::new(&path).exists() {
|
||||||
|
eprintln!("[proxy] PROFILE FOUND: {}", path);
|
||||||
|
let bytes = std::fs::read(&path).ok()?;
|
||||||
|
return Some(format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
})();
|
||||||
|
match result {
|
||||||
|
Some(data) => {
|
||||||
|
let mut cache = get_profile_proxy_cache();
|
||||||
|
if let Some(c) = cache.as_mut() {
|
||||||
|
c.put(uuid.clone(), data.clone());
|
||||||
|
}
|
||||||
|
eprintln!("[proxy] <-- PROFILE {} 200 {} {}ms", uuid, data.len(), start.elapsed().as_millis());
|
||||||
|
([("content-type", "text/plain")], data).into_response()
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
eprintln!("[proxy] <-- PROFILE {} 404 {}ms", uuid, start.elapsed().as_millis());
|
||||||
|
(StatusCode::NOT_FOUND, "Profile image not found").into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_face_thumbnail_handler(State(state): State<ProxyState>, axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>) -> Response {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let uuid = match params.get("uuid") {
|
||||||
|
Some(v) => v.clone(),
|
||||||
|
None => return (StatusCode::BAD_REQUEST, "Missing uuid").into_response(),
|
||||||
|
};
|
||||||
|
let frame: u32 = params.get("frame").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||||
|
let bbox_x = params.get("bbox_x").and_then(|v| v.parse::<f64>().ok());
|
||||||
|
let bbox_y = params.get("bbox_y").and_then(|v| v.parse::<f64>().ok());
|
||||||
|
let bbox_w = params.get("bbox_w").and_then(|v| v.parse::<f64>().ok());
|
||||||
|
let bbox_h = params.get("bbox_h").and_then(|v| v.parse::<f64>().ok());
|
||||||
|
|
||||||
|
let cache_key = format!("{}:{}:{}:{}:{}:{}", uuid, frame,
|
||||||
|
bbox_x.map_or(0.0, |v| v),
|
||||||
|
bbox_y.map_or(0.0, |v| v),
|
||||||
|
bbox_w.map_or(0.0, |v| v),
|
||||||
|
bbox_h.map_or(0.0, |v| v));
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cache = get_face_thumb_cache();
|
||||||
|
if let Some(c) = cache.as_mut() {
|
||||||
|
if let Some(val) = c.get(&cache_key) {
|
||||||
|
eprintln!("[proxy] <-- FACE-THUMB {} (cached) {}ms", uuid, start.elapsed().as_millis());
|
||||||
|
return ([("content-type", "text/plain")], val.clone()).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = format!("{}/api/v1/file/{}/thumbnail?api_key={}&frame={}", CORE_API, uuid, API_KEY, frame);
|
||||||
|
|
||||||
|
let bytes = match state.client.get(&url).send().await {
|
||||||
|
Ok(resp) => match resp.bytes().await {
|
||||||
|
Ok(b) => b.to_vec(),
|
||||||
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Read failed: {}", e)).into_response(),
|
||||||
|
},
|
||||||
|
Err(e) => return (StatusCode::BAD_GATEWAY, format!("Request failed: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = if let (Some(bx), Some(by), Some(bw), Some(bh)) = (bbox_x, bbox_y, bbox_w, bbox_h) {
|
||||||
|
let img = match image::load_from_memory(&bytes) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("Image decode failed: {}", e)).into_response(),
|
||||||
|
};
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let (px, py, pw, ph) = if bx <= 1.0 && by <= 1.0 && bw <= 1.0 && bh <= 1.0 {
|
||||||
|
((bx * w as f64) as u32, (by * h as f64) as u32, (bw * w as f64) as u32, (bh * h as f64) as u32)
|
||||||
|
} else {
|
||||||
|
(bx as u32, by as u32, bw as u32, bh as u32)
|
||||||
|
};
|
||||||
|
let cx = px.min(w.saturating_sub(1));
|
||||||
|
let cy = py.min(h.saturating_sub(1));
|
||||||
|
let cw = pw.min(w.saturating_sub(cx)).max(1);
|
||||||
|
let ch = ph.min(h.saturating_sub(cy)).max(1);
|
||||||
|
let cropped = img.crop_imm(cx, cy, cw, ch);
|
||||||
|
let mut buf = std::io::Cursor::new(Vec::new());
|
||||||
|
if let Err(e) = cropped.write_to(&mut buf, image::ImageFormat::Jpeg) {
|
||||||
|
return (StatusCode::INTERNAL_SERVER_ERROR, format!("Encode failed: {}", e)).into_response();
|
||||||
|
}
|
||||||
|
format!("data:image/jpeg;base64,{}", STANDARD.encode(buf.into_inner()))
|
||||||
|
} else {
|
||||||
|
format!("data:image/jpeg;base64,{}", STANDARD.encode(&bytes))
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cache = get_face_thumb_cache();
|
||||||
|
if let Some(c) = cache.as_mut() {
|
||||||
|
c.put(cache_key, result.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!("[proxy] <-- FACE-THUMB {} {} {}ms", uuid, result.len(), start.elapsed().as_millis());
|
||||||
|
([("content-type", "text/plain")], result).into_response()
|
||||||
|
}
|
||||||
@@ -22,7 +22,13 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": null,
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": [
|
||||||
|
"**"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
34
src/App.vue
34
src/App.vue
@@ -66,21 +66,21 @@ onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
|
body { font-family: 'DM Sans', 'Noto Sans TC', -apple-system, BlinkMacSystemFont, sans-serif; background: #fff; color: #202124; }
|
||||||
#app { display: flex; min-height: 100vh; }
|
#app { display: flex; max-width: 1400px; margin: 0 auto; padding: 28px; gap: 28px; min-height: 100vh; box-sizing: border-box; align-items: stretch; }
|
||||||
.ms-side { width: 260px; background: #fff; border-right: 1px solid #e8eaed; display: flex; flex-direction: column; position: fixed; top: 0; left: 0; bottom: 0; z-index: 100; }
|
.ms-side { width: 240px; min-width: 240px; flex-shrink: 0; background: #fff; border-radius: 18px; padding: 18px 14px; box-shadow: 0 16px 38px rgba(0,0,0,0.10), 0 2px 8px rgba(0,0,0,0.06); display: flex; flex-direction: column; position: relative; height: calc(100vh - 125px); align-self: flex-start; overflow: visible; }
|
||||||
.gs-logo { padding: 16px 20px; font-size: 16px; font-weight: 700; border-bottom: 1px solid #e8eaed; }
|
.gs-logo { font-size: 15px; font-weight: 600; color: #202124; padding: 8px 12px 16px 12px; flex-shrink: 0; }
|
||||||
.gs-nav { flex: 1; padding: 8px 0; overflow-y: auto; }
|
.gs-nav { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
|
||||||
.gs-nav-item { display: flex; align-items: center; gap: 12px; padding: 10px 20px; color: #5f6368; text-decoration: none; font-size: 13px; font-weight: 500; border-left: 3px solid transparent; cursor: pointer; }
|
.gs-nav-item { display: flex; align-items: center; gap: 12px; height: 48px; padding: 0 12px; border-radius: 10px; text-decoration: none; color: #3c4043; font-weight: 600; font-size: 18px; line-height: 1; background: transparent; box-sizing: border-box; }
|
||||||
.gs-nav-item:hover { background: #f1f3f4; color: #202124; }
|
.gs-nav-item:hover { background: var(--ms-accent-soft); color: var(--ms-accent-text); }
|
||||||
.gs-nav-item.active { background: #e8f0fe; color: #1967d2; border-left-color: #1967d2; font-weight: 600; }
|
.gs-nav-item.active { background: var(--ms-accent-soft) !important; color: var(--ms-accent-text) !important; font-weight: 600; }
|
||||||
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; }
|
.gs-nav-icon { width: 24px; height: 24px; object-fit: contain; flex-shrink: 0; }
|
||||||
.gs-divider { height: 1px; background: #e8eaed; margin: 4px 0; }
|
.gs-divider { height: 1px; background: #eee; margin: 14px 8px; flex-shrink: 0; }
|
||||||
.gs-footer { padding: 12px 20px; border-top: 1px solid #e8eaed; }
|
.gs-footer { margin-top: auto; padding: 10px 12px; border-radius: 12px; display: flex; align-items: center; gap: 10px; background: #f8f9fa; flex-shrink: 0; }
|
||||||
.gs-theme-switcher { display: flex; gap: 6px; margin-bottom: 10px; }
|
.gs-theme-switcher { display: flex; gap: 5px; }
|
||||||
.gs-theme-btn { width: 32px; height: 32px; border: 1px solid #dadce0; background: #fff; border-radius: 8px; cursor: pointer; font-size: 14px; }
|
.gs-theme-btn { width: 14px; height: 14px; border-radius: 50%; border: 1px solid rgba(0,0,0,.08); background: #f3f3f3; cursor: pointer; padding: 0; }
|
||||||
.gs-theme-btn:hover { background: #f1f3f4; }
|
.gs-theme-btn:hover { transform: scale(1.08); }
|
||||||
.gs-theme-btn.active { border-color: #1967d2; background: #e8f0fe; }
|
.gs-theme-btn.active { box-shadow: 0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,.16); }
|
||||||
.gs-account-name { font-size: 12px; color: #80868b; }
|
.gs-account-name { font-size: 12px; color: #5f6368; }
|
||||||
.ms-main { margin-left: 260px; flex: 1; min-height: 100vh; background: #fff; }
|
.ms-main { flex: 1; min-width: 0; background: #fff; border-radius: 18px; padding: 36px 42px; overflow-y: auto; overflow-x: hidden; }
|
||||||
.ms-content { padding: 24px 32px; max-width: 1200px; }
|
.ms-content { max-width: 1200px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
6
src/api/config.ts
Normal file
6
src/api/config.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export const isTauri = typeof window !== 'undefined' && !!(window as any).__TAURI__
|
||||||
|
|
||||||
|
export function getApiBase(): string {
|
||||||
|
if (isTauri) return ''
|
||||||
|
return localStorage.getItem('proxy_url') || window.location.origin
|
||||||
|
}
|
||||||
446
src/api/index.ts
Normal file
446
src/api/index.ts
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
import { isTauri, getApiBase } from './config'
|
||||||
|
|
||||||
|
// API proxy: Tauri IPC or HTTP fetch
|
||||||
|
export async function apiCall(cmd: string, args: Record<string, any>): Promise<any> {
|
||||||
|
if (cmd === 'get_video_stream') {
|
||||||
|
const base = isTauri ? 'http://localhost:8888' : getApiBase()
|
||||||
|
const { url } = buildHttpRequest(cmd, args)
|
||||||
|
return `${base}${url}`
|
||||||
|
}
|
||||||
|
if (cmd === 'upload_profile_image') {
|
||||||
|
if (isTauri) {
|
||||||
|
return invoke('upload_profile_image', { uuid: args.uuid, filePath: args.filePath })
|
||||||
|
}
|
||||||
|
const base = getApiBase()
|
||||||
|
const { url } = buildHttpRequest(cmd, args)
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('image', args.file)
|
||||||
|
const fullUrl = `${base}${url}`
|
||||||
|
const response = await fetch(fullUrl, { method: 'POST', body: formData })
|
||||||
|
if (!response.ok) throw new Error(`Upload failed: ${response.status}`)
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
if (!isTauri && (cmd === 'update_identity_starred' || cmd === 'update_identity_status')) {
|
||||||
|
const current: any = await httpCall('get_identity', { uuid: args.uuid })
|
||||||
|
const metadata = { ...(current.metadata || {}), ...(current.metadataJson ? JSON.parse(current.metadataJson) : {}) }
|
||||||
|
if (cmd === 'update_identity_starred') {
|
||||||
|
metadata.starred = args.starred
|
||||||
|
} else {
|
||||||
|
metadata.status = args.status
|
||||||
|
}
|
||||||
|
return httpCall('update_identity', { uuid: args.uuid, metadataJson: JSON.stringify(metadata) })
|
||||||
|
}
|
||||||
|
if (isTauri) {
|
||||||
|
return invoke(cmd, args)
|
||||||
|
}
|
||||||
|
const data = await httpCall(cmd, args)
|
||||||
|
return transformResponse(cmd, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function httpCall(cmd: string, args: Record<string, any>, retries = 3): Promise<any> {
|
||||||
|
const base = getApiBase()
|
||||||
|
const { url, method, body } = buildHttpRequest(cmd, args)
|
||||||
|
const fullUrl = `${base}${url}`
|
||||||
|
|
||||||
|
let response: Response | null = null
|
||||||
|
for (let i = 0; i < retries; i++) {
|
||||||
|
try {
|
||||||
|
response = await fetch(fullUrl, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (response.ok || response.status < 500) break
|
||||||
|
} catch (e: any) {
|
||||||
|
if (i < retries - 1) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000 * (i + 1)))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response) throw new Error('No response')
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') || ''
|
||||||
|
|
||||||
|
if (contentType.includes('image/') || contentType.includes('video/') || contentType.includes('octet-stream')) {
|
||||||
|
const buffer = await response.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
if (cmd === 'get_identity_profile' || cmd === 'get_thumbnail' || cmd === 'get_face_thumbnail') {
|
||||||
|
const ext = contentType.includes('png') ? 'png' : 'jpeg'
|
||||||
|
const blob = new Blob([bytes], { type: `image/${ext}` })
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = () => reject(new Error('FileReader failed'))
|
||||||
|
reader.readAsDataURL(blob)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const chunks: string[] = []
|
||||||
|
const chunkSize = 8192
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const slice = bytes.subarray(i, Math.min(i + chunkSize, bytes.length))
|
||||||
|
chunks.push(String.fromCharCode(...slice))
|
||||||
|
}
|
||||||
|
return btoa(chunks.join(''))
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text()
|
||||||
|
try {
|
||||||
|
return JSON.parse(text)
|
||||||
|
} catch {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string; method: string; body?: any } {
|
||||||
|
const a = args
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
// --- Data APIs ---
|
||||||
|
case 'get_files': {
|
||||||
|
const ps = a.args?.pageSize || 500
|
||||||
|
return { url: `/api/v1/files/scan?page_size=${ps}`, method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'get_people': {
|
||||||
|
return { url: `/api/v1/identities?page=${a.page || 1}&per_page=${a.perPage || 100}`, method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'get_faces': {
|
||||||
|
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' }
|
||||||
|
}
|
||||||
|
case 'get_face_candidates': {
|
||||||
|
return { url: `/api/v1/faces/candidates?page=${a.page || 1}&page_size=${a.perPage || 100}`, method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'get_identity': {
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}`, method: 'GET' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search APIs ---
|
||||||
|
case 'search_llm_smart': {
|
||||||
|
return { url: '/api/v1/search/llm-smart', method: 'POST', body: { query: a.query, limit: a.limit || 20 } }
|
||||||
|
}
|
||||||
|
case 'search_agents': {
|
||||||
|
const body: any = { query: a.query }
|
||||||
|
if (a.conversationId) body.conversation_id = a.conversationId
|
||||||
|
return { url: '/api/v1/agents/search', method: 'POST', body }
|
||||||
|
}
|
||||||
|
case 'search_identities': {
|
||||||
|
return { url: `/api/v1/identities/search?q=${encodeURIComponent(a.query)}&limit=${a.limit || 50}`, method: 'GET' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image APIs ---
|
||||||
|
case 'get_thumbnail': {
|
||||||
|
return { url: `/api/v1/file/${a.uuid}/thumbnail?frame=${a.frame || 30}`, method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'get_identity_profile': {
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/profile`, method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'get_face_thumbnail': {
|
||||||
|
let url = `/api/v1/face-thumbnail?uuid=${a.uuid}&frame=${a.frame || 0}`
|
||||||
|
if (a.bboxX != null) url += `&bbox_x=${a.bboxX}`
|
||||||
|
if (a.bboxY != null) url += `&bbox_y=${a.bboxY}`
|
||||||
|
if (a.bboxW != null) url += `&bbox_w=${a.bboxW}`
|
||||||
|
if (a.bboxH != null) url += `&bbox_h=${a.bboxH}`
|
||||||
|
return { url, method: 'GET' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Video API ---
|
||||||
|
case 'get_video_stream': {
|
||||||
|
let url = `/api/v1/file/${a.uuid}/video?start_time=${a.startTime}&end_time=${a.endTime}`
|
||||||
|
if (a.startFrame != null) url += `&start_frame=${a.startFrame}`
|
||||||
|
if (a.endFrame != null) url += `&end_frame=${a.endFrame}`
|
||||||
|
return { url, method: 'GET' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Identity Management ---
|
||||||
|
case 'update_identity': {
|
||||||
|
const meta: any = a.metadataJson ? JSON.parse(a.metadataJson) : {}
|
||||||
|
const body: any = {}
|
||||||
|
if (a.name) body.name = a.name
|
||||||
|
if (Object.keys(meta).length) body.metadata = meta
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}`, method: 'PATCH', body }
|
||||||
|
}
|
||||||
|
case 'update_identity_name': {
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}`, method: 'PATCH', body: { name: a.name } }
|
||||||
|
}
|
||||||
|
case 'update_identity_status': {
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}`, method: 'PATCH', body: { metadata: { status: a.status } } }
|
||||||
|
}
|
||||||
|
case 'update_identity_starred': {
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}`, method: 'PATCH', body: { metadata: { starred: a.starred } } }
|
||||||
|
}
|
||||||
|
case 'upload_profile_image': {
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/profile-image`, method: 'POST' }
|
||||||
|
}
|
||||||
|
case 'delete_identity': {
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}`, method: 'DELETE' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Identity Operations ---
|
||||||
|
case 'merge_identities': {
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/mergeinto`, method: 'POST', body: { into_uuid: a.intoUuid } }
|
||||||
|
}
|
||||||
|
case 'bind_face': {
|
||||||
|
const bindBody: any = { file_uuid: a.fileUuid }
|
||||||
|
if (a.faceId) bindBody.face_id = a.faceId
|
||||||
|
if (a.faceRowId) bindBody.id = a.faceRowId
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/bind`, method: 'POST', body: bindBody }
|
||||||
|
}
|
||||||
|
case 'unbind_face': {
|
||||||
|
const unbindBody: any = { file_uuid: a.fileUuid }
|
||||||
|
if (a.faceId) unbindBody.face_id = a.faceId
|
||||||
|
if (a.faceRowId) unbindBody.id = a.faceRowId
|
||||||
|
if (a.frameNumber != null) unbindBody.frame_number = a.frameNumber
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/unbind`, method: 'POST', body: unbindBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Identity Undo/Redo ---
|
||||||
|
case 'identity_undo': {
|
||||||
|
const body: any = {}
|
||||||
|
if (a.steps != null) body.steps = a.steps
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/undo`, method: 'POST', body }
|
||||||
|
}
|
||||||
|
case 'identity_redo': {
|
||||||
|
const body: any = {}
|
||||||
|
if (a.steps != null) body.steps = a.steps
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/redo`, method: 'POST', body }
|
||||||
|
}
|
||||||
|
case 'identity_history': {
|
||||||
|
let url = `/api/v1/identity/${a.uuid}/history`
|
||||||
|
const params: string[] = []
|
||||||
|
if (a.page != null) params.push(`page=${a.page}`)
|
||||||
|
if (a.pageSize != null) params.push(`page_size=${a.pageSize}`)
|
||||||
|
if (params.length) url += '?' + params.join('&')
|
||||||
|
return { url, method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'identity_bind_undo': {
|
||||||
|
const body: any = {}
|
||||||
|
if (a.steps != null) body.steps = a.steps
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/bind/undo`, method: 'POST', body }
|
||||||
|
}
|
||||||
|
case 'identity_bind_redo': {
|
||||||
|
const body: any = {}
|
||||||
|
if (a.steps != null) body.steps = a.steps
|
||||||
|
return { url: `/api/v1/identity/${a.uuid}/bind/redo`, method: 'POST', body }
|
||||||
|
}
|
||||||
|
case 'identity_bind_history': {
|
||||||
|
let url = `/api/v1/identity/${a.uuid}/bind/history`
|
||||||
|
const params: string[] = []
|
||||||
|
if (a.page != null) params.push(`page=${a.page}`)
|
||||||
|
if (a.pageSize != null) params.push(`page_size=${a.pageSize}`)
|
||||||
|
if (params.length) url += '?' + params.join('&')
|
||||||
|
return { url, method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'merge_undo': {
|
||||||
|
return { url: `/api/v1/identity/merge/${a.mergeId}/undo`, method: 'POST' }
|
||||||
|
}
|
||||||
|
case 'merge_redo': {
|
||||||
|
return { url: `/api/v1/identity/merge/${a.mergeId}/redo`, method: 'POST' }
|
||||||
|
}
|
||||||
|
case 'merge_history': {
|
||||||
|
let url = '/api/v1/identity/merge/history'
|
||||||
|
const params: string[] = []
|
||||||
|
if (a.sourceUuid) params.push(`source_uuid=${encodeURIComponent(a.sourceUuid)}`)
|
||||||
|
if (a.targetUuid) params.push(`target_uuid=${encodeURIComponent(a.targetUuid)}`)
|
||||||
|
if (a.page != null) params.push(`page=${a.page}`)
|
||||||
|
if (a.pageSize != null) params.push(`page_size=${a.pageSize}`)
|
||||||
|
if (params.length) url += '?' + params.join('&')
|
||||||
|
return { url, method: 'GET' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File Operations ---
|
||||||
|
case 'register_file': {
|
||||||
|
return { url: '/api/v1/files/register', method: 'POST', body: { file_path: a.filePath } }
|
||||||
|
}
|
||||||
|
case 'process_file': {
|
||||||
|
return { url: `/api/v1/file/${a.fileUuid}/process`, method: 'POST', body: { processors: a.processors } }
|
||||||
|
}
|
||||||
|
case 'unregister_file': {
|
||||||
|
const body: any = { file_uuid: a.fileUuid }
|
||||||
|
if (a.deleteOutputFiles != null) body.delete_output_files = a.deleteOutputFiles
|
||||||
|
return { url: '/api/v1/unregister', method: 'POST', body }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- File Detail ---
|
||||||
|
case 'get_file_info': {
|
||||||
|
return { url: `/api/v1/file/${a.uuid}`, method: 'GET' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search History ---
|
||||||
|
case 'get_search_history': {
|
||||||
|
const limit = a.limit ?? 30
|
||||||
|
return { url: `/api/v1/search-history?limit=${limit}`, method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'save_search_history': {
|
||||||
|
return { url: '/api/v1/search-history', method: 'POST', body: { id: a.id, query: a.query, title: a.title, chat_state: a.chatState, mode: a.mode } }
|
||||||
|
}
|
||||||
|
case 'rename_search_history': {
|
||||||
|
return { url: `/api/v1/search-history/${a.id}/rename`, method: 'PATCH', body: { title: a.title } }
|
||||||
|
}
|
||||||
|
case 'pin_search_history': {
|
||||||
|
return { url: `/api/v1/search-history/${a.id}/pin`, method: 'PATCH', body: { pinned: a.pinned } }
|
||||||
|
}
|
||||||
|
case 'delete_search_history': {
|
||||||
|
return { url: `/api/v1/search-history/${a.id}`, method: 'DELETE' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bookmarks ---
|
||||||
|
case 'get_bookmarks': {
|
||||||
|
return { url: '/api/v1/bookmarks', method: 'GET' }
|
||||||
|
}
|
||||||
|
case 'save_bookmark': {
|
||||||
|
return { url: '/api/v1/bookmarks', method: 'POST', body: { label: a.label, history_id: a.historyId } }
|
||||||
|
}
|
||||||
|
case 'delete_bookmark': {
|
||||||
|
return { url: `/api/v1/bookmarks/${a.id}`, method: 'DELETE' }
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown command: ${cmd}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 || []
|
||||||
|
return files.map((f: any) => ({
|
||||||
|
file_uuid: f.file_uuid || '',
|
||||||
|
file_name: f.file_name || '',
|
||||||
|
file_path: f.file_path || '',
|
||||||
|
file_size: f.file_size || 0,
|
||||||
|
modified_time: f.modified_time || '',
|
||||||
|
isRegistered: f.is_registered ?? false,
|
||||||
|
status: f.status || '',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
case 'get_people': {
|
||||||
|
const identities = data.identities || data.data || data || []
|
||||||
|
return identities.map((p: any) => ({
|
||||||
|
identity_uuid: p.identity_uuid || '',
|
||||||
|
name: p.name || '',
|
||||||
|
starred: p.metadata?.starred ?? p.starred ?? false,
|
||||||
|
status: p.metadata?.status ?? p.status ?? 'pending',
|
||||||
|
metadata: p.metadata || {},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
case 'get_faces': {
|
||||||
|
const faces = data.data || data.faces || data || []
|
||||||
|
return faces.map((f: any) => ({
|
||||||
|
id: f.id,
|
||||||
|
file_uuid: f.file_uuid || '',
|
||||||
|
frame_number: f.frame_number || 0,
|
||||||
|
timestamp_secs: f.timestamp_secs || 0,
|
||||||
|
face_id: f.face_id ?? null,
|
||||||
|
confidence: f.confidence || 0,
|
||||||
|
bbox: f.bbox ? { x: f.bbox.x, y: f.bbox.y, width: f.bbox.width, height: f.bbox.height } : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
case 'get_face_candidates': {
|
||||||
|
const candidates = data.candidates || data.data || data || []
|
||||||
|
return candidates.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
face_id: c.face_id ?? null,
|
||||||
|
file_uuid: c.file_uuid || '',
|
||||||
|
frame_number: c.frame_number || 0,
|
||||||
|
confidence: c.confidence || 0,
|
||||||
|
bbox: c.bbox ? { x: c.bbox.x, y: c.bbox.y, width: c.bbox.width, height: c.bbox.height } : null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
case 'search_identities': {
|
||||||
|
const results = data.results || data.data || data || []
|
||||||
|
return results.map((r: any) => ({
|
||||||
|
identity_id: r.identity_id,
|
||||||
|
name: r.name,
|
||||||
|
source: r.source || '',
|
||||||
|
tmdb_id: r.tmdb_id ?? null,
|
||||||
|
file_uuid: r.file_uuid ?? null,
|
||||||
|
start_time: r.start_time ?? 0,
|
||||||
|
end_time: r.end_time ?? 0,
|
||||||
|
start_frame: r.start_frame ?? null,
|
||||||
|
end_frame: r.end_frame ?? null,
|
||||||
|
text_content: r.text_content ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
case 'register_file': {
|
||||||
|
return {
|
||||||
|
success: data.success ?? false,
|
||||||
|
file_uuid: data.file_uuid ?? '',
|
||||||
|
file_name: data.file_name ?? '',
|
||||||
|
message: data.message ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'process_file': {
|
||||||
|
return {
|
||||||
|
success: data.success ?? false,
|
||||||
|
file_uuid: data.file_uuid ?? '',
|
||||||
|
message: data.message ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'unregister_file': {
|
||||||
|
return {
|
||||||
|
success: data.success ?? false,
|
||||||
|
file_uuid: data.file_uuid ?? '',
|
||||||
|
message: data.message ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'update_identity':
|
||||||
|
case 'upload_profile_image':
|
||||||
|
case 'get_identity_profile':
|
||||||
|
case 'get_face_thumbnail':
|
||||||
|
case 'get_search_history':
|
||||||
|
case 'save_search_history':
|
||||||
|
case 'rename_search_history':
|
||||||
|
case 'pin_search_history':
|
||||||
|
case 'delete_search_history':
|
||||||
|
case 'get_bookmarks':
|
||||||
|
case 'save_bookmark':
|
||||||
|
case 'delete_bookmark':
|
||||||
|
case 'identity_undo':
|
||||||
|
case 'identity_redo':
|
||||||
|
case 'identity_history':
|
||||||
|
case 'identity_bind_undo':
|
||||||
|
case 'identity_bind_redo':
|
||||||
|
case 'identity_bind_history':
|
||||||
|
case 'merge_undo':
|
||||||
|
case 'merge_redo':
|
||||||
|
case 'merge_history':
|
||||||
|
return data
|
||||||
|
default:
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +1,71 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="visible" class="video-player-modal" @click.self="close">
|
<div v-if="visible" class="ms-modal-overlay show" @click.self="close">
|
||||||
<div class="video-player-box">
|
<div class="ms-modal ms-modal-video">
|
||||||
<button class="close-btn" @click="close">×</button>
|
<div class="ms-modal-video-header">
|
||||||
|
<h3 class="ms-modal-video-title">{{ title }}</h3>
|
||||||
<div v-if="videoLoading" class="video-loading">
|
<span class="ms-video-seg-info">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||||
<div class="loading-spinner"></div>
|
<button class="ms-modal-video-close" @click="close">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="videoError" class="ms-video-loading">
|
||||||
|
<p style="color:#f44336">{{ videoError }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="videoLoading" class="ms-video-loading">
|
||||||
|
<div class="ms-video-loading-spinner"></div>
|
||||||
<p>Loading video...</p>
|
<p>Loading video...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<video v-show="!videoLoading" ref="videoEl" class="video" controls autoplay @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate">
|
<video v-if="videoSrc && !videoLoading" ref="videoEl" :src="videoSrc" class="video" controls autoplay playsinline @loadedmetadata="onLoaded" @timeupdate="onTimeUpdate" @error="onVideoError"></video>
|
||||||
<source :src="videoSrc" type="video/mp4" />
|
|
||||||
</video>
|
<!-- Timeline bar (all raw traces) -->
|
||||||
|
<div class="ms-video-timeline-wrap" v-if="!simple && allTraces.length && !videoLoading">
|
||||||
<div class="video-info">
|
<div class="ms-video-tl-bar">
|
||||||
<h3>{{ title }}</h3>
|
<div
|
||||||
<div class="time-display">
|
v-for="(dot, i) in timelineMarkers"
|
||||||
<span>{{ formatTime(currentTime) }}</span>
|
:key="i"
|
||||||
<span class="separator">/</span>
|
class="ms-video-tl-dot"
|
||||||
<span>{{ formatTime(duration) }}</span>
|
:class="{ active: i === currentTraceIdx }"
|
||||||
<span v-if="hasRange" class="range">({{ formatTime(rangeStart) }} - {{ formatTime(rangeEnd) }})</span>
|
:style="{ left: dot.pct + '%', width: dot.w + '%' }"
|
||||||
|
@click="loadTrace(dot.idx)"
|
||||||
|
>
|
||||||
|
<span class="ms-video-tl-tip">{{ formatTime(dot.start) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-video-tl-labels">
|
||||||
|
<span>{{ formatTime(tlStart) }}</span>
|
||||||
|
<span>{{ formatTime(tlEnd) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasRange && !videoLoading" class="segment-controls">
|
<div class="ms-video-nav" v-if="!videoLoading">
|
||||||
<button @click="seekToStart" class="seg-btn">⏮ 跳到起點</button>
|
<div v-if="!simple && allTraces.length" style="display:flex;align-items:center;gap:10px;flex:1;">
|
||||||
<button @click="togglePlay" class="seg-btn">{{ isPlaying ? '⏸ 暫停' : '▶ 播放' }}</button>
|
<button class="ms-fm-btn ms-video-nav-btn" @click="prevTrace" :disabled="currentTraceIdx <= 0">← 上一個</button>
|
||||||
<button @click="seekToEnd" class="seg-btn">跳到結尾 ⏭</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>
|
||||||
|
<span v-else class="ms-video-seg-info">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { apiCall } from '@/api'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
fileUuid: string
|
fileUuid: string
|
||||||
startTime?: number
|
startTime?: number
|
||||||
endTime?: number
|
endTime?: number
|
||||||
|
allTraces?: any[]
|
||||||
|
initialTraceIdx?: number
|
||||||
title?: string
|
title?: string
|
||||||
}>()
|
simple?: boolean
|
||||||
|
}>(), {
|
||||||
|
allTraces: () => [],
|
||||||
|
initialTraceIdx: 0,
|
||||||
|
simple: false,
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
@@ -51,13 +76,81 @@ const duration = ref(0)
|
|||||||
const isPlaying = ref(false)
|
const isPlaying = ref(false)
|
||||||
const videoSrc = ref('')
|
const videoSrc = ref('')
|
||||||
const videoLoading = ref(true)
|
const videoLoading = ref(true)
|
||||||
|
const videoError = ref('')
|
||||||
|
const currentTraceIdx = ref(props.initialTraceIdx ?? 0)
|
||||||
|
const curFileUuid = ref(props.fileUuid)
|
||||||
|
|
||||||
const hasRange = computed(() => {
|
// Timeline computation
|
||||||
return props.startTime !== undefined && props.endTime !== undefined
|
const tlStart = computed(() => {
|
||||||
|
if (!props.allTraces.length) return 0
|
||||||
|
return props.allTraces[0].first_sec || props.allTraces[0].start_time || 0
|
||||||
|
})
|
||||||
|
const tlEnd = computed(() => {
|
||||||
|
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(() => {
|
||||||
|
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
|
||||||
|
return {
|
||||||
|
idx: i,
|
||||||
|
start: st,
|
||||||
|
end: en,
|
||||||
|
pct: ((st - tlStart.value) / tlRange.value) * 100,
|
||||||
|
w: Math.max(0.5, ((en - st) / tlRange.value) * 100),
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const rangeStart = computed(() => props.startTime ?? 0)
|
async function loadTrace(idx: number) {
|
||||||
const rangeEnd = computed(() => props.endTime ?? 0)
|
const traces = props.allTraces
|
||||||
|
if (idx < 0 || idx >= traces.length) return
|
||||||
|
currentTraceIdx.value = 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)
|
||||||
|
|
||||||
|
curFileUuid.value = fu
|
||||||
|
// Preserve volume across reloads
|
||||||
|
const prevVolume = videoEl.value?.volume ?? 1
|
||||||
|
videoLoading.value = true
|
||||||
|
videoError.value = ''
|
||||||
|
videoSrc.value = ''
|
||||||
|
try {
|
||||||
|
const data = await apiCall('get_video_stream', {
|
||||||
|
uuid: fu,
|
||||||
|
startTime: st,
|
||||||
|
endTime: en,
|
||||||
|
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')
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
videoLoading.value = false
|
||||||
|
await nextTick()
|
||||||
|
const el = videoEl.value
|
||||||
|
if (el) {
|
||||||
|
el.volume = prevVolume
|
||||||
|
el.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
@@ -65,51 +158,46 @@ function close() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onLoaded() {
|
function onLoaded() {
|
||||||
if (videoEl.value && props.startTime) {
|
const el = videoEl.value
|
||||||
videoEl.value.currentTime = props.startTime
|
if (!el) return
|
||||||
videoEl.value.play()
|
const t = props.allTraces[currentTraceIdx.value]
|
||||||
isPlaying.value = true
|
if (t) {
|
||||||
|
const off = props.startTime ?? 0
|
||||||
|
el.currentTime = (t.first_sec || t.start_time || 0) - off
|
||||||
|
} else if (props.startTime) {
|
||||||
|
el.currentTime = 0
|
||||||
}
|
}
|
||||||
|
el.play().catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTimeUpdate() {
|
function onTimeUpdate() {
|
||||||
if (videoEl.value) {
|
if (!videoEl.value) return
|
||||||
currentTime.value = videoEl.value.currentTime
|
currentTime.value = videoEl.value.currentTime
|
||||||
duration.value = videoEl.value.duration || 0
|
duration.value = videoEl.value.duration || 0
|
||||||
|
}
|
||||||
// 如果播放到 range end,暫停
|
|
||||||
if (hasRange.value && videoEl.value.currentTime >= (props.endTime ?? 0)) {
|
function onVideoError() {
|
||||||
videoEl.value.pause()
|
const el = videoEl.value
|
||||||
isPlaying.value = false
|
if (el) {
|
||||||
}
|
const msg = el.error?.message || 'unknown error'
|
||||||
|
videoError.value = `Video playback error: ${msg}`
|
||||||
|
} else {
|
||||||
|
videoError.value = 'Video playback error'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekToStart() {
|
function prevTrace() {
|
||||||
if (videoEl.value && props.startTime) {
|
if (currentTraceIdx.value > 0) loadTrace(currentTraceIdx.value - 1)
|
||||||
videoEl.value.currentTime = props.startTime
|
|
||||||
videoEl.value.play()
|
|
||||||
isPlaying.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function seekToEnd() {
|
function nextTrace() {
|
||||||
if (videoEl.value && props.endTime) {
|
if (currentTraceIdx.value < props.allTraces.length - 1) loadTrace(currentTraceIdx.value + 1)
|
||||||
videoEl.value.currentTime = props.endTime - 1
|
|
||||||
videoEl.value.play()
|
|
||||||
isPlaying.value = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function togglePlay() {
|
function togglePlay() {
|
||||||
if (videoEl.value) {
|
if (videoEl.value) {
|
||||||
if (videoEl.value.paused) {
|
if (videoEl.value.paused) { videoEl.value.play(); isPlaying.value = true }
|
||||||
videoEl.value.play()
|
else { videoEl.value.pause(); isPlaying.value = false }
|
||||||
isPlaying.value = true
|
|
||||||
} else {
|
|
||||||
videoEl.value.pause()
|
|
||||||
isPlaying.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,38 +208,47 @@ function formatTime(sec: number): string {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 鍵盤快捷鍵
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (!visible.value) return
|
if (!visible.value) return
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Escape':
|
case 'Escape': close(); break
|
||||||
close()
|
case ' ': e.preventDefault(); togglePlay(); break
|
||||||
break
|
case 'ArrowLeft': if (videoEl.value) videoEl.value.currentTime = Math.max(0, videoEl.value.currentTime - 5); break
|
||||||
case ' ':
|
case 'ArrowRight': if (videoEl.value) videoEl.value.currentTime = Math.min(duration.value, videoEl.value.currentTime + 5); break
|
||||||
e.preventDefault()
|
|
||||||
togglePlay()
|
|
||||||
break
|
|
||||||
case 'ArrowLeft':
|
|
||||||
if (videoEl.value) videoEl.value.currentTime = Math.max(0, videoEl.value.currentTime - 5)
|
|
||||||
break
|
|
||||||
case 'ArrowRight':
|
|
||||||
if (videoEl.value) videoEl.value.currentTime = Math.min(duration.value, videoEl.value.currentTime + 5)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
document.addEventListener('keydown', onKeydown)
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
if (!props.fileUuid || props.fileUuid === 'undefined') {
|
||||||
|
videoError.value = 'No file associated with this result'
|
||||||
|
videoLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
videoSrc.value = await invoke('get_video_stream', {
|
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,
|
uuid: props.fileUuid,
|
||||||
startTime: props.startTime ?? 0,
|
startTime: st,
|
||||||
endTime: props.endTime ?? 99999
|
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) {
|
} catch (e: any) {
|
||||||
console.error('Video stream failed:', e)
|
videoError.value = typeof e === 'string' ? e : (e?.message || 'Failed to load video')
|
||||||
} finally {
|
} finally {
|
||||||
videoLoading.value = false
|
videoLoading.value = false
|
||||||
|
await nextTick()
|
||||||
|
videoEl.value?.load()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -161,52 +258,29 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.video-player-modal {
|
.ms-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.3); z-index: 999; display: grid; place-items: center; }
|
||||||
position: fixed; inset: 0;
|
.ms-modal { background: #fff; border-radius: 20px; max-width: 380px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.18); }
|
||||||
background: rgba(0,0,0,0.85);
|
.ms-modal-video { max-width: 720px; width: 95%; padding: 20px 24px 24px; text-align: left; background: #1a1a1a; }
|
||||||
display: flex; align-items: center; justify-content: center;
|
.ms-modal-video-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||||
z-index: 2000;
|
.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; }
|
||||||
animation: fadeIn 0.2s ease;
|
.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; }
|
||||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
.video { max-width: 100%; max-height: 60vh; border-radius: 8px; display: block; width: 100%; }
|
||||||
.video-player-box {
|
.ms-video-loading { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 20px; color: #5f6368; font-size: 13px; }
|
||||||
background: #111;
|
.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; }
|
||||||
border-radius: 16px;
|
@keyframes ms-spin { to { transform: rotate(360deg); } }
|
||||||
padding: 20px;
|
.ms-video-timeline-wrap { padding: 14px 0 6px; }
|
||||||
max-width: 90vw;
|
.ms-video-tl-bar { position: relative; width: 100%; height: 4px; background: rgba(255,255,255,0.15); border-radius: 999px; margin-bottom: 8px; overflow: visible; }
|
||||||
position: relative;
|
.ms-video-tl-dot { position: absolute; top: 50%; transform: translate(0, -50%); height: 8px; border-radius: 4px; background: #9aa0a6; cursor: pointer; transition: height .12s, background .12s; z-index: 2; }
|
||||||
animation: slideUp 0.3s ease;
|
.ms-video-tl-dot:hover { height: 12px; background: #fff; }
|
||||||
}
|
.ms-video-tl-dot.active { background: #fff; box-shadow: 0 0 0 2.5px rgba(255,255,255,0.35); }
|
||||||
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
.ms-video-tl-labels { display: flex; justify-content: space-between; font-size: 10px; color: rgba(255,255,255,0.45); }
|
||||||
.close-btn {
|
.ms-video-tl-tip { display: none; position: absolute; bottom: 16px; transform: translateX(-50%); background: rgba(0,0,0,.75); color: #fff; font-size: 10px; padding: 2px 7px; border-radius: 4px; white-space: nowrap; pointer-events: none; z-index: 10; }
|
||||||
position: absolute; top: -12px; right: -12px;
|
.ms-video-tl-dot:hover .ms-video-tl-tip { display: block; }
|
||||||
width: 36px; height: 36px; border-radius: 50%;
|
.ms-video-nav { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 10px; }
|
||||||
background: #fff; border: none; font-size: 1.5rem;
|
.ms-video-seg-info { font-size: 12px; color: rgba(255,255,255,0.6); flex-shrink: 0; }
|
||||||
cursor: pointer; z-index: 10;
|
.ms-video-seg-info2 { font-size: 12px; color: #9aa0a6; flex: 1; text-align: center; }
|
||||||
display: flex; align-items: center; justify-content: center;
|
.ms-video-nav-btn { background: #2c2c2c; border-color: #3c3c3c; color: #e8eaed; }
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
.ms-video-nav-btn:hover { background: #3c3c3c; }
|
||||||
}
|
.ms-video-nav-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
.close-btn:hover { background: #f3f4f6; }
|
</style>
|
||||||
.video {
|
|
||||||
max-width: 80vw; max-height: 60vh;
|
|
||||||
border-radius: 8px; display: block;
|
|
||||||
}
|
|
||||||
.video-info {
|
|
||||||
color: #fff; margin-top: 16px; text-align: center;
|
|
||||||
}
|
|
||||||
.video-info h3 { font-size: 1rem; margin-bottom: 8px; font-weight: 500; }
|
|
||||||
.time-display { font-size: 0.85rem; color: #9ca3af; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
|
||||||
.range { color: #4f46e5; }
|
|
||||||
.segment-controls {
|
|
||||||
display: flex; justify-content: center; gap: 12px; margin-top: 12px;
|
|
||||||
}
|
|
||||||
.seg-btn {
|
|
||||||
padding: 8px 16px; background: #1f2937; color: #fff;
|
|
||||||
border: 1px solid #374151; border-radius: 8px;
|
|
||||||
cursor: pointer; font-size: 0.85rem; transition: background 0.15s;
|
|
||||||
}
|
|
||||||
.seg-btn:hover { background: #374151; }
|
|
||||||
.video-loading { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #9ca3af; }
|
|
||||||
.loading-spinner { width: 40px; height: 40px; border: 4px solid #374151; border-top-color: #4f46e5; border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 12px; }
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
|
||||||
</style>
|
|
||||||
138
src/composables/useSearchHistory.ts
Normal file
138
src/composables/useSearchHistory.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { apiCall } from '@/api'
|
||||||
|
|
||||||
|
export interface HistoryItem {
|
||||||
|
id: string
|
||||||
|
query: string
|
||||||
|
title: string
|
||||||
|
chat_state: string | null
|
||||||
|
mode: string | null
|
||||||
|
pinned: boolean
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSearchHistory() {
|
||||||
|
const history = ref<HistoryItem[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function loadHistory() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await apiCall('get_search_history', { limit: 30 })
|
||||||
|
history.value = Array.isArray(result) ? result : []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load history:', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveToHistory(query: string, chatState: string, mode: string) {
|
||||||
|
const id = `h_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
try {
|
||||||
|
await apiCall('save_search_history', {
|
||||||
|
id,
|
||||||
|
query,
|
||||||
|
title: query,
|
||||||
|
chatState,
|
||||||
|
mode,
|
||||||
|
})
|
||||||
|
await loadHistory()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save history:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renameHistory(id: string, title: string) {
|
||||||
|
try {
|
||||||
|
await apiCall('rename_search_history', { id, title })
|
||||||
|
await loadHistory()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to rename history:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pinHistory(id: string, pinned: boolean) {
|
||||||
|
try {
|
||||||
|
await apiCall('pin_search_history', { id, pinned })
|
||||||
|
await loadHistory()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to pin history:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteHistory(id: string) {
|
||||||
|
try {
|
||||||
|
await apiCall('delete_search_history', { id })
|
||||||
|
await loadHistory()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete history:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreFromHistory(item: HistoryItem): any[] | null {
|
||||||
|
if (!item.chat_state) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(item.chat_state)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
history,
|
||||||
|
loading,
|
||||||
|
loadHistory,
|
||||||
|
saveToHistory,
|
||||||
|
renameHistory,
|
||||||
|
pinHistory,
|
||||||
|
deleteHistory,
|
||||||
|
restoreFromHistory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookmarkItem {
|
||||||
|
id: number
|
||||||
|
label: string
|
||||||
|
history_id: string | null
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBookmarks() {
|
||||||
|
const bookmarks = ref<BookmarkItem[]>([])
|
||||||
|
|
||||||
|
async function loadBookmarks() {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('get_bookmarks', {})
|
||||||
|
bookmarks.value = Array.isArray(result) ? result : []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load bookmarks:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBookmark(label: string, historyId?: string) {
|
||||||
|
try {
|
||||||
|
await apiCall('save_bookmark', { label, historyId: historyId || null })
|
||||||
|
await loadBookmarks()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save bookmark:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBookmark(id: number) {
|
||||||
|
try {
|
||||||
|
await apiCall('delete_bookmark', { id })
|
||||||
|
await loadBookmarks()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete bookmark:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
bookmarks,
|
||||||
|
loadBookmarks,
|
||||||
|
saveBookmark,
|
||||||
|
deleteBookmark,
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/composables/useUndoRedo.ts
Normal file
253
src/composables/useUndoRedo.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
import { apiCall } from '@/api'
|
||||||
|
import { invalidatePeople, ensurePeople } from '@/store'
|
||||||
|
|
||||||
|
interface HistoryCounts {
|
||||||
|
patchUndo: number
|
||||||
|
patchRedo: number
|
||||||
|
bindUndo: number
|
||||||
|
bindRedo: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MergeHistoryItem {
|
||||||
|
mergeId: string
|
||||||
|
sourceUuid: string
|
||||||
|
targetUuid: string
|
||||||
|
sourceName: string
|
||||||
|
targetName: string
|
||||||
|
undone: boolean
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionHistoryItem {
|
||||||
|
type: 'patch' | 'bind' | 'merge' | 'delete'
|
||||||
|
label: string
|
||||||
|
uuid: string
|
||||||
|
timestamp: string
|
||||||
|
canUndo: boolean
|
||||||
|
canRedo: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUndoRedo() {
|
||||||
|
const counts = reactive<Record<string, HistoryCounts>>({})
|
||||||
|
const mergeHistory = reactive<MergeHistoryItem[]>([])
|
||||||
|
const recentActions = reactive<ActionHistoryItem[]>([])
|
||||||
|
|
||||||
|
async function refreshCounts(uuid: string) {
|
||||||
|
try {
|
||||||
|
const [patchHist, bindHist] = await Promise.all([
|
||||||
|
apiCall('identity_history', { uuid, page: 1, pageSize: 1 }),
|
||||||
|
apiCall('identity_bind_history', { uuid, page: 1, pageSize: 1 }),
|
||||||
|
])
|
||||||
|
if (!counts[uuid]) counts[uuid] = { patchUndo: 0, patchRedo: 0, bindUndo: 0, bindRedo: 0 }
|
||||||
|
counts[uuid].patchUndo = patchHist?.undo_stack_count ?? patchHist?.undoStackCount ?? 0
|
||||||
|
counts[uuid].patchRedo = patchHist?.redo_stack_count ?? patchHist?.redoStackCount ?? 0
|
||||||
|
counts[uuid].bindUndo = bindHist?.undo_stack_count ?? bindHist?.undoStackCount ?? 0
|
||||||
|
counts[uuid].bindRedo = bindHist?.redo_stack_count ?? bindHist?.redoStackCount ?? 0
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh undo counts:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshMergeHistory(uuid?: string) {
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { pageSize: 10 }
|
||||||
|
if (uuid) params.sourceUuid = uuid
|
||||||
|
const data = await apiCall('merge_history', params)
|
||||||
|
const items = data?.history || data?.merges || data || []
|
||||||
|
mergeHistory.splice(0, mergeHistory.length)
|
||||||
|
for (const m of Array.isArray(items) ? items : []) {
|
||||||
|
mergeHistory.push({
|
||||||
|
mergeId: m.merge_id ?? m.mergeId ?? '',
|
||||||
|
sourceUuid: m.source_uuid ?? m.sourceUuid ?? '',
|
||||||
|
targetUuid: m.target_uuid ?? m.targetUuid ?? '',
|
||||||
|
sourceName: m.source_name ?? m.sourceName ?? '',
|
||||||
|
targetName: m.target_name ?? m.targetName ?? '',
|
||||||
|
undone: m.is_undone ?? m.isUndone ?? false,
|
||||||
|
createdAt: m.created_at ?? m.createdAt ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh merge history:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshActionHistory(uuid: string) {
|
||||||
|
try {
|
||||||
|
const [patchHist, bindHist] = await Promise.all([
|
||||||
|
apiCall('identity_history', { uuid, page: 1, pageSize: 10 }),
|
||||||
|
apiCall('identity_bind_history', { uuid, page: 1, pageSize: 10 }),
|
||||||
|
])
|
||||||
|
const actions: ActionHistoryItem[] = []
|
||||||
|
const patchItems = patchHist?.history || patchHist?.records || []
|
||||||
|
for (const r of Array.isArray(patchItems) ? patchItems : []) {
|
||||||
|
const op = r.operation ?? r.op ?? ''
|
||||||
|
const label = op === 'delete' ? '刪除人物' : op === 'update' ? '更新人物' : op
|
||||||
|
actions.push({
|
||||||
|
type: op === 'delete' ? 'delete' : 'patch',
|
||||||
|
label,
|
||||||
|
uuid,
|
||||||
|
timestamp: r.created_at ?? r.createdAt ?? r.timestamp ?? '',
|
||||||
|
canUndo: !r.is_undone && !r.isUndone,
|
||||||
|
canRedo: !!(r.is_undone || r.isUndone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const bindItems = bindHist?.history || bindHist?.records || []
|
||||||
|
for (const r of Array.isArray(bindItems) ? bindItems : []) {
|
||||||
|
const op = r.operation ?? r.op ?? ''
|
||||||
|
const label = op === 'bind' ? '綁定面部' : op === 'unbind' ? '解綁面部' : op === 'bind_trace' ? '綁定軌跡' : op
|
||||||
|
actions.push({
|
||||||
|
type: 'bind',
|
||||||
|
label,
|
||||||
|
uuid,
|
||||||
|
timestamp: r.created_at ?? r.createdAt ?? r.timestamp ?? '',
|
||||||
|
canUndo: !(r.is_undone ?? r.isUndone),
|
||||||
|
canRedo: !!(r.is_undone ?? r.isUndone),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
actions.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||||
|
recentActions.splice(0, recentActions.length, ...actions.slice(0, 20))
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh action history:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undo(uuid: string, steps = 1) {
|
||||||
|
try {
|
||||||
|
await apiCall('identity_undo', { uuid, steps })
|
||||||
|
await refreshCounts(uuid)
|
||||||
|
await refreshActionHistory(uuid)
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Undo failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redo(uuid: string, steps = 1) {
|
||||||
|
try {
|
||||||
|
await apiCall('identity_redo', { uuid, steps })
|
||||||
|
await refreshCounts(uuid)
|
||||||
|
await refreshActionHistory(uuid)
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Redo failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindUndo(uuid: string, steps = 1) {
|
||||||
|
try {
|
||||||
|
await apiCall('identity_bind_undo', { uuid, steps })
|
||||||
|
await refreshCounts(uuid)
|
||||||
|
await refreshActionHistory(uuid)
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Bind undo failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bindRedo(uuid: string, steps = 1) {
|
||||||
|
try {
|
||||||
|
await apiCall('identity_bind_redo', { uuid, steps })
|
||||||
|
await refreshCounts(uuid)
|
||||||
|
await refreshActionHistory(uuid)
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Bind redo failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeUndo(mergeId: string) {
|
||||||
|
try {
|
||||||
|
await apiCall('merge_undo', { mergeId })
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Merge undo failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mergeRedo(mergeId: string) {
|
||||||
|
try {
|
||||||
|
await apiCall('merge_redo', { mergeId })
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Merge redo failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUndo(uuid: string): boolean {
|
||||||
|
const c = counts[uuid]
|
||||||
|
if (!c) return false
|
||||||
|
return c.patchUndo > 0 || c.bindUndo > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function canRedo(uuid: string): boolean {
|
||||||
|
const c = counts[uuid]
|
||||||
|
if (!c) return false
|
||||||
|
return c.patchRedo > 0 || c.bindRedo > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undoAction(uuid: string, type: 'patch' | 'bind') {
|
||||||
|
try {
|
||||||
|
if (type === 'bind') {
|
||||||
|
await apiCall('identity_bind_undo', { uuid, steps: 1 })
|
||||||
|
} else {
|
||||||
|
await apiCall('identity_undo', { uuid, steps: 1 })
|
||||||
|
}
|
||||||
|
await refreshCounts(uuid)
|
||||||
|
await refreshActionHistory(uuid)
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Undo action failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function redoAction(uuid: string, type: 'patch' | 'bind') {
|
||||||
|
try {
|
||||||
|
if (type === 'bind') {
|
||||||
|
await apiCall('identity_bind_redo', { uuid, steps: 1 })
|
||||||
|
} else {
|
||||||
|
await apiCall('identity_redo', { uuid, steps: 1 })
|
||||||
|
}
|
||||||
|
await refreshCounts(uuid)
|
||||||
|
await refreshActionHistory(uuid)
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Redo action failed:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
counts,
|
||||||
|
mergeHistory,
|
||||||
|
recentActions,
|
||||||
|
refreshCounts,
|
||||||
|
refreshMergeHistory,
|
||||||
|
refreshActionHistory,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
bindUndo,
|
||||||
|
bindRedo,
|
||||||
|
mergeUndo,
|
||||||
|
mergeRedo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
undoAction,
|
||||||
|
redoAction,
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/directives/vObserve.ts
Normal file
44
src/directives/vObserve.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Directive } from 'vue'
|
||||||
|
|
||||||
|
const vObserve: Directive<HTMLElement> = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
if (typeof binding.value !== 'function') return
|
||||||
|
|
||||||
|
const callback = binding.value
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (!entries.length) return
|
||||||
|
if (entries[0].isIntersecting) {
|
||||||
|
callback()
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px 0px' }
|
||||||
|
)
|
||||||
|
|
||||||
|
observer.observe(el)
|
||||||
|
;(el as any)._io = observer
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
if (
|
||||||
|
rect.top < window.innerHeight + 200 &&
|
||||||
|
rect.bottom > -200 &&
|
||||||
|
rect.width > 0
|
||||||
|
) {
|
||||||
|
callback()
|
||||||
|
observer.disconnect()
|
||||||
|
;(el as any)._io = undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
if ((el as any)._io) {
|
||||||
|
;(el as any)._io.disconnect()
|
||||||
|
;(el as any)._io = undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default vObserve
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import vObserve from './directives/vObserve'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.directive('observe', vObserve)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
221
src/store.ts
Normal file
221
src/store.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { apiCall } from '@/api'
|
||||||
|
import { isTauri } from './api/config'
|
||||||
|
|
||||||
|
export const filesCache = ref<any[]>([])
|
||||||
|
export const filesLoaded = ref(false)
|
||||||
|
|
||||||
|
export const peopleCache = ref<any[]>([])
|
||||||
|
export const peopleLoaded = ref(false)
|
||||||
|
|
||||||
|
export const faceCandidatesCache = ref<any[]>([])
|
||||||
|
export const faceCandidatesLoaded = ref(false)
|
||||||
|
|
||||||
|
export const thumbnailsCache = ref<Record<string, string>>({})
|
||||||
|
export const profilesCache = ref<Record<string, string>>({})
|
||||||
|
export const faceThumbsCache = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const thumbQueue: (() => Promise<void>)[] = []
|
||||||
|
let activeThumbLoads = 0
|
||||||
|
const MAX_CONCURRENT = 16
|
||||||
|
|
||||||
|
const profileQueue: (() => Promise<void>)[] = []
|
||||||
|
let activeProfileLoads = 0
|
||||||
|
const MAX_PROFILE_CONCURRENT = 4
|
||||||
|
|
||||||
|
const loadingThumbs = new Set<string>()
|
||||||
|
const loadingProfiles = new Set<string>()
|
||||||
|
const loadingFaceThumbs = new Set<string>()
|
||||||
|
|
||||||
|
function drainThumbQueue() {
|
||||||
|
if (activeThumbLoads >= MAX_CONCURRENT || thumbQueue.length === 0) return
|
||||||
|
activeThumbLoads++
|
||||||
|
const task = thumbQueue.shift()!
|
||||||
|
task().then(() => {
|
||||||
|
activeThumbLoads--
|
||||||
|
drainThumbQueue()
|
||||||
|
}).catch(() => {
|
||||||
|
activeThumbLoads--
|
||||||
|
drainThumbQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainProfileQueue() {
|
||||||
|
if (activeProfileLoads >= MAX_PROFILE_CONCURRENT || profileQueue.length === 0) return
|
||||||
|
activeProfileLoads++
|
||||||
|
const task = profileQueue.shift()!
|
||||||
|
task().then(() => {
|
||||||
|
activeProfileLoads--
|
||||||
|
drainProfileQueue()
|
||||||
|
}).catch(() => {
|
||||||
|
activeProfileLoads--
|
||||||
|
drainProfileQueue()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueThumb(fn: () => Promise<void>) {
|
||||||
|
thumbQueue.push(fn)
|
||||||
|
drainThumbQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueProfile(fn: () => Promise<void>) {
|
||||||
|
profileQueue.push(fn)
|
||||||
|
drainProfileQueue()
|
||||||
|
}
|
||||||
|
|
||||||
|
let _peopleLoading = false
|
||||||
|
let _filesLoading = false
|
||||||
|
let _faceCandidatesLoading = false
|
||||||
|
|
||||||
|
export async function ensureFiles() {
|
||||||
|
if (filesLoaded.value) return
|
||||||
|
if (_filesLoading) {
|
||||||
|
await new Promise<void>(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_filesLoading = true
|
||||||
|
try {
|
||||||
|
const result = await apiCall('get_files', { args: { pageSize: 500 } })
|
||||||
|
filesCache.value = Array.isArray(result) ? result : []
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load files:', e)
|
||||||
|
} finally {
|
||||||
|
_filesLoading = false
|
||||||
|
filesLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePeople() {
|
||||||
|
if (peopleLoaded.value) return
|
||||||
|
if (_peopleLoading) {
|
||||||
|
await new Promise<void>(r => { const check = setInterval(() => { if (peopleLoaded.value || !_peopleLoading) { clearInterval(check); r() } }, 100) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_peopleLoading = true
|
||||||
|
try {
|
||||||
|
if (isTauri) {
|
||||||
|
const result: any = await apiCall('get_people', { page: 1, perPage: 100 })
|
||||||
|
peopleCache.value = Array.isArray(result) ? result : []
|
||||||
|
} else {
|
||||||
|
const allPeople: any[] = []
|
||||||
|
for (let page = 1; page <= 5; page++) {
|
||||||
|
const batch: any = await apiCall('get_people', { page, perPage: 20 })
|
||||||
|
const arr = Array.isArray(batch) ? batch : []
|
||||||
|
if (!arr.length) break
|
||||||
|
allPeople.push(...arr)
|
||||||
|
if (arr.length < 20) break
|
||||||
|
}
|
||||||
|
peopleCache.value = allPeople
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load people:', e)
|
||||||
|
} finally {
|
||||||
|
_peopleLoading = false
|
||||||
|
peopleLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureFaceCandidates() {
|
||||||
|
if (faceCandidatesLoaded.value) return
|
||||||
|
if (_faceCandidatesLoading) {
|
||||||
|
await new Promise<void>(r => { const check = setInterval(() => { if (faceCandidatesLoaded.value || !_faceCandidatesLoading) { clearInterval(check); r() } }, 100) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_faceCandidatesLoading = true
|
||||||
|
try {
|
||||||
|
if (isTauri) {
|
||||||
|
const fc: any = await apiCall('get_face_candidates', { page: 1, perPage: 100 })
|
||||||
|
faceCandidatesCache.value = Array.isArray(fc) ? fc : []
|
||||||
|
} else {
|
||||||
|
const all: any[] = []
|
||||||
|
for (let page = 1; page <= 5; page++) {
|
||||||
|
const batch: any = await apiCall('get_face_candidates', { page, perPage: 20 })
|
||||||
|
const arr = Array.isArray(batch) ? batch : []
|
||||||
|
if (!arr.length) break
|
||||||
|
all.push(...arr)
|
||||||
|
if (arr.length < 20) break
|
||||||
|
}
|
||||||
|
faceCandidatesCache.value = all
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load face candidates:', e)
|
||||||
|
} finally {
|
||||||
|
_faceCandidatesLoading = false
|
||||||
|
faceCandidatesLoaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadThumbnail(uuid: string, frame = 30) {
|
||||||
|
const key = `${uuid}:${frame}`
|
||||||
|
if (!uuid || thumbnailsCache.value[key] || loadingThumbs.has(key)) return
|
||||||
|
loadingThumbs.add(key)
|
||||||
|
queueThumb(async () => {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('get_thumbnail', { uuid, frame })
|
||||||
|
if (result) thumbnailsCache.value[key] = result
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadThumbnail failed:', uuid, frame, e)
|
||||||
|
} finally {
|
||||||
|
loadingThumbs.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadProfile(uuid: string) {
|
||||||
|
if (!uuid) { console.error('[loadProfile] called with empty uuid'); return }
|
||||||
|
if (profilesCache.value[uuid]) return
|
||||||
|
if (loadingProfiles.has(uuid)) return
|
||||||
|
loadingProfiles.add(uuid)
|
||||||
|
console.error('[loadProfile] requesting', uuid)
|
||||||
|
queueProfile(async () => {
|
||||||
|
try {
|
||||||
|
const result = await apiCall('get_identity_profile', { uuid })
|
||||||
|
console.error('[loadProfile] got result for', uuid, typeof result, result ? (result as string).substring(0, 30) : 'null')
|
||||||
|
if (result) profilesCache.value[uuid] = result
|
||||||
|
else console.error('[loadProfile] empty result for', uuid)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadProfile failed:', uuid, e)
|
||||||
|
} finally {
|
||||||
|
loadingProfiles.delete(uuid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadFaceThumb(key: string, uuid: string, frame: number, bbox?: any) {
|
||||||
|
if (!uuid || faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return
|
||||||
|
loadingFaceThumbs.add(key)
|
||||||
|
queueThumb(async () => {
|
||||||
|
try {
|
||||||
|
const args: any = { uuid, frame }
|
||||||
|
if (bbox) {
|
||||||
|
args.bboxX = Math.round(bbox.x)
|
||||||
|
args.bboxY = Math.round(bbox.y)
|
||||||
|
args.bboxW = Math.round(bbox.width)
|
||||||
|
args.bboxH = Math.round(bbox.height)
|
||||||
|
}
|
||||||
|
const result = await apiCall('get_face_thumbnail', args)
|
||||||
|
if (result) faceThumbsCache.value[key] = result
|
||||||
|
} catch (e) {
|
||||||
|
console.error('loadFaceThumb failed:', key, uuid, e)
|
||||||
|
} finally {
|
||||||
|
loadingFaceThumbs.delete(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateFiles() {
|
||||||
|
filesLoaded.value = false
|
||||||
|
filesCache.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidatePeople() {
|
||||||
|
peopleLoaded.value = false
|
||||||
|
peopleCache.value = []
|
||||||
|
faceCandidatesLoaded.value = false
|
||||||
|
faceCandidatesCache.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateProfile(uuid: string) {
|
||||||
|
delete profilesCache.value[uuid]
|
||||||
|
loadingProfiles.delete(uuid)
|
||||||
|
}
|
||||||
@@ -1,97 +1,131 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="ms-files-page">
|
<div id="ms-files-page">
|
||||||
<section class="mp-panel is-active">
|
<section class="ms-fm-panel">
|
||||||
<div class="mp-toolbar">
|
<div class="ms-fm-toolbar">
|
||||||
<div class="mp-toolbar-left">
|
<div class="ms-fm-toolbar-left">
|
||||||
<button class="mp-btn mp-btn-primary" type="button" @click="addToPeople">Register</button>
|
<button class="ms-fm-icon-btn" type="button" title="Refresh" @click="loadFiles">⟳</button>
|
||||||
<button class="mp-btn mp-btn-icon" type="button" title="Refresh" @click="loadFiles">
|
<label class="ms-fm-radio-label">
|
||||||
<span class="mp-refresh-icon">⟳</span>
|
<input type="radio" name="ms-display-filter" value="all" v-model="displayFilter"> All
|
||||||
</button>
|
|
||||||
<label class="mp-radio-row">
|
|
||||||
<input type="radio" name="mp-display-filter" value="all" v-model="displayFilter"> All
|
|
||||||
</label>
|
</label>
|
||||||
<label class="mp-radio-row">
|
<label class="ms-fm-radio-label">
|
||||||
<input type="radio" name="mp-display-filter" value="video" v-model="displayFilter"> All Videos
|
<input type="radio" name="ms-display-filter" value="video" v-model="displayFilter"> Videos
|
||||||
</label>
|
</label>
|
||||||
<label class="mp-radio-row">
|
<label class="ms-fm-radio-label">
|
||||||
<input type="radio" name="mp-display-filter" value="photo" v-model="displayFilter"> All Photos
|
<input type="radio" name="ms-display-filter" value="photo" v-model="displayFilter"> Photos
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mp-toolbar-right">
|
<div class="ms-fm-toolbar-right">
|
||||||
<div class="mp-search-wrap">
|
<div class="ms-fm-search-wrap">
|
||||||
<input type="text" class="mp-search" v-model="filterText" placeholder="Search files / docs / videos">
|
<input type="text" class="ms-fm-search" v-model="filterText" placeholder="Search files / docs / videos">
|
||||||
</div>
|
</div>
|
||||||
<button class="mp-filter-btn" type="button" @click="showFilter = !showFilter">☰</button>
|
<button class="ms-fm-icon-btn" type="button" @click="showFilter = !showFilter">☰</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filter Popup -->
|
<!-- Filter Popup -->
|
||||||
<div class="mp-filter-pop" :class="{ show: showFilter }">
|
<div class="ms-fm-sort-panel" :class="{ show: showFilter }">
|
||||||
<div class="mp-filter-title">排序方式</div>
|
<div class="ms-fm-sort-section">
|
||||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="time_desc" v-model="sortBy" checked> 依時間・由近到遠</label>
|
<div class="ms-fm-sort-title">排序方式</div>
|
||||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="time_asc" v-model="sortBy"> 依時間・由遠到近</label>
|
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="time_desc" v-model="sortBy"> 依時間・由近到遠</label>
|
||||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="size_desc" v-model="sortBy"> 依大小・由大到小</label>
|
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="time_asc" v-model="sortBy"> 依時間・由遠到近</label>
|
||||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="size_asc" v-model="sortBy"> 依大小・由小到大</label>
|
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="size_desc" v-model="sortBy"> 依大小・由大到小</label>
|
||||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="name_asc" v-model="sortBy"> 依檔名・由 A 到 Z</label>
|
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="size_asc" v-model="sortBy"> 依大小・由小到大</label>
|
||||||
<label class="mp-radio-row"><input type="radio" name="mp-sort" value="name_desc" v-model="sortBy"> 依檔名・由 Z 到 A</label>
|
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="name_asc" v-model="sortBy"> 依檔名・由 A 到 Z</label>
|
||||||
|
<label class="ms-fm-radio-label"><input type="radio" name="ms-sort" value="name_desc" v-model="sortBy"> 依檔名・由 Z 到 A</label>
|
||||||
<div class="mp-filter-title">過濾方式</div>
|
|
||||||
<label class="mp-check-row"><input type="checkbox" v-model="filterUnregistered"> 未註冊</label>
|
|
||||||
<label class="mp-check-row"><input type="checkbox" v-model="filterRegistered"> 已註冊</label>
|
|
||||||
<label class="mp-check-row"><input type="checkbox" v-model="onlyVideos"> 僅顯示影片</label>
|
|
||||||
<label class="mp-check-row"><input type="checkbox" v-model="onlyPhotos"> 僅顯示照片</label>
|
|
||||||
|
|
||||||
<div class="mp-filter-title">大小</div>
|
|
||||||
<div class="mp-range-row">
|
|
||||||
<input type="number" class="mp-filter-number" v-model.number="sizeMin" min="0" placeholder="最小 MB">
|
|
||||||
<span>—</span>
|
|
||||||
<input type="number" class="mp-filter-number" v-model.number="sizeMax" min="0" placeholder="最大 MB">
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ms-fm-sort-section">
|
||||||
<div class="mp-filter-title">影片時長</div>
|
<div class="ms-fm-sort-title">過濾方式</div>
|
||||||
<div class="mp-range-row">
|
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterUnregistered"> 未註冊</label>
|
||||||
<input type="number" class="mp-filter-number" v-model.number="durationMin" min="0" placeholder="最小分鐘">
|
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterRegistered"> 已註冊</label>
|
||||||
<span>—</span>
|
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterPending"> 待處理</label>
|
||||||
<input type="number" class="mp-filter-number" v-model.number="durationMax" min="0" placeholder="最大分鐘">
|
<label class="ms-fm-radio-label"><input type="checkbox" v-model="filterCompleted"> 已完成</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>
|
</div>
|
||||||
|
<div class="ms-fm-sort-section">
|
||||||
<button type="button" class="mp-filter-reset" @click="resetFilters">清除篩選</button>
|
<div class="ms-fm-sort-title">大小</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<input type="number" class="ms-fm-edit-input" v-model.number="sizeMin" min="0" placeholder="最小 MB" style="width:100%;">
|
||||||
|
<span>—</span>
|
||||||
|
<input type="number" class="ms-fm-edit-input" v-model.number="sizeMax" min="0" placeholder="最大 MB" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-fm-sort-section">
|
||||||
|
<div class="ms-fm-sort-title">影片時長</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<input type="number" class="ms-fm-edit-input" v-model.number="durationMin" min="0" placeholder="最小分鐘" style="width:100%;">
|
||||||
|
<span>—</span>
|
||||||
|
<input type="number" class="ms-fm-edit-input" v-model.number="durationMax" min="0" placeholder="最大分鐘" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ms-fm-btn" style="width:100%;margin-top:8px;" @click="resetFilters">清除篩選</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mp-status" :class="{ show: loading || statusText }">{{ statusText }}</div>
|
<div class="ms-fm-status">{{ statusText }}<span v-if="actionMsg" class="ms-fm-action-msg">{{ actionMsg }}</span></div>
|
||||||
|
|
||||||
<div class="mp-grid">
|
<div class="ms-fm-grid">
|
||||||
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="mp-file-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)">
|
<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)">
|
||||||
<div class="mp-thumb-wrap">
|
<div class="ms-fm-thumb-wrap" v-observe="() => { if (isPhoto(f) || isVideo(f)) loadThumbnailLocal(f.file_uuid) }">
|
||||||
<div class="mp-badge-type">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
|
<div class="ms-fm-badge">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
|
||||||
<img v-if="isPhoto(f) || isVideo(f)" v-show="thumbnails[f.file_uuid]" class="lt-thumb" :src="thumbnails[f.file_uuid]" alt="" @error="handleThumbError" @vue:mounted="loadThumbnail(f.file_uuid)">
|
<img v-if="isPhoto(f) || isVideo(f)" v-show="thumbnails[thumbKey(f.file_uuid)]" class="ms-fm-thumb" :src="thumbnails[thumbKey(f.file_uuid)]" alt="" @error="handleThumbError">
|
||||||
<div v-if="(isPhoto(f) || isVideo(f)) && !thumbnails[f.file_uuid]" class="mp-thumb-loading">⟳</div>
|
<div v-if="(isPhoto(f) || isVideo(f)) && !thumbnails[thumbKey(f.file_uuid)]" class="ms-fm-thumb-loading">⟳</div>
|
||||||
<div v-else class="mp-doc-thumb">
|
<div v-else class="ms-fm-doc-thumb">
|
||||||
<span class="mp-doc-icon">📄</span>
|
<span class="ms-fm-doc-icon">📄</span>
|
||||||
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
<span class="ms-fm-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="isVideo(f)" class="mp-play-btn" @click.stop="playVideo(f)">▶</button>
|
<button v-if="isVideo(f)" class="ms-fm-play-btn" @click.stop="playVideo(f)">▶</button>
|
||||||
<div class="mp-complete-mark" v-if="f.isRegistered">✓</div>
|
<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>
|
</div>
|
||||||
<div class="mp-meta">
|
<div class="ms-fm-meta">
|
||||||
<div class="mp-name">{{ f.file_name }}</div>
|
<div class="ms-fm-name">{{ f.file_name }}</div>
|
||||||
<div class="mp-source">{{ formatSize(f.file_size) }} · {{ formatDate(f.modified_time) }}</div>
|
<div class="ms-fm-source">{{ formatSize(f.file_size) }} · {{ formatDate(f.modified_time) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
<!-- Context Menu -->
|
||||||
|
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px', display: 'block' }">
|
||||||
|
<div class="ms-ctx-header">
|
||||||
|
<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>
|
||||||
|
<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" 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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { apiCall } from '@/api'
|
||||||
|
import { ensureFiles, filesCache, filesLoaded, thumbnailsCache, loadThumbnail, invalidateFiles } from '@/store'
|
||||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||||
|
|
||||||
const files = ref<any[]>([])
|
const files = computed(() => filesCache.value)
|
||||||
const loading = ref(false)
|
const loading = computed(() => !filesLoaded.value)
|
||||||
const statusText = ref('')
|
const statusText = ref('')
|
||||||
const filterText = ref('')
|
const filterText = ref('')
|
||||||
const displayFilter = ref('all')
|
const displayFilter = ref('all')
|
||||||
@@ -100,12 +134,14 @@ const selectedFiles = ref<string[]>([])
|
|||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
||||||
|
|
||||||
const thumbnails = ref<Record<string, string>>({})
|
const thumbnails = computed(() => thumbnailsCache.value)
|
||||||
const thumbnailLoading = ref<Set<string>>(new Set())
|
const thumbKey = (uuid: string) => `${uuid}:30`
|
||||||
|
|
||||||
const sortBy = ref('time_desc')
|
const sortBy = ref('time_desc')
|
||||||
const filterUnregistered = ref(false)
|
const filterUnregistered = ref(false)
|
||||||
const filterRegistered = ref(true)
|
const filterRegistered = ref(true)
|
||||||
|
const filterPending = ref(false)
|
||||||
|
const filterCompleted = ref(false)
|
||||||
const onlyVideos = ref(false)
|
const onlyVideos = ref(false)
|
||||||
const onlyPhotos = ref(false)
|
const onlyPhotos = ref(false)
|
||||||
const sizeMin = ref<number | null>(null)
|
const sizeMin = ref<number | null>(null)
|
||||||
@@ -130,7 +166,8 @@ const sortedFilteredFiles = computed(() => {
|
|||||||
// Checkbox filters
|
// Checkbox filters
|
||||||
if (filterUnregistered.value && !filterRegistered.value) result = result.filter((f: any) => !f.isRegistered)
|
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)
|
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 (filterCompleted.value) result = result.filter((f: any) => f.status === 'completed')
|
||||||
|
|
||||||
if (onlyVideos.value) result = result.filter(isVideo)
|
if (onlyVideos.value) result = result.filter(isVideo)
|
||||||
if (onlyPhotos.value) result = result.filter(isPhoto)
|
if (onlyPhotos.value) result = result.filter(isPhoto)
|
||||||
@@ -166,25 +203,21 @@ const sortedFilteredFiles = computed(() => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => loadFiles())
|
onMounted(() => { loadFiles(); document.addEventListener('click', docClickClose) })
|
||||||
|
onUnmounted(() => { document.removeEventListener('click', docClickClose) })
|
||||||
|
|
||||||
|
function docClickClose(e: MouseEvent) {
|
||||||
|
if (e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
|
||||||
|
if (ctxMenu.value.show) ctxMenu.value.show = false
|
||||||
|
}
|
||||||
|
|
||||||
async function loadFiles() {
|
async function loadFiles() {
|
||||||
loading.value = true
|
|
||||||
statusText.value = 'Loading...'
|
statusText.value = 'Loading...'
|
||||||
try {
|
invalidateFiles()
|
||||||
console.log('Calling get_files...')
|
await ensureFiles()
|
||||||
files.value = await invoke('get_files', { args: { pageSize: 500 } })
|
statusText.value = `${files.value.length} files`
|
||||||
console.log('Files loaded:', files.value.length); console.log('First file:', JSON.stringify(files.value[0]))
|
for (const f of files.value.slice(0, 6)) {
|
||||||
if (files.value.length > 0) {
|
if ((isPhoto(f) || isVideo(f)) && f.file_uuid) loadThumbnail(f.file_uuid)
|
||||||
console.log('First file:', files.value[0])
|
|
||||||
console.log('is_registered sample:', files.value.slice(0,5).map((f:any) => ({ name: f.file_name?.slice(0,20), is_registered: f.is_registered, type: typeof f.is_registered })))
|
|
||||||
}
|
|
||||||
statusText.value = `${files.value.length} files`
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Failed to load files:', e)
|
|
||||||
statusText.value = 'Failed: ' + (e.message || e)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,16 +243,8 @@ function formatDate(date: string) {
|
|||||||
return new Date(date).toISOString().slice(0, 10)
|
return new Date(date).toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadThumbnail(uuid: string) {
|
async function loadThumbnailLocal(uuid: string) {
|
||||||
if (!uuid || thumbnails.value[uuid] || thumbnailLoading.value.has(uuid)) return
|
loadThumbnail(uuid, 30)
|
||||||
thumbnailLoading.value.add(uuid)
|
|
||||||
try {
|
|
||||||
thumbnails.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 })
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('Thumbnail load failed:', e)
|
|
||||||
} finally {
|
|
||||||
thumbnailLoading.value.delete(uuid)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleThumbError(e: Event) {
|
function handleThumbError(e: Event) {
|
||||||
@@ -238,15 +263,137 @@ function playVideo(f: any) {
|
|||||||
playing.value = true
|
playing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function addToPeople() {
|
const registering = ref(false)
|
||||||
if (!selectedFiles.value.length) { alert('Please select files first') }
|
const processing = ref(false)
|
||||||
alert(`Register ${selectedFiles.value.length} file(s)`)
|
const unregistering = ref(false)
|
||||||
|
const actionMsg = ref('')
|
||||||
|
const procAsr = ref(true)
|
||||||
|
const procYolo = ref(true)
|
||||||
|
const procFace = ref(true)
|
||||||
|
const procOcr = ref(false)
|
||||||
|
const procPose = ref(false)
|
||||||
|
const procCut = ref(true)
|
||||||
|
|
||||||
|
const ctxMenu = ref<{ show: boolean; x: number; y: number; file: any }>({ show: false, x: 0, y: 0, file: null })
|
||||||
|
|
||||||
|
function selectedProcessors(): string[] {
|
||||||
|
const procs: string[] = []
|
||||||
|
if (procAsr.value) procs.push('asr')
|
||||||
|
if (procYolo.value) procs.push('yolo')
|
||||||
|
if (procFace.value) procs.push('face')
|
||||||
|
if (procOcr.value) procs.push('ocr')
|
||||||
|
if (procPose.value) procs.push('pose')
|
||||||
|
if (procCut.value) procs.push('cut')
|
||||||
|
return procs
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(f: any): string {
|
||||||
|
if (!f) return ''
|
||||||
|
if (f.status === 'completed') return '已完成'
|
||||||
|
if (f.status === 'registered') return '已註冊・待處理'
|
||||||
|
if (f.status === 'processing') return '處理中'
|
||||||
|
if (f.isRegistered) return '已註冊'
|
||||||
|
return '未註冊'
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
file: f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContextMenu() {
|
||||||
|
ctxMenu.value.show = false
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if (!filePath) { registering.value = false; actionMsg.value = '缺少檔案路徑'; return }
|
||||||
|
try {
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
actionMsg.value = `註冊失敗:${result.message}`
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
actionMsg.value = `註冊錯誤:${e}`
|
||||||
|
console.error('[register]', e)
|
||||||
|
}
|
||||||
|
registering.value = false
|
||||||
|
await loadFiles()
|
||||||
|
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ctxProcess(f: any) {
|
||||||
|
closeContextMenu()
|
||||||
|
if (processing.value) return
|
||||||
|
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||||
|
const procs = selectedProcessors()
|
||||||
|
if (!procs.length) { actionMsg.value = '請至少選擇一個處理器'; return }
|
||||||
|
processing.value = true
|
||||||
|
actionMsg.value = '處理中...'
|
||||||
|
try {
|
||||||
|
const result: any = await apiCall('process_file', { fileUuid: f.file_uuid, processors: procs })
|
||||||
|
if (result.success) {
|
||||||
|
actionMsg.value = `處理已觸發:${f.file_name}`
|
||||||
|
} else {
|
||||||
|
actionMsg.value = `處理失敗:${result.message}`
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
actionMsg.value = `處理錯誤:${e}`
|
||||||
|
console.error('[process]', e)
|
||||||
|
}
|
||||||
|
processing.value = false
|
||||||
|
await loadFiles()
|
||||||
|
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ctxUnregister(f: any) {
|
||||||
|
closeContextMenu()
|
||||||
|
if (unregistering.value) return
|
||||||
|
if (!f.isRegistered) { actionMsg.value = '檔案尚未註冊'; return }
|
||||||
|
unregistering.value = true
|
||||||
|
actionMsg.value = '移除中...'
|
||||||
|
try {
|
||||||
|
const result: any = await apiCall('unregister_file', { fileUuid: f.file_uuid, deleteOutputFiles: true })
|
||||||
|
if (result.success) {
|
||||||
|
actionMsg.value = `移除成功:${f.file_name}`
|
||||||
|
} else {
|
||||||
|
actionMsg.value = `移除失敗:${result.message}`
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
actionMsg.value = `移除錯誤:${e}`
|
||||||
|
console.error('[unregister]', e)
|
||||||
|
}
|
||||||
|
unregistering.value = false
|
||||||
|
await loadFiles()
|
||||||
|
setTimeout(() => { actionMsg.value = '' }, 8000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters() {
|
function resetFilters() {
|
||||||
sortBy.value = 'time_desc'
|
sortBy.value = 'time_desc'
|
||||||
filterUnregistered.value = false
|
filterUnregistered.value = false
|
||||||
filterRegistered.value = false
|
filterRegistered.value = false
|
||||||
|
filterPending.value = false
|
||||||
|
filterCompleted.value = false
|
||||||
onlyVideos.value = false
|
onlyVideos.value = false
|
||||||
onlyPhotos.value = false
|
onlyPhotos.value = false
|
||||||
sizeMin.value = null
|
sizeMin.value = null
|
||||||
@@ -261,43 +408,53 @@ function resetFilters() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
#ms-files-page { width: 100%; max-width: 1200px; margin: 0 auto; color: #202124; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans TC', sans-serif; position: relative; }
|
#ms-files-page { width: 100%; max-width: 1200px; margin: 0 auto; color: #202124; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans TC', sans-serif; position: relative; }
|
||||||
#ms-files-page * { box-sizing: border-box; }
|
#ms-files-page * { box-sizing: border-box; }
|
||||||
.mp-panel { display: block; position: relative; overflow: visible; }
|
.ms-fm-panel { display: block; position: relative; overflow: visible; }
|
||||||
.mp-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; position: relative; z-index: 2; }
|
.ms-fm-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; position: relative; z-index: 2; }
|
||||||
.mp-toolbar-left { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
.ms-fm-toolbar-left { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
|
||||||
.mp-toolbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
.ms-fm-processor-bar { display: flex; align-items: center; gap: 10px; padding: 6px 12px; margin-bottom: 12px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e8eaed; flex-wrap: wrap; }
|
||||||
.mp-btn { border: 1px solid #d8dce3; background: #fff; color: #202124; border-radius: 18px; font-size: 14px; padding: 10px 18px; cursor: pointer; box-shadow: 0 2px 6px rgba(0,0,0,.04); font-family: inherit; }
|
.ms-fm-processor-label { font-size: 12px; color: #5f6368; font-weight: 600; margin-right: 2px; }
|
||||||
.mp-btn-primary { font-weight: 600; background: #1967d2; color: #fff; border-color: #1967d2; }
|
.ms-fm-toolbar-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||||
.mp-btn-icon { width: 40px; height: 40px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; box-shadow: 0 4px 10px rgba(0,0,0,.08); }
|
.ms-fm-radio-label { font-size: 13px; color: #5f6368; display: flex; align-items: center; gap: 6px; cursor: pointer; min-height: 24px; margin-bottom: 4px; }
|
||||||
.mp-refresh-icon { font-size: 22px; line-height: 1; font-weight: 500; transform: translateY(-1px); }
|
.ms-fm-processor-group { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding-left: 8px; border-left: 1.5px solid #e8eaed; }
|
||||||
.mp-filter-btn { width: 42px; height: 42px; padding: 0; display: flex; align-items: center; justify-content: center; border-radius: 50%; border: 1px solid #d8dce3; background: #fff; cursor: pointer; font-size: 16px; }
|
.ms-fm-check-label { font-size: 12px; color: #5f6368; display: flex; align-items: center; gap: 4px; cursor: pointer; }
|
||||||
.mp-radio-row, .mp-check-row { font-size: 13px; color: #5f6368; display: flex; align-items: center; gap: 6px; cursor: pointer; min-height: 24px; margin-bottom: 4px; }
|
.ms-fm-check-label input { margin: 0; accent-color: #202124; }
|
||||||
.mp-search-wrap { width: 240px; }
|
.ms-fm-status-badge { position: absolute; bottom: 4px; right: 4px; font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: 600; letter-spacing: 0.3px; }
|
||||||
.mp-search { width: 100%; height: 42px; border: 1px solid #e1e5ea; border-radius: 14px; padding: 0 14px; font-size: 14px; background: #fff; outline: none; font-family: inherit; }
|
.ms-fm-search-wrap { width: 240px; }
|
||||||
.mp-status { font-size: 13px; color: #7a7f87; margin: 4px 0 14px; }
|
.ms-fm-search { width: 100%; height: 42px; border: 1px solid #e1e5ea; border-radius: 14px; padding: 0 14px; font-size: 14px; background: #fff; outline: none; font-family: inherit; }
|
||||||
.mp-status.show { display: block; }
|
.ms-fm-status { font-size: 13px; color: #7a7f87; margin: 4px 0 14px; }
|
||||||
.mp-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; }
|
.ms-fm-action-msg { margin-left: 12px; color: #1e8e3e; font-weight: 600; }
|
||||||
.mp-file-card { border-radius: 14px; cursor: pointer; position: relative; }
|
.ms-fm-btn-danger { background: #d93025; color: #fff; border-color: #d93025; }
|
||||||
.mp-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; }
|
.ms-fm-btn-danger:hover:not(:disabled) { background: #c5221f; }
|
||||||
.lt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.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; }
|
||||||
.mp-thumb-loading { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; color: #9aa0a6; animation: spin 1s linear infinite; }
|
.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; }
|
||||||
|
.ms-ctx-done { color: #1e8e3e; font-weight: 600; }
|
||||||
|
.ms-ctx-pending { color: #e37400; font-weight: 600; }
|
||||||
|
.ms-ctx-none { color: #9aa0a6; }
|
||||||
|
.ms-ctx-divider { height: 1px; background: #eee; margin: 4px 8px; border: none; }
|
||||||
|
.ms-ctx-item { display: block; width: 100%; text-align: left; padding: 7px 12px; cursor: pointer; font-size: 13px; color: #3c4043; border: none; background: none; border-radius: 6px; font-weight: 500; }
|
||||||
|
.ms-ctx-item:hover { background: #f1f3f4; }
|
||||||
|
.ms-ctx-danger { color: #d93025 !important; }
|
||||||
|
.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-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; }
|
||||||
|
.ms-fm-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.ms-fm-thumb-loading { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; color: #9aa0a6; animation: spin 1s linear infinite; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.mp-doc-thumb { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; background: linear-gradient(180deg, #f7f9fc, #eef2f7); color: #6b7280; text-align: center; padding: 14px; }
|
.ms-fm-doc-thumb { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; background: linear-gradient(180deg, #f7f9fc, #eef2f7); color: #6b7280; text-align: center; padding: 14px; }
|
||||||
.mp-doc-icon { font-size: 38px; line-height: 1; }
|
.ms-fm-doc-icon { font-size: 38px; line-height: 1; }
|
||||||
.mp-doc-ext { font-size: 11px; font-weight: 700; letter-spacing: .5px; color: #7a818c; text-transform: uppercase; }
|
.ms-fm-doc-ext { font-size: 11px; font-weight: 700; letter-spacing: .5px; color: #7a818c; text-transform: uppercase; }
|
||||||
.mp-badge-type { position: absolute; top: 8px; left: 8px; font-size: 10px; line-height: 1; padding: 4px 6px; border-radius: 999px; background: rgba(17,24,39,.75); color: #fff; letter-spacing: .3px; }
|
.ms-fm-badge { position: absolute; top: 8px; left: 8px; font-size: 10px; line-height: 1; padding: 4px 6px; border-radius: 999px; background: rgba(17,24,39,.75); color: #fff; letter-spacing: .3px; }
|
||||||
.mp-complete-mark { position: absolute; right: 10px; bottom: 10px; width: 28px; height: 28px; border-radius: 50%; background: rgba(17,24,39,.78); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; z-index: 9; }
|
.ms-fm-check { position: absolute; right: 10px; bottom: 10px; width: 28px; height: 28px; border-radius: 50%; background: rgba(17,24,39,.78); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; z-index: 9; }
|
||||||
.mp-file-card.is-selected .mp-thumb-wrap { border-color: #7db9ff !important; background: #eaf4ff !important; box-shadow: 0 0 0 3px rgba(125,185,255,.55) inset !important; }
|
.ms-fm-card.is-selected .ms-fm-thumb-wrap { border-color: #7db9ff !important; background: #eaf4ff !important; box-shadow: 0 0 0 3px rgba(125,185,255,.55) inset !important; }
|
||||||
.mp-file-card.is-selected .mp-doc-thumb { background: linear-gradient(180deg, #eef7ff, #dceeff) !important; }
|
.ms-fm-card.is-selected .ms-fm-doc-thumb { background: linear-gradient(180deg, #eef7ff, #dceeff) !important; }
|
||||||
.mp-meta { padding-top: 6px; }
|
.ms-fm-meta { padding-top: 6px; }
|
||||||
.mp-source { font-size: 11px; color: #8a919c; margin-bottom: 2px; }
|
.ms-fm-source { font-size: 11px; color: #8a919c; margin-bottom: 2px; }
|
||||||
.mp-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-name { font-size: 12px; color: #42474f; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
.mp-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-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; }
|
||||||
.mp-filter-pop { position: absolute; top: 64px; right: 0; width: 250px; background: #fff; border: 1px solid #eceff3; border-radius: 24px; box-shadow: 0 16px 36px rgba(0,0,0,.08); padding: 18px 18px 16px; z-index: 999; display: none; max-height: 560px; overflow-y: auto; }
|
.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; }
|
||||||
.mp-filter-pop.show { display: block; }
|
|
||||||
.mp-filter-title { font-size: 15px; font-weight: 700; color: #202124; margin: 10px 0 10px; }
|
|
||||||
.mp-filter-title:first-child { margin-top: 0; }
|
|
||||||
.mp-range-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; }
|
|
||||||
.mp-filter-number { 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; }
|
|
||||||
.mp-filter-reset { width: 100%; height: 34px; margin-top: 8px; border: 1px solid #d8dce3; background: #fff; color: #5f6368; border-radius: 12px; font-size: 13px; cursor: pointer; font-family: inherit; }
|
|
||||||
.mp-filter-reset:hover { background: #f6f7f9; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="people-view">
|
<div class="people-view">
|
||||||
<div class="ms-ppl-toolbar">
|
<div class="ms-ppl-toolbar">
|
||||||
<h1 class="ms-ppl-section-title">People</h1>
|
<button class="ms-fm-btn ms-ppl-star-toggle-btn" @click="starFilter = !starFilter">
|
||||||
<div class="ms-ppl-search-wrap">
|
<span class="ms-ppl-star-icon" :class="{ starred: starFilter }">★</span>
|
||||||
<span class="ms-ppl-search-icon">🔍</span>
|
<span>{{ starFilter ? '查看所有人物' : '查看重要人物' }}</span>
|
||||||
<input v-model="searchQuery" class="ms-ppl-search-input" placeholder="Search people..." @input="onSearch" />
|
</button>
|
||||||
</div>
|
<button class="ms-fm-icon-btn" @click="refresh" title="重新整理" style="margin-left:8px;">⟳</button>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<button class="ms-ppl-section-toggle-btn" :class="{ active: showPending }" @click="showPending = !showPending">
|
||||||
|
待定人物 <span class="ms-ppl-toggle-dot" :class="{ on: showPending }"></span>
|
||||||
|
</button>
|
||||||
|
<button class="ms-ppl-section-toggle-btn" :class="{ active: showSkipped }" @click="showSkipped = !showSkipped">
|
||||||
|
已略過 <span class="ms-ppl-toggle-dot" :class="{ on: showSkipped }"></span>
|
||||||
|
</button>
|
||||||
|
<button class="ms-ppl-section-toggle-btn" :class="{ active: showUface }" @click="showUface = !showUface">
|
||||||
|
待定人臉 <span class="ms-ppl-toggle-dot" :class="{ on: showUface }"></span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
@@ -13,234 +23,422 @@
|
|||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
</div>
|
</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" class="empty">
|
||||||
No people found. people.value.length = {{ people.length }}
|
No people found.
|
||||||
</div>
|
</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 已知人物 -->
|
<!-- 已知人物 -->
|
||||||
<div v-if="confirmedPeople.length" class="ms-ppl-section">
|
<div v-if="confirmedPeople.length" class="ms-ppl-section">
|
||||||
<div class="ms-ppl-section-toolbar">
|
<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">({{ confirmedPeople.length }})</span></div>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<div class="ms-ppl-search-wrap">
|
||||||
|
<input v-model="knownSearch" class="ms-ppl-search-input" placeholder="搜尋已知人物">
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<button class="ms-fm-icon-btn" @click.prevent="showKnownSort = !showKnownSort; showPendingSort = false" title="排序與記錄">☰</button>
|
||||||
|
<div v-if="showKnownSort" class="ms-fm-sort-panel" @click.stop>
|
||||||
|
<div class="ms-fm-sort-section" v-if="lastCtxUuid">
|
||||||
|
<div class="ms-fm-sort-title">操作記錄 · {{ lastName }}</div>
|
||||||
|
<div class="ms-undo-row" style="margin-bottom:6px;">
|
||||||
|
<button class="ms-undo-btn" :disabled="!canUndoPerson" @click="doUndo" title="復原 (Ctrl+Z)">↩ 全部復原</button>
|
||||||
|
<button class="ms-undo-btn" :disabled="!canRedoPerson" @click="doRedo" title="重做 (Ctrl+Shift+Z)">↪ 全部重做</button>
|
||||||
|
<button class="ms-undo-btn ms-undo-btn-danger" @click="deleteFromMenu" title="刪除此人物">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="actionHistory.length" class="ms-history-list">
|
||||||
|
<div v-for="a in actionHistory.slice(0, 8)" :key="a.timestamp + a.type" class="ms-history-item" :class="{ 'ms-history-item-undone': a.canRedo }">
|
||||||
|
<span class="ms-history-label">{{ a.label }}</span>
|
||||||
|
<span class="ms-history-time">{{ formatHistoryTime(a.timestamp) }}</span>
|
||||||
|
<span class="ms-history-actions">
|
||||||
|
<button v-if="a.canUndo && (a.type === 'patch' || a.type === 'bind')" class="ms-history-act-btn" @click="doUndoAction(a.type as 'patch' | 'bind')" title="復原此操作">↩</button>
|
||||||
|
<button v-if="a.canRedo && (a.type === 'patch' || a.type === 'bind')" class="ms-history-act-btn ms-history-act-redo" @click="doRedoAction(a.type as 'patch' | 'bind')" title="重做此操作">↪</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ms-history-empty">尚無操作記錄</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-fm-sort-section">
|
||||||
|
<div class="ms-fm-sort-title">排序方式</div>
|
||||||
|
<label><input type="radio" v-model="knownSort" value="recent"> 最近瀏覽</label>
|
||||||
|
<label><input type="radio" v-model="knownSort" value="name_az"> 依檔名・由 A 到 Z</label>
|
||||||
|
<label><input type="radio" v-model="knownSort" value="name_za"> 依檔名・由 Z 到 A</label>
|
||||||
|
<label><input type="radio" v-model="knownSort" value="time_desc"> 依時間・由近到遠</label>
|
||||||
|
<label><input type="radio" v-model="knownSort" value="time_asc"> 依時間・由遠到近</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-ppl-face-grid">
|
<div class="ms-ppl-face-grid">
|
||||||
<div v-for="p in confirmedPeople" :key="p.identityUuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
|
<div v-for="p in confirmedPeople" :key="p.identity_uuid" class="ms-ppl-face-card" :class="{ starred: p.starred }" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)" v-observe="() => enqueueProfileLoad(p.identity_uuid)">
|
||||||
<div class="ms-ppl-face-img-wrap">
|
<div class="ms-ppl-face-img-wrap">
|
||||||
<img v-if="profiles[p.identityUuid]" :src="profiles[p.identityUuid]" alt="" @vue:mounted="loadProfile(p.identityUuid)">
|
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="">
|
||||||
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identityUuid)" viewBox="0 0 120 120" fill="none">
|
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
|
||||||
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
||||||
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</span>
|
<span class="ms-ppl-card-star">⭐</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="ms-ppl-hr">
|
<hr v-if="confirmedPeople.length" class="ms-ppl-hr">
|
||||||
|
|
||||||
<!-- 待定人物 -->
|
<!-- 待定人物 -->
|
||||||
<div v-if="pendingPeople.length" class="ms-ppl-section">
|
<div v-if="showPending && pendingPeople.length" class="ms-ppl-section">
|
||||||
<div class="ms-ppl-section-toolbar">
|
<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">({{ pendingPeople.length }})</span></div>
|
||||||
|
<div style="flex:1;"></div>
|
||||||
|
<div class="ms-ppl-search-wrap">
|
||||||
|
<input v-model="pendingSearch" class="ms-ppl-search-input" placeholder="搜尋待定人物">
|
||||||
|
</div>
|
||||||
|
<div style="position:relative;">
|
||||||
|
<button class="ms-fm-icon-btn" @click.prevent="showPendingSort = !showPendingSort; showKnownSort = false" title="排序與記錄">☰</button>
|
||||||
|
<div v-if="showPendingSort" class="ms-fm-sort-panel" @click.stop>
|
||||||
|
<div class="ms-fm-sort-section" v-if="lastCtxUuid">
|
||||||
|
<div class="ms-fm-sort-title">操作記錄 · {{ lastName }}</div>
|
||||||
|
<div class="ms-undo-row" style="margin-bottom:6px;">
|
||||||
|
<button class="ms-undo-btn" :disabled="!canUndoPerson" @click="doUndo" title="復原 (Ctrl+Z)">↩ 全部復原</button>
|
||||||
|
<button class="ms-undo-btn" :disabled="!canRedoPerson" @click="doRedo" title="重做 (Ctrl+Shift+Z)">↪ 全部重做</button>
|
||||||
|
<button class="ms-undo-btn ms-undo-btn-danger" @click="deleteFromMenu" title="刪除此人物">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="actionHistory.length" class="ms-history-list">
|
||||||
|
<div v-for="a in actionHistory.slice(0, 8)" :key="a.timestamp + a.type" class="ms-history-item" :class="{ 'ms-history-item-undone': a.canRedo }">
|
||||||
|
<span class="ms-history-label">{{ a.label }}</span>
|
||||||
|
<span class="ms-history-time">{{ formatHistoryTime(a.timestamp) }}</span>
|
||||||
|
<span class="ms-history-actions">
|
||||||
|
<button v-if="a.canUndo && (a.type === 'patch' || a.type === 'bind')" class="ms-history-act-btn" @click="doUndoAction(a.type as 'patch' | 'bind')" title="復原此操作">↩</button>
|
||||||
|
<button v-if="a.canRedo && (a.type === 'patch' || a.type === 'bind')" class="ms-history-act-btn ms-history-act-redo" @click="doRedoAction(a.type as 'patch' | 'bind')" title="重做此操作">↪</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ms-history-empty">尚無操作記錄</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-fm-sort-section">
|
||||||
|
<div class="ms-fm-sort-title">排序方式</div>
|
||||||
|
<label><input type="radio" v-model="pendingSort" value="recent"> 最近瀏覽</label>
|
||||||
|
<label><input type="radio" v-model="pendingSort" value="name_az"> 依檔名・由 A 到 Z</label>
|
||||||
|
<label><input type="radio" v-model="pendingSort" value="name_za"> 依檔名・由 Z 到 A</label>
|
||||||
|
<label><input type="radio" v-model="pendingSort" value="time_desc"> 依時間・由近到遠</label>
|
||||||
|
<label><input type="radio" v-model="pendingSort" value="time_asc"> 依時間・由遠到近</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-ppl-face-grid">
|
<div class="ms-ppl-face-grid">
|
||||||
<div v-for="p in pendingPeople" :key="p.identityUuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
|
<div v-for="p in pendingPeople" :key="p.identity_uuid" class="ms-ppl-face-card" :class="{ starred: p.starred }" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)" v-observe="() => enqueueProfileLoad(p.identity_uuid)">
|
||||||
<div class="ms-ppl-face-img-wrap">
|
<div class="ms-ppl-face-img-wrap">
|
||||||
<img v-if="profiles[p.identityUuid]" :src="profiles[p.identityUuid]" alt="" @vue:mounted="loadProfile(p.identityUuid)">
|
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="">
|
||||||
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identityUuid)" viewBox="0 0 120 120" fill="none">
|
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
|
||||||
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
||||||
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</span>
|
<span class="ms-ppl-card-star">⭐</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="ms-ppl-hr">
|
<hr v-if="showPending && pendingPeople.length" class="ms-ppl-hr">
|
||||||
|
|
||||||
<!-- 已略過 -->
|
<!-- 已略過 -->
|
||||||
<div v-if="skippedPeople.length" class="ms-ppl-section">
|
<div v-if="showSkipped && skippedPeople.length" class="ms-ppl-section">
|
||||||
<div class="ms-ppl-section-toolbar">
|
<div class="ms-ppl-section-toolbar">
|
||||||
<div class="ms-ppl-section-title skipped-title">
|
<div class="ms-ppl-section-title skipped-title">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="flex-shrink:0;">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" style="flex-shrink:0;">
|
||||||
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"></circle>
|
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.8"></circle>
|
||||||
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
|
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
|
||||||
</svg>
|
</svg>
|
||||||
已略過:
|
已略過: <span class="ms-ppl-section-count">({{ skippedPeople.length }})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-ppl-face-grid">
|
<div class="ms-ppl-face-grid">
|
||||||
<div v-for="p in skippedPeople" :key="p.identityUuid" class="ms-ppl-face-card skipped-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
|
<div v-for="p in skippedPeople" :key="p.identity_uuid" class="ms-ppl-face-card ms-ppl-card-skipped" :class="{ starred: p.starred }" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)" v-observe="() => enqueueProfileLoad(p.identity_uuid)">
|
||||||
<div class="ms-ppl-face-img-wrap">
|
<div class="ms-ppl-face-img-wrap">
|
||||||
<img v-if="profiles[p.identityUuid]" :src="profiles[p.identityUuid]" alt="" @vue:mounted="loadProfile(p.identityUuid)">
|
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="">
|
||||||
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identityUuid)" viewBox="0 0 120 120" fill="none">
|
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
|
||||||
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
||||||
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ms-ppl-card-star" :class="{ starred: p.starred }">⭐</span>
|
<span class="ms-ppl-card-star">⭐</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
<span class="ms-ppl-face-name">{{ p.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="ms-ppl-hr">
|
<hr v-if="(showSkipped && skippedPeople.length) || (showPending && pendingPeople.length) || confirmedPeople.length" class="ms-ppl-hr">
|
||||||
|
|
||||||
<!-- 待定人臉 -->
|
<!-- 待定人臉 -->
|
||||||
<div v-if="faceCandidates.length" class="ms-ppl-section">
|
<div v-if="showUface && faceCandidates.length" class="ms-ppl-section">
|
||||||
<div class="ms-ppl-section-toolbar">
|
<div class="ms-ppl-section-toolbar">
|
||||||
<div class="ms-ppl-section-title">待定人臉:</div>
|
<div class="ms-ppl-section-title">待定人臉:</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-ppl-face-grid ms-uface-grid">
|
<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="showAssignModal(c)">
|
<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 class="ms-ppl-face-img-wrap">
|
<div class="ms-ppl-face-img-wrap">
|
||||||
<img v-if="candidateThumbs[c.file_uuid]" :src="candidateThumbs[c.file_uuid]" alt="" @vue:mounted="loadCandidateThumb(c.file_uuid)">
|
<img v-if="candidateThumbs[c.id]" :src="candidateThumbs[c.id]" alt="">
|
||||||
<div v-else class="face-placeholder" @vue:mounted="loadCandidateThumb(c.file_uuid)">{{ Math.round(c.confidence * 100) }}%</div>
|
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="ms-ppl-face-name">{{ c.file_uuid.slice(0, 8) }}... #{{ c.frame_number }}</span>
|
<span class="ms-ppl-face-name">{{ (c.file_uuid || '').slice(0, 8) }}... #{{ c.frame_number }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Person context menu -->
|
||||||
<div v-if="ctxMenu.show" class="ms-ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px', display: 'block' }">
|
<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>
|
<button class="ms-ctx-item" @click="ctxAction('star')">{{ ctxMenu.person?.starred ? '☆ 取消重要人物' : '★ 標為重要人物' }}</button>
|
||||||
<hr class="ms-ctx-menu-divider">
|
<hr class="ms-ctx-menu-divider">
|
||||||
|
<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>
|
<button class="ms-ctx-item" @click="ctxAction('rename')">✎ 編輯名稱</button>
|
||||||
<button class="ms-ctx-item" @click="ctxAction('merge')">⇄ 已有此人物</button>
|
<button class="ms-ctx-item" @click="ctxAction('merge')">⇄ 已有此人物</button>
|
||||||
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')">✕ 略過此人物</button>
|
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('skip')">✕ 略過此人物</button>
|
||||||
|
<hr class="ms-ctx-menu-divider" v-if="ctxMenu.person">
|
||||||
|
<button class="ms-ctx-item ms-ctx-undo" :disabled="!canUndoPerson" @click="ctxAction('undo')">↩ 復原<template v-if="undoCounts[ctxMenu.person?.identity_uuid]?.patchUndo || undoCounts[ctxMenu.person?.identity_uuid]?.bindUndo"> ({{ (undoCounts[ctxMenu.person?.identity_uuid]?.patchUndo || 0) + (undoCounts[ctxMenu.person?.identity_uuid]?.bindUndo || 0) }})</template></button>
|
||||||
|
<button class="ms-ctx-item ms-ctx-redo" :disabled="!canRedoPerson" @click="ctxAction('redo')">↪ 重做<template v-if="undoCounts[ctxMenu.person?.identity_uuid]?.patchRedo || undoCounts[ctxMenu.person?.identity_uuid]?.bindRedo"> ({{ (undoCounts[ctxMenu.person?.identity_uuid]?.patchRedo || 0) + (undoCounts[ctxMenu.person?.identity_uuid]?.bindRedo || 0) }})</template></button>
|
||||||
|
<template v-if="ctxHistory.length">
|
||||||
|
<hr class="ms-ctx-menu-divider">
|
||||||
|
<div v-for="a in ctxHistory.slice(0, 5)" :key="a.timestamp + a.type" class="ms-ctx-history-item">
|
||||||
|
<span class="ms-ctx-history-label">{{ a.label }}</span>
|
||||||
|
<span class="ms-ctx-history-time">{{ formatHistoryTime(a.timestamp) }}</span>
|
||||||
|
<button v-if="a.canUndo && (a.type === 'patch' || a.type === 'bind')" class="ms-ctx-item ms-ctx-undo ms-ctx-history-btn" @click="ctxAction('undoAction', a.type)">↩</button>
|
||||||
|
<button v-if="a.canRedo && (a.type === 'patch' || a.type === 'bind')" class="ms-ctx-item ms-ctx-redo ms-ctx-history-btn" @click="ctxAction('redoAction', a.type)">↪</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<hr class="ms-ctx-menu-divider">
|
||||||
|
<button class="ms-ctx-item ms-ctx-danger" @click="ctxAction('delete')">🗑 刪除此人物</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
<!-- 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('assign')">⇄ 指派給現有人物</button>
|
||||||
|
<button class="ms-ctx-item ms-ctx-danger" @click="faceCtxAction('skip')">✕ 略過此人臉</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign modal -->
|
||||||
|
<div v-if="assignModal.show" class="ms-modal-overlay show" @click.self="assignModal.show = false">
|
||||||
|
<div class="ms-modal ms-modal-assign">
|
||||||
|
<button class="ms-fm-icon-btn close-btn" @click="assignModal.show = false">×</button>
|
||||||
|
<div class="ms-assign-header">
|
||||||
|
<div class="ms-assign-trigger-face">
|
||||||
|
<img v-if="candidateThumbs[assignModal.candidate?.id]" :src="candidateThumbs[assignModal.candidate?.id]" alt="">
|
||||||
|
<div v-else class="face-placeholder">{{ Math.round(assignModal.candidate?.confidence * 100 || 0) }}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-assign-info">
|
||||||
|
<div class="ms-assign-title">指派給現有人物</div>
|
||||||
|
<div class="ms-assign-sub">{{ assignModal.candidate?.file_uuid?.slice(0,8) }}... #{{ assignModal.candidate?.frame_number }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-assign-search-wrap">
|
||||||
|
<span class="ms-assign-search-icon">🔍</span>
|
||||||
|
<input v-model="assignSearchQuery" class="ms-assign-search-input" placeholder="搜尋人物名稱...">
|
||||||
|
</div>
|
||||||
|
<div class="ms-assign-grid">
|
||||||
|
<div v-for="p in assignSearchResults" :key="p.identity_uuid" class="ms-assign-face-card" :class="{ selected: assignSelected?.identity_uuid === p.identity_uuid }" @click="assignSelected = p">
|
||||||
|
<div class="ms-assign-face-img">
|
||||||
|
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="">
|
||||||
|
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
|
||||||
|
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
||||||
|
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="ms-assign-face-name">{{ p.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!assignSearchResults.length && assignSearchQuery" class="ms-assign-empty">沒有找到符合的人物</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-assign-footer">
|
||||||
|
<button class="ms-fm-btn" @click="assignModal.show = false">取消</button>
|
||||||
|
<button class="ms-fm-btn ms-fm-btn-primary" @click="confirmAssign" :disabled="!assignSelected">確認指派</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { apiCall } from '@/api'
|
||||||
|
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, invalidatePeople } from '@/store'
|
||||||
console.log('PeopleView script loaded')
|
import { useUndoRedo } from '@/composables/useUndoRedo'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||||
|
|
||||||
const people = ref<any[]>([])
|
const { counts: undoCounts, refreshCounts: refreshUndoCounts, undo, redo, bindUndo, bindRedo, canUndo, canRedo, refreshActionHistory, recentActions: actionHistory, undoAction, redoAction } = useUndoRedo()
|
||||||
const loading = ref(true)
|
|
||||||
const searchQuery = ref('')
|
const people = peopleCache
|
||||||
const searchResults = ref<any[]>([])
|
const loading = computed(() => !peopleLoaded.value)
|
||||||
const isSearching = ref(false)
|
|
||||||
const selected = ref<any>(null)
|
const selected = ref<any>(null)
|
||||||
const faces = ref<any[]>([])
|
const faces = ref<any[]>([])
|
||||||
const traces = ref<any[]>([])
|
const traces = ref<any[]>([])
|
||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
||||||
const profiles = ref<Record<string, string>>({})
|
const profiles = profilesCache
|
||||||
const showCandidates = ref(false)
|
const showCandidates = ref(false)
|
||||||
const showMerge = ref(false)
|
const showMerge = ref(false)
|
||||||
const mergeTarget = ref('')
|
const mergeTarget = ref('')
|
||||||
const candidates = ref<any[]>([])
|
const candidates = ref<any[]>([])
|
||||||
const candidateThumbs = ref<Record<string, string>>({})
|
const candidateThumbs = faceThumbsCache
|
||||||
const faceCandidates = ref<any[]>([])
|
const faceCandidates = faceCandidatesCache
|
||||||
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
|
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)
|
||||||
|
|
||||||
|
// Section visibility toggles
|
||||||
|
const showPending = ref(true)
|
||||||
|
const showSkipped = ref(false)
|
||||||
|
const showUface = ref(true)
|
||||||
|
const starFilter = ref(false)
|
||||||
|
|
||||||
|
// Per-section search
|
||||||
|
const knownSearch = ref('')
|
||||||
|
const pendingSearch = ref('')
|
||||||
|
|
||||||
|
// Per-section sort
|
||||||
|
const knownSort = ref('recent')
|
||||||
|
const pendingSort = ref('name_az')
|
||||||
|
const showKnownSort = ref(false)
|
||||||
|
const showPendingSort = ref(false)
|
||||||
|
|
||||||
|
const lastCtxUuid = ref('')
|
||||||
|
const lastName = ref('')
|
||||||
|
|
||||||
|
const canUndoPerson = computed(() => canUndo(lastCtxUuid.value))
|
||||||
|
const canRedoPerson = computed(() => canRedo(lastCtxUuid.value))
|
||||||
|
const ctxHistory = computed(() => actionHistory.filter((a: any) => a.uuid === lastCtxUuid.value))
|
||||||
|
|
||||||
|
async function doUndoAction(type: 'patch' | 'bind') {
|
||||||
|
if (!lastCtxUuid.value) return
|
||||||
|
try {
|
||||||
|
await undoAction(lastCtxUuid.value, type)
|
||||||
|
} catch (e) { console.error('Undo action failed:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRedoAction(type: 'patch' | 'bind') {
|
||||||
|
if (!lastCtxUuid.value) return
|
||||||
|
try {
|
||||||
|
await redoAction(lastCtxUuid.value, type)
|
||||||
|
} catch (e) { console.error('Redo action failed:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUndo() {
|
||||||
|
if (!lastCtxUuid.value || !canUndoPerson.value) return
|
||||||
|
try {
|
||||||
|
await undo(lastCtxUuid.value)
|
||||||
|
} catch (e) { console.error('Undo failed:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRedo() {
|
||||||
|
if (!lastCtxUuid.value || !canRedoPerson.value) return
|
||||||
|
try {
|
||||||
|
await redo(lastCtxUuid.value)
|
||||||
|
} catch (e) { console.error('Redo failed:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortPeople(list: any[], sortKey: string): any[] {
|
||||||
|
if (sortKey === 'name_az') return [...list].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
if (sortKey === 'name_za') return [...list].sort((a, b) => b.name.localeCompare(a.name))
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
const confirmedPeople = computed(() => {
|
const confirmedPeople = computed(() => {
|
||||||
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
|
let base = people.value.filter((p: any) => p.status === 'confirmed')
|
||||||
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
|
if (starFilter.value) base = base.filter((p: any) => p.starred)
|
||||||
return filtered.filter((p: any) => p.status === 'confirmed')
|
if (knownSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(knownSearch.value.toLowerCase()))
|
||||||
|
return sortPeople(base, knownSort.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const pendingPeople = computed(() => {
|
const pendingPeople = computed(() => {
|
||||||
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
|
let base = people.value.filter((p: any) => p.status === 'pending')
|
||||||
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
|
if (starFilter.value) base = base.filter((p: any) => p.starred)
|
||||||
return filtered.filter((p: any) => p.status === 'pending')
|
if (pendingSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(pendingSearch.value.toLowerCase()))
|
||||||
|
return sortPeople(base, pendingSort.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const skippedPeople = computed(() => {
|
const skippedPeople = computed(() => {
|
||||||
const base = isSearching.value && searchResults.value.length ? searchResults.value : people.value
|
let base = people.value.filter((p: any) => p.status === 'skipped')
|
||||||
const filtered = searchQuery.value ? base.filter((p: any) => p.name.toLowerCase().includes(searchQuery.value.toLowerCase())) : base
|
if (starFilter.value) base = base.filter((p: any) => p.starred)
|
||||||
return filtered.filter((p: any) => p.status === 'skipped')
|
return base
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
const assignSearchResults = computed(() => {
|
||||||
// Wait for Tauri to be ready
|
let base = people.value.filter((p: any) => p.status === 'confirmed' || p.status === 'pending')
|
||||||
let tauri = (window as any).__TAURI_INTERNALS__ || (window as any).__TAURI__
|
if (assignSearchQuery.value) {
|
||||||
let retries = 0
|
base = base.filter((p: any) => p.name.toLowerCase().includes(assignSearchQuery.value.toLowerCase()))
|
||||||
while (!tauri && retries < 20) {
|
|
||||||
await new Promise(r => setTimeout(r, 100))
|
|
||||||
tauri = (window as any).__TAURI_INTERNALS__ || (window as any).__TAURI__
|
|
||||||
retries++
|
|
||||||
}
|
}
|
||||||
if (!tauri) {
|
return base.slice(0, 30)
|
||||||
console.error('Tauri not available after waiting')
|
})
|
||||||
loading.value = false
|
|
||||||
return
|
async function refresh() {
|
||||||
}
|
ensurePeople().then(() => {
|
||||||
try {
|
for (const p of people.value.slice(0, 30)) {
|
||||||
console.log('PeopleView: Tauri available, calling getPeople...')
|
if (p.identity_uuid) loadProfile(p.identity_uuid)
|
||||||
const result = await invoke('get_people', { page: 1, perPage: 1000 })
|
|
||||||
console.log('PeopleView: getPeople raw result type:', typeof result)
|
|
||||||
console.log('PeopleView: getPeople raw result:', result)
|
|
||||||
console.log('PeopleView: isArray?', Array.isArray(result))
|
|
||||||
people.value = Array.isArray(result) ? result : []
|
|
||||||
console.log('PeopleView: people.value.length:', people.value.length)
|
|
||||||
if (people.value.length > 0) {
|
|
||||||
console.log('PeopleView: first person:', people.value[0])
|
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
})
|
||||||
console.error('Failed to load people:', e)
|
ensureFaceCandidates().then(() => {
|
||||||
console.error('Error message:', e.message)
|
for (const c of faceCandidates.value.slice(0, 20)) {
|
||||||
console.error('Error stack:', e.stack)
|
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
|
||||||
} finally {
|
}
|
||||||
loading.value = false
|
})
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const fc: any = await invoke('get_face_candidates', { page: 1, perPage: 100 })
|
onMounted(async () => {
|
||||||
faceCandidates.value = Array.isArray(fc) ? fc : []
|
await refresh()
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load face candidates:', e)
|
|
||||||
}
|
|
||||||
document.addEventListener('click', closeCtxMenu)
|
document.addEventListener('click', closeCtxMenu)
|
||||||
|
document.addEventListener('click', closeFaceCtxMenu)
|
||||||
|
document.addEventListener('click', closeSortPanels)
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', closeCtxMenu)
|
document.removeEventListener('click', closeCtxMenu)
|
||||||
|
document.removeEventListener('click', closeFaceCtxMenu)
|
||||||
|
document.removeEventListener('click', closeSortPanels)
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
let searchTimer: any
|
function closeSortPanels(e?: Event) {
|
||||||
function onSearch() {
|
const target = e?.target as HTMLElement | null
|
||||||
clearTimeout(searchTimer)
|
if (target?.closest('.ms-fm-icon-btn')) return
|
||||||
searchTimer = setTimeout(async () => {
|
if (target?.closest('.ms-fm-sort-panel')) return
|
||||||
if (!searchQuery.value.trim()) { isSearching.value = false; return }
|
showKnownSort.value = false
|
||||||
try {
|
showPendingSort.value = false
|
||||||
const results: any = await invoke('search_identities', { query: searchQuery.value, limit: 50 })
|
|
||||||
searchResults.value = Array.isArray(results) ? results : []
|
|
||||||
isSearching.value = true
|
|
||||||
} catch (e) { console.error('Search failed:', e) }
|
|
||||||
}, 300)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadProfile(uuid: string) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (profiles.value[uuid]) return
|
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||||
try {
|
if (lastCtxUuid.value && canUndoPerson.value) {
|
||||||
const result: string = await invoke('get_identity_profile', { uuid })
|
e.preventDefault()
|
||||||
console.log('Profile loaded for:', uuid, result ? 'success' : 'empty')
|
doUndo()
|
||||||
profiles.value[uuid] = result
|
}
|
||||||
} catch (e) {
|
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'Z' || (e.key === 'z' && e.shiftKey))) {
|
||||||
console.error('Profile load failed for:', uuid, e)
|
if (lastCtxUuid.value && canRedoPerson.value) {
|
||||||
|
e.preventDefault()
|
||||||
|
doRedo()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCandidateThumb(uuid: string) {
|
|
||||||
if (!uuid || candidateThumbs.value[uuid]) return
|
function enqueueProfileLoad(uuid: string) {
|
||||||
try { candidateThumbs.value[uuid] = await invoke('get_thumbnail', { uuid, frame: 30 }) } catch {}
|
loadProfile(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCandidateThumb(c: any) {
|
||||||
|
if (!c?.file_uuid || candidateThumbs.value[c.id]) return
|
||||||
|
loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectPerson(p: any) {
|
function selectPerson(p: any) {
|
||||||
const uuid = p.identityUuid || p.identityUuid
|
const uuid = p.identity_uuid
|
||||||
console.log('selectPerson called:', uuid, p.name)
|
|
||||||
router.push({ name: 'PersonDetail', params: { uuid } })
|
router.push({ name: 'PersonDetail', params: { uuid } })
|
||||||
.catch(e => console.error('Router push failed:', e))
|
.catch(e => console.error('Router push failed:', e))
|
||||||
}
|
}
|
||||||
@@ -255,15 +453,15 @@ async function toggleStar() {
|
|||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!confirm(`Delete "${selected.value.name}"?`)) return
|
if (!confirm(`Delete "${selected.value.name}"?`)) return
|
||||||
try {
|
try {
|
||||||
await invoke('delete_identity', { uuid: selected.value.identity_uuid })
|
await apiCall('delete_identity', { uuid: selected.value.identity_uuid })
|
||||||
people.value = people.value.filter((p: any) => p.identityUuid !== selected.value.identity_uuid)
|
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
|
||||||
selected.value = null
|
selected.value = null
|
||||||
} catch (e) { console.error('Failed to delete:', e) }
|
} catch (e) { console.error('Failed to delete:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCandidates() {
|
async function loadCandidates() {
|
||||||
try {
|
try {
|
||||||
const result: any = await invoke('get_face_candidates', { page: 1, perPage: 50 })
|
const result: any = await apiCall('get_face_candidates', { page: 1, perPage: 50 })
|
||||||
candidates.value = Array.isArray(result) ? result : []
|
candidates.value = Array.isArray(result) ? result : []
|
||||||
} catch (e) { console.error('Failed to load candidates:', e) }
|
} catch (e) { console.error('Failed to load candidates:', e) }
|
||||||
}
|
}
|
||||||
@@ -271,7 +469,7 @@ async function loadCandidates() {
|
|||||||
async function bindCandidate(c: any) {
|
async function bindCandidate(c: any) {
|
||||||
if (!selected.value) return
|
if (!selected.value) return
|
||||||
try {
|
try {
|
||||||
await invoke('bind_face', { uuid: selected.value.identity_uuid, faceId: String(c.id), fileUuid: c.file_uuid })
|
await apiCall('bind_face', { uuid: selected.value.identity_uuid, faceId: c.face_id || String(c.id), faceRowId: c.face_id ? undefined : c.id, fileUuid: c.file_uuid })
|
||||||
showCandidates.value = false
|
showCandidates.value = false
|
||||||
if (selected.value) selectPerson(selected.value)
|
if (selected.value) selectPerson(selected.value)
|
||||||
} catch (e) { console.error('Bind failed:', e) }
|
} catch (e) { console.error('Bind failed:', e) }
|
||||||
@@ -280,53 +478,106 @@ async function bindCandidate(c: any) {
|
|||||||
async function confirmMerge() {
|
async function confirmMerge() {
|
||||||
if (!selected.value || !mergeTarget.value) return
|
if (!selected.value || !mergeTarget.value) return
|
||||||
try {
|
try {
|
||||||
await invoke('merge_identities', { uuid: selected.value.identity_uuid, intoUuid: mergeTarget.value })
|
await apiCall('merge_identities', { uuid: selected.value.identity_uuid, intoUuid: mergeTarget.value })
|
||||||
showMerge.value = false
|
showMerge.value = false
|
||||||
people.value = people.value.filter((p: any) => p.identityUuid !== selected.value.identity_uuid)
|
people.value = people.value.filter((p: any) => p.identity_uuid !== selected.value.identity_uuid)
|
||||||
selected.value = null
|
selected.value = null
|
||||||
} catch (e) { console.error('Merge failed:', e) }
|
} catch (e) { console.error('Merge failed:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function showAssignModal(c: any) {
|
|
||||||
alert(`Assign face ${c.id} to existing person - not yet implemented`)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(sec: number): string {
|
|
||||||
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
|
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function playTrace(t: any) {
|
function playTrace(t: any) {
|
||||||
currentVideo.value = { fileUuid: t.file_uuid, startTime: t.first_sec, endTime: t.last_sec, title: `${selected.value?.name} - ${formatTime(t.first_sec)}-${formatTime(t.last_sec)}` }
|
currentVideo.value = { fileUuid: t.file_uuid, startTime: t.first_sec, endTime: t.last_sec, title: `${selected.value?.name} - ${formatTime(t.first_sec)}-${formatTime(t.last_sec)}` }
|
||||||
playing.value = true
|
playing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatTime(sec: number): string {
|
||||||
|
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHistoryTime(ts: string): string {
|
||||||
|
if (!ts) return ''
|
||||||
|
const d = new Date(ts)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
if (diff < 60000) return '剛剛'
|
||||||
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}分鐘前`
|
||||||
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小時前`
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${d.getMinutes().toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFromMenu() {
|
||||||
|
if (!lastCtxUuid.value) return
|
||||||
|
const p = people.value.find((x: any) => x.identity_uuid === lastCtxUuid.value)
|
||||||
|
if (!p) return
|
||||||
|
if (!confirm(`刪除「${p.name}」?此操作無法復原。`)) return
|
||||||
|
try {
|
||||||
|
await apiCall('delete_identity', { uuid: lastCtxUuid.value })
|
||||||
|
people.value = people.value.filter((x: any) => x.identity_uuid !== lastCtxUuid.value)
|
||||||
|
invalidatePeople()
|
||||||
|
} catch (e) { console.error('Delete failed:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
function showContextMenu(e: MouseEvent, p: any) {
|
function showContextMenu(e: MouseEvent, p: any) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
lastCtxUuid.value = p.identity_uuid
|
||||||
|
lastName.value = p.name
|
||||||
ctxMenu.value = { show: true, x: e.clientX, y: e.clientY, person: p }
|
ctxMenu.value = { show: true, x: e.clientX, y: e.clientY, person: p }
|
||||||
|
refreshUndoCounts(p.identity_uuid)
|
||||||
|
refreshActionHistory(p.identity_uuid)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ctxAction(action: string) {
|
async function ctxAction(action: string, extra?: any) {
|
||||||
const p = ctxMenu.value.person
|
const p = ctxMenu.value.person
|
||||||
if (!p) return
|
if (!p) return
|
||||||
ctxMenu.value.show = false
|
ctxMenu.value.show = false
|
||||||
|
const uuid = p.identity_uuid
|
||||||
|
lastCtxUuid.value = uuid
|
||||||
|
lastName.value = p.name
|
||||||
if (action === 'star') {
|
if (action === 'star') {
|
||||||
p.starred = !p.starred
|
const newVal = !p.starred
|
||||||
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identityUuid)
|
apiCall('update_identity_starred', { uuid, starred: newVal }).then(() => {
|
||||||
if (idx >= 0) people.value[idx].starred = p.starred
|
p.starred = newVal
|
||||||
|
const idx = people.value.findIndex((x: any) => x.identity_uuid === uuid)
|
||||||
|
if (idx >= 0) people.value[idx].starred = newVal
|
||||||
|
refreshUndoCounts(uuid)
|
||||||
|
}).catch(e => console.error('Star failed:', e))
|
||||||
} else if (action === 'skip') {
|
} else if (action === 'skip') {
|
||||||
invoke('update_identity_status', { uuid: p.identityUuid, status: 'skipped' }).then(() => {
|
apiCall('update_identity_status', { uuid, status: 'skipped' }).then(() => {
|
||||||
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identityUuid)
|
const idx = people.value.findIndex((x: any) => x.identity_uuid === uuid)
|
||||||
if (idx >= 0) people.value[idx].status = 'skipped'
|
if (idx >= 0) people.value[idx].status = 'skipped'
|
||||||
|
refreshUndoCounts(uuid)
|
||||||
}).catch(e => console.error('Skip failed:', e))
|
}).catch(e => console.error('Skip failed:', e))
|
||||||
} else if (action === 'confirm') {
|
} else if (action === 'confirm') {
|
||||||
invoke('update_identity_status', { uuid: p.identityUuid, status: 'confirmed' }).then(() => {
|
apiCall('update_identity_status', { uuid, status: 'confirmed' }).then(() => {
|
||||||
const idx = people.value.findIndex((x: any) => x.identity_uuid === p.identityUuid)
|
const idx = people.value.findIndex((x: any) => x.identity_uuid === uuid)
|
||||||
if (idx >= 0) people.value[idx].status = 'confirmed'
|
if (idx >= 0) people.value[idx].status = 'confirmed'
|
||||||
|
refreshUndoCounts(uuid)
|
||||||
}).catch(e => console.error('Confirm failed:', e))
|
}).catch(e => console.error('Confirm failed:', e))
|
||||||
|
} else if (action === 'pending') {
|
||||||
|
apiCall('update_identity_status', { uuid, status: 'pending' }).then(() => {
|
||||||
|
const idx = people.value.findIndex((x: any) => x.identity_uuid === uuid)
|
||||||
|
if (idx >= 0) people.value[idx].status = 'pending'
|
||||||
|
refreshUndoCounts(uuid)
|
||||||
|
}).catch(e => console.error('Pending failed:', e))
|
||||||
} else if (action === 'rename' || action === 'merge') {
|
} else if (action === 'rename' || action === 'merge') {
|
||||||
selectPerson(p)
|
selectPerson(p)
|
||||||
|
} else if (action === 'undo') {
|
||||||
|
doUndo()
|
||||||
|
} else if (action === 'redo') {
|
||||||
|
doRedo()
|
||||||
|
} else if (action === 'undoAction') {
|
||||||
|
doUndoAction(extra as 'patch' | 'bind')
|
||||||
|
} else if (action === 'redoAction') {
|
||||||
|
doRedoAction(extra as 'patch' | 'bind')
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
if (!confirm(`刪除「${p.name}」?此操作無法復原。`)) return
|
||||||
|
try {
|
||||||
|
await apiCall('delete_identity', { uuid })
|
||||||
|
people.value = people.value.filter((x: any) => x.identity_uuid !== uuid)
|
||||||
|
invalidatePeople()
|
||||||
|
} catch (e) { console.error('Delete failed:', e) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,80 +586,168 @@ function closeCtxMenu(e?: MouseEvent) {
|
|||||||
ctxMenu.value.show = false
|
ctxMenu.value.show = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showFaceCtxMenu(e: MouseEvent, c: any) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: c }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFaceCtxMenu(e?: MouseEvent) {
|
||||||
|
if (e && e.target instanceof Element && e.target.closest('.ms-ctx-menu')) return
|
||||||
|
faceCtxMenu.value.show = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function faceCtxAction(action: string) {
|
||||||
|
const c = faceCtxMenu.value.candidate
|
||||||
|
if (!c) return
|
||||||
|
faceCtxMenu.value.show = false
|
||||||
|
if (action === 'assign') {
|
||||||
|
openAssignModal(c)
|
||||||
|
} else if (action === 'skip') {
|
||||||
|
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssignModal(c: any) {
|
||||||
|
assignModal.value = { show: true, candidate: c }
|
||||||
|
assignSearchQuery.value = ''
|
||||||
|
assignSelected.value = null
|
||||||
|
loadCandidateThumb(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAssign() {
|
||||||
|
const c = assignModal.value.candidate
|
||||||
|
if (!c || !assignSelected.value) return
|
||||||
|
try {
|
||||||
|
await apiCall('bind_face', {
|
||||||
|
uuid: assignSelected.value.identity_uuid,
|
||||||
|
faceId: c.face_id || String(c.id),
|
||||||
|
faceRowId: c.face_id ? undefined : c.id,
|
||||||
|
fileUuid: c.file_uuid
|
||||||
|
})
|
||||||
|
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
|
||||||
|
assignModal.value.show = false
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Bind failed:', e)
|
||||||
|
alert('指派失敗:' + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(showCandidates, (v) => { if (v) loadCandidates() })
|
watch(showCandidates, (v) => { if (v) loadCandidates() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ms-ppl-card-star { position: absolute; top: 6px; left: 6px; font-size: 16px; color: #f59e0b; text-shadow: 0 1px 3px rgba(0,0,0,.25); display: none; }
|
|
||||||
.ms-ppl-face-card.starred .ms-ppl-card-star, .ms-ppl-card-star.starred { display: block; }
|
|
||||||
.people-view { max-width: 1200px; }
|
.people-view { max-width: 1200px; }
|
||||||
h1 { margin: 0; }
|
h1 { margin: 0; }
|
||||||
.loading-state, .empty { text-align: center; padding: 60px 0; color: #5f6368; }
|
.loading-state, .empty { text-align: center; padding: 60px 0; color: #5f6368; }
|
||||||
.spinner-lg { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #202124; border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
|
.spinner-lg { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #202124; border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.ms-ppl-detail-view { display: none; }
|
|
||||||
.ms-ppl-detail-view.show { display: block; }
|
.ms-ppl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||||
.ms-ppl-detail-header { display: flex; align-items: flex-start; gap: 22px; margin-bottom: 28px; position: relative; margin-top: 20px; }
|
.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-detail-avatar { width: 120px; height: 120px; border-radius: 20px; background: #e0e0e0; flex-shrink: 0; overflow: hidden; }
|
.ms-ppl-star-toggle-btn:hover { border-color: #202124; }
|
||||||
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 20px; }
|
.ms-ppl-star-icon { font-size: 16px; color: #d1d5db; transition: color .15s; }
|
||||||
.ms-ppl-detail-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
.ms-ppl-star-icon.starred { color: #f59e0b; }
|
||||||
.ms-ppl-star-btn { font-size: 20px; background: transparent; border: none; cursor: pointer; outline: none; line-height: 1; color: #d1d5db; transition: color .15s; padding: 0; flex-shrink: 0; }
|
.ms-fm-icon-btn { width: 34px; height: 34px; border: 1.5px solid #d1d5db; border-radius: 10px; background: #fff; cursor: pointer; display: grid; place-items: center; font-size: 16px; color: #5f6368; transition: border-color .15s, color .15s; }
|
||||||
.ms-ppl-star-btn.starred { color: #f59e0b; }
|
.ms-fm-icon-btn:hover { border-color: #202124; color: #202124; }
|
||||||
.ms-ppl-detail-aliases { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }
|
.ms-ppl-section-toggle-btn { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1.5px solid #d1d5db; border-radius: 10px; background: #fff; cursor: pointer; font-size: 12.5px; font-family: inherit; color: #5f6368; transition: border-color .15s, color .15s, background .15s; }
|
||||||
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: #f0f0f0; border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: #5f6368; }
|
.ms-ppl-section-toggle-btn.active { border-color: #202124; color: #202124; background: #f8f9fa; }
|
||||||
.ms-ppl-edit-fields { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
.ms-ppl-toggle-dot { width: 8px; height: 8px; border-radius: 50%; background: #d1d5db; transition: background .15s; }
|
||||||
.ms-ppl-edit-field-row { display: flex; align-items: center; gap: 12px; }
|
.ms-ppl-toggle-dot.on { background: #1a56db; }
|
||||||
.ms-ppl-edit-label { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; font-weight: 700; color: #202124; letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
|
.ms-ppl-section { margin-bottom: 8px; }
|
||||||
.ms-ppl-view-box { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; min-height: 24px; display: flex; align-items: center; word-break: break-word; padding: 2px 0; }
|
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||||
.ms-ppl-view-name-box { font-size: 20px; font-weight: 700; flex: 1; min-width: 0; cursor: pointer; }
|
.ms-ppl-section-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 14px; font-weight: 600; color: #202124; margin: 0; display: flex; align-items: center; gap: 6px; }
|
||||||
.ms-ppl-view-name-box:hover { text-decoration: underline; }
|
.ms-ppl-section-count { font-weight: 400; color: #9aa0a6; font-size: 13px; }
|
||||||
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: #3c4043; }
|
.ms-ppl-hr { border: none; border-top: 1.5px solid #e8eaed; margin: 24px 0; }
|
||||||
.ms-ppl-strip-wrap { display: flex; align-items: center; gap: 10px; margin-bottom: 28px; }
|
.skipped-title { color: #9aa0a6; }
|
||||||
.ms-ppl-strip-add-btn { width: 52px; height: 52px; border-radius: 12px; border: 1.5px dashed #bdc1c6; background: #fff; font-size: 20px; color: #bdc1c6; display: grid; place-items: center; cursor: pointer; outline: none; flex-shrink: 0; transition: border-color .15s, color .15s; }
|
.ms-ppl-card-skipped .ms-ppl-face-img-wrap { filter: grayscale(0.6); opacity: 0.7; }
|
||||||
.ms-ppl-strip-add-btn:hover { border-color: #202124; color: #202124; }
|
.ms-ppl-card-skipped .ms-ppl-face-name { color: #bdc1c6; }
|
||||||
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
|
.ms-ppl-face-grid { display: flex; flex-wrap: wrap; gap: 16px; }
|
||||||
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
|
.ms-ppl-face-card { width: 120px; cursor: pointer; border-radius: 12px; transition: transform .15s, box-shadow .15s; }
|
||||||
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: #e8eaed; overflow: hidden; transition: border-color .15s; }
|
.ms-ppl-face-card:hover { transform: translateY(-3px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
|
||||||
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: #202124; }
|
.ms-ppl-face-card.starred .ms-ppl-card-star { display: block; }
|
||||||
|
.ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; background: #e8eaed; overflow: hidden; position: relative; }
|
||||||
|
.ms-ppl-face-img-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.ms-ppl-card-star { display: none; position: absolute; top: 4px; right: 4px; font-size: 14px; line-height: 1; }
|
||||||
|
.ms-silhouette { width: 100%; height: 100%; }
|
||||||
|
.ms-ppl-face-name { display: block; text-align: center; font-size: 12px; color: #202124; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.ms-uface-grid .ms-ppl-face-card { width: 120px; }
|
.ms-uface-grid .ms-ppl-face-card { width: 120px; }
|
||||||
.ms-uface-grid .ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; }
|
.ms-uface-grid .ms-ppl-face-img-wrap { width: 120px; height: 120px; border-radius: 20px; }
|
||||||
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
|
|
||||||
.close-btn { position: absolute; top: 16px; right: 16px; }
|
|
||||||
.detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 24px; }
|
|
||||||
.detail-avatar { width: 80px; height: 80px; border-radius: 16px; flex-shrink: 0; }
|
|
||||||
.detail-info { flex: 1; }
|
|
||||||
.name-row { display: flex; align-items: center; gap: 8px; }
|
|
||||||
.name-row h2 { margin: 0; cursor: pointer; font-size: 1.25rem; }
|
|
||||||
.name-row h2:hover { text-decoration: underline; }
|
|
||||||
.name-input { font-size: 1.25rem; font-weight: 700; border: 1.5px solid #d1d5db; border-radius: 8px; padding: 4px 8px; width: 200px; outline: none; }
|
|
||||||
.edit-btn { width: 28px; height: 28px; }
|
|
||||||
.uuid { color: #9aa0a6; font-size: 0.8rem; font-family: monospace; margin: 4px 0 8px; }
|
|
||||||
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
||||||
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
|
|
||||||
.tab { padding: 8px 16px; border: 1.5px solid #d1d5db; background: #fff; border-radius: 10px; cursor: pointer; font-size: 0.85rem; }
|
|
||||||
.tab.active { background: #202124; color: #fff; border-color: #202124; }
|
|
||||||
.face-strip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
|
|
||||||
.face-thumb { width: 52px; height: 52px; border-radius: 8px; background: #e8eaed; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
||||||
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
|
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
|
||||||
.ms-ppl-media-item { cursor: pointer; }
|
|
||||||
.ms-ppl-media-thumb { position: relative; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; }
|
/* Search input */
|
||||||
.thumb-play { color: #fff; font-size: 1.2rem; opacity: 0.8; }
|
.ms-ppl-search-wrap { position: relative; }
|
||||||
.merge-input { width: 100%; padding: 10px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; margin-bottom: 16px; font-size: 0.9rem; outline: none; }
|
.ms-ppl-search-input { padding: 6px 12px; border: 1.5px solid #d1d5db; border-radius: 8px; font-size: 12.5px; outline: none; width: 160px; font-family: inherit; transition: border-color .15s; }
|
||||||
.merge-input:focus { border-color: #202124; }
|
.ms-ppl-search-input:focus { border-color: #202124; }
|
||||||
.ms-modal-actions { display: flex; justify-content: flex-end; }
|
|
||||||
h2 { margin: 0 0 16px; font-size: 1rem; }
|
/* Sort panel */
|
||||||
.ms-merge-grid { display: flex; flex-wrap: wrap; gap: 16px; max-height: 50vh; overflow-y: auto; }
|
.ms-fm-sort-panel { position: absolute; top: 100%; right: 0; z-index: 999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 12px 16px; min-width: 180px; margin-top: 4px; }
|
||||||
|
.ms-fm-sort-section { }
|
||||||
|
.ms-fm-sort-title { font-size: 11.5px; font-weight: 600; color: #9aa0a6; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; }
|
||||||
|
.ms-undo-row { display: flex; gap: 8px; }
|
||||||
|
.ms-undo-btn { display: flex; align-items: center; gap: 4px; padding: 6px 14px; border: 1.5px solid #d1d5db; border-radius: 8px; background: #fff; cursor: pointer; font-size: 13px; font-family: inherit; color: #1a56db; transition: border-color .15s, background .15s; }
|
||||||
|
.ms-undo-btn:hover:not(:disabled) { border-color: #1a56db; background: #e8f0fe; }
|
||||||
|
.ms-undo-btn:disabled { color: #bdc1c6; cursor: not-allowed; opacity: 0.6; }
|
||||||
|
.ms-undo-btn-danger { color: #d93025; border-color: #f1c0b8; }
|
||||||
|
.ms-undo-btn-danger:hover:not(:disabled) { border-color: #d93025; background: #fce8e6; }
|
||||||
|
.ms-history-list { margin-top: 8px; max-height: 200px; overflow-y: auto; }
|
||||||
|
.ms-history-item { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 12px; color: #5f6368; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.ms-history-item:last-child { border-bottom: none; }
|
||||||
|
.ms-history-item-undone { opacity: 0.5; text-decoration: line-through; }
|
||||||
|
.ms-history-label { color: #3c4043; }
|
||||||
|
.ms-history-time { color: #9aa0a6; font-size: 11px; white-space: nowrap; margin-left: 8px; }
|
||||||
|
.ms-history-actions { display: flex; gap: 2px; margin-left: 4px; }
|
||||||
|
.ms-history-act-btn { background: none; border: 1px solid #d1d5db; border-radius: 4px; padding: 1px 5px; font-size: 12px; cursor: pointer; color: #1a56db; line-height: 1; }
|
||||||
|
.ms-history-act-btn:hover { background: #e8f0fe; border-color: #1a56db; }
|
||||||
|
.ms-history-act-redo { color: #1a56db; }
|
||||||
|
.ms-history-empty { font-size: 12px; color: #9aa0a6; padding: 4px 0; }
|
||||||
|
.ms-fm-sort-section label { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; color: #3c4043; cursor: pointer; }
|
||||||
|
.ms-fm-sort-section input[type="radio"] { margin: 0; accent-color: #202124; }
|
||||||
|
|
||||||
|
/* Context menu */
|
||||||
.ms-ctx-menu { position: fixed; z-index: 99999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 160px; font-size: 13px; color: #222; }
|
.ms-ctx-menu { position: fixed; z-index: 99999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 160px; font-size: 13px; color: #222; }
|
||||||
.ms-ctx-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-radius: 8px; border: none; background: transparent; width: 100%; text-align: left; font-size: 13px; color: #222; font-family: inherit; }
|
.ms-ctx-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-radius: 8px; border: none; background: transparent; width: 100%; text-align: left; font-size: 13px; color: #222; font-family: inherit; }
|
||||||
.ms-ctx-item:hover { background: #f3f4f6; }
|
.ms-ctx-item:hover { background: #f3f4f6; }
|
||||||
.ms-ctx-item.ms-ctx-danger { color: #d93025; }
|
.ms-ctx-item.ms-ctx-danger { color: #d93025; }
|
||||||
.ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; }
|
.ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; }
|
||||||
|
.ms-ctx-item.ms-ctx-undo { color: #1a56db; }
|
||||||
|
.ms-ctx-item.ms-ctx-redo { color: #1a56db; }
|
||||||
|
.ms-ctx-item.ms-ctx-undo:disabled, .ms-ctx-item.ms-ctx-redo:disabled { color: #bdc1c6; cursor: default; }
|
||||||
|
.ms-ctx-item.ms-ctx-undo:disabled:hover, .ms-ctx-item.ms-ctx-redo:disabled:hover { background: transparent; }
|
||||||
.ms-ctx-menu-divider { height: 1px; background: #eee; margin: 4px 8px; }
|
.ms-ctx-menu-divider { height: 1px; background: #eee; margin: 4px 8px; }
|
||||||
.ms-ppl-section { margin-bottom: 8px; }
|
.ms-ctx-history-item { display: flex; align-items: center; gap: 4px; padding: 3px 10px; font-size: 12px; color: #5f6368; }
|
||||||
.ms-ppl-section-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
.ms-ctx-history-label { flex: 1; color: #3c4043; font-size: 12px; }
|
||||||
.ms-ppl-section-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 14px; font-weight: 600; color: #202124; margin: 0; display: flex; align-items: center; gap: 6px; }
|
.ms-ctx-history-time { color: #9aa0a6; font-size: 10px; white-space: nowrap; }
|
||||||
.ms-ppl-hr { border: none; border-top: 1.5px solid #e8eaed; margin: 24px 0; }
|
.ms-ctx-history-btn { padding: 2px 6px !important; font-size: 12px !important; min-width: 24px; }
|
||||||
.skipped-title { color: #9aa0a6; }
|
|
||||||
.skipped-card .ms-ppl-face-img-wrap { filter: grayscale(0.6); opacity: 0.7; }
|
/* Modal overlay */
|
||||||
.skipped-card .ms-ppl-face-name { color: #bdc1c6; }
|
.ms-modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.35); z-index: 9998; align-items: center; justify-content: center; }
|
||||||
|
.ms-modal-overlay.show { display: flex; }
|
||||||
|
.close-btn { position: absolute; top: 16px; right: 16px; }
|
||||||
|
|
||||||
|
/* Assign modal */
|
||||||
|
.ms-modal-assign { max-width: 560px; width: 92%; padding: 28px 32px; text-align: left; max-height: 85vh; overflow-y: auto; margin-top: 48px; align-self: flex-start; background: #fff; border-radius: 16px; box-shadow: 0 8px 30px rgba(0,0,0,.2); position: relative; }
|
||||||
|
.ms-assign-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
|
||||||
|
.ms-assign-trigger-face { width: 72px; height: 72px; border-radius: 14px; background: #e8eaed; flex-shrink: 0; overflow: hidden; }
|
||||||
|
.ms-assign-trigger-face img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.ms-assign-info { flex: 1; min-width: 0; }
|
||||||
|
.ms-assign-title { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 16px; font-weight: 600; color: #202124; margin: 0 0 4px; }
|
||||||
|
.ms-assign-sub { font-size: 12px; color: #9aa0a6; }
|
||||||
|
.ms-assign-search-wrap { position: relative; margin-bottom: 16px; }
|
||||||
|
.ms-assign-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); font-size: 14px; color: #9aa0a6; }
|
||||||
|
.ms-assign-search-input { width: 100%; padding: 10px 14px 10px 36px; border: 1.5px solid #d1d5db; border-radius: 10px; font-size: 13px; outline: none; font-family: inherit; transition: border-color .15s; }
|
||||||
|
.ms-assign-search-input:focus { border-color: #202124; }
|
||||||
|
.ms-assign-grid { display: flex; flex-wrap: wrap; gap: 12px; min-height: 120px; max-height: 45vh; overflow-y: auto; padding: 4px 0; }
|
||||||
|
.ms-assign-face-card { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; width: 80px; padding: 8px; border-radius: 12px; transition: background .15s; }
|
||||||
|
.ms-assign-face-card:hover { background: #f3f4f6; }
|
||||||
|
.ms-assign-face-card.selected { background: #e8f0fe; }
|
||||||
|
.ms-assign-face-img { width: 64px; height: 64px; border-radius: 14px; overflow: hidden; background: #e8eaed; border: 2px solid transparent; transition: border-color .15s; }
|
||||||
|
.ms-assign-face-card.selected .ms-assign-face-img { border-color: #1a56db; }
|
||||||
|
.ms-assign-face-img img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.ms-assign-face-name { font-size: 11px; color: #202124; text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 72px; }
|
||||||
|
.ms-assign-empty { width: 100%; padding: 32px 0; text-align: center; color: #9aa0a6; font-size: 13px; }
|
||||||
|
.ms-assign-footer { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||||
|
.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; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,6 +7,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
返回
|
返回
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="!isEditing" class="ms-ppl-edit-text-btn" @click="startEditing">
|
||||||
|
編輯
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" style="margin-left:4px;">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
@@ -16,18 +23,26 @@
|
|||||||
<template v-else-if="person">
|
<template v-else-if="person">
|
||||||
<div class="ms-ppl-detail-header">
|
<div class="ms-ppl-detail-header">
|
||||||
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;">
|
<div style="display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;">
|
||||||
<div class="ms-ppl-detail-avatar">
|
<div class="ms-ppl-detail-avatar" :class="{ 'ms-ppl-avatar-editable': isEditing }" @click="isEditing && triggerAvatarUpload()">
|
||||||
<img v-if="profile" :src="profile" alt="">
|
<img v-if="profile" :src="profile" alt="" style="width:100%;height:100%;object-fit:cover;">
|
||||||
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
|
<svg v-else class="ms-silhouette" viewBox="0 0 120 120" fill="none">
|
||||||
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
||||||
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<div v-if="isEditing" class="ms-ppl-avatar-upload-hint">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||||
|
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="12" cy="13" r="4" stroke="currentColor" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<input ref="fileInput" type="file" accept="image/jpeg,image/png" style="display:none" @change="handleAvatarUpload">
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1;min-width:0;">
|
<div style="flex:1;min-width:0;">
|
||||||
<div id="msPplInfoView">
|
<!-- View mode -->
|
||||||
|
<div v-if="!isEditing" id="msPplInfoView">
|
||||||
<div class="ms-ppl-detail-name-row">
|
<div class="ms-ppl-detail-name-row">
|
||||||
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">☆</button>
|
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">{{ person.starred ? '★' : '☆' }}</button>
|
||||||
<div class="ms-ppl-view-box ms-ppl-view-name-box">{{ person.name || '—' }}</div>
|
<div class="ms-ppl-view-box ms-ppl-view-name-box">{{ person.name || '—' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ms-ppl-detail-aliases" v-if="person.metadata?.aliases?.length">
|
<div class="ms-ppl-detail-aliases" v-if="person.metadata?.aliases?.length">
|
||||||
@@ -44,49 +59,129 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Edit mode -->
|
||||||
|
<div v-else id="msPplInfoEdit">
|
||||||
|
<div class="ms-ppl-edit-row ms-ppl-edit-name-row">
|
||||||
|
<button class="ms-ppl-star-btn" :class="{ starred: person.starred }" @click="toggleStar">{{ person.starred ? '★' : '☆' }}</button>
|
||||||
|
<input v-model="editName" class="ms-ppl-edit-input ms-ppl-edit-name-input" placeholder="加入人名">
|
||||||
|
</div>
|
||||||
|
<div class="ms-ppl-edit-row ms-ppl-edit-alias-row">
|
||||||
|
<div class="ms-ppl-alias-wrap-inner">
|
||||||
|
<span v-for="(a, i) in editAliases" :key="i" class="ms-ppl-alias-tag">
|
||||||
|
{{ a.name }}
|
||||||
|
<button @click="removeAlias(i)">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<template v-if="showAliasInput">
|
||||||
|
<select v-model="aliasLocale" class="ms-ppl-alias-locale-select">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh-TW">繁體中文</option>
|
||||||
|
<option value="zh-CN">简体中文</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="aliasName" class="ms-ppl-alias-inline-input" placeholder="輸入別名後按 Enter" @keyup.enter="addAlias">
|
||||||
|
</template>
|
||||||
|
<button v-if="!showAliasInput" class="ms-ppl-alias-add-btn" @click="showAliasInput = true; aliasName = ''">+ 別名</button>
|
||||||
|
<button v-else class="ms-ppl-alias-add-btn" @click="showAliasInput = false">收起</button>
|
||||||
|
</div>
|
||||||
|
<div class="ms-ppl-edit-fields">
|
||||||
|
<div class="ms-ppl-edit-field-row">
|
||||||
|
<label class="ms-ppl-edit-label">角色</label>
|
||||||
|
<input v-model="editRole" class="ms-ppl-edit-input ms-ppl-edit-field-input" placeholder="角色名稱">
|
||||||
|
</div>
|
||||||
|
<div class="ms-ppl-edit-field-row ms-ppl-edit-field-row--top">
|
||||||
|
<label class="ms-ppl-edit-label">描述</label>
|
||||||
|
<textarea v-model="editNotes" class="ms-ppl-edit-textarea ms-ppl-edit-field-input" placeholder="自訂描述"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-ppl-edit-actions">
|
||||||
|
<button class="ms-fm-btn ms-fm-btn-primary" @click="saveEdit" :disabled="saving">{{ saving ? '儲存中...' : '✓ 儲存更改' }}</button>
|
||||||
|
<button class="ms-fm-btn" @click="cancelEditing">取消</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-ppl-strip-wrap">
|
<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-add-btn" @click="showCandidates = true" title="加入相同人物">+</button>
|
||||||
<div class="ms-ppl-face-strip">
|
<div class="ms-ppl-face-strip">
|
||||||
<div v-for="f in faces.slice(0, 30)" :key="f.id" class="ms-ppl-strip-face">
|
<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 class="ms-ppl-strip-face-img">
|
<div class="ms-ppl-strip-face-img">
|
||||||
<img v-if="f.thumbUrl" :src="f.thumbUrl" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;border-radius:8px;">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
<button v-if="isEditing" class="ms-ppl-strip-remove-btn" @click="unbindFace(f)">×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="faces.length > 30" class="ms-ppl-strip-arrow" @click="showAllFaces = !showAllFaces">›</button>
|
<button v-if="faces.length > 30" class="ms-ppl-strip-arrow" @click="showAllFaces = !showAllFaces">›</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-ppl-media-label" v-if="mergedTraces.length">{{ mergedTraces.length }} segments</div>
|
<!-- Face detail card -->
|
||||||
<div class="ms-ppl-media-grid">
|
<div v-if="selectedFace" class="ms-ppl-face-card-detail">
|
||||||
<div v-for="(m, i) in mergedTraces.slice(0, 20)" :key="i" class="ms-ppl-media-item" @click="playVideo(m.file_uuid, m.start, m.end)">
|
<button class="ms-ppl-face-card-close" @click="selectedFace = null">×</button>
|
||||||
<div class="ms-ppl-media-thumb">
|
<div class="ms-ppl-face-card-img-wrap">
|
||||||
<img v-if="m.thumbUrl" :src="m.thumbUrl" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;" @error="handleThumbError">
|
<img v-if="faceThumbs[selectedFace.id]" :src="faceThumbs[selectedFace.id]" alt="" style="width:100%;height:100%;object-fit:cover;border-radius:12px;">
|
||||||
<div class="ms-thumb-play-circle">
|
<div v-else class="face-placeholder">?</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20" fill="white"/></svg>
|
</div>
|
||||||
</div>
|
<div class="ms-ppl-face-card-info">
|
||||||
<span class="ms-ppl-media-dur">{{ (m.end - m.start).toFixed(1) }}s</span>
|
<div class="ms-ppl-face-card-file">{{ (selectedFace.file_uuid || '').slice(0, 12) }}...</div>
|
||||||
</div>
|
<div class="ms-ppl-face-card-frame">Frame #{{ selectedFace.frame_number }}</div>
|
||||||
<div class="ms-ppl-media-info">
|
<div v-if="selectedFace.confidence" class="ms-ppl-face-card-conf">Confidence: {{ (selectedFace.confidence * 100).toFixed(1) }}%</div>
|
||||||
<div class="ms-ppl-media-title">{{ m.count > 1 ? 'Merged ' + m.count + ' segments' : 'Segment' }}</div>
|
<div class="ms-ppl-face-card-actions">
|
||||||
<div class="ms-ppl-media-sub">{{ m.start.toFixed(1) }}s - {{ m.end.toFixed(1) }}s</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-ppl-delete-zone">
|
<!-- Segment card -->
|
||||||
<hr class="ms-ppl-delete-hr">
|
<div v-if="mergedSegments.length" class="ms-ppl-media-item segment-card" @click="playMerged(mergedSegments[0])">
|
||||||
<button class="ms-fm-btn ms-ppl-delete-zone-btn" @click="confirmDelete">
|
<div class="ms-ppl-media-thumb">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
<img v-if="thumbs[mergedSegments[0].thumbKey]" :src="thumbs[mergedSegments[0].thumbKey]" alt="" loading="lazy" style="width:100%;height:100%;object-fit:cover;">
|
||||||
<polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline>
|
<div class="ms-thumb-play-circle">
|
||||||
<path d="M19 6l-1 14H6L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
<svg width="20" height="20" viewBox="0 0 24 24"><polygon points="6,4 20,12 6,20" fill="white"/></svg>
|
||||||
<path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
</div>
|
||||||
</svg>
|
<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>
|
||||||
</button>
|
</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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<div v-show="activeTab === 'actions'">
|
||||||
|
<div class="ms-ppl-actions-section">
|
||||||
|
<div class="ms-ppl-actions-status">
|
||||||
|
<span class="ms-ppl-actions-label">狀態:</span>
|
||||||
|
<span class="ms-ppl-actions-value">{{ person.status || 'confirmed' }}</span>
|
||||||
|
<button v-if="person.status !== 'confirmed'" class="ms-fm-btn ms-fm-btn-primary" @click="updateStatus('confirmed')">✓ 確認</button>
|
||||||
|
<button v-if="person.status !== 'pending'" class="ms-fm-btn" @click="updateStatus('pending')">待定</button>
|
||||||
|
<button v-if="person.status !== 'skipped'" class="ms-fm-btn" @click="updateStatus('skipped')">略過</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-ppl-actions-section" style="margin-top:16px;">
|
||||||
|
<button class="ms-fm-btn ms-fm-btn-primary" @click="showCandidates = true" style="margin-bottom:8px;">+ 綁定人臉</button>
|
||||||
|
<button class="ms-fm-btn ms-fm-btn-blue" @click="showMerge = true" style="margin-bottom:8px;">⇄ 合併到其他人物</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ms-ppl-delete-zone">
|
||||||
|
<hr class="ms-ppl-delete-hr">
|
||||||
|
<button class="ms-fm-btn ms-ppl-delete-zone-btn" @click="confirmDelete">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||||
|
<polyline points="3 6 5 6 21 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></polyline>
|
||||||
|
<path d="M19 6l-1 14H6L5 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||||
|
<path d="M10 11v6M14 11v6" stroke="currentColor" stroke-width="2" stroke-linecap="round"></path>
|
||||||
|
</svg>
|
||||||
|
刪除此人物
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="empty">
|
<div v-else class="empty">
|
||||||
@@ -102,7 +197,7 @@
|
|||||||
<div class="ms-merge-grid">
|
<div class="ms-merge-grid">
|
||||||
<div v-for="c in candidates" :key="c.id" class="ms-merge-face-card" @click="bindCandidate(c)">
|
<div v-for="c in candidates" :key="c.id" class="ms-merge-face-card" @click="bindCandidate(c)">
|
||||||
<div class="ms-merge-face-img">
|
<div class="ms-merge-face-img">
|
||||||
<img v-if="c.thumbUrl" :src="c.thumbUrl" alt="">
|
<img v-if="faceThumbs[c.thumbKey]" :src="faceThumbs[c.thumbKey]" alt="">
|
||||||
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
|
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="ms-merge-face-name">{{ c.file_uuid?.slice(0, 8) }}... #{{ c.frame_number }}</span>
|
<span class="ms-merge-face-name">{{ c.file_uuid?.slice(0, 8) }}... #{{ c.frame_number }}</span>
|
||||||
@@ -112,63 +207,101 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showMerge" class="ms-modal-overlay show" @click.self="showMerge = false">
|
<div v-if="showMerge" class="ms-modal-overlay show" @click.self="showMerge = false">
|
||||||
<div class="ms-modal">
|
<div class="ms-modal ms-modal-merge">
|
||||||
<button class="ms-fm-icon-btn close-btn" @click="showMerge = false">×</button>
|
<button class="ms-fm-icon-btn close-btn" @click="showMerge = false">×</button>
|
||||||
<h2 class="ms-ppl-section-title">Merge Identity</h2>
|
<h2 class="ms-ppl-section-title">⇄ 合併到其他人物</h2>
|
||||||
<input v-model="mergeTarget" class="merge-input" placeholder="Target identity UUID" />
|
<div class="ms-merge-search-wrap">
|
||||||
<div class="ms-modal-actions">
|
<span class="ms-merge-search-icon">🔍</span>
|
||||||
<button class="ms-fm-btn ms-fm-btn-blue" :disabled="!mergeTarget" @click="confirmMerge">Merge</button>
|
<input v-model="mergeSearchQuery" class="ms-merge-search-input" placeholder="搜尋人物名稱..." @input="onMergeSearch" />
|
||||||
|
</div>
|
||||||
|
<div class="ms-merge-grid">
|
||||||
|
<div v-for="r in mergeSearchResults" :key="r.identity_id" class="ms-merge-face-card" @click="confirmMergeTarget(r)">
|
||||||
|
<div class="ms-merge-face-img">
|
||||||
|
<svg viewBox="0 0 120 120" fill="none" style="width:100%;height:100%;">
|
||||||
|
<circle cx="60" cy="45" r="25" fill="#d1d5db"/>
|
||||||
|
<ellipse cx="60" cy="105" rx="40" ry="25" fill="#d1d5db"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="ms-merge-face-name">{{ r.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!mergeSearchResults.length && mergeSearchQuery" class="ms-merge-face-card" style="opacity:0.5;cursor:default;width:100%;padding:20px;text-align:center;">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ms-merge-footer">
|
||||||
|
<button class="ms-fm-btn" @click="showMerge = false">取消</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :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" :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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { apiCall } from '@/api'
|
||||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
import { isTauri } from '@/api/config'
|
||||||
|
import { ensurePeople, peopleCache, peopleLoaded, profilesCache, loadProfile as storeLoadProfile, faceThumbsCache, loadFaceThumb as storeLoadFaceThumb, thumbnailsCache, invalidatePeople, invalidateProfile } from '@/store'
|
||||||
|
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const person = ref<any>(null)
|
const person = ref<any>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
const loadingFaces = ref(true)
|
||||||
|
const loadingTraces = ref(true)
|
||||||
const profile = ref('')
|
const profile = ref('')
|
||||||
|
const profileUuid = ref('')
|
||||||
const peopleCount = ref(0)
|
const peopleCount = ref(0)
|
||||||
|
const allPeople = ref<any[]>([])
|
||||||
const faces = ref<any[]>([])
|
const faces = ref<any[]>([])
|
||||||
const traces = ref<any[]>([])
|
const allTraces = ref<any[]>([])
|
||||||
const mergedTraces = ref<any[]>([])
|
const mergedSegments = ref<any[]>([])
|
||||||
const playing = ref(false)
|
const playing = ref(false)
|
||||||
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, title: '' })
|
const currentVideo = ref({ fileUuid: '', startTime: 0, endTime: 0, traceIdx: 0, title: '' })
|
||||||
const showCandidates = ref(false)
|
const showCandidates = ref(false)
|
||||||
const showMerge = ref(false)
|
const showMerge = ref(false)
|
||||||
const mergeTarget = ref('')
|
const mergeSearchQuery = ref('')
|
||||||
|
const mergeSearchResults = ref<any[]>([])
|
||||||
const candidates = ref<any[]>([])
|
const candidates = ref<any[]>([])
|
||||||
const showAllFaces = ref(false)
|
const showAllFaces = ref(false)
|
||||||
|
const thumbs = thumbnailsCache
|
||||||
const CORE_API = 'http://localhost:3002'
|
const faceThumbs = faceThumbsCache
|
||||||
const API_KEY = 'muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69'
|
const isEditing = ref(false)
|
||||||
|
const editName = ref('')
|
||||||
|
const editRole = ref('')
|
||||||
|
const editNotes = ref('')
|
||||||
|
const editAliases = ref<{name: string, locale: string}[]>([])
|
||||||
|
const showAliasInput = ref(false)
|
||||||
|
const aliasLocale = ref('zh-TW')
|
||||||
|
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 fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const uuid = route.params.uuid as string
|
const uuid = route.params.uuid as string
|
||||||
console.log('PersonDetailView mounted, uuid:', uuid)
|
|
||||||
try {
|
try {
|
||||||
console.log('Calling getPeople with uuid:', uuid)
|
await ensurePeople()
|
||||||
const people: any = await invoke('get_people', { page: 1, perPage: 1000 })
|
peopleCount.value = peopleCache.value.length
|
||||||
console.log('getPeople raw result:', JSON.stringify(people).slice(0, 200))
|
allPeople.value = peopleCache.value
|
||||||
console.log('getPeople result count:', Array.isArray(people) ? people.length : 'not array')
|
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
|
||||||
peopleCount.value = Array.isArray(people) ? people.length : 0
|
|
||||||
const found = (Array.isArray(people) ? people : []).find((p: any) => p.identityUuid === uuid)
|
|
||||||
console.log('Person found:', !!found, found?.name)
|
|
||||||
if (found) {
|
if (found) {
|
||||||
person.value = { ...found, status: found.status || 'confirmed' }
|
person.value = { ...found, status: found.status || 'confirmed' }
|
||||||
await loadProfile(uuid)
|
loadProfile(uuid)
|
||||||
await loadFaces(uuid)
|
loadFaces(uuid)
|
||||||
await loadMedia(uuid)
|
loadMedia(uuid)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load person:', e)
|
console.error('Failed to load person:', e)
|
||||||
@@ -176,99 +309,335 @@ onMounted(async () => {
|
|||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
loadCandidates()
|
loadCandidates()
|
||||||
|
document.addEventListener('click', closeFaceCtxMenu)
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadProfile(uuid: string) {
|
onUnmounted(() => {
|
||||||
try { profile.value = await invoke('get_identity_profile', { uuid }) } catch {}
|
document.removeEventListener('click', closeFaceCtxMenu)
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadProfile(uuid: string) {
|
||||||
|
profileUuid.value = uuid
|
||||||
|
if (profilesCache.value[uuid]) {
|
||||||
|
profile.value = profilesCache.value[uuid]
|
||||||
|
} else {
|
||||||
|
storeLoadProfile(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => profileUuid.value && profilesCache.value[profileUuid.value], (val) => {
|
||||||
|
if (val && !profile.value) profile.value = val
|
||||||
|
})
|
||||||
|
|
||||||
|
function thumbKey(uuid: string, frame: number): string {
|
||||||
|
return `${uuid}:${frame}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFaces(uuid: string) {
|
async function loadFaces(uuid: string) {
|
||||||
|
loadingFaces.value = true
|
||||||
try {
|
try {
|
||||||
const result: any = await invoke('getFaces', { uuid, perPage: 1000 })
|
const result: any = await apiCall('get_faces', { uuid, perPage: 20 })
|
||||||
const items = Array.isArray(result) ? result : []
|
const items = Array.isArray(result) ? result : []
|
||||||
faces.value = items.map((f: any) => ({
|
faces.value = items
|
||||||
...f,
|
items.forEach((f: any) => {
|
||||||
thumbUrl: f.file_uuid ? `${CORE_API}/api/v1/file/${f.file_uuid}/thumbnail?api_key=${API_KEY}&frame=${f.frame_number || 0}` : ''
|
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) }
|
} catch (e) { console.error('Failed to load faces:', e) }
|
||||||
|
finally { loadingFaces.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
playing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFaceCtxMenu(e: MouseEvent, f: any) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, face: { ...f } }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFaceCtxMenu(e?: MouseEvent) {
|
||||||
|
faceCtxMenu.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')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!person.value) {
|
||||||
|
console.error('faceCtxAction: no person data')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (action === 'unbind') {
|
||||||
|
unbindFace(f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMedia(uuid: string) {
|
async function loadMedia(uuid: string) {
|
||||||
|
loadingTraces.value = true
|
||||||
try {
|
try {
|
||||||
const result: any = await invoke('getTraces', { uuid, perPage: 1000 })
|
const result: any = await apiCall('get_traces', { uuid, perPage: 50 })
|
||||||
const rawItems = Array.isArray(result) ? result : []
|
const rawItems = Array.isArray(result) ? result : []
|
||||||
rawItems.sort((a: any, b: any) => (a.first_sec || 0) - (b.first_sec || 0))
|
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[] = []
|
const merged: any[] = []
|
||||||
let cur: any = null
|
let cur: any = null
|
||||||
rawItems.forEach((item: any) => {
|
rawItems.forEach((item: any, idx: number) => {
|
||||||
const st = item.first_sec || 0
|
const st = item.first_sec || item.start_time || 0
|
||||||
const en = item.last_sec || 0
|
const en = item.last_sec || item.end_time || 0
|
||||||
const fu = item.file_uuid || ''
|
const fu = item.file_uuid || ''
|
||||||
if (cur && fu === cur.file_uuid && (st - cur.end) < 30) {
|
if (cur && fu === cur.file_uuid && (st - cur.end) < 30) {
|
||||||
cur.end = Math.max(cur.end, en)
|
cur.end = Math.max(cur.end, en)
|
||||||
|
cur.end_frame = Math.max(cur.end_frame, item.last_frame || 0)
|
||||||
cur.count++
|
cur.count++
|
||||||
|
cur._endIdx = idx
|
||||||
|
cur.total_confidence += item.avg_confidence || 0
|
||||||
} else {
|
} else {
|
||||||
if (cur) merged.push(cur)
|
if (cur) merged.push(cur)
|
||||||
cur = { file_uuid: fu, start: st, end: en, count: 1, first_frame: item.first_frame || 0 }
|
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)
|
if (cur) merged.push(cur)
|
||||||
|
|
||||||
mergedTraces.value = merged.map((m: any) => ({
|
merged.forEach((m: any) => {
|
||||||
...m,
|
m.avg_confidence = m.count > 1 ? m.total_confidence / m.count : m.total_confidence
|
||||||
thumbUrl: m.file_uuid ? `${CORE_API}/api/v1/file/${m.file_uuid}/thumbnail?api_key=${API_KEY}&frame=${m.first_frame || Math.floor(m.start * 24)}` : ''
|
delete m.total_confidence
|
||||||
}))
|
})
|
||||||
|
mergedSegments.value = merged.slice(0, 1)
|
||||||
|
merged.forEach((m: any) => loadThumb(m.file_uuid, m.start_frame))
|
||||||
} catch (e) { console.error('Failed to load media:', e) }
|
} catch (e) { console.error('Failed to load media:', e) }
|
||||||
|
finally { loadingTraces.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
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(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCandidates() {
|
async function loadCandidates() {
|
||||||
try {
|
try {
|
||||||
const result: any = await invoke('get_face_candidates', { page: 1, perPage: 100 })
|
const result: any = await apiCall('get_face_candidates', { page: 1, perPage: 20 })
|
||||||
candidates.value = (Array.isArray(result) ? result : []).map((c: any) => ({
|
candidates.value = (Array.isArray(result) ? result : []).map((c: any) => ({
|
||||||
...c,
|
...c,
|
||||||
thumbUrl: c.file_uuid ? `${CORE_API}/api/v1/file/${c.file_uuid}/thumbnail?api_key=${API_KEY}&frame=${c.frame_number || 0}` : ''
|
thumbKey: `cand_${c.id}`
|
||||||
}))
|
}))
|
||||||
|
;(Array.isArray(result) ? result : []).forEach((c: any) => loadFaceCandidateThumb(c))
|
||||||
} catch (e) { console.error('Failed to load candidates:', e) }
|
} catch (e) { console.error('Failed to load candidates:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleThumbError(e: Event) {
|
async function loadFaceCandidateThumb(c: any) {
|
||||||
const img = e.target as HTMLImageElement
|
if (!c?.file_uuid) return
|
||||||
img.style.display = 'none'
|
storeLoadFaceThumb(`cand_${c.id}`, c.file_uuid, c.frame_number || 0, c.bbox)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleStar() {
|
async function toggleStar() {
|
||||||
if (!person.value) return
|
if (!person.value) return
|
||||||
person.value.starred = !person.value.starred
|
const newVal = !person.value.starred
|
||||||
|
try {
|
||||||
|
await apiCall('update_identity_starred', { uuid: person.value.identity_uuid, starred: newVal })
|
||||||
|
person.value.starred = newVal
|
||||||
|
invalidatePeople()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to update star:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDelete() {
|
async function confirmDelete() {
|
||||||
if (!person.value || !confirm(`Delete "${person.value.name}"?`)) return
|
if (!person.value || !confirm(`Delete "${person.value.name}"?`)) return
|
||||||
try {
|
try {
|
||||||
await invoke('delete_identity', { uuid: person.value.identityUuid })
|
await apiCall('delete_identity', { uuid: person.value.identity_uuid })
|
||||||
router.back()
|
router.back()
|
||||||
} catch (e) { console.error('Failed to delete:', e) }
|
} catch (e) { console.error('Failed to delete:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bindCandidate(c: any) {
|
async function bindCandidate(c: any) {
|
||||||
if (!person.value) return
|
if (!person.value) return
|
||||||
|
const fid = c.face_id || String(c.id)
|
||||||
try {
|
try {
|
||||||
await invoke('bind_face', { uuid: person.value.identityUuid, faceId: String(c.id), fileUuid: c.file_uuid })
|
await apiCall('bind_face', { uuid: person.value.identity_uuid, faceId: fid, faceRowId: c.face_id ? undefined : c.id, fileUuid: c.file_uuid })
|
||||||
showCandidates.value = false
|
showCandidates.value = false
|
||||||
await loadFaces(person.value.identityUuid)
|
await refreshPerson()
|
||||||
} catch (e) { console.error('Bind failed:', e) }
|
} catch (e) { console.error('Bind failed:', e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmMerge() {
|
function startEditing() {
|
||||||
if (!person.value || !mergeTarget.value) return
|
if (!person.value) return
|
||||||
try {
|
editName.value = person.value.name || ''
|
||||||
await invoke('merge_identities', { uuid: person.value.identityUuid, intoUuid: mergeTarget.value })
|
const meta = person.value.metadata || {}
|
||||||
router.back()
|
editRole.value = meta.role || ''
|
||||||
} catch (e) { console.error('Merge failed:', e) }
|
editNotes.value = meta.notes || ''
|
||||||
|
editAliases.value = JSON.parse(JSON.stringify(meta.aliases || []))
|
||||||
|
isEditing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function playVideo(fileUuid: string, start: number, end: number) {
|
function cancelEditing() {
|
||||||
currentVideo.value = { fileUuid, startTime: start, endTime: end, title: `${person.value?.name} - ${start.toFixed(1)}s-${end.toFixed(1)}s` }
|
isEditing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!person.value || saving.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const cleanAliases = editAliases.value.filter(a => a.name?.trim()).map(a => ({ name: a.name.trim(), locale: a.locale }))
|
||||||
|
const metadata = {
|
||||||
|
...(person.value.metadata || {}),
|
||||||
|
name: editName.value.trim(),
|
||||||
|
role: editRole.value.trim(),
|
||||||
|
notes: editNotes.value.trim(),
|
||||||
|
starred: person.value.starred,
|
||||||
|
aliases: cleanAliases,
|
||||||
|
}
|
||||||
|
await apiCall('update_identity', {
|
||||||
|
uuid: person.value.identity_uuid,
|
||||||
|
name: editName.value.trim(),
|
||||||
|
metadataJson: JSON.stringify(metadata),
|
||||||
|
})
|
||||||
|
isEditing.value = false
|
||||||
|
invalidatePeople()
|
||||||
|
await refreshPerson()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save:', e)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAlias() {
|
||||||
|
const name = aliasName.value.trim()
|
||||||
|
if (!name) return
|
||||||
|
editAliases.value.push({ name, locale: aliasLocale.value })
|
||||||
|
aliasName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAlias(index: number) {
|
||||||
|
editAliases.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAvatarUpload(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (!file || !person.value) return
|
||||||
|
avatarUploading.value = true
|
||||||
|
try {
|
||||||
|
if (isTauri) {
|
||||||
|
const path = (file as any).path || file.name
|
||||||
|
await (window as any).__TAURI__.invoke('upload_profile_image', {
|
||||||
|
uuid: person.value.identity_uuid,
|
||||||
|
filePath: path,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await apiCall('upload_profile_image', {
|
||||||
|
uuid: person.value.identity_uuid,
|
||||||
|
file: file,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
invalidateProfile(person.value.identity_uuid)
|
||||||
|
loadProfile(person.value.identity_uuid)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Avatar upload failed:', e)
|
||||||
|
} finally {
|
||||||
|
avatarUploading.value = false
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerAvatarUpload() {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unbindFace(f: any) {
|
||||||
|
if (!person.value) return
|
||||||
|
const uuid = person.value.identity_uuid
|
||||||
|
if (!confirm('解綁此人臉?')) 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,
|
||||||
|
})
|
||||||
|
await loadFaces(uuid)
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
allPeople.value = peopleCache.value
|
||||||
|
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
|
||||||
|
if (found) {
|
||||||
|
person.value = { ...found, status: found.status || 'confirmed' }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Unbind failed:', e)
|
||||||
|
alert('解綁失敗:' + (e instanceof Error ? e.message : String(e)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(status: string) {
|
||||||
|
if (!person.value) return
|
||||||
|
try {
|
||||||
|
await apiCall('update_identity_status', { uuid: person.value.identity_uuid, status })
|
||||||
|
person.value.status = status
|
||||||
|
await refreshPerson()
|
||||||
|
} catch (e) { console.error('Failed to update status:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function playMerged(m: any) {
|
||||||
|
const t = allTraces.value[m._startIdx]
|
||||||
|
if (!t) return
|
||||||
|
currentVideo.value = {
|
||||||
|
fileUuid: t.file_uuid || '',
|
||||||
|
startTime: t.first_sec || t.start_time || 0,
|
||||||
|
endTime: t.last_sec || t.end_time || 0,
|
||||||
|
traceIdx: m._startIdx,
|
||||||
|
title: `${person.value?.name} · #${m.start_frame}–#${m.end_frame}`,
|
||||||
|
}
|
||||||
playing.value = true
|
playing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,27 +645,87 @@ function formatTime(sec: number): string {
|
|||||||
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
|
const m = Math.floor(sec / 60); const s = Math.floor(sec % 60)
|
||||||
return `${m}:${s.toString().padStart(2, '0')}`
|
return `${m}:${s.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPerson() {
|
||||||
|
invalidatePeople()
|
||||||
|
await ensurePeople()
|
||||||
|
allPeople.value = peopleCache.value
|
||||||
|
const uuid = person.value?.identity_uuid || (route.params.uuid as string)
|
||||||
|
const found = allPeople.value.find((p: any) => p.identity_uuid === uuid)
|
||||||
|
if (found) {
|
||||||
|
person.value = { ...found, status: found.status || 'confirmed' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onMergeSearch() {
|
||||||
|
clearTimeout(mergeSearchTimer)
|
||||||
|
if (!mergeSearchQuery.value.trim()) { mergeSearchResults.value = []; return }
|
||||||
|
mergeSearchTimer = setTimeout(() => {
|
||||||
|
const q = mergeSearchQuery.value.toLowerCase()
|
||||||
|
mergeSearchResults.value = allPeople.value
|
||||||
|
.filter((p: any) => p.identity_uuid !== person.value?.identity_uuid && p.name.toLowerCase().includes(q))
|
||||||
|
.slice(0, 20)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmMergeTarget(target: any) {
|
||||||
|
if (!person.value) return
|
||||||
|
if (!confirm(`合併「${person.value.name}」到「${target.name}」?`)) return
|
||||||
|
try {
|
||||||
|
await apiCall('merge_identities', { uuid: person.value.identity_uuid, intoUuid: target.identity_uuid })
|
||||||
|
showMerge.value = false
|
||||||
|
router.back()
|
||||||
|
} catch (e) { console.error('Merge failed:', e) }
|
||||||
|
}
|
||||||
|
|
||||||
|
let mergeSearchTimer: any
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.people-view { max-width: 1200px; padding-top: 20px; }
|
.people-view { max-width: 1200px; padding-top: 20px; }
|
||||||
.ms-ppl-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; margin-top: 20px; }
|
.ms-ppl-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; margin-top: 20px; }
|
||||||
|
.ms-ppl-edit-text-btn { border: none; background: transparent; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: #5f6368; cursor: pointer; outline: none; padding: 4px 10px; display: flex; align-items: center; gap: 4px; transition: color .15s; }
|
||||||
|
.ms-ppl-edit-text-btn:hover { color: #202124; }
|
||||||
.loading-state, .empty { text-align: center; padding: 60px 0; color: #5f6368; }
|
.loading-state, .empty { text-align: center; padding: 60px 0; color: #5f6368; }
|
||||||
.spinner-lg { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #202124; border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
|
.spinner-lg { width: 24px; height: 24px; border: 3px solid #e8eaed; border-top-color: #202124; border-radius: 50%; animation: spin 0.7s linear infinite; margin: 0 auto 12px; }
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.close-btn { position: absolute; top: 16px; right: 16px; }
|
.close-btn { position: absolute; top: 16px; right: 16px; }
|
||||||
.ms-ppl-detail-header { display: flex; align-items: flex-start; gap: 22px; margin-bottom: 28px; position: relative; margin-top: 20px; }
|
.ms-ppl-detail-header { display: flex; align-items: flex-start; gap: 22px; margin-bottom: 28px; position: relative; margin-top: 20px; }
|
||||||
.ms-ppl-detail-avatar { width: 120px; height: 120px; border-radius: 20px; background: #e0e0e0; flex-shrink: 0; overflow: hidden; }
|
.ms-ppl-detail-avatar { width: 120px; height: 120px; border-radius: 20px; background: #e0e0e0; flex-shrink: 0; overflow: hidden; position: relative; }
|
||||||
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 20px; }
|
.ms-ppl-detail-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 20px; }
|
||||||
|
.ms-ppl-avatar-editable { cursor: pointer; }
|
||||||
|
.ms-ppl-avatar-upload-hint { position: absolute; inset: 0; border-radius: 20px; background: rgba(0,0,0,.45); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity .15s; pointer-events: none; }
|
||||||
|
.ms-ppl-avatar-editable:hover .ms-ppl-avatar-upload-hint { opacity: 1; }
|
||||||
|
.ms-ppl-avatar-upload-hint svg { color: #fff; }
|
||||||
|
.ms-silhouette { width: 100%; height: 100%; }
|
||||||
.ms-ppl-detail-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
.ms-ppl-detail-name-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
.ms-ppl-star-btn { font-size: 20px; background: transparent; border: none; cursor: pointer; outline: none; line-height: 1; color: #d1d5db; transition: color .15s; padding: 0; flex-shrink: 0; }
|
.ms-ppl-star-btn { font-size: 20px; background: transparent; border: none; cursor: pointer; outline: none; line-height: 1; color: #d1d5db; transition: color .15s; padding: 0; flex-shrink: 0; }
|
||||||
.ms-ppl-star-btn.starred { color: #f59e0b; }
|
.ms-ppl-star-btn.starred { color: #f59e0b; }
|
||||||
.ms-ppl-detail-aliases { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }
|
.ms-ppl-detail-aliases { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 12px; }
|
||||||
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: #f0f0f0; border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: #5f6368; }
|
.ms-ppl-alias-chip { display: inline-flex; align-items: center; background: #f0f0f0; border-radius: 999px; padding: 3px 10px; font-size: 11.5px; color: #5f6368; }
|
||||||
|
.ms-ppl-edit-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.ms-ppl-edit-name-row { margin-bottom: 8px; }
|
||||||
|
.ms-ppl-edit-alias-row { padding-left: 32px; margin-bottom: 16px; gap: 6px; align-items: center; }
|
||||||
|
.ms-ppl-alias-wrap-inner { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
|
.ms-ppl-alias-tag { display: inline-flex; align-items: center; gap: 5px; background: #e8eaed; border-radius: 999px; padding: 4px 10px; font-size: 12.5px; color: #202124; }
|
||||||
|
.ms-ppl-alias-tag button { border: none; background: transparent; cursor: pointer; color: #5f6368; font-size: 14px; line-height: 1; padding: 0; display: flex; align-items: center; }
|
||||||
|
.ms-ppl-alias-add-btn { border: 1.5px dashed #bbb; background: transparent; border-radius: 999px; padding: 4px 10px; font-size: 12px; color: #5f6368; cursor: pointer; outline: none; }
|
||||||
|
.ms-ppl-alias-add-btn:hover { border-color: #202124; color: #202124; }
|
||||||
|
.ms-ppl-alias-inline-wrap { display: flex; align-items: center; }
|
||||||
|
.ms-ppl-alias-inline-input { border: 1.5px solid #1a56db; border-radius: 999px; padding: 4px 12px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12.5px; color: #202124; outline: none; background: #fff; min-width: 140px; transition: border-color .15s; }
|
||||||
|
.ms-ppl-alias-inline-input:focus { border-color: #1a56db; }
|
||||||
|
.ms-ppl-alias-locale-select { border: 1.5px solid #1a56db; border-radius: 999px; padding: 4px 10px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; color: #202124; background: #fff; outline: none; cursor: pointer; height: 30px; }
|
||||||
.ms-ppl-edit-fields { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
.ms-ppl-edit-fields { display: flex; flex-direction: column; gap: 10px; margin-bottom: 16px; }
|
||||||
.ms-ppl-edit-field-row { display: flex; align-items: center; gap: 12px; }
|
.ms-ppl-edit-field-row { display: flex; align-items: center; gap: 12px; }
|
||||||
.ms-ppl-edit-field-row--top { align-items: flex-start; }
|
.ms-ppl-edit-field-row--top { align-items: flex-start; }
|
||||||
.ms-ppl-edit-label { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; font-weight: 700; color: #202124; letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
|
.ms-ppl-edit-label { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 12px; font-weight: 700; color: #202124; letter-spacing: .03em; min-width: 30px; text-align: right; flex-shrink: 0; padding-top: 2px; }
|
||||||
|
.ms-ppl-edit-input { border: 1.5px solid #e8eaed; border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; outline: none; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: border-color .15s, box-shadow .15s; min-width: 0; box-sizing: border-box; width: 100%; }
|
||||||
|
.ms-ppl-edit-input:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
|
||||||
|
.ms-ppl-edit-name-input { font-size: 16px !important; font-weight: 700 !important; flex: 1; min-width: 160px; }
|
||||||
|
.ms-ppl-edit-textarea { width: 100%; border: 1.5px solid #e8eaed; border-radius: 12px; padding: 9px 14px; font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13px; color: #202124; outline: none; resize: vertical; min-height: 72px; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); transition: border-color .15s, box-shadow .15s; box-sizing: border-box; }
|
||||||
|
.ms-ppl-edit-textarea:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
|
||||||
|
.ms-ppl-edit-actions { display: flex; gap: 8px; padding-left: 42px; }
|
||||||
|
.ms-ppl-edit-field-input { flex: 1; min-width: 0; }
|
||||||
.ms-ppl-view-box { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; min-height: 24px; display: flex; align-items: center; word-break: break-word; padding: 2px 0; }
|
.ms-ppl-view-box { font-family: 'DM Sans', 'Noto Sans TC', sans-serif; font-size: 13.5px; color: #202124; min-height: 24px; display: flex; align-items: center; word-break: break-word; padding: 2px 0; }
|
||||||
.ms-ppl-view-name-box { font-size: 20px; font-weight: 700; flex: 1; min-width: 0; }
|
.ms-ppl-view-name-box { font-size: 20px; font-weight: 700; flex: 1; min-width: 0; }
|
||||||
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: #3c4043; }
|
.ms-ppl-view-field-box { flex: 1; min-width: 0; color: #3c4043; }
|
||||||
@@ -306,17 +735,27 @@ function formatTime(sec: number): string {
|
|||||||
.ms-ppl-strip-add-btn:hover { border-color: #202124; color: #202124; }
|
.ms-ppl-strip-add-btn:hover { border-color: #202124; color: #202124; }
|
||||||
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
|
.ms-ppl-face-strip { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: thin; flex: 1; }
|
||||||
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
|
.ms-ppl-strip-face { position: relative; flex-shrink: 0; cursor: pointer; }
|
||||||
|
.ms-ppl-strip-face-clickable.selected { outline: 2px solid #1a56db; outline-offset: 2px; border-radius: 14px; }
|
||||||
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: #e8eaed; overflow: hidden; transition: border-color .15s; }
|
.ms-ppl-strip-face-img { width: 52px; height: 52px; border-radius: 12px; border: 2px solid transparent; background: #e8eaed; overflow: hidden; transition: border-color .15s; }
|
||||||
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: #202124; }
|
.ms-ppl-strip-face:hover .ms-ppl-strip-face-img { border-color: #202124; }
|
||||||
|
.ms-ppl-face-card-detail { display: flex; gap: 16px; align-items: flex-start; background: #fff; border: 1px solid #e0e0e0; border-radius: 16px; padding: 16px; margin: 12px 0; position: relative; box-shadow: 0 4px 16px rgba(0,0,0,.08); }
|
||||||
|
.ms-ppl-face-card-close { position: absolute; top: 8px; right: 10px; background: none; border: none; font-size: 20px; cursor: pointer; color: #9aa0a6; line-height: 1; padding: 2px 6px; border-radius: 6px; }
|
||||||
|
.ms-ppl-face-card-close:hover { background: #f3f4f6; color: #202124; }
|
||||||
|
.ms-ppl-face-card-img-wrap { width: 120px; height: 120px; border-radius: 12px; overflow: hidden; flex-shrink: 0; background: #e8eaed; }
|
||||||
|
.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-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 { 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 { background: #f3f4f6; color: #202124; }
|
||||||
.ms-ppl-media-label { font-size: 13px; color: #5f6368; margin-bottom: 16px; }
|
.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 { 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); }
|
.ms-ppl-media-item:hover { transform: translateY(-2px); box-shadow: 0 6px 18px rgba(0,0,0,.1); }
|
||||||
.ms-ppl-media-thumb { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: #e8eaed; border-radius: 12px 12px 0 0; }
|
.ms-ppl-media-thumb { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; background: #e8eaed; border-radius: 12px 12px 0 0; }
|
||||||
.ms-thumb-play-circle { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.18); border-radius: 12px 12px 0 0; }
|
.ms-ppl-thumb-play-circle { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.18); border-radius: 12px 12px 0 0; }
|
||||||
.ms-thumb-play-circle svg { width: 38px; height: 38px; opacity: .7; transition: opacity .15s, transform .15s; }
|
.ms-ppl-thumb-play-circle svg { width: 38px; height: 38px; opacity: .7; transition: opacity .15s, transform .15s; }
|
||||||
.ms-ppl-media-item:hover .ms-thumb-play-circle svg { opacity: 1; transform: scale(1.08); }
|
.ms-ppl-media-item:hover .ms-ppl-thumb-play-circle svg { opacity: 1; transform: scale(1.08); }
|
||||||
.ms-ppl-media-dur { position: absolute; bottom: 5px; right: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
|
.ms-ppl-media-dur { position: absolute; bottom: 5px; right: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
|
||||||
.ms-ppl-media-info { padding: 8px 10px 10px; background: #fff; }
|
.ms-ppl-media-info { padding: 8px 10px 10px; background: #fff; }
|
||||||
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: #202124; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
|
.ms-ppl-media-title { font-size: 12px; font-weight: 600; color: #202124; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 2px; }
|
||||||
@@ -326,10 +765,22 @@ function formatTime(sec: number): string {
|
|||||||
.ms-ppl-delete-zone-btn { color: #d93025; border-color: #fecaca; background: #fff; }
|
.ms-ppl-delete-zone-btn { color: #d93025; border-color: #fecaca; background: #fff; }
|
||||||
.ms-ppl-delete-zone-btn:hover { background: #fef2f2; border-color: #d93025; }
|
.ms-ppl-delete-zone-btn:hover { background: #fef2f2; border-color: #d93025; }
|
||||||
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
|
.face-placeholder { font-size: 0.6rem; color: #5f6368; }
|
||||||
.merge-input { width: 100%; padding: 10px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; margin-bottom: 16px; font-size: 0.9rem; outline: none; }
|
.ms-ppl-actions-section { display: flex; flex-direction: column; gap: 8px; }
|
||||||
.merge-input:focus { border-color: #202124; }
|
.ms-ppl-actions-status { display: flex; align-items: center; gap: 10px; padding: 12px; background: #f8f9fa; border-radius: 10px; font-size: 13px; }
|
||||||
.ms-modal-actions { display: flex; justify-content: flex-end; }
|
.ms-ppl-actions-label { color: #5f6368; font-weight: 500; }
|
||||||
h2 { margin: 0 0 16px; font-size: 1rem; }
|
.ms-ppl-actions-value { color: #202124; font-weight: 600; text-transform: capitalize; }
|
||||||
.ms-merge-grid { display: flex; flex-wrap: wrap; gap: 16px; max-height: 50vh; overflow-y: auto; }
|
.ms-ppl-media-badge-conf { position: absolute; top: 5px; left: 7px; background: rgba(0,0,0,.5); color: #fff; font-size: 10px; padding: 1px 5px; border-radius: 3px; }
|
||||||
.ms-silhouette { width: 100%; height: 100%; }
|
.segment-card { max-width: 320px; margin-bottom: 20px; }
|
||||||
</style>
|
|
||||||
|
/* Context menu */
|
||||||
|
.ms-ctx-menu { position: fixed; z-index: 99999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 140px; font-size: 13px; color: #222; }
|
||||||
|
.ms-ctx-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-radius: 8px; border: none; background: transparent; width: 100%; text-align: left; font-size: 13px; color: #222; font-family: inherit; }
|
||||||
|
.ms-ctx-item:hover { background: #f3f4f6; }
|
||||||
|
.ms-ctx-item.ms-ctx-danger { color: #d93025; }
|
||||||
|
.ms-ctx-item.ms-ctx-danger:hover { background: #fce8e6; }
|
||||||
|
.ms-ctx-item-disabled { padding: 8px 12px; font-size: 12px; color: #9aa0a6; cursor: default; }
|
||||||
|
|
||||||
|
/* Remove button for face strip */
|
||||||
|
.ms-ppl-strip-remove-btn { display: none; position: absolute; top: -6px; right: -6px; width: 18px; height: 18px; border-radius: 50%; background: #fff; border: 1.5px solid #d1d5db; font-size: 11px; color: #5f6368; line-height: 1; cursor: pointer; outline: none; place-items: center; }
|
||||||
|
.ms-ppl-edit-mode .ms-ppl-strip-remove-btn { display: grid; }
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user