// 由于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();