Procházet zdrojové kódy

feat: add admin bulk workflow controls

彭峰 před 2 měsíci
rodič
revize
b039095931

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

@@ -22,6 +22,7 @@
 - 当前线上 `Group` 表为空;用户组移动只接受当前帐套中真实存在的 `Group.groupId`,界面会明确提示先在“全部数据类”建立用户组,不会把不存在的默认编号伪装成可选组。
 - 旧 `GroupAdd/GroupAdd_Submit` 已迁为专用 `saveGroup`:新增组使用 PostgreSQL 事务锁在帐套内原子分配只读 `groupId`,校验同名组、父组归属与层级循环;通用保存不能绕过。删除组前会拒绝仍被用户或下级组引用的记录。
 - 旧 `ContentManage_Status`、`ContentManage_Del`、回收站恢复与 `ContentMove_Submit` 已迁为专用 `contentBatch`:支持每批 1–100 条内容、限定旧状态 `-3/-2/-1/0/99`、校验目标节点帐套并保留 addon 关联。`CommonModel` 的 `generalId/modelId/nodeId/itemId/tableName/status/sourceKey/orderId` 改为通用只读;新增按钮关闭,避免只写主表却遗漏模型附表。
+- `_User` 与 `CommonModel` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,以及审核、回收、恢复、移动内容节点;翻页或重新查询会清空选择,避免误操作隐藏页记录。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -5,14 +5,28 @@
 
 <section class="resource-card">
   <div class="toolbar"><label><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="search()" (ngModelChange)="search.set($event)" (keyup.enter)="load(1)" placeholder="按名称、标题、账号或来源键搜索" /></label><button type="button" (click)="load(1)">查询</button></div>
+  @if (bulkEnabled() && selectedIds().length) {
+    <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
+      @if (className() === '_User') {
+        <input class="bulk-reason" maxlength="200" [ngModel]="userActionReason()" (ngModelChange)="userActionReason.set($event)" placeholder="状态原因(选填)" />
+        <button class="danger-action" type="button" [disabled]="updatingUser()" (click)="applyBulkUserAction('lock')">停用</button><button type="button" [disabled]="updatingUser()" (click)="applyBulkUserAction('unlock')">解锁</button>
+        <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><button type="button" [disabled]="updatingUser() || !selectedUserGroupId()" (click)="applyBulkUserAction('move')">移动分组</button>
+      } @else {
+        <select [ngModel]="selectedContentStatus()" (ngModelChange)="selectedContentStatus.set($event)"><option [ngValue]="99">已审核</option><option [ngValue]="0">待审核</option><option [ngValue]="-1">未通过</option><option [ngValue]="-3">草稿</option></select><button type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('status')">更新状态</button><button class="danger-action" type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('recycle')">回收</button><button type="button" [disabled]="updatingContent()" (click)="applyBulkContentAction('recover')">恢复</button>
+        <input class="bulk-node" type="number" min="1" [ngModel]="targetContentNodeId()" (ngModelChange)="targetContentNodeId.set(+$event)" placeholder="节点 ID" /><button type="button" [disabled]="updatingContent() || !targetContentNodeId()" (click)="applyBulkContentAction('move')">移动节点</button>
+      }
+    </div>
+  }
+  @if (bulkError()) { <div class="bulk-notice error">{{ bulkError() }}</div> }
+  @if (bulkMessage()) { <div class="bulk-notice success">{{ bulkMessage() }}</div> }
   @if (error()) { <div class="inline-error">{{ error() }}</div> }
   @if (loading() && !page()) {
     <div class="empty"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载数据…</div>
   } @else {
-    <div class="table-scroll"><table><thead><tr>@for (field of columns(); track field.name) { <th>{{ field.name }}</th> }<th class="actions">操作</th></tr></thead><tbody>
+    <div class="table-scroll"><table><thead><tr>@if (bulkEnabled()) { <th class="select-col"><input type="checkbox" aria-label="全选当前页" [checked]="allPageSelected()" (change)="togglePageSelection($any($event.target).checked)" /></th> }@for (field of columns(); track field.name) { <th>{{ field.name }}</th> }<th class="actions">操作</th></tr></thead><tbody>
       @for (row of page()?.results || []; track row['objectId']) {
-        <tr>@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable) { <button class="danger" type="button" aria-label="删除" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
-      } @empty { <tr><td class="empty-cell" [attr.colspan]="columns().length + 1">没有符合条件的数据</td></tr> }
+        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable) { <button class="danger" type="button" [attr.aria-label]="className() === 'CommonModel' ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
+      } @empty { <tr><td class="empty-cell" [attr.colspan]="columns().length + (bulkEnabled() ? 2 : 1)">没有符合条件的数据</td></tr> }
     </tbody></table></div>
     <footer class="pagination"><span>第 {{ page()?.page || 1 }} 页,共 {{ pageCount() }} 页</span><div><button type="button" [disabled]="(page()?.page || 1) <= 1 || loading()" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button><button type="button" [disabled]="(page()?.page || 1) * (page()?.pageSize || 20) >= (page()?.total || 0) || loading()" (click)="load((page()?.page || 1) + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></div></footer>
   }

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

