feat: 重构前端项目结构并优化代码

refactor: 迁移前端资源文件至web目录
feat: 新增多种图标资源
style: 统一代码风格和格式化配置
docs: 更新README和文档说明
chore: 更新依赖和配置文件
fix: 修复部分类型定义和枚举
perf: 优化路由和组件加载逻辑
This commit is contained in:
zhangtao
2026-05-01 01:01:17 +08:00
parent 1355a09441
commit 80263e98e1
887 changed files with 116349 additions and 33825 deletions
+658
View File
@@ -0,0 +1,658 @@
<template>
<div class="app-container">
<el-tabs type="border-card" class="cache-page-tabs">
<!-- 监控信息 Tab -->
<el-tab-pane class="monitor" label="监控信息">
<div class="monitor-tab">
<el-row :gutter="16">
<!-- 基本信息 -->
<el-col :span="24">
<el-card shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon>
<Monitor />
</el-icon>
<span>Redis监控信息</span>
<el-tooltip content="展示Redis监控信息详情">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-descriptions :column="12" border :direction="'vertical'">
<el-descriptions-item label="Redis版本">
{{ cache.info?.redis_version || '-' }}
</el-descriptions-item>
<el-descriptions-item label="运行模式">
{{ cache.info?.redis_mode === 'standalone' ? '单机' : '集群' }}
</el-descriptions-item>
<el-descriptions-item label="端口">
{{ cache.info?.tcp_port || '-' }}
</el-descriptions-item>
<el-descriptions-item label="客户端数">
{{ cache.info?.connected_clients || 0 }}
</el-descriptions-item>
<el-descriptions-item label="运行时间(天)">
{{ cache.info?.uptime_in_days || 0 }}
</el-descriptions-item>
<el-descriptions-item label="使用内存">
{{ cache.info?.used_memory_human || '-' }}
</el-descriptions-item>
<el-descriptions-item label="使用CPU">
{{
cache.info?.used_cpu_user_children
? parseFloat(cache.info.used_cpu_user_children).toFixed(2)
: '-'
}}
</el-descriptions-item>
<el-descriptions-item label="内存配置">
{{ cache.info?.maxmemory_human || '-' }}
</el-descriptions-item>
<el-descriptions-item label="AOF是否开启">
{{ cache.info?.aof_enabled === '0' ? '否' : '是' }}
</el-descriptions-item>
<el-descriptions-item label="RDB是否成功">
{{ cache.info?.rdb_last_bgsave_status || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Key数量">
{{ cache.db_size || 0 }}
</el-descriptions-item>
<el-descriptions-item label="网络入口/出口">
{{
`${cache.info?.instantaneous_input_kbps || 0}kps/${cache.info?.instantaneous_output_kbps || 0}kps`
}}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<!-- 监控图表:单独一行并占满剩余高度 -->
<el-row :gutter="16" class="monitor-charts-row">
<el-col :span="12" class="cache-chart-col">
<el-card shadow="hover" class="cache-chart-card">
<template #header>
<div class="flex items-center gap-2">
<el-icon>
<Stopwatch />
</el-icon>
<span class="title">命令统计</span>
<el-tooltip content="展示命令统计详情">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<div ref="commandstats" class="chart-container" />
</el-card>
</el-col>
<el-col :span="12" class="cache-chart-col">
<el-card shadow="hover" class="cache-chart-card">
<template #header>
<div class="flex items-center gap-2">
<el-icon>
<Stopwatch />
</el-icon>
<span>内存信息</span>
<el-tooltip content="展示内存信息详情">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<div ref="usedmemory" class="chart-container" />
</el-card>
</el-col>
</el-row>
</div>
</el-tab-pane>
<!-- 缓存管理 Tab -->
<el-tab-pane class="cache" label="缓存管理">
<div class="cache-mgmt-tab">
<el-row :gutter="16" class="cache-mgmt-row">
<!-- 缓存列表 -->
<el-col :span="8" class="cache-mgmt-col">
<el-card :loading="loading" shadow="hover" class="cache-mgmt-card">
<template #header>
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<el-icon>
<Tickets />
</el-icon>
<span class="flex items-center gap-2">缓存列表</span>
<el-tooltip content="展示缓存列表详情">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
<div>
<el-button
v-hasPerm="['module_monitor:cache:query']"
type="primary"
link
icon="RefreshRight"
@click="refreshCacheNames"
/>
</div>
</div>
</template>
<el-table
:loading="loading"
:data="cacheNames"
row-key="cache_name"
height="100%"
border
stripe
>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column prop="cache_name" label="缓存名称" show-overflow-tooltip>
<template #default="{ row }">
<el-button
v-hasPerm="['module_monitor:cache:query']"
type="primary"
link
@click="getCacheKeyList(row)"
>
{{ row.cache_name }}
</el-button>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
<el-table-column label="操作" width="60" align="center">
<template #default="{ row }">
<el-popconfirm
class="box-item"
:title="`确认删除缓存 ${row.cache_name} 吗?`"
placement="top"
@confirm="handleClearCacheName(row)"
>
<template #reference>
<el-button
v-hasPerm="['module_monitor:cache:delete']"
type="danger"
size="small"
link
icon="delete"
/>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<!-- 键名列表 -->
<el-col :span="8" class="cache-mgmt-col">
<el-card :loading="loading" shadow="hover" class="cache-mgmt-card">
<template #header>
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<el-icon>
<Key />
</el-icon>
<span class="flex items-center gap-2">键名列表</span>
<el-tooltip content="展示键名列表详情">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
<div>
<el-button
v-hasPerm="['module_monitor:cache:query']"
type="primary"
link
icon="RefreshRight"
@click="refreshCacheKeys"
/>
</div>
</div>
</template>
<el-table
:loading="subLoading"
:data="cacheKeys.map((key) => ({ cacheKey: key }))"
height="100%"
row-key="cacheKey"
border
>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column prop="cacheKey" label="缓存键名" show-overflow-tooltip>
<template #default="{ row }">
<el-button
v-hasPerm="['module_monitor:cache:detail']"
type="primary"
link
@click="handleCacheValue(row.cacheKey)"
>
{{ row.cacheKey }}
</el-button>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
<template #default="{ row }">
<el-popconfirm
class="box-item"
:title="`确认删除键 ${row.cacheKey} 吗?`"
placement="top"
@confirm="handleClearCacheKey(row.cacheKey)"
>
<template #reference>
<el-button
v-hasPerm="['module_monitor:cache:delete']"
type="danger"
size="small"
link
icon="delete"
/>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<!-- 缓存内容 -->
<el-col :span="8">
<el-card :loading="loading" shadow="hover">
<template #header>
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<el-icon>
<Key />
</el-icon>
<span class="flex items-center gap-2">缓存内容</span>
<el-tooltip content="展示缓存内容详情">
<el-icon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
<div>
<el-button
v-hasPerm="['module_monitor:cache:delete']"
type="danger"
link
icon="delete"
@click="handleClearCacheAll"
>
清理全部
</el-button>
</div>
</div>
</template>
<el-form
:model="cacheForm"
label-suffix=":"
label-width="auto"
label-position="top"
>
<el-form-item label="缓存名称">
<el-input v-model="cacheForm.cache_name" readonly placeholder="缓存名称" />
</el-form-item>
<el-form-item label="缓存键名">
<el-input v-model="cacheForm.cache_key" readonly placeholder="缓存键名" />
</el-form-item>
<el-form-item label="缓存内容">
<el-input
v-model="cacheForm.cache_value"
type="textarea"
:rows="20"
readonly
placeholder="缓存内容"
/>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
</div>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script lang="ts" setup>
import CacheAPI, {
type CacheInfo,
type CacheForm,
type CacheMonitor,
type RedisInfo,
} from '@/api/module_monitor/cache';
import * as echarts from 'echarts';
// 响应式状态定义
const cacheNames = ref<CacheInfo[]>([]);
const cacheKeys = ref<string[]>([]);
const loading = ref(true);
const subLoading = ref(false);
const nowCacheName = ref('');
const commandstats = ref<HTMLElement | null>(null);
const usedmemory = ref<HTMLElement | null>(null);
const cache = ref<CacheMonitor>({
info: {} as RedisInfo,
command_stats: [],
db_size: 0,
});
const cacheForm = ref<CacheForm>({
cache_name: '',
cache_key: '',
cache_value: '',
});
let commandstatsInstance: echarts.ECharts | null = null;
let usedmemoryInstance: echarts.ECharts | null = null;
const resetCacheForm = () => {
cacheKeys.value = [];
cacheForm.value = {
cache_name: '',
cache_key: '',
cache_value: '',
};
};
// 缓存名称相关方法
const getCacheNameList = async () => {
try {
loading.value = true;
const response = await CacheAPI.getCacheNames();
cacheNames.value = response.data.data;
resetCacheForm();
} catch (error) {
console.error('获取缓存列表出错:', error);
} finally {
loading.value = false;
}
};
// 刷新缓存列表
const refreshCacheNames = () => {
getCacheNameList();
};
// 清理缓存名称
const handleClearCacheName = async (row: CacheInfo) => {
try {
await CacheAPI.deleteCacheName(row.cache_name);
refreshCacheNames();
} catch (error) {
console.error('清理缓存名称出错:', error);
}
};
// 缓存键名相关方法
const getCacheKeyList = async (row?: CacheInfo) => {
try {
const cacheName = row?.cache_name || nowCacheName.value;
if (!cacheName) return;
subLoading.value = true;
const response = await CacheAPI.getCacheKeys(cacheName);
cacheKeys.value = response.data.data;
nowCacheName.value = cacheName;
cacheForm.value = {
cache_name: cacheName,
cache_key: '',
cache_value: '',
};
} catch (error) {
console.error('获取缓存键名列表出错:', error);
} finally {
subLoading.value = false;
}
};
// 刷新键名列表
const refreshCacheKeys = () => {
getCacheKeyList();
};
// 清理缓存键名
async function handleClearCacheKey(cacheKey: string) {
try {
await CacheAPI.deleteCacheKey(cacheKey);
getCacheKeyList();
} catch (error) {
console.error('清理缓存键名出错:', error);
}
}
// 缓存内容相关方法
async function handleCacheValue(cacheKey: string) {
try {
loading.value = true;
const response = await CacheAPI.getCacheValue(nowCacheName.value, cacheKey);
cacheForm.value = response.data.data;
} catch (error) {
console.error('获取缓存内容失败:', error);
} finally {
loading.value = false;
}
}
// 清理全部缓存
const handleClearCacheAll = async () => {
ElMessageBox.confirm('确定要清理全部缓存吗?', '危险!', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
return await CacheAPI.deleteCacheAll();
})
.then(() => {
getCacheNameList();
})
.catch((error) => {
if (error !== 'cancel') {
console.error('清理全部缓存失败:', error);
}
});
};
// 监控数据获取
const getInfo = async () => {
try {
loading.value = true;
const response = await CacheAPI.getCacheInfo();
cache.value = response.data.data || {
info: {},
command_stats: [],
dbSize: 0,
};
initCharts();
} catch (error) {
console.error('获取缓存监控数据失败:', error);
} finally {
loading.value = false;
}
};
// 图表初始化
const initCharts = () => {
if (!commandstats.value || !usedmemory.value) return;
commandstatsInstance = echarts.init(commandstats.value, 'macarons');
usedmemoryInstance = echarts.init(usedmemory.value, 'macarons');
const commandStatsOption = {
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b} : {c} ({d}%)',
},
series: [
{
name: '命令',
type: 'pie',
roseType: 'radius',
radius: [15, 95],
center: ['50%', '38%'],
data: cache.value.command_stats || [],
animationEasing: 'cubicInOut',
animationDuration: 1000,
},
],
};
const usedMemory = cache.value.info?.used_memory_human || '0';
const usedMemoryOption = {
tooltip: {
formatter: `{b} <br/>{a} : ${usedMemory}`,
},
series: [
{
name: '峰值',
type: 'gauge',
min: 0,
max: 1000,
detail: {
formatter: usedMemory,
},
data: [
{
value: parseFloat(usedMemory) || 0,
name: '内存消耗',
},
],
},
],
};
commandstatsInstance.setOption(commandStatsOption);
usedmemoryInstance.setOption(usedMemoryOption);
void nextTick(() => {
commandstatsInstance?.resize();
usedmemoryInstance?.resize();
});
};
// 生命周期钩子
onMounted(() => {
getCacheNameList();
getInfo();
});
onUnmounted(() => {
commandstatsInstance?.dispose();
usedmemoryInstance?.dispose();
});
</script>
<style scoped lang="scss">
/* 与 .app-container 纵向 flex 衔接:tabs 占满剩余高度 */
.cache-page-tabs {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
min-height: 0;
:deep(.el-tabs__header) {
flex-shrink: 0;
}
:deep(.el-tabs__content) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
:deep(.el-tab-pane) {
box-sizing: border-box;
flex: 1;
min-height: 0;
overflow: auto;
}
}
.monitor-tab {
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
}
.monitor-charts-row {
flex: 1;
align-items: stretch;
min-height: 0;
}
.cache-chart-col {
display: flex;
flex-direction: column;
min-height: 0;
}
.cache-chart-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
:deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
}
.chart-container {
flex: 1;
height: 100%;
min-height: 200px;
}
.cache-mgmt-tab {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.cache-mgmt-row {
flex: 1;
align-items: stretch;
min-height: 0;
}
.cache-mgmt-col {
display: flex;
flex-direction: column;
min-height: 0;
}
.cache-mgmt-card {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
:deep(.el-card__body) {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
}
</style>
@@ -0,0 +1,282 @@
<!-- 在线用户 -->
<template>
<div class="app-container">
<PageSearch
ref="searchRef"
:search-config="searchConfig"
@query-click="handleQueryClick"
@reset-click="handleResetClick"
/>
<PageContent ref="contentRef" :content-config="contentConfig">
<!-- eslint-disable-next-line vue/no-unused-vars -- 与 demo 同源解构,本页无批删逻辑 -->
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
<CrudToolbarLeft>
<el-button
v-hasPerm="['module_monitor:online:delete']"
type="danger"
icon="delete"
@click="handleClear"
>
强退所有
</el-button>
</CrudToolbarLeft>
<div class="data-table__toolbar--right">
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
</div>
</template>
<template #table="{ data, loading, tableRef, onSelectionChange, pagination }">
<div class="data-table__content">
<el-table
:ref="tableRef as any"
v-loading="loading"
:data="data"
height="100%"
border
stripe
@selection-change="onSelectionChange"
>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'selection')?.show"
type="selection"
min-width="55"
align="center"
/>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'index')?.show"
type="index"
fixed
label="序号"
min-width="60"
>
<template #default="scope">
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'session_id')?.show"
key="session_id"
label="会话编号"
prop="session_id"
min-width="250"
show-overflow-tooltip
/>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'login_type')?.show"
key="login_type"
label="登录类型"
prop="login_type"
min-width="100"
/>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'ipaddr')?.show"
key="ipaddr"
label="IP地址"
prop="ipaddr"
min-width="150"
show-overflow-tooltip
>
<template #default="scope">
<el-text>{{ scope.row.ipaddr }}</el-text>
<CopyButton
v-if="scope.row.ipaddr"
:text="scope.row.ipaddr"
:style="{ marginLeft: '2px' }"
/>
</template>
</el-table-column>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'name')?.show"
key="name"
label="用户名"
prop="name"
min-width="80"
show-overflow-tooltip
/>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'user_name')?.show"
key="user_name"
label="账号"
prop="user_name"
min-width="80"
/>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'login_location')?.show"
key="login_location"
label="登录位置"
prop="login_location"
min-width="280"
show-overflow-tooltip
/>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'os')?.show"
key="os"
label="操作系统"
prop="os"
min-width="120"
show-overflow-tooltip
/>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'login_time')?.show"
key="login_time"
label="登录时间"
prop="login_time"
min-width="180"
/>
<el-table-column
v-if="contentCols.find((col) => col.prop === 'operation')?.show"
key="operation"
fixed="right"
label="操作"
min-width="100"
>
<template #default="scope">
<el-button
v-hasPerm="['module_monitor:online:delete']"
type="danger"
size="small"
link
icon="delete"
@click="handleSubmit(scope.row.session_id)"
>
强退
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
</PageContent>
</div>
</template>
<script lang="ts" setup>
defineOptions({
name: 'OnlineUser',
inheritAttrs: false,
});
import OnlineAPI, { type OnlineUserPageQuery } from '@/api/module_monitor/online';
import { reactive } from 'vue';
import CrudToolbarLeft from '@/components/CURD/CrudToolbarLeft.vue';
import CrudToolbarRight from '@/components/CURD/CrudToolbarRight.vue';
import PageSearch from '@/components/CURD/PageSearch.vue';
import PageContent from '@/components/CURD/PageContent.vue';
import { useCrudList } from '@/components/CURD/useCrudList';
import type { ISearchConfig, IContentConfig } from '@/components/CURD/types';
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
const searchConfig = reactive<ISearchConfig>({
permPrefix: 'module_monitor:online',
colon: true,
isExpandable: false,
showNumber: 3,
form: { labelWidth: 'auto' },
formItems: [
{
prop: 'ipaddr',
label: 'IP地址',
type: 'input',
attrs: { placeholder: '请输入IP地址', clearable: true },
},
{
prop: 'name',
label: '用户名',
type: 'input',
attrs: { placeholder: '请输入用户名', clearable: true },
},
{
prop: 'login_location',
label: '登录位置',
type: 'input',
attrs: { placeholder: '请输入登录位置', clearable: true },
},
],
});
const contentCols = reactive<
Array<{
prop?: string;
label?: string;
show?: boolean;
}>
>([
{ prop: 'selection', label: '选择框', show: true },
{ prop: 'index', label: '序号', show: true },
{ prop: 'session_id', label: '会话编号', show: true },
{ prop: 'login_type', label: '登录类型', show: true },
{ prop: 'name', label: '登录名称', show: true },
{ prop: 'user_name', label: '用户账号', show: true },
{ prop: 'ipaddr', label: '主机', show: true },
{ prop: 'login_location', label: '登录地点', show: true },
{ prop: 'os', label: '操作系统', show: true },
{ prop: 'login_time', label: '登录时间', show: true },
{ prop: 'operation', label: '操作', show: true },
]);
const contentConfig = reactive<IContentConfig<OnlineUserPageQuery>>({
permPrefix: 'module_monitor:online',
cols: contentCols as IContentConfig['cols'],
hideColumnFilter: false,
toolbar: [],
defaultToolbar: ['refresh', 'filter'],
pagination: {
pageSize: 10,
pageSizes: [10, 20, 30, 50],
},
request: { page_no: 'page_no', page_size: 'page_size' },
indexAction: async (params) => {
const res = await OnlineAPI.listOnline(params as OnlineUserPageQuery);
return {
total: res.data.data.total,
list: res.data.data.items,
};
},
});
async function handleSubmit(session_id: string) {
ElMessageBox.confirm(`确认强制退出会话 ${session_id}?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await OnlineAPI.deleteOnline(session_id);
refreshList();
} catch (error: unknown) {
console.error(error);
}
})
.catch(() => {
ElMessageBox.close();
});
}
async function handleClear() {
ElMessageBox.confirm('确认强制退出所有用户?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(async () => {
try {
await OnlineAPI.clearOnline();
refreshList();
} catch (error: unknown) {
console.error(error);
}
})
.catch(() => {
ElMessageBox.close();
});
}
</script>
<style lang="scss" scoped></style>
@@ -0,0 +1,609 @@
<template>
<div class="app-container">
<PageSearch
ref="searchRef"
:search-config="searchConfig"
@query-click="handleQueryClick"
@reset-click="handleResetClick"
/>
<PageContent ref="contentRef" :content-config="contentConfig">
<template #header>
<div class="card-header">
<span>
<el-tooltip content="资源文件管理系统: 点击路径可以快速返回上级目录">
<QuestionFilled class="w-4 h-4 mx-1" />
</el-tooltip>
文件列表(当前路径):
</span>
<div class="breadcrumb-container">
<span class="breadcrumb-label"></span>
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="(item, index) in breadcrumbList"
:key="index"
:class="{ 'is-link': index < breadcrumbList.length - 1 }"
@click="handleBreadcrumbClick(item)"
>
{{ item.name }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
</div>
</template>
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
<CrudToolbarLeft :remove-ids="removeIds">
<el-row :gutter="10">
<el-col :span="1.5">
<el-button
v-hasPerm="['module_monitor:resource:upload']"
type="success"
icon="plus"
@click="handleUpload"
>
上传文件
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPerm="['module_monitor:resource:create_dir']"
type="primary"
icon="folder-add"
@click="handleCreateDir"
>
新建文件夹
</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-hasPerm="['module_monitor:resource:delete']"
type="danger"
icon="delete"
:disabled="removeIds.length === 0"
@click="handleBatchDelete"
>
批量删除
</el-button>
</el-col>
</el-row>
</CrudToolbarLeft>
<div class="data-table__toolbar--right flex flex-wrap items-center gap-3">
<el-checkbox
v-model="showHiddenFiles"
v-hasPerm="['module_monitor:resource:query']"
@change="onShowHiddenChange"
>
显示隐藏文件
</el-checkbox>
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
</div>
</template>
<template #table="{ data, loading: tableLoading, tableRef, onSelectionChange, pagination }">
<div class="data-table__content">
<el-table
:ref="tableRef as any"
v-loading="tableLoading"
row-key="file_url"
:data="data"
height="100%"
border
stripe
@selection-change="
(s) => {
handleSelectionChange(s as ResourceItem[]);
onSelectionChange(s);
}
"
>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column type="selection" min-width="40" align="center" />
<el-table-column type="index" fixed label="序号" min-width="50">
<template #default="scope">
{{ (pagination.currentPage - 1) * pagination.pageSize + scope.$index + 1 }}
</template>
</el-table-column>
<el-table-column label="名称" prop="name" min-width="200">
<template #default="{ row }">
<div class="file-name">
<el-icon class="file-icon">
<Folder v-if="row.is_dir" />
<Document v-else />
</el-icon>
<span class="file-name-clickable" @click="handleFileNameClick(row)">
{{ row.name }}
</span>
</div>
</template>
</el-table-column>
<el-table-column label="大小" prop="size" min-width="120" align="center">
<template #default="{ row }">
<span v-if="!row.is_dir">{{ formatFileSize(row.size) }}</span>
</template>
</el-table-column>
<el-table-column label="修改时间" prop="modified_time" min-width="180" sortable />
<el-table-column
fixed="right"
label="操作"
align="center"
min-width="200"
class-name="search-buttons"
>
<template #default="{ row }">
<el-button
v-if="!row.is_dir"
v-hasPerm="['module_monitor:resource:download']"
type="success"
size="small"
link
icon="download"
@click="handleDownload(row)"
>
下载
</el-button>
<el-button
v-hasPerm="['module_monitor:resource:rename']"
type="primary"
size="small"
link
icon="edit"
@click="handleRename(row)"
>
重命名
</el-button>
<el-button
v-hasPerm="['module_monitor:resource:delete']"
type="danger"
size="small"
link
icon="delete"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
</PageContent>
<!-- 上传对话框 -->
<EnhancedDialog
v-model="uploadDialogVisible"
title="上传文件"
width="500px"
@close="handleUploadClose"
>
<el-upload
ref="uploadRef"
:auto-upload="false"
:multiple="true"
:file-list="uploadFileList"
drag
@change="handleUploadChange"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text">
将文件拖到此处,或
<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip" style="color: red">
不支持多文件上传,单个文件不超过100MB,多文件上传,取最后一个文件上传
</div>
</template>
</el-upload>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button
v-hasPerm="['module_monitor:resource:upload']"
type="primary"
:loading="uploading"
@click="handleUploadConfirm"
>
确定上传
</el-button>
</template>
</EnhancedDialog>
<!-- 新建文件夹对话框 -->
<EnhancedDialog v-model="createDirDialogVisible" title="新建文件夹" width="400px">
<el-form :model="createDirForm" label-width="80px">
<el-form-item label="文件夹名" required>
<el-input
v-model="createDirForm.dir_name"
placeholder="请输入文件夹名称"
@keyup.enter="handleCreateDirConfirm"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDirDialogVisible = false">取消</el-button>
<el-button
v-hasPerm="['module_monitor:resource:create_dir']"
type="primary"
@click="handleCreateDirConfirm"
>
确定
</el-button>
</template>
</EnhancedDialog>
<!-- 重命名对话框 -->
<EnhancedDialog v-model="renameDialogVisible" title="重命名" width="400px">
<el-form :model="renameForm" label-width="80px">
<el-form-item label="新名称" required>
<el-input
v-model="renameForm.new_name"
placeholder="请输入新名称"
@keyup.enter="handleRenameConfirm"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="renameDialogVisible = false">取消</el-button>
<el-button
v-hasPerm="['module_monitor:resource:rename']"
type="primary"
@click="handleRenameConfirm"
>
确定
</el-button>
</template>
</EnhancedDialog>
</div>
</template>
<script setup lang="ts">
defineOptions({
name: 'ResourceMonitor',
inheritAttrs: false,
});
import { ref, reactive } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Folder, Document, UploadFilled, QuestionFilled } from '@element-plus/icons-vue';
import {
ResourceAPI,
type ResourceItem,
type ResourcePageQuery,
} from '@/api/module_monitor/resource';
import CrudToolbarLeft from '@/components/CURD/CrudToolbarLeft.vue';
import CrudToolbarRight from '@/components/CURD/CrudToolbarRight.vue';
import PageSearch from '@/components/CURD/PageSearch.vue';
import PageContent from '@/components/CURD/PageContent.vue';
import EnhancedDialog from '@/components/CURD/EnhancedDialog.vue';
import { useCrudList } from '@/components/CURD/useCrudList';
import type { IContentConfig, ISearchConfig } from '@/components/CURD/types';
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
const selectedItems = ref<ResourceItem[]>([]);
const breadcrumbList = ref([{ name: '资源根目录', path: '/' }]);
const showHiddenFiles = ref(false);
const currentPath = ref('/');
const searchConfig = reactive<ISearchConfig>({
permPrefix: 'module_monitor:resource',
colon: true,
isExpandable: false,
showNumber: 3,
form: { labelWidth: 'auto' },
searchButtonPerm: 'module_monitor:resource:query',
resetButtonPerm: 'module_monitor:resource:query',
formItems: [
{
prop: 'name',
label: '关键词',
type: 'input',
attrs: { placeholder: '请输入文件名或目录名', clearable: true, style: { width: '200px' } },
},
],
});
const contentConfig = reactive<IContentConfig<ResourcePageQuery>>({
permPrefix: 'module_monitor:resource',
cols: [],
hideColumnFilter: true,
toolbar: [],
defaultToolbar: ['refresh'],
pk: 'file_url',
pagination: {
pageSize: 10,
pageSizes: [10, 20, 30, 50],
},
request: { page_no: 'page_no', page_size: 'page_size' },
indexAction: async (params) => {
const merged: ResourcePageQuery = {
...(params as ResourcePageQuery),
include_hidden: showHiddenFiles.value,
};
if (currentPath.value && currentPath.value !== '/') {
merged.path = currentPath.value;
}
const res = await ResourceAPI.listResource(merged);
return {
total: res.data.data.total,
list: res.data.data.items,
};
},
});
const uploadDialogVisible = ref(false);
const createDirDialogVisible = ref(false);
const renameDialogVisible = ref(false);
const uploading = ref(false);
const uploadRef = ref();
const uploadFileList = ref<any[]>([]);
const createDirForm = reactive({
dir_name: '',
});
const renameForm = reactive({
new_name: '',
old_path: '',
});
function handleBreadcrumbClick(item: { path: string }) {
currentPath.value = item.path;
updateBreadcrumb();
refreshList();
}
function updateBreadcrumb() {
if (currentPath.value === '/') {
breadcrumbList.value = [{ name: '资源根目录', path: '/' }];
return;
}
const parts = currentPath.value.split('/').filter((part) => part !== '');
breadcrumbList.value = [
{ name: '资源根目录', path: '/' },
...parts.map((part, index) => ({
name: part,
path: parts.slice(0, index + 1).join('/'),
})),
];
}
function handleFileNameClick(row: ResourceItem) {
if (row.is_dir) {
if (currentPath.value === '/') {
currentPath.value = row.name;
} else {
currentPath.value = currentPath.value + '/' + row.name;
}
updateBreadcrumb();
refreshList();
} else {
handleFilePreview(row);
}
}
function handleFilePreview(file: ResourceItem) {
let previewUrl = file.file_url;
if (previewUrl && !previewUrl.startsWith('http')) {
previewUrl = `${window.location.origin}${previewUrl}`;
}
window.open(previewUrl, '_blank');
}
function handleSelectionChange(selection: ResourceItem[]) {
selectedItems.value = selection;
}
function handleUpload() {
uploadDialogVisible.value = true;
uploadFileList.value = [];
}
function handleUploadChange(_file: unknown, fileList: unknown[]) {
uploadFileList.value = fileList as any[];
}
async function handleUploadConfirm() {
if (uploadFileList.value.length === 0) {
ElMessage.warning('请选择要上传的文件');
return;
}
try {
uploading.value = true;
const formData = new FormData();
uploadFileList.value.forEach((file: { raw?: Blob }) => {
const raw = file.raw;
if (raw) formData.append('file', raw);
});
const targetPath = currentPath.value === '/' ? '' : currentPath.value;
formData.append('target_path', targetPath);
await ResourceAPI.uploadFile(formData);
uploadDialogVisible.value = false;
refreshList();
} catch (error) {
console.error('Upload error:', error);
} finally {
uploading.value = false;
}
}
function handleUploadClose() {
uploadDialogVisible.value = false;
uploadFileList.value = [];
}
function handleCreateDir() {
createDirForm.dir_name = '';
createDirDialogVisible.value = true;
}
async function handleCreateDirConfirm() {
if (!createDirForm.dir_name.trim()) {
ElMessage.warning('请输入文件夹名称');
return;
}
try {
const parentPath = currentPath.value === '/' ? '' : currentPath.value;
await ResourceAPI.createDirectory({
parent_path: parentPath,
dir_name: createDirForm.dir_name.trim(),
});
createDirDialogVisible.value = false;
refreshList();
} catch (error) {
console.error('Create directory error:', error);
}
}
function onShowHiddenChange() {
refreshList();
}
async function handleDownload(item: ResourceItem) {
try {
const response = await ResourceAPI.downloadFile(item.file_url);
const blob = response.data;
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = item.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Download error:', error);
}
}
function handleRename(item: ResourceItem) {
renameForm.old_path = item.file_url;
renameForm.new_name = item.name;
renameDialogVisible.value = true;
}
async function handleRenameConfirm() {
if (!renameForm.new_name.trim()) {
ElMessage.warning('请输入新名称');
return;
}
try {
await ResourceAPI.renameResource({
old_path: renameForm.old_path,
new_name: renameForm.new_name.trim(),
});
renameDialogVisible.value = false;
refreshList();
} catch (error) {
console.error('Rename error:', error);
}
}
async function handleDelete(item: ResourceItem) {
try {
await ElMessageBox.confirm(`确定要删除 ${item.name} 吗?`, '确认删除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
});
await ResourceAPI.deleteResource([item.file_url]);
refreshList();
} catch (error) {
if (error !== 'cancel') {
console.error('Delete error:', error);
}
}
}
async function handleBatchDelete() {
if (selectedItems.value.length === 0) {
ElMessage.warning('请选择要删除的文件');
return;
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedItems.value.length} 个文件吗?`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
);
const paths = selectedItems.value.map((item) => item.file_url);
await ResourceAPI.deleteResource(paths);
refreshList();
} catch (error) {
if (error !== 'cancel') {
console.error('Batch delete error:', error);
}
}
}
function formatFileSize(size?: number | null) {
if (!size || size === null) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let fileSize = size;
while (fileSize >= 1024 && unitIndex < units.length - 1) {
fileSize /= 1024;
unitIndex++;
}
return `${fileSize.toFixed(1)} ${units[unitIndex]}`;
}
</script>
<style lang="scss" scoped>
.card-header {
display: flex;
align-items: center;
}
.data-table__content {
.file-name {
display: flex;
gap: 8px;
align-items: center;
.file-name-clickable {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
color: var(--el-color-primary-light-3);
text-decoration: underline;
}
}
}
}
:deep(.el-breadcrumb__item) {
&.is-link {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
color: var(--el-color-primary-light-3);
}
}
}
</style>
@@ -0,0 +1,318 @@
<template>
<div class="app-container">
<el-row :gutter="16">
<el-col :span="12" class="mb-4">
<el-card :loading="loading" shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Cpu /></el-icon>
<span class="flex items-center gap-2">CPU使用情况</span>
<el-tooltip content="展示CPU核心数及使用率">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-row :gutter="16">
<!-- CPU核心数卡片 -->
<el-col :span="12">
<el-card shadow="hover">
<span>核心数</span>
<el-tooltip :content="(server.cpu?.cpu_num || 0).toFixed(1)">
<div class="text-center mb-4">
<el-progress
type="circle"
:percentage="100"
:format="() => `${server.cpu?.cpu_num || 0}`"
/>
</div>
</el-tooltip>
<el-descriptions :column="1" border>
<el-descriptions-item label="总核心数">
{{ server.cpu?.cpu_num || 0 }}
</el-descriptions-item>
<el-descriptions-item label="已用核心">
{{ Math.floor(((server.cpu?.used || 0) * server.cpu?.cpu_num) / 100) }}
</el-descriptions-item>
<el-descriptions-item label="空闲核心">
{{ Math.floor(((server.cpu?.free || 0) * server.cpu?.cpu_num) / 100) }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<!-- CPU使用率卡片 -->
<el-col :span="12">
<el-card shadow="hover" class="h-full">
<span>使用率</span>
<el-tooltip :content="(server.cpu?.used || 0).toFixed(1) + '%'">
<div class="text-center mb-4">
<el-progress
type="circle"
:percentage="server.cpu?.used || 0"
:status="
server.cpu?.used > 80
? 'exception'
: server.cpu?.used > 60
? 'warning'
: 'success'
"
/>
</div>
</el-tooltip>
<el-descriptions :column="1" border>
<el-descriptions-item label="用户使用率">
{{ (server.cpu?.used || 0).toFixed(1) + '%' }}
</el-descriptions-item>
<el-descriptions-item label="系统使用率">
{{ (server.cpu?.sys || 0).toFixed(1) + '%' }}
</el-descriptions-item>
<el-descriptions-item label="当前空闲率">
{{ (server.cpu?.free || 0).toFixed(1) + '%' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</el-card>
</el-col>
<el-col :span="12" class="mb-4">
<el-card :loading="loading" shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Memo /></el-icon>
<span>内存使用情况</span>
<el-tooltip content="展示系统内存和Python程序内存使用情况">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-row :gutter="16">
<!-- 系统内存卡片 -->
<el-col :span="12">
<el-card shadow="hover" class="h-full">
<span>系统内存</span>
<el-tooltip :content="(server.mem?.usage || 0).toFixed(1) + '%'">
<div class="text-center mb-4">
<el-progress
type="circle"
:percentage="server.mem?.usage || 0"
:status="
server.mem?.usage > 80
? 'exception'
: server.mem?.usage > 60
? 'warning'
: 'success'
"
/>
</div>
</el-tooltip>
<el-descriptions :column="1" border>
<el-descriptions-item label="总内存">
{{ server.mem?.total }}
</el-descriptions-item>
<el-descriptions-item label="已用内存">
{{ server.mem?.used }}
</el-descriptions-item>
<el-descriptions-item label="空闲内存">
{{ server.mem?.free }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<!-- Python内存卡片 -->
<el-col :span="12">
<el-card shadow="hover" class="h-full">
<span>Python内存</span>
<el-tooltip :content="(server.py?.memory_usage || 0).toFixed(1) + '%'">
<div class="text-center mb-4">
<el-progress
type="circle"
:percentage="server.py?.memory_usage || 0"
:status="
server.py?.memory_usage > 80
? 'exception'
: server.py?.memory_usage > 60
? 'warning'
: 'success'
"
/>
</div>
</el-tooltip>
<el-descriptions :column="1" border>
<el-descriptions-item label="总内存">
{{ server.py?.memory_total }}
</el-descriptions-item>
<el-descriptions-item label="已用内存">
{{ server.py?.memory_used }}
</el-descriptions-item>
<el-descriptions-item label="空闲内存">
{{ server.py?.memory_free }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</el-card>
</el-col>
<el-col :span="24" class="mb-4">
<el-card :loading="loading">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Monitor /></el-icon>
<span class="font-medium">服务器基本信息</span>
<el-tooltip content="展示服务器基本配置信息">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="服务器名称">
{{ server.sys?.computer_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="操作系统">
{{ server.sys?.os_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="服务器IP">
{{ server.sys?.computer_ip || '-' }}
</el-descriptions-item>
<el-descriptions-item label="系统架构">
{{ server.sys?.os_arch || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="24" class="mb-4">
<el-card :loading="loading" class="shadow-sm">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Dish /></el-icon>
<span class="font-medium">Python运行环境</span>
<el-tooltip content="展示Python环境配置及运行状态">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-descriptions :column="3" border>
<el-descriptions-item label="Python名称">
{{ server.py?.name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="Python版本">
{{ server.py?.version || '-' }}
</el-descriptions-item>
<el-descriptions-item label="启动时间">
{{ server.py?.start_time || '-' }}
</el-descriptions-item>
<el-descriptions-item label="运行时长">
{{ server.py?.run_time || '-' }}
</el-descriptions-item>
<el-descriptions-item label="安装路径">
{{ server.py?.home || '-' }}
</el-descriptions-item>
<el-descriptions-item label="项目路径">
{{ server.sys?.user_dir || '-' }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
<el-col :span="24">
<el-card :loading="loading">
<template #header>
<div class="flex items-center gap-2">
<el-icon>
<Files />
</el-icon>
<span class="font-medium">磁盘使用情况</span>
<el-tooltip content="展示磁盘空间使用详情">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</div>
</template>
<el-table :data="server.disks" border stripe>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
</template>
<el-table-column label="盘符路径" prop="dir_name" :show-overflow-tooltip="true" />
<el-table-column label="文件系统" prop="sys_type_name" align="center" width="100" />
<el-table-column label="盘符名称" prop="type_name" />
<el-table-column prop="usage" label="使用率" align="center">
<template #default="{ row }">
<!-- 使用 element-plus 的 Progress 组件 -->
<div>
<el-progress
:percentage="Number(row.usage)"
:status="row.usage > 80 ? 'exception' : row.usage > 60 ? 'warning' : 'success'"
:text-inside="true"
:stroke-width="16"
/>
</div>
</template>
</el-table-column>
<el-table-column label="总大小" prop="total" align="center" width="100" />
<el-table-column label="可用大小" prop="free" align="center" width="100" />
<el-table-column label="已用大小" prop="used" align="center" width="100" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script lang="ts" setup>
import ServerAPI, { type ServerInfo } from '@/api/module_monitor/server';
const loading = ref(false);
const server = ref<ServerInfo>({
cpu: {
cpu_num: 0,
used: 0,
sys: 0,
free: 0,
},
mem: {
total: '',
used: '',
free: '',
usage: 0,
},
sys: {
computer_name: '',
os_name: '',
computer_ip: '',
os_arch: '',
user_dir: '',
},
py: {
name: '',
version: '',
start_time: '',
run_time: '',
home: '',
memory_total: '',
memory_used: '',
memory_free: '',
memory_usage: 0,
},
disks: [],
});
async function getList() {
loading.value = true;
try {
const response = await ServerAPI.getServer();
server.value = response.data.data;
} catch (error) {
console.error('获取服务器信息失败:', error);
} finally {
loading.value = false;
}
}
onMounted(() => {
getList();
});
</script>
<style lang="scss" scoped></style>