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:
148
markbase-tauri/src-tauri/src/commands/acl.rs
Normal file
148
markbase-tauri/src-tauri/src/commands/acl.rs
Normal 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)
|
||||
}
|
||||
@@ -294,3 +294,101 @@ 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,
|
||||
})
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -21,3 +24,6 @@ pub use backup::*;
|
||||
pub use user_management::*;
|
||||
pub use share_management::*;
|
||||
pub use system_stats::*;
|
||||
pub use virtual_folders::*;
|
||||
pub use quota::*;
|
||||
pub use acl::*;
|
||||
97
markbase-tauri/src-tauri/src/commands/quota.rs
Normal file
97
markbase-tauri/src-tauri/src/commands/quota.rs
Normal 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, "a)
|
||||
.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)
|
||||
}
|
||||
113
markbase-tauri/src-tauri/src/commands/virtual_folders.rs
Normal file
113
markbase-tauri/src-tauri/src/commands/virtual_folders.rs
Normal 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(())
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useAppStore } from './stores/app'
|
||||
import {
|
||||
HomeFilled, Setting, Tools, Monitor, Operation,
|
||||
CircleCheck, DataAnalysis, FolderOpened, Loading
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
@@ -53,6 +57,26 @@ onMounted(async () => {
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>Monitor</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/webclient">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span>WebClient</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/webadmin">
|
||||
<el-icon><Operation /></el-icon>
|
||||
<span>WebAdmin</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/virtualfolders">
|
||||
<el-icon><FolderOpened /></el-icon>
|
||||
<span>Virtual Folders</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/quota">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<span>Quota</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/acl">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
<span>ACL</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-main class="app-main">
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import { invoke } from '@tauri-apps/api'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
export async function getTree(userId, treeType) {
|
||||
return invoke('get_tree', { userId, treeType })
|
||||
return invoke('get_tree', { user_id: userId, tree_type: treeType })
|
||||
}
|
||||
|
||||
export async function listFiles(userId, treeType, parentId) {
|
||||
return invoke('list_files', { userId, treeType, parentId })
|
||||
return invoke('list_files', { user_id: userId, tree_type: treeType, parent_id: parentId })
|
||||
}
|
||||
|
||||
export async function uploadFile(userId, sourcePath, targetPath, treeType) {
|
||||
return invoke('upload_file', { userId, sourcePath, targetPath, treeType })
|
||||
return invoke('upload_file', { user_id: userId, source_path: sourcePath, target_path: targetPath, tree_type: treeType })
|
||||
}
|
||||
|
||||
export async function searchFiles(userId, treeType, query) {
|
||||
return invoke('search_files', { userId, treeType, query })
|
||||
return invoke('search_files', { user_id: userId, tree_type: treeType, query })
|
||||
}
|
||||
|
||||
export async function downloadFile(userId, fileUuid) {
|
||||
return invoke('download_file', { userId, fileUuid })
|
||||
return invoke('download_file', { user_id: userId, file_uuid: fileUuid })
|
||||
}
|
||||
|
||||
export async function openFile(filePath) {
|
||||
return invoke('open_file', { filePath })
|
||||
return invoke('open_file', { file_path: filePath })
|
||||
}
|
||||
|
||||
export async function checkSystemEnvironment() {
|
||||
@@ -29,7 +29,7 @@ export async function checkSystemEnvironment() {
|
||||
}
|
||||
|
||||
export async function initializeDatabase(installPath, dbPath) {
|
||||
return invoke('initialize_database', { installPath, dbPath })
|
||||
return invoke('initialize_database', { install_path: installPath, db_path: dbPath })
|
||||
}
|
||||
|
||||
export async function createServiceAccount() {
|
||||
@@ -85,7 +85,7 @@ export async function createBackup() {
|
||||
}
|
||||
|
||||
export async function restoreBackup(backupPath) {
|
||||
return invoke('restore_backup', { backupPath })
|
||||
return invoke('restore_backup', { backup_path: backupPath })
|
||||
}
|
||||
|
||||
export async function listBackups() {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import WebClient from '../views/WebClient.vue'
|
||||
import WebAdmin from '../views/WebAdmin.vue'
|
||||
import VirtualFolders from '../views/VirtualFolders.vue'
|
||||
import Quota from '../views/Quota.vue'
|
||||
import ACL from '../views/ACL.vue'
|
||||
import FilePreview from '../views/FilePreview.vue'
|
||||
import Home from '../views/Home.vue'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import Install from '../views/Install.vue'
|
||||
@@ -17,6 +23,36 @@ const routes = [
|
||||
name: 'Home',
|
||||
component: Home
|
||||
},
|
||||
{
|
||||
path: '/webclient',
|
||||
name: 'WebClient',
|
||||
component: WebClient
|
||||
},
|
||||
{
|
||||
path: '/webadmin',
|
||||
name: 'WebAdmin',
|
||||
component: WebAdmin
|
||||
},
|
||||
{
|
||||
path: '/virtualfolders',
|
||||
name: 'VirtualFolders',
|
||||
component: VirtualFolders
|
||||
},
|
||||
{
|
||||
path: '/quota',
|
||||
name: 'Quota',
|
||||
component: Quota
|
||||
},
|
||||
{
|
||||
path: '/acl',
|
||||
name: 'ACL',
|
||||
component: ACL
|
||||
},
|
||||
{
|
||||
path: '/filepreview',
|
||||
name: 'FilePreview',
|
||||
component: FilePreview
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/tauri'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Check if running in Tauri environment
|
||||
const isTauri = window.__TAURI_INTERNALS__ !== undefined
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const currentTreeType = ref('demo_library_zh')
|
||||
@@ -12,6 +15,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadConfig() {
|
||||
if (!isTauri) return
|
||||
try {
|
||||
config.value = await invoke('load_config')
|
||||
} catch (error) {
|
||||
@@ -20,6 +24,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
|
||||
async function loadServiceStatus() {
|
||||
if (!isTauri) return
|
||||
try {
|
||||
services.value = await invoke('get_service_status')
|
||||
} catch (error) {
|
||||
@@ -28,6 +33,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
|
||||
async function loadHealthData() {
|
||||
if (!isTauri) return
|
||||
try {
|
||||
healthData.value = await invoke('run_health_check')
|
||||
} catch (error) {
|
||||
@@ -36,6 +42,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
|
||||
async function loadMonitorData() {
|
||||
if (!isTauri) return
|
||||
try {
|
||||
monitorData.value = await invoke('get_monitor_data')
|
||||
} catch (error) {
|
||||
@@ -44,6 +51,10 @@ export const useAppStore = defineStore('app', () => {
|
||||
}
|
||||
|
||||
async function initializeApp() {
|
||||
if (!isTauri) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
|
||||
325
markbase-tauri/src/src/views/ACL.vue
Normal file
325
markbase-tauri/src/src/views/ACL.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Lock, Check, Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const userId = ref('demo')
|
||||
const currentPath = ref('/')
|
||||
const acl = ref({
|
||||
path: '',
|
||||
aces: []
|
||||
})
|
||||
const loading = ref(false)
|
||||
const checkPrincipal = ref('')
|
||||
const checkMask = ref('ReadData')
|
||||
const checkResult = ref(null)
|
||||
const showAceDialog = ref(false)
|
||||
const editingAceIndex = ref(-1)
|
||||
const currentAce = ref({
|
||||
ace_type: 'Allow',
|
||||
principal: '',
|
||||
flags: [],
|
||||
mask: []
|
||||
})
|
||||
|
||||
const aceTypeOptions = ['Allow', 'Deny', 'Audit', 'Alarm']
|
||||
const aceFlagOptions = ['FileInherit', 'DirectoryInherit', 'NoPropagateInherit', 'InheritOnly', 'Inherited']
|
||||
const aceMaskOptions = ['ReadData', 'WriteData', 'Execute', 'ListDirectory', 'AddFile', 'AddSubdirectory', 'DeleteChild', 'Delete', 'ReadAttributes', 'WriteAttributes', 'ReadAcl', 'WriteAcl', 'ReadOwner', 'WriteOwner', 'Synchronize', 'FullControl']
|
||||
|
||||
const loadAcl = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await invoke('get_acl', {
|
||||
user_id: userId.value,
|
||||
path: currentPath.value
|
||||
})
|
||||
acl.value = result
|
||||
ElMessage.success('ACL loaded successfully')
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to load ACL: ${error}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkAclPermission = async () => {
|
||||
try {
|
||||
const result = await invoke('check_acl', {
|
||||
user_id: userId.value,
|
||||
path: currentPath.value,
|
||||
principal: checkPrincipal.value,
|
||||
mask: checkMask.value
|
||||
})
|
||||
checkResult.value = result
|
||||
if (result) {
|
||||
ElMessage.success(`${checkPrincipal.value} has ${checkMask.value} permission`)
|
||||
} else {
|
||||
ElMessage.warning(`${checkPrincipal.value} does NOT have ${checkMask.value} permission`)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to check ACL: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const openAddAceDialog = () => {
|
||||
editingAceIndex.value = -1
|
||||
currentAce.value = {
|
||||
ace_type: 'Allow',
|
||||
principal: '',
|
||||
flags: [],
|
||||
mask: []
|
||||
}
|
||||
showAceDialog.value = true
|
||||
}
|
||||
|
||||
const openEditAceDialog = (index) => {
|
||||
editingAceIndex.value = index
|
||||
currentAce.value = {
|
||||
ace_type: acl.value.aces[index].ace_type,
|
||||
principal: acl.value.aces[index].principal,
|
||||
flags: acl.value.aces[index].flags,
|
||||
mask: acl.value.aces[index].mask
|
||||
}
|
||||
showAceDialog.value = true
|
||||
}
|
||||
|
||||
const deleteAce = async (index) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
'Are you sure you want to delete this ACE?',
|
||||
'Confirm Delete',
|
||||
{
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
const newAces = acl.value.aces.filter((_, i) => i !== index)
|
||||
await invoke('set_acl', {
|
||||
user_id: userId.value,
|
||||
path: currentPath.value,
|
||||
aces: newAces
|
||||
})
|
||||
ElMessage.success('ACE deleted successfully')
|
||||
await loadAcl()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(`Failed to delete ACE: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const saveAce = async () => {
|
||||
try {
|
||||
let newAces
|
||||
if (editingAceIndex.value === -1) {
|
||||
newAces = [...acl.value.aces, currentAce.value]
|
||||
} else {
|
||||
newAces = acl.value.aces.map((ace, i) =>
|
||||
i === editingAceIndex.value ? currentAce.value : ace
|
||||
)
|
||||
}
|
||||
|
||||
await invoke('set_acl', {
|
||||
user_id: userId.value,
|
||||
path: currentPath.value,
|
||||
aces: newAces
|
||||
})
|
||||
ElMessage.success('ACE saved successfully')
|
||||
showAceDialog.value = false
|
||||
await loadAcl()
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to save ACE: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const aceTypeColor = (type) => {
|
||||
switch (type) {
|
||||
case 'Allow': return 'success'
|
||||
case 'Deny': return 'danger'
|
||||
case 'Audit': return 'warning'
|
||||
case 'Alarm': return 'info'
|
||||
default: return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAcl()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="acl-container">
|
||||
<div class="acl-header">
|
||||
<h2>ACL Management</h2>
|
||||
<p class="header-subtitle">NFSv4/SMB 权限控制列表</p>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" class="acl-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Path: {{ acl.path }}</span>
|
||||
<el-button @click="loadAcl" :icon="Lock" size="small">Refresh</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="acl.aces" stripe style="width: 100%">
|
||||
<el-table-column prop="principal" label="Principal" min-width="150" />
|
||||
<el-table-column prop="ace_type" label="Type" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="aceTypeColor(row.ace_type)" size="small">
|
||||
{{ row.ace_type }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="flags" label="Flags" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="flag in row.flags" :key="flag" size="small" style="margin: 2px">
|
||||
{{ flag }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mask" label="Permissions" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="perm in row.mask" :key="perm" size="small" style="margin: 2px">
|
||||
{{ perm }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" width="120" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button-group>
|
||||
<el-button
|
||||
@click="openEditAceDialog($index)"
|
||||
:icon="Edit"
|
||||
size="small"
|
||||
circle
|
||||
/>
|
||||
<el-button
|
||||
@click="deleteAce($index)"
|
||||
:icon="Delete"
|
||||
size="small"
|
||||
circle
|
||||
type="danger"
|
||||
/>
|
||||
</el-button-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div style="margin-top: 15px">
|
||||
<el-button @click="openAddAceDialog" :icon="Plus" type="primary">Add ACE</el-button>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showAceDialog" :title="editingAceIndex === -1 ? 'Add ACE' : 'Edit ACE'" width="600px">
|
||||
<el-form :model="currentAce" label-width="120px">
|
||||
<el-form-item label="Principal">
|
||||
<el-input
|
||||
v-model="currentAce.principal"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Type">
|
||||
<el-select v-model="currentAce.ace_type" style="width: 100%">
|
||||
<el-option v-for="type in aceTypeOptions" :key="type" :label="type" :value="type" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Flags">
|
||||
<el-select v-model="currentAce.flags" multiple style="width: 100%">
|
||||
<el-option v-for="flag in aceFlagOptions" :key="flag" :label="flag" :value="flag" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Permissions">
|
||||
<el-select v-model="currentAce.mask" multiple style="width: 100%">
|
||||
<el-option v-for="perm in aceMaskOptions" :key="perm" :label="perm" :value="perm" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAceDialog = false">Cancel</el-button>
|
||||
<el-button @click="saveAce" type="primary">Save</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="check-section">
|
||||
<h3>Permission Check</h3>
|
||||
<el-form inline>
|
||||
<el-form-item label="Principal">
|
||||
<el-input
|
||||
v-model="checkPrincipal"
|
||||
placeholder="user@example.com"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Permission">
|
||||
<el-select v-model="checkMask" style="width: 150px">
|
||||
<el-option label="ReadData" value="ReadData" />
|
||||
<el-option label="WriteData" value="WriteData" />
|
||||
<el-option label="Execute" value="Execute" />
|
||||
<el-option label="ReadAcl" value="ReadAcl" />
|
||||
<el-option label="WriteAcl" value="WriteAcl" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="checkAclPermission" :icon="Check" type="primary">Check</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert
|
||||
v-if="checkResult !== null"
|
||||
:type="checkResult ? 'success' : 'error'"
|
||||
:title="checkResult ? 'Permission Granted' : 'Permission Denied'"
|
||||
show-icon
|
||||
style="margin-top: 10px"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.acl-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.acl-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.acl-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
margin: 5px 0 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.acl-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.check-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.check-section h3 {
|
||||
margin: 0 0 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
347
markbase-tauri/src/src/views/FilePreview.vue
Normal file
347
markbase-tauri/src/src/views/FilePreview.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
file: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
userId: {
|
||||
type: String,
|
||||
default: 'demo'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const loading = ref(false)
|
||||
const fileUrl = ref('')
|
||||
const fileContent = ref('')
|
||||
const fileMetadata = ref(null)
|
||||
|
||||
const fileType = computed(() => {
|
||||
if (!props.file) return 'unknown'
|
||||
if (props.file.node_type === 'folder') return 'folder'
|
||||
|
||||
const ext = props.file.name.split('.').pop()?.toLowerCase()
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext)) return 'image'
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'].includes(ext)) return 'video'
|
||||
if (['mp3', 'wav', 'ogg', 'flac', 'aac'].includes(ext)) return 'audio'
|
||||
if (['pdf'].includes(ext)) return 'pdf'
|
||||
if (['txt', 'md', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'log', 'csv'].includes(ext)) return 'text'
|
||||
if (['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext)) return 'office'
|
||||
return 'file'
|
||||
})
|
||||
|
||||
const canPreview = computed(() => {
|
||||
return ['image', 'video', 'audio', 'pdf', 'text'].includes(fileType.value)
|
||||
})
|
||||
|
||||
const loadFileContent = async () => {
|
||||
if (!props.file || !canPreview.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const filePath = await invoke('download_file', {
|
||||
userId: props.userId,
|
||||
fileUuid: props.file.id
|
||||
})
|
||||
|
||||
fileUrl.value = filePath
|
||||
|
||||
if (fileType.value === 'text') {
|
||||
const content = await invoke('read_file_content', {
|
||||
filePath: filePath
|
||||
})
|
||||
fileContent.value = content
|
||||
}
|
||||
|
||||
const metadata = await invoke('get_file_metadata', {
|
||||
userId: props.userId,
|
||||
fileUuid: props.file.id
|
||||
})
|
||||
fileMetadata.value = metadata
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to load file: ${error}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadFile = async () => {
|
||||
if (!props.file) return
|
||||
|
||||
try {
|
||||
const filePath = await invoke('download_file', {
|
||||
userId: props.userId,
|
||||
fileUuid: props.file.id
|
||||
})
|
||||
await invoke('open_file', { filePath })
|
||||
ElMessage.success('File opened successfully')
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to open file: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
emit('update:visible', false)
|
||||
fileUrl.value = ''
|
||||
fileContent.value = ''
|
||||
fileMetadata.value = null
|
||||
}
|
||||
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal && props.file) {
|
||||
loadFileContent()
|
||||
} else {
|
||||
closePreview()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
:title="file?.name || 'File Preview'"
|
||||
fullscreen
|
||||
@close="closePreview"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<div class="preview-container" v-loading="loading">
|
||||
<!-- ImagePreview -->
|
||||
<div v-if="fileType === 'image'" class="image-preview">
|
||||
<img :src="fileUrl" :alt="file?.name" class="preview-image" />
|
||||
</div>
|
||||
|
||||
<!-- VideoPreview -->
|
||||
<div v-else-if="fileType === 'video'" class="video-preview">
|
||||
<video :src="fileUrl" controls class="preview-video">
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<!-- AudioPreview -->
|
||||
<div v-else-if="fileType === 'audio'" class="audio-preview">
|
||||
<audio :src="fileUrl" controls class="preview-audio">
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
<div class="audio-info">
|
||||
<h3>{{ file?.name }}</h3>
|
||||
<p v-if="fileMetadata">
|
||||
<strong>Duration:</strong> {{ fileMetadata.duration || 'Unknown' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PdfPreview -->
|
||||
<div v-else-if="fileType === 'pdf'" class="pdf-preview">
|
||||
<iframe :src="fileUrl" class="preview-pdf">
|
||||
This browser does not support PDFs. Please download the PDF to view it.
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
<!-- TextPreview -->
|
||||
<div v-else-if="fileType === 'text'" class="text-preview">
|
||||
<pre class="preview-text">{{ fileContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- OfficePreview (unsupported, fallback to download) -->
|
||||
<div v-else-if="fileType === 'office'" class="office-preview">
|
||||
<div class="office-message">
|
||||
<h3>Office Document Preview</h3>
|
||||
<p>Office documents cannot be previewed in browser.</p>
|
||||
<el-button type="primary" @click="downloadFile">Download & Open</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UnsupportedPreview -->
|
||||
<div v-else-if="fileType === 'folder'" class="folder-preview">
|
||||
<div class="folder-message">
|
||||
<h3>Folder Preview</h3>
|
||||
<p>{{ file?.name }} is a folder.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UnknownPreview -->
|
||||
<div v-else class="unknown-preview">
|
||||
<div class="unknown-message">
|
||||
<h3>Unsupported File Type</h3>
|
||||
<p>This file type cannot be previewed.</p>
|
||||
<el-button type="primary" @click="downloadFile">Download & Open</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Panel -->
|
||||
<div v-if="fileMetadata" class="metadata-panel">
|
||||
<h3>File Metadata</h3>
|
||||
<div class="metadata-item">
|
||||
<strong>Name:</strong> {{ file?.name }}
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<strong>Type:</strong> {{ fileType }}
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<strong>Size:</strong> {{ fileMetadata.size }}
|
||||
</div>
|
||||
<div class="metadata-item">
|
||||
<strong>Modified:</strong> {{ fileMetadata.modified }}
|
||||
</div>
|
||||
<div v-if="fileMetadata.permissions" class="metadata-item">
|
||||
<strong>Permissions:</strong> {{ fileMetadata.permissions }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<template #footer>
|
||||
<el-button @click="closePreview">Close</el-button>
|
||||
<el-button type="primary" @click="downloadFile">Download</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preview-container {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.audio-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.preview-audio {
|
||||
width: 80%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.audio-info {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.audio-info h3 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pdf-preview {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-pdf {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.text-preview {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
background-color: #f5f7fa;
|
||||
padding: 20px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.office-preview,
|
||||
.folder-preview,
|
||||
.unknown-preview {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.office-message,
|
||||
.folder-message,
|
||||
.unknown-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.office-message h3,
|
||||
.folder-message h3,
|
||||
.unknown-message h3 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.office-message p,
|
||||
.folder-message p,
|
||||
.unknown-message p {
|
||||
margin-bottom: 24px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.metadata-panel {
|
||||
width: 300px;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.metadata-panel h3 {
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.metadata-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.metadata-item strong {
|
||||
color: #606266;
|
||||
}
|
||||
</style>
|
||||
@@ -2,10 +2,10 @@
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '../stores/app'
|
||||
import { invoke } from '@tauri-apps/api/tauri'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Folder, Document, Upload, Clock, UserFilled, FolderOpened, Monitor } from '@element-plus/icons-vue'
|
||||
import { open } from '@tauri-apps/api/dialog'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
226
markbase-tauri/src/src/views/Quota.vue
Normal file
226
markbase-tauri/src/src/views/Quota.vue
Normal file
@@ -0,0 +1,226 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataAnalysis, Setting } from '@element-plus/icons-vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const userId = ref('demo')
|
||||
const currentPath = ref('/')
|
||||
const quota = ref({
|
||||
space_limit: 0,
|
||||
file_limit: 0,
|
||||
soft_limit: 0,
|
||||
grace_period: 0,
|
||||
user_id: null
|
||||
})
|
||||
const usage = ref({
|
||||
space_used: 0,
|
||||
files_used: 0
|
||||
})
|
||||
const loading = ref(false)
|
||||
|
||||
const loadQuota = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const quotaResult = await invoke('get_quota', {
|
||||
user_id: userId.value,
|
||||
path: currentPath.value
|
||||
})
|
||||
quota.value = quotaResult
|
||||
|
||||
const usageResult = await invoke('get_quota_usage', {
|
||||
user_id: userId.value,
|
||||
path: currentPath.value
|
||||
})
|
||||
usage.value = usageResult
|
||||
|
||||
ElMessage.success('Quota loaded successfully')
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to load quota: ${error}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setQuota = async () => {
|
||||
try {
|
||||
await invoke('set_quota', {
|
||||
user_id: userId.value,
|
||||
path: currentPath.value,
|
||||
space_limit: quota.value.space_limit,
|
||||
file_limit: quota.value.file_limit,
|
||||
soft_limit: quota.value.soft_limit,
|
||||
grace_period: quota.value.grace_period
|
||||
})
|
||||
ElMessage.success('Quota updated successfully')
|
||||
await loadQuota()
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to update quota: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (bytes === 0) return 'Unlimited'
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB'
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / 1024 / 1024).toFixed(2) + ' MB'
|
||||
return (bytes / 1024 / 1024 / 1024).toFixed(2) + ' GB'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadQuota()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quota-container">
|
||||
<div class="quota-header">
|
||||
<h2>Quota Management</h2>
|
||||
<p class="header-subtitle">Storage quota configuration and monitoring</p>
|
||||
</div>
|
||||
|
||||
<el-card v-loading="loading" class="quota-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>Current Path: {{ currentPath }}</span>
|
||||
<el-button @click="loadQuota" :icon="DataAnalysis" size="small">Refresh</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="12">
|
||||
<div class="quota-stat">
|
||||
<el-icon :size="30"><DataAnalysis /></el-icon>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">Space Limit</div>
|
||||
<div class="stat-value">{{ formatSize(quota.space_limit) }}</div>
|
||||
<div class="stat-usage">Used: {{ formatSize(usage.space_used) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<div class="quota-stat">
|
||||
<el-icon :size="30"><Setting /></el-icon>
|
||||
<div class="stat-content">
|
||||
<div class="stat-label">File Limit</div>
|
||||
<div class="stat-value">{{ quota.file_limit === 0 ? 'Unlimited' : quota.file_limit }}</div>
|
||||
<div class="stat-usage">Used: {{ usage.files_used }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form :model="quota" label-width="120px">
|
||||
<el-form-item label="Space Limit">
|
||||
<el-input-number
|
||||
v-model="quota.space_limit"
|
||||
:min="0"
|
||||
:step="1024 * 1024"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="unit-label">Bytes (0 = Unlimited)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="File Limit">
|
||||
<el-input-number
|
||||
v-model="quota.file_limit"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="unit-label">Files (0 = Unlimited)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Soft Limit">
|
||||
<el-input-number
|
||||
v-model="quota.soft_limit"
|
||||
:min="0"
|
||||
:max="100"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="unit-label">Warning threshold (0-100%)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="Grace Period">
|
||||
<el-input-number
|
||||
v-model="quota.grace_period"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
/>
|
||||
<span class="unit-label">Seconds</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="setQuota" type="primary">Update Quota</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quota-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.quota-header {
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.quota-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
margin: 5px 0 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.quota-card {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quota-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: #f0f2f5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-usage {
|
||||
font-size: 12px;
|
||||
color: #67c23a;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.unit-label {
|
||||
margin-left: 10px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
Edit,
|
||||
Delete,
|
||||
Connection,
|
||||
Network,
|
||||
Document,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
|
||||
211
markbase-tauri/src/src/views/VirtualFolders.vue
Normal file
211
markbase-tauri/src/src/views/VirtualFolders.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { FolderOpened, Plus, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
const userId = ref('demo')
|
||||
const folders = ref([])
|
||||
const loading = ref(false)
|
||||
const showCreateDialog = ref(false)
|
||||
const showEditDialog = ref(false)
|
||||
const currentFolder = ref({
|
||||
folder: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const loadFolders = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await invoke('list_virtual_folders', { user_id: userId.value })
|
||||
folders.value = result
|
||||
ElMessage.success('Virtual folders loaded successfully')
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to load virtual folders: ${error}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createFolder = async () => {
|
||||
try {
|
||||
await invoke('create_virtual_folder', {
|
||||
user_id: userId.value,
|
||||
folder: currentFolder.value.folder,
|
||||
description: currentFolder.value.description
|
||||
})
|
||||
ElMessage.success('Virtual folder created successfully')
|
||||
showCreateDialog.value = false
|
||||
currentFolder.value = { folder: '', description: '' }
|
||||
await loadFolders()
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to create virtual folder: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const editFolder = async () => {
|
||||
try {
|
||||
await invoke('update_virtual_folder', {
|
||||
user_id: userId.value,
|
||||
folder: currentFolder.value.folder,
|
||||
description: currentFolder.value.description
|
||||
})
|
||||
ElMessage.success('Virtual folder updated successfully')
|
||||
showEditDialog.value = false
|
||||
await loadFolders()
|
||||
} catch (error) {
|
||||
ElMessage.error(`Failed to update virtual folder: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFolder = async (folder) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Are you sure you want to delete virtual folder "${folder}"?`,
|
||||
'Confirm Delete',
|
||||
{
|
||||
confirmButtonText: 'Delete',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
await invoke('delete_virtual_folder', {
|
||||
user_id: userId.value,
|
||||
folder: folder
|
||||
})
|
||||
ElMessage.success('Virtual folder deleted successfully')
|
||||
await loadFolders()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error(`Failed to delete virtual folder: ${error}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openEditDialog = (folder) => {
|
||||
currentFolder.value = {
|
||||
folder: folder.folder,
|
||||
description: folder.description
|
||||
}
|
||||
showEditDialog.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFolders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="virtual-folders-container">
|
||||
<div class="folders-header">
|
||||
<h2>Virtual Folders</h2>
|
||||
<p class="header-subtitle">跨 backend 路径映射管理</p>
|
||||
<el-button @click="showCreateDialog = true" :icon="Plus" type="primary">Create Folder</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="folders" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column prop="folder" label="Folder Path" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-icon style="margin-right: 5px"><FolderOpened /></el-icon>
|
||||
<span>{{ row.folder }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="Description" min-width="300" />
|
||||
<el-table-column prop="created_at" label="Created At" width="180" />
|
||||
<el-table-column label="Actions" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button-group>
|
||||
<el-button
|
||||
@click="openEditDialog(row)"
|
||||
:icon="Edit"
|
||||
size="small"
|
||||
circle
|
||||
/>
|
||||
<el-button
|
||||
@click="deleteFolder(row.folder)"
|
||||
:icon="Delete"
|
||||
size="small"
|
||||
circle
|
||||
type="danger"
|
||||
/>
|
||||
</el-button-group>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="showCreateDialog" title="Create Virtual Folder" width="500px">
|
||||
<el-form :model="currentFolder" label-width="120px">
|
||||
<el-form-item label="Folder Path">
|
||||
<el-input
|
||||
v-model="currentFolder.folder"
|
||||
placeholder="/path/to/virtual/folder"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Description">
|
||||
<el-input
|
||||
v-model="currentFolder.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="Virtual folder description"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDialog = false">Cancel</el-button>
|
||||
<el-button @click="createFolder" type="primary">Create</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="showEditDialog" title="Edit Virtual Folder" width="500px">
|
||||
<el-form :model="currentFolder" label-width="120px">
|
||||
<el-form-item label="Folder Path">
|
||||
<el-input
|
||||
v-model="currentFolder.folder"
|
||||
disabled
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Description">
|
||||
<el-input
|
||||
v-model="currentFolder.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="Virtual folder description"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEditDialog = false">Cancel</el-button>
|
||||
<el-button @click="editFolder" type="primary">Update</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.virtual-folders-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.folders-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.folders-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
130
markbase-tauri/src/src/views/WebAdmin.vue
Normal file
130
markbase-tauri/src/src/views/WebAdmin.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
DataAnalysis, UserFilled, Share, Setting, Monitor,
|
||||
Operation, Document
|
||||
} from '@element-plus/icons-vue'
|
||||
import DashboardView from './Dashboard.vue'
|
||||
import UsersView from './Users.vue'
|
||||
import SharesView from './Shares.vue'
|
||||
|
||||
const activeTab = ref('dashboard')
|
||||
|
||||
const tabs = [
|
||||
{ name: 'dashboard', label: 'Dashboard', icon: DataAnalysis, description: '系统状态监控' },
|
||||
{ name: 'users', label: 'Users', icon: UserFilled, description: '用户管理' },
|
||||
{ name: 'shares', label: 'Shares', icon: Share, description: '共享管理' },
|
||||
{ name: 'monitor', label: 'Monitor', icon: Monitor, description: '服务监控' }
|
||||
]
|
||||
|
||||
const currentTab = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'dashboard': return DashboardView
|
||||
case 'users': return UsersView
|
||||
case 'shares': return SharesView
|
||||
case 'monitor': return null
|
||||
default: return DashboardView
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="webadmin-container">
|
||||
<div class="webadmin-header">
|
||||
<div class="header-title">
|
||||
<h2>WebAdmin</h2>
|
||||
<p class="header-subtitle">MarkBase 管理中心</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button-group>
|
||||
<el-button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.name"
|
||||
@click="activeTab = tab.name"
|
||||
:type="activeTab === tab.name ? 'primary' : ''"
|
||||
:icon="tab.icon"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="webadmin-content">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="currentTab" v-if="currentTab" />
|
||||
<div v-else class="monitor-placeholder">
|
||||
<el-icon :size="50"><Monitor /></el-icon>
|
||||
<p>Monitor 功能开发中...</p>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.webadmin-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.webadmin-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header-title h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.header-subtitle {
|
||||
margin: 5px 0 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.webadmin-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.monitor-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.monitor-placeholder p {
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
1273
markbase-tauri/src/src/views/WebClient.vue
Normal file
1273
markbase-tauri/src/src/views/WebClient.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user