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