Преглед изворни кода

fix: limited-role login home redirect, menu ancestors, demo seeds, router guard

Backend: expand role menu ancestors; Homepage on GetUserInfo; demo_general role + Ai-DOP menus; remove seed Demo02. Frontend: home redirect clamp; router beforeEach return; Session homepage; login replace. Table/xlsx fixes. scripts/remove-seed-demo02-user.sql.
aidopdev пре 1 недеља
родитељ
комит
191a36a236
28 измењених фајлова са 303 додато и 114 уклоњено
  1. 8 0
      Web/src/api-services/models/login-user-output.ts
  2. 23 23
      Web/src/components/table/formatter.vue
  3. 37 21
      Web/src/components/table/index.vue
  4. 0 1
      Web/src/components/table/search.vue
  5. 53 3
      Web/src/router/backEnd.ts
  6. 40 32
      Web/src/router/index.ts
  7. 6 0
      Web/src/stores/userInfo.ts
  8. 13 0
      Web/src/utils/aidopMenuDisplay.ts
  9. 2 2
      Web/src/utils/exportExcel.ts
  10. 14 6
      Web/src/views/login/component/account.vue
  11. 2 2
      Web/src/views/system/codeGen/component/editCodeGenDialog.vue
  12. 1 1
      Web/src/views/system/config/component/editConfig.vue
  13. 1 1
      Web/src/views/system/lang/index.vue
  14. 1 1
      Web/src/views/system/langText/index.vue
  15. 1 1
      Web/src/views/system/template/component/editTemplate.vue
  16. 1 1
      Web/src/views/system/tenantConfig/component/editConfig.vue
  17. 1 1
      Web/src/views/system/tenantConfig/index.vue
  18. 2 2
      Web/src/views/system/user/component/editUser.vue
  19. 4 1
      Web/src/views/system/user/component/userCenter.vue
  20. 7 0
      scripts/remove-seed-demo02-user.sql
  21. 32 2
      server/Admin.NET.Core/SeedData/SysRoleMenuSeedData.cs
  22. 2 0
      server/Admin.NET.Core/SeedData/SysRoleSeedData.cs
  23. 1 1
      server/Admin.NET.Core/SeedData/SysUserRoleSeedData.cs
  24. 2 1
      server/Admin.NET.Core/SeedData/SysUserSeedData.cs
  25. 5 0
      server/Admin.NET.Core/Service/Auth/Dto/LoginUserOutput.cs
  26. 1 0
      server/Admin.NET.Core/Service/Auth/SysAuthService.cs
  27. 25 0
      server/Admin.NET.Core/Service/Menu/SysMenuService.cs
  28. 18 11
      server/Admin.NET.Core/Service/Tenant/SysTenantService.cs

+ 8 - 0
Web/src/api-services/models/login-user-output.ts

@@ -186,4 +186,12 @@ export interface LoginUserOutput {
      * @memberof LoginUserOutput
      * @memberof LoginUserOutput
      */
      */
     langCode?: string | null;
     langCode?: string | null;
+
+    /**
+     * 个性化首页(与菜单 path 一致)
+     *
+     * @type {string}
+     * @memberof LoginUserOutput
+     */
+    homepage?: string | null;
 }
 }

+ 23 - 23
Web/src/components/table/formatter.vue

@@ -1,23 +1,23 @@
-<template>
-	<!-- 将render函数变量写在temolate标签中 -->
-	<render></render>
-</template>
-
-<script lang="ts" setup>
-import { ref, watch, h } from 'vue';
-// 定义父组件传过来的值
-const props = defineProps<{
-	fn: any;
-}>();
-const render = ref();
-watch(
-	props,
-	async () => {
-		render.value = h('div', null, props.fn);
-	},
-	{
-		deep: true, //确认是否深入监听
-		immediate: true, //确认是否以当前的初始值执行handler的函数
-	}
-);
-</script>
+<template>
+	<!-- 将render函数变量写在temolate标签中 -->
+	<render></render>
+</template>
+
+<script lang="ts" setup>
+import { ref, watch, h } from 'vue';
+// 定义父组件传过来的值
+const props = defineProps<{
+	fn: any;
+}>();
+const render = ref();
+watch(
+	props,
+	async () => {
+		render.value = h('div', null, props.fn);
+	},
+	{
+		deep: true, //确认是否深入监听
+		immediate: true, //确认是否以当前的初始值执行handler的函数
+	}
+);
+</script>

+ 37 - 21
Web/src/components/table/index.vue

@@ -142,8 +142,6 @@ import printJs from 'print-js';
 //import { EmptyObjectType } from "/@/types/global";
 //import { EmptyObjectType } from "/@/types/global";
 import formatter from '/@/components/table/formatter.vue';
 import formatter from '/@/components/table/formatter.vue';
 import { useThemeConfig } from '/@/stores/themeConfig';
 import { useThemeConfig } from '/@/stores/themeConfig';
