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>
这个提交包含在:
liuwenxin和Claude Code 于 2026-10-10 16:28:53 +08:00 提交
1 父节点 c83287819a
当前提交 8355e0cc7d
26 个文件已更改
+2785 -180

没有与搜索条件相匹配的文件

+393
查看文件
@@ -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>