Ver Fonte

feat: migrate common administration tools

彭峰 há 1 mês atrás
pai
commit
b8024fc71f

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

@@ -51,9 +51,11 @@
 - 旧裱件计算后台 5 个动作全部依赖未迁入的 `BiaoCalcModel/BiaoCalcUserFiles`:目标 Schema 与签约数据库文档都没有裱件主记录、用户文件授权、等级或 `ExpireTime` 历史数据。现有账户会员到期时间控制整个账户权益,不等价于指定裱件用户文件授权,因此不用它伪实现到期/续期。`legacyBiaoCalcBlocker` 已对 `Default/BiaoDetail/Biao_API/UserFiles/UserFiles_API` 返回逐项数据阻塞原因。
 - 旧 `Office/Chart.Index` 控制器只执行 `View()`,但反编译交付包和 Zoomla 官方 `cms2` 源码树都不包含对应 `Areas/Admin/Views/Office/Chart/Index.cshtml`,也没有任何参数、数据源或交互合约可供推导。该动作按缺失旧视图资产的基础设施阻塞登记,不把无关的问卷统计或 `ECharts` 编辑器冒充为 1:1 页面。
 - 旧聊天后台的用户消息数、个人/群聊历史、好友、群组、群成员和管理员操作全部依赖 `ChatMsg/ChatGroup/ChatGroupUser/User_Friend`,目标 Schema 与数据库文档均未迁入这些类和历史关系。`UserFriendGroup` 仅保留好友分组,不是好友关系;旧 `RoomConfig` 的 TRTC AppID/SecretKey 与消费它的聊天运行时也不存在。因此 9 个数据动作和 2 个配置动作分别登记为数据/基础设施阻塞,`legacyChatBlocker` 返回逐项原因。