-import { exportExcel } from '/@/utils/exportExcel';  //TODO: 此包会引起浏览器控制台报 Module "stream" has been externalized for browser compatibility. Cannot access "stream.Readable" in client code. 警告,建议替换
-
 // 定义父组件传过来的值
 // 定义父组件传过来的值
 const props = defineProps({
 const props = defineProps({
 	// 获取数据的方法,由父组件传递
 	// 获取数据的方法,由父组件传递
@@ -227,6 +225,15 @@ const getProperty = (obj: any, property: any) => {
 	return value;
 	return value;
 };
 };
 
 
+/** 分页结果可能为 items/total(camelCase)或 Items/Total(PascalCase),统一读取 */
+const readPagedList = (raw: any): { items: EmptyObjectType[]; total: number } | null => {
+	if (!raw || typeof raw !== 'object') return null;
+	const items = raw.items ?? raw.Items;
+	if (!Array.isArray(items)) return null;
+	const total = raw.total ?? raw.Total ?? 0;
+	return { items, total: Number(total) || 0 };
+};
+
 // 设置边框显示/隐藏
 // 设置边框显示/隐藏
 const setBorder = computed(() => {
 const setBorder = computed(() => {
 	return props.config.isBorder ? true : false;
 	return props.config.isBorder ? true : false;
@@ -287,35 +294,40 @@ const pageReset = () => {
 // 导出当前页
 // 导出当前页
 const onExportTable = () => {
 const onExportTable = () => {
 	if (setHeader.value.length <= 0) return ElMessage.error('没有勾选要导出的列');
 	if (setHeader.value.length <= 0) return ElMessage.error('没有勾选要导出的列');
-	exportData(state.data);
+	void exportData(state.data);
 };
 };
 // 全部导出
 // 全部导出
 const onExportTableAll = async () => {
 const onExportTableAll = async () => {
 	if (setHeader.value.length <= 0) return ElMessage.error('没有勾选要导出的列');
 	if (setHeader.value.length <= 0) return ElMessage.error('没有勾选要导出的列');
-	state.exportLoading = true;
 	const param = Object.assign({}, props.param, { page: 1, pageSize: 9999999 });
 	const param = Object.assign({}, props.param, { page: 1, pageSize: 9999999 });
 	const res = await props.getData(param);
 	const res = await props.getData(param);
-	state.exportLoading = false;
-	const data = res.result?.items ?? [];
-	exportData(data);
+	const data = readPagedList(res?.result)?.items ?? [];
+	await exportData(data);
 };
 };
-// 导出方法
-const exportData = (data: Array<EmptyObjectType>) => {
+// 导出方法(动态加载 xlsx-js-style,避免表格页首屏拉取 Node stream 相关代码导致运行时报错)
+const exportData = async (data: Array<EmptyObjectType>) => {
 	if (data.length <= 0) return ElMessage.error('没有数据可以导出');
 	if (data.length <= 0) return ElMessage.error('没有数据可以导出');
 	state.exportLoading = true;
 	state.exportLoading = true;
-	let exportData = JSON.parse(JSON.stringify(data));
-	if (props.exportChangeData) {
-		exportData = props.exportChangeData(exportData);
-	}
-	exportExcel(
-			exportData,
+	try {
+		let rows = JSON.parse(JSON.stringify(data));
+		if (props.exportChangeData) {
+			rows = props.exportChangeData(rows);
+		}
+		const { exportExcel } = await import('/@/utils/exportExcel');
+		await exportExcel(
+			rows,
 			`${props.config.exportFileName ? props.config.exportFileName : themeConfig.value.globalTitle}_${new Date().toLocaleString()}`,
 			`${props.config.exportFileName ? props.config.exportFileName : themeConfig.value.globalTitle}_${new Date().toLocaleString()}`,
 			setHeader.value.filter((item) => {
 			setHeader.value.filter((item) => {
 				return item.type != 'action';
 				return item.type != 'action';
 			}),
 			}),
 			'导出数据'
 			'导出数据'
-	);
-	state.exportLoading = false;
+		);
+	} catch (e) {
+		console.error(e);
+		ElMessage.error('导出失败');
+	} finally {
+		state.exportLoading = false;
+	}
 };
 };
 // 打印
 // 打印
 const onPrintTable = () => {
 const onPrintTable = () => {
@@ -441,13 +453,17 @@ const onRefreshTable = async () => {
 	Object.keys(param).forEach((key) => param[key] === undefined && delete param[key]);
 	Object.keys(param).forEach((key) => param[key] === undefined && delete param[key]);
 	const res = await props.getData(param);
 	const res = await props.getData(param);
 	state.loading = false;
 	state.loading = false;
-	if (res && res.result && res.result.items) {
+	const paged = readPagedList(res?.result);
+	if (paged) {
 		state.showPagination = true;
 		state.showPagination = true;
-		state.data = res.result?.items ?? [];
-		state.total = res.result?.total ?? 0;
+		state.data = paged.items;
+		state.total = paged.total;
+	} else if (Array.isArray(res?.result)) {
+		state.showPagination = false;
+		state.data = res.result;
 	} else {
 	} else {
 		state.showPagination = false;
 		state.showPagination = false;
-		state.data = res && res.result ? res.result : [];
+		state.data = [];
 	}
 	}
 };
 };
 
 

+ 0 - 1
Web/src/components/table/search.vue

@@ -6,7 +6,6 @@
                 <span v-for="(val, key) in search" :key="key" v-show="key < defaultShowCount || state.isToggle">
                 <span v-for="(val, key) in search" :key="key" v-show="key < defaultShowCount || state.isToggle">
                     <template v-if="val.type">
                     <template v-if="val.type">
                         <el-form-item
                         <el-form-item
-                                label-width="auto"
                                 :label="val.label"
                                 :label="val.label"
                                 :prop="val.prop"
                                 :prop="val.prop"
                                 :rules="[{ required: val.required, message: `${val.label}不能为空`, trigger: val.type === 'input' ? 'blur' : 'change' }]"
                                 :rules="[{ required: val.required, message: `${val.label}不能为空`, trigger: val.type === 'input' ? 'blur' : 'change' }]"

+ 53 - 3
Web/src/router/backEnd.ts

@@ -1,3 +1,4 @@
+import { nextTick } from 'vue';
 import { RouteRecordRaw } from 'vue-router';
 import { RouteRecordRaw } from 'vue-router';
 import pinia from '/@/stores/index';
 import pinia from '/@/stores/index';
 import { useUserInfo } from '/@/stores/userInfo';
 import { useUserInfo } from '/@/stores/userInfo';
@@ -11,7 +12,7 @@ import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
 
 
 import { getAPI } from '/@/utils/axios-utils';
 import { getAPI } from '/@/utils/axios-utils';
 import { SysMenuApi } from '/@/api-services/api';
 import { SysMenuApi } from '/@/api-services/api';
-import { patchAidopMenuTitles } from '/@/utils/aidopMenuDisplay';
+import { augmentAllowedPathsForAidopHomepage, patchAidopMenuTitles } from '/@/utils/aidopMenuDisplay';
 // import { ElMessage } from 'element-plus';
 // import { ElMessage } from 'element-plus';
 
 
 // 后端控制路由
 // 后端控制路由
@@ -42,11 +43,26 @@ function collectMenuPathsFromTree(menus: any[] | undefined, acc: Set<string>): v
 	}
 	}
 }
 }
 
 
+/** 后端 MenuTypeEnum:2=菜单(可对应页面路由),排除目录与按钮 */
+function pickFirstRoutableMenuPath(menus: any[] | undefined): string {
+	for (const m of menus || []) {
+		const t = Number(m?.type);
+		if (t === 2) {
+			const np = normalizeMenuPath(m?.path);
+			if (np) return np;
+		}
+		const sub = pickFirstRoutableMenuPath(m?.children);
+		if (sub) return sub;
+	}
+	return '';
+}
+
 /**
 /**
- * 个性化首页仅当落在当前用户菜单 path 中时才作为根重定向,否则回退默认,避免无路由匹配而 404。
+ * 个性化首页仅当落在当前用户菜单 path 中时才作为根重定向;
+ * 回退为「当前用户菜单树里第一个页面菜单」而非写死 /dashboard/home,避免受限角色 404。
  */
  */
 function resolveHomeRedirect(menuTree: any[]): string {
 function resolveHomeRedirect(menuTree: any[]): string {
-	const fallback = (dynamicRoutes[0].redirect as string) || DEFAULT_HOME_REDIRECT;
+	const fallback = pickFirstRoutableMenuPath(menuTree) || DEFAULT_HOME_REDIRECT;
 	let hpRaw: string | undefined;
 	let hpRaw: string | undefined;
 	try {
 	try {
 		hpRaw = Session.get('homepage') as string;
 		hpRaw = Session.get('homepage') as string;
@@ -57,12 +73,45 @@ function resolveHomeRedirect(menuTree: any[]): string {
 	if (!hp) return fallback;
 	if (!hp) return fallback;
 	const allowed = new Set<string>();
 	const allowed = new Set<string>();
 	collectMenuPathsFromTree(menuTree, allowed);
 	collectMenuPathsFromTree(menuTree, allowed);
+	augmentAllowedPathsForAidopHomepage(menuTree, allowed);
 	if (allowed.has(hp)) return hp;
 	if (allowed.has(hp)) return hp;
 	const noTrail = hp.replace(/\/$/, '') || '/';
 	const noTrail = hp.replace(/\/$/, '') || '/';
 	if (allowed.has(noTrail)) return noTrail;
 	if (allowed.has(noTrail)) return noTrail;
 	return fallback;
 	return fallback;
 }
 }
 
 
+/** addRoute 之后:若根 redirect 仍无法匹配(组件解析失败等),再回退到可解析路径 */
+async function clampRootRedirectAfterAddRoute(menuTree: any[]): Promise<void> {
+	await nextTick();
+	let target = normalizeMenuPath(dynamicRoutes[0].redirect as string) || DEFAULT_HOME_REDIRECT;
+	const trySet = (p: string) => {
+		const np = normalizeMenuPath(p);
+		if (!np) return false;
+		const { matched } = router.resolve(np);
+		if (!matched.length) return false;
+		dynamicRoutes[0].redirect = np;
+		return true;
+	};
+	if (trySet(target)) return;
+	const fromMenu = pickFirstRoutableMenuPath(menuTree);
+	if (fromMenu && trySet(fromMenu)) return;
+	const walk = (nodes: any[] | undefined): string => {
+		for (const n of nodes || []) {
+			if (n.component === false) continue;
+			const p = normalizeMenuPath(n.path);
+			if (p && p !== '/' && n.component) {
+				if (router.resolve(p).matched.length) return p;
+			}
+			const sub = walk(n.children);
+			if (sub) return sub;
+		}
+		return '';
+	};
+	const fromCfg = walk(dynamicRoutes[0].children as any[]);
+	if (fromCfg && trySet(fromCfg)) return;
+	trySet(DEFAULT_HOME_REDIRECT);
+}
+
 /**
 /**
  * 后端控制路由:初始化方法,防止刷新时路由丢失
  * 后端控制路由:初始化方法,防止刷新时路由丢失
  * @method NextLoading 界面 loading 动画开始执行
  * @method NextLoading 界面 loading 动画开始执行
@@ -95,6 +144,7 @@ export async function initBackEndControlRoutes() {
 	dynamicRoutes[0].redirect = resolveHomeRedirect(res as any[]);
 	dynamicRoutes[0].redirect = resolveHomeRedirect(res as any[]);
 	// 添加动态路由
 	// 添加动态路由
 	await setAddRoute();
 	await setAddRoute();
+	await clampRootRedirectAfterAddRoute(res as any[]);
 	// 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
 	// 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
 	setFilterMenuAndCacheTagsViewRoutes();
 	setFilterMenuAndCacheTagsViewRoutes();
 }
 }

+ 40 - 32
Web/src/router/index.ts

@@ -1,3 +1,5 @@
+// 须先于本包内对 meta.title 的 $t(...) 求值(生产分包时 router chunk 可能早于 main 里 lang 初始化)
+import '../../lang/index';
 import { createRouter, createWebHashHistory } from 'vue-router';
 import { createRouter, createWebHashHistory } from 'vue-router';
 import NProgress from 'nprogress';
 import NProgress from 'nprogress';
 import 'nprogress/nprogress.css';
 import 'nprogress/nprogress.css';
@@ -6,8 +8,8 @@ import { storeToRefs } from 'pinia';
 import { useKeepALiveNames } from '/@/stores/keepAliveNames';
 import { useKeepALiveNames } from '/@/stores/keepAliveNames';
 import { useRoutesList } from '/@/stores/routesList';
 import { useRoutesList } from '/@/stores/routesList';
 import { useThemeConfig } from '/@/stores/themeConfig';
 import { useThemeConfig } from '/@/stores/themeConfig';
-import {Local, Session} from '/@/utils/storage';
-import { staticRoutes, notFoundAndNoPower } from '/@/router/route';
+import { Session } from '/@/utils/storage';
+import { dynamicRoutes, staticRoutes, notFoundAndNoPower } from '/@/router/route';
 import { initFrontEndControlRoutes } from '/@/router/frontEnd';
 import { initFrontEndControlRoutes } from '/@/router/frontEnd';
 import { initBackEndControlRoutes } from '/@/router/backEnd';
 import { initBackEndControlRoutes } from '/@/router/backEnd';
 
 
@@ -92,42 +94,48 @@ export function formatTwoStageRoutes(arr: any) {
 	return newArr;
 	return newArr;
 }
 }
 
 
-// 路由加载前
-router.beforeEach(async (to, from, next) => {
+// 路由加载前(Vue Router 4.4+:用返回值代替 next(),避免弃用警告)
+router.beforeEach(async (to) => {
 	NProgress.configure({ showSpinner: false });
 	NProgress.configure({ showSpinner: false });
 	if (to.meta.title) NProgress.start();
 	if (to.meta.title) NProgress.start();
 	const token = Session.get('token');
 	const token = Session.get('token');
 	if (to.meta.isPublic && !token) {
 	if (to.meta.isPublic && !token) {
-		next();
-		NProgress.done();
-	} else {
-		if (!token) {
-			next(`/login?redirect=${to.path}&params=${JSON.stringify(to.query ? to.query : to.params)}`);
-			Session.clear();
-			NProgress.done();
-		} else if (token && to.path === '/login') {
-			next('/dashboard/home');
-			NProgress.done();
-		} else {
-			const storesRoutesList = useRoutesList(pinia);
-			const { routesList } = storeToRefs(storesRoutesList);
-			if (routesList.value.length === 0) {
-				if (isRequestRoutes) {
-					// 后端控制路由:路由数据初始化,防止刷新时丢失
-					await initBackEndControlRoutes();
-					// 解决刷新时,一直跳 404 页面问题,关联问题 No match found for location with path 'xxx'
-					// to.query 防止页面刷新时,普通路由带参数时,参数丢失。动态路由(xxx/:id/:name")isDynamic 无需处理
-					next({ path: to.path, query: to.query });
-				} else {
-					// https://gitee.com/lyt-top/vue-next-admin/issues/I5F1HP
-					await initFrontEndControlRoutes();
-					next({ path: to.path, query: to.query });
-				}
-			} else {
-				next();
-			}
+		return true;
+	}
+	if (!token) {
+		Session.clear();
+		return {
+			path: '/login',
+			query: {
+				redirect: to.path,
+				params: JSON.stringify(to.query ? to.query : to.params),
+			},
+		};
+	}
+	if (to.path === '/login') {
+		const storesRoutesList = useRoutesList(pinia);
+		const { routesList } = storeToRefs(storesRoutesList);
+		if (routesList.value.length === 0) {
+			if (isRequestRoutes) await initBackEndControlRoutes();
+			else await initFrontEndControlRoutes();
+		}
+		const home =
+			typeof dynamicRoutes[0]?.redirect === 'string' && dynamicRoutes[0].redirect
+				? dynamicRoutes[0].redirect
+				: '/dashboard/home';
+		return home;
+	}
+	const storesRoutesList = useRoutesList(pinia);
+	const { routesList } = storeToRefs(storesRoutesList);
+	if (routesList.value.length === 0) {
+		if (isRequestRoutes) {
+			await initBackEndControlRoutes();
+			return { path: to.path, query: to.query };
 		}
 		}
+		await initFrontEndControlRoutes();
+		return { path: to.path, query: to.query };
 	}
 	}
+	return true;
 });
 });
 
 
 // 路由加载后
 // 路由加载后

+ 6 - 0
Web/src/stores/userInfo.ts

@@ -1,5 +1,10 @@
 import { defineStore } from 'pinia';
 import { defineStore } from 'pinia';
 import { Local, Session } from '/@/utils/storage';
 import { Local, Session } from '/@/utils/storage';
+
+function syncHomepageSession(homepage: string | null | undefined) {
+	if (homepage == null || String(homepage).trim() === '') Session.remove('homepage');
+	else Session.set('homepage', String(homepage).trim());
+}
 import Watermark from '/@/utils/watermark';
 import Watermark from '/@/utils/watermark';
 import { useThemeConfig } from '/@/stores/themeConfig';
 import { useThemeConfig } from '/@/stores/themeConfig';
 
 
@@ -64,6 +69,7 @@ export const useUserInfo = defineStore('userInfo', {
 							return;
 							return;
 						}
 						}
 						var d = res.data.result;
 						var d = res.data.result;
+						syncHomepageSession(d.homepage);
 						const userInfos = {
 						const userInfos = {
 							id: d.id,
 							id: d.id,
 							account: d.account,
 							account: d.account,

+ 13 - 0
Web/src/utils/aidopMenuDisplay.ts

@@ -66,6 +66,19 @@ function upsertMenu(parent: AMenu, menu: AMenu): void {
 	}
 	}
 }
 }
 
 
+/**
+ * 用户已有 /aidop 根菜单时,patchAidopCustomMenusIfMissing 会补全智慧运营等子路由;
+ * 个性化首页校验须认可这些 path(避免库中子项 path 与绝对 path 不一致时误判回退)。
+ */
+export function augmentAllowedPathsForAidopHomepage(menuTree: any[] | undefined, allowed: Set<string>): void {
+	if (!menuTree?.length) return;
+	const aidopRoot = menuTree.find((x: AMenu) => x.path === '/aidop' || x.name === 'aidopRoot');
+	if (!aidopRoot) return;
+	for (const c of SMART_OPS_CHILDREN) allowed.add(c.path);
+	allowed.add('/aidop/smart-ops');
+	allowed.add('/aidop/smart-diagnosis');
+}
+
 /** 仅当接口返回的树里尚不存在对应 path 时补节点,避免与 sys_menu 重复;库已同步时等价于 no-op。 */
 /** 仅当接口返回的树里尚不存在对应 path 时补节点,避免与 sys_menu 重复;库已同步时等价于 no-op。 */
 function patchAidopCustomMenusIfMissing(routes: AMenu[] | undefined): void {
 function patchAidopCustomMenusIfMissing(routes: AMenu[] | undefined): void {
 	if (!routes?.length) return;
 	if (!routes?.length) return;

+ 2 - 2
Web/src/utils/exportExcel.ts

@@ -1,4 +1,3 @@
-import XLSXS from 'xlsx-js-style';
 /**
 /**
 * @description:
 * @description:
 * @param {Object} json 服务端发过来的数据
 * @param {Object} json 服务端发过来的数据
@@ -9,7 +8,8 @@ import XLSXS from 'xlsx-js-style';
 * @param {String} sheetName 导出sheetName名字
 * @param {String} sheetName 导出sheetName名字
 * @return:
 * @return:
 **/
 **/
-export function exportExcel(jsonarr: Array<EmptyObjectType>, name: string, header: Array<EmptyObjectType>, sheetName: string) {
+export async function exportExcel(jsonarr: Array<EmptyObjectType>, name: string, header: Array<EmptyObjectType>, sheetName: string) {
+	const XLSXS = (await import('xlsx-js-style')).default;
 	var data = new Array();
 	var data = new Array();
 	var wpxArr = new Array(); //列宽度
 	var wpxArr = new Array(); //列宽度
 	const borderStyle = {
 	const borderStyle = {

+ 14 - 6
Web/src/views/login/component/account.vue

@@ -78,6 +78,7 @@ import { reactive, computed, ref, onMounted, defineAsyncComponent, onUnmounted,
 import { useRoute, useRouter } from 'vue-router';
 import { useRoute, useRouter } from 'vue-router';
 import { ElMessage, InputInstance } from 'element-plus';
 import { ElMessage, InputInstance } from 'element-plus';
 import { initBackEndControlRoutes } from '/@/router/backEnd';
 import { initBackEndControlRoutes } from '/@/router/backEnd';
+import { dynamicRoutes } from '/@/router/route';
 import { Local, Session } from '/@/utils/storage';
 import { Local, Session } from '/@/utils/storage';
 import { formatAxis } from '/@/utils/formatTime';
 import { formatAxis } from '/@/utils/formatTime';
 import { NextLoading } from '/@/utils/loading';
 import { NextLoading } from '/@/utils/loading';
@@ -250,8 +251,10 @@ const onSignIn = async () => {
 				ElMessage.error('登录失败,请检查账号!');
 				ElMessage.error('登录失败,请检查账号!');
 				return;
 				return;
 			}
 			}
-			// 记录用户自定义首页设置
-			Session.set('homepage', res.data.result?.homepage);
+			// 记录用户自定义首页(避免 undefined 写入 session 导致 JSON 异常)
+			const hp = res.data.result?.homepage;
+			if (hp != null && String(hp).trim() !== '') Session.set('homepage', String(hp).trim());
+			else Session.remove('homepage');
 			await saveTokenAndInitRoutes(res.data.result?.accessToken);
 			await saveTokenAndInitRoutes(res.data.result?.accessToken);
 		} finally {
 		} finally {
 			state.loading.signIn = false;
 			state.loading.signIn = false;
@@ -270,11 +273,11 @@ const saveTokenAndInitRoutes = async (accessToken: string | any) => {
 
 
 	// 添加完动态路由再进行router跳转,否则可能报错 No match found for location with path "/"
 	// 添加完动态路由再进行router跳转,否则可能报错 No match found for location with path "/"
 	const isNoPower = await initBackEndControlRoutes();
 	const isNoPower = await initBackEndControlRoutes();
-	signInSuccess(isNoPower); // 再执行 signInSuccess
+	await signInSuccess(isNoPower); // 再执行 signInSuccess
 };
 };
 
 
 // 登录成功后的跳转
 // 登录成功后的跳转
-const signInSuccess = (isNoPower: boolean | undefined) => {
+const signInSuccess = async (isNoPower: boolean | undefined) => {
 	if (isNoPower) {
 	if (isNoPower) {
 		ElMessage.warning('抱歉,您没有登录权限');
 		ElMessage.warning('抱歉,您没有登录权限');
 		clearTokens(); // 清空Token缓存
 		clearTokens(); // 清空Token缓存
@@ -283,12 +286,17 @@ const signInSuccess = (isNoPower: boolean | undefined) => {
 		let currentTimeInfo = currentTime.value;
 		let currentTimeInfo = currentTime.value;
 		// 登录成功,跳到转首页 如果是复制粘贴的路径,非首页/登录页,那么登录成功后重定向到对应的路径中
 		// 登录成功,跳到转首页 如果是复制粘贴的路径,非首页/登录页,那么登录成功后重定向到对应的路径中
 		if (route.query?.redirect) {
 		if (route.query?.redirect) {
-			router.push({
+			await router.replace({
 				path: <string>route.query?.redirect,
 				path: <string>route.query?.redirect,
 				query: Object.keys(<string>route.query?.params).length > 0 ? JSON.parse(<string>route.query?.params) : '',
 				query: Object.keys(<string>route.query?.params).length > 0 ? JSON.parse(<string>route.query?.params) : '',
 			});
 			});
 		} else {
 		} else {
-			router.push('/');
+			// replace + 与 init 内 clamp 后的 redirect 一致,避免 addRoute 后 push 偶发未匹配到而 404
+			const home =
+				typeof dynamicRoutes[0]?.redirect === 'string' && dynamicRoutes[0].redirect
+					? dynamicRoutes[0].redirect
+					: '/dashboard/home';
+			await router.replace(home);
 		}
 		}
 
 
 		// 登录成功提示
 		// 登录成功提示

+ 2 - 2
Web/src/views/system/codeGen/component/editCodeGenDialog.vue

@@ -142,12 +142,12 @@
 					<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="unique-box">
 					<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="unique-box">
 						<template v-if="state.ruleForm.tableUniqueList != undefined && state.ruleForm.tableUniqueList.length > 0">
 						<template v-if="state.ruleForm.tableUniqueList != undefined && state.ruleForm.tableUniqueList.length > 0">
                             <div v-for="(v, k) in state.ruleForm.tableUniqueList" :key="k" class="unique-line">
                             <div v-for="(v, k) in state.ruleForm.tableUniqueList" :key="k" class="unique-line">
-                                <el-form-item label="字段" label-width="55" :prop="`tableUniqueList[${k}].columns`" :rules="[{ required: true, message: `字段不能为空`, trigger: 'blur' }]">
+                                <el-form-item label="字段" label-width="55px" :prop="`tableUniqueList[${k}].columns`" :rules="[{ required: true, message: `字段不能为空`, trigger: 'blur' }]">
 									<el-select v-model="state.ruleForm.tableUniqueList[k].columns" @change="(val: any) => changeTableUniqueColumn(val, k)" multiple filterable clearable collapse-tags collapse-tags-tooltip class="w100">
 									<el-select v-model="state.ruleForm.tableUniqueList[k].columns" @change="(val: any) => changeTableUniqueColumn(val, k)" multiple filterable clearable collapse-tags collapse-tags-tooltip class="w100">
 										<el-option v-for="item in state.columnData" :key="item.propertyName" :label="item.propertyName + ' [' + item.columnComment + ']'" :value="item.propertyName" />
 										<el-option v-for="item in state.columnData" :key="item.propertyName" :label="item.propertyName + ' [' + item.columnComment + ']'" :value="item.propertyName" />
 									</el-select>
 									</el-select>
 								</el-form-item>
 								</el-form-item>
-                                <el-form-item label="描述" label-width="55" :prop="`tableUniqueList[${k}].message`" :rules="[{ required: true, message: `描述信息不能为空`, trigger: 'blur' }]">
+                                <el-form-item label="描述" label-width="55px" :prop="`tableUniqueList[${k}].message`" :rules="[{ required: true, message: `描述信息不能为空`, trigger: 'blur' }]">
 									<el-input v-model="state.ruleForm.tableUniqueList[k].message" clearable placeholder="请输入" />
 									<el-input v-model="state.ruleForm.tableUniqueList[k].message" clearable placeholder="请输入" />
 								</el-form-item>
 								</el-form-item>
                                 <div class="delete-btn">
                                 <div class="delete-btn">

+ 1 - 1
Web/src/views/system/config/component/editConfig.vue

@@ -7,7 +7,7 @@
 					<span> {{ props.title }} </span>
 					<span> {{ props.title }} </span>
 				</div>
 				</div>
 			</template>
 			</template>
-			<el-form :model="state.ruleForm" ref="ruleFormRef" label-width="auto">
+			<el-form :model="state.ruleForm" ref="ruleFormRef" label-width="100px">
 				<el-row :gutter="35">
 				<el-row :gutter="35">
 					<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
 					<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
 						<el-form-item label="配置名称" prop="name" :rules="[{ required: true, message: '配置名称不能为空', trigger: 'blur' }]">
 						<el-form-item label="配置名称" prop="name" :rules="[{ required: true, message: '配置名称不能为空', trigger: 'blur' }]">

+ 1 - 1
Web/src/views/system/lang/index.vue

@@ -81,7 +81,7 @@ handleQuery();
 <template>
 <template>
   <div class="sysLang-container" v-loading="state.exportLoading">
   <div class="sysLang-container" v-loading="state.exportLoading">
     <el-card shadow="hover" :body-style="{ paddingBottom: '0' }"> 
     <el-card shadow="hover" :body-style="{ paddingBottom: '0' }"> 
-      <el-form :model="state.tableQueryParams" ref="queryForm" labelWidth="90">
+      <el-form :model="state.tableQueryParams" ref="queryForm" label-width="90px">
         <el-row>
         <el-row>
           <el-col :xs="24" :sm="12" :md="12" :lg="8" :xl="4" class="mb10">
           <el-col :xs="24" :sm="12" :md="12" :lg="8" :xl="4" class="mb10">
             <el-form-item label="关键字">
             <el-form-item label="关键字">

+ 1 - 1
Web/src/views/system/langText/index.vue

@@ -104,7 +104,7 @@ handleQuery();
 <template>
 <template>
   <div class="sysLangText-container" v-loading="state.exportLoading">
   <div class="sysLangText-container" v-loading="state.exportLoading">
     <el-card shadow="hover" :body-style="{ paddingBottom: '0' }"> 
     <el-card shadow="hover" :body-style="{ paddingBottom: '0' }"> 
-      <el-form :model="state.tableQueryParams" ref="queryForm" labelWidth="90">
+      <el-form :model="state.tableQueryParams" ref="queryForm" label-width="90px">
         <el-row>
         <el-row>
           <el-col :xs="24" :sm="12" :md="12" :lg="8" :xl="4" class="mb10">
           <el-col :xs="24" :sm="12" :md="12" :lg="8" :xl="4" class="mb10">
             <el-form-item label="关键字">
             <el-form-item label="关键字">

+ 1 - 1
Web/src/views/system/template/component/editTemplate.vue

@@ -80,7 +80,7 @@
 								</el-row>
 								</el-row>
 							</el-col>
 							</el-col>
 							<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
 							<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
-								<el-form-item label="预览结果:" label-width="85" />
+								<el-form-item label="预览结果:" label-width="85px" />
 							</el-col>
 							</el-col>
 							<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
 							<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
 								<span v-html="state.result"></span>
 								<span v-html="state.result"></span>

+ 1 - 1
Web/src/views/system/tenantConfig/component/editConfig.vue

@@ -7,7 +7,7 @@
 					<span> {{ props.title }} </span>
 					<span> {{ props.title }} </span>
 				</div>
 				</div>
 			</template>
 			</template>
-			<el-form :model="state.ruleForm" ref="ruleFormRef" label-width="auto">
+			<el-form :model="state.ruleForm" ref="ruleFormRef" label-width="100px">
 				<el-row :gutter="35">
 				<el-row :gutter="35">
 					<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
 					<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">
 						<el-form-item label="配置名称" prop="name" :rules="[{ required: true, message: '配置名称不能为空', trigger: 'blur' }]">
 						<el-form-item label="配置名称" prop="name" :rules="[{ required: true, message: '配置名称不能为空', trigger: 'blur' }]">

+ 1 - 1
Web/src/views/system/tenantConfig/index.vue

@@ -70,7 +70,7 @@ const tb = reactive<TableDemoState>({
 			showSelection: auth('sysTenantConfig:batchDelete'), //是否显示表格多选
 			showSelection: auth('sysTenantConfig:batchDelete'), //是否显示表格多选
 			pageSize: 50, // 每页条数
 			pageSize: 50, // 每页条数
 			hideExport: false, //是否隐藏导出按钮
 			hideExport: false, //是否隐藏导出按钮
-			exportFileName: '系统参数', //导出报表的文件名,不填写取应用名称
+			exportFileName: '租户参数', //导出报表的文件名,不填写取应用名称
 		},
 		},
 		// 搜索表单,动态生成(传空数组时,将不显示搜索,type有3种类型:input,date,select)
 		// 搜索表单,动态生成(传空数组时,将不显示搜索,type有3种类型:input,date,select)
 		search: [
 		search: [

+ 2 - 2
Web/src/views/system/user/component/editUser.vue

@@ -108,7 +108,7 @@
 							<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="unique-box">
 							<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="unique-box">
 								<template v-if="state.ruleForm.extOrgIdList != undefined && state.ruleForm.extOrgIdList.length > 0">
 								<template v-if="state.ruleForm.extOrgIdList != undefined && state.ruleForm.extOrgIdList.length > 0">
                                     <div v-for="(v, k) in state.ruleForm.extOrgIdList" :key="k" class="unique-line">
                                     <div v-for="(v, k) in state.ruleForm.extOrgIdList" :key="k" class="unique-line">
-                                        <el-form-item label="机构" label-width="55" :prop="`extOrgIdList[${k}].orgId`" :rules="[{ required: true, message: `机构不能为空`, trigger: 'blur' }]">
+                                        <el-form-item label="机构" label-width="55px" :prop="`extOrgIdList[${k}].orgId`" :rules="[{ required: true, message: `机构不能为空`, trigger: 'blur' }]">
 											<el-cascader :options="props.orgTreeData" :props="cascaderProps" placeholder="机构组织" clearable filterable class="w100" v-model="state.ruleForm.extOrgIdList[k].orgId">
 											<el-cascader :options="props.orgTreeData" :props="cascaderProps" placeholder="机构组织" clearable filterable class="w100" v-model="state.ruleForm.extOrgIdList[k].orgId">
 												<template #default="{ node, data }">
 												<template #default="{ node, data }">
 													<span>{{ data.name }}</span>
 													<span>{{ data.name }}</span>
@@ -116,7 +116,7 @@
 												</template>
 												</template>
 											</el-cascader>
 											</el-cascader>
 										</el-form-item>
 										</el-form-item>
-                                        <el-form-item label="职位" label-width="55" :prop="`extOrgIdList[${k}].posId`" :rules="[{ required: true, message: `职位不能为空`, trigger: 'blur' }]">
+                                        <el-form-item label="职位" label-width="55px" :prop="`extOrgIdList[${k}].posId`" :rules="[{ required: true, message: `职位不能为空`, trigger: 'blur' }]">
 											<el-select v-model="state.ruleForm.extOrgIdList[k].posId" placeholder="职位名称" class="w100">
 											<el-select v-model="state.ruleForm.extOrgIdList[k].posId" placeholder="职位名称" class="w100">
 												<el-option v-for="d in state.posData" :key="d.id" :label="d.name" :value="d.id" />
 												<el-option v-for="d in state.posData" :key="d.id" :label="d.name" :value="d.id" />
 											</el-select>
 											</el-select>

+ 4 - 1
Web/src/views/system/user/component/userCenter.vue

@@ -190,7 +190,7 @@ import { accessTokenKey, clearAccessAfterReload, getAPI } from '/@/utils/axios-u
 import { SysAuthApi, SysFileApi, SysUserApi } from '/@/api-services/api';
 import { SysAuthApi, SysFileApi, SysUserApi } from '/@/api-services/api';
 import { ChangePwdInput, SysUser, SysFile } from '/@/api-services/models';
 import { ChangePwdInput, SysUser, SysFile } from '/@/api-services/models';
 import { useLangStore } from '/@/stores/useLangStore';
 import { useLangStore } from '/@/stores/useLangStore';
-import { Local } from '/@/utils/storage';
+import { Local, Session } from '/@/utils/storage';
 const langStore = useLangStore();
 const langStore = useLangStore();
 const stores = useUserInfo();
 const stores = useUserInfo();
 const { userInfos } = storeToRefs(stores);
 const { userInfos } = storeToRefs(stores);
@@ -294,6 +294,9 @@ const submitUserBase = () => {
 			type: 'warning',
 			type: 'warning',
 		}).then(async () => {
 		}).then(async () => {
 			await getAPI(SysUserApi).apiSysUserBaseInfoPost(state.ruleFormBase);
 			await getAPI(SysUserApi).apiSysUserBaseInfoPost(state.ruleFormBase);
+			const hp = state.ruleFormBase.homepage;
+			if (hp == null || String(hp).trim() === '') Session.remove('homepage');
+			else Session.set('homepage', String(hp).trim());
 			const accessToken = Local.get(accessTokenKey);
 			const accessToken = Local.get(accessTokenKey);
 			await getAPI(SysAuthApi).apiSysAuthRefreshTokenGet(`${accessToken}`);
 			await getAPI(SysAuthApi).apiSysAuthRefreshTokenGet(`${accessToken}`);
 			window.location.reload();
 			window.location.reload();

+ 7 - 0
scripts/remove-seed-demo02-user.sql

@@ -0,0 +1,7 @@
+-- 已按 aidopdev(SqlSugar 默认表名)执行:删除种子用户 Id=1300000000118。
+-- 表名:SysUser / SysUserRole(EnableUnderLine=false 时)
+
+START TRANSACTION;
+DELETE FROM SysUserRole WHERE UserId = 1300000000118;
+DELETE FROM SysUser WHERE Id = 1300000000118 AND Account = 'Demo02';
+COMMIT;

+ 32 - 2
server/Admin.NET.Core/SeedData/SysRoleMenuSeedData.cs

@@ -26,11 +26,41 @@ public class SysRoleMenuSeedData : ISqlSugarEntitySeedData<SysRoleMenu>
         // 第一个角色拥有全部默认租户菜单
         // 第一个角色拥有全部默认租户菜单
         roleMenuList.AddRange(defaultMenuList.Select(u => new SysRoleMenu { Id = u.MenuId + (roleList[0].Id % 1300000000000), RoleId = roleList[0].Id, MenuId = u.MenuId }));
         roleMenuList.AddRange(defaultMenuList.Select(u => new SysRoleMenu { Id = u.MenuId + (roleList[0].Id % 1300000000000), RoleId = roleList[0].Id, MenuId = u.MenuId }));
 
 
-        // 其他角色权限:工作台、系统管理、个人中心、帮助文档、关于项目
+        // 其他角色权限:工作台、帮助文档、关于项目、个人中心(整棵子树)
+        // 系统管理:仅挂「机构管理」子树(与租户基线一致),避免仅有空目录导致侧栏有「系统管理」却无子路由、访问未授权路径时应用内 404
         var otherRoleMenuList = menuList.ToChildList(u => u.Id, u => u.Pid, u => new[] { "工作台", "帮助文档", "关于项目", "个人中心" }.Contains(u.Title)).ToList();
         var otherRoleMenuList = menuList.ToChildList(u => u.Id, u => u.Pid, u => new[] { "工作台", "帮助文档", "关于项目", "个人中心" }.Contains(u.Title)).ToList();
-        otherRoleMenuList.Add(menuList.First(u => u.Type == MenuTypeEnum.Dir && u.Title == "系统管理"));
+        var systemDir = menuList.FirstOrDefault(u => u.Type == MenuTypeEnum.Dir && u.Title == "系统管理");
+        if (systemDir != null)
+        {
+            if (!otherRoleMenuList.Any(u => u.Id == systemDir.Id))
+                otherRoleMenuList.Add(systemDir);
+            otherRoleMenuList.AddRange(menuList.ToChildList(u => u.Id, u => u.Pid, u => u.Pid == systemDir.Id && u.Title == "机构管理"));
+        }
         foreach (var role in roleList.Skip(1)) roleMenuList.AddRange(otherRoleMenuList.Select(u => new SysRoleMenu { Id = u.Id + (role.Id % 1300000000000), RoleId = role.Id, MenuId = u.Id }));
         foreach (var role in roleList.Skip(1)) roleMenuList.AddRange(otherRoleMenuList.Select(u => new SysRoleMenu { Id = u.Id + (role.Id % 1300000000000), RoleId = role.Id, MenuId = u.Id }));
 
 
+        // 演示一般账户:在「其他角色」基线之上追加 Ai-DOP 全子树(智慧运营首页等)
+        var demoGeneralRole = roleList.FirstOrDefault(r => r.Code == "demo_general");
+        if (demoGeneralRole != null)
+        {
+            var allFlat = App.GetService<SysTenantService>().GetAllSeedMenusFlat();
+            var aidopRoot = allFlat.FirstOrDefault(u =>
+                u.Name == "aidopRoot" || string.Equals(u.Path?.Trim(), "/aidop", StringComparison.Ordinal));
+            if (aidopRoot != null)
+            {
+                var subtree = new List<SysMenu> { aidopRoot };
+                subtree.AddRange(allFlat.ToChildList(u => u.Id, u => u.Pid, aidopRoot.Id));
+                foreach (var m in subtree)
+                {
+                    roleMenuList.Add(new SysRoleMenu
+                    {
+                        Id = m.Id + (demoGeneralRole.Id % 1300000000000),
+                        RoleId = demoGeneralRole.Id,
+                        MenuId = m.Id
+                    });
+                }
+            }
+        }
+
         return roleMenuList;
         return roleMenuList;
     }
     }
 }
 }

+ 2 - 0
server/Admin.NET.Core/SeedData/SysRoleSeedData.cs

@@ -24,6 +24,8 @@ public class SysRoleSeedData : ISqlSugarEntitySeedData<SysRole>
             new SysRole{ Id=1300000000103, Name="本部门数据", DataScope=DataScopeEnum.Dept, Code="sys_dept", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), Remark="本部门数据", TenantId=SqlSugarConst.DefaultTenantId },
             new SysRole{ Id=1300000000103, Name="本部门数据", DataScope=DataScopeEnum.Dept, Code="sys_dept", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), Remark="本部门数据", TenantId=SqlSugarConst.DefaultTenantId },
             new SysRole{ Id=1300000000104, Name="仅本人数据", DataScope=DataScopeEnum.Self, Code="sys_self", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), Remark="仅本人数据", TenantId=SqlSugarConst.DefaultTenantId },
             new SysRole{ Id=1300000000104, Name="仅本人数据", DataScope=DataScopeEnum.Self, Code="sys_self", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), Remark="仅本人数据", TenantId=SqlSugarConst.DefaultTenantId },
             new SysRole{ Id=1300000000105, Name="自定义数据", DataScope=DataScopeEnum.Define, Code="sys_define", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), Remark="自定义数据", TenantId=SqlSugarConst.DefaultTenantId },
             new SysRole{ Id=1300000000105, Name="自定义数据", DataScope=DataScopeEnum.Define, Code="sys_define", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), Remark="自定义数据", TenantId=SqlSugarConst.DefaultTenantId },
+            // 演示账号专用:非系统管理员菜单范围,见 SysRoleMenuSeedData
+            new SysRole{ Id=1300000000106, Name="演示一般账户", DataScope=DataScopeEnum.Self, Code="demo_general", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), Remark="种子用户 Demo01 默认角色:工作台/帮助/关于/个人中心/机构管理 + Ai-DOP", TenantId=SqlSugarConst.DefaultTenantId },
         };
         };
     }
     }
 }
 }

+ 1 - 1
server/Admin.NET.Core/SeedData/SysUserRoleSeedData.cs

@@ -25,7 +25,7 @@ public class SysUserRoleSeedData : ISqlSugarEntitySeedData<SysUserRole>
             new SysUserRole{ Id=1300000000102, UserId=userList.First(u => u.Account == "TestUser2").Id, RoleId=roleList.First(u => u.Code == "sys_dept").Id },
             new SysUserRole{ Id=1300000000102, UserId=userList.First(u => u.Account == "TestUser2").Id, RoleId=roleList.First(u => u.Code == "sys_dept").Id },
             new SysUserRole{ Id=1300000000103, UserId=userList.First(u => u.Account == "TestUser3").Id, RoleId=roleList.First(u => u.Code == "sys_self").Id },
             new SysUserRole{ Id=1300000000103, UserId=userList.First(u => u.Account == "TestUser3").Id, RoleId=roleList.First(u => u.Code == "sys_self").Id },
             new SysUserRole{ Id=1300000000104, UserId=userList.First(u => u.Account == "TestUser4").Id, RoleId=roleList.First(u => u.Code == "sys_define").Id },
             new SysUserRole{ Id=1300000000104, UserId=userList.First(u => u.Account == "TestUser4").Id, RoleId=roleList.First(u => u.Code == "sys_define").Id },
-            new SysUserRole{ Id=1300000000120, UserId=userList.First(u => u.Account == "Demo01").Id, RoleId=roleList.First(u => u.Code == "sys_admin").Id },
+            new SysUserRole{ Id=1300000000120, UserId=userList.First(u => u.Account == "Demo01").Id, RoleId=roleList.First(u => u.Code == "demo_general").Id },
         };
         };
     }
     }
 }
 }

