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

@@ -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">

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) {
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() {

View File

@@ -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',

View File

@@ -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([

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 { 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()

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,
Delete,
Connection,
Network,
Document,
} 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