+- 旧可视化模板分类 `Design/Class` 的 4 个动作依赖 `DesignTlpClass/Design_TlpClass`,但线上两个候选 Schema 都不存在。现有 `DesignTlp` 确有 2 条历史模板且均引用 `classId=6`,分类 6 的名称、父级、排序和说明却已丢失。不擅自新建分类伪造语义,也不执行可能使模板引用悬空的删除;`legacyDesignClassBlocker` 会返回逐项数据阻塞原因。
+- 旧 `Com` 通用控制器中,`Logout` 已迁为管理云函数销毁当前 Parse `_Session`,Angular 再清除本地会话并返回登录页;`BootLayout` 已完整迁为 Angular Bootstrap 栅格工具,保留 `.container/.container-fluid`、2–8 列、5 类设备断点、6 组预设、预览和复制 HTML。`SPwdCheck` 依赖旧 `Manager.RandNumber` 二级密码 Session 及业务消费者;6 个 Excel 导入动作依赖已缺失的 `/Config/Import/{mode}.xml`、NPOI、动态模型附表和 `ZL_Commodities`,由 `legacyComBlocker` 返回基础设施原因。`Sort/Sort_API` 中的 `ModelField` 帐套安全排序已由 `modelFieldOrder` 覆盖,内容、商品、栏目和贴吧分类分支仍按部分迁移登记。
 - 旧百科后台的主词条审查已映射到 `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、原因与状态搜索筛选;当前保守登记为 95 项已实现、63 项部分实现、170 项数据阻塞、211 项基础设施阻塞、503 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 97 项已实现、65 项部分实现、174 项数据阻塞、218 项基础设施阻塞、488 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 27 - 0
scripts/deploy-admin-functions.mjs

@@ -204,6 +204,14 @@ async function handler(request, response) {
         classes: ALLOWED_CLASSES.size, cloudFunctions
       }});
     }
+    if (operation === 'logout') {
+      const sessionToken = String(input.sessionToken || '');
+      if (!sessionToken || sessionToken.length > 512) fail(400, '缺少有效的管理员会话');
+      const query = new Parse.Query('_Session'); query.equalTo('sessionToken', sessionToken); query.equalTo('user', context.current); query.limit(1);
+      const session = await query.first({ useMasterKey: true });
+      if (session) await session.destroy({ useMasterKey: true });
+      return response.json({ success: true, data: { revoked: Boolean(session) } });
+    }
     if (operation === 'dashboard') {
       const metrics = [
         ['_User','用户总数'],['CourseBinding','课程绑定'],['CourseAppointment','课程预约'],['PracticeRecord','练习记录'],['LessonRecord','上课记录'],['DailyStudyRecord','每日学习'],['VocabularyWord','词库单词'],['CommonModel','内容记录']
@@ -1288,6 +1296,25 @@ async function handler(request, response) {
         Chat_API: '目标缺少 ChatMsg/ChatGroup/ChatGroupUser 可删除、启停或授权的旧聊天记录'
       }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧聊天动作'); fail(501, 'migration_blocked: ' + blockers[action]);
     }
+    if (operation === 'legacyDesignClassBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        ClassList: '目标 Schema 缺少 DesignTlpClass/Design_TlpClass 模板分类主表与历史分类数据',
+        ClassAdd: '目标无可定位或新建的模板分类数据类;DesignTlp 仅保留数字 classId 引用',
+        ClassAdd_Submit: '历史 DesignTlp 引用 classId=6,但分类 6 的名称、父级、排序和说明已丢失,不能伪造保存',
+        Class_API: '目标无可删除的模板分类记录,且直接处理 classId=6 会使既有 DesignTlp 引用悬空'
+      }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧模板分类动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
+    if (operation === 'legacyComBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        SPwdCheck: '目标管理员会话没有旧 Manager.RandNumber 二级密码验证状态和业务消费者,不能伪造 SPWD Session',
+        Import: '目标与云函数运行时缺少旧 /Config/Import/{mode}.xml 导入定义',
+        Import_DownTlp: '缺少旧 XML 字段别名、隐藏字段、类型与默认值规则,无法等价生成 xlsx 模板',
+        Import_Submit: '云函数没有旧 XML+NPOI 动态表导入流水线,也不允许按客户端表名直接插入',
+        ImportForContent: '目标缺少旧动态模型附表结构与通用 xlsx 内容导入页',
+        ImportForContent_DownTlp: '目标无法从旧模型动态附表还原完整 xlsx 字段模板',
+        ImportForContent_Submit: '目标缺少动态模型附表批量写入流程,商品分支还缺少 ZL_Commodities 主表'
+      }; 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) } });
     }

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

@@ -198,6 +198,21 @@ const explicit = new Map([
   ['Extend / Chat/Config', ['blocked-infrastructure', '目标没有 RoomConfig TRTC 配置持久层和聊天运行时']],
   ['Extend / Chat/Config_Submit', ['blocked-infrastructure', '目标没有消费 TRTC AppID/SecretKey 的聊天服务']],
   ['Extend / Chat/Chat_API', ['blocked-data', '目标缺少 ChatMsg/ChatGroup/ChatGroupUser 可删除、启停或授权记录']],
+  ['Design / Class/ClassList', ['blocked-data', '目标 Schema 缺少 DesignTlpClass/Design_TlpClass 模板分类主表与历史数据']],
+  ['Design / Class/ClassAdd', ['blocked-data', '目标无模板分类数据类;DesignTlp 仅保留数字 classId 引用']],
+  ['Design / Class/ClassAdd_Submit', ['blocked-data', '现有 DesignTlp 引用 classId=6,但分类 6 的名称、父级、排序和说明已丢失']],
+  ['Design / Class/Class_API', ['blocked-data', '目标无可删除分类记录,且不能使现有 DesignTlp.classId=6 引用悬空']],
+  ['Com/Logout', ['implemented', '已由 Angular 退出入口调用管理云函数销毁当前 Parse _Session 后清除本地会话']],
+  ['Com/SPwdCheck', ['blocked-infrastructure', '目标没有 Manager.RandNumber 二级密码会话状态和业务消费者']],
+  ['Com/BootLayout', ['implemented', '已迁移为 Angular Bootstrap 栅格生成器,保留框架、列数、断点、预设、预览和复制 HTML']],
+  ['Com/Import', ['blocked-infrastructure', '目标与云函数运行时缺少 /Config/Import/{mode}.xml 导入定义']],
+  ['Com/Import_DownTlp', ['blocked-infrastructure', '缺少旧 XML 字段别名、隐藏字段、类型和默认值规则']],
+  ['Com/Import_Submit', ['blocked-infrastructure', '云函数没有旧 XML+NPOI 动态表 xlsx 导入流水线']],
+  ['Com/ImportForContent', ['blocked-infrastructure', '目标缺少旧动态模型附表结构与通用 xlsx 导入页']],
+  ['Com/ImportForContent_DownTlp', ['blocked-infrastructure', '无法从已缺失的动态模型附表还原完整 xlsx 字段模板']],
+  ['Com/ImportForContent_Submit', ['blocked-infrastructure', '缺少动态附表批量写入流程,商品分支还缺少 ZL_Commodities']],
+  ['Com/Sort', ['partial', '模型字段顺序已由 modelFieldOrder 迁移;内容、商品、栏目和贴吧分类的旧统一排序页尚未完整迁移']],
+  ['Com/Sort_API', ['partial', 'modelFieldOrder 已覆盖 ModelField 白名单帐套排序;其余 4 类旧表名分支未提供等价云函数']],
   ['Content / Baike/Default', ['partial', '已由 Angular 百科审核入口替代;旧默认跳转的 BaikeEdit 版本列表因数据缺失而阻塞']],
   ['Content / Baike/BKVersionList', ['blocked-data', '目标 Schema 缺少旧 BaikeEdit 版本表、待审版本内容与历史版本数据']],
   ['Content / Baike/Version_API', ['blocked-data', '目标无 BaikeEdit 版本记录,无法审核并 Apply、取消审核、驳回或删除']],

Diff do ficheiro suprimidas por serem muito extensas
+ 2 - 0
scripts/smoke-admin-functions.mjs


+ 38 - 0
src/app/admin/admin-session.service.spec.ts

@@ -0,0 +1,38 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL } from './admin.constants';
+import { AdminSessionService } from './admin-session.service';
+
+describe('AdminSessionService', () => {
+  let http: HttpTestingController;
+  let router: jasmine.SpyObj<Router>;
+
+  beforeEach(() => {
+    sessionStorage.clear();
+    router = jasmine.createSpyObj<Router>('Router', ['navigate']);
+    TestBed.configureTestingModule({
+      providers: [provideHttpClient(), provideHttpClientTesting(), AdminSessionService, { provide: Router, useValue: router }],
+    });
+    http = TestBed.inject(HttpTestingController);
+  });
+
+  afterEach(() => {
+    http.verify();
+    sessionStorage.clear();
+  });
+
+  it('revokes the Parse session through the admin cloud function on logout', () => {
+    sessionStorage.setItem(ADMIN_SESSION_KEY, JSON.stringify({ objectId: 'admin-1', username: 'root', sessionToken: 'r:admin-token' }));
+    const service = TestBed.inject(AdminSessionService);
+
+    service.logout(false);
+
+    const request = http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`);
+    expect(request.request.body).toEqual({ token: 'r:admin-token', params: { operation: 'logout', sessionToken: 'r:admin-token' } });
+    expect(service.session()).toBeNull();
+    expect(sessionStorage.getItem(ADMIN_SESSION_KEY)).toBeNull();
+    request.flush({ success: true, data: { revoked: true } });
+  });
+});

