فهرست منبع

feat: migrate hierarchical dictionary lifecycle

彭峰 2 ماه پیش
والد
کامیت
6662459428

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

@@ -34,8 +34,9 @@
 - 新版考试后台 `KnowledgeManage/AddKnowledge/Knowledge_Add/Knowledge_GetChild` 已单独迁到 `QuestionsKnowledge`,不再与早期把 `ExamPoint` 称作“知识点”的页面混用:`saveKnowledge` 原子分配 `kId/kOrderBy/sourceKey`,要求绑定同帐套真实 `ExamClass`,父知识点必须同科目,并阻断名称重复和层级循环;`knowledgeChildren` 保留科目内子级读取,删除会检查下级知识点及 `ExamSysQuestions.pKnowledge` 引用。Angular 新增知识点导航和科目/父级/排序结构表单;旧展开树视觉仍标为部分实现。
 - 旧考试后台 `ExTeacherManage/AddExTeacher/Teacher_Add` 已迁到 `ExTeacher`:`saveExamTeacher` 仅白名单保存姓名、岗位、授课、头像路径和简介,使用帐套事务锁原子分配旧 `id/sourceKey`,服务端记录创建管理员与时间,并校验任教 `ExamClass` 属于同一帐套。Angular 新增“考试教师”入口和任教分类结构控件;线上 Schema 扫描未发现指向 `ExTeacher` 的 Pointer 或明确数字外键,删除仍保留帐套隔离和审计。
 - 旧数据字典 `DicCateList/DictCate_API/DicCateSel/DicItemList/DicItem_API` 已按官方 `ZL_Datadiccategory/ZL_Datadic` 模型迁为 `saveDictionaryCategory/saveDictionaryItem/dictionaryCategories/dictionaryBatch`:分类与字典项分别分配旧 `DicCateID/DicID`,校验帐套内分类名称和分类内字典项名称防重,支持每批 1–100 条启用、停用和删除;分类仍有字典项时禁止删除。线上这两类的业务字段由迁移兼容层从登记数据还原,Parse Schema 虽声明字段但 PostgreSQL 物理表没有对应列,因此编号改为经 Parse 读取全局最大值并依靠唯一 `sourceKey` 冲突重试,未伪装成不可执行的物理列事务;新分类编号还会高于所有既有 `diccate` 引用,避免错误接管历史字典项。Angular 新增分类、字典项导航和帐套隔离分类选择控件。
