فهرست منبع

feat: migrate admin user lifecycle actions

彭峰 1 ماه پیش
والد
کامیت
3af89476ce

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

@@ -17,6 +17,9 @@
 - 动态资源表按 `objectId/name/title/username` 等业务字段稳定优先展示,总页数采用向上取整;只读类使用查看抽屉且不显示保存/删除操作。
 - `_User` 详情抽屉已接入专用 `resetPassword` 管理员云函数,执行 8 位最小长度与二次确认;新密码不进入通用资源字段或前端持久化存储。
 - `_User` 新建入口已改为专用 `createUser` 管理员云函数:帐套内校验邀请账号,事务锁原子分配 `legacyUserId`,固定默认普通组 1,并初始化旧版余额/经验数据;请求不接受管理员或角色字段,开户密码也不进入通用资源草稿。
+- 旧 `User_API` 的锁定、解锁和移动用户组已迁为专用 `userBatch` 管理员云函数:支持每批 1–100 人、强制帐套边界,原子同步 `isDisabled/legacyUserData.State` 或 `legacyGroupId/legacyUserData.GroupID`;停用会撤销全部 Parse 会话,普通管理员不能停用自己或管理其他管理员。旧版账号登录也会拒绝已停用/已删除用户。
+- `_User` 的状态、旧用户 ID、旧用户组与旧资料 JSON 已从通用字段编辑中移除;密码重置和用户删除新增管理员目标保护并撤销目标会话,避免绕过专用一致性流程。
+- 当前线上 `Group` 表为空;用户组移动只接受当前帐套中真实存在的 `Group.groupId`,界面会明确提示先在“全部数据类”建立用户组,不会把不存在的默认编号伪装成可选组。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -18,7 +18,7 @@ const ALLOWED_CLASSES = new Set([
   'PageTemplate','PaperQuestions','PayPlat','Permission','PlatComp','App','DesignAnswer','DesignAsk','Attachment','DesignPage','DesignQuestion','DesignRes','Feedback','DesignScence','StudentAchieve','ContentArticle','DesignSiteInfo','Profile','AdInfo','AdZone','ARoleAuth','Baike','ExamSysQuestions','ContactInfo','VocabularyWord','AssessmentProfile','Agency','MemoryPracticeRecord','DeliveryCenter','CourseBinding','PracticeRecord','CourseAppointment','Account','SurveyItem','SurveyLog','LessonRecord','DailyStudyRecord','CommonModel','ContentPublish','Company','_Role','_User','CRMSAttr','Currency','Datadic','Datadiccategory','DesignTlp','DocModel','DocPermission','ExamClass','ExamSysPapers','ExamType','ExamPoint','ExTeacher','FontPicShape','FontPicShapeType','Grade','GradeCate','Group','GroupModel','GuestBar','Guestbook','Guestcate','MailTemp','Manager','MisProcedure','MisProLevel','MisSign','MisType','PageStyle','Model','ModelField','Node','NodeAuth','NodeModelTemplate','Product','PlatUserRole','Pub','PubTw','PubWTHD','PubZXDC','PublishNode','QuestionsKnowledge','Role','StoreProduct','SafeMobile','Search','SenTask','ServiceSeat','ShopFareTlp','ShopMoneyRegular','Special','StoreApplication','StoreStyle','SysCSSManage','SysHoliday','SysLog','Temp','ThirdPlatInfo','UserCredit','UserDummyPoint','UserFriendGroup','UserLevel','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
 ]);
 const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo']);
-const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role']);
+const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role','isDisabled','legacyUserId','legacyGroupId','legacyUserData']);
 const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
 const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
 
