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

feat: migrate admin API and icon catalogs

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

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

@@ -38,8 +38,9 @@
 - 旧商城扩展 `Currency/Currency_API` 的现有货币管理已迁到目标 `Currency`:`saveCurrency` 白名单更新货币名称、2–10 位大写代码、符号、正数当前汇率与备注,并校验帐套内名称及代码唯一;`currencyBatch` 支持每批 1–100 条帐套隔离删除。`CurrencyAdd/CurrencyAdd_Submit` 明确列为基础设施阻塞:旧数字字段就叫 `id`,与 Parse SDK 对象保留 `id` 冲突,兼容层物理表又没有可写业务列,实测以 `sourceKey` 新增后重新读取仍无法得到旧数字 ID;Angular 已隐藏新增入口,云函数对新增返回 501,避免产生坏数据。
 - 旧系统 `Config/Holiday/Holiday_API` 的现有节假日管理已迁到 `SysHoliday`:`saveHoliday` 只允许更新名称、公/农历类型、`MM月DD日` 日期、影响天数、状态与展示文本,保护旧 ID、创建人、创建时间和来源键;`holidayBatch` 支持每批 1–100 条帐套隔离删除。`HolidayAdd/HolidayAdd_Submit` 因 `SysHoliday.id` 与 Parse 保留字段冲突且物理表缺少可写旧 ID 列而明确列为基础设施阻塞,Angular 隐藏新增入口,新增请求返回 501。目标 `CourseAppointment/LessonRecord` 均无节假日外键,未伪造删除级联。
 - 旧 `Config/SearchFunc/SearchFunc_API/SearchFuncAdd/SearchFuncAdd_Submit` 已映射到目标 `Search`(后台快捷入口,非内容搜索日志):`searchNavigations` 按管理后台/用户中心类型、启停和推荐状态筛选,`saveSearchNavigation` 白名单维护名称、站内路径、图标、打开方式、移动端、尺寸、颜色、用户组和推荐级别,`searchNavigationBatch` 覆盖启停、推荐/取消推荐、排序和删除。目标兼容层只能在不指定字段时还原旧数据;可回滚的临时创建—读取—删除实测证实 `sourceKey` 无法还原保留名为 `id` 的数字业务键,因此 Angular 隐藏新增,云函数返回 501。旧 `starturl` 依赖不存在的 `SiteConfig.SiteOption.Admin_StartUrl` 持久层,同样明确 501,未以浏览器本地存储伪造全局配置。
+- 旧 `Config/APIInfo/ReadFile` 页面的文档功能已迁为 Angular 云函数接口目录:`apiCatalog` 从实时 `Function` 元数据安全返回名称、路径、描述、参数、响应类型和更新时间,不暴露 `code`;界面可搜索并复制路径。`FontIcon` 替换为完整 Lucide 图标库的搜索、分页和点击复制页。旧 API 密钥/IP 策略、Swagger 开关、协议文件、进程关闭、`KeyboardConfig/Hotkey` XML 快捷键与 `PrizeConfig/PrizeTask` 奖励规则在目标 Schema/云函数运行时均无等价执行层;`legacyConfigBlocker` 对全部 11 个旧请求返回逐项 501 原因,避免伪保存。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 66 项已实现、35 项部分实现、114 项数据阻塞、193 项基础设施阻塞、634 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 68 项已实现、36 项部分实现、115 项数据阻塞、203 项基础设施阻塞、620 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 2 - 0
package.json

@@ -9,6 +9,8 @@
     "parity:check": "node scripts/generate-source-parity.mjs --check",
     "admin-coverage:generate": "node scripts/generate-admin-coverage.mjs",
     "admin-coverage:check": "node scripts/generate-admin-coverage.mjs --check",
+    "admin-icons:generate": "node scripts/generate-admin-icon-catalog.mjs",
+    "admin-icons:check": "node scripts/generate-admin-icon-catalog.mjs --check",
     "cloud:validate": "node scripts/deploy-admin-functions.mjs --validate",
     "cloud:deploy": "node scripts/deploy-admin-functions.mjs",
     "cloud:smoke": "node scripts/smoke-admin-functions.mjs",

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/static/admin/lucide-icons.json


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

@@ -193,6 +193,27 @@ async function handler(request, response) {
       const resources = [...ALLOWED_CLASSES].sort().map((className) => ({ className, label: CLASS_LABELS[className] || className, writable: !READ_ONLY_CLASSES.has(className), route: '/admin/resources/' + className }));
       return response.json({ success: true, data: { total: resources.length, resources } });
     }
