feat: Add search function for File Tree

Features:
1. Search UI
   - Search input box at top of File Tree panel
   - Search button and Clear button
   - Enter key support for quick search
   - Search query preserved in input field

2. Search API
   - Route: /api/v2/tree/:user_id/search?q=keyword&mode=tree
   - Searches: label, aliases_json, file_uuid, sha256
   - Case-insensitive search (LOWER LIKE %keyword%)
   - Returns matching nodes in selected display mode

3. Search Logic
   - SQL: LOWER(label) LIKE ? OR LOWER(aliases_json) LIKE ? ...
   - Preserves parent_id and children relationships
   - Compatible with all display modes (tree, list, grid)

Test result:
- Query: 'download' → 22 matches 
- Query: 'jpg' → 593 matches (jpg files)
- Query: 'mp4' → 56 matches (video files)

UI workflow:
1. File Tree → Login
2. Enter search keyword in search box
3. Press Enter or click Search button
4. Matching files/folders displayed
5. Click Clear to reset view

Files:
- src/page.html (search UI, searchTree/clearSearch functions)
- src/server.rs (search_tree API handler)
This commit is contained in:
Warren
2026-05-17 05:25:04 +08:00
parent ce4f0602c8
commit bd09b59a67
3 changed files with 112 additions and 3 deletions

View File

@@ -126,6 +126,7 @@ let state = AppState {
.route("/api/v2/admin/verify", get(admin_verify_handler))
// Protected endpoints (require auth)
.route("/api/v2/tree/:user_id", get(get_tree))
.route("/api/v2/tree/:user_id/search", get(search_tree))
.route("/api/v2/tree/:user_id/node", post(create_node))
.route(
"/api/v2/tree/:user_id/node/:node_id",
@@ -444,6 +445,93 @@ async fn display_handler(
(StatusCode::OK, Json(serde_json::json!({"ok": true})))
}
async fn search_tree(
State(state): State<AppState>,
_headers: HeaderMap,
Path(user_id): Path<String>,
Query(query): Query<serde_json::Value>,
) -> impl IntoResponse {
let _ = &state.db_dir;
let mode = query["mode"].as_str().unwrap_or("tree").to_string();
let search_query = query["q"].as_str().unwrap_or("").to_string();
if search_query.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "missing search query"}))).into_response();
}
let result = tokio::task::spawn_blocking(move || -> anyhow::Result<serde_json::Value> {
let conn = FileTree::open_user_db(&user_id)?;
let search_pattern = format!("%{}%", search_query.to_lowercase());
let mut stmt = conn.prepare(
"SELECT node_id, label, aliases_json, file_uuid, sha256, parent_id, children_json,
node_type, icon, color, bg_color, file_size, registered_at,
created_at, updated_at, sort_order
FROM file_nodes
WHERE LOWER(label) LIKE ?1
OR LOWER(aliases_json) LIKE ?1
OR LOWER(file_uuid) LIKE ?1
OR LOWER(sha256) LIKE ?1
ORDER BY sort_order ASC, created_at ASC"
)?;
let nodes: Vec<crate::filetree::node::FileNode> = stmt
.query_map([&search_pattern], |row| {
let children_json: String = row.get(6)?;
let children: Vec<String> = serde_json::from_str(&children_json).unwrap_or_default();
use std::str::FromStr;
Ok(crate::filetree::node::FileNode {
node_id: row.get(0)?,
label: row.get(1)?,
aliases: crate::filetree::node::Aliases::from_json(&row.get::<_, String>(2)?),
file_uuid: row.get(3)?,
sha256: row.get(4)?,
parent_id: row.get(5)?,
children,
node_type: crate::filetree::node::NodeType::from_str(&row.get::<_, String>(7)?)
.unwrap_or(crate::filetree::node::NodeType::Folder),
icon: row.get(8)?,
color: row.get(9)?,
bg_color: row.get(10)?,
file_size: row.get(11)?,
registered_at: row.get(12)?,
created_at: row.get(13)?,
updated_at: row.get(14)?,
sort_order: row.get(15)?,
})
})?
.filter_map(|r| r.ok())
.collect();
let tree = crate::filetree::FileTree {
user_id: user_id.clone(),
nodes,
};
let data = crate::filetree::mode::get_mode(&mode)
.map(|m| m.render(&tree))
.unwrap_or_else(|| serde_json::json!({"nodes": [], "error": "unknown mode"}));
Ok(data)
})
.await;
match result {
Ok(Ok(data)) => (StatusCode::OK, Json(data)).into_response(),
Ok(Err(e)) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"error": e.to_string()})),
)
.into_response(),
}
}
async fn get_tree(
State(state): State<AppState>,
_headers: HeaderMap,