serv\web- 日志

This commit is contained in:
2025-10-30 16:40:56 +08:00
parent 82b6f14e64
commit 2b252e1b3c
91 changed files with 6003 additions and 1485 deletions

View File

@@ -42,6 +42,11 @@ export const learningTaskApi = {
return response.data;
},
async getTaskUsers(taskID: string): Promise<ResultDomain<TaskItemVO>> {
const response = await api.get<TaskItemVO>(`${this.learningTaskPrefix}/${taskID}/users`);
return response.data;
},
/**
* 获取任务分页列表
* @param pageParam 分页参数

View File

@@ -5,7 +5,7 @@
*/
import { api } from '@/apis/index';
import type { SysDept, SysRole, DeptRoleVO, SysDeptRole, ResultDomain } from '@/types';
import type { SysDept, SysRole, SysDeptRole, ResultDomain, UserDeptRoleVO } from '@/types';
/**
* 部门API服务
@@ -89,8 +89,8 @@ export const deptApi = {
* @author yslg
* @ since 2025-10-06
*/
async getDeptByRole(dept: SysDept): Promise<ResultDomain<SysRole>> {
const response = await api.post<SysRole>('/depts/role', dept);
async getDeptByRole(dept: SysDept): Promise<ResultDomain<UserDeptRoleVO>> {
const response = await api.post<UserDeptRoleVO>('/depts/role', dept);
return response.data;
},
@@ -113,7 +113,7 @@ export const deptApi = {
* @author yslg
* @ since 2025-10-06
*/
async bindDeptRole(deptRole: DeptRoleVO): Promise<ResultDomain<SysDeptRole>> {
async bindDeptRole(deptRole: UserDeptRoleVO): Promise<ResultDomain<SysDeptRole>> {
const response = await api.post<SysDeptRole>('/depts/bind/role', deptRole);
return response.data;
},
@@ -125,7 +125,7 @@ export const deptApi = {
* @author yslg
* @ since 2025-10-06
*/
async unbindDeptRole(deptRole: DeptRoleVO): Promise<ResultDomain<SysDeptRole>> {
async unbindDeptRole(deptRole: UserDeptRoleVO): Promise<ResultDomain<SysDeptRole>> {
const response = await api.post<SysDeptRole>('/depts/unbind/role', deptRole);
return response.data;
}

View File

@@ -13,4 +13,5 @@ export { permissionApi } from './permission';
export { authApi } from './auth';
export { fileApi } from './file';
export { moduleApi } from './module';
export { logApi } from './log';

View File

@@ -0,0 +1,33 @@
/**
* @description 系统日志API
* @author yslg
* @since 2025-10-30
*/
import { api } from '../index';
import type { LoginLog, OperationLog } from '@/types/log';
import type { PageParam, ResultDomain } from '@/types';
/**
* 日志API接口
*/
export const logApi = {
baseUrl: '/sys/log',
async getLoginLogPage(pageParam: PageParam, filter: LoginLog): Promise<ResultDomain<LoginLog>> {
const response = await api.post<LoginLog>(`${this.baseUrl}/login/page`, {
pageParam,
filter,
});
return response.data;
},
async getOperationLogPage(pageParam: PageParam, filter: OperationLog): Promise<ResultDomain<OperationLog>> {
const response = await api.post<OperationLog>(`${this.baseUrl}/operation/page`, {
pageParam,
filter,
});
return response.data;
}
};

View File

@@ -43,8 +43,8 @@ export const userApi = {
* @param filter 过滤条件
* @returns Promise<ResultDomain<SysUser>>
*/
async getUserList(filter: SysUser): Promise<ResultDomain<SysUser>> {
const response = await api.post<SysUser>('/users/list', filter);
async getUserList(filter: SysUser): Promise<ResultDomain<UserVO>> {
const response = await api.post<UserVO>('/users/list', filter);
return response.data;
},

View File

@@ -625,12 +625,6 @@ function toggleAvailable(itemId: string) {
// 树形模式下的级联选择
const node = findNodeInTree(itemId, treeData.value);
if (node) {
// 如果只允许选择叶子节点,检查是否为叶子节点
if (props.onlyLeafSelectable && !isLeafNode(node)) {
// 非叶子节点,不允许选择
return;
}
const childrenIds = getAllChildrenIds(node);
if (index > -1) {
@@ -701,6 +695,11 @@ function moveSelectedToTarget() {
itemsToMove = flatItems.filter(item =>
selectedAvailable.value.includes(getNodeId(item))
);
// 如果只允许选择叶子节点,过滤掉非叶子节点
if (props.onlyLeafSelectable) {
itemsToMove = itemsToMove.filter(item => isLeafNode(item));
}
} else {
// 列表模式:从可选列表中查找
itemsToMove = availableList.value.filter(item =>
@@ -712,8 +711,10 @@ function moveSelectedToTarget() {
if (props.useTree && treeData.value.length > 0) {
// 树形模式:需要重新构建树(移除已选项)
// 只移除叶子节点
const idsToRemove = new Set(itemsToMove.map(item => getNodeId(item)));
availableList.value = availableList.value.filter(item =>
!selectedAvailable.value.includes(getItemId(item))
!idsToRemove.has(getItemId(item))
);
// 重新转换为树形结构
if (props.treeTransform) {
@@ -782,10 +783,27 @@ function moveToTarget(itemId: string) {
function moveAllToTarget() {
if (props.useTree && treeData.value.length > 0) {
// 树形模式:扁平化所有节点
const flatItems = flattenTree(treeData.value);
let flatItems = flattenTree(treeData.value);
// 如果只允许选择叶子节点,过滤掉非叶子节点
if (props.onlyLeafSelectable) {
flatItems = flatItems.filter(item => isLeafNode(item));
}
targetList.value.push(...flatItems);
availableList.value = [];
treeData.value = [];
// 从availableList中移除已移动的项
const idsToRemove = new Set(flatItems.map(item => getNodeId(item)));
availableList.value = availableList.value.filter(item =>
!idsToRemove.has(getItemId(item))
);
// 重新构建树(如果还有非叶子节点)
if (props.onlyLeafSelectable && props.treeTransform) {
treeData.value = props.treeTransform(availableList.value);
} else {
treeData.value = [];
}
} else {
// 列表模式
targetList.value.push(...availableList.value);

View File

@@ -3,10 +3,7 @@
<div
class="tree-node-content"
:style="{ paddingLeft: `${level * 20}px` }"
:class="{
selected: selectedIds.includes(nodeId),
disabled: isCheckboxDisabled
}"
:class="{ selected: selectedIds.includes(nodeId) }"
@click.stop="handleClick"
@dblclick.stop="handleDblClick"
>
@@ -25,7 +22,6 @@
<input
type="checkbox"
:checked="selectedIds.includes(nodeId)"
:disabled="isCheckboxDisabled"
@click.stop="handleCheckboxClick"
/>
@@ -107,36 +103,28 @@ const hasChildren = computed(() => children.value && children.value.length > 0);
const expanded = computed(() => props.expandedKeys.has(nodeId.value));
// 判断复选框是否应该被禁用
const isCheckboxDisabled = computed(() => {
// 如果只允许选择叶子节点,且当前节点有子节点,则禁用复选框
return props.onlyLeafSelectable && hasChildren.value;
});
function getChildId(child: any): string {
const idProp = props.treeProps?.id || 'id';
return String(child[idProp] || '');
}
function handleClick() {
// 点击节点主体时切换选中状态(如果复选框未禁用)
if (!isCheckboxDisabled.value) {
emit('toggle-select', nodeId.value);
}
// 点击节点主体时切换选中状态
emit('toggle-select', nodeId.value);
}
function handleCheckboxClick() {
// 复选框点击事件(如果未禁用)
if (!isCheckboxDisabled.value) {
emit('toggle-select', nodeId.value);
}
// 复选框点击事件
emit('toggle-select', nodeId.value);
}
function handleDblClick() {
// 双击时触发(如果复选框未禁用)
if (!isCheckboxDisabled.value) {
emit('dblclick', nodeId.value);
// 双击时触发
// 如果只允许选择叶子节点,且当前是非叶子节点,则不触发双击
if (props.onlyLeafSelectable && hasChildren.value) {
return;
}
emit('dblclick', nodeId.value);
}
</script>
@@ -191,11 +179,6 @@ function handleDblClick() {
input[type="checkbox"] {
margin: 0 8px;
cursor: pointer;
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
.node-label {
@@ -203,14 +186,6 @@ function handleDblClick() {
font-size: 14px;
color: #606266;
}
&.disabled {
cursor: not-allowed;
.node-label {
color: #999;
}
}
}
&-children {

View File

@@ -48,6 +48,9 @@ export * from './usercenter';
// 定时任务相关
export * from './crontab';
// 日志相关
export * from './log';
// 枚举类型
export * from './enums';
export * from './enums/achievement-enums';

View File

@@ -0,0 +1,84 @@
/**
* @description 系统日志类型定义
* @author yslg
* @since 2025-10-30
*/
import { BaseDTO } from '../base';
/**
* 登录日志
*/
export interface LoginLog extends BaseDTO {
/** 用户名 */
username?: string;
/** 用户ID */
userId?: string;
/** 登录IP */
ipAddress?: string;
/** 登录地点 */
location?: string;
/** 浏览器 */
browser?: string;
/** 操作系统 */
os?: string;
/** 登录状态: success-成功, failed-失败 */
status?: number;
/** 登录信息 */
message?: string;
/** 登录时间 */
loginTime?: string;
/** 部门ID */
deptId?: string;
/** 部门名称 */
deptName?: string;
/** 开始时间 (查询条件) */
startTime?: string;
/** 结束时间 (查询条件) */
endTime?: string;
}
/**
* 操作日志
*/
export interface OperationLog extends BaseDTO {
/** 操作人用户名 */
username?: string;
/** 操作人ID */
userId?: string;
/** 操作模块 */
module?: string;
/** 操作类型: create-新增, update-修改, delete-删除, read-查询 */
operation?: 'create' | 'update' | 'delete' | 'read' | string;
/** 操作描述 */
description?: string;
/** 请求方法 */
method?: string;
/** 请求路径 */
requestUrl?: string;
/** 请求参数 */
requestParams?: string;
/** 响应结果 */
responseData?: string;
/** IP地址 */
ipAddress?: string;
/** 操作地点 */
location?: string;
/** 操作耗时(毫秒) */
duration?: number;
/** 操作状态: success-成功, failed-失败 */
status?: 'success' | 'failed' | string;
/** 错误信息 */
errorMessage?: string;
/** 操作时间 */
operationTime?: string;
/** 部门ID */
deptId?: string;
/** 部门名称 */
deptName?: string;
/** 开始时间 (查询条件) */
startTime?: string;
/** 结束时间 (查询条件) */
endTime?: string;
}

View File

@@ -18,6 +18,15 @@ export interface SysPermission extends BaseDTO {
/** 权限ID */
permissionID?: string;
moduleID?: string;
moduleName?: string;
moduleCode?: string;
moduleDescription?: string;
menuID?: string;
menuName?: string;
menuUrl?: string;
roleID?: string;
roleName?: string;
roleDescription?: string;
/** 权限名称 */
name?: string;
/** 权限描述 */

View File

@@ -317,6 +317,9 @@ export interface TaskItemVO extends LearningTask {
userID?: string;
/** 用户名 */
username?: string;
deptID?: string;
deptName?: string;
parentDeptID?: string;
/** 是否必修 */
required?: boolean;
/** 排序号 */

View File

@@ -61,16 +61,19 @@ export interface UserVO extends BaseDTO {
wechatID?: string;
/** 用户状态 0-正常 1-禁用 */
status?: number;
/** 真实姓名 */
realName?: string;
familyName?: string;
/** 名 */
givenName?: string;
/** 昵称 */
nickname?: string;
fullName?: string;
/** 头像URL */
avatar?: string;
/** 性别 0-未知 1-男 2-女 */
gender?: number;
/** 学习等级 */
level?: number;
deptID?: string;
parentDeptID?: string;
/** 部门名称 */
deptName?: string;
/** 角色名称 */

View File

@@ -1,110 +1,312 @@
<template>
<AdminLayout title="登录日志" subtitle="登录日志管理">
<div class="login-logs">
<div class="filter-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索用户名..."
style="width: 200px"
clearable
/>
<el-select v-model="loginStatus" placeholder="登录状态" style="width: 150px" clearable>
<el-option label="成功" value="success" />
<el-option label="失败" value="failed" />
</el-select>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
/>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleExport">导出</el-button>
<el-button type="danger" @click="handleClear">清空日志</el-button>
<!-- 搜索栏 -->
<div class="search-bar">
<div class="search-group">
<label class="search-label">用户名</label>
<input
v-model="searchKeyword"
type="text"
class="search-input"
placeholder="输入用户名搜索"
@keyup.enter="handleSearch"
/>
</div>
<div class="search-group">
<label class="search-label">登录状态</label>
<select v-model="loginStatus" class="search-select">
<option value="">全部</option>
<option value="1">成功</option>
<option value="0">失败</option>
</select>
</div>
<div class="search-group">
<label class="search-label">开始日期</label>
<input
v-model="startDate"
type="date"
class="search-input"
/>
</div>
<div class="search-group">
<label class="search-label">结束日期</label>
<input
v-model="endDate"
type="date"
class="search-input"
/>
</div>
<div class="search-actions">
<button class="btn-search" @click="handleSearch">搜索</button>
<button class="btn-reset" @click="handleReset">重置</button>
</div>
</div>
<el-table :data="logs" style="width: 100%">
<el-table-column prop="username" label="用户名" width="120" />
<el-table-column prop="ipAddress" label="IP地址" width="140" />
<el-table-column prop="location" label="登录地点" width="150" />
<el-table-column prop="browser" label="浏览器" width="120" />
<el-table-column prop="os" label="操作系统" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'success' ? 'success' : 'danger'">
{{ row.status === 'success' ? '成功' : '失败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="信息" min-width="150" />
<el-table-column prop="loginTime" label="登录时间" width="180" />
</el-table>
<!-- 操作按钮 -->
<!-- <div class="toolbar">
<button class="btn-danger" @click="handleClear">清空日志</button>
</div> -->
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
<!-- 日志表格 -->
<div class="log-table-wrapper">
<table class="log-table">
<thead>
<tr>
<th width="120">用户名</th>
<th width="140">IP地址</th>
<th width="150">登录地点</th>
<th width="120">浏览器</th>
<th width="120">操作系统</th>
<th width="100">状态</th>
<th>信息</th>
<th width="180">登录时间</th>
</tr>
</thead>
<tbody v-if="loading">
<tr>
<td colspan="8" class="loading-cell">
<div class="loading-spinner"></div>
<span>加载中...</span>
</td>
</tr>
</tbody>
<tbody v-else-if="logs.length === 0">
<tr>
<td colspan="8" class="empty-cell">
<div class="empty-icon">📋</div>
<p>暂无登录日志</p>
</td>
</tr>
</tbody>
<tbody v-else>
<tr v-for="log in logs" :key="log.id" class="table-row">
<td>{{ log.username || '-' }}</td>
<td>{{ log.ipAddress || '-' }}</td>
<td>{{ log.location || '-' }}</td>
<td>{{ log.browser || '-' }}</td>
<td>{{ log.os || '-' }}</td>
<td>
<span class="status-tag" :class="log.status === 1 ? 'status-success' : 'status-failed'">
{{ log.status === 1 ? '成功' : '失败' }}
</span>
</td>
<td class="log-message">
<div class="message-text">{{ log.message || '-' }}</div>
</td>
<td>{{ log.loginTime || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="!loading && logs.length > 0" class="pagination">
<div class="pagination-info">
{{ total }} 条数据每页 {{ pageSize }}
</div>
<div class="pagination-controls">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="handlePageChange(1)"
>
首页
</button>
<button
class="page-btn"
:disabled="currentPage === 1"
@click="handlePageChange(currentPage - 1)"
>
上一页
</button>
<div class="page-numbers">
<button
v-for="page in displayPages"
:key="page"
class="page-number"
:class="{ active: page === currentPage }"
@click="handlePageChange(page)"
:disabled="page === -1"
>
{{ page === -1 ? '...' : page }}
</button>
</div>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="handlePageChange(currentPage + 1)"
>
下一页
</button>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="handlePageChange(totalPages)"
>
末页
</button>
</div>
<div class="pagination-jump">
<span>跳转到</span>
<input
v-model.number="jumpPage"
type="number"
class="jump-input"
@keyup.enter="handleJumpPage"
/>
<span></span>
<button class="jump-btn" @click="handleJumpPage">跳转</button>
</div>
</div>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ElInput, ElSelect, ElOption, ElDatePicker, ElButton, ElTable, ElTableColumn, ElTag, ElPagination, ElMessage, ElMessageBox } from 'element-plus';
import { ref, computed, onMounted } from 'vue';
import { AdminLayout } from '@/views/admin';
import { logApi } from '@/apis/system';
import type { LoginLog } from '@/types/log';
defineOptions({
name: 'LoginLogsView'
});
const loading = ref(false);
const searchKeyword = ref('');
const loginStatus = ref('');
const dateRange = ref<[Date, Date] | null>(null);
const startDate = ref('');
const endDate = ref('');
const currentPage = ref(1);
const pageSize = ref(10);
const pageSize = ref(20);
const total = ref(0);
const logs = ref<any[]>([]);
const logs = ref<LoginLog[]>([]);
const jumpPage = ref<number>();
// 计算总页数
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1);
// 计算显示的页码
const displayPages = computed(() => {
const pages: number[] = [];
const maxDisplay = 7;
if (totalPages.value <= maxDisplay) {
for (let i = 1; i <= totalPages.value; i++) {
pages.push(i);
}
} else {
if (currentPage.value <= 4) {
for (let i = 1; i <= 5; i++) {
pages.push(i);
}
pages.push(-1);
pages.push(totalPages.value);
} else if (currentPage.value >= totalPages.value - 3) {
pages.push(1);
pages.push(-1);
for (let i = totalPages.value - 4; i <= totalPages.value; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push(-1);
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
pages.push(i);
}
pages.push(-1);
pages.push(totalPages.value);
}
}
return pages;
});
onMounted(() => {
loadLogs();
});
function loadLogs() {
// TODO: 加载登录日志
/**
* 加载登录日志
*/
async function loadLogs() {
try {
loading.value = true;
// 构建查询条件
const query: LoginLog = {
username: searchKeyword.value || undefined,
status: loginStatus.value ? Number(loginStatus.value) : undefined,
startTime: startDate.value || undefined,
endTime: endDate.value || undefined,
};
const result = await logApi.getLoginLogPage(
{pageNumber: currentPage.value, pageSize: pageSize.value},
query
);
if (result.success) {
logs.value = result.pageDomain?.dataList || [];
total.value = result.pageDomain?.pageParam.totalElements || 0;
}
} catch (error) {
console.error('加载登录日志失败:', error);
alert('加载登录日志失败');
} finally {
loading.value = false;
}
}
/**
* 查询
*/
function handleSearch() {
currentPage.value = 1;
loadLogs();
}
function handleExport() {
// TODO: 导出日志
ElMessage.info('导出功能开发中');
}
async function handleClear() {
try {
await ElMessageBox.confirm('确定要清空所有登录日志吗?此操作不可恢复!', '警告', {
type: 'warning'
});
// TODO: 清空日志
ElMessage.success('日志已清空');
} catch {
// 取消操作
}
}
function handleSizeChange(val: number) {
pageSize.value = val;
/**
* 重置查询条件
*/
function handleReset() {
searchKeyword.value = '';
loginStatus.value = '';
startDate.value = '';
endDate.value = '';
currentPage.value = 1;
loadLogs();
}
function handleCurrentChange(val: number) {
currentPage.value = val;
/**
* 页码改变
*/
function handlePageChange(page: number) {
if (page < 1 || page > totalPages.value || page === -1) return;
currentPage.value = page;
loadLogs();
}
/**
* 跳转页面
*/
function handleJumpPage() {
if (!jumpPage.value || jumpPage.value < 1 || jumpPage.value > totalPages.value) {
alert('请输入有效的页码');
return;
}
currentPage.value = jumpPage.value;
loadLogs();
}
</script>
@@ -114,15 +316,310 @@ function handleCurrentChange(val: number) {
padding: 20px;
}
.filter-bar {
.search-bar {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.search-group {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 180px;
}
.search-label {
font-size: 14px;
font-weight: 500;
color: #606266;
}
.search-input,
.search-select {
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
transition: border-color 0.3s;
&:focus {
outline: none;
border-color: #409eff;
}
}
.search-select {
cursor: pointer;
}
.search-actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.btn-search,
.btn-reset {
padding: 8px 20px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
border: none;
}
.btn-search {
background: #409eff;
color: #fff;
&:hover {
background: #66b1ff;
}
}
.btn-reset {
background: #fff;
color: #606266;
border: 1px solid #dcdfe6;
&:hover {
color: #409eff;
border-color: #409eff;
}
}
.toolbar {
margin-bottom: 20px;
}
.btn-danger {
padding: 8px 20px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
border: none;
background: #f56c6c;
color: #fff;
&:hover {
background: #f78989;
}
}
.btn-primary {
padding: 8px 20px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
border: none;
background: #409eff;
color: #fff;
&:hover {
background: #66b1ff;
}
}
.log-table-wrapper {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.log-table {
width: 100%;
border-collapse: collapse;
th {
background: #f5f7fa;
color: #606266;
font-weight: 600;
font-size: 14px;
padding: 12px 16px;
text-align: left;
border-bottom: 2px solid #e0e0e0;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
color: #606266;
}
.table-row {
transition: background-color 0.3s;
&:hover {
background: #f5f7fa;
}
&:last-child td {
border-bottom: none;
}
}
}
.log-message {
max-width: 300px;
}
.message-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
&.status-success {
background: #e1f3d8;
color: #67c23a;
}
&.status-failed {
background: #fde2e2;
color: #f56c6c;
}
}
.loading-cell,
.empty-cell {
text-align: center;
padding: 60px 20px !important;
color: #909399;
}
.loading-spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
flex-wrap: wrap;
gap: 16px;
}
.pagination-info {
font-size: 14px;
color: #606266;
}
.pagination-controls {
display: flex;
gap: 8px;
align-items: center;
}
.el-table {
margin-bottom: 20px;
.page-btn,
.page-number {
padding: 6px 12px;
border: 1px solid #dcdfe6;
background: #fff;
color: #606266;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
&:hover:not(:disabled) {
color: #409eff;
border-color: #409eff;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.active {
background: #409eff;
color: #fff;
border-color: #409eff;
}
}
.page-numbers {
display: flex;
gap: 4px;
}
.pagination-jump {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #606266;
.jump-input {
width: 60px;
padding: 6px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
text-align: center;
&:focus {
outline: none;
border-color: #409eff;
}
}
.jump-btn {
padding: 6px 12px;
border: 1px solid #dcdfe6;
background: #fff;
color: #606266;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: #409eff;
border-color: #409eff;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,46 +1,968 @@
<template>
<AdminLayout title="系统日志" subtitle="系统日志管理">
<div class="system-logs">
<h1 class="page-title">系统日志</h1>
<AdminLayout title="操作日志" subtitle="操作日志管理">
<div class="operation-logs">
<!-- 搜索栏 -->
<div class="search-bar">
<div class="search-group">
<label class="search-label">用户名</label>
<input
v-model="searchKeyword"
type="text"
class="search-input"
placeholder="输入用户名搜索"
@keyup.enter="handleSearch"
/>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="登录日志" name="login">
<LoginLogs />
</el-tab-pane>
<el-tab-pane label="操作日志" name="operation">
<OperationLogs />
</el-tab-pane>
<el-tab-pane label="系统配置" name="config">
<SystemConfig />
</el-tab-pane>
</el-tabs>
<div class="search-group">
<label class="search-label">操作模块</label>
<input
v-model="searchModule"
type="text"
class="search-input"
placeholder="输入模块名搜索"
@keyup.enter="handleSearch"
/>
</div>
<!-- <div class="search-group">
<label class="search-label">操作类型</label>
<select v-model="operationType" class="search-select">
<option value="">全部</option>
<option value="create">新增</option>
<option value="update">修改</option>
<option value="delete">删除</option>
<option value="read">查询</option>
</select>
</div> -->
<!--
<div class="search-group">
<label class="search-label">操作状态</label>
<select v-model="operationStatus" class="search-select">
<option value="">全部</option>
<option value="success">成功</option>
<option value="failed">失败</option>
</select>
</div> -->
<div class="search-group">
<label class="search-label">开始日期</label>
<input
v-model="startDate"
type="date"
class="search-input"
/>
</div>
<div class="search-group">
<label class="search-label">结束日期</label>
<input
v-model="endDate"
type="date"
class="search-input"
/>
</div>
<div class="search-actions">
<button class="btn-search" @click="handleSearch">搜索</button>
<button class="btn-reset" @click="handleReset">重置</button>
</div>
</div>
<!-- 日志表格 -->
<div class="log-table-wrapper">
<table class="log-table">
<thead>
<tr>
<th width="10%">操作人</th>
<th width="10%">操作模块</th>
<th width="10%">操作类型</th>
<th width="10%">请求链接</th>
<th width="10%">IP地址</th>
<th width="10%">操作描述</th>
<!-- <th width="100">耗时(ms)</th> -->
<!-- <th width="100">状态</th> -->
<th width="10%">操作时间</th>
<th width="10%">操作</th>
</tr>
</thead>
<tbody v-if="loading">
<tr>
<td colspan="9" class="loading-cell">
<div class="loading-spinner"></div>
<span>加载中...</span>
</td>
</tr>
</tbody>
<tbody v-else-if="logs.length === 0">
<tr>
<td colspan="9" class="empty-cell">
<div class="empty-icon">📋</div>
<p>暂无操作日志</p>
</td>
</tr>
</tbody>
<tbody v-else>
<tr v-for="log in logs" :key="log.id" class="table-row">
<td>{{ log.username || '-' }}</td>
<td>{{ log.module || '-' }}</td>
<td>
<span class="status-tag" :class="getOperationClass(log.operation)">
{{ getOperationText(log.operation) }}
</span>
</td>
<td class="log-desc">
<div class="desc-text">{{ log.requestUrl || '-' }}</div>
</td>
<td>{{ log.ipAddress || '-' }}</td>
<td class="log-desc">
<div class="desc-text">{{ truncateText(log.responseData, 50) }}</div>
</td>
<!-- <td>
<span class="status-tag" :class="log.status === 'success' ? 'status-success' : 'status-failed'">
{{ log.status === 'success' ? '成功' : '失败' }}
</span>
</td> -->
<td>{{ formatTime(log?.createTime || '') || '-' }}</td>
<td class="action-cell">
<button class="btn-link btn-primary" @click="viewDetail(log)">详情</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<div v-if="!loading && logs.length > 0" class="pagination">
<div class="pagination-info">
{{ total }} 条数据每页 {{ pageSize }}
</div>
<div class="pagination-controls">
<button
class="page-btn"
:disabled="currentPage === 1"
@click="handlePageChange(1)"
>
首页
</button>
<button
class="page-btn"
:disabled="currentPage === 1"
@click="handlePageChange(currentPage - 1)"
>
上一页
</button>
<div class="page-numbers">
<button
v-for="page in displayPages"
:key="page"
class="page-number"
:class="{ active: page === currentPage }"
@click="handlePageChange(page)"
:disabled="page === -1"
>
{{ page === -1 ? '...' : page }}
</button>
</div>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="handlePageChange(currentPage + 1)"
>
下一页
</button>
<button
class="page-btn"
:disabled="currentPage === totalPages"
@click="handlePageChange(totalPages)"
>
末页
</button>
</div>
<div class="pagination-jump">
<span>跳转到</span>
<input
v-model.number="jumpPage"
type="number"
class="jump-input"
@keyup.enter="handleJumpPage"
/>
<span></span>
<button class="jump-btn" @click="handleJumpPage">跳转</button>
</div>
</div>
</div>
<!-- 详情对话框 -->
<div v-if="detailVisible" class="modal-overlay" @click.self="detailVisible = false">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">操作日志详情</h3>
<button class="modal-close" @click="detailVisible = false"></button>
</div>
<div class="modal-body">
<div v-if="currentLog" class="log-detail">
<div class="detail-section">
<h4 class="detail-title">基本信息</h4>
<div class="detail-content">
<div class="detail-row">
<span class="detail-label">操作人:</span>
<span class="detail-value">{{ currentLog.username || '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">操作模块:</span>
<span class="detail-value">{{ currentLog.module || '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">操作类型:</span>
<span class="detail-value">
<span class="status-tag" :class="getOperationClass(currentLog.operation)">
{{ getOperationText(currentLog.operation) }}
</span>
</span>
</div>
<!-- <div class="detail-row">
<span class="detail-label">操作状态:</span>
<span class="detail-value">
<span class="status-tag" :class="currentLog.status === 'success' ? 'status-success' : 'status-failed'">
{{ currentLog.status === 'success' ? '成功' : '失败' }}
</span>
</span>
</div> -->
<div class="detail-row">
<span class="detail-label">请求链接:</span>
<span class="detail-value">{{ currentLog.requestUrl || '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">IP地址:</span>
<span class="detail-value">{{ currentLog.ipAddress || '-' }}</span>
</div>
<div class="detail-row">
<span class="detail-label">日志内容:</span>
<span class="detail-value">{{ currentLog.responseData || '-' }}</span>
</div>
<!-- <div class="detail-row">
<span class="detail-label">耗时:</span>
<span class="detail-value">{{ currentLog.duration }}ms</span>
</div> -->
<div class="detail-row">
<span class="detail-label">操作时间:</span>
<span class="detail-value">{{ formatTime(currentLog?.createTime || '') || '-' }}</span>
</div>
</div>
</div>
<div class="detail-section" v-if="currentLog.requestParams">
<h4 class="detail-title">请求参数</h4>
<div class="detail-content">
<pre class="code-block">{{ currentLog.requestParams }}</pre>
</div>
</div>
<div class="detail-section" v-if="currentLog.responseData">
<h4 class="detail-title">响应结果</h4>
<div class="detail-content">
<pre class="code-block">{{ currentLog.responseData }}</pre>
</div>
</div>
<div class="detail-section" v-if="currentLog.errorMessage">
<h4 class="detail-title">错误信息</h4>
<div class="detail-content">
<div class="error-message">{{ currentLog.errorMessage }}</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn-primary" @click="detailVisible = false">关闭</button>
</div>
</div>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { ElTabs, ElTabPane } from 'element-plus';
import LoginLogs from './components/LoginLogs.vue';
import OperationLogs from './components/OperationLogs.vue';
import SystemConfig from './components/SystemConfig.vue';
import { ref, computed, onMounted } from 'vue';
import { AdminLayout } from '@/views/admin';
import { logApi } from '@/apis/system';
import type { OperationLog } from '@/types/log';
import dayjs from 'dayjs';
defineOptions({
name: 'SystemLogsView'
});
const activeTab = ref('login');
const loading = ref(false);
const searchKeyword = ref('');
const searchModule = ref('');
const operationType = ref('');
const operationStatus = ref('');
const startDate = ref('');
const endDate = ref('');
const currentPage = ref(1);
const pageSize = ref(20);
const total = ref(0);
const logs = ref<OperationLog[]>([]);
const jumpPage = ref<number>();
const detailVisible = ref(false);
const currentLog = ref<OperationLog | null>(null);
// 计算总页数
const totalPages = computed(() => Math.ceil(total.value / pageSize.value) || 1);
// 计算显示的页码
const displayPages = computed(() => {
const pages: number[] = [];
const maxDisplay = 7;
if (totalPages.value <= maxDisplay) {
for (let i = 1; i <= totalPages.value; i++) {
pages.push(i);
}
} else {
if (currentPage.value <= 4) {
for (let i = 1; i <= 5; i++) {
pages.push(i);
}
pages.push(-1);
pages.push(totalPages.value);
} else if (currentPage.value >= totalPages.value - 3) {
pages.push(1);
pages.push(-1);
for (let i = totalPages.value - 4; i <= totalPages.value; i++) {
pages.push(i);
}
} else {
pages.push(1);
pages.push(-1);
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
pages.push(i);
}
pages.push(-1);
pages.push(totalPages.value);
}
}
return pages;
});
onMounted(() => {
loadLogs();
});
function formatTime(time: string) {
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
}
/**
* 截取文本并添加省略号
*/
function truncateText(text?: string, maxLength = 20): string {
if (!text) return '-';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
/**
* 加载操作日志
*/
async function loadLogs() {
try {
loading.value = true;
// 构建查询条件
const query: OperationLog = {
username: searchKeyword.value || undefined,
module: searchModule.value || undefined,
operation: operationType.value || undefined,
status: operationStatus.value || undefined,
startTime: startDate.value || undefined,
endTime: endDate.value || undefined,
};
const result = await logApi.getOperationLogPage(
{pageNumber: currentPage.value, pageSize: pageSize.value},
query
);
if (result.success) {
logs.value = result.pageDomain?.dataList || [];
total.value = result.pageDomain?.pageParam.totalElements || 0;
}
} catch (error) {
console.error('加载操作日志失败:', error);
} finally {
loading.value = false;
}
}
/**
* 查询
*/
function handleSearch() {
currentPage.value = 1;
loadLogs();
}
/**
* 重置查询条件
*/
function handleReset() {
searchKeyword.value = '';
searchModule.value = '';
operationType.value = '';
operationStatus.value = '';
startDate.value = '';
endDate.value = '';
currentPage.value = 1;
loadLogs();
}
/**
* 获取操作类型样式类
*/
function getOperationClass(type?: string) {
const classMap: Record<string, string> = {
'create': 'operation-create',
'update': 'operation-update',
'delete': 'operation-delete',
'read': 'operation-read'
};
return classMap[type || ''] || 'operation-read';
}
/**
* 获取操作类型文本
*/
function getOperationText(type?: string) {
const textMap: Record<string, string> = {
'create': '新增',
'update': '修改',
'delete': '删除',
'read': '查询'
};
return textMap[type || ''] || (type || '-');
}
/**
* 查看详情
*/
function viewDetail(log: OperationLog) {
currentLog.value = { ...log };
detailVisible.value = true;
}
/**
* 页码改变
*/
function handlePageChange(page: number) {
if (page < 1 || page > totalPages.value || page === -1) return;
currentPage.value = page;
loadLogs();
}
/**
* 跳转页面
*/
function handleJumpPage() {
if (!jumpPage.value || jumpPage.value < 1 || jumpPage.value > totalPages.value) {
alert('请输入有效的页码');
return;
}
currentPage.value = jumpPage.value;
loadLogs();
}
</script>
<style lang="scss" scoped>
.system-logs {
.operation-logs {
padding: 20px;
}
.page-title {
font-size: 28px;
.search-bar {
background: #fff;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.search-group {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 150px;
}
.search-label {
font-size: 14px;
font-weight: 500;
color: #606266;
}
.search-input,
.search-select {
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
color: #606266;
transition: border-color 0.3s;
&:focus {
outline: none;
border-color: #409eff;
}
}
.search-select {
cursor: pointer;
}
.search-actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.btn-search,
.btn-reset {
padding: 8px 20px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
border: none;
}
.btn-search {
background: #409eff;
color: #fff;
&:hover {
background: #66b1ff;
}
}
.btn-reset {
background: #fff;
color: #606266;
border: 1px solid #dcdfe6;
&:hover {
color: #409eff;
border-color: #409eff;
}
}
.btn-primary {
padding: 8px 20px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
border: none;
background: #409eff;
color: #fff;
&:hover {
background: #66b1ff;
}
}
.log-table-wrapper {
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.log-table {
width: 100%;
border-collapse: collapse;
th {
background: #f5f7fa;
color: #606266;
font-weight: 600;
font-size: 14px;
padding: 12px 16px;
text-align: left;
border-bottom: 2px solid #e0e0e0;
}
td {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
font-size: 14px;
color: #606266;
}
.table-row {
transition: background-color 0.3s;
&:hover {
background: #f5f7fa;
}
&:last-child td {
border-bottom: none;
}
}
}
.log-desc {
max-width: 300px;
}
.desc-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.align-right {
text-align: right;
}
.status-tag {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
&.status-success {
background: #e1f3d8;
color: #67c23a;
}
&.status-failed {
background: #fde2e2;
color: #f56c6c;
}
&.operation-create {
background: #e1f3d8;
color: #67c23a;
}
&.operation-update {
background: #fdf6ec;
color: #e6a23c;
}
&.operation-delete {
background: #fde2e2;
color: #f56c6c;
}
&.operation-read {
background: #ecf5ff;
color: #409eff;
}
}
.action-cell {
display: flex;
justify-content: center;
align-items: center;
}
.btn-link {
border: none;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
transition: all 0.3s;
border-radius: 4px;
min-width: 64px;
text-align: center;
color: #fff;
&:hover {
opacity: 0.8;
}
&.btn-primary {
background: #409eff;
}
}
.loading-cell,
.empty-cell {
text-align: center;
padding: 60px 20px !important;
color: #909399;
}
.loading-spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 20px;
padding: 20px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
flex-wrap: wrap;
gap: 16px;
}
.pagination-info {
font-size: 14px;
color: #606266;
}
.pagination-controls {
display: flex;
gap: 8px;
align-items: center;
}
.page-btn,
.page-number {
padding: 6px 12px;
border: 1px solid #dcdfe6;
background: #fff;
color: #606266;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
&:hover:not(:disabled) {
color: #409eff;
border-color: #409eff;
}
&:disabled {
cursor: not-allowed;
opacity: 0.5;
}
&.active {
background: #409eff;
color: #fff;
border-color: #409eff;
}
}
.page-numbers {
display: flex;
gap: 4px;
}
.pagination-jump {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #606266;
.jump-input {
width: 60px;
padding: 6px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
text-align: center;
&:focus {
outline: none;
border-color: #409eff;
}
}
.jump-btn {
padding: 6px 12px;
border: 1px solid #dcdfe6;
background: #fff;
color: #606266;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: all 0.3s;
&:hover {
color: #409eff;
border-color: #409eff;
}
}
}
// 模态框样式
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
padding: 20px;
}
.modal-content {
background: #fff;
border-radius: 8px;
width: 100%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e0e0e0;
}
.modal-title {
font-size: 18px;
font-weight: 600;
color: #141F38;
margin-bottom: 24px;
color: #303133;
margin: 0;
}
.modal-close {
width: 32px;
height: 32px;
border: none;
background: transparent;
font-size: 24px;
color: #909399;
cursor: pointer;
transition: all 0.3s;
border-radius: 4px;
&:hover {
background: #f5f7fa;
color: #303133;
}
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
}
.log-detail {
display: flex;
flex-direction: column;
gap: 24px;
}
.detail-section {
background: #fafafa;
border-radius: 6px;
padding: 16px;
}
.detail-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 2px solid #e0e0e0;
}
.detail-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.detail-row {
display: flex;
font-size: 14px;
}
.detail-label {
color: #909399;
min-width: 100px;
font-weight: 500;
}
.detail-value {
color: #606266;
flex: 1;
word-break: break-all;
}
.code-block {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 12px;
font-size: 13px;
font-family: 'Courier New', monospace;
color: #303133;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
}
.error-message {
background: #fef0f0;
border: 1px solid #fde2e2;
border-radius: 4px;
padding: 12px;
font-size: 14px;
color: #f56c6c;
}
</style>

View File

@@ -424,7 +424,19 @@ async function fetchDeptRoles() {
auth: true
};
}
return await deptApi.getDeptByRole(currentDept.value);
const result = await deptApi.getDeptByRole(currentDept.value);
// 转换数据格式,将 UserDeptRoleVO 转换为与 SysRole 一致的格式
if (result.success && result.dataList) {
result.dataList = result.dataList.map(item => ({
roleID: item.roleID,
name: item.roleName, // roleName -> name
description: item.roleDescription // roleDescription -> description
}));
}
return result;
}
// 查看绑定角色

View File

@@ -166,7 +166,12 @@
right-title="已选权限"
:fetch-available-api="fetchAllPermissions"
:fetch-selected-api="fetchMenuPermissions"
:filter-selected="filterPermissions"
:item-config="{ id: 'permissionID', label: 'name', sublabel: 'code' }"
:use-tree="true"
:tree-transform="transformPermissionsToTree"
:tree-props="{ children: 'children', label: 'displayName', id: 'permissionID' }"
:only-leaf-selectable="true"
unit-name=""
search-placeholder="搜索权限名称或编码..."
@confirm="handlePermissionConfirm"
@@ -505,11 +510,80 @@ async function fetchAllPermissions() {
// 获取菜单已绑定权限的接口
async function fetchMenuPermissions() {
if (!currentMenu.value?.menuID) {
return { success: true, dataList: [] };
return {
success: true,
dataList: [],
code: 200,
message: '',
login: true,
auth: true
};
}
return await menuApi.getMenuPermission(currentMenu.value.menuID);
}
// 过滤已选项
function filterPermissions(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.permissionID));
return available.filter(item => !selectedIds.has(item.permissionID));
}
// 将权限按模块ID构建树形结构
function transformPermissionsToTree(flatData: any[]) {
if (!flatData || flatData.length === 0) {
return [];
}
// 按模块分组
const moduleMap = new Map<string, any>();
const tree: any[] = [];
flatData.forEach(item => {
const moduleID = item.moduleID;
if (!moduleID) {
// 没有模块ID的权限作为根节点
tree.push({
...item,
displayName: item.name,
isModule: false
});
return;
}
if (!moduleMap.has(moduleID)) {
// 创建模块节点
moduleMap.set(moduleID, {
permissionID: moduleID, // 使用moduleID作为ID确保唯一性
displayName: item.moduleName || moduleID,
moduleID: moduleID,
moduleName: item.moduleName,
moduleCode: item.moduleCode,
moduleDescription: item.moduleDescription,
children: [],
isModule: true // 标记这是模块节点
});
}
// 添加权限到模块的children中
const moduleNode = moduleMap.get(moduleID);
if (moduleNode) {
moduleNode.children.push({
...item,
displayName: item.name,
isModule: false // 标记这是权限节点
});
}
});
// 将所有模块添加到树中
moduleMap.forEach(moduleNode => {
tree.push(moduleNode);
});
return tree;
}
// 查看绑定权限
async function handleBindPermission(row: SysMenu) {
currentMenu.value = row;
@@ -535,8 +609,11 @@ async function handlePermissionConfirm(items: SysPermission[]) {
const currentBoundResult = await menuApi.getMenuPermission(currentMenu.value.menuID);
const currentBoundIds = (currentBoundResult.dataList || []).map(p => p.permissionID).filter((id): id is string => !!id);
// 新选择的权限ID
const newSelectedIds = items.map(p => p.permissionID).filter((id): id is string => !!id);
// 新选择的权限ID(过滤掉模块节点,只保留权限节点)
const newSelectedIds = items
.filter((item: any) => !item.isModule) // 只保留权限节点,过滤模块节点
.map(p => p.permissionID)
.filter((id): id is string => !!id);
// 找出需要绑定的权限(新增的)
const permissionsToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
@@ -546,7 +623,8 @@ async function handlePermissionConfirm(items: SysPermission[]) {
// 构建需要绑定的权限对象数组
if (permissionsToBind.length > 0) {
const permissionsToBindObjects = items.filter(p => p.permissionID && permissionsToBind.includes(p.permissionID));
const permissionsToBindObjects = items
.filter((item: any) => !item.isModule && item.permissionID && permissionsToBind.includes(item.permissionID));
const bindMenu = {
...currentMenu.value,
@@ -569,6 +647,8 @@ async function handlePermissionConfirm(items: SysPermission[]) {
}
ElMessage.success('权限绑定保存成功');
bindPermissionDialogVisible.value = false;
resetBindList();
// 刷新菜单列表
await loadMenuList();

View File

@@ -286,7 +286,12 @@
right-title="已选菜单"
:fetch-available-api="fetchAllMenus"
:fetch-selected-api="fetchPermissionMenus"
:filter-selected="filterMenus"
:item-config="{ id: 'menuID', label: 'name', sublabel: 'url' }"
:use-tree="true"
:tree-transform="transformMenusToTree"
:tree-props="{ children: 'children', label: 'name', id: 'menuID' }"
:only-leaf-selectable="true"
unit-name=""
search-placeholder="搜索菜单名称..."
@confirm="handleMenuConfirm"
@@ -301,6 +306,7 @@
right-title="已选角色"
:fetch-available-api="fetchAllRoles"
:fetch-selected-api="fetchPermissionRoles"
:filter-selected="filterRoles"
:item-config="{ id: 'roleID', label: 'name', sublabel: 'description' }"
unit-name=""
search-placeholder="搜索角色名称..."
@@ -622,7 +628,11 @@ async function fetchPermissionMenus() {
success: result.success,
login: result.login ?? true,
auth: result.auth ?? true,
dataList: result.data?.menus || []
dataList: result.dataList?.map(item => ({
...item,
name: item.menuName,
url: item.menuUrl
})) || []
};
}
@@ -651,10 +661,115 @@ async function fetchPermissionRoles() {
success: result.success,
login: result.login ?? true,
auth: result.auth ?? true,
dataList: result.data?.roles || []
dataList: result.dataList?.map(item => ({
...item,
name: item.roleName,
description: item.roleDescription
})) || []
};
}
// 过滤已选菜单项
function filterMenus(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.menuID));
return available.filter(item => !selectedIds.has(item.menuID));
}
// 过滤已选角色项
function filterRoles(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.roleID));
return available.filter(item => !selectedIds.has(item.roleID));
}
// 将菜单扁平数据转换为树形结构根据parentID构建
function transformMenusToTree(flatData: any[]): any[] {
if (!flatData || flatData.length === 0) {
return [];
}
const tree: any[] = [];
const map = new Map<string, any>();
const maxDepth = flatData.length; // 最多遍历len层
// 初始化所有节点
flatData.forEach(item => {
if (item.menuID) {
map.set(item.menuID, { ...item, children: [] });
}
});
// 循环构建树结构最多遍历maxDepth次
for (let depth = 0; depth < maxDepth; depth++) {
let hasChanges = false;
flatData.forEach(item => {
if (!item.menuID) return;
const node = map.get(item.menuID);
if (!node) return;
// 如果节点已经在树中,跳过
if (isMenuNodeInTree(node, tree)) {
return;
}
if (!item.parentID || item.parentID === '0' || item.parentID === '') {
// 根节点
if (!isMenuNodeInTree(node, tree)) {
tree.push(node);
hasChanges = true;
}
} else {
// 查找父节点
const parent = map.get(item.parentID);
if (parent && isMenuNodeInTree(parent, tree)) {
if (!parent.children) {
parent.children = [];
}
if (!parent.children.includes(node)) {
parent.children.push(node);
hasChanges = true;
}
}
}
});
// 如果没有变化,说明树构建完成
if (!hasChanges) {
break;
}
}
// 清理空的children数组
function cleanEmptyChildren(nodes: any[]) {
nodes.forEach(node => {
if (node.children && node.children.length === 0) {
delete node.children;
} else if (node.children && node.children.length > 0) {
cleanEmptyChildren(node.children);
}
});
}
cleanEmptyChildren(tree);
return tree;
}
// 检查菜单节点是否已经在树中
function isMenuNodeInTree(node: any, tree: any[]): boolean {
for (const treeNode of tree) {
if (treeNode.menuID === node.menuID) {
return true;
}
if (treeNode.children && treeNode.children.length > 0) {
if (isMenuNodeInTree(node, treeNode.children)) {
return true;
}
}
}
return false;
}
// 绑定菜单
async function handleBindMenu(permission: SysPermission) {
currentPermission.value = permission;
@@ -708,6 +823,8 @@ async function handleMenuConfirm(items: SysMenu[]) {
}
ElMessage.success('菜单绑定保存成功');
bindMenuDialogVisible.value = false;
resetBindList();
} catch (error) {
console.error('保存菜单绑定失败:', error);
ElMessage.error('保存菜单绑定失败');
@@ -757,6 +874,8 @@ async function handleRoleConfirm(items: SysRole[]) {
}
ElMessage.success('角色绑定保存成功');
bindRoleDialogVisible.value = false;
resetBindList();
} catch (error) {
console.error('保存角色绑定失败:', error);
ElMessage.error('保存角色绑定失败');

View File

@@ -102,9 +102,14 @@
:title="`绑定权限 - ${currentRole?.name || ''}`"
left-title="可选权限"
right-title="已选权限"
:available-items="availablePermissions"
:initial-target-items="initialBoundPermissions"
:fetch-available-api="fetchAllPermissions"
:fetch-selected-api="fetchRolePermissions"
:filter-selected="filterPermissions"
:item-config="{ id: 'permissionID', label: 'name', sublabel: 'code' }"
:use-tree="true"
:tree-transform="transformPermissionsToTree"
:tree-props="{ children: 'children', label: 'displayName', id: 'permissionID' }"
:only-leaf-selectable="true"
unit-name=""
search-placeholder="搜索权限名称或编码..."
@confirm="handlePermissionConfirm"
@@ -124,7 +129,7 @@ import { GenericSelector } from '@/components/base';
defineOptions({
name: 'RoleManageView'
});
import { ref, onMounted, reactive, computed } from 'vue';
import { ref, onMounted, reactive } from 'vue';
import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
@@ -134,9 +139,10 @@ const loading = ref(false);
const submitting = ref(false);
// 权限绑定相关数据
const permissionList = ref<SysPermission[]>([]);
// 移除:改为由 GenericSelector 通过 API 加载
// const permissionList = ref<SysPermission[]>([]);
// const initialBoundPermissions = ref<SysPermission[]>([]);
const currentRole = ref<SysRole | null>(null);
const initialBoundPermissions = ref<SysPermission[]>([]);
// 对话框状态
const dialogVisible = ref(false);
@@ -251,43 +257,105 @@ function resetForm() {
});
}
// 计算可选权限(过滤掉已绑定的)
const availablePermissions = computed(() => {
const boundIds = new Set(initialBoundPermissions.value.map(p => p.permissionID));
return permissionList.value.filter(p => !boundIds.has(p.permissionID));
});
// 获取所有权限的API函数
async function fetchAllPermissions() {
const permission: SysPermission = {
permissionID: undefined,
name: undefined,
code: undefined,
description: undefined,
};
return await permissionApi.getPermissionList(permission);
}
// 获取角色已绑定权限的API函数
async function fetchRolePermissions() {
if (!currentRole.value || !currentRole.value.roleID) {
return {
success: true,
dataList: [],
code: 200,
message: '',
login: true,
auth: true
};
}
return await roleApi.getRolePermission({
roleID: currentRole.value.roleID
});
}
// 过滤已选项
function filterPermissions(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.permissionID));
return available.filter(item => !selectedIds.has(item.permissionID));
}
// 将权限按模块ID构建树形结构
function transformPermissionsToTree(flatData: any[]) {
if (!flatData || flatData.length === 0) {
return [];
}
// 按模块分组
const moduleMap = new Map<string, any>();
const tree: any[] = [];
flatData.forEach(item => {
const moduleID = item.moduleID;
if (!moduleID) {
// 没有模块ID的权限作为根节点
tree.push({
...item,
displayName: item.name,
isModule: false
});
return;
}
if (!moduleMap.has(moduleID)) {
// 创建模块节点
moduleMap.set(moduleID, {
permissionID: moduleID, // 使用moduleID作为ID确保唯一性
displayName: item.moduleName || moduleID,
moduleID: moduleID,
moduleName: item.moduleName,
moduleCode: item.moduleCode,
moduleDescription: item.moduleDescription,
children: [],
isModule: true // 标记这是模块节点
});
}
// 添加权限到模块的children中
const moduleNode = moduleMap.get(moduleID);
if (moduleNode) {
moduleNode.children.push({
...item,
displayName: item.name,
isModule: false // 标记这是权限节点
});
}
});
// 将所有模块添加到树中
moduleMap.forEach(moduleNode => {
tree.push(moduleNode);
});
return tree;
}
// 查看绑定权限
async function handleBindPermission(row: SysRole) {
currentRole.value = row;
try {
// 获取所有权限
let permission: SysPermission = {
permissionID: undefined,
name: undefined,
code: undefined,
description: undefined,
};
const permissionResult = await permissionApi.getPermissionList(permission);
permissionList.value = permissionResult.dataList || [];
// 获取已绑定的权限
const bindingResult = await roleApi.getRolePermission({
roleID: row.roleID
});
initialBoundPermissions.value = bindingResult.dataList || [];
bindPermissionDialogVisible.value = true;
} catch (error) {
console.error('获取权限绑定信息失败:', error);
ElMessage.error('获取权限绑定信息失败');
}
bindPermissionDialogVisible.value = true;
// 不再需要预加载数据,由 GenericSelector 在打开时自动调用 API 加载
}
// 重置绑定列表
function resetBindList() {
initialBoundPermissions.value = [];
currentRole.value = null;
}
@@ -301,11 +369,20 @@ async function handlePermissionConfirm(items: SysPermission[]) {
try {
submitting.value = true;
// 获取当前已绑定的权限ID
const currentBoundIds = initialBoundPermissions.value.map(p => p.permissionID).filter((id): id is string => !!id);
// 重新获取当前已绑定的权限
const bindingResult = await roleApi.getRolePermission({
roleID: currentRole.value.roleID
});
const currentBoundPermissions = bindingResult.dataList || [];
// 新选择的权限ID
const newSelectedIds = items.map(p => p.permissionID).filter((id): id is string => !!id);
// 获取当前已绑定的权限ID
const currentBoundIds = currentBoundPermissions.map(p => p.permissionID).filter((id): id is string => !!id);
// 新选择的权限ID过滤掉模块节点只保留权限节点
const newSelectedIds = items
.filter((item: any) => !item.isModule) // 只保留权限节点,过滤模块节点
.map(p => p.permissionID)
.filter((id): id is string => !!id);
// 找出需要绑定的权限(新增的)
const permissionsToBind = newSelectedIds.filter(id => !currentBoundIds.includes(id));
@@ -316,7 +393,7 @@ async function handlePermissionConfirm(items: SysPermission[]) {
// 构建需要绑定的权限对象数组
if (permissionsToBind.length > 0) {
const permissionsToBindObjects = permissionsToBind.map(permissionID => {
const permission = permissionList.value.find(p => p.permissionID === permissionID);
const permission = items.find(p => p.permissionID === permissionID);
return permission || { permissionID: permissionID };
});
@@ -331,7 +408,7 @@ async function handlePermissionConfirm(items: SysPermission[]) {
// 构建需要解绑的权限对象数组
if (permissionsToUnbind.length > 0) {
const permissionsToUnbindObjects = permissionsToUnbind.map(permissionID => {
const permission = permissionList.value.find(p => p.permissionID === permissionID);
const permission = currentBoundPermissions.find(p => p.permissionID === permissionID);
return permission || { permissionID: permissionID };
});
@@ -344,6 +421,8 @@ async function handlePermissionConfirm(items: SysPermission[]) {
}
ElMessage.success('权限绑定保存成功');
bindPermissionDialogVisible.value = false;
resetBindList();
// 刷新角色列表
await loadRoleList();

View File

@@ -319,16 +319,17 @@
:title="selectorMode === 'add' ? '添加人员' : '删除人员'"
:left-title="selectorMode === 'add' ? '可添加人员' : '当前人员'"
:right-title="selectorMode === 'add' ? '待添加人员' : '待删除人员'"
:available-items="selectorMode === 'remove' ? availableUsers : []"
:initial-target-items="[]"
:fetch-available-api="selectorMode === 'add' ? fetchAllUsers : fetchCurrentUsers"
:fetch-selected-api="selectorMode === 'add' ? fetchTaskUsers : undefined"
:filter-selected="filterUsers"
:loading="saving"
:item-config="{ id: 'id', label: 'username', sublabel: 'deptName' }"
:use-tree="true"
:tree-transform="transformUsersToTree"
:tree-props="{ children: 'children', label: 'displayName', id: 'id' }"
:only-leaf-selectable="true"
unit-name=""
search-placeholder="搜索人员..."
:use-pagination="selectorMode === 'add'"
:fetch-api="selectorMode === 'add' ? userApi.getUserPage : undefined"
:filter-params="userFilterParams"
:page-size="20"
@confirm="handleUserSelectConfirm"
@cancel="closeSelectorModal"
/>
@@ -388,9 +389,7 @@ const deleting = ref(false);
const managingTask = ref<LearningTask | null>(null);
const selectorMode = ref<'add' | 'remove'>('add');
const currentUsers = ref<UserVO[]>([]);
const availableUsers = ref<UserVO[]>([]);
const saving = ref(false);
const userFilterParams = ref<any>({});
// 计算显示的页码
const displayPages = computed(() => {
@@ -523,7 +522,9 @@ function handleEdit(task: LearningTask) {
emit('edit', task);
}
function handleStatistics(task: LearningTask) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function handleStatistics(_task: LearningTask) {
// TODO: 实现统计功能
return;
}
@@ -565,30 +566,171 @@ function closeUserList() {
currentUsers.value = [];
}
// 获取所有用户的API函数
async function fetchAllUsers() {
const filter: any = { username: undefined };
return await userApi.getUserList(filter);
}
// 获取当前任务用户的API函数用于删除模式
async function fetchCurrentUsers() {
// 删除模式时,左侧显示当前任务的用户列表
return {
success: true,
dataList: currentUsers.value,
code: 200,
message: '',
login: true,
auth: true
};
}
// 获取任务已分配用户的API函数
async function fetchTaskUsers() {
if (!managingTask.value || !managingTask.value.taskID) {
return {
success: true,
dataList: [],
code: 200,
message: '',
login: true,
auth: true
};
}
// 从任务详情中获取用户列表
const result = await learningTaskApi.getTaskUsers(managingTask.value.taskID);
if (result.success && result.dataList) {
// 将返回的用户列表转换为UserVO格式
const users = (result.dataList || []).map((item: any) => ({
id: item.userID,
username: item.username,
deptID: item.deptID,
deptName: item.deptName,
parentDeptID: item.parentDeptID
}));
return {
success: true,
dataList: users,
code: 200,
message: '',
login: true,
auth: true
};
}
return {
success: false,
dataList: [],
code: result.code || 500,
message: result.message || '获取任务用户失败',
login: true,
auth: true
};
}
// 过滤已选用户
function filterUsers(available: any[], selected: any[]) {
const selectedIds = new Set(selected.map(item => item.id));
return available.filter(item => !selectedIds.has(item.id));
}
// 将用户扁平数据转换为树形结构(按部门分组)
function transformUsersToTree(flatData: any[]): any[] {
if (!flatData || flatData.length === 0) {
return [];
}
// 按部门分组
const deptMap = new Map<string, any>();
const tree: any[] = [];
flatData.forEach(item => {
const deptID = item.deptID || 'unknown';
const deptName = item.deptName || '未分配部门';
if (!deptMap.has(deptID)) {
// 创建部门节点
deptMap.set(deptID, {
id: `dept_${deptID}`, // 使用特殊前缀避免与用户ID冲突
displayName: deptName,
deptID: deptID,
deptName: deptName,
parentDeptID: item.parentDeptID,
children: [],
isDept: true // 标记这是部门节点
});
}
// 添加用户到部门的children中
const deptNode = deptMap.get(deptID);
if (deptNode) {
deptNode.children.push({
...item,
displayName: item.username || item.id,
isDept: false // 标记这是用户节点
});
}
});
// 构建部门层级关系
const deptNodes = Array.from(deptMap.values());
const deptTreeMap = new Map<string, any>();
// 初始化所有部门节点
deptNodes.forEach(dept => {
deptTreeMap.set(dept.deptID, { ...dept });
});
// 构建部门树
deptNodes.forEach(dept => {
const node = deptTreeMap.get(dept.deptID);
if (!node) return;
if (!dept.parentDeptID || dept.parentDeptID === '0' || dept.parentDeptID === '') {
// 根部门
tree.push(node);
} else {
// 子部门
const parent = deptTreeMap.get(dept.parentDeptID);
if (parent) {
// 将用户节点暂存
const users = node.children || [];
node.children = [];
// 添加部门到父部门
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
// 将用户添加回来(放在部门子节点之后)
node.children = users;
} else {
// 找不到父节点,作为根节点
tree.push(node);
}
}
});
return tree;
}
// 显示添加人员选择器
function showAddUserSelector() {
selectorMode.value = 'add';
// 设置过滤参数(可以过滤掉已分配的用户,但在组件内部会自动过滤)
userFilterParams.value = {};
showUserSelector.value = true;
}
// 显示删除人员选择器
function showRemoveUserSelector() {
selectorMode.value = 'remove';
// 左侧显示当前用户
availableUsers.value = [...currentUsers.value];
showUserSelector.value = true;
}
// 关闭选择器弹窗
function closeSelectorModal() {
showUserSelector.value = false;
availableUsers.value = [];
}
// 处理用户选择器确认事件