- BasicLayout:侧边栏/顶栏/面包屑/移动端抽屉;菜单从路由表派生; 路由守卫新增 requiresAdmin 层(菜单隐藏 + 直连 URL 弹回) - 用户/角色/日志三个页面:分页请求序号防竞态、确认动作流抽 useConfirmAction、 时间范围配 default-time、日志详情按需懒加载并缓存 - 空 catch 统一改 handledError 显式声明;词表/选项常量集中(与后端对齐), 「停用」文案全站统一为「禁用」 - 品牌色提升为全局 token,Element Plus 主色指向赭金 - e2e:公共工具抽 common.py;系统管理 13 项(含详情懒加载脱敏断言), 管理员密码改 E2E_ADMIN_PASSWORD 环境变量;login_flow 8 项回归通过 Co-Authored-By: Claude Code <noreply@anthropic.com>
394 lines
13 KiB
Vue
394 lines
13 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref, watch } from 'vue'
|
||
|
||
import * as logApi from '@/api/logs'
|
||
import type {
|
||
LoginLogItem,
|
||
LoginLogQueryParams,
|
||
OperationLogDetail,
|
||
OperationLogItem,
|
||
OperationLogQueryParams,
|
||
} from '@/api/logs'
|
||
import StatusTag from '@/components/table/StatusTag.vue'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { ACTION_OPTIONS, MODULE_OPTIONS, RESULT_OPTIONS } from '@/utils/constants'
|
||
import { handledError } from '@/utils/error'
|
||
import { formatDateTime } from '@/utils/format'
|
||
|
||
/**
|
||
* 日志查询(需求 2.5.1「支持日志查询」)。
|
||
*
|
||
* 操作日志与登录日志分开两个 tab(后端也是两个端点:字段与筛选项不同)。
|
||
*
|
||
* 时间筛选的两个关键点:
|
||
* - 必须给 el-date-picker 指定 `value-format` —— 否则绑定值是 Date 对象,
|
||
* axios 会序列化为 UTC ISO 串,随本地时区产生 8 小时偏移
|
||
* - 必须配 `default-time` —— datetimerange 只选日期时时刻默认 00:00:00,
|
||
* 跨天查询会漏掉结束日整天(起点补 00:00:00、终点补 23:59:59)
|
||
*
|
||
* 操作日志列表**不含请求参数**(大字段,随列表返回会让翻页负担随日志表
|
||
* 增长);展开行时按 id 调详情端点懒加载并缓存。
|
||
*/
|
||
|
||
/** datetimerange 的起止时刻补全(与 value-format 配合) */
|
||
const RANGE_DEFAULT_TIME: [Date, Date] = [
|
||
new Date(2000, 0, 1, 0, 0, 0),
|
||
new Date(2000, 0, 1, 23, 59, 59),
|
||
]
|
||
|
||
const activeTab = ref('operation')
|
||
|
||
// ==================== 操作日志 ====================
|
||
|
||
const opFilters = reactive({
|
||
username: '',
|
||
module: undefined as string | undefined,
|
||
action: undefined as string | undefined,
|
||
success: undefined as number | undefined,
|
||
})
|
||
const opTimeRange = ref<[string, string] | null>(null)
|
||
|
||
const {
|
||
records: opRecords,
|
||
total: opTotal,
|
||
pageNum: opPageNum,
|
||
pageSize: opPageSize,
|
||
loading: opLoading,
|
||
load: loadOperationLogs,
|
||
search: searchOperationLogs,
|
||
onPageChange: onOpPageChange,
|
||
onSizeChange: onOpSizeChange,
|
||
} = usePagedList<OperationLogItem, OperationLogQueryParams>(logApi.listOperationLogs, () => ({
|
||
username: opFilters.username,
|
||
module: opFilters.module,
|
||
action: opFilters.action,
|
||
success: opFilters.success,
|
||
start_time: opTimeRange.value?.[0],
|
||
end_time: opTimeRange.value?.[1],
|
||
}))
|
||
|
||
function resetOpFilters(): void {
|
||
opFilters.username = ''
|
||
opFilters.module = undefined
|
||
opFilters.action = undefined
|
||
opFilters.success = undefined
|
||
opTimeRange.value = null
|
||
searchOperationLogs()
|
||
}
|
||
|
||
/** 详情缓存与加载中集合(列表无 request_data,展开时按需拉取) */
|
||
const detailMap = reactive(new Map<number, OperationLogDetail>())
|
||
const detailLoading = reactive(new Set<number>())
|
||
|
||
async function onExpandChange(row: OperationLogItem, expandedRows: OperationLogItem[]): Promise<void> {
|
||
const isExpanded = expandedRows.some((item) => item.id === row.id)
|
||
if (!isExpanded || detailMap.has(row.id)) return
|
||
detailLoading.add(row.id)
|
||
try {
|
||
detailMap.set(row.id, await logApi.getOperationLog(row.id))
|
||
} catch (error) {
|
||
handledError(error) // 拦截器已提示,展开处显示占位
|
||
} finally {
|
||
detailLoading.delete(row.id)
|
||
}
|
||
}
|
||
|
||
/** 展开行展示的请求参数:加载中 / 暂无 / 格式化后的 JSON(已脱敏) */
|
||
function detailText(logId: number): string {
|
||
if (detailLoading.has(logId)) return '加载中…'
|
||
const detail = detailMap.get(logId)
|
||
if (!detail) return '—'
|
||
return formatRequestData(detail.request_data)
|
||
}
|
||
|
||
function formatRequestData(raw: string | null): string {
|
||
if (!raw) return '—'
|
||
try {
|
||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||
} catch {
|
||
return raw
|
||
}
|
||
}
|
||
|
||
// ==================== 登录日志 ====================
|
||
|
||
const loginFilters = reactive({
|
||
username: '',
|
||
ip: '',
|
||
success: undefined as number | undefined,
|
||
})
|
||
const loginTimeRange = ref<[string, string] | null>(null)
|
||
|
||
const {
|
||
records: loginRecords,
|
||
total: loginTotal,
|
||
pageNum: loginPageNum,
|
||
pageSize: loginPageSize,
|
||
loading: loginLoading,
|
||
load: loadLoginLogs,
|
||
search: searchLoginLogs,
|
||
onPageChange: onLoginPageChange,
|
||
onSizeChange: onLoginSizeChange,
|
||
} = usePagedList<LoginLogItem, LoginLogQueryParams>(logApi.listLoginLogs, () => ({
|
||
username: loginFilters.username,
|
||
ip: loginFilters.ip,
|
||
success: loginFilters.success,
|
||
start_time: loginTimeRange.value?.[0],
|
||
end_time: loginTimeRange.value?.[1],
|
||
}))
|
||
|
||
function resetLoginFilters(): void {
|
||
loginFilters.username = ''
|
||
loginFilters.ip = ''
|
||
loginFilters.success = undefined
|
||
loginTimeRange.value = null
|
||
searchLoginLogs()
|
||
}
|
||
|
||
// ==================== 初始化 ====================
|
||
|
||
onMounted(loadOperationLogs)
|
||
|
||
// 登录日志 tab 每次激活都刷新(首屏不打多余请求;此前"只加载一次"的
|
||
// 标志在首次失败后不会重置,切回时停在空表且无从自查)
|
||
watch(activeTab, (tab) => {
|
||
if (tab === 'login') {
|
||
void loadLoginLogs()
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<el-card shadow="never">
|
||
<el-tabs v-model="activeTab">
|
||
<!-- 操作日志 -->
|
||
<el-tab-pane label="操作日志" name="operation" lazy>
|
||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||
<el-form-item label="操作人">
|
||
<el-input
|
||
v-model="opFilters.username"
|
||
placeholder="模糊匹配"
|
||
clearable
|
||
style="width: 140px"
|
||
@keyup.enter="searchOperationLogs"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="模块">
|
||
<el-select v-model="opFilters.module" placeholder="全部" clearable style="width: 130px">
|
||
<el-option v-for="item in MODULE_OPTIONS" :key="item" :label="item" :value="item" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="操作">
|
||
<el-select v-model="opFilters.action" placeholder="全部" clearable style="width: 120px">
|
||
<el-option v-for="item in ACTION_OPTIONS" :key="item" :label="item" :value="item" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="结果">
|
||
<el-select v-model="opFilters.success" placeholder="全部" clearable style="width: 110px">
|
||
<el-option
|
||
v-for="item in RESULT_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="时间范围">
|
||
<el-date-picker
|
||
v-model="opTimeRange"
|
||
type="datetimerange"
|
||
range-separator="至"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
:default-time="RANGE_DEFAULT_TIME"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="searchOperationLogs">查询</el-button>
|
||
<el-button @click="resetOpFilters">重置</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-table
|
||
v-loading="opLoading"
|
||
:data="opRecords"
|
||
stripe
|
||
@expand-change="onExpandChange"
|
||
>
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<div class="log-detail">
|
||
<p>
|
||
<span class="log-detail__label">请求:</span>
|
||
{{ row.method || '—' }} {{ row.path || '—' }}
|
||
</p>
|
||
<p>
|
||
<span class="log-detail__label">User-Agent:</span>
|
||
{{ row.user_agent || '—' }}
|
||
</p>
|
||
<p class="log-detail__label">请求参数(已脱敏):</p>
|
||
<pre class="log-detail__data">{{ detailText(row.id) }}</pre>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="时间" min-width="165">
|
||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="username" label="操作人" min-width="110" />
|
||
<el-table-column prop="module" label="模块" width="100" />
|
||
<el-table-column prop="action" label="操作" width="90" />
|
||
<el-table-column prop="summary" label="摘要" min-width="200" show-overflow-tooltip />
|
||
<el-table-column label="结果" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<el-tooltip
|
||
:content="row.error_message ?? ''"
|
||
:disabled="row.success !== 0 || !row.error_message"
|
||
placement="top"
|
||
>
|
||
<StatusTag
|
||
:value="row.success"
|
||
active-text="成功"
|
||
inactive-text="失败"
|
||
inactive-type="danger"
|
||
/>
|
||
</el-tooltip>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="耗时(ms)" width="90" align="right">
|
||
<template #default="{ row }">{{ row.duration_ms ?? '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="ip" label="IP" min-width="120">
|
||
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<AppPagination
|
||
:total="opTotal"
|
||
:page-num="opPageNum"
|
||
:page-size="opPageSize"
|
||
@page-change="onOpPageChange"
|
||
@size-change="onOpSizeChange"
|
||
/>
|
||
</el-tab-pane>
|
||
|
||
<!-- 登录日志 -->
|
||
<el-tab-pane label="登录日志" name="login" lazy>
|
||
<el-form class="filter-form" :inline="true" @submit.prevent>
|
||
<el-form-item label="用户名">
|
||
<el-input
|
||
v-model="loginFilters.username"
|
||
placeholder="模糊匹配"
|
||
clearable
|
||
style="width: 160px"
|
||
@keyup.enter="searchLoginLogs"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="IP">
|
||
<el-input
|
||
v-model="loginFilters.ip"
|
||
placeholder="精确匹配"
|
||
clearable
|
||
style="width: 150px"
|
||
@keyup.enter="searchLoginLogs"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="结果">
|
||
<el-select v-model="loginFilters.success" placeholder="全部" clearable style="width: 110px">
|
||
<el-option
|
||
v-for="item in RESULT_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="时间范围">
|
||
<el-date-picker
|
||
v-model="loginTimeRange"
|
||
type="datetimerange"
|
||
range-separator="至"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
:default-time="RANGE_DEFAULT_TIME"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="searchLoginLogs">查询</el-button>
|
||
<el-button @click="resetLoginFilters">重置</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<el-table v-loading="loginLoading" :data="loginRecords" stripe>
|
||
<el-table-column label="时间" min-width="165">
|
||
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="username" label="用户名" min-width="130" />
|
||
<el-table-column label="结果" width="80" align="center">
|
||
<template #default="{ row }">
|
||
<StatusTag
|
||
:value="row.success"
|
||
active-text="成功"
|
||
inactive-text="失败"
|
||
inactive-type="danger"
|
||
/>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="fail_reason" label="失败原因" min-width="150">
|
||
<template #default="{ row }">{{ row.fail_reason || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="ip" label="IP" min-width="120">
|
||
<template #default="{ row }">{{ row.ip || '—' }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="User-Agent" min-width="200">
|
||
<template #default="{ row }">
|
||
<span class="ua-cell" :title="row.user_agent || ''">{{ row.user_agent || '—' }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<AppPagination
|
||
:total="loginTotal"
|
||
:page-num="loginPageNum"
|
||
:page-size="loginPageSize"
|
||
@page-change="onLoginPageChange"
|
||
@size-change="onLoginSizeChange"
|
||
/>
|
||
</el-tab-pane>
|
||
</el-tabs>
|
||
</el-card>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.log-detail {
|
||
padding: 4px 16px;
|
||
color: var(--ys-text);
|
||
}
|
||
|
||
.log-detail__label {
|
||
font-weight: 600;
|
||
}
|
||
|
||
.log-detail__data {
|
||
padding: 8px 12px;
|
||
margin: 6px 0 12px;
|
||
overflow-x: auto;
|
||
font-family: Consolas, 'Courier New', monospace;
|
||
font-size: 12px;
|
||
background-color: var(--ys-paper);
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.ua-cell {
|
||
display: inline-block;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
vertical-align: bottom;
|
||
}
|
||
</style>
|