Merge branch 'master' of http://192.168.195.14:3000/outsource/mi-assessment
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
zpc 2026-04-20 09:55:01 +08:00
commit d6db9996be
13 changed files with 195 additions and 38 deletions

View File

@ -24,10 +24,15 @@ public class CreateAdminUserRequest
public string Password { get; set; } = null!;
/// <summary>
/// 真实姓名
/// 真实姓名(为空时自动转 null
/// </summary>
[MaxLength(50, ErrorMessage = "真实姓名最多50个字符")]
public string? RealName { get; set; }
public string? RealName
{
get => _realName;
set => _realName = string.IsNullOrWhiteSpace(value) ? null : value;
}
private string? _realName;
/// <summary>
/// 头像URL
@ -36,17 +41,27 @@ public class CreateAdminUserRequest
public string? Avatar { get; set; }
/// <summary>
/// 邮箱
/// 邮箱(为空时跳过格式验证)
/// </summary>
[MaxLength(100, ErrorMessage = "邮箱最多100个字符")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string? Email { get; set; }
public string? Email
{
get => _email;
set => _email = string.IsNullOrWhiteSpace(value) ? null : value;
}
private string? _email;
/// <summary>
/// 手机号
/// 手机号(为空时自动转 null
/// </summary>
[MaxLength(20, ErrorMessage = "手机号最多20个字符")]
public string? Phone { get; set; }
public string? Phone
{
get => _phone;
set => _phone = string.IsNullOrWhiteSpace(value) ? null : value;
}
private string? _phone;
/// <summary>
/// 部门ID

View File

@ -8,10 +8,15 @@ namespace MiAssessment.Admin.Models.AdminUser;
public class UpdateAdminUserRequest
{
/// <summary>
/// 真实姓名
/// 真实姓名(为空时自动转 null
/// </summary>
[MaxLength(50, ErrorMessage = "真实姓名最多50个字符")]
public string? RealName { get; set; }
public string? RealName
{
get => _realName;
set => _realName = string.IsNullOrWhiteSpace(value) ? null : value;
}
private string? _realName;
/// <summary>
/// 头像URL
@ -20,17 +25,27 @@ public class UpdateAdminUserRequest
public string? Avatar { get; set; }
/// <summary>
/// 邮箱
/// 邮箱(为空时跳过格式验证)
/// </summary>
[MaxLength(100, ErrorMessage = "邮箱最多100个字符")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string? Email { get; set; }
public string? Email
{
get => _email;
set => _email = string.IsNullOrWhiteSpace(value) ? null : value;
}
private string? _email;
/// <summary>
/// 手机号
/// 手机号(为空时自动转 null
/// </summary>
[MaxLength(20, ErrorMessage = "手机号最多20个字符")]
public string? Phone { get; set; }
public string? Phone
{
get => _phone;
set => _phone = string.IsNullOrWhiteSpace(value) ? null : value;
}
private string? _phone;
/// <summary>
/// 部门ID

View File

@ -15,15 +15,17 @@ public class AdminUserService : IAdminUserService
private readonly AdminDbContext _dbContext;
private readonly ILogger<AdminUserService> _logger;
private readonly IAuthService _authService;
private readonly IPermissionService _permissionService;
// 超级管理员角色编码
private const string SuperAdminRoleCode = "super_admin";
public AdminUserService(AdminDbContext dbContext, ILogger<AdminUserService> logger, IAuthService authService)
public AdminUserService(AdminDbContext dbContext, ILogger<AdminUserService> logger, IAuthService authService, IPermissionService permissionService)
{
_dbContext = dbContext;
_logger = logger;
_authService = authService;
_permissionService = permissionService;
}
/// <inheritdoc />
@ -310,6 +312,7 @@ public class AdminUserService : IAdminUserService
}
await _dbContext.SaveChangesAsync();
_permissionService.InvalidateCache(userId);
_logger.LogInformation("管理员 {UserId} 分配角色成功,角色数量: {Count}", userId, roleIds.Count);
}
@ -353,6 +356,10 @@ public class AdminUserService : IAdminUserService
}
await _dbContext.SaveChangesAsync();
// 清除用户权限缓存,使新菜单权限立即生效
_permissionService.InvalidateCache(userId);
_logger.LogInformation("管理员 {UserId} 分配用户专属菜单成功,菜单数量: {Count}", userId, menuIds.Count);
}

View File

