重构操作日志展示组件,将对话框改为抽屉式布局

This commit is contained in:
WUSIJIAN
2026-01-17 17:39:11 +08:00
parent e090b0a468
commit c743feee6e

View File

@@ -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>