diff --git a/src/App.vue b/src/App.vue index 966c3c6..f4c967c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -10,6 +10,7 @@ import Apis from '@/api/index' import { alovaInstance } from "@/utils/alova/alova-iview"; import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import { UserRoleEnum } from "./types"; +import { isRouteAllowed } from "@/router"; const store = useDefaultStore(); const router = useRouter(); const { $bus } = useCurrentInstance(); @@ -53,10 +54,9 @@ const layoutInit = (info: any, modList: any[], appList: Array, baseHost: st }; /** - * 角色判定 - * 现基于 GetUserInfo 返回的 user.type 判定角色: - * type=1 → SuperAdmin,type=2 → School(不再有 Teacher 角色) - * 同时写入 currentRole 供下游 Utils.getCurrentRoleInfo 读取(保持兼容) + * 角色判定(降级兜底):GetUserScope 失败时按 GetUserInfo 的 user.type 推断—— + * type=1 → SuperAdmin,其余 → School。同时写入 currentRole 供下游 + * Utils.getCurrentRoleInfo 与请求头注入读取(保持兼容;正常路径以 GetUserScope 为准)。 */ const setCurrentRole = (user: any) => { const userId = user?.id ?? null; @@ -87,12 +87,43 @@ onMounted(async () => { try { var user: any = await alovaInstance.Request(Apis.Base.getUserInfo()); - // 系统字典 & 模块列表(模块当前写死,后续接入接口) - await store.fetchSysDict(); - await store.fetchModules(); - currentUser.value = user; + + // 系统字典 + await store.fetchSysDict(); + + // 角色判定:优先 GetUserScope(后端单点判定四级访问级别,前端不重复实现规则), + // 失败时降级走 setCurrentRole 的 type 推断 setCurrentRole(user); + try { + const scope: any = await alovaInstance.Request(Apis.Base.getUserScope()); + if (scope && scope.level != null) { + store.setUserRole(scope.level); + store.setUserScope({ + bureauGroupId: scope.bureauGroupId ?? null, + schoolGroupIds: scope.schoolGroupIds ?? null, + }); + } + } catch (e) { + console.warn("获取访问范围失败,按用户类型降级判定角色", e); + } + + // NONE 级:整体「暂无权限」,不加载菜单(接口层已被后端 NONE 拦截器 403 双挡) + store.setRoleLoaded(true); + if (store.userRole === UserRoleEnum.None) { + isNoAuthority.value = true; + layoutInit(user, [], [], baseHost, ssoHost); + isLogin.value = true; + return; + } + + // 首次导航早于角色落定被守卫放行:此处复核当前路由(直接敲 URL 场景),越权重定向 /no-auth + if (!isRouteAllowed(router.currentRoute.value, store.userRole)) { + router.replace("/no-auth"); + } + + // 模块列表(按当前级别过滤菜单权限矩阵) + await store.fetchModules(); layoutInit(user, store.modules, store.modules, baseHost, ssoHost); // 登录后直接跳转到左侧菜单第一个可用入口 diff --git a/src/api/apiDefinitions.ts b/src/api/apiDefinitions.ts index a799596..09e5444 100644 --- a/src/api/apiDefinitions.ts +++ b/src/api/apiDefinitions.ts @@ -96,7 +96,9 @@ export default { 'Base.RedirectTo': ['GET', '/api/base/RedirectTo/{id}'], 'Base.manualHashCacheDemo': ['GET', '/api/base/ManualHashCacheDemo'], 'Base.getUserInfo': ['GET', '/api/base/GetUserInfo'], + 'Base.getUserScope': ['GET', '/api/base/GetUserScope'], 'Base.getSysDict': ['GET', '/api/base/GetSysDict'], + 'AppSettings.schoolList': ['GET', '/api/app/SchoolList'], 'AppRole.roleUsers': ['GET', '/api/approle/RoleUsers'], 'AppRole.roleModuleTree': ['GET', '/api/approle/RoleModuleTree'], 'AppRole.roleList': ['GET', '/api/approle/RoleList'], diff --git a/src/api/globals.d.ts b/src/api/globals.d.ts index 072a235..ccc0331 100644 --- a/src/api/globals.d.ts +++ b/src/api/globals.d.ts @@ -2758,6 +2758,23 @@ declare global { getUserInfo>( config?: Config ): Alova2Method; + /** + * --- + * + * [GET] + * + * **path:** /api/base/GetUserScope + * + * --- + * + * **Response** + * ```ts + * type Response = { level: number, bureauGroupId?: number, schoolGroupIds?: number[] } + * ``` + */ + getUserScope>( + config?: Config + ): Alova2Method; /** * --- * @@ -3920,6 +3937,23 @@ declare global { >( config: Config ): Alova2Method; + /** + * --- + * + * [GET] + * + * **path:** /api/app/SchoolList + * + * --- + * + * **Response** + * ```ts + * type Response = Array<{ id: number, name: string }> + * ``` + */ + schoolList>>( + config?: Config + ): Alova2Method, 'AppSettings.schoolList', Config>; /** * --- * diff --git a/src/assets/config/menu.ts b/src/assets/config/menu.ts index b136c6b..5b592e7 100644 --- a/src/assets/config/menu.ts +++ b/src/assets/config/menu.ts @@ -1,26 +1,23 @@ import type { IModule } from "@/store"; +import { UserRoleEnum } from "@/types"; /** - * 左侧菜单数据(系统管理功能分组) + * 左侧菜单数据(系统管理功能分组)+ 菜单权限矩阵(管理端权限分级改造 §4.2)。 * ---------------------------------------------------------------- - * 当前直接 Promise.resolve 返回写死数据;后续接入真实接口时, - * 只需把 `return Promise.resolve(menu)` 替换为 - * `return alovaInstance.Request(Apis.xxx.getMenu())` - * 即可无缝对接,调用方(store.fetchModules)无需任何改动。 + * 每个节点带 roles 元数据(可访问级别集合),getMenuList(userRole) 按级别过滤, + * children 过滤后为空的父目录整体隐藏。当前数据写死;后续接入真实接口时, + * 只需把 `return Promise.resolve(...)` 替换为接口调用后 filterMenuByRole 即可。 * * 分组结构: - * 1. 组,用户管理 —— 顶层独立选项卡(单页) - * 2. 应用管理 —— 顶层独立选项卡(= 应用列表主页 /app-manage/list); - * 页内按钮跳转「应用类别管理」子页(/app-manage/category,不在菜单里, - * 由 routes.ts 的 meta.parentId=1102 关联,面包屑「应用管理>应用类别管理」) - * 3. 权限管理 —— 父目录:应用开通 / 组授权 / 权限一览 - * 4. 日志管理 —— 父目录:接口日志 / 系统日志 + * 1. 组,用户管理 —— S+B+C + * 2. 应用管理 —— S+C(局端不开放:局端的应用动作是「开通」,应用维护是平台级) + * 3. 权限管理 —— 父目录:应用开通(S+B) / 组授权 / 角色授权 / 权限一览(S+B+C) + * 4. 日志管理 —— 仅超管(父目录) * * 每个节点带唯一 id;子节点的 pid 由 layout.vue 的 flaten() 扁平化时 * 依据父节点 id 自动推导,用于「高亮自身 + 面包屑展示父级」联动。 - * 菜单项不再渲染 icon。 */ -export function getMenuList(): Promise { +export function getMenuList(userRole?: UserRoleEnum): Promise { const menu: IModule[] = [ // 1. 组,用户管理(顶层独立选项卡) { @@ -28,6 +25,7 @@ export function getMenuList(): Promise { modId: 1101, name: "组,用户管理", page: "/group-user", + roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School], }, // 2. 应用管理(顶层独立选项卡,= 应用列表主页;页内按钮跳转应用类别管理子页) { @@ -35,6 +33,7 @@ export function getMenuList(): Promise { modId: 1102, name: "应用管理", page: "/app-manage/list", + roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.School], }, // 3. 权限管理(父目录) { @@ -43,11 +42,12 @@ export function getMenuList(): Promise { name: "权限管理", page: "", childState: true, + roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School], children: [ - { id: 41, modId: 1103, name: "应用开通", page: "/app-open" }, - { id: 42, modId: 1104, name: "组授权", page: "/group-auth" }, - { id: 44, modId: 1110, name: "角色授权", page: "/role-authz/list" }, - { id: 43, modId: 1105, name: "权限一览", page: "/permission-view" }, + { id: 41, modId: 1103, name: "应用开通", page: "/app-open", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau] }, + { id: 42, modId: 1104, name: "组授权", page: "/group-auth", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] }, + { id: 44, modId: 1110, name: "角色授权", page: "/role-authz/list", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] }, + { id: 43, modId: 1105, name: "权限一览", page: "/permission-view", roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School] }, ], }, // 4. 日志管理(父目录) @@ -57,13 +57,37 @@ export function getMenuList(): Promise { name: "日志管理", page: "", childState: true, + roles: [UserRoleEnum.SuperAdmin], children: [ - { id: 53, modId: 1111, name: "操作日志", page: "/oplog" }, - { id: 56, modId: 1114, name: "开放接口日志", page: "/oapilog" }, - { id: 54, modId: 1112, name: "推送日志", page: "/push-log" }, - { id: 55, modId: 1113, name: "测试推送", page: "/push-test" }, + { id: 53, modId: 1111, name: "操作日志", page: "/oplog", roles: [UserRoleEnum.SuperAdmin] }, + { id: 56, modId: 1114, name: "开放接口日志", page: "/oapilog", roles: [UserRoleEnum.SuperAdmin] }, + { id: 54, modId: 1112, name: "推送日志", page: "/push-log", roles: [UserRoleEnum.SuperAdmin] }, + { id: 55, modId: 1113, name: "测试推送", page: "/push-test", roles: [UserRoleEnum.SuperAdmin] }, ], }, ]; - return Promise.resolve(menu); + return Promise.resolve(filterMenuByRole(menu, userRole)); +} + +/** 按级别过滤菜单:roles 未声明的节点视为全部级别可见;children 过滤后为空的父目录整体隐藏 */ +export function filterMenuByRole(menu: IModule[], userRole?: UserRoleEnum): IModule[] { + if (userRole == null) { + return menu; + } + const result: IModule[] = []; + for (const item of menu) { + const roles = (item as any).roles as UserRoleEnum[] | undefined; + if (roles && !roles.includes(userRole)) { + continue; + } + const children = item.children + ? filterMenuByRole(item.children, userRole) + : undefined; + if (item.childState && (!children || children.length === 0)) { + // 父目录:可见但全部子项被过滤 → 整体隐藏 + continue; + } + result.push(children ? { ...item, children } : item); + } + return result; } diff --git a/src/router/index.ts b/src/router/index.ts index e113c19..aeff4ab 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,9 +1,44 @@ import { createRouter, createWebHashHistory } from "vue-router"; +import type { RouteLocationNormalizedLoaded, RouteRecordRaw } from "vue-router"; import routes from "./routes"; - +import { useDefaultStore } from "@/store"; +import { UserRoleEnum } from "@/types"; const router = createRouter({ history: createWebHashHistory(), routes, }); -export default router; \ No newline at end of file + +/** + * 当前角色是否可访问目标路由(meta.roles 未声明 = 登录即可访问)。 + * 供路由守卫与 App.vue 角色落定后的复核共用。 + */ +export function isRouteAllowed( + route: RouteLocationNormalizedLoaded | RouteRecordRaw, + role: UserRoleEnum +): boolean { + const roles = (route.meta?.roles ?? null) as UserRoleEnum[] | null; + return !roles || roles.length === 0 || roles.includes(role); +} + +/** + * 路由守卫(防止直接敲 URL 绕过菜单): + * - 角色未落定(roleLoaded=false,首次导航早于 App.vue 的 GetUserScope)时放行, + * 由 App.vue 在角色落定后调用本守卫的复核逻辑重定向; + * - 角色已落定后按 meta.roles 校验,不满足重定向 /no-auth。 + */ +router.beforeEach(to => { + if (to.path === "/no-auth") { + return true; + } + const store = useDefaultStore(); + if (!store.roleLoaded) { + return true; + } + if (!isRouteAllowed(to, store.userRole)) { + return { path: "/no-auth", replace: true }; + } + return true; +}); + +export default router; diff --git a/src/router/routes.ts b/src/router/routes.ts index 037bea3..645e0cf 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -1,4 +1,5 @@ import type { RouteRecordRaw } from "vue-router"; +import { UserRoleEnum } from "@/types"; import GroupUser from "@/views/group-user/index.vue"; import AppList from "@/views/app/AppList.vue"; import AppCategory from "@/views/app/AppCategory.vue"; @@ -12,41 +13,54 @@ import OperationLogList from "@/views/oplog/OperationLogList.vue"; import OapiLogList from "@/views/oapilog/OapiLogList.vue"; import PushLog from "@/views/push/PushLog.vue"; import PushTest from "@/views/push/PushTest.vue"; +import NoAuth from "@/views/NoAuth.vue"; + +// 管理端四级访问控制:meta.roles 为空/未声明 = 登录即可访问 +const ALL_ADMIN = [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau, UserRoleEnum.School]; const routes: RouteRecordRaw[] = [ { path: "/", redirect: "/group-user", }, + // ===== 无权限页(路由守卫重定向目标)===== + { + path: "/no-auth", + name: "NoAuth", + component: NoAuth, + meta: { title: "暂无权限" }, + }, // ===== 开发组(组/用户管理,多页嵌套目录)===== { path: "/group-user", name: "GroupUser", component: GroupUser, - meta: { title: "组,用户管理", icon: "icon-list", modId: 1101 }, + meta: { title: "组,用户管理", icon: "icon-list", modId: 1101, roles: ALL_ADMIN }, }, // ===== 应用管理(单选项卡)===== // 应用管理主页(= 应用列表),菜单顶层「应用管理」指向此路由(modId 1102) + // 局端不开放应用管理(局端的应用动作是「开通」) { path: "/app-manage/list", name: "AppList", component: AppList, - meta: { title: "应用管理", icon: "icon-list", modId: 1102, keepAlive: true }, + meta: { title: "应用管理", icon: "icon-list", modId: 1102, keepAlive: true, roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.School] }, }, // 应用类型管理(深层子页,不在菜单里):meta.parentId=1102 关联父菜单「应用管理」, // 由 layout.vue 的 routeChange 推导面包屑「应用管理>应用类型管理」并高亮父菜单 + // 校管可:类别改名/排序(写覆盖表);新增/删除类别按钮由页面按级别隐藏 { path: "/app-manage/category", name: "AppCategory", component: AppCategory, - meta: { title: "应用类型管理", modId: 11022, parentId: 1102 }, + meta: { title: "应用类型管理", modId: 11022, parentId: 1102, roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.School] }, }, - // 模块管理(深层子页,不在菜单里):meta.parentId=1102 关联父菜单「应用管理」 + // 模块管理(深层子页,不在菜单里):仅超管 { path: "/app-manage/module", name: "ModuleManage", component: ModuleManage, - meta: { title: "模块管理", modId: 11023, parentId: 1102 }, + meta: { title: "模块管理", modId: 11023, parentId: 1102, roles: [UserRoleEnum.SuperAdmin] }, }, // /app-manage 兜底重定向到主页 { @@ -59,7 +73,7 @@ const routes: RouteRecordRaw[] = [ path: "/role-authz/list", name: "RoleAuthzList", component: RoleAuthzList, - meta: { title: "角色授权", icon: "icon-list", modId: 1110, keepAlive: true }, + meta: { title: "角色授权", icon: "icon-list", modId: 1110, keepAlive: true, roles: ALL_ADMIN }, }, // 角色管理(深层子页,不在菜单里):meta.parentId=1110 关联父菜单「角色授权」, // 由 layout.vue 推导面包屑「角色授权>角色管理」并高亮父菜单(与 /app-manage/category 范式一致) @@ -67,7 +81,7 @@ const routes: RouteRecordRaw[] = [ path: "/role-authz/manage", name: "RoleManage", component: RoleManage, - meta: { title: "角色管理", modId: 11101, parentId: 1110 }, + meta: { title: "角色管理", modId: 11101, parentId: 1110, roles: ALL_ADMIN }, }, // /role-authz 兜底重定向到主页 { @@ -78,45 +92,45 @@ const routes: RouteRecordRaw[] = [ path: "/app-open", name: "AppOpen", component: AppOpen, - meta: { title: "应用开通", icon: "icon-list", modId: 1103 }, + meta: { title: "应用开通", icon: "icon-list", modId: 1103, roles: [UserRoleEnum.SuperAdmin, UserRoleEnum.Bureau] }, }, { path: "/group-auth", name: "GroupAuth", component: GroupAuth, - meta: { title: "组授权", icon: "icon-list", modId: 1104 }, + meta: { title: "组授权", icon: "icon-list", modId: 1104, roles: ALL_ADMIN }, }, { path: "/permission-view", name: "PermissionView", component: PermissionView, - meta: { title: "权限一览", icon: "icon-list", modId: 1105 }, + meta: { title: "权限一览", icon: "icon-list", modId: 1105, roles: ALL_ADMIN }, }, { path: "/push-log", name: "PushLog", component: PushLog, - meta: { title: "推送日志", icon: "icon-list", modId: 1112, keepAlive: true }, + meta: { title: "推送日志", icon: "icon-list", modId: 1112, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] }, }, { path: "/push-test", name: "PushTest", component: PushTest, - meta: { title: "测试推送", icon: "icon-list", modId: 1113 }, + meta: { title: "测试推送", icon: "icon-list", modId: 1113, roles: [UserRoleEnum.SuperAdmin] }, }, // ===== 操作日志 ===== { path: "/oplog", name: "OperationLogList", component: OperationLogList, - meta: { title: "操作日志", icon: "icon-list", modId: 1111, keepAlive: true }, + meta: { title: "操作日志", icon: "icon-list", modId: 1111, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] }, }, // ===== 开放接口日志 ===== { path: "/oapilog", name: "OapiLogList", component: OapiLogList, - meta: { title: "开放接口日志", icon: "icon-list", modId: 1114, keepAlive: true }, + meta: { title: "开放接口日志", icon: "icon-list", modId: 1114, keepAlive: true, roles: [UserRoleEnum.SuperAdmin] }, }, ]; diff --git a/src/store/index.ts b/src/store/index.ts index afa320e..584aba7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -22,12 +22,16 @@ export interface IModule { export interface IdefaultStore { curStatsDate: Date; userRole: UserRoleEnum; + /** 管理端访问范围(GetUserScope 返回,后端单点判定):BUREAU 区域组 / SCHOOL 可管理学校组 */ + userScope: { bureauGroupId?: number | null; schoolGroupIds?: number[] | null }; lenLevel: Array; subjects: Array; /** 系统字典(GetSysDict 接口返回) */ sysDict: Record; /** 模块列表(当前写死,后续接入接口) */ modules: Array; + /** 角色是否已落定(App.vue GetUserScope/降级判定完成后置 true,路由守卫依赖) */ + roleLoaded: boolean; } export const useDefaultStore = defineStore("defaultStore", { @@ -35,10 +39,12 @@ export const useDefaultStore = defineStore("defaultStore", { return { curStatsDate: new Date(), userRole: UserRoleEnum.School, + userScope: { bureauGroupId: null, schoolGroupIds: null }, lenLevel: [], subjects: [], sysDict: {}, modules: [], + roleLoaded: false, }; }, actions: { @@ -48,6 +54,12 @@ export const useDefaultStore = defineStore("defaultStore", { setUserRole(role: UserRoleEnum) { this.userRole = role; }, + setUserScope(scope: { bureauGroupId?: number | null; schoolGroupIds?: number[] | null }) { + this.userScope = scope || { bureauGroupId: null, schoolGroupIds: null }; + }, + setRoleLoaded(loaded: boolean) { + this.roleLoaded = loaded; + }, setSchLenLevel(len: Array) { this.lenLevel = len; }, @@ -61,9 +73,9 @@ export const useDefaultStore = defineStore("defaultStore", { this.sysDict = {}; } }, - /** 获取模块列表(数据来源见 src/assets/config/menu.ts,后续接入接口无缝替换) */ + /** 获取模块列表(数据源 src/assets/config/menu.ts,按当前级别过滤菜单权限矩阵) */ async fetchModules() { - this.modules = await getMenuList(); + this.modules = await getMenuList(this.userRole); }, }, getters: { diff --git a/src/types/index.ts b/src/types/index.ts index 22d94af..493644b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,9 +7,15 @@ export enum OperatorEnum { sel, } +/** + * 管理端访问级别(与后端 AccessLevelEnum / GetUserScope.level 对齐,后端单点判定)。 + * 注意:School 由旧值 2 改为 3(原枚举值无消费方依赖具体数值,安全重排)。 + */ export enum UserRoleEnum { SuperAdmin = 1, + Bureau, School, + None, } //学段 diff --git a/src/utils/hook/useAppSchoolScope.ts b/src/utils/hook/useAppSchoolScope.ts new file mode 100644 index 0000000..ccd6eab --- /dev/null +++ b/src/utils/hook/useAppSchoolScope.ts @@ -0,0 +1,43 @@ +import { computed, ref } from "vue"; +import { Message } from "view-ui-plus"; +import { useDefaultStore } from "@/store"; +import { UserRoleEnum } from "@/types"; + +/** + * 应用管理页学校上下文(管理端权限分级改造 §4.3)。
+ * SCHOOL 模式专属:多校管理员(schoolGroupIds.length > 1)页面顶部出现「学校」下拉, + * currentSchoolId 随接口下发给 AppList/AppSortList/AppSort/EditApp/EditType/AppTypeSort, + * 覆盖只对所选学校生效;单校管理员不显示下拉(后端自动取唯一学校), + * 超管/局端不进入本上下文(局端无应用管理菜单)。 + */ +export function useAppSchoolScope() { + const store = useDefaultStore(); + const isSchoolMode = computed(() => store.userRole === UserRoleEnum.School); + const schoolGroupIds = computed(() => store.userScope?.schoolGroupIds ?? []); + const multiSchool = computed(() => isSchoolMode.value && schoolGroupIds.value.length > 1); + + const schoolList = ref<{ id: number; name: string }[]>([]); + // 默认不选中(currentSchoolId=undefined):查询走后端「多校并集」模式; + // 写操作(编辑/排序)要求先选定学校,由页面用 requireSchool() 守卫 + const currentSchoolId = ref(undefined); + + /** 多校模式下写操作前置校验:未选学校时提示并阻断,返回 false */ + function requireSchool(): boolean { + if (multiSchool.value && !currentSchoolId.value) { + Message.warning("您管理多所学校,请先在顶部选择学校后再操作"); + return false; + } + return true; + } + + if (multiSchool.value) { + // 下拉数据源(SUPER/BUREAU 后端返回空数组,前端也不渲染下拉) + Promise.resolve(Apis.AppSettings.schoolList({ cacheFor: null })) + .then((list: any) => { + schoolList.value = Array.isArray(list) ? list : []; + }) + .catch(e => console.warn("获取可管理学校列表失败", e)); + } + + return { isSchoolMode, multiSchool, schoolList, currentSchoolId, requireSchool }; +} diff --git a/src/views/NoAuth.vue b/src/views/NoAuth.vue new file mode 100644 index 0000000..4fb7f11 --- /dev/null +++ b/src/views/NoAuth.vue @@ -0,0 +1,33 @@ + + + + + diff --git a/src/views/app/AppCategory.vue b/src/views/app/AppCategory.vue index d25aea3..d1622d9 100644 --- a/src/views/app/AppCategory.vue +++ b/src/views/app/AppCategory.vue @@ -2,7 +2,13 @@
设置说明:拖动排序,被使用的分类不能删除 - + + +