@ -212,14 +212,71 @@ public class PermissionService : IPermissionService
var roleIds = userRoles.Select(ur => ur.RoleId).ToList();
// 获取角色关联的权限
var permissions = await _dbContext.RolePermissions
// 1. 获取角色关联的权限permissions 表)
var rolePermissions = await _dbContext.RolePermissions
.Where(rp => roleIds.Contains(rp.RoleId))
.Include(rp => rp.Permission)
.Select(rp => rp.Permission.Code)
.Distinct()
.ToListAsync();
return permissions;
// 2. 收集角色关联的菜单 ID
var roleMenuIds = await _dbContext.RoleMenus
.Where(rm => roleIds.Contains(rm.RoleId))
.Select(rm => rm.MenuId)
.Distinct()
.ToListAsync();
// 3. 收集用户专属菜单 ID
var userMenuIds = await _dbContext.AdminUserMenus
.Where(um => um.AdminUserId == adminUserId)
.Select(um => um.MenuId)
.Distinct()
.ToListAsync();
// 4. 合并所有菜单 ID并递归查找所有子菜单
var allMenuIds = roleMenuIds.Concat(userMenuIds).Distinct().ToHashSet();
var allChildMenuIds = await GetAllChildMenuIdsAsync(allMenuIds);
allMenuIds.UnionWith(allChildMenuIds);
// 5. 从所有菜单中提取权限标识
var menuPermissions = await _dbContext.Set<Entities.Menu>()
.Where(m => allMenuIds.Contains(m.Id) && m.Permission != null && m.Permission != "")
.Select(m => m.Permission!)
.Distinct()
.ToListAsync();
// 合并所有权限
var allPermissions = rolePermissions
.Concat(menuPermissions)
.Distinct()
.ToList();
return allPermissions;
}
/// <summary>
/// 递归获取所有子菜单 ID
/// </summary>
private async Task<HashSet<long>> GetAllChildMenuIdsAsync(HashSet<long> parentIds)
{
var result = new HashSet<long>();
var currentParentIds = parentIds;
while (currentParentIds.Count > 0)
{
var childIds = await _dbContext.Set<Entities.Menu>()
.Where(m => currentParentIds.Contains(m.ParentId))
.Select(m => m.Id)
.ToListAsync();
var newIds = childIds.Where(id => !result.Contains(id) && !parentIds.Contains(id)).ToHashSet();
if (newIds.Count == 0) break;
result.UnionWith(newIds);
currentParentIds = newIds;
}
return result;
}
}

View File

