소스 검색

feat: migrate admin forum workflow

彭峰 2 달 전
부모
커밋
92484895c1

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

@@ -27,8 +27,9 @@
 - 旧 `ModelManage/Field/Order_Submit` 的安全部分已迁为 `saveModelMetadata/saveModelFieldMetadata/modelFieldOrder`:现有模型可编辑名称、条目文案、图标、说明和内容模板元数据,现有字段可编辑别名、提示、显示/搜索配置,并可在同模型内批量排序;`modelId/modelType/tableName/fieldId/modelId/fieldName/fieldType/sourceKey` 等结构字段通用只读。新增、复制、删除模型或字段会显式返回 501,因为旧实现同时执行 PostgreSQL 建表/改列、标签生成和模板文件写入,目标托管环境没有该 DDL/旧模板运行时。
 - 旧 `Guest/MsgList/ReplyGuest/MsgShow/Msg_API` 已迁为 `saveGuestbook/guestbookReply/guestbookBatch`:留言列表支持帐套分页、标题搜索和每批 1–100 条审核/取消审核/回收/恢复;详情可白名单编辑标题与正文,管理员回复继承原分类和父 `gid`,再以事务锁原子分配新 `gid/sourceKey`。永久删除会拒绝仍有回复的留言;普通留言新增继续只走已上线 H5 留言云函数。`gid/parentid/cateid/userid/status/sourceKey/gdate/ip` 均通用只读。
 - 旧 `Guest/Bar` 共用的分类新增、编辑、推荐与删除已迁为 `saveGuestCategory/guestCategoryBatch`:新增分类用事务锁原子分配 `cateid/orderId/sourceKey`,`gtype=0` 留言分类固定顶级,`gtype=1` 贴吧分类支持父子结构并阻断循环;同父同类型名称防重,删除前同时检查下级分类、留言与帖子引用。贴吧发帖频率仍依赖旧 `GuestConfig` 文件,版块清空与用户级贴吧授权也尚未开放,因此对应动作保守标为部分实现。