@@ -1,12 +1,12 @@
 .resource-header { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin-bottom: 22px; }.resource-header p { margin: 0 0 7px; color: #2e9a71; font-size: 11px; font-weight: 800; letter-spacing: .18em; }.resource-header h1 { margin: 0; font-size: 27px; }.resource-header span { display: block; margin-top: 7px; color: #7d899a; font-size: 12px; }
 .header-actions { display: flex; gap: 9px; }.header-actions button, .toolbar button, .pagination button, .editor-drawer footer button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 38px; padding: 0 14px; border: 1px solid #dfe5ed; border-radius: 9px; color: #4e5b6e; background: white; font: inherit; cursor: pointer; }.primary { border-color: #258c67 !important; color: white !important; background: #258c67 !important; }
 .resource-card { overflow: hidden; border: 1px solid #e4e9f0; border-radius: 14px; background: white; box-shadow: 0 7px 24px rgba(24,46,76,.04); }.toolbar { display: flex; gap: 10px; padding: 17px; border-bottom: 1px solid #edf0f4; }.toolbar label { display: flex; align-items: center; gap: 9px; width: min(430px, 100%); height: 39px; padding: 0 12px; border: 1px solid #dfe5ed; border-radius: 9px; color: #8490a1; background: #f9fafc; }.toolbar input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font: inherit; }
-.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; }
+.bulk-toolbar { display: flex; align-items: center; gap: 8px; overflow-x: auto; padding: 11px 17px; border-bottom: 1px solid #e1e9e5; background: #f5faf8; }.bulk-toolbar strong { margin-right: 4px; color: #28795b; font-size: 12px; white-space: nowrap; }.bulk-toolbar :is(button,input,select) { min-height: 34px; padding: 0 10px; border: 1px solid #d6e0dc; border-radius: 8px; color: #52645d; background: white; font-size: 11px; }.bulk-toolbar button { cursor: pointer; }.bulk-toolbar button:disabled { opacity: .5; }.bulk-toolbar .danger-action { color: #a74049; }.bulk-reason { width: 170px; }.bulk-node { width: 92px; }.bulk-notice { padding: 9px 17px; font-size: 11px; }.bulk-notice.error { color: #b33f48; background: #fff2f3; }.bulk-notice.success { color: #237454; background: #eff9f4; }
+.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; }.select-col { width: 38px; padding-right: 4px; text-align: center; }.select-col input { accent-color: #258c67; cursor: pointer; }.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, .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; }
-.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; }
-.content-controls { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr auto; gap: 13px; padding: 16px; border: 1px solid #e2dfd2; border-radius: 11px; background: #fcfbf6; }.content-controls > header { grid-column: 1 / -1; padding: 0 0 12px; border-bottom: 1px solid #ebe7d9; }.content-controls > header strong { font-size: 13px; }.content-controls > header b { padding: 5px 9px; border-radius: 999px; color: #775f20; background: #f3e9c8; font-size: 11px; }.content-controls > header b.recycled { color: #a23e47; background: #fdebed; }.content-controls > button { align-self: end; min-height: 38px; padding: 0 13px; border: 1px solid #d8d0b8; border-radius: 8px; color: #665728; background: white; cursor: pointer; }.content-recycle { grid-column: 1 / -1; display: flex; justify-content: flex-end; }.content-recycle button { min-height: 36px; padding: 0 13px; border: 1px solid #d8d0b8; border-radius: 8px; color: #665728; background: white; cursor: pointer; }.content-recycle .danger-action { border-color: #e5c4c7; color: #a74049; background: #fff7f7; }.content-controls button:disabled { opacity: .5; cursor: default; }.content-action-status { grid-column: 1 / -1; font-size: 11px; }.content-action-status.error { color: #b33f48; }.content-action-status.success { color: #237454; }
+.user-controls, .content-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; }.content-controls { border-color: #e2dfd2; background: #fcfbf6; }:is(.user-controls,.content-controls) > header { grid-column: 1 / -1; padding: 0 0 12px; border-bottom: 1px solid #e2e8ef; }:is(.user-controls,.content-controls) > header strong { font-size: 13px; }:is(.user-controls,.content-controls) > header b { padding: 5px 9px; border-radius: 999px; font-size: 11px; }.user-controls > header b { color: #237454; background: #e5f5ed; }.content-controls > header b { color: #775f20; background: #f3e9c8; }:is(.user-controls > header b.disabled,.content-controls > header b.recycled) { color: #a23e47; background: #fdebed; }.reason-field, .status-actions, .content-recycle { grid-column: 1 / -1; }.status-actions, .content-recycle { display: flex; justify-content: flex-end; }:is(.status-actions button,.group-submit,.content-controls > button,.content-recycle button) { align-self: end; min-height: 38px; padding: 0 13px; border: 1px solid #cfd9e4; border-radius: 8px; color: #476174; background: white; cursor: pointer; }:is(.status-actions,.content-recycle) .danger-action { color: #a74049; }:is(.user-controls,.content-controls) button:disabled { opacity: .5; }.user-action-status, .content-action-status { grid-column: 1 / -1; font-size: 11px; }:is(.user-action-status,.content-action-status).error { color: #b33f48; }:is(.user-action-status,.content-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, .content-controls { grid-template-columns: 1fr; }.editor-fields label:has(textarea) { grid-column: auto; }.pagination > span { display: none; }.pagination { justify-content: flex-end; } }

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

@@ -161,4 +161,31 @@ describe('AdminResourceComponent', () => {
     expect(component.contentStatusValue()).toBe(99);
     expect(component.contentActionMessage()).toContain('已审核');
   });
+
+  it('selects and clears all records on the current page', () => {
+    component.className.set('CommonModel');
+    component.page.set({ className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [{ objectId: 'content-a' }, { objectId: 'content-b' }] });
+    component.togglePageSelection(true);
+    expect(component.selectedIds()).toEqual(['content-a', 'content-b']);
+    expect(component.allPageSelected()).toBeTrue();
+    component.toggleSelection('content-a', false);
+    expect(component.selectedIds()).toEqual(['content-b']);
+    expect(component.allPageSelected()).toBeFalse();
+  });
+
+  it('sends all selected content ids through the bulk workflow operation', async () => {
+    component.className.set('CommonModel');
+    component.schema.set({ className: 'CommonModel', label: '内容', writable: true, creatable: false, supportsSoftDelete: true, fields: [] });
+    component.selectedIds.set(['content-a', 'content-b']);
+    component.selectedContentStatus.set(99);
+    functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch'
+      ? { action: 'status', updated: 2, results: [] }
+      : { className: 'CommonModel', page: 1, pageSize: 20, total: 2, results: [] });
+
+    await component.applyBulkContentAction('status');
+
+    expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'status', objectIds: ['content-a', 'content-b'], status: 99 }]);
+    expect(component.bulkMessage()).toContain('2 条内容');
+    expect(component.selectedIds()).toEqual([]);
+  });
 });

