fix(components): 菜单管理调整

This commit is contained in:
2026-03-27 14:03:42 +08:00
parent 120a5b4dfd
commit ca3d697941
9 changed files with 11075 additions and 103 deletions

View File

@@ -1,5 +1,5 @@
{ {
"generatedAt": "2026-03-25T08:15:18.925Z", "generatedAt": "2026-03-27T05:39:32.467Z",
"description": "Frontend visible page resource whitelist for backend route/menu configuration.", "description": "Frontend visible page resource whitelist for backend route/menu configuration.",
"rules": { "rules": {
"directoryComponent": "layout.base", "directoryComponent": "layout.base",

View File

@@ -24,11 +24,11 @@
"cleanup": "sa cleanup", "cleanup": "sa cleanup",
"commit": "sa git-commit", "commit": "sa git-commit",
"commit:zh": "sa git-commit -l=zh-cn", "commit:zh": "sa git-commit -l=zh-cn",
"create-route": "sa gen-route",
"dev": "vite --mode dev", "dev": "vite --mode dev",
"dev:prod": "vite --mode prod", "dev:prod": "vite --mode prod",
"create-route": "sa gen-route",
"gen:page-resource-manifest": "node --experimental-strip-types scripts/generate-page-resource-manifest.mjs",
"gen-route": "node --experimental-strip-types scripts/generate-elegant-router.mjs", "gen-route": "node --experimental-strip-types scripts/generate-elegant-router.mjs",
"gen:page-resource-manifest": "node --experimental-strip-types scripts/generate-page-resource-manifest.mjs",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"prepare": "simple-git-hooks", "prepare": "simple-git-hooks",
"preview": "vite preview", "preview": "vite preview",

10919
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,11 @@ import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { setupElegantRouter } from '../build/plugins/router.ts'; import { setupElegantRouter } from '../build/plugins/router.ts';
const __filename = fileURLToPath(import.meta.url); const currentFilePath = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const currentDirPath = path.dirname(currentFilePath);
const rootDir = path.resolve(__dirname, '..'); const rootDir = path.resolve(currentDirPath, '..');
const generatedFiles = [ const generatedFiles = [
path.resolve(rootDir, 'src/router/elegant/imports.ts'), path.resolve(rootDir, 'src/router/elegant/imports.ts'),
@@ -36,17 +35,25 @@ function isGenerated(before, after) {
async function waitForGeneration(beforeMtimes, timeoutMs = 10000) { async function waitForGeneration(beforeMtimes, timeoutMs = 10000) {
const startTime = Date.now(); const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) { async function poll() {
const afterMtimes = await getGeneratedFileMtimes(); const afterMtimes = await getGeneratedFileMtimes();
if (isGenerated(beforeMtimes, afterMtimes)) { if (isGenerated(beforeMtimes, afterMtimes)) {
return; return;
} }
await new Promise(resolve => setTimeout(resolve, 100)); if (Date.now() - startTime >= timeoutMs) {
throw new Error('Timed out while waiting for elegant-router generated files.');
}
await new Promise(resolve => {
setTimeout(resolve, 100);
});
await poll();
} }
throw new Error('Timed out while waiting for elegant-router generated files.'); await poll();
} }
process.chdir(rootDir); process.chdir(rootDir);

View File

@@ -1,14 +1,13 @@
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { customRoutes } from '../src/router/routes/custom-routes.ts'; import { customRoutes } from '../src/router/routes/custom-routes.ts';
import { generatedRoutes } from '../src/router/elegant/routes.ts'; import { generatedRoutes } from '../src/router/elegant/routes.ts';
import zhCn from '../src/locales/langs/zh-cn.ts'; import zhCn from '../src/locales/langs/zh-cn.ts';
const __filename = fileURLToPath(import.meta.url); const currentFilePath = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const currentDirPath = path.dirname(currentFilePath);
const rootDir = path.resolve(__dirname, '..'); const rootDir = path.resolve(currentDirPath, '..');
const outputPath = path.resolve(rootDir, 'docs/frontend-page-resource-manifest.json'); const outputPath = path.resolve(rootDir, 'docs/frontend-page-resource-manifest.json');
const excludedPageResourceRouteNames = new Set(['exception_403', 'exception_404', 'exception_500']); const excludedPageResourceRouteNames = new Set(['exception_403', 'exception_404', 'exception_500']);
const excludedPageResourcePathPrefixes = ['/function/', '/plugin/']; const excludedPageResourcePathPrefixes = ['/function/', '/plugin/'];
@@ -120,54 +119,76 @@ function getRouteLabel(route) {
return route.meta?.title || route.name; return route.meta?.title || route.name;
} }
function getPageResourceMeta(route) {
const hideInMenu = Boolean(route.meta?.hideInMenu);
const order = getNumberMetaValue(route.meta?.order);
const fixedIndexInTab = getNumberMetaValue(route.meta?.fixedIndexInTab);
return {
title: getRouteLabel(route),
i18nKey: route.meta?.i18nKey || null,
icon: route.meta?.icon || null,
localIcon: route.meta?.localIcon || null,
order,
keepAlive: Boolean(route.meta?.keepAlive),
hideInMenu,
activeMenu: route.meta?.activeMenu || null,
multiTab: Boolean(route.meta?.multiTab),
fixedIndexInTab
};
}
function shouldCollectPageResource(route, pageComponent) {
if (!pageComponent) {
return false;
}
if (route.meta?.hideInMenu) {
return false;
}
return !shouldExcludePageResource(route);
}
function createPageResourceItem(route, options) {
const { pageComponent, parentName, source } = options;
const props = normalizeRouteProps(route.props);
const meta = getPageResourceMeta(route);
return {
name: route.name,
path: route.path,
component: pageComponent,
title: meta.title,
routeTitle: route.meta?.title || route.name,
i18nKey: meta.i18nKey,
icon: meta.icon,
localIcon: meta.localIcon,
order: meta.order,
hideInMenu: meta.hideInMenu,
keepAlive: meta.keepAlive,
activeMenu: meta.activeMenu,
multiTab: meta.multiTab,
fixedIndexInTab: meta.fixedIndexInTab,
redirect: route.redirect || null,
props,
meta,
parentName,
pageType: getPageType(pageComponent),
source
};
}
function collectPageResources(routes, options, items) { function collectPageResources(routes, options, items) {
const { parentName = null, source } = options; const { parentName = null, source } = options;
const orderedRoutes = sortRoutes(routes); const orderedRoutes = sortRoutes(routes);
for (const route of orderedRoutes) { for (const route of orderedRoutes) {
const pageComponent = getPageComponent(route.component); const pageComponent = getPageComponent(route.component);
const hideInMenu = Boolean(route.meta?.hideInMenu);
// The backend page-node whitelist only contains menu-visible pages. // The backend page-node whitelist only contains menu-visible pages.
if (pageComponent && !hideInMenu && !shouldExcludePageResource(route)) { if (shouldCollectPageResource(route, pageComponent)) {
const order = getNumberMetaValue(route.meta?.order); items.push(createPageResourceItem(route, { pageComponent, parentName, source }));
const fixedIndexInTab = getNumberMetaValue(route.meta?.fixedIndexInTab);
const props = normalizeRouteProps(route.props);
const meta = {
title: getRouteLabel(route),
i18nKey: route.meta?.i18nKey || null,
icon: route.meta?.icon || null,
localIcon: route.meta?.localIcon || null,
order,
keepAlive: Boolean(route.meta?.keepAlive),
hideInMenu,
activeMenu: route.meta?.activeMenu || null,
multiTab: Boolean(route.meta?.multiTab),
fixedIndexInTab
};
items.push({
name: route.name,
path: route.path,
component: pageComponent,
title: meta.title,
routeTitle: route.meta?.title || route.name,
i18nKey: meta.i18nKey,
icon: meta.icon,
localIcon: meta.localIcon,
order,
hideInMenu,
keepAlive: meta.keepAlive,
activeMenu: meta.activeMenu,
multiTab: meta.multiTab,
fixedIndexInTab,
redirect: route.redirect || null,
props,
meta,
parentName,
pageType: getPageType(pageComponent),
source
});
} }
if (route.children?.length) { if (route.children?.length) {
@@ -179,8 +200,16 @@ function collectPageResources(routes, options, items) {
function getPageResources() { function getPageResources() {
const items = []; const items = [];
collectPageResources(customRoutes.filter(route => !isConstantRoute(route)), { source: 'custom' }, items); collectPageResources(
collectPageResources(generatedRoutes.filter(route => !isConstantRoute(route)), { source: 'generated' }, items); customRoutes.filter(route => !isConstantRoute(route)),
{ source: 'custom' },
items
);
collectPageResources(
generatedRoutes.filter(route => !isConstantRoute(route)),
{ source: 'generated' },
items
);
const uniqueItems = Array.from(new Map(items.map(item => [item.name, item])).values()); const uniqueItems = Array.from(new Map(items.map(item => [item.name, item])).values());

View File

@@ -98,9 +98,7 @@ export const useTabStore = defineStore(SetupStoreId.Tab, () => {
) )
); );
for (const routeKey of routeKeysToReset) { await Promise.all(routeKeysToReset.map(routeKey => routeStore.resetRouteCache(routeKey)));
await routeStore.resetRouteCache(routeKey);
}
} }
/** /**

View File

@@ -346,10 +346,6 @@ const pageResourceOptions = pageResourceItems.map(item => ({
const selectedPageResource = computed(() => pageResourceMap.get(model.value.pageResourcePath) ?? null); const selectedPageResource = computed(() => pageResourceMap.get(model.value.pageResourcePath) ?? null);
const displayRoutePath = computed(() => { const displayRoutePath = computed(() => {
if (selectedPageResource.value) {
return selectedPageResource.value.path;
}
return joinRoutePaths(currentParentFullPath.value, model.value.path); return joinRoutePaths(currentParentFullPath.value, model.value.path);
}); });
@@ -357,14 +353,6 @@ const hasCompatibleViewRouteData = computed(() =>
Boolean(getNullableText(model.value.component) && getNullableText(model.value.path)) Boolean(getNullableText(model.value.component) && getNullableText(model.value.path))
); );
const isSelectedPageResourceCompatible = computed(() => {
if (!selectedPageResource.value) {
return false;
}
return Boolean(getRelativeRoutePath(selectedPageResource.value.path, currentParentFullPath.value));
});
const rules = computed(() => { const rules = computed(() => {
const pathRule: RuleFormItem = { const pathRule: RuleFormItem = {
message: $t('page.system.menu.form.path'), message: $t('page.system.menu.form.path'),
@@ -403,11 +391,6 @@ const rules = computed(() => {
return; return;
} }
if (!isSelectedPageResourceCompatible.value) {
callback(new Error($t('page.system.menu.form.pageResourceParentMismatch')));
return;
}
callback(); callback();
} }
}; };
@@ -569,14 +552,16 @@ const parentTreeOptions = computed<ParentTreeOption[]>(() => {
]; ];
}); });
function filterParentTreeNode(value: string, data: ParentTreeOption) { function filterParentTreeNode(value: string, data: { label?: string }) {
const keyword = value.trim().toLowerCase(); const keyword = value.trim().toLowerCase();
if (!keyword) { if (!keyword) {
return true; return true;
} }
return data.label.toLowerCase().includes(keyword); return String(data.label ?? '')
.toLowerCase()
.includes(keyword);
} }
function closeModal() { function closeModal() {
@@ -642,7 +627,13 @@ function syncViewRouteFields() {
return; return;
} }
model.value.path = getRelativeRoutePath(pageResource.path, currentParentFullPath.value); const nextRelativePath =
getRelativeRoutePath(pageResource.path, currentParentFullPath.value) ||
normalizeRoutePart(model.value.path) ||
normalizeRoutePart(pageResource.path).split('/').filter(Boolean).at(-1) ||
'';
model.value.path = nextRelativePath;
model.value.component = pageResource.component; model.value.component = pageResource.component;
model.value.componentName = pageResource.name; model.value.componentName = pageResource.name;
model.value.routePropsJson = stringifyRouteProps(pageResource.props as Record<string, unknown> | null); model.value.routePropsJson = stringifyRouteProps(pageResource.props as Record<string, unknown> | null);
@@ -867,8 +858,11 @@ watch(
watch( watch(
() => model.value.parentId, () => model.value.parentId,
() => { async () => {
syncCurrentRouteFields(); syncCurrentRouteFields();
await nextTick();
clearFormValidation();
} }
); );

View File

@@ -100,7 +100,10 @@ watch(visible, async value => {
:scrollbar="false" :scrollbar="false"
@confirm="handleSubmit" @confirm="handleSubmit"
> >
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top"> <ElForm ref="formRef" :model="model" :rules="rules" label-position="top" autocomplete="off">
<input class="business-form-autofill-guard" type="text" name="fake-username" autocomplete="username" />
<input class="business-form-autofill-guard" type="password" name="fake-password" autocomplete="new-password" />
<ElRow :gutter="16"> <ElRow :gutter="16">
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem :label="$t('page.system.user.userName')"> <ElFormItem :label="$t('page.system.user.userName')">
@@ -109,14 +112,22 @@ watch(visible, async value => {
</ElCol> </ElCol>
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem :label="$t('page.system.user.newPassword')" prop="password"> <ElFormItem :label="$t('page.system.user.newPassword')" prop="password">
<ElInput v-model="model.password" show-password :placeholder="$t('page.system.user.form.newPassword')" /> <ElInput
v-model="model.password"
name="system-user-reset-password"
show-password
autocomplete="new-password"
:placeholder="$t('page.system.user.form.newPassword')"
/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem :label="$t('page.system.user.confirmPassword')" prop="confirmPassword"> <ElFormItem :label="$t('page.system.user.confirmPassword')" prop="confirmPassword">
<ElInput <ElInput
v-model="model.confirmPassword" v-model="model.confirmPassword"
name="system-user-reset-confirm-password"
show-password show-password
autocomplete="new-password"
:placeholder="$t('page.system.user.form.confirmPassword')" :placeholder="$t('page.system.user.form.confirmPassword')"
/> />
</ElFormItem> </ElFormItem>
@@ -125,3 +136,19 @@ watch(visible, async value => {
</ElForm> </ElForm>
</BusinessFormDialog> </BusinessFormDialog>
</template> </template>
<style scoped>
.business-form-autofill-guard {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
opacity: 0;
pointer-events: none;
}
</style>

View File

@@ -29,12 +29,13 @@ const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required
<TableSearchPanel <TableSearchPanel
:model="model" :model="model"
:disabled="disabled" :disabled="disabled"
:action-col-lg="8" :action-col-lg="24"
:action-col-md="8" :action-col-md="24"
:action-col-sm="24"
@reset="$emit('reset')" @reset="$emit('reset')"
@search="$emit('search')" @search="$emit('search')"
> >
<ElCol :lg="8" :md="8" :sm="12"> <ElCol :lg="6" :md="8" :sm="12">
<ElFormItem :label="$t('page.system.user.userName')" prop="username"> <ElFormItem :label="$t('page.system.user.userName')" prop="username">
<ElInput <ElInput
v-model="model.username" v-model="model.username"
@@ -44,7 +45,7 @@ const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :lg="8" :md="8" :sm="12"> <ElCol :lg="6" :md="8" :sm="12">
<ElFormItem :label="$t('page.system.user.userPhone')" prop="mobile"> <ElFormItem :label="$t('page.system.user.userPhone')" prop="mobile">
<ElInput <ElInput
v-model="model.mobile" v-model="model.mobile"
@@ -54,7 +55,7 @@ const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :lg="8" :md="8" :sm="12"> <ElCol :lg="6" :md="8" :sm="12">
<ElFormItem :label="$t('page.system.user.userStatus')" prop="status"> <ElFormItem :label="$t('page.system.user.userStatus')" prop="status">
<ElSelect <ElSelect
v-model="model.status" v-model="model.status"
@@ -71,21 +72,18 @@ const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :lg="6" :md="8" :sm="12">
<template #extra> <ElFormItem :label="$t('page.system.user.userRole')" prop="roleId">
<ElCol :lg="8" :md="8" :sm="12"> <ElSelect
<ElFormItem :label="$t('page.system.user.userRole')" prop="roleId"> v-model="model.roleId"
<ElSelect clearable
v-model="model.roleId" filterable
clearable :disabled="disabled"
filterable :placeholder="$t('page.system.user.form.userRole')"
:disabled="disabled" >
:placeholder="$t('page.system.user.form.userRole')" <ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
> </ElSelect>
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" /> </ElFormItem>
</ElSelect> </ElCol>
</ElFormItem>
</ElCol>
</template>
</TableSearchPanel> </TableSearchPanel>
</template> </template>