+- 旧 `Bar/TieList/TieDetail/TieMove/Tie_API` 的帖子工作流已迁为 `barBatch`:每批 1–100 条支持审核/取消审核、回收/恢复、全局/版块置顶、取消排序、沉底、精华/取消精华和移动贴吧分类;移动会验证目标为同帐套 `gtype=1` 分类。`GuestBar` 在通用资源中改为结构只读,避免直接篡改帖子 ID、作者、父回复、状态、排序和精华标记。旧“勋章”动作依赖未迁移奖章关系表,继续保守标为部分实现而不伪造成功。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 33 项已实现、29 项部分实现、112 项数据阻塞、189 项基础设施阻塞、679 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 36 项已实现、30 项部分实现、112 项数据阻塞、189 项基础设施阻塞、675 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -17,7 +17,7 @@ const CLASS_LABELS = {
 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 READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo','GuestBar']);
 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']);
@@ -28,7 +28,8 @@ const CLASS_SYSTEM_FIELDS = {
   Model: new Set(['modelId','modelType','tableName','sourceKey','nodeId','fromModel','multiFlag','sysModel']),
   ModelField: new Set(['fieldId','modelId','fieldName','fieldType','sourceKey','sysType','orderId']),
   Guestbook: new Set(['gid','parentid','cateid','userid','status','sourceKey','gdate','ip']),
-  Guestcate: new Set(['cateid','parentId','gtype','sourceKey','orderId'])
+  Guestcate: new Set(['cateid','parentId','gtype','sourceKey','orderId']),
+  GuestBar: new Set(['id','cateId','pid','replyId','replyUserId','cuser','cuname','cdate','status','sourceKey','orderFlag','postFlag'])
 };
 
 function inputOf(request) {
@@ -677,6 +678,46 @@ async function handler(request, response) {
       for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'guest-category-' + action, 'Guestcate', target.id);
       return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
     }
+    if (operation === 'barBatch') {
+      const action = String(input.action || '');
+      const actions = ['audit','unaudit','recycle','recover','move','top-global','top-section','top-undo','elite','elite-undo','bottom','bottom-undo'];
+      if (!actions.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 fields = await schemaFor('GuestBar');
+      const query = new Parse.Query('GuestBar'); applyTenant(query, fields, 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 cateId = null;
+      if (action === 'move') {
+        cateId = Number(input.cateId);
+        if (!Number.isInteger(cateId) || cateId < 1) fail(400, '目标贴吧分类编号无效');
+        const companyIds = [...new Set(targets.map((target) => pointerId(target.get('company'))).filter(Boolean))];
+        if (companyIds.length !== 1) fail(400, '批量移动的帖子必须属于同一帐套');
+        const category = await Psql.oneOrNone('SELECT "objectId" FROM "Guestcate" WHERE "company"=$1 AND "cateid"=$2 AND COALESCE("gtype",0)=1 LIMIT 1', [companyIds[0], cateId]);
+        if (!category) fail(404, '目标贴吧分类不存在或不属于当前帐套');
+      }
+      for (const target of targets) {
+        if (action === 'audit' || action === 'recover') target.set('status', 99);
+        else if (action === 'unaudit') target.set('status', 0);
+        else if (action === 'recycle') target.set('status', -2);
+        else if (action === 'move') target.set('cateId', cateId);
+        else if (action === 'top-global') target.set('orderFlag', 2);
+        else if (action === 'top-section') target.set('orderFlag', 1);
+        else if (action === 'bottom') target.set('orderFlag', -1);
+        else if (action === 'top-undo' || action === 'bottom-undo') target.set('orderFlag', 0);
+        else {
+          const flags = String(target.get('postFlag') || '').split(',').map((value) => value.trim()).filter(Boolean);
+          const next = new Set(flags);
+          if (action === 'elite') next.add('Recommend'); else next.delete('Recommend');
+          target.set('postFlag', next.size ? ',' + [...next].join(',') + ',' : '');
+        }
+      }
+      await Parse.Object.saveAll(targets, { useMasterKey: true });
+      for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'bar-' + action, 'GuestBar', target.id);
+      return response.json({ success: true, data: { action, updated: targets.length, cateId, results: targets.map(serializeObject) } });
+    }
     if (operation === 'contentBatch') {
       const action = String(input.action || '');
       if (!['status','recycle','recover','move'].includes(action)) fail(400, '不支持的内容批量操作');

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

@@ -76,6 +76,10 @@ const explicit = new Map([
   ['Content / Bar/CateAdd', ['partial', '已提供贴吧分类表单;旧版发帖频率文件配置尚未迁移']],
   ['Content / Bar/CateAdd_Submit', ['partial', '已覆盖原子编号、防重、父子循环和分类元数据;文件配置尚缺']],
   ['Content / Bar/Cate_API', ['partial', '已覆盖删除引用保护及推荐/取消推荐;清空版块帖子尚未开放']],
+  ['Content / Bar/TieList', ['implemented', '已提供帐套帖子列表、标题搜索、分页与批量工作流']],
+  ['Content / Bar/TieDetail', ['implemented', '已由只读帖子详情抽屉和专用工作流面板替代']],
+  ['Content / Bar/TieMove', ['implemented', '已由 barBatch 目标贴吧分类校验与移动替代']],
+  ['Content / Bar/Tie_API', ['partial', '已覆盖审核、回收恢复、置顶/沉底、精华和移动;勋章关系未迁移']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -84,6 +84,7 @@ let temporaryAppointmentId = '';
 let temporaryAppointmentObjectId = '';
 let temporaryGuestbookId = '';
 let temporaryGuestbookReplyId = '';
+let temporaryGuestBarId = '';
 let contentHitObjectId = '';
 let contentHitOriginal = 0;
 const temporaryLessonIds = [];
@@ -281,12 +282,27 @@ try {
   if (!temporaryGuestCategoryObjectIds[1] || !childCateid || Number(childCategory.parentId) !== rootCateid) throw new Error('贴吧子分类创建失败');
   const guestCategory = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestCategory', className: 'Guestcate', gtype: 0, parentId: 0, fields: { catename: `冒烟留言分类-${categorySuffix}`, status: 1 } });
   temporaryGuestCategoryObjectIds.push(String(guestCategory.objectId || ''));
+  const temporaryBar = await jsonRequest(`${PARSE_URL}/classes/GuestBar`, { method: 'POST', body: JSON.stringify({ sourceKey: `cloud:smoke-bar:${categorySuffix}`, company, cateId: rootCateid, pid: 0, title: '后台贴吧工作流冒烟', msgContent: '冒烟正文', status: 0, orderFlag: 0, postFlag: '', cdate: { __type: 'Date', iso: new Date().toISOString() } }) }, true);
+  temporaryGuestBarId = String(temporaryBar.objectId || '');
+  const barAudited = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'barBatch', className: 'GuestBar', action: 'audit', objectIds: [temporaryGuestBarId] });
+  if (Number(barAudited.results?.[0]?.status) !== 99) throw new Error('贴吧帖子审核失败');
+  const barElite = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'barBatch', className: 'GuestBar', action: 'elite', objectIds: [temporaryGuestBarId] });
+  if (!String(barElite.results?.[0]?.postFlag).includes('Recommend')) throw new Error('贴吧帖子加精失败');
+  const barTop = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'barBatch', className: 'GuestBar', action: 'top-global', objectIds: [temporaryGuestBarId] });
+  if (Number(barTop.results?.[0]?.orderFlag) !== 2) throw new Error('贴吧帖子全局置顶失败');
+  const barMoved = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'barBatch', className: 'GuestBar', action: 'move', objectIds: [temporaryGuestBarId], cateId: childCateid });
+  if (Number(barMoved.results?.[0]?.cateId) !== childCateid) throw new Error('贴吧帖子移动版块失败');
+  const barRecycled = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'barBatch', className: 'GuestBar', action: 'recycle', objectIds: [temporaryGuestBarId] });
+  if (Number(barRecycled.results?.[0]?.status) !== -2) throw new Error('贴吧帖子回收失败');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'barBatch', className: 'GuestBar', action: 'recover', objectIds: [temporaryGuestBarId] });
   await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestCategory', className: 'Guestcate', gtype: 1, parentId: 0, fields: { catename: rootCategory.catename } }, 409);
   await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveGuestCategory', className: 'Guestcate', objectId: rootCategory.objectId, gtype: 0, parentId: childCateid, fields: { ...rootCategory, catename: rootCategory.catename } }, 409);
   const recommendedCategory = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'guestCategoryBatch', className: 'Guestcate', action: 'recommend', objectIds: [rootCategory.objectId] });
   if (recommendedCategory.results?.[0]?.barInfo !== 'Recommend') throw new Error('贴吧分类推荐失败');
   const categoryDeleteBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Guestcate', objectId: rootCategory.objectId }, 409);
   if (!String(categoryDeleteBlocked.message).includes('引用')) throw new Error('分类子级引用删除保护未返回明确原因');