+    if (operation === 'apiCatalog') {
+      const query = new Parse.Query('Function'); query.ascending('path'); query.limit(1000); const rows = await query.find({ useMasterKey: true });
+      const results = rows.filter((entry) => entry.get('isDeleted') !== true && String(entry.get('path') || '').startsWith('xiaoshu/')).map((entry) => ({ objectId: entry.id, name: String(entry.get('name') || ''), desc: String(entry.get('desc') || ''), path: String(entry.get('path') || ''), type: String(entry.get('type') || 'standalone'), paramList: Array.isArray(entry.get('paramList')) ? safeValue(entry.get('paramList')) : [], respType: String(entry.get('respType') || 'json'), updatedAt: safeValue(entry.updatedAt) }));
+      return response.json({ success: true, data: { total: results.length, results } });
+    }
+    if (operation === 'legacyConfigBlocker') {
+      const action = String(input.action || ''); const blockers = {
+        APIInfo_Submit: '旧 API_ID/API_Key/API_AdminKey、IP 访问策略与目标 sessionToken/云函数权限模型不等价,且没有能对全部函数生效的站点配置层',
+        APIInfo_SwaggerClose: '目标云函数引擎没有帐套级 Swagger 实时调试关闭开关',
+        APIInfo_SwaggerOpen: '目标云函数引擎没有帐套级 Swagger 实时调试开启开关',
+        LicenceFile_API: '目标 Schema 缺少许可、隐私、协议与版权文档存储,云函数也不能写旧 wwwroot/Config 文件',
+        close_system: '云函数运行时不存在可通过 app_offline.htm 关闭的单体 ASP.NET 进程',
+        Hotkey: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,旧 XML 配置文件也不在云函数运行时',
+        HotkeyAdd: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,无法生成可全局生效的快捷键',
+        HotkeyAdd_Submit: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,不能伪造保存成功',
+        Hotkey_API: '目标 Schema 没有 KeyboardConfig/Hotkey 持久化模型,启停、删除、重载和脚本匹配无可操作对象',
+        Prize: '目标 Schema 没有 PrizeConfig/PrizeTask 配置模型及签到、任务规则数据',
+        Prize_Submit: '目标 Schema 没有 PrizeConfig/PrizeTask 配置模型,无法持久化奖励积分、重复周期和过期规则'
+      };
+      if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧配置动作'); fail(501, 'migration_blocked: ' + blockers[action]);
+    }
     if (operation === 'createUser') {
       const username = String(input.username || '').trim();
       const password = typeof input.password === 'string' ? input.password : '';

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

@@ -121,6 +121,20 @@ const explicit = new Map([
   ['Config/SearchFunc_API', ['partial', '已覆盖启停、推荐、取消推荐、排序与删除;设为全局后台起始页缺少 SiteConfig 持久层']],
   ['Config/SearchFuncAdd', ['partial', '已由 Search 专用现有入口编辑抽屉替代;新增因旧 id 不可持久化而阻塞']],
   ['Config/SearchFuncAdd_Submit', ['partial', '已由 saveSearchNavigation 白名单更新替代;新增明确返回 migration_blocked']],
+  ['Config/APIInfo', ['partial', '已由实时 Function 元数据目录替代旧 WXAPP.cshtml 注释解析;旧密钥与访问开关无等价执行层']],
+  ['Config/APIInfo_Submit', ['blocked-infrastructure', '旧 API 密钥、IP 策略与目标 sessionToken/帐套权限不等价,且无全函数生效配置层']],
+  ['Config/APIInfo_SwaggerClose', ['blocked-infrastructure', '目标云函数引擎没有帐套级 Swagger 调试关闭开关']],
+  ['Config/APIInfo_SwaggerOpen', ['blocked-infrastructure', '目标云函数引擎没有帐套级 Swagger 调试开启开关']],
+  ['Config/ReadFile', ['implemented', '已由 apiCatalog 安全读取 Function 名称、路径、描述、参数和响应类型,不暴露源码']],
+  ['Config/LicenceFile_API', ['blocked-data', '目标 Schema 缺少许可、隐私、协议与版权文档数据,云函数也不能写旧 wwwroot 文件']],
+  ['Config/FontIcon', ['implemented', '已由 Angular Lucide 完整图标选择器、搜索、分页和点击复制替代']],
+  ['Config/close_system', ['blocked-infrastructure', '云函数运行时没有可通过 app_offline.htm 关闭的单体 ASP.NET 进程']],
+  ['Config/Hotkey', ['blocked-infrastructure', '目标 Schema 缺少 KeyboardConfig/Hotkey 持久化模型与旧 XML 数据']],
+  ['Config/HotkeyAdd', ['blocked-infrastructure', '目标 Schema 缺少 KeyboardConfig/Hotkey 持久化模型,无法安全新增']],
+  ['Config/HotkeyAdd_Submit', ['blocked-infrastructure', '目标 Schema 缺少 KeyboardConfig/Hotkey 持久化模型,不伪造保存成功']],
+  ['Config/Hotkey_API', ['blocked-infrastructure', '目标无可执行快捷键启停、删除、重载或脚本匹配的全局运行时']],
+  ['Config/Prize', ['blocked-infrastructure', '目标 Schema 缺少 PrizeConfig/PrizeTask 配置模型及签到、任务规则数据']],
+  ['Config/Prize_Submit', ['blocked-infrastructure', '目标无法持久化旧奖励积分、重复周期和过期规则']],
 ]);
 
 const blockedDataModules = new Map([

+ 27 - 0
scripts/generate-admin-icon-catalog.mjs

@@ -0,0 +1,27 @@
+#!/usr/bin/env node
+
+import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+
+const root = resolve(import.meta.dirname, '..');
+const sourceDir = resolve(root, 'node_modules/lucide-angular/icons');
+const outputPath = resolve(root, 'public/static/admin/lucide-icons.json');
+const checkOnly = process.argv.includes('--check');
+const entries = [];
+
+for (const file of readdirSync(sourceDir).filter((name) => name.endsWith('.d.ts') && name !== 'lucide-icons.d.ts').sort()) {
+  const source = readFileSync(resolve(sourceDir, file), 'utf8');
+  const preview = source.match(/data:image\/svg\+xml;base64,([A-Za-z0-9+/=]+)/)?.[1];
+  if (!preview) continue;
+  entries.push({ name: file.slice(0, -5), data: preview });
+}
+
+const output = JSON.stringify({ version: 1, total: entries.length, icons: entries });
+if (checkOnly) {
+  if (readFileSync(outputPath, 'utf8') !== output) throw new Error('图标目录已过期,请运行 npm run admin-icons:generate');
+  console.log(`Admin icon catalog is current: ${entries.length} icons.`);
+} else {
+  mkdirSync(dirname(outputPath), { recursive: true });
+  writeFileSync(outputPath, output);
+  console.log(`Generated ${outputPath}: ${entries.length} icons.`);
+}

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

@@ -244,6 +244,8 @@ try {
   if (searchNavigationSchema.creatable !== false || ['id','type','state','time','adminId','orderId','linkType','linkState','sourceKey'].some((name) => searchNavigationFieldMap[name]?.writable !== false)) throw new Error('快捷入口结构字段未从新增或通用编辑中隔离');
   const searchNavigationPage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'searchNavigations', className: 'Search', type: 1, state: -100, elite: -100, page: 1, pageSize: 20 });
   if (!Array.isArray(searchNavigationPage.results) || !searchNavigationPage.results.length || searchNavigationPage.results.some((entry) => Number(entry.type) !== 1)) throw new Error('后台快捷入口筛选列表返回异常');
+  const apiCatalog = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'apiCatalog' });
+  if (!Array.isArray(apiCatalog.results) || apiCatalog.results.length < 9 || apiCatalog.results.some((entry) => !String(entry.path || '').startsWith('xiaoshu/') || Object.prototype.hasOwnProperty.call(entry, 'code'))) throw new Error('云函数接口目录缺失或泄露了源码');
   await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'save', className: 'Node', fields: { nodeName: '禁止通用新增' } }, 400);
   const nodeSuffix = Date.now().toString(36);
   const rootNode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveNode', className: 'Node', parentId: 0, fields: { nodeName: `云函数冒烟栏目-${nodeSuffix}`, nodeDir: `smoke-${nodeSuffix}`, nodeType: 1, contentModel: '54' } });
