Răsfoiți Sursa

feat: migrate encyclopedia administration

彭峰 1 lună în urmă
părinte
comite
18bbd46edd

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

@@ -48,8 +48,9 @@
 - 旧商城供应商 `Vendor/Vendor_API/VendorAdd/VendorAdd_Submit` 全部依赖未迁入的 `ZL_Shop_Vendor`:线上 Schema 与签约数据库文档均没有 `Vendor/ShopVendor/Manufacturers`。现有 `Agency` 只有机构编号/名称/负责人,`DeliveryCenter` 只有交付中心编号/负责人,都缺供应商地址、手机、传真、邮箱和状态,不能等价映射。`legacyShopVendorBlocker` 已按动作返回数据阻塞原因,未擅自新建未签约数据类或污染机构数据。
 - 旧用户附加功能中,`PointGroup/PointGroupAdd/PointGroup_API/PointGroup_Submit` 已映射到 `UserLevel`:提供帐套隔离列表、关键词搜索、现有等级别名/积分阈值/图片/备注编辑和批量删除;旧 `id` 与 Parse 保留字段冲突,因此新增等级明确 501。`VMoneyLog` 已由只读 `UserMoneyLog` 虚拟资源统一聚合 `UserExpDomP/UserSIcon/UserExpHis/UserUserPoint/UserDummyPoint/UserCredit` 六类规范化账本,支持余额/银币/经验/积分/虚拟币/信用分类型、日期、用户名与摘要筛选,不允许篡改历史流水。旧 `ThirdInfo.config` 六个提供商和密钥未迁入,现有 `ThirdPlatInfo` 是不同业务平台且没有旧 OAuth/XML 消费者;`PrizeTaskLog(Global.SignIn)` 用户签到日志与 `UserRealNameAuth` 实名申请/奖励链也全部缺失。这 6 个动作由 `legacyUserAddonBlocker` 逐项返回数或基础设施阻塞,未把 `MisSign` 工作签到或 `Profile` 零散身份字段伪装成旧审批历史。
 - 旧问答后台 11 个动作无法从目标数据库等价重建:线上 Schema 没有 `Ask/GuestAnswer`、`ZL_Ask/ZL_GuestAnswer` 历史记录或 `QueId` 问题—回答关系。`Baike` 是百科词条,`Guestbook` 是留言/回复,`GuestBar` 是论坛帖子,`DesignQuestion/DesignAnswer` 是问卷题目/答卷,都不能伪冒问答主从表。`Default/AskInfo/AskAdd/AskAdd_Submit/Ask_API/AnswerList/AnswerAdd/AnswerAdd_Submit/Answer_API` 已按数据阻塞登记;`Config/Config_Submit` 依赖已丢失的 `GuestConfig.WDOption` 问答运行时、用户组权限与积分消费者,按基础设施阻塞登记。`legacyAskBlocker` 会对每个请求返回具体原因。
+- 旧百科后台的主词条审查已映射到 `Baike`:`baikeEntries` 提供帐套、关键词、审核/未审核和推荐/未推荐筛选,`baikeBatch` 支持审核、取消审核、推荐和取消推荐,Angular 新增百科审核入口和只读详情。线上仅保留 1 条 `Baike` 主记录,完全没有旧 `BaikeEdit` 版本表,因此 `BKVersionList/Version_API/BKList` 及 `BK_API` 的版本删除/应用部分明确数据阻塞。`GuestConfig.BKOption` 百科创建/编辑/推荐权限与积分奖励消费者也缺失,`Config/Config_Submit` 按基础设施阻塞登记;推荐操作不伪造已发放奖励。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 94 项已实现、61 项部分实现、153 项数据阻塞、206 项基础设施阻塞、528 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 95 项已实现、63 项部分实现、156 项数据阻塞、208 项基础设施阻塞、520 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

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

