fix: 更新提交
This commit is contained in:
661
extensions/max-studio/scripts/generate-configs.ts
Normal file
661
extensions/max-studio/scripts/generate-configs.ts
Normal file
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* 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();
|
||||
Reference in New Issue
Block a user