完善平台角色菜单树与授权登录导航
This commit is contained in:
@@ -16,6 +16,8 @@
|
||||
"resource-pages:check": "node scripts/check-resource-pages.mjs",
|
||||
"account-roles:check": "node scripts/check-account-role-presentation.mjs",
|
||||
"avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs",
|
||||
"auth-landing:check": "node scripts/check-authorized-login-landing.mjs",
|
||||
"menu-permission-tree:check": "node scripts/check-menu-permission-tree.mjs",
|
||||
"contract-attachment:check": "node scripts/check-contract-attachment-control.mjs",
|
||||
"contract-product-entry:check": "node scripts/check-contract-product-entry.mjs",
|
||||
"contract-product-filter:check": "node scripts/check-contract-product-filter.mjs",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 功能:校验非 root 账号登录落点、历史地址恢复与零菜单降级行为。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(currentDirectory, '..');
|
||||
const source = fs.readFileSync(
|
||||
path.join(root, 'src/router/authorized-landing.ts'),
|
||||
'utf8',
|
||||
);
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.ESNext,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
},
|
||||
}).outputText;
|
||||
const landing = await import(
|
||||
`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`
|
||||
);
|
||||
|
||||
const menuTree = [
|
||||
{
|
||||
name: 'finance',
|
||||
meta: { menuCode: 'finance' },
|
||||
children: [
|
||||
{
|
||||
name: 'finance-hidden',
|
||||
meta: { menuCode: 'finance', hideInMenu: true },
|
||||
},
|
||||
{
|
||||
name: 'finance-payments',
|
||||
meta: { menuCode: 'fin_payment' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(landing.findFirstAuthorizedMenuRoute(menuTree), {
|
||||
name: 'finance-payments',
|
||||
});
|
||||
assert.equal(landing.findFirstAuthorizedMenuRoute([]), null);
|
||||
|
||||
function fakeRouter(routeByTarget) {
|
||||
return {
|
||||
resolve(target) {
|
||||
return routeByTarget(String(target.name ?? target.path ?? ''));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const router = fakeRouter((target) => {
|
||||
if (target === 'finance') {
|
||||
return {
|
||||
name: 'finance',
|
||||
meta: { menuCode: 'finance' },
|
||||
matched: [{ redirect: '/finance/payments', children: [{}] }],
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: target,
|
||||
meta: {
|
||||
menuCode:
|
||||
target === 'finance-payments' ? 'fin_payment' : 'dashboard_overview',
|
||||
},
|
||||
matched: [{ children: [] }],
|
||||
};
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
landing.resolveAuthorizedLanding(
|
||||
router,
|
||||
menuTree,
|
||||
'finance_operator',
|
||||
['finance', 'fin_payment'],
|
||||
{ name: 'finance-payments' },
|
||||
),
|
||||
{ name: 'finance-payments' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
landing.resolveAuthorizedLanding(
|
||||
router,
|
||||
menuTree,
|
||||
'finance_operator',
|
||||
['finance', 'fin_payment'],
|
||||
{ name: 'dashboard-overview' },
|
||||
),
|
||||
{ name: 'finance-payments' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
landing.resolveAuthorizedLanding(
|
||||
router,
|
||||
menuTree,
|
||||
'finance_operator',
|
||||
['finance', 'fin_payment'],
|
||||
{ name: 'finance' },
|
||||
),
|
||||
{ name: 'finance-payments' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
landing.resolveAuthorizedLanding(router, [], 'empty_role', [], undefined),
|
||||
{ name: 'noPermission' },
|
||||
);
|
||||
|
||||
for (const [relativePath, expected] of [
|
||||
['src/router/constants.ts', "{ name: 'noPermission', children: [] }"],
|
||||
['src/router/routes/base.ts', "path: '/no-permission'"],
|
||||
[
|
||||
'src/views/login/components/login-form.vue',
|
||||
'resolveAuthorizedLanding(',
|
||||
],
|
||||
[
|
||||
'src/views/no-permission/index.vue',
|
||||
'尚未分配菜单权限',
|
||||
],
|
||||
]) {
|
||||
const content = fs.readFileSync(path.join(root, relativePath), 'utf8');
|
||||
assert.ok(content.includes(expected), `${relativePath} 缺少契约:${expected}`);
|
||||
}
|
||||
|
||||
process.stdout.write('平台账号授权登录落点检查通过\n');
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 功能:静态检查角色菜单树、叶子提交和自身导航读取契约。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const resources = read('../src/api/resources.ts');
|
||||
const form = read('../src/views/resource/ResourceFieldForm.vue');
|
||||
const tree = read('../src/views/resource/MenuPermissionTree.vue');
|
||||
const dialog = read('../src/views/resource/ResourceActionDialog.vue');
|
||||
const payload = read('../src/api/resource-form.ts');
|
||||
|
||||
function requireMarker(source, marker, message) {
|
||||
if (!source.includes(marker)) throw new Error(`角色菜单树检查失败:${message}`);
|
||||
}
|
||||
|
||||
requireMarker(resources, "type: 'menu-tree'", '分配菜单未使用专用树字段');
|
||||
requireMarker(form, 'MenuPermissionTree', '通用表单未挂载菜单树');
|
||||
requireMarker(tree, ':indeterminate="groupIndeterminate(group)"', '父菜单缺少半选状态');
|
||||
requireMarker(tree, 'toggleGroup(group, checked === true)', '父菜单缺少全选或全清行为');
|
||||
requireMarker(tree, 'group.name }} / {{ child.name', '子菜单未显示完整路径');
|
||||
requireMarker(payload, "field.type === 'menu-tree'", '空叶子数组不能稳定提交');
|
||||
requireMarker(dialog, '确认清空菜单权限', '清空全部菜单前缺少明确警告');
|
||||
|
||||
console.log('角色菜单树静态检查通过');
|
||||
@@ -23,7 +23,10 @@ export function buildResourcePayload(
|
||||
for (const field of fields) {
|
||||
if (mode === 'edit' && field.type === 'password') continue;
|
||||
const value = form[field.key];
|
||||
if (field.type === 'identity-list' && Array.isArray(value)) {
|
||||
if (
|
||||
(field.type === 'identity-list' || field.type === 'menu-tree') &&
|
||||
Array.isArray(value)
|
||||
) {
|
||||
payload[field.key] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ResourceFieldType =
|
||||
| 'password'
|
||||
| 'identity'
|
||||
| 'identity-list'
|
||||
| 'menu-tree'
|
||||
| 'number'
|
||||
| 'money'
|
||||
| 'boolean'
|
||||
@@ -949,7 +950,7 @@ export const resources: ResourceUiDefinition[] = [
|
||||
]),
|
||||
define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true, unknownValueLabel: '未知角色' }), f('phone', { emptyText: '未填写' })]),
|
||||
define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: resourceSearchEnumOptions('platform_role', 'location_scope') })], 'list', [
|
||||
{ name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'identity-list', relation: '/platform_menu' })] },
|
||||
{ name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'menu-tree', relation: '/platform_menu' })] },
|
||||
]),
|
||||
define('platform_menu', '平台菜单', 'readonly', [
|
||||
relation('parent_identity', '/platform_menu'), f('group_code'), f('name'), f('icon'),
|
||||
|
||||
85
frontend/platform_admin/src/router/authorized-landing.ts
Normal file
85
frontend/platform_admin/src/router/authorized-landing.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 功能:为平台管理员选择安全的登录落点。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import type {
|
||||
RouteLocationRaw,
|
||||
RouteMeta,
|
||||
RouteRecordName,
|
||||
Router,
|
||||
} from 'vue-router';
|
||||
|
||||
export const NO_PERMISSION_ROUTE_NAME = 'noPermission';
|
||||
|
||||
export interface AuthorizedMenuRoute {
|
||||
name?: RouteRecordName | null;
|
||||
meta?: RouteMeta;
|
||||
children?: AuthorizedMenuRoute[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按服务端菜单顺序寻找首个可落地的叶子页面。
|
||||
* 父级分组和隐藏详情页不能成为默认首页。
|
||||
*/
|
||||
export function findFirstAuthorizedMenuRoute(
|
||||
routes: readonly AuthorizedMenuRoute[],
|
||||
): RouteLocationRaw | null {
|
||||
for (const route of routes) {
|
||||
if (route.children?.length) {
|
||||
const child = findFirstAuthorizedMenuRoute(route.children);
|
||||
if (child) return child;
|
||||
continue;
|
||||
}
|
||||
if (route.name && route.meta?.hideInMenu !== true) {
|
||||
return { name: route.name };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 仅允许恢复当前角色确实有权访问的叶子路由。 */
|
||||
export function isAuthorizedLoginTarget(
|
||||
router: Router,
|
||||
target: RouteLocationRaw,
|
||||
role: string,
|
||||
menuCodes: readonly string[],
|
||||
) {
|
||||
const resolved = router.resolve(target);
|
||||
const terminal = resolved.matched[resolved.matched.length - 1];
|
||||
if (
|
||||
!terminal ||
|
||||
resolved.name === 'login' ||
|
||||
resolved.name === 'notFound' ||
|
||||
resolved.name === NO_PERMISSION_ROUTE_NAME ||
|
||||
terminal.redirect ||
|
||||
terminal.children.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const menuCode = resolved.meta.menuCode;
|
||||
return (
|
||||
role === 'root' ||
|
||||
(typeof menuCode === 'string' && menuCodes.includes(menuCode))
|
||||
);
|
||||
}
|
||||
|
||||
/** 优先恢复有权历史地址,否则进入首个授权菜单或权限引导页。 */
|
||||
export function resolveAuthorizedLanding(
|
||||
router: Router,
|
||||
menus: readonly AuthorizedMenuRoute[],
|
||||
role: string,
|
||||
menuCodes: readonly string[],
|
||||
requested?: RouteLocationRaw,
|
||||
): RouteLocationRaw {
|
||||
if (
|
||||
requested &&
|
||||
isAuthorizedLoginTarget(router, requested, role, menuCodes)
|
||||
) {
|
||||
return requested;
|
||||
}
|
||||
return (
|
||||
findFirstAuthorizedMenuRoute(menus) ?? {
|
||||
name: NO_PERMISSION_ROUTE_NAME,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const WHITE_LIST = [
|
||||
{ name: 'notFound', children: [] },
|
||||
{ name: 'login', children: [] },
|
||||
{ name: 'noPermission', children: [] },
|
||||
];
|
||||
|
||||
export const NOT_FOUND = {
|
||||
|
||||
@@ -4,7 +4,11 @@ import 'nprogress/nprogress.css';
|
||||
|
||||
import createRouteGuard from './guard';
|
||||
import { appRoutes } from './routes';
|
||||
import { NOT_FOUND_ROUTE, REDIRECT_MAIN } from './routes/base';
|
||||
import {
|
||||
NOT_FOUND_ROUTE,
|
||||
NO_PERMISSION_ROUTE,
|
||||
REDIRECT_MAIN,
|
||||
} from './routes/base';
|
||||
|
||||
NProgress.configure({ showSpinner: false }); // NProgress Configuration
|
||||
|
||||
@@ -25,6 +29,7 @@ const router = createRouter({
|
||||
},
|
||||
...appRoutes,
|
||||
REDIRECT_MAIN,
|
||||
NO_PERMISSION_ROUTE,
|
||||
NOT_FOUND_ROUTE,
|
||||
],
|
||||
scrollBehavior() {
|
||||
|
||||
@@ -24,6 +24,17 @@ export const REDIRECT_MAIN: RouteRecordRaw = {
|
||||
],
|
||||
};
|
||||
|
||||
/** 已认证但暂无业务菜单时的独立引导页。 */
|
||||
export const NO_PERMISSION_ROUTE: RouteRecordRaw = {
|
||||
path: '/no-permission',
|
||||
name: 'noPermission',
|
||||
component: () => import('@/views/no-permission/index.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
hideInMenu: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NOT_FOUND_ROUTE: RouteRecordRaw = {
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'notFound',
|
||||
|
||||
@@ -72,9 +72,11 @@ import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { LoginData } from '@/api/auth';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import { useUserStore } from '@/store';
|
||||
import { resolveAuthorizedLanding } from '@/router/authorized-landing';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const errorMessage = ref('');
|
||||
const { loading, setLoading } = useLoading();
|
||||
const userStore = useUserStore();
|
||||
@@ -102,20 +104,32 @@ const handleSubmit = async ({
|
||||
setLoading(true);
|
||||
try {
|
||||
await userStore.login(values as LoginData);
|
||||
await userStore.info();
|
||||
const { redirect, ...othersQuery } = router.currentRoute.value.query;
|
||||
const storedRedirect = sessionStorage.getItem('auth-redirect');
|
||||
sessionStorage.removeItem('auth-redirect');
|
||||
router.push({
|
||||
...(storedRedirect?.startsWith('/')
|
||||
? { path: storedRedirect }
|
||||
: { name: (redirect as string) || 'dashboard-overview', query: othersQuery }),
|
||||
});
|
||||
const requested = storedRedirect?.startsWith('/')
|
||||
? { path: storedRedirect }
|
||||
: typeof redirect === 'string'
|
||||
? { name: redirect, query: othersQuery }
|
||||
: undefined;
|
||||
await router.push(
|
||||
resolveAuthorizedLanding(
|
||||
router,
|
||||
appStore.appAsyncMenus,
|
||||
userStore.role,
|
||||
userStore.menuCodes,
|
||||
requested,
|
||||
),
|
||||
);
|
||||
Message.success('登录成功');
|
||||
const { rememberPassword } = loginConfig.value;
|
||||
const { username } = values;
|
||||
loginConfig.value.username = rememberPassword ? username : '';
|
||||
loginConfig.value.password = '';
|
||||
} catch (err) {
|
||||
// 登录后资料或菜单加载失败时同步撤销令牌,避免留下半登录会话。
|
||||
await userStore.logout();
|
||||
// 将登录接口的已知英文错误转换为中文,未知错误保持服务端原文。
|
||||
const message = (err as Error).message;
|
||||
const loginErrorMessages: Record<string, string> = {
|
||||
|
||||
78
frontend/platform_admin/src/views/no-permission/index.vue
Normal file
78
frontend/platform_admin/src/views/no-permission/index.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<!--
|
||||
功能:为未分配任何菜单的平台账号提供可恢复的权限引导页。
|
||||
版本:v1.0.0
|
||||
-->
|
||||
<template>
|
||||
<main class="permission-page">
|
||||
<a-result
|
||||
status="403"
|
||||
title="尚未分配菜单权限"
|
||||
subtitle="当前账号已登录,但还没有可访问的业务页面。请联系平台根管理员分配菜单权限。"
|
||||
>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button :loading="reloading" type="primary" @click="reloadPermission">
|
||||
重新加载权限
|
||||
</a-button>
|
||||
<a-button @click="logout">退出登录</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-result>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { findFirstAuthorizedMenuRoute } from '@/router/authorized-landing';
|
||||
import { useAppStore, useUserStore } from '@/store';
|
||||
|
||||
defineOptions({ name: 'NoPermissionPage' });
|
||||
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const userStore = useUserStore();
|
||||
const reloading = ref(false);
|
||||
|
||||
/** 重新读取服务端角色菜单,授权生效后直接进入首个业务页。 */
|
||||
async function reloadPermission() {
|
||||
reloading.value = true;
|
||||
try {
|
||||
await userStore.info();
|
||||
const destination = findFirstAuthorizedMenuRoute(appStore.appAsyncMenus);
|
||||
if (!destination) {
|
||||
Message.warning('仍未分配菜单权限');
|
||||
return;
|
||||
}
|
||||
await router.replace(destination);
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
reloading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 退出当前账号并返回登录页。 */
|
||||
async function logout() {
|
||||
await userStore.logout();
|
||||
await router.replace({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.permission-page {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: var(--color-fill-2);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.permission-page :deep(.arco-result) {
|
||||
width: min(640px, 100%);
|
||||
padding: 56px 32px;
|
||||
background: var(--color-bg-2);
|
||||
border-radius: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!--
|
||||
功能:以父子树方式分配平台角色菜单,仅向表单写入叶子菜单标识。
|
||||
版本:v1.0.0
|
||||
-->
|
||||
<template>
|
||||
<div class="menu-permission-tree">
|
||||
<a-input v-model="keyword" allow-clear placeholder="搜索菜单名称或完整路径" />
|
||||
<div v-if="visibleGroups.length" class="menu-groups">
|
||||
<section v-for="group in visibleGroups" :key="String(group.identity)" class="menu-group">
|
||||
<a-checkbox
|
||||
v-if="group.standalone"
|
||||
:model-value="selected.has(String(group.identity))"
|
||||
@change="(checked: unknown) => toggleLeaf(String(group.identity), checked === true)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-checkbox>
|
||||
<a-checkbox
|
||||
v-else
|
||||
:model-value="groupChecked(group)"
|
||||
:indeterminate="groupIndeterminate(group)"
|
||||
@change="(checked: unknown) => toggleGroup(group, checked === true)"
|
||||
>
|
||||
{{ group.name }}
|
||||
</a-checkbox>
|
||||
<div v-if="!group.standalone" class="menu-children">
|
||||
<a-checkbox
|
||||
v-for="child in group.children"
|
||||
:key="String(child.identity)"
|
||||
:model-value="selected.has(String(child.identity))"
|
||||
@change="(checked: unknown) => toggleLeaf(String(child.identity), checked === true)"
|
||||
>
|
||||
{{ group.name }} / {{ child.name }}
|
||||
</a-checkbox>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<a-empty v-else description="没有匹配的菜单" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
|
||||
type MenuGroup = ResourceRow & { children: ResourceRow[]; standalone: boolean };
|
||||
|
||||
const props = defineProps<{ options: ResourceRow[] }>();
|
||||
const model = defineModel<string[]>({ required: true });
|
||||
const keyword = ref('');
|
||||
const selected = computed(() => new Set((model.value ?? []).map(String)));
|
||||
|
||||
/** 将静态菜单扁平数据整理为两层展示树,父节点不进入提交值。 */
|
||||
const groups = computed<MenuGroup[]>(() => {
|
||||
const children = new Map<string, ResourceRow[]>();
|
||||
for (const item of props.options) {
|
||||
const parent = String(item.parent_identity ?? '');
|
||||
if (!parent) continue;
|
||||
children.set(parent, [...(children.get(parent) ?? []), item]);
|
||||
}
|
||||
return props.options
|
||||
.filter((item) => !String(item.parent_identity ?? ''))
|
||||
.map((item) => {
|
||||
const items = children.get(String(item.identity)) ?? [];
|
||||
return { ...item, children: items, standalone: items.length === 0 };
|
||||
});
|
||||
});
|
||||
|
||||
/** 搜索命中父级时保留全部子级,命中子级时保留其父级上下文。 */
|
||||
const visibleGroups = computed<MenuGroup[]>(() => {
|
||||
const query = keyword.value.trim().toLowerCase();
|
||||
if (!query) return groups.value;
|
||||
return groups.value.flatMap((group) => {
|
||||
const parentMatches = String(group.name ?? '')
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
const matchedChildren = parentMatches
|
||||
? group.children
|
||||
: group.children.filter((child) =>
|
||||
`${String(group.name ?? '')} / ${String(child.name ?? '')}`
|
||||
.toLowerCase()
|
||||
.includes(query),
|
||||
);
|
||||
return matchedChildren.length
|
||||
? [{ ...group, children: matchedChildren }]
|
||||
: [];
|
||||
});
|
||||
});
|
||||
|
||||
function groupChecked(group: MenuGroup) {
|
||||
return (
|
||||
group.children.length > 0 &&
|
||||
group.children.every((child) => selected.value.has(String(child.identity)))
|
||||
);
|
||||
}
|
||||
|
||||
function groupIndeterminate(group: MenuGroup) {
|
||||
const count = group.children.filter((child) =>
|
||||
selected.value.has(String(child.identity)),
|
||||
).length;
|
||||
return count > 0 && count < group.children.length;
|
||||
}
|
||||
|
||||
/** 父级仅执行当前分组全部叶子的批量选择,不作为独立权限保存。 */
|
||||
function toggleGroup(group: MenuGroup, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
for (const child of group.children) {
|
||||
const identity = String(child.identity);
|
||||
checked ? next.add(identity) : next.delete(identity);
|
||||
}
|
||||
model.value = [...next];
|
||||
}
|
||||
|
||||
function toggleLeaf(identity: string, checked: boolean) {
|
||||
const next = new Set(selected.value);
|
||||
checked ? next.add(identity) : next.delete(identity);
|
||||
model.value = [...next];
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.menu-permission-tree { display: grid; gap: 12px; }
|
||||
.menu-groups { max-height: 420px; overflow: auto; border: 1px solid var(--color-border-2); border-radius: 4px; }
|
||||
.menu-group { padding: 12px 16px; border-bottom: 1px solid var(--color-border-1); }
|
||||
.menu-group:last-child { border-bottom: 0; }
|
||||
.menu-children { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 20px; padding: 10px 0 0 24px; }
|
||||
@media (max-width: 700px) { .menu-children { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
@@ -40,7 +40,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
@@ -62,6 +62,7 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const submitting = ref(false);
|
||||
const emptyMenuAssignmentConfirmed = ref(false);
|
||||
const relations = useResourceRelations();
|
||||
const isProductLifecycleAction = computed(
|
||||
() => props.action?.resource === '/product_info/:identity/lifecycle',
|
||||
@@ -232,10 +233,14 @@ watch(
|
||||
async ([visible]) => {
|
||||
if (!visible || !props.action) return;
|
||||
for (const field of actionFields.value) {
|
||||
form[field.key] = isOwnershipAction.value
|
||||
? props.record[field.key]
|
||||
: undefined;
|
||||
form[field.key] =
|
||||
field.type === 'menu-tree'
|
||||
? []
|
||||
: isOwnershipAction.value
|
||||
? props.record[field.key]
|
||||
: undefined;
|
||||
}
|
||||
emptyMenuAssignmentConfirmed.value = false;
|
||||
if (isGasorderAssignmentAction.value) {
|
||||
await initializeGasorderAssignment();
|
||||
return;
|
||||
@@ -264,6 +269,22 @@ function close() {
|
||||
async function submit() {
|
||||
const action = props.action;
|
||||
if (!action) return;
|
||||
if (
|
||||
action.resource.includes('/menu') &&
|
||||
!emptyMenuAssignmentConfirmed.value &&
|
||||
(!Array.isArray(form.menu_identities) || form.menu_identities.length === 0)
|
||||
) {
|
||||
Modal.warning({
|
||||
title: '确认清空菜单权限',
|
||||
content: '保存后,该角色下的账号将无法访问任何业务页面。是否继续?',
|
||||
hideCancel: false,
|
||||
onOk: () => {
|
||||
emptyMenuAssignmentConfirmed.value = true;
|
||||
void submit();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (
|
||||
actionFields.value.some(
|
||||
(field) => field.required && isMissingField(form[field.key]),
|
||||
|
||||
@@ -56,6 +56,11 @@
|
||||
:disabled="disabledSet.has(field.key)"
|
||||
:placeholder="requiredKeys.includes(field.key) ? '请输入至少 6 个字符' : '留空表示不修改密码'"
|
||||
/>
|
||||
<MenuPermissionTree
|
||||
v-else-if="field.type === 'menu-tree'"
|
||||
v-model="model[field.key]"
|
||||
:options="relationOptions[field.relation ?? ''] ?? []"
|
||||
/>
|
||||
<a-select
|
||||
v-else-if="field.key === 'platform_role_code'"
|
||||
v-model="model[field.key]"
|
||||
@@ -156,6 +161,7 @@ import IdentityText from '@/components/IdentityText.vue';
|
||||
import ContractAttachmentField, {
|
||||
type ContractAttachmentFieldState,
|
||||
} from './ContractAttachmentField.vue';
|
||||
import MenuPermissionTree from './MenuPermissionTree.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -199,6 +205,7 @@ function isWide(field: ResourceField) {
|
||||
return (
|
||||
field.type === 'textarea' ||
|
||||
field.type === 'identity-list' ||
|
||||
field.type === 'menu-tree' ||
|
||||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user