feat(admin): AuditLogs + RuntimeLogs views + RBAC role management

This commit is contained in:
Simon
2026-07-09 13:46:20 +08:00
parent d480aa4c1d
commit db70a3a6a8
10 changed files with 957 additions and 27 deletions
+78 -1
View File
@@ -5,6 +5,21 @@
// 所有函数返回 axios response,调用方从 response 获取业务数据 // 所有函数返回 axios response,调用方从 response 获取业务数据
import apiClient from './index' import apiClient from './index'
import axios from 'axios'
// 运行期日志下载使用独立 axios 实例:响应为 blob(文件流),
// 不能走 apiClient 的 JSON 信封拦截器(会把 blob 当成 {code,message,data} 解析而报错)。
const downloadClient = axios.create({
baseURL: '/api',
timeout: 30000,
})
downloadClient.interceptors.request.use((config) => {
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
import type { import type {
DashboardOverview, DashboardOverview,
ConfigGroup, ConfigGroup,
@@ -29,6 +44,7 @@ import type {
RoleRevokeRequest, RoleRevokeRequest,
RoleMappingRule, RoleMappingRule,
RoleMappingRuleRequest, RoleMappingRuleRequest,
EmployeeSearchResponse,
} from '@/types' } from '@/types'
// ========================================================================== // ==========================================================================
@@ -387,6 +403,13 @@ export function assignRole(
return apiClient.post('/admin/roles/assign', data) return apiClient.post('/admin/roles/assign', data)
} }
/** 员工目录搜索(分配角色时按姓名/账号自动补全) */
export function searchEmployees(
q: string
): Promise<{ data: { code: number; data: EmployeeSearchResponse; message: string } }> {
return apiClient.get('/admin/roles/employees/search', { params: { q } })
}
/** 撤销用户角色 */ /** 撤销用户角色 */
export function revokeRole( export function revokeRole(
data: RoleRevokeRequest data: RoleRevokeRequest
@@ -454,14 +477,65 @@ export function getAgentPerformance(params?: {
// P2: 系统日志 // P2: 系统日志
// ========================================================================== // ==========================================================================
/** 获取系统日志(配置变更日志) */ /** 获取系统日志(配置变更历史),支持配置键/操作人/时间范围筛选 */
export function getSystemLogs(params?: { export function getSystemLogs(params?: {
page?: number page?: number
page_size?: number page_size?: number
config_key?: string
changed_by?: string
from?: string
to?: string
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> { }): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
return apiClient.get('/admin/system-logs', { params }) return apiClient.get('/admin/system-logs', { params })
} }
// ==========================================================================
// 安全审计日志 (B) — 复用既有后端 GET /admin/audit-logs
// ==========================================================================
/** 获取安全审计日志(分页 + 操作人/操作类型/时间范围筛选) */
export function getAuditLogs(params?: {
employee_id?: string
action?: string
from?: string
to?: string
page?: number
page_size?: number
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
return apiClient.get('/admin/audit-logs', { params })
}
// ==========================================================================
// 运行期日志 (D) — 新增后端 GET /admin/runtime-logs
// ==========================================================================
/** 获取运行期日志(分页 + 级别/时间/关键字筛选) */
export function getRuntimeLogs(params?: {
level?: string
from?: string
to?: string
keyword?: string
page?: number
page_size?: number
}): Promise<{ data: { code: number; data: { items: any[]; total: number; page: number; page_size: number }; message: string } }> {
return apiClient.get('/admin/runtime-logs', { params })
}
/** 下载运行期日志(命中行文本,blob 流,responseType: blob */
export function getRuntimeLogDownload(params?: {
level?: string
from?: string
to?: string
keyword?: string
}): Promise<Blob> {
return downloadClient
.get('/admin/runtime-logs', {
params: { ...params, download: true },
responseType: 'blob',
})
.then((res) => res.data as Blob)
}
// ========================================================================== // ==========================================================================
// P2: 满意度评价统计 // P2: 满意度评价统计
// ========================================================================== // ==========================================================================
@@ -633,4 +707,7 @@ export const adminApi = {
getAgentPerformance, getAgentPerformance,
getSystemLogs, getSystemLogs,
getEvaluationStats, getEvaluationStats,
getAuditLogs,
getRuntimeLogs,
getRuntimeLogDownload,
} }
+9 -1
View File
@@ -97,7 +97,15 @@
</el-menu-item> </el-menu-item>
<el-menu-item index="/system-logs"> <el-menu-item index="/system-logs">
<el-icon><Notebook /></el-icon> <el-icon><Notebook /></el-icon>
<span>系统日志</span> <span>配置变更历史</span>
</el-menu-item>
<el-menu-item index="/audit-logs">
<el-icon><Document /></el-icon>
<span>安全审计日志</span>
</el-menu-item>
<el-menu-item index="/runtime-logs">
<el-icon><Warning /></el-icon>
<span>运行期日志</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/evaluation-stats"> <el-menu-item index="/evaluation-stats">
<el-icon><Star /></el-icon> <el-icon><Star /></el-icon>
+15 -1
View File
@@ -116,7 +116,21 @@ const routes = [
path: 'system-logs', path: 'system-logs',
name: 'SystemLogs', name: 'SystemLogs',
component: () => import('@/views/SystemLogs.vue'), component: () => import('@/views/SystemLogs.vue'),
meta: { title: '系统日志', requiresAuth: true }, meta: { title: '配置变更历史', requiresAuth: true },
},
{
// 安全审计日志(复用既有后端 GET /api/admin/audit-logs
path: 'audit-logs',
name: 'AuditLogs',
component: () => import('@/views/AuditLogs.vue'),
meta: { title: '安全审计日志', requiresAuth: true },
},
{
// 运行期日志(新增后端 GET /api/admin/runtime-logs
path: 'runtime-logs',
name: 'RuntimeLogs',
component: () => import('@/views/RuntimeLogs.vue'),
meta: { title: '运行期日志', requiresAuth: true },
}, },
{ {
// P2 占位页:主题模板 // P2 占位页:主题模板
+16 -1
View File
@@ -433,6 +433,7 @@ export interface Role {
export interface UserRole { export interface UserRole {
id: string id: string
employee_id: string // 企微 UserID employee_id: string // 企微 UserID
employee_name?: string // 员工姓名(可选,后端 JOIN 返回)
role_id: string // 关联角色 ID role_id: string // 关联角色 ID
role_name: string // 角色标识 role_name: string // 角色标识
role_display_name: string // 角色显示名称 role_display_name: string // 角色显示名称
@@ -474,9 +475,23 @@ export const MAPPING_SOURCE_LABELS: Record<MappingSourceSource, string> = {
ehr_position: 'eHR 岗位', ehr_position: 'eHR 岗位',
} }
/** 员工目录候选(搜索自动补全用) */
export interface EmployeeCandidate {
employee_id: string
name: string
department: string
}
/** 员工搜索响应 */
export interface EmployeeSearchResponse {
items: EmployeeCandidate[]
full_directory: boolean | null // true=企微全组织;false=仅已登录员工;null=未搜索
}
/** 分配角色请求 */ /** 分配角色请求 */
export interface RoleAssignRequest { export interface RoleAssignRequest {
employee_id: string target?: string // 员工账号或姓名(推荐,后端自动解析)
employee_id?: string // 兼容旧字段:直接传企微 UserID
role_name: string role_name: string
reason?: string reason?: string
} }
+297
View File
@@ -0,0 +1,297 @@
<template>
<!-- ================================================================== -->
<!-- 安全审计日志页面B 复用后端 GET /admin/audit-logs -->
<!-- 时间 / 操作人 / 操作类型 / 资源 / 结果 / 详情 / IP -->
<!-- 筛选操作人 / 操作类型 / 时间范围 -->
<!-- ================================================================== -->
<div class="audit-logs">
<!-- 筛选栏 -->
<el-card class="filter-bar" shadow="never">
<el-form :inline="true" @submit.prevent>
<el-form-item label="操作人">
<el-input
v-model="filters.employeeId"
placeholder="employee_id"
clearable
style="width: 200px"
@keyup.enter="handleFilter"
/>
</el-form-item>
<el-form-item label="操作类型">
<el-select
v-model="filters.action"
placeholder="全部"
clearable
filterable
allow-create
default-first-option
style="width: 200px"
>
<el-option
v-for="item in actionOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:shortcuts="dateShortcuts"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 日志列表 -->
<el-table :data="logs" v-loading="loading" stripe style="width: 100%">
<el-table-column type="expand">
<template #default="{ row }">
<div class="detail-block">
<div class="detail-title">详情 (details)</div>
<pre class="detail-json">{{ formatDetails(row.details) }}</pre>
<div v-if="row.resource_id" class="detail-meta">
<span>资源ID{{ row.resource_id }}</span>
<span>UA{{ row.user_agent || '—' }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="created_at" label="时间" width="170">
<template #default="{ row }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="employee_id" label="操作人" width="130" show-overflow-tooltip />
<el-table-column prop="action" label="操作类型" width="150">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ row.action || '—' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="resource" label="资源" width="150" show-overflow-tooltip />
<el-table-column prop="result" label="结果" width="90">
<template #default="{ row }">
<el-tag
size="small"
:type="row.result === 'success' ? 'success' : 'danger'"
>
{{ row.result || '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="ip_address" label="IP" width="140" show-overflow-tooltip />
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadLogs"
@size-change="handleSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
// =============================================================================
// 安全审计日志页面逻辑(B
// =============================================================================
import { ref, reactive, onMounted } from 'vue'
import { getAuditLogs } from '@/api/admin'
import { ElMessage } from 'element-plus'
// ---------- 状态 ----------
const loading = ref(false)
const logs = ref<any[]>([])
const filters = reactive({
employeeId: '',
action: '',
dateRange: null as null | [string, string],
})
const pagination = reactive({
page: 1,
pageSize: 50,
total: 0,
})
// 常见审计操作类型(可选,也支持用户自定义输入)
const actionOptions = [
'config_change',
'login',
'logout',
'permission_change',
'role_assign',
'role_revoke',
'agent_create',
'agent_update',
'agent_delete',
'integration_update',
]
// 时间快捷选项
const dateShortcuts = [
{
text: '最近24小时',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24)
return [start, end]
},
},
{
text: '最近7天',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
return [start, end]
},
},
]
// ---------- 构建请求参数 ----------
function buildParams(): Record<string, any> {
const params: Record<string, any> = {
page: pagination.page,
page_size: pagination.pageSize,
}
// 空筛选等价于原行为:仅分页
if (filters.employeeId) params.employee_id = filters.employeeId
if (filters.action) params.action = filters.action
if (filters.dateRange && filters.dateRange.length === 2) {
// 闭区间:结束日期补全到当天 23:59:59,确保整日命中
params.from = `${filters.dateRange[0]}T00:00:00`
params.to = `${filters.dateRange[1]}T23:59:59`
}
return params
}
// ---------- 数据加载 ----------
async function loadLogs() {
loading.value = true
try {
const { data } = await getAuditLogs(buildParams())
if (data.code === 0) {
logs.value = data.data.items || []
pagination.total = data.data.total || 0
}
} catch (err: any) {
ElMessage.error('加载安全审计日志失败')
} finally {
loading.value = false
}
}
// ---------- 事件处理 ----------
function handleFilter() {
pagination.page = 1
loadLogs()
}
function resetFilter() {
filters.employeeId = ''
filters.action = ''
filters.dateRange = null
pagination.page = 1
loadLogs()
}
function handleSizeChange() {
pagination.page = 1
loadLogs()
}
// ---------- 工具函数 ----------
function formatTime(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function formatDetails(details: any): string {
if (details === null || details === undefined) return '—'
try {
return JSON.stringify(details, null, 2)
} catch {
return String(details)
}
}
// ---------- 生命周期 ----------
onMounted(() => loadLogs())
</script>
<style scoped>
.audit-logs {
padding: 0;
}
.filter-bar {
margin-bottom: 16px;
border: none;
background: var(--bg-secondary);
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.detail-block {
padding: 8px 16px;
background: var(--bg-secondary, #f5f7fa);
border-radius: 4px;
}
.detail-title {
font-size: 12px;
color: #909399;
margin-bottom: 6px;
}
.detail-json {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
max-height: 280px;
overflow: auto;
}
.detail-meta {
display: flex;
gap: 24px;
margin-top: 8px;
font-size: 12px;
color: #909399;
}
</style>
+2 -2
View File
@@ -33,7 +33,7 @@
<div class="filter-bar"> <div class="filter-bar">
<el-input <el-input
v-model="filters.keyword" v-model="filters.keyword"
placeholder="搜索员工姓名 / Employee ID" placeholder="搜索员工姓名 / 员工账号"
:prefix-icon="Search" :prefix-icon="Search"
clearable clearable
style="width: 260px" style="width: 260px"
@@ -78,7 +78,7 @@
size="small" size="small"
empty-text="暂无用户记录" empty-text="暂无用户记录"
> >
<el-table-column prop="employee_id" label="Employee ID" min-width="140" /> <el-table-column prop="employee_id" label="员工账号" min-width="140" />
<el-table-column prop="name" label="姓名" min-width="100"> <el-table-column prop="name" label="姓名" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
<span v-if="row.name">{{ row.name }}</span> <span v-if="row.name">{{ row.name }}</span>
+128 -13
View File
@@ -96,8 +96,14 @@
stripe stripe
size="small" size="small"
empty-text="暂无用户角色记录" empty-text="暂无用户角色记录"
class="user-roles-table"
> >
<el-table-column prop="employee_id" label="员工 ID" min-width="140" /> <el-table-column prop="employee_id" label="员工账号" min-width="120" />
<el-table-column prop="employee_name" label="姓名" min-width="100">
<template #default="{ row }">
<span class="employee-name">{{ row.employee_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="role_display_name" label="角色" min-width="100"> <el-table-column prop="role_display_name" label="角色" min-width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="getRoleTagType(row.role_name)" size="small"> <el-tag :type="getRoleTagType(row.role_name)" size="small">
@@ -214,11 +220,25 @@
destroy-on-close destroy-on-close
> >
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="员工 ID(企微 UserID"> <el-form-item label="员工账号或姓名">
<el-input <el-autocomplete
v-model="assignForm.employee_id" v-model="assignForm.target"
placeholder="输入企微 UserID" :fetch-suggestions="querySearchEmployees"
/> :filter-method="filterEmployeeSuggestions"
placeholder="输入员工账号或姓名,自动匹配"
clearable
style="width: 100%"
@select="onSelectEmployee"
>
<template #default="{ item }">
<div class="emp-option">
<span class="emp-name">{{ item.name || '—' }}</span>
<span class="emp-id">{{ item.employee_id }}</span>
<span v-if="item.department" class="emp-dept">{{ item.department }}</span>
</div>
</template>
</el-autocomplete>
<div class="form-hint">支持直接输入员工账号 tangzhenzhen或输入姓名自动转换系统会实时校验该员工是否在企微组织架构内</div>
</el-form-item> </el-form-item>
<el-form-item label="目标角色"> <el-form-item label="目标角色">
<el-select v-model="assignForm.role_name" placeholder="选择角色" style="width: 100%"> <el-select v-model="assignForm.role_name" placeholder="选择角色" style="width: 100%">
@@ -260,7 +280,7 @@
<div v-if="revokingUserRole" class="revoke-info"> <div v-if="revokingUserRole" class="revoke-info">
<p>确认撤销以下角色分配</p> <p>确认撤销以下角色分配</p>
<div class="revoke-detail"> <div class="revoke-detail">
<div><strong>员工</strong>{{ revokingUserRole.employee_id }}</div> <div><strong>员工</strong>{{ revokingUserRole.employee_id }} <span v-if="revokingUserRole.employee_name">({{ revokingUserRole.employee_name }})</span></div>
<div><strong>角色</strong>{{ revokingUserRole.role_display_name }} ({{ revokingUserRole.role_name }})</div> <div><strong>角色</strong>{{ revokingUserRole.role_display_name }} ({{ revokingUserRole.role_name }})</div>
<div><strong>来源</strong>{{ ROLE_SOURCE_LABELS[revokingUserRole.source as UserRoleSource] || revokingUserRole.source }}</div> <div><strong>来源</strong>{{ ROLE_SOURCE_LABELS[revokingUserRole.source as UserRoleSource] || revokingUserRole.source }}</div>
</div> </div>
@@ -353,6 +373,7 @@ import {
getUserRoleAssignments, getUserRoleAssignments,
createRoleMappingRule, createRoleMappingRule,
deleteRoleMappingRule, deleteRoleMappingRule,
searchEmployees,
} from '@/api/admin' } from '@/api/admin'
import type { import type {
Role, Role,
@@ -360,6 +381,7 @@ import type {
UserRoleSource, UserRoleSource,
RoleMappingRule, RoleMappingRule,
MappingSourceSource, MappingSourceSource,
EmployeeCandidate,
} from '@/types' } from '@/types'
import { ROLE_SOURCE_LABELS, MAPPING_SOURCE_LABELS } from '@/types' import { ROLE_SOURCE_LABELS, MAPPING_SOURCE_LABELS } from '@/types'
@@ -377,7 +399,7 @@ const mappingRules = ref<RoleMappingRule[]>([])
const assignDialogVisible = ref<boolean>(false) const assignDialogVisible = ref<boolean>(false)
const assigning = ref<boolean>(false) const assigning = ref<boolean>(false)
const assignForm = reactive({ const assignForm = reactive({
employee_id: '', target: '',
role_name: 'agent', role_name: 'agent',
reason: '', reason: '',
}) })
@@ -557,16 +579,56 @@ function unwrapList<T>(resp: unknown): T[] {
/** 打开分配角色对话框 */ /** 打开分配角色对话框 */
function showAssignDialog(): void { function showAssignDialog(): void {
assignForm.employee_id = '' assignForm.target = ''
assignForm.role_name = 'agent' assignForm.role_name = 'agent'
assignForm.reason = '' assignForm.reason = ''
assignDialogVisible.value = true assignDialogVisible.value = true
} }
/** 员工目录搜索(自动补全候选) */
async function querySearchEmployees(
queryString: string,
cb: (results: Array<Record<string, unknown>>) => void,
): Promise<void> {
const q = (queryString || '').trim()
if (!q) {
cb([])
return
}
try {
const resp = await searchEmployees(q)
// 响应拦截器已解包:resp.data 即后端 data 字段(EmployeeSearchResponse
const body = (resp as { data?: EmployeeSearchResponse }).data
const items = (body?.items || []) as EmployeeCandidate[]
// el-autocomplete 以 value 作为回填文本,这里回填员工账号(精确)
cb(items.map((it) => ({ ...it, value: it.employee_id }) as Record<string, unknown>))
} catch {
cb([])
}
}
/** 选中候选时,确保输入框回填为员工账号(精确值) */
function onSelectEmployee(item: Record<string, unknown>): void {
assignForm.target = String(item.employee_id || '')
}
/** 自定义过滤方法:支持按员工账号或姓名搜索(解决无法输入中文姓名的 bug) */
function filterEmployeeSuggestions(
queryString: string,
item: Record<string, unknown>,
): boolean {
if (!queryString) return true
const q = queryString.toLowerCase()
const id = String(item.employee_id || '').toLowerCase()
const name = String(item.name || '').toLowerCase()
// 同时匹配账号和姓名,支持中文
return id.includes(q) || name.includes(q)
}
/** 提交分配角色 */ /** 提交分配角色 */
async function handleAssign(): Promise<void> { async function handleAssign(): Promise<void> {
if (!assignForm.employee_id.trim()) { if (!assignForm.target.trim()) {
ElMessage.warning('请输入员工 ID') ElMessage.warning('请输入员工账号或姓名')
return return
} }
if (!assignForm.role_name) { if (!assignForm.role_name) {
@@ -577,7 +639,7 @@ async function handleAssign(): Promise<void> {
assigning.value = true assigning.value = true
try { try {
await assignRole({ await assignRole({
employee_id: assignForm.employee_id.trim(), target: assignForm.target.trim(),
role_name: assignForm.role_name, role_name: assignForm.role_name,
reason: assignForm.reason || undefined, reason: assignForm.reason || undefined,
}) })
@@ -586,7 +648,7 @@ async function handleAssign(): Promise<void> {
// 刷新数据 // 刷新数据
await loadData() await loadData()
} catch { } catch {
ElMessage.error('分配失败,请检查员工 ID 是否正确') ElMessage.error('分配失败,请检查员工账号或姓名是否正确')
} finally { } finally {
assigning.value = false assigning.value = false
} }
@@ -698,6 +760,33 @@ async function loadData(): Promise<void> {
/* 页面容器 */ /* 页面容器 */
} }
/* 分配角色:员工账号/姓名自动补全选项 */
.emp-option {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.emp-name {
font-weight: 600;
color: var(--text-primary);
}
.emp-id {
color: var(--text-muted);
font-size: 12px;
}
.emp-dept {
color: var(--el-color-primary);
font-size: 12px;
margin-left: auto;
}
.form-hint {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
margin-top: 4px;
}
.page-header { .page-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -841,6 +930,32 @@ async function loadData(): Promise<void> {
overflow: hidden; overflow: hidden;
} }
/* 表格样式增强:解决白底看不清问题 */
.user-roles-table :deep(.el-table__header th) {
background-color: var(--bg-tertiary) !important;
color: var(--text-primary);
font-weight: 600;
}
.user-roles-table :deep(.el-table__body td) {
background-color: #fafafa;
color: var(--text-primary);
}
.user-roles-table :deep(.el-table__body tr:hover > td) {
background-color: #f0f7ff !important;
}
.user-roles-table :deep(.el-table__row--striped td) {
background-color: #f8fafc;
}
/* 员工姓名样式 */
.employee-name {
color: var(--text-primary);
font-weight: 500;
}
/* 来源标签 */ /* 来源标签 */
.source-badge { .source-badge {
display: inline-block; display: inline-block;
+303
View File
@@ -0,0 +1,303 @@
<template>
<!-- ================================================================== -->
<!-- 运行期日志页面D 后端 GET /admin/runtime-logs -->
<!-- 时间 / 级别 / logger / 消息 / request_id -->
<!-- 筛选级别 / 时间范围 / 关键字支持下载命中行 -->
<!-- ================================================================== -->
<div class="runtime-logs">
<!-- 筛选栏 -->
<el-card class="filter-bar" shadow="never">
<el-form :inline="true" @submit.prevent>
<el-form-item label="级别">
<el-select
v-model="filters.level"
placeholder="级别阈值"
clearable
style="width: 150px"
>
<el-option label="DEBUG" value="DEBUG" />
<el-option label="INFO" value="INFO" />
<el-option label="WARNING" value="WARNING" />
<el-option label="ERROR" value="ERROR" />
<el-option label="CRITICAL" value="CRITICAL" />
</el-select>
</el-form-item>
<el-form-item label="关键字">
<el-input
v-model="filters.keyword"
placeholder="消息关键字(子串)"
clearable
style="width: 220px"
@keyup.enter="handleFilter"
/>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:shortcuts="dateShortcuts"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
<el-button type="success" :loading="downloading" @click="handleDownload">
下载命中行
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 日志列表 -->
<el-table :data="logs" v-loading="loading" stripe style="width: 100%">
<el-table-column type="expand">
<template #default="{ row }">
<div class="detail-block">
<div class="detail-meta">
<span>模块{{ row.module || '—' }}</span>
<span>函数{{ row.function || '—' }}</span>
<span>行号{{ row.line || '—' }}</span>
</div>
<pre class="detail-message">{{ row.message || '—' }}</pre>
</div>
</template>
</el-table-column>
<el-table-column prop="timestamp" label="时间" width="170">
<template #default="{ row }">
{{ formatTime(row.timestamp) }}
</template>
</el-table-column>
<el-table-column prop="level" label="级别" width="110">
<template #default="{ row }">
<el-tag :type="levelTagType(row.level)" size="small" effect="dark">
{{ row.level || '—' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="logger" label="logger" width="220" show-overflow-tooltip />
<el-table-column prop="message" label="消息" min-width="320" show-overflow-tooltip />
<el-table-column prop="request_id" label="request_id" width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.request_id || '—' }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadLogs"
@size-change="handleSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
// =============================================================================
// 运行期日志页面逻辑(D
// =============================================================================
import { ref, reactive, onMounted } from 'vue'
import { getRuntimeLogs, getRuntimeLogDownload } from '@/api/admin'
import { ElMessage } from 'element-plus'
// ---------- 状态 ----------
const loading = ref(false)
const downloading = ref(false)
const logs = ref<any[]>([])
const filters = reactive({
level: 'INFO',
keyword: '',
dateRange: null as null | [string, string],
})
const pagination = reactive({
page: 1,
pageSize: 50,
total: 0,
})
// 时间快捷选项
const dateShortcuts = [
{
text: '最近24小时',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24)
return [start, end]
},
},
{
text: '最近7天',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
return [start, end]
},
},
]
// ---------- 构建请求参数 ----------
function buildParams(download = false): Record<string, any> {
const params: Record<string, any> = {}
if (!download) {
params.page = pagination.page
params.page_size = pagination.pageSize
}
// 空筛选等价于全部:仅按默认级别
if (filters.level) params.level = filters.level
if (filters.keyword) params.keyword = filters.keyword
if (filters.dateRange && filters.dateRange.length === 2) {
// 闭区间:结束日期补全到当天 23:59:59,确保整日命中
params.from = `${filters.dateRange[0]}T00:00:00`
params.to = `${filters.dateRange[1]}T23:59:59`
}
return params
}
// ---------- 数据加载 ----------
async function loadLogs() {
loading.value = true
try {
const { data } = await getRuntimeLogs(buildParams())
if (data.code === 0) {
logs.value = data.data.items || []
pagination.total = data.data.total || 0
}
} catch (err: any) {
ElMessage.error('加载运行期日志失败')
} finally {
loading.value = false
}
}
// ---------- 下载命中行 ----------
async function handleDownload() {
downloading.value = true
try {
const blob = await getRuntimeLogDownload(buildParams(true))
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const ts = new Date().toISOString().replace(/[:.]/g, '-')
a.download = `runtime-logs-${ts}.log`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
ElMessage.success('下载已开始')
} catch (err: any) {
ElMessage.error('下载运行期日志失败')
} finally {
downloading.value = false
}
}
// ---------- 事件处理 ----------
function handleFilter() {
pagination.page = 1
loadLogs()
}
function resetFilter() {
filters.level = 'INFO'
filters.keyword = ''
filters.dateRange = null
pagination.page = 1
loadLogs()
}
function handleSizeChange() {
pagination.page = 1
loadLogs()
}
// ---------- 工具函数 ----------
function levelTagType(level: string): '' | 'success' | 'warning' | 'danger' | 'info' {
switch ((level || '').toUpperCase()) {
case 'DEBUG':
return 'info'
case 'INFO':
return 'success'
case 'WARNING':
return 'warning'
case 'ERROR':
case 'CRITICAL':
return 'danger'
default:
return 'info'
}
}
function formatTime(iso: string): string {
if (!iso) return '—'
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// ---------- 生命周期 ----------
onMounted(() => loadLogs())
</script>
<style scoped>
.runtime-logs {
padding: 0;
}
.filter-bar {
margin-bottom: 16px;
border: none;
background: var(--bg-secondary);
}
.pagination-bar {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.detail-block {
padding: 8px 16px;
background: var(--bg-secondary, #f5f7fa);
border-radius: 4px;
}
.detail-meta {
display: flex;
gap: 24px;
margin-bottom: 8px;
font-size: 12px;
color: #909399;
}
.detail-message {
margin: 0;
white-space: pre-wrap;
word-break: break-all;
font-family: 'Menlo', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
max-height: 280px;
overflow: auto;
}
</style>
+1 -1
View File
@@ -106,7 +106,7 @@
<h4>基本信息</h4> <h4>基本信息</h4>
<el-descriptions :column="2" border size="small"> <el-descriptions :column="2" border size="small">
<el-descriptions-item label="员工姓名">{{ conversationDetail.employee_name }}</el-descriptions-item> <el-descriptions-item label="员工姓名">{{ conversationDetail.employee_name }}</el-descriptions-item>
<el-descriptions-item label="员工ID">{{ conversationDetail.employee_id }}</el-descriptions-item> <el-descriptions-item label="员工账号">{{ conversationDetail.employee_id }}</el-descriptions-item>
<el-descriptions-item label="部门">{{ conversationDetail.department || '—' }}</el-descriptions-item> <el-descriptions-item label="部门">{{ conversationDetail.department || '—' }}</el-descriptions-item>
<el-descriptions-item label="岗位">{{ conversationDetail.position || '—' }}</el-descriptions-item> <el-descriptions-item label="岗位">{{ conversationDetail.position || '—' }}</el-descriptions-item>
<el-descriptions-item label="状态"> <el-descriptions-item label="状态">
+108 -7
View File
@@ -1,8 +1,47 @@
<template> <template>
<!-- ================================================================== --> <!-- ================================================================== -->
<!-- 系统日志页面 配置变更历史记录 --> <!-- 配置变更历史页面"系统日志" 配置变更记录 + 多维筛选 -->
<!-- ================================================================== --> <!-- ================================================================== -->
<div class="system-logs"> <div class="system-logs">
<!-- 筛选栏 -->
<el-card class="filter-bar" shadow="never">
<el-form :inline="true" @submit.prevent>
<el-form-item label="配置键">
<el-input
v-model="filters.configKey"
placeholder="如 emergency_mode"
clearable
style="width: 220px"
@keyup.enter="handleFilter"
/>
</el-form-item>
<el-form-item label="操作人">
<el-input
v-model="filters.changedBy"
placeholder="agent_id"
clearable
style="width: 200px"
@keyup.enter="handleFilter"
/>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="filters.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
:shortcuts="dateShortcuts"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleFilter">查询</el-button>
<el-button @click="resetFilter">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 日志列表 --> <!-- 日志列表 -->
<el-table :data="logs" v-loading="loading" stripe style="width: 100%"> <el-table :data="logs" v-loading="loading" stripe style="width: 100%">
<el-table-column prop="changed_at" label="时间" width="170"> <el-table-column prop="changed_at" label="时间" width="170">
@@ -45,7 +84,7 @@
<script setup lang="ts"> <script setup lang="ts">
// ============================================================================= // =============================================================================
// 系统日志页面逻辑 // 配置变更历史页面逻辑
// ============================================================================= // =============================================================================
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { getSystemLogs } from '@/api/admin' import { getSystemLogs } from '@/api/admin'
@@ -55,32 +94,87 @@ import { ElMessage } from 'element-plus'
const loading = ref(false) const loading = ref(false)
const logs = ref<any[]>([]) const logs = ref<any[]>([])
const filters = reactive({
configKey: '',
changedBy: '',
dateRange: null as null | [string, string],
})
const pagination = reactive({ const pagination = reactive({
page: 1, page: 1,
pageSize: 50, pageSize: 50,
total: 0, total: 0,
}) })
// 时间快捷选项
const dateShortcuts = [
{
text: '最近24小时',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24)
return [start, end]
},
},
{
text: '最近7天',
value: () => {
const end = new Date()
const start = new Date()
start.setTime(start.getTime() - 3600 * 1000 * 24 * 7)
return [start, end]
},
},
]
// ---------- 构建请求参数 ----------
function buildParams(): Record<string, any> {
const params: Record<string, any> = {
page: pagination.page,
page_size: pagination.pageSize,
}
// 空筛选等价于原行为:仅分页
if (filters.configKey) params.config_key = filters.configKey
if (filters.changedBy) params.changed_by = filters.changedBy
if (filters.dateRange && filters.dateRange.length === 2) {
// 闭区间:结束日期补全到当天 23:59:59,确保整日命中
params.from = `${filters.dateRange[0]}T00:00:00`
params.to = `${filters.dateRange[1]}T23:59:59`
}
return params
}
// ---------- 数据加载 ---------- // ---------- 数据加载 ----------
async function loadLogs() { async function loadLogs() {
loading.value = true loading.value = true
try { try {
const { data } = await getSystemLogs({ const { data } = await getSystemLogs(buildParams())
page: pagination.page,
page_size: pagination.pageSize,
})
if (data.code === 0) { if (data.code === 0) {
logs.value = data.data.items || [] logs.value = data.data.items || []
pagination.total = data.data.total || 0 pagination.total = data.data.total || 0
} }
} catch (err: any) { } catch (err: any) {
ElMessage.error('加载系统日志失败') ElMessage.error('加载配置变更历史失败')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
// ---------- 事件处理 ---------- // ---------- 事件处理 ----------
function handleFilter() {
pagination.page = 1
loadLogs()
}
function resetFilter() {
filters.configKey = ''
filters.changedBy = ''
filters.dateRange = null
pagination.page = 1
loadLogs()
}
function handleSizeChange() { function handleSizeChange() {
pagination.page = 1 pagination.page = 1
loadLogs() loadLogs()
@@ -90,6 +184,7 @@ function handleSizeChange() {
function formatTime(iso: string): string { function formatTime(iso: string): string {
if (!iso) return '—' if (!iso) return '—'
const d = new Date(iso) const d = new Date(iso)
if (isNaN(d.getTime())) return iso
return d.toLocaleString('zh-CN', { return d.toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
@@ -109,6 +204,12 @@ onMounted(() => loadLogs())
padding: 0; padding: 0;
} }
.filter-bar {
margin-bottom: 16px;
border: none;
background: var(--bg-secondary);
}
.pagination-bar { .pagination-bar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;