Selaa lähdekoodia

fix(aidop): 登录与路由种子修复,智慧运营菜单同步与首页重定向

- Web:目录菜单 Layout 映射到 parent.vue,修复 /system/user 等子路由 404;个性化首页仅在当前用户菜单 path 中存在时作为根 redirect;登录成功清除 tagsViewList 缓存;个人中心首页 placeholder 说明。

- Core:预置 Demo01(密码 1234567890dop)及 sys_admin 角色;sys_password 备注说明与已有用户密码关系;登录默认账号/密码与 GetSysInfo 扩展(历史改动一并纳入)。

- AiDOP:AidopMenuLinkSync 补全租户/角色菜单关联;智慧运营看板种子与文档、工具脚本更新;Startup 注册同步。

- Web 开发环境默认预填 Demo01;主题与 aidop 菜单展示等配套调整。

Made-with: Cursor
sky-guo 1 viikko sitten
vanhempi
commit
b89eda5fa4

+ 3 - 3
Web/.env.development

@@ -4,8 +4,8 @@ ENV=development
 # 本地环境接口地址
 VITE_API_URL=http://localhost:5005
 
-# 登陆界面默认用户
-VITE_DEFAULT_USER=superAdmin.NET
+# 登陆界面默认用户(账号登录页打开时预填)
+VITE_DEFAULT_USER=Demo01
 
-# 登陆界面默认密码
+# 登录页密码优先来自 /api/sysConfig/sysInfo 的平台参数「默认密码」;此处仅作接口失败时的备用
 VITE_DEFAULT_USER_PASSWORD=Admin.NET++010101

+ 3 - 0
Web/src/App.vue