+ 2 - 1
server/Admin.NET.Core/SeedData/SysUserSeedData.cs

@@ -29,7 +29,8 @@ public class SysUserSeedData : ISqlSugarEntitySeedData<SysUser>
             new SysUser{ Id=1300000000113, Account="TestUser2", Password=encryptPassword, NickName="部门职员", RealName="部门职员", Phone="18012345675", Birthday=DateTime.Parse("2000-01-01"), Sex=GenderEnum.Female, AccountType=AccountTypeEnum.NormalUser, Remark="部门职员", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrgId=SqlSugarConst.DefaultTenantId + 2, PosId=posList[2].Id, TenantId=SqlSugarConst.DefaultTenantId },
             new SysUser{ Id=1300000000113, Account="TestUser2", Password=encryptPassword, NickName="部门职员", RealName="部门职员", Phone="18012345675", Birthday=DateTime.Parse("2000-01-01"), Sex=GenderEnum.Female, AccountType=AccountTypeEnum.NormalUser, Remark="部门职员", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrgId=SqlSugarConst.DefaultTenantId + 2, PosId=posList[2].Id, TenantId=SqlSugarConst.DefaultTenantId },
             new SysUser{ Id=1300000000114, Account="TestUser3", Password=encryptPassword, NickName="普通用户", RealName="普通用户", Phone="18012345674", Birthday=DateTime.Parse("2000-01-01"), Sex=GenderEnum.Female, AccountType=AccountTypeEnum.NormalUser, Remark="普通用户", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrgId=SqlSugarConst.DefaultTenantId + 3, PosId=posList[3].Id, TenantId=SqlSugarConst.DefaultTenantId },
             new SysUser{ Id=1300000000114, Account="TestUser3", Password=encryptPassword, NickName="普通用户", RealName="普通用户", Phone="18012345674", Birthday=DateTime.Parse("2000-01-01"), Sex=GenderEnum.Female, AccountType=AccountTypeEnum.NormalUser, Remark="普通用户", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrgId=SqlSugarConst.DefaultTenantId + 3, PosId=posList[3].Id, TenantId=SqlSugarConst.DefaultTenantId },
             new SysUser{ Id=1300000000115, Account="TestUser4", Password=encryptPassword, NickName="其他", RealName="其他", Phone="18012345673", Birthday=DateTime.Parse("2000-01-01"), Sex=GenderEnum.Female, AccountType=AccountTypeEnum.Member, Remark="会员", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrgId=SqlSugarConst.DefaultTenantId + 4, PosId=posList[4].Id, TenantId=SqlSugarConst.DefaultTenantId },
             new SysUser{ Id=1300000000115, Account="TestUser4", Password=encryptPassword, NickName="其他", RealName="其他", Phone="18012345673", Birthday=DateTime.Parse("2000-01-01"), Sex=GenderEnum.Female, AccountType=AccountTypeEnum.Member, Remark="会员", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrgId=SqlSugarConst.DefaultTenantId + 4, PosId=posList[4].Id, TenantId=SqlSugarConst.DefaultTenantId },
