Просмотр исходного кода

feat: migrate legacy role administration

彭峰 1 месяц назад
Родитель
Сommit
c8ff5d71c6

+ 1 - 0
docs/migration/admin-migration-status.md

@@ -44,6 +44,7 @@
 - 旧 `Extend/PWA` 会在 ASP.NET 站点上读写 `SiteConfig.SiteOption`,缩放八种图标,生成 `manifest.json`,注入页面 head,并从 `pwa_sw_js.tlp` 覆盖站点根目录 `/sw.js`。当前 Angular 构建没有 service-worker 发布链,云函数也不能修改已部署静态资产;6 个页面和提交动作由 `legacyPWABlocker` 明确为基础设施阻塞,不提供“保存成功但客户端不生效”的伪配置。
 - `Extend/QuartzTask` 管理的是 `QuartzNetTask` 任务记录、任务内日志及常驻进程中的 `IScheduler/ScheduleTaskHandler`,创建、更新、立即执行、暂停、恢复和删除都必须同步调度器。目标只存在语义不同且为空的 `SenTask`,云函数没有 Quartz 控制面;含 GET/POST 两个同名 `TaskAdd` 在内的 11 个动作由 `legacyQuartzTaskBlocker` 分别返回数据或基础设施阻塞原因。
 - 旧 `Design/Resource` 的历史资源已从线上 `DesignRes` 恢复到 Angular“可视化设计资源”页:`designResources` 提供帐套隔离、分页、搜索和 `ztype` 精确筛选,`saveDesignResource` 只允许更新名称、类型、用途、状态及 `style/use/fun` 元数据,`designResourceBatch` 覆盖单条和批量删除。`id/vpath/previewimg/cdate/userid/sourceKey` 均锁为只读;线上 Parse 文件适配器仍错误要求 MongoDB 连接串,而当前项目使用 PostgreSQL,因此新增及替换原文件/预览图继续以 `migration_blocked` 明确拒绝,不会伪造可用资源路径。
+- 旧 `User/Role` 的 9 个管理员角色、3 个用户角色与 `ARoleAuth` 权限记录已迁到 Angular“系统角色”页及 `roles/saveRole/roleAuthorization/saveRoleAuthorization/roleBatch`:支持角色类型切换、搜索、分页、名称防重、原子 `roleId`、12 个旧权限域完整编辑及权限码格式校验。角色类型创建后不得在 `admin/user` 之间迁移;删除前同时检查 `Manager.adminRole` 和 `_User.legacyUserData.UserRole`,无账号引用时才级联删除 `ARoleAuth`。Parse 原生 `_Role` 为空,`Permission` 是另一套网页设计师模型,二者都没有被误当作旧 `ZL_Role`。交付程序中 `B_ARoleAuth.Check/CheckEx/AuthCheckEx` 均固定返回 `true`,因此保留的旧权限码不用来绕过当前云函数的独立管理员会话与帐套校验。
 - 旧 `NodeAdd_Submit`、`Node_API` 与 `UnionNode_Move` 的核心栏目生命周期已迁为专用 `saveNode/nodeBatch`:新增栏目以临时来源键完成 Parse 初始写入,再使用 PostgreSQL 事务锁原子分配 `nodeId/orderId` 并替换为旧 `sourceKey`;保存会校验同级名称/目录重复、父栏目帐套和回收状态,移动会阻断自身/下级循环并递归修正所有后代深度。回收、恢复和每批 1–100 个栏目迁移已接入 Angular;永久删除仅在没有内容、下级栏目、节点权限和模型模板引用时开放,通用保存与删除不能绕过。
 - 旧 `SpecialAdd_Save` 与 `UnionSpecial_Move` 已迁为专用 `saveSpecial/specialBatch`:新增专题使用临时来源键和 PostgreSQL 事务锁原子分配 `specId/orderId`,保存时校验全帐套名称/目录唯一、父专题归属与层级循环;删除会拒绝仍有下级专题的记录。旧 `UnionSpecial_Merge` 被明确列为数据阻塞:`CommonModel.specialId` 只存在于 Parse Schema、PostgreSQL 物理列缺失,商品主表也未迁移,无法安全改写内容/商品专题关系。
 - 旧 `ModelManage/Field/Order_Submit` 的安全部分已迁为 `saveModelMetadata/saveModelFieldMetadata/modelFieldOrder`:现有模型可编辑名称、条目文案、图标、说明和内容模板元数据,现有字段可编辑别名、提示、显示/搜索配置,并可在同模型内批量排序;`modelId/modelType/tableName/fieldId/modelId/fieldName/fieldType/sourceKey` 等结构字段通用只读。新增、复制、删除模型或字段会显式返回 501,因为旧实现同时执行 PostgreSQL 建表/改列、标签生成和模板文件写入,目标托管环境没有该 DDL/旧模板运行时。

+ 32 - 2
scripts/deploy-admin-functions.mjs