@@ -15,12 +15,12 @@ const CLASS_LABELS = {
   GradeCate: '多级字典分类', Grade: '多级字典选项',
   Currency: '货币管理', SysHoliday: '节假日管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
-  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', Agency: '代理机构', DeliveryCenter: '交付中心'
+  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
 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','GuestBar','DesignAnswer']);
+const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo','GuestBar','DesignAnswer','Baike']);
 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']);
@@ -1260,6 +1260,21 @@ async function handler(request, response) {
         Config_Submit: '即使存储积分与用户组参数,目标也没有问答发布/回复/推荐运行时执行这些规则,不能伪造保存生效'
       }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧问答动作'); fail(501, 'migration_blocked: ' + blockers[action]);
     }
+    if (operation === 'baikeEntries') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); const status = Number(input.status == null ? -100 : input.status); const elite = Number(input.elite == null ? -100 : input.elite); if (search.length > 100 || ![-100,0,1].includes(status) || ![-100,0,1].includes(elite)) fail(400, '百科搜索或状态筛选无效'); const fields = await schemaFor('Baike'); const query = new Parse.Query('Baike'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (status === -100 || Number(entry.get('status')) === status) && (elite === -100 || Number(entry.get('elite')) === elite) && (!search || [entry.get('tittle'),entry.get('brief'),entry.get('contents'),entry.get('userName'),entry.get('classification')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left,right) => Number(right.get('id') || 0) - Number(left.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'Baike', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'baikeBatch') {
+      const action = String(input.action || ''); if (!['audit','unaudit','elite','unelite'].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('Baike'); const query = new Parse.Query('Baike'); 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, '部分百科词条不存在或不属于当前帐套'); for (const target of targets) { if (action === 'audit') target.set('status', 1); if (action === 'unaudit') target.set('status', 0); if (action === 'elite') target.set('elite', 1); if (action === 'unelite') target.set('elite', 0); } await Parse.Object.saveAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'baike-' + action, 'Baike', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: targets.map(serializeObject) } });
+    }
+    if (operation === 'legacyBaikeBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        BKVersionList: '目标 Schema 缺少旧 BaikeEdit 版本表、待审核版本内容与历史版本数据',
+        Version_API: '目标无 BaikeEdit 版本记录,无法审核后 Apply、取消审核、驳回或删除版本',
+        BKList: '旧 BKList 按 Flow 读取 BaikeEdit 词条版本链,目标仅保留一条 Baike 主记录',
+        Config: '目标没有 GuestConfig.BKOption 百科运行时、用户组权限和积分奖励消费者',
+        Config_Submit: '目标没有百科创建/编辑/推荐运行时执行 CreatePoint/EditPoint/RemmPoint 规则,不能伪造配置生效'
+      }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧百科动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
     if (operation === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};

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