@ -2,28 +2,29 @@ import { request, type ApiResponse, type PagedResult } from '@/utils/request'
export interface OperationLog {
id: number
userId: number
username: string
module: string
action: string
method: string
url: string
params: string | null
adminUserId: number | null
username: string | null
module: string | null
action: string | null
method: string | null
url: string | null
ip: string | null
userAgent: string | null
duration: number
requestData: string | null
responseData: string | null
status: number
errorMessage: string | null
errorMsg: string | null
duration: number
createdAt: string
}
export interface OperationLogQuery {
keyword?: string
module?: string
startDate?: string
endDate?: string
page: number
pageSize: number
username?: string
module?: string
status?: number
startDate?: string
endDate?: string
}
// 获取操作日志列表
@ -35,7 +36,7 @@ export function getOperationLogList(params: OperationLogQuery): Promise<ApiRespo
})
}
// 获取操作日志详情
// 获取日志详情
export function getOperationLog(id: number): Promise<ApiResponse<OperationLog>> {
return request({
url: `/admin/logs/${id}`,

View File

@ -10,7 +10,7 @@ export { businessRoutes, type RouteMeta, getBusinessPermissions, filterRoutesByP
const constantRoutes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/dashboard'
redirect: '/dashboard' // 默认值,登录后会被动态路由覆盖
},
{
path: '/login',
@ -60,6 +60,23 @@ const router = createRouter({
// 白名单路由
const whiteList = ['/login', '/404']
/**
* 访
*/
function getFirstAccessiblePath(routes: RouteRecordRaw[], parentPath = ''): string | null {
for (const route of routes) {
const fullPath = parentPath ? `${parentPath}/${route.path}`.replace(/\/+/g, '/') : route.path
// 有 component 且不是 Layout 的就是叶子页面
if (route.children && route.children.length > 0) {
const childPath = getFirstAccessiblePath(route.children, fullPath)
if (childPath) return childPath
} else if (route.component) {
return fullPath
}
}
return null
}
// 标记动态路由是否已加载
let dynamicRoutesLoaded = false
@ -94,6 +111,11 @@ router.beforeEach(async (to, _from, next) => {
accessRoutes.forEach(route => {
router.addRoute(route)
})
// 动态设置首页重定向到用户有权限的第一个页面
const firstRoute = getFirstAccessiblePath(accessRoutes)
if (firstRoute) {
router.addRoute({ path: '/', redirect: firstRoute })
}
// 添加 404 兜底路由
router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' })
// 标记动态路由已加载

View File

@ -123,6 +123,11 @@ async function handle401Error(error: any): Promise<any> {
// Don't retry if already retried or if it's a refresh request
if (originalConfig._retry || isWhiteListUrl(originalConfig.url)) {
// 白名单接口(如登录)的 401 错误,直接显示后端返回的错误信息
const msg = error.response?.data?.message
if (msg) {
ElMessage.error(msg)
}
return Promise.reject(error)
}

View File

@ -161,7 +161,6 @@
show-checkbox
node-key="id"
:default-checked-keys="checkedMenuIds"
:check-strictly="true"
/>
<template #footer>
<el-button @click="menuDialogVisible = false">取消</el-button>
@ -304,6 +303,25 @@ const passwordRules: FormRules = {
]
}
/**
* 过滤出叶子节点 ID用于树组件回显 check-strictly 模式
*/
function filterLeafMenuIds(tree: MenuTree[], checkedIds: number[]): number[] {
const result: number[] = []
const idSet = new Set(checkedIds)
function walk(nodes: MenuTree[]) {
for (const node of nodes) {
if (node.children && node.children.length > 0) {
walk(node.children)
} else if (idSet.has(node.id)) {
result.push(node.id)
}
}
}
walk(tree)
return result
}
const fetchData = async () => {
loading.value = true
try {
@ -372,11 +390,18 @@ const handleSubmit = async () => {
submitLoading.value = true
try {
// null
const submitData = {
...formData,
realName: formData.realName || null,
email: formData.email || null,
phone: formData.phone || null
}
if (isEdit.value) {
await updateAdminUser(formData.id, formData)
await updateAdminUser(submitData.id, submitData)
ElMessage.success('更新成功')
} else {
await createAdminUser(formData)
await createAdminUser(submitData)
ElMessage.success('创建成功')
}
dialogVisible.value = false
@ -446,14 +471,20 @@ const handleSubmitDepartment = async () => {
const handleAssignMenu = async (row: AdminUser) => {
currentUserId.value = row.id
const res = await getUserMenus(row.id)
checkedMenuIds.value = res.data
// check-strictly ID
const allIds: number[] = res.data
const leafIds = filterLeafMenuIds(menuTree.value, allIds)
checkedMenuIds.value = leafIds
menuDialogVisible.value = true
}
const handleSubmitMenus = async () => {
menuSubmitLoading.value = true
try {
const menuIds = menuTreeRef.value?.getCheckedKeys(false) as number[]
const menuIds = [
...(menuTreeRef.value?.getCheckedKeys(false) as number[]),
...(menuTreeRef.value?.getHalfCheckedKeys() as number[])
]
await assignUserMenus({ userId: currentUserId.value, menuIds })
ElMessage.success('分配成功')
menuDialogVisible.value = false

1
temp_menu29.txt Normal file
View File

@ -0,0 +1 @@
{"code":0,"message":"success","data":{"id":29,"parentId":28,"name":"用户列表","path":"/user/list","component":"business/user/index","icon":"peoples","menuType":2,"permission":"user:view","sortOrder":1,"status":1,"isExternal":false,"isCache":true,"createdAt":"2026-02-03T17:32:15.877","updatedAt":"2026-04-19T20:22:50.107"}}

1
temp_menus.json Normal file

File diff suppressed because one or more lines are too long

1
temp_test_menus.txt Normal file
View File

@ -0,0 +1 @@
{"code":0,"message":"success","data":[28,29,30,31,32,34,35,36,37]}

View File

@ -1,6 +1,6 @@
{
"name" : "学业邑规划",
"appid" : "__UNI__1BAACAB",
"appid" : "__UNI__A612028",
"description" : "",
"versionName" : "1.0.1",
"versionCode" : 101,

View File

@ -653,6 +653,7 @@
</view>
</view>
</view>
<!-- 进行中测评弹窗 -->
<view v-if="showPendingPopup" class="popup-mask" @click="handleDismissPending">
<view class="popup-container pending-popup" @click.stop>