feat(system): 完成前端系统管理(布局框架、三个管理页面、e2e)
- 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>
这个提交包含在:
1 父节点
c83287819a
当前提交
8355e0cc7d
26 个文件已更改
+2785
-180
没有与搜索条件相匹配的文件
@@ -0,0 +1,393 @@
|
||||
<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>
|
||||
在新工单中引用
屏蔽一个用户