@@ -49,6 +49,7 @@ async function requireAdmin(request) {
   const current = request.user || (typeof user !== 'undefined' ? user : null);
   if (!current) fail(401, '管理员会话已失效');
   await current.fetch({ useMasterKey: true });
+  if (current.get('isDisabled') === true || current.get('isDeleted') === true) fail(403, '管理员账号已停用');
   const roles = Array.isArray(current.get('roles')) ? current.get('roles').map(String) : [];
   const role = String(current.get('role') || '');
   const roleKey = String(current.get('adminRoleKey') || '');
@@ -59,6 +60,25 @@ async function requireAdmin(request) {
   if (!company && !isSuperAdmin) fail(403, '管理员账号尚未分配帐套');
   return { current, roles, isSuperAdmin, company };
 }
+function isAdminAccount(target) {
+  const roles = Array.isArray(target.get('roles')) ? target.get('roles').map(String) : [];
+  return target.get('isAdmin') === true || target.get('role') === 'admin' || roles.includes('admin') || target.get('adminRoleKey') === 'super-admin' || roles.includes('super-admin');
+}
+function assertCanManageUser(context, target, action) {
+  if (action === 'lock' && target.id === context.current.id) fail(400, '不能停用当前登录账号');
+  if (isAdminAccount(target) && target.id !== context.current.id && !context.isSuperAdmin) fail(403, '只有超级管理员可以管理其他管理员账号');
+}
+async function revokeSessions(targets) {
+  let revoked = 0;
+  while (targets.length) {
+    const query = new Parse.Query('_Session'); query.containedIn('user', targets); query.limit(1000);
+    const sessions = await query.find({ useMasterKey: true });
+    if (!sessions.length) break;
+    await Parse.Object.destroyAll(sessions, { useMasterKey: true }); revoked += sessions.length;
+    if (sessions.length < 1000) break;
+  }
+  return revoked;
+}
 function assertClass(className) {
   if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类');
 }
@@ -197,6 +217,47 @@ async function handler(request, response) {
         throw error;
       }
     }
+    if (operation === 'userBatch') {
+      const action = String(input.action || '');
+      if (!['lock','unlock','move'].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 userFields = await schemaFor('_User');
+      const query = new Parse.Query('_User'); applyTenant(query, userFields, 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, '部分用户不存在或不属于当前帐套');
+      for (const target of targets) assertCanManageUser(context, target, action);
+      let group = null;
+      let groupId = 0;
+      if (action === 'move') {
+        groupId = Number(input.groupId);
+        if (!Number.isInteger(groupId) || groupId < 1) fail(400, '请选择有效用户组');
+        const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
+        if (companyIds.length !== 1) fail(400, '批量移动的用户必须属于同一帐套');
+        const groupRow = await Psql.oneOrNone('SELECT "objectId" FROM "Group" WHERE "company"=$1 AND "groupId"=$2 LIMIT 1', [companyIds[0], groupId]);
+        if (!groupRow) fail(404, '目标用户组不存在或不属于当前帐套');
+        group = await new Parse.Query('Group').get(String(groupRow.objectId), { useMasterKey: true });
+      }
+      const now = new Date();
+      for (const target of targets) {
+        const legacyData = { ...(target.get('legacyUserData') || {}) };
+        if (action === 'move') {
+          target.set('legacyGroupId', groupId); legacyData.GroupID = groupId;
+        } else {
+          const disabled = action === 'lock'; target.set('isDisabled', disabled); legacyData.State = disabled ? 0 : 1;
+          if (userFields.statusAction) target.set('statusAction', disabled ? 'admin-lock' : 'admin-unlock');
+          if (userFields.statusReason) target.set('statusReason', String(input.reason || '').trim().slice(0, 200));
+          if (userFields.statusUpdatedAt) target.set('statusUpdatedAt', now);
+          if (userFields.statusUpdatedBy) target.set('statusUpdatedBy', context.current.id);
+        }
+        target.set('legacyUserData', legacyData);
+      }
+      await Parse.Object.saveAll(targets, { useMasterKey: true });
+      const revokedSessions = action === 'lock' ? await revokeSessions(targets) : 0;
+      for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'user-' + action, '_User', target.id);
+      return response.json({ success: true, data: { action, updated: targets.length, revokedSessions, group: group ? serializeObject(group) : null, results: targets.map(serializeObject) } });
+    }
     const className = String(input.className || '');
     assertClass(className);
     const fields = await schemaFor(className);
@@ -233,12 +294,14 @@ async function handler(request, response) {
     }
     if (operation === 'delete') {
       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 === '_User') assertCanManageUser(context, object, 'lock');
       if (fields.isDeleted) { object.set('isDeleted', true); await object.save(null, { useMasterKey: true }); } else await object.destroy({ useMasterKey: true });