+ 8 - 1
src/app/admin/admin-session.service.ts

@@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http';
 import { computed, inject, Injectable, signal } from '@angular/core';
 import { Router } from '@angular/router';
 import { firstValueFrom } from 'rxjs';
-import { ADMIN_SESSION_KEY, PARSE_APP_ID, PARSE_BASE_URL } from './admin.constants';
+import { ADMIN_SESSION_KEY, CLOUD_FUNCTION_BASE_URL, PARSE_APP_ID, PARSE_BASE_URL } from './admin.constants';
 import { AdminSession } from './admin.models';
 
 @Injectable({ providedIn: 'root' })
@@ -37,8 +37,15 @@ export class AdminSessionService {
   }
 
   logout(redirect = true): void {
+    const sessionToken = this.state()?.sessionToken ?? '';
     sessionStorage.removeItem(ADMIN_SESSION_KEY);
     this.state.set(null);
+    if (sessionToken) {
+      this.http.post(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/admin/gateway`, {
+        token: sessionToken,
+        params: { operation: 'logout', sessionToken },
+      }).subscribe({ error: () => undefined });
+    }
     if (redirect) void this.router.navigate(['/admin/login']);
   }
 

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

@@ -13,6 +13,7 @@ export const ADMIN_NAVIGATION = [
   { label: '管理概览', icon: 'layout-dashboard', route: '/admin/dashboard' },
   { label: '云函数接口', icon: 'braces', route: '/admin/api-catalog' },
   { label: '图标选择器', icon: 'shapes', route: '/admin/icon-catalog' },
+  { label: 'Bootstrap 布局', icon: 'layout-template', route: '/admin/boot-layout' },
   { label: '全部数据类', icon: 'table-properties', route: '/admin/resources' },
   { label: '用户与组织', icon: 'users', route: '/admin/resources/_User' },
   { label: '课程绑定', icon: 'book-open', route: '/admin/resources/CourseBinding' },

+ 4 - 0
src/app/admin/admin.routes.ts

@@ -24,6 +24,10 @@ export const adminRoutes: Routes = [
         path: 'icon-catalog',
         loadComponent: () => import('./pages/admin-icon-catalog.component').then((module) => module.AdminIconCatalogComponent),
       },
+      {
+        path: 'boot-layout',
+        loadComponent: () => import('./pages/admin-boot-layout.component').then((module) => module.AdminBootLayoutComponent),
+      },
       {
         path: 'resources',
         loadComponent: () => import('./pages/admin-resource-catalog.component').then((module) => module.AdminResourceCatalogComponent),

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

@@ -20,6 +20,7 @@ import {
   Headphones,
   Languages,
   LayoutDashboard,
+  LayoutTemplate,
   ListChecks,
   ListTodo,
   LogOut,
@@ -55,7 +56,7 @@ export class AdminShellComponent {
   readonly icons = {
     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,
+    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users, WalletCards,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

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

@@ -113,6 +113,16 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Extend / Chat/Config')?.status).toBe('blocked-infrastructure');
     expect(byKey.get('Extend / Chat/Config_Submit')?.status).toBe('blocked-infrastructure');
     expect(byKey.get('Extend / Chat/Chat_API')?.status).toBe('blocked-data');
+    expect(byKey.get('Design / Class/ClassList')?.status).toBe('blocked-data');
+    expect(byKey.get('Design / Class/ClassAdd_Submit')?.status).toBe('blocked-data');
+    expect(byKey.get('Design / Class/Class_API')?.status).toBe('blocked-data');
+    expect(byKey.get('Com/Logout')?.status).toBe('implemented');
+    expect(byKey.get('Com/SPwdCheck')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Com/BootLayout')?.status).toBe('implemented');
+    expect(byKey.get('Com/Import_Submit')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Com/ImportForContent_Submit')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Com/Sort')?.status).toBe('partial');
+    expect(byKey.get('Com/Sort_API')?.status).toBe('partial');
     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');

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

@@ -580,106 +580,106 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Design / Class",
     "action": "ClassList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少 DesignTlpClass/Design_TlpClass 模板分类主表与历史数据"
   },
   {
     "module": "Design / Class",
     "action": "ClassAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无模板分类数据类;DesignTlp 仅保留数字 classId 引用"
   },
   {
     "module": "Design / Class",
     "action": "ClassAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "现有 DesignTlp 引用 classId=6,但分类 6 的名称、父级、排序和说明已丢失"
   },
   {
     "module": "Design / Class",
     "action": "Class_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无可删除分类记录,且不能使现有 DesignTlp.classId=6 引用悬空"
   },
   {
     "module": "Com",
     "action": "Logout",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 退出入口调用管理云函数销毁当前 Parse _Session 后清除本地会话"
   },
   {
     "module": "Com",
     "action": "SPwdCheck",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标没有 Manager.RandNumber 二级密码会话状态和业务消费者"
   },
   {
     "module": "Com",
     "action": "BootLayout",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已迁移为 Angular Bootstrap 栅格生成器,保留框架、列数、断点、预设、预览和复制 HTML"
   },
   {
     "module": "Com",
     "action": "Import",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标与云函数运行时缺少 /Config/Import/{mode}.xml 导入定义"
   },
   {
     "module": "Com",
     "action": "Import_DownTlp",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "缺少旧 XML 字段别名、隐藏字段、类型和默认值规则"
   },
   {
     "module": "Com",
     "action": "Import_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "云函数没有旧 XML+NPOI 动态表 xlsx 导入流水线"
   },
   {
     "module": "Com",
     "action": "ImportForContent",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少旧动态模型附表结构与通用 xlsx 导入页"
   },
   {
     "module": "Com",
     "action": "ImportForContent_DownTlp",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "无法从已缺失的动态模型附表还原完整 xlsx 字段模板"
   },
   {
     "module": "Com",
     "action": "ImportForContent_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "缺少动态附表批量写入流程,商品分支还缺少 ZL_Commodities"
   },
   {
     "module": "Com",
     "action": "Sort",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "模型字段顺序已由 modelFieldOrder 迁移;内容、商品、栏目和贴吧分类的旧统一排序页尚未完整迁移"
   },
   {
     "module": "Com",
     "action": "Sort_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "modelFieldOrder 已覆盖 ModelField 白名单帐套排序;其余 4 类旧表名分支未提供等价云函数"
   },
   {
     "module": "Common",

+ 48 - 0
src/app/admin/pages/admin-boot-layout.component.html

@@ -0,0 +1,48 @@
+<header class="page-heading">
+  <div class="heading-icon"><lucide-icon [img]="icons.LayoutTemplate" [size]="25" /></div>
+  <div><span>开发工具</span><h1>Bootstrap 布局</h1><p>按旧后台规则生成多设备响应式栅格 HTML。</p></div>
+</header>
+
+<section class="layout-card">
+  <div class="field-row">
+    <strong>外部框架</strong>
+    <div class="choice-list">
+      @for (item of frames; track item.value) {
+        <label><input type="radio" name="frame" [value]="item.value" [ngModel]="frame()" (ngModelChange)="frame.set($event)" />{{ item.name }}</label>
+      }
+    </div>
+  </div>
+  <div class="field-row">
+    <strong>列数</strong>
+    <div class="choice-list compact">
+      @for (count of columnCounts; track count) {
+        <label><input type="radio" name="column-count" [value]="count" [ngModel]="columnCount()" (ngModelChange)="columnCount.set(+$event)" />{{ count }}</label>
+      }
+    </div>
+  </div>
+  <div class="field-row devices-row">
+    <strong>设备</strong>
+    <div class="devices">
+      @for (device of devices(); track device.value; let index = $index) {
+        <div class="device-line">
+          <label class="device-toggle"><input type="checkbox" [ngModel]="device.checked" (ngModelChange)="updateDevice(index, { checked: $event })" />{{ device.name }} <code>{{ device.value }}</code></label>
+          <input class="column-input" type="text" [ngModel]="device.columns" (ngModelChange)="updateDevice(index, { columns: $event })" [attr.aria-label]="device.name + '列分配'" />
+          <div class="presets">@for (preset of presets; track preset) { <button type="button" (click)="setPreset(index, preset)">{{ preset }}</button> }</div>
+        </div>
+      }
+      <small>框内数字段落应与列数相符,同一设备下每行总数不应超过 Bootstrap 栅格的 12 列。</small>
+    </div>
+  </div>
+</section>
+
+<section class="preview-card">
+  <header><strong>布局预览</strong><span>{{ frame() }} · {{ columnCount() }} 列</span></header>
+  <div class="preview-frame">
+    @for (className of columnClasses(); track $index) { <div [title]="className"><span>空间 {{ $index + 1 }}</span><code>{{ className || '(无 class)' }}</code></div> }
+  </div>
+</section>
+
+<section class="code-card">
+  <header><div><strong>HTML</strong><span>可直接复制到 Bootstrap 页面</span></div><button type="button" (click)="copyHtml()"><lucide-icon [img]="copied() ? icons.Check : icons.Clipboard" [size]="17" />{{ copied() ? '已复制' : '复制代码' }}</button></header>
+  <textarea readonly [value]="html()" rows="10" aria-label="Bootstrap 布局 HTML"></textarea>
+</section>

+ 34 - 0
src/app/admin/pages/admin-boot-layout.component.scss

@@ -0,0 +1,34 @@
+:host { display: block; color: #172033; }
+.page-heading { display: flex; gap: 14px; align-items: center; margin-bottom: 18px; }
+.heading-icon { width: 48px; height: 48px; display: grid; place-items: center; border-radius: 14px; color: #fff; background: linear-gradient(135deg, #6558e8, #8f72f5); box-shadow: 0 10px 24px rgba(101, 88, 232, .24); }
+.page-heading span { color: #7466e8; font-size: 12px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
+.page-heading h1 { margin: 2px 0; font-size: 25px; }
+.page-heading p { margin: 0; color: #758097; font-size: 14px; }
+.layout-card, .preview-card, .code-card { background: #fff; border: 1px solid #e5e9f2; border-radius: 16px; box-shadow: 0 7px 24px rgba(28, 39, 66, .055); overflow: hidden; margin-bottom: 16px; }
+.field-row { display: grid; grid-template-columns: 118px minmax(0, 1fr); gap: 16px; padding: 16px 18px; border-bottom: 1px solid #edf0f6; }
+.field-row:last-child { border-bottom: 0; }
+.field-row > strong { padding-top: 8px; font-size: 14px; }
+.choice-list { display: flex; flex-wrap: wrap; gap: 8px; }
+.choice-list label, .device-toggle { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 12px; border: 1px solid #dfe4ed; border-radius: 9px; background: #f9fafc; cursor: pointer; font-size: 13px; }
+.choice-list.compact label { min-width: 46px; justify-content: center; }
+input[type='radio'], input[type='checkbox'] { accent-color: #685ce8; }
+.devices { min-width: 0; display: grid; gap: 9px; }
+.device-line { display: grid; grid-template-columns: 175px minmax(130px, 1fr) minmax(330px, auto); gap: 8px; align-items: center; }
+.device-toggle { justify-content: flex-start; }
+.device-toggle code { color: #7367dc; font-size: 11px; }
+.column-input, textarea { border: 1px solid #dfe4ed; border-radius: 9px; background: #fff; color: #283249; font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
+.column-input { height: 38px; padding: 0 11px; min-width: 0; }
+.presets { display: flex; flex-wrap: wrap; gap: 5px; }
+.presets button, .code-card button { border: 1px solid #dfe4ed; border-radius: 8px; background: #fff; color: #4a556d; min-height: 34px; padding: 0 9px; cursor: pointer; }
+.presets button:hover { border-color: #8a7ff0; color: #6256da; }
+.devices small { color: #7f899d; line-height: 1.5; }
+.preview-card header, .code-card header { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid #edf0f6; }
+.preview-card header span, .code-card header span { display: block; margin-top: 3px; color: #8992a5; font-size: 12px; }
+.preview-frame { display: grid; grid-template-columns: repeat(var(--columns, auto-fit), minmax(110px, 1fr)); grid-auto-flow: column; gap: 8px; padding: 18px; overflow-x: auto; }
+.preview-frame > div { min-width: 110px; padding: 22px 10px; border: 1px solid rgba(104, 92, 232, .22); border-radius: 10px; background: rgba(104, 92, 232, .1); text-align: center; }
+.preview-frame span { display: block; font-weight: 750; }
+.preview-frame code { display: block; margin-top: 6px; color: #6f64d6; font-size: 10px; overflow-wrap: anywhere; }
+.code-card button { display: inline-flex; align-items: center; gap: 7px; border-color: #685ce8; color: #5d52d4; font-weight: 700; padding: 0 13px; }
+textarea { box-sizing: border-box; display: block; width: calc(100% - 36px); margin: 18px; padding: 14px; resize: vertical; background: #f8f9fc; }
+@media (max-width: 1050px) { .device-line { grid-template-columns: 160px minmax(120px, 1fr); } .presets { grid-column: 1 / -1; padding-left: 168px; } }
+@media (max-width: 720px) { .field-row { grid-template-columns: 1fr; gap: 8px; } .field-row > strong { padding-top: 0; } .device-line { grid-template-columns: 1fr; } .presets { grid-column: auto; padding-left: 0; } .page-heading p { display: none; } }

+ 22 - 0
src/app/admin/pages/admin-boot-layout.component.spec.ts

@@ -0,0 +1,22 @@
+import { AdminBootLayoutComponent } from './admin-boot-layout.component';
+
+describe('AdminBootLayoutComponent', () => {
+  it('recreates the legacy responsive grid HTML rules', () => {
+    const component = new AdminBootLayoutComponent();
+    component.columnCount.set(3);
+    component.setPreset(0, '3*3*6');
+
+    expect(component.columnClasses()).toEqual(['col-3', 'col-3', 'col-6']);
+    expect(component.html()).toContain('<div class="container">');
+    expect(component.html()).toContain('<div class="col-6"></div>');
+  });
+
+  it('combines enabled device breakpoints and preserves zero-width shorthand', () => {
+    const component = new AdminBootLayoutComponent();
+    component.columnCount.set(2);
+    component.setPreset(0, '0*6');
+    component.updateDevice(1, { checked: true, columns: '4*8' });
+
+    expect(component.columnClasses()).toEqual(['col col-sm-4', 'col-6 col-sm-8']);
+  });
+});

+ 70 - 0
src/app/admin/pages/admin-boot-layout.component.ts

@@ -0,0 +1,70 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Check, Clipboard, LayoutTemplate, LucideAngularModule } from 'lucide-angular';
+
+interface GridDevice {
+  name: string;
+  value: string;
+  checked: boolean;
+  columns: string;
+}
+
+@Component({
+  selector: 'app-admin-boot-layout',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule],
+  templateUrl: './admin-boot-layout.component.html',
+  styleUrl: './admin-boot-layout.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminBootLayoutComponent {
+  readonly icons = { Check, Clipboard, LayoutTemplate };
+  readonly columnCounts = [2, 3, 4, 5, 6, 7, 8];
+  readonly presets = ['4*4*4', '0*0*0*0', '3*3*6', '6*6', '2*10', '10*2'];
+  readonly frames = [
+    { name: '.container 标准居中', value: 'container' },
+    { name: '.container-fluid 全屏 100%', value: 'container-fluid' },
+  ];
+  readonly frame = signal('container');
+  readonly columnCount = signal(2);
+  readonly devices = signal<GridDevice[]>([
+    { name: '自适应', value: 'col', checked: true, columns: '4*4*4' },
+    { name: '平板 PAD', value: 'col-sm', checked: false, columns: '4*4*4' },
+    { name: '窄屏设备', value: 'col-md', checked: false, columns: '4*4*4' },
+    { name: '传统电脑', value: 'col-lg', checked: false, columns: '4*4*4' },
+    { name: '高清屏幕', value: 'col-xl', checked: false, columns: '4*4*4' },
+  ]);
+  readonly copied = signal(false);
+
+  readonly columnClasses = computed(() => {
+    const classes = Array.from({ length: this.columnCount() }, () => [] as string[]);
+    for (const device of this.devices().filter((item) => item.checked)) {
+      const values = device.columns.split('*');
+      for (let index = 0; index < values.length && index < classes.length; index += 1) {
+        const value = Number.parseInt(values[index] || '0', 10);
+        classes[index].push(value === 0 ? device.value : `${device.value}-${value}`);
+      }
+    }
+    return classes.map((items) => items.join(' ').trim());
+  });
+
+  readonly html = computed(() => {
+    const rows = this.columnClasses().map((className) => `    <div class="${className}"></div>`).join('\n');
+    return `<div class="${this.frame()}">\n  <div class="row">\n${rows}\n  </div>\n</div>`;
+  });
+
+  updateDevice(index: number, patch: Partial<GridDevice>): void {
+    this.devices.update((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
+  }
+
+  setPreset(index: number, columns: string): void {
+    this.updateDevice(index, { columns });
+  }
+
+  async copyHtml(): Promise<void> {
+    await navigator.clipboard.writeText(this.html());
+    this.copied.set(true);
+    globalThis.setTimeout(() => this.copied.set(false), 1500);
+  }
+}

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff