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

feat: migrate exam class lifecycle

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

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

@@ -29,8 +29,9 @@
 - 旧 `Guest/Bar` 共用的分类新增、编辑、推荐与删除已迁为 `saveGuestCategory/guestCategoryBatch`:新增分类用事务锁原子分配 `cateid/orderId/sourceKey`,`gtype=0` 留言分类固定顶级,`gtype=1` 贴吧分类支持父子结构并阻断循环;同父同类型名称防重,删除前同时检查下级分类、留言与帖子引用。贴吧发帖频率仍依赖旧 `GuestConfig` 文件,版块清空与用户级贴吧授权也尚未开放,因此对应动作保守标为部分实现。
 - 旧 `Bar/TieList/TieDetail/TieMove/Tie_API` 的帖子工作流已迁为 `barBatch`:每批 1–100 条支持审核/取消审核、回收/恢复、全局/版块置顶、取消排序、沉底、精华/取消精华和移动贴吧分类;移动会验证目标为同帐套 `gtype=1` 分类。`GuestBar` 在通用资源中改为结构只读,避免直接篡改帖子 ID、作者、父回复、状态、排序和精华标记。旧“勋章”动作依赖未迁移奖章关系表,继续保守标为部分实现而不伪造成功。
 - 旧 `BarAuthSet/BarAuthSet_Submit` 经逐浪官方源码模型复核,依赖独立 `ZL_Guest_BarAuth` 用户版块关系(`Uid/BarID/Look/Send/Reply`),并非仅靠 `Guestcate.needLog/postAuth` 即可等价实现;目标 109 个 Parse Schema 中只有 `GuestBar/Guestcate`,没有该权限关系或 `ZL_Guest_Medals` 勋章关系。管理员网关新增 `barAuthorization/barMedal` 显式 501 响应,避免界面或调用方误以为保存成功,并将两项授权动作列入最终数据阻塞清单。
+- 旧考试后台 `Question_Class_Manage/AddQuestion_Class/Question_Class_GetChild/Question_Class_Add` 已按官方 `ZL_Exam_Class` 模型迁到目标 `ExamClass`:`saveExamClass` 用 PostgreSQL 帐套事务锁原子分配 `cId/cOrderBy/sourceKey`,校验名称唯一、父分类归属和层级循环;`examClassChildren` 保留旧按父编号取子项能力,删除会拒绝仍有下级分类或 `ExamSysQuestions.pClass` 试题引用的记录。Angular 新增“试题分类”导航和结构表单;旧展开树视觉布局尚未复刻,因此列表动作仍保守标为部分实现。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 36 项已实现、30 项部分实现、114 项数据阻塞、189 项基础设施阻塞、673 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 39 项已实现、31 项部分实现、114 项数据阻塞、189 项基础设施阻塞、669 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 59 - 3
scripts/deploy-admin-functions.mjs

@@ -10,7 +10,7 @@ const CLASS_LABELS = {
   _User: '用户管理', Company: '帐套管理', Profile: '员工档案', Group: '用户组', Role: '角色', Permission: '权限', Department: '组织部门',
   CourseBinding: '课程绑定', CourseAppointment: '课程预约', LessonRecord: '上课记录', DailyStudyRecord: '每日学习记录', PracticeRecord: '练习记录', MemoryPracticeRecord: '抗遗忘记录',
   VocabularyWord: '词库', CommonModel: '通用内容', ContentArticle: '文章内容', ContentPublish: '内容发布', Node: '栏目节点', NodeAuth: '节点权限', Model: '内容模型', ModelField: '模型字段',
-  ExamClass: '考试班级', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目',
+  ExamClass: '试题分类', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
   GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '搜索记录', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
@@ -29,7 +29,8 @@ const CLASS_SYSTEM_FIELDS = {
   ModelField: new Set(['fieldId','modelId','fieldName','fieldType','sourceKey','sysType','orderId']),
   Guestbook: new Set(['gid','parentid','cateid','userid','status','sourceKey','gdate','ip']),
   Guestcate: new Set(['cateid','parentId','gtype','sourceKey','orderId']),
-  GuestBar: new Set(['id','cateId','pid','replyId','replyUserId','cuser','cuname','cdate','status','sourceKey','orderFlag','postFlag'])
+  GuestBar: new Set(['id','cateId','pid','replyId','replyUserId','cuser','cuname','cdate','status','sourceKey','orderFlag','postFlag']),
+  ExamClass: new Set(['cId','cClassid','cOrderBy','cClassType','sourceKey'])
 };
 
 function inputOf(request) {
@@ -684,6 +685,59 @@ async function handler(request, response) {
     if (operation === 'barMedal') {
       fail(501, 'migration_blocked: 目标 Schema 缺少旧 ZL_Guest_Medals 勋章及用户帖子授予关系,无法安全授予或撤销帖子勋章');
     }
+    if (operation === 'saveExamClass') {
+      const objectId = String(input.objectId || '');
+      const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      const className = String(payload.cClassName || '').trim();
+      const parentId = Number(input.parentId == null ? payload.cClassid || 0 : input.parentId);
+      const classType = Number(input.classType == null ? payload.cClassType || 1 : input.classType);
+      const orderId = Number(input.orderId == null ? payload.cOrderBy || 0 : input.orderId);
+      if (!className || className.length > 255) fail(400, '试题分类名称长度应为 1 至 255 位');
+      if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父试题分类编号无效');
+      if (![1,2].includes(classType)) fail(400, '试题分类类型只能是答题类型或视频操作类型');
+      if (!Number.isInteger(orderId) || orderId < 0) 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('ExamClass');
+      let target;
+      let currentId = 0;
+      if (objectId) {
+        const query = new Parse.Query('ExamClass'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true });
+        companyId = pointerId(target.get('company')) || companyId; currentId = Number(target.get('cId')) || 0;
+      } else target = new Parse.Object('ExamClass');
+      if (parentId > 0) {
+        const parent = await Psql.oneOrNone('SELECT "cId" FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 LIMIT 1', [companyId, parentId]);
+        if (!parent) fail(404, '父试题分类不存在或不属于当前帐套');
+        if (currentId) {
+          const cycle = await Psql.oneOrNone('WITH RECURSIVE chain AS (SELECT "cId","cClassid",ARRAY["cId"::text] AS path FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 UNION ALL SELECT c."cId",c."cClassid",chain.path||c."cId"::text FROM "ExamClass" c JOIN chain ON c."cId"=chain."cClassid" WHERE c."company"=$1 AND NOT c."cId"::text=ANY(chain.path)) SELECT 1 AS found FROM chain WHERE "cId"=$3 LIMIT 1', [companyId, parentId, currentId]);
+          if (cycle) fail(409, '不能把试题分类移动到自身或其下级分类');
+        }
+      }
+      const duplicate = await Psql.oneOrNone('SELECT "objectId" FROM "ExamClass" WHERE "company"=$1 AND "objectId"<>$2 AND LOWER(TRIM(COALESCE("cClassName",\'\')))=LOWER($3) LIMIT 1', [companyId, objectId, className]);
+      if (duplicate) fail(409, '同一帐套的试题分类名称不能重复');
+      target.set('cClassName', className); target.set('cClassid', parentId); target.set('cClassType', classType); target.set('company', company);
+      if (objectId) target.set('cOrderBy', orderId); else target.set('sourceKey', 'cloud:admin-exam-class:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10));
+      try { await target.save(null, { useMasterKey: true }); }
+      catch (error) { fail(422, '试题分类初始写入失败: ' + String(error && error.message || error)); }
+      try {
+        if (!objectId) {
+          const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-exam-class-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("cId"),0)+1 AS id FROM "ExamClass",lock_row WHERE "company"=$1 AND COALESCE("cId",0)>0), next_order AS MATERIALIZED (SELECT COALESCE(MAX("cOrderBy"),0)+1 AS id FROM "ExamClass",lock_row WHERE "company"=$1 AND "objectId"<>$2) UPDATE "ExamClass" SET "cId"=next_id.id,"cOrderBy"=CASE WHEN $3>0 THEN $3 ELSE next_order.id END,"sourceKey"=\'[["C_id",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id,next_order WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id, orderId]);
+          currentId = Number(rows[0] && rows[0].id) || 0;
+          if (!currentId) throw new Error('无法分配试题分类编号');
+        }
+      } catch (error) { if (!objectId) await target.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '试题分类结构写入失败: ' + String(error && error.message || error)); }
+      await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-exam-class' : 'create-exam-class', 'ExamClass', target.id);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'examClassChildren') {
+      const parentId = Number(input.parentId || 0);
+      if (!Number.isInteger(parentId) || parentId < 0) fail(400, '父试题分类编号无效');
+      const fields = await schemaFor('ExamClass'); const query = new Parse.Query('ExamClass'); applyTenant(query, fields, context, input.companyId); query.equalTo('cClassid', parentId); query.ascending('cOrderBy'); query.limit(1000);
+      const results = await query.find({ useMasterKey: true });
+      return response.json({ success: true, data: { parentId, results: results.map(serializeObject) } });
+    }
     if (operation === 'barBatch') {
       const action = String(input.action || '');
       const actions = ['audit','unaudit','recycle','recover','move','top-global','top-section','top-undo','elite','elite-undo','bottom','bottom-undo'];
@@ -769,7 +823,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','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','cClassName','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);
@@ -789,6 +843,7 @@ async function handler(request, response) {
       if (className === 'Model' || className === 'ModelField') fail(400, '模型结构必须走专用元数据流程');
       if (className === 'Guestbook') fail(400, '留言必须走专用编辑或回复流程');
       if (className === 'Guestcate') fail(400, '留言/贴吧分类必须走专用保存流程');
+      if (className === 'ExamClass') 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);
@@ -802,6 +857,7 @@ async function handler(request, response) {
       if (!classWritable || !objectId) fail(400, '该记录不允许删除'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true });
       if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
       if (className === 'Guestcate') { const cateid = Number(object.get('cateid')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=$2) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=$2) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=$2) AS posts', [companyId, cateid]); if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-guest-category', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
+      if (className === 'ExamClass') { const classId = Number(object.get('cId')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "ExamClass" WHERE "company"=$1 AND "cClassid"=$2) AS children,(SELECT COUNT(*)::int FROM "ExamSysQuestions" WHERE "company"=$1 AND "pClass"=$2) AS questions', [companyId, classId]); if (Number(refs.children) || Number(refs.questions)) fail(409, '试题分类仍被下级分类或试题引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-class', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === '_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 } }); }

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

@@ -82,6 +82,10 @@ const explicit = new Map([
   ['Content / Bar/TieDetail', ['implemented', '已由只读帖子详情抽屉和专用工作流面板替代']],
   ['Content / Bar/TieMove', ['implemented', '已由 barBatch 目标贴吧分类校验与移动替代']],
   ['Content / Bar/Tie_API', ['partial', '已覆盖审核、回收恢复、置顶/沉底、精华和移动;勋章关系未迁移']],
+  ['Exam/Question_Class_Manage', ['partial', '已提供帐套分类列表、搜索和父分类编号;旧展开树布局尚未逐像素复刻']],
+  ['Exam/AddQuestion_Class', ['implemented', '已由 ExamClass 专用结构表单替代']],
+  ['Exam/Question_Class_GetChild', ['implemented', '已由 examClassChildren 帐套隔离子分类接口替代']],
+  ['Exam/Question_Class_Add', ['implemented', '已由 saveExamClass 原子编号、防重、父级与循环校验替代']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -72,6 +72,7 @@ let temporaryGroupObjectId = '';
 const temporaryNodeObjectIds = [];
 const temporarySpecialObjectIds = [];
 const temporaryGuestCategoryObjectIds = [];
+const temporaryExamClassObjectIds = [];
 let temporaryModelObjectId = '';
 const temporaryModelFieldObjectIds = [];
 let registeredUserId = '';
@@ -311,6 +312,27 @@ try {
     await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Guestcate', objectId: category.objectId });
     temporaryGuestCategoryObjectIds.splice(temporaryGuestCategoryObjectIds.indexOf(String(category.objectId)), 1);
   }
+  const examClassSuffix = Date.now().toString(36);
+  const rootExamClass = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamClass', className: 'ExamClass', parentId: 0, classType: 1, orderId: 0, fields: { cClassName: `冒烟试题分类-${examClassSuffix}` } });
+  temporaryExamClassObjectIds.push(String(rootExamClass.objectId || ''));
+  const rootExamClassId = Number(rootExamClass.cId);
+  if (!temporaryExamClassObjectIds[0] || !rootExamClassId || rootExamClass.sourceKey !== `[["C_id",${rootExamClassId}]]`) throw new Error(`试题分类原子创建异常:${JSON.stringify(rootExamClass)}`);
+  const childExamClass = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamClass', className: 'ExamClass', parentId: rootExamClassId, classType: 2, orderId: 0, fields: { cClassName: `冒烟子试题分类-${examClassSuffix}` } });
+  temporaryExamClassObjectIds.push(String(childExamClass.objectId || ''));
+  const childExamClassId = Number(childExamClass.cId);
+  if (!childExamClassId || Number(childExamClass.cClassid) !== rootExamClassId || Number(childExamClass.cClassType) !== 2) throw new Error('子试题分类创建失败');
+  const examChildren = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'examClassChildren', className: 'ExamClass', parentId: rootExamClassId });
+  if (!examChildren.results?.some((entry) => entry.objectId === childExamClass.objectId)) throw new Error('试题子分类接口未返回目标记录');
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamClass', className: 'ExamClass', parentId: 0, classType: 1, orderId: 0, fields: { cClassName: rootExamClass.cClassName } }, 409);
+  await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamClass', className: 'ExamClass', objectId: rootExamClass.objectId, parentId: childExamClassId, classType: 1, orderId: Number(rootExamClass.cOrderBy) || 0, fields: { cClassName: rootExamClass.cClassName } }, 409);
+  const examClassDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'ExamClass', objectId: rootExamClass.objectId }, 409);
+  if (!String(examClassDeleteBlocked.message).includes('下级分类')) throw new Error('试题分类子级引用删除保护未返回明确原因');
+  const movedExamClass = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamClass', className: 'ExamClass', objectId: childExamClass.objectId, parentId: 0, classType: 2, orderId: Number(childExamClass.cOrderBy) || 0, fields: { cClassName: childExamClass.cClassName } });
+  if (Number(movedExamClass.cClassid) !== 0) throw new Error('试题分类结构移动失败');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'ExamClass', objectId: childExamClass.objectId });
+  temporaryExamClassObjectIds.pop();
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'ExamClass', objectId: rootExamClass.objectId });
+  temporaryExamClassObjectIds.pop();
   const metadataSuffix = Date.now().toString(36);
   const temporaryModelId = 800000000 + Math.floor(Date.now() / 1000) % 100000000;
   const tempModel = await jsonRequest(`${PARSE_URL}/classes/Model`, { method: 'POST', body: JSON.stringify({ sourceKey: `cloud:smoke-model:${metadataSuffix}`, company, modelId: temporaryModelId, modelName: `冒烟模型-${metadataSuffix}`, tableName: `ZL_C_Smoke_${metadataSuffix}`, modelType: 1, itemName: '记录', itemUnit: '条', multiFlag: true }) }, true);
