Sfoglia il codice sorgente

feat: migrate exam teacher lifecycle

彭峰 2 mesi fa
parent
commit
5558821644

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

@@ -32,8 +32,9 @@
 - 旧考试后台 `Question_Class_Manage/AddQuestion_Class/Question_Class_GetChild/Question_Class_Add` 已按官方 `ZL_Exam_Class` 模型迁到目标 `ExamClass`:`saveExamClass` 用 PostgreSQL 帐套事务锁原子分配 `cId/cOrderBy/sourceKey`,校验名称唯一、父分类归属和层级循环;`examClassChildren` 保留旧按父编号取子项能力,删除会拒绝仍有下级分类或 `ExamSysQuestions.pClass` 试题引用的记录。Angular 新增“试题分类”导航和结构表单;旧展开树视觉布局尚未复刻,因此列表动作仍保守标为部分实现。
 - 旧考试后台 `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 或明确数字外键,删除仍保留帐套隔离和审计。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 45 项已实现、32 项部分实现、114 项数据阻塞、189 项基础设施阻塞、662 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 48 项已实现、32 项部分实现、114 项数据阻塞、189 项基础设施阻塞、659 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -32,7 +32,8 @@ const CLASS_SYSTEM_FIELDS = {
   GuestBar: new Set(['id','cateId','pid','replyId','replyUserId','cuser','cuname','cdate','status','sourceKey','orderFlag','postFlag']),
   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'])
+  QuestionsKnowledge: new Set(['kId','kClassId','kOrderBy','pid','cuser','cdate','sourceKey']),
+  ExTeacher: new Set(['id','tclsss','addUser','creatTime','sourceKey'])
 };
 
 function inputOf(request) {
@@ -845,6 +846,32 @@ async function handler(request, response) {
       const results = await query.find({ useMasterKey: true });
       return response.json({ success: true, data: { classId, parentId, results: results.map(serializeObject) } });
     }
+    if (operation === 'saveExamTeacher') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      const teacherName = String(payload.tname || '').trim(); const classId = Number(input.classId == null ? payload.tclsss || 0 : input.classId);
+      if (!teacherName || teacherName.length > 50) fail(400, '教师姓名长度应为 1 至 50 位');
+      if (!Number.isInteger(classId) || classId < 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 });
+      if (classId > 0) { const subject = await Psql.oneOrNone('SELECT "cId" FROM "ExamClass" WHERE "company"=$1 AND "cId"=$2 LIMIT 1', [companyId, classId]); if (!subject) fail(404, '任教试题分类不存在或不属于当前帐套'); }
+      const fields = await schemaFor('ExTeacher'); let target;
+      if (objectId) { const query = new Parse.Query('ExTeacher'); applyTenant(query, fields, context, input.companyId); target = await query.get(objectId, { useMasterKey: true }); companyId = pointerId(target.get('company')) || companyId; }
+      else target = new Parse.Object('ExTeacher');
+      const limits = { post: 50, teach: 50, fileUpload: 255, remark: 1000 };
+      for (const [name, limit] of Object.entries(limits)) { const value = String(payload[name] || '').trim(); if (value.length > limit) fail(400, name + ' 超出最大长度 ' + limit); target.set(name, value); }
+      target.set('tname', teacherName); target.set('tclsss', classId); target.set('company', company); target.set('addUser', Number(context.current.get('legacyUserId')) || 0);
+      if (!objectId) { target.set('sourceKey', 'cloud:admin-exam-teacher:' + companyId + ':' + Date.now() + ':' + Math.random().toString(36).slice(2,10)); target.set('creatTime', new Date()); }
+      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-teacher-id:\'||$1))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "ExTeacher",lock_row WHERE "company"=$1 AND COALESCE("id",0)>0) UPDATE "ExTeacher" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, target.id]);
+          if (!(Number(rows[0] && rows[0].id) || 0)) 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-teacher' : 'create-exam-teacher', 'ExTeacher', target.id);
+      return response.json({ success: true, data: serializeObject(target) });
+    }
     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'];
@@ -930,7 +957,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','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','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);
@@ -953,6 +980,7 @@ async function handler(request, response) {
       if (className === 'ExamClass') fail(400, '试题分类必须走专用保存流程');
       if (className === 'ExamPoint') fail(400, '考点必须走专用保存流程');
       if (className === 'QuestionsKnowledge') fail(400, '知识点必须走专用保存流程');
+      if (className === 'ExTeacher') 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);

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

@@ -93,6 +93,9 @@ const explicit = new Map([
   ['Exam/AddKnowledge', ['implemented', '已由 QuestionsKnowledge 专用知识点结构表单替代']],
   ['Exam/Knowledge_Add', ['implemented', '已由 saveKnowledge 原子编号、科目防重、父级与循环校验替代']],
   ['Exam/Knowledge_GetChild', ['implemented', '已由 knowledgeChildren 帐套及科目隔离子级接口替代']],
+  ['Exam/ExTeacherManage', ['implemented', '已提供帐套考试教师列表、搜索、分页与删除']],
+  ['Exam/AddExTeacher', ['implemented', '已由 ExTeacher 专用教师资料表单替代']],
+  ['Exam/Teacher_Add', ['implemented', '已由 saveExamTeacher 原子编号和任教分类归属校验替代']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -75,6 +75,7 @@ const temporaryGuestCategoryObjectIds = [];
 const temporaryExamClassObjectIds = [];
 const temporaryExamPointObjectIds = [];
 const temporaryKnowledgeObjectIds = [];
+let temporaryExamTeacherId = '';
 let temporaryModelObjectId = '';
 const temporaryModelFieldObjectIds = [];
 let registeredUserId = '';
@@ -352,6 +353,14 @@ try {
   temporaryKnowledgeObjectIds.pop();
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'QuestionsKnowledge', objectId: rootKnowledge.objectId });
   temporaryKnowledgeObjectIds.pop();
+  const examTeacher = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamTeacher', className: 'ExTeacher', classId: rootExamClassId, fields: { tname: `冒烟考试教师-${examClassSuffix}`, post: '测试岗位', teach: '测试科目', fileUpload: '', remark: '教师生命周期验证' } });
+  temporaryExamTeacherId = String(examTeacher.objectId || '');
+  const examTeacherLegacyId = Number(examTeacher.id);
+  if (!temporaryExamTeacherId || !examTeacherLegacyId || Number(examTeacher.tclsss) !== rootExamClassId || examTeacher.sourceKey !== `[["ID",${examTeacherLegacyId}]]`) throw new Error(`考试教师原子创建异常:${JSON.stringify(examTeacher)}`);
+  const updatedExamTeacher = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamTeacher', className: 'ExTeacher', objectId: temporaryExamTeacherId, classId: childExamClassId, fields: { tname: examTeacher.tname, post: '更新岗位', teach: '更新科目', fileUpload: '', remark: '已更新' } });
+  if (Number(updatedExamTeacher.tclsss) !== childExamClassId || updatedExamTeacher.post !== '更新岗位') throw new Error('考试教师更新失败');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'ExTeacher', objectId: temporaryExamTeacherId });
+  temporaryExamTeacherId = '';
   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 });
@@ -590,7 +599,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/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/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}`);
@@ -624,6 +633,10 @@ try {
     console.error(`临时知识点清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  if (temporaryExamTeacherId) await jsonRequest(`${PARSE_URL}/classes/ExTeacher/${temporaryExamTeacherId}`, { 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;

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

@@ -23,6 +23,7 @@ export const ADMIN_NAVIGATION = [
   { label: '试题分类', icon: 'folders', route: '/admin/resources/ExamClass' },
   { 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: '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

@@ -28,6 +28,8 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Exam/ExamPoint_Add')?.status).toBe('implemented');
     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('Exam/Teacher_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');

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

@@ -2036,15 +2036,15 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Exam",
     "action": "ExTeacherManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供帐套考试教师列表、搜索、分页与删除"
   },
   {
     "module": "Exam",
     "action": "AddExTeacher",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 ExTeacher 专用教师资料表单替代"
   },
   {
     "module": "Exam",
@@ -2197,8 +2197,8 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Exam",
     "action": "Teacher_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveExamTeacher 原子编号和任教分类归属校验替代"
   },
   {
     "module": "Exam",

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

@@ -134,6 +134,12 @@
             <label><span>排序 <small>新建填 0 自动顺排</small></span><input type="number" min="0" [ngModel]="knowledgeOrder()" (ngModelChange)="knowledgeOrder.set(+$event)" /></label>
           </section>
         }
+        @if (className() === 'ExTeacher') {
+          <section class="content-controls exam-teacher-controls">
+            <header><div><strong>考试教师资料</strong><span>专用云函数维护原子教师编号、创建管理员和任教试题分类归属</span></div>@if (draftId()) { <b>ID {{ draft()['id'] }}</b> }</header>
+            <label><span>任教试题分类 ID <small>未指定填 0</small></span><input type="number" min="0" [ngModel]="targetTeacherClassId()" (ngModelChange)="targetTeacherClassId.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>

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

@@ -356,6 +356,22 @@ describe('AdminResourceComponent', () => {
     }]);
   });
 
+  it('creates an exam teacher through the subject-safe operation', async () => {
+    component.className.set('ExTeacher');
+    component.schema.set({ className: 'ExTeacher', label: '考试教师', writable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ tname: '张老师', tclsss: 999, post: '高级教师', teach: '英语', remark: '简介' });
+    component.targetTeacherClassId.set(7);
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveExamTeacher'
+      ? { objectId: 'teacher-object', id: 20, tname: '张老师', tclsss: 7 }
+      : { className: 'ExTeacher', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveExamTeacher', {
+      className: 'ExTeacher', objectId: undefined, fields: { tname: '张老师', tclsss: 999, post: '高级教师', teach: '英语', remark: '简介' }, classId: 7,
+    }]);
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

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

@@ -79,13 +79,14 @@ export class AdminResourceComponent implements OnInit {
   readonly targetKnowledgeClassId = signal(0);
   readonly targetKnowledgeParentId = signal(0);
   readonly knowledgeOrder = signal(0);
+  readonly targetTeacherClassId = 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', 'cClassName', 'testPoint', 'kName', 'username', 'realName', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', '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);
   });
@@ -117,17 +118,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.resetExamClassActions(); this.resetExamPointActions(); this.resetKnowledgeActions(); 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.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.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.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(); }
+  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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -167,7 +168,8 @@ export class AdminResourceComponent implements OnInit {
                   : this.className() === 'Guestcate' ? 'saveGuestCategory'
                     : this.className() === 'ExamClass' ? 'saveExamClass'
                       : this.className() === 'ExamPoint' ? 'saveExamPoint'
-                        : this.className() === 'QuestionsKnowledge' ? 'saveKnowledge' : 'save';
+                        : this.className() === 'QuestionsKnowledge' ? 'saveKnowledge'
+                          : this.className() === 'ExTeacher' ? 'saveExamTeacher' : '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();
@@ -175,6 +177,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'ExamClass') { payload['parentId'] = this.targetExamClassParentId(); payload['classType'] = this.examClassType(); payload['orderId'] = this.examClassOrder(); }
       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();
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -487,4 +490,5 @@ export class AdminResourceComponent implements OnInit {
   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); }
   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); }
 }