+- 旧多级数据字典 `GradeCateManage/GCateAdd/GCateAdd_Submit/GCate_API/GradeOption/GOptionAdd/GOptionAdd_Submit/GOption_API` 已按官方 `ZL_GradeCate/ZL_Grade` 模型迁为 `saveGradeCategory/saveGradeOption/gradeCategories/gradeOptions/gradeBatch`:分类维护至少两级的 `gradeField` 层级别名,选项按分类和父选项自动推导 `grade`,校验同级名称防重、父级归属和最大层级;编辑选项只允许改名,不能篡改 `cate/parentId/grade`。分类和选项分别保护仍有选项、下级选项的删除,Angular 提供按分类及父选项查看当前层、专用新建表单与批量删除。`GradeCate` 的兼容层计数会错误返回 0,因此编号和读取同样使用 Parse 行结果与唯一 `sourceKey` 冲突重试,不依赖失真的 count 或缺失物理业务列。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 53 项已实现、32 项部分实现、114 项数据阻塞、189 项基础设施阻塞、654 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 61 项已实现、32 项部分实现、114 项数据阻塞、189 项基础设施阻塞、646 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -12,6 +12,7 @@ const CLASS_LABELS = {
   VocabularyWord: '词库', CommonModel: '通用内容', ContentArticle: '文章内容', ContentPublish: '内容发布', Node: '栏目节点', NodeAuth: '节点权限', Model: '内容模型', ModelField: '模型字段',
   ExamClass: '试题分类', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目',
   Datadiccategory: '数据字典分类', Datadic: '数据字典项',
+  GradeCate: '多级字典分类', Grade: '多级字典选项',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
   GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '搜索记录', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
@@ -36,7 +37,9 @@ const CLASS_SYSTEM_FIELDS = {
   QuestionsKnowledge: new Set(['kId','kClassId','kOrderBy','pid','cuser','cdate','sourceKey']),
   ExTeacher: new Set(['id','tclsss','addUser','creatTime','sourceKey']),
   Datadiccategory: new Set(['diccateid','sourceKey']),
-  Datadic: new Set(['dicid','diccate','sourceKey'])
+  Datadic: new Set(['dicid','diccate','sourceKey']),
+  GradeCate: new Set(['cateId','sourceKey']),
+  Grade: new Set(['gradeId','cate','parentId','grade','sourceKey'])
 };
 
 function inputOf(request) {
@@ -797,6 +800,87 @@ async function handler(request, response) {
       for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'dictionary-' + action, className, target.id);
       return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
     }
+    if (operation === 'saveGradeCategory') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      const cateName = String(payload.cateName || '').trim(); const remark = String(payload.remark || '').trim();
+      const aliases = String(payload.gradeField || '').split(/\r?\n|\|/).map((value) => value.trim()).filter(Boolean); const gradeField = aliases.join('|');
+      if (!cateName || cateName.length > 50) fail(400, '多级字典分类名称长度应为 1 至 50 位');
+      if (remark.length > 255) fail(400, '多级字典分类备注不能超过 255 位');
+      if (aliases.length < 2 || gradeField.length > 500) fail(400, '层级别名至少填写 2 级,且总长度不能超过 500 位');
+      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('GradeCate'); let target;
+      if (objectId) { const query = new Parse.Query('GradeCate'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; }
+      else target = new Parse.Object('GradeCate');
+      const duplicateQuery = new Parse.Query('GradeCate'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
+      if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('cateName') || '').trim().toLowerCase() === cateName.toLowerCase())) fail(409, '同一帐套的多级字典分类名称不能重复');
+      target.set('cateName', cateName); target.set('remark', remark); target.set('gradeField', gradeField); target.set('company', company);
+      if (objectId) { try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '多级字典分类写入失败: ' + String(error && error.message || error)); } }
+      else {
+        let lastError;
+        for (let attempt = 0; attempt < 5; attempt++) {
+          const categories = new Parse.Query('GradeCate'); categories.limit(1000); const options = new Parse.Query('Grade'); options.limit(1000);
+          const [categoryRows, optionRows] = await Promise.all([categories.find({ useMasterKey: true }), options.find({ useMasterKey: true })]);
+          const nextId = Math.max(0, ...categoryRows.map((entry) => Number(entry.get('cateId')) || 0), ...optionRows.map((entry) => Number(entry.get('cate')) || 0)) + 1;
+          target.set('cateId', nextId); target.set('sourceKey', '[["CateID",' + nextId + ']]');
+          try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
+          catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
+        }
+        if (lastError || !target.id) fail(422, '多级字典分类原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
+      }
+      await audit({ ...context, company }, objectId ? 'update-grade-category' : 'create-grade-category', 'GradeCate', target.id);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'saveGradeOption') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; const gradeName = String(payload.gradeName || '').trim();
+      if (!gradeName || gradeName.length > 50) fail(400, '多级字典选项名称长度应为 1 至 50 位');
+      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('Grade'); let target; let categoryId; let parentId; let level;
+      if (objectId) {
+        const query = new Parse.Query('Grade'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId;
+        categoryId = Number(target.get('cate')) || 0; parentId = Number(target.get('parentId')) || 0; level = Number(target.get('grade')) || 0;
+      } else { target = new Parse.Object('Grade'); categoryId = Number(input.categoryId == null ? payload.cate || 0 : input.categoryId); parentId = Number(input.parentId == null ? payload.parentId || 0 : input.parentId); level = 1; }
+      if (!Number.isInteger(categoryId) || categoryId < 1 || !Number.isInteger(parentId) || parentId < 0) fail(400, '多级字典分类或父选项编号无效');
+      const categoryFields = await schemaFor('GradeCate'); const categoryQuery = new Parse.Query('GradeCate'); applyTenant(categoryQuery, categoryFields, context, input.companyId); categoryQuery.equalTo('cateId', categoryId); const category = await categoryQuery.first({ useMasterKey: true });
+      if (!category || pointerId(category.get('company')) !== companyId) fail(404, '多级字典分类不存在或不属于当前帐套');
+      const aliases = String(category.get('gradeField') || '').split('|').map((value) => value.trim()).filter(Boolean);
+      if (!objectId && parentId > 0) { const parentQuery = new Parse.Query('Grade'); applyTenant(parentQuery, fields, context, input.companyId); parentQuery.equalTo('gradeId', parentId); const parent = await parentQuery.first({ useMasterKey: true }); if (!parent || Number(parent.get('cate')) !== categoryId || pointerId(parent.get('company')) !== companyId) fail(404, '父选项不存在、分类不一致或不属于当前帐套'); level = Number(parent.get('grade')) + 1; }
+      if (!Number.isInteger(level) || level < 1 || level > aliases.length) fail(409, '当前层级已超过分类定义的层级别名');
+      const duplicateQuery = new Parse.Query('Grade'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.equalTo('cate', categoryId); duplicateQuery.equalTo('parentId', parentId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
+      if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('gradeName') || '').trim().toLowerCase() === gradeName.toLowerCase())) fail(409, '同一父级下的多级字典选项名称不能重复');
+      target.set('gradeName', gradeName); target.set('cate', categoryId); target.set('parentId', parentId); target.set('grade', level); target.set('company', company);
+      if (objectId) { try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '多级字典选项写入失败: ' + String(error && error.message || error)); } }
+      else {
+        let lastError;
+        for (let attempt = 0; attempt < 5; attempt++) {
+          const allOptions = new Parse.Query('Grade'); allOptions.limit(1000); const optionRows = await allOptions.find({ useMasterKey: true }); const nextId = Math.max(0, ...optionRows.map((entry) => Number(entry.get('gradeId')) || 0)) + 1;
+          target.set('gradeId', nextId); target.set('sourceKey', '[["GradeID",' + nextId + ']]');
+          try { await target.save(null, { useMasterKey: true }); lastError = null; break; }
+          catch (error) { lastError = error; if (!(Number(error && error.code) === 137 || /duplicate|unique/i.test(String(error && error.message || '')))) break; }
+        }
+        if (lastError || !target.id) fail(422, '多级字典选项原子编号写入失败: ' + String(lastError && lastError.message || lastError || '无法分配编号'));
+      }
+      await audit({ ...context, company }, objectId ? 'update-grade-option' : 'create-grade-option', 'Grade', target.id);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'gradeCategories') {
+      const fields = await schemaFor('GradeCate'); const query = new Parse.Query('GradeCate'); applyTenant(query, fields, context, input.companyId); query.ascending('cateId'); query.limit(1000); const results = await query.find({ useMasterKey: true });
+      return response.json({ success: true, data: { results: results.map(serializeObject) } });
+    }
+    if (operation === 'gradeOptions') {
+      const categoryId = Number(input.categoryId || 0); const parentId = input.parentId == null || input.parentId === '' ? -1 : Number(input.parentId); if (!Number.isInteger(categoryId) || categoryId < 1 || !Number.isInteger(parentId) || parentId < -1) fail(400, '多级字典筛选条件无效');
+      const fields = await schemaFor('Grade'); const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const query = new Parse.Query('Grade'); applyTenant(query, fields, context, input.companyId); query.equalTo('cate', categoryId); if (parentId >= 0) query.equalTo('parentId', parentId);
+      const search = String(input.search || '').trim(); if (search) query.matches('gradeName', escapeRegex(search), 'i'); const total = await query.count({ useMasterKey: true }); query.ascending('gradeId'); query.skip((page - 1) * pageSize); query.limit(pageSize); const results = await query.find({ useMasterKey: true });
+      return response.json({ success: true, data: { className: 'Grade', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'gradeBatch') {
+      const className = String(input.className || ''); if (!['GradeCate','Grade'].includes(className) || 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(className); const query = new Parse.Query(className); 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, '部分多级字典记录不存在或不属于当前帐套');
+      if (className === 'GradeCate') { const ids = targets.map((target) => Number(target.get('cateId')) || 0); const optionFields = await schemaFor('Grade'); const refs = new Parse.Query('Grade'); applyTenant(refs, optionFields, context, input.companyId); refs.containedIn('cate', ids); if (await refs.count({ useMasterKey: true })) fail(409, '多级字典分类仍有选项,不能删除'); }
+      else { const ids = targets.map((target) => Number(target.get('gradeId')) || 0); const refs = new Parse.Query('Grade'); applyTenant(refs, fields, context, input.companyId); refs.containedIn('parentId', ids); if (await refs.count({ useMasterKey: true })) fail(409, '多级字典选项仍有下级选项,不能删除'); }
+      await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'grade-delete', className, target.id);
+      return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
+    }
     if (operation === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -1066,7 +1150,7 @@ async function handler(request, response) {
       const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
       let query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId);
       const search = String(input.search || '').trim();
-      if (search) { const searchField = ['name','title','nodeName','specName','modelName','fieldName','fieldAlias','groupName','categoryname','dicname','cClassName','testPoint','kName','tname','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); }
+      if (search) { const searchField = ['name','title','nodeName','specName','modelName','fieldName','fieldAlias','groupName','categoryname','dicname','cateName','gradeName','cClassName','testPoint','kName','tname','username','realName','mobile','sourceKey'].find((name) => fields[name] && fields[name].type === 'String'); if (searchField) query.matches(searchField, escapeRegex(search), 'i'); }
       const sort = fields[input.sort] ? String(input.sort) : fields.updatedAt ? 'updatedAt' : 'createdAt'; if (input.order === 'asc') query.ascending(sort); else query.descending(sort);
       const total = await query.count({ useMasterKey: true }); query.skip((page - 1) * pageSize); query.limit(pageSize);
       const results = (await query.find({ useMasterKey: true })).filter(isVisible);
@@ -1091,6 +1175,7 @@ async function handler(request, response) {
       if (className === 'QuestionsKnowledge') fail(400, '知识点必须走专用保存流程');
       if (className === 'ExTeacher') fail(400, '考试教师必须走专用保存流程');
       if (className === 'Datadiccategory' || className === 'Datadic') fail(400, '数据字典必须走专用保存流程');
+      if (className === 'GradeCate' || className === 'Grade') 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);
@@ -1108,6 +1193,7 @@ async function handler(request, response) {
       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 } }); }
       if (className === 'QuestionsKnowledge') { const knowledgeId = Number(object.get('kId')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "QuestionsKnowledge" WHERE "company"=$1 AND "pid"=$2) AS children,(SELECT COUNT(*)::int FROM "ExamSysQuestions" WHERE "company"=$1 AND "pKnowledge"=$2) AS questions', [companyId, knowledgeId]); if (Number(refs.children) || Number(refs.questions)) fail(409, '知识点仍被下级知识点或试题引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-knowledge', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === 'Datadiccategory' || className === 'Datadic') { if (className === 'Datadiccategory') { const categoryId = Number(object.get('diccateid')) || 0; const itemFields = await schemaFor('Datadic'); const refs = new Parse.Query('Datadic'); applyTenant(refs, itemFields, context, pointerId(object.get('company'))); refs.equalTo('diccate', categoryId); if (await refs.count({ useMasterKey: true })) fail(409, '字典分类仍有字典项,不能删除'); } await object.destroy({ useMasterKey: true }); await audit(context, 'delete-dictionary', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
+      if (className === 'GradeCate' || className === 'Grade') { const referenceFields = await schemaFor('Grade'); const refs = new Parse.Query('Grade'); applyTenant(refs, referenceFields, context, pointerId(object.get('company'))); if (className === 'GradeCate') refs.equalTo('cate', Number(object.get('cateId')) || 0); else refs.equalTo('parentId', Number(object.get('gradeId')) || 0); if (await refs.count({ useMasterKey: true })) fail(409, className === 'GradeCate' ? '多级字典分类仍有选项,不能删除' : '多级字典选项仍有下级选项,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-grade', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === '_User') assertCanManageUser(context, object, 'lock');
       if (className === 'Group') { const groupId = Number(object.get('groupId')) || 0; const companyId = pointerId(object.get('company')); const users = await Psql.one('SELECT COUNT(*)::int AS count FROM "_User" WHERE "company"=$1 AND COALESCE("legacyGroupId",0)=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE)', [companyId, groupId]); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "Group" WHERE "company"=$1 AND COALESCE("parentGroupId",0)=$2', [companyId, groupId]); if (Number(users.count) > 0) fail(409, '该用户组仍有用户,不能删除'); if (Number(children.count) > 0) fail(409, '该用户组仍有下级组,不能删除'); }
       if (className === 'CommonModel') { object.set('status', -2); await object.save(null, { useMasterKey: true }); await audit(context, 'content-recycle', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: true, status: -2 } }); }

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

@@ -101,6 +101,14 @@ const explicit = new Map([
   ['Dict/DicCateSel', ['implemented', '已由 dictionaryCategories 帐套隔离分类选择接口和 Angular 下拉控件替代']],
   ['Dict/DicItemList', ['implemented', '已提供帐套字典项列表、搜索、分页和所属分类选择']],
   ['Dict/DicItem_API', ['implemented', '已由字典项专用保存、分类归属校验、批量启停和删除替代']],
+  ['Dict/GradeCateManage', ['implemented', '已提供多级字典分类列表、搜索、分页和引用保护删除']],
+  ['Dict/GCateAdd', ['implemented', '已由 GradeCate 专用分类表单与层级别名输入替代']],
+  ['Dict/GCateAdd_Submit', ['implemented', '已由 saveGradeCategory 编号、防重和层级别名校验替代']],
+  ['Dict/GCate_API', ['implemented', '已由 gradeBatch 带选项引用保护的批量删除替代']],
+  ['Dict/GradeOption', ['implemented', '已由按分类和父选项筛选的 gradeOptions 列表替代']],
+  ['Dict/GOptionAdd', ['implemented', '已由 Grade 专用分类、父级选项表单替代']],
+  ['Dict/GOptionAdd_Submit', ['implemented', '已由 saveGradeOption 自动层级、同级防重和父级归属校验替代']],
+  ['Dict/GOption_API', ['implemented', '已由 gradeBatch 带下级引用保护的批量删除替代']],
 ]);
 
 const blockedDataModules = new Map([

+ 40 - 1
scripts/smoke-admin-functions.mjs

@@ -78,6 +78,8 @@ const temporaryKnowledgeObjectIds = [];
 let temporaryExamTeacherId = '';
 const temporaryDictionaryCategoryIds = [];
 const temporaryDictionaryItemIds = [];
+const temporaryGradeCategoryIds = [];
+const temporaryGradeOptionIds = [];
 let temporaryModelObjectId = '';
 const temporaryModelFieldObjectIds = [];
 let registeredUserId = '';
@@ -391,6 +393,35 @@ try {
   temporaryDictionaryItemIds.pop();
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'dictionaryBatch', className: 'Datadiccategory', action: 'delete', objectIds: [dictionaryCategory.objectId] });
   temporaryDictionaryCategoryIds.pop();
+  const gradeSuffix = Date.now().toString(36);
+  const gradeCategory = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGradeCategory', className: 'GradeCate', fields: { cateName: `冒烟多级字典-${gradeSuffix}`, remark: '多级字典生命周期验证', gradeField: '省份|城市' } });
+  temporaryGradeCategoryIds.push(String(gradeCategory.objectId || ''));
+  const gradeCategoryId = Number(gradeCategory.cateId);
+  if (!temporaryGradeCategoryIds[0] || !gradeCategoryId || gradeCategory.sourceKey !== `[["CateID",${gradeCategoryId}]]` || gradeCategory.gradeField !== '省份|城市') throw new Error(`多级字典分类原子创建异常:${JSON.stringify(gradeCategory)}`);
+  const gradeCategoryList = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeCategories' });
+  if (!gradeCategoryList.results?.some((entry) => entry.objectId === gradeCategory.objectId)) throw new Error('多级字典分类选择接口未返回新建分类');
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGradeCategory', className: 'GradeCate', fields: { cateName: gradeCategory.cateName, gradeField: '一级|二级' } }, 409);
+  const rootGrade = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGradeOption', className: 'Grade', categoryId: gradeCategoryId, parentId: 0, fields: { gradeName: `冒烟省份-${gradeSuffix}` } });
+  temporaryGradeOptionIds.push(String(rootGrade.objectId || ''));
+  const rootGradeId = Number(rootGrade.gradeId);
+  if (!temporaryGradeOptionIds[0] || !rootGradeId || Number(rootGrade.grade) !== 1 || Number(rootGrade.parentId) !== 0 || rootGrade.sourceKey !== `[["GradeID",${rootGradeId}]]`) throw new Error(`一级字典选项创建异常:${JSON.stringify(rootGrade)}`);
+  const childGrade = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGradeOption', className: 'Grade', categoryId: gradeCategoryId, parentId: rootGradeId, fields: { gradeName: `冒烟城市-${gradeSuffix}` } });
+  temporaryGradeOptionIds.push(String(childGrade.objectId || ''));
+  const childGradeId = Number(childGrade.gradeId);
+  if (!childGradeId || Number(childGrade.parentId) !== rootGradeId || Number(childGrade.grade) !== 2 || Number(childGrade.cate) !== gradeCategoryId) throw new Error('二级字典选项创建失败');
+  const gradePage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeOptions', categoryId: gradeCategoryId, parentId: rootGradeId, page: 1, pageSize: 20 });
+  if (!gradePage.results?.some((entry) => entry.objectId === childGrade.objectId)) throw new Error('多级字典分层列表未返回子选项');
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGradeOption', className: 'Grade', categoryId: gradeCategoryId, parentId: rootGradeId, fields: { gradeName: childGrade.gradeName } }, 409);
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGradeOption', className: 'Grade', categoryId: gradeCategoryId, parentId: childGradeId, fields: { gradeName: '越界三级选项' } }, 409);
+  const updatedChildGrade = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGradeOption', className: 'Grade', objectId: childGrade.objectId, categoryId: 999999, parentId: 0, fields: { gradeName: `${childGrade.gradeName}-更新`, cate: 999999, parentId: 0, grade: 1 } });
+  if (Number(updatedChildGrade.cate) !== gradeCategoryId || Number(updatedChildGrade.parentId) !== rootGradeId || Number(updatedChildGrade.grade) !== 2) throw new Error('多级字典选项编辑篡改了结构字段');
+  const gradeCategoryDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'GradeCate', action: 'delete', objectIds: [gradeCategory.objectId] }, 409);
+  if (!String(gradeCategoryDeleteBlocked.message).includes('仍有选项')) throw new Error('多级字典分类引用删除保护未返回明确原因');
+  const rootGradeDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'Grade', action: 'delete', objectIds: [rootGrade.objectId] }, 409);
+  if (!String(rootGradeDeleteBlocked.message).includes('下级选项')) throw new Error('多级字典子级删除保护未返回明确原因');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'Grade', action: 'delete', objectIds: [childGrade.objectId] }); temporaryGradeOptionIds.pop();
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'Grade', action: 'delete', objectIds: [rootGrade.objectId] }); temporaryGradeOptionIds.pop();
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'GradeCate', action: 'delete', objectIds: [gradeCategory.objectId] }); temporaryGradeCategoryIds.pop();
   const examPointSuffix = Date.now().toString(36);
   const rootExamPoint = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamPoint', className: 'ExamPoint', parentId: 0, orderId: 0, fields: { testPoint: `冒烟考点-${examPointSuffix}` } });
   temporaryExamPointObjectIds.push(String(rootExamPoint.objectId || ''));
