662 lines
27 KiB
TypeScript
662 lines
27 KiB
TypeScript
/**
|
||
* Excel 配置生成脚本(增强版)
|
||
*
|
||
* 功能:
|
||
* - 将 /excels 目录下的 .xlsx 文件转换为 JSON(assets/configs/generated/data)
|
||
* - 依据表头(字段:类型 / 可选? / 默认=值 / //备注)生成 TS 数据类与解析函数(assets/configs/generated/core)
|
||
* - 从 __enums__.xlsx 生成 Enums.ts;从 __beans__.xlsx 生成 Beans.ts 与解析函数
|
||
* - 生成 Manager.ts(缺失时),支持唯一索引 getByXxx
|
||
* - 生成时进行唯一约束校验与跨表引用校验
|
||
*
|
||
* 使用:
|
||
* - 在 extensions/max-studio/package.json 中暴露脚本:"gen:configs"
|
||
* - 运行:npm run gen:configs
|
||
*/
|
||
import path from "path";
|
||
import fs from "fs";
|
||
import { ensureDirSync, writeFileSync, existsSync } from "fs-extra";
|
||
import xlsx from "xlsx";
|
||
|
||
type PrimitiveType = "number" | "string" | "boolean";
|
||
type CcType = "Color" | "Vec2" | "Vec3" | "Vec4" | "Size" | "Rect" | "Quat" | "Mat4";
|
||
|
||
interface FieldDef {
|
||
name: string;
|
||
type: string; // number|string|boolean|CcType|Enum|Bean
|
||
// 原始类型标识,用于从 Excel 表头保留 `int` / `float` 信息
|
||
rawType?: string;
|
||
isArray: boolean;
|
||
optional: boolean;
|
||
defaultValue?: string;
|
||
comment?: string;
|
||
unique?: boolean;
|
||
refTarget?: string; // 跨表引用目标(表名)
|
||
}
|
||
|
||
interface TableDef {
|
||
name: string; // e.g. PetConfig
|
||
fields: FieldDef[];
|
||
}
|
||
|
||
interface EnumItem {
|
||
name: string;
|
||
value: number;
|
||
comment?: string;
|
||
}
|
||
interface EnumDef {
|
||
name: string;
|
||
items: EnumItem[];
|
||
}
|
||
|
||
interface BeanDef {
|
||
name: string;
|
||
fields: FieldDef[];
|
||
}
|
||
|
||
const PROJECT_ROOT = path.resolve(__dirname, "../../..");
|
||
const EXCEL_DIR = path.join(PROJECT_ROOT, "excels");
|
||
const OUTPUT_CORE_DIR = path.join(PROJECT_ROOT, "assets/configs/generated/core");
|
||
const OUTPUT_DATA_DIR = path.join(PROJECT_ROOT, "assets/configs/generated/data");
|
||
|
||
const CC_COMPLEX_TYPES: ReadonlySet<string> = new Set([
|
||
"Color",
|
||
"Vec2",
|
||
"Vec3",
|
||
"Vec4",
|
||
"Size",
|
||
"Rect",
|
||
"Quat",
|
||
"Mat4",
|
||
]);
|
||
|
||
function log(...args: any[]) {
|
||
// 统一日志输出
|
||
|
||
console.log("[gen-configs]", ...args);
|
||
}
|
||
|
||
function parseHeaderCell(cell: string): FieldDef | null {
|
||
const raw = cell?.trim();
|
||
if (!raw) return null;
|
||
|
||
const [left, commentRaw] = raw.split("//");
|
||
const comment = commentRaw?.trim();
|
||
|
||
const [namePartRaw, typePartRaw = ""] = left.split(":").map((v) => v.trim());
|
||
const optional = /\?$/.test(namePartRaw);
|
||
const name = namePartRaw.replace(/\?$/, "");
|
||
|
||
const [typeIdentRaw, defaultRaw] = typePartRaw.split("=").map((v) => v.trim());
|
||
let typeIdent = typeIdentRaw || (name === "id" ? "number" : "string");
|
||
const isArray = /\[\]$/.test(typeIdent);
|
||
if (isArray) typeIdent = typeIdent.replace(/\[\]$/, "");
|
||
|
||
let refTarget: string | undefined;
|
||
if (/^Ref:/i.test(typeIdent)) {
|
||
refTarget = typeIdent.replace(/^Ref:/i, "");
|
||
typeIdent = "number"; // 引用用目标 id(数值)
|
||
}
|
||
|
||
// 规范 Cocos 原生类型:去掉 "cc." 前缀
|
||
if (/^cc\./i.test(typeIdent)) {
|
||
typeIdent = typeIdent.replace(/^cc\./i, "");
|
||
}
|
||
|
||
// 记录原始数值类型,并将 int/float 规范化为 TS 的 number
|
||
const rawType = /^(int|float)$/i.test(typeIdent) ? typeIdent.toLowerCase() : undefined;
|
||
if (rawType === "int" || rawType === "float") {
|
||
typeIdent = "number";
|
||
}
|
||
|
||
const unique = /\[\s*unique\s*\]/i.test(comment || "");
|
||
const refMatch = (comment || "").match(/\[\s*ref\s*=\s*([A-Za-z0-9_]+)\s*\]/i);
|
||
if (refMatch) refTarget = refMatch[1];
|
||
|
||
const def: FieldDef = {
|
||
name,
|
||
type: typeIdent,
|
||
rawType,
|
||
isArray,
|
||
optional,
|
||
defaultValue: defaultRaw || undefined,
|
||
comment,
|
||
unique,
|
||
refTarget,
|
||
};
|
||
return def;
|
||
}
|
||
|
||
function readWorkbookTables(filePath: string): { table: TableDef; rows: any[] } {
|
||
const workbook = xlsx.readFile(filePath);
|
||
const sheetName = workbook.SheetNames[0];
|
||
const sheet = workbook.Sheets[sheetName];
|
||
|
||
// 读取为二维数组,支持三行表头:第一行字段名、第二行类型、第三行备注
|
||
const rows2d = xlsx.utils.sheet_to_json(sheet, { header: 1, defval: "" }) as any[][];
|
||
const headerNames = (rows2d[0] || []).map((v) => String(v ?? "").trim());
|
||
const headerTypes = (rows2d[1] || []).map((v) => String(v ?? "").trim());
|
||
const headerComments = (rows2d[2] || []).map((v) => String(v ?? "").trim());
|
||
|
||
const fields: FieldDef[] = [];
|
||
for (let i = 0; i < headerNames.length; i++) {
|
||
const nameRaw = headerNames[i];
|
||
if (!nameRaw) continue;
|
||
const typeSpecRaw = headerTypes[i] || "";
|
||
const commentRaw = headerComments[i] || "";
|
||
|
||
// 解析类型行:支持 Ref:Table、数组[]、默认值=xxx、可选?(写在类型末尾)
|
||
let typeIdent = typeSpecRaw;
|
||
let defaultValue: string | undefined;
|
||
const optional = /\?$/.test(typeIdent);
|
||
if (optional) typeIdent = typeIdent.replace(/\?$/, "");
|
||
const isArray = /\[\]$/.test(typeIdent);
|
||
if (isArray) typeIdent = typeIdent.replace(/\[\]$/, "");
|
||
const parts = typeIdent.split("=");
|
||
if (parts.length > 1) {
|
||
typeIdent = parts[0].trim();
|
||
defaultValue = parts.slice(1).join("=").trim();
|
||
}
|
||
|
||
let refTarget: string | undefined;
|
||
if (/^Ref:/i.test(typeIdent)) {
|
||
refTarget = typeIdent.replace(/^Ref:/i, "");
|
||
typeIdent = "number"; // 引用以目标 id(数值)表达
|
||
}
|
||
|
||
// 规范 Cocos 原生类型:去掉 "cc." 前缀
|
||
if (/^cc\./i.test(typeIdent)) {
|
||
typeIdent = typeIdent.replace(/^cc\./i, "");
|
||
}
|
||
|
||
// 记录原始数值类型,并将 int/float 规范化为 TS 的 number
|
||
const rawType = /^(int|float)$/i.test(typeIdent) ? typeIdent.toLowerCase() : undefined;
|
||
if (rawType === "int" || rawType === "float") {
|
||
typeIdent = "number";
|
||
}
|
||
const unique = /\[\s*unique\s*\]/i.test(commentRaw);
|
||
const refMatch = commentRaw.match(/\[\s*ref\s*=\s*([A-Za-z0-9_]+)\s*\]/i);
|
||
if (refMatch) refTarget = refMatch[1];
|
||
|
||
const name = String(nameRaw).replace(/\?$/, "");
|
||
const type = typeIdent || (name === "id" ? "number" : "string");
|
||
|
||
fields.push({
|
||
name,
|
||
type,
|
||
rawType,
|
||
isArray,
|
||
optional,
|
||
defaultValue,
|
||
comment: commentRaw || undefined,
|
||
unique,
|
||
refTarget,
|
||
});
|
||
}
|
||
|
||
// 将第 4 行开始的数据行转换为对象数组(按第一行字段名映射)
|
||
const dataRows: any[] = [];
|
||
for (let r = 3; r < rows2d.length; r++) {
|
||
const rowArr = rows2d[r] || [];
|
||
// 空行跳过
|
||
if (rowArr.every((v: any) => String(v ?? "").trim() === "")) continue;
|
||
const obj: Record<string, any> = {};
|
||
for (let c = 0; c < headerNames.length; c++) {
|
||
const key = headerNames[c];
|
||
if (!key) continue;
|
||
obj[key.replace(/\?$/, "")] = rowArr[c];
|
||
}
|
||
dataRows.push(obj);
|
||
}
|
||
|
||
const tableName = path.basename(filePath, path.extname(filePath));
|
||
return { table: { name: tableName, fields }, rows: dataRows };
|
||
}
|
||
|
||
function writeFileSafe(filePath: string, content: string) {
|
||
ensureDirSync(path.dirname(filePath));
|
||
writeFileSync(filePath, content, { encoding: "utf-8" });
|
||
}
|
||
|
||
function generateEnumsTs(enums: EnumDef[]): string {
|
||
const parts: string[] = [];
|
||
parts.push("/** 自动生成的枚举定义 */");
|
||
for (const e of enums) {
|
||
parts.push(`export enum ${e.name} {`);
|
||
for (const item of e.items) {
|
||
const comment = item.comment ? ` // ${item.comment}` : "";
|
||
parts.push(` ${item.name} = ${item.value},${comment}`);
|
||
}
|
||
parts.push("}\n");
|
||
}
|
||
return parts.join("\n");
|
||
}
|
||
|
||
function readEnumsExcel(absPath: string): EnumDef[] {
|
||
const workbook = xlsx.readFile(absPath);
|
||
const results: EnumDef[] = [];
|
||
for (const sheetName of workbook.SheetNames) {
|
||
const sheet = workbook.Sheets[sheetName];
|
||
const rows: any[] = xlsx.utils.sheet_to_json(sheet, { defval: "" });
|
||
|
||
// 检测是否为分组格式:包含 EnumName/ValueName/Value/Comment 列
|
||
const hasGrouped =
|
||
rows.length > 0 &&
|
||
Object.keys(rows[0]).some((k) => /^(EnumName|enumName)$/i.test(String(k))) &&
|
||
Object.keys(rows[0]).some((k) => /^(ValueName|valueName|Name|name)$/i.test(String(k))) &&
|
||
Object.keys(rows[0]).some((k) => /^(Value|value)$/i.test(String(k)));
|
||
|
||
if (hasGrouped) {
|
||
const enumMap = new Map<string, EnumItem[]>();
|
||
let currentEnum = "";
|
||
for (const row of rows) {
|
||
const enumNameRaw = String(row.EnumName ?? row.enumName ?? "").trim();
|
||
if (enumNameRaw) currentEnum = enumNameRaw;
|
||
if (!currentEnum) continue;
|
||
|
||
const valueName = String(row.ValueName ?? row.valueName ?? row.Name ?? row.name ?? "").trim();
|
||
if (!valueName) continue;
|
||
|
||
const valueRaw = row.Value ?? row.value ?? 0;
|
||
const value = Number(valueRaw);
|
||
const comment = String(row.Comment ?? row.comment ?? "").trim();
|
||
|
||
if (!enumMap.has(currentEnum)) enumMap.set(currentEnum, []);
|
||
enumMap.get(currentEnum)!.push({ name: valueName, value, comment });
|
||
}
|
||
for (const [name, items] of enumMap.entries()) {
|
||
results.push({ name, items });
|
||
}
|
||
} else {
|
||
// 兼容每个 Sheet 一个枚举:使用 Name/Value/Comment
|
||
const items: EnumItem[] = [];
|
||
for (const row of rows) {
|
||
const name = String(row.ValueName ?? row.valueName ?? row.Name ?? row.name ?? "").trim();
|
||
if (!name) continue;
|
||
const valueRaw = row.Value ?? row.value ?? 0;
|
||
const value = Number(valueRaw);
|
||
const comment = String(row.Comment ?? row.comment ?? "").trim();
|
||
items.push({ name, value, comment });
|
||
}
|
||
results.push({ name: sheetName, items });
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
function generateBeansTs(beans: BeanDef[]): string {
|
||
const usedCcTypes = new Set<string>();
|
||
for (const b of beans) {
|
||
for (const f of b.fields) {
|
||
const base = f.type;
|
||
if (CC_COMPLEX_TYPES.has(base)) usedCcTypes.add(base);
|
||
}
|
||
}
|
||
const ccImports = usedCcTypes.size > 0 ? `import { ${Array.from(usedCcTypes).join(", ")} } from 'cc';\n\n` : "";
|
||
const parts: string[] = [];
|
||
parts.push(`${ccImports}import { ConfigParseUtils } from './ConfigParseUtils';`);
|
||
for (const b of beans) {
|
||
parts.push(`\n/** Bean: ${b.name} */`);
|
||
const ifaceLines: string[] = [];
|
||
for (const f of b.fields) {
|
||
const tsType = `${f.type}${f.isArray ? "[]" : ""}`;
|
||
ifaceLines.push(` ${f.name}${f.optional ? "?" : ""}: ${tsType};`);
|
||
}
|
||
parts.push(`export interface ${b.name} {\n${ifaceLines.join("\n")}\n}`);
|
||
// 解析函数
|
||
const parseExprs: string[] = [];
|
||
for (const f of b.fields) {
|
||
const expr = buildParseExpression(f, new Set<string>(), new Set<string>(), true);
|
||
parseExprs.push(` ${f.name}: ${expr},`);
|
||
}
|
||
parts.push(
|
||
`export function parse${b.name}(data: any): ${b.name} {\n return {\n${parseExprs.join("\n")}\n };\n}`,
|
||
);
|
||
}
|
||
return parts.join("\n");
|
||
}
|
||
|
||
function readBeansExcel(absPath: string): BeanDef[] {
|
||
const workbook = xlsx.readFile(absPath);
|
||
const results: BeanDef[] = [];
|
||
for (const sheetName of workbook.SheetNames) {
|
||
const sheet = workbook.Sheets[sheetName];
|
||
const headerRow = xlsx.utils.sheet_to_json(sheet, { header: 1 })[0] as string[];
|
||
const fields: FieldDef[] = [];
|
||
for (const cell of headerRow) {
|
||
const def = parseHeaderCell(String(cell || ""));
|
||
if (def) fields.push(def);
|
||
}
|
||
results.push({ name: sheetName, fields });
|
||
}
|
||
return results;
|
||
}
|
||
|
||
function isPrimitive(t: string): t is PrimitiveType {
|
||
return t === "number" || t === "string" || t === "boolean";
|
||
}
|
||
|
||
function isCcType(t: string): t is CcType {
|
||
return CC_COMPLEX_TYPES.has(t);
|
||
}
|
||
|
||
function buildParseExpression(
|
||
field: FieldDef,
|
||
enumNames: ReadonlySet<string>,
|
||
beanNames: ReadonlySet<string>,
|
||
inBean = false,
|
||
): string {
|
||
const source = inBean ? `data.${field.name}` : `data.${field.name}`;
|
||
const base = field.type;
|
||
|
||
if (!field.isArray) {
|
||
if (isPrimitive(base)) {
|
||
if (base === "number") {
|
||
// 根据原始类型选择解析函数:int -> parseInt,float -> parseFloat
|
||
return field.rawType === "float"
|
||
? `ConfigParseUtils.parseFloat(${source})`
|
||
: `ConfigParseUtils.parseInt(${source})`;
|
||
}
|
||
if (base === "boolean") return `ConfigParseUtils.parseBoolean(${source})`;
|
||
return `ConfigParseUtils.parseString(${source})`;
|
||
}
|
||
if (isCcType(base)) {
|
||
const fn =
|
||
base === "Color"
|
||
? "parseColor"
|
||
: base === "Vec2"
|
||
? "parseVec2"
|
||
: base === "Vec3"
|
||
? "parseVec3"
|
||
: base === "Vec4"
|
||
? "parseVec4"
|
||
: base === "Size"
|
||
? "parseSize"
|
||
: base === "Rect"
|
||
? "parseRect"
|
||
: base === "Quat"
|
||
? "parseQuat"
|
||
: base === "Mat4"
|
||
? "parseMat4"
|
||
: "parseString";
|
||
return `ConfigParseUtils.${fn}(${source})`;
|
||
}
|
||
if (enumNames.has(base)) {
|
||
// 支持 name 或 number,两者均可
|
||
return `(typeof ${source} === 'number' ? ${source} : ((${"Enums"}).${base}[String(${source}) as keyof typeof ${"Enums"}.${base}] ?? 0))`;
|
||
}
|
||
if (beanNames.has(base)) {
|
||
return `parse${base}(${source})`;
|
||
}
|
||
// 未知类型按字符串解析
|
||
return `ConfigParseUtils.parseString(${source})`;
|
||
} else {
|
||
// 数组解析
|
||
if (isPrimitive(base)) {
|
||
if (base === "number") return `ConfigParseUtils.parseNumberArray(${source})`;
|
||
if (base === "string") return `ConfigParseUtils.parseStringArray(${source})`;
|
||
// boolean[]:先按字符串数组再逐项转布尔
|
||
return `ConfigParseUtils.parseStringArray(${source}).map((v) => ConfigParseUtils.parseBoolean(v))`;
|
||
}
|
||
if (isCcType(base)) {
|
||
const fn =
|
||
base === "Color"
|
||
? "parseColor"
|
||
: base === "Vec2"
|
||
? "parseVec2"
|
||
: base === "Vec3"
|
||
? "parseVec3"
|
||
: base === "Vec4"
|
||
? "parseVec4"
|
||
: base === "Size"
|
||
? "parseSize"
|
||
: base === "Rect"
|
||
? "parseRect"
|
||
: base === "Quat"
|
||
? "parseQuat"
|
||
: base === "Mat4"
|
||
? "parseMat4"
|
||
: "parseString";
|
||
// 使用 ';' 分隔元素,每个元素用解析函数处理
|
||
return `String(${source}).split(';').filter(Boolean).map((chunk) => ConfigParseUtils.${fn}(chunk))`;
|
||
}
|
||
if (enumNames.has(base)) {
|
||
return `ConfigParseUtils.parseStringArray(${source}).map((v) => (isNaN(Number(v)) ? ((${"Enums"}).${base}[String(v) as keyof typeof ${"Enums"}.${base}] ?? 0) : Number(v)))`;
|
||
}
|
||
if (beanNames.has(base)) {
|
||
return `String(${source}).split(';').filter(Boolean).map((json) => parse${base}(JSON.parse(json)))`;
|
||
}
|
||
return `ConfigParseUtils.parseStringArray(${source})`;
|
||
}
|
||
}
|
||
|
||
function generateDataJson(table: TableDef, rows: any[]): string {
|
||
return JSON.stringify(rows, null, 2);
|
||
}
|
||
|
||
function generateDataTs(table: TableDef, enumNames: ReadonlySet<string>, beanNames: ReadonlySet<string>): string {
|
||
const usedCcTypes = new Set<string>();
|
||
const usedEnums = new Set<string>();
|
||
const usedBeans = new Set<string>();
|
||
for (const f of table.fields) {
|
||
const base = f.type;
|
||
if (isCcType(base)) usedCcTypes.add(base);
|
||
if (enumNames.has(base)) usedEnums.add(base);
|
||
if (beanNames.has(base)) usedBeans.add(base);
|
||
}
|
||
|
||
const importLines: string[] = [];
|
||
if (usedCcTypes.size > 0) {
|
||
importLines.push(`import { ${Array.from(usedCcTypes).join(", ")} } from "cc";`);
|
||
}
|
||
if (usedEnums.size > 0) {
|
||
importLines.push(`import * as Enums from "./Enums";`);
|
||
}
|
||
if (usedBeans.size > 0) {
|
||
importLines.push(
|
||
`import { ${Array.from(usedBeans)
|
||
.map((b) => `${b}, parse${b}`)
|
||
.join(", ")} } from "./Beans";`,
|
||
);
|
||
}
|
||
importLines.push(`import { ConfigParseUtils } from "./ConfigParseUtils";`);
|
||
const imports = `${importLines.join("\n")}\n\n`;
|
||
|
||
const className = `${table.name}Data`;
|
||
const fieldsPrivate = table.fields
|
||
.map((f) => {
|
||
let value = `\n private _${f.name}: ${f.type}${f.isArray ? "[]" : ""};`;
|
||
value += `\n public get ${f.name}(): ${f.type}${f.isArray ? "[]" : ""} {`;
|
||
value += `\n return this._${f.name};`;
|
||
value += `\n }`;
|
||
return value;
|
||
})
|
||
.join("\n");
|
||
|
||
const getters = table.fields
|
||
.map((f) => {
|
||
const title = f.comment ? `${f.comment}` : f.name;
|
||
const tsType = `${f.type}${f.isArray ? "[]" : ""}`;
|
||
const wrap = isCcType(f.type)
|
||
? `ConfigParseUtils.createReadonlyProxy(ConfigParseUtils.deepFreeze(this._${f.name}), '${title}')`
|
||
: `this._${f.name}`;
|
||
return `\n\n /** ${title} */\n public get ${f.name}(): ${tsType} {\n return ${wrap};\n }`;
|
||
})
|
||
.join("\n");
|
||
|
||
const ctorParams = table.fields.map((f) => `${f.name}: ${f.type}${f.isArray ? "[]" : ""}`).join(",\n ");
|
||
const ctorAssigns = table.fields.map((f) => `\n this._${f.name} = ${f.name};`).join("");
|
||
|
||
const parseArgs = table.fields.map((f) => `\n ${buildParseExpression(f, enumNames, beanNames)}`).join(",\n");
|
||
|
||
const file = `${imports}/**\n * ${
|
||
table.name
|
||
}数据结构\n */\nexport class ${className} {\n${fieldsPrivate}\n\n public constructor(\n${
|
||
" " + ctorParams
|
||
}\n ) {${ctorAssigns}\n }\n}\n\n/**\n * 解析配置数据\n */\nexport function parse${
|
||
table.name
|
||
}Data(data: any): ${className} {\n return new ${className}(${parseArgs}\n );\n}\n`;
|
||
return file;
|
||
}
|
||
|
||
function generateManagerTs(table: TableDef): string {
|
||
const name = table.name;
|
||
const uniqueFields = table.fields.filter(
|
||
(f) => f.unique && !f.isArray && (isPrimitive(f.type) || isCcType(f.type) === false),
|
||
);
|
||
const idxDecls = uniqueFields
|
||
.map((f) => ` private by_${f.name} = new Map<${f.type}, ${name}Data>();`)
|
||
.join("\n");
|
||
const idxAssigns = uniqueFields
|
||
.map((f) => ` this.by_${f.name}.set((config as any).${f.name}, config);`)
|
||
.join("\n");
|
||
const idxGetters = uniqueFields
|
||
.map(
|
||
(f) =>
|
||
`\n public getBy${f.name.charAt(0).toUpperCase() + f.name.slice(1)}(key: ${
|
||
f.type
|
||
}): ${name}Data | null {\n if (!this.isLoaded) {\n LogUtils.warn("${name}Manager", "${name} 配置尚未加载完成,请等待加载完成");\n return null;\n }\n return this.by_${
|
||
f.name
|
||
}.get(key) || null;\n }\n`,
|
||
)
|
||
.join("");
|
||
|
||
return `import { JsonAsset } from "cc";\n\nimport { singleton, Singleton } from "@max-studio/core/Singleton";\nimport LogUtils from "@max-studio/core/utils/LogUtils";\n\nimport { ConfigParseUtils } from "./ConfigParseUtils";\nimport { ${name}Data, parse${name}Data } from "./${name}Data";\nimport ResManager from "@max-studio/core/res/ResManager";\n\n/**\n * ${name}配置管理器\n *\n * ⚠️ 此文件由配置表生成器自动生成,请勿手动修改!\n * 如需修改,请编辑对应的Excel配置文件,然后重新生成\n */\n@singleton({ auto: true })\nexport class ${name}Manager extends Singleton {\n private configList: readonly ${name}Data[] = [];\n private configMap = new Map<number, ${name}Data>();\n ${idxDecls}\n private isLoaded = false;\n\n protected async onInit(): Promise<void> {\n await this.loadConfig();\n }\n\n private async loadConfig(): Promise<void> {\n try {\n const {err, asset} = await ResManager.getInstance().loadAsset<JsonAsset>({\n path: "generated/data/${name}",\n type: JsonAsset,\n bundle: "configs",\n });\n this.parseConfig(<any>asset.json);\n this.isLoaded = true;\n } catch (err) {\n LogUtils.error("${name}Manager", "加载 ${name} 配置失败:", err);\n }\n }\n\n private parseConfig(data: any[]): void {\n this.configList = Object.freeze(\n data.map((item) => {\n const config = parse${name}Data(item);\n const frozenConfig = ConfigParseUtils.deepFreeze(config);\n return ConfigParseUtils.createReadonlyProxy(frozenConfig, "${name}Data配置");\n }),\n );\n this.configMap.clear();\n ${
|
||
uniqueFields.length > 0 ? uniqueFields.map((f) => `this.by_${f.name}.clear();`).join(" ") : ""
|
||
}\n for (const config of this.configList) {\n this.configMap.set((config as any).id, config);\n${
|
||
idxAssigns ? ` ${idxAssigns}\n` : ""
|
||
} }\n ConfigParseUtils.deepFreeze(this.configMap);\n }\n\n public getConfig(id: number): ${name}Data | null {\n if (!this.isLoaded) {\n LogUtils.warn("${name}Manager", "${name} 配置尚未加载完成,请等待加载完成");\n return null;\n }\n return this.configMap.get(id) || null;\n }\n\n public getAllConfigs(): ${name}Data[] {\n if (!this.isLoaded) {\n LogUtils.warn("${name}Manager", "${name} 配置尚未加载完成,请等待加载完成");\n return [];\n }\n return [...this.configList];\n }\n\n public isConfigLoaded(): boolean {\n return this.isLoaded;\n }\n ${idxGetters}
|
||
}
|
||
`;
|
||
}
|
||
|
||
function validateUnique(table: TableDef, rows: any[]): void {
|
||
for (const f of table.fields) {
|
||
if (!f.unique) continue;
|
||
const seen = new Set<string>();
|
||
let dupCount = 0;
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const v = String(rows[i][f.name] ?? "");
|
||
if (!v) continue;
|
||
if (seen.has(v)) {
|
||
dupCount++;
|
||
log(`⚠️ [唯一约束] ${table.name}.${f.name} 在第 ${i + 2} 行出现重复值: ${v}`);
|
||
} else {
|
||
seen.add(v);
|
||
}
|
||
}
|
||
if (dupCount === 0) {
|
||
log(`✅ [唯一约束] ${table.name}.${f.name} 校验通过`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateRefsAll(tables: { table: TableDef; rows: any[] }[]): void {
|
||
const idMap = new Map<string, Set<string>>();
|
||
for (const { table, rows } of tables) {
|
||
const ids = new Set<string>();
|
||
for (const r of rows) {
|
||
const id = String(r.id ?? "");
|
||
if (id) ids.add(id);
|
||
}
|
||
idMap.set(table.name, ids);
|
||
}
|
||
for (const { table, rows } of tables) {
|
||
for (const f of table.fields) {
|
||
if (!f.refTarget) continue;
|
||
const targetIds = idMap.get(f.refTarget);
|
||
if (!targetIds) {
|
||
log(`⚠️ [引用校验] ${table.name}.${f.name} 指向的表 ${f.refTarget} 未在本次生成中发现`);
|
||
continue;
|
||
}
|
||
for (let i = 0; i < rows.length; i++) {
|
||
const v = String(rows[i][f.name] ?? "");
|
||
if (!v) continue;
|
||
if (!targetIds.has(v)) {
|
||
log(`⚠️ [引用校验] ${table.name}.${f.name} 第 ${i + 2} 行引用不存在的 ${f.refTarget}.id=${v}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function genForExcel(file: string, enumNames: ReadonlySet<string>, beanNames: ReadonlySet<string>) {
|
||
const absPath = path.join(EXCEL_DIR, file);
|
||
log("处理 Excel:", absPath);
|
||
const { table, rows } = readWorkbookTables(absPath);
|
||
|
||
validateUnique(table, rows);
|
||
|
||
const jsonOut = path.join(OUTPUT_DATA_DIR, `${table.name}.json`);
|
||
writeFileSafe(jsonOut, generateDataJson(table, rows));
|
||
log("生成 JSON:", jsonOut);
|
||
|
||
const dataTsOut = path.join(OUTPUT_CORE_DIR, `${table.name}Data.ts`);
|
||
writeFileSafe(dataTsOut, generateDataTs(table, enumNames, beanNames));
|
||
log("生成 TS 数据类:", dataTsOut);
|
||
|
||
const mgrTsOut = path.join(OUTPUT_CORE_DIR, `${table.name}Manager.ts`);
|
||
if (!existsSync(mgrTsOut)) {
|
||
writeFileSafe(mgrTsOut, generateManagerTs(table));
|
||
log("生成 TS 管理器:", mgrTsOut);
|
||
} else {
|
||
log("检测到已有管理器,跳过生成:", mgrTsOut);
|
||
}
|
||
}
|
||
|
||
function main() {
|
||
try {
|
||
if (!existsSync(EXCEL_DIR)) {
|
||
throw new Error(`未找到 Excel 目录: ${EXCEL_DIR}`);
|
||
}
|
||
const files = fs.readdirSync(EXCEL_DIR).filter((f) => f.endsWith(".xlsx"));
|
||
if (files.length === 0) {
|
||
log("excels 目录下没有 .xlsx 文件,无需生成");
|
||
return;
|
||
}
|
||
|
||
const enumsFile = files.find((f) => f === "__enums__.xlsx");
|
||
const beansFile = files.find((f) => f === "__beans__.xlsx");
|
||
const tableFiles = files.filter((f) => f !== "__enums__.xlsx" && f !== "__beans__.xlsx");
|
||
|
||
// 1) 生成枚举
|
||
const enumDefs = enumsFile ? readEnumsExcel(path.join(EXCEL_DIR, enumsFile)) : [];
|
||
const enumNames = new Set<string>(enumDefs.map((e) => e.name));
|
||
if (enumDefs.length > 0) {
|
||
const enumsOut = path.join(OUTPUT_CORE_DIR, "Enums.ts");
|
||
writeFileSafe(enumsOut, generateEnumsTs(enumDefs));
|
||
log("生成枚举:", enumsOut);
|
||
}
|
||
|
||
// 2) 生成 Beans
|
||
const beanDefs = beansFile ? readBeansExcel(path.join(EXCEL_DIR, beansFile)) : [];
|
||
const beanNames = new Set<string>(beanDefs.map((b) => b.name));
|
||
if (beanDefs.length > 0) {
|
||
const beansOut = path.join(OUTPUT_CORE_DIR, "Beans.ts");
|
||
writeFileSafe(beansOut, generateBeansTs(beanDefs));
|
||
log("生成 Beans:", beansOut);
|
||
}
|
||
|
||
// 3) 读取所有表用于跨表引用校验
|
||
const parsedTables: { table: TableDef; rows: any[] }[] = [];
|
||
for (const f of tableFiles) {
|
||
const absPath = path.join(EXCEL_DIR, f);
|
||
const { table, rows } = readWorkbookTables(absPath);
|
||
parsedTables.push({ table, rows });
|
||
}
|
||
validateRefsAll(parsedTables);
|
||
|
||
// 4) 为每个表生成产物
|
||
for (const f of tableFiles) {
|
||
genForExcel(f, enumNames, beanNames);
|
||
}
|
||
|
||
log("配置生成完成 ✅");
|
||
} catch (err) {
|
||
|
||
console.error("[gen-configs] 生成失败:", err);
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
main();
|