-            new SysUser{ Id=1300000000117, Account="Demo01", Password=demoEncryptPassword, NickName="演示账号", RealName="演示账号", Phone="18012345671", Birthday=DateTime.Parse("2000-01-01"), Sex=GenderEnum.Male, AccountType=AccountTypeEnum.NormalUser, Remark="Ai-DOP 登录页默认演示账号", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrgId=SqlSugarConst.DefaultTenantId, PosId=posList[0].Id, TenantId=SqlSugarConst.DefaultTenantId },
+            new SysUser{ Id=1300000000117, Account="Demo01", Password=demoEncryptPassword, NickName="演示账号", RealName="演示账号", Phone="18012345671", Birthday=DateTime.Parse("2000-01-01"), Sex=GenderEnum.Male, AccountType=AccountTypeEnum.NormalUser, Remark="Ai-DOP 登录页默认演示账号;默认角色「演示一般账户」", CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrgId=SqlSugarConst.DefaultTenantId, PosId=posList[0].Id, TenantId=SqlSugarConst.DefaultTenantId },
+            // 不再种子化 Demo02:与租户自建「Demo02」账号冲突;见 scripts/remove-seed-demo02-user.sql
         };
         };
     }
     }
 }
 }

+ 5 - 0
server/Admin.NET.Core/Service/Auth/Dto/LoginUserOutput.cs