@@ -15,12 +15,12 @@ const CLASS_LABELS = {
   GradeCate: '多级字典分类', Grade: '多级字典选项',
   Currency: '货币管理', SysHoliday: '节假日管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '商城运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
-  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Pub: '互动模块定义', PubTw: '提问互动提交', PubWTHD: '问题回答提交', PubZXDC: '在线调查提交', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', DesignRes: '可视化设计资源', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', PageStyle: '黄页样式', PlatComp: '协同办公企业', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', UserPromotion: '用户推广关系', CRMSAttr: 'CRM 客户类型', SysLog: '系统日志', MisType: 'OA 流程类型', Agency: '代理机构', DeliveryCenter: '交付中心'
+  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Pub: '互动模块定义', PubTw: '提问互动提交', PubWTHD: '问题回答提交', PubZXDC: '在线调查提交', Role: '系统角色', ARoleAuth: '角色权限', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', DesignRes: '可视化设计资源', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', PageStyle: '黄页样式', PlatComp: '协同办公企业', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', UserPromotion: '用户推广关系', CRMSAttr: 'CRM 客户类型', SysLog: '系统日志', MisType: 'OA 流程类型', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
 const ALLOWED_CLASSES = new Set([
   'PageTemplate','PaperQuestions','PayPlat','Permission','PlatComp','App','DesignAnswer','DesignAsk','Attachment','DesignPage','DesignQuestion','DesignRes','Feedback','DesignScence','StudentAchieve','ContentArticle','DesignSiteInfo','Profile','AdInfo','AdZone','ARoleAuth','Baike','ExamSysQuestions','ContactInfo','VocabularyWord','AssessmentProfile','Agency','MemoryPracticeRecord','DeliveryCenter','CourseBinding','PracticeRecord','CourseAppointment','Account','SurveyItem','SurveyLog','LessonRecord','DailyStudyRecord','CommonModel','ContentPublish','Company','_Role','_User','CRMSAttr','Currency','Datadic','Datadiccategory','DesignTlp','DocModel','DocPermission','ExamClass','ExamSysPapers','ExamType','ExamPoint','ExTeacher','FontPicShape','FontPicShapeType','Grade','GradeCate','Group','GroupModel','GuestBar','Guestbook','Guestcate','MailTemp','Manager','MisProcedure','MisProLevel','MisSign','MisType','PageStyle','Model','ModelField','Node','NodeAuth','NodeModelTemplate','Product','PlatUserRole','Pub','PubTw','PubWTHD','PubZXDC','PublishNode','QuestionsKnowledge','Role','StoreProduct','SafeMobile','Search','SenTask','ServiceSeat','ShopFareTlp','ShopMoneyRegular','Special','StoreApplication','StoreStyle','SysCSSManage','SysHoliday','SysLog','Temp','ThirdPlatInfo','UserCredit','UserDummyPoint','UserFriendGroup','UserLevel','UserMoneyLog','UserPromotion','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
 ]);
-const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo','GuestBar','DesignAnswer','Baike','ExamSysPapers','ExamSysQuestions','PaperQuestions','ExamType','ContentPublish','PublishNode','SysLog','Pub','PubTw','PubWTHD','PubZXDC']);
+const READ_ONLY_CLASSES = new Set(['_Role','Permission','ARoleAuth','PayPlat','ThirdPlatInfo','GuestBar','DesignAnswer','Baike','ExamSysPapers','ExamSysQuestions','PaperQuestions','ExamType','ContentPublish','PublishNode','SysLog','Pub','PubTw','PubWTHD','PubZXDC']);
 const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role','isDisabled','legacyUserId','legacyGroupId','legacyUserData','legacyUserPlat','groupId']);
 const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
 const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
@@ -52,6 +52,8 @@ const CLASS_SYSTEM_FIELDS = {
   DesignQuestion: new Set(['id','askId','cdate','cuser','orderId','sourceKey']),
   DesignAnswer: new Set(['id','askId','ip','cdate','userId','sourceKey']),
   DesignRes: new Set(['id','vpath','previewimg','cdate','userid','sourceKey']),
+  Role: new Set(['roleId','ztype','zstatus','nodeId','auth','auth2','auth3','cadminId','cdate','sourceKey']),
+  ARoleAuth: new Set(['id','rid','adminId','sourceKey']),
   ServiceSeat: new Set(['sId','sAdminId','sRemrk','sDateTime','sourceKey']),
   Temp: new Set(['id','useType','str3','str5','str6','describe','cdate','userId','sourceKey']),
   StoreApplication: new Set(['id','userId','userName','addTime','storeState','storeCommendState','storeModelId','sourceKey']),
@@ -1147,6 +1149,32 @@ async function handler(request, response) {
       if (String(input.className || '') !== 'DesignRes' || String(input.action || '') !== 'delete') fail(400, '不支持的设计资源批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个设计资源');
       const fields = await schemaFor('DesignRes'); const query = new Parse.Query('DesignRes'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分设计资源不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-design-resource', 'DesignRes', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
     }
+    if (operation === 'roles') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const type = String(input.type || 'admin').trim().toLowerCase(); const search = String(input.search || '').trim().toLowerCase(); if (!['admin','user','-100'].includes(type) || search.length > 100) fail(400, '角色类型或搜索词无效');
+      const fields = await schemaFor('Role'); const query = new Parse.Query('Role'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (type === '-100' || String(entry.get('ztype') || 'admin').toLowerCase() === type) && (!search || [entry.get('roleName'),entry.get('description')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('roleId') || 0) - Number(right.get('roleId') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
+      return response.json({ success: true, data: { className: 'Role', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'saveRole') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const roleName = String(payload.roleName || '').trim(); const description = String(payload.description || '').trim(); const requestedType = String(input.roleType || payload.ztype || 'admin').trim().toLowerCase(); if (!roleName || roleName.length > 100 || description.length > 1000 || !['admin','user'].includes(requestedType)) fail(400, '角色名称、说明或类型无效');
+      let companyId = pointerId(context.company); if (context.isSuperAdmin && input.companyId) companyId = String(input.companyId); if (!companyId) fail(400, '角色必须指定帐套'); const company = await new Parse.Query('Company').get(companyId, { useMasterKey: true }); const fields = await schemaFor('Role'); let target; let created = false; let roleType = requestedType;
+      if (objectId) { const query = new Parse.Query('Role'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; roleType = String(target.get('ztype') || 'admin').toLowerCase(); if (requestedType !== roleType) fail(400, '角色类型不允许在管理员角色与用户角色之间迁移'); } else { target = new Parse.Object('Role'); target.set('company', company); target.set('sourceKey', 'cloud:role:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('ztype', roleType); target.set('zstatus', 99); target.set('nodeId', 0); target.set('auth', ''); target.set('auth2', ''); target.set('auth3', ''); target.set('cdate', new Date()); target.set('cadminId', Number(context.current.get('legacyUserId') || (context.current.get('legacyUserData') || {}).UserID || 0)); created = true; }
+      const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "Role" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("roleName",\'\')))=LOWER($3) AND LOWER(COALESCE("ztype",\'admin\'))=$4 LIMIT 1', [companyId, objectId, roleName, roleType]); if (duplicate) fail(409, '同一帐套同一类型的角色名称不能重复'); target.set('roleName', roleName); target.set('description', description);
+      try { await target.save(null, { useMasterKey: true }); if (created) { const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-role-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("roleId"),0)+1 AS id FROM "Role",lock_row WHERE "company"=$1) UPDATE "Role" SET "roleId"=next_id.id,"sourceKey"=\'[["RoleID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(rows[0] && rows[0].id)) throw new Error('无法分配角色编号'); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '角色保存失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || company }, objectId ? 'update-role' : 'create-role', 'Role', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'roleAuthorization') {
+      const objectId = String(input.objectId || ''); if (!objectId) fail(400, '缺少角色 objectId'); const roleFields = await schemaFor('Role'); const roleQuery = new Parse.Query('Role'); applyTenant(roleQuery, roleFields, context, input.companyId); const role = await roleQuery.get(objectId, { useMasterKey: true }); const roleId = Number(role.get('roleId')) || 0; if (!roleId) fail(409, '角色缺少旧数字 ID'); const authFields = await schemaFor('ARoleAuth'); const authQuery = new Parse.Query('ARoleAuth'); applyTenant(authQuery, authFields, { ...context, company: role.get('company') || context.company }, pointerId(role.get('company'))); authQuery.equalTo('rid', roleId); authQuery.limit(2); const authRows = await authQuery.find({ useMasterKey: true }); if (authRows.length > 1) fail(409, '角色存在重复权限记录'); const names = ['model','content','shop','page','exam','user','system','office','portable','sites','other','extend']; const authorization = authRows[0] ? serializeObject(authRows[0]) : { rid: roleId }; for (const name of names) if (!Object.prototype.hasOwnProperty.call(authorization, name)) authorization[name] = '';
+      return response.json({ success: true, data: { ...serializeObject(role), authorization } });
+    }
+    if (operation === 'saveRoleAuthorization') {
+      const roleObjectId = String(input.roleObjectId || input.objectId || ''); const payload = input.authorization && typeof input.authorization === 'object' ? input.authorization : {}; if (!roleObjectId) fail(400, '缺少角色 objectId'); const roleFields = await schemaFor('Role'); const roleQuery = new Parse.Query('Role'); applyTenant(roleQuery, roleFields, context, input.companyId); const role = await roleQuery.get(roleObjectId, { useMasterKey: true }); const roleId = Number(role.get('roleId')) || 0; const companyId = pointerId(role.get('company')) || pointerId(context.company); if (!roleId || !companyId) fail(409, '角色缺少旧编号或帐套'); const names = ['model','content','shop','page','exam','user','system','office','portable','sites','other','extend']; const normalized = {}; let totalLength = 0; for (const name of names) { const values = [...new Set(String(payload[name] || '').split(',').map((value) => value.trim()).filter(Boolean))]; if (values.length > 100 || values.some((value) => !/^[A-Za-z0-9_.:-]{1,64}$/.test(value))) fail(400, '角色权限码格式无效'); normalized[name] = values.join(','); totalLength += normalized[name].length; } if (totalLength > 10000) fail(400, '角色权限码过长');
+      const authFields = await schemaFor('ARoleAuth'); const authQuery = new Parse.Query('ARoleAuth'); applyTenant(authQuery, authFields, { ...context, company: role.get('company') || context.company }, companyId); authQuery.equalTo('rid', roleId); authQuery.limit(2); const rows = await authQuery.find({ useMasterKey: true }); if (rows.length > 1) fail(409, '角色存在重复权限记录'); let target = rows[0]; const created = !target; if (!target) { target = new Parse.Object('ARoleAuth'); target.set('company', role.get('company')); target.set('rid', roleId); target.set('sourceKey', 'cloud:role-auth:' + companyId + ':' + roleId + ':' + Date.now()); } for (const [name,value] of Object.entries(normalized)) target.set(name, value); target.set('adminId', Number(context.current.get('legacyUserId') || (context.current.get('legacyUserData') || {}).UserID || 0));
+      try { await target.save(null, { useMasterKey: true }); if (created) { const assigned = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-role-auth-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "ARoleAuth",lock_row WHERE "company"=$1) UPDATE "ARoleAuth" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]); if (!Number(assigned[0] && assigned[0].id)) throw new Error('无法分配角色权限编号'); } } catch (error) { if (created && target.id) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '角色权限保存失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: role.get('company') || context.company }, created ? 'create-role-authorization' : 'update-role-authorization', 'ARoleAuth', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'roleBatch') {
+      if (String(input.className || '') !== 'Role' || String(input.action || '') !== 'delete') fail(400, '不支持的角色批量操作'); const objectIds = [...new Set((Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]).map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个角色'); const fields = await schemaFor('Role'); const query = new Parse.Query('Role'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分角色不存在或不属于当前帐套'); const authFields = await schemaFor('ARoleAuth'); const authTargets = [];
+      for (const target of targets) { const companyId = pointerId(target.get('company')); const roleId = Number(target.get('roleId')) || 0; if (!companyId || !roleId) fail(409, '角色缺少旧编号或帐套'); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Manager" WHERE "company"=$1 AND POSITION(\',\'||$2::text||\',\' IN \',\'||REPLACE(COALESCE("adminRole",\'\'),\' \',\'\')||\',\')>0) AS managers,(SELECT COUNT(*)::int FROM "_User" WHERE "company"=$1 AND POSITION(\',\'||$2::text||\',\' IN \',\'||REPLACE(COALESCE("legacyUserData"->>\'UserRole\',\'\'),\' \',\'\')||\',\')>0) AS users', [companyId, roleId]); if (Number(refs.managers) || Number(refs.users)) fail(409, '角色仍被管理员或用户引用,不能删除'); const authQuery = new Parse.Query('ARoleAuth'); applyTenant(authQuery, authFields, { ...context, company: target.get('company') || context.company }, companyId); authQuery.equalTo('rid', roleId); authQuery.limit(100); authTargets.push(...await authQuery.find({ useMasterKey: true })); }
+      if (authTargets.length) await Parse.Object.destroyAll(authTargets, { useMasterKey: true }); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-role', 'Role', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, deletedAuthorizations: authTargets.length } });
+    }
     if (operation === 'legacyFontPicBlocker') {
       const action = String(input.action || ''); const blockers = { Index: '目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据', FontPicInfo: '目标 Schema 缺少旧 FontPic 成品字体图详情数据', FontPicInfo_Submit: '目标 Schema 缺少旧 FontPic 成品字体图持久化模型', FontPic_API: '目标 Schema 缺少旧 FontPic 成品字体图可删除记录', Draft: '目标 Schema 缺少旧 FontPicDraft 草稿表与历史数据', DraftInfo: '目标 Schema 缺少旧 FontPicDraft 草稿详情数据', DraftInfo_Submit: '目标 Schema 缺少旧 FontPicDraft 草稿持久化模型', Draft_API: '目标 Schema 缺少旧 FontPicDraft 可删除记录' }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧字体图动作'); fail(501, 'migration_blocked: ' + blockers[action]);
     }
@@ -1994,6 +2022,7 @@ async function handler(request, response) {
       if (className === 'PageStyle') fail(400, '黄页样式必须走专用保存流程');
       if (className === 'PlatComp') fail(400, '协同办公企业必须走专用保存流程');
       if (className === 'DesignRes') fail(400, '设计资源必须走专用保存流程');
+      if (className === 'Role') fail(400, '系统角色必须走专用保存流程');
       if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
       let object;
       if (objectId) { const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); object = await query.get(objectId, { useMasterKey: true }); } else object = new Parse.Object(className);
@@ -2017,6 +2046,7 @@ async function handler(request, response) {
       if (className === 'PageStyle') fail(400, '黄页样式必须走专用批量流程');
       if (className === 'PlatComp') fail(400, '协同办公企业必须走专用批量流程');
       if (className === 'DesignRes') fail(400, '设计资源必须走专用批量流程');
+      if (className === 'Role') fail(400, '系统角色必须走专用批量流程');
       if (className === 'Guestcate') { const cateid = Number(object.get('cateid')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=$2) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=$2) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=$2) AS posts', [companyId, cateid]); if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-guest-category', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === 'ExamClass') { const classId = Number(object.get('cId')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "ExamClass" WHERE "company"=$1 AND "cClassid"=$2) AS children,(SELECT COUNT(*)::int FROM "ExamSysQuestions" WHERE "company"=$1 AND "pClass"=$2) AS questions', [companyId, classId]); if (Number(refs.children) || Number(refs.questions)) fail(409, '试题分类仍被下级分类或试题引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-class', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === 'ExamPoint') { const pointId = Number(object.get('id')) || 0; const companyId = pointerId(object.get('company')); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "ExamPoint" WHERE "company"=$1 AND "tid"=$2', [companyId, pointId]); if (Number(children.count)) fail(409, '考点仍有下级考点,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-point', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }

+ 11 - 0
scripts/generate-admin-coverage.mjs

@@ -313,6 +313,17 @@ const explicit = new Map([
   ['Design / Resource/ResourceAdd', ['partial', '现有资源可编辑元数据并查看原文件/预览图路径;新增与文件替换受 Parse 文件适配器阻塞']],
   ['Design / Resource/ResourceAdd_Submit', ['partial', 'saveDesignResource 已白名单更新 Name/style/use/Useage/ZStatus/ZType/fun 并保护旧 ID 与文件路径;上传明确返回 migration_blocked']],
   ['Design / Resource/Resource_API', ['implemented', '已由 designResourceBatch 覆盖帐套隔离的单条与每批 1–100 条资源记录删除']],
+  ['User / Role/UserRole', ['implemented', '已由 roles 用户角色列表覆盖帐套隔离、搜索和分页']],
+  ['User / Role/UserRoleAdd', ['implemented', '已由 Angular 用户角色新建/编辑抽屉替代']],
+  ['User / Role/UserRoleAdd_Submit', ['implemented', 'saveRole 已覆盖用户角色名称防重、原子旧编号和白名单保存']],
+  ['User / Role/UserRoleAuth', ['implemented', 'roleAuthorization 已联合返回用户角色与 12 个旧 ARoleAuth 权限域']],
+  ['User / Role/UserRole_API', ['implemented', 'roleBatch 已覆盖帐套隔离删除、账号引用保护和权限记录级联清理']],
+  ['User / Role/ARole', ['implemented', '已由 roles 管理员角色列表覆盖旧 500 条上限列表与名称搜索']],
+  ['User / Role/ARole_API', ['implemented', 'roleBatch 已覆盖管理员角色批量删除与 Manager.adminRole 引用保护']],
+  ['User / Role/ARoleAdd', ['implemented', '已由 Angular 管理员角色新建/编辑抽屉替代']],
+  ['User / Role/ARoleAdd_Submit', ['implemented', 'saveRole 已覆盖管理员角色名称、说明、类型固化和原子 roleId']],
+  ['User / Role/ARoleAuth', ['implemented', '已在角色详情中完整展示 model/content/shop/page/exam/user/system/office/portable/sites/other/extend']],
+  ['User / Role/ARoleAuth_Submit', ['implemented', 'saveRoleAuthorization 已覆盖 12 域权限码去重校验、新建/更新和原子权限编号']],
   ['Design / Interactive/InteractiveList', ['blocked-data', '目标缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer/Pub 不等价']],
   ['Design / Interactive/Interactive_API', ['blocked-data', '目标没有可按旧 Design_Pub ID 删除的交互提交记录']],
   ['Office / iServer/Default', ['blocked-data', '目标 Schema 缺少旧 IServer 服务工单主表与回复表']],

+ 8 - 0
scripts/smoke-admin-functions.mjs

@@ -102,6 +102,8 @@ let temporaryFontShapeId = '';
 let temporaryFontShapeTypeId = '';
 let designResourceRestore = null;
 let temporaryDesignResourceId = '';
+let temporaryRoleId = '';
+let temporaryRoleAuthId = '';
 let surveyRestore = null;
 let surveyQuestionRestore = null;
 let temporarySurveyId = '';
@@ -324,6 +326,10 @@ try {
   const restoreDesignField = (name) => Object.prototype.hasOwnProperty.call(sampleDesignResource, name) ? (name === 'zstatus' ? Number(sampleDesignResource[name]) : sampleDesignResource[name]) : { __op: 'Delete' }; designResourceRestore = { objectId: sampleDesignResource.objectId, fields: Object.fromEntries(['name','style','use','useage','zstatus','ztype','fun'].map((name) => [name, restoreDesignField(name)])) }; const designProbeName = `${String(sampleDesignResource.name || '')} [angular-smoke]`.trim(); const updatedDesignResource = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDesignResource', className: 'DesignRes', objectId: sampleDesignResource.objectId, fields: { ...sampleDesignResource, name: designProbeName } }); if (updatedDesignResource.name !== designProbeName || updatedDesignResource.vpath !== sampleDesignResource.vpath || updatedDesignResource.previewimg !== sampleDesignResource.previewimg) throw new Error('设计资源元数据专用编辑异常');
   const designFileBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDesignResource', className: 'DesignRes', objectId: sampleDesignResource.objectId, fields: { ...sampleDesignResource, name: designProbeName }, file: { name: 'design-resource-smoke.png', mimeType: 'image/png', base64: 'iVBORw0KGgo=' } }, 501); if (!String(designFileBlocked.message).includes('文件适配器') || !String(designFileBlocked.message).includes('PostgreSQL')) throw new Error('设计资源文件阻塞未返回明确原因'); await jsonRequest(`${PARSE_URL}/classes/DesignRes/${designResourceRestore.objectId}`, { method: 'PUT', body: JSON.stringify(designResourceRestore.fields) }, true); designResourceRestore = null;
   const temporaryDesignResource = await jsonRequest(`${PARSE_URL}/classes/DesignRes`, { method: 'POST', body: JSON.stringify({ name: '__design_resource_batch_smoke__', ztype: 'img', useage: 'smoke', vpath: '/smoke/design-resource.png', sourceKey: `smoke-design-resource-${Date.now()}`, company }) }, true); temporaryDesignResourceId = temporaryDesignResource.objectId; const deletedTemporaryDesignResource = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designResourceBatch', className: 'DesignRes', action: 'delete', objectIds: [temporaryDesignResourceId] }); if (deletedTemporaryDesignResource.updated !== 1) throw new Error('设计资源批量删除失败'); temporaryDesignResourceId = '';
+  const roleSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'Role' }); const roleFieldMap = Object.fromEntries((roleSchema.fields || []).map((field) => [field.name, field])); if (roleSchema.writable !== true || roleSchema.creatable !== true || ['roleId','ztype','zstatus','nodeId','auth','auth2','auth3','cadminId','cdate','sourceKey'].some((name) => roleFieldMap[name]?.writable !== false)) throw new Error('角色结构字段未隔离'); const roleAuthSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'ARoleAuth' }); if (roleAuthSchema.writable !== false || roleAuthSchema.fields?.some((field) => field.writable)) throw new Error('角色权限类未禁止通用写入');
+  const adminRoles = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'roles', className: 'Role', type: 'admin', page: 1, pageSize: 100, search: '' }); const userRoles = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'roles', className: 'Role', type: 'user', page: 1, pageSize: 100, search: '' }); if (adminRoles.total < 9 || adminRoles.results.some((entry) => String(entry.ztype) !== 'admin') || userRoles.total < 3 || userRoles.results.some((entry) => String(entry.ztype) !== 'user')) throw new Error('管理员/用户角色分类列表异常'); const referencedRole = adminRoles.results.find((entry) => Number(entry.roleId) === 1); if (!referencedRole) throw new Error('缺少历史超级管理员角色'); const referencedRoleDelete = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'roleBatch', className: 'Role', action: 'delete', objectIds: [referencedRole.objectId] }, 409); if (!String(referencedRoleDelete.message).includes('引用')) throw new Error('角色账号引用保护未返回明确原因');
+  const roleProbeName = `__angular_role_smoke_${Date.now()}__`; const createdRole = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveRole', className: 'Role', roleType: 'admin', fields: { roleName: roleProbeName, description: 'temporary role' } }); temporaryRoleId = createdRole.objectId; if (!temporaryRoleId || !Number(createdRole.roleId) || createdRole.ztype !== 'admin') throw new Error('角色原子新增失败'); const duplicateRole = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveRole', className: 'Role', roleType: 'admin', fields: { roleName: roleProbeName, description: 'duplicate' } }, 409); if (!String(duplicateRole.message).includes('重复')) throw new Error('角色名称防重异常');
+  const savedRoleAuth = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveRoleAuthorization', className: 'Role', roleObjectId: temporaryRoleId, authorization: { model: 'manage, manage,edit', content: 'manage', shop: '', page: '', exam: '', user: '', system: '', office: '', portable: '', sites: '', other: '', extend: '' } }); temporaryRoleAuthId = savedRoleAuth.objectId; if (!temporaryRoleAuthId || !Number(savedRoleAuth.id) || savedRoleAuth.model !== 'manage,edit' || Number(savedRoleAuth.rid) !== Number(createdRole.roleId)) throw new Error('角色权限新增、去重或原子编号异常'); const roleAuthorization = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'roleAuthorization', className: 'Role', objectId: temporaryRoleId }); if (roleAuthorization.authorization?.objectId !== temporaryRoleAuthId || roleAuthorization.authorization?.model !== 'manage,edit') throw new Error('角色与权限联合详情异常'); const deletedRole = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'roleBatch', className: 'Role', action: 'delete', objectIds: [temporaryRoleId] }); if (deletedRole.updated !== 1 || deletedRole.deletedAuthorizations !== 1) throw new Error('角色与权限级联删除异常'); temporaryRoleId = ''; temporaryRoleAuthId = '';
   const surveySchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignAsk' }); const surveyFieldMap = Object.fromEntries((surveySchema.fields || []).map((field) => [field.name, field])); if (surveySchema.creatable !== false || surveySchema.writable !== true || ['id','cdate','cuser','adminId','sourceKey'].some((name) => surveyFieldMap[name]?.writable !== false)) throw new Error('问卷系统字段未隔离');
   const surveyQuestionSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignQuestion' }); const surveyQuestionFieldMap = Object.fromEntries((surveyQuestionSchema.fields || []).map((field) => [field.name, field])); if (surveyQuestionSchema.creatable !== false || surveyQuestionSchema.writable !== true || ['id','askId','cdate','cuser','orderId','sourceKey'].some((name) => surveyQuestionFieldMap[name]?.writable !== false)) throw new Error('问卷题目系统字段未隔离');
   const surveyAnswerSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignAnswer' }); if (surveyAnswerSchema.writable !== false || surveyAnswerSchema.creatable !== false) throw new Error('问卷答卷未保持只读');