@@ -526,7 +548,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/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/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}`);
@@ -556,6 +578,10 @@ try {
     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;
+  });
   for (const fieldId of temporaryModelFieldObjectIds) if (fieldId) await jsonRequest(`${PARSE_URL}/classes/ModelField/${fieldId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时模型字段清理失败:${error.message}`);
     process.exitCode = 1;

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

@@ -20,6 +20,7 @@ export const ADMIN_NAVIGATION = [
   { label: '每日学习', icon: 'chart-no-axes-combined', route: '/admin/resources/DailyStudyRecord' },
   { label: '词库管理', icon: 'languages', route: '/admin/resources/VocabularyWord' },
   { label: '内容管理', icon: 'files', route: '/admin/resources/CommonModel' },
+  { label: '试题分类', icon: 'folders', route: '/admin/resources/ExamClass' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -22,6 +22,8 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Content / Bar/BarAuthSet')?.status).toBe('blocked-data');
     expect(byKey.get('Content / Bar/BarAuthSet_Submit')?.status).toBe('blocked-data');
     expect(byKey.get('Content / Bar/TieMove')?.status).toBe('implemented');
+    expect(byKey.get('Exam/Question_Class_Manage')?.status).toBe('partial');
+    expect(byKey.get('Exam/Question_Class_Add')?.status).toBe('implemented');
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');
     expect(byKey.get('Label / PowerShell/RunScript')?.status).toBe('blocked-infrastructure');

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

@@ -1966,15 +1966,15 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Exam",
     "action": "Question_Class_Manage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已提供帐套分类列表、搜索和父分类编号;旧展开树布局尚未逐像素复刻"
   },
   {
     "module": "Exam",
     "action": "AddQuestion_Class",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 ExamClass 专用结构表单替代"
   },
   {
     "module": "Exam",
@@ -2134,15 +2134,15 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Exam",
     "action": "Question_Class_GetChild",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 examClassChildren 帐套隔离子分类接口替代"
   },
   {
     "module": "Exam",
     "action": "Question_Class_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveExamClass 原子编号、防重、父级与循环校验替代"
   },
   {
     "module": "Exam",

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

@@ -111,6 +111,14 @@
             <label><span>父分类 ID <small>留言分类固定为 0</small></span><input type="number" min="0" [disabled]="guestCategoryType() === 0" [ngModel]="targetGuestCategoryParentId()" (ngModelChange)="targetGuestCategoryParentId.set(+$event)" /></label>
           </section>
         }
+        @if (className() === 'ExamClass') {
+          <section class="content-controls exam-class-controls">
+            <header><div><strong>试题分类结构</strong><span>专用云函数维护原子分类编号、父子循环、名称防重和试题引用保护</span></div>@if (draftId()) { <b>ID {{ draft()['cId'] }}</b> }</header>
+            <label><span>父分类 ID <small>顶级分类填 0</small></span><input type="number" min="0" [ngModel]="targetExamClassParentId()" (ngModelChange)="targetExamClassParentId.set(+$event)" /></label>
+            <label><span>分类类型</span><select [ngModel]="examClassType()" (ngModelChange)="examClassType.set(+$event)"><option [ngValue]="1">答题类型</option><option [ngValue]="2">视频操作类型</option></select></label>
+            <label><span>排序 <small>新建填 0 自动顺排</small></span><input type="number" min="0" [ngModel]="examClassOrder()" (ngModelChange)="examClassOrder.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>

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

@@ -303,6 +303,24 @@ describe('AdminResourceComponent', () => {
     }]);
   });
 
+  it('creates a question category through the atomic structural operation', async () => {
+    component.className.set('ExamClass');
+    component.schema.set({ className: 'ExamClass', label: '试题分类', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ cClassName: '英语', cId: 999, cClassid: 999 });
+    component.targetExamClassParentId.set(7);
+    component.examClassType.set(2);
+    component.examClassOrder.set(8);
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveExamClass'
+      ? { objectId: 'exam-class-object', cId: 20, cClassName: '英语', cClassid: 7, cClassType: 2, cOrderBy: 8 }
+      : { className: 'ExamClass', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveExamClass', {
+      className: 'ExamClass', objectId: undefined, fields: { cClassName: '英语', cId: 999, cClassid: 999 }, parentId: 7, classType: 2, orderId: 8,
+    }]);
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

+ 11 - 5
src/app/admin/pages/admin-resource.component.ts

@@ -71,13 +71,16 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingBar = signal(false);
   readonly barActionError = signal('');
   readonly barActionMessage = signal('');
+  readonly targetExamClassParentId = signal(0);
+  readonly examClassType = signal(1);
+  readonly examClassOrder = signal(0);
   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', 'username', 'realName', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'cClassName', '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);
   });
@@ -109,17 +112,17 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.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.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.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.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(); }
+  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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -156,11 +159,13 @@ export class AdminResourceComponent implements OnInit {
             : this.className() === 'Model' ? 'saveModelMetadata'
               : this.className() === 'ModelField' ? 'saveModelFieldMetadata'
                 : this.className() === 'Guestbook' ? 'saveGuestbook'
-                  : this.className() === 'Guestcate' ? 'saveGuestCategory' : 'save';
+                  : this.className() === 'Guestcate' ? 'saveGuestCategory'
+                    : this.className() === 'ExamClass' ? 'saveExamClass' : '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();
       if (this.className() === 'Guestcate') { payload['gtype'] = this.guestCategoryType(); payload['parentId'] = this.targetGuestCategoryParentId(); }
+      if (this.className() === 'ExamClass') { payload['parentId'] = this.targetExamClassParentId(); payload['classType'] = this.examClassType(); payload['orderId'] = this.examClassOrder(); }
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -470,4 +475,5 @@ export class AdminResourceComponent implements OnInit {
   private resetGuestbookActions(detail: Record<string, unknown> = {}): void { this.selectedGuestbookStatus.set(Number(detail['status'] ?? 99)); this.guestbookReplyTitle.set(''); this.guestbookReplyContent.set(''); this.guestbookActionError.set(''); this.guestbookActionMessage.set(''); }
   private resetGuestCategoryActions(detail: Record<string, unknown> = {}): void { this.guestCategoryType.set(Number(detail['gtype']) || 0); this.targetGuestCategoryParentId.set(Number(detail['parentId']) || 0); }
   private resetBarActions(detail: Record<string, unknown> = {}): void { this.targetBarCategoryId.set(Number(detail['cateId']) || 0); this.barActionError.set(''); this.barActionMessage.set(''); }
+  private resetExamClassActions(detail: Record<string, unknown> = {}): void { this.targetExamClassParentId.set(Number(detail['cClassid']) || 0); this.examClassType.set(Number(detail['cClassType']) || 1); this.examClassOrder.set(Number(detail['cOrderBy']) || 0); }
 }