@@ -115,4 +115,9 @@ public class LoginUserOutput
     /// 语言代码
     /// 语言代码
     /// </summary>
     /// </summary>
     public string LangCode { get; internal set; }
     public string LangCode { get; internal set; }
+
+    /// <summary>
+    /// 个性化首页(与菜单 path 一致,如 /aidop/smart-ops/grid)
+    /// </summary>
+    public string? Homepage { get; set; }
 }
 }

+ 1 - 0
server/Admin.NET.Core/Service/Auth/SysAuthService.cs

@@ -330,6 +330,7 @@ public class SysAuthService : IDynamicApiController, ITransient
             TenantId = user.TenantId,
             TenantId = user.TenantId,
             WatermarkText = watermarkText,
             WatermarkText = watermarkText,
             LangCode = user.LangCode,
             LangCode = user.LangCode,
+            Homepage = user.Homepage,
         };
         };
 
 
         //将登录信息中的当前租户id,更新为当前所切换到的租户
         //将登录信息中的当前租户id,更新为当前所切换到的租户

+ 25 - 0
server/Admin.NET.Core/Service/Menu/SysMenuService.cs

@@ -61,6 +61,8 @@ public class SysMenuService : IDynamicApiController, ITransient
         if (!(_userManager.SuperAdmin || _userManager.SysAdmin))
         if (!(_userManager.SuperAdmin || _userManager.SysAdmin))
         {
         {
             var menuIdList = await GetMenuIdList();
             var menuIdList = await GetMenuIdList();
+            // 角色表可能只存了叶子或未含完整父链,ToTree 会丢弃孤儿节点 → 前端动态路由缺失 → 登录后 404
+            menuIdList = await ExpandMenuIdsWithAncestorsAsync(menuIdList);
             menuQuery = menuQuery.Where(u => menuIdList.Contains(u.Id));
             menuQuery = menuQuery.Where(u => menuIdList.Contains(u.Id));
         }
         }
 
 