@@ -936,6 +942,8 @@ try {
   if (temporaryFontShapeTypeId) await jsonRequest(`${PARSE_URL}/classes/FontPicShapeType/${temporaryFontShapeTypeId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时图形分类清理失败:${error.message}`); process.exitCode = 1; });
   if (designResourceRestore) await jsonRequest(`${PARSE_URL}/classes/DesignRes/${designResourceRestore.objectId}`, { method: 'PUT', body: JSON.stringify(designResourceRestore.fields) }, true).catch((error) => { console.error(`设计资源冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (temporaryDesignResourceId) await jsonRequest(`${PARSE_URL}/classes/DesignRes/${temporaryDesignResourceId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时设计资源清理失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryRoleAuthId) await jsonRequest(`${PARSE_URL}/classes/ARoleAuth/${temporaryRoleAuthId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时角色权限清理失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryRoleId) await jsonRequest(`${PARSE_URL}/classes/Role/${temporaryRoleId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时角色清理失败:${error.message}`); process.exitCode = 1; });
   if (surveyRestore) await jsonRequest(`${PARSE_URL}/classes/DesignAsk/${surveyRestore.objectId}`, { method: 'PUT', body: JSON.stringify(surveyRestore.fields) }, true).catch((error) => { console.error(`问卷冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (surveyQuestionRestore) await jsonRequest(`${PARSE_URL}/classes/DesignQuestion/${surveyQuestionRestore.objectId}`, { method: 'PUT', body: JSON.stringify(surveyQuestionRestore.fields) }, true).catch((error) => { console.error(`问卷题目冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (temporarySurveyAnswerId) await jsonRequest(`${PARSE_URL}/classes/DesignAnswer/${temporarySurveyAnswerId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时问卷答卷清理失败:${error.message}`); process.exitCode = 1; });

+ 22 - 22
src/app/admin/legacy-admin-coverage.generated.ts

@@ -5263,78 +5263,78 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "User / Role",
     "action": "UserRole",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 roles 用户角色列表覆盖帐套隔离、搜索和分页"
   },
   {
     "module": "User / Role",
     "action": "UserRoleAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 用户角色新建/编辑抽屉替代"
   },
   {
     "module": "User / Role",
     "action": "UserRoleAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "saveRole 已覆盖用户角色名称防重、原子旧编号和白名单保存"
   },
   {
     "module": "User / Role",
     "action": "UserRoleAuth",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "roleAuthorization 已联合返回用户角色与 12 个旧 ARoleAuth 权限域"
   },
   {
     "module": "User / Role",
     "action": "UserRole_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "roleBatch 已覆盖帐套隔离删除、账号引用保护和权限记录级联清理"
   },
   {
     "module": "User / Role",
     "action": "ARole",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 roles 管理员角色列表覆盖旧 500 条上限列表与名称搜索"
   },
   {
     "module": "User / Role",
     "action": "ARole_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "roleBatch 已覆盖管理员角色批量删除与 Manager.adminRole 引用保护"
   },
   {
     "module": "User / Role",
     "action": "ARoleAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 管理员角色新建/编辑抽屉替代"
   },
   {
     "module": "User / Role",
     "action": "ARoleAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "saveRole 已覆盖管理员角色名称、说明、类型固化和原子 roleId"
   },
   {
     "module": "User / Role",
     "action": "ARoleAuth",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已在角色详情中完整展示 model/content/shop/page/exam/user/system/office/portable/sites/other/extend"
   },
   {
     "module": "User / Role",
     "action": "ARoleAuth_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "saveRoleAuthorization 已覆盖 12 域权限码去重校验、新建/更新和原子权限编号"
   },
   {
     "module": "Extend / Safe",

+ 11 - 0
src/app/admin/pages/admin-resource.component.html

@@ -23,6 +23,9 @@
   @if (className() === 'DesignRes') {
     <div class="bulk-toolbar"><strong>设计资源类型</strong><input maxlength="32" [ngModel]="filterDesignResourceType()" (ngModelChange)="filterDesignResourceType.set($event)" (keyup.enter)="load(1)" placeholder="如 img(精确匹配)" /><button type="button" (click)="load(1)">筛选</button></div>
   }
+  @if (className() === 'Role') {
+    <div class="bulk-toolbar"><strong>角色类型</strong><select [ngModel]="filterRoleType()" (ngModelChange)="filterRoleType.set($event); load(1)"><option value="admin">管理员角色</option><option value="user">用户角色</option></select></div>
+  }
   @if (className() === 'DesignAsk') {
     <div class="bulk-toolbar"><strong>问卷状态</strong><select [ngModel]="filterSurveyStatus()" (ngModelChange)="filterSurveyStatus.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="99">已启用</option><option [ngValue]="0">已停用</option></select></div>
   }
@@ -82,6 +85,8 @@
         <button class="danger-action" type="button" [disabled]="updatingFontShape()" (click)="applyBulkFontShapeDelete()">删除</button>
       } @else if (className() === 'DesignRes') {
         <button class="danger-action" type="button" [disabled]="updatingDesignResource()" (click)="applyBulkDesignResourceDelete()">删除资源记录</button>
+      } @else if (className() === 'Role') {
+        <button class="danger-action" type="button" [disabled]="savingRoleAuthorization()" (click)="applyBulkRoleDelete()">删除角色</button>
       } @else if (className() === 'DesignAsk') {
         <button type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('start')">启用</button><button type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('stop')">停用</button><button class="danger-action" type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('delete')">删除</button>
       } @else if (className() === 'DesignQuestion') {
@@ -214,6 +219,12 @@
         @if (className() === 'DesignRes') {
           <section class="account-create-note"><strong>历史设计资源元数据</strong><span>可编辑名称、类型、用途、状态及样式/函数字段;原文件和预览图保留旧路径并只读。Parse 文件适配器修复前,新增与替换文件会明确阻断。</span><code>{{ draft()['vpath'] || '无原文件路径' }} · {{ draft()['previewimg'] || '无预览图路径' }}</code></section>
         }
+        @if (className() === 'Role') {
+          <section class="account-create-note"><strong>{{ (draft()['ztype'] || filterRoleType()) === 'user' ? '用户角色' : '管理员角色' }}</strong><span>角色编号、类型、状态和创建人由云函数维护;类型创建后不可迁移。旧 B_ARoleAuth 权限校验在交付程序中固定返回 true,下方完整保留旧权限码,但不用它绕过新云函数的管理员校验。</span>@if (draftId()) { <code>角色 ID {{ draft()['roleId'] }} · {{ draft()['ztype'] }}</code> }</section>
+          @if (draftId()) {
+            <section class="content-controls"><header><div><strong>12 个旧权限域</strong><span>使用英文逗号分隔权限码,保存时去重并校验格式</span></div></header>@for (field of roleAuthorizationFields; track field[0]) { <label><span>{{ field[1] }} <small>{{ field[0] }}</small></span><textarea rows="2" [ngModel]="roleAuthorization()[field[0]]" (ngModelChange)="setRoleAuthorizationField(field[0], $event)" placeholder="manage,edit,delete"></textarea></label> }<button type="button" [disabled]="savingRoleAuthorization()" (click)="saveRoleAuthorization()">{{ savingRoleAuthorization() ? '权限保存中…' : '保存旧权限码' }}</button>@if (roleAuthorizationError()) { <div class="content-action-status error">{{ roleAuthorizationError() }}</div> }@if (roleAuthorizationMessage()) { <div class="content-action-status">{{ roleAuthorizationMessage() }}</div> }</section>
+          }
+        }
         @if (className() === 'Baike') {
           <section class="account-create-note"><strong>百科词条只读详情</strong><span>目标仅保留 Baike 主记录;可在列表执行审核和推荐状态操作。旧 BaikeEdit 版本表、版本应用/驳回链与积分奖励运行时已缺失,不提供伪版本操作。</span></section>
         }

+ 51 - 0
src/app/admin/pages/admin-resource.component.spec.ts

@@ -681,6 +681,57 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('1 个设计资源');
   });
 
+  it('loads legacy roles through the dedicated role-type filter', async () => {
+    component.className.set('Role');
+    component.schema.set({ className: 'Role', label: '系统角色', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });
+    component.filterRoleType.set('user'); component.search.set('商城');
+    functions.admin.and.resolveTo({ className: 'Role', page: 2, pageSize: 20, total: 3, results: [] });
+
+    await component.load(2);
+
+    expect(functions.admin.calls.first().args).toEqual(['roles', { className: 'Role', page: 2, pageSize: 20, search: '商城', type: 'user' }]);
+  });
+
+  it('loads a role together with all legacy authorization domains', async () => {
+    component.className.set('Role');
+    functions.admin.and.resolveTo({ objectId: 'role-object', roleId: 2, roleName: '内容管理员', ztype: 'admin', authorization: { model: 'manage', content: 'manage,edit' } });
+
+    await component.openEdit({ objectId: 'role-object' });
+
+    expect(functions.admin).toHaveBeenCalledWith('roleAuthorization', { className: 'Role', objectId: 'role-object' });
+    expect(component.roleAuthorization()['content']).toBe('manage,edit');
+    expect(component.roleAuthorization()['shop']).toBe('');
+  });
+
+  it('saves role metadata and authorization through dedicated operations', async () => {
+    component.className.set('Role'); component.filterRoleType.set('admin'); component.draftId.set('role-object');
+    component.schema.set({ className: 'Role', label: '系统角色', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ objectId: 'role-object', roleId: 2, roleName: '内容管理员', description: '内容', ztype: 'admin' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveRoleAuthorization'
+      ? { objectId: 'auth-object', model: 'manage', content: 'manage,edit' }
+      : operation === 'saveRole' ? { objectId: 'role-object', roleId: 2 } : { className: 'Role', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+    expect(functions.admin.calls.first().args).toEqual(['saveRole', { className: 'Role', objectId: 'role-object', fields: { objectId: 'role-object', roleId: 2, roleName: '内容管理员', description: '内容', ztype: 'admin' }, roleType: 'admin' }]);
+
+    component.draftId.set('role-object'); component.roleAuthorization.set({ model: 'manage', content: 'manage,edit' }); functions.admin.calls.reset();
+    await component.saveRoleAuthorization();
+    expect(functions.admin).toHaveBeenCalledWith('saveRoleAuthorization', { className: 'Role', roleObjectId: 'role-object', authorization: { model: 'manage', content: 'manage,edit' } });
+    expect(component.roleAuthorizationMessage()).toContain('已保存');
+  });
+
+  it('deletes selected roles through the reference-safe batch operation', async () => {
+    component.className.set('Role'); component.selectedIds.set(['role-a']); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.callFake(async (operation: string) => operation === 'roleBatch'
+      ? { action: 'delete', updated: 1 }
+      : { className: 'Role', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkRoleDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['roleBatch', { className: 'Role', action: 'delete', objectIds: ['role-a'] }]);
+    expect(component.bulkMessage()).toContain('1 个角色');
+  });
+
   it('loads survey questions through the selected legacy survey id', async () => {
     component.className.set('DesignQuestion');
     component.schema.set({ className: 'DesignQuestion', label: '问卷题目', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });

+ 42 - 6
src/app/admin/pages/admin-resource.component.ts

@@ -130,6 +130,15 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingFontShape = signal(false);
   readonly filterDesignResourceType = signal('');
   readonly updatingDesignResource = signal(false);
+  readonly filterRoleType = signal<'admin' | 'user'>('admin');
+  readonly roleAuthorization = signal<Record<string, string>>({});
+  readonly savingRoleAuthorization = signal(false);
+  readonly roleAuthorizationError = signal('');
+  readonly roleAuthorizationMessage = signal('');
+  readonly roleAuthorizationFields = [
+    ['model', '模型'], ['content', '内容'], ['shop', '商城'], ['page', '黄页'], ['exam', '考试'], ['user', '用户'],
+    ['system', '系统'], ['office', '办公'], ['portable', '移动端'], ['sites', '站群'], ['other', '其他'], ['extend', '扩展'],
+  ] as const;
   readonly filterSurveyStatus = signal(-100);
   readonly filterSurveyId = signal(0);
   readonly surveys = signal<Record<string, unknown>[]>([]);
@@ -182,7 +191,7 @@ export class AdminResourceComponent implements OnInit {
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'DesignRes', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike', 'CRMSAttr', 'ShopFareTlp', 'MisType', 'PageStyle', 'PlatComp'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'Role', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'DesignRes', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike', 'CRMSAttr', 'ShopFareTlp', 'MisType', 'PageStyle', 'PlatComp'].includes(this.className()));
   readonly allPageSelected = computed(() => {
     const ids = (this.page()?.results || []).map((row) => String(row['objectId'] || '')).filter(Boolean);
     return Boolean(ids.length) && ids.every((id) => this.selectedIds().includes(id));
@@ -200,6 +209,7 @@ export class AdminResourceComponent implements OnInit {
       this.filterAdZoneType.set(''); this.filterAdStatus.set(-100); this.filterAdInfoZoneId.set(''); this.resetAdActions();
       this.filterFontShapeTypeId.set(0); this.resetFontShapeActions();
       this.filterDesignResourceType.set('');
+      this.filterRoleType.set('admin'); this.resetRoleActions();
       this.filterSurveyStatus.set(-100); this.filterSurveyId.set(0); this.resetSurveyActions();
       this.resetServiceActions();
       this.filterStoreStatus.set(-100); this.resetStoreActions();
@@ -221,7 +231,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
       if (['DesignQuestion', 'DesignAnswer'].includes(this.className()) && reloadSchema) { await this.loadSurveys(); if (!this.filterSurveyId() && this.surveys().length) this.filterSurveyId.set(this.surveyIdOf(this.surveys()[0])); }
       if (this.className() === 'StoreApplication' && reloadSchema) await this.loadStoreStyles();
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignRes' ? 'designResources' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'UserPromotion' ? 'userPromotions' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : this.className() === 'PageStyle' ? 'pageStyles' : this.className() === 'PlatComp' ? 'platformCompanies' : 'list';
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'Role' ? 'roles' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignRes' ? 'designResources' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'UserPromotion' ? 'userPromotions' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : this.className() === 'PageStyle' ? 'pageStyles' : this.className() === 'PlatComp' ? 'platformCompanies' : 'list';
       const pagePayload: Record<string, unknown> = { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() };
       if (pageOperation === 'gradeOptions') { pagePayload['categoryId'] = this.filterGradeCategoryId(); pagePayload['parentId'] = this.filterGradeParentId(); }
       if (pageOperation === 'searchNavigations') { pagePayload['type'] = this.filterSearchNavigationType(); pagePayload['state'] = this.filterSearchNavigationState(); pagePayload['elite'] = this.filterSearchNavigationElite(); }
@@ -229,6 +239,7 @@ export class AdminResourceComponent implements OnInit {
       if (pageOperation === 'adInfos') { pagePayload['zoneId'] = this.filterAdInfoZoneId(); pagePayload['status'] = this.filterAdStatus(); }
       if (pageOperation === 'fontShapes') pagePayload['typeId'] = this.filterFontShapeTypeId();
       if (pageOperation === 'designResources') pagePayload['type'] = this.filterDesignResourceType().trim();
+      if (pageOperation === 'roles') pagePayload['type'] = this.filterRoleType();
       if (pageOperation === 'surveys') pagePayload['status'] = this.filterSurveyStatus();
       if (pageOperation === 'surveyQuestions' || pageOperation === 'surveyResults') pagePayload['askId'] = this.filterSurveyId();
       if (pageOperation === 'storeApplications') pagePayload['status'] = this.filterStoreStatus();
@@ -243,14 +254,14 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.resetExamClassActions(); this.resetExamPointActions(); this.resetKnowledgeActions(); this.resetTeacherActions(); this.resetDictionaryActions(); this.resetGradeActions(); this.resetAdActions(); this.resetFontShapeActions(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); this.resetTagKeyActions(); this.resetPlatformCompanyActions(); this.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.resetExamClassActions(); this.resetExamPointActions(); this.resetKnowledgeActions(); this.resetTeacherActions(); this.resetDictionaryActions(); this.resetGradeActions(); this.resetAdActions(); this.resetFontShapeActions(); this.resetRoleActions(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); this.resetTagKeyActions(); this.resetPlatformCompanyActions(); this.editorOpen.set(true); }
   async openEdit(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
-      const detailOperation = this.className() === 'ContentTagKey' ? 'getContentTagKeys' : this.className() === 'UserMoneyLog' ? 'getUserMoneyLog' : this.className() === 'UserPromotion' ? 'getUserPromotion' : 'get';
+      const detailOperation = this.className() === 'ContentTagKey' ? 'getContentTagKeys' : this.className() === 'UserMoneyLog' ? 'getUserMoneyLog' : this.className() === 'UserPromotion' ? 'getUserPromotion' : this.className() === 'Role' ? 'roleAuthorization' : 'get';
       const detail = await this.functions.admin<Record<string, unknown>>(detailOperation, { className: this.className(), objectId });
-      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.resetBarActions(detail); this.resetExamClassActions(detail); this.resetExamPointActions(detail); this.resetKnowledgeActions(detail); this.resetTeacherActions(detail); this.resetDictionaryActions(detail); this.resetGradeActions(detail); this.resetAdActions(detail); this.resetFontShapeActions(detail); this.resetSurveyActions(); this.resetServiceActions(detail); this.resetStoreActions(detail); this.resetTagKeyActions(detail); this.resetPlatformCompanyActions(detail); this.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.resetBarActions(detail); this.resetExamClassActions(detail); this.resetExamPointActions(detail); this.resetKnowledgeActions(detail); this.resetTeacherActions(detail); this.resetDictionaryActions(detail); this.resetGradeActions(detail); this.resetAdActions(detail); this.resetFontShapeActions(detail); this.resetRoleActions(detail); this.resetSurveyActions(); this.resetServiceActions(detail); this.resetStoreActions(detail); this.resetTagKeyActions(detail); this.resetPlatformCompanyActions(detail); this.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();
       if (this.className() === 'AdInfo' && !this.adZones().length) await this.loadAdZones();
       if (this.className() === 'AdZone') await this.loadAdPreview();
@@ -258,7 +269,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'PlatComp') await this.loadPlatformMembers();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '详情加载失败'); }
   }
-  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.resetExamClassActions(); this.resetExamPointActions(); this.resetKnowledgeActions(); this.resetTeacherActions(); this.resetDictionaryActions(); this.resetGradeActions(); this.resetAdActions(); this.resetFontShapeActions(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); this.resetTagKeyActions(); this.resetPlatformCompanyActions(); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.resetExamClassActions(); this.resetExamPointActions(); this.resetKnowledgeActions(); this.resetTeacherActions(); this.resetDictionaryActions(); this.resetGradeActions(); this.resetAdActions(); this.resetFontShapeActions(); this.resetRoleActions(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); this.resetTagKeyActions(); this.resetPlatformCompanyActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -312,6 +323,7 @@ export class AdminResourceComponent implements OnInit {
                                               : this.className() === 'FontPicShape' ? 'saveFontShape'
                                                 : this.className() === 'FontPicShapeType' ? 'saveFontShapeType'
                                                   : this.className() === 'DesignRes' ? 'saveDesignResource'
+                                                    : this.className() === 'Role' ? 'saveRole'
                                                   : this.className() === 'DesignAsk' ? 'saveSurvey'
                                                     : this.className() === 'DesignQuestion' ? 'saveSurveyQuestion'
                                                       : this.className() === 'ServiceSeat' ? 'saveServiceSeat'
@@ -341,6 +353,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'StoreApplication') { payload['username'] = this.targetStoreUsername(); payload['styleId'] = this.targetStoreStyleId(); }
       if (this.className() === 'ContentTagKey') payload['content'] = this.tagKeyContent();
       if (this.className() === 'PlatComp') payload['creatorUsername'] = this.platformCreatorUsername();
+      if (this.className() === 'Role') payload['roleType'] = String(this.draft()['ztype'] || this.filterRoleType());
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -732,6 +745,27 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '设计资源删除失败'); }
     finally { this.updatingDesignResource.set(false); }
   }
+  setRoleAuthorizationField(name: string, value: string): void {
+    this.roleAuthorization.update((current) => ({ ...current, [name]: value }));
+  }
+  async saveRoleAuthorization(): Promise<void> {
+    if (this.className() !== 'Role' || !this.draftId()) { this.roleAuthorizationError.set('请先保存并重新打开角色'); return; }
+    this.savingRoleAuthorization.set(true); this.roleAuthorizationError.set(''); this.roleAuthorizationMessage.set('');
+    try {
+      const result = await this.functions.admin<Record<string, string>>('saveRoleAuthorization', { className: 'Role', roleObjectId: this.draftId(), authorization: this.roleAuthorization() });
+      this.roleAuthorization.set(Object.fromEntries(this.roleAuthorizationFields.map(([name]) => [name, String(result[name] || '')]))); this.roleAuthorizationMessage.set('角色权限已保存');
+    } catch (error) { this.roleAuthorizationError.set(error instanceof Error ? error.message : '角色权限保存失败'); }
+    finally { this.savingRoleAuthorization.set(false); }
+  }
+  async applyBulkRoleDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'Role' || !objectIds.length) { this.bulkError.set('请先选择角色'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 个${this.filterRoleType() === 'admin' ? '管理员' : '用户'}角色?仍被账号引用的角色会被拒绝。`)) return;
+    this.savingRoleAuthorization.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('roleBatch', { className: 'Role', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 个角色`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '角色删除失败'); }
+    finally { this.savingRoleAuthorization.set(false); }
+  }
   async applyBulkSurveyAction(action: 'start' | 'stop' | 'sort' | 'delete'): Promise<void> {
     const className = this.className(); const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
     const allowed = className === 'DesignAsk' ? ['start', 'stop', 'delete'] : className === 'DesignQuestion' ? ['sort', 'delete'] : className === 'DesignAnswer' ? ['delete'] : [];
@@ -871,6 +905,7 @@ export class AdminResourceComponent implements OnInit {
       else if (this.className() === 'FontPicShape') await this.functions.admin('fontShapeBatch', { className: 'FontPicShape', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'FontPicShapeType') await this.functions.admin('fontShapeTypeBatch', { className: 'FontPicShapeType', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'DesignRes') await this.functions.admin('designResourceBatch', { className: 'DesignRes', action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'Role') await this.functions.admin('roleBatch', { className: 'Role', action: 'delete', objectIds: [objectId] });
       else if (['DesignAsk', 'DesignQuestion', 'DesignAnswer'].includes(this.className())) await this.functions.admin('surveyBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else if (['ServiceSeat', 'Temp'].includes(this.className())) await this.functions.admin('serviceBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else if (['StoreApplication', 'StoreStyle'].includes(this.className())) await this.functions.admin('storeBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
@@ -957,6 +992,7 @@ export class AdminResourceComponent implements OnInit {
   private resetGradeActions(detail: Record<string, unknown> = {}): void { this.targetGradeCategoryId.set(Number(detail['cate']) || this.filterGradeCategoryId()); this.targetGradeParentId.set(Number(detail['parentId']) || this.filterGradeParentId()); }
   private resetAdActions(detail: Record<string, unknown> = {}): void { this.targetAdZoneId.set(String(detail['zoneId'] || this.filterAdInfoZoneId() || '')); this.adPreview.set([]); this.adPreviewError.set(''); this.adPreviewLoading.set(false); }
   private resetFontShapeActions(detail: Record<string, unknown> = {}): void { this.targetFontShapeTypeId.set(Number(detail['typeId']) || this.filterFontShapeTypeId()); this.fontShapePreview.set(String(detail['shape'] || '')); }
+  private resetRoleActions(detail: Record<string, unknown> = {}): void { const authorization = detail['authorization'] && typeof detail['authorization'] === 'object' ? detail['authorization'] as Record<string, unknown> : {}; this.roleAuthorization.set(Object.fromEntries(this.roleAuthorizationFields.map(([name]) => [name, String(authorization[name] || '')]))); this.roleAuthorizationError.set(''); this.roleAuthorizationMessage.set(''); this.savingRoleAuthorization.set(false); }
   private resetSurveyActions(): void { this.surveyOrderStart.set(1); this.surveyChart.set(null); this.surveyChartError.set(''); this.surveyChartLoading.set(false); }
   private resetServiceActions(detail: Record<string, unknown> = {}): void { this.targetServiceSeatUsername.set(String(detail['sRemrk'] || '')); }
   private resetStoreActions(detail: Record<string, unknown> = {}): void { this.targetStoreUsername.set(String(detail['userName'] || '')); this.targetStoreStyleId.set(Number(detail['storeStyleId']) || 0); }