@@ -625,7 +656,7 @@ try {
   const productBlocked = await callLegacyFunction('', { action: 'product_list' }, 501);
   if (!String(productBlocked.retmsg).includes('ZL_Commodities')) throw new Error('缺商品主表的读取接口未明确阻塞');
 
-  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/guest-category/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
+  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/guest-category/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);
@@ -671,6 +702,14 @@ try {
     console.error(`临时字典分类清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  for (const gradeOptionId of [...temporaryGradeOptionIds].reverse()) if (gradeOptionId) await jsonRequest(`${PARSE_URL}/classes/Grade/${gradeOptionId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时多级字典选项清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
+  for (const gradeCategoryId of temporaryGradeCategoryIds) if (gradeCategoryId) await jsonRequest(`${PARSE_URL}/classes/GradeCate/${gradeCategoryId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时多级字典分类清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   for (const examClassId of [...temporaryExamClassObjectIds].reverse()) if (examClassId) await jsonRequest(`${PARSE_URL}/classes/ExamClass/${examClassId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时试题分类清理失败:${error.message}`);
     process.exitCode = 1;

+ 2 - 0
src/app/admin/admin.constants.ts

@@ -26,6 +26,8 @@ export const ADMIN_NAVIGATION = [
   { label: '考试教师', icon: 'user-round-check', route: '/admin/resources/ExTeacher' },
   { label: '字典分类', icon: 'library-big', route: '/admin/resources/Datadiccategory' },
   { label: '数据字典', icon: 'list-tree', route: '/admin/resources/Datadic' },
+  { label: '多级字典分类', icon: 'layers-3', route: '/admin/resources/GradeCate' },
+  { label: '多级字典选项', icon: 'git-branch', route: '/admin/resources/Grade' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

+ 4 - 0
src/app/admin/legacy-admin-coverage.generated.spec.ts

@@ -34,6 +34,10 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Dict/DicCateSel')?.status).toBe('implemented');
     expect(byKey.get('Dict/DicItemList')?.status).toBe('implemented');
     expect(byKey.get('Dict/DicItem_API')?.status).toBe('implemented');
+    expect(byKey.get('Dict/GradeCateManage')?.status).toBe('implemented');
+    expect(byKey.get('Dict/GCateAdd_Submit')?.status).toBe('implemented');
+    expect(byKey.get('Dict/GradeOption')?.status).toBe('implemented');
+    expect(byKey.get('Dict/GOptionAdd_Submit')?.status).toBe('implemented');
     expect(byKey.get('Exam/Teacher_Add')?.status).toBe('implemented');
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');

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

@@ -1812,57 +1812,57 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Dict",
     "action": "GradeCateManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供多级字典分类列表、搜索、分页和引用保护删除"
   },
   {
     "module": "Dict",
     "action": "GCateAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 GradeCate 专用分类表单与层级别名输入替代"
   },
   {
     "module": "Dict",
     "action": "GCateAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveGradeCategory 编号、防重和层级别名校验替代"
   },
   {
     "module": "Dict",
     "action": "GCate_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 gradeBatch 带选项引用保护的批量删除替代"
   },
   {
     "module": "Dict",
     "action": "GradeOption",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由按分类和父选项筛选的 gradeOptions 列表替代"
   },
   {
     "module": "Dict",
     "action": "GOptionAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Grade 专用分类、父级选项表单替代"
   },
   {
     "module": "Dict",
     "action": "GOptionAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveGradeOption 自动层级、同级防重和父级归属校验替代"
   },
   {
     "module": "Dict",
     "action": "GOption_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 gradeBatch 带下级引用保护的批量删除替代"
   },
   {
     "module": "ECharts / ECharts",

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

@@ -5,6 +5,9 @@
 
 <section class="resource-card">
   <div class="toolbar"><label><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="search()" (ngModelChange)="search.set($event)" (keyup.enter)="load(1)" placeholder="按名称、标题、账号或来源键搜索" /></label><button type="button" (click)="load(1)">查询</button></div>
+  @if (className() === 'Grade') {
+    <div class="bulk-toolbar"><strong>多级字典范围</strong><select [ngModel]="filterGradeCategoryId()" (ngModelChange)="filterGradeCategoryId.set(+$event); filterGradeParentId.set(0); load(1)"><option [ngValue]="0">请选择分类</option>@for (category of gradeCategories(); track category['objectId']) { <option [ngValue]="gradeCategoryIdOf(category)">{{ category['cateName'] }}({{ category['cateId'] }})</option> }</select><input class="bulk-node" type="number" min="0" [ngModel]="filterGradeParentId()" (ngModelChange)="filterGradeParentId.set(+$event)" placeholder="父选项 ID" /><button type="button" [disabled]="!filterGradeCategoryId()" (click)="load(1)">查看该层</button></div>
+  }
   @if (bulkEnabled() && selectedIds().length) {
     <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
       @if (className() === '_User') {
@@ -23,6 +26,8 @@
         <button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('audit')">审核</button><button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('unaudit')">取消审核</button><button class="danger-action" type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('recycle')">回收</button><button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('recover')">恢复</button>
       } @else if (className() === 'Datadiccategory' || className() === 'Datadic') {
         <button type="button" [disabled]="updatingDictionary()" (click)="applyBulkDictionaryAction('enable')">启用</button><button type="button" [disabled]="updatingDictionary()" (click)="applyBulkDictionaryAction('disable')">停用</button><button class="danger-action" type="button" [disabled]="updatingDictionary()" (click)="applyBulkDictionaryAction('delete')">删除</button>
+      } @else if (className() === 'GradeCate' || className() === 'Grade') {
+        <button class="danger-action" type="button" [disabled]="updatingGrade()" (click)="applyBulkGradeDelete()">删除</button>
       } @else {
         <button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('audit')">审核</button><button class="danger-action" type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recycle')">回收</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recover')">恢复</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('elite')">加精</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-section')">版块置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-global')">全局置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-undo')">取消排序</button>
         <input class="bulk-node" type="number" min="1" [ngModel]="targetBarCategoryId()" (ngModelChange)="targetBarCategoryId.set(+$event)" placeholder="版块 ID" /><button type="button" [disabled]="updatingBar() || !targetBarCategoryId()" (click)="applyBulkBarAction('move')">移动版块</button>
@@ -153,6 +158,18 @@
             <label><span>所属字典分类</span><select [ngModel]="targetDictionaryCategoryId()" (ngModelChange)="targetDictionaryCategoryId.set(+$event)"><option [ngValue]="0">请选择分类</option>@for (category of dictionaryCategories(); track category['objectId']) { <option [ngValue]="dictionaryCategoryIdOf(category)">{{ category['categoryname'] }}({{ category['diccateid'] }}){{ category['isused'] === false ? ' · 已停用' : '' }}</option> }</select>@if (!dictionaryCategories().length) { <small>当前帐套尚无可用分类,请先从“数据字典分类”建立分类</small> }</label>
           </section>
         }
+        @if (className() === 'GradeCate') {
+          <section class="content-controls grade-category-controls">
+            <header><div><strong>多级字典分类</strong><span>层级别名使用“|”或换行分隔,至少定义两级;专用云函数维护旧 CateID 和引用保护</span></div>@if (draftId()) { <b>ID {{ draft()['cateId'] }}</b> }</header>
+          </section>
+        }
+        @if (className() === 'Grade') {
+          <section class="content-controls grade-option-controls">
+            <header><div><strong>多级字典选项</strong><span>新建时按父选项自动推导层级;编辑时保持原分类、父级和层级不变</span></div>@if (draftId()) { <b>ID {{ draft()['gradeId'] }} · 第 {{ draft()['grade'] }} 级</b> }</header>
+            <label><span>所属多级字典分类</span><select [disabled]="!!draftId()" [ngModel]="targetGradeCategoryId()" (ngModelChange)="targetGradeCategoryId.set(+$event)"><option [ngValue]="0">请选择分类</option>@for (category of gradeCategories(); track category['objectId']) { <option [ngValue]="gradeCategoryIdOf(category)">{{ category['cateName'] }}({{ category['cateId'] }})</option> }</select></label>
+            <label><span>父选项 ID <small>第一级填 0</small></span><input type="number" min="0" [disabled]="!!draftId()" [ngModel]="targetGradeParentId()" (ngModelChange)="targetGradeParentId.set(+$event)" /></label>
+          </section>
+        }
         @if (className() === 'Guestbook' && draftId()) {
           <section class="content-controls guestbook-controls">
             <header><div><strong>留言审核与管理员回复</strong><span>专用云函数维护旧 status、父留言关系和回复 GID</span></div><b [class.recycled]="selectedGuestbookStatus() === -2">{{ selectedGuestbookStatus() === 99 ? '已审核' : selectedGuestbookStatus() === -2 ? '回收站' : '待审核' }}</b></header>

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

@@ -416,6 +416,49 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('2 条字典记录');
   });
 
+  it('creates a multi-level dictionary category through the dedicated operation', async () => {
+    component.className.set('GradeCate');
+    component.schema.set({ className: 'GradeCate', label: '多级字典分类', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ cateName: '地区联动', gradeField: '省|市', cateId: 999, remark: '两级地区' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveGradeCategory'
+      ? { objectId: 'grade-category-object', cateId: 20, cateName: '地区联动', gradeField: '省|市' }
+      : { className: 'GradeCate', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveGradeCategory', {
+      className: 'GradeCate', objectId: undefined, fields: { cateName: '地区联动', gradeField: '省|市', cateId: 999, remark: '两级地区' },
+    }]);
+  });
+
+  it('creates a multi-level dictionary option through the parent-safe operation', async () => {
+    component.className.set('Grade');
+    component.schema.set({ className: 'Grade', label: '多级字典选项', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ gradeName: '杭州市', gradeId: 999, cate: 999, parentId: 999, grade: 999 });
+    component.targetGradeCategoryId.set(20); component.targetGradeParentId.set(30);
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveGradeOption'
+      ? { objectId: 'grade-option-object', gradeId: 40, cate: 20, parentId: 30, grade: 2, gradeName: '杭州市' }
+      : { className: 'Grade', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveGradeOption', {
+      className: 'Grade', objectId: undefined, fields: { gradeName: '杭州市', gradeId: 999, cate: 999, parentId: 999, grade: 999 }, categoryId: 20, parentId: 30,
+    }]);
+  });
+
+  it('batch deletes selected multi-level dictionary options with the dedicated operation', async () => {
+    component.className.set('Grade'); component.selectedIds.set(['grade-a', 'grade-b']); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.callFake(async (operation: string) => operation === 'gradeBatch'
+      ? { action: 'delete', updated: 2, results: [] }
+      : { className: 'Grade', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkGradeDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['gradeBatch', { className: 'Grade', action: 'delete', objectIds: ['grade-a', 'grade-b'] }]);
+    expect(component.bulkMessage()).toContain('2 条多级字典记录');
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

+ 36 - 7
src/app/admin/pages/admin-resource.component.ts

@@ -83,18 +83,24 @@ export class AdminResourceComponent implements OnInit {
   readonly targetDictionaryCategoryId = signal(0);
   readonly dictionaryCategories = signal<Record<string, unknown>[]>([]);
   readonly updatingDictionary = signal(false);
+  readonly targetGradeCategoryId = signal(0);
+  readonly targetGradeParentId = signal(0);
+  readonly filterGradeCategoryId = signal(0);
+  readonly filterGradeParentId = signal(0);
+  readonly gradeCategories = signal<Record<string, unknown>[]>([]);
+  readonly updatingGrade = signal(false);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
-    const preferred = ['objectId', 'name', 'title', 'categoryname', 'dicname', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
     const rank = (name: string): number => { const index = preferred.indexOf(name); return index < 0 ? preferred.length : index; };
     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'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade'].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));
@@ -107,6 +113,7 @@ export class AdminResourceComponent implements OnInit {
     this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
       this.className.set(params.get('className') ?? '');
       this.search.set('');
+      this.filterGradeCategoryId.set(0); this.filterGradeParentId.set(0);
       void this.load(1, true);
     });
   }
@@ -115,24 +122,28 @@ export class AdminResourceComponent implements OnInit {
     this.loading.set(true); this.error.set(''); this.selectedIds.set([]); this.bulkError.set(''); this.bulkMessage.set('');
     try {
       if (reloadSchema || !this.schema()) this.schema.set(await this.functions.admin<AdminClassSchema>('schema', { className: this.className() }));
-      this.page.set(await this.functions.admin<ResourcePage>('list', { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() }));
+      if (this.className() === 'Grade' && reloadSchema) { await this.loadGradeCategories(); if (!this.filterGradeCategoryId() && this.gradeCategories().length) this.filterGradeCategoryId.set(this.gradeCategoryIdOf(this.gradeCategories()[0])); }
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : '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(); }
+      this.page.set(await this.functions.admin<ResourcePage>(pageOperation, pagePayload));
       if (this.className() === '_User' && reloadSchema) await this.loadUserGroups();
       if (this.className() === 'Datadic' && reloadSchema) await this.loadDictionaryCategories();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '数据加载失败'); }
     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.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.editorOpen.set(true); }
   async openEdit(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
       const detail = await this.functions.admin<Record<string, unknown>>('get', { 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.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.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();
     } 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(); }
+  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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -175,7 +186,9 @@ export class AdminResourceComponent implements OnInit {
                         : this.className() === 'QuestionsKnowledge' ? 'saveKnowledge'
                           : this.className() === 'ExTeacher' ? 'saveExamTeacher'
                             : this.className() === 'Datadiccategory' ? 'saveDictionaryCategory'
-                              : this.className() === 'Datadic' ? 'saveDictionaryItem' : 'save';
+                              : this.className() === 'Datadic' ? 'saveDictionaryItem'
+                                : this.className() === 'GradeCate' ? 'saveGradeCategory'
+                                  : this.className() === 'Grade' ? 'saveGradeOption' : 'save';
       const payload: Record<string, unknown> = { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() };
       if (this.className() === 'Node') payload['parentId'] = this.targetNodeParentId();
       if (this.className() === 'Special') payload['pid'] = this.targetSpecialParentId();
@@ -185,6 +198,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'QuestionsKnowledge') { payload['classId'] = this.targetKnowledgeClassId(); payload['parentId'] = this.targetKnowledgeParentId(); payload['orderId'] = this.knowledgeOrder(); }
       if (this.className() === 'ExTeacher') payload['classId'] = this.targetTeacherClassId();
       if (this.className() === 'Datadic') payload['categoryId'] = this.targetDictionaryCategoryId();
+      if (this.className() === 'Grade') { payload['categoryId'] = this.targetGradeCategoryId(); payload['parentId'] = this.targetGradeParentId(); }
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -470,6 +484,15 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量字典操作失败'); }
     finally { this.updatingDictionary.set(false); }
   }
+  async applyBulkGradeDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (!['GradeCate', 'Grade'].includes(this.className()) || !objectIds.length) { this.bulkError.set('请先选择多级字典记录'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 条多级字典记录?`)) return;
+    this.updatingGrade.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('gradeBatch', { className: this.className(), action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 条多级字典记录`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量删除多级字典失败'); }
+    finally { this.updatingGrade.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
@@ -488,6 +511,7 @@ export class AdminResourceComponent implements OnInit {
   groupIdOf(group: Record<string, unknown>): number { return Number(group['groupId']) || 0; }
   groupLabel(group: Record<string, unknown>): string { return `${String(group['groupName'] || group['name'] || '未命名组')}(${this.groupIdOf(group)})`; }
   dictionaryCategoryIdOf(category: Record<string, unknown>): number { return Number(category['diccateid']) || 0; }
+  gradeCategoryIdOf(category: Record<string, unknown>): number { return Number(category['cateId']) || 0; }
   contentStatusValue(): number { return Number(this.draft()['status']) || 0; }
   contentStatusLabel(status: number): string { return ({ [-3]: '草稿', [-2]: '回收站', [-1]: '未通过', 0: '待审核', 99: '已审核' } as Record<number, string>)[status] || String(status); }
   nodeStatusValue(): number { return Number(this.draft()['zstatus'] ?? 99); }
@@ -503,6 +527,10 @@ export class AdminResourceComponent implements OnInit {
       this.dictionaryCategories.set(result.results || []);
     } catch (error) { this.dictionaryCategories.set([]); this.error.set(error instanceof Error ? error.message : '字典分类加载失败'); }
   }
+  private async loadGradeCategories(): Promise<void> {
+    try { const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('gradeCategories', {}); this.gradeCategories.set(result.results || []); }
+    catch (error) { this.gradeCategories.set([]); this.error.set(error instanceof Error ? error.message : '多级字典分类加载失败'); }
+  }
   private resetPasswordFields(): void { this.newPassword.set(''); this.confirmPassword.set(''); this.passwordMessage.set(''); this.passwordError.set(''); }
   private resetCreateUserFields(): void { this.createUsername.set(''); this.createPassword.set(''); this.createConfirmPassword.set(''); this.createDisplayName.set(''); this.createMobile.set(''); this.createInviteUsername.set(''); this.createUserError.set(''); }
   private resetUserActions(detail: Record<string, unknown> = {}): void { this.userGroups.set([]); this.selectedUserGroupId.set(Number(detail['legacyGroupId'] ?? (detail['legacyUserData'] as Record<string, unknown> | undefined)?.['GroupID']) || 0); this.userActionReason.set(''); this.userActionError.set(''); this.userActionMessage.set(''); }
@@ -517,4 +545,5 @@ export class AdminResourceComponent implements OnInit {
   private resetKnowledgeActions(detail: Record<string, unknown> = {}): void { this.targetKnowledgeClassId.set(Number(detail['kClassId']) || 0); this.targetKnowledgeParentId.set(Number(detail['pid']) || 0); this.knowledgeOrder.set(Number(detail['kOrderBy']) || 0); }
   private resetTeacherActions(detail: Record<string, unknown> = {}): void { this.targetTeacherClassId.set(Number(detail['tclsss']) || 0); }
   private resetDictionaryActions(detail: Record<string, unknown> = {}): void { this.targetDictionaryCategoryId.set(Number(detail['diccate']) || 0); }
+  private resetGradeActions(detail: Record<string, unknown> = {}): void { this.targetGradeCategoryId.set(Number(detail['cate']) || this.filterGradeCategoryId()); this.targetGradeParentId.set(Number(detail['parentId']) || this.filterGradeParentId()); }
 }