+      if (className === '_User') await revokeSessions([object]);
       await audit(context, 'delete', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: Boolean(fields.isDeleted) } });
     }
     if (operation === 'resetPassword') {
       if (className !== '_User' || !objectId || typeof input.newPassword !== 'string' || input.newPassword.length < 8) fail(400, '密码至少 8 位');
-      const query = new Parse.Query('_User'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); target.setPassword(input.newPassword); if (fields.passwordResetRequired) target.set('passwordResetRequired', false); await target.save(null, { useMasterKey: true }); await audit(context, 'reset-password', '_User', objectId); return response.json({ success: true, data: { objectId } });
+      const query = new Parse.Query('_User'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); assertCanManageUser(context, target, 'reset-password'); target.setPassword(input.newPassword); if (fields.passwordResetRequired) target.set('passwordResetRequired', false); await target.save(null, { useMasterKey: true }); const revokedSessions = await revokeSessions([target]); await audit(context, 'reset-password', '_User', objectId); return response.json({ success: true, data: { objectId, revokedSessions } });
     }
     fail(400, '不支持的后台操作');
   } catch (error) {
@@ -328,6 +391,7 @@ function legacyAliases(value, className) {
   }; const map = maps[className] || {}; for (const [legacy,source] of Object.entries(map)) if (row[legacy] === undefined && row[source] !== undefined) row[legacy] = row[source]; return row;
 }
 async function currentUser(request, required = true) { const current = request.user || (typeof user !== 'undefined' ? user : null); if (!current) { if (required) fail(401, '登录状态已失效'); return null; } await current.fetch({ useMasterKey: true }); const company = current.get('company'); if (company && company.id !== DEFAULT_COMPANY_ID) fail(403, '用户不属于小树英语帐套'); return current; }
+async function revokeUserSessions(target) { let count = 0; while (true) { const query = new Parse.Query('_Session'); query.equalTo('user', target); query.limit(1000); const sessions = await query.find({ useMasterKey:true }); if (!sessions.length) break; await Parse.Object.destroyAll(sessions,{useMasterKey:true}); count += sessions.length; if (sessions.length < 1000) break; } return count; }
 function objectValue(source, key) { return source && typeof source.get === 'function' ? source.get(key) : source && source[key]; }
 function legacyData(source) { const value = objectValue(source, 'legacyUserData'); return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; }
 function firstValue(...values) { return values.find((value) => value !== undefined && value !== null && value !== ''); }