+ 58 - 1
src/app/admin/pages/admin-resource.component.ts

@@ -51,6 +51,9 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingContent = signal(false);
   readonly contentActionError = signal('');
   readonly contentActionMessage = signal('');
+  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 ?? [];
@@ -59,6 +62,11 @@ export class AdminResourceComponent implements OnInit {
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel'].includes(this.className()));
+  readonly allPageSelected = computed(() => {
+    const ids = (this.page()?.results || []).map((row) => String(row['objectId'] || '')).filter(Boolean);
+    return Boolean(ids.length) && ids.every((id) => this.selectedIds().includes(id));
+  });
   private readonly functions = inject(CloudFunctionsService);
   private readonly route = inject(ActivatedRoute);
   private readonly destroyRef = inject(DestroyRef);
@@ -72,10 +80,11 @@ export class AdminResourceComponent implements OnInit {
   }
 
   async load(pageNumber = this.page()?.page ?? 1, reloadSchema = false): Promise<void> {
-    this.loading.set(true); this.error.set('');
+    this.loading.set(true); this.error.set(''); this.selectedIds.set([]); this.bulkError.set(''); this.bulkMessage.set('');
     try {
       if (reloadSchema || !this.schema()) this.schema.set(await this.functions.admin<AdminClassSchema>('schema', { className: this.className() }));
       this.page.set(await this.functions.admin<ResourcePage>('list', { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() }));
+      if (this.className() === '_User' && reloadSchema) await this.loadUserGroups();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '数据加载失败'); }
     finally { this.loading.set(false); }
   }
