- 为 Dashboard、教师/学生/家长/班级管理、课程、反馈、报告、成长档案、设置等页面添加 padding: 24px - 任务、排期、课表、操作日志等页面补充 padding 与背景样式 - 课程详情、校本课程包详情页添加内边距 - 统一校园端内容区视觉与 admin/teacher 端一致 Made-with: Cursor
333 lines
9.0 KiB
Vue
333 lines
9.0 KiB
Vue
<template>
|
|
<div class="operation-log-view">
|
|
<div class="page-header">
|
|
<h2>操作日志</h2>
|
|
</div>
|
|
|
|
<!-- 筛选区 -->
|
|
<div class="filter-section">
|
|
<a-space wrap>
|
|
<a-select
|
|
v-model:value="filters.module"
|
|
placeholder="选择模块"
|
|
allowClear
|
|
style="width: 150px"
|
|
@change="loadLogs"
|
|
>
|
|
<a-select-option v-for="module in modules" :key="module" :value="module">
|
|
{{ module }}
|
|
</a-select-option>
|
|
</a-select>
|
|
<a-select
|
|
v-model:value="filters.action"
|
|
placeholder="选择操作"
|
|
allowClear
|
|
style="width: 150px"
|
|
@change="loadLogs"
|
|
>
|
|
<a-select-option v-for="action in actions" :key="action" :value="action">
|
|
{{ action }}
|
|
</a-select-option>
|
|
</a-select>
|
|
<a-range-picker
|
|
:value="dateRange"
|
|
@change="handleDateChange"
|
|
/>
|
|
<a-button @click="loadLogs">
|
|
<template #icon><SearchOutlined /></template>
|
|
查询
|
|
</a-button>
|
|
</a-space>
|
|
</div>
|
|
|
|
<!-- 统计卡片 -->
|
|
<div class="stats-section">
|
|
<a-row :gutter="16">
|
|
<a-col :span="6">
|
|
<a-card size="small">
|
|
<a-statistic title="总操作数" :value="stats.total" />
|
|
</a-card>
|
|
</a-col>
|
|
<a-col :span="18">
|
|
<a-card size="small" title="模块分布">
|
|
<div class="module-stats">
|
|
<a-tag v-for="mod in stats.modules" :key="mod.name" color="blue">
|
|
{{ mod.name }}: {{ mod.count }}
|
|
</a-tag>
|
|
<span v-if="stats.modules.length === 0">暂无数据</span>
|
|
</div>
|
|
</a-card>
|
|
</a-col>
|
|
</a-row>
|
|
</div>
|
|
|
|
<!-- 日志列表 -->
|
|
<a-table
|
|
:columns="columns"
|
|
:data-source="logs"
|
|
:loading="loading"
|
|
:pagination="pagination"
|
|
rowKey="id"
|
|
@change="handleTableChange"
|
|
>
|
|
<template #bodyCell="{ column, record }">
|
|
<template v-if="column.key === 'userType'">
|
|
<a-tag :color="getUserTypeColor(record.userType)">{{ record.userType }}</a-tag>
|
|
</template>
|
|
<template v-if="column.key === 'module'">
|
|
<a-tag color="geekblue">{{ record.module }}</a-tag>
|
|
</template>
|
|
<template v-if="column.key === 'action'">
|
|
<a-tag color="purple">{{ record.action }}</a-tag>
|
|
</template>
|
|
<template v-if="column.key === 'createdAt'">
|
|
{{ formatDateTime(record.createdAt) }}
|
|
</template>
|
|
<template v-if="column.key === 'actions'">
|
|
<a-button type="link" size="small" @click="showDetailModal(record as any)">详情</a-button>
|
|
</template>
|
|
</template>
|
|
</a-table>
|
|
|
|
<!-- 详情弹窗 -->
|
|
<a-modal
|
|
v-model:open="detailModalVisible"
|
|
title="操作日志详情"
|
|
:footer="null"
|
|
width="700px"
|
|
>
|
|
<a-descriptions :column="2" bordered size="small" v-if="selectedLog">
|
|
<a-descriptions-item label="操作用户">
|
|
{{ selectedLog.userType }} (ID: {{ selectedLog.userId }})
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="操作时间">
|
|
{{ formatDateTime(selectedLog.createdAt) }}
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="操作模块">
|
|
<a-tag color="geekblue">{{ selectedLog.module }}</a-tag>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="操作类型">
|
|
<a-tag color="purple">{{ selectedLog.action }}</a-tag>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="目标ID">
|
|
{{ selectedLog.targetId || '-' }}
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="IP地址">
|
|
{{ selectedLog.ipAddress || '-' }}
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="操作描述" :span="2">
|
|
{{ selectedLog.description }}
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="变更前数据" :span="2">
|
|
<pre class="json-data">{{ formatJson(selectedLog.oldValue) }}</pre>
|
|
</a-descriptions-item>
|
|
<a-descriptions-item label="变更后数据" :span="2">
|
|
<pre class="json-data">{{ formatJson(selectedLog.newValue) }}</pre>
|
|
</a-descriptions-item>
|
|
</a-descriptions>
|
|
</a-modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, onMounted } from 'vue';
|
|
import { message } from 'ant-design-vue';
|
|
import type { TableProps } from 'ant-design-vue';
|
|
import dayjs, { type Dayjs } from 'dayjs';
|
|
import { SearchOutlined } from '@ant-design/icons-vue';
|
|
import { getOperationLogs, getOperationLogStats } from '@/api/school';
|
|
|
|
interface OperationLog {
|
|
id: number;
|
|
tenantId: number;
|
|
userId: number;
|
|
userType: string;
|
|
action: string;
|
|
module: string;
|
|
description: string;
|
|
targetId: number | null;
|
|
oldValue: string | null;
|
|
newValue: string | null;
|
|
ipAddress: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
// 数据
|
|
const loading = ref(false);
|
|
const logs = ref<OperationLog[]>([]);
|
|
const modules = ref<string[]>(['排课管理', '教师管理', '学生管理', '班级管理', '课程管理']);
|
|
const actions = ref<string[]>(['创建', '更新', '删除', '创建排课', '批量创建排课', '取消排课']);
|
|
|
|
// 筛选
|
|
const filters = reactive({
|
|
module: undefined as string | undefined,
|
|
action: undefined as string | undefined,
|
|
startDate: undefined as string | undefined,
|
|
endDate: undefined as string | undefined,
|
|
});
|
|
const dateRange = ref<[Dayjs, Dayjs] | undefined>(undefined);
|
|
|
|
// 统计
|
|
const stats = reactive({
|
|
total: 0,
|
|
modules: [] as { name: string; count: number }[],
|
|
});
|
|
|
|
// 分页
|
|
const pagination = reactive({
|
|
current: 1,
|
|
pageSize: 20,
|
|
total: 0,
|
|
showSizeChanger: true,
|
|
showTotal: (total: number) => `共 ${total} 条`,
|
|
});
|
|
|
|
// 表格列
|
|
const columns = [
|
|
{ title: '用户类型', key: 'userType', width: 100 },
|
|
{ title: '模块', key: 'module', width: 120 },
|
|
{ title: '操作', key: 'action', width: 120 },
|
|
{ title: '描述', dataIndex: 'description', key: 'description', ellipsis: true },
|
|
{ title: 'IP地址', dataIndex: 'ipAddress', key: 'ipAddress', width: 130 },
|
|
{ title: '时间', key: 'createdAt', width: 180 },
|
|
{ title: '操作', key: 'actions', width: 80 },
|
|
];
|
|
|
|
// 详情弹窗
|
|
const detailModalVisible = ref(false);
|
|
const selectedLog = ref<OperationLog | null>(null);
|
|
|
|
// 加载日志
|
|
const loadLogs = async () => {
|
|
loading.value = true;
|
|
try {
|
|
const res = await getOperationLogs({
|
|
pageNum: pagination.current,
|
|
pageSize: pagination.pageSize,
|
|
...filters,
|
|
});
|
|
logs.value = res.list;
|
|
pagination.total = res.total;
|
|
} catch (error) {
|
|
message.error('加载日志失败');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// 加载统计
|
|
const loadStats = async () => {
|
|
try {
|
|
const res = await getOperationLogStats(filters.startDate, filters.endDate);
|
|
stats.total = res.total;
|
|
stats.modules = res.modules || [];
|
|
} catch (error) {
|
|
console.error('加载统计失败', error);
|
|
}
|
|
};
|
|
|
|
// 事件处理
|
|
const handleTableChange: TableProps['onChange'] = (pag) => {
|
|
pagination.current = pag.current || 1;
|
|
pagination.pageSize = pag.pageSize || 20;
|
|
loadLogs();
|
|
};
|
|
|
|
const handleDateChange = (dates: any) => {
|
|
if (dates && dates.length === 2) {
|
|
dateRange.value = dates;
|
|
filters.startDate = dates[0].format('YYYY-MM-DD');
|
|
filters.endDate = dates[1].format('YYYY-MM-DD');
|
|
} else {
|
|
dateRange.value = undefined;
|
|
filters.startDate = undefined;
|
|
filters.endDate = undefined;
|
|
}
|
|
loadLogs();
|
|
loadStats();
|
|
};
|
|
|
|
const showDetailModal = async (record: OperationLog) => {
|
|
selectedLog.value = record as any;
|
|
detailModalVisible.value = true;
|
|
};
|
|
|
|
// 工具函数
|
|
const formatDateTime = (date: string | undefined) => {
|
|
if (!date) return '-';
|
|
return dayjs(date).format('YYYY-MM-DD HH:mm:ss');
|
|
};
|
|
|
|
const formatJson = (str: string | null) => {
|
|
if (!str) return '-';
|
|
try {
|
|
return JSON.stringify(JSON.parse(str), null, 2);
|
|
} catch {
|
|
return str;
|
|
}
|
|
};
|
|
|
|
const getUserTypeColor = (type: string) => {
|
|
const colors: Record<string, string> = {
|
|
admin: 'red',
|
|
school: 'blue',
|
|
teacher: 'green',
|
|
parent: 'orange',
|
|
};
|
|
return colors[type] || 'default';
|
|
};
|
|
|
|
onMounted(() => {
|
|
loadLogs();
|
|
loadStats();
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.operation-log-view {
|
|
padding: 24px;
|
|
min-height: 100vh;
|
|
background: linear-gradient(180deg, #FFF8F0 0%, #FFFFFF 100%);
|
|
|
|
.page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
|
|
h2 {
|
|
margin: 0;
|
|
}
|
|
}
|
|
|
|
.filter-section {
|
|
margin-bottom: 20px;
|
|
padding: 16px;
|
|
background: #fafafa;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.stats-section {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.module-stats {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.json-data {
|
|
max-height: 200px;
|
|
overflow: auto;
|
|
background: #f5f5f5;
|
|
padding: 8px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
margin: 0;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
}
|
|
</style>
|