feat(admin): AuditLogs + RuntimeLogs views + RBAC role management
This commit is contained in:
@@ -5,6 +5,21 @@
|
||||
// 所有函数返回 axios response,调用方从 response 获取业务数据
|
||||
|
||||
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 {
|
||||
DashboardOverview,
|
||||
ConfigGroup,
|
||||
@@ -29,6 +44,7 @@ import type {
|
||||
RoleRevokeRequest,
|
||||
RoleMappingRule,
|
||||
RoleMappingRuleRequest,
|
||||
EmployeeSearchResponse,
|
||||
} from '@/types'
|
||||
|
||||
// ==========================================================================
|
||||
@@ -387,6 +403,13 @@ export function assignRole(
|
||||
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(
|
||||
data: RoleRevokeRequest
|
||||
@@ -454,14 +477,65 @@ export function getAgentPerformance(params?: {
|
||||
// P2: 系统日志
|
||||
// ==========================================================================
|
||||
|
||||
/** 获取系统日志(配置变更日志) */
|
||||
/** 获取系统日志(配置变更历史),支持配置键/操作人/时间范围筛选 */
|
||||
export function getSystemLogs(params?: {
|
||||
page?: 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 } }> {
|
||||
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: 满意度评价统计
|
||||
// ==========================================================================
|
||||
@@ -633,4 +707,7 @@ export const adminApi = {
|
||||
getAgentPerformance,
|
||||
getSystemLogs,
|
||||
getEvaluationStats,
|
||||
getAuditLogs,
|
||||
getRuntimeLogs,
|
||||
getRuntimeLogDownload,
|
||||
}
|
||||
|
||||
@@ -97,7 +97,15 @@
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/system-logs">
|
||||
<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 index="/evaluation-stats">
|
||||
<el-icon><Star /></el-icon>
|
||||
|
||||
@@ -116,7 +116,21 @@ const routes = [
|
||||
path: 'system-logs',
|
||||
name: 'SystemLogs',
|
||||
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 占位页:主题模板
|
||||
|
||||
@@ -433,6 +433,7 @@ export interface Role {
|
||||
export interface UserRole {
|
||||
id: string
|
||||
employee_id: string // 企微 UserID
|
||||
employee_name?: string // 员工姓名(可选,后端 JOIN 返回)
|
||||
role_id: string // 关联角色 ID
|
||||
role_name: string // 角色标识
|
||||
role_display_name: string // 角色显示名称
|
||||
@@ -474,9 +475,23 @@ export const MAPPING_SOURCE_LABELS: Record<MappingSourceSource, string> = {
|
||||
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 {
|
||||
employee_id: string
|
||||
target?: string // 员工账号或姓名(推荐,后端自动解析)
|
||||
employee_id?: string // 兼容旧字段:直接传企微 UserID
|
||||
role_name: string
|
||||
reason?: string
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -33,7 +33,7 @@
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="filters.keyword"
|
||||
placeholder="搜索员工姓名 / Employee ID"
|
||||
placeholder="搜索员工姓名 / 员工账号"
|
||||
:prefix-icon="Search"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@@ -78,7 +78,7 @@
|
||||
size="small"
|
||||
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">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.name">{{ row.name }}</span>
|
||||
|
||||
@@ -96,8 +96,14 @@
|
||||
stripe
|
||||
size="small"
|
||||
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">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRoleTagType(row.role_name)" size="small">
|
||||
@@ -214,11 +220,25 @@
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="员工 ID(企微 UserID)">
|
||||
<el-input
|
||||
v-model="assignForm.employee_id"
|
||||
placeholder="输入企微 UserID"
|
||||
/>
|
||||
<el-form-item label="员工账号或姓名">
|
||||
<el-autocomplete
|
||||
v-model="assignForm.target"
|
||||
: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 label="目标角色">
|
||||
<el-select v-model="assignForm.role_name" placeholder="选择角色" style="width: 100%">
|
||||
@@ -260,7 +280,7 @@
|
||||
<div v-if="revokingUserRole" class="revoke-info">
|
||||
<p>确认撤销以下角色分配?</p>
|
||||
<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>{{ ROLE_SOURCE_LABELS[revokingUserRole.source as UserRoleSource] || revokingUserRole.source }}</div>
|
||||
</div>
|
||||
@@ -353,6 +373,7 @@ import {
|
||||
getUserRoleAssignments,
|
||||
createRoleMappingRule,
|
||||
deleteRoleMappingRule,
|
||||
searchEmployees,
|
||||
} from '@/api/admin'
|
||||
import type {
|
||||
Role,
|
||||
@@ -360,6 +381,7 @@ import type {
|
||||
UserRoleSource,
|
||||
RoleMappingRule,
|
||||
MappingSourceSource,
|
||||
EmployeeCandidate,
|
||||
} 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 assigning = ref<boolean>(false)
|
||||
const assignForm = reactive({
|
||||
employee_id: '',
|
||||
target: '',
|
||||
role_name: 'agent',
|
||||
reason: '',
|
||||
})
|
||||
@@ -557,16 +579,56 @@ function unwrapList<T>(resp: unknown): T[] {
|
||||
|
||||
/** 打开分配角色对话框 */
|
||||
function showAssignDialog(): void {
|
||||
assignForm.employee_id = ''
|
||||
assignForm.target = ''
|
||||
assignForm.role_name = 'agent'
|
||||
assignForm.reason = ''
|
||||
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> {
|
||||
if (!assignForm.employee_id.trim()) {
|
||||
ElMessage.warning('请输入员工 ID')
|
||||
if (!assignForm.target.trim()) {
|
||||
ElMessage.warning('请输入员工账号或姓名')
|
||||
return
|
||||
}
|
||||
if (!assignForm.role_name) {
|
||||
@@ -577,7 +639,7 @@ async function handleAssign(): Promise<void> {
|
||||
assigning.value = true
|
||||
try {
|
||||
await assignRole({
|
||||
employee_id: assignForm.employee_id.trim(),
|
||||
target: assignForm.target.trim(),
|
||||
role_name: assignForm.role_name,
|
||||
reason: assignForm.reason || undefined,
|
||||
})
|
||||
@@ -586,7 +648,7 @@ async function handleAssign(): Promise<void> {
|
||||
// 刷新数据
|
||||
await loadData()
|
||||
} catch {
|
||||
ElMessage.error('分配失败,请检查员工 ID 是否正确')
|
||||
ElMessage.error('分配失败,请检查员工账号或姓名是否正确')
|
||||
} finally {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -841,6 +930,32 @@ async function loadData(): Promise<void> {
|
||||
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 {
|
||||
display: inline-block;
|
||||
|
||||
@@ -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>
|
||||
@@ -106,7 +106,7 @@
|
||||
<h4>基本信息</h4>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<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.position || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
|
||||
@@ -1,8 +1,47 @@
|
||||
<template>
|
||||
<!-- ================================================================== -->
|
||||
<!-- 系统日志页面 — 配置变更历史记录 -->
|
||||
<!-- 配置变更历史页面(原"系统日志") — 配置变更记录 + 多维筛选 -->
|
||||
<!-- ================================================================== -->
|
||||
<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-column prop="changed_at" label="时间" width="170">
|
||||
@@ -45,7 +84,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
// =============================================================================
|
||||
// 系统日志页面逻辑
|
||||
// 配置变更历史页面逻辑
|
||||
// =============================================================================
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { getSystemLogs } from '@/api/admin'
|
||||
@@ -55,32 +94,87 @@ import { ElMessage } from 'element-plus'
|
||||
const loading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
|
||||
const filters = reactive({
|
||||
configKey: '',
|
||||
changedBy: '',
|
||||
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(): 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() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getSystemLogs({
|
||||
page: pagination.page,
|
||||
page_size: pagination.pageSize,
|
||||
})
|
||||
const { data } = await getSystemLogs(buildParams())
|
||||
if (data.code === 0) {
|
||||
logs.value = data.data.items || []
|
||||
pagination.total = data.data.total || 0
|
||||
}
|
||||
} catch (err: any) {
|
||||
ElMessage.error('加载系统日志失败')
|
||||
ElMessage.error('加载配置变更历史失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 事件处理 ----------
|
||||
function handleFilter() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
filters.configKey = ''
|
||||
filters.changedBy = ''
|
||||
filters.dateRange = null
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
pagination.page = 1
|
||||
loadLogs()
|
||||
@@ -90,6 +184,7 @@ function handleSizeChange() {
|
||||
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',
|
||||
@@ -109,6 +204,12 @@ onMounted(() => loadLogs())
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
border: none;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user