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

feat: migrate admin content workflow

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

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

@@ -21,6 +21,7 @@
 - `_User` 的状态、旧用户 ID、旧用户组与旧资料 JSON 已从通用字段编辑中移除;密码重置和用户删除新增管理员目标保护并撤销目标会话,避免绕过专用一致性流程。
 - 当前线上 `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` 改为通用只读;新增按钮关闭,避免只写主表却遗漏模型附表。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -21,6 +21,7 @@ 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','isDisabled','legacyUserId','legacyGroupId','legacyUserData','groupId']);
 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']);
+const CLASS_SYSTEM_FIELDS = { CommonModel: new Set(['generalId','modelId','nodeId','itemId','tableName','status','isDeleted','sourceKey','orderId']) };
 
 function inputOf(request) {
   const body = request.body || {};
@@ -82,6 +83,7 @@ async function revokeSessions(targets) {
 function assertClass(className) {
   if (!ALLOWED_CLASSES.has(className)) fail(400, '不允许访问该数据类');
 }
+function isSystemField(className, name) { return SYSTEM_FIELDS.has(name) || Boolean(CLASS_SYSTEM_FIELDS[className] && CLASS_SYSTEM_FIELDS[className].has(name)); }
 async function schemaFor(className) {
   assertClass(className);
   const schema = await new Parse.Schema(className).get({ useMasterKey: true });
@@ -309,13 +311,46 @@ async function handler(request, response) {
       await target.fetch({ useMasterKey: true }); await audit({ ...context, company }, objectId ? 'update-group' : 'create-group', 'Group', target.id);
       return response.json({ success: true, data: serializeObject(target) });
     }
+    if (operation === 'contentBatch') {
+      const action = String(input.action || '');
+      if (!['status','recycle','recover','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 contentFields = await schemaFor('CommonModel');
+      const query = new Parse.Query('CommonModel'); applyTenant(query, contentFields, 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, '部分内容不存在或不属于当前帐套');
+      let status = null;
+      let nodeId = null;
+      if (action === 'status') {
+        status = Number(input.status);
+        if (![-3,-1,0,99].includes(status)) fail(400, '不支持的内容状态');
+      } else if (action === 'recycle') status = -2;
+      else if (action === 'recover') status = 0;
+      else {
+        nodeId = Number(input.nodeId);
+        if (!Number.isInteger(nodeId) || nodeId < 1) fail(400, '请选择有效目标节点');
+        const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
+        if (companyIds.length !== 1) fail(400, '批量移动的内容必须属于同一帐套');
+        const node = await Psql.oneOrNone('SELECT "objectId" FROM "Node" WHERE "company"=$1 AND "nodeId"=$2 LIMIT 1', [companyIds[0], nodeId]);
+        if (!node) fail(404, '目标节点不存在或不属于当前帐套');
+      }
+      for (const target of targets) {
+        if (status !== null) target.set('status', status);
+        if (nodeId !== null) target.set('nodeId', nodeId);
+      }
+      await Parse.Object.saveAll(targets, { useMasterKey: true });
+      for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'content-' + action, 'CommonModel', target.id);
+      return response.json({ success: true, data: { action, updated: targets.length, status, nodeId, results: targets.map(serializeObject) } });
+    }
     const className = String(input.className || '');
     assertClass(className);
     const fields = await schemaFor(className);
     const classWritable = !READ_ONLY_CLASSES.has(className);
     if (operation === 'schema') {
-      const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !SYSTEM_FIELDS.has(name) && GENERIC_WRITE_TYPES.has(field.type) }));
-      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, supportsSoftDelete: Boolean(fields.isDeleted) } });
+      const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !isSystemField(className, name) && GENERIC_WRITE_TYPES.has(field.type) }));
+      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && className !== 'CommonModel', supportsSoftDelete: Boolean(fields.isDeleted) } });
     }
     if (operation === 'list') {
       const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
@@ -336,10 +371,11 @@ async function handler(request, response) {
       if (!classWritable) fail(403, '该系统类不允许通用编辑');
       if (className === '_User' && !objectId) fail(400, '新增用户必须走专用开户流程');
       if (className === 'Group') 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);
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
-      for (const [name, value] of Object.entries(payload)) { if (!fields[name] || SYSTEM_FIELDS.has(name)) continue; if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许通用编辑: ' + name); if (value === null) object.unset(name); else object.set(name, toParseValue(fields[name], value)); }
+      for (const [name, value] of Object.entries(payload)) { if (!fields[name] || isSystemField(className, name)) continue; if (!GENERIC_WRITE_TYPES.has(fields[name].type)) fail(400, '字段不允许通用编辑: ' + name); if (value === null) object.unset(name); else object.set(name, toParseValue(fields[name], value)); }
       if (fields.company && context.company) object.set('company', context.company); if (fields.isDeleted && !objectId) object.set('isDeleted', false);
       await object.save(null, { useMasterKey: true }); await audit(context, objectId ? 'update' : 'create', className, object.id);
       return response.json({ success: true, data: serializeObject(object) });
@@ -348,6 +384,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 === '_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 } }); }
       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) } });

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