+  await jsonRequest(`${PARSE_URL}/classes/GuestBar/${temporaryGuestBarId}`, { method: 'DELETE' }, true);
+  temporaryGuestBarId = '';
   for (const category of [childCategory, rootCategory, guestCategory]) {
     await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'delete', className: 'Guestcate', objectId: category.objectId });
     temporaryGuestCategoryObjectIds.splice(temporaryGuestCategoryObjectIds.indexOf(String(category.objectId)), 1);
@@ -506,7 +522,7 @@ try {
   const productBlocked = await callLegacyFunction('', { action: 'product_list' }, 501);
   if (!String(productBlocked.retmsg).includes('ZL_Commodities')) throw new Error('缺商品主表的读取接口未明确阻塞');
 
-  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/guest-category/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
+  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/guest-category/bar/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);
@@ -552,6 +568,10 @@ try {
     console.error(`临时管理员留言回复清理失败:${error.message}`);
     process.exitCode = 1;
   });
+  if (temporaryGuestBarId) await jsonRequest(`${PARSE_URL}/classes/GuestBar/${temporaryGuestBarId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时贴吧帖子清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   for (const lessonId of temporaryLessonIds) await jsonRequest(`${PARSE_URL}/classes/LessonRecord/${lessonId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时陪练课次清理失败:${error.message}`);
     process.exitCode = 1;

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

@@ -19,6 +19,7 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Content / Guest/Msg_API')?.status).toBe('implemented');
     expect(byKey.get('Content / Guest/CateAdd_Submit')?.status).toBe('implemented');
     expect(byKey.get('Content / Bar/Cate_API')?.status).toBe('partial');
+    expect(byKey.get('Content / Bar/TieMove')?.status).toBe('implemented');
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');
     expect(byKey.get('Label / PowerShell/RunScript')?.status).toBe('blocked-infrastructure');

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

@@ -433,29 +433,29 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Bar",
     "action": "TieList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供帐套帖子列表、标题搜索、分页与批量工作流"
   },
   {
     "module": "Content / Bar",
     "action": "TieDetail",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由只读帖子详情抽屉和专用工作流面板替代"
   },
   {
     "module": "Content / Bar",
     "action": "TieMove",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 barBatch 目标贴吧分类校验与移动替代"
   },
   {
     "module": "Content / Bar",
     "action": "Tie_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖审核、回收恢复、置顶/沉底、精华和移动;勋章关系未迁移"
   },
   {
     "module": "BiaoCalc",

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

@@ -19,8 +19,11 @@
         <input class="bulk-node" type="number" min="0" [ngModel]="targetNodeParentId()" (ngModelChange)="targetNodeParentId.set(+$event)" placeholder="父节点 ID" /><button type="button" [disabled]="updatingNode()" (click)="applyBulkNodeAction('move')">移动栏目</button>
       } @else if (className() === 'Special') {
         <input class="bulk-node" type="number" min="0" [ngModel]="targetSpecialParentId()" (ngModelChange)="targetSpecialParentId.set(+$event)" placeholder="父专题 ID" /><button type="button" [disabled]="updatingSpecial()" (click)="applyBulkSpecialMove()">移动专题</button>
-      } @else {
+      } @else if (className() === 'Guestbook') {
         <button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('audit')">审核</button><button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('unaudit')">取消审核</button><button class="danger-action" type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('recycle')">回收</button><button type="button" [disabled]="updatingGuestbook()" (click)="applyBulkGuestbookAction('recover')">恢复</button>
+      } @else {
+        <button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('audit')">审核</button><button class="danger-action" type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recycle')">回收</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recover')">恢复</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('elite')">加精</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-section')">版块置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-global')">全局置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-undo')">取消排序</button>
+        <input class="bulk-node" type="number" min="1" [ngModel]="targetBarCategoryId()" (ngModelChange)="targetBarCategoryId.set(+$event)" placeholder="版块 ID" /><button type="button" [disabled]="updatingBar() || !targetBarCategoryId()" (click)="applyBulkBarAction('move')">移动版块</button>
       }
     </div>
   }
@@ -120,6 +123,19 @@
             @if (guestbookActionMessage()) { <div class="content-action-status success">{{ guestbookActionMessage() }}</div> }
           </section>
         }
+        @if (className() === 'GuestBar' && draftId()) {
+          <section class="content-controls bar-controls">
+            <header><div><strong>贴吧帖子工作流</strong><span>审核、回收、精华、置顶/沉底和移动均由专用云函数维护</span></div><b [class.recycled]="draft()['status'] === -2">{{ draft()['status'] === -2 ? '回收站' : draft()['status'] === 99 ? '已审核' : '待审核' }}</b></header>
+            <button type="button" [disabled]="updatingBar()" (click)="applyBarAction('audit')">审核</button><button type="button" [disabled]="updatingBar()" (click)="applyBarAction('unaudit')">取消审核</button>
+            <button type="button" [disabled]="updatingBar()" (click)="applyBarAction('elite')">设为精华</button><button type="button" [disabled]="updatingBar()" (click)="applyBarAction('elite-undo')">取消精华</button>
+            <button type="button" [disabled]="updatingBar()" (click)="applyBarAction('top-section')">版块置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBarAction('top-global')">全局置顶</button>
+            <button type="button" [disabled]="updatingBar()" (click)="applyBarAction('bottom')">沉底</button><button type="button" [disabled]="updatingBar()" (click)="applyBarAction('top-undo')">取消排序</button>
+            <label><span>目标贴吧分类 ID</span><input type="number" min="1" [ngModel]="targetBarCategoryId()" (ngModelChange)="targetBarCategoryId.set(+$event)" /></label><button type="button" [disabled]="updatingBar() || !targetBarCategoryId()" (click)="applyBarAction('move')">移动版块</button>
+            <div class="content-recycle">@if (draft()['status'] === -2) { <button type="button" [disabled]="updatingBar()" (click)="applyBarAction('recover')">从回收站恢复</button> } @else { <button class="danger-action" type="button" [disabled]="updatingBar()" (click)="applyBarAction('recycle')">移入回收站</button> }</div>
+            @if (barActionError()) { <div class="content-action-status error">{{ barActionError() }}</div> }
+            @if (barActionMessage()) { <div class="content-action-status success">{{ barActionMessage() }}</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>

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

@@ -303,6 +303,34 @@ describe('AdminResourceComponent', () => {
     }]);
   });
 
+  it('updates a forum post through the dedicated workflow operation', async () => {
+    component.className.set('GuestBar');
+    component.draftId.set('bar-object');
+    component.draft.set({ objectId: 'bar-object', id: 10, cateId: 7, status: 0, orderFlag: 0 });
+    functions.admin.and.callFake(async (operation: string) => operation === 'barBatch'
+      ? { action: 'top-global', updated: 1, results: [{ objectId: 'bar-object', id: 10, cateId: 7, status: 0, orderFlag: 2 }] }
+      : { className: 'GuestBar', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.applyBarAction('top-global');
+
+    expect(functions.admin.calls.first().args).toEqual(['barBatch', { className: 'GuestBar', action: 'top-global', objectIds: ['bar-object'] }]);
+    expect(component.draft()['orderFlag']).toBe(2);
+  });
+
+  it('moves selected forum posts through the tenant-safe batch operation', async () => {
+    component.className.set('GuestBar');
+    component.selectedIds.set(['bar-a', 'bar-b']);
+    component.targetBarCategoryId.set(8);
+    functions.admin.and.callFake(async (operation: string) => operation === 'barBatch'
+      ? { action: 'move', updated: 2, results: [] }
+      : { className: 'GuestBar', page: 1, pageSize: 20, total: 2, results: [] });
+
+    await component.applyBulkBarAction('move');
+
+    expect(functions.admin.calls.first().args).toEqual(['barBatch', { className: 'GuestBar', action: 'move', objectIds: ['bar-a', 'bar-b'], cateId: 8 }]);
+    expect(component.bulkMessage()).toContain('2 条帖子');
+  });
+
   it('updates content workflow state through the dedicated batch operation', async () => {
     component.className.set('CommonModel');
     component.draftId.set('content-object');

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

@@ -67,6 +67,10 @@ export class AdminResourceComponent implements OnInit {
   readonly guestbookActionMessage = signal('');
   readonly guestCategoryType = signal(0);
   readonly targetGuestCategoryParentId = signal(0);
+  readonly targetBarCategoryId = signal(0);
+  readonly updatingBar = signal(false);
+  readonly barActionError = signal('');
+  readonly barActionMessage = signal('');
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -78,7 +82,7 @@ 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', 'Node', 'Special', 'Guestbook'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar'].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));
@@ -105,17 +109,17 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.editorOpen.set(true); }
   async openEdit(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
       const detail = await this.functions.admin<Record<string, unknown>>('get', { className: this.className(), objectId });
-      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.resetBarActions(detail); this.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '详情加载失败'); }
   }
-  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.set(''); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -398,6 +402,39 @@ export class AdminResourceComponent implements OnInit {
     } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量留言操作失败'); }
     finally { this.updatingGuestbook.set(false); }
   }
+  async applyBarAction(action: 'audit' | 'unaudit' | 'recycle' | 'recover' | 'move' | 'top-global' | 'top-section' | 'top-undo' | 'elite' | 'elite-undo' | 'bottom' | 'bottom-undo'): Promise<void> {
+    this.barActionError.set(''); this.barActionMessage.set('');
+    if (this.className() !== 'GuestBar' || !this.draftId()) { this.barActionError.set('只能管理现有帖子'); return; }
+    if (action === 'recycle' && !confirm('确认将该帖子移入回收站?')) return;
+    const payload: Record<string, unknown> = { className: 'GuestBar', action, objectIds: [this.draftId()] };
+    if (action === 'move') {
+      const cateId = this.targetBarCategoryId();
+      if (!Number.isInteger(cateId) || cateId < 1) { this.barActionError.set('请输入有效目标贴吧分类 ID'); return; }
+      payload['cateId'] = cateId;
+    }
+    this.updatingBar.set(true);
+    try {
+      const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('barBatch', payload);
+      if (result.results?.[0]) { this.draft.set(result.results[0]); this.targetBarCategoryId.set(Number(result.results[0]['cateId']) || 0); }
+      this.barActionMessage.set('帖子状态已更新'); await this.load();
+    } catch (error) { this.barActionError.set(error instanceof Error ? error.message : '帖子操作失败'); }
+    finally { this.updatingBar.set(false); }
+  }
+  async applyBulkBarAction(action: 'audit' | 'unaudit' | 'recycle' | 'recover' | 'move' | 'top-global' | 'top-section' | 'top-undo' | 'elite' | 'elite-undo' | 'bottom' | 'bottom-undo'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'GuestBar' || !objectIds.length) { this.bulkError.set('请先选择帖子'); return; }
+    if (action === 'recycle' && !confirm(`确认将选中的 ${objectIds.length} 条帖子移入回收站?`)) return;
+    const payload: Record<string, unknown> = { className: 'GuestBar', action, objectIds };
+    if (action === 'move') {
+      const cateId = this.targetBarCategoryId();
+      if (!Number.isInteger(cateId) || cateId < 1) { this.bulkError.set('请输入有效目标贴吧分类 ID'); return; }
+      payload['cateId'] = cateId;
+    }
+    this.updatingBar.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('barBatch', payload); await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 条帖子`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量帖子操作失败'); }
+    finally { this.updatingBar.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
@@ -432,4 +469,5 @@ export class AdminResourceComponent implements OnInit {
   private resetSpecialActions(detail: Record<string, unknown> = {}): void { this.targetSpecialParentId.set(Number(detail['pid']) || 0); this.specialActionError.set(''); this.specialActionMessage.set(''); }
   private resetGuestbookActions(detail: Record<string, unknown> = {}): void { this.selectedGuestbookStatus.set(Number(detail['status'] ?? 99)); this.guestbookReplyTitle.set(''); this.guestbookReplyContent.set(''); this.guestbookActionError.set(''); this.guestbookActionMessage.set(''); }
   private resetGuestCategoryActions(detail: Record<string, unknown> = {}): void { this.guestCategoryType.set(Number(detail['gtype']) || 0); this.targetGuestCategoryParentId.set(Number(detail['parentId']) || 0); }
+  private resetBarActions(detail: Record<string, unknown> = {}): void { this.targetBarCategoryId.set(Number(detail['cateId']) || 0); this.barActionError.set(''); this.barActionMessage.set(''); }
 }