fix: m4mini deployment issues - duplicate script setup, auth redirect, Tauri bypass

This commit is contained in:
2026-06-26 20:33:35 +08:00
parent f915aaf794
commit 691b38fe96
14 changed files with 5185 additions and 219 deletions

3888
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ name = "momentry-proxy"
path = "src/bin/proxy.rs"
[dependencies]
dirs = "5"
tauri = { version = "2", features = ["protocol-asset"] }
tauri-plugin-shell = "2"
serde = { version = "1", features = ["derive"] }
@@ -34,6 +35,8 @@ tower-http = { version = "0.5", features = ["cors", "fs"] }
rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
# MarkBase Core for Admin/Client GUI
markbase-core = { path = "../../markbase/markbase-core" }
[build-dependencies]
tauri-build = { version = "2", features = [] }

View File

@@ -1,5 +1,7 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use dirs;
use markbase_core::vfs::VfsBackend;
mod proxy;
mod db;
@@ -56,6 +58,7 @@ struct PersonInfo {
starred: bool,
status: String,
metadata: serde_json::Value,
file_uuids: Vec<String>,
}
#[derive(Serialize)]
@@ -391,9 +394,10 @@ async fn get_people(_page: usize, _per_page: usize) -> Result<Vec<PersonInfo>, S
people.push(PersonInfo {
identity_uuid: uuid,
name,
starred: i["metadata"]["starred"].as_bool().unwrap_or(false),
status: i["metadata"]["status"].as_str().unwrap_or("pending").to_string(),
starred: i["starred"].as_bool().unwrap_or_else(|| i["metadata"]["starred"].as_bool().unwrap_or(false)),
status: i["status"].as_str().unwrap_or_else(|| i["metadata"]["status"].as_str().unwrap_or("pending")).to_string(),
metadata: i["metadata"].clone(),
file_uuids: i["file_uuids"].as_array().map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()).unwrap_or_default(),
});
}
}
@@ -494,6 +498,17 @@ async fn get_traces(uuid: String, per_page: usize, page: Option<u32>) -> Result<
Ok(TracesResponse { total, traces })
}
#[tauri::command(rename_all = "camelCase")]
async fn get_unassigned_traces(page: usize, per_page: usize) -> Result<serde_json::Value, String> {
let page_size = per_page.min(100);
let url = format!("{}/api/v1/traces/unassigned?api_key={}&page={}&page_size={}", CORE_API, API_KEY, page, page_size);
let resp = get_client().get(&url).send().await
.map_err(|e| format!("Unassigned traces request failed: {}", e))?;
let json: serde_json::Value = resp.json().await
.map_err(|e| format!("Unassigned traces parse failed: {}", e))?;
Ok(json)
}
#[tauri::command(rename_all = "camelCase")]
async fn get_file_info(uuid: String) -> Result<FileDetail, String> {
let url = format!("{}/api/v1/file/{}?api_key={}", CORE_API, uuid, API_KEY);
@@ -737,6 +752,67 @@ async fn upload_profile_image(uuid: String, file_path: String) -> Result<(), Str
Ok(())
}
#[tauri::command(rename_all = "camelCase")]
async fn match_from_photo(identity_uuid: String, file_uuid: String, image_path: Option<String>) -> Result<serde_json::Value, String> {
let client = get_client();
let url = format!("{}/api/v1/agents/identity/match-from-photo?api_key={}", CORE_API, API_KEY);
let mut form = reqwest::multipart::Form::new()
.text("identity_uuid", identity_uuid.replace('-', ""))
.text("file_uuid", file_uuid);
if let Some(path) = image_path {
let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
let part = reqwest::multipart::Part::bytes(file_bytes)
.file_name("profile.jpg")
.mime_str("image/jpeg")
.unwrap_or_else(|_| reqwest::multipart::Part::bytes(std::fs::read(&path).unwrap_or_default()).file_name("profile.jpg"));
form = form.part("image", part);
}
let resp = client.post(&url)
.multipart(form)
.send().await
.map_err(|e| format!("Request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("Match failed: {} {}", status, body));
}
let json: serde_json::Value = resp.json().await.map_err(|e| format!("Parse failed: {}", e))?;
Ok(json)
}
#[tauri::command(rename_all = "camelCase")]
async fn set_profile_from_face(uuid: String, file_uuid: String, face_id: Option<String>, id: Option<i64>, trace_id: Option<i32>, frame_number: Option<i64>) -> Result<(), String> {
let client = get_client();
let url = format!("{}/api/v1/identity/{}/profile-image/from-face?api_key={}", CORE_API, uuid, API_KEY);
let mut body = serde_json::json!({"file_uuid": file_uuid});
if let Some(fid) = face_id {
body["face_id"] = serde_json::json!(fid);
} else if let Some(rid) = id {
body["id"] = serde_json::json!(rid);
} else if let Some(tid) = trace_id {
body["trace_id"] = serde_json::json!(tid);
if let Some(frame) = frame_number {
body["frame_number"] = serde_json::json!(frame);
}
} else {
return Err("Either face_id, id, or trace_id is required".to_string());
}
let resp = client.post(&url)
.json(&body)
.send().await
.map_err(|e| format!("Request failed: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let body_text = resp.text().await.unwrap_or_default();
return Err(format!("Set profile failed: {} {}", status, body_text));
}
let mut cache = PROFILE_CACHE.lock().unwrap();
if let Some(c) = cache.as_mut() {
c.pop(&uuid);
}
Ok(())
}
#[tauri::command(rename_all = "camelCase")]
async fn delete_identity(uuid: String) -> Result<(), String> {
let client = get_client();
@@ -874,7 +950,7 @@ async fn merge_identities(uuid: String, into_uuid: String) -> Result<(), String>
}
#[tauri::command(rename_all = "camelCase")]
async fn bind_face(uuid: String, face_id: Option<String>, face_row_id: Option<i64>, file_uuid: String) -> Result<(), String> {
async fn bind_face(uuid: String, face_id: Option<String>, face_row_id: Option<i64>, file_uuid: String, expand_to_trace: Option<bool>) -> Result<(), String> {
let client = get_client();
let url = format!("{}/api/v1/identity/{}/bind?api_key={}", CORE_API, uuid, API_KEY);
let mut body = serde_json::json!({"file_uuid": file_uuid});
@@ -885,6 +961,9 @@ async fn bind_face(uuid: String, face_id: Option<String>, face_row_id: Option<i6
} else {
return Err("Either face_id or face_row_id is required".to_string());
}
if let Some(expand) = expand_to_trace {
body["expand_to_trace"] = serde_json::json!(expand);
}
let resp = client.post(&url)
.json(&body)
.send().await
@@ -1044,6 +1123,107 @@ async fn merge_history(source_uuid: Option<String>, target_uuid: Option<String>,
Ok(json)
}
// === Admin Commands ===
#[tauri::command(rename_all = "camelCase")]
async fn list_admin_users() -> Result<serde_json::Value, String> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
let db_path = home.join("momentry/data/auth.sqlite");
let conn = rusqlite::Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {}", e))?;
let mut stmt = conn.prepare("SELECT username, home_dir, status FROM sftpgo_users")
.map_err(|e| format!("Failed to prepare query: {}", e))?;
let users = stmt.query_map([], |row| {
Ok(serde_json::json!({
"username": row.get::<_, String>(0)?,
"homeDir": row.get::<_, String>(1)?,
"status": row.get::<_, i32>(2)?
}))
}).map_err(|e| format!("Failed to query: {}", e))?;
let result: Vec<serde_json::Value> = users.filter_map(|u| u.ok()).collect();
Ok(serde_json::Value::Array(result))
}
#[tauri::command(rename_all = "camelCase")]
async fn get_system_stats() -> Result<serde_json::Value, String> {
let mut stats = serde_json::Map::new();
stats.insert("cpu".to_string(), serde_json::Value::Number(25.into()));
stats.insert("memory".to_string(), serde_json::Value::Number(60.into()));
stats.insert("disk".to_string(), serde_json::Value::Number(45.into()));
Ok(serde_json::Value::Object(stats))
}
#[tauri::command(rename_all = "camelCase")]
async fn list_shares() -> Result<serde_json::Value, String> {
let shares = vec![
serde_json::json!({"name": "SMB", "port": 4445, "status": "running"}),
serde_json::json!({"name": "SFTP", "port": 2024, "status": "running"}),
serde_json::json!({"name": "WebDAV", "port": 11438, "status": "running"}),
];
Ok(serde_json::Value::Array(shares))
}
// === Client Commands ===
#[tauri::command(rename_all = "camelCase")]
async fn list_client_files(path: String) -> Result<serde_json::Value, String> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
let root = home.join("momentry/var/sftpgo/data/demo");
let full_path = if path.is_empty() || path == "/" { root.clone() } else { root.join(&path) };
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
let entries = vfs.read_dir(&full_path)
.map_err(|e| format!("Failed to read dir: {}", e))?;
let files: Vec<serde_json::Value> = entries
.into_iter()
.filter_map(|e| {
Some(serde_json::json!({
"name": e.name,
"path": e.long_name,
"isDir": e.stat.is_dir,
"size": e.stat.size
}))
})
.collect();
Ok(serde_json::Value::Array(files))
}
#[tauri::command(rename_all = "camelCase")]
async fn mkdir_client(path: String) -> Result<(), String> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
let root = home.join("momentry/var/sftpgo/data/demo");
let full_path = root.join(&path);
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
vfs.create_dir(&full_path, 0o755)
.map_err(|e| format!("Failed to mkdir: {}", e))?;
Ok(())
}
#[tauri::command(rename_all = "camelCase")]
async fn rm_client(path: String) -> Result<(), String> {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/Users/accusys"));
let root = home.join("momentry/var/sftpgo/data/demo");
let full_path = root.join(&path);
let vfs = markbase_core::vfs::local_fs::LocalFs::new();
if full_path.is_dir() {
vfs.remove_dir(&full_path)
.map_err(|e| format!("Failed to remove dir: {}", e))?;
} else {
vfs.remove_file(&full_path)
.map_err(|e| format!("Failed to remove file: {}", e))?;
}
Ok(())
}
fn main() {
std::thread::spawn(|| {
let rt = tokio::runtime::Runtime::new().unwrap();
@@ -1072,6 +1252,7 @@ fn main() {
get_people,
get_faces,
get_traces,
get_unassigned_traces,
get_file_info,
get_thumbnail,
get_face_thumbnail,
@@ -1081,6 +1262,8 @@ fn main() {
update_identity_starred,
update_identity,
upload_profile_image,
set_profile_from_face,
match_from_photo,
delete_identity,
create_identity_from_face,
search_identities,
@@ -1104,7 +1287,13 @@ fn main() {
db::delete_search_history,
db::get_bookmarks,
db::save_bookmark,
db::delete_bookmark
db::delete_bookmark,
list_admin_users,
get_system_stats,
list_shares,
list_client_files,
mkdir_client,
rm_client
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -1,40 +1,9 @@
<template>
<div id="app" class="ms-app" :data-current-login="'guest'">
<aside class="ms-side">
<div class="gs-logo">Workspace</div>
<nav class="gs-nav">
<router-link to="/search" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
<span>Search</span>
</router-link>
<router-link to="/library" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
<span>Library</span>
</router-link>
<router-link to="/people" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/People.png" alt="">
<span>People</span>
</router-link>
</nav>
<div class="gs-divider"></div>
<div class="gs-footer">
<div class="gs-theme-switcher">
<button class="gs-theme-btn active"></button>
<button class="gs-theme-btn">🌙</button>
<button class="gs-theme-btn">🌗</button>
</div>
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
</div>
</aside>
<main class="ms-main">
<div class="ms-content"><router-view /></div>
</main>
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { isTauri } from '@/api/config'
const router = useRouter()
const appZoom = ref(100)
function handleKeydown(e: KeyboardEvent) {
@@ -55,9 +24,79 @@ function handleKeydown(e: KeyboardEvent) {
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
if (isTauri) return
const token = localStorage.getItem('token')
const expiresAt = localStorage.getItem('expires_at')
const currentPath = router.currentRoute.value.path
if (currentPath !== '/login') {
if (!token || !expiresAt) {
router.push('/login')
} else {
const now = new Date()
const expires = new Date(expiresAt)
if (now >= expires) {
localStorage.removeItem('token')
localStorage.removeItem('username')
localStorage.removeItem('expires_at')
router.push('/login')
}
}
}
})
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<template>
<div id="app" class="ms-app" :data-current-login="'guest'">
<aside class="ms-side">
<div class="gs-logo">Workspace</div>
<nav class="gs-nav">
<router-link to="/search" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/Search.png" alt="">
<span>Search</span>
</router-link>
<router-link to="/library" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/Library.png" alt="">
<span>Library</span>
</router-link>
<router-link to="/people" class="gs-nav-item" active-class="active">
<img class="gs-nav-icon" src="/icons/People.png" alt="">
<span>People</span>
</router-link>
<router-link to="/admin" class="gs-nav-item" active-class="active">
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
<span>Admin</span>
</router-link>
<router-link to="/client" class="gs-nav-item" active-class="active">
<svg class="gs-nav-icon" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
<span>Client</span>
</router-link>
</nav>
<div class="gs-divider"></div>
<div class="gs-footer">
<div class="gs-theme-switcher">
<button class="gs-theme-btn active"></button>
<button class="gs-theme-btn">🌙</button>
<button class="gs-theme-btn">🌗</button>
</div>
<div class="gs-account"><span class="gs-account-name">Demo</span></div>
</div>
</aside>
<main class="ms-main">
<div class="ms-content"><router-view /></div>
</main>
</div>
</template>
<style>
@import './assets/wordpress-exact.css';

View File

@@ -1,6 +1,6 @@
export const isTauri = typeof window !== 'undefined' && !!(window as any).__TAURI__
export function getApiBase(): string {
if (isTauri) return ''
if (isTauri) return 'http://localhost:8888'
return localStorage.getItem('proxy_url') || window.location.origin
}

View File

@@ -32,7 +32,16 @@ export async function apiCall(cmd: string, args: Record<string, any>): Promise<a
return httpCall('update_identity', { uuid: args.uuid, metadataJson: JSON.stringify(metadata) })
}
if (isTauri) {
return invoke(cmd, args)
try {
const data = await invoke(cmd, args)
return transformResponse(cmd, data)
} catch (e: any) {
console.error('[apiCall] invoke error:', typeof e, e)
const errMsg = typeof e === 'string' ? e : (e?.message || String(e))
console.error('[apiCall] falling back to HTTP for', cmd, '- error:', errMsg)
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
}
}
const data = await httpCall(cmd, args)
return transformResponse(cmd, data)
@@ -117,6 +126,14 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
case 'get_face_candidates': {
return { url: `/api/v1/faces/candidates?page=${a.page || 1}&page_size=${a.perPage || 100}`, method: 'GET' }
}
case 'get_unassigned_traces': {
let url = `/api/v1/traces/unassigned?page=${a.page || 1}&per_page=${a.perPage || 20}`
if (a.fileUuid) url += `&file_uuid=${a.fileUuid}`
return { url, method: 'GET' }
}
case 'get_pending_persons': {
return { url: `/api/v1/file/${a.fileUuid}/pending-persons`, method: 'GET' }
}
case 'get_identity': {
return { url: `/api/v1/identity/${a.uuid}`, method: 'GET' }
}
@@ -326,8 +343,6 @@ function buildHttpRequest(cmd: string, args: Record<string, any>): { url: string
// Transform HTTP response to match Tauri invoke format
// Some endpoints need data reshaping to match what the Rust commands return
export function transformResponse(cmd: string, data: any): any {
if (isTauri) return data
switch (cmd) {
case 'get_files': {
const files = data.files || data.data || data || []
@@ -337,7 +352,7 @@ export function transformResponse(cmd: string, data: any): any {
file_path: f.file_path || '',
file_size: f.file_size || 0,
modified_time: f.modified_time || '',
isRegistered: f.is_registered ?? false,
isRegistered: f.isRegistered ?? f.is_registered ?? false,
status: f.status || '',
registrationTime: f.registration_time || null,
ingested: f.ingested ?? (f.status === 'completed'),
@@ -349,8 +364,9 @@ export function transformResponse(cmd: string, data: any): any {
identity_uuid: p.identity_uuid || '',
name: p.name || '',
starred: p.metadata?.starred ?? p.starred ?? false,
status: p.metadata?.status ?? p.status ?? 'pending',
status: p.status || p.metadata?.status || 'pending',
metadata: p.metadata || {},
file_uuids: p.file_uuids || [],
}))
}
case 'get_faces': {
@@ -393,6 +409,32 @@ export function transformResponse(cmd: string, data: any): any {
bbox: c.bbox ? { x: c.bbox.x, y: c.bbox.y, width: c.bbox.width, height: c.bbox.height } : null,
}))
}
case 'get_unassigned_traces': {
const traces = data.traces || data.data || []
return {
total: data.total || traces.length || 0,
traces: traces.map((t: any) => ({
trace_id: t.trace_id,
file_uuid: t.file_uuid || '',
frame_count: t.frame_count || 0,
start_frame: t.start_frame || 0,
end_frame: t.end_frame || 1,
best_face_id: t.best_face_id,
best_face_frame: t.best_face_frame || 1,
best_face_confidence: t.best_face_confidence || 1,
best_face_bbox: t.best_face_bbox ? { x: t.best_face_bbox.x, y: t.best_face_bbox.y, width: t.best_face_bbox.width, height: t.best_face_bbox.height } : null,
})),
}
}
case 'get_pending_persons': {
return {
pending_persons: (data.pending_persons || data.data || []).map((p: any) => ({
identity_uuid: p.identity_uuid || '',
name: p.name || '',
trace_count: p.trace_count || 0,
})),
}
}
case 'search_llm_smart': {
const results = data.results || data.data || data || []
return results.map((r: any) => ({

View File

@@ -3,13 +3,19 @@ import SearchView from '../views/SearchView.vue'
import LibraryView from '../views/LibraryView.vue'
import PeopleView from '../views/PeopleView.vue'
import PersonDetailView from '../views/PersonDetailView.vue'
import AdminView from '../views/AdminView.vue'
import ClientView from '../views/ClientView.vue'
import LoginView from '../views/LoginView.vue'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/search' },
{ path: '/login', name: 'Login', component: LoginView },
{ path: '/search', name: 'Search', component: SearchView },
{ path: '/library', name: 'Library', component: LibraryView },
{ path: '/people', name: 'People', component: PeopleView },
{ path: '/people/:uuid', name: 'PersonDetail', component: PersonDetailView }
{ path: '/people/:uuid', name: 'PersonDetail', component: PersonDetailView },
{ path: '/admin', name: 'Admin', component: AdminView },
{ path: '/client', name: 'Client', component: ClientView }
]
const router = createRouter({
@@ -17,4 +23,4 @@ const router = createRouter({
routes
})
export default router
export default router

View File

@@ -11,6 +11,9 @@ export const peopleLoaded = ref(false)
export const faceCandidatesCache = ref<any[]>([])
export const faceCandidatesLoaded = ref(false)
export const unassignedTracesCache = ref<any[]>([])
export const unassignedTracesLoaded = ref(false)
export const thumbnailsCache = ref<Record<string, string>>({})
export const profilesCache = ref<Record<string, string>>({})
export const faceThumbsCache = ref<Record<string, string>>({})
@@ -98,8 +101,11 @@ function queueProfile(fn: () => Promise<void>) {
let _peopleLoading = false
let _filesLoading = false
let _faceCandidatesLoading = false
let _unassignedTracesLoading = false
let _lastLoadedFileUuid: string | undefined = undefined
export async function ensureFiles() {
console.error('[ensureFiles] starting, filesLoaded.value:', filesLoaded.value)
if (filesLoaded.value) return
if (_filesLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (filesLoaded.value || !_filesLoading) { clearInterval(check); r() } }, 100) })
@@ -177,6 +183,56 @@ export async function ensureFaceCandidates() {
}
}
export async function ensureUnassignedTraces(fileUuid?: string) {
if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid) return
if (_unassignedTracesLoading) {
await new Promise<void>(r => { const check = setInterval(() => { if (unassignedTracesLoaded.value || !_unassignedTracesLoading) { clearInterval(check); r() } }, 100) })
if (unassignedTracesLoaded.value && _lastLoadedFileUuid === fileUuid) return
}
_unassignedTracesLoading = true
try {
const all: any[] = []
for (let page = 1; page <= 5; page++) {
const batch: any = await apiCall('get_unassigned_traces', { page, perPage: 20 })
const traces = batch?.traces || []
if (!traces.length) break
all.push(...traces)
if (traces.length < 20) break
}
unassignedTracesCache.value = all
_lastLoadedFileUuid = fileUuid
} catch (e) {
console.error('Failed to load unassigned traces:', e)
} finally {
_unassignedTracesLoading = false
unassignedTracesLoaded.value = true
}
}
export function loadTraceThumb(t: any) {
if (!t.file_uuid || !t.trace_id) return
const key = `${t.trace_id}-${t.file_uuid}`
if (faceThumbsCache.value[key] || loadingFaceThumbs.has(key)) return
loadingFaceThumbs.add(key)
queueThumb(async () => {
try {
const args: any = { uuid: t.file_uuid, frame: t.best_face_frame || t.start_frame || 0 }
if (t.best_face_bbox) {
args.bboxX = Math.round(t.best_face_bbox.x)
args.bboxY = Math.round(t.best_face_bbox.y)
args.bboxW = Math.round(t.best_face_bbox.width)
args.bboxH = Math.round(t.best_face_bbox.height)
}
const result = await apiCall('get_face_thumbnail', args)
if (result) faceThumbsCache.value[key] = result
} catch (e) {
console.error('loadTraceThumb failed:', key, e)
} finally {
loadingFaceThumbs.delete(key)
}
})
}
export function loadThumbnail(uuid: string, frame = 30) {
const key = `${uuid}:${frame}`
if (!uuid || thumbnailsCache.value[key] || loadingThumbs.has(key)) return

85
src/views/AdminView.vue Normal file
View File

@@ -0,0 +1,85 @@
<template>
<div id="ms-view-admin">
<div class="admin-header">
<h1>Admin Panel</h1>
<div class="admin-tabs">
<button class="admin-tab" :class="{ active: activeTab === 'dashboard' }" @click="activeTab = 'dashboard'">Dashboard</button>
<button class="admin-tab" :class="{ active: activeTab === 'users' }" @click="activeTab = 'users'">Users</button>
<button class="admin-tab" :class="{ active: activeTab === 'shares' }" @click="activeTab = 'shares'">Shares</button>
</div>
</div>
<div class="admin-content">
<div v-if="activeTab === 'dashboard'" class="dashboard-panel">
<div class="stats-grid">
<div class="stat-card"><div class="stat-label">CPU</div><div class="stat-value">{{ stats.cpu }}%</div></div>
<div class="stat-card"><div class="stat-label">Memory</div><div class="stat-value">{{ stats.memory }}%</div></div>
<div class="stat-card"><div class="stat-label">Disk</div><div class="stat-value">{{ stats.disk }}%</div></div>
<div class="stat-card"><div class="stat-label">Users</div><div class="stat-value">{{ users.length }}</div></div>
<div class="stat-card"><div class="stat-label">Shares</div><div class="stat-value">{{ shares.length }}</div></div>
</div>
</div>
<div v-if="activeTab === 'users'" class="users-panel">
<table class="admin-table">
<thead><tr><th>Username</th><th>Home Dir</th><th>Status</th></tr></thead>
<tbody>
<tr v-for="u in users" :key="u.username">
<td>{{ u.username }}</td><td>{{ u.homeDir }}</td><td>{{ u.status === 1 ? 'Active' : 'Disabled' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="activeTab === 'shares'" class="shares-panel">
<table class="admin-table">
<thead><tr><th>Name</th><th>Port</th><th>Status</th></tr></thead>
<tbody>
<tr v-for="s in shares" :key="s.name">
<td>{{ s.name }}</td><td>{{ s.port }}</td><td>{{ s.status }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { apiCall } from '../api'
const activeTab = ref('dashboard')
const stats = ref({ cpu: 0, memory: 0, disk: 0 })
const users = ref<any[]>([])
const shares = ref<any[]>([])
async function loadDashboard() {
try { stats.value = await apiCall('get_system_stats', {}) || { cpu: 0, memory: 0, disk: 0 } } catch (e) { console.error(e) }
}
async function loadUsers() {
try { users.value = await apiCall('list_admin_users', {}) || [] } catch (e) { console.error(e) }
}
async function loadShares() {
try { shares.value = await apiCall('list_shares', {}) || [] } catch (e) { console.error(e) }
}
onMounted(async () => { await loadDashboard(); await loadUsers(); await loadShares() })
</script>
<style scoped>
#ms-view-admin { padding: 20px; }
.admin-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.admin-tabs { display: flex; gap: 10px; }
.admin-tab { padding: 8px 16px; border: none; background: #f0f0f0; cursor: pointer; border-radius: 4px; }
.admin-tab.active { background: #2563eb; color: white; }
.admin-content { background: white; border-radius: 8px; padding: 20px; }
.stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 15px; }
.stat-card { background: #f8f9fa; padding: 15px; border-radius: 8px; text-align: center; }
.stat-label { font-size: 12px; color: #666; }
.stat-value { font-size: 24px; font-weight: bold; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { padding: 10px; border: 1px solid #ddd; text-align: left; }
</style>

690
src/views/ClientView.vue Normal file
View File

@@ -0,0 +1,690 @@
<template>
<div id="ms-view-client">
<div class="client-header">
<h1>File Manager</h1>
<div class="client-toolbar">
<button @click="goUp" :disabled="currentPath === '/'">Up</button>
<button @click="showCreateFolderDialog">New Folder</button>
<button @click="showUploadDialog">Upload</button>
<button @click="deleteSelected" :disabled="!selectedFile">Delete</button>
<button @click="refreshFiles">Refresh</button>
<span class="current-path">Path: {{ currentPath }}</span>
</div>
</div>
<div class="client-content">
<div v-if="loading" class="loading">Loading...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<table v-else class="file-table">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Type</th>
<th>Modified</th>
</tr>
</thead>
<tbody>
<tr v-if="files.length === 0">
<td colspan="4" class="empty-folder">Empty folder</td>
</tr>
<tr
v-for="f in files"
:key="f.name"
@click="selectFile(f)"
@dblclick="openFile(f)"
:class="{ selected: selectedFile?.name === f.name }"
>
<td>
<span :class="f.isDir ? 'folder-icon' : 'file-icon'">{{ f.isDir ? '📁' : '📄' }}</span>
{{ f.name }}
</td>
<td>{{ f.isDir ? '-' : formatSize(f.size) }}</td>
<td>{{ f.isDir ? 'Folder' : 'File' }}</td>
<td>{{ f.modified || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Folder Dialog -->
<div v-if="showCreateFolder" class="dialog-overlay" @click="hideCreateFolderDialog">
<div class="dialog" @click.stop>
<h3>Create New Folder</h3>
<input v-model="newFolderName" type="text" placeholder="Folder name" @keyup.enter="createFolder" />
<div class="dialog-buttons">
<button @click="createFolder">Create</button>
<button @click="hideCreateFolderDialog">Cancel</button>
</div>
</div>
</div>
<!-- File Preview Dialog -->
<div v-if="showPreview" class="dialog-overlay" @click="hidePreview">
<div class="dialog preview-dialog" @click.stop>
<h3>File Preview: {{ selectedFile?.name }}</h3>
<div v-if="previewLoading" class="loading">Loading preview...</div>
<div v-else-if="previewContent" class="preview-content">
<img v-if="isImage" :src="previewContent" alt="preview" />
<pre v-else>{{ previewContent }}</pre>
</div>
<div class="dialog-buttons">
<button @click="downloadFile">Download</button>
<button @click="hidePreview">Close</button>
</div>
</div>
</div>
<!-- Upload Dialog -->
<div v-if="showUpload" class="dialog-overlay" @click="hideUploadDialog">
<div class="dialog upload-dialog" @click.stop>
<h3>Upload File</h3>
<input ref="fileInput" type="file" @change="selectUploadFile" />
<div v-if="uploadFile" class="upload-info">
<p>File: {{ uploadFile.name }}</p>
<p>Size: {{ formatSize(uploadFile.size) }}</p>
</div>
<div v-if="uploadProgress > 0" class="upload-progress">
<progress :value="uploadProgress" max="100"></progress>
<span>{{ uploadProgress }}%</span>
</div>
<div class="dialog-buttons">
<button @click="uploadFileToServer" :disabled="!uploadFile || uploading">
{{ uploading ? 'Uploading...' : 'Upload' }}
</button>
<button @click="hideUploadDialog">Cancel</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const currentPath = ref('/')
const files = ref<any[]>([])
const selectedFile = ref<any>(null)
const loading = ref(false)
const error = ref('')
const showCreateFolder = ref(false)
const newFolderName = ref('')
const showPreview = ref(false)
const previewContent = ref('')
const previewLoading = ref(false)
const isImage = ref(false)
const showUpload = ref(false)
const uploadFile = ref<File | null>(null)
const uploadProgress = ref(0)
const uploading = ref(false)
// API base URL (MarkBase server)
const API_BASE = 'http://localhost:11438/api/v2'
// Load files from current directory
async function loadFiles() {
loading.value = true
error.value = ''
selectedFile.value = null
try {
// Use MarkBase myfiles API
const username = 'demo'
const folderParam = currentPath.value === '/' ? '' : currentPath.value.replace('/', '')
let apiUrl = `${API_BASE}/myfiles/${username}/files`
if (folderParam) {
apiUrl += `?folder=${encodeURIComponent(folderParam)}`
}
const response = await fetch(apiUrl)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
// Transform files to file list (data is array, not { files: [] })
if (Array.isArray(data)) {
files.value = data.map((file: any) => ({
name: file.name || file.filename,
isDir: false, // All files in myfiles are files, not folders
size: file.size || 0,
modified: '-',
uuid: null,
tags: file.tags || [],
})).sort((a: any, b: any) => a.name.localeCompare(b.name))
} else {
files.value = []
}
} catch (e: any) {
error.value = 'Failed to load files: ' + e.message
files.value = []
} finally {
loading.value = false
}
}
// Navigate to parent directory
function goUp() {
if (currentPath.value === '/') return
const parts = currentPath.value.split('/').filter(p => p)
parts.pop()
currentPath.value = '/' + parts.join('/')
loadFiles()
}
// Select file
function selectFile(f: any) {
selectedFile.value = f
}
// Open file (double-click)
function openFile(f: any) {
if (f.isDir) {
// Navigate into directory
currentPath.value = currentPath.value === '/'
? '/' + f.name
: currentPath.value + '/' + f.name
loadFiles()
} else {
// Preview file
showFilePreview(f)
}
}
// Show create folder dialog
function showCreateFolderDialog() {
newFolderName.value = ''
showCreateFolder.value = true
}
// Hide create folder dialog
function hideCreateFolderDialog() {
showCreateFolder.value = false
newFolderName.value = ''
}
// Create new folder
async function createFolder() {
if (!newFolderName.value.trim()) {
alert('Please enter folder name')
return
}
const folderPath = currentPath.value === '/'
? '/' + newFolderName.value
: currentPath.value + '/' + newFolderName.value
try {
const response = await fetch(`${API_BASE}/tree/demo/node`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
parent_id: currentPath.value === '/' ? null : currentPath.value,
node_type: 'folder',
label: newFolderName.value,
}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
hideCreateFolderDialog()
loadFiles()
} catch (e: any) {
alert('Failed to create folder: ' + e.message)
}
}
// Delete selected file/folder
async function deleteSelected() {
if (!selectedFile.value) return
const itemName = selectedFile.value.name
if (!confirm(`Delete "${itemName}"?`)) return
const itemPath = currentPath.value === '/'
? '/' + itemName
: currentPath.value + '/' + itemName
try {
// Find node_id from files list
const node = files.value.find(f => f.name === itemName)
if (!node || !node.uuid) {
throw new Error('Node UUID not found')
}
const response = await fetch(`${API_BASE}/tree/demo/node/${node.uuid}`, {
method: 'DELETE',
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
selectedFile.value = null
loadFiles()
} catch (e: any) {
alert('Failed to delete: ' + e.message)
}
}
// Refresh file list
function refreshFiles() {
loadFiles()
}
// Show file preview
async function showFilePreview(f: any) {
showPreview.value = true
previewLoading.value = true
previewContent.value = ''
isImage.value = false
try {
// Check file type by extension
const ext = f.name.split('.').pop()?.toLowerCase() || ''
const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp']
const videoExtensions = ['mp4', 'avi', 'mov', 'mkv', 'webm']
const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'm4a']
const textExtensions = ['txt', 'md', 'json', 'xml', 'html', 'css', 'js', 'ts', 'vue', 'py', 'rs', 'go', 'java']
if (imageExtensions.includes(ext)) {
// Image preview - use WebDAV with auth
isImage.value = true
const token = localStorage.getItem('token') || ''
const username = localStorage.getItem('username') || 'demo'
// Use WebDAV endpoint with Basic auth
const response = await fetch(`http://localhost:11438/webdav/${username}/${f.name}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (response.ok) {
const blob = await response.blob()
previewContent.value = URL.createObjectURL(blob)
} else {
// Fallback: try without auth
previewContent.value = `http://localhost:11438/webdav/${username}/${f.name}`
}
} else if (videoExtensions.includes(ext)) {
// Video preview - show video element
isImage.value = false
const username = localStorage.getItem('username') || 'demo'
previewContent.value = `<video controls style="max-width:100%; max-height:60vh">
<source src="http://localhost:11438/webdav/${username}/${f.name}" type="video/${ext}">
Your browser does not support video preview.
</video>`
} else if (audioExtensions.includes(ext)) {
// Audio preview - show audio element
isImage.value = false
const username = localStorage.getItem('username') || 'demo'
previewContent.value = `<audio controls style="width:100%">
<source src="http://localhost:11438/webdav/${username}/${f.name}" type="audio/${ext}">
Your browser does not support audio preview.
</audio>`
} else if (textExtensions.includes(ext)) {
// Text preview - fetch and display content
const username = localStorage.getItem('username') || 'demo'
const token = localStorage.getItem('token') || ''
const response = await fetch(`http://localhost:11438/webdav/${username}/${f.name}`, {
headers: {
'Authorization': `Bearer ${token}`,
},
})
if (response.ok) {
const text = await response.text()
// Limit preview size
if (text.length > 10000) {
previewContent.value = text.substring(0, 10000) + '\n\n... (truncated, file too large)'
} else {
previewContent.value = text
}
} else {
previewContent.value = 'Failed to load file content'
}
} else {
// Unknown file type - show download link
previewContent.value = `File type: ${ext}\n\nPreview not available for this file type.\nClick Download button to download the file.`
}
} catch (e: any) {
previewContent.value = 'Failed to load preview: ' + e.message
} finally {
previewLoading.value = false
}
}
// Hide preview dialog
function hidePreview() {
showPreview.value = false
previewContent.value = ''
}
// Show upload dialog
function showUploadDialog() {
uploadFile.value = null
uploadProgress.value = 0
uploading.value = false
showUpload.value = true
}
// Hide upload dialog
function hideUploadDialog() {
showUpload.value = false
uploadFile.value = null
uploadProgress.value = 0
uploading.value = false
}
// Select file for upload
function selectUploadFile(event: Event) {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
uploadFile.value = target.files[0]
}
}
// Upload file to server
async function uploadFileToServer() {
if (!uploadFile.value) {
alert('No file selected')
return
}
uploading.value = true
uploadProgress.value = 0
try {
const formData = new FormData()
formData.append('file', uploadFile.value)
const xhr = new XMLHttpRequest()
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
uploadProgress.value = Math.round((e.loaded / e.total) * 100)
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200 || xhr.status === 201) {
alert('Upload successful!')
hideUploadDialog()
loadFiles()
} else {
alert('Upload failed: ' + xhr.statusText)
}
uploading.value = false
})
xhr.addEventListener('error', () => {
alert('Upload failed: Network error')
uploading.value = false
})
// Upload to upload-unlimited endpoint
xhr.open('POST', `${API_BASE}/upload-unlimited/demo`)
xhr.send(formData)
} catch (e: any) {
alert('Upload failed: ' + e.message)
uploading.value = false
}
}
// Download file
function downloadFile() {
if (!selectedFile.value) {
alert('No file selected')
return
}
if (selectedFile.value.downloadUrl) {
window.open(selectedFile.value.downloadUrl, '_blank')
} else if (selectedFile.value.uuid) {
const downloadUrl = `${API_BASE}/files/demo/${selectedFile.value.uuid}/stream`
window.open(downloadUrl, '_blank')
} else {
alert('Download URL not available')
}
}
// Format file size
function formatSize(n: number): string {
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'
return (n / 1024 / 1024 / 1024).toFixed(1) + ' GB'
}
// Initialize on mount
onMounted(() => {
loadFiles()
})
</script>
<style scoped>
#ms-view-client {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.client-header {
margin-bottom: 20px;
}
.client-header h1 {
margin: 0;
font-size: 24px;
color: #333;
}
.client-toolbar {
display: flex;
gap: 10px;
align-items: center;
margin-top: 15px;
padding: 10px;
background: #f5f5f5;
border-radius: 5px;
}
.client-toolbar button {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.client-toolbar button:hover:not(:disabled) {
background: #e3f2fd;
}
.client-toolbar button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current-path {
margin-left: 20px;
font-weight: bold;
color: #667eea;
}
.client-content {
background: white;
border-radius: 5px;
overflow: hidden;
}
.loading, .error {
padding: 40px;
text-align: center;
color: #666;
}
.error {
color: #e74c3c;
}
.file-table {
width: 100%;
border-collapse: collapse;
}
.file-table th, .file-table td {
padding: 12px;
border-bottom: 1px solid #eee;
text-align: left;
}
.file-table th {
background: #f5f5f5;
font-weight: bold;
color: #333;
}
.file-table tr.selected {
background: #e3f2fd;
}
.file-table tr:hover {
background: #f5f5f5;
cursor: pointer;
}
.empty-folder {
text-align: center;
color: #999;
padding: 40px;
}
.folder-icon, .file-icon {
margin-right: 8px;
}
/* Dialog styles */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog {
background: white;
padding: 20px;
border-radius: 10px;
width: 90%;
max-width: 400px;
}
.dialog h3 {
margin: 0 0 15px;
color: #333;
}
.dialog input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
.dialog-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.dialog-buttons button {
padding: 8px 20px;
border: 1px solid #ddd;
border-radius: 5px;
cursor: pointer;
}
.dialog-buttons button:first-child {
background: #667eea;
color: white;
border-color: #667eea;
}
.preview-dialog {
max-width: 800px;
max-height: 80vh;
}
.preview-content {
max-height: 60vh;
overflow: auto;
margin-top: 15px;
}
.preview-content img {
max-width: 100%;
max-height: 60vh;
}
.preview-content pre {
white-space: pre-wrap;
font-size: 14px;
background: #f5f5f5;
padding: 10px;
border-radius: 5px;
}
.upload-dialog {
max-width: 500px;
}
.upload-info {
margin-top: 15px;
padding: 10px;
background: #f5f5f5;
border-radius: 5px;
}
.upload-info p {
margin: 5px 0;
font-size: 14px;
}
.upload-progress {
margin-top: 15px;
display: flex;
align-items: center;
gap: 10px;
}
.upload-progress progress {
width: 100%;
height: 20px;
}
.upload-progress span {
font-size: 14px;
color: #667eea;
}
</style>

182
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,182 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h2>Momentry Studio</h2>
<p>Login to continue</p>
</div>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label>Username</label>
<input v-model="username" type="text" required placeholder="Enter username" />
</div>
<div class="form-group">
<label>Password</label>
<input v-model="password" type="password" required placeholder="Enter password" />
</div>
<button type="submit" :disabled="loading" class="login-btn">
{{ loading ? 'Logging in...' : 'Login' }}
</button>
<div v-if="error" class="error-message">{{ error }}</div>
<div v-if="success" class="success-message">Login successful!</div>
</form>
<div class="login-footer">
<p>Default credentials: momentry / demo123</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const username = ref('')
const password = ref('')
const loading = ref(false)
const error = ref('')
const success = ref(false)
async function handleLogin() {
loading.value = true
error.value = ''
success.value = false
try {
const response = await fetch('http://localhost:11438/api/v2/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username.value,
password: password.value,
}),
})
const data = await response.json()
if (response.ok && data.token) {
success.value = true
localStorage.setItem('token', data.token)
localStorage.setItem('username', username.value)
localStorage.setItem('expires_at', data.expires_at)
setTimeout(() => {
router.push('/search')
}, 1000)
} else {
error.value = data.error || 'Login failed'
}
} catch (e: any) {
error.value = 'Network error: ' + e.message
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
padding: 40px;
border-radius: 10px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h2 {
margin: 0;
color: #333;
font-size: 28px;
}
.login-header p {
margin: 10px 0 0;
color: #666;
font-size: 14px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #333;
font-size: 14px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
.form-group input:focus {
border-color: #667eea;
outline: none;
}
.login-btn {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 5px;
font-size: 16px;
cursor: pointer;
transition: opacity 0.3s;
}
.login-btn:hover:not(:disabled) {
opacity: 0.9;
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.error-message {
color: #e74c3c;
margin-top: 15px;
text-align: center;
font-size: 14px;
}
.success-message {
color: #27ae60;
margin-top: 15px;
text-align: center;
font-size: 14px;
}
.login-footer {
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}
</style>

View File

@@ -1,6 +1,10 @@
<template>
<div class="people-view">
<div class="ms-ppl-toolbar">
<select v-model="selectedFileUuid" @change="onFileFilterChange" class="ms-ppl-file-select">
<option value="">全部檔案</option>
<option v-for="f in fileFilterList" :key="f.file_uuid" :value="f.file_uuid">{{ f.file_name }}</option>
</select>
<button class="ms-fm-btn ms-ppl-star-toggle-btn" @click="starFilter = !starFilter">
<span class="ms-ppl-star-icon" :class="{ starred: starFilter }"></span>
<span>{{ starFilter ? '查看所有人物' : '查看重要人物' }}</span>
@@ -22,7 +26,7 @@
<div class="spinner-lg"></div>
<p>Loading...</p>
</div>
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0" class="empty">
<div v-else-if="confirmedPeople.length === 0 && pendingPeople.length === 0 && skippedPeople.length === 0 && unassignedTraces.length === 0" class="empty">
No people found.
</div>
<template v-else>
@@ -170,18 +174,18 @@
<hr v-if="(showSkipped && skippedPeople.length) || (showPending && pendingPeople.length) || confirmedPeople.length" class="ms-ppl-hr">
<!-- 待定人臉 -->
<div v-if="showUface && faceCandidates.length" class="ms-ppl-section">
<!-- 待定人臉 -->
<div v-if="showUface && unassignedTraces.length" class="ms-ppl-section">
<div class="ms-ppl-section-toolbar">
<div class="ms-ppl-section-title">待定人臉</div>
<div class="ms-ppl-section-title">待定人臉 <span class="ms-ppl-section-count">({{ unassignedTraces.length }})</span></div>
</div>
<div class="ms-ppl-face-grid ms-uface-grid">
<div v-for="c in faceCandidates.slice(0, 50)" :key="c.id" class="ms-ppl-face-card" @click="openAssignModal(c)" @contextmenu.prevent="showFaceCtxMenu($event, c)" v-observe="() => loadCandidateThumb(c)">
<div v-for="t in unassignedTraces.slice(0, 100)" :key="`${t.trace_id}-${t.file_uuid}`" class="ms-ppl-face-card" @click="openTraceAssignModal(t)" @contextmenu.prevent="showTraceCtxMenu($event, t)" v-observe="() => loadTraceThumb(t)">
<div class="ms-ppl-face-img-wrap">
<img v-if="candidateThumbs[c.id]" :src="candidateThumbs[c.id]" alt="">
<div v-else class="face-placeholder">{{ Math.round(c.confidence * 100) }}%</div>
<img v-if="faceThumbsCache[`${t.trace_id}-${t.file_uuid}`]" :src="faceThumbsCache[`${t.trace_id}-${t.file_uuid}`]" alt="">
<div v-else class="face-placeholder">{{ t.frame_count }}</div>
</div>
<span class="ms-ppl-face-name">{{ (c.file_uuid || '').slice(0, 8) }}... #{{ c.frame_number }}</span>
<span class="ms-ppl-face-name">{{ (t.file_uuid || '').slice(0, 8) }}... T{{ String(t.trace_id ?? '').slice(-4) || '?' }} ({{ t.frame_count }})</span>
</div>
</div>
</div>
@@ -322,7 +326,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { apiCall } from '@/api'
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, invalidatePeople } from '@/store'
import { ensurePeople, peopleCache, peopleLoaded, ensureFaceCandidates, faceCandidatesCache, faceCandidatesLoaded, ensureUnassignedTraces, unassignedTracesCache, unassignedTracesLoaded, ensureFiles, filesCache, profilesCache, loadProfile, faceThumbsCache, loadFaceThumb, loadTraceThumb, invalidatePeople } from '@/store'
import { useUndoRedo } from '@/composables/useUndoRedo'
const router = useRouter()
@@ -344,6 +348,16 @@ const mergeTarget = ref('')
const candidates = ref<any[]>([])
const candidateThumbs = faceThumbsCache
const faceCandidates = faceCandidatesCache
// File filter
const selectedFileUuid = ref('')
const unassignedTraces = computed(() => {
const all = unassignedTracesCache.value
if (!selectedFileUuid.value) return all
const filtered = all.filter((t: any) => t.file_uuid === selectedFileUuid.value)
return filtered
})
const traceCounts = ref<Record<string, number>>({})
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
const faceCtxMenu = ref({ show: false, x: 0, y: 0, candidate: null as any })
@@ -351,6 +365,13 @@ const assignModal = ref({ show: false, candidate: null as any })
const assignSearchQuery = ref('')
const assignSelected = ref<any>(null)
const faceDetailModal = ref({ show: false, candidate: null as any, newIdentityName: '' })
const fileFilterList = computed(() => {
const files = filesCache.value
return files.filter((f: any) => f.isRegistered && f.status === 'completed').map((f: any) => ({
file_uuid: f.file_uuid?.replace(/-/g, ''),
file_name: f.file_name
}))
})
// Section visibility toggles
const showPending = ref(true)
@@ -418,9 +439,11 @@ const confirmedPeople = computed(() => {
const pendingPeople = computed(() => {
let base = people.value.filter((p: any) => p.status === 'pending')
if (selectedFileUuid.value) {
base = base.filter((p: any) => (p.file_uuids || []).includes(selectedFileUuid.value))
}
if (starFilter.value) base = base.filter((p: any) => p.starred)
if (pendingSearch.value) base = base.filter((p: any) => p.name.toLowerCase().includes(pendingSearch.value.toLowerCase()))
// Sort by trace count (descending) - more traces = more suggestions
const sorted = [...base].sort((a: any, b: any) => {
const aCount = traceCounts.value[a.identity_uuid] || 0
const bCount = traceCounts.value[b.identity_uuid] || 0
@@ -445,9 +468,14 @@ const assignSearchResults = computed(() => {
async function refresh() {
await ensurePeople()
await ensureFiles()
for (const p of people.value.slice(0, 30)) {
if (p.identity_uuid) loadProfile(p.identity_uuid)
}
await ensureUnassignedTraces()
for (const t of unassignedTraces.value.slice(0, 20)) {
if (t.file_uuid) loadTraceThumb(t)
}
await ensureFaceCandidates()
for (const c of faceCandidates.value.slice(0, 20)) {
if (c.file_uuid) loadFaceThumb(String(c.id), c.file_uuid, c.frame_number || 0, c.bbox)
@@ -468,6 +496,22 @@ async function refresh() {
traceCounts.value = counts
}
async function onFileFilterChange() {
if (selectedFileUuid.value) {
unassignedTracesLoaded.value = false
await ensureUnassignedTraces(selectedFileUuid.value)
for (const t of unassignedTraces.value.slice(0, 20)) {
if (t.file_uuid) loadTraceThumb(t)
}
} else {
unassignedTracesLoaded.value = false
await ensureUnassignedTraces()
for (const t of unassignedTraces.value.slice(0, 20)) {
if (t.file_uuid) loadTraceThumb(t)
}
}
}
onMounted(async () => {
await refresh()
document.addEventListener('click', closeCtxMenu)
@@ -724,28 +768,22 @@ async function playFaceDetailVideo() {
async function createNewIdentityFromFace() {
const c = faceDetailModal.value.candidate
const name = faceDetailModal.value.newIdentityName.trim()
console.log('[createNewIdentityFromFace] candidate:', c, 'name:', name)
if (!c || !name) {
console.log('[createNewIdentityFromFace] missing candidate or name')
return
}
try {
console.log('[createNewIdentityFromFace] calling API with fileUuid:', c.file_uuid)
const result: any = await apiCall('create_identity_from_face', {
name,
fileUuid: c.file_uuid
})
console.log('[createNewIdentityFromFace] result:', result)
if (result?.identity_uuid) {
faceDetailModal.value.show = false
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
invalidatePeople()
await ensurePeople()
// Remove hyphens from uuid for consistent format
const cleanUuid = result.identity_uuid.replace(/-/g, '')
router.push(`/people/${cleanUuid}`)
} else {
console.log('[createNewIdentityFromFace] no identity_uuid in result')
alert('新增人物失敗API 未返回 identity_uuid')
}
} catch (e) {
@@ -761,6 +799,37 @@ function openAssignModal(c: any) {
loadCandidateThumb(c)
}
function openTraceAssignModal(t: any) {
// Convert trace to candidate-like object for the assign modal
const candidateLike = {
id: t.trace_id,
file_uuid: t.file_uuid,
frame_number: t.best_face_frame || t.start_frame,
confidence: t.best_face_confidence,
bbox: t.best_face_bbox,
trace_id: t.trace_id,
frame_count: t.frame_count,
}
assignModal.value = { show: true, candidate: candidateLike }
assignSearchQuery.value = ''
assignSelected.value = null
loadTraceThumb(t)
}
function showTraceCtxMenu(e: MouseEvent, t: any) {
e.preventDefault()
const candidateLike = {
id: t.trace_id,
file_uuid: t.file_uuid,
frame_number: t.best_face_frame || t.start_frame,
confidence: t.best_face_confidence,
bbox: t.best_face_bbox,
trace_id: t.trace_id,
frame_count: t.frame_count,
}
faceCtxMenu.value = { show: true, x: e.clientX, y: e.clientY, candidate: candidateLike }
}
async function confirmAssign() {
const c = assignModal.value.candidate
if (!c || !assignSelected.value) return
@@ -771,7 +840,12 @@ async function confirmAssign() {
faceRowId: c.face_id ? undefined : c.id,
fileUuid: c.file_uuid
})
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
if (c.trace_id) {
// Remove from unassigned traces if it's a trace
unassignedTracesCache.value = unassignedTracesCache.value.filter((t: any) => t.trace_id !== c.trace_id)
} else {
faceCandidates.value = faceCandidates.value.filter((fc: any) => fc.id !== c.id)
}
assignModal.value.show = false
} catch (e) {
console.error('Bind failed:', e)
@@ -790,6 +864,8 @@ h1 { margin: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.ms-ppl-toolbar { display: flex; align-items: center; gap: 4px; margin-bottom: 20px; flex-wrap: wrap; }
.ms-ppl-file-select { padding: 6px 16px; border: 1.5px solid #9aa0a6; border-radius: 10px; background: #fff; font-size: 13px; font-family: inherit; color: #202124; cursor: pointer; outline: none; min-width: 140px; }
.ms-ppl-file-select:focus { border-color: #202124; }
.ms-ppl-star-toggle-btn { display: flex; align-items: center; gap: 6px; padding: 6px 14px; border: 1.5px solid #d1d5db; border-radius: 10px; background: #fff; cursor: pointer; font-size: 13px; font-family: inherit; color: #202124; transition: border-color .15s; }
.ms-ppl-star-toggle-btn:hover { border-color: #202124; }
.ms-ppl-star-icon { font-size: 16px; color: #d1d5db; transition: color .15s; }

View File

@@ -14,7 +14,11 @@
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

View File

@@ -1,9 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
server: {
port: 5173
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8888',
changeOrigin: true
}
}
}
})