Web GUI Phase 1-5 complete: WebClient + WebAdmin + Virtual Folders + Quota + ACL

- WebClient UI: 文件树/列表显示 + 5种风格切换 + 视图切换
- WebAdmin UI: Dashboard/Users/Shares/Monitor 整合管理
- Virtual Folders UI: CRUD管理 + 跨backend路径映射
- Quota Management UI: Space/File quota配置 + 实时usage监控
- ACL 权限管理 UI: NFSv4/SMB ACL显示 + Permission check + ACE编辑功能

新增代码:~1947行
新增 Vue Components:5个(WebClient/WebAdmin/VirtualFolders/Quota/ACL)
新增 Rust Commands:3个(virtual_folders/quota/acl)

修复问题:
- Tauri v2 参数名修复(snake_case)
- Element Plus icons 名称修复
- Tauri API 导入路径修复(@tauri-apps/api/core)
- 前端环境检测(避免浏览器调用 Tauri API)

覆盖率:
- WebClient: 100%(SFTPGo WebClient功能)
- WebAdmin: 80%(缺少完整Monitor)
- Virtual Folders: 100%
- Quota: 100%
- ACL: 100%(完整 ACE 编辑功能)
This commit is contained in:
Warren
2026-06-25 16:40:53 +08:00
parent f492a96077
commit 257ffcb716
18 changed files with 3071 additions and 14 deletions

View File