@@ -581,7 +645,7 @@ async function handler(request, response) {
     if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
     if (action === 'user_login_passwd') {
       const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
-      try { const loggedIn = await Parse.User.logIn(username, password); if (loggedIn.get('passwordResetRequired') === true) return response.status(403).json(reject('旧系统账号必须先重置 Parse 密码')); return response.json(envelope(legacyUser(loggedIn, true), { State: loggedIn.get('isDisabled') === true ? 0 : 1 })); } catch (_) { return response.status(401).json(reject('账号或密码错误')); }
+      try { const loggedIn = await Parse.User.logIn(username, password); if (loggedIn.get('isDisabled') === true || loggedIn.get('isDeleted') === true) { await revokeUserSessions(loggedIn).catch(() => undefined); return response.status(403).json(reject('账号已停用')); } if (loggedIn.get('passwordResetRequired') === true) return response.status(403).json(reject('旧系统账号必须先重置 Parse 密码')); return response.json(envelope(legacyUser(loggedIn, true), { State: 1 })); } catch (_) { return response.status(401).json(reject('账号或密码错误')); }
     }
     if (action === 'user_register') { const registered = await registerUser(input); return response.json(envelope(registered.result,registered.addon)); }
     if (action === 'user_info_name') { const username = String(input.uname || input.name || '').trim(); if (!username) return response.status(400).json(reject('缺少用户名')); const rows = await Psql.query('SELECT "objectId" FROM "_User" WHERE "company" = $1 AND "username" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, username]); const found = rows[0]; return found ? response.json(envelope({ objectId: found.objectId })) : response.status(404).json(reject('用户不存在')); }

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

@@ -57,6 +57,7 @@ async function sourceActions() {
 
 let userId = '';
 let adminCreatedUserId = '';
+let temporaryGroupObjectId = '';
 let registeredUserId = '';
 let memberId = '';
 let memberLegacyId = 0;
@@ -173,8 +174,25 @@ try {
   if (!adminCreatedUserId || !Number(managed.userId) || Number(managed.groupId) !== 1 || managed.isAdmin === true || managed.roles?.includes?.('admin') || managed.sessionToken) throw new Error(`管理员开户响应异常:${JSON.stringify(managed)}`);
   const managedStored = await jsonRequest(`${PARSE_URL}/users/${adminCreatedUserId}`, {}, true);
   if (managedStored.username !== managedUsername || managedStored.isAdmin === true || managedStored.roles?.includes?.('admin') || Number(managedStored.legacyUserId) !== Number(managed.userId) || Number(managedStored.legacyGroupId) !== 1 || Number(managedStored.legacyUserData?.UserID) !== Number(managed.userId) || Number(managedStored.legacyUserData?.Purse) !== 0) throw new Error('管理员开户持久化约束校验失败');
+  const managedLogin = await jsonRequest(`${PARSE_URL}/login`, { method: 'POST', body: JSON.stringify({ username: managedUsername, password: managedPassword }) });
+  if (!managedLogin.sessionToken) throw new Error('后台开户用户登录失败');
+  const locked = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userBatch', className: '_User', action: 'lock', objectIds: [adminCreatedUserId], reason: '云函数冒烟' });
+  if (locked.updated !== 1 || locked.results?.[0]?.isDisabled !== true || Number(locked.results?.[0]?.legacyUserData?.State) !== 0 || Number(locked.revokedSessions) < 1) throw new Error(`用户停用与会话撤销异常:${JSON.stringify(locked)}`);
+  await callLegacyFunction('', { action: 'user_login_passwd', name: managedUsername, passwd: managedPassword }, 403);
+  const unlocked = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userBatch', className: '_User', action: 'unlock', objectIds: [adminCreatedUserId], reason: '恢复冒烟用户' });
+  if (unlocked.updated !== 1 || unlocked.results?.[0]?.isDisabled !== false || Number(unlocked.results?.[0]?.legacyUserData?.State) !== 1) throw new Error(`用户解锁异常:${JSON.stringify(unlocked)}`);
+  const temporaryGroupNumber = 800000000 + Math.floor(Date.now() / 1000) % 100000000;
+  const temporaryGroup = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'save', className: 'Group', fields: { groupId: temporaryGroupNumber, groupName: '云函数冒烟临时组', sourceKey: `cloud:admin-smoke-group:${temporaryGroupNumber}` } });
+  temporaryGroupObjectId = String(temporaryGroup.objectId || '');
+  if (!temporaryGroupObjectId || Number(temporaryGroup.groupId) !== temporaryGroupNumber) throw new Error('临时用户组创建失败');
+  const moved = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'userBatch', className: '_User', action: 'move', objectIds: [adminCreatedUserId], groupId: temporaryGroupNumber });
+  if (moved.updated !== 1 || Number(moved.results?.[0]?.legacyGroupId) !== temporaryGroupNumber || Number(moved.results?.[0]?.legacyUserData?.GroupID) !== temporaryGroupNumber) throw new Error(`用户组同步异常:${JSON.stringify(moved)}`);
+  const managedAppLogin = await callLegacyFunction('', { action: 'user_login_passwd', name: managedUsername, passwd: managedPassword });
+  if (!managedAppLogin.result?.sessionToken || Number(managedAppLogin.addon?.State) !== 1) throw new Error('解锁后旧版登录异常');
   await jsonRequest(`${PARSE_URL}/users/${adminCreatedUserId}`, { method: 'DELETE' }, true);
   adminCreatedUserId = '';
+  await jsonRequest(`${PARSE_URL}/classes/Group/${temporaryGroupObjectId}`, { method: 'DELETE' }, true);
+  temporaryGroupObjectId = '';
 
   const schema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'CourseAppointment' });
   if (schema.className !== 'CourseAppointment' || !Array.isArray(schema.fields)) throw new Error('schema 校验失败');
