You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
896 lines
31 KiB
896 lines
31 KiB
// 由于Cocos Creator插件环境特殊,使用动态require方式
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import * as XLSX from 'xlsx';
|
|
import * as chokidar from 'chokidar'; // 补充导入chokidar模块
|
|
|
|
|
|
// 定义类型以提高代码可读性
|
|
interface PluginConfig {
|
|
excelPath: string;
|
|
jsonExportPath: string;
|
|
tsExportPath: string;
|
|
autoExport: boolean;
|
|
watcher: any;
|
|
}
|
|
|
|
class ExcelToJsonPlugin {
|
|
private config: PluginConfig = {
|
|
excelPath: './配置表/excelList',
|
|
jsonExportPath: './assets/bundles/first/config',
|
|
tsExportPath: './assets/script/interface',
|
|
autoExport: true,
|
|
watcher: null
|
|
};
|
|
|
|
private fileWatcher: fs.FSWatcher | null = null;
|
|
|
|
async load() {
|
|
|
|
// 先执行一次导出
|
|
await this.exportAllExcel();
|
|
|
|
// 设置文件监听
|
|
this.setupFileWatcher();
|
|
|
|
|
|
// // 注册消息处理
|
|
// Editor.Message.addBroadcastListener('excel-to-json-ts:profile-updated', () => {
|
|
// this.loadConfig();
|
|
|
|
// // 如果配置了自动导出,重新设置监听
|
|
// if (this.config.autoExport) {
|
|
// this.stopFileWatcher();
|
|
// this.setupFileWatcher();
|
|
// this.exportAllExcel(); // 配置保存后立即导出一次
|
|
// } else {
|
|
// this.stopFileWatcher();
|
|
// }
|
|
// });
|
|
}
|
|
|
|
unload() {
|
|
this.log('[Excel插件] 插件已卸载');
|
|
this.stopFileWatcher();
|
|
}
|
|
|
|
// 加载配置
|
|
async loadConfig() {
|
|
try {
|
|
let conf = await Editor.Profile.getProject('excel-to-json-ts');
|
|
if (conf)
|
|
this.config = conf;
|
|
else {
|
|
Editor.Profile.setProject('excel-to-json-ts', "excelPath", this.config.excelPath);
|
|
Editor.Profile.setProject('excel-to-json-ts', "jsonExportPath", this.config.jsonExportPath);
|
|
Editor.Profile.setProject('excel-to-json-ts', "tsExportPath", this.config.tsExportPath);
|
|
this.warn(`[Excel插件] 路径配置不正确`);
|
|
}
|
|
this.log('[Excel插件] 配置已加载');
|
|
this.log('[Excel插件] excelPath' + this.config.excelPath);
|
|
this.log('[Excel插件] jsonPath' + this.config.jsonExportPath);
|
|
this.log('[Excel插件] tsPath' + this.config.tsExportPath);
|
|
|
|
} catch (err) {
|
|
this.error(`[Excel插件] 加载配置失败: ${err}`);
|
|
}
|
|
}
|
|
|
|
// 手动导出
|
|
exportManual() {
|
|
this.exportAllExcel();
|
|
}
|
|
|
|
// 导出所有Excel(增强健壮性)
|
|
async exportAllExcel() {
|
|
await this.loadConfig();
|
|
|
|
if (!this.config.excelPath) {
|
|
this.error('[Excel插件] Excel目录路径未配置');
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(this.config.excelPath)) {
|
|
this.error(`[Excel插件] Excel目录不存在: ${this.config.excelPath}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 确保导出目录存在
|
|
if (this.config.jsonExportPath && !fs.existsSync(this.config.jsonExportPath)) {
|
|
fs.mkdirSync(this.config.jsonExportPath, { recursive: true });
|
|
}
|
|
if (this.config.tsExportPath && !fs.existsSync(this.config.tsExportPath)) {
|
|
fs.mkdirSync(this.config.tsExportPath, { recursive: true });
|
|
}
|
|
|
|
// 获取目录中所有Excel文件
|
|
const excelFiles = this.getExcelFilesInDirectory(this.config.excelPath);
|
|
|
|
if (excelFiles.length === 0) {
|
|
this.warn(`[Excel插件] 目录中没有找到Excel文件: ${this.config.excelPath}`);
|
|
return;
|
|
}
|
|
|
|
this.log(`[Excel插件] 找到 ${excelFiles.length} 个Excel文件`);
|
|
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
|
|
// 处理每个Excel文件
|
|
for (const excelFile of excelFiles) {
|
|
try {
|
|
this.processExcelFile(excelFile);
|
|
successCount++;
|
|
} catch (err) {
|
|
this.error(`[Excel插件] 处理文件失败 ${path.basename(excelFile)}: ${err}`);
|
|
errorCount++;
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
this.refreshAssetDatabase();
|
|
this.success(`[Excel插件] 导出完成,成功处理 ${successCount} 个文件,失败 ${errorCount} 个`);
|
|
|
|
} else {
|
|
this.error(`[Excel插件] 导出失败,没有成功处理任何文件`);
|
|
}
|
|
} catch (err) {
|
|
this.error(`[Excel插件] 导出失败: ${err}`);
|
|
}
|
|
}
|
|
|
|
|
|
// 获取目录中的所有Excel文件
|
|
getExcelFilesInDirectory(dirPath: string): string[] {
|
|
return fs.readdirSync(dirPath)
|
|
.filter(file => {
|
|
// 过滤隐藏文件(以点开头的文件)
|
|
if (file.startsWith('.') || file.startsWith('~')) {
|
|
return false;
|
|
}
|
|
const ext = path.extname(file).toLowerCase();
|
|
return (ext === '.xlsx' || ext === '.xls') &&
|
|
fs.statSync(path.join(dirPath, file)).isFile();
|
|
})
|
|
.map(file => path.join(dirPath, file));
|
|
}
|
|
|
|
// 处理单个Excel文件
|
|
processExcelFile(filePath: string) {
|
|
try {
|
|
// 安全读取打开状态的Excel文件
|
|
const workbook = this.readExcelSafely(filePath);
|
|
const sheetNames = workbook.SheetNames;
|
|
|
|
// 导出每个工作表
|
|
for (const sheetName of sheetNames) {
|
|
if (sheetName.match("Sheet") || !sheetName.match("|")) {
|
|
continue;
|
|
}
|
|
this.exportSheet(workbook, sheetName, filePath);
|
|
}
|
|
} catch (err) {
|
|
throw new Error(`处理文件 ${path.basename(filePath)} 失败: ${err}`);
|
|
}
|
|
}
|
|
|
|
// 安全读取打开状态的Excel文件(终极版)
|
|
readExcelSafely(filePath: string): any {
|
|
const tempPath = path.join(os.tmpdir(), `excel-temp-${Date.now()}-${path.basename(filePath)}`);
|
|
|
|
try {
|
|
// 详细记录文件状态
|
|
const stats = fs.statSync(filePath);
|
|
// this.log(`[Excel插件] 尝试读取文件 - 大小: ${stats.size} bytes, 最后修改: ${new Date(stats.mtimeMs).toLocaleTimeString()}`);
|
|
|
|
// 尝试复制文件,带详细错误处理
|
|
try {
|
|
fs.copyFileSync(filePath, tempPath);
|
|
} catch (copyErr) {
|
|
this.warn(`[Excel插件] 第一次复制失败: ${copyErr}`);
|
|
|
|
// 再次尝试,有时第一次会失败
|
|
try {
|
|
fs.copyFileSync(filePath, tempPath);
|
|
} catch (finalErr) {
|
|
this.error(`[Excel插件] 两次复制尝试均失败: ${finalErr}`);
|
|
throw finalErr;
|
|
}
|
|
}
|
|
|
|
// 验证临时文件
|
|
const tempStats = fs.statSync(tempPath);
|
|
if (tempStats.size === 0) {
|
|
throw new Error("临时文件大小为0,复制可能未完成");
|
|
}
|
|
|
|
return XLSX.readFile(tempPath);
|
|
} catch (err: any) {
|
|
throw new Error(`读取Excel失败: ${err.message || err}`);
|
|
} finally {
|
|
// 确保清理临时文件
|
|
try {
|
|
if (fs.existsSync(tempPath)) {
|
|
fs.unlinkSync(tempPath);
|
|
}
|
|
} catch (e) {
|
|
this.warn(`[Excel插件] 无法删除临时文件: ${tempPath} - ${e}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 导出单个工作表
|
|
exportSheet(workbook: any, sheetName: string, filePath: string) {
|
|
try {
|
|
const worksheet = workbook.Sheets[sheetName];
|
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
|
|
|
// 检查是否有足够的表头行(至少3行:字段名、类型、说明)
|
|
if (jsonData.length < 3) {
|
|
this.warn(`[Excel插件] 工作表 "${sheetName}" 数据不足,跳过导出 (文件: ${path.basename(filePath)})的${sheetName}`);
|
|
return;
|
|
}
|
|
|
|
if (!sheetName.match("|") || sheetName.split("|").length < 2) {
|
|
this.warn(`[Excel插件] 工作表 "${sheetName}" 名称格式错误,跳过导出 (文件: ${path.basename(filePath)})的${sheetName}`);
|
|
return;
|
|
}
|
|
|
|
const sName = sheetName.split("|")[1];
|
|
|
|
// 提取表头信息
|
|
const headers = {
|
|
fields: jsonData[0],
|
|
types: jsonData[1],
|
|
descriptions: jsonData[2]
|
|
};
|
|
|
|
// 从第4行开始是数据
|
|
const dataRows = jsonData.slice(3) as any[][];
|
|
|
|
// 生成JSON数据
|
|
const jsonResult = this.generateJsonData(dataRows, headers);
|
|
|
|
// 生成TS接口
|
|
const tsInterface = this.generateTSInterface(sName, headers);
|
|
|
|
// 生成文件名
|
|
const safeSheetName = this.makeSafeFileName(sName);
|
|
|
|
const jsonFileName = `${safeSheetName}.json`;
|
|
//const tsFileName = `ITable${safeSheetName}.ts`;
|
|
const tsFileName = `AutoTable.ts`;
|
|
|
|
// 写入JSON文件
|
|
if (this.config.jsonExportPath) {
|
|
const jsonPath = path.join(this.config.jsonExportPath, jsonFileName);
|
|
fs.writeFileSync(jsonPath, JSON.stringify(jsonResult, null, 4), 'utf-8');
|
|
}
|
|
|
|
// 写入TS文件
|
|
if (this.config.tsExportPath) {
|
|
const tsPath = path.join(this.config.tsExportPath, tsFileName);
|
|
let tsContent = "";
|
|
try {
|
|
if (fs.existsSync(tsPath)) {
|
|
tsContent = fs.readFileSync(tsPath, 'utf-8');
|
|
}
|
|
} catch (err) {
|
|
this.warn(`[Excel插件] 读取TS文件失败: ${err}`);
|
|
}
|
|
if (tsContent == "") {
|
|
tsContent = "declare global {\n\n}\nexport { };";
|
|
}
|
|
if (this.extractFullInterfaceBlock(tsContent, "ITable" + sName)) {
|
|
//console.log("替换接口:" + sName + ",内容:" + tsInterface);
|
|
tsContent = this.replaceInterfaceBlock(tsContent, "ITable" + sName, tsInterface);
|
|
} else {
|
|
//console.log("加到末尾:" + tsInterface);
|
|
tsContent = tsContent.replace(/}\nexport { };$/, tsInterface + "}\nexport { };");
|
|
}
|
|
fs.writeFileSync(tsPath, tsContent, 'utf-8');
|
|
}
|
|
} catch (err) {
|
|
throw new Error(`工作表 "${sheetName}" 导出失败 (文件: ${path.basename(filePath)}): ${err}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 转义正则特殊字符
|
|
*/
|
|
escapeRegExp(str: string): string {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* 精准查找接口声明行(全字匹配)
|
|
*/
|
|
findInterfaceDeclarationLine(fileContent: string, interfaceName: string): {
|
|
lineStart: number;
|
|
lineEnd: number;
|
|
lineContent: string;
|
|
} | null {
|
|
// 使用单词边界 \b 确保全字匹配
|
|
const regex = new RegExp(
|
|
`\\binterface\\s+${this.escapeRegExp(interfaceName)}\\b(?:\\s*<[^>]*>)?\\s*[\\{\\n]`,
|
|
'g'
|
|
);
|
|
|
|
let match;
|
|
while ((match = regex.exec(fileContent)) !== null) {
|
|
// 找到匹配位置,确定完整行
|
|
const matchStart = match.index;
|
|
|
|
// 向前找到行开始
|
|
const lineStart = fileContent.lastIndexOf('\n', matchStart - 1) + 1;
|
|
|
|
// 向后找到行结束
|
|
const lineEnd = fileContent.indexOf('\n', matchStart);
|
|
const actualLineEnd = lineEnd === -1 ? fileContent.length : lineEnd;
|
|
|
|
const lineContent = fileContent.substring(lineStart, actualLineEnd);
|
|
|
|
// 验证:确保这一行确实是我们的目标接口
|
|
if (lineContent.trim().startsWith(`interface ${interfaceName}`)) {
|
|
return {
|
|
lineStart,
|
|
lineEnd: actualLineEnd,
|
|
lineContent
|
|
};
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 提取完整的接口块(包括上方注释)
|
|
*/
|
|
extractFullInterfaceBlock(fileContent: string, interfaceName: string): {
|
|
start: number;
|
|
end: number;
|
|
content: string;
|
|
} | null {
|
|
const interfaceLine = this.findInterfaceDeclarationLine(fileContent, interfaceName);
|
|
if (!interfaceLine) {
|
|
return null;
|
|
}
|
|
|
|
// === 向前查找连续的注释块 ===
|
|
let blockStart = interfaceLine.lineStart;
|
|
let currentPos = interfaceLine.lineStart;
|
|
|
|
while (currentPos > 0) {
|
|
const prevLineEnd = currentPos - 1;
|
|
const prevLineStart = fileContent.lastIndexOf('\n', prevLineEnd - 1) + 1;
|
|
const prevLine = fileContent.substring(prevLineStart, prevLineEnd).trim();
|
|
|
|
// 检查是否为注释行或空行
|
|
if (prevLine === '' ||
|
|
prevLine.startsWith('//') ||
|
|
prevLine.startsWith('/*') ||
|
|
prevLine.startsWith('*')) {
|
|
currentPos = prevLineStart;
|
|
} else {
|
|
blockStart = currentPos;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (currentPos === 0) {
|
|
blockStart = 0;
|
|
}
|
|
|
|
// === 向后查找接口体结束 ===
|
|
// 找到 interface 行中的开括号位置
|
|
const openBraceIndex = interfaceLine.lineContent.indexOf('{');
|
|
let startPos = interfaceLine.lineStart;
|
|
let braceCount = 0;
|
|
|
|
if (openBraceIndex !== -1) {
|
|
// 开括号在同一行
|
|
startPos = interfaceLine.lineStart + openBraceIndex;
|
|
braceCount = 1;
|
|
} else {
|
|
// 开括号在下一行,找到第一个 {
|
|
const nextOpenBrace = fileContent.indexOf('{', interfaceLine.lineEnd);
|
|
if (nextOpenBrace === -1) return null;
|
|
startPos = nextOpenBrace;
|
|
braceCount = 1;
|
|
}
|
|
|
|
let pos = startPos + 1; // 跳过 {
|
|
|
|
while (pos < fileContent.length && braceCount > 0) {
|
|
if (fileContent[pos] === '{') {
|
|
braceCount++;
|
|
} else if (fileContent[pos] === '}') {
|
|
braceCount--;
|
|
}
|
|
pos++;
|
|
}
|
|
|
|
if (braceCount !== 0) {
|
|
return null; // 括号不匹配
|
|
}
|
|
|
|
const blockEnd = pos;
|
|
const fullContent = fileContent.substring(blockStart, blockEnd);
|
|
|
|
return {
|
|
start: blockStart,
|
|
end: blockEnd,
|
|
content: fullContent
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 完全替换接口块(全字匹配,精准替换)
|
|
*/
|
|
replaceInterfaceBlock(
|
|
fileContent: string,
|
|
interfaceName: string,
|
|
newInterfaceBlock: string
|
|
): string {
|
|
const blockInfo = this.extractFullInterfaceBlock(fileContent, interfaceName);
|
|
|
|
if (!blockInfo) {
|
|
throw new Error(`❌ 接口 "${interfaceName}" 未找到!请检查名称是否正确。`);
|
|
}
|
|
|
|
// 验证:确保提取的块确实只包含目标接口
|
|
const interfaceLines = blockInfo.content.split('\n');
|
|
const interfaceDeclLines = interfaceLines.filter(line =>
|
|
line.trim().startsWith('interface ')
|
|
);
|
|
|
|
if (interfaceDeclLines.length !== 1) {
|
|
throw new Error(`❌ 找到多个 interface 声明,可能匹配错误!`);
|
|
}
|
|
|
|
if (!interfaceDeclLines[0].includes(`interface ${interfaceName}`)) {
|
|
throw new Error(`❌ 匹配到错误的接口名称!`);
|
|
}
|
|
|
|
//console.log(`✅ 精准匹配到接口 "${interfaceName}"`);
|
|
|
|
return (
|
|
fileContent.substring(0, blockInfo.start) +
|
|
newInterfaceBlock +
|
|
fileContent.substring(blockInfo.end)
|
|
);
|
|
}
|
|
|
|
// 设置文件监听(终极版)
|
|
setupFileWatcher() {
|
|
this.stopFileWatcher();
|
|
|
|
if (!this.config.excelPath) return;
|
|
|
|
if (!fs.existsSync(this.config.excelPath)) {
|
|
this.error(`[Excel插件] 监听目录不存在: ${this.config.excelPath}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// 使用更可靠的监听方式
|
|
this.fileWatcher = fs.watch(this.config.excelPath, {
|
|
recursive: false,
|
|
encoding: 'utf8'
|
|
}, async (eventType, filename) => {
|
|
if (!filename) return;
|
|
this.loadConfig();
|
|
if (this.config.autoExport == false) return;
|
|
|
|
// 确保是字符串
|
|
const fileNameStr = typeof filename === 'string' ? filename : filename + "";
|
|
const ext = path.extname(fileNameStr).toLowerCase();
|
|
|
|
if (ext === '.xlsx' || ext === '.xls') {
|
|
const filePath = path.join(this.config.excelPath, fileNameStr);
|
|
|
|
// 额外验证:确保是文件且存在
|
|
try {
|
|
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
this.log(`[Excel插件] 检测到文件变更: ${fileNameStr} (${eventType})`);
|
|
|
|
// 使用终极重试机制
|
|
// 重试次数增加到8,基础延迟1秒
|
|
this.processExcelFileWithRetry(filePath, 8, 1000);
|
|
}
|
|
} catch (err) {
|
|
this.warn(`[Excel插件] 验证文件状态时出错: ${err}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
// 处理监听错误
|
|
if (this.fileWatcher && typeof this.fileWatcher.on === 'function') {
|
|
this.fileWatcher.on('error', (error: Error) => {
|
|
this.error(`[Excel插件] 文件监听错误: ${error}`);
|
|
|
|
// 尝试重新设置监听
|
|
setTimeout(() => {
|
|
this.stopFileWatcher();
|
|
this.setupFileWatcher();
|
|
}, 15000);
|
|
});
|
|
}
|
|
|
|
this.log('[Excel插件] 已启动Excel目录监听(终极版)');
|
|
} catch (err) {
|
|
this.error(`[Excel插件] 目录监听失败: ${err}`);
|
|
|
|
// 尝试重新设置监听
|
|
setTimeout(() => {
|
|
this.stopFileWatcher();
|
|
this.setupFileWatcher();
|
|
}, 20000);
|
|
}
|
|
}
|
|
|
|
// 带终极重试机制处理Excel文件
|
|
processExcelFileWithRetry(filePath: string, maxRetries: number = 8, baseDelay: number = 1000) {
|
|
let attempt = 0;
|
|
const startTime = Date.now();
|
|
|
|
const tryProcess = () => {
|
|
attempt++;
|
|
const elapsed = Date.now() - startTime;
|
|
const delay = baseDelay * Math.min(1.5 ** (attempt - 1), 4); // 指数退避,但上限为4倍基础延迟
|
|
|
|
this.log(`[Excel插件] 处理文件 "${path.basename(filePath)}" - 尝试 ${attempt}/${maxRetries} (延迟 ${Math.round(delay)}ms, 已耗时 ${Math.round(elapsed / 1000)}s)`);
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
// 额外检查文件是否可读
|
|
const stats = fs.statSync(filePath);
|
|
if (stats.size === 0) {
|
|
throw new Error("文件大小为0,可能仍在写入");
|
|
}
|
|
|
|
// 检查文件最后修改时间,确保不是刚被修改
|
|
const now = Date.now();
|
|
const fileAge = now - stats.mtimeMs;
|
|
if (fileAge < 2000) { // 文件修改时间少于2秒,可能还在写入
|
|
throw new Error(`文件刚被修改 (${Math.round(fileAge)}ms前),可能仍在写入`);
|
|
}
|
|
|
|
this.processExcelFile(filePath);
|
|
const totalTime = Date.now() - startTime;
|
|
this.refreshAssetDatabase();
|
|
this.success(`[Excel插件] 文件 "${path.basename(filePath)}" 自动导出成功 (总耗时 ${Math.round(totalTime / 1000)}s, 尝试 ${attempt}/${maxRetries})`);
|
|
} catch (err: any) {
|
|
|
|
// 详细记录错误
|
|
this.warn(`[Excel插件] 尝试 ${attempt} 失败: ${err.message || err}`);
|
|
|
|
// 检查是否是文件锁定或权限错误
|
|
const isFileLockError = err.message && (
|
|
err.message.includes('EPERM') ||
|
|
err.message.includes('EBUSY') ||
|
|
err.message.includes('permission') ||
|
|
err.message.includes('locked') ||
|
|
err.message.includes('EACCES') ||
|
|
err.message.includes('file size 0') ||
|
|
err.message.includes('刚被修改')
|
|
);
|
|
|
|
if (isFileLockError && attempt < maxRetries) {
|
|
// 计算下一次尝试的延迟(指数退避)
|
|
const nextDelay = baseDelay * Math.min(1.5 ** attempt, 4);
|
|
this.warn(`[Excel插件] 文件 "${path.basename(filePath)}" 可能仍在保存中,将在 ${Math.round(nextDelay)}ms 后重试 (尝试 ${attempt + 1}/${maxRetries})`);
|
|
setTimeout(tryProcess, nextDelay);
|
|
} else {
|
|
const totalTime = Date.now() - startTime;
|
|
this.error(`[Excel插件] 文件 "${path.basename(filePath)}" 自动导出失败 (总耗时 ${Math.round(totalTime / 1000)}s): ${err}`);
|
|
|
|
// 如果是文件锁定错误但已达到最大重试次数,提供具体建议
|
|
if (isFileLockError) {
|
|
this.error(`[Excel插件] 建议: 确保Excel完全保存后再进行修改,或增加重试次数和延迟`);
|
|
}
|
|
}
|
|
}
|
|
}, delay);
|
|
};
|
|
|
|
// 开始第一次尝试
|
|
tryProcess();
|
|
}
|
|
|
|
// 停止文件监听(更新)
|
|
stopFileWatcher() {
|
|
if (this.fileWatcher) {
|
|
try {
|
|
// 使用类型断言确保调用close方法
|
|
(this.fileWatcher as any).close();
|
|
this.fileWatcher = null;
|
|
} catch (e) {
|
|
this.warn(`[Excel插件] 停止监听时出错: ${e}`);
|
|
}
|
|
|
|
if (this.config.watcher) {
|
|
clearTimeout(this.config.watcher);
|
|
this.config.watcher = null;
|
|
}
|
|
this.log('[Excel插件] 已停止Excel文件监听');
|
|
}
|
|
}
|
|
|
|
// 生成JSON数据
|
|
generateJsonData(dataRows: any[][], headers: any): any[] {
|
|
const result = [];
|
|
|
|
for (const row of dataRows) {
|
|
const obj: any = {};
|
|
|
|
for (let i = 0; i < headers.fields.length; i++) {
|
|
const field = headers.fields[i];
|
|
const type = headers.types[i];
|
|
|
|
if (field) {
|
|
// 处理空单元格,使用对应类型的默认值
|
|
const value = row[i] !== undefined ? row[i] : this.getDefaultValue(type);
|
|
obj[field] = this.convertValue(value, type);
|
|
}
|
|
}
|
|
|
|
result.push(obj);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// 根据类型获取默认值
|
|
getDefaultValue(type: string): any {
|
|
if (!type) return null;
|
|
|
|
const cleanType = type.toLowerCase().trim();
|
|
switch (cleanType) {
|
|
case 'int':
|
|
case 'float':
|
|
return 0;
|
|
case 'boolean':
|
|
return false;
|
|
case 'int[]':
|
|
case 'float[]':
|
|
case 'string[]':
|
|
case 'any[]':
|
|
return [];
|
|
case 'string':
|
|
return '';
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
|
|
// 类型转换
|
|
convertValue(value: any, type: string): any {
|
|
if (value === null || value === undefined) return null;
|
|
|
|
const cleanType = type.toLowerCase().trim();
|
|
|
|
try {
|
|
switch (cleanType) {
|
|
case 'int':
|
|
return parseInt(value);
|
|
case 'float':
|
|
return parseFloat(value);
|
|
case 'json':
|
|
return JSON.parse(value);
|
|
case 'boolean':
|
|
return value.toString().toLowerCase() === 'true' || value === 1;
|
|
case 'int[]':
|
|
return this.parseArray(value, 'int');
|
|
case 'float[]':
|
|
return this.parseArray(value, 'float');
|
|
case 'string[]':
|
|
return this.parseArray(value, 'string');
|
|
case 'any[]':
|
|
return this.parseArray(value, 'any');
|
|
default:
|
|
return value;
|
|
}
|
|
} catch (e) {
|
|
this.warn(`[Excel插件] 类型转换失败 [${type}]: ${value} - ${e}`);
|
|
return value;
|
|
}
|
|
}
|
|
|
|
// 解析数组
|
|
parseArray(value: any, targetType: string): any[] {
|
|
if (Array.isArray(value)) return value;
|
|
|
|
// 尝试分割字符串
|
|
let items = [];
|
|
if (typeof value === 'string') {
|
|
items = value.split(/[,;#\s]\s*/).filter(v => v);
|
|
} else {
|
|
items = [value];
|
|
}
|
|
|
|
return items.map(item => {
|
|
if (targetType === 'any') {
|
|
// 自动识别类型
|
|
if (!isNaN(item)) {
|
|
return !isNaN(parseFloat(item)) ? parseFloat(item) : parseInt(item);
|
|
}
|
|
if (item.toLowerCase() === 'true' || item.toLowerCase() === 'false') {
|
|
return item.toLowerCase() === 'true';
|
|
}
|
|
return item;
|
|
}
|
|
|
|
// 指定类型转换
|
|
switch (targetType) {
|
|
case 'int': return parseInt(item);
|
|
case 'float': return parseFloat(item);
|
|
case 'string': return String(item);
|
|
default: return item;
|
|
}
|
|
});
|
|
}
|
|
|
|
generateTSInterfaceStart(sheetName: string): string {
|
|
const interfaceName = `ITable${sheetName}`;
|
|
const safeSheetName = `${this.makeSafeFileName(sheetName)}`;
|
|
|
|
let tsContent = ` /**\n * ${safeSheetName} 配置数据\n */\n`;
|
|
tsContent += ` interface ${interfaceName} {\n`;
|
|
return tsContent;
|
|
}
|
|
|
|
// 生成TS接口
|
|
generateTSInterface(sheetName: string, headers: any): string {
|
|
const interfaceName = `ITable${sheetName}`;
|
|
const safeSheetName = `${this.makeSafeFileName(sheetName)}`;
|
|
|
|
let tsContent = ` /**\n * ${safeSheetName} 配置数据\n */\n`;
|
|
tsContent += ` interface ${interfaceName} {\n`;
|
|
|
|
for (let i = 0; i < headers.fields.length; i++) {
|
|
const field = headers.fields[i];
|
|
const type = headers.types[i];
|
|
const description = headers.descriptions[i] || '字段说明';
|
|
|
|
if (field) {
|
|
tsContent += ` /** ${description} */\n`;
|
|
tsContent += ` readonly ${field}: ${this.convertToTSType(type)};\n`;
|
|
}
|
|
}
|
|
|
|
tsContent += ` }\n`;
|
|
return tsContent;
|
|
}
|
|
|
|
// 转换为TS类型
|
|
convertToTSType(excelType: string): string {
|
|
if (!excelType) return 'any';
|
|
|
|
const type = excelType.toLowerCase().trim();
|
|
switch (type) {
|
|
case 'string':
|
|
return 'string';
|
|
case 'int':
|
|
case 'float':
|
|
return 'number';
|
|
case 'boolean':
|
|
return 'boolean';
|
|
case 'int[]':
|
|
return 'number[]';
|
|
case 'float[]':
|
|
return 'number[]';
|
|
case 'string[]':
|
|
return 'string[]';
|
|
case 'any[]':
|
|
return '(number | string | boolean)[]';
|
|
default:
|
|
return 'any';
|
|
}
|
|
}
|
|
|
|
// 转换为PascalCase
|
|
toPascalCase(str: string): string {
|
|
return str.replace(/(\w)(\w*)/g, (_, g1, g2) =>
|
|
g1.toUpperCase() + g2.toLowerCase()
|
|
).replace(/[^a-zA-Z0-9]/g, '');
|
|
}
|
|
|
|
// 创建安全的文件名
|
|
makeSafeFileName(name: string): string {
|
|
return name.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
}
|
|
|
|
// 日志输出
|
|
log(message: string) {
|
|
console.log(message);
|
|
}
|
|
|
|
error(message: string) {
|
|
console.error(message);
|
|
}
|
|
|
|
warn(message: string) {
|
|
console.warn(message);
|
|
}
|
|
|
|
success(message: string) {
|
|
console.log(message);
|
|
}
|
|
|
|
// 消息处理
|
|
messages = {
|
|
'excel-to-json-ts:export-manual': () => {
|
|
this.exportManual();
|
|
}
|
|
}
|
|
|
|
// 刷新资源管理器
|
|
async refreshAssetDatabase() {
|
|
try {
|
|
// 确保路径是项目内的路径(db://assets/开头)
|
|
const jsonDbPath = this.convertToDbPath(this.config.jsonExportPath);
|
|
const tsDbPath = this.convertToDbPath(this.config.tsExportPath);
|
|
|
|
// 刷新JSON导出目录
|
|
if (jsonDbPath && jsonDbPath.startsWith('db://')) {
|
|
await Editor.Message.request('asset-db', 'refresh-asset', jsonDbPath);
|
|
}
|
|
|
|
// 刷新TS导出目录
|
|
if (tsDbPath && tsDbPath.startsWith('db://')) {
|
|
await Editor.Message.request('asset-db', 'refresh-asset', tsDbPath);
|
|
}
|
|
|
|
this.success('[Excel插件] 资源刷新请求已发送');
|
|
} catch (err) {
|
|
this.error(`[Excel插件] 资源刷新失败: ${err}`);
|
|
}
|
|
}
|
|
|
|
// 将系统路径转换为Cocos的db路径
|
|
convertToDbPath(systemPath: string): string {
|
|
if (!systemPath) return '';
|
|
|
|
try {
|
|
// 获取项目路径
|
|
const projectPath = Editor.Project.path;
|
|
|
|
// 规范化路径(统一使用正斜杠)
|
|
systemPath = systemPath.replace(/\\/g, '/');
|
|
const normalizedProjectPath = projectPath.replace(/\\/g, '/');
|
|
|
|
// 确保路径以/结尾
|
|
const projectPathWithSlash = normalizedProjectPath.endsWith('/')
|
|
? normalizedProjectPath
|
|
: normalizedProjectPath + '/';
|
|
|
|
// 检查是否在项目目录内
|
|
if (systemPath.startsWith(projectPathWithSlash)) {
|
|
// 计算相对路径
|
|
const relativePath = systemPath.substring(projectPathWithSlash.length);
|
|
return `db://${relativePath}`;
|
|
}
|
|
|
|
// 如果路径包含assets目录,尝试直接转换
|
|
if (systemPath.includes('/assets/') || systemPath.includes('\\assets\\')) {
|
|
const assetsIndex = systemPath.toLowerCase().indexOf('/assets/');
|
|
if (assetsIndex !== -1) {
|
|
const relativeToAssets = systemPath.substring(assetsIndex + 1);
|
|
return `db://${relativeToAssets}`;
|
|
}
|
|
}
|
|
|
|
this.warn(`[Excel插件] 路径不在项目内,可能无法正确刷新: ${systemPath}`);
|
|
return systemPath;
|
|
} catch (err) {
|
|
this.error(`[Excel插件] 路径转换失败: ${err}`);
|
|
return systemPath;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const excelToJsonPlugin = new ExcelToJsonPlugin(); |