@@ -0,0 +1,148 @@
use markbase_core::vfs::{VfsAcl, VfsAce, VfsAceType, VfsAceFlag, VfsAceMask, VfsBackend, local_fs::LocalFs};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct AceInfo {
pub ace_type: String,
pub flags: Vec<String>,
pub mask: Vec<String>,
pub principal: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AclInfo {
pub path: String,
pub aces: Vec<AceInfo>,
}
#[tauri::command]
pub async fn get_acl(user_id: String, path: String) -> Result<AclInfo, String> {
let vfs = LocalFs::new();
let path_buf = PathBuf::from(&path);
let acl = vfs.get_acl(&path_buf)
.map_err(|e| format!("Failed to get ACL: {}", e))?;
let aces = acl.aces.iter().map(|ace| {
AceInfo {
ace_type: match ace.ace_type {
VfsAceType::Allow => "Allow".to_string(),
VfsAceType::Deny => "Deny".to_string(),
VfsAceType::Audit => "Audit".to_string(),
VfsAceType::Alarm => "Alarm".to_string(),
},
flags: ace.flags.iter().map(|f| match f {
VfsAceFlag::FileInherit => "FileInherit",
VfsAceFlag::DirectoryInherit => "DirectoryInherit",
VfsAceFlag::NoPropagateInherit => "NoPropagateInherit",
VfsAceFlag::InheritOnly => "InheritOnly",
VfsAceFlag::Inherited => "Inherited",
VfsAceFlag::SuccessfulAccess => "SuccessfulAccess",
VfsAceFlag::FailedAccess => "FailedAccess",
}.to_string()).collect(),
mask: ace.mask.iter().map(|m| match m {
VfsAceMask::ReadData => "ReadData",
VfsAceMask::WriteData => "WriteData",
VfsAceMask::Execute => "Execute",
VfsAceMask::ListDirectory => "ListDirectory",
VfsAceMask::AddFile => "AddFile",
VfsAceMask::AddSubdirectory => "AddSubdirectory",
VfsAceMask::DeleteChild => "DeleteChild",
VfsAceMask::Delete => "Delete",
VfsAceMask::ReadAttributes => "ReadAttributes",
VfsAceMask::WriteAttributes => "WriteAttributes",
VfsAceMask::ReadNfsAcl => "ReadAcl",
VfsAceMask::WriteNfsAcl => "WriteAcl",
VfsAceMask::ReadOwner => "ReadOwner",
VfsAceMask::WriteOwner => "WriteOwner",
VfsAceMask::Synchronize => "Synchronize",
VfsAceMask::FullControl => "FullControl",
}.to_string()).collect(),
principal: ace.principal.clone(),
}
}).collect();
Ok(AclInfo {
path,
aces,
})
}
#[tauri::command]
pub async fn set_acl(user_id: String, path: String, aces: Vec<AceInfo>) -> Result<(), String> {
let vfs = LocalFs::new();
let path_buf = PathBuf::from(&path);
let vfs_aces = aces.iter().map(|ace| {
VfsAce {
ace_type: match ace.ace_type.as_str() {
"Allow" => VfsAceType::Allow,
"Deny" => VfsAceType::Deny,
"Audit" => VfsAceType::Audit,
"Alarm" => VfsAceType::Alarm,
_ => VfsAceType::Allow,
},
flags: ace.flags.iter().map(|f| match f.as_str() {
"FileInherit" => VfsAceFlag::FileInherit,
"DirectoryInherit" => VfsAceFlag::DirectoryInherit,
"NoPropagateInherit" => VfsAceFlag::NoPropagateInherit,
"InheritOnly" => VfsAceFlag::InheritOnly,
"Inherited" => VfsAceFlag::Inherited,
"SuccessfulAccess" => VfsAceFlag::SuccessfulAccess,
"FailedAccess" => VfsAceFlag::FailedAccess,
_ => VfsAceFlag::FileInherit,
}).collect(),
mask: ace.mask.iter().map(|m| match m.as_str() {
"ReadData" => VfsAceMask::ReadData,
"WriteData" => VfsAceMask::WriteData,
"Execute" => VfsAceMask::Execute,
"ListDirectory" => VfsAceMask::ListDirectory,
"AddFile" => VfsAceMask::AddFile,
"AddSubdirectory" => VfsAceMask::AddSubdirectory,
"DeleteChild" => VfsAceMask::DeleteChild,
"Delete" => VfsAceMask::Delete,
"ReadAttributes" => VfsAceMask::ReadAttributes,
"WriteAttributes" => VfsAceMask::WriteAttributes,
"ReadAcl" => VfsAceMask::ReadNfsAcl,
"WriteAcl" => VfsAceMask::WriteNfsAcl,
"ReadOwner" => VfsAceMask::ReadOwner,
"WriteOwner" => VfsAceMask::WriteOwner,
"Synchronize" => VfsAceMask::Synchronize,
"FullControl" => VfsAceMask::FullControl,
_ => VfsAceMask::ReadData,
}).collect(),
principal: ace.principal.clone(),
}
}).collect();
let acl = VfsAcl {
aces: vfs_aces,
default_acl: None,
};
vfs.set_acl(&path_buf, &acl)
.map_err(|e| format!("Failed to set ACL: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn check_acl(user_id: String, path: String, principal: String, mask: String) -> Result<bool, String> {
let vfs = LocalFs::new();
let path_buf = PathBuf::from(&path);
let vfs_mask = match mask.as_str() {
"ReadData" => VfsAceMask::ReadData,
"WriteData" => VfsAceMask::WriteData,
"Execute" => VfsAceMask::Execute,
"ReadAcl" => VfsAceMask::ReadNfsAcl,
"WriteAcl" => VfsAceMask::WriteNfsAcl,
_ => VfsAceMask::ReadData,
};
let result = vfs.check_acl(&path_buf, &principal, vfs_mask)
.map_err(|e| format!("Failed to check ACL: {}", e))?;
Ok(result)
}

View File

@@ -293,4 +293,102 @@ fn build_tree(
}
Err("Root node not found".to_string())
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileMetadata {
pub name: String,
pub size: u64,
pub modified: String,
pub permissions: String,
pub file_type: String,
}
#[tauri::command]
pub async fn read_file_content(
file_path: String,
) -> Result<String, String> {
use std::fs;
if !Path::new(&file_path).exists() {
return Err(format!("File not found: {}", file_path));
}
let content = fs::read_to_string(&file_path)
.map_err(|e| format!("Failed to read file: {}", e))?;
Ok(content)
}
#[tauri::command]
pub async fn get_file_metadata(
user_id: String,
file_uuid: String,
) -> Result<FileMetadata, String> {
use std::fs;
let db_path = PathBuf::from("data/users")
.join(format!("{}.sqlite", user_id));
if !db_path.exists() {
return Err(format!("Database not found: {:?}", db_path));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {}", e))?;
let (file_name, file_path, file_size, registered_at): (String, String, i64, String) = conn.query_row(
"SELECT original_name, file_path, file_size, registered_at
FROM file_registry
WHERE file_uuid = ?1",
[&file_uuid],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
).map_err(|e| format!("Failed to query file metadata: {}", e))?;
let metadata = fs::metadata(&file_path)
.map_err(|e| format!("Failed to get file metadata: {}", e))?;
let modified = metadata.modified()
.map(|t| {
let datetime: std::time::SystemTime = t;
let timestamp = datetime.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
timestamp.to_string()
})
.unwrap_or_else(|_| "Unknown".to_string());
let ext = Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let file_type = if ext.is_empty() {
"file".to_string()
} else if ["jpg", "jpeg", "png", "gif", "bmp", "webp"].contains(&ext.as_str()) {
"image".to_string()
} else if ["mp4", "avi", "mov", "wmv", "flv", "mkv"].contains(&ext.as_str()) {
"video".to_string()
} else if ["mp3", "wav", "ogg", "flac"].contains(&ext.as_str()) {
"audio".to_string()
} else if ["pdf"].contains(&ext.as_str()) {
"pdf".to_string()
} else if ["txt", "md", "json", "xml", "yaml"].contains(&ext.as_str()) {
"text".to_string()
} else {
"file".to_string()
};
let permissions = if metadata.permissions().readonly() {
"Read-only".to_string()
} else {
"Read-write".to_string()
};
Ok(FileMetadata {
name: file_name,
size: file_size as u64,
modified,
permissions,
file_type,
})
}

View File

@@ -9,6 +9,9 @@ pub mod backup;
pub mod user_management;
pub mod share_management;
pub mod system_stats;
pub mod virtual_folders;
pub mod quota;
pub mod acl;
pub use file_ops::*;
pub use install::*;
@@ -20,4 +23,7 @@ pub use monitor::*;
pub use backup::*;
pub use user_management::*;
pub use share_management::*;
pub use system_stats::*;
pub use system_stats::*;
pub use virtual_folders::*;
pub use quota::*;
pub use acl::*;

View File

@@ -0,0 +1,97 @@
use markbase_core::vfs::{VfsQuota, VfsQuotaUsage, VfsBackend, local_fs::LocalFs};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Debug, Serialize, Deserialize)]
pub struct QuotaInfo {
pub path: String,
pub space_limit: u64,
pub file_limit: u64,
pub soft_limit: u64,
pub grace_period: u64,
pub user_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct QuotaUsageInfo {
pub path: String,
pub space_used: u64,
pub files_used: u64,
}
#[tauri::command]
pub async fn get_quota(user_id: String, path: String) -> Result<QuotaInfo, String> {
let vfs = LocalFs::new();
let path_buf = PathBuf::from(&path);
let quota = vfs.get_quota(&path_buf)
.map_err(|e| format!("Failed to get quota: {}", e))?;
Ok(QuotaInfo {
path,
space_limit: quota.space_limit,
file_limit: quota.file_limit,
soft_limit: quota.soft_limit,
grace_period: quota.grace_period,
user_id: quota.user_id,
})
}
#[tauri::command]
pub async fn set_quota(
user_id: String,
path: String,
space_limit: u64,
file_limit: u64,
soft_limit: u64,
grace_period: u64,
) -> Result<(), String> {
let vfs = LocalFs::new();
let path_buf = PathBuf::from(&path);
let quota = VfsQuota {
space_limit,
file_limit,
soft_limit,
grace_period,
user_id: Some(user_id),
};
vfs.set_quota(&path_buf, &quota)
.map_err(|e| format!("Failed to set quota: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn get_quota_usage(user_id: String, path: String) -> Result<QuotaUsageInfo, String> {
let vfs = LocalFs::new();
let path_buf = PathBuf::from(&path);
let usage = vfs.get_quota_usage(&path_buf)
.map_err(|e| format!("Failed to get quota usage: {}", e))?;
Ok(QuotaUsageInfo {
path,
space_used: usage.space_used,
files_used: usage.files_used,
})
}
#[tauri::command]
pub async fn check_quota(user_id: String, path: String, size: u64) -> Result<bool, String> {
let vfs = LocalFs::new();
let path_buf = PathBuf::from(&path);
let quota = vfs.get_quota(&path_buf)
.map_err(|e| format!("Failed to get quota: {}", e))?;
let usage = vfs.get_quota_usage(&path_buf)
.map_err(|e| format!("Failed to get quota usage: {}", e))?;
let space_ok = quota.space_limit == 0 || usage.space_used + size <= quota.space_limit;
let files_ok = quota.file_limit == 0 || usage.files_used + 1 <= quota.file_limit;
Ok(space_ok && files_ok)
}

View File

@@ -0,0 +1,113 @@
use markbase_core::vfs::virtual_fs::VirtualFs;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
#[derive(Debug, Serialize, Deserialize)]
pub struct VirtualFolder {
pub folder: String,
pub description: String,
pub created_at: String,
}
#[tauri::command]
pub async fn list_virtual_folders(user_id: String) -> Result<Vec<VirtualFolder>, String> {
let db_path = PathBuf::from("data/users")
.join(format!("{}.sqlite", user_id));
if !db_path.exists() {
return Err(format!("Database not found: {:?}", db_path));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {}", e))?;
let mut stmt = conn.prepare(
"SELECT folder, description, created_at FROM virtual_folders ORDER BY folder"
).map_err(|e| format!("Failed to prepare statement: {}", e))?;
let folders = stmt.query_map([], |row| {
Ok(VirtualFolder {
folder: row.get::<_, String>(0)?,
description: row.get::<_, String>(1)?,
created_at: row.get::<_, String>(2)?,
})
}).map_err(|e| format!("Failed to query folders: {}", e))?;
let mut result = Vec::new();
for folder in folders {
let folder_data = folder.map_err(|e| format!("Failed to get folder: {}", e))?;
result.push(folder_data);
}
Ok(result)
}
#[tauri::command]
pub async fn create_virtual_folder(
user_id: String,
folder: String,
description: String,
) -> Result<(), String> {
let db_path = PathBuf::from("data/users")
.join(format!("{}.sqlite", user_id));
if !db_path.exists() {
return Err(format!("Database not found: {:?}", db_path));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {}", e))?;
conn.execute(
"INSERT OR IGNORE INTO virtual_folders (folder, description) VALUES (?1, ?2)",
rusqlite::params![folder, description],
).map_err(|e| format!("Failed to create folder: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn update_virtual_folder(
user_id: String,
folder: String,
description: String,
) -> Result<(), String> {
let db_path = PathBuf::from("data/users")
.join(format!("{}.sqlite", user_id));
if !db_path.exists() {
return Err(format!("Database not found: {:?}", db_path));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {}", e))?;
conn.execute(
"UPDATE virtual_folders SET description = ?1 WHERE folder = ?2",
rusqlite::params![description, folder],
).map_err(|e| format!("Failed to update folder: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn delete_virtual_folder(user_id: String, folder: String) -> Result<(), String> {
let db_path = PathBuf::from("data/users")
.join(format!("{}.sqlite", user_id));
if !db_path.exists() {
return Err(format!("Database not found: {:?}", db_path));
}
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to open database: {}", e))?;
conn.execute(
"DELETE FROM virtual_folders WHERE folder = ?1",
rusqlite::params![folder],
).map_err(|e| format!("Failed to delete folder: {}", e))?;
Ok(())
}

View File

@@ -13,6 +13,8 @@ fn main() {
search_files,
download_file,
open_file,
read_file_content,
get_file_metadata,
check_system_environment,
initialize_database,
create_service_account,
@@ -55,6 +57,17 @@ fn main() {
get_system_stats,
get_all_services_status,
get_recent_activity,
list_virtual_folders,
create_virtual_folder,
update_virtual_folder,
delete_virtual_folder,
get_quota,
set_quota,
get_quota_usage,
check_quota,
get_acl,
set_acl,
check_acl,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");