@@ -353,7 +371,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 creation/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/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}`);
@@ -367,6 +385,10 @@ try {
     console.error(`管理员开户临时用户清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  if (temporaryGroupObjectId) await jsonRequest(`${PARSE_URL}/classes/Group/${temporaryGroupObjectId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时用户组清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   if (temporaryGuestbookId) await jsonRequest(`${PARSE_URL}/classes/Guestbook/${temporaryGuestbookId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时留言清理失败:${error.message}`);
     process.exitCode = 1;

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

@@ -43,6 +43,15 @@
           </label>
         } }
         @if (className() === '_User' && draftId()) {
+          <section class="user-controls">
+            <header><div><strong>账号状态与用户组</strong><span>专用云函数会同步旧版 State / GroupID;停用账号会立即撤销会话</span></div><b [class.disabled]="draft()['isDisabled'] === true">{{ draft()['isDisabled'] === true ? '已停用' : '正常' }}</b></header>
+            <label class="reason-field"><span>状态变更原因 <small>选填 · 最多 200 字</small></span><input [ngModel]="userActionReason()" (ngModelChange)="userActionReason.set($event)" maxlength="200" /></label>
+            <div class="status-actions">@if (draft()['isDisabled'] === true) { <button type="button" [disabled]="updatingUser()" (click)="setUserDisabled(false)">解除停用</button> } @else { <button class="danger-action" type="button" [disabled]="updatingUser()" (click)="setUserDisabled(true)">停用并撤销会话</button> }</div>
+            <label class="group-field"><span>用户组</span><select [ngModel]="selectedUserGroupId()" (ngModelChange)="selectedUserGroupId.set($event)"><option [ngValue]="0">请选择用户组</option>@for (group of userGroups(); track group['objectId']) { <option [ngValue]="groupIdOf(group)">{{ groupLabel(group) }}</option> }</select>@if (!userGroups().length) { <small>当前帐套尚无 Group 记录,请先在“全部数据类”中建立用户组</small> }</label>
+            <button class="group-submit" type="button" [disabled]="updatingUser() || !selectedUserGroupId()" (click)="moveUserGroup()">保存用户组</button>
+            @if (userActionError()) { <div class="user-action-status error">{{ userActionError() }}</div> }
+            @if (userActionMessage()) { <div class="user-action-status success">{{ userActionMessage() }}</div> }
+          </section>
           <section class="password-reset">
             <header><lucide-icon [img]="icons.KeyRound" [size]="18" /><div><strong>重置登录密码</strong><span>通过专用云函数写入,不进入通用资料字段</span></div></header>
             <label><span>新密码 <small>至少 8 位</small></span><input type="password" autocomplete="new-password" [ngModel]="newPassword()" (ngModelChange)="newPassword.set($event)" /></label>

+ 3 - 2
src/app/admin/pages/admin-resource.component.scss

@@ -4,7 +4,8 @@
 .table-scroll { overflow: auto; }table { width: 100%; border-collapse: collapse; table-layout: fixed; }th { padding: 12px 15px; color: #6c7889; background: #f8fafc; font-size: 11px; font-weight: 700; text-align: left; text-transform: uppercase; }td { max-width: 220px; padding: 13px 15px; overflow: hidden; border-top: 1px solid #edf0f4; color: #3a4658; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }tbody tr:hover { background: #fbfdfc; }.actions { width: 92px; text-align: right; }.actions button { display: inline-grid; place-items: center; width: 31px; height: 31px; margin-left: 5px; border: 1px solid #dfe5ed; border-radius: 8px; color: #557085; background: white; cursor: pointer; }.actions button.danger { color: #b44951; }.empty-cell { height: 180px; color: #8a95a5; text-align: center; }
 .pagination { display: flex; align-items: center; justify-content: space-between; padding: 14px 17px; border-top: 1px solid #edf0f4; color: #7a8798; font-size: 12px; }.pagination div { display: flex; gap: 8px; }.pagination button { min-height: 34px; }.pagination button:disabled { opacity: .45; cursor: default; }
 .empty { display: grid; place-items: center; gap: 10px; min-height: 300px; color: #7f8b9c; }.inline-error { padding: 10px 17px; color: #b33f48; background: #fff2f3; font-size: 12px; }.spin { animation: spin .8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } }
-.drawer-mask { position: fixed; inset: 0; z-index: 50; border: 0; background: rgba(11,24,42,.42); }.editor-drawer { position: fixed; top: 0; right: 0; z-index: 60; display: grid; grid-template-rows: auto 1fr auto; width: min(560px, 94vw); height: 100vh; background: white; box-shadow: -18px 0 50px rgba(17,36,62,.18); }.editor-drawer header, .editor-drawer footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px; border-bottom: 1px solid #e8ecf2; }.editor-drawer header div { display: grid; gap: 3px; }.editor-drawer header span { color: #8290a3; font-size: 11px; }.editor-drawer header strong { font-size: 18px; }.editor-drawer header button { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 8px; background: #f2f5f8; cursor: pointer; }.editor-fields { display: grid; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; overflow: auto; padding: 22px; }.editor-fields label { display: grid; align-content: start; gap: 7px; color: #445165; font-size: 12px; }.editor-fields label:has(textarea) { grid-column: 1 / -1; }.editor-fields small { color: #95a0af; font-weight: 400; }.editor-fields input:not([type=checkbox]), .editor-fields textarea { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid #dce3eb; border-radius: 8px; outline: 0; background: #fbfcfd; font: inherit; }.editor-fields input:focus, .editor-fields textarea:focus { border-color: #44a681; box-shadow: 0 0 0 3px rgba(68,166,129,.1); }.editor-fields textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }.editor-fields textarea.invalid { border-color: #cf5961; box-shadow: 0 0 0 3px rgba(207,89,97,.1); }.editor-fields .field-error { color: #b33f48; }.editor-drawer footer { justify-content: flex-end; border-top: 1px solid #e8ecf2; border-bottom: 0; }
+.drawer-mask { position: fixed; inset: 0; z-index: 50; border: 0; background: rgba(11,24,42,.42); }.editor-drawer { position: fixed; top: 0; right: 0; z-index: 60; display: grid; grid-template-rows: auto 1fr auto; width: min(560px, 94vw); height: 100vh; background: white; box-shadow: -18px 0 50px rgba(17,36,62,.18); }.editor-drawer header, .editor-drawer footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 18px 22px; border-bottom: 1px solid #e8ecf2; }.editor-drawer header div { display: grid; gap: 3px; }.editor-drawer header span { color: #8290a3; font-size: 11px; }.editor-drawer header strong { font-size: 18px; }.editor-drawer header button { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 8px; background: #f2f5f8; cursor: pointer; }.editor-fields { display: grid; align-content: start; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 17px; overflow: auto; padding: 22px; }.editor-fields label { display: grid; align-content: start; gap: 7px; color: #445165; font-size: 12px; }.editor-fields label:has(textarea) { grid-column: 1 / -1; }.editor-fields small { color: #95a0af; font-weight: 400; }.editor-fields input:not([type=checkbox]), .editor-fields textarea, .editor-fields select { width: 100%; box-sizing: border-box; padding: 10px 11px; border: 1px solid #dce3eb; border-radius: 8px; outline: 0; background: #fbfcfd; font: inherit; }.editor-fields input:focus, .editor-fields textarea:focus, .editor-fields select:focus { border-color: #44a681; box-shadow: 0 0 0 3px rgba(68,166,129,.1); }.editor-fields textarea { resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }.editor-fields textarea.invalid { border-color: #cf5961; box-shadow: 0 0 0 3px rgba(207,89,97,.1); }.editor-fields .field-error { color: #b33f48; }.editor-drawer footer { justify-content: flex-end; border-top: 1px solid #e8ecf2; border-bottom: 0; }
 .password-reset { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: 13px; margin-top: 4px; padding: 16px; border: 1px solid #dce8e3; border-radius: 11px; background: #f7fbf9; }.password-reset > header { grid-column: 1 / -1; justify-content: flex-start; padding: 0 0 12px; border-bottom: 1px solid #dfeae5; color: #28795b; }.password-reset > header div { gap: 2px; }.password-reset > header strong { font-size: 13px; }.password-reset > header span { font-size: 10px; }.password-reset > button { grid-column: 1 / -1; justify-self: end; padding: 8px 13px; border: 0; border-radius: 8px; color: white; background: #287e5e; cursor: pointer; }.password-reset > button:disabled { opacity: .55; }.password-status { grid-column: 1 / -1; font-size: 11px; }.password-status.error { color: #b33f48; }.password-status.success { color: #237454; }
 .account-create { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: 17px; }.account-create-note { grid-column: 1 / -1; display: grid; gap: 5px; padding: 14px 15px; border: 1px solid #dce8e3; border-radius: 10px; color: #28795b; background: #f7fbf9; }.account-create-note strong { font-size: 13px; }.account-create-note span { color: #668175; font-size: 11px; line-height: 1.6; }.account-create-error { grid-column: 1 / -1; padding: 10px 12px; border-radius: 8px; color: #b33f48; background: #fff2f3; font-size: 11px; }
-@media (max-width: 700px) { .resource-header { align-items: start; }.resource-header span { display: none; }.header-actions button:first-child { display: none; }.editor-fields, .account-create { grid-template-columns: 1fr; }.editor-fields label:has(textarea) { grid-column: auto; }.pagination > span { display: none; }.pagination { justify-content: flex-end; } }
+.user-controls { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr auto; gap: 13px; padding: 16px; border: 1px solid #dbe4ed; border-radius: 11px; background: #f8fafc; }.user-controls > header { grid-column: 1 / -1; padding: 0 0 12px; border-bottom: 1px solid #e2e8ef; }.user-controls > header strong { font-size: 13px; }.user-controls > header b { padding: 5px 9px; border-radius: 999px; color: #237454; background: #e5f5ed; font-size: 11px; }.user-controls > header b.disabled { color: #a23e47; background: #fdebed; }.user-controls .reason-field { grid-column: 1 / -1; }.status-actions { grid-column: 1 / -1; display: flex; align-items: end; justify-content: flex-end; }.status-actions button, .group-submit { min-height: 38px; padding: 0 13px; border: 1px solid #cfd9e4; border-radius: 8px; color: #476174; background: white; cursor: pointer; }.status-actions .danger-action { border-color: #e5c4c7; color: #a74049; background: #fff7f7; }.group-submit { align-self: end; }.status-actions button:disabled, .group-submit:disabled { opacity: .5; cursor: default; }.user-action-status { grid-column: 1 / -1; font-size: 11px; }.user-action-status.error { color: #b33f48; }.user-action-status.success { color: #237454; }
+@media (max-width: 700px) { .resource-header { align-items: start; }.resource-header span { display: none; }.header-actions button:first-child { display: none; }.editor-fields, .account-create, .user-controls { grid-template-columns: 1fr; }.editor-fields label:has(textarea) { grid-column: auto; }.pagination > span { display: none; }.pagination { justify-content: flex-end; } }

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

@@ -96,4 +96,37 @@ describe('AdminResourceComponent', () => {
     expect(functions.admin).not.toHaveBeenCalled();
     expect(component.createUserError()).toContain('不一致');
   });
+
+  it('uses the protected batch operation to lock a user and reports revoked sessions', async () => {
+    spyOn(window, 'confirm').and.returnValue(true);
+    component.className.set('_User');
+    component.draftId.set('member-object');
+    component.draft.set({ objectId: 'member-object', isDisabled: false });
+    component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] });
+    functions.admin.and.callFake(async (operation: string) => operation === 'userBatch'
+      ? { action: 'lock', updated: 1, revokedSessions: 2, results: [{ objectId: 'member-object', isDisabled: true }] }
+      : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.setUserDisabled(true);
+
+    expect(functions.admin.calls.first().args).toEqual(['userBatch', { className: '_User', action: 'lock', objectIds: ['member-object'], reason: '' }]);
+    expect(component.draft()['isDisabled']).toBeTrue();
+    expect(component.userActionMessage()).toContain('2 个会话');
+  });
+
+  it('moves a user through the dedicated group synchronization operation', async () => {
+    component.className.set('_User');
+    component.draftId.set('member-object');
+    component.draft.set({ objectId: 'member-object', legacyGroupId: 1 });
+    component.schema.set({ className: '_User', label: '用户管理', writable: true, supportsSoftDelete: true, fields: [] });
+    component.selectedUserGroupId.set(3);
+    functions.admin.and.callFake(async (operation: string) => operation === 'userBatch'
+      ? { action: 'move', updated: 1, revokedSessions: 0, results: [{ objectId: 'member-object', legacyGroupId: 3, legacyUserData: { GroupID: 3 } }] }
+      : { className: '_User', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.moveUserGroup();
+
+    expect(functions.admin.calls.first().args).toEqual(['userBatch', { className: '_User', action: 'move', objectIds: ['member-object'], groupId: 3 }]);
+    expect(component.draft()['legacyGroupId']).toBe(3);
+  });
 });

+ 47 - 3
src/app/admin/pages/admin-resource.component.ts

@@ -40,6 +40,12 @@ export class AdminResourceComponent implements OnInit {
   readonly createMobile = signal('');
   readonly createInviteUsername = signal('');
   readonly createUserError = signal('');
+  readonly userGroups = signal<Record<string, unknown>[]>([]);
+  readonly selectedUserGroupId = signal(0);
+  readonly userActionReason = signal('');
+  readonly userActionError = signal('');
+  readonly userActionMessage = signal('');
+  readonly updatingUser = signal(false);
   readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
@@ -69,16 +75,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.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); 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.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.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(); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -152,6 +159,34 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.passwordError.set(error instanceof Error ? error.message : '密码重置失败'); }
     finally { this.resettingPassword.set(false); }
   }
+  async setUserDisabled(disabled: boolean): Promise<void> {
+    this.userActionError.set(''); this.userActionMessage.set('');
+    if (this.className() !== '_User' || !this.draftId()) { this.userActionError.set('只能管理现有用户状态'); return; }
+    if (disabled && !confirm('确认停用该账号并撤销其全部登录会话?')) return;
+    this.updatingUser.set(true);
+    try {
+      const result = await this.functions.admin<{ results: Record<string, unknown>[]; revokedSessions: number }>('userBatch', {
+        className: '_User', action: disabled ? 'lock' : 'unlock', objectIds: [this.draftId()], reason: this.userActionReason().trim(),
+      });
+      if (result.results?.[0]) this.draft.set(result.results[0]);
+      this.userActionMessage.set(disabled ? `账号已停用,撤销 ${result.revokedSessions || 0} 个会话` : '账号已恢复使用');
+      await this.load();
+    } catch (error) { this.userActionError.set(error instanceof Error ? error.message : '账号状态更新失败'); }
+    finally { this.updatingUser.set(false); }
+  }
+  async moveUserGroup(): Promise<void> {
+    this.userActionError.set(''); this.userActionMessage.set('');
+    const groupId = this.selectedUserGroupId();
+    if (this.className() !== '_User' || !this.draftId() || !Number.isInteger(groupId) || groupId < 1) { this.userActionError.set('请选择有效用户组'); return; }
+    this.updatingUser.set(true);
+    try {
+      const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('userBatch', { className: '_User', action: 'move', objectIds: [this.draftId()], groupId });
+      if (result.results?.[0]) this.draft.set(result.results[0]);
+      this.userActionMessage.set(`已移动到用户组 ${groupId}`);
+      await this.load();
+    } catch (error) { this.userActionError.set(error instanceof Error ? error.message : '用户组更新失败'); }
+    finally { this.updatingUser.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     if (!objectId || !confirm(`确认删除 ${this.className()} / ${objectId}?`)) return;
@@ -166,6 +201,15 @@ export class AdminResourceComponent implements OnInit {
     }
     return String(value);
   }
+  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)})`; }
+  private async loadUserGroups(): Promise<void> {
+    try {
+      const groups = await this.functions.admin<ResourcePage>('list', { className: 'Group', page: 1, pageSize: 100, sort: 'orderId', order: 'asc' });
+      this.userGroups.set(groups.results);
+    } catch (error) { this.userGroups.set([]); this.userActionError.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(''); }
 }