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

@@ -294,3 +294,101 @@ fn build_tree(
Err("Root node not found".to_string()) 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 user_management;
pub mod share_management; pub mod share_management;
pub mod system_stats; pub mod system_stats;
pub mod virtual_folders;
pub mod quota;
pub mod acl;
pub use file_ops::*; pub use file_ops::*;
pub use install::*; pub use install::*;
@@ -21,3 +24,6 @@ pub use backup::*;
pub use user_management::*; pub use user_management::*;
pub use share_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, search_files,
download_file, download_file,
open_file, open_file,
read_file_content,
get_file_metadata,
check_system_environment, check_system_environment,
initialize_database, initialize_database,
create_service_account, create_service_account,
@@ -55,6 +57,17 @@ fn main() {
get_system_stats, get_system_stats,
get_all_services_status, get_all_services_status,
get_recent_activity, 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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

View File

@@ -1,6 +1,10 @@
<script setup> <script setup>
import { onMounted } from 'vue' import { onMounted } from 'vue'
import { useAppStore } from './stores/app' import { useAppStore } from './stores/app'
import {
HomeFilled, Setting, Tools, Monitor, Operation,
CircleCheck, DataAnalysis, FolderOpened, Loading
} from '@element-plus/icons-vue'
const appStore = useAppStore() const appStore = useAppStore()
@@ -53,6 +57,26 @@ onMounted(async () => {
<el-icon><DataAnalysis /></el-icon> <el-icon><DataAnalysis /></el-icon>
<span>Monitor</span> <span>Monitor</span>
</el-menu-item> </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-menu>
</el-aside> </el-aside>
<el-main class="app-main"> <el-main class="app-main">

View File

@@ -1,27 +1,27 @@
import { invoke } from '@tauri-apps/api' import { invoke } from '@tauri-apps/api/core'
export async function getTree(userId, treeType) { 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) { 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) { 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) { 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) { 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) { export async function openFile(filePath) {
return invoke('open_file', { filePath }) return invoke('open_file', { file_path: filePath })
} }
export async function checkSystemEnvironment() { export async function checkSystemEnvironment() {
@@ -29,7 +29,7 @@ export async function checkSystemEnvironment() {
} }
export async function initializeDatabase(installPath, dbPath) { 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() { export async function createServiceAccount() {
@@ -85,7 +85,7 @@ export async function createBackup() {
} }
export async function restoreBackup(backupPath) { export async function restoreBackup(backupPath) {
return invoke('restore_backup', { backupPath }) return invoke('restore_backup', { backup_path: backupPath })
} }
export async function listBackups() { export async function listBackups() {

View File

@@ -1,4 +1,10 @@
import { createRouter, createWebHistory } from 'vue-router' 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 Home from '../views/Home.vue'
import Dashboard from '../views/Dashboard.vue' import Dashboard from '../views/Dashboard.vue'
import Install from '../views/Install.vue' import Install from '../views/Install.vue'
@@ -17,6 +23,36 @@ const routes = [
name: 'Home', name: 'Home',
component: 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', path: '/dashboard',
name: 'Dashboard', name: 'Dashboard',

View File

@@ -1,6 +1,9 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' 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', () => { export const useAppStore = defineStore('app', () => {
const currentTreeType = ref('demo_library_zh') const currentTreeType = ref('demo_library_zh')
@@ -12,6 +15,7 @@ export const useAppStore = defineStore('app', () => {
const loading = ref(false) const loading = ref(false)
async function loadConfig() { async function loadConfig() {
if (!isTauri) return
try { try {
config.value = await invoke('load_config') config.value = await invoke('load_config')
} catch (error) { } catch (error) {
@@ -20,6 +24,7 @@ export const useAppStore = defineStore('app', () => {
} }
async function loadServiceStatus() { async function loadServiceStatus() {
if (!isTauri) return
try { try {
services.value = await invoke('get_service_status') services.value = await invoke('get_service_status')
} catch (error) { } catch (error) {
@@ -28,6 +33,7 @@ export const useAppStore = defineStore('app', () => {
} }
async function loadHealthData() { async function loadHealthData() {
if (!isTauri) return
try { try {
healthData.value = await invoke('run_health_check') healthData.value = await invoke('run_health_check')
} catch (error) { } catch (error) {
@@ -36,6 +42,7 @@ export const useAppStore = defineStore('app', () => {
} }
async function loadMonitorData() { async function loadMonitorData() {
if (!isTauri) return
try { try {
monitorData.value = await invoke('get_monitor_data') monitorData.value = await invoke('get_monitor_data')
} catch (error) { } catch (error) {
@@ -44,6 +51,10 @@ export const useAppStore = defineStore('app', () => {
} }
async function initializeApp() { async function initializeApp() {
if (!isTauri) {
loading.value = false
return
}
loading.value = true loading.value = true
try { try {
await Promise.all([ await Promise.all([

View 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>

View 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>

View File

@@ -2,10 +2,10 @@
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAppStore } from '../stores/app' 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 { ElMessage } from 'element-plus'
import { Folder, Document, Upload, Clock, UserFilled, FolderOpened, Monitor } from '@element-plus/icons-vue' 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 router = useRouter()
const appStore = useAppStore() const appStore = useAppStore()

View 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>

View File

@@ -8,7 +8,6 @@ import {
Edit, Edit,
Delete, Delete,
Connection, Connection,
Network,
Document, Document,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'

View 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>

View 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>

File diff suppressed because it is too large Load Diff