kindergarten_java/reading-platform-frontend/src/views/school/settings/OperationLogView.vue
zhonghua 6b65bd656f fix(school): 校园端页面添加内边距,与其他端保持一致
- 为 Dashboard、教师/学生/家长/班级管理、课程、反馈、报告、成长档案、设置等页面添加 padding: 24px
- 任务、排期、课表、操作日志等页面补充 padding 与背景样式
- 课程详情、校本课程包详情页添加内边距
- 统一校园端内容区视觉与 admin/teacher 端一致

Made-with: Cursor
2026-03-16 14:25:05 +08:00

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>