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

feat: migrate data dictionary lifecycle

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

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

@@ -33,8 +33,9 @@
 - 旧考试后台 `ExamPointManage/AddExamPoint/ExamPoint_Add` 已按官方 `ZL_ExamPoint` 模型迁到目标 `ExamPoint`:`saveExamPoint` 使用帐套事务锁原子分配 `id/orderBy/sourceKey`,自动记录创建管理员与时间,并校验名称唯一、上级考点归属和层级循环;删除拒绝仍有下级考点的记录。Angular 新增“考点管理”导航及父级/排序结构表单,通用保存不能绕过专用一致性规则。
 - 新版考试后台 `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 新增分类、字典项导航和帐套隔离分类选择控件。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 48 项已实现、32 项部分实现、114 项数据阻塞、189 项基础设施阻塞、659 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 53 项已实现、32 项部分实现、114 项数据阻塞、189 项基础设施阻塞、654 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -11,6 +11,7 @@ const CLASS_LABELS = {
   CourseBinding: '课程绑定', CourseAppointment: '课程预约', LessonRecord: '上课记录', DailyStudyRecord: '每日学习记录', PracticeRecord: '练习记录', MemoryPracticeRecord: '抗遗忘记录',
   VocabularyWord: '词库', CommonModel: '通用内容', ContentArticle: '文章内容', ContentPublish: '内容发布', Node: '栏目节点', NodeAuth: '节点权限', Model: '内容模型', ModelField: '模型字段',
   ExamClass: '试题分类', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目',
+  Datadiccategory: '数据字典分类', Datadic: '数据字典项',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
   GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '搜索记录', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
@@ -33,7 +34,9 @@ const CLASS_SYSTEM_FIELDS = {
   ExamClass: new Set(['cId','cClassid','cOrderBy','cClassType','sourceKey']),
   ExamPoint: new Set(['id','tid','orderBy','addUser','addTime','sourceKey']),
   QuestionsKnowledge: new Set(['kId','kClassId','kOrderBy','pid','cuser','cdate','sourceKey']),
-  ExTeacher: new Set(['id','tclsss','addUser','creatTime','sourceKey'])
+  ExTeacher: new Set(['id','tclsss','addUser','creatTime','sourceKey']),
+  Datadiccategory: new Set(['diccateid','sourceKey']),
+  Datadic: new Set(['dicid','diccate','sourceKey'])
 };
 
 function inputOf(request) {
@@ -688,6 +691,112 @@ async function handler(request, response) {
     if (operation === 'barMedal') {
       fail(501, 'migration_blocked: 目标 Schema 缺少旧 ZL_Guest_Medals 勋章及用户帖子授予关系,无法安全授予或撤销帖子勋章');
     }
+    if (operation === 'saveDictionaryCategory') {
+      const objectId = String(input.objectId || '');
+      const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      const categoryName = String(payload.categoryname || '').trim();
+      const isUsed = payload.isused == null ? true : payload.isused === true || payload.isused === 'true';
+      if (!categoryName || categoryName.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('Datadiccategory');
+      let target;
+      if (objectId) {
+        const query = new Parse.Query('Datadiccategory'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
+        companyId = pointerId(target.get('company')) || companyId;
+      } else target = new Parse.Object('Datadiccategory');
+      const duplicateQuery = new Parse.Query('Datadiccategory'); 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('categoryname') || '').trim().toLowerCase() === categoryName.toLowerCase())) fail(409, '同一帐套的字典分类名称不能重复');
+      target.set('categoryname', categoryName); target.set('isused', isUsed); 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 allCategories = new Parse.Query('Datadiccategory'); allCategories.limit(1000);
+          const allItems = new Parse.Query('Datadic'); allItems.limit(1000);
+          const [categoryRows, itemRows] = await Promise.all([allCategories.find({ useMasterKey: true }), allItems.find({ useMasterKey: true })]);
+          const nextId = Math.max(0, ...categoryRows.map((entry) => Number(entry.get('diccateid')) || 0), ...itemRows.map((entry) => Number(entry.get('diccate')) || 0)) + 1;
+          target.set('diccateid', nextId); target.set('sourceKey', '[["Diccateid",' + 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-dictionary-category' : 'create-dictionary-category', 'Datadiccategory', target.id);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'saveDictionaryItem') {
+      const objectId = String(input.objectId || '');
+      const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      const itemName = String(payload.dicname || '').trim();
+      const categoryId = Number(input.categoryId == null ? payload.diccate || 0 : input.categoryId);
+      const isUsed = payload.isused == null ? true : payload.isused === true || payload.isused === 'true';
+      if (!itemName || itemName.length > 50) fail(400, '字典项名称长度应为 1 至 50 位');
+      if (!Number.isInteger(categoryId) || categoryId < 1) 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('Datadic');
+      let target;
+      if (objectId) {
+        const query = new Parse.Query('Datadic'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
+        companyId = pointerId(target.get('company')) || companyId;
+      } else target = new Parse.Object('Datadic');
+      const categoryFields = await schemaFor('Datadiccategory'); const categoryQuery = new Parse.Query('Datadiccategory'); applyTenant(categoryQuery, categoryFields, context, input.companyId); categoryQuery.equalTo('diccateid', categoryId);
+      const category = await categoryQuery.first({ useMasterKey: true });
+      if (!category || pointerId(category.get('company')) !== companyId) fail(404, '字典分类不存在或不属于当前帐套');
+      const duplicateQuery = new Parse.Query('Datadic'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.equalTo('diccate', categoryId); duplicateQuery.limit(1000);
+      const duplicates = await duplicateQuery.find({ useMasterKey: true });
+      if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('dicname') || '').trim().toLowerCase() === itemName.toLowerCase())) fail(409, '同一字典分类中的字典项名称不能重复');
+      target.set('dicname', itemName); target.set('diccate', categoryId); target.set('isused', isUsed); 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 allItems = new Parse.Query('Datadic'); allItems.limit(1000); const itemRows = await allItems.find({ useMasterKey: true });
+          const nextId = Math.max(0, ...itemRows.map((entry) => Number(entry.get('dicid')) || 0)) + 1;
+          target.set('dicid', nextId); target.set('sourceKey', '[["Dicid",' + 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-dictionary-item' : 'create-dictionary-item', 'Datadic', target.id);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'dictionaryCategories') {
+      const fields = await schemaFor('Datadiccategory'); const query = new Parse.Query('Datadiccategory'); applyTenant(query, fields, context, input.companyId);
+      if (input.enabledOnly === true) query.equalTo('isused', true);
+      query.ascending('diccateid'); query.limit(1000);
+      const results = await query.find({ useMasterKey: true });
+      return response.json({ success: true, data: { results: results.map(serializeObject) } });
+    }
+    if (operation === 'dictionaryBatch') {
+      const className = String(input.className || ''); const action = String(input.action || '');
+      if (!['Datadiccategory','Datadic'].includes(className) || !['enable','disable','delete'].includes(action)) 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 (action === 'delete' && className === 'Datadiccategory') {
+        const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))]; const categoryIds = targets.map((target) => Number(target.get('diccateid')) || 0);
+        if (companyIds.length !== 1 || categoryIds.some((id) => id < 1)) fail(409, '字典分类缺少有效帐套或编号');
+        const itemFields = await schemaFor('Datadic'); const refs = new Parse.Query('Datadic'); applyTenant(refs, itemFields, context, companyIds[0]); refs.containedIn('diccate', categoryIds);
+        if (await refs.count({ useMasterKey: true })) fail(409, '字典分类仍有字典项,不能删除');
+      }
+      if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true });
+      else { for (const target of targets) target.set('isused', action === 'enable'); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
+      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 === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -957,7 +1066,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','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','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);
@@ -981,6 +1090,7 @@ async function handler(request, response) {
       if (className === 'ExamPoint') fail(400, '考点必须走专用保存流程');
       if (className === 'QuestionsKnowledge') fail(400, '知识点必须走专用保存流程');
       if (className === 'ExTeacher') fail(400, '考试教师必须走专用保存流程');
+      if (className === 'Datadiccategory' || className === 'Datadic') 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);
@@ -997,6 +1107,7 @@ async function handler(request, response) {
       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 } }); }
       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 === '_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 } }); }

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

@@ -96,6 +96,11 @@ const explicit = new Map([
   ['Exam/ExTeacherManage', ['implemented', '已提供帐套考试教师列表、搜索、分页与删除']],
   ['Exam/AddExTeacher', ['implemented', '已由 ExTeacher 专用教师资料表单替代']],
   ['Exam/Teacher_Add', ['implemented', '已由 saveExamTeacher 原子编号和任教分类归属校验替代']],
+  ['Dict/DicCateList', ['implemented', '已提供帐套字典分类列表、搜索、分页及启用状态管理']],
+  ['Dict/DictCate_API', ['implemented', '已由字典分类专用保存、批量启停和带字典项引用保护的删除替代']],
+  ['Dict/DicCateSel', ['implemented', '已由 dictionaryCategories 帐套隔离分类选择接口和 Angular 下拉控件替代']],
+  ['Dict/DicItemList', ['implemented', '已提供帐套字典项列表、搜索、分页和所属分类选择']],
+  ['Dict/DicItem_API', ['implemented', '已由字典项专用保存、分类归属校验、批量启停和删除替代']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -76,6 +76,8 @@ const temporaryExamClassObjectIds = [];
 const temporaryExamPointObjectIds = [];
 const temporaryKnowledgeObjectIds = [];
 let temporaryExamTeacherId = '';
+const temporaryDictionaryCategoryIds = [];
+const temporaryDictionaryItemIds = [];
 let temporaryModelObjectId = '';
 const temporaryModelFieldObjectIds = [];
 let registeredUserId = '';
@@ -365,6 +367,30 @@ try {
   temporaryExamClassObjectIds.pop();
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'ExamClass', objectId: rootExamClass.objectId });
   temporaryExamClassObjectIds.pop();
+  const dictionarySuffix = Date.now().toString(36);
+  const existingDictionaryItems = await jsonRequest(`${PARSE_URL}/classes/Datadic?limit=1000`, {}, true);
+  const highestExistingCategoryRef = Math.max(0, ...(existingDictionaryItems.results || []).map((entry) => Number(entry.diccate) || 0));
+  const dictionaryCategory = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDictionaryCategory', className: 'Datadiccategory', fields: { categoryname: `冒烟字典分类-${dictionarySuffix}`, isused: true } });
+  temporaryDictionaryCategoryIds.push(String(dictionaryCategory.objectId || ''));
+  const dictionaryCategoryId = Number(dictionaryCategory.diccateid);
+  if (!temporaryDictionaryCategoryIds[0] || dictionaryCategoryId <= highestExistingCategoryRef || dictionaryCategory.sourceKey !== `[["Diccateid",${dictionaryCategoryId}]]`) throw new Error(`字典分类原子编号或既有引用避让异常:${JSON.stringify(dictionaryCategory)}`);
+  const selectableCategories = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'dictionaryCategories', enabledOnly: true });
+  if (!selectableCategories.results?.some((entry) => entry.objectId === dictionaryCategory.objectId)) throw new Error('字典分类选择接口未返回新建分类');
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDictionaryCategory', className: 'Datadiccategory', fields: { categoryname: dictionaryCategory.categoryname, isused: true } }, 409);
+  const dictionaryItem = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDictionaryItem', className: 'Datadic', categoryId: dictionaryCategoryId, fields: { dicname: `冒烟字典项-${dictionarySuffix}`, isused: true } });
+  temporaryDictionaryItemIds.push(String(dictionaryItem.objectId || ''));
+  const dictionaryItemId = Number(dictionaryItem.dicid);
+  if (!temporaryDictionaryItemIds[0] || !dictionaryItemId || Number(dictionaryItem.diccate) !== dictionaryCategoryId || dictionaryItem.sourceKey !== `[["Dicid",${dictionaryItemId}]]`) throw new Error(`字典项原子创建异常:${JSON.stringify(dictionaryItem)}`);
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDictionaryItem', className: 'Datadic', categoryId: dictionaryCategoryId, fields: { dicname: dictionaryItem.dicname, isused: true } }, 409);
+  const dictionaryCategoryDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'dictionaryBatch', className: 'Datadiccategory', action: 'delete', objectIds: [dictionaryCategory.objectId] }, 409);
+  if (!String(dictionaryCategoryDeleteBlocked.message).includes('仍有字典项')) throw new Error('字典分类引用删除保护未返回明确原因');
+  const dictionaryDisabled = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'dictionaryBatch', className: 'Datadic', action: 'disable', objectIds: [dictionaryItem.objectId] });
+  if (dictionaryDisabled.results?.[0]?.isused !== false) throw new Error('字典项批量停用失败');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'dictionaryBatch', className: 'Datadic', action: 'enable', objectIds: [dictionaryItem.objectId] });
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'dictionaryBatch', className: 'Datadic', action: 'delete', objectIds: [dictionaryItem.objectId] });
+  temporaryDictionaryItemIds.pop();
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'dictionaryBatch', className: 'Datadiccategory', action: 'delete', objectIds: [dictionaryCategory.objectId] });
+  temporaryDictionaryCategoryIds.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 || ''));
@@ -599,7 +625,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/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/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}`);
@@ -637,6 +663,14 @@ try {
     console.error(`临时考试教师清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  for (const dictionaryItemId of temporaryDictionaryItemIds) if (dictionaryItemId) await jsonRequest(`${PARSE_URL}/classes/Datadic/${dictionaryItemId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时字典项清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
+  for (const dictionaryCategoryId of temporaryDictionaryCategoryIds) if (dictionaryCategoryId) await jsonRequest(`${PARSE_URL}/classes/Datadiccategory/${dictionaryCategoryId}`, { 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

@@ -24,6 +24,8 @@ export const ADMIN_NAVIGATION = [
   { label: '考点管理', icon: 'map-pin', route: '/admin/resources/ExamPoint' },
   { label: '知识点管理', icon: 'network', route: '/admin/resources/QuestionsKnowledge' },
   { 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: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -29,6 +29,11 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Exam/KnowledgeManage')?.status).toBe('partial');
     expect(byKey.get('Exam/Knowledge_Add')?.status).toBe('implemented');
     expect(byKey.get('Exam/ExTeacherManage')?.status).toBe('implemented');
+    expect(byKey.get('Dict/DicCateList')?.status).toBe('implemented');
+    expect(byKey.get('Dict/DictCate_API')?.status).toBe('implemented');
+    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('Exam/Teacher_Add')?.status).toBe('implemented');
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');

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

@@ -1777,36 +1777,36 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Dict",
     "action": "DicCateList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供帐套字典分类列表、搜索、分页及启用状态管理"
   },
   {
     "module": "Dict",
     "action": "DictCate_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由字典分类专用保存、批量启停和带字典项引用保护的删除替代"
   },
   {
     "module": "Dict",
     "action": "DicCateSel",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 dictionaryCategories 帐套隔离分类选择接口和 Angular 下拉控件替代"
   },
   {
     "module": "Dict",
     "action": "DicItemList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供帐套字典项列表、搜索、分页和所属分类选择"
   },
   {
     "module": "Dict",
     "action": "DicItem_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由字典项专用保存、分类归属校验、批量启停和删除替代"
   },
   {
     "module": "Dict",

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

@@ -21,6 +21,8 @@
         <input class="bulk-node" type="number" min="0" [ngModel]="targetSpecialParentId()" (ngModelChange)="targetSpecialParentId.set(+$event)" placeholder="父专题 ID" /><button type="button" [disabled]="updatingSpecial()" (click)="applyBulkSpecialMove()">移动专题</button>
       } @else if (className() === 'Guestbook') {
         <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 {
         <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>
@@ -140,6 +142,17 @@
             <label><span>任教试题分类 ID <small>未指定填 0</small></span><input type="number" min="0" [ngModel]="targetTeacherClassId()" (ngModelChange)="targetTeacherClassId.set(+$event)" /></label>
           </section>
         }
+        @if (className() === 'Datadiccategory') {
+          <section class="content-controls dictionary-controls">
+            <header><div><strong>数据字典分类</strong><span>专用云函数维护旧 DicCateID、帐套内名称防重和字典项引用保护</span></div>@if (draftId()) { <b>ID {{ draft()['diccateid'] }}</b> }</header>
+          </section>
+        }
+        @if (className() === 'Datadic') {
+          <section class="content-controls dictionary-controls">
+            <header><div><strong>数据字典项</strong><span>专用云函数维护旧 DicID,并只允许挂到当前帐套真实存在的分类</span></div>@if (draftId()) { <b>ID {{ draft()['dicid'] }}</b> }</header>
+            <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() === '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>

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

@@ -372,6 +372,50 @@ describe('AdminResourceComponent', () => {
     }]);
   });
 
+  it('creates a dictionary category through the collision-safe operation', async () => {
+    component.className.set('Datadiccategory');
+    component.schema.set({ className: 'Datadiccategory', label: '数据字典分类', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ categoryname: '学习状态', diccateid: 999, isused: true });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveDictionaryCategory'
+      ? { objectId: 'dictionary-category-object', diccateid: 20, categoryname: '学习状态', isused: true }
+      : { className: 'Datadiccategory', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveDictionaryCategory', {
+      className: 'Datadiccategory', objectId: undefined, fields: { categoryname: '学习状态', diccateid: 999, isused: true },
+    }]);
+  });
+
+  it('creates a dictionary item through the category-safe operation', async () => {
+    component.className.set('Datadic');
+    component.schema.set({ className: 'Datadic', label: '数据字典项', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ dicname: '已完成', dicid: 999, diccate: 999, isused: true });
+    component.targetDictionaryCategoryId.set(20);
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveDictionaryItem'
+      ? { objectId: 'dictionary-item-object', dicid: 30, diccate: 20, dicname: '已完成', isused: true }
+      : { className: 'Datadic', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveDictionaryItem', {
+      className: 'Datadic', objectId: undefined, fields: { dicname: '已完成', dicid: 999, diccate: 999, isused: true }, categoryId: 20,
+    }]);
+  });
+
+  it('batch disables selected dictionary records through the dedicated operation', async () => {
+    component.className.set('Datadic');
+    component.selectedIds.set(['dictionary-a', 'dictionary-b']);
+    functions.admin.and.callFake(async (operation: string) => operation === 'dictionaryBatch'
+      ? { action: 'disable', updated: 2, results: [] }
+      : { className: 'Datadic', page: 1, pageSize: 20, total: 2, results: [] });
+
+    await component.applyBulkDictionaryAction('disable');
+
+    expect(functions.admin.calls.first().args).toEqual(['dictionaryBatch', { className: 'Datadic', action: 'disable', objectIds: ['dictionary-a', 'dictionary-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');

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

@@ -80,18 +80,21 @@ export class AdminResourceComponent implements OnInit {
   readonly targetKnowledgeParentId = signal(0);
   readonly knowledgeOrder = signal(0);
   readonly targetTeacherClassId = signal(0);
+  readonly targetDictionaryCategoryId = signal(0);
+  readonly dictionaryCategories = signal<Record<string, unknown>[]>([]);
+  readonly updatingDictionary = 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', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'categoryname', 'dicname', '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'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic'].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));
@@ -114,21 +117,22 @@ export class AdminResourceComponent implements OnInit {
       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() === '_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.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.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.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.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(); }
+  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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -169,7 +173,9 @@ export class AdminResourceComponent implements OnInit {
                     : this.className() === 'ExamClass' ? 'saveExamClass'
                       : this.className() === 'ExamPoint' ? 'saveExamPoint'
                         : this.className() === 'QuestionsKnowledge' ? 'saveKnowledge'
-                          : this.className() === 'ExTeacher' ? 'saveExamTeacher' : 'save';
+                          : this.className() === 'ExTeacher' ? 'saveExamTeacher'
+                            : this.className() === 'Datadiccategory' ? 'saveDictionaryCategory'
+                              : this.className() === 'Datadic' ? 'saveDictionaryItem' : '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();
@@ -178,6 +184,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'ExamPoint') { payload['parentId'] = this.targetExamPointParentId(); payload['orderId'] = this.examPointOrder(); }
       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();
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -452,6 +459,17 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量帖子操作失败'); }
     finally { this.updatingBar.set(false); }
   }
+  async applyBulkDictionaryAction(action: 'enable' | 'disable' | 'delete'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (!['Datadiccategory', 'Datadic'].includes(this.className()) || !objectIds.length) { this.bulkError.set('请先选择字典记录'); return; }
+    if (action === 'delete' && !confirm(`确认删除选中的 ${objectIds.length} 条字典记录?`)) return;
+    this.updatingDictionary.set(true);
+    try {
+      const result = await this.functions.admin<{ updated: number }>('dictionaryBatch', { className: this.className(), action, objectIds });
+      await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 条字典记录`);
+    } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量字典操作失败'); }
+    finally { this.updatingDictionary.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
@@ -469,6 +487,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; }
   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); }
@@ -478,6 +497,12 @@ export class AdminResourceComponent implements OnInit {
       this.userGroups.set(groups.results);
     } catch (error) { this.userGroups.set([]); this.userActionError.set(error instanceof Error ? error.message : '用户组加载失败'); }
   }
+  private async loadDictionaryCategories(): Promise<void> {
+    try {
+      const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('dictionaryCategories', { enabledOnly: false });
+      this.dictionaryCategories.set(result.results || []);
+    } catch (error) { this.dictionaryCategories.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(''); }
@@ -491,4 +516,5 @@ export class AdminResourceComponent implements OnInit {
   private resetExamPointActions(detail: Record<string, unknown> = {}): void { this.targetExamPointParentId.set(Number(detail['tid']) || 0); this.examPointOrder.set(Number(detail['orderBy']) || 0); }
   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); }
 }