@@ -181,6 +181,14 @@ const explicit = new Map([
   ['Content / Ask/Answer_API', ['blocked-data', '目标缺少可审核、取消审核或删除的旧问答回答记录']],
   ['Content / Ask/Config', ['blocked-infrastructure', '目标没有 GuestConfig.WDOption 问答运行时和用户组权限消费者']],
   ['Content / Ask/Config_Submit', ['blocked-infrastructure', '目标没有问答发布/回复/推荐运行时执行积分与用户组规则']],
+  ['Content / Baike/Default', ['partial', '已由 Angular 百科审核入口替代;旧默认跳转的 BaikeEdit 版本列表因数据缺失而阻塞']],
+  ['Content / Baike/BKVersionList', ['blocked-data', '目标 Schema 缺少旧 BaikeEdit 版本表、待审版本内容与历史版本数据']],
+  ['Content / Baike/Version_API', ['blocked-data', '目标无 BaikeEdit 版本记录,无法审核并 Apply、取消审核、驳回或删除']],
+  ['Content / Baike/BKList', ['blocked-data', '旧 BKList 按 Flow 读取 BaikeEdit 版本链,目标仅保留 Baike 主记录']],
+  ['Content / Baike/BK_API', ['partial', '已由 baikeBatch 覆盖主词条审核、取消审核、推荐和取消推荐;版本删除/应用与奖励链缺失']],
+  ['Content / Baike/Config', ['blocked-infrastructure', '目标没有 GuestConfig.BKOption 百科运行时、用户组权限和积分奖励消费者']],
+  ['Content / Baike/Config_Submit', ['blocked-infrastructure', '目标没有百科创建/编辑/推荐运行时执行 CreatePoint/EditPoint/RemmPoint 规则']],
+  ['Content / Baike/BkCheck', ['implemented', '已由 baikeEntries 按帐套、关键词、审核与推荐状态分页审查主词条']],
   ['FontPic/Index', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据']],
   ['FontPic/FontPicInfo', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图详情数据']],
   ['FontPic/FontPicInfo_Submit', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图持久化模型']],

Fișier diff suprimat deoarece este prea mare
+ 1 - 0
scripts/smoke-admin-functions.mjs


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

@@ -47,6 +47,7 @@ export const ADMIN_NAVIGATION = [
   { label: '内容标签词库', icon: 'tags', route: '/admin/resources/ContentTagKey' },
   { label: '积分等级', icon: 'badge-cent', route: '/admin/resources/UserLevel' },
   { label: '资金积分流水', icon: 'wallet-cards', route: '/admin/resources/UserMoneyLog' },
+  { label: '百科审核', icon: 'book-marked', route: '/admin/resources/Baike' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

+ 2 - 1
src/app/admin/layout/admin-shell.component.ts

@@ -4,6 +4,7 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
 import {
   BadgeCent,
   Bell,
+  BookMarked,
   BookOpen,
   Braces,
   CalendarDays,
@@ -52,7 +53,7 @@ export class AdminShellComponent {
   readonly collapsed = signal(false);
   readonly mobileOpen = signal(false);
   readonly icons = {
-    BadgeCent, Bell, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
+    BadgeCent, Bell, BookMarked, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
     ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, Languages, LayoutDashboard, ListChecks, ListTodo,
     LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users, WalletCards,
   };

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

@@ -100,6 +100,12 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Content / Ask/Answer_API')?.status).toBe('blocked-data');
     expect(byKey.get('Content / Ask/Config')?.status).toBe('blocked-infrastructure');
     expect(byKey.get('Content / Ask/Config_Submit')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Content / Baike/Default')?.status).toBe('partial');
+    expect(byKey.get('Content / Baike/BKVersionList')?.status).toBe('blocked-data');
+    expect(byKey.get('Content / Baike/Version_API')?.status).toBe('blocked-data');
+    expect(byKey.get('Content / Baike/BK_API')?.status).toBe('partial');
+    expect(byKey.get('Content / Baike/Config')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Content / Baike/BkCheck')?.status).toBe('implemented');
     expect(byKey.get('Exam/Teacher_Add')?.status).toBe('implemented');
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');

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

@@ -328,57 +328,57 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Baike",
     "action": "Default",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已由 Angular 百科审核入口替代;旧默认跳转的 BaikeEdit 版本列表因数据缺失而阻塞"
   },
   {
     "module": "Content / Baike",
     "action": "BKVersionList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 BaikeEdit 版本表、待审版本内容与历史版本数据"
   },
   {
     "module": "Content / Baike",
     "action": "Version_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无 BaikeEdit 版本记录,无法审核并 Apply、取消审核、驳回或删除"
   },
   {
     "module": "Content / Baike",
     "action": "BKList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "旧 BKList 按 Flow 读取 BaikeEdit 版本链,目标仅保留 Baike 主记录"
   },
   {
     "module": "Content / Baike",
     "action": "BK_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已由 baikeBatch 覆盖主词条审核、取消审核、推荐和取消推荐;版本删除/应用与奖励链缺失"
   },
   {
     "module": "Content / Baike",
     "action": "Config",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标没有 GuestConfig.BKOption 百科运行时、用户组权限和积分奖励消费者"
   },
   {
     "module": "Content / Baike",
     "action": "Config_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标没有百科创建/编辑/推荐运行时执行 CreatePoint/EditPoint/RemmPoint 规则"
   },
   {
     "module": "Content / Baike",
     "action": "BkCheck",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 baikeEntries 按帐套、关键词、审核与推荐状态分页审查主词条"
   },
   {
     "module": "Content / Bar",

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

@@ -36,6 +36,9 @@
   @if (className() === 'UserMoneyLog') {
     <div class="bulk-toolbar"><strong>流水类型</strong><select [ngModel]="filterUserMoneyType()" (ngModelChange)="filterUserMoneyType.set(+$event); load(1)"><option [ngValue]="1">余额</option><option [ngValue]="2">银币</option><option [ngValue]="3">经验</option><option [ngValue]="4">积分</option><option [ngValue]="5">虚拟币</option><option [ngValue]="6">信用分</option></select><input type="date" aria-label="开始日期" [ngModel]="filterUserMoneyStartDate()" (ngModelChange)="filterUserMoneyStartDate.set($event)" /><input type="date" aria-label="结束日期" [ngModel]="filterUserMoneyEndDate()" (ngModelChange)="filterUserMoneyEndDate.set($event)" /><button type="button" (click)="load(1)">按日期筛选</button></div>
   }
+  @if (className() === 'Baike') {
+    <div class="bulk-toolbar"><strong>百科状态</strong><select [ngModel]="filterBaikeStatus()" (ngModelChange)="filterBaikeStatus.set(+$event); load(1)"><option [ngValue]="-100">全部审核状态</option><option [ngValue]="1">已审核</option><option [ngValue]="0">未审核</option></select><select [ngModel]="filterBaikeElite()" (ngModelChange)="filterBaikeElite.set(+$event); load(1)"><option [ngValue]="-100">全部推荐状态</option><option [ngValue]="1">已推荐</option><option [ngValue]="0">未推荐</option></select></div>
+  }
   @if (bulkEnabled() && selectedIds().length) {
     <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
       @if (className() === '_User') {
@@ -82,6 +85,8 @@
         <button class="danger-action" type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('delete')">删除样式</button>
       } @else if (className() === 'UserLevel') {
         <button class="danger-action" type="button" [disabled]="updatingUserLevel()" (click)="applyBulkUserLevelDelete()">删除等级</button>
+      } @else if (className() === 'Baike') {
+        <button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('audit')">审核</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('unaudit')">取消审核</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('elite')">推荐</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('unelite')">取消推荐</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>
@@ -153,6 +158,9 @@
         @if (className() === 'UserMoneyLog') {
           <section class="account-create-note"><strong>资金积分流水只读</strong><span>旧单表已按余额、银币、经验、积分、虚拟币和信用分拆分为六个规范化账本;管理页只合并查看,不允许篡改历史流水。</span></section>
         }
+        @if (className() === 'Baike') {
+          <section class="account-create-note"><strong>百科词条只读详情</strong><span>目标仅保留 Baike 主记录;可在列表执行审核和推荐状态操作。旧 BaikeEdit 版本表、版本应用/驳回链与积分奖励运行时已缺失,不提供伪版本操作。</span></section>
+        }
         @for (field of schema()?.fields || []; track field.name) { @if (field.writable) {
           <label><span>{{ field.name }} <small>{{ field.type }}{{ field.required ? ' · 必填' : '' }}</small></span>
             @if (field.type === 'Boolean') { <input type="checkbox" [ngModel]="draft()[field.name] === true" (ngModelChange)="setField(field, $event)" /> }

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

@@ -911,4 +911,29 @@ describe('AdminResourceComponent', () => {
     expect(functions.admin.calls.first().args).toEqual(['userLevelBatch', { className: 'UserLevel', action: 'delete', objectIds: ['level-a', 'level-b'] }]);
     expect(component.bulkMessage()).toContain('2 个积分等级');
   });
+
+  it('loads encyclopedia entries through status and recommendation filters', async () => {
+    component.className.set('Baike');
+    component.schema.set({ className: 'Baike', label: '百科审核', writable: false, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.filterBaikeStatus.set(1);
+    component.filterBaikeElite.set(0);
+    functions.admin.and.resolveTo({ className: 'Baike', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.load(1);
+
+    expect(functions.admin).toHaveBeenCalledWith('baikeEntries', { className: 'Baike', page: 1, pageSize: 20, search: '', status: 1, elite: 0 });
+  });
+
+  it('recommends selected encyclopedia entries through the dedicated workflow', async () => {
+    component.className.set('Baike');
+    component.selectedIds.set(['baike-a', 'baike-b']);
+    functions.admin.and.callFake(async (operation: string) => operation === 'baikeBatch'
+      ? { action: 'elite', updated: 2, results: [] }
+      : { className: 'Baike', page: 1, pageSize: 20, total: 2, results: [] });
+
+    await component.applyBulkBaikeAction('elite');
+
+    expect(functions.admin.calls.first().args).toEqual(['baikeBatch', { className: 'Baike', action: 'elite', objectIds: ['baike-a', 'baike-b'] }]);
+    expect(component.bulkMessage()).toContain('2 个百科词条');
+  });
 });

+ 15 - 2
src/app/admin/pages/admin-resource.component.ts

@@ -137,6 +137,9 @@ export class AdminResourceComponent implements OnInit {
   readonly filterUserMoneyStartDate = signal('');
   readonly filterUserMoneyEndDate = signal('');
   readonly updatingUserLevel = signal(false);
+  readonly filterBaikeStatus = signal(-100);
+  readonly filterBaikeElite = signal(-100);
+  readonly updatingBaike = signal(false);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -148,7 +151,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', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike'].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));
@@ -170,6 +173,7 @@ export class AdminResourceComponent implements OnInit {
       this.filterStoreStatus.set(-100); this.resetStoreActions();
       this.resetTagKeyActions();
       this.filterUserMoneyType.set(1); this.filterUserMoneyStartDate.set(''); this.filterUserMoneyEndDate.set('');
+      this.filterBaikeStatus.set(-100); this.filterBaikeElite.set(-100);
       void this.load(1, true);
     });
   }
@@ -183,7 +187,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
       if (['DesignQuestion', 'DesignAnswer'].includes(this.className()) && reloadSchema) { await this.loadSurveys(); if (!this.filterSurveyId() && this.surveys().length) this.filterSurveyId.set(this.surveyIdOf(this.surveys()[0])); }
       if (this.className() === 'StoreApplication' && reloadSchema) await this.loadStoreStyles();
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : 'list';
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'Baike' ? 'baikeEntries' : 'list';
       const pagePayload: Record<string, unknown> = { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() };
       if (pageOperation === 'gradeOptions') { pagePayload['categoryId'] = this.filterGradeCategoryId(); pagePayload['parentId'] = this.filterGradeParentId(); }
       if (pageOperation === 'searchNavigations') { pagePayload['type'] = this.filterSearchNavigationType(); pagePayload['state'] = this.filterSearchNavigationState(); pagePayload['elite'] = this.filterSearchNavigationElite(); }
@@ -194,6 +198,7 @@ export class AdminResourceComponent implements OnInit {
       if (pageOperation === 'surveyQuestions' || pageOperation === 'surveyResults') pagePayload['askId'] = this.filterSurveyId();
       if (pageOperation === 'storeApplications') pagePayload['status'] = this.filterStoreStatus();
       if (pageOperation === 'userMoneyLogs') { pagePayload['type'] = this.filterUserMoneyType(); pagePayload['startDate'] = this.filterUserMoneyStartDate(); pagePayload['endDate'] = this.filterUserMoneyEndDate(); }
+      if (pageOperation === 'baikeEntries') { pagePayload['status'] = this.filterBaikeStatus(); pagePayload['elite'] = this.filterBaikeElite(); }
       this.page.set(await this.functions.admin<ResourcePage>(pageOperation, pagePayload));
       if (this.className() === '_User' && reloadSchema) await this.loadUserGroups();
       if (this.className() === 'Datadic' && reloadSchema) await this.loadDictionaryCategories();
@@ -700,6 +705,14 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '积分等级删除失败'); }
     finally { this.updatingUserLevel.set(false); }
   }
+  async applyBulkBaikeAction(action: 'audit' | 'unaudit' | 'elite' | 'unelite'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'Baike' || !objectIds.length) { this.bulkError.set('请先选择百科词条'); return; }
+    this.updatingBaike.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('baikeBatch', { className: 'Baike', action, objectIds }); await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 个百科词条`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '百科状态更新失败'); }
+    finally { this.updatingBaike.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff