Files
2026-07-15 16:13:27 +08:00

315 lines
8.5 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-wrapper">
<!-- 搜索区域 -->
<el-card shadow="never" class="search-card">
<template #header>
<div class="card-header-title">
<el-icon><Search /></el-icon>
<span>搜索条件</span>
</div>
</template>
<el-form :model="query" inline>
<el-form-item label="用户名">
<el-input v-model="query.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="操作模块">
<el-input v-model="query.module" placeholder="请输入操作模块" clearable />
</el-form-item>
<el-form-item label="操作类型">
<el-select v-model="query.operateType" placeholder="全部" clearable style="width: 140px">
<el-option label="全部" value="" />
<el-option label="新增" value="INSERT" />
<el-option label="修改" value="UPDATE" />
<el-option label="删除" value="DELETE" />
<el-option label="查询" value="SELECT" />
<el-option label="其他" value="OTHER" />
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="query.dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 表格 -->
<el-card shadow="never">
<el-table :data="tableData" border stripe v-loading="loading" style="width: 100%">
<el-table-column type="expand">
<template #default="{ row }">
<div class="expand-detail">
<div class="detail-section" v-if="row.requestBody">
<h4>请求体</h4>
<pre>{{ formatJson(row.requestBody) }}</pre>
</div>
<div class="detail-section" v-if="row.errorMsg">
<h4>错误信息</h4>
<pre class="error-msg">{{ row.errorMsg }}</pre>
</div>
<div v-if="!row.requestBody && !row.errorMsg" class="empty-detail">暂无详情</div>
</div>
</template>
</el-table-column>
<el-table-column prop="username" label="操作人" width="120" />
<el-table-column prop="module" label="模块" width="140" show-overflow-tooltip />
<el-table-column prop="operateType" label="操作类型" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="operateTypeTag(row.operateType)"
size="small"
effect="dark"
>
{{ row.operateType }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="requestUrl" label="请求地址" min-width="200" show-overflow-tooltip />
<el-table-column prop="requestMethod" label="请求方式" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="methodTag(row.requestMethod)"
size="small"
effect="dark"
>
{{ row.requestMethod }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="responseStatus" label="响应状态码" width="110" align="center">
<template #default="{ row }">
<el-tag
:type="row.responseStatus === 200 ? 'success' : 'danger'"
size="small"
effect="dark"
>
{{ row.responseStatus ?? '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="costTime" label="耗时(ms)" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="row.costTime > 1000 ? 'warning' : row.costTime > 500 ? '' : 'success'"
size="small"
>
{{ row.costTime ?? '-' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="operateTime" label="操作时间" width="180">
<template #default="{ row }">
{{ row.operateTime ? new Date(row.operateTime).toLocaleString('zh-CN') : '-' }}
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无操作日志" :image-size="100" />
</template>
</el-table>
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="fetchData"
@size-change="fetchData"
/>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { Search } from '@element-plus/icons-vue'
import request from '@/utils/request'
/** 操作日志类型 */
interface OperationLogItem {
username: string
module: string
operateType: string
requestUrl: string
requestMethod: string
requestBody?: string
responseStatus: number
costTime: number
operateTime: string
errorMsg?: string
}
// --- 查询 ---
const query = reactive({
page: 1,
pageSize: 10,
username: '',
module: '',
operateType: '',
dateRange: [] as string[],
})
const tableData = ref<OperationLogItem[]>([])
const total = ref(0)
const loading = ref(false)
async function fetchData() {
loading.value = true
try {
const params: Record<string, unknown> = {
page: query.page,
pageSize: query.pageSize,
}
if (query.username) params.username = query.username
if (query.module) params.module = query.module
if (query.operateType) params.operateType = query.operateType
if (query.dateRange && query.dateRange.length === 2) {
params.startTime = query.dateRange[0]
params.endTime = query.dateRange[1]
}
const res = await request.post('/zgapi/v1/admin/log/operation/page', params)
tableData.value = res.data?.records ?? []
total.value = res.data?.total ?? 0
} catch {
// ignore
} finally {
loading.value = false
}
}
function handleSearch() {
query.page = 1
fetchData()
}
function handleReset() {
query.username = ''
query.module = ''
query.operateType = ''
query.dateRange = []
query.page = 1
fetchData()
}
/** 操作类型 tag 颜色 */
function operateTypeTag(type: string) {
const map: Record<string, string> = {
INSERT: 'success',
UPDATE: 'warning',
DELETE: 'danger',
SELECT: '',
OTHER: 'info',
}
return map[type] || 'info'
}
/** 请求方式 tag 颜色 */
function methodTag(method: string) {
const map: Record<string, string> = {
GET: 'success',
POST: 'primary',
PUT: 'warning',
DELETE: 'danger',
PATCH: 'info',
}
return map[method] || 'info'
}
/** 格式化 JSON */
function formatJson(str: string) {
try {
return JSON.stringify(JSON.parse(str), null, 2)
} catch {
return str
}
}
// 初始化
fetchData()
</script>
<style scoped>
.page-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
}
.card-header-title {
display: flex;
align-items: center;
gap: 6px;
font-weight: 600;
color: #303133;
}
.pagination-wrapper {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.expand-detail {
padding: 16px 28px;
}
.detail-section {
margin-bottom: 14px;
}
.detail-section h4 {
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #303133;
}
.detail-section pre {
background: #f5f7fa;
padding: 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
max-height: 240px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
border: 1px solid #ebeef5;
}
.error-msg {
color: #f56c6c;
}
.empty-detail {
color: #c0c4cc;
text-align: center;
padding: 20px 0;
}
@media (max-width: 768px) {
.page-wrapper { gap: 10px; }
.page-toolbar { flex-direction: column; gap: 10px; }
.page-toolbar .el-select,
.page-toolbar .el-date-picker { width: 100% !important; }
.expand-detail { padding: 12px; }
}
</style>