@@ -433,6 +435,29 @@ public class SysMenuService : IDynamicApiController, ITransient
     }
     }
 
 
     /// <summary>
     /// <summary>
+    /// 将菜单 Id 集合补全为含所有祖先 Id,保证登录菜单树可挂载(与 GrantRoleMenu 追加父级逻辑一致,兼容历史/手工改库数据)。
+    /// </summary>
+    [NonAction]
+    public async Task<List<long>> ExpandMenuIdsWithAncestorsAsync(List<long> menuIds)
+    {
+        if (menuIds == null || menuIds.Count == 0) return menuIds ?? new List<long>();
+        var rows = await _sysMenuRep.AsQueryable().Select(u => new { u.Id, u.Pid }).ToListAsync();
+        var map = rows.ToDictionary(x => x.Id, x => x.Pid);
+        var set = new HashSet<long>(menuIds);
+        foreach (var start in menuIds)
+        {
+            var cur = start;
+            while (map.TryGetValue(cur, out var pid) && pid > 0)
+            {
+                if (!set.Add(pid))
+                    break;
+                cur = pid;
+            }
+        }
+        return set.ToList();
+    }
+
+    /// <summary>
     /// 排除前端存在全选的父级菜单
     /// 排除前端存在全选的父级菜单
     /// </summary>
     /// </summary>
     /// <returns></returns>
     /// <returns></returns>

