#!/usr/bin/env node import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; const root = resolve(import.meta.dirname, '..'); const sourcePath = resolve(root, 'docs/migration/legacy-admin-inventory.generated.md'); const outputPath = resolve(root, 'src/app/admin/legacy-admin-coverage.generated.ts'); const checkOnly = process.argv.includes('--check'); const explicit = new Map([ ['User/GroupManage', ['implemented', '已由 Group 资源页与专用帐套组生命周期替代']], ['User/Group_API', ['implemented', '已覆盖帐套组列表与带引用保护的删除']], ['User/GroupAdd', ['implemented', '已由动态组表单与专用 saveGroup 替代']], ['User/GroupAdd_Submit', ['implemented', '已覆盖原子编号、父组校验与保存']], ['Content/ContentManage_Status', ['implemented', '已由 contentBatch 状态操作替代']], ['Content/ContentManage_Del', ['implemented', '已由 contentBatch 回收站操作替代']], ['Content/ContentMove', ['implemented', '已由内容详情与批量节点移动控件替代']], ['Content/ContentMove_Submit', ['implemented', '已由 contentBatch 帐套节点校验与移动替代']], ['Content/IHtmlToString', ['implemented', '已由 cms.content-normalizer 替代']], ['User/UserManage', ['partial', '已提供帐套用户列表、搜索、分页和批量操作;旧多条件筛选与导出仍待补齐']], ['User/UserAdd', ['partial', '已提供安全普通用户开户;旧扩展资料字段尚未全部复刻']], ['User/UserAdd_Submit', ['partial', '已覆盖开户、旧用户 ID 与默认账户初始化']], ['User/UserInfo', ['partial', '已提供动态详情、资料编辑、状态、分组和密码重置']], ['User/UserInfo_Submit', ['partial', '已覆盖安全字段更新;旧 UserBase 全字段仍待专用映射']], ['User/User_API', ['partial', '已覆盖锁定、解锁、移动分组;标注、归属管理员和手机归属地尚未迁移']], ['User/UserExp', ['partial', '现有账本可查询,管理员余额变更表单与事务规则尚未补齐']], ['Content/ContentManage', ['partial', '已提供帐套内容列表、搜索、分页、审核和批量操作']], ['Content/EditContent', ['partial', '可编辑安全主表字段;模型附表专用表单尚未全部生成']], ['Content/Content_Update', ['partial', '可更新安全主表字段;模型附表更新仅覆盖已迁移学习模型']], ['Content/ContentRecycle', ['partial', '可回收和恢复内容;独立回收站筛选页尚未提供']], ['Content/ContentManage_API', ['partial', '已覆盖恢复;永久删除、清空、专题移除和排序交换尚未迁移']], ['Content / Node/NodeManage', ['implemented', '已由 Node 资源列表、分页与专用结构操作替代']], ['Content / Node/NodeSearch', ['implemented', '已覆盖按栏目名称搜索并保持帐套隔离']], ['Content / Node/NodeAdd', ['implemented', '已由 Node 动态表单与父节点结构控件替代']], ['Content / Node/NodeAdd_Submit', ['implemented', '已覆盖原子栏目编号、同级防重、父节点与层级校验']], ['Content / Node/UnionNode_Move', ['implemented', '已由 nodeBatch 批量移动与递归深度同步替代']], ['Content / Node/NodeRecycle', ['partial', '已覆盖回收和恢复;独立回收站筛选页尚未提供']], ['Content / Node/SPageAdd', ['partial', '节点类型与外链字段可编辑;旧单页专用布局尚未逐字段复刻']], ['Content / Node/OutLinkAdd', ['partial', '节点类型与 URL 字段可编辑;旧外链专用布局尚未逐字段复刻']], ['Content / Node/Node_API', ['partial', '已覆盖回收、恢复与安全永久删除;清空全部节点被明确禁止']], ['Content / Node/BatchNode', ['partial', '已提供栏目批量回收、恢复和结构迁移']], ['Content / Node/BatchNode_Submit', ['partial', '已覆盖结构批处理;旧批量模板、权限与计费字段仍待专用实现']], ['Content / Node/UnionNode', ['partial', '已提供栏目批量迁移;栏目合并仍待实现']], ['Content / Node/Special_API', ['partial', '已覆盖删除引用保护与保存时名称/目录校验;旧独立校验响应未保留']], ['Content / Node/Special', ['implemented', '已由 Special 资源列表、搜索与分页替代']], ['Content / Node/SpecialAdd', ['implemented', '已由专题动态表单与父专题结构控件替代']], ['Content / Node/SpecialAdd_Save', ['implemented', '已覆盖原子专题编号、全帐套防重、父专题与循环校验']], ['Content / Node/UnionSpecial', ['partial', '已提供专题批量迁移;关系合并因目标数据缺失被显式阻塞']], ['Content / Node/UnionSpecial_Move', ['implemented', '已由 specialBatch 帐套隔离批量移动替代']], ['Content / Node/UnionSpecial_Merge', ['blocked-data', 'CommonModel.specialId 物理列缺失且商品主表未迁移,无法安全重写专题关系']], ['Model/Index', ['partial', '已由全部数据类与 Model/ModelField 资源页替代;旧模型类型入口布局未逐项复刻']], ['Model/ModelManage', ['implemented', '已提供帐套模型列表、搜索、分页与安全元数据编辑']], ['Model/ModelAdd', ['blocked-infrastructure', '新增模型需要 PostgreSQL 建表、生成标签与写入旧模板文件']], ['Model/ModelAdd_Submit', ['blocked-infrastructure', '托管云函数未开放模型 DDL 与旧 Zoomla 模板文件运行时']], ['Model/Model_API', ['partial', '已覆盖安全模板/展示元数据更新;复制和删除模型因 DDL 被阻塞']], ['Model/ModelData', ['partial', '规范化目标类可从资源页管理;任意旧附表名到 Parse 类的映射尚未完整恢复']], ['Model/Field', ['implemented', '已提供模型字段列表、搜索、分页与安全展示元数据编辑']], ['Model/Order_Submit', ['partial', '已提供同模型字段批量排序云函数;专用拖拽界面尚未复刻']], ['Model/FieldAdd', ['blocked-infrastructure', '新增字段需要 PostgreSQL ALTER TABLE 与旧字段控件模板']], ['Model/FieldAdd_Submit', ['blocked-infrastructure', '目标托管环境未开放任意物理列 DDL 与旧控件生成器']], ['Model/Field_API', ['partial', '已覆盖字段顺序更新;字段删除因历史数据与物理列 DDL 被阻塞']], ]); const blockedDataModules = new Map([ ['Order', '目标库缺少订单、订单商品、支付与物流主表'], ['Pay', '目标库缺少支付流水、订单支付明细及可验证的交易数据'], ['Sale', '目标库缺少订单销售明细与可复算的销售事实表'], ['Shop / Coupon', '目标库无优惠券、促销实例与用户领取关系'], ['Shop / Refund', '目标库无订单退款与退款流水'], ['Product', '目标 Product 仅为旧商品附表,缺少 ZL_Commodities 主表'], ]); const blockedInfrastructureModules = new Map([ ['Extend / DBTool', '依赖数据库备份恢复、建表或服务器文件系统,不适用于托管 Parse 运行时'], ['Files', '依赖旧站点文件系统与文件云配置,尚无新对象存储方案'], ['FontManager', '依赖服务器字体文件安装与删除权限'], ['Label / PowerShell', '依赖 Windows PowerShell 与服务器命令执行,云函数明确禁止'], ['Label / SiteDeploy', '依赖站点发布、远程命令与文件下载基础设施'], ['Site', '依赖 IIS 站点、虚拟目录、主机池与数据库服务器管理'], ['Template', '依赖旧模板/CSS 文件系统和 Zoomla 标签运行时'], ['Extend / MyCss', '依赖旧站点 CSS 文件读写与下载'], ['Mobile / WeiXin', '依赖新版微信、支付、模板消息和服务通知凭据'], ['User / Mail', '依赖邮件与短信服务商配置及发送凭据'], ]); const markdown = readFileSync(sourcePath, 'utf8'); const entries = []; const occurrences = new Map(); for (const line of markdown.split(/\r?\n/)) { const match = line.match(/^\| ([^|]+) \| (\d+) \| (.+) \|$/); if (!match || match[1].trim() === '模块 / 控制器') continue; const moduleName = match[1].trim(); const actions = [...match[3].matchAll(/`([^`]+)`/g)].map((item) => item[1]); if (actions.length !== Number(match[2])) throw new Error(`${moduleName}: 动作数量不一致`); for (const action of actions) { const key = `${moduleName}/${action}`; const occurrence = (occurrences.get(key) || 0) + 1; occurrences.set(key, occurrence); let status = 'pending'; let reason = '尚未逐项迁移或完成等价性核验'; if (explicit.has(key)) [status, reason] = explicit.get(key); else if (blockedDataModules.has(moduleName)) { status = 'blocked-data'; reason = blockedDataModules.get(moduleName); } else if (blockedInfrastructureModules.has(moduleName)) { status = 'blocked-infrastructure'; reason = blockedInfrastructureModules.get(moduleName); } entries.push({ module: moduleName, action, occurrence, status, reason }); } } if (entries.length !== 1042) throw new Error(`预期 1042 个后台动作,实际 ${entries.length}`); const generated = `// Generated by scripts/generate-admin-coverage.mjs. Do not edit by hand.\n` + `export type LegacyAdminCoverageStatus = 'implemented' | 'partial' | 'blocked-data' | 'blocked-infrastructure' | 'pending';\n` + `export interface LegacyAdminCoverageEntry { module: string; action: string; occurrence: number; status: LegacyAdminCoverageStatus; reason: string; }\n` + `export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = ${JSON.stringify(entries, null, 2)};\n`; if (checkOnly) { const current = readFileSync(outputPath, 'utf8'); if (current !== generated) throw new Error('后台迁移覆盖台账已过期,请运行 npm run admin-coverage:generate'); console.log(`Admin coverage is current: ${entries.length} actions.`); } else { writeFileSync(outputPath, generated); console.log(`Generated ${outputPath}: ${entries.length} actions.`); }