feat(admin): AuditLogs + RuntimeLogs views + RBAC role management
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 占位页:主题模板
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
<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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
<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="状态">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user