+ 18 - 11
server/Admin.NET.Core/Service/Tenant/SysTenantService.cs

@@ -308,29 +308,36 @@ public class SysTenantService : IDynamicApiController, ITransient
     }
     }
 
 
     /// <summary>
     /// <summary>
-    /// 获取租户默认菜单
+    /// 聚合所有程序集内 SysMenu 种子(扁平列表,供角色授权、子树裁剪等复用)
     /// </summary>
     /// </summary>
-    /// <param name="ignoreHome">如果某租户需要定制主页,可以忽略</param>
-    /// <returns></returns>
     [NonAction]
     [NonAction]
-    public IEnumerable<SysTenantMenu> GetTenantDefaultMenuList(bool ignoreHome = false)
+    public List<SysMenu> GetAllSeedMenusFlat()
     {
     {
-        var menuList = new List<SysMenu>();
-
-        // 默认数据库配置
         var defaultConfig = App.GetOptions<DbConnectionOptions>().ConnectionConfigs.FirstOrDefault();
         var defaultConfig = App.GetOptions<DbConnectionOptions>().ConnectionConfigs.FirstOrDefault();
-        if (defaultConfig == null) return Enumerable.Empty<SysTenantMenu>();
-        //从程序集中获取种子菜单数据,种子菜单存在于其他类库中,需要动态加载
+        if (defaultConfig == null) return new List<SysMenu>();
         var menuSeedDataTypeList = GetSeedDataTypes(defaultConfig, nameof(SysMenuSeedData));
         var menuSeedDataTypeList = GetSeedDataTypes(defaultConfig, nameof(SysMenuSeedData));
         var allMenuList = new List<SysMenu>();
         var allMenuList = new List<SysMenu>();
         foreach (var menu in menuSeedDataTypeList)
         foreach (var menu in menuSeedDataTypeList)
         {
         {
             var menuSeedDataList = ((IEnumerable)menu.GetMethod("HasData")?.Invoke(Activator.CreateInstance(menu), null))?.Cast<SysMenu>();
             var menuSeedDataList = ((IEnumerable)menu.GetMethod("HasData")?.Invoke(Activator.CreateInstance(menu), null))?.Cast<SysMenu>();
             if (menuSeedDataList != null)
             if (menuSeedDataList != null)
-            {
                 allMenuList.AddRange(menuSeedDataList);
                 allMenuList.AddRange(menuSeedDataList);
-            }
         }
         }
+        return allMenuList.DistinctBy(u => u.Id).ToList();
+    }
+
+    /// <summary>
+    /// 获取租户默认菜单
+    /// </summary>
+    /// <param name="ignoreHome">如果某租户需要定制主页,可以忽略</param>
+    /// <returns></returns>
+    [NonAction]
+    public IEnumerable<SysTenantMenu> GetTenantDefaultMenuList(bool ignoreHome = false)
+    {
+        var menuList = new List<SysMenu>();
+
+        var allMenuList = GetAllSeedMenusFlat();
+        if (allMenuList.Count == 0) return Enumerable.Empty<SysTenantMenu>();
 
 
         //实现三个层级的菜单
         //实现三个层级的菜单
         var topMenuList = allMenuList.Where(u => u.Pid == 0 && u.Type == MenuTypeEnum.Dir).ToList();
         var topMenuList = allMenuList.Where(u => u.Pid == 0 && u.Type == MenuTypeEnum.Dir).ToList();