refactor: 系统权限模块重构,迁移至PostgreSQL并对齐新设计
- 切换数据库为PostgreSQL,更新依赖与配置 - 重构权限体系,使用sys_user/sys_role/sys_menu等新实体替代旧模型 - 移除旧的用户角色/权限/系统设置相关代码与实体 - 新增字典、菜单管理模块与基础CRUD接口 - 优化鉴权逻辑,基于菜单权限标识进行接口校验 - 前端同步更新API、路由与权限控制逻辑 - 删除冗余的v-permission指令与相关代码
This commit is contained in:
1 parent
98c22f6afb
commit
0a8103f33d
67 files changed
+3263
-1924
No files matched your search
+43
-8
@@ -1,15 +1,50 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 菜单节点(内联定义,避免与 api/menu.ts 产生依赖)
|
||||
interface MenuNode {
|
||||
id: number;
|
||||
parentId: number | null;
|
||||
menuName: string;
|
||||
menuPath?: string | null;
|
||||
component?: string | null;
|
||||
icon?: string | null;
|
||||
sortOrder: number;
|
||||
menuType: number; // 1目录 2菜单
|
||||
permission?: string | null;
|
||||
visible: number;
|
||||
children?: MenuNode[];
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userInfo: {
|
||||
id: number;
|
||||
username: string;
|
||||
realName: string;
|
||||
};
|
||||
roles: string[];
|
||||
menus: MenuNode[];
|
||||
}
|
||||
|
||||
export interface CurrentUserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
realName: string;
|
||||
roles: string[];
|
||||
menus: MenuNode[];
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (data: { username: string; password: string }) =>
|
||||
request.post('/auth/login', data),
|
||||
login: (data: { username: string; password: string }) =>
|
||||
request.post<LoginResult>('/auth/login', data),
|
||||
|
||||
logout: () =>
|
||||
request.post('/auth/logout'),
|
||||
logout: () =>
|
||||
request.post('/auth/logout'),
|
||||
|
||||
refreshToken: (refreshToken: string) =>
|
||||
request.post('/auth/refresh', { refreshToken }),
|
||||
refreshToken: (refreshToken: string) =>
|
||||
request.post<{ accessToken: string }>('/auth/refresh', { refreshToken }),
|
||||
|
||||
getCurrentUser: () =>
|
||||
request.get('/auth/currentUser'),
|
||||
getCurrentUser: () =>
|
||||
request.get<CurrentUserInfo>('/auth/currentUser'),
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface DictItem {
|
||||
id: number;
|
||||
dictType: string;
|
||||
dictLabel: string;
|
||||
dictValue: string;
|
||||
sortOrder: number;
|
||||
status: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DictQuery {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
dictType?: string;
|
||||
keyword?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export interface DictListResult {
|
||||
list: DictItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface DictForm {
|
||||
dictType: string;
|
||||
dictLabel: string;
|
||||
dictValue: string;
|
||||
sortOrder?: number;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
export const dictApi = {
|
||||
getDictList: (params?: DictQuery) =>
|
||||
request.get<DictListResult>('/dicts', { params }),
|
||||
|
||||
getDictTypes: () =>
|
||||
request.get<string[]>('/dicts/types'),
|
||||
|
||||
createDict: (data: DictForm) =>
|
||||
request.post('/dicts', data),
|
||||
|
||||
updateDict: (id: number, data: Partial<DictForm>) =>
|
||||
request.put(`/dicts/${id}`, data),
|
||||
|
||||
deleteDict: (id: number) =>
|
||||
request.delete(`/dicts/${id}`),
|
||||
};
|
||||
+34
-27
@@ -1,36 +1,43 @@
|
||||
import request from '@/utils/request'
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface LogItem {
|
||||
id: number
|
||||
userId?: number
|
||||
username?: string
|
||||
action: string
|
||||
module?: string
|
||||
method: string
|
||||
url: string
|
||||
ipAddress: string
|
||||
statusCode: number
|
||||
duration: number
|
||||
createdAt: string
|
||||
id: number;
|
||||
userId?: number;
|
||||
module?: string;
|
||||
action: string;
|
||||
method: string;
|
||||
params?: string;
|
||||
ip: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface LogQuery {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
operator?: string
|
||||
actionType?: string
|
||||
moduleName?: string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
userId?: number;
|
||||
module?: string;
|
||||
action?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface LogListResult {
|
||||
list: LogItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export const logApi = {
|
||||
getLogList: (params: LogQuery) =>
|
||||
request.get('/logs', { params }),
|
||||
getLogList: (params: LogQuery) =>
|
||||
request.get<LogListResult>('/logs', { params }),
|
||||
|
||||
exportLogs: (params: Omit<LogQuery, 'page' | 'pageSize'>) =>
|
||||
request.get('/logs/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
}),
|
||||
}
|
||||
getLogDetail: (id: number) =>
|
||||
request.get<LogItem>(`/logs/${id}`),
|
||||
|
||||
exportLogs: (params: Omit<LogQuery, 'page' | 'pageSize'>) =>
|
||||
request.get('/logs/export', {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 菜单节点
|
||||
export interface MenuNode {
|
||||
id: number;
|
||||
parentId: number | null;
|
||||
menuName: string;
|
||||
menuPath?: string | null;
|
||||
component?: string | null;
|
||||
icon?: string | null;
|
||||
sortOrder: number;
|
||||
menuType: number; // 1目录 2菜单
|
||||
permission?: string | null;
|
||||
visible: number;
|
||||
children?: MenuNode[];
|
||||
}
|
||||
|
||||
export interface MenuForm {
|
||||
parentId?: number;
|
||||
menuName: string;
|
||||
menuPath?: string;
|
||||
component?: string;
|
||||
icon?: string;
|
||||
sortOrder?: number;
|
||||
menuType: number;
|
||||
permission?: string;
|
||||
visible?: number;
|
||||
}
|
||||
|
||||
export const menuApi = {
|
||||
getMenuTree: () =>
|
||||
request.get<MenuNode[]>('/menus/tree'),
|
||||
|
||||
createMenu: (data: MenuForm) =>
|
||||
request.post('/menus', data),
|
||||
|
||||
updateMenu: (id: number, data: Partial<MenuForm>) =>
|
||||
request.put(`/menus/${id}`, data),
|
||||
|
||||
deleteMenu: (id: number) =>
|
||||
request.delete(`/menus/${id}`),
|
||||
};
|
||||
+20
-24
@@ -1,31 +1,22 @@
|
||||
import request from '@/utils/request';
|
||||
import type { MenuNode } from './menu';
|
||||
|
||||
export interface RoleInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
description?: string;
|
||||
status: number;
|
||||
sortOrder: number;
|
||||
userCount: number;
|
||||
permissions?: any[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PermissionTreeNode {
|
||||
export interface SysRole {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
type: string;
|
||||
parent_id?: number | null;
|
||||
sortOrder: number;
|
||||
icon?: string;
|
||||
route?: string;
|
||||
component?: string;
|
||||
status: number;
|
||||
children?: PermissionTreeNode[];
|
||||
checked?: boolean;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
description?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface RoleListResult {
|
||||
@@ -35,28 +26,33 @@ export interface RoleListResult {
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface RoleMenusResult {
|
||||
menuTree: MenuNode[];
|
||||
menuIds: number[];
|
||||
}
|
||||
|
||||
export const roleApi = {
|
||||
getRoleList: (params?: { page?: number; pageSize?: number; keyword?: string }) =>
|
||||
request.get<RoleListResult>('/roles', { params }),
|
||||
|
||||
getAllRoles: () =>
|
||||
request.get<RoleInfo[]>('/roles/all'),
|
||||
request.get<SysRole[]>('/roles/all'),
|
||||
|
||||
getRoleDetail: (id: number) =>
|
||||
request.get<RoleInfo>(`/roles/${id}`),
|
||||
|
||||
createRole: (data: { name: string; code: string; description?: string; status?: number; sortOrder?: number }) =>
|
||||
createRole: (data: { roleCode: string; roleName: string; description?: string; sortOrder?: number }) =>
|
||||
request.post('/roles', data),
|
||||
|
||||
updateRole: (id: number, data: { name?: string; description?: string; status?: number; sortOrder?: number }) =>
|
||||
updateRole: (id: number, data: { roleCode?: string; roleName?: string; description?: string; sortOrder?: number }) =>
|
||||
request.put(`/roles/${id}`, data),
|
||||
|
||||
deleteRole: (id: number) =>
|
||||
request.delete(`/roles/${id}`),
|
||||
|
||||
getRolePermissions: (id: number) =>
|
||||
request.get<PermissionTreeNode[]>(`/roles/${id}/permissions`),
|
||||
getRoleMenus: (id: number) =>
|
||||
request.get<RoleMenusResult>(`/roles/${id}/menus`),
|
||||
|
||||
updateRolePermissions: (id: number, permissionIds: number[]) =>
|
||||
request.put(`/roles/${id}/permissions`, { permissionIds }),
|
||||
updateRoleMenus: (id: number, menuIds: number[]) =>
|
||||
request.put(`/roles/${id}/menus`, { menuIds }),
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
export interface BasicSettings {
|
||||
siteName: string;
|
||||
logo: string;
|
||||
sessionTimeout: number;
|
||||
}
|
||||
|
||||
export interface SecuritySettings {
|
||||
passwordMinLength: number;
|
||||
maxLoginAttempts: number;
|
||||
lockoutDuration: number;
|
||||
ipWhitelist: string[];
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
enableEmail: boolean;
|
||||
emailSmtpConfig: any;
|
||||
}
|
||||
|
||||
export interface SystemSettings {
|
||||
basic: BasicSettings;
|
||||
security: SecuritySettings;
|
||||
notification: NotificationSettings;
|
||||
}
|
||||
|
||||
export const systemApi = {
|
||||
getSettings: () =>
|
||||
request.get<SystemSettings>('/settings'),
|
||||
|
||||
updateBasicSettings: (data: Partial<BasicSettings>) =>
|
||||
request.put('/settings/basic', data),
|
||||
|
||||
updateSecuritySettings: (data: Partial<SecuritySettings>) =>
|
||||
request.put('/settings/security', data),
|
||||
|
||||
updateNotificationSettings: (data: Partial<NotificationSettings>) =>
|
||||
request.put('/settings/notification', data),
|
||||
};
|
||||
+29
-21
@@ -3,30 +3,24 @@ import request from '@/utils/request';
|
||||
export interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
realName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
status: number;
|
||||
lastLoginAt: string | null;
|
||||
userRoles?: UserRoleInfo[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserRoleInfo {
|
||||
id: number;
|
||||
userId: number;
|
||||
roleId: number;
|
||||
role?: RoleInfo;
|
||||
}
|
||||
|
||||
export interface RoleInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
status: number;
|
||||
sortOrder: number;
|
||||
roleCode: string;
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
export interface UserQuery {
|
||||
@@ -37,10 +31,17 @@ export interface UserQuery {
|
||||
roleId?: number;
|
||||
}
|
||||
|
||||
export interface UserListResult {
|
||||
list: UserInfo[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface CreateUserParams {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
realName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
roleIds: number[];
|
||||
@@ -48,29 +49,36 @@ export interface CreateUserParams {
|
||||
}
|
||||
|
||||
export interface UpdateUserParams {
|
||||
nickname?: string;
|
||||
realName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar?: string;
|
||||
roleIds?: number[];
|
||||
status?: number;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export const userApi = {
|
||||
getUserList: (params: UserQuery) => request.get('/users', { params }),
|
||||
getUserList: (params: UserQuery) =>
|
||||
request.get<UserListResult>('/users', { params }),
|
||||
|
||||
getRoleList: () => request.get<RoleInfo[]>('/users/roles'),
|
||||
getRoleList: () =>
|
||||
request.get<RoleInfo[]>('/users/roles'),
|
||||
|
||||
getUserDetail: (id: number) => request.get<UserInfo>(`/users/${id}`),
|
||||
getUserDetail: (id: number) =>
|
||||
request.get<UserInfo>(`/users/${id}`),
|
||||
|
||||
createUser: (data: CreateUserParams) => request.post('/users', data),
|
||||
createUser: (data: CreateUserParams) =>
|
||||
request.post('/users', data),
|
||||
|
||||
updateUser: (id: number, data: UpdateUserParams) => request.put(`/users/${id}`, data),
|
||||
updateUser: (id: number, data: UpdateUserParams) =>
|
||||
request.put(`/users/${id}`, data),
|
||||
|
||||
deleteUser: (id: number) => request.delete(`/users/${id}`),
|
||||
deleteUser: (id: number) =>
|
||||
request.delete(`/users/${id}`),
|
||||
|
||||
batchDeleteUsers: (ids: number[]) => request.post('/users/batch-delete', { ids }),
|
||||
batchDeleteUsers: (ids: number[]) =>
|
||||
request.post('/users/batch-delete', { ids }),
|
||||
|
||||
updateStatus: (id: number, status: number) => request.put(`/users/${id}/status`, { status }),
|
||||
updateStatus: (id: number, status: number) =>
|
||||
request.put(`/users/${id}/status`, { status }),
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { App, Directive, DirectiveBinding } from 'vue';
|
||||
|
||||
const permissionDirective: Directive = {
|
||||
mounted(el: HTMLElement, binding: DirectiveBinding<string>) {
|
||||
const { value } = binding;
|
||||
|
||||
if (!value) {
|
||||
console.warn('[v-permission] 需要提供权限码');
|
||||
return;
|
||||
}
|
||||
|
||||
const stores = import('@/stores/permission');
|
||||
stores.then(({ usePermissionStore }) => {
|
||||
const store = usePermissionStore();
|
||||
|
||||
if (store.userInfo?.roles.includes('super_admin')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!store.permissions.includes(value)) {
|
||||
el.parentNode?.removeChild(el);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default function setupPermissionDirective(app: App) {
|
||||
app.directive('permission', permissionDirective);
|
||||
}
|
||||
@@ -91,24 +91,29 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, h } from 'vue'
|
||||
import { ref, computed, watch, h, type Component, type VNode } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
ToolOutlined,
|
||||
InfoCircleOutlined,
|
||||
LockOutlined,
|
||||
MenuOutlined,
|
||||
TeamOutlined,
|
||||
ProjectOutlined,
|
||||
ScheduleOutlined,
|
||||
MonitorOutlined,
|
||||
BarChartOutlined,
|
||||
BookOutlined,
|
||||
FileTextOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
HomeOutlined,
|
||||
DownOutlined,
|
||||
LogoutOutlined,
|
||||
LockOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import { usePermissionStore } from '@/stores/permission'
|
||||
import { usePermissionStore, type MenuNode } from '@/stores/permission'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -119,57 +124,79 @@ const collapsed = ref(false)
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const openKeys = ref<string[]>([])
|
||||
|
||||
// 菜单配置
|
||||
const menuItems = computed(() => [
|
||||
// 菜单图标映射:菜单 icon 字符串 → 图标组件,未知图标显示默认图标
|
||||
const iconMap: Record<string, Component> = {
|
||||
TeamOutlined,
|
||||
ProjectOutlined,
|
||||
ScheduleOutlined,
|
||||
MonitorOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
UserOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
MenuOutlined,
|
||||
BookOutlined,
|
||||
FileTextOutlined,
|
||||
DashboardOutlined,
|
||||
}
|
||||
|
||||
function resolveIcon(icon?: string | null) {
|
||||
const iconComponent = icon ? iconMap[icon] : undefined
|
||||
return () => h(iconComponent || MenuOutlined)
|
||||
}
|
||||
|
||||
// 侧边栏菜单项结构(与 a-menu items 兼容)
|
||||
interface MenuItem {
|
||||
key: string
|
||||
icon: () => VNode
|
||||
label: string
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
// 由后端菜单树构建侧边栏菜单:menuType=1 目录 → 子菜单组,menuType=2 菜单 → 菜单项
|
||||
function buildMenuItems(nodes: MenuNode[]): MenuItem[] {
|
||||
const items: MenuItem[] = []
|
||||
for (const node of nodes) {
|
||||
if (node.menuType === 1) {
|
||||
const children = (node.children || [])
|
||||
.filter(child => child.menuType === 2 && child.menuPath)
|
||||
.map(child => ({
|
||||
key: child.menuPath as string,
|
||||
icon: resolveIcon(child.icon),
|
||||
label: child.menuName,
|
||||
}))
|
||||
if (children.length) {
|
||||
items.push({
|
||||
key: node.menuPath || `dir-${node.id}`,
|
||||
icon: resolveIcon(node.icon),
|
||||
label: node.menuName,
|
||||
children,
|
||||
})
|
||||
}
|
||||
} else if (node.menuType === 2 && node.menuPath) {
|
||||
items.push({
|
||||
key: node.menuPath,
|
||||
icon: resolveIcon(node.icon),
|
||||
label: node.menuName,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// 菜单配置:仪表盘固定展示在最前 + 后端菜单树动态渲染
|
||||
const menuItems = computed<MenuItem[]>(() => [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: () => h(DashboardOutlined),
|
||||
label: '仪表盘',
|
||||
},
|
||||
{
|
||||
key: '/system',
|
||||
icon: () => h(SettingOutlined),
|
||||
label: '系统管理',
|
||||
children: [
|
||||
{
|
||||
key: '/system/user',
|
||||
icon: () => h(UserOutlined),
|
||||
label: '用户管理',
|
||||
},
|
||||
{
|
||||
key: '/system/role',
|
||||
icon: () => h(SafetyCertificateOutlined),
|
||||
label: '角色权限',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/settings',
|
||||
icon: () => h(ToolOutlined),
|
||||
label: '系统设置',
|
||||
children: [
|
||||
{
|
||||
key: '/settings/basic',
|
||||
icon: () => h(InfoCircleOutlined),
|
||||
label: '基本设置',
|
||||
},
|
||||
{
|
||||
key: '/settings/security',
|
||||
icon: () => h(LockOutlined),
|
||||
label: '安全设置',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/log/operation',
|
||||
icon: () => h(FileTextOutlined),
|
||||
label: '操作日志',
|
||||
},
|
||||
...buildMenuItems(permissionStore.menus),
|
||||
])
|
||||
|
||||
// 计算属性
|
||||
const userInfo = computed(() => permissionStore.userInfo)
|
||||
const displayName = computed(() => userInfo.value?.nickname || userInfo.value?.username || '管理员')
|
||||
const displayName = computed(() => userInfo.value?.realName || userInfo.value?.username || '管理员')
|
||||
const userInitial = computed(() => (displayName.value?.charAt(0) || 'A').toUpperCase())
|
||||
|
||||
// 面包屑列表
|
||||
@@ -381,15 +408,15 @@ function handleUserMenuClick({ key }: { key: string }) {
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 16px;
|
||||
min-height: calc(100vh - 64px - 32px);
|
||||
margin: 0;
|
||||
min-height: calc(100vh - 64px);
|
||||
|
||||
.view-wrapper {
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
min-height: calc(100vh - 64px - 32px - 48px); // 减去header、margin和padding
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
min-height: calc(100vh - 64px);
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -435,11 +462,11 @@ function handleUserMenuClick({ key }: { key: string }) {
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 12px;
|
||||
margin: 0;
|
||||
|
||||
.view-wrapper {
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import "@/assets/styles/global.less";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import pinia from "./stores";
|
||||
import setupPermissionDirective from "@/directives/permission";
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -13,6 +12,4 @@ app.use(Antd);
|
||||
app.use(router);
|
||||
app.use(pinia);
|
||||
|
||||
setupPermissionDirective(app);
|
||||
|
||||
app.mount("#app");
|
||||
+50
-38
@@ -17,6 +17,7 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
name: "Root",
|
||||
component: () => import("@/layouts/index.vue"),
|
||||
redirect: "/dashboard",
|
||||
children: [
|
||||
@@ -26,65 +27,76 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/dashboard/index.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "仪表盘",
|
||||
permission: "dashboard"
|
||||
title: "仪表盘"
|
||||
}
|
||||
},
|
||||
// 业务模块(未开发,先使用占位页;菜单 component 落地后替换为真实组件)
|
||||
{
|
||||
path: "customer",
|
||||
name: "Customer",
|
||||
component: () => import("@/views/Placeholder.vue"),
|
||||
meta: { requiresAuth: true, title: "客户管理", permission: "customer:list" }
|
||||
},
|
||||
{
|
||||
path: "project",
|
||||
name: "Project",
|
||||
component: () => import("@/views/Placeholder.vue"),
|
||||
meta: { requiresAuth: true, title: "项目管理", permission: "project:list" }
|
||||
},
|
||||
{
|
||||
path: "dispatch",
|
||||
name: "Dispatch",
|
||||
component: () => import("@/views/Placeholder.vue"),
|
||||
meta: { requiresAuth: true, title: "运营派单", permission: "dispatch:list" }
|
||||
},
|
||||
{
|
||||
path: "device",
|
||||
name: "Device",
|
||||
component: () => import("@/views/Placeholder.vue"),
|
||||
meta: { requiresAuth: true, title: "设备监控", permission: "device:list" }
|
||||
},
|
||||
{
|
||||
path: "report",
|
||||
name: "Report",
|
||||
component: () => import("@/views/Placeholder.vue"),
|
||||
meta: { requiresAuth: true, title: "统计报表", permission: "report:list" }
|
||||
},
|
||||
// 系统管理
|
||||
{
|
||||
path: "system/user",
|
||||
name: "UserList",
|
||||
name: "SystemUser",
|
||||
component: () => import("@/views/system/user/index.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "用户管理",
|
||||
permission: "user:list"
|
||||
}
|
||||
meta: { requiresAuth: true, title: "用户管理", permission: "system:user" }
|
||||
},
|
||||
{
|
||||
path: "system/role",
|
||||
name: "RoleList",
|
||||
name: "SystemRole",
|
||||
component: () => import("@/views/system/role/index.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "角色管理",
|
||||
permission: "role:list"
|
||||
}
|
||||
meta: { requiresAuth: true, title: "角色管理", permission: "system:role" }
|
||||
},
|
||||
{
|
||||
path: "settings/basic",
|
||||
name: "BasicSettings",
|
||||
component: () => import("@/views/settings/basic.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "基本设置",
|
||||
permission: "system:basicSetting"
|
||||
}
|
||||
path: "system/menu",
|
||||
name: "SystemMenu",
|
||||
component: () => import("@/views/system/menu/index.vue"),
|
||||
meta: { requiresAuth: true, title: "菜单管理", permission: "system:menu" }
|
||||
},
|
||||
{
|
||||
path: "settings/security",
|
||||
name: "SecuritySettings",
|
||||
component: () => import("@/views/settings/security.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "安全设置",
|
||||
permission: "system:securitySetting"
|
||||
}
|
||||
path: "system/dict",
|
||||
name: "SystemDict",
|
||||
component: () => import("@/views/system/dict/index.vue"),
|
||||
meta: { requiresAuth: true, title: "数据字典", permission: "system:dict" }
|
||||
},
|
||||
{
|
||||
path: "log/operation",
|
||||
name: "OperationLog",
|
||||
component: () => import("@/views/log/operation.vue"),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "操作日志",
|
||||
permission: "log:operation"
|
||||
}
|
||||
component: () => import("@/views/log/operation/index.vue"),
|
||||
meta: { requiresAuth: true, title: "操作日志", permission: "system:log" }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/login"
|
||||
redirect: "/403"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -146,4 +158,4 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
export default router;
|
||||
@@ -3,18 +3,34 @@ import { ref, computed } from 'vue';
|
||||
import { authApi } from '@/api/auth';
|
||||
import router from '@/router';
|
||||
|
||||
// 后端菜单树节点结构(sys_menu)
|
||||
export interface MenuNode {
|
||||
id: number;
|
||||
parentId: number | null;
|
||||
menuName: string;
|
||||
menuPath?: string | null;
|
||||
component?: string | null;
|
||||
icon?: string | null;
|
||||
sortOrder: number;
|
||||
menuType: number; // 1目录(父级) 2菜单(叶子)
|
||||
permission?: string | null;
|
||||
visible: number;
|
||||
children?: MenuNode[];
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
realName: string;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
menus: MenuNode[];
|
||||
}
|
||||
|
||||
export const usePermissionStore = defineStore('permission', () => {
|
||||
const userInfo = ref<UserInfo | null>(null);
|
||||
const token = ref<string>(localStorage.getItem('access_token') || '');
|
||||
const menus = ref<MenuNode[]>([]);
|
||||
// 兼容旧调用方(如登录页 setPermissions),权限判断已改为基于菜单树
|
||||
const permissions = ref<string[]>([]);
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value);
|
||||
@@ -23,23 +39,40 @@ export const usePermissionStore = defineStore('permission', () => {
|
||||
userInfo.value?.roles.includes(role) || false
|
||||
);
|
||||
|
||||
// 从菜单树展平所有节点的 permission 标识集合
|
||||
const permissionSet = computed(() => {
|
||||
const set = new Set<string>();
|
||||
const walk = (nodes: MenuNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.permission) {
|
||||
set.add(node.permission);
|
||||
}
|
||||
if (node.children?.length) {
|
||||
walk(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(menus.value);
|
||||
return set;
|
||||
});
|
||||
|
||||
const hasPermission = computed(() => (permission: string) =>
|
||||
permissions.value.includes(permission) ||
|
||||
userInfo.value?.roles.includes('super_admin') || false
|
||||
permissionSet.value.has(permission) || hasRole.value('ADMIN') || false
|
||||
);
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
try {
|
||||
const res = await authApi.login({ username, password });
|
||||
if (res.code === 200) {
|
||||
const { accessToken, refreshToken, userInfo: info } = res.data;
|
||||
const { accessToken, refreshToken, userInfo: info, roles, menus: menuTree } = res.data;
|
||||
|
||||
token.value = accessToken;
|
||||
localStorage.setItem('access_token', accessToken);
|
||||
localStorage.setItem('refresh_token', refreshToken);
|
||||
|
||||
userInfo.value = info;
|
||||
permissions.value = info.permissions || [];
|
||||
// 后端将 roles/menus 作为 userInfo 的同级字段返回
|
||||
userInfo.value = { ...info, roles: roles || [], menus: menuTree || [] };
|
||||
menus.value = menuTree || [];
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -52,6 +85,7 @@ export const usePermissionStore = defineStore('permission', () => {
|
||||
function logout() {
|
||||
token.value = '';
|
||||
userInfo.value = null;
|
||||
menus.value = [];
|
||||
permissions.value = [];
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
@@ -63,7 +97,7 @@ export const usePermissionStore = defineStore('permission', () => {
|
||||
const res = await authApi.getCurrentUser();
|
||||
if (res.code === 200) {
|
||||
userInfo.value = res.data;
|
||||
permissions.value = res.data.permissions || [];
|
||||
menus.value = res.data.menus || [];
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -80,6 +114,7 @@ export const usePermissionStore = defineStore('permission', () => {
|
||||
|
||||
function setUserInfo(info: UserInfo | null) {
|
||||
userInfo.value = info;
|
||||
menus.value = info?.menus || [];
|
||||
}
|
||||
|
||||
function setPermissions(perms: string[]) {
|
||||
@@ -89,6 +124,7 @@ export const usePermissionStore = defineStore('permission', () => {
|
||||
return {
|
||||
userInfo,
|
||||
token,
|
||||
menus,
|
||||
permissions,
|
||||
isLoggedIn,
|
||||
hasRole,
|
||||
@@ -100,4 +136,4 @@ export const usePermissionStore = defineStore('permission', () => {
|
||||
setUserInfo,
|
||||
setPermissions,
|
||||
};
|
||||
});
|
||||
});
|
||||
+20
-14
@@ -86,20 +86,26 @@ service.interceptors.response.use(
|
||||
}
|
||||
);
|
||||
|
||||
export default service;
|
||||
|
||||
export function get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.get(url, config);
|
||||
// 响应拦截器已解包 response.data,统一返回 { code, message, data } 结构
|
||||
export interface ApiResult<T = any> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export function post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.post(url, data, config);
|
||||
}
|
||||
const request = {
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResult<T>> {
|
||||
return service.get(url, config) as unknown as Promise<ApiResult<T>>;
|
||||
},
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResult<T>> {
|
||||
return service.post(url, data, config) as unknown as Promise<ApiResult<T>>;
|
||||
},
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResult<T>> {
|
||||
return service.put(url, data, config) as unknown as Promise<ApiResult<T>>;
|
||||
},
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResult<T>> {
|
||||
return service.delete(url, config) as unknown as Promise<ApiResult<T>>;
|
||||
}
|
||||
};
|
||||
|
||||
export function put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.put(url, data, config);
|
||||
}
|
||||
|
||||
export function del<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return service.delete(url, config);
|
||||
}
|
||||
export default request;
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="placeholder-container">
|
||||
<div class="placeholder-content">
|
||||
<div class="placeholder-icon">
|
||||
<ToolOutlined />
|
||||
</div>
|
||||
<h2 class="placeholder-title">功能开发中</h2>
|
||||
<p class="placeholder-desc">该模块正在开发中,敬请期待</p>
|
||||
<a-button type="primary" @click="goBack">
|
||||
<ArrowLeftOutlined />
|
||||
返回
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ToolOutlined, ArrowLeftOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.placeholder-container {
|
||||
min-height: calc(100vh - 200px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.placeholder-content {
|
||||
text-align: center;
|
||||
padding: 60px 40px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 24px;
|
||||
background: #e6f7ff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 64px;
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #1f1f1f;
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
||||
.placeholder-desc {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin: 0 0 32px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -4,19 +4,28 @@
|
||||
<a-form layout="inline" :model="queryParams">
|
||||
<a-form-item label="操作人">
|
||||
<a-input
|
||||
v-model:value="queryParams.operator"
|
||||
placeholder="请输入操作人"
|
||||
v-model:value="queryParams.userId"
|
||||
placeholder="请输入用户ID"
|
||||
allow-clear
|
||||
style="width: 140px"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="操作模块">
|
||||
<a-input
|
||||
v-model:value="queryParams.module"
|
||||
placeholder="请输入操作模块"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
@pressEnter="handleSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="操作类型">
|
||||
<a-form-item label="操作动作">
|
||||
<a-select
|
||||
v-model:value="queryParams.actionType"
|
||||
placeholder="请选择操作类型"
|
||||
v-model:value="queryParams.action"
|
||||
placeholder="请选择操作动作"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
style="width: 140px"
|
||||
>
|
||||
<a-select-option value="登录">登录</a-select-option>
|
||||
<a-select-option value="创建">创建</a-select-option>
|
||||
@@ -63,24 +72,14 @@
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'statusCode'">
|
||||
<a-tag :color="getStatusCodeColor(record.statusCode)">
|
||||
{{ record.statusCode }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'duration'">
|
||||
<span :class="{ 'duration-warning': record.duration > 1000 }">
|
||||
{{ record.duration }}ms
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'createdAt'">
|
||||
{{ formatTime(record.createdAt) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-tooltip :title="record.action">
|
||||
<span class="action-text">{{ record.action }}</span>
|
||||
<template v-if="column.key === 'params'">
|
||||
<a-tooltip :title="record.params || ''">
|
||||
<span class="params-text">{{ record.params || '-' }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
@@ -92,19 +91,20 @@ import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { SearchOutlined, ReloadOutlined, DownloadOutlined } from '@ant-design/icons-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { logApi, type LogItem } from '@/api/log'
|
||||
import { logApi } from '@/api/log'
|
||||
|
||||
const tableLoading = ref(false)
|
||||
const exportLoading = ref(false)
|
||||
const logList = ref<LogItem[]>([])
|
||||
const logList = ref<any[]>([])
|
||||
const total = ref(0)
|
||||
const timeRange = ref<any[]>([])
|
||||
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
operator: '',
|
||||
actionType: undefined as string | undefined,
|
||||
userId: '' as string | number,
|
||||
module: '',
|
||||
action: undefined as string | undefined,
|
||||
startDate: '',
|
||||
endDate: '',
|
||||
})
|
||||
@@ -118,23 +118,23 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: '操作人',
|
||||
dataIndex: 'username',
|
||||
key: 'username',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作类型',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
dataIndex: 'userId',
|
||||
key: 'userId',
|
||||
width: 100,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作模块',
|
||||
dataIndex: 'module',
|
||||
key: 'module',
|
||||
width: 110,
|
||||
width: 130,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作动作',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
@@ -145,29 +145,22 @@ const columns = [
|
||||
},
|
||||
{
|
||||
title: 'IP地址',
|
||||
dataIndex: 'ipAddress',
|
||||
key: 'ipAddress',
|
||||
dataIndex: 'ip',
|
||||
key: 'ip',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态码',
|
||||
dataIndex: 'statusCode',
|
||||
key: 'statusCode',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '耗时(ms)',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
title: '请求参数',
|
||||
dataIndex: 'params',
|
||||
key: 'params',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作时间',
|
||||
dataIndex: 'createdAt',
|
||||
key: 'createdAt',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 180,
|
||||
},
|
||||
]
|
||||
@@ -182,13 +175,6 @@ const paginationConfig = reactive({
|
||||
showTotal: (t: number) => `共 ${t} 条记录`,
|
||||
})
|
||||
|
||||
function getStatusCodeColor(code: number): string {
|
||||
if (code >= 200 && code < 300) return 'success'
|
||||
if (code >= 400 && code < 500) return 'warning'
|
||||
if (code >= 500) return 'error'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function formatTime(time: string): string {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
@@ -204,7 +190,14 @@ async function fetchLogList() {
|
||||
queryParams.endDate = ''
|
||||
}
|
||||
|
||||
const res: any = await logApi.getLogList(queryParams)
|
||||
const requestParams: any = { ...queryParams }
|
||||
if (requestParams.userId !== '') {
|
||||
requestParams.userId = Number(requestParams.userId)
|
||||
} else {
|
||||
delete requestParams.userId
|
||||
}
|
||||
|
||||
const res: any = await logApi.getLogList(requestParams)
|
||||
if (res.code === 200) {
|
||||
logList.value = res.data.list
|
||||
total.value = res.data.total
|
||||
@@ -224,8 +217,9 @@ function handleSearch() {
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
queryParams.operator = ''
|
||||
queryParams.actionType = undefined
|
||||
queryParams.userId = ''
|
||||
queryParams.module = ''
|
||||
queryParams.action = undefined
|
||||
queryParams.startDate = ''
|
||||
queryParams.endDate = ''
|
||||
timeRange.value = []
|
||||
@@ -244,8 +238,9 @@ async function handleExport() {
|
||||
exportLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (queryParams.operator) params.operator = queryParams.operator
|
||||
if (queryParams.actionType) params.actionType = queryParams.actionType
|
||||
if (queryParams.userId !== '') params.userId = Number(queryParams.userId)
|
||||
if (queryParams.module) params.module = queryParams.module
|
||||
if (queryParams.action) params.action = queryParams.action
|
||||
if (timeRange.value && timeRange.value.length === 2) {
|
||||
params.startDate = timeRange.value[0].format('YYYY-MM-DD HH:mm:ss')
|
||||
params.endDate = timeRange.value[1].format('YYYY-MM-DD HH:mm:ss')
|
||||
@@ -292,12 +287,7 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
.duration-warning {
|
||||
color: #cf1322;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-text {
|
||||
.params-text {
|
||||
max-width: 150px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
@@ -68,7 +68,6 @@
|
||||
import { reactive, ref, onMounted, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
@@ -121,36 +120,18 @@ async function handleLogin() {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const response = await axios.post('/api/auth/login', {
|
||||
username: formState.username,
|
||||
password: formState.password
|
||||
})
|
||||
await permissionStore.login(formState.username, formState.password)
|
||||
|
||||
const res = response.data
|
||||
|
||||
if (res.code === 200) {
|
||||
const { accessToken, refreshToken, userInfo } = res.data
|
||||
|
||||
localStorage.setItem('access_token', accessToken)
|
||||
localStorage.setItem('refresh_token', refreshToken)
|
||||
|
||||
permissionStore.setToken(accessToken)
|
||||
permissionStore.setUserInfo(userInfo)
|
||||
permissionStore.setPermissions(userInfo.permissions || [])
|
||||
|
||||
if (formState.rememberMe) {
|
||||
localStorage.setItem('remembered_username', formState.username)
|
||||
} else {
|
||||
localStorage.removeItem('remembered_username')
|
||||
}
|
||||
|
||||
message.success('登录成功!')
|
||||
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.push(redirect)
|
||||
if (formState.rememberMe) {
|
||||
localStorage.setItem('remembered_username', formState.username)
|
||||
} else {
|
||||
throw new Error(res.message || '登录失败')
|
||||
localStorage.removeItem('remembered_username')
|
||||
}
|
||||
|
||||
message.success('登录成功!')
|
||||
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
router.push(redirect)
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
message.error(error.message || '登录失败,请检查用户名和密码')
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
<template>
|
||||
<div class="basic-settings">
|
||||
<a-card title="基本设置" :loading="loading">
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 14 }"
|
||||
>
|
||||
<a-form-item label="站点名称" name="siteName">
|
||||
<a-input
|
||||
v-model:value="formState.siteName"
|
||||
placeholder="请输入站点名称"
|
||||
:maxlength="50"
|
||||
show-count
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="Logo" name="logo">
|
||||
<a-upload
|
||||
name="file"
|
||||
list-type="picture-card"
|
||||
class="logo-uploader"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="handleUpload"
|
||||
>
|
||||
<img v-if="formState.logo" :src="formState.logo" alt="logo" style="width: 100%" />
|
||||
<div v-else>
|
||||
<plus-outlined />
|
||||
<div class="ant-upload-text">上传</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="form-tip">支持 JPG、PNG 格式,建议尺寸 200x200px</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="会话超时时间" name="sessionTimeout">
|
||||
<a-input-number
|
||||
v-model:value="formState.sessionTimeout"
|
||||
:min="5"
|
||||
:max="1440"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span class="form-tip">单位:分钟,范围 5-1440</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
|
||||
<a-button type="primary" @click="handleSave" :loading="saving">
|
||||
保存设置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined } from '@ant-design/icons-vue';
|
||||
import { systemApi, type BasicSettings } from '@/api/system';
|
||||
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const formState = reactive<BasicSettings>({
|
||||
siteName: '',
|
||||
logo: '',
|
||||
sessionTimeout: 120,
|
||||
});
|
||||
|
||||
const rules = {
|
||||
siteName: [
|
||||
{ required: true, message: '请输入站点名称', trigger: 'blur' },
|
||||
{ max: 50, message: '站点名称不能超过50个字符', trigger: 'blur' },
|
||||
],
|
||||
sessionTimeout: [
|
||||
{ required: true, message: '请输入会话超时时间', trigger: 'blur' },
|
||||
{ type: 'number', min: 5, max: 1440, message: '超时时间范围为5-1440分钟', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await systemApi.getSettings();
|
||||
if (res.code === 200 && res.data?.basic) {
|
||||
Object.assign(formState, res.data.basic);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error);
|
||||
message.error('加载设置失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function beforeUpload(file: File) {
|
||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
|
||||
if (!isJpgOrPng) {
|
||||
message.error('只能上传 JPG/PNG 格式的图片!');
|
||||
return false;
|
||||
}
|
||||
const isLt2M = file.size / 1024 / 1024 < 2;
|
||||
if (!isLt2M) {
|
||||
message.error('图片大小不能超过 2MB!');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleUpload(options: any) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
formState.logo = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(options.file);
|
||||
options.onSuccess();
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await formRef.value.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const res: any = await systemApi.updateBasicSettings({
|
||||
siteName: formState.siteName,
|
||||
logo: formState.logo,
|
||||
sessionTimeout: formState.sessionTimeout,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
message.success('基本设置保存成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败,请重试');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.basic-settings {
|
||||
padding: 20px;
|
||||
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logo-uploader {
|
||||
:deep(.ant-upload) {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-upload-text {
|
||||
margin-top: 8px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,151 +0,0 @@
|
||||
<template>
|
||||
<div class="security-settings">
|
||||
<a-card title="安全设置" :loading="loading">
|
||||
<a-form
|
||||
:model="formState"
|
||||
:rules="rules"
|
||||
ref="formRef"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 14 }"
|
||||
>
|
||||
<a-form-item label="密码最小长度" name="passwordMinLength">
|
||||
<a-input-number
|
||||
v-model:value="formState.passwordMinLength"
|
||||
:min="6"
|
||||
:max="20"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span class="form-tip">范围 6-20 位</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="最大登录尝试次数" name="maxLoginAttempts">
|
||||
<a-input-number
|
||||
v-model:value="formState.maxLoginAttempts"
|
||||
:min="3"
|
||||
:max="10"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span class="form-tip">超过此次数将锁定账户</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="账户锁定时长" name="lockoutDuration">
|
||||
<a-input-number
|
||||
v-model:value="formState.lockoutDuration"
|
||||
:min="5"
|
||||
:max="60"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span class="form-tip">单位:分钟,范围 5-60</span>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="IP白名单" name="ipWhitelist">
|
||||
<a-select
|
||||
v-model:value="formState.ipWhitelist"
|
||||
mode="tags"
|
||||
placeholder="输入IP地址后按回车添加"
|
||||
style="width: 100%"
|
||||
:token-separators="[',']"
|
||||
/>
|
||||
<div class="form-tip">
|
||||
留空表示不限制IP访问,多个IP用回车或逗号分隔(如:192.168.1.1)
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :wrapper-col="{ offset: 6, span: 14 }">
|
||||
<a-button type="primary" @click="handleSave" :saving="saving">
|
||||
保存设置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { systemApi, type SecuritySettings } from '@/api/system';
|
||||
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
const formState = reactive<SecuritySettings>({
|
||||
passwordMinLength: 6,
|
||||
maxLoginAttempts: 5,
|
||||
lockoutDuration: 30,
|
||||
ipWhitelist: [],
|
||||
});
|
||||
|
||||
const rules = {
|
||||
passwordMinLength: [
|
||||
{ required: true, message: '请输入密码最小长度', trigger: 'blur' },
|
||||
{ type: 'number', min: 6, max: 20, message: '长度范围为6-20', trigger: 'blur' },
|
||||
],
|
||||
maxLoginAttempts: [
|
||||
{ required: true, message: '请输入最大登录尝试次数', trigger: 'blur' },
|
||||
{ type: 'number', min: 3, max: 10, message: '范围为3-10次', trigger: 'blur' },
|
||||
],
|
||||
lockoutDuration: [
|
||||
{ required: true, message: '请输入账户锁定时长', trigger: 'blur' },
|
||||
{ type: 'number', min: 5, max: 60, message: '范围为5-60分钟', trigger: 'blur' },
|
||||
],
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await systemApi.getSettings();
|
||||
if (res.code === 200 && res.data?.security) {
|
||||
Object.assign(formState, res.data.security);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error);
|
||||
message.error('加载设置失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
await formRef.value.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
const res: any = await systemApi.updateSecuritySettings({
|
||||
passwordMinLength: formState.passwordMinLength,
|
||||
maxLoginAttempts: formState.maxLoginAttempts,
|
||||
lockoutDuration: formState.lockoutDuration,
|
||||
ipWhitelist: formState.ipWhitelist,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
message.success('安全设置保存成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
message.error('保存失败,请重试');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.security-settings {
|
||||
padding: 20px;
|
||||
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,368 @@
|
||||
<template>
|
||||
<div class="dict-management">
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="inline" :model="queryParams" class="search-form">
|
||||
<a-form-item label="字典类型">
|
||||
<a-select
|
||||
v-model:value="queryParams.dictType"
|
||||
placeholder="请选择字典类型"
|
||||
allow-clear
|
||||
style="width: 200px"
|
||||
>
|
||||
<a-select-option v-for="type in dictTypeOptions" :key="type" :value="type">{{ type }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="queryParams.keyword" placeholder="字典标签/值" allow-clear style="width: 200px" @pressEnter="handleSearch" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="queryParams.status" placeholder="请选择状态" allow-clear style="width: 120px">
|
||||
<a-select-option :value="1">启用</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<div class="table-operations">
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增字典
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'success' : 'default'">
|
||||
{{ record.status === 1 ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'created_at'">
|
||||
{{ formatTime(record.created_at) }}
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
<EditOutlined />编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除该字典?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined />删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="modalLoading"
|
||||
width="560px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<a-form-item label="字典类型" name="dictType">
|
||||
<a-input v-model:value="formData.dictType" placeholder="如 sys_user_status" :disabled="isEdit" />
|
||||
</a-form-item>
|
||||
<a-form-item label="字典标签" name="dictLabel">
|
||||
<a-input v-model:value="formData.dictLabel" placeholder="请输入字典标签" />
|
||||
</a-form-item>
|
||||
<a-form-item label="字典值" name="dictValue">
|
||||
<a-input v-model:value="formData.dictValue" placeholder="请输入字典值" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序" name="sortOrder">
|
||||
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import {
|
||||
SearchOutlined,
|
||||
ReloadOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { dictApi } from '@/api/dict';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface DictItem {
|
||||
id: number;
|
||||
dictType: string;
|
||||
dictLabel: string;
|
||||
dictValue: string;
|
||||
sortOrder: number;
|
||||
status: number;
|
||||
created_at: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<DictItem[]>([]);
|
||||
const total = ref(0);
|
||||
const dictTypeOptions = ref<string[]>([]);
|
||||
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
dictType: undefined as string | undefined,
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{ title: '字典类型', dataIndex: 'dictType', key: 'dictType', width: 160 },
|
||||
{ title: '字典标签', dataIndex: 'dictLabel', key: 'dictLabel', width: 160 },
|
||||
{ title: '字典值', dataIndex: 'dictValue', key: 'dictValue', width: 160 },
|
||||
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80, align: 'center' as const },
|
||||
{ title: '状态', key: 'status', width: 90, align: 'center' as const },
|
||||
{ title: '创建时间', key: 'created_at', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
const pagination = computed(() => ({
|
||||
current: queryParams.page,
|
||||
pageSize: queryParams.pageSize,
|
||||
total: total.value,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (t: number) => `共 ${t} 条数据`,
|
||||
}));
|
||||
|
||||
onMounted(() => {
|
||||
fetchDictList();
|
||||
fetchDictTypes();
|
||||
});
|
||||
|
||||
async function fetchDictTypes() {
|
||||
try {
|
||||
const res: any = await dictApi.getDictTypes();
|
||||
if (res.code === 200) {
|
||||
const data = res.data || [];
|
||||
dictTypeOptions.value = data.map((item: any) =>
|
||||
typeof item === 'string' ? item : item.dictType || item.type || item.label
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取字典类型失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDictList() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await dictApi.getDictList(queryParams);
|
||||
if (res.code === 200) {
|
||||
dataSource.value = res.data.list;
|
||||
total.value = res.data.total;
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取字典列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
queryParams.page = 1;
|
||||
fetchDictList();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
queryParams.dictType = undefined;
|
||||
queryParams.keyword = '';
|
||||
queryParams.status = undefined;
|
||||
queryParams.page = 1;
|
||||
fetchDictList();
|
||||
}
|
||||
|
||||
function handleTableChange(pag: any) {
|
||||
queryParams.page = pag.current;
|
||||
queryParams.pageSize = pag.pageSize;
|
||||
fetchDictList();
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const modalLoading = ref(false);
|
||||
const modalTitle = ref('');
|
||||
const formRef = ref();
|
||||
const isEdit = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
|
||||
const formData = reactive({
|
||||
dictType: '',
|
||||
dictLabel: '',
|
||||
dictValue: '',
|
||||
sortOrder: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
dictType: [{ required: true, message: '请输入字典类型', trigger: 'blur' }],
|
||||
dictLabel: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
|
||||
dictValue: [{ required: true, message: '请输入字典值', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
function handleAdd() {
|
||||
isEdit.value = false;
|
||||
editId.value = null;
|
||||
modalTitle.value = '新增字典';
|
||||
resetForm();
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(record: DictItem) {
|
||||
isEdit.value = true;
|
||||
editId.value = record.id;
|
||||
modalTitle.value = '编辑字典';
|
||||
formData.dictType = record.dictType;
|
||||
formData.dictLabel = record.dictLabel;
|
||||
formData.dictValue = record.dictValue;
|
||||
formData.sortOrder = record.sortOrder ?? 0;
|
||||
formData.status = record.status;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.dictType = '';
|
||||
formData.dictLabel = '';
|
||||
formData.dictValue = '';
|
||||
formData.sortOrder = 0;
|
||||
formData.status = 1;
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
modalLoading.value = true;
|
||||
try {
|
||||
const payload: any = {
|
||||
dictType: formData.dictType,
|
||||
dictLabel: formData.dictLabel,
|
||||
dictValue: formData.dictValue,
|
||||
sortOrder: formData.sortOrder,
|
||||
status: formData.status,
|
||||
};
|
||||
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
res = await dictApi.updateDict(editId.value, payload);
|
||||
} else {
|
||||
res = await dictApi.createDict(payload);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功');
|
||||
modalVisible.value = false;
|
||||
fetchDictList();
|
||||
fetchDictTypes();
|
||||
} else {
|
||||
message.error(res.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
modalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modalVisible.value = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleDelete(record: DictItem) {
|
||||
try {
|
||||
const res: any = await dictApi.deleteDict(record.id);
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功');
|
||||
fetchDictList();
|
||||
fetchDictTypes();
|
||||
} else {
|
||||
message.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.dict-management {
|
||||
padding: 24px;
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-operations {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-link) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="menu-management">
|
||||
<a-card title="菜单管理">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleAdd()">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增菜单
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="menuList"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
:default-expand-all-rows="true"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'menuType'">
|
||||
<a-tag :color="record.menuType === 1 ? 'blue' : 'green'">
|
||||
{{ record.menuType === 1 ? '目录' : '菜单' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.dataIndex === 'visible'">
|
||||
<a-tag :color="record.visible === 1 ? 'success' : 'default'">
|
||||
{{ record.visible === 1 ? '显示' : '隐藏' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleAdd(record)">
|
||||
<PlusOutlined />新增子菜单
|
||||
</a-button>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
<EditOutlined />编辑
|
||||
</a-button>
|
||||
<a-popconfirm
|
||||
title="确定删除该菜单?"
|
||||
ok-text="确定"
|
||||
cancel-text="取消"
|
||||
@confirm="handleDelete(record)"
|
||||
>
|
||||
<a-button type="link" size="small" danger>
|
||||
<DeleteOutlined />删除
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="modalTitle"
|
||||
:confirm-loading="modalLoading"
|
||||
width="640px"
|
||||
@ok="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 16 }"
|
||||
>
|
||||
<a-form-item label="上级菜单" name="parentId">
|
||||
<a-tree-select
|
||||
v-model:value="formData.parentId"
|
||||
:tree-data="parentOptions"
|
||||
:field-names="{ label: 'menuName', value: 'id', children: 'children' }"
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
placeholder="请选择上级菜单"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="菜单类型" name="menuType">
|
||||
<a-radio-group v-model:value="formData.menuType">
|
||||
<a-radio :value="1">目录</a-radio>
|
||||
<a-radio :value="2">菜单</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="菜单名称" name="menuName">
|
||||
<a-input v-model:value="formData.menuName" placeholder="请输入菜单名称" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="路由路径" name="menuPath">
|
||||
<a-input v-model:value="formData.menuPath" placeholder="如 /system/user" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="组件" name="component">
|
||||
<a-input v-model:value="formData.component" placeholder="如 system/user/index" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="图标" name="icon">
|
||||
<a-input v-model:value="formData.icon" placeholder="如 UserOutlined" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="排序" name="sortOrder">
|
||||
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="权限标识" name="permission">
|
||||
<a-input v-model:value="formData.permission" placeholder="如 user:list" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="可见性" name="visible">
|
||||
<a-switch
|
||||
:checked="formData.visible === 1"
|
||||
checked-children="显示"
|
||||
un-checked-children="隐藏"
|
||||
@change="(checked: boolean) => (formData.visible = checked ? 1 : 0)"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue';
|
||||
import { menuApi } from '@/api/menu';
|
||||
|
||||
interface MenuItem {
|
||||
id: number;
|
||||
parentId?: number | null;
|
||||
menuName: string;
|
||||
menuPath?: string;
|
||||
component?: string;
|
||||
icon?: string;
|
||||
menuType: number;
|
||||
sortOrder: number;
|
||||
permission?: string;
|
||||
visible: number;
|
||||
children?: MenuItem[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const menuList = ref<MenuItem[]>([]);
|
||||
|
||||
const columns = [
|
||||
{ title: '菜单名称', dataIndex: 'menuName', key: 'menuName' },
|
||||
{ title: '路由路径', dataIndex: 'menuPath', key: 'menuPath', width: 160 },
|
||||
{ title: '组件', dataIndex: 'component', key: 'component', width: 180 },
|
||||
{ title: '图标', dataIndex: 'icon', key: 'icon', width: 120 },
|
||||
{ title: '类型', dataIndex: 'menuType', key: 'menuType', width: 80, align: 'center' as const },
|
||||
{ title: '排序', dataIndex: 'sortOrder', key: 'sortOrder', width: 80, align: 'center' as const },
|
||||
{ title: '权限标识', dataIndex: 'permission', key: 'permission', width: 150 },
|
||||
{ title: '可见性', dataIndex: 'visible', key: 'visible', width: 90, align: 'center' as const },
|
||||
{ title: '操作', key: 'action', width: 240, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
// 上级菜单树(编辑时排除自身及后代节点,防止循环引用)
|
||||
const parentOptions = computed(() => {
|
||||
const excludeId = isEdit.value ? editId.value : null;
|
||||
const filtered = excludeId ? filterTree(menuList.value, excludeId) : menuList.value;
|
||||
return [{ id: 0, menuName: '根目录', children: filtered }];
|
||||
});
|
||||
|
||||
function filterTree(nodes: MenuItem[], excludeId: number): MenuItem[] {
|
||||
const result: MenuItem[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.id === excludeId) continue;
|
||||
const children = node.children?.length ? filterTree(node.children, excludeId) : undefined;
|
||||
if (children) {
|
||||
result.push({ ...node, children });
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const modalVisible = ref(false);
|
||||
const modalLoading = ref(false);
|
||||
const modalTitle = ref('');
|
||||
const formRef = ref();
|
||||
const isEdit = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
|
||||
const formData = reactive({
|
||||
parentId: 0,
|
||||
menuType: 2,
|
||||
menuName: '',
|
||||
menuPath: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
sortOrder: 0,
|
||||
permission: '',
|
||||
visible: 1,
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
menuName: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchMenuTree();
|
||||
});
|
||||
|
||||
async function fetchMenuTree() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res: any = await menuApi.getMenuTree();
|
||||
if (res.code === 200) {
|
||||
menuList.value = res.data || [];
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取菜单列表失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdd(parent?: MenuItem) {
|
||||
isEdit.value = false;
|
||||
editId.value = null;
|
||||
modalTitle.value = '新增菜单';
|
||||
resetForm();
|
||||
if (parent) {
|
||||
formData.parentId = parent.id;
|
||||
formData.menuType = 2;
|
||||
}
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(record: MenuItem) {
|
||||
isEdit.value = true;
|
||||
editId.value = record.id;
|
||||
modalTitle.value = '编辑菜单';
|
||||
formData.parentId = record.parentId || 0;
|
||||
formData.menuType = record.menuType;
|
||||
formData.menuName = record.menuName;
|
||||
formData.menuPath = record.menuPath || '';
|
||||
formData.component = record.component || '';
|
||||
formData.icon = record.icon || '';
|
||||
formData.sortOrder = record.sortOrder ?? 0;
|
||||
formData.permission = record.permission || '';
|
||||
formData.visible = record.visible ? 1 : 0;
|
||||
modalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.parentId = 0;
|
||||
formData.menuType = 2;
|
||||
formData.menuName = '';
|
||||
formData.menuPath = '';
|
||||
formData.component = '';
|
||||
formData.icon = '';
|
||||
formData.sortOrder = 0;
|
||||
formData.permission = '';
|
||||
formData.visible = 1;
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
try {
|
||||
await formRef.value?.validate();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
modalLoading.value = true;
|
||||
try {
|
||||
const payload: any = {
|
||||
parentId: formData.parentId || null,
|
||||
menuType: formData.menuType,
|
||||
menuName: formData.menuName,
|
||||
menuPath: formData.menuPath,
|
||||
component: formData.component,
|
||||
icon: formData.icon,
|
||||
sortOrder: formData.sortOrder,
|
||||
permission: formData.permission,
|
||||
visible: formData.visible,
|
||||
};
|
||||
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
res = await menuApi.updateMenu(editId.value, payload);
|
||||
} else {
|
||||
res = await menuApi.createMenu(payload);
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success(isEdit.value ? '更新成功' : '创建成功');
|
||||
modalVisible.value = false;
|
||||
fetchMenuTree();
|
||||
} else {
|
||||
message.error(res.message || '操作失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('操作失败');
|
||||
} finally {
|
||||
modalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
modalVisible.value = false;
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function handleDelete(record: MenuItem) {
|
||||
try {
|
||||
const res: any = await menuApi.deleteMenu(record.id);
|
||||
if (res.code === 200) {
|
||||
message.success('删除成功');
|
||||
fetchMenuTree();
|
||||
} else {
|
||||
message.error(res.message || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('删除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.menu-management {
|
||||
padding: 20px;
|
||||
|
||||
:deep(.ant-btn-link) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -30,16 +30,12 @@
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'name'">
|
||||
<span>{{ record.name }}</span>
|
||||
<template v-if="column.dataIndex === 'roleName'">
|
||||
<span>{{ record.roleName }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'code'">
|
||||
<a-tag color="blue">{{ record.code }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'status'">
|
||||
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '启用' : '禁用'" />
|
||||
<template v-if="column.dataIndex === 'roleCode'">
|
||||
<a-tag color="blue">{{ record.roleCode }}</a-tag>
|
||||
</template>
|
||||
|
||||
<template v-if="column.dataIndex === 'userCount'">
|
||||
@@ -88,12 +84,12 @@
|
||||
:label-col="{ span: 5 }"
|
||||
:wrapper-col="{ span: 17 }"
|
||||
>
|
||||
<a-form-item label="角色名称" name="name">
|
||||
<a-input v-model:value="formData.name" placeholder="请输入角色名称" />
|
||||
<a-form-item label="角色名称" name="roleName">
|
||||
<a-input v-model:value="formData.roleName" placeholder="请输入角色名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色编码" name="code">
|
||||
<a-form-item label="角色编码" name="roleCode">
|
||||
<a-input
|
||||
v-model:value="formData.code"
|
||||
v-model:value="formData.roleCode"
|
||||
placeholder="请输入角色编码"
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
@@ -104,12 +100,6 @@
|
||||
<a-form-item label="排序" name="sortOrder">
|
||||
<a-input-number v-model:value="formData.sortOrder" :min="0" :max="999" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
@@ -124,17 +114,17 @@
|
||||
<a-tree
|
||||
v-if="!permissionTreeLoading"
|
||||
v-model:checkedKeys="checkedKeys"
|
||||
:tree-data="permissionTree"
|
||||
:tree-data="menuTree"
|
||||
checkable
|
||||
:field-names="{ title: 'name', key: 'id', children: 'children' }"
|
||||
:check-strictly="false"
|
||||
:field-names="{ title: 'menuName', key: 'id', children: 'children' }"
|
||||
:default-expand-all="true"
|
||||
style="max-height: 500px; overflow-y: auto;"
|
||||
>
|
||||
<template #title="{ name, icon }">
|
||||
<template #title="{ menuName, menuType }">
|
||||
<span>
|
||||
<component v-if="icon" :is="icon" style="margin-right: 8px" />
|
||||
{{ name }}
|
||||
<a-tag v-if="menuType === 1" color="blue">目录</a-tag>
|
||||
<a-tag v-else color="green">菜单</a-tag>
|
||||
{{ menuName }}
|
||||
</span>
|
||||
</template>
|
||||
</a-tree>
|
||||
@@ -153,9 +143,17 @@ import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { roleApi, type RoleInfo, type PermissionTreeNode } from '@/api/role';
|
||||
import { roleApi, type RoleInfo } from '@/api/role';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface MenuTreeNode {
|
||||
id: number;
|
||||
menuName: string;
|
||||
menuType?: number;
|
||||
children?: MenuTreeNode[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const roleList = ref<RoleInfo[]>([]);
|
||||
const keyword = ref('');
|
||||
@@ -171,13 +169,13 @@ const pagination = reactive({
|
||||
const columns = [
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
dataIndex: 'roleName',
|
||||
key: 'roleName',
|
||||
},
|
||||
{
|
||||
title: '角色编码',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
dataIndex: 'roleCode',
|
||||
key: 'roleCode',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
@@ -186,13 +184,6 @@ const columns = [
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: '关联用户数',
|
||||
dataIndex: 'userCount',
|
||||
@@ -259,16 +250,15 @@ const currentRoleId = ref<number | null>(null);
|
||||
const formModalTitle = computed(() => (isEdit.value ? '编辑角色' : '新增角色'));
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
roleName: '',
|
||||
roleCode: '',
|
||||
description: '',
|
||||
sortOrder: 0,
|
||||
status: 1,
|
||||
});
|
||||
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
|
||||
roleName: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
||||
roleCode: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
function handleAdd() {
|
||||
@@ -281,20 +271,18 @@ function handleAdd() {
|
||||
function handleEdit(record: RoleInfo) {
|
||||
isEdit.value = true;
|
||||
currentRoleId.value = record.id;
|
||||
formData.name = record.name;
|
||||
formData.code = record.code;
|
||||
formData.roleName = record.roleName;
|
||||
formData.roleCode = record.roleCode;
|
||||
formData.description = record.description || '';
|
||||
formData.sortOrder = record.sortOrder;
|
||||
formData.status = record.status;
|
||||
formModalVisible.value = true;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formData.name = '';
|
||||
formData.code = '';
|
||||
formData.roleName = '';
|
||||
formData.roleCode = '';
|
||||
formData.description = '';
|
||||
formData.sortOrder = 0;
|
||||
formData.status = 1;
|
||||
formRef.value?.resetFields();
|
||||
}
|
||||
|
||||
@@ -310,18 +298,16 @@ async function handleSubmitForm() {
|
||||
let res: any;
|
||||
if (isEdit.value && currentRoleId.value) {
|
||||
res = await roleApi.updateRole(currentRoleId.value, {
|
||||
name: formData.name,
|
||||
roleName: formData.roleName,
|
||||
description: formData.description,
|
||||
sortOrder: formData.sortOrder,
|
||||
status: formData.status,
|
||||
});
|
||||
} else {
|
||||
res = await roleApi.createRole({
|
||||
name: formData.name,
|
||||
code: formData.code,
|
||||
roleName: formData.roleName,
|
||||
roleCode: formData.roleCode,
|
||||
description: formData.description,
|
||||
sortOrder: formData.sortOrder,
|
||||
status: formData.status,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -360,60 +346,39 @@ function handleViewUsers(_record: RoleInfo) {
|
||||
const permissionModalVisible = ref(false);
|
||||
const permissionLoading = ref(false);
|
||||
const permissionTreeLoading = ref(false);
|
||||
const permissionTree = ref<PermissionTreeNode[]>([]);
|
||||
const menuTree = ref<MenuTreeNode[]>([]);
|
||||
const checkedKeys = ref<number[]>([]);
|
||||
|
||||
async function handlePermission(record: RoleInfo) {
|
||||
currentRoleId.value = record.id;
|
||||
permissionModalVisible.value = true;
|
||||
checkedKeys.value = [];
|
||||
await loadPermissionTree();
|
||||
await loadMenuTree();
|
||||
}
|
||||
|
||||
async function loadPermissionTree() {
|
||||
async function loadMenuTree() {
|
||||
permissionTreeLoading.value = true;
|
||||
try {
|
||||
if (!currentRoleId.value) return;
|
||||
|
||||
const res: any = await roleApi.getRolePermissions(currentRoleId.value);
|
||||
const res: any = await roleApi.getRoleMenus(currentRoleId.value);
|
||||
if (res.code === 200) {
|
||||
permissionTree.value = res.data;
|
||||
extractCheckedKeys(res.data);
|
||||
menuTree.value = res.data.menuTree || [];
|
||||
checkedKeys.value = res.data.menuIds || [];
|
||||
}
|
||||
} catch (error) {
|
||||
message.error('获取权限数据失败');
|
||||
message.error('获取菜单权限失败');
|
||||
} finally {
|
||||
permissionTreeLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCheckedKeys(tree: PermissionTreeNode[]) {
|
||||
const keys: number[] = [];
|
||||
|
||||
function traverse(nodes: PermissionTreeNode[]) {
|
||||
for (const node of nodes) {
|
||||
if (node.checked) {
|
||||
keys.push(node.id);
|
||||
}
|
||||
if (node.children?.length) {
|
||||
traverse(node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(tree);
|
||||
checkedKeys.value = keys;
|
||||
}
|
||||
|
||||
async function handleSavePermissions() {
|
||||
if (!currentRoleId.value) return;
|
||||
|
||||
permissionLoading.value = true;
|
||||
try {
|
||||
const res: any = await roleApi.updateRolePermissions(
|
||||
currentRoleId.value,
|
||||
checkedKeys.value as unknown as number[]
|
||||
);
|
||||
const res: any = await roleApi.updateRoleMenus(currentRoleId.value, checkedKeys.value);
|
||||
|
||||
if (res.code === 200) {
|
||||
message.success('权限保存成功');
|
||||
|
||||
@@ -1,64 +1,53 @@
|
||||
<template>
|
||||
<div class="user-management">
|
||||
<a-card :bordered="false">
|
||||
<a-form layout="inline" :model="searchForm" class="search-form">
|
||||
<a-form-item label="关键词">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="用户名/昵称/手机号" allow-clear style="width: 220px" @pressEnter="handleSearch" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="请选择状态" allow-clear style="width: 120px">
|
||||
<a-select-option :value="1">启用</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">
|
||||
<template #icon><SearchOutlined /></template>
|
||||
搜索
|
||||
</a-button>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
<a-card title="用户管理">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增用户
|
||||
</a-button>
|
||||
<a-button danger :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
批量删除 ({{ selectedRowKeys.length }})
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<div class="table-operations">
|
||||
<a-space>
|
||||
<a-button type="primary" v-permission="'user:create'" @click="handleAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增用户
|
||||
<div class="search-bar">
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="用户名/姓名/手机号"
|
||||
allow-clear
|
||||
style="width: 220px"
|
||||
@pressEnter="handleSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 120px">
|
||||
<a-select-option :value="1">启用</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleReset">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
<a-button danger v-permission="'user:delete'" :disabled="!selectedRowKeys.length" @click="handleBatchDelete">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
批量删除 ({{ selectedRowKeys.length }})
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="dataSource"
|
||||
:row-selection="rowSelection"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:scroll="{ x: 1200 }"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar :src="record.avatar" :size="40">{{ record.nickname?.charAt(0) }}</a-avatar>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'roles'">
|
||||
<a-space>
|
||||
<a-tag v-for="role in record.userRoles" :key="role.roleId" :color="getRoleColor(role.role?.code)">
|
||||
{{ role.role?.name }}
|
||||
<a-tag v-for="role in record.userRoles" :key="role.roleId" :color="getRoleColor(role.role?.roleCode)">
|
||||
{{ role.role?.roleName }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
</template>
|
||||
@@ -69,18 +58,21 @@
|
||||
checked-children="启用"
|
||||
un-checked-children="禁用"
|
||||
@change="(checked: boolean) => handleStatusChange(record.id, checked ? 1 : 0)"
|
||||
v-permission="'user:update'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'created_at'">
|
||||
{{ formatDateTime(record.created_at) }}
|
||||
</template>
|
||||
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" v-permission="'user:update'" @click="handleEdit(record)">
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">
|
||||
<template #icon><EditOutlined /></template>
|
||||
编辑
|
||||
</a-button>
|
||||
<a-popconfirm title="确定要删除该用户吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
|
||||
<a-button type="link" size="small" danger v-permission="'user:delete'">
|
||||
<a-button type="link" size="small" danger>
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
删除
|
||||
</a-button>
|
||||
@@ -126,8 +118,8 @@
|
||||
<a-button type="link" size="small" @click="showPassword = true">修改密码</a-button>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="昵称" name="nickname">
|
||||
<a-input v-model:value="formData.nickname" placeholder="请输入昵称" />
|
||||
<a-form-item label="姓名" name="realName">
|
||||
<a-input v-model:value="formData.realName" placeholder="请输入姓名" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="手机号" name="phone">
|
||||
@@ -169,6 +161,7 @@ import {
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { userApi, type UserInfo, type UserQuery, type RoleInfo } from '@/api/user';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const loading = ref(false);
|
||||
const dataSource = ref<UserInfo[]>([]);
|
||||
@@ -193,7 +186,7 @@ const formData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
nickname: '',
|
||||
realName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
roleIds: [] as number[],
|
||||
@@ -214,25 +207,25 @@ const formRules = {
|
||||
{ min: 6, message: '密码长度不能小于6位', trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }],
|
||||
nickname: [{ required: true, message: '请输入昵称', trigger: 'blur' }],
|
||||
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
|
||||
email: [{ type: 'email' as const, message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
roleIds: [{ required: true, message: '请选择角色', trigger: 'change', type: 'array' as const }],
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: '昵称', dataIndex: 'nickname', key: 'nickname', width: 120 },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 180 },
|
||||
{ title: '角色', key: 'roles', width: 200 },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'username', ellipsis: true },
|
||||
{ title: '姓名', dataIndex: 'realName', key: 'realName', ellipsis: true },
|
||||
{ title: '手机号', dataIndex: 'phone', key: 'phone', ellipsis: true },
|
||||
{ title: '邮箱', dataIndex: 'email', key: 'email', ellipsis: true },
|
||||
{ title: '角色', key: 'roles' },
|
||||
{
|
||||
title: '状态',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
width: 80,
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', ellipsis: true },
|
||||
{ title: '操作', key: 'action', width: 160, fixed: 'right' as const },
|
||||
];
|
||||
|
||||
@@ -277,7 +270,7 @@ async function fetchRoleList() {
|
||||
const res: any = await userApi.getRoleList();
|
||||
if (res.code === 200) {
|
||||
roleOptions.value = res.data.map((item: RoleInfo) => ({
|
||||
label: item.name,
|
||||
label: item.roleName,
|
||||
value: item.id,
|
||||
}));
|
||||
}
|
||||
@@ -321,7 +314,7 @@ function handleEdit(record: UserInfo) {
|
||||
resetForm();
|
||||
|
||||
formData.username = record.username;
|
||||
formData.nickname = record.nickname;
|
||||
formData.realName = record.realName;
|
||||
formData.phone = record.phone || '';
|
||||
formData.email = record.email || '';
|
||||
formData.status = record.status;
|
||||
@@ -334,7 +327,7 @@ function resetForm() {
|
||||
formData.username = '';
|
||||
formData.password = '';
|
||||
formData.confirmPassword = '';
|
||||
formData.nickname = '';
|
||||
formData.realName = '';
|
||||
formData.phone = '';
|
||||
formData.email = '';
|
||||
formData.roleIds = [];
|
||||
@@ -357,7 +350,7 @@ async function handleSubmit() {
|
||||
let res: any;
|
||||
if (isEdit.value && editId.value) {
|
||||
const updateData: any = {
|
||||
nickname: formData.nickname,
|
||||
realName: formData.realName,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
roleIds: formData.roleIds,
|
||||
@@ -373,7 +366,7 @@ async function handleSubmit() {
|
||||
res = await userApi.createUser({
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
nickname: formData.nickname,
|
||||
realName: formData.realName,
|
||||
phone: formData.phone,
|
||||
email: formData.email,
|
||||
roleIds: formData.roleIds,
|
||||
@@ -463,37 +456,30 @@ function getRoleColor(code?: string): string {
|
||||
};
|
||||
return colorMap[code || ''] || 'default';
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return dayjs(dateStr).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
<style scoped>
|
||||
.user-management {
|
||||
padding: 24px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.search-bar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table-operations {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
:deep(.ant-table) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
.ant-table-thead > tr > th {
|
||||
background-color: #fafafa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-avatar) {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
:deep(.ant-btn-link) {
|
||||
padding: 0 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user