feat: 迁移前端资源文件并重构项目结构

refactor: 优化前端代码结构和资源管理

style: 调整前端代码格式和样式

chore: 更新.gitignore和构建配置

fix: 修复前端资源路径和引用问题

docs: 更新前端文档和注释

perf: 优化前端性能和加载速度

test: 更新前端测试用例

build: 调整前端构建配置

ci: 更新CI/CD配置
This commit is contained in:
zhangtao
2026-05-09 00:46:43 +08:00
parent 1bd125d99b
commit bf81480ada
967 changed files with 21018 additions and 83019 deletions
+138 -180
View File
@@ -1,139 +1,139 @@
<template>
<div class="app-container">
<el-tabs type="border-card" class="cache-page-tabs">
<div class="art-full-height">
<el-tabs>
<!-- 监控信息 Tab -->
<el-tab-pane class="monitor" label="监控信息">
<ElTabPane label="监控信息">
<div class="monitor-tab">
<el-row :gutter="16">
<ElRow :gutter="16">
<!-- 基本信息 -->
<el-col :span="24">
<el-card shadow="hover">
<ElCol :span="24">
<ElCard shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon>
<ElIcon>
<Monitor />
</el-icon>
</ElIcon>
<span>Redis监控信息</span>
<el-tooltip content="展示Redis监控信息详情">
<el-icon>
<ElTooltip content="展示Redis监控信息详情">
<ElIcon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</ElIcon>
</ElTooltip>
</div>
</template>
<el-descriptions :column="12" border :direction="'vertical'">
<el-descriptions-item label="Redis版本">
<ElDescriptions :column="12" border :direction="'vertical'">
<ElDescriptionsItem label="Redis版本">
{{ cache.info?.redis_version || "-" }}
</el-descriptions-item>
<el-descriptions-item label="运行模式">
</ElDescriptionsItem>
<ElDescriptionsItem label="运行模式">
{{ cache.info?.redis_mode === "standalone" ? "单机" : "集群" }}
</el-descriptions-item>
<el-descriptions-item label="端口">
</ElDescriptionsItem>
<ElDescriptionsItem label="端口">
{{ cache.info?.tcp_port || "-" }}
</el-descriptions-item>
<el-descriptions-item label="客户端数">
</ElDescriptionsItem>
<ElDescriptionsItem label="客户端数">
{{ cache.info?.connected_clients || 0 }}
</el-descriptions-item>
<el-descriptions-item label="运行时间(天)">
</ElDescriptionsItem>
<ElDescriptionsItem label="运行时间(天)">
{{ cache.info?.uptime_in_days || 0 }}
</el-descriptions-item>
<el-descriptions-item label="使用内存">
</ElDescriptionsItem>
<ElDescriptionsItem label="使用内存">
{{ cache.info?.used_memory_human || "-" }}
</el-descriptions-item>
<el-descriptions-item label="使用CPU">
</ElDescriptionsItem>
<ElDescriptionsItem label="使用CPU">
{{
cache.info?.used_cpu_user_children
? parseFloat(cache.info.used_cpu_user_children).toFixed(2)
: "-"
}}
</el-descriptions-item>
<el-descriptions-item label="内存配置">
</ElDescriptionsItem>
<ElDescriptionsItem label="内存配置">
{{ cache.info?.maxmemory_human || "-" }}
</el-descriptions-item>
<el-descriptions-item label="AOF是否开启">
</ElDescriptionsItem>
<ElDescriptionsItem label="AOF是否开启">
{{ cache.info?.aof_enabled === "0" ? "否" : "是" }}
</el-descriptions-item>
<el-descriptions-item label="RDB是否成功">
</ElDescriptionsItem>
<ElDescriptionsItem label="RDB是否成功">
{{ cache.info?.rdb_last_bgsave_status || "-" }}
</el-descriptions-item>
<el-descriptions-item label="Key数量">
</ElDescriptionsItem>
<ElDescriptionsItem label="Key数量">
{{ cache.db_size || 0 }}
</el-descriptions-item>
<el-descriptions-item label="网络入口/出口">
</ElDescriptionsItem>
<ElDescriptionsItem 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>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</ElCol>
</ElRow>
<!-- 监控图表:单独一行并占满剩余高度 -->
<el-row :gutter="16" class="monitor-charts-row">
<el-col :span="12" class="cache-chart-col">
<el-card shadow="hover" class="cache-chart-card">
<ElRow :gutter="16" class="monitor-charts-row">
<ElCol :span="12" class="cache-chart-col">
<ElCard shadow="hover" class="cache-chart-card">
<template #header>
<div class="flex items-center gap-2">
<el-icon>
<ElIcon>
<Stopwatch />
</el-icon>
</ElIcon>
<span class="title">命令统计</span>
<el-tooltip content="展示命令统计详情">
<el-icon>
<ElTooltip content="展示命令统计详情">
<ElIcon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</ElIcon>
</ElTooltip>
</div>
</template>
<div ref="commandstats" class="chart-container" />
</el-card>
</el-col>
</ElCard>
</ElCol>
<el-col :span="12" class="cache-chart-col">
<el-card shadow="hover" class="cache-chart-card">
<ElCol :span="12" class="cache-chart-col">
<ElCard shadow="hover" class="cache-chart-card">
<template #header>
<div class="flex items-center gap-2">
<el-icon>
<ElIcon>
<Stopwatch />
</el-icon>
</ElIcon>
<span>内存信息</span>
<el-tooltip content="展示内存信息详情">
<el-icon>
<ElTooltip content="展示内存信息详情">
<ElIcon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</ElIcon>
</ElTooltip>
</div>
</template>
<div ref="usedmemory" class="chart-container" />
</el-card>
</el-col>
</el-row>
</ElCard>
</ElCol>
</ElRow>
</div>
</el-tab-pane>
</ElTabPane>
<!-- 缓存管理 Tab -->
<el-tab-pane class="cache" label="缓存管理">
<ElTabPane label="缓存管理">
<div class="cache-mgmt-tab">
<el-row :gutter="16" class="cache-mgmt-row">
<ElRow :gutter="16" class="cache-mgmt-row">
<!-- 缓存列表 -->
<el-col :span="8" class="cache-mgmt-col">
<el-card :loading="loading" shadow="hover" class="cache-mgmt-card">
<ElCol :span="8" class="cache-mgmt-col">
<ElCard :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>
<ElIcon>
<Tickets />
</el-icon>
</ElIcon>
<span class="flex items-center gap-2">缓存列表</span>
<el-tooltip content="展示缓存列表详情">
<el-icon>
<ElTooltip content="展示缓存列表详情">
<ElIcon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</ElIcon>
</ElTooltip>
</div>
<div>
<el-button
<ElButton
v-hasPerm="['module_monitor:cache:query']"
type="primary"
link
@@ -143,40 +143,33 @@
</div>
</div>
</template>
<el-table
:loading="loading"
:data="cacheNames"
row-key="cache_name"
height="100%"
border
stripe
>
<ElTable :loading="loading" :data="cacheNames" row-key="cache_name">
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
<ElEmpty :image-size="80" description="暂无数据" />
</template>
<el-table-column prop="cache_name" label="缓存名称" show-overflow-tooltip>
<ElTableColumn prop="cache_name" label="缓存名称" show-overflow-tooltip>
<template #default="{ row }">
<el-button
<ElButton
v-hasPerm="['module_monitor:cache:query']"
type="primary"
link
@click="getCacheKeyList(row)"
>
{{ row.cache_name }}
</el-button>
</ElButton>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" show-overflow-tooltip />
<el-table-column label="操作" width="60" align="center">
</ElTableColumn>
<ElTableColumn prop="remark" label="备注" show-overflow-tooltip />
<ElTableColumn label="操作" width="60" align="center">
<template #default="{ row }">
<el-popconfirm
<ElPopconfirm
class="box-item"
:title="`确认删除缓存 ${row.cache_name} 吗?`"
placement="top"
@confirm="handleClearCacheName(row)"
>
<template #reference>
<el-button
<ElButton
v-hasPerm="['module_monitor:cache:delete']"
type="danger"
size="small"
@@ -184,31 +177,31 @@
icon="delete"
/>
</template>
</el-popconfirm>
</ElPopconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</ElTableColumn>
</ElTable>
</ElCard>
</ElCol>
<!-- 键名列表 -->
<el-col :span="8" class="cache-mgmt-col">
<el-card :loading="loading" shadow="hover" class="cache-mgmt-card">
<ElCol :span="8" class="cache-mgmt-col">
<ElCard :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>
<ElIcon>
<Key />
</el-icon>
</ElIcon>
<span class="flex items-center gap-2">键名列表</span>
<el-tooltip content="展示键名列表详情">
<el-icon>
<ElTooltip content="展示键名列表详情">
<ElIcon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</ElIcon>
</ElTooltip>
</div>
<div>
<el-button
<ElButton
v-hasPerm="['module_monitor:cache:query']"
type="primary"
link
@@ -218,38 +211,36 @@
</div>
</div>
</template>
<el-table
<ElTable
:loading="subLoading"
:data="cacheKeys.map((key) => ({ cacheKey: key }))"
height="100%"
row-key="cacheKey"
border
>
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
<ElEmpty :image-size="80" description="暂无数据" />
</template>
<el-table-column prop="cacheKey" label="缓存键名" show-overflow-tooltip>
<ElTableColumn prop="cacheKey" label="缓存键名" show-overflow-tooltip>
<template #default="{ row }">
<el-button
<ElButton
v-hasPerm="['module_monitor:cache:detail']"
type="primary"
link
@click="handleCacheValue(row.cacheKey)"
>
{{ row.cacheKey }}
</el-button>
</ElButton>
</template>
</el-table-column>
<el-table-column label="操作" width="60" align="center">
</ElTableColumn>
<ElTableColumn label="操作" width="60" align="center">
<template #default="{ row }">
<el-popconfirm
<ElPopconfirm
class="box-item"
:title="`确认删除键 ${row.cacheKey} 吗?`"
placement="top"
@confirm="handleClearCacheKey(row.cacheKey)"
>
<template #reference>
<el-button
<ElButton
v-hasPerm="['module_monitor:cache:delete']"
type="danger"
size="small"
@@ -257,31 +248,31 @@
icon="delete"
/>
</template>
</el-popconfirm>
</ElPopconfirm>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</ElTableColumn>
</ElTable>
</ElCard>
</ElCol>
<!-- 缓存内容 -->
<el-col :span="8">
<el-card :loading="loading" shadow="hover">
<ElCol :span="8">
<ElCard :loading="loading" shadow="hover">
<template #header>
<div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<el-icon>
<ElIcon>
<Key />
</el-icon>
</ElIcon>
<span class="flex items-center gap-2">缓存内容</span>
<el-tooltip content="展示缓存内容详情">
<el-icon>
<ElTooltip content="展示缓存内容详情">
<ElIcon>
<QuestionFilled />
</el-icon>
</el-tooltip>
</ElIcon>
</ElTooltip>
</div>
<div>
<el-button
<ElButton
v-hasPerm="['module_monitor:cache:delete']"
type="danger"
link
@@ -289,37 +280,32 @@
@click="handleClearCacheAll"
>
清理全部
</el-button>
</ElButton>
</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
<ElForm :model="cacheForm" label-suffix=":" label-width="auto" label-position="top">
<ElFormItem label="缓存名称">
<ElInput v-model="cacheForm.cache_name" readonly placeholder="缓存名称" />
</ElFormItem>
<ElFormItem label="缓存键名">
<ElInput v-model="cacheForm.cache_key" readonly placeholder="缓存键名" />
</ElFormItem>
<ElFormItem label="缓存内容">
<ElInput
v-model="cacheForm.cache_value"
type="textarea"
:rows="20"
readonly
placeholder="缓存内容"
/>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
</ElFormItem>
</ElForm>
</ElCard>
</ElCol>
</ElRow>
</div>
</el-tab-pane>
</ElTabPane>
</el-tabs>
</div>
</template>
@@ -553,34 +539,6 @@ onUnmounted(() => {
</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;
@@ -1,265 +1,279 @@
<!-- 在线用户 -->
<!-- 在线用户:Art + useTable -->
<template>
<div class="app-container">
<PageSearch
ref="searchRef"
:search-config="searchConfig"
@query-click="handleQueryClick"
@reset-click="handleResetClick"
<div class="art-full-height">
<ArtSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="onlineSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<PageContent ref="contentRef" :content-config="contentConfig">
<!-- eslint-disable-next-line vue/no-unused-vars -- 与 demo 同源解构,本页无批删逻辑 -->
<template #toolbar="{ toolbarRight, onToolbar, removeIds, cols }">
<CrudToolbarLeft>
<el-button
<ElCard class="art-table-card" :style="{ 'margin-top': showSearchBar ? '12px' : '0' }">
<ArtTableHeader
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<ElButton
v-hasPerm="['module_monitor:online:delete']"
type="danger"
icon="delete"
@click="handleClear"
plain
:loading="clearAllLoading"
@click="handleClearAll"
>
强退所有
</el-button>
</CrudToolbarLeft>
<div class="data-table__toolbar--right">
<CrudToolbarRight :buttons="toolbarRight" :cols="cols" :on-toolbar="onToolbar" />
</div>
</template>
</ElButton>
</template>
</ArtTableHeader>
<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>
<ArtTable
row-key="session_id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="paginationBind"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
</div>
</template>
<script lang="ts" setup>
<script setup lang="ts">
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";
import { h, ref, computed } from "vue";
import { useTable } from "@/hooks/core/useTable";
import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
import CopyButton from "@/components/CopyButton/index.vue";
import OnlineAPI, { type OnlineUserTable } from "@/api/module_monitor/online";
import type { ColumnOption } from "@/types/component";
import { ElMessage, ElMessageBox, ElTooltip } from "element-plus";
import { useAuth } from "@/hooks/core/useAuth";
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
const { hasAuth } = useAuth();
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 },
},
],
type OnlineSearchForm = {
ipaddr?: string;
name?: string;
login_location?: string;
};
function buildOnlineReplaceParams(u: OnlineSearchForm): Record<string, unknown> {
return {
ipaddr: u.ipaddr,
name: u.name,
login_location: u.login_location,
};
}
const searchForm = ref<OnlineSearchForm>({
ipaddr: undefined,
name: undefined,
login_location: undefined,
});
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 showSearchBar = ref(true);
const searchBarRef = ref<InstanceType<typeof ArtSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const onlineSearchItems = computed<SearchFormItem[]>(() => [
{
label: "IP地址",
key: "ipaddr",
type: "input",
placeholder: "请输入IP地址",
clearable: true,
span: 6,
},
{
label: "用户名",
key: "name",
type: "input",
placeholder: "请输入用户名",
clearable: true,
span: 6,
},
{
label: "登录位置",
key: "login_location",
type: "input",
placeholder: "请输入登录位置",
clearable: true,
span: 6,
},
]);
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,
};
},
});
const clearAllLoading = ref(false);
async function handleSubmit(session_id: string) {
ElMessageBox.confirm(`确认强制退出会话 ${session_id}?`, "警告", {
function kickSession(sessionId: string) {
ElMessageBox.confirm(`确认强制退出会话 ${sessionId}?`, "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
type: "warning",
})
.then(async () => {
try {
await OnlineAPI.deleteOnline(session_id);
refreshList();
await OnlineAPI.deleteOnline(sessionId);
ElMessage.success("操作成功");
await refreshData();
} catch (error: unknown) {
console.error(error);
}
})
.catch(() => {
ElMessageBox.close();
});
.catch(() => {});
}
async function handleClear() {
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
} = useTable({
core: {
apiFn: OnlineAPI.listOnline,
apiParams: {
page_no: 1,
page_size: 10,
},
columnsFactory: (): ColumnOption<OnlineUserTable>[] => [
{
prop: "session_id",
label: "会话编号",
minWidth: 250,
showOverflowTooltip: true,
},
{
prop: "login_type",
label: "登录类型",
minWidth: 100,
showOverflowTooltip: true,
},
{
prop: "ipaddr",
label: "IP地址",
minWidth: 150,
formatter: (row: OnlineUserTable) =>
h("span", { class: "inline-flex items-center flex-wrap gap-0.5" }, [
row.ipaddr ?? "",
row.ipaddr
? h(CopyButton, {
text: row.ipaddr,
style: { marginLeft: "2px" },
})
: null,
]),
},
{
prop: "name",
label: "用户名",
minWidth: 100,
showOverflowTooltip: true,
},
{
prop: "user_name",
label: "账号",
minWidth: 100,
showOverflowTooltip: true,
},
{
prop: "login_location",
label: "登录位置",
minWidth: 220,
showOverflowTooltip: true,
},
{
prop: "os",
label: "操作系统",
minWidth: 120,
showOverflowTooltip: true,
},
{
prop: "login_time",
label: "登录时间",
width: 180,
showOverflowTooltip: true,
},
{
prop: "operation",
label: "操作",
width: 88,
fixed: "right",
align: "right",
formatter: (row: OnlineUserTable) => {
if (!hasAuth("module_monitor:online:delete")) {
return h("span", { class: "text-g-400" }, "—");
}
return h(ElTooltip, { content: "强退", placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(ArtButtonTable, {
type: "delete",
onClick: () => kickSession(row.session_id),
}),
])
);
},
},
],
},
});
const paginationBind = computed(() => {
const p = pagination as unknown as {
current?: number;
size?: number;
total?: number;
page_no?: number;
page_size?: number;
};
return {
current: p.current ?? p.page_no ?? 1,
size: p.size ?? p.page_size ?? 20,
total: p.total ?? 0,
};
});
async function handleSearchBarSearch(params: OnlineSearchForm) {
await searchBarRef.value?.validate?.();
replaceSearchParams(buildOnlineReplaceParams(params));
getData();
}
async function onResetSearch() {
searchForm.value = {
ipaddr: undefined,
name: undefined,
login_location: undefined,
};
await resetSearchParams();
}
function handleClearAll() {
ElMessageBox.confirm("确认强制退出所有用户?", "警告", {
confirmButtonText: "确定",
cancelButtonText: "取消",
@@ -267,15 +281,17 @@ async function handleClear() {
})
.then(async () => {
try {
clearAllLoading.value = true;
await OnlineAPI.clearOnline();
refreshList();
ElMessage.success("操作成功");
await refreshData();
} catch (error: unknown) {
console.error(error);
} finally {
clearAllLoading.value = false;
}
})
.catch(() => {
ElMessageBox.close();
});
.catch(() => {});
}
</script>
@@ -1,184 +1,119 @@
<!-- 资源管理:Art + useTable -->
<template>
<div class="app-container">
<PageSearch
ref="searchRef"
:search-config="searchConfig"
@query-click="handleQueryClick"
@reset-click="handleResetClick"
<div class="art-full-height resource-monitor-page">
<ArtSearchBar
v-show="showSearchBar"
ref="searchBarRef"
v-model="searchForm"
:items="resourceSearchItems"
:rules="searchBarRules"
:is-expand="false"
:show-expand="true"
:show-reset="true"
:show-search="true"
:disabled-search="false"
:default-expanded="false"
@search="handleSearchBarSearch"
@reset="onResetSearch"
/>
<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"
<ElCard
class="resource-monitor-card flex flex-col flex-1 min-h-0 art-table-card"
:style="{ 'margin-top': showSearchBar ? '12px' : '0' }"
>
<div
class="resource-breadcrumb mb-3 shrink-0 flex flex-wrap items-center gap-2 border-b border-g-200 pb-3"
>
<ElTooltip content="资源文件管理系统: 点击路径可以快速返回上级目录">
<QuestionFilled class="mx-1 h-4 w-4" />
</ElTooltip>
<span class="text-sm">文件列表(当前路径):</span>
<div class="breadcrumb-container min-w-0 flex-1">
<ElBreadcrumb separator="/">
<ElBreadcrumbItem
v-for="(item, index) in breadcrumbList"
:key="index"
:class="{ 'is-link': index < breadcrumbList.length - 1 }"
@click="handleBreadcrumbClick(item)"
>
<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>
{{ item.name }}
</ElBreadcrumbItem>
</ElBreadcrumb>
</div>
</template>
</PageContent>
</div>
<!-- 上传对话框 -->
<EnhancedDialog
<ArtTableHeader
class="resource-toolbar shrink-0"
v-model:columns="columnChecks"
v-model:showSearchBar="showSearchBar"
:loading="loading"
@refresh="refreshData"
>
<template #left>
<ElSpace wrap>
<ElButton
v-hasPerm="['module_monitor:resource:upload']"
type="success"
plain
:icon="Plus"
@click="handleUpload"
>
上传文件
</ElButton>
<ElButton
v-hasPerm="['module_monitor:resource:create_dir']"
type="primary"
plain
:icon="FolderAdd"
@click="handleCreateDir"
>
新建文件夹
</ElButton>
<ElButton
v-hasPerm="['module_monitor:resource:delete']"
type="danger"
plain
:icon="Delete"
:disabled="selectedPaths.length === 0"
:loading="batchDeleting"
@click="handleBatchDelete"
>
批量删除
</ElButton>
<ElCheckbox
v-model="showHiddenFiles"
v-hasPerm="['module_monitor:resource:query']"
@change="onShowHiddenChange"
>
显示隐藏文件
</ElCheckbox>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 面包屑与表头已占用高度:表格高度须在独立 flex 子项内计算,否则分页会被挤出卡片 -->
<div class="resource-table-region min-h-0 flex flex-1 flex-col overflow-hidden pb-3">
<ArtTable
row-key="file_url"
:show-table-header="false"
:loading="loading"
:data="data"
:columns="columns"
:pagination="paginationBind"
@selection-change="onTableSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</div>
</ElCard>
<ArtDialog
v-model="uploadDialogVisible"
title="上传文件"
width="500px"
@close="handleUploadClose"
>
<el-upload
<ElUpload
ref="uploadRef"
:auto-upload="false"
:multiple="true"
@@ -186,75 +121,73 @@
drag
@change="handleUploadChange"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<ElIcon class="el-icon--upload"><UploadFilled /></ElIcon>
<div class="el-upload__text">
将文件拖到此处,或
<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip" style="color: red">
<div class="el-upload__tip text-error">
不支持多文件上传,单个文件不超过100MB,多文件上传,取最后一个文件上传
</div>
</template>
</el-upload>
</ElUpload>
<template #footer>
<el-button @click="uploadDialogVisible = false">取消</el-button>
<el-button
<ElButton @click="uploadDialogVisible = false">取消</ElButton>
<ElButton
v-hasPerm="['module_monitor:resource:upload']"
type="primary"
:loading="uploading"
@click="handleUploadConfirm"
>
确定上传
</el-button>
</ElButton>
</template>
</EnhancedDialog>
</ArtDialog>
<!-- 新建文件夹对话框 -->
<EnhancedDialog v-model="createDirDialogVisible" title="新建文件夹" width="400px">
<el-form :model="createDirForm" label-width="80px">
<el-form-item label="文件夹名" required>
<el-input
<ArtDialog v-model="createDirDialogVisible" title="新建文件夹" width="400px">
<ElForm :model="createDirForm" label-width="80px">
<ElFormItem label="文件夹名" required>
<ElInput
v-model="createDirForm.dir_name"
placeholder="请输入文件夹名称"
@keyup.enter="handleCreateDirConfirm"
/>
</el-form-item>
</el-form>
</ElFormItem>
</ElForm>
<template #footer>
<el-button @click="createDirDialogVisible = false">取消</el-button>
<el-button
<ElButton @click="createDirDialogVisible = false">取消</ElButton>
<ElButton
v-hasPerm="['module_monitor:resource:create_dir']"
type="primary"
@click="handleCreateDirConfirm"
>
确定
</el-button>
</ElButton>
</template>
</EnhancedDialog>
</ArtDialog>
<!-- 重命名对话框 -->
<EnhancedDialog v-model="renameDialogVisible" title="重命名" width="400px">
<el-form :model="renameForm" label-width="80px">
<el-form-item label="新名称" required>
<el-input
<ArtDialog v-model="renameDialogVisible" title="重命名" width="400px">
<ElForm :model="renameForm" label-width="80px">
<ElFormItem label="新名称" required>
<ElInput
v-model="renameForm.new_name"
placeholder="请输入新名称"
@keyup.enter="handleRenameConfirm"
/>
</el-form-item>
</el-form>
</ElFormItem>
</ElForm>
<template #footer>
<el-button @click="renameDialogVisible = false">取消</el-button>
<el-button
<ElButton @click="renameDialogVisible = false">取消</ElButton>
<ElButton
v-hasPerm="['module_monitor:resource:rename']"
type="primary"
@click="handleRenameConfirm"
>
确定
</el-button>
</ElButton>
</template>
</EnhancedDialog>
</ArtDialog>
</div>
</template>
@@ -264,83 +197,95 @@ defineOptions({
inheritAttrs: false,
});
import { ref, reactive } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Folder, Document, UploadFilled, QuestionFilled } from "@element-plus/icons-vue";
import { h, ref, reactive, computed } from "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";
ElMessage,
ElMessageBox,
ElIcon,
ElSpace,
ElTooltip,
type UploadFile,
type UploadFiles,
type UploadUserFile,
} from "element-plus";
import {
Delete,
Document,
Folder,
FolderAdd,
Plus,
QuestionFilled,
UploadFilled,
} from "@element-plus/icons-vue";
import { useTable } from "@/hooks/core/useTable";
import ArtTable from "@/components/Core/tables/art-table/index.vue";
import ArtTableHeader from "@/components/Core/tables/art-table-header/index.vue";
import ArtSearchBar from "@/components/Core/forms/art-search-bar/index.vue";
import type { SearchFormItem } from "@/components/Core/forms/art-search-bar/index.vue";
import ArtButtonTable from "@/components/Core/forms/art-button-table/index.vue";
import ArtDialog from "@/components/Core/modal/art-dialog/index.vue";
import { ResourceAPI, type ResourceItem } from "@/api/module_monitor/resource";
import type { ColumnOption } from "@/types/component";
import { useAuth } from "@/hooks/core/useAuth";
const { searchRef, contentRef, handleQueryClick, handleResetClick, refreshList } = useCrudList();
const { hasAuth } = useAuth();
type ResourceSearchForm = {
name?: string;
};
function buildResourceReplaceParams(u: ResourceSearchForm): Record<string, unknown> {
return {
name: u.name,
};
}
function fetchResourceTableList(params: Record<string, unknown>) {
return ResourceAPI.listResource({
page_no: 1,
page_size: 10,
...params,
});
}
const searchForm = ref<ResourceSearchForm>({
name: undefined,
});
const showSearchBar = ref(true);
const searchBarRef = ref<InstanceType<typeof ArtSearchBar> | null>(null);
const searchBarRules: Record<string, unknown> = {};
const resourceSearchItems = computed<SearchFormItem[]>(() => [
{
label: "关键词",
key: "name",
type: "input",
placeholder: "请输入文件名或目录名",
clearable: true,
span: 6,
},
]);
const selectedRows = ref<ResourceItem[]>([]);
const selectedPaths = computed(() => selectedRows.value.map((r) => r.file_url));
function onTableSelectionChange(rows: ResourceItem[]) {
selectedRows.value = rows;
}
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 batchDeleting = ref(false);
const uploadRef = ref();
const uploadFileList = ref<any[]>([]);
const uploadFileList = ref<UploadUserFile[]>([]);
const createDirForm = reactive({
dir_name: "",
@@ -351,10 +296,18 @@ const renameForm = reactive({
old_path: "",
});
function handleBreadcrumbClick(item: { path: string }) {
currentPath.value = item.path;
updateBreadcrumb();
refreshList();
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]}`;
}
function updateBreadcrumb() {
@@ -374,20 +327,6 @@ function updateBreadcrumb() {
];
}
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;
@@ -398,8 +337,158 @@ function handleFilePreview(file: ResourceItem) {
window.open(previewUrl, "_blank");
}
function handleSelectionChange(selection: ResourceItem[]) {
selectedItems.value = selection;
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
replaceSearchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData,
} = useTable({
core: {
apiFn: fetchResourceTableList,
apiParams: {
current: 1,
size: 20,
},
columnsFactory: (): ColumnOption<ResourceItem>[] => [
{ type: "selection", width: 48, fixed: "left" },
{ type: "globalIndex", width: 56, label: "序号" },
{
prop: "name",
label: "名称",
minWidth: 200,
formatter: (row: ResourceItem) =>
h("div", { class: "file-name flex items-center gap-2" }, [
h(ElIcon, { class: "file-icon" }, () => h(row.is_dir ? Folder : Document)),
h(
"span",
{
class:
"file-name-clickable cursor-pointer text-theme hover:text-theme/80 hover:underline",
onClick: () => handleFileNameClick(row),
},
row.name
),
]),
},
{
prop: "size",
label: "大小",
minWidth: 120,
align: "center",
formatter: (row: ResourceItem) => (row.is_dir ? "" : h("span", formatFileSize(row.size))),
},
{
prop: "modified_time",
label: "修改时间",
minWidth: 180,
sortable: true,
showOverflowTooltip: true,
},
{
prop: "operation",
label: "操作",
width: 220,
fixed: "right",
align: "center",
formatter: (row: ResourceItem) =>
h(
ElSpace,
{ wrap: true, size: 4 },
() =>
[
!row.is_dir && hasAuth("module_monitor:resource:download")
? h(ElTooltip, { content: "下载", placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(ArtButtonTable, {
type: "view",
icon: "ri:download-line",
onClick: () => void handleDownload(row),
}),
])
)
: null,
hasAuth("module_monitor:resource:rename")
? h(ElTooltip, { content: "重命名", placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(ArtButtonTable, {
type: "edit",
onClick: () => void handleRenameOpen(row),
}),
])
)
: null,
hasAuth("module_monitor:resource:delete")
? h(ElTooltip, { content: "删除", placement: "top" }, () =>
h("span", { class: "inline-flex" }, [
h(ArtButtonTable, {
type: "delete",
onClick: () => void handleDelete(row),
}),
])
)
: null,
].filter(Boolean) as ReturnType<typeof h>[]
),
},
],
},
});
function handleBreadcrumbClick(item: { path: string }) {
currentPath.value = item.path;
updateBreadcrumb();
void getData();
}
function handleFileNameClick(row: ResourceItem) {
if (row.is_dir) {
if (currentPath.value === "/") {
currentPath.value = row.name;
} else {
currentPath.value = `${currentPath.value}/${row.name}`;
}
updateBreadcrumb();
void getData();
} else {
handleFilePreview(row);
}
}
const paginationBind = computed(() => {
const p = pagination as unknown as {
current?: number;
size?: number;
total?: number;
page_no?: number;
page_size?: number;
};
return {
current: p.current ?? p.page_no ?? 1,
size: p.size ?? p.page_size ?? 20,
total: p.total ?? 0,
};
});
async function handleSearchBarSearch(params: ResourceSearchForm) {
await searchBarRef.value?.validate?.();
replaceSearchParams(buildResourceReplaceParams(params));
getData();
}
async function onResetSearch() {
searchForm.value = { name: undefined };
await resetSearchParams();
}
function onShowHiddenChange() {
getData();
}
function handleUpload() {
@@ -407,8 +496,8 @@ function handleUpload() {
uploadFileList.value = [];
}
function handleUploadChange(_file: unknown, fileList: unknown[]) {
uploadFileList.value = fileList as any[];
function handleUploadChange(_file: UploadFile, fileList: UploadFiles) {
uploadFileList.value = fileList as UploadUserFile[];
}
async function handleUploadConfirm() {
@@ -420,7 +509,7 @@ async function handleUploadConfirm() {
try {
uploading.value = true;
const formData = new FormData();
uploadFileList.value.forEach((file: { raw?: Blob }) => {
uploadFileList.value.forEach((file) => {
const raw = file.raw;
if (raw) formData.append("file", raw);
});
@@ -430,7 +519,7 @@ async function handleUploadConfirm() {
await ResourceAPI.uploadFile(formData);
uploadDialogVisible.value = false;
refreshList();
await refreshData();
} catch (error) {
console.error("Upload error:", error);
} finally {
@@ -461,14 +550,34 @@ async function handleCreateDirConfirm() {
dir_name: createDirForm.dir_name.trim(),
});
createDirDialogVisible.value = false;
refreshList();
await refreshData();
} catch (error) {
console.error("Create directory error:", error);
}
}
function onShowHiddenChange() {
refreshList();
function handleRenameOpen(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;
await refreshData();
} catch (error) {
console.error("Rename error:", error);
}
}
async function handleDownload(item: ResourceItem) {
@@ -488,30 +597,6 @@ async function handleDownload(item: ResourceItem) {
}
}
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} 吗?`, "确认删除", {
@@ -521,7 +606,8 @@ async function handleDelete(item: ResourceItem) {
});
await ResourceAPI.deleteResource([item.file_url]);
refreshList();
selectedRows.value = [];
await refreshData();
} catch (error) {
if (error !== "cancel") {
console.error("Delete error:", error);
@@ -530,14 +616,14 @@ async function handleDelete(item: ResourceItem) {
}
async function handleBatchDelete() {
if (selectedItems.value.length === 0) {
if (selectedRows.value.length === 0) {
ElMessage.warning("请选择要删除的文件");
return;
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${selectedItems.value.length} 个文件吗?`,
`确定要删除选中的 ${selectedRows.value.length} 个文件吗?`,
"确认删除",
{
confirmButtonText: "确定",
@@ -546,54 +632,27 @@ async function handleBatchDelete() {
}
);
const paths = selectedItems.value.map((item) => item.file_url);
batchDeleting.value = true;
const paths = selectedRows.value.map((item) => item.file_url);
await ResourceAPI.deleteResource(paths);
refreshList();
selectedRows.value = [];
await refreshData();
} catch (error) {
if (error !== "cancel") {
console.error("Batch delete error:", error);
}
} finally {
batchDeleting.value = false;
}
}
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 {
.resource-monitor-page :deep(.resource-monitor-card.el-card > .el-card__body) {
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;
}
}
}
flex: 1;
flex-direction: column;
min-height: 0;
}
:deep(.el-breadcrumb__item) {
@@ -1,51 +1,51 @@
<template>
<div class="app-container">
<el-row :gutter="16">
<el-col :span="12" class="mb-4">
<el-card :loading="loading" shadow="hover">
<ElRow :gutter="16">
<ElCol :span="12" class="mb-4">
<ElCard :loading="loading" shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Cpu /></el-icon>
<ElIcon><Cpu /></ElIcon>
<span class="flex items-center gap-2">CPU使用情况</span>
<el-tooltip content="展示CPU核心数及使用率">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<ElTooltip content="展示CPU核心数及使用率">
<ElIcon><QuestionFilled /></ElIcon>
</ElTooltip>
</div>
</template>
<el-row :gutter="16">
<ElRow :gutter="16">
<!-- CPU核心数卡片 -->
<el-col :span="12">
<el-card shadow="hover">
<ElCol :span="12">
<ElCard shadow="hover">
<span>核心数</span>
<el-tooltip :content="(server.cpu?.cpu_num || 0).toFixed(1)">
<ElTooltip :content="(server.cpu?.cpu_num || 0).toFixed(1)">
<div class="text-center mb-4">
<el-progress
<ElProgress
type="circle"
:percentage="100"
:format="() => `${server.cpu?.cpu_num || 0}`"
/>
</div>
</el-tooltip>
<el-descriptions :column="1" border>
<el-descriptions-item label="总核心数">
</ElTooltip>
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="总核心数">
{{ server.cpu?.cpu_num || 0 }}
</el-descriptions-item>
<el-descriptions-item label="已用核心">
</ElDescriptionsItem>
<ElDescriptionsItem label="已用核心">
{{ Math.floor(((server.cpu?.used || 0) * server.cpu?.cpu_num) / 100) }}
</el-descriptions-item>
<el-descriptions-item label="空闲核心">
</ElDescriptionsItem>
<ElDescriptionsItem label="空闲核心">
{{ Math.floor(((server.cpu?.free || 0) * server.cpu?.cpu_num) / 100) }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</ElCol>
<!-- CPU使用率卡片 -->
<el-col :span="12">
<el-card shadow="hover" class="h-full">
<ElCol :span="12">
<ElCard shadow="hover" class="h-full">
<span>使用率</span>
<el-tooltip :content="(server.cpu?.used || 0).toFixed(1) + '%'">
<ElTooltip :content="(server.cpu?.used || 0).toFixed(1) + '%'">
<div class="text-center mb-4">
<el-progress
<ElProgress
type="circle"
:percentage="server.cpu?.used || 0"
:status="
@@ -57,43 +57,43 @@
"
/>
</div>
</el-tooltip>
<el-descriptions :column="1" border>
<el-descriptions-item label="用户使用率">
</ElTooltip>
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="用户使用率">
{{ (server.cpu?.used || 0).toFixed(1) + "%" }}
</el-descriptions-item>
<el-descriptions-item label="系统使用率">
</ElDescriptionsItem>
<ElDescriptionsItem label="系统使用率">
{{ (server.cpu?.sys || 0).toFixed(1) + "%" }}
</el-descriptions-item>
<el-descriptions-item label="当前空闲率">
</ElDescriptionsItem>
<ElDescriptionsItem label="当前空闲率">
{{ (server.cpu?.free || 0).toFixed(1) + "%" }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</el-card>
</el-col>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</ElCol>
</ElRow>
</ElCard>
</ElCol>
<el-col :span="12" class="mb-4">
<el-card :loading="loading" shadow="hover">
<ElCol :span="12" class="mb-4">
<ElCard :loading="loading" shadow="hover">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Memo /></el-icon>
<ElIcon><Memo /></ElIcon>
<span>内存使用情况</span>
<el-tooltip content="展示系统内存和Python程序内存使用情况">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<ElTooltip content="展示系统内存和Python程序内存使用情况">
<ElIcon><QuestionFilled /></ElIcon>
</ElTooltip>
</div>
</template>
<el-row :gutter="16">
<ElRow :gutter="16">
<!-- 系统内存卡片 -->
<el-col :span="12">
<el-card shadow="hover" class="h-full">
<ElCol :span="12">
<ElCard shadow="hover" class="h-full">
<span>系统内存</span>
<el-tooltip :content="(server.mem?.usage || 0).toFixed(1) + '%'">
<ElTooltip :content="(server.mem?.usage || 0).toFixed(1) + '%'">
<div class="text-center mb-4">
<el-progress
<ElProgress
type="circle"
:percentage="server.mem?.usage || 0"
:status="
@@ -105,27 +105,27 @@
"
/>
</div>
</el-tooltip>
<el-descriptions :column="1" border>
<el-descriptions-item label="总内存">
</ElTooltip>
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="总内存">
{{ server.mem?.total }}
</el-descriptions-item>
<el-descriptions-item label="已用内存">
</ElDescriptionsItem>
<ElDescriptionsItem label="已用内存">
{{ server.mem?.used }}
</el-descriptions-item>
<el-descriptions-item label="空闲内存">
</ElDescriptionsItem>
<ElDescriptionsItem label="空闲内存">
{{ server.mem?.free }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</ElCol>
<!-- Python内存卡片 -->
<el-col :span="12">
<el-card shadow="hover" class="h-full">
<ElCol :span="12">
<ElCard shadow="hover" class="h-full">
<span>Python内存</span>
<el-tooltip :content="(server.py?.memory_usage || 0).toFixed(1) + '%'">
<ElTooltip :content="(server.py?.memory_usage || 0).toFixed(1) + '%'">
<div class="text-center mb-4">
<el-progress
<ElProgress
type="circle"
:percentage="server.py?.memory_usage || 0"
:status="
@@ -137,111 +137,111 @@
"
/>
</div>
</el-tooltip>
<el-descriptions :column="1" border>
<el-descriptions-item label="总内存">
</ElTooltip>
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="总内存">
{{ server.py?.memory_total }}
</el-descriptions-item>
<el-descriptions-item label="已用内存">
</ElDescriptionsItem>
<ElDescriptionsItem label="已用内存">
{{ server.py?.memory_used }}
</el-descriptions-item>
<el-descriptions-item label="空闲内存">
</ElDescriptionsItem>
<ElDescriptionsItem label="空闲内存">
{{ server.py?.memory_free }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</el-card>
</el-col>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</ElCol>
</ElRow>
</ElCard>
</ElCol>
<el-col :span="24" class="mb-4">
<el-card :loading="loading">
<ElCol :span="24" class="mb-4">
<ElCard :loading="loading">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Monitor /></el-icon>
<ElIcon><Monitor /></ElIcon>
<span class="font-medium">服务器基本信息</span>
<el-tooltip content="展示服务器基本配置信息">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<ElTooltip content="展示服务器基本配置信息">
<ElIcon><QuestionFilled /></ElIcon>
</ElTooltip>
</div>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="服务器名称">
<ElDescriptions :column="2" border>
<ElDescriptionsItem label="服务器名称">
{{ server.sys?.computer_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="操作系统">
</ElDescriptionsItem>
<ElDescriptionsItem label="操作系统">
{{ server.sys?.os_name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="服务器IP">
</ElDescriptionsItem>
<ElDescriptionsItem label="服务器IP">
{{ server.sys?.computer_ip || "-" }}
</el-descriptions-item>
<el-descriptions-item label="系统架构">
</ElDescriptionsItem>
<ElDescriptionsItem label="系统架构">
{{ server.sys?.os_arch || "-" }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</ElCol>
<el-col :span="24" class="mb-4">
<el-card :loading="loading" class="shadow-sm">
<ElCol :span="24" class="mb-4">
<ElCard :loading="loading" class="shadow-sm">
<template #header>
<div class="flex items-center gap-2">
<el-icon><Dish /></el-icon>
<ElIcon><Dish /></ElIcon>
<span class="font-medium">Python运行环境</span>
<el-tooltip content="展示Python环境配置及运行状态">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<ElTooltip content="展示Python环境配置及运行状态">
<ElIcon><QuestionFilled /></ElIcon>
</ElTooltip>
</div>
</template>
<el-descriptions :column="3" border>
<el-descriptions-item label="Python名称">
<ElDescriptions :column="3" border>
<ElDescriptionsItem label="Python名称">
{{ server.py?.name || "-" }}
</el-descriptions-item>
<el-descriptions-item label="Python版本">
</ElDescriptionsItem>
<ElDescriptionsItem label="Python版本">
{{ server.py?.version || "-" }}
</el-descriptions-item>
<el-descriptions-item label="启动时间">
</ElDescriptionsItem>
<ElDescriptionsItem label="启动时间">
{{ server.py?.start_time || "-" }}
</el-descriptions-item>
<el-descriptions-item label="运行时长">
</ElDescriptionsItem>
<ElDescriptionsItem label="运行时长">
{{ server.py?.run_time || "-" }}
</el-descriptions-item>
<el-descriptions-item label="安装路径">
</ElDescriptionsItem>
<ElDescriptionsItem label="安装路径">
{{ server.py?.home || "-" }}
</el-descriptions-item>
<el-descriptions-item label="项目路径">
</ElDescriptionsItem>
<ElDescriptionsItem label="项目路径">
{{ server.sys?.user_dir || "-" }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</ElDescriptionsItem>
</ElDescriptions>
</ElCard>
</ElCol>
<el-col :span="24">
<el-card :loading="loading">
<ElCol :span="24">
<ElCard :loading="loading">
<template #header>
<div class="flex items-center gap-2">
<el-icon>
<ElIcon>
<Files />
</el-icon>
</ElIcon>
<span class="font-medium">磁盘使用情况</span>
<el-tooltip content="展示磁盘空间使用详情">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
<ElTooltip content="展示磁盘空间使用详情">
<ElIcon><QuestionFilled /></ElIcon>
</ElTooltip>
</div>
</template>
<el-table :data="server.disks" border stripe>
<ElTable :data="server.disks">
<template #empty>
<el-empty :image-size="80" description="暂无数据" />
<ElEmpty :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">
<ElTableColumn label="盘符路径" prop="dir_name" :show-overflow-tooltip="true" />
<ElTableColumn label="文件系统" prop="sys_type_name" align="center" width="100" />
<ElTableColumn label="盘符名称" prop="type_name" />
<ElTableColumn prop="usage" label="使用率" align="center">
<template #default="{ row }">
<!-- 使用 element-plus 的 Progress 组件 -->
<div>
<el-progress
<ElProgress
:percentage="Number(row.usage)"
:status="row.usage > 80 ? 'exception' : row.usage > 60 ? 'warning' : 'success'"
:text-inside="true"
@@ -249,14 +249,14 @@
/>
</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>
</ElTableColumn>
<ElTableColumn label="总大小" prop="total" align="center" width="100" />
<ElTableColumn label="可用大小" prop="free" align="center" width="100" />
<ElTableColumn label="已用大小" prop="used" align="center" width="100" />
</ElTable>
</ElCard>
</ElCol>
</ElRow>
</div>
</template>