diff --git a/.trae/specs/fix-menu-edit-save/checklist.md b/.trae/specs/fix-menu-edit-save/checklist.md new file mode 100644 index 0000000..5a50fec --- /dev/null +++ b/.trae/specs/fix-menu-edit-save/checklist.md @@ -0,0 +1,8 @@ +# Checklist + +- [x] MenuService.create() 以 camelCase 属性名写入(parentId/menuName/menuPath/component/icon/sortOrder/menuType/permission/visible),父菜单 parentId=null 语义保留 +- [x] MenuService.update() 以 camelCase 属性名写入,部分字段更新不会清空未提交字段 +- [x] sys_menu 自增序列失步已修复(生产库 setval 到 max(id)=64;sys_role/sys_user 序列一并核对),db_seed.sql 的 setval 全部改为 pg_get_serial_sequence() 形式以兼容不同环境序列名 +- [x] 后端 tsc --noEmit 通过 +- [x] PUT /api/menus/:id 修改名称后重新 GET 菜单树已持久化;POST /api/menus 返回 200 且新菜单可见(测试数据已清理) +- [x] 浏览器菜单管理页:编辑名称保存成功、树实时刷新、刷新页面后名称保持;新增菜单可用 diff --git a/.trae/specs/fix-menu-edit-save/spec.md b/.trae/specs/fix-menu-edit-save/spec.md new file mode 100644 index 0000000..cb3a242 --- /dev/null +++ b/.trae/specs/fix-menu-edit-save/spec.md @@ -0,0 +1,31 @@ +# 修复菜单管理「编辑无法保存」Spec + +## Why +菜单管理页面点击「编辑」修改菜单名称后点击保存,接口返回「更新菜单成功」但数据未持久化(名称未变);新增菜单则直接返回 500。根因:`MenuService.create/update` 以 snake_case 数据库列名(`menu_name` 等)作为 Sequelize 的取值键,而实体属性是 camelCase(`menuName` 等),Sequelize 无法匹配从而静默忽略(update)或触发 NOT NULL 报错(create)。 + +## What Changes +- 修正 `server/src/service/menu.service.ts` 中 `create()` 与 `update()`:将写入 Sequelize 的键改为实体 camelCase 属性名(`parentId/menuName/menuPath/component/icon/sortOrder/menuType/permission/visible`),字段映射由实体 `field` 配置负责。 +- 前端与接口协议不变(前端已发送 camelCase body,`menu.controller.ts`、`menu.dto.ts`、`client/src/api/menu.ts` 均无需改动)。 +- **BREAKING**: 无(仅为内部映射修正,接口行为从「假成功/500」变为「真实持久化」)。 + +## Impact +- Affected specs: 系统管理域(sys_menu RBAC) +- Affected code: `server/src/service/menu.service.ts`(仅 create/update 两处取值键);连带修复后「新增菜单」「编辑菜单」均恢复可用 + +## ADDED Requirements +(无新增能力) + +## MODIFIED Requirements +### Requirement: 菜单新增与编辑持久化 +菜单管理的「新增」与「编辑」操作 SHALL 将表单值真实写入 `sys_menu` 表。 + +#### Scenario: 编辑菜单名称后保存 +- **WHEN** 用户在菜单管理中点击某菜单「编辑」,修改菜单名称并点击确定 +- **THEN** 接口返回成功,重新拉取菜单树显示新名称,且刷新页面后名称保持不变 + +#### Scenario: 新增菜单 +- **WHEN** 用户在菜单管理中点击「新增」,填写必填项并确定 +- **THEN** 接口返回 200 并持久化,菜单树中出现新菜单(而非 500) + +## REMOVED Requirements +(无) diff --git a/.trae/specs/fix-menu-edit-save/tasks.md b/.trae/specs/fix-menu-edit-save/tasks.md new file mode 100644 index 0000000..b12a341 --- /dev/null +++ b/.trae/specs/fix-menu-edit-save/tasks.md @@ -0,0 +1,16 @@ +# Tasks +- [x] Task 1: 修复 MenuService 写入键映射 + - [x] 1.1 阅读 menu.service.ts,确认 create()/update() 中 snake_case 键的位置 + - [x] 1.2 将 create() 与 update() 写入 Sequelize 的键改为实体 camelCase 属性名,并确保父子菜单 parentId=null 与 visible 处理逻辑不变 + - [x] 1.3 后端 `npx tsc --noEmit` 通过 +- [x] Task 2: 修复 sys_menu 自增序列失步(新增菜单 500 的根因) + - [x] 2.1 定位根因:列默认值指向 sys_menu_id_seq1 且停留在 2(显式 id 插入不推进序列),与已存在 id 冲突 + - [x] 2.2 生产库一次性同步序列(setval 到 max(id)=64),并顺带核对 sys_role/sys_user 序列 + - [x] 2.3 db_seed.sql 全部 setval 改为 pg_get_serial_sequence() 形式,兼容不同环境序列名 +- [x] Task 3: 验证 + - [x] 3.1 接口自测:PUT /api/menus/:id 修改菜单名称后重新 GET 树,名称已持久化 + - [x] 3.2 接口自测:POST /api/menus 新增菜单返回 200 且树中出现新菜单(自测后清理测试数据) + - [x] 3.3 浏览器验证:菜单管理页编辑名称保存后树实时刷新、刷新页面后保持 + +# Task Dependencies +- [Task 2][Task 3] depend on [Task 1] diff --git a/database/db_seed.sql b/database/db_seed.sql index ad11aa7..b284ba1 100644 --- a/database/db_seed.sql +++ b/database/db_seed.sql @@ -43,7 +43,7 @@ ON CONFLICT (id) DO UPDATE SET DELETE FROM sys_role WHERE role_code IN ('WORKER', 'CUSTOMER'); -- 重置 sys_role 自增序列 -SELECT setval('sys_role_id_seq', 2, true); +SELECT setval(pg_get_serial_sequence('sys_role', 'id'), GREATEST((SELECT MAX(id) FROM sys_role), 2), true); -- ------------------------------------------------------------ -- 2. 初始菜单(顶级菜单,显式指定 id:1-6,系统管理/运营派单为目录类型 menu_type=1,其余为 menu_type=2) @@ -185,7 +185,7 @@ ON CONFLICT (id) DO UPDATE SET visible = EXCLUDED.visible; -- 重置 sys_menu 自增序列 -SELECT setval('sys_menu_id_seq', 50, true); +SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 50), true); -- ------------------------------------------------------------ -- 3. 初始字典(sys_dict 无业务唯一键,清空重建以支持幂等执行) @@ -252,7 +252,7 @@ ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status; -- 重置 sys_user 自增序列 -SELECT setval('sys_user_id_seq', 1, true); +SELECT setval(pg_get_serial_sequence('sys_user', 'id'), GREATEST((SELECT MAX(id) FROM sys_user), 1), true); -- ------------------------------------------------------------ -- 5. 用户-角色绑定:admin → ADMIN @@ -328,7 +328,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES ON CONFLICT (role_id, menu_id) DO NOTHING; -- 重置 sys_menu 自增序列(覆盖到 61) -SELECT setval('sys_menu_id_seq', 61, true); +SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 61), true); -- ------------------------------------------------------------ -- 8a. 设备监控下的智电改造线路台账菜单(id=61,挂设备监控 parent_id=4) @@ -386,7 +386,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES ON CONFLICT (role_id, menu_id) DO NOTHING; -- 重置 sys_menu 自增序列 -SELECT setval('sys_menu_id_seq', 57, true); +SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 57), true); -- ------------------------------------------------------------ -- 10. 通知规则扫描任务种子(sys_job,按 job_key 幂等) @@ -521,7 +521,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES ON CONFLICT (role_id, menu_id) DO NOTHING; -- 重置 sys_menu 自增序列(覆盖到 62) -SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 62), true); +SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 62), true); -- 智电网关同步任务种子(sys_job,按 job_key 幂等;默认每 10 分钟) INSERT INTO sys_job (job_key, job_name, cron_expression, params, retry_times, retry_interval, status, remark) VALUES @@ -574,7 +574,7 @@ INSERT INTO sys_role_menu (role_id, menu_id) VALUES ON CONFLICT (role_id, menu_id) DO NOTHING; -- 重置 sys_menu 自增序列(覆盖到 64) -SELECT setval('sys_menu_id_seq', GREATEST((SELECT MAX(id) FROM sys_menu), 64), true); +SELECT setval(pg_get_serial_sequence('sys_menu', 'id'), GREATEST((SELECT MAX(id) FROM sys_menu), 64), true); COMMIT; diff --git a/server/src/service/menu.service.ts b/server/src/service/menu.service.ts index 343c562..878b103 100644 --- a/server/src/service/menu.service.ts +++ b/server/src/service/menu.service.ts @@ -43,13 +43,13 @@ export class MenuService { } const menu = await this.menuModel.create({ - parent_id: parentId, - menu_name: menuName, - menu_path: menuPath, + parentId, + menuName, + menuPath, component, icon, - sort_order: sortOrder, - menu_type: menuType, + sortOrder, + menuType, permission, visible, }); @@ -73,13 +73,13 @@ export class MenuService { } const updateData: any = {}; - if (data.parentId !== undefined) updateData.parent_id = data.parentId; - if (data.menuName !== undefined) updateData.menu_name = data.menuName; - if (data.menuPath !== undefined) updateData.menu_path = data.menuPath; + if (data.parentId !== undefined) updateData.parentId = data.parentId; + if (data.menuName !== undefined) updateData.menuName = data.menuName; + if (data.menuPath !== undefined) updateData.menuPath = data.menuPath; if (data.component !== undefined) updateData.component = data.component; if (data.icon !== undefined) updateData.icon = data.icon; - if (data.sortOrder !== undefined) updateData.sort_order = data.sortOrder; - if (data.menuType !== undefined) updateData.menu_type = data.menuType; + if (data.sortOrder !== undefined) updateData.sortOrder = data.sortOrder; + if (data.menuType !== undefined) updateData.menuType = data.menuType; if (data.permission !== undefined) updateData.permission = data.permission; if (data.visible !== undefined) updateData.visible = data.visible;