@@ -440,6 +442,10 @@ try {
   if (!String(searchNavigationCreateBlocked.message).includes('保留 id')) throw new Error('快捷入口新增未返回明确旧 ID 阻塞原因');
   const searchNavigationStartBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'searchNavigationBatch', className: 'Search', action: 'starturl', objectIds: ['not-used'] }, 501);
   if (!String(searchNavigationStartBlocked.message).includes('SiteConfig')) throw new Error('快捷入口全局起始页未返回明确阻塞原因');
+  for (const action of ['APIInfo_Submit','APIInfo_SwaggerClose','APIInfo_SwaggerOpen','LicenceFile_API','close_system','Hotkey','HotkeyAdd','HotkeyAdd_Submit','Hotkey_API','Prize','Prize_Submit']) {
+    const configBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyConfigBlocker', action }, 501);
+    if (!String(configBlocked.message).startsWith('migration_blocked:')) throw new Error(`旧配置动作 ${action} 未返回明确阻塞原因`);
+  }
   if (dictionaryCategory.company?.__type !== 'Pointer' || JSON.stringify(dictionaryCategory).match(/accessToken|mch_key|"ak"|"sk"/i)) throw new Error('后台响应展开了帐套敏感配置');
   const examPointSuffix = Date.now().toString(36);
   const rootExamPoint = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamPoint', className: 'ExamPoint', parentId: 0, orderId: 0, fields: { testPoint: `冒烟考点-${examPointSuffix}` } });