@@ -168,6 +168,9 @@ try {
   const appointmentRows = await jsonRequest(`${PARSE_URL}/classes/CourseAppointment?where=${appointmentWhere}&limit=1`, {}, true);
   temporaryAppointmentObjectId = appointmentRows.results?.[0]?.objectId || '';
   if (!temporaryAppointmentObjectId) throw new Error('临时预约副表创建失败');
+  const appointmentCommonRows = await jsonRequest(`${PARSE_URL}/classes/CommonModel?where=${appointmentWhere}&limit=1`, {}, true);
+  const temporaryAppointmentCommonObjectId = String(appointmentCommonRows.results?.[0]?.objectId || '');
+  if (!temporaryAppointmentCommonObjectId) throw new Error('临时预约主内容创建失败');
 
   const meta = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'meta' });
   if (meta.identity?.objectId !== userId || !meta.cloudFunctions) throw new Error('管理员 meta 校验失败');
@@ -210,6 +213,19 @@ try {
 
   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 校验失败');
+  const contentSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'CommonModel' });
+  const contentFieldMap = Object.fromEntries((contentSchema.fields || []).map((field) => [field.name, field]));
+  if (contentSchema.creatable !== false || contentFieldMap.status?.writable !== false || contentFieldMap.modelId?.writable !== false || contentFieldMap.itemId?.writable !== false || contentFieldMap.nodeId?.writable !== false) throw new Error('内容结构字段未从通用创建/编辑中隔离');
+
+  const contentPending = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'status', objectIds: [temporaryAppointmentCommonObjectId], status: 0 });
+  if (contentPending.updated !== 1 || Number(contentPending.results?.[0]?.status) !== 0) throw new Error('内容待审核状态更新失败');
+  const contentRecycled = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'recycle', objectIds: [temporaryAppointmentCommonObjectId] });
+  if (Number(contentRecycled.results?.[0]?.status) !== -2) throw new Error('内容回收站状态更新失败');
+  const contentRecovered = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'recover', objectIds: [temporaryAppointmentCommonObjectId] });
+  if (Number(contentRecovered.results?.[0]?.status) !== 0) throw new Error('内容回收站恢复失败');
+  const contentMoved = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'move', objectIds: [temporaryAppointmentCommonObjectId], nodeId: 29 });
+  if (Number(contentMoved.results?.[0]?.nodeId) !== 29) throw new Error('内容节点移动失败');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'status', objectIds: [temporaryAppointmentCommonObjectId], status: 99 });
 
   const page = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'list', className: 'CourseAppointment', page: 1, pageSize: 2 });
   if (!Array.isArray(page.results) || page.pageSize !== 2) throw new Error('资源分页校验失败');
