This commit is contained in:
ygx
2026-03-08 22:41:42 +08:00
parent f7bbb5ee46
commit 180d980514
23 changed files with 1392 additions and 850 deletions

View File

@@ -18,6 +18,8 @@ export interface MenuItem {
children?: MenuItem[];
is_web_page?: boolean; // 是否为网页
web_url?: string; // 嵌入的网页URL
component?: string; // 路由文件地址
is_new_tab?: boolean; // 是否在新窗口打开
}
/** 获取菜单 */

View File

@@ -18,7 +18,7 @@ export const modifyUser = (data: UserItem) => request.post("/rbac2/v1/user/modif
/** 删除用户 */
export const deleteUser = (data: { id: UserItem['id'] }) => request.post("/rbac2/v1/user/del", data);
/** 获取所有权限【树形】 */
export const getUserPmnTree = (data: { id: UserItem['id'] }) => request.post("/rbac2/v1/user/pmn_tree", data);
export const getUserPmnTree = (data: { id: UserItem['id'], workspace: string }) => request.post("/rbac2/v1/user/pmn_tree", data);
/** 用户-获取所有权限【平面】 */
export const userPmn = (data: { id: UserItem['id'], workspace: string }) => request.post("/rbac2/v1/user/pmn", data, { needWorkspace: true });
@@ -39,3 +39,11 @@ export const resetUserPassword = (data: {
/** 手机号 */
phone: string;
}) => request.post("/rbac2/v1/reset", data);
/** 管理员重置用户密码 */
export const adminResetUserPassword = (data: {
/** 用户ID */
id: number;
/** 新密码 */
password: string;
}) => request.post("/rbac2/v1/user/reset_password", data);

View File

@@ -15,12 +15,16 @@ export interface LoginData {
/** 用户信息 */
export interface UserItem {
id?: number
account?: string
name?: string
username?: string
nickname?: string
email?: string
phone?: string
avatar?: string
status?: number
role_id?: number
role_name?: string
created_at?: string
updated_at?: string
}

View File

@@ -34,8 +34,8 @@ export default defineComponent({
const goto = (item: RouteRecordRaw) => {
// Open external link
if (regexUrl.test(item.path)) {
openWindow(item.path)
if (regexUrl.test(item.path) || item.meta?.isNewTab) {
openWindow(`${window.location.origin}/#${item.path}`)
selectedKey.value = [item.name as string]
return
}
@@ -45,6 +45,7 @@ export default defineComponent({
selectedKey.value = [item.name as string]
return
}
console.log('item', item)
// Trigger router change
router.push({
name: item.name,
@@ -114,14 +115,14 @@ export default defineComponent({
key={element?.name}
v-slots={{
icon,
title: () => h(compile(t(element?.meta?.locale || ''))),
title: () => element?.meta?.locale,
}}
>
{travel(element?.children)}
</a-sub-menu>
) : (
<a-menu-item key={element?.name} v-slots={{ icon }} onClick={() => goto(element)}>
{t(element?.meta?.locale || '')}
{element?.meta?.locale}
</a-menu-item>
)
nodes.push(node as never)

View File

@@ -19,7 +19,7 @@
</a-button>
</a-tooltip>
</li> -->
<li>
<!-- <li>
<a-tooltip :content="$t('settings.language')">
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="setDropDownVisible">
<template #icon>
@@ -38,7 +38,7 @@
</a-doption>
</template>
</a-dropdown>
</li>
</li> -->
<li>
<a-tooltip :content="theme === 'light' ? $t('settings.navbar.theme.toDark') : $t('settings.navbar.theme.toLight')">
<a-button class="nav-btn" type="outline" :shape="'circle'" @click="handleToggleTheme">

View File

@@ -1,13 +1,13 @@
<template>
<a-layout class="layout" :class="{ mobile: appStore.hideMenu }">
<div v-if="navbar" class="layout-navbar">
<div v-if="navbar && !route?.meta?.isNewTab" class="layout-navbar">
<NavBar />
</div>
<a-layout>
<a-layout>
<a-layout-sider
v-if="renderMenu"
v-show="!hideMenu"
v-show="!hideMenu && !route?.meta?.isNewTab"
class="layout-sider"
:breakpoint="'xl'"
:collapsible="true"
@@ -41,7 +41,7 @@
<Menu />
</a-drawer>
<a-layout class="layout-content" :style="paddingStyle">
<TabBar v-if="appStore.tabBar" />
<!-- <TabBar v-if="appStore.tabBar" /> -->
<a-layout-content>
<PageLayout />
</a-layout-content>
@@ -71,14 +71,17 @@ const router = useRouter()
const route = useRoute()
const permission = usePermission()
useResponsive(true)
const navbarHeight = `60px`
const navbarHeight = route?.meta?.isNewTab ? '0' : `60px`
const navbar = computed(() => appStore.navbar)
const renderMenu = computed(() => appStore.menu && !appStore.topMenu)
const hideMenu = computed(() => appStore.hideMenu)
const footer = computed(() => appStore.footer)
const menuWidth = computed(() => {
if (route?.meta?.isNewTab) return 0
return appStore.menuCollapse ? 48 : appStore.menuWidth
})
console.log('route', route)
const paddingStyle = computed(() => {
const paddingLeft = renderMenu.value && !hideMenu.value ? { paddingLeft: `${menuWidth.value}px` } : {}
const paddingTop = navbar.value ? { paddingTop: navbarHeight } : {}

View File

@@ -13,6 +13,6 @@ export const DEFAULT_ROUTE_NAME = 'Workplace'
export const DEFAULT_ROUTE = {
title: 'menu.dashboard.workplace',
name: DEFAULT_ROUTE_NAME,
fullPath: '/dashboard/workplace',
name: 'overview',
fullPath: '/overview',
}

View File

@@ -1,40 +1,73 @@
import NProgress from 'nprogress' // progress bar
import type { Router, RouteRecordNormalized } from 'vue-router'
import type { Router, RouteLocationRaw } from 'vue-router'
import usePermission from '@/hooks/permission'
import { useAppStore, useUserStore } from '@/store'
import { NOT_FOUND, WHITE_LIST } from '../constants'
import { appRoutes } from '../routes'
// 标记菜单是否正在加载,防止重复加载
let isMenuLoading = false
// 标记菜单是否已加载
let isMenuLoaded = false
export default function setupPermissionGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
const appStore = useAppStore()
const userStore = useUserStore()
const Permission = usePermission()
const permissionsAllow = Permission.accessRouter(to)
if (appStore.menuFromServer) {
// 针对来自服务端的菜单配置进行处理
// Handle routing configuration from the server
// 根据需要自行完善来源于服务端的菜单配置的permission逻辑
// Refine the permission logic from the server's menu configuration as needed
if (!appStore.appAsyncMenus.length && !WHITE_LIST.find((el) => el.name === to.name)) {
await appStore.fetchServerMenuConfig()
}
const serverMenuConfig = [...appStore.appAsyncMenus, ...WHITE_LIST]
let exist = false
while (serverMenuConfig.length && !exist) {
const element = serverMenuConfig.shift()
if (element?.name === to.name) exist = true
if (element?.children) {
serverMenuConfig.push(...(element.children as unknown as RouteRecordNormalized[]))
}
}
if (exist && permissionsAllow) {
// 检查是否在白名单中
if (WHITE_LIST.find((el) => el.name === to.name)) {
next()
} else next(NOT_FOUND)
NProgress.done()
return
}
console.log('[Permission Guard] Menu not loaded, loading...')
// 检查动态路由是否已加载(使用标志位而非菜单长度,更可靠)
if (!isMenuLoaded && !isMenuLoading) {
console.log('[Permission Guard] Menu not loaded, loading...')
// 设置加载标志
isMenuLoading = true
try {
// 动态路由未加载,先获取菜单配置并注册路由
await appStore.fetchServerMenuConfig()
// 标记加载完成
isMenuLoaded = true
console.log('[Permission Guard] Menu loaded, redirecting to:', to.path)
// 路由注册后需要重新导航到当前路径
next({ path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw)
} catch (error) {
console.error('[Permission Guard] Failed to load menu:', error)
isMenuLoading = false
next(NOT_FOUND)
} finally {
isMenuLoading = false
}
NProgress.done()
return
}
// 如果正在加载菜单,等待加载完成
if (isMenuLoading) {
next({ path: to.path, query: to.query, params: to.params, replace: true } as RouteLocationRaw)
NProgress.done()
return
}
// 动态路由已加载,直接放行
// 因为路由已经通过 addRoute 注册Vue Router 会自动匹配
if (permissionsAllow) {
next()
} else {
next(NOT_FOUND)
}
} else {
// eslint-disable-next-line no-lonely-if
if (permissionsAllow) next()

View File

@@ -4,7 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router'
import createRouteGuard from './guard'
import { appRoutes } from './routes'
import { NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base'
import { DEFAULT_LAYOUT, NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base'
NProgress.configure({ showSpinner: false }) // NProgress Configuration
@@ -25,7 +25,7 @@ const router = createRouter({
},
...appRoutes,
REDIRECT_MAIN,
NOT_FOUND_ROUTE,
// NOT_FOUND_ROUTE,
],
scrollBehavior() {
return { top: 0 }

View File

@@ -1,5 +1,194 @@
import { DEFAULT_LAYOUT } from './routes/base'
import type { AppRouteRecordRaw } from './routes/types'
import type { TreeNodeBase } from '@/utils/tree'
/**
* 服务器返回的菜单数据结构
*/
export interface ServerMenuItem extends TreeNodeBase {
id: number | string
parent_id: number | string | null
name?: string
title?: string // 菜单标题
title_en?: string // 英文标题
code?: string // 菜单编码
menu_path?: string // 菜单路径,如 '/overview'
component?: string // 组件路径,如 'ops/pages/overview'
icon?: string
locale?: string
sort_key?: number // 排序字段
order?: number
hideInMenu?: boolean
hideChildrenInMenu?: boolean
requiresAuth?: boolean
roles?: string[]
children?: ServerMenuItem[]
[key: string]: any
}
// 预定义的视图模块映射(用于 Vite 动态导入)
const viewModules = import.meta.glob('@/views/**/*.vue')
/**
* 动态加载视图组件
* @param componentPath 组件路径,如 'ops/pages/overview' 或 'ops/pages/overview/index'
* @returns 动态导入的组件
*/
export function loadViewComponent(componentPath: string) {
// 将路径转换为完整的视图路径
// 如果路径不以 /index 结尾,自动补全
let fullPath = componentPath
if (!fullPath.endsWith('/index') && !fullPath.endsWith('.vue')) {
fullPath = `${fullPath}/index`
}
// 构建完整的文件路径
const filePath = `/src/views/${fullPath}.vue`
// 从预加载的模块中查找
const modulePath = Object.keys(viewModules).find((path) => path.endsWith(filePath) || path === filePath)
if (modulePath && viewModules[modulePath]) {
return viewModules[modulePath]
}
// 如果找不到,返回一个默认组件或抛出错误
console.warn(`View component not found: ${filePath}`)
return () => import('@/views/redirect/index.vue')
}
/**
* 将服务器菜单数据转换为路由配置
* @param menuItems 服务器返回的菜单项(树状结构)
* @returns 路由配置数组
*/
export function transformMenuToRoutes(menuItems: ServerMenuItem[]): AppRouteRecordRaw[] {
const routes: AppRouteRecordRaw[] = []
for (const item of menuItems) {
const route: AppRouteRecordRaw = {
path: item.menu_path || '',
name: item.title || item.name || `menu_${item.id}`,
meta: {
locale: item.locale || item.title,
requiresAuth: item.requiresAuth !== false,
icon: item.icon,
order: item.sort_key ?? item.order,
hideInMenu: item.hideInMenu,
hideChildrenInMenu: item.hideChildrenInMenu,
roles: item.roles,
isNewTab: item.is_new_tab
},
component: DEFAULT_LAYOUT,
}
// 处理子菜单
if (item.children && item.children.length > 0) {
// 传递父级的 component 和 path 给子路由处理函数
route.children = transformChildRoutes(item.children, item.component, item.menu_path)
} else if (item.component) {
// 一级菜单没有 children 但有 component创建一个空路径的子路由
const routeName = route.name
route.children = [
{
path: item.menu_path || '',
name: typeof routeName === 'string' ? `${routeName}Index` : `menu_${item.id}_index`,
component: loadViewComponent(item.component),
meta: {
locale: item.locale || item.title,
requiresAuth: item.requiresAuth !== false,
isNewTab: item.is_new_tab
},
},
]
}
routes.push(route)
}
return routes
}
/**
* 将路径转换为相对路径(去掉开头的 /
* @param path 路径
* @returns 相对路径
*/
function toRelativePath(path: string): string {
if (!path) return ''
// 去掉开头的 /
return path.startsWith('/') ? path.slice(1) : path
}
/**
* 从完整路径中提取子路由的相对路径
* 例如:父路径 '/dashboard',子路径 '/dashboard/workplace' -> 'workplace'
* @param childPath 子菜单的完整路径
* @param parentPath 父菜单的路径
* @returns 相对路径
*/
function extractRelativePath(childPath: string, parentPath: string): string {
if (!childPath) return ''
// 如果子路径以父路径开头,提取相对部分
if (parentPath && childPath.startsWith(parentPath)) {
let relativePath = childPath.slice(parentPath.length)
// 去掉开头的 /
if (relativePath.startsWith('/')) {
relativePath = relativePath.slice(1)
}
return relativePath
}
// 否则转换为相对路径
return toRelativePath(childPath)
}
/**
* 转换子路由配置
* @param children 子菜单项
* @param parentComponent 父级菜单的 component 字段(用于子菜单没有 component 时继承)
* @param parentPath 父级菜单的路径(用于计算相对路径)
* @returns 子路由配置数组
*/
function transformChildRoutes(
children: ServerMenuItem[],
parentComponent?: string,
parentPath?: string
): AppRouteRecordRaw[] {
return children.map((child) => {
// 优先使用子菜单自己的 component否则继承父级的 component
const componentPath = child.component || parentComponent
// 计算子路由的相对路径
const childFullPath = child.menu_path || child.path || ''
const relativePath = extractRelativePath(childFullPath, parentPath || '')
const route: AppRouteRecordRaw = {
path: relativePath,
name: child.title || child.name || `menu_${child.id}`,
meta: {
locale: child.locale || child.title,
requiresAuth: child.requiresAuth !== false,
roles: child.roles,
},
component: componentPath
? loadViewComponent(componentPath)
: () => import('@/views/redirect/index.vue'),
}
// 递归处理子菜单的子菜单
if (child.children && child.children.length > 0) {
route.children = transformChildRoutes(
child.children,
child.component || parentComponent,
childFullPath // 传递当前子菜单的完整路径作为下一层的父路径
)
}
return route
})
}
// 本地菜单数据 - 接口未准备好时使用
export const localMenuData: AppRouteRecordRaw[] = [{
@@ -20,10 +209,8 @@ export const localMenuData: AppRouteRecordRaw[] = [{
meta: {
locale: 'menu.dashboard.workplace',
requiresAuth: true,
roles: ['*'],
},
},
/** simple */
{
path: 'monitor',
name: 'Monitor',
@@ -31,10 +218,8 @@ export const localMenuData: AppRouteRecordRaw[] = [{
meta: {
locale: 'menu.dashboard.monitor',
requiresAuth: true,
roles: ['admin'],
},
},
/** simple end */
],
},
{
@@ -46,687 +231,15 @@ export const localMenuData: AppRouteRecordRaw[] = [{
requiresAuth: true,
icon: 'icon-home',
order: 1,
hideChildrenInMenu: true,
},
children: [
{
path: '',
name: 'OverviewIndex',
component: () => import('@/views/redirect/index.vue'),
component: () => import('@/views/ops/pages/overview/index.vue'),
meta: {
locale: '系统概况',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/visual',
name: 'VisualDashboard',
component: DEFAULT_LAYOUT,
meta: {
locale: '可视化大屏管理',
requiresAuth: true,
icon: 'icon-apps',
order: 2,
},
children: [
{
path: 'component',
name: 'VisualComponent',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '大屏管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'view-publish',
name: 'ViewPublish',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '已发布大屏列表',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/dc',
name: 'DataCenter',
component: DEFAULT_LAYOUT,
meta: {
locale: '服务器及PC管理',
requiresAuth: true,
icon: 'icon-storage',
order: 3,
},
children: [
{
path: 'pc',
name: 'OfficePC',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '办公PC管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'server',
name: 'ServerManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '服务器管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/dc/cluster',
name: 'ClusterCollection',
component: DEFAULT_LAYOUT,
meta: {
locale: '集群采集控制中心',
requiresAuth: true,
icon: 'icon-settings',
order: 4,
},
children: [
{
path: 'database',
name: 'DatabaseCollection',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '数据库采集管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'middleware',
name: 'MiddlewareCollection',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '中间件采集管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'network',
name: 'NetworkDeviceCollection',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '网络设备采集管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/monitor',
name: 'Monitor',
component: DEFAULT_LAYOUT,
meta: {
locale: '综合监控',
requiresAuth: true,
icon: 'icon-desktop',
order: 5,
},
children: [
{
path: 'log',
name: 'LogMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '日志监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'virtualization',
name: 'VirtualizationMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '虚拟化监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'storage',
name: 'StorageMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '存储设备监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'network',
name: 'NetworkMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '网络设备监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'power',
name: 'PowerMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '电力/UPS/空调/温湿度',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'url',
name: 'URLMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: 'URL监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'guard',
name: 'GuardMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '消防/门禁/漏水/有害气体',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'security',
name: 'SecurityMonitor',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '安全设备监控',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/netarch',
name: 'NetworkArchitecture',
component: DEFAULT_LAYOUT,
meta: {
locale: '网络架构管理',
requiresAuth: true,
icon: 'icon-nav',
order: 6,
},
children: [
{
path: 'auto-topo',
name: 'AutoTopo',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '自动感知拓扑图',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'topo-group',
name: 'TopoGroup',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '拓扑管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'traffic',
name: 'TrafficAnalysis',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '流量分析管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'ip',
name: 'IPAddress',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: 'IP地址管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/alert',
name: 'Alert',
component: DEFAULT_LAYOUT,
meta: {
locale: '告警管理',
requiresAuth: true,
icon: 'icon-bulb',
order: 7,
},
children: [
{
path: 'setting',
name: 'AlertSetting',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警策略管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'tackle',
name: 'AlertTackle',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警受理处理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'history',
name: 'AlertHistory',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警历史',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'template',
name: 'AlertTemplate',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警模版',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'notice',
name: 'AlertNotice',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警通知设置',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'level',
name: 'AlertLevel',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '告警级别管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/feedback',
name: 'Feedback',
component: DEFAULT_LAYOUT,
meta: {
locale: '工单管理',
requiresAuth: true,
icon: 'icon-list',
order: 8,
},
children: [
{
path: 'all',
name: 'AllTickets',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '所有工单',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'undo',
name: 'PendingTickets',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '我的工单',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/datacenter',
name: 'DataCenterManagement',
component: DEFAULT_LAYOUT,
meta: {
locale: '数据中心管理',
requiresAuth: true,
icon: 'icon-drive-file',
order: 9,
},
children: [
{
path: 'rack',
name: 'RackManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '机柜管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'house',
name: 'DataCenterHouse',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '数据中心',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'floor',
name: 'FloorManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '楼层管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/assets',
name: 'Assets',
component: DEFAULT_LAYOUT,
meta: {
locale: '资产管理',
requiresAuth: true,
icon: 'icon-apps',
order: 10,
},
children: [
{
path: 'classify',
name: 'AssetClassify',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '设备分类管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'device',
name: 'AssetDevice',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '设备管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'supplier',
name: 'AssetSupplier',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '供应商管理',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/kb',
name: 'KnowledgeBase',
component: DEFAULT_LAYOUT,
meta: {
locale: '知识库管理',
requiresAuth: true,
icon: 'icon-file',
order: 11,
},
children: [
{
path: 'items',
name: 'KnowledgeItems',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '知识管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'tags',
name: 'KnowledgeTags',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '标签管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'review',
name: 'KnowledgeReview',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '我的审核',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'favorite',
name: 'KnowledgeFavorite',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '我的收藏',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'recycle',
name: 'KnowledgeRecycle',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '回收站',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/report',
name: 'Report',
component: DEFAULT_LAYOUT,
meta: {
locale: '报告管理',
requiresAuth: true,
icon: 'icon-nav',
order: 12,
},
children: [
{
path: 'host',
name: 'ServerReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '服务器报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'fault',
name: 'FaultReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '故障报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'device',
name: 'DeviceReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '网络设备报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'traffic',
name: 'TrafficReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '流量统计报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'history',
name: 'HistoryReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '历史报告',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'statistics',
name: 'StatisticsReport',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '统计报告',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/system-settings',
name: 'SystemSettings',
component: DEFAULT_LAYOUT,
meta: {
locale: '系统设置',
requiresAuth: true,
icon: 'icon-settings',
order: 13,
},
children: [
{
path: 'system-monitoring',
name: 'SystemMonitoring',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '系统监控',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'system-logs',
name: 'SystemLogs',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '系统日志',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'account-management',
name: 'AccountManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '用户管理',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'menu-management',
name: 'MenuManagement',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '菜单设置',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'license-center',
name: 'LicenseCenter',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '许可授权中心',
requiresAuth: true,
roles: ['*'],
},
},
],
},
{
path: '/help',
name: 'HelpCenter',
component: DEFAULT_LAYOUT,
meta: {
locale: '帮助中心',
requiresAuth: true,
icon: 'icon-bulb',
order: 14,
hideChildrenInMenu: true,
},
children: [
{
path: '',
name: 'HelpCenterIndex',
component: () => import('@/views/redirect/index.vue'),
meta: {
locale: '帮助中心',
requiresAuth: true,
roles: ['*'],
},
},
],

View File

@@ -4,9 +4,10 @@ import type { NotificationReturn } from '@arco-design/web-vue/es/notification/in
import type { RouteRecordNormalized } from 'vue-router'
import defaultSettings from '@/config/settings.json'
import { userPmn } from '@/api/module/user'
import { localMenuData } from '@/router/menu-data'
import { localMenuData, transformMenuToRoutes, type ServerMenuItem } from '@/router/menu-data'
import { buildTree } from '@/utils/tree'
import SafeStorage, { AppStorageKey } from "@/utils/safeStorage";
import router from '@/router'
import { AppState } from './types'
@@ -57,19 +58,54 @@ const useAppStore = defineStore('app', {
const res = await userPmn({ id: userInfo.user_id, workspace: import.meta.env.VITE_APP_WORKSPACE })
console.log('res', res)
if (res.code === 0 && res?.details?.length) {
console.log('buildTree', buildTree(res.details[0].permissions))
// 使用 buildTree 将扁平数据构建为树结构
const treeResult = buildTree(res.details[0].permissions as ServerMenuItem[], {
orderKey: 'order'
})
console.log('buildTree', treeResult)
// 使用 transformMenuToRoutes 将树结构转换为路由配置
const routes = transformMenuToRoutes(treeResult.rootItems as ServerMenuItem[])
console.log('transformMenuToRoutes', routes)
// 动态注册路由
routes.forEach((route) => {
// 打印路由结构以便调试
// console.log('Registering route:', JSON.stringify(route, (key, value) => {
// if (typeof value === 'function') return '[Function]'
// return value
// }, 2))
router.addRoute(route as any)
})
this.serverMenu = routes as unknown as RouteRecordNormalized[]
} else {
// 如果接口返回数据为空,使用本地数据
localMenuData.forEach((route) => {
router.addRoute(route as any)
})
this.serverMenu = localMenuData as unknown as RouteRecordNormalized[]
}
// this.serverMenu = data
// 使用本地数据
this.serverMenu = localMenuData as unknown as RouteRecordNormalized[]
} catch (error) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// 接口失败时使用本地数据
console.error('fetchServerMenuConfig error:', error)
localMenuData.forEach((route) => {
router.addRoute(route as any)
})
this.serverMenu = localMenuData as unknown as RouteRecordNormalized[]
}
},
clearServerMenu() {
// 清除动态注册的路由
if (this.serverMenu && this.serverMenu.length > 0) {
this.serverMenu.forEach((route) => {
const routeName = (route as any).name
if (routeName && typeof routeName === 'string') {
router.removeRoute(routeName)
}
})
}
this.serverMenu = []
},
},

View File

@@ -16,5 +16,6 @@ export interface AppState {
tabBar: boolean
menuFromServer: boolean
serverMenu: RouteRecordNormalized[]
workspace?: string
[key: string]: unknown
}

View File

@@ -46,7 +46,7 @@
<script lang="ts" setup>
import type { LoginData } from '@/api/types'
import useLoading from '@/hooks/loading'
import { useUserStore } from '@/store'
import { useUserStore, useAppStore } from '@/store'
import { Message } from '@arco-design/web-vue'
import { ValidatedError } from '@arco-design/web-vue/es/form/interface'
import { useStorage } from '@vueuse/core'
@@ -59,6 +59,7 @@ const { t } = useI18n()
const errorMessage = ref('')
const { loading, setLoading } = useLoading()
const userStore = useUserStore()
const appStore = useAppStore()
const loginConfig = useStorage('login-config', {
rememberPassword: true,
@@ -76,12 +77,10 @@ const handleSubmit = async ({ errors, values }: { errors: Record<string, Validat
setLoading(true)
try {
await userStore.login(values as LoginData)
await appStore.fetchServerMenuConfig()
const { redirect, ...othersQuery } = router.currentRoute.value.query
router.push({
name: (redirect as string) || 'Workplace',
query: {
...othersQuery,
},
path: '/overview'
})
Message.success(t('login.form.login.success'))
const { rememberPassword } = loginConfig.value

View File

@@ -0,0 +1,5 @@
<template>
<div class="menu-container">
帮助中心
</div>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<div class="pmn-container">
overview
</div>
</template>

View File

@@ -0,0 +1,138 @@
<template>
<a-modal
v-model:visible="visible"
title="修改用户密码"
:mask-closable="false"
:width="500"
@cancel="handleClose"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
layout="vertical"
>
<a-form-item label="用户账号">
<a-input :model-value="user?.account" disabled />
</a-form-item>
<a-form-item field="password" label="新密码">
<a-input-password
v-model="formData.password"
placeholder="请输入密码"
allow-clear
/>
</a-form-item>
<a-form-item field="confirmPassword" label="确认新密码">
<a-input-password
v-model="formData.confirmPassword"
placeholder="请确认密码"
allow-clear
/>
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleClose">取消</a-button>
<a-button type="primary" :loading="loading" @click="handleSubmit">
确定
</a-button>
</a-space>
</template>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { adminResetUserPassword } from '@/api/module/user'
import type { UserItem } from '@/api/types'
const props = defineProps<{
user: UserItem | null
}>()
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void
(e: 'success'): void
}>()
const visible = computed({
get: () => !!props.user,
set: (val) => emit('update:visible', val),
})
const formRef = ref()
const loading = ref(false)
const formData = reactive({
password: '',
confirmPassword: '',
})
const rules = {
password: [
{ required: true, message: '请输入密码' },
{ minLength: 6, message: '密码至少6个字符' },
],
confirmPassword: [
{ required: true, message: '请确认密码' },
{
validator: (value: string, callback: (error?: string) => void) => {
if (value !== formData.password) {
callback('两次密码输入不一致')
} else {
callback()
}
},
},
],
}
// 重置表单
watch(
() => props.user,
(val) => {
if (val) {
formData.password = ''
formData.confirmPassword = ''
}
}
)
const handleClose = () => {
formRef.value?.resetFields()
emit('update:visible', false)
}
const handleSubmit = async () => {
try {
const valid = await formRef.value?.validate()
if (valid) return
loading.value = true
const res = await adminResetUserPassword({
id: props.user!.id!,
password: formData.password,
})
if (res.code === 0) {
Message.success(res.message || '密码修改成功')
handleClose()
emit('success')
}
} catch (error) {
console.error('修改密码失败', error)
} finally {
loading.value = false
}
}
</script>
<script lang="ts">
export default {
name: 'PasswordChangeDialog',
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<a-modal
v-model:visible="visible"
title="设置权限"
:mask-closable="false"
:width="600"
@cancel="handleClose"
>
<a-spin :loading="loading" style="width: 100%">
<div class="permission-header">
<span>用户账号: {{ user?.account }}</span>
</div>
<div class="permission-content">
<PermissionTreeItem
v-if="permissionTree.length > 0"
:items="permissionTree"
:selected-permissions="selectedPermissions"
:expanded-keys="expandedKeys"
@change="handlePermissionChange"
/>
<a-empty v-else description="暂无权限数据" />
</div>
</a-spin>
<template #footer>
<a-space>
<a-button @click="handleClose">取消</a-button>
<a-button type="primary" :loading="submitting" @click="handleSubmit">
确定
</a-button>
</a-space>
</template>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import PermissionTreeItem from './PermissionTree.vue'
import {
getUserPmnTree,
userPmn,
userModifyPmn,
} from '@/api/module/user'
import type { UserItem } from '@/api/types'
import { useAppStore } from '@/store'
const props = defineProps<{
user: UserItem | null
}>()
const emit = defineEmits<{
(e: 'update:visible', visible: boolean): void
(e: 'success'): void
}>()
const appStore = useAppStore()
const visible = computed({
get: () => !!props.user,
set: (val) => emit('update:visible', val),
})
const loading = ref(false)
const submitting = ref(false)
const permissionTree = ref<any[]>([])
const selectedPermissions = ref<number[]>([])
const originalPermissions = ref<number[]>([])
const expandedKeys = ref<Set<number>>(new Set())
// 获取权限数据
const fetchPermissions = async () => {
if (!props.user?.id) return
loading.value = true
try {
// 并行获取权限树和用户当前权限
const [treeRes, pmnRes] = await Promise.all([
getUserPmnTree({ id: props.user.id, workspace: import.meta.env.VITE_APP_WORKSPACE || '' }),
userPmn({
id: props.user.id,
workspace: import.meta.env.VITE_APP_WORKSPACE || '',
}),
])
console.log('treeRes', treeRes)
if (treeRes.details) {
permissionTree.value = treeRes.data
// 默认展开第一层
treeRes.data.forEach((item: any) => {
expandedKeys.value.add(item.id)
})
}
if (pmnRes.data) {
selectedPermissions.value = pmnRes.data.map((item: any) => item.pmn_id || item)
originalPermissions.value = [...selectedPermissions.value]
}
} catch (error) {
console.error('获取权限数据失败', error)
} finally {
loading.value = false
}
}
// 监听 user 变化
watch(
() => props.user,
(val) => {
if (val?.id) {
fetchPermissions()
} else {
permissionTree.value = []
selectedPermissions.value = []
}
}
)
// 处理权限变化
const handlePermissionChange = (permissions: number[]) => {
selectedPermissions.value = permissions
}
const handleClose = () => {
emit('update:visible', false)
}
const handleSubmit = async () => {
if (!props.user?.id) return
submitting.value = true
try {
const res = await userModifyPmn({
id: props.user.id,
list: selectedPermissions.value,
})
if (res.code === 0) {
Message.success('权限设置成功')
handleClose()
emit('success')
}
} catch (error) {
console.error('设置权限失败', error)
} finally {
submitting.value = false
}
}
</script>
<script lang="ts">
export default {
name: 'PermissionSettingDialog',
}
</script>
<style scoped lang="less">
.permission-header {
margin-bottom: 16px;
padding: 12px;
background-color: var(--color-fill-1);
border-radius: 4px;
}
.permission-content {
max-height: 400px;
overflow-y: auto;
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,241 @@
<template>
<div class="permission-tree">
<div v-for="item in items" :key="item.id" class="tree-node">
<div class="node-content" :style="{ paddingLeft: `${level * 24}px` }">
<!-- 展开/折叠按钮 -->
<span
v-if="hasChildren(item)"
class="expand-icon"
@click="toggleExpand(item.id)"
>
<icon-right v-if="!isExpanded(item.id)" />
<icon-down v-else />
</span>
<span v-else class="expand-icon-placeholder" />
<!-- 复选框 -->
<a-checkbox
:model-value="isChecked(item.id)"
:indeterminate="isIndeterminate(item)"
@change="handleCheck(item.id, $event)"
/>
<!-- 名称 -->
<span class="node-name">{{ item.name }}</span>
</div>
<!-- 子节点 -->
<div v-if="hasChildren(item) && isExpanded(item.id)" class="children">
<PermissionTree
:items="getChildren(item)"
:selected-permissions="selectedPermissions"
:level="level + 1"
:expanded-keys="expandedKeys"
@change="handleChange"
/>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
export interface PermissionItem {
id: number
name: string
children?: PermissionItem[]
}
const props = withDefaults(
defineProps<{
items: PermissionItem[]
selectedPermissions: number[]
level?: number
expandedKeys?: Set<number>
}>(),
{
level: 0,
expandedKeys: () => new Set(),
}
)
const emit = defineEmits<{
(e: 'change', permissions: number[]): void
}>()
// 本地展开状态
const localExpandedKeys = ref<Set<number>>(new Set())
const expandedKeys = computed(() => props.expandedKeys.size > 0 ? props.expandedKeys : localExpandedKeys.value)
// 检查是否有子节点
const hasChildren = (item: PermissionItem) => {
return item.children && item.children.length > 0
}
// 获取子节点
const getChildren = (item: PermissionItem): PermissionItem[] => {
return item.children || []
}
// 检查是否展开
const isExpanded = (id: number) => {
return expandedKeys.value.has(id)
}
// 切换展开状态
const toggleExpand = (id: number) => {
if (expandedKeys.value.has(id)) {
expandedKeys.value.delete(id)
} else {
expandedKeys.value.add(id)
}
}
// 检查是否选中
const isChecked = (id: number) => {
return props.selectedPermissions.includes(id)
}
// 检查是否半选状态
const isIndeterminate = (item: PermissionItem) => {
if (!hasChildren(item)) return false
const childIds = getAllChildIds(item)
const selectedCount = childIds.filter((id) => props.selectedPermissions.includes(id)).length
return selectedCount > 0 && selectedCount < childIds.length
}
// 递归获取所有子节点ID
const getAllChildIds = (item: PermissionItem): number[] => {
const ids: number[] = []
if (item.children) {
item.children.forEach((child) => {
ids.push(child.id)
ids.push(...getAllChildIds(child))
})
}
return ids
}
// 根据ID查找权限项
const findItemById = (items: PermissionItem[], id: number): PermissionItem | null => {
for (const item of items) {
if (item.id === id) return item
if (item.children) {
const found = findItemById(item.children, id)
if (found) return found
}
}
return null
}
// 获取父节点ID
const getParentIds = (
items: PermissionItem[],
targetId: number,
parentIds: number[] = []
): number[] => {
for (const item of items) {
if (item.children) {
if (item.children.some((child) => child.id === targetId)) {
return [...parentIds, item.id]
}
const result = getParentIds(item.children, targetId, [...parentIds, item.id])
if (result.length > 0) return result
}
}
return []
}
// 处理选中状态变化
const handleCheck = (id: number, checked: boolean) => {
let newPermissions = [...props.selectedPermissions]
if (checked) {
// 添加当前权限
if (!newPermissions.includes(id)) {
newPermissions.push(id)
}
// 添加所有子权限
const item = findItemById(props.items, id)
if (item) {
const childIds = getAllChildIds(item)
childIds.forEach((childId) => {
if (!newPermissions.includes(childId)) {
newPermissions.push(childId)
}
})
}
} else {
// 移除当前权限
newPermissions = newPermissions.filter((pId) => pId !== id)
// 移除所有子权限
const item = findItemById(props.items, id)
if (item) {
const childIds = getAllChildIds(item)
newPermissions = newPermissions.filter((pId) => !childIds.includes(pId))
}
// 移除父权限
const parentIds = getParentIds(props.items, id)
parentIds.forEach((parentId) => {
newPermissions = newPermissions.filter((pId) => pId !== parentId)
})
}
emit('change', newPermissions)
}
// 处理子组件变化
const handleChange = (permissions: number[]) => {
emit('change', permissions)
}
</script>
<script lang="ts">
export default {
name: 'PermissionTree',
}
</script>
<style scoped lang="less">
.permission-tree {
.tree-node {
.node-content {
display: flex;
align-items: center;
padding: 4px 0;
.expand-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-text-2);
transition: color 0.2s;
&:hover {
color: rgb(var(--primary-6));
}
}
.expand-icon-placeholder {
width: 20px;
height: 20px;
}
.node-name {
margin-left: 8px;
}
}
.children {
// 子节点样式
}
}
}
</style>

View File

@@ -0,0 +1,409 @@
<template>
<div class="container">
<Breadcrumb :items="['用户管理']" />
<SearchTable
title="用户管理"
:form-model="searchForm"
:form-items="formItems"
:data="tableData"
:columns="columns"
:loading="loading"
:pagination="pagination"
@search="handleSearch"
@reset="handleReset"
@page-change="handlePageChange"
@refresh="fetchData"
>
<!-- 工具栏左侧 - 新增按钮 -->
<template #toolbar-left>
<a-button type="primary" @click="handleAdd">
<template #icon><icon-plus /></template>
新增用户
</a-button>
</template>
<!-- 操作列 -->
<template #operation="{ record }">
<a-space>
<a-button type="text" size="small" @click="handleEdit(record)">
编辑
</a-button>
<a-button type="text" size="small" @click="handleSetPermission(record)">
权限
</a-button>
<a-button type="text" size="small" @click="handleChangePassword(record)">
改密
</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">
删除
</a-button>
</a-space>
</template>
<!-- 状态列 -->
<template #status="{ record }">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? '启用' : '禁用' }}
</a-tag>
</template>
</SearchTable>
<!-- 新增/编辑用户弹窗 -->
<a-modal
v-model:visible="modalVisible"
:title="modalTitle"
:mask-closable="false"
:width="520"
@ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item field="account" label="账号" validate-trigger="blur">
<a-input
v-model="formData.account"
placeholder="请输入账号"
:disabled="isEdit"
/>
</a-form-item>
<a-form-item field="name" label="姓名" validate-trigger="blur">
<a-input v-model="formData.name" placeholder="请输入姓名" />
</a-form-item>
<a-form-item field="email" label="邮箱" validate-trigger="blur">
<a-input v-model="formData.email" placeholder="请输入邮箱" />
</a-form-item>
<a-form-item field="phone" label="手机号" validate-trigger="blur">
<a-input v-model="formData.phone" placeholder="请输入手机号" />
</a-form-item>
<a-form-item field="status" label="状态">
<a-select v-model="formData.status" placeholder="请选择状态">
<a-option :value="1">正常</a-option>
<a-option :value="-1">禁用</a-option>
</a-select>
</a-form-item>
</a-form>
</a-modal>
<!-- 删除确认对话框 -->
<a-modal
v-model:visible="deleteConfirmVisible"
title="删除确认"
@ok="handleConfirmDelete"
@cancel="deleteConfirmVisible = false"
>
<p>确定要删除用户 "{{ userToDelete?.name || userToDelete?.account }}" </p>
</a-modal>
<!-- 修改密码弹窗 -->
<PasswordChangeDialog
:user="passwordChangeUser"
@update:visible="passwordChangeUser = null"
@success="fetchData"
/>
<!-- 权限设置弹窗 -->
<PermissionSettingDialog
:user="permissionUser"
@update:visible="handlePermissionDialogClose"
@success="fetchData"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-vue'
import type { TableColumnData } from '@arco-design/web-vue/es/table/interface'
import SearchTable from '@/components/search-table/index.vue'
import type { FormItem } from '@/components/search-form/types'
import type { UserItem } from '@/api/types'
import {
fetchUserList,
createUser,
modifyUser,
deleteUser,
} from '@/api/module/user'
import PasswordChangeDialog from './components/PasswordChangeDialog.vue'
import PermissionSettingDialog from './components/PermissionSettingDialog.vue'
// 搜索表单配置
const formItems: FormItem[] = [
{
field: 'keyword',
label: '关键词',
type: 'input',
placeholder: '请输入用户名/昵称/手机号',
span: 8,
},
]
// 表格列配置
const columns: TableColumnData[] = [
{
title: 'ID',
dataIndex: 'id',
width: 80,
},
{
title: '账号',
dataIndex: 'account',
width: 120,
},
{
title: '姓名',
dataIndex: 'name',
width: 120,
},
{
title: '邮箱',
dataIndex: 'email',
width: 180,
},
{
title: '手机号',
dataIndex: 'phone',
width: 140,
},
{
title: '状态',
dataIndex: 'status',
slotName: 'status',
width: 100,
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
},
{
title: '操作',
slotName: 'operation',
width: 150,
fixed: 'right',
},
]
// 搜索表单数据
const searchForm = reactive({
keyword: '',
})
// 分页配置
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
})
// 表格数据
const tableData = ref<UserItem[]>([])
const loading = ref(false)
// 弹窗相关
const modalVisible = ref(false)
const isEdit = ref(false)
const formRef = ref()
const formData = reactive<UserItem>({
id: undefined,
account: '',
name: '',
email: '',
phone: '',
status: 1,
})
// 表单验证规则
const formRules = {
account: [
{ required: true, message: '请输入账号' },
{ minLength: 3, message: '账号至少3个字符' },
],
name: [
{ required: true, message: '请输入姓名' },
],
email: [
{ match: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, message: '请输入正确的邮箱格式' },
],
phone: [
{ match: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' },
],
}
// 删除确认
const deleteConfirmVisible = ref(false)
const userToDelete = ref<UserItem | null>(null)
// 修改密码
const passwordChangeUser = ref<UserItem | null>(null)
// 权限设置
const permissionUser = ref<UserItem | null>(null)
// 关闭权限设置弹窗
const handlePermissionDialogClose = (visible: boolean) => {
if (!visible) {
permissionUser.value = null
}
}
// 弹窗标题
const modalTitle = computed(() => (isEdit.value ? '编辑用户' : '新增用户'))
// 获取数据
const fetchData = async () => {
try {
loading.value = true
const res = await fetchUserList({
page: pagination.current,
size: pagination.pageSize,
keyword: searchForm.keyword || undefined,
})
if (res?.code === 0) {
tableData.value = res.details?.data || []
pagination.total = res.details?.total || 0
}
} catch (error) {
console.error('Failed to fetch user list:', error)
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.current = 1
fetchData()
}
// 重置
const handleReset = () => {
searchForm.keyword = ''
pagination.current = 1
fetchData()
}
// 分页变化
const handlePageChange = (current: number) => {
pagination.current = current
fetchData()
}
// 新增用户
const handleAdd = () => {
isEdit.value = false
resetFormData()
modalVisible.value = true
}
// 编辑用户
const handleEdit = (record: UserItem) => {
isEdit.value = true
Object.assign(formData, {
id: record.id,
account: record.account,
name: record.name,
email: record.email,
phone: record.phone,
status: record.status ?? 1,
})
modalVisible.value = true
}
// 删除用户
const handleDelete = (record: UserItem) => {
userToDelete.value = record
deleteConfirmVisible.value = true
}
// 修改密码
const handleChangePassword = (record: UserItem) => {
passwordChangeUser.value = record
}
// 权限设置
const handleSetPermission = (record: UserItem) => {
permissionUser.value = record
}
// 确认删除
const handleConfirmDelete = async () => {
if (!userToDelete.value?.id) return
try {
loading.value = true
await deleteUser({ id: userToDelete.value.id })
Message.success('删除成功')
deleteConfirmVisible.value = false
userToDelete.value = null
await fetchData()
} catch (error) {
console.error('Failed to delete user:', error)
} finally {
loading.value = false
}
}
// 弹窗确认
const handleModalOk = async () => {
try {
const valid = await formRef.value?.validate()
if (valid) return
loading.value = true
if (isEdit.value && formData.id) {
await modifyUser(formData)
Message.success('修改成功')
} else {
await createUser(formData)
Message.success('创建成功')
}
modalVisible.value = false
await fetchData()
} catch (error) {
console.error('Failed to save user:', error)
} finally {
loading.value = false
}
}
// 弹窗取消
const handleModalCancel = () => {
modalVisible.value = false
formRef.value?.resetFields()
}
// 重置表单数据
const resetFormData = () => {
Object.assign(formData, {
id: undefined,
account: '',
name: '',
email: '',
phone: '',
status: 1,
})
}
// 初始化
onMounted(() => {
fetchData()
})
</script>
<script lang="ts">
export default {
name: 'AccountManagement',
}
</script>
<style scoped lang="less">
.container {
padding: 0 20px 20px 20px;
}
</style>

View File

@@ -43,19 +43,34 @@
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="component" label="组件路径">
<a-input v-model="formData.component" placeholder="请输入组件路径" />
</a-form-item>
</a-col>
<!-- 根菜单才显示图标选择 -->
<a-col v-if="!parentId" :span="12">
<a-form-item field="menu_icon" label="菜单图标" :rules="[{ required: true, message: '请选择菜单图标' }]">
<a-input
v-model="formData.menu_icon"
placeholder="点击选择图标"
readonly
@click="iconPickerVisible = true"
>
<template #prefix>
<component v-if="formData.menu_icon" :is="getIconComponent(formData.menu_icon)" :size="18" />
</template>
</a-input>
<a-form-item field="menu_icon" label="菜单图标" style="width: 100%">
<a-input-group style="width: 100%;">
<a-input
v-model="formData.menu_icon"
placeholder="点击选择图标"
readonly
style="cursor: pointer"
@click="iconPickerVisible = true"
>
<template #prefix>
<component v-if="formData.menu_icon" :is="getIconComponent(formData.menu_icon)" :size="18" />
</template>
</a-input>
<a-button
v-if="formData.menu_icon"
@click="clearIcon"
>
<template #icon><icon-close /></template>
</a-button>
</a-input-group>
</a-form-item>
</a-col>
@@ -66,16 +81,29 @@
</a-col>
</a-row>
<!-- 路由配置 -->
<a-divider orientation="left">路由配置</a-divider>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="is_new_tab">
<a-switch v-model="formData.is_new_tab" />
<span class="switch-label">在新窗口打开</span>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="is_web_page">
<a-switch v-model="formData.is_web_page" />
<span class="switch-label">嵌入网页</span>
</a-form-item>
</a-col>
</a-row>
<!-- 网页嵌入配置 -->
<a-divider orientation="left">网页嵌入配置</a-divider>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item field="is_web_page">
<a-switch v-model="formData.is_web_page" />
<span class="switch-label">是否为嵌入网页</span>
</a-form-item>
</a-col>
<a-col v-if="formData.is_web_page" :span="24">
<a-form-item field="web_url" label="网页地址">
@@ -154,12 +182,14 @@ const formData = ref<MenuRouteRequest>({
description: '',
menu_icon: '',
menu_path: '',
component: '',
parent_id: undefined,
title: '',
title_en: '',
type: 1,
is_web_page: false,
web_url: '',
is_new_tab: false,
// 编辑时需要的额外字段
id: undefined,
identity: undefined,
@@ -194,12 +224,14 @@ const resetForm = () => {
description: '',
menu_icon: '',
menu_path: '',
component: '',
parent_id: props.parentId || undefined,
title: '',
title_en: '',
type: 1,
is_web_page: false,
web_url: '',
is_new_tab: false,
id: undefined,
identity: undefined,
app_id: undefined,
@@ -227,12 +259,14 @@ watch(
description: initialValues.description || '',
menu_icon: initialValues.menu_icon || '',
menu_path: initialValues.menu_path || '',
component: initialValues.component || '',
parent_id: initialValues.parent_id,
title: initialValues.title || '',
title_en: initialValues.title_en || '',
type: initialValues.type || 1,
is_web_page: initialValues.is_web_page || false,
web_url: initialValues.web_url || '',
is_new_tab: initialValues.is_new_tab || false,
}
}
},
@@ -253,6 +287,11 @@ const selectIcon = (icon: string) => {
iconSearch.value = ''
}
// 清空图标
const clearIcon = () => {
formData.value.menu_icon = ''
}
// 提交表单
const handleSubmit = async () => {
try {
@@ -296,10 +335,11 @@ export default {
.icon-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(5, 1fr);
gap: 8px;
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
}
.icon-item {

View File

@@ -16,66 +16,29 @@
@drag-end="$emit('drag-end')"
@drop="$emit('drop', $event)"
/>
<!-- 根级别拖放区域 -->
<div
v-if="draggingNode"
class="root-drop-zone"
:class="{ 'drag-over': isDragOver }"
@dragover.prevent="handleDragOver"
@dragleave="handleDragLeave"
@drop.prevent="handleDropToRoot"
>
<icon-plus-circle /> 拖放到此处设为根级菜单
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import MenuTreeItem from './MenuTreeItem.vue'
import type { MenuNode } from '../types'
const props = defineProps<{
defineProps<{
nodes: MenuNode[]
expandedKeys: Set<number>
selectedKey: number | null
draggingNode?: MenuNode | null
}>()
const emit = defineEmits<{
defineEmits<{
(e: 'toggle', nodeId: number): void
(e: 'select', node: MenuNode): void
(e: 'add-child', parentId: number): void
(e: 'delete', node: MenuNode): void
(e: 'drag-start', node: MenuNode): void
(e: 'drag-end'): void
(e: 'drop', data: { dragNode: MenuNode; targetNode: MenuNode | null; position: 'before' | 'after' | 'inside' | 'root' }): void
(e: 'drop', data: { dragNode: MenuNode; targetNode: MenuNode; position: 'before' | 'after' | 'inside' }): void
}>()
const isDragOver = ref(false)
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
isDragOver.value = true
}
const handleDragLeave = () => {
isDragOver.value = false
}
const handleDropToRoot = () => {
isDragOver.value = false
if (props.draggingNode) {
// 拖放到根级别
emit('drop', {
dragNode: props.draggingNode,
targetNode: null,
position: 'root'
})
}
}
</script>
<script lang="ts">
@@ -88,26 +51,4 @@ export default {
.menu-tree {
user-select: none;
}
.root-drop-zone {
margin-top: 8px;
padding: 16px;
border: 2px dashed var(--color-border-2);
border-radius: 4px;
text-align: center;
color: var(--color-text-3);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
&:hover, &.drag-over {
border-color: rgb(var(--primary-6));
color: rgb(var(--primary-6));
background-color: rgb(var(--primary-1));
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops', 'menu.ops.systemSettings', 'menu.ops.systemSettings.menuManagement']" />
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.menuManagement']" />
<a-row :gutter="16" class="menu-container">
<!-- 左侧: 菜单树 -->
<a-col :xs="24" :md="10" :lg="8">
@@ -286,7 +286,7 @@ const handleDragEnd = () => {
}
// 处理放置
const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode | null; position: 'before' | 'after' | 'inside' | 'root' }) => {
const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode; position: 'before' | 'after' | 'inside' }) => {
const { dragNode, targetNode, position } = data
if (!dragNode.id) return
@@ -294,56 +294,47 @@ const handleDrop = async (data: { dragNode: MenuNode; targetNode: MenuNode | nul
try {
loading.value = true
if (position === 'root' && !targetNode) {
// 拖放到根级别
if (position === 'inside') {
// 拖放到某个节点内部,成为其子节点
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: null
parent_id: targetNode.id
} as MenuRouteRequest)
} else if (targetNode) {
if (position === 'inside') {
// 拖放到某个节点内部,成为其子节点
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.id
} as MenuRouteRequest)
} else {
// 拖放到某个节点前后,需要更新排序
const sortList: { pmn_id: number; sort_key: number }[] = []
let sortKey = 1
// 获取同级节点
const siblings = menuItems.value
.filter(item => item.parent_id === targetNode.parent_id)
.sort((a, b) => (a.order || 0) - (b.order || 0))
siblings.forEach((item, index) => {
if (item.id === targetNode.id) {
if (position === 'before') {
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
} else {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
}
} else if (item.id !== dragNode.id) {
} else {
// 拖放到某个节点前后,需要更新排序
const sortList: { pmn_id: number; sort_key: number }[] = []
let sortKey = 1
// 获取同级节点
const siblings = menuItems.value
.filter(item => item.parent_id === targetNode.parent_id)
.sort((a, b) => (a.order || 0) - (b.order || 0))
siblings.forEach((item) => {
if (item.id === targetNode.id) {
if (position === 'before') {
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
} else {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
sortList.push({ pmn_id: dragNode.id!, sort_key: sortKey++ })
}
})
// 更新拖拽节点的 parent_id
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.parent_id || null
} as MenuRouteRequest)
// 更新排序
if (sortList.length > 0) {
await updateMenuOrder(sortList)
} else if (item.id !== dragNode.id) {
sortList.push({ pmn_id: item.id!, sort_key: sortKey++ })
}
})
// 更新拖拽节点的 parent_id
const { children, ...dragData } = dragNode
await modifyMenu({
...dragData,
parent_id: targetNode.parent_id || null
} as MenuRouteRequest)
// 更新排序
if (sortList.length > 0) {
await updateMenuOrder(sortList)
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="container">
<Breadcrumb :items="['menu.ops', 'menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<Breadcrumb :items="['menu.ops.systemSettings', 'menu.ops.systemSettings.systemLogs']" />
<!-- 使用 SearchTable 公共组件 -->
<SearchTable