重构操作日志展示组件,将对话框改为抽屉式布局
This commit is contained in:
@@ -1,67 +1,125 @@
|
||||
<template>
|
||||
<el-dialog v-model="dialogVisible" title="操作日志" width="800px" :close-on-click-modal="false" append-to-body>
|
||||
<el-table :data="logList" style="width: 100%" v-loading="loading" border max-height="500">
|
||||
<el-table-column prop="createdAt" label="操作时间" width="170" />
|
||||
<el-table-column prop="operation" label="操作类型" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getOperationTagType(scope.row.operation)">{{ getOperationLabel(scope.row.operation) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="creator" label="操作人" width="120" />
|
||||
<el-table-column prop="data" label="变更内容" min-width="200">
|
||||
<template #default="scope">
|
||||
<div v-if="scope.row.data && scope.row.data.length > 0" class="change-content">
|
||||
<div v-for="(item, index) in scope.row.data" :key="index" class="change-item">
|
||||
<span class="field-name">{{ item.FieldName }}:</span>
|
||||
<el-tooltip v-if="isLongValue(item.FieldValue)" placement="top" :content="formatFieldValue(item.FieldValue)" :show-after="300">
|
||||
<span class="field-value truncate">{{ truncateValue(item.FieldValue) }}</span>
|
||||
</el-tooltip>
|
||||
<span v-else class="field-value">{{ formatFieldValue(item.FieldValue) }}</span>
|
||||
<el-drawer v-model="drawerVisible" title="操作日志" size="70%" direction="rtl" :close-on-click-modal="true" :modal="true" append-to-body>
|
||||
<div class="log-container" v-loading="loading" style="padding: 20px;">
|
||||
<div class="log-layout">
|
||||
<!-- 左侧时间线 -->
|
||||
<div class="timeline-panel">
|
||||
<el-scrollbar height="100%">
|
||||
<el-timeline>
|
||||
<el-timeline-item
|
||||
v-for="(log, index) in logList"
|
||||
:key="log.id"
|
||||
:type="getOperationTagType(log.operation)"
|
||||
:hollow="selectedLogIndex !== index"
|
||||
:timestamp="log.createdAt"
|
||||
placement="top"
|
||||
@click="selectLog(index)"
|
||||
:class="{ 'timeline-item-active': selectedLogIndex === index }"
|
||||
>
|
||||
<div class="timeline-content" @click="selectLog(index)">
|
||||
<div class="timeline-header">
|
||||
<el-tag size="small" :type="getOperationTagType(log.operation)">{{ getOperationLabel(log.operation) }}</el-tag>
|
||||
</div>
|
||||
<div class="timeline-info">
|
||||
<span class="creator">{{ log.creator }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<!-- 加载更多 -->
|
||||
<div class="load-more" v-if="logList.length > 0 && logList.length < total">
|
||||
<el-button text type="primary" @click="loadMore" :loading="loadingMore">加载更多</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="no-data">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ip_address" label="IP地址" width="140" />
|
||||
</el-table>
|
||||
<!-- 分页 -->
|
||||
<div class="mt15" style="text-align: right" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.pageNum"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="onSizeChange"
|
||||
@current-change="onCurrentChange"
|
||||
/>
|
||||
<el-empty v-if="!loading && logList.length === 0" description="暂无日志记录" />
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
<!-- 右侧详情表格 -->
|
||||
<div class="detail-panel">
|
||||
<template v-if="selectedLog">
|
||||
<div class="detail-header">
|
||||
<el-tag :type="getOperationTagType(selectedLog.operation)">{{ getOperationLabel(selectedLog.operation) }}</el-tag>
|
||||
<span class="detail-time">{{ selectedLog.createdAt }}</span>
|
||||
<span class="detail-creator">操作人:{{ selectedLog.creator }}</span>
|
||||
</div>
|
||||
<el-table :data="selectedLogWithOldValue" border style="width: 100%" max-height="calc(100vh - 200px)">
|
||||
<el-table-column prop="FieldName" label="字段名" width="150" />
|
||||
<el-table-column prop="OldValue" label="原值" min-width="200">
|
||||
<template #default="scope">
|
||||
<span class="cell-value old-value">{{ formatFieldValue(scope.row.OldValue) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="NewValue" label="新值" min-width="200">
|
||||
<template #default="scope">
|
||||
<span class="cell-value new-value">{{ formatFieldValue(scope.row.NewValue) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="selectedLogWithOldValue.length === 0" description="无变更内容" />
|
||||
</template>
|
||||
<el-empty v-else description="请选择左侧日志记录查看详情" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue';
|
||||
import { ref, reactive, computed } from 'vue';
|
||||
import { listLogs, OperationLogInfo } from '/@/api/assets/asset';
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const drawerVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const logList = ref<OperationLogInfo[]>([]);
|
||||
const total = ref(0);
|
||||
const selectedLogIndex = ref<number | null>(null);
|
||||
|
||||
const queryParams = reactive({
|
||||
collection_id: '',
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
// 打开弹窗
|
||||
// 当前选中的日志
|
||||
const selectedLog = computed(() => {
|
||||
if (selectedLogIndex.value !== null && logList.value[selectedLogIndex.value]) {
|
||||
return logList.value[selectedLogIndex.value];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
// 获取下一条日志(更早的记录)中某字段的值作为原值
|
||||
const getOldValue = (fieldName: string) => {
|
||||
if (selectedLogIndex.value === null) return null;
|
||||
// 日志按时间倒序,下一条是更早的记录
|
||||
const nextIndex = selectedLogIndex.value + 1;
|
||||
if (nextIndex >= logList.value.length) {
|
||||
// 没有更早的记录,返回 null
|
||||
return null;
|
||||
}
|
||||
const nextLog = logList.value[nextIndex];
|
||||
if (!nextLog.data) return null;
|
||||
const field = nextLog.data.find((item) => item.FieldName === fieldName);
|
||||
return field ? field.FieldValue : null;
|
||||
};
|
||||
|
||||
// 带有原值的选中日志数据
|
||||
const selectedLogWithOldValue = computed(() => {
|
||||
if (!selectedLog.value || !selectedLog.value.data) return [];
|
||||
return selectedLog.value.data.map((item) => ({
|
||||
FieldName: item.FieldName,
|
||||
OldValue: getOldValue(item.FieldName),
|
||||
NewValue: item.FieldValue,
|
||||
}));
|
||||
});
|
||||
|
||||
// 打开抽屉
|
||||
const openDialog = (collectionId: string) => {
|
||||
queryParams.collection_id = collectionId;
|
||||
queryParams.pageNum = 1;
|
||||
dialogVisible.value = true;
|
||||
logList.value = [];
|
||||
selectedLogIndex.value = null;
|
||||
drawerVisible.value = true;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
@@ -73,6 +131,10 @@ const fetchLogs = async () => {
|
||||
if (res.code === 0 && res.data) {
|
||||
logList.value = res.data.logs || [];
|
||||
total.value = res.data.total || 0;
|
||||
// 默认选中第一条
|
||||
if (logList.value.length > 0) {
|
||||
selectedLogIndex.value = 0;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 错误已由拦截器处理
|
||||
@@ -81,6 +143,28 @@ const fetchLogs = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const loadMore = async () => {
|
||||
loadingMore.value = true;
|
||||
queryParams.pageNum++;
|
||||
try {
|
||||
const res: any = await listLogs(queryParams);
|
||||
if (res.code === 0 && res.data) {
|
||||
logList.value.push(...(res.data.logs || []));
|
||||
total.value = res.data.total || 0;
|
||||
}
|
||||
} catch (error) {
|
||||
queryParams.pageNum--;
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 选择日志
|
||||
const selectLog = (index: number) => {
|
||||
selectedLogIndex.value = index;
|
||||
};
|
||||
|
||||
// 操作类型标签
|
||||
const getOperationTagType = (operation: string) => {
|
||||
switch (operation) {
|
||||
@@ -117,64 +201,106 @@ const formatFieldValue = (value: any) => {
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// 判断是否为长文本
|
||||
const isLongValue = (value: any) => {
|
||||
const str = formatFieldValue(value);
|
||||
return str.length > 50;
|
||||
};
|
||||
|
||||
// 截断长文本
|
||||
const truncateValue = (value: any) => {
|
||||
const str = formatFieldValue(value);
|
||||
if (str.length > 50) {
|
||||
return str.substring(0, 50) + '...';
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
// 分页
|
||||
const onSizeChange = () => {
|
||||
queryParams.pageNum = 1;
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
const onCurrentChange = () => {
|
||||
fetchLogs();
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openDialog,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.change-content {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
.log-container {
|
||||
height: calc(100vh - 120px);
|
||||
padding: 20px !important;
|
||||
}
|
||||
.change-item {
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.6;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
.log-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
gap: 24px;
|
||||
}
|
||||
.timeline-panel {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
border-right: 1px solid #ebeef5;
|
||||
padding-right: 16px;
|
||||
height: 100%;
|
||||
:deep(.el-timeline) {
|
||||
padding-top: 4px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.field-name {
|
||||
color: #909399;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.field-value {
|
||||
color: #303133;
|
||||
word-break: break-all;
|
||||
&.truncate {
|
||||
cursor: pointer;
|
||||
color: #409eff;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
:deep(.el-timeline-item) {
|
||||
cursor: pointer;
|
||||
padding-bottom: 16px;
|
||||
.el-timeline-item__timestamp {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
&.timeline-item-active {
|
||||
.el-timeline-item__node {
|
||||
transform: scale(1.3);
|
||||
}
|
||||
.timeline-content {
|
||||
background: #f0f9ff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
}
|
||||
.timeline-content {
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ebeef5;
|
||||
transition: all 0.2s;
|
||||
&:hover {
|
||||
border-color: #c0c4cc;
|
||||
background: #fafafa;
|
||||
}
|
||||
}
|
||||
.timeline-header {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.timeline-info {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
.creator {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
}
|
||||
.load-more {
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
.no-data {
|
||||
color: #c0c4cc;
|
||||
.detail-panel {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding-right: 10px;
|
||||
.detail-header {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
.detail-time {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-creator {
|
||||
color: #606266;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
.cell-value {
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.old-value {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.new-value {
|
||||
color: #67c23a;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user