@@ -222,6 +231,54 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.contentActionError.set(error instanceof Error ? error.message : '内容操作失败'); }
     finally { this.updatingContent.set(false); }
   }
+  toggleSelection(objectId: string, selected: boolean): void {
+    this.selectedIds.update((current) => selected ? [...new Set([...current, objectId])] : current.filter((id) => id !== objectId));
+  }
+  togglePageSelection(selected: boolean): void {
+    const pageIds = (this.page()?.results || []).map((row) => String(row['objectId'] || '')).filter(Boolean);
+    this.selectedIds.set(selected ? pageIds : []);
+  }
+  isSelected(objectId: unknown): boolean { return this.selectedIds().includes(String(objectId || '')); }
+  async applyBulkUserAction(action: 'lock' | 'unlock' | 'move'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== '_User' || !objectIds.length) { this.bulkError.set('请先选择用户'); return; }
+    if (action === 'lock' && !confirm(`确认停用选中的 ${objectIds.length} 个账号并撤销全部会话?`)) return;
+    const payload: Record<string, unknown> = { className: '_User', action, objectIds };
+    if (action === 'move') {
+      const groupId = this.selectedUserGroupId();
+      if (!Number.isInteger(groupId) || groupId < 1) { this.bulkError.set('请选择有效用户组'); return; }
+      payload['groupId'] = groupId;
+    } else payload['reason'] = this.userActionReason().trim();
+    this.updatingUser.set(true);
+    try {
+      const result = await this.functions.admin<{ updated: number; revokedSessions: number }>('userBatch', payload);
+      const message = action === 'lock' ? `已停用 ${result.updated} 个账号并撤销 ${result.revokedSessions || 0} 个会话` : action === 'unlock' ? `已解锁 ${result.updated} 个账号` : `已移动 ${result.updated} 个用户`;
+      await this.load(); this.bulkMessage.set(message);
+    } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量用户操作失败'); }
+    finally { this.updatingUser.set(false); }
+  }
+  async applyBulkContentAction(action: 'status' | 'recycle' | 'recover' | 'move'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'CommonModel' || !objectIds.length) { this.bulkError.set('请先选择内容'); return; }
+    if (action === 'recycle' && !confirm(`确认将选中的 ${objectIds.length} 条内容移入回收站?`)) return;
+    const payload: Record<string, unknown> = { className: 'CommonModel', action, objectIds };
+    if (action === 'status') {
+      const status = this.selectedContentStatus();
+      if (![-3, -1, 0, 99].includes(status)) { this.bulkError.set('请选择有效内容状态'); return; }
+      payload['status'] = status;
+    }
+    if (action === 'move') {
+      const nodeId = this.targetContentNodeId();
+      if (!Number.isInteger(nodeId) || nodeId < 1) { this.bulkError.set('请输入有效目标节点 ID'); return; }
+      payload['nodeId'] = nodeId;
+    }
+    this.updatingContent.set(true);
+    try {
+      const result = await this.functions.admin<{ updated: number }>('contentBatch', payload);
+      const message = `已处理 ${result.updated} 条内容`; await this.load(); this.bulkMessage.set(message);
+    } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量内容操作失败'); }
+    finally { this.updatingContent.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = this.className() === 'CommonModel' ? '移入回收站' : '删除';