@@ -143,6 +143,9 @@ const loadSysInfo = () => {
 			themeConfig.value.hideTenantForLogin = data.hideTenantForLogin;
 			// 注册功能
 			themeConfig.value.registration = data.enableReg == 1;
+			// 登录页预填:平台参数
+			themeConfig.value.loginDefaultAccount = (data as any).defaultLoginAccount ?? '';
+			themeConfig.value.loginDefaultPassword = (data as any).defaultPassword ?? '';
 			// 更新配置加载状态
 			themeConfig.value.isLoaded = true;
 

+ 47 - 2
Web/src/router/backEnd.ts

@@ -25,6 +25,44 @@ const layouModules: any = import.meta.glob('../layout/routerView/*.{vue,tsx}');
 const viewsModules: any = import.meta.glob('../views/**/*.{vue,tsx}');
 const dynamicViewsModules: Record<string, Function> = Object.assign({}, { ...layouModules }, { ...viewsModules });
 
+const DEFAULT_HOME_REDIRECT = '/dashboard/home';
+
+function normalizeMenuPath(p: string | undefined | null): string {
+	if (p == null || typeof p !== 'string') return '';
+	const t = p.trim();
+	if (!t) return '';
+	return t.startsWith('/') ? t : `/${t}`;
+}
+
+function collectMenuPathsFromTree(menus: any[] | undefined, acc: Set<string>): void {
+	for (const m of menus || []) {
+		const np = normalizeMenuPath(m?.path);
+		if (np) acc.add(np);
+		if (m?.children?.length) collectMenuPathsFromTree(m.children, acc);
+	}
+}
+
+/**
+ * 个性化首页仅当落在当前用户菜单 path 中时才作为根重定向,否则回退默认,避免无路由匹配而 404。
+ */
+function resolveHomeRedirect(menuTree: any[]): string {
+	const fallback = (dynamicRoutes[0].redirect as string) || DEFAULT_HOME_REDIRECT;
+	let hpRaw: string | undefined;
+	try {
+		hpRaw = Session.get('homepage') as string;
+	} catch {
+		hpRaw = undefined;
+	}
+	const hp = normalizeMenuPath(hpRaw);
+	if (!hp) return fallback;
+	const allowed = new Set<string>();
+	collectMenuPathsFromTree(menuTree, allowed);
+	if (allowed.has(hp)) return hp;
+	const noTrail = hp.replace(/\/$/, '') || '/';
+	if (allowed.has(noTrail)) return noTrail;
+	return fallback;
+}
+
 /**
  * 后端控制路由:初始化方法,防止刷新时路由丢失
  * @method NextLoading 界面 loading 动画开始执行
@@ -54,8 +92,7 @@ export async function initBackEndControlRoutes() {
 	useRequestOldRoutes().setRequestOldRoutes(res as string[]);
 	// 处理路由(component),替换 dynamicRoutes(/@/router/route)第一个顶级 children 的路由
 	dynamicRoutes[0].children = await backEndComponent(res);
-	// 检查用户自定义首页设置
-	dynamicRoutes[0].redirect = Session.get('homepage') || dynamicRoutes[0].redirect;
+	dynamicRoutes[0].redirect = resolveHomeRedirect(res as any[]);
 	// 添加动态路由
 	await setAddRoute();
 	// 设置路由到 pinia routesList 中(已处理成多级嵌套路由)及缓存多级嵌套数组处理后的一维数组
@@ -155,6 +192,14 @@ export function backEndComponent(routes: any) {
  * @returns 返回处理成函数后的 component
  */
 export function dynamicImport(dynamicViewsModules: Record<string, Function>, component: string) {
+	if (component == null || component === '') return;
+	const compTrim = String(component).trim();
+	// 后端目录菜单 component 为「Layout」,实际文件在 layout/routerView/parent.vue(小写),不能仅靠 startsWith 匹配
+	if (/^layout$/i.test(compTrim)) {
+		const keys = Object.keys(dynamicViewsModules);
+		const layoutKey = keys.find((key) => key.replace(/\\/g, '/').includes('/layout/routerView/parent.'));
+		if (layoutKey) return dynamicViewsModules[layoutKey];
+	}
 	const keys = Object.keys(dynamicViewsModules);
 	const matchKeys = keys.filter((key) => {
 		const k = key.replace(/..\/views|../, '');

+ 3 - 0
Web/src/stores/themeConfig.ts

@@ -163,6 +163,9 @@ export const useThemeConfig = defineStore('themeConfig', {
 			captcha: false,
 			// 是否加载完成
 			isLoaded: false,
+			// 平台参数(/api/sysConfig/sysInfo)
+			loginDefaultAccount: '',
+			loginDefaultPassword: '',
 		},
 	}),
 	actions: {

+ 4 - 0
Web/src/types/pinia.d.ts

@@ -120,5 +120,9 @@ declare interface ThemeConfigState {
 		hideTenantForLogin: boolean; // 登陆时隐藏租户
 		captcha: boolean; // 是否开启验证码
 		isLoaded: boolean; // 是否加载完成
+		/** 平台参数「登录页默认账号」sys_login_default_account */
+		loginDefaultAccount: string;
+		/** 平台参数「默认密码」sys_password,登录页预填用 */
+		loginDefaultPassword: string;
 	};
 }

+ 32 - 15
Web/src/utils/aidopMenuDisplay.ts

@@ -50,6 +50,12 @@ const SMART_OPS_CHILDREN: Array<{ path: string; title: string; component: string
 	{ path: '/aidop/smart-ops/modeling', title: '运营指标建模', component: '/aidop/kanban/s0', name: 'aidopSmartOpsModeling' },
 ];
 
+function collectPathsUnder(node: AMenu, acc: Set<string>): void {
+	const p = node.path as string | undefined;
+	if (p) acc.add(p);
+	for (const c of node.children ?? []) collectPathsUnder(c as AMenu, acc);
+}
+
 function upsertMenu(parent: AMenu, menu: AMenu): void {
 	parent.children = parent.children ?? [];
 	const idx = parent.children.findIndex((x: AMenu) => x.path === menu.path);
@@ -60,27 +66,35 @@ function upsertMenu(parent: AMenu, menu: AMenu): void {
 	}
 }
 
-function patchAidopCustomMenus(routes: AMenu[] | undefined): void {
+/** 仅当接口返回的树里尚不存在对应 path 时补节点,避免与 sys_menu 重复;库已同步时等价于 no-op。 */
+function patchAidopCustomMenusIfMissing(routes: AMenu[] | undefined): void {
 	if (!routes?.length) return;
 	const aidopRoot = routes.find((x) => x.path === '/aidop' || x.name === 'aidopRoot');
 	if (!aidopRoot) return;
 
-	upsertMenu(aidopRoot, {
-		path: '/aidop/smart-ops',
-		name: 'aidopSmartOpsRoot',
-		component: 'Layout',
-		meta: { title: '智慧运营看板', icon: 'ele-DataBoard' },
-	});
+	const existing = new Set<string>();
+	collectPathsUnder(aidopRoot, existing);
+
+	if (!existing.has('/aidop/smart-ops')) {
+		upsertMenu(aidopRoot, {
+			path: '/aidop/smart-ops',
+			name: 'aidopSmartOpsRoot',
+			component: 'Layout',
+			meta: { title: '智慧运营看板', icon: 'ele-DataBoard' },
+		});
+		existing.add('/aidop/smart-ops');
+	}
 	const smartOps = aidopRoot.children.find((x: AMenu) => x.path === '/aidop/smart-ops');
 	if (smartOps) {
-		// 顺序要求:九宫格在最上,S1~S9依次,运营指标建模在最后
 		for (const child of SMART_OPS_CHILDREN) {
+			if (existing.has(child.path)) continue;
 			upsertMenu(smartOps, {
 				path: child.path,
 				name: child.name,
 				component: child.component,
 				meta: { title: child.title, icon: 'ele-DataAnalysis' },
 			});
+			existing.add(child.path);
 		}
 		const orderMap = new Map(SMART_OPS_CHILDREN.map((c, i) => [c.path, i]));
 		smartOps.children = (smartOps.children ?? []).sort((a: AMenu, b: AMenu) => {
@@ -90,20 +104,23 @@ function patchAidopCustomMenus(routes: AMenu[] | undefined): void {
 		});
 	}
 
-	upsertMenu(aidopRoot, {
-		path: '/aidop/smart-diagnosis',
-		name: 'aidopSmartDiagnosis',
-		component: '/aidop/diagnosis/index',
-		meta: { title: '智慧诊断', icon: 'ele-TrendCharts' },
-	});
+	if (!existing.has('/aidop/smart-diagnosis')) {
+		upsertMenu(aidopRoot, {
+			path: '/aidop/smart-diagnosis',
+			name: 'aidopSmartDiagnosis',
+			component: '/aidop/diagnosis/index',
+			meta: { title: '智慧诊断', icon: 'ele-TrendCharts' },
+		});
+	}
 }
 
 /**
  * 递归覆盖 Ai-DOP 一级目录的 meta.title,供侧栏 / tagsView / 菜单搜索使用。
+ * 智慧诊断、智慧运营看板以 sys_menu 为准;若租户/角色未关联到库中菜单,则仅补缺失 path,避免双份。
  */
 export function patchAidopMenuTitles(routes: any[] | undefined): void {
 	if (!routes?.length) return;
-	patchAidopCustomMenus(routes);
+	patchAidopCustomMenusIfMissing(routes as AMenu[]);
 	for (const item of routes) {
 		const name = item.name as string | undefined;
 		if (name && AIDOP_DIRECTORY_TITLES[name]) {

+ 22 - 2
Web/src/views/login/component/account.vue

@@ -111,11 +111,18 @@ const passwordRef = ref<InputInstance>();
 const codeRef = ref<InputInstance>();
 
 const dragRef: any = ref(null);
+
+/** config.js 可能被 production 构建写成空串,故用 import.meta.env(.env.development)兜底 */
+const defaultAccountFromEnv = (): string =>
+	(String(window.__env__?.VITE_DEFAULT_USER ?? '').trim() ||
+		String(import.meta.env.VITE_DEFAULT_USER ?? '').trim()) as string;
+
 const state = reactive({
 	isShowPassword: false,
 	ruleForm: {
-		account: window.__env__.VITE_DEFAULT_USER,
-		password: window.__env__.VITE_DEFAULT_USER_PASSWORD,
+		// 仅预填表单,不自动登录。账号:优先 sysInfo 平台参数,其次 env
+		account: defaultAccountFromEnv(),
+		password: '',
 		tenantId: props.tenantInfo?.id ?? undefined,
 		code: '',
 		codeId: 0,
@@ -157,6 +164,17 @@ onMounted(async () => {
 				state.secondVerEnabled = themeConfig.value.secondVer ?? true;
 				state.captchaEnabled = themeConfig.value.captcha ?? true;
 
+				const accPlatform = String(themeConfig.value.loginDefaultAccount ?? '').trim();
+				if (accPlatform) state.ruleForm.account = accPlatform;
+				else if (!String(state.ruleForm.account ?? '').trim()) state.ruleForm.account = defaultAccountFromEnv();
+
+				const fromPlatform = themeConfig.value.loginDefaultPassword;
+				const fromEnv =
+					String(window.__env__?.VITE_DEFAULT_USER_PASSWORD ?? '').trim() ||
+					String(import.meta.env.VITE_DEFAULT_USER_PASSWORD ?? '').trim();
+				if (fromPlatform) state.ruleForm.password = fromPlatform;
+				else if (fromEnv) state.ruleForm.password = fromEnv;
+
 				// 获取验证码
 				getCaptcha();
 
@@ -247,6 +265,8 @@ const saveTokenAndInitRoutes = async (accessToken: string | any) => {
 	Local.set(accessTokenKey, accessToken);
 	// Local.set(refreshAccessTokenKey, refreshAccessToken);
 	Session.set('token', accessToken);
+	// 避免沿用上一账号缓存的 tags(路径可能已不在当前用户路由表中,会进 404)
+	Session.remove('tagsViewList');
 
 	// 添加完动态路由再进行router跳转,否则可能报错 No match found for location with path "/"
 	const isNoPower = await initBackEndControlRoutes();

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

@@ -98,7 +98,11 @@
 									</el-col>
 									<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12" class="mb20">
 										<el-form-item label="个性化首页" prop="homepage">
-											<el-input v-model="state.ruleFormBase.homepage" placeholder="个性化首页" clearable />
+											<el-input
+												v-model="state.ruleFormBase.homepage"
+												placeholder="与左侧菜单 path 一致,如 /aidop/smart-ops/grid(须本人有该菜单权限)"
+												clearable
+											/>
 										</el-form-item>
 									</el-col>
 									<el-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24" class="mb20">

+ 14 - 0
ai-dop-platform/docs/AIDOP_MENU_SEED.md

@@ -1,5 +1,7 @@
 # Ai-DOP 规划菜单落地说明
 
+**原则**:凡要在侧栏或权限里出现的 **新菜单**,都必须落在 **`sys_menu`**(种子 + **菜单管理** 可配);前端只对旧数据做标题/组件等 **小范围兼容**,不在运行时 **整段注入** 菜单树。团队约定见仓库 **`.cursor/rules/project.mdc`** 中「菜单与侧栏」一节。
+
 因 Cursor 当前为 **Plan 模式** 时无法直接写入插件/前端源码,请将下方脚本保存后本地执行;或切换到 **Agent 模式** 让助手自动写入。
 
 ## 做法概览
@@ -15,6 +17,18 @@
 3. 在主库 **`Web/src/views/aidop/planning/index.vue`** 创建占位页(内容见下)。
 4. **重启后端**,使种子写入/更新 `sys_menu`;超级管理员若仍看不到新菜单,在 **角色管理** 中给角色勾选 **Ai-DOP** 下菜单,或清空库重建(仅开发环境)。
 
+## 智慧诊断与智慧运营看板(菜单管理可配)
+
+这两项及「智慧运营看板」下子菜单(九宫格、S1~S9 看板、运营指标建模)由 **`SysMenuSeedData`** 中的 **`BuildAidopSmartOpsSeedMenus`** 写入 **`sys_menu`**(固定 Id 段 **`1320990000201`**、**`1320990000200`** 目录及其子项 **`1320990000301`~`1320990000311`**)。前端 **`Web/src/utils/aidopMenuDisplay.ts`** 只对目录标题与个别 `component` 做覆盖,**不再在运行时注入上述菜单**,避免与库表重复。
+
+生成器 **`tools/gen_aidop_menu.py`** 内已包含 **`SMART_OPS_SEED_METHOD_LINES`**,执行 `python tools/gen_aidop_menu.py` 会一并生成该段 C#,勿再单独维护一份副本。
+
+### 已有数据库升级后如何看到新菜单
+
+1. **部署含新种子的后端** 并 **重启**,让框架执行 **`ISqlSugarEntitySeedData<SysMenu>`** 的合并逻辑(与你们环境首次建库/初始化一致;若项目有「初始化数据」管理端入口,按其说明执行一次亦可)。插件在 **`Startup.Configure`** 中会执行 **`AidopMenuLinkSync`**:对已存在于 **`sys_menu`** 的 Ai-DOP 种子 Id,向 **`sys_tenant` 中全部租户** 补 **`sys_tenant_menu`**;向 **`sys_role_menu` 中已拥有任一 Ai-DOP 种子菜单** 的角色(并始终包含默认系统管理员角色 **`1300000000101`**)补全缺失项,避免仅写入菜单主表却不在侧栏/菜单管理(按租户筛选)中可见。
+2. 若当前环境的种子策略是 **仅首次插入** 且库中已存在旧 `sys_menu`:新 Id 可能尚未写入,需在 **菜单管理** 中 **手工新增** 与种子相同的 Path/Name/Component,或按团队约定的 **SQL/运维脚本** 补录(以实际种子实现为准)。开启配置 **`EnableIncreSeed: true`** 时,Ai-DOP 的 **`SysMenuSeedData`** 已贴 **`[IncreSeed]`**,会参与增量种子更新。
+3. 菜单已在 **`sys_menu`** 但账号侧栏仍无:在 **角色管理** 为对应角色勾选 **Ai-DOP** 下新增项;**多租户** 时还需确认租户与菜单的关联(如 `sys_role_menu` / 租户菜单同步)已包含新菜单 Id。
+
 ## `tools/gen_aidop_menu.py`
 
 **请以主库内 `tools/gen_aidop_menu.py` 为准**(含主库根解析);在 **`ai-dop-platform/`** 根执行 `python tools/gen_aidop_menu.py` 即可。下列代码块为说明用摘录,与仓库文件冲突时以仓库为准。

+ 76 - 0
ai-dop-platform/tools/gen_aidop_menu.py

@@ -31,6 +31,77 @@ COMPONENT_OVERRIDES: Dict[Tuple[str, int], str] = {
     ("S1", 4): "/aidop/demo/cockpit",
 }
 
+# 与 Web 原 aidopMenuDisplay 注入项一致;入库后由菜单管理配置,勿再前端重复注入。
+SMART_OPS_SEED_METHOD_LINES = [
+    "    /// <summary>",
+    "    /// 智慧诊断 + 智慧运营看板(入库,便于平台「菜单管理」配置;勿在 Web aidopMenuDisplay 再注入整段菜单)。",
+    "    /// </summary>",
+    "    private static IEnumerable<SysMenu> BuildAidopSmartOpsSeedMenus(DateTime ct)",
+    "    {",
+    "        const long smartOpsDirId = 1320990000200L;",
+    "        yield return new SysMenu",
+    "        {",
+    "            Id = 1320990000201L,",
+    "            Pid = AidopRootId,",
+    '            Title = "智慧诊断",',
+    '            Path = "/aidop/smart-diagnosis",',
+    '            Name = "aidopSmartDiagnosis",',
+    '            Component = "/aidop/diagnosis/index",',
+    '            Icon = "ele-TrendCharts",',
+    "            Type = MenuTypeEnum.Menu,",
+    "            CreateTime = ct,",
+    "            OrderNo = 251,",
+    '            Remark = "Ai-DOP 智慧诊断"',
+    "        };",
+    "        yield return new SysMenu",
+    "        {",
+    "            Id = smartOpsDirId,",
+    "            Pid = AidopRootId,",
+    '            Title = "智慧运营看板",',
+    '            Path = "/aidop/smart-ops",',
+    '            Name = "aidopSmartOpsRoot",',
+    '            Component = "Layout",',
+    '            Icon = "ele-DataBoard",',
+    "            Type = MenuTypeEnum.Dir,",
+    "            CreateTime = ct,",
+    "            OrderNo = 252,",
+    '            Remark = "Ai-DOP 智慧运营看板分组"',
+    "        };",
+    "        var children = new (long Id, string Path, string Name, string Title, string Component, int Order)[]",
+    "        {",
+    '            (1320990000301L, "/aidop/smart-ops/grid", "aidopSmartOpsGrid", "九宫格智慧运营看板", "/dashboard/home", 100),',
+    '            (1320990000302L, "/aidop/smart-ops/s1", "aidopSmartOpsS1", "S1产销协同看板", "/aidop/kanban/s1", 110),',
+    '            (1320990000303L, "/aidop/smart-ops/s2", "aidopSmartOpsS2", "S2制造协同看板", "/aidop/kanban/s2", 120),',
+    '            (1320990000304L, "/aidop/smart-ops/s3", "aidopSmartOpsS3", "S3供应协同看板", "/aidop/kanban/s3", 130),',
+    '            (1320990000305L, "/aidop/smart-ops/s4", "aidopSmartOpsS4", "S4采购执行看板", "/aidop/kanban/s4", 140),',
+    '            (1320990000306L, "/aidop/smart-ops/s5", "aidopSmartOpsS5", "S5物料仓储看板", "/aidop/kanban/s5", 150),',
+    '            (1320990000307L, "/aidop/smart-ops/s6", "aidopSmartOpsS6", "S6生产执行看板", "/aidop/kanban/s6", 160),',
+    '            (1320990000308L, "/aidop/smart-ops/s7", "aidopSmartOpsS7", "S7成品仓储看板", "/aidop/kanban/s7", 170),',
+    '            (1320990000309L, "/aidop/smart-ops/s8", "aidopSmartOpsS8", "S8异常监控看板", "/aidop/kanban/s8", 180),',
+    '            (1320990000310L, "/aidop/smart-ops/s9", "aidopSmartOpsS9", "S9运营指标看板", "/aidop/kanban/s9", 190),',
+    '            (1320990000311L, "/aidop/smart-ops/modeling", "aidopSmartOpsModeling", "运营指标建模", "/aidop/kanban/s0", 200),',
+    "        };",
+    "        foreach (var (id, path, name, title, component, order) in children)",
+    "        {",
+    "            yield return new SysMenu",
+    "            {",
+    "                Id = id,",
+    "                Pid = smartOpsDirId,",
+    "                Title = title,",
+    "                Path = path,",
+    "                Name = name,",
+    "                Component = component,",
+    '                Icon = "ele-DataAnalysis",',
+    "                Type = MenuTypeEnum.Menu,",
+    "                CreateTime = ct,",
+    "                OrderNo = order,",
+    "                Remark = title",
+    "            };",
+    "        }",
+    "    }",
+    "",
+]
+
 mods = [
     ("S0", "S0 运营建模", [("数据建模", "支持数据库表结构设计与建模", "高", "5", "核心基础功能"), ("业务建模", "支持业务流程建模与配置", "高", "5", "核心基础功能")]),
     ("S1", "S1 产销协同", [("订单管理", "销售订单录入、查询、编辑、删除", "中", "3", ""), ("计划管理", "生产计划制定与分解", "中", "3", ""), ("工单管理", "工单创建、分配、跟踪", "中", "3", ""), ("产销协同看板", "订单与生产协同数据可视化", "高", "5", "数据看板类")]),
@@ -64,6 +135,7 @@ def main() -> None:
     lines.append("/// <summary>")
     lines.append("/// Ai-DOP 规划菜单;类名须为 SysMenuSeedData,以便租户默认菜单聚合所有程序集种子。")
     lines.append("/// </summary>")
+    lines.append("[IncreSeed]")
     lines.append("public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>")
     lines.append("{")
     lines.append("    private const long AidopRootId = 1320990000101L;")
@@ -138,9 +210,13 @@ def main() -> None:
     lines.append("            }")
     lines.append("        }")
     lines.append("")
+    lines.append("        foreach (var m in BuildAidopSmartOpsSeedMenus(ct))")
+    lines.append("            list.Add(m);")
+    lines.append("")
     lines.append("        return list;")
     lines.append("    }")
     lines.append("")
+    lines.extend(SMART_OPS_SEED_METHOD_LINES)
     lines.append(
         '    private static string ResolveComponent(string modCode, int leafIndex) =>'
     )

+ 5 - 0
server/Admin.NET.Core/Const/ConfigConst.cs

@@ -17,6 +17,11 @@ public class ConfigConst
     public const string SysDemoEnv = "sys_demo";
 
     /// <summary>
+    /// 登录页默认账号(预填,不自动登录)
+    /// </summary>
+    public const string SysLoginDefaultAccount = "sys_login_default_account";
+
+    /// <summary>
     /// 默认密码
     /// </summary>
     public const string SysPassword = "sys_password";

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

@@ -20,7 +20,8 @@ public class SysConfigSeedData : ISqlSugarEntitySeedData<SysConfig>
         return new[]
         {
             new SysConfig{ Id=1300000000101, Name="演示环境", Code=ConfigConst.SysDemoEnv, Value="False", SysFlag=YesNoEnum.Y, Remark="演示环境", OrderNo=10, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
-            new SysConfig{ Id=1300000000111, Name="默认密码", Code=ConfigConst.SysPassword, Value="Admin.NET++010101", SysFlag=YesNoEnum.Y, Remark="默认密码", OrderNo=20, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000106, Name="登录页默认账号", Code=ConfigConst.SysLoginDefaultAccount, Value="Demo01", SysFlag=YesNoEnum.N, Remark="登录页预填账号,可在参数配置修改", OrderNo=15, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
+            new SysConfig{ Id=1300000000111, Name="默认密码", Code=ConfigConst.SysPassword, Value="Admin.NET++010101", SysFlag=YesNoEnum.Y, Remark="新建用户/租户未填密码时的默认明文;登录页预填也读此值。修改此处不会自动更新已有用户表中的 Password,登录仍以各用户已存密文为准。", OrderNo=20, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
             new SysConfig{ Id=1300000000121, Name="密码最大错误次数", Code=ConfigConst.SysPasswordMaxErrorTimes, Value="5", SysFlag=YesNoEnum.Y, Remark="允许密码最大输入错误次数", OrderNo=30, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
             new SysConfig{ Id=1300000000131, Name="日志保留天数", Code=ConfigConst.SysLogRetentionDays, Value="180", SysFlag=YesNoEnum.Y, Remark="日志保留天数(天)", OrderNo=40, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") },
             new SysConfig{ Id=1300000000141, Name="记录操作日志", Code=ConfigConst.SysOpLog, Value="True", SysFlag=YesNoEnum.Y, Remark="是否记录操作日志", OrderNo=50, GroupCode=ConfigConst.SysDefaultGroup, CreateTime=DateTime.Parse("2022-02-10 00:00:00") },

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

@@ -25,6 +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=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=1300000000120, UserId=userList.First(u => u.Account == "Demo01").Id, RoleId=roleList.First(u => u.Code == "sys_admin").Id },
         };
     }
 }

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

@@ -19,6 +19,7 @@ public class SysUserSeedData : ISqlSugarEntitySeedData<SysUser>
     public IEnumerable<SysUser> HasData()
     {
         var encryptPassword = CryptogramUtil.Encrypt(new SysConfigSeedData().HasData().First(u => u.Code == ConfigConst.SysPassword).Value);
+        var demoEncryptPassword = CryptogramUtil.Encrypt("1234567890dop");
         var posList = new SysPosSeedData().HasData().ToList();
         return new[]
         {
@@ -28,6 +29,7 @@ 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=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=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 },
         };
     }
 }

+ 7 - 1
server/Admin.NET.Core/Service/Config/SysConfigService.cs

@@ -261,6 +261,10 @@ public class SysConfigService : IDynamicApiController, ITransient
         var captcha = await GetConfigValue<bool>(ConfigConst.SysCaptcha);
         var secondVer = await GetConfigValue<bool>(ConfigConst.SysSecondVer);
         var hideTenantForLogin = await GetConfigValue<bool>(ConfigConst.SysHideTenantLogin);
+        // 与「参数配置」一致,供登录页预填;仅预填不自动登录
+        var loginAccCfg = await _sysConfigRep.GetFirstAsync(u => u.Code == ConfigConst.SysLoginDefaultAccount);
+        var defaultLoginAccount = loginAccCfg?.Value ?? "";
+        var defaultPassword = await GetConfigValue<string>(ConfigConst.SysPassword);
         return new
         {
             tenant.Logo,
@@ -276,7 +280,9 @@ public class SysConfigService : IDynamicApiController, ITransient
             SecondVer = secondVer ? YesNoEnum.Y : YesNoEnum.N,
             Captcha = captcha ? YesNoEnum.Y : YesNoEnum.N,
             HideTenantForLogin = hideTenantForLogin,
-            WayList = wayList
+            WayList = wayList,
+            DefaultLoginAccount = defaultLoginAccount,
+            DefaultPassword = defaultPassword
         };
     }
 

+ 101 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/AidopMenuLinkSync.cs

@@ -0,0 +1,101 @@
+using Admin.NET.Core;
+using Microsoft.Extensions.DependencyInjection;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure;
+
+/// <summary>
+/// 将 Ai-DOP 种子菜单 Id 补写到 <c>sys_tenant_menu</c> / <c>sys_role_menu</c>。
+/// 解决:<c>SysTenantMenuSeedData</c> 带 IgnoreUpdateSeed,库已存在时新增菜单不会自动进租户/角色;多租户下仅补默认租户会导致其它租户侧栏与菜单管理不可见。
+/// </summary>
+public static class AidopMenuLinkSync
+{
+    /// <summary>与框架种子中首条「系统管理员」角色 Id 一致。</summary>
+    private const long SysAdminRoleId = 1300000000101L;
+
+    public static void EnsureLinked(IServiceProvider services)
+    {
+        using var scope = services.CreateScope();
+        var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
+        var seedMenus = new global::Admin.NET.Plugin.AiDOP.SysMenuSeedData().HasData().ToList();
+        var seedMenuIds = seedMenus.Select(m => m.Id).ToHashSet();
+
+        var existingMenuIds = db.Queryable<SysMenu>()
+            .Where(m => seedMenuIds.Contains(m.Id))
+            .Select(m => m.Id)
+            .ToList()
+            .ToHashSet();
+
+        if (existingMenuIds.Count == 0)
+            return;
+
+        var tenantIds = db.Queryable<SysTenant>().Select(t => t.Id).ToList();
+        if (tenantIds.Count == 0)
+            return;
+
+        var tenantMenuPairs = db.Queryable<SysTenantMenu>()
+            .Where(tm => tenantIds.Contains(tm.TenantId) && seedMenuIds.Contains(tm.MenuId))
+            .Select(tm => new { tm.TenantId, tm.MenuId })
+            .ToList()
+            .Select(x => (x.TenantId, x.MenuId))
+            .ToHashSet();
+
+        var tenantRows = new List<SysTenantMenu>();
+        foreach (var tid in tenantIds)
+        {
+            foreach (var menu in seedMenus)
+            {
+                if (!existingMenuIds.Contains(menu.Id))
+                    continue;
+                if (tenantMenuPairs.Contains((tid, menu.Id)))
+                    continue;
+                tenantRows.Add(new SysTenantMenu
+                {
+                    Id = CommonUtil.GetFixedHashCode("" + tid + menu.Id, 1300000000000),
+                    TenantId = tid,
+                    MenuId = menu.Id
+                });
+            }
+        }
+
+        if (tenantRows.Count > 0)
+            db.Insertable(tenantRows).ExecuteCommand();
+
+        // 已为任一 Ai-DOP 种子菜单授权的角色,补全新增子菜单;并始终包含默认租户系统管理员角色。
+        var roleIdsWithAnyAidop = db.Queryable<SysRoleMenu>()
+            .Where(rm => seedMenuIds.Contains(rm.MenuId))
+            .Select(rm => rm.RoleId)
+            .ToList()
+            .Distinct()
+            .ToHashSet();
+        roleIdsWithAnyAidop.Add(SysAdminRoleId);
+
+        var roleMenuPairs = db.Queryable<SysRoleMenu>()
+            .Where(rm => roleIdsWithAnyAidop.Contains(rm.RoleId) && seedMenuIds.Contains(rm.MenuId))
+            .Select(rm => new { rm.RoleId, rm.MenuId })
+            .ToList()
+            .Select(x => (x.RoleId, x.MenuId))
+            .ToHashSet();
+
+        var roleRows = new List<SysRoleMenu>();
+        foreach (var roleId in roleIdsWithAnyAidop)
+        {
+            foreach (var menu in seedMenus)
+            {
+                if (!existingMenuIds.Contains(menu.Id))
+                    continue;
+                if (roleMenuPairs.Contains((roleId, menu.Id)))
+                    continue;
+                roleRows.Add(new SysRoleMenu
+                {
+                    Id = menu.Id + (roleId % 1300000000000),
+                    RoleId = roleId,
+                    MenuId = menu.Id
+                });
+            }
+        }
+
+        if (roleRows.Count > 0)
+            db.Insertable(roleRows).ExecuteCommand();
+    }
+}

+ 71 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/SysMenuSeedData.cs

@@ -5,6 +5,7 @@ namespace Admin.NET.Plugin.AiDOP;
 /// <summary>
 /// Ai-DOP 规划菜单;类名须为 SysMenuSeedData,以便租户默认菜单聚合所有程序集种子。
 /// </summary>
+[IncreSeed]
 public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
 {
     private const long AidopRootId = 1320990000101L;
@@ -77,9 +78,79 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData<SysMenu>
             }
         }
 
+        foreach (var m in BuildAidopSmartOpsSeedMenus(ct))
+            list.Add(m);
+
         return list;
     }
 
+    /// <summary>
+    /// 智慧诊断 + 智慧运营看板(入库,便于平台「菜单管理」配置;勿在 Web aidopMenuDisplay 再注入整段菜单)。
+    /// </summary>
+    private static IEnumerable<SysMenu> BuildAidopSmartOpsSeedMenus(DateTime ct)
+    {
+        const long smartOpsDirId = 1320990000200L;
+        yield return new SysMenu
+        {
+            Id = 1320990000201L,
+            Pid = AidopRootId,
+            Title = "智慧诊断",
+            Path = "/aidop/smart-diagnosis",
+            Name = "aidopSmartDiagnosis",
+            Component = "/aidop/diagnosis/index",
+            Icon = "ele-TrendCharts",
+            Type = MenuTypeEnum.Menu,
+            CreateTime = ct,
+            OrderNo = 251,
+            Remark = "Ai-DOP 智慧诊断"
+        };
+        yield return new SysMenu
+        {
+            Id = smartOpsDirId,
+            Pid = AidopRootId,
+            Title = "智慧运营看板",
+            Path = "/aidop/smart-ops",
+            Name = "aidopSmartOpsRoot",
+            Component = "Layout",
+            Icon = "ele-DataBoard",
+            Type = MenuTypeEnum.Dir,
+            CreateTime = ct,
+            OrderNo = 252,
+            Remark = "Ai-DOP 智慧运营看板分组"
+        };
+        var children = new (long Id, string Path, string Name, string Title, string Component, int Order)[]
+        {
+            (1320990000301L, "/aidop/smart-ops/grid", "aidopSmartOpsGrid", "九宫格智慧运营看板", "/dashboard/home", 100),
+            (1320990000302L, "/aidop/smart-ops/s1", "aidopSmartOpsS1", "S1产销协同看板", "/aidop/kanban/s1", 110),
+            (1320990000303L, "/aidop/smart-ops/s2", "aidopSmartOpsS2", "S2制造协同看板", "/aidop/kanban/s2", 120),
+            (1320990000304L, "/aidop/smart-ops/s3", "aidopSmartOpsS3", "S3供应协同看板", "/aidop/kanban/s3", 130),
+            (1320990000305L, "/aidop/smart-ops/s4", "aidopSmartOpsS4", "S4采购执行看板", "/aidop/kanban/s4", 140),
+            (1320990000306L, "/aidop/smart-ops/s5", "aidopSmartOpsS5", "S5物料仓储看板", "/aidop/kanban/s5", 150),
+            (1320990000307L, "/aidop/smart-ops/s6", "aidopSmartOpsS6", "S6生产执行看板", "/aidop/kanban/s6", 160),
+            (1320990000308L, "/aidop/smart-ops/s7", "aidopSmartOpsS7", "S7成品仓储看板", "/aidop/kanban/s7", 170),
+            (1320990000309L, "/aidop/smart-ops/s8", "aidopSmartOpsS8", "S8异常监控看板", "/aidop/kanban/s8", 180),
+            (1320990000310L, "/aidop/smart-ops/s9", "aidopSmartOpsS9", "S9运营指标看板", "/aidop/kanban/s9", 190),
+            (1320990000311L, "/aidop/smart-ops/modeling", "aidopSmartOpsModeling", "运营指标建模", "/aidop/kanban/s0", 200),
+        };
+        foreach (var (id, path, name, title, component, order) in children)
+        {
+            yield return new SysMenu
+            {
+                Id = id,
+                Pid = smartOpsDirId,
+                Title = title,
+                Path = path,
+                Name = name,
+                Component = component,
+                Icon = "ele-DataAnalysis",
+                Type = MenuTypeEnum.Menu,
+                CreateTime = ct,
+                OrderNo = order,
+                Remark = title
+            };
+        }
+    }
+
     private static string ResolveComponent(string modCode, int leafIndex) =>
         ComponentOverrides.TryGetValue((modCode, leafIndex), out var c) ? c : "/aidop/planning/index";
 

+ 11 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -1,4 +1,6 @@
+using System.Diagnostics;
 using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Hosting;
 using Microsoft.Extensions.DependencyInjection;
@@ -22,6 +24,15 @@ public class Startup : AppStartup
     /// </summary>
     public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
     {
+        try
+        {
+            AidopMenuLinkSync.EnsureLinked(app.ApplicationServices);
+        }
+        catch (Exception ex)
+        {
+            Trace.TraceWarning("Ai-DOP AidopMenuLinkSync: " + ex);
+        }
+
         if (!env.IsDevelopment())
             return;