@@ -385,7 +401,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/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/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}`);

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

@@ -63,6 +63,7 @@ export interface AdminClassSchema {
   label: string;
   fields: AdminFieldSchema[];
   writable: boolean;
+  creatable?: boolean;
   supportsSoftDelete: boolean;
 }
 

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

@@ -1,6 +1,6 @@
 <header class="resource-header">
   <div><p>DATA RESOURCE</p><h1>{{ schema()?.label || className() }}</h1><span>{{ className() }} · {{ page()?.total || 0 | number }} 条记录</span></div>
-  <div class="header-actions"><button type="button" (click)="load(1, true)"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button>@if (schema()?.writable) { <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="18" />新建记录</button> }</div>
+  <div class="header-actions"><button type="button" (click)="load(1, true)"><lucide-icon [img]="icons.RefreshCw" [size]="17" />刷新</button>@if (schema()?.writable && schema()?.creatable !== false) { <button class="primary" type="button" (click)="openCreate()"><lucide-icon [img]="icons.Plus" [size]="18" />新建记录</button> }</div>
 </header>
 
 <section class="resource-card">
@@ -42,6 +42,18 @@
             @else { <input [type]="field.type === 'Number' ? 'number' : field.type === 'Date' ? 'datetime-local' : 'text'" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)" [placeholder]="field.type === 'Pointer' ? (field.targetClass + ' objectId') : field.name" /> }
           </label>
         } }
+        @if (className() === 'CommonModel' && draftId()) {
+          <section class="content-controls">
+            <header><div><strong>内容审核与归档</strong><span>专用云函数同步旧 ContentManage 状态与节点移动语义</span></div><b [class.recycled]="contentStatusValue() === -2">{{ contentStatusLabel(contentStatusValue()) }}</b></header>
+            <label><span>审核状态</span><select [ngModel]="selectedContentStatus()" (ngModelChange)="selectedContentStatus.set($event)"><option [ngValue]="99">已审核(99)</option><option [ngValue]="0">待审核(0)</option><option [ngValue]="-1">未通过(-1)</option><option [ngValue]="-3">草稿(-3)</option></select></label>
+            <button type="button" [disabled]="updatingContent()" (click)="applyContentAction('status')">更新状态</button>
+            <label><span>目标节点 ID</span><input type="number" min="1" [ngModel]="targetContentNodeId()" (ngModelChange)="targetContentNodeId.set(+$event)" /></label>
+            <button type="button" [disabled]="updatingContent() || !targetContentNodeId()" (click)="applyContentAction('move')">移动节点</button>
+            <div class="content-recycle">@if (contentStatusValue() === -2) { <button type="button" [disabled]="updatingContent()" (click)="applyContentAction('recover')">从回收站恢复</button> } @else { <button class="danger-action" type="button" [disabled]="updatingContent()" (click)="applyContentAction('recycle')">移入回收站</button> }</div>
+            @if (contentActionError()) { <div class="content-action-status error">{{ contentActionError() }}</div> }
+            @if (contentActionMessage()) { <div class="content-action-status success">{{ contentActionMessage() }}</div> }
+          </section>
+        }
         @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>

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

@@ -8,4 +8,5 @@
 .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; }
-@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; } }
+.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; }
+@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; } }

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

@@ -144,4 +144,21 @@ describe('AdminResourceComponent', () => {
       className: 'Group', objectId: undefined, fields: { groupName: '普通会员', parentGroupId: 0, regSelect: true },
     }]);
   });
+
+  it('updates content workflow state through the dedicated batch operation', async () => {
+    component.className.set('CommonModel');
+    component.draftId.set('content-object');
+    component.draft.set({ objectId: 'content-object', status: 0, nodeId: 29 });
+    component.schema.set({ className: 'CommonModel', label: '内容', writable: true, creatable: false, supportsSoftDelete: true, fields: [] });
+    component.selectedContentStatus.set(99);
+    functions.admin.and.callFake(async (operation: string) => operation === 'contentBatch'
+      ? { action: 'status', updated: 1, results: [{ objectId: 'content-object', status: 99, nodeId: 29 }] }
+      : { className: 'CommonModel', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.applyContentAction('status');
+
+    expect(functions.admin.calls.first().args).toEqual(['contentBatch', { className: 'CommonModel', action: 'status', objectIds: ['content-object'], status: 99 }]);
+    expect(component.contentStatusValue()).toBe(99);
+    expect(component.contentActionMessage()).toContain('已审核');
+  });
 });

+ 42 - 4
src/app/admin/pages/admin-resource.component.ts

@@ -46,6 +46,11 @@ export class AdminResourceComponent implements OnInit {
   readonly userActionError = signal('');
   readonly userActionMessage = signal('');
   readonly updatingUser = signal(false);
+  readonly selectedContentStatus = signal(0);
+  readonly targetContentNodeId = signal(0);
+  readonly updatingContent = signal(false);
+  readonly contentActionError = signal('');
+  readonly contentActionMessage = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
@@ -75,17 +80,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.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.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.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.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(); }
+  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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -188,9 +193,39 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.userActionError.set(error instanceof Error ? error.message : '用户组更新失败'); }
     finally { this.updatingUser.set(false); }
   }
+  async applyContentAction(action: 'status' | 'recycle' | 'recover' | 'move'): Promise<void> {
+    this.contentActionError.set(''); this.contentActionMessage.set('');
+    if (this.className() !== 'CommonModel' || !this.draftId()) { this.contentActionError.set('只能管理现有内容'); return; }
+    if (action === 'recycle' && !confirm('确认将该内容移入回收站?')) return;
+    const payload: Record<string, unknown> = { className: 'CommonModel', action, objectIds: [this.draftId()] };
+    if (action === 'status') {
+      const status = this.selectedContentStatus();
+      if (![-3, -1, 0, 99].includes(status)) { this.contentActionError.set('请选择有效内容状态'); return; }
+      payload['status'] = status;
+    }
+    if (action === 'move') {
+      const nodeId = this.targetContentNodeId();
+      if (!Number.isInteger(nodeId) || nodeId < 1) { this.contentActionError.set('请输入有效目标节点 ID'); return; }
+      payload['nodeId'] = nodeId;
+    }
+    this.updatingContent.set(true);
+    try {
+      const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('contentBatch', payload);
+      if (result.results?.[0]) {
+        this.draft.set(result.results[0]);
+        this.selectedContentStatus.set(Number(result.results[0]['status']) || 0);
+        this.targetContentNodeId.set(Number(result.results[0]['nodeId']) || 0);
+      }
+      const messages = { status: `内容状态已更新为${this.contentStatusLabel(this.selectedContentStatus())}`, recycle: '内容已移入回收站', recover: '内容已恢复为待审核', move: `内容已移动到节点 ${this.targetContentNodeId()}` };
+      this.contentActionMessage.set(messages[action]);
+      await this.load();
+    } catch (error) { this.contentActionError.set(error instanceof Error ? error.message : '内容操作失败'); }
+    finally { this.updatingContent.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
-    if (!objectId || !confirm(`确认删除 ${this.className()} / ${objectId}?`)) return;
+    const verb = this.className() === 'CommonModel' ? '移入回收站' : '删除';
+    if (!objectId || !confirm(`确认${verb} ${this.className()} / ${objectId}?`)) return;
     try { await this.functions.admin('delete', { className: this.className(), objectId }); await this.load(); }
     catch (error) { this.error.set(error instanceof Error ? error.message : '删除失败'); }
   }
@@ -204,6 +239,8 @@ export class AdminResourceComponent implements OnInit {
   }
   groupIdOf(group: Record<string, unknown>): number { return Number(group['groupId']) || 0; }
   groupLabel(group: Record<string, unknown>): string { return `${String(group['groupName'] || group['name'] || '未命名组')}(${this.groupIdOf(group)})`; }
+  contentStatusValue(): number { return Number(this.draft()['status']) || 0; }
+  contentStatusLabel(status: number): string { return ({ [-3]: '草稿', [-2]: '回收站', [-1]: '未通过', 0: '待审核', 99: '已审核' } as Record<number, string>)[status] || String(status); }
   private async loadUserGroups(): Promise<void> {
     try {
       const groups = await this.functions.admin<ResourcePage>('list', { className: 'Group', page: 1, pageSize: 100, sort: 'orderId', order: 'asc' });
@@ -213,4 +250,5 @@ export class AdminResourceComponent implements OnInit {
   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(''); }
+  private resetContentActions(detail: Record<string, unknown> = {}): void { this.selectedContentStatus.set(Number(detail['status']) || 0); this.targetContentNodeId.set(Number(detail['nodeId']) || 0); this.contentActionError.set(''); this.contentActionMessage.set(''); }
 }