@@ -675,7 +681,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/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/currency/holiday/search-navigation/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/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/currency/holiday/search-navigation/api-catalog/config-blockers/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}`);

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

@@ -11,6 +11,8 @@ export const ADMIN_SESSION_KEY = 'xiaoshu_admin_session_v1';
 
 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: '全部数据类', icon: 'table-properties', route: '/admin/resources' },
   { label: '用户与组织', icon: 'users', route: '/admin/resources/_User' },
   { label: '课程绑定', icon: 'book-open', route: '/admin/resources/CourseBinding' },

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

@@ -16,6 +16,14 @@ export const adminRoutes: Routes = [
         path: 'dashboard',
         loadComponent: () => import('./pages/admin-dashboard.component').then((module) => module.AdminDashboardComponent),
       },
+      {
+        path: 'api-catalog',
+        loadComponent: () => import('./pages/admin-api-catalog.component').then((module) => module.AdminApiCatalogComponent),
+      },
+      {
+        path: 'icon-catalog',
+        loadComponent: () => import('./pages/admin-icon-catalog.component').then((module) => module.AdminIconCatalogComponent),
+      },
       {
         path: 'resources',
         loadComponent: () => import('./pages/admin-resource-catalog.component').then((module) => module.AdminResourceCatalogComponent),

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

@@ -4,6 +4,7 @@ import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
 import {
   Bell,
   BookOpen,
+  Braces,
   CalendarDays,
   ChartNoAxesCombined,
   ChevronLeft,
@@ -19,6 +20,7 @@ import {
   Menu,
   Route,
   Search,
+  Shapes,
   TableProperties,
   Users,
 } from 'lucide-angular';
@@ -38,9 +40,9 @@ export class AdminShellComponent {
   readonly collapsed = signal(false);
   readonly mobileOpen = signal(false);
   readonly icons = {
-    Bell, BookOpen, CalendarDays, ChartNoAxesCombined, ChevronLeft, ChevronRight,
+    Bell, BookOpen, Braces, CalendarDays, ChartNoAxesCombined, ChevronLeft, ChevronRight,
     ClipboardCheck, Files, GraduationCap, Languages, LayoutDashboard, ListChecks,
-    LogOut, Menu, Route, Search, TableProperties, Users,
+    LogOut, Menu, Route, Search, Shapes, TableProperties, Users,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

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

@@ -49,6 +49,13 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Config/SearchFunc_API')?.status).toBe('partial');
     expect(byKey.get('Config/SearchFuncAdd')?.status).toBe('partial');
     expect(byKey.get('Config/SearchFuncAdd_Submit')?.status).toBe('partial');
+    expect(byKey.get('Config/APIInfo')?.status).toBe('partial');
+    expect(byKey.get('Config/APIInfo_Submit')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Config/ReadFile')?.status).toBe('implemented');
+    expect(byKey.get('Config/LicenceFile_API')?.status).toBe('blocked-data');
+    expect(byKey.get('Config/FontIcon')?.status).toBe('implemented');
+    expect(byKey.get('Config/Hotkey_API')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Config/Prize_Submit')?.status).toBe('blocked-infrastructure');
     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');

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

@@ -860,43 +860,43 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Config",
     "action": "APIInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已由实时 Function 元数据目录替代旧 WXAPP.cshtml 注释解析;旧密钥与访问开关无等价执行层"
   },
   {
     "module": "Config",
     "action": "APIInfo_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "旧 API 密钥、IP 策略与目标 sessionToken/帐套权限不等价,且无全函数生效配置层"
   },
   {
     "module": "Config",
     "action": "APIInfo_SwaggerClose",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标云函数引擎没有帐套级 Swagger 调试关闭开关"
   },
   {
     "module": "Config",
     "action": "APIInfo_SwaggerOpen",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标云函数引擎没有帐套级 Swagger 调试开启开关"
   },
   {
     "module": "Config",
     "action": "ReadFile",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 apiCatalog 安全读取 Function 名称、路径、描述、参数和响应类型,不暴露源码"
   },
   {
     "module": "Config",
     "action": "LicenceFile_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少许可、隐私、协议与版权文档数据,云函数也不能写旧 wwwroot 文件"
   },
   {
     "module": "Config",
@@ -930,57 +930,57 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Config",
     "action": "FontIcon",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular Lucide 完整图标选择器、搜索、分页和点击复制替代"
   },
   {
     "module": "Config",
     "action": "close_system",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "云函数运行时没有可通过 app_offline.htm 关闭的单体 ASP.NET 进程"
   },
   {
     "module": "Config",
     "action": "Hotkey",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标 Schema 缺少 KeyboardConfig/Hotkey 持久化模型与旧 XML 数据"
   },
   {
     "module": "Config",
     "action": "HotkeyAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标 Schema 缺少 KeyboardConfig/Hotkey 持久化模型,无法安全新增"
   },
   {
     "module": "Config",
     "action": "HotkeyAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标 Schema 缺少 KeyboardConfig/Hotkey 持久化模型,不伪造保存成功"
   },
   {
     "module": "Config",
     "action": "Hotkey_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标无可执行快捷键启停、删除、重载或脚本匹配的全局运行时"
   },
   {
     "module": "Config",
     "action": "Prize",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标 Schema 缺少 PrizeConfig/PrizeTask 配置模型及签到、任务规则数据"
   },
   {
     "module": "Config",
     "action": "Prize_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标无法持久化旧奖励积分、重复周期和过期规则"
   },
   {
     "module": "Content",

+ 27 - 0
src/app/admin/pages/admin-api-catalog.component.html

@@ -0,0 +1,27 @@
+<header class="page-header">
+  <div><p>CLOUD FUNCTION API</p><h1>云函数接口目录</h1><span>直接从 Parse Function 表读取当前已部署接口,不解析旧服务器文件</span></div>
+  <button type="button" [disabled]="loading()" (click)="load()"><lucide-icon [img]="icons.RefreshCw" [size]="17" [class.spin]="loading()" />刷新</button>
+</header>
+
+<section class="legacy-note"><lucide-icon [img]="icons.Braces" [size]="20" /><div><strong>旧 APIInfo 配置已隔离</strong><span>目标接口使用 sessionToken、管理员角色和帐套权限。旧 API_ID/API_Key、IP 黑白名单与 Swagger 开关没有可对全部云函数生效的等价层,已保留明确阻塞响应。</span></div></section>
+
+<section class="catalog-card">
+  <div class="catalog-toolbar"><label><lucide-icon [img]="icons.Search" [size]="18" /><input [ngModel]="search()" (ngModelChange)="search.set($event)" placeholder="搜索名称、路径、描述或参数" /></label><span>{{ filtered().length }} / {{ data()?.total || 0 }} 个接口</span></div>
+  @if (loading() && !data()) {
+    <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在通过管理员云函数读取接口目录…</div>
+  } @else if (error() && !data()) {
+    <div class="state error"><strong>接口目录加载失败</strong><span>{{ error() }}</span><button type="button" (click)="load()">重试</button></div>
+  } @else {
+    <div class="api-list">
+      @for (entry of filtered(); track entry.objectId) {
+        <article>
+          <header><div><span>{{ entry.type }} · {{ entry.respType }}</span><h2>{{ entry.name }}</h2></div><button type="button" (click)="copy(entry.path)"><lucide-icon [img]="icons.Copy" [size]="16" />{{ copiedPath() === entry.path ? '已复制' : '复制路径' }}</button></header>
+          <code>/api/functions/{{ entry.path }}</code>
+          <p>{{ entry.desc || '暂无描述' }}</p>
+          <div class="params"><strong>参数</strong>@for (param of entry.paramList; track param.name) { <span><b>{{ param.name }}</b><small>{{ param.type }}{{ param.required ? ' · 必填' : ' · 选填' }}</small></span> } @empty { <em>无声明参数</em> }</div>
+          <footer>更新于 {{ entry.updatedAt | date:'yyyy-MM-dd HH:mm' }}</footer>
+        </article>
+      } @empty { <div class="state">没有符合条件的云函数接口</div> }
+    </div>
+  }
+</section>

+ 6 - 0
src/app/admin/pages/admin-api-catalog.component.scss

@@ -0,0 +1,6 @@
+.page-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin-bottom: 18px; }.page-header p { margin: 0 0 7px; color: #2e9a71; font-size: 11px; font-weight: 800; letter-spacing: .18em; }.page-header h1 { margin: 0; font-size: 28px; }.page-header span { display: block; margin-top: 7px; color: #7e8999; font-size: 12px; }.page-header button, article header button, .state button { display: inline-flex; align-items: center; gap: 7px; min-height: 36px; padding: 0 13px; border: 1px solid #dfe5ec; border-radius: 8px; color: #4a5a6c; background: #fff; cursor: pointer; }
+.legacy-note { display: flex; align-items: flex-start; gap: 12px; padding: 15px 17px; border: 1px solid #e4dcf1; border-radius: 12px; color: #665b7c; background: #f8f5fc; }.legacy-note strong, .legacy-note span { display: block; }.legacy-note span { margin-top: 4px; font-size: 12px; line-height: 1.7; }
+.catalog-card { overflow: hidden; margin-top: 17px; border: 1px solid #e3e8ef; border-radius: 14px; background: #fff; }.catalog-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 15px 18px; border-bottom: 1px solid #edf0f4; }.catalog-toolbar label { display: flex; align-items: center; gap: 9px; width: min(520px, 100%); padding: 0 11px; border: 1px solid #dfe5ec; border-radius: 9px; background: #fafbfd; }.catalog-toolbar input { width: 100%; min-height: 38px; border: 0; outline: 0; background: transparent; }.catalog-toolbar > span { color: #798697; font-size: 12px; white-space: nowrap; }
+.api-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; padding: 16px; }.api-list article { min-width: 0; padding: 17px; border: 1px solid #e6eaf0; border-radius: 12px; background: #fcfdfe; }.api-list article header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }.api-list article header span { color: #39906f; font-size: 10px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }.api-list h2 { margin: 4px 0 0; font-size: 17px; }.api-list code { display: block; overflow: hidden; margin-top: 14px; padding: 9px 11px; border-radius: 7px; color: #355c75; background: #edf5f9; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }.api-list p { min-height: 38px; margin: 12px 0; color: #657286; font-size: 12px; line-height: 1.6; }.params { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }.params > strong { margin-right: 2px; color: #596677; font-size: 11px; }.params > span { display: inline-flex; align-items: center; gap: 5px; padding: 5px 8px; border-radius: 14px; color: #4b6275; background: #eef2f6; font-size: 10px; }.params small, .params em { color: #8994a3; font-size: 10px; font-style: normal; }.api-list footer { margin-top: 14px; color: #9aa4b1; font-size: 10px; }
+.state { display: flex; align-items: center; justify-content: center; gap: 9px; min-height: 130px; color: #7f8b9b; font-size: 12px; }.state.error { flex-direction: column; color: #a43b43; }.spin { animation: spin .9s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }
+@media (max-width: 900px) { .api-list { grid-template-columns: 1fr; } }@media (max-width: 650px) { .page-header, .catalog-toolbar { align-items: stretch; flex-direction: column; }.api-list { padding: 10px; }.api-list article header { flex-direction: column; } }

+ 26 - 0
src/app/admin/pages/admin-api-catalog.component.spec.ts

@@ -0,0 +1,26 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { CloudFunctionsService } from '../cloud-functions.service';
+import { AdminApiCatalogComponent } from './admin-api-catalog.component';
+
+describe('AdminApiCatalogComponent', () => {
+  let fixture: ComponentFixture<AdminApiCatalogComponent>;
+  let component: AdminApiCatalogComponent;
+  const functions = { admin: jasmine.createSpy('admin') };
+
+  beforeEach(async () => {
+    functions.admin.calls.reset();
+    functions.admin.and.resolveTo({ total: 2, results: [
+      { objectId: 'a', name: 'Admin Gateway', desc: '帐套数据网关', path: 'xiaoshu/admin/gateway', type: 'standalone', paramList: [{ name: 'operation', type: 'String', required: true }], respType: 'json', updatedAt: '2026-08-18T10:00:00.000Z' },
+      { objectId: 'b', name: 'Search', desc: '内容搜索', path: 'xiaoshu/cms/search', type: 'standalone', paramList: [{ name: 'keyword', type: 'String', required: true }], respType: 'json', updatedAt: '2026-08-18T10:00:00.000Z' },
+    ] });
+    await TestBed.configureTestingModule({ imports: [AdminApiCatalogComponent], providers: [{ provide: CloudFunctionsService, useValue: functions }] }).compileComponents();
+    fixture = TestBed.createComponent(AdminApiCatalogComponent); component = fixture.componentInstance;
+  });
+
+  it('loads the deployed cloud function catalog and filters it', async () => {
+    await component.load();
+    expect(functions.admin).toHaveBeenCalledWith('apiCatalog');
+    component.search.set('operation');
+    expect(component.filtered().map((entry) => entry.path)).toEqual(['xiaoshu/admin/gateway']);
+  });
+});

+ 66 - 0
src/app/admin/pages/admin-api-catalog.component.ts

@@ -0,0 +1,66 @@
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Braces, Copy, LoaderCircle, LucideAngularModule, RefreshCw, Search } from 'lucide-angular';
+import { CloudFunctionsService } from '../cloud-functions.service';
+
+interface ApiParameter {
+  name: string;
+  type: string;
+  required?: boolean;
+  default?: unknown;
+}
+
+interface ApiCatalogEntry {
+  objectId: string;
+  name: string;
+  desc: string;
+  path: string;
+  type: string;
+  paramList: ApiParameter[];
+  respType: string;
+  updatedAt: string;
+}
+
+interface ApiCatalogResponse {
+  total: number;
+  results: ApiCatalogEntry[];
+}
+
+@Component({
+  selector: 'app-admin-api-catalog',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule],
+  templateUrl: './admin-api-catalog.component.html',
+  styleUrl: './admin-api-catalog.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminApiCatalogComponent implements OnInit {
+  readonly data = signal<ApiCatalogResponse | null>(null);
+  readonly loading = signal(true);
+  readonly error = signal('');
+  readonly search = signal('');
+  readonly copiedPath = signal('');
+  readonly icons = { Braces, Copy, LoaderCircle, RefreshCw, Search };
+  readonly filtered = computed(() => {
+    const keyword = this.search().trim().toLocaleLowerCase();
+    const results = this.data()?.results ?? [];
+    if (!keyword) return results;
+    return results.filter((entry) => [entry.name, entry.path, entry.desc, ...entry.paramList.map((item) => item.name)].some((value) => String(value || '').toLocaleLowerCase().includes(keyword)));
+  });
+  private readonly functions = inject(CloudFunctionsService);
+
+  ngOnInit(): void { void this.load(); }
+
+  async load(): Promise<void> {
+    this.loading.set(true); this.error.set('');
+    try { this.data.set(await this.functions.admin<ApiCatalogResponse>('apiCatalog')); }
+    catch (error) { this.error.set(error instanceof Error ? error.message : '接口目录加载失败'); }
+    finally { this.loading.set(false); }
+  }
+
+  async copy(path: string): Promise<void> {
+    try { await navigator.clipboard.writeText(path); this.copiedPath.set(path); setTimeout(() => this.copiedPath.set(''), 1600); }
+    catch { this.error.set('无法访问剪贴板,请手动复制接口路径'); }
+  }
+}

+ 12 - 0
src/app/admin/pages/admin-icon-catalog.component.html

@@ -0,0 +1,12 @@
+<header class="page-header"><div><p>ICON CATALOG</p><h1>图标选择器</h1><span>Angular 后台完整 Lucide 图标库,点击图标即可复制 kebab-case 名称</span></div></header>
+<section class="icon-card">
+  <div class="toolbar"><label><lucide-icon [img]="controls.Search" [size]="18" /><input [ngModel]="search()" (ngModelChange)="setSearch($event)" [placeholder]="'搜索 ' + allIcons().length + ' 个图标'" /></label><span>{{ filtered().length }} 个结果</span></div>
+  @if (loading()) {
+    <div class="empty">正在按需加载完整图标目录…</div>
+  } @else if (error()) {
+    <div class="empty error">{{ error() }}<button type="button" (click)="load()">重试</button></div>
+  } @else {
+    <div class="icon-grid">@for (entry of visible(); track entry.name) { <button type="button" [attr.title]="entry.name" (click)="copy(entry.name)"><img [src]="'data:image/svg+xml;base64,' + entry.data" alt="" width="27" height="27" /><span>{{ entry.name }}</span>@if (copied() === entry.name) { <b><lucide-icon [img]="controls.Copy" [size]="12" />已复制</b> }</button> } @empty { <div class="empty">没有符合条件的图标</div> }</div>
+  }
+  <footer><span>第 {{ page() }} / {{ pageCount() }} 页</span><div><button type="button" [disabled]="page() <= 1" (click)="move(-1)"><lucide-icon [img]="controls.ChevronLeft" [size]="17" />上一页</button><button type="button" [disabled]="page() >= pageCount()" (click)="move(1)">下一页<lucide-icon [img]="controls.ChevronRight" [size]="17" /></button></div></footer>
+</section>

+ 5 - 0
src/app/admin/pages/admin-icon-catalog.component.scss

@@ -0,0 +1,5 @@
+.page-header { margin-bottom: 18px; }.page-header p { margin: 0 0 7px; color: #2e9a71; font-size: 11px; font-weight: 800; letter-spacing: .18em; }.page-header h1 { margin: 0; font-size: 28px; }.page-header span { display: block; margin-top: 7px; color: #7e8999; font-size: 12px; }
+.icon-card { overflow: hidden; border: 1px solid #e3e8ef; border-radius: 14px; background: #fff; }.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 15px 18px; border-bottom: 1px solid #edf0f4; }.toolbar label { display: flex; align-items: center; gap: 9px; width: min(520px, 100%); padding: 0 11px; border: 1px solid #dfe5ec; border-radius: 9px; background: #fafbfd; }.toolbar input { width: 100%; min-height: 38px; border: 0; outline: 0; background: transparent; }.toolbar > span { color: #798697; font-size: 12px; white-space: nowrap; }
+.icon-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 8px; min-height: 420px; padding: 14px; }.icon-grid > button { position: relative; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; min-width: 0; min-height: 98px; padding: 10px 7px; border: 1px solid #e7ebf0; border-radius: 10px; color: #506174; background: #fcfdfe; cursor: pointer; transition: border-color .15s, color .15s, transform .15s; }.icon-grid > button:hover { border-color: #8bc8af; color: #267858; transform: translateY(-1px); }.icon-grid > button > span { overflow: hidden; width: 100%; font-size: 10px; text-align: center; text-overflow: ellipsis; white-space: nowrap; }.icon-grid img { display: block; }.icon-grid b { position: absolute; top: 5px; right: 5px; display: inline-flex; align-items: center; gap: 3px; padding: 3px 5px; border-radius: 10px; color: #217756; background: #eaf7f1; font-size: 8px; }.empty { grid-column: 1 / -1; display: grid; place-items: center; min-height: 420px; color: #8894a3; font-size: 12px; }.empty.error { align-content: center; gap: 10px; color: #a43b43; }.empty button { min-height: 34px; padding: 0 12px; border: 1px solid #dfe5ec; border-radius: 8px; background: #fff; cursor: pointer; }
+footer { display: flex; align-items: center; justify-content: space-between; padding: 13px 18px; border-top: 1px solid #edf0f4; color: #778394; font-size: 12px; }footer div { display: flex; gap: 8px; }footer button { display: inline-flex; align-items: center; gap: 6px; min-height: 34px; padding: 0 11px; border: 1px solid #dfe5ec; border-radius: 8px; color: #526174; background: #fff; cursor: pointer; }footer button:disabled { cursor: not-allowed; opacity: .45; }
+@media (max-width: 1200px) { .icon-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); } }@media (max-width: 850px) { .icon-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } }@media (max-width: 560px) { .toolbar { align-items: stretch; flex-direction: column; }.icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.icon-grid > button { min-height: 86px; } }

+ 18 - 0
src/app/admin/pages/admin-icon-catalog.component.spec.ts

@@ -0,0 +1,18 @@
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { AdminIconCatalogComponent } from './admin-icon-catalog.component';
+
+describe('AdminIconCatalogComponent', () => {
+  it('loads the complete lazy icon catalog with search and paging', async () => {
+    await TestBed.configureTestingModule({ imports: [AdminIconCatalogComponent], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
+    const fixture = TestBed.createComponent(AdminIconCatalogComponent); const component = fixture.componentInstance; fixture.detectChanges();
+    const http = TestBed.inject(HttpTestingController); http.expectOne('/static/admin/lucide-icons.json').flush({ version: 1, total: 1002, icons: Array.from({ length: 1002 }, (_, index) => ({ name: index ? `icon-${index}` : 'alarm-clock', data: 'PHN2Zy8+' })) });
+    await fixture.whenStable();
+    expect(component.allIcons().length).toBeGreaterThan(1000);
+    component.setSearch('alarm-clock');
+    expect(component.filtered().map((entry) => entry.name)).toEqual(['alarm-clock']);
+    expect(component.page()).toBe(1);
+    http.verify();
+  });
+});

+ 38 - 0
src/app/admin/pages/admin-icon-catalog.component.ts

@@ -0,0 +1,38 @@
+import { CommonModule } from '@angular/common';
+import { HttpClient } from '@angular/common/http';
+import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { ChevronLeft, ChevronRight, Copy, LucideAngularModule, Search } from 'lucide-angular';
+import { firstValueFrom } from 'rxjs';
+
+interface IconCatalogEntry { name: string; data: string; }
+interface IconCatalogResponse { version: number; total: number; icons: IconCatalogEntry[]; }
+
+@Component({
+  selector: 'app-admin-icon-catalog',
+  standalone: true,
+  imports: [CommonModule, FormsModule, LucideAngularModule],
+  templateUrl: './admin-icon-catalog.component.html',
+  styleUrl: './admin-icon-catalog.component.scss',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AdminIconCatalogComponent implements OnInit {
+  readonly search = signal('');
+  readonly page = signal(1);
+  readonly pageSize = 120;
+  readonly copied = signal('');
+  readonly loading = signal(true);
+  readonly error = signal('');
+  readonly allIcons = signal<IconCatalogEntry[]>([]);
+  readonly controls = { ChevronLeft, ChevronRight, Copy, Search };
+  readonly filtered = computed(() => { const keyword = this.search().trim().toLocaleLowerCase(); return keyword ? this.allIcons().filter((entry) => entry.name.includes(keyword)) : this.allIcons(); });
+  readonly pageCount = computed(() => Math.max(1, Math.ceil(this.filtered().length / this.pageSize)));
+  readonly visible = computed(() => { const page = Math.min(this.page(), this.pageCount()); return this.filtered().slice((page - 1) * this.pageSize, page * this.pageSize); });
+  private readonly http = inject(HttpClient);
+
+  ngOnInit(): void { void this.load(); }
+  async load(): Promise<void> { this.loading.set(true); this.error.set(''); try { const response = await firstValueFrom(this.http.get<IconCatalogResponse>('/static/admin/lucide-icons.json')); this.allIcons.set(response.icons || []); } catch (error) { this.error.set(error instanceof Error ? error.message : '图标目录加载失败'); } finally { this.loading.set(false); } }
+  setSearch(value: string): void { this.search.set(value); this.page.set(1); }
+  move(delta: number): void { this.page.set(Math.min(this.pageCount(), Math.max(1, this.page() + delta))); }
+  async copy(name: string): Promise<void> { try { await navigator.clipboard.writeText(name); this.copied.set(name); setTimeout(() => this.copied.set(''), 1600); } catch { this.copied.set('复制失败'); } }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов