物业代码生成

This commit is contained in:
2025-06-18 11:03:42 +08:00
commit 1262d4c745
1881 changed files with 249599 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<!-- 建议通过菜单配置成外链/内嵌 达到相同的效果且灵活性更高 -->
<template>
<iframe
class="size-full"
src="http://localhost:9090/admin/applications"
></iframe>
</template>

View File

@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onActivated, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
interface Props {
data?: { name: string; value: string }[];
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
});
const chartRef = ref<EchartsUIType>();
const { renderEcharts, resize } = useEcharts(chartRef);
watch(
() => props.data,
() => {
if (!chartRef.value) return;
setEchartsOption(props.data);
},
{ immediate: true },
);
onMounted(() => {
setEchartsOption(props.data);
});
/**
* 从其他页面切换回来会有一个奇怪的动画效果 需要调用resize
* 该饼图组件需要关闭animation
*/
onActivated(() => resize(false));
type EChartsOption = Parameters<typeof renderEcharts>['0'];
function setEchartsOption(data: any[]) {
const option: EChartsOption = {
series: [
{
animationDuration: 1000,
animationEasing: 'cubicInOut',
center: ['50%', '50%'],
data,
name: '命令',
radius: [15, 95],
roseType: 'radius',
type: 'pie',
},
],
tooltip: {
formatter: '{a} <br/>{b} : {c} ({d}%)',
trigger: 'item',
},
};
renderEcharts(option);
}
</script>
<template>
<EchartsUI ref="chartRef" height="400px" width="100%" />
</template>

View File

@@ -0,0 +1,3 @@
export { default as CommandChart } from './command-chart.vue';
export { default as MemoryChart } from './memory-chart.vue';
export { default as RedisDescription } from './redis-description.vue';

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onActivated, onMounted, ref, watch } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
interface Props {
data?: string;
}
const props = withDefaults(defineProps<Props>(), {
data: '0',
});
const memoryHtmlRef = ref<EchartsUIType>();
const { renderEcharts, resize } = useEcharts(memoryHtmlRef);
watch(
() => props.data,
() => {
if (!memoryHtmlRef.value) return;
setEchartsOption(props.data);
},
{ immediate: true },
);
onMounted(() => {
setEchartsOption(props.data);
});
// 从其他页面切换回来会有一个奇怪的动画效果 需要调用resize
onActivated(resize);
/**
* 获取最近的十的幂次
* 该函数用于寻找大于给定数字num的最近的10的幂次
* 主要解决的问题是确定一个数附近较大的十的幂次,这在某些算法中很有用
*
* @param num {number} 输入的数字,用于寻找最近的十的幂次
*/
function getNearestPowerOfTen(num: number) {
let power = 10;
while (power <= num) {
power *= 10;
}
return power;
}
type EChartsOption = Parameters<typeof renderEcharts>['0'];
function setEchartsOption(value: string) {
// x10
const formattedValue = Math.floor(Number.parseFloat(value));
// 最大值 10以内取10 100以内取100 以此类推
const max = getNearestPowerOfTen(formattedValue);
const options: EChartsOption = {
series: [
{
animation: true,
animationDuration: 1000,
data: [
{
name: '内存消耗',
value: Number.parseFloat(value),
},
],
detail: {
formatter: `${value}M`,
valueAnimation: true,
},
max,
min: 0,
name: '峰值',
progress: {
show: true,
},
type: 'gauge',
},
],
tooltip: {
formatter: `{b} <br/>{a} : ${value}M`,
},
};
renderEcharts(options);
}
</script>
<template>
<EchartsUI ref="memoryHtmlRef" height="400px" width="100%" />
</template>

View File

@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { RedisInfo } from '#/api/monitor/cache';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
interface IRedisInfo extends RedisInfo {
dbSize: string;
}
defineProps<{ data: IRedisInfo }>();
</script>
<template>
<Descriptions
bordered
:column="{ lg: 4, md: 3, sm: 1, xl: 4, xs: 1 }"
size="small"
>
<DescriptionsItem label="redis版本">
{{ data.redis_version }}
</DescriptionsItem>
<DescriptionsItem label="redis模式">
{{ data.redis_mode === 'standalone' ? '单机模式' : '集群模式' }}
</DescriptionsItem>
<DescriptionsItem label="tcp端口">
{{ data.tcp_port }}
</DescriptionsItem>
<DescriptionsItem label="客户端数">
{{ data.connected_clients }}
</DescriptionsItem>
<DescriptionsItem label="运行时间">
{{ data.uptime_in_days }}
</DescriptionsItem>
<DescriptionsItem label="使用内存">
{{ data.used_memory_human }}
</DescriptionsItem>
<DescriptionsItem label="使用CPU">
{{ Number.parseFloat(data?.used_cpu_user_children ?? '0').toFixed(2) }}
</DescriptionsItem>
<DescriptionsItem label="内存配置">
{{ data.maxmemory_human }}
</DescriptionsItem>
<DescriptionsItem label="AOF是否开启">
{{ data.aof_enabled === '0' ? '否' : '是' }}
</DescriptionsItem>
<DescriptionsItem label="RDB是否成功">
{{ data.rdb_last_bgsave_status }}
</DescriptionsItem>
<DescriptionsItem label="key数量">
{{ data.dbSize }}
</DescriptionsItem>
<DescriptionsItem label="网络入口/出口">
{{
`${data.instantaneous_input_kbps}kps/${data.instantaneous_output_kbps}kps`
}}
</DescriptionsItem>
</Descriptions>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import type { RedisInfo } from '#/api/monitor/cache';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { CommandLineIcon, MemoryIcon, RedisIcon } from '@vben/icons';
import { Button, Card, Col, Row } from 'ant-design-vue';
import { redisCacheInfo } from '#/api/monitor/cache';
import { CommandChart, MemoryChart, RedisDescription } from './components';
const baseSpan = { lg: 12, md: 24, sm: 24, xl: 12, xs: 24 };
const chartData = reactive<{
command: { name: string; value: string }[];
memory: string;
}>({
command: [],
memory: '0',
});
interface IRedisInfo extends RedisInfo {
dbSize: string;
}
const redisInfo = ref<IRedisInfo>();
onMounted(async () => {
await loadInfo();
});
async function loadInfo() {
try {
const ret = await redisCacheInfo();
// 单位MB 保留两位小数
const usedMemory = (
Number.parseInt(ret.info.used_memory!) /
1024 /
1024
).toFixed(2);
chartData.memory = usedMemory;
// 命令统计
chartData.command = ret.commandStats;
console.log(chartData.command);
// redis信息
redisInfo.value = { ...ret.info, dbSize: String(ret.dbSize) };
} catch (error) {
console.warn(error);
}
}
</script>
<template>
<Page>
<Row :gutter="[15, 15]">
<Col :span="24">
<Card size="small">
<template #title>
<div class="flex items-center justify-start gap-[6px]">
<RedisIcon class="size-[16px]" />
<span>redis信息</span>
</div>
</template>
<template #extra>
<Button size="small" @click="loadInfo">
<div class="flex">
<span class="icon-[charm--refresh]"></span>
</div>
</Button>
</template>
<RedisDescription v-if="redisInfo" :data="redisInfo" />
</Card>
</Col>
<Col v-bind="baseSpan">
<Card size="small">
<template #title>
<div class="flex items-center gap-[6px]">
<CommandLineIcon class="size-[16px]" />
<span>命令统计</span>
</div>
</template>
<CommandChart
v-if="chartData.command.length > 0"
:data="chartData.command"
/>
</Card>
</Col>
<Col v-bind="baseSpan">
<Card size="small">
<template #title>
<div class="flex items-center justify-start gap-[6px]">
<MemoryIcon class="size-[16px]" />
<span>内存占用</span>
</div>
</template>
<MemoryChart
v-if="chartData.memory !== '0'"
:data="chartData.memory"
/>
</Card>
</Col>
</Row>
</Page>
</template>

View File

@@ -0,0 +1,108 @@
import type { VNode } from 'vue';
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { DictEnum } from '@vben/constants';
import { getDictOptions } from '#/utils/dict';
import { renderBrowserIcon, renderDict, renderOsIcon } from '#/utils/render';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'ipaddr',
label: 'IP地址',
},
{
component: 'Input',
fieldName: 'userName',
label: '用户账号',
},
{
component: 'Select',
componentProps: {
options: getDictOptions(DictEnum.SYS_COMMON_STATUS),
},
fieldName: 'status',
label: '登录状态',
},
{
component: 'RangePicker',
fieldName: 'dateTime',
label: '登录日期',
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{
title: '用户账号',
field: 'userName',
},
{
title: '登录平台',
field: 'clientKey',
},
{
title: 'IP地址',
field: 'ipaddr',
},
{
title: 'IP地点',
field: 'loginLocation',
width: 200,
},
{
title: '浏览器',
field: 'browser',
slots: {
default: ({ row }) => {
return renderBrowserIcon(row.browser, true) as VNode;
},
},
},
{
title: '系统',
field: 'os',
slots: {
default: ({ row }) => {
/**
* Windows 10 or Windows Server 2016 太长了 分割一下 详情依旧能看到详细的
*/
let value = row.os;
if (value) {
const split = value.split(' or ');
if (split.length === 2) {
value = split[0];
}
}
return renderOsIcon(value, true) as VNode;
},
},
},
{
title: '登录结果',
field: 'status',
slots: {
default: ({ row }) => {
return renderDict(row.status, DictEnum.SYS_COMMON_STATUS);
},
},
},
{
title: '信息',
field: 'msg',
},
{
title: '日期',
field: 'loginTime',
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
width: 150,
},
];

View File

@@ -0,0 +1,210 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { LoginLog } from '#/api/monitor/logininfo/model';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Modal, Popconfirm, Space } from 'ant-design-vue';
import { useVbenVxeGrid, vxeCheckboxChecked } from '#/adapter/vxe-table';
import {
loginInfoClean,
loginInfoExport,
loginInfoList,
loginInfoRemove,
userUnlock,
} from '#/api/monitor/logininfo';
import { commonDownloadExcel } from '#/utils/file/download';
import { confirmDeleteModal } from '#/utils/modal';
import { columns, querySchema } from './data';
import loginInfoModal from './login-info-modal.vue';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'dateTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
const gridOptions: VxeGridProps = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'row',
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues = {}) => {
return await loginInfoList({
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
rowConfig: {
keyField: 'infoId',
},
id: 'monitor-logininfo-index',
};
const canUnlock = ref(false);
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
checkboxChange: (e) => {
const records = e.$grid.getCheckboxRecords();
canUnlock.value = records.length === 1 && records[0]!.status === '1';
},
},
});
const [LoginInfoModal, modalApi] = useVbenModal({
connectedComponent: loginInfoModal,
});
function handlePreview(record: LoginLog) {
modalApi.setData(record);
modalApi.open();
}
function handleClear() {
confirmDeleteModal({
onValidated: async () => {
await loginInfoClean();
await tableApi.reload();
},
});
}
async function handleDelete(row: LoginLog) {
await loginInfoRemove([row.infoId]);
await tableApi.query();
}
function handleMultiDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: LoginLog) => row.infoId);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条记录吗?`,
onOk: async () => {
await loginInfoRemove(ids);
await tableApi.query();
},
});
}
async function handleUnlock() {
const records = tableApi.grid.getCheckboxRecords();
if (records.length !== 1) {
return;
}
const { userName } = records[0];
await userUnlock(userName);
await tableApi.query();
canUnlock.value = false;
tableApi.grid.clearCheckboxRow();
}
function handleDownloadExcel() {
commonDownloadExcel(
loginInfoExport,
'登录日志',
tableApi.formApi.form.values,
{
fieldMappingTime: formOptions.fieldMappingTime,
},
);
}
</script>
<template>
<Page auto-content-height>
<BasicTable table-title="登录日志列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['monitor:logininfor:remove']"
@click="handleClear"
>
{{ $t('pages.common.clear') }}
</a-button>
<a-button
v-access:code="['monitor:logininfor:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['monitor:logininfor:remove']"
@click="handleMultiDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
<a-button
:disabled="!canUnlock"
type="primary"
v-access:code="['monitor:logininfor:unlock']"
@click="handleUnlock"
>
{{ $t('pages.common.unlock') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<Space>
<ghost-button @click.stop="handlePreview(row)">
{{ $t('pages.common.info') }}
</ghost-button>
<Popconfirm
:get-popup-container="getVxePopupContainer"
placement="left"
title="确认删除?"
@confirm="() => handleDelete(row)"
>
<ghost-button
danger
v-access:code="['monitor:logininfor:remove']"
@click.stop=""
>
{{ $t('pages.common.delete') }}
</ghost-button>
</Popconfirm>
</Space>
</template>
</BasicTable>
<LoginInfoModal />
</Page>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { LoginLog } from '#/api/monitor/logininfo/model';
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { Descriptions, DescriptionsItem } from 'ant-design-vue';
import { renderBrowserIcon, renderDict, renderOsIcon } from '#/utils/render';
const loginInfo = ref<LoginLog>();
const [BasicModal, modalApi] = useVbenModal({
onOpenChange: (isOpen) => {
if (!isOpen) {
return null;
}
const record = modalApi.getData() as LoginLog;
loginInfo.value = record;
},
onClosed() {
loginInfo.value = undefined;
},
});
</script>
<template>
<BasicModal
:footer="false"
:fullscreen-button="false"
class="w-[550px]"
title="登录日志"
>
<Descriptions v-if="loginInfo" size="small" :column="1" bordered>
<DescriptionsItem label="登录状态">
<component
:is="renderDict(loginInfo.status, DictEnum.SYS_COMMON_STATUS)"
/>
</DescriptionsItem>
<DescriptionsItem label="登录平台">
{{ loginInfo.clientKey.toLowerCase() }}
</DescriptionsItem>
<DescriptionsItem label="账号信息">
{{
`账号: ${loginInfo.userName} / ${loginInfo.ipaddr} / ${loginInfo.loginLocation}`
}}
</DescriptionsItem>
<DescriptionsItem label="登录时间">
{{ loginInfo.loginTime }}
</DescriptionsItem>
<DescriptionsItem label="登录信息">
<span
class="font-semibold"
:class="{ 'text-red-500': loginInfo.status !== '0' }"
>
{{ loginInfo.msg }}
</span>
</DescriptionsItem>
<DescriptionsItem label="登录设备">
<component :is="renderOsIcon(loginInfo.os)" />
</DescriptionsItem>
<DescriptionsItem label="浏览器">
<component :is="renderBrowserIcon(loginInfo.browser)" />
</DescriptionsItem>
</Descriptions>
</BasicModal>
</template>

View File

@@ -0,0 +1,85 @@
import type { VNode } from 'vue';
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import dayjs from 'dayjs';
import { renderBrowserIcon, renderOsIcon } from '#/utils/render';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'ipaddr',
label: 'IP地址',
},
{
component: 'Input',
fieldName: 'userName',
label: '用户账号',
},
];
export const columns: VxeGridProps['columns'] = [
{
title: '登录平台',
field: 'deviceType',
},
{
title: '登录账号',
field: 'userName',
},
{
title: '部门名称',
field: 'deptName',
},
{
title: 'IP地址',
field: 'ipaddr',
},
{
title: '登录地址',
field: 'loginLocation',
},
{
title: '浏览器',
field: 'browser',
slots: {
default: ({ row }) => {
return renderBrowserIcon(row.browser, true) as VNode;
},
},
},
{
title: '系统',
field: 'os',
slots: {
default: ({ row }) => {
// Windows 10 or Windows Server 2016 太长了 分割一下 详情依旧能看到详细的
let value = row.os;
if (value) {
const split = value.split(' or ');
if (split.length === 2) {
value = split[0];
}
}
return renderOsIcon(value, true) as VNode;
},
},
},
{
title: '登录时间',
field: 'loginTime',
formatter: ({ cellValue }) => {
return dayjs(cellValue).format('YYYY-MM-DD HH:mm:ss');
},
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 'auto',
},
];

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { OnlineUser } from '#/api/monitor/online/model';
import { ref } from 'vue';
import { Page } from '@vben/common-ui';
import { getVxePopupContainer } from '@vben/utils';
import { Popconfirm } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { forceLogout, onlineList } from '#/api/monitor/online';
import { columns, querySchema } from './data';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
};
const onlineCount = ref(0);
const gridOptions: VxeGridProps = {
columns,
height: 'auto',
keepSource: true,
pagerConfig: {
enabled: false,
},
proxyConfig: {
ajax: {
query: async (_, formValues = {}) => {
const resp = await onlineList({
...formValues,
});
onlineCount.value = resp.total;
return resp;
},
},
},
scrollY: {
enabled: true,
gt: 0,
},
rowConfig: {
keyField: 'tokenId',
},
id: 'monitor-online-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({ formOptions, gridOptions });
async function handleForceOffline(row: OnlineUser) {
await forceLogout(row.tokenId);
await tableApi.query();
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable>
<template #toolbar-actions>
<div class="mr-1 pl-1 text-[1rem]">
<div>
在线用户列表 (
<span class="text-primary font-bold">{{ onlineCount }}</span>
人在线)
</div>
</div>
</template>
<template #action="{ row }">
<Popconfirm
:get-popup-container="getVxePopupContainer"
:title="`确认强制下线[${row.userName}]?`"
placement="left"
@confirm="handleForceOffline(row)"
>
<ghost-button danger>强制下线</ghost-button>
</Popconfirm>
</template>
</BasicTable>
</Page>
</template>

View File

@@ -0,0 +1,92 @@
import type { FormSchemaGetter } from '#/adapter/form';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { DictEnum } from '@vben/constants';
import { getDictOptions } from '#/utils/dict';
import { renderDict } from '#/utils/render';
export const querySchema: FormSchemaGetter = () => [
{
component: 'Input',
fieldName: 'title',
label: '系统模块',
},
{
component: 'Input',
fieldName: 'operName',
label: '操作人员',
},
{
component: 'Select',
componentProps: {
options: getDictOptions(DictEnum.SYS_OPER_TYPE),
},
fieldName: 'businessType',
label: '操作类型',
},
{
component: 'Input',
fieldName: 'operIp',
label: '操作IP',
},
{
component: 'Select',
componentProps: {
options: getDictOptions(DictEnum.SYS_COMMON_STATUS),
},
fieldName: 'status',
label: '状态',
},
{
component: 'RangePicker',
fieldName: 'createTime',
label: '操作时间',
componentProps: {
valueFormat: 'YYYY-MM-DD HH:mm:ss',
},
},
];
export const columns: VxeGridProps['columns'] = [
{ type: 'checkbox', width: 60 },
{ field: 'title', title: '系统模块' },
{
title: '操作类型',
field: 'businessType',
slots: {
default: ({ row }) => {
return renderDict(row.businessType, DictEnum.SYS_OPER_TYPE);
},
},
},
{ field: 'operName', title: '操作人员' },
{ field: 'operIp', title: 'IP地址' },
{ field: 'operLocation', title: 'IP信息' },
{
field: 'status',
title: '操作状态',
slots: {
default: ({ row }) => {
return renderDict(row.status, DictEnum.SYS_COMMON_STATUS);
},
},
},
{ field: 'operTime', title: '操作日期', sortable: true },
{
field: 'costTime',
title: '操作耗时',
sortable: true,
formatter({ cellValue }) {
return `${cellValue} ms`;
},
},
{
field: 'action',
fixed: 'right',
slots: { default: 'action' },
title: '操作',
resizable: false,
width: 'auto',
},
];

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { VbenFormProps } from '@vben/common-ui';
import type { VxeGridProps } from '#/adapter/vxe-table';
import type { PageQuery } from '#/api/common';
import type { OperationLog } from '#/api/monitor/operlog/model';
import { Page, useVbenDrawer } from '@vben/common-ui';
import { $t } from '@vben/locales';
import { Modal, Space } from 'ant-design-vue';
import {
addSortParams,
useVbenVxeGrid,
vxeCheckboxChecked,
} from '#/adapter/vxe-table';
import {
operLogClean,
operLogDelete,
operLogExport,
operLogList,
} from '#/api/monitor/operlog';
import { commonDownloadExcel } from '#/utils/file/download';
import { confirmDeleteModal } from '#/utils/modal';
import { columns, querySchema } from './data';
import operationPreviewDrawer from './operation-preview-drawer.vue';
const formOptions: VbenFormProps = {
commonConfig: {
labelWidth: 80,
componentProps: {
allowClear: true,
},
},
schema: querySchema(),
wrapperClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
// 日期选择格式化
fieldMappingTime: [
[
'createTime',
['params[beginTime]', 'params[endTime]'],
['YYYY-MM-DD 00:00:00', 'YYYY-MM-DD 23:59:59'],
],
],
};
const gridOptions: VxeGridProps<OperationLog> = {
checkboxConfig: {
// 高亮
highlight: true,
// 翻页时保留选中状态
reserve: true,
// 点击行选中
trigger: 'row',
},
columns,
height: 'auto',
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page, sorts }, formValues = {}) => {
const params: PageQuery = {
pageNum: page.currentPage,
pageSize: page.pageSize,
...formValues,
};
// 添加排序参数
addSortParams(params, sorts);
return await operLogList(params);
},
},
},
rowConfig: {
keyField: 'operId',
},
sortConfig: {
// 远程排序
remote: true,
// 支持多字段排序 默认关闭
multiple: true,
},
id: 'monitor-operlog-index',
};
const [BasicTable, tableApi] = useVbenVxeGrid({
formOptions,
gridOptions,
gridEvents: {
// 排序 重新请求接口
sortChange: () => tableApi.query(),
},
});
const [OperationPreviewDrawer, drawerApi] = useVbenDrawer({
connectedComponent: operationPreviewDrawer,
});
/**
* 预览
* @param record 操作日志记录
*/
function handlePreview(record: OperationLog) {
drawerApi.setData({ record });
drawerApi.open();
}
/**
* 清空全部日志
*/
function handleClear() {
confirmDeleteModal({
onValidated: async () => {
await operLogClean();
await tableApi.reload();
},
});
}
/**
* 删除日志
*/
async function handleDelete() {
const rows = tableApi.grid.getCheckboxRecords();
const ids = rows.map((row: OperationLog) => row.operId);
Modal.confirm({
title: '提示',
okType: 'danger',
content: `确认删除选中的${ids.length}条操作日志吗?`,
onOk: async () => {
await operLogDelete(ids);
await tableApi.query();
},
});
}
function handleDownloadExcel() {
commonDownloadExcel(operLogExport, '操作日志', tableApi.formApi.form.values, {
fieldMappingTime: formOptions.fieldMappingTime,
});
}
</script>
<template>
<Page :auto-content-height="true">
<BasicTable table-title="操作日志列表">
<template #toolbar-tools>
<Space>
<a-button
v-access:code="['monitor:operlog:remove']"
@click="handleClear"
>
{{ $t('pages.common.clear') }}
</a-button>
<a-button
v-access:code="['monitor:operlog:export']"
@click="handleDownloadExcel"
>
{{ $t('pages.common.export') }}
</a-button>
<a-button
:disabled="!vxeCheckboxChecked(tableApi)"
danger
type="primary"
v-access:code="['monitor:operlog:remove']"
@click="handleDelete"
>
{{ $t('pages.common.delete') }}
</a-button>
</Space>
</template>
<template #action="{ row }">
<ghost-button
v-access:code="['monitor:operlog:list']"
@click.stop="handlePreview(row)"
>
{{ $t('pages.common.preview') }}
</ghost-button>
</template>
</BasicTable>
<OperationPreviewDrawer />
</Page>
</template>

View File

@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { OperationLog } from '#/api/monitor/operlog/model';
import { computed, shallowRef } from 'vue';
import { useVbenDrawer } from '@vben/common-ui';
import { DictEnum } from '@vben/constants';
import { Descriptions, DescriptionsItem, Tag } from 'ant-design-vue';
import {
renderDict,
renderHttpMethodTag,
renderJsonPreview,
} from '#/utils/render';
const [BasicDrawer, drawerApi] = useVbenDrawer({
onOpenChange: handleOpenChange,
onClosed() {
currentLog.value = null;
},
});
const currentLog = shallowRef<null | OperationLog>(null);
function handleOpenChange(open: boolean) {
if (!open) {
return null;
}
const { record } = drawerApi.getData() as { record: OperationLog };
currentLog.value = record;
}
const actionInfo = computed(() => {
if (!currentLog.value) {
return '-';
}
const data = currentLog.value;
return `账号: ${data.operName} / ${data.deptName} / ${data.operIp} / ${data.operLocation}`;
});
</script>
<template>
<BasicDrawer :footer="false" class="w-[600px]" title="查看日志">
<Descriptions v-if="currentLog" size="small" bordered :column="1">
<DescriptionsItem label="日志编号" :label-style="{ minWidth: '120px' }">
{{ currentLog.operId }}
</DescriptionsItem>
<DescriptionsItem label="操作结果">
<component
:is="renderDict(currentLog.status, DictEnum.SYS_COMMON_STATUS)"
/>
</DescriptionsItem>
<DescriptionsItem label="操作模块">
<div class="flex items-center">
<Tag>{{ currentLog.title }}</Tag>
<component
:is="renderDict(currentLog.businessType, DictEnum.SYS_OPER_TYPE)"
/>
</div>
</DescriptionsItem>
<DescriptionsItem label="操作信息">
{{ actionInfo }}
</DescriptionsItem>
<DescriptionsItem label="请求信息">
<component :is="renderHttpMethodTag(currentLog.requestMethod)" />
{{ currentLog.operUrl }}
</DescriptionsItem>
<DescriptionsItem v-if="currentLog.errorMsg" label="异常信息">
<span class="font-semibold text-red-600">
{{ currentLog.errorMsg }}
</span>
</DescriptionsItem>
<DescriptionsItem label="方法">
{{ currentLog.method }}
</DescriptionsItem>
<DescriptionsItem label="请求参数">
<div class="max-h-[300px] overflow-y-auto">
<component :is="renderJsonPreview(currentLog.operParam)" />
</div>
</DescriptionsItem>
<DescriptionsItem v-if="currentLog.jsonResult" label="响应参数">
<div class="max-h-[300px] overflow-y-auto">
<component :is="renderJsonPreview(currentLog.jsonResult)" />
</div>
</DescriptionsItem>
<DescriptionsItem label="请求耗时">
{{ `${currentLog.costTime} ms` }}
</DescriptionsItem>
<DescriptionsItem label="操作时间">
{{ `${currentLog.operTime}` }}
</DescriptionsItem>
</Descriptions>
</BasicDrawer>
</template>

View File

@@ -0,0 +1,4 @@
<!-- 建议通过菜单配置成外链/内嵌 达到相同的效果且灵活性更高 -->
<template>
<iframe class="size-full" src="http://localhost:8800/snail-job"></iframe>
</template>