'use strict'; const { Controller } = require('ee-core'); const { shell } = require('electron'); const Addon = require('ee-core/addon'); const { dialog } = require('electron'); const fs = require('fs'); const path = require('path'); const CoreWindow = require('ee-core/electron/window'); const { BrowserWindow, Menu,app } = require('electron'); const { spawn } = require('child_process'); const Log = require('ee-core/log'); const { readConfigFile } = require('../utils/config'); const configDeault = readConfigFile(); const errData = { msg :'请求失败,请联系管理员', code:999 } const sharp = require('sharp'); // 确保安装:npm install sharp /** * example * @class */ class UtilsController extends Controller { constructor(ctx) { super(ctx); } /** * 运行外部工具(如exe) * @param {{exeName?: string, exePath?: string, args?: string[]}} params */ async runExternalTool (params = {}) { try { const { exeName, exePath, args = [] } = params; const targetName = exePath || exeName; if (!targetName) { throw new Error('缺少可执行文件名称'); } const isPackaged = app.isPackaged; const execDir = isPackaged ? path.dirname(app.getPath('exe')) : path.join(app.getAppPath(), '.'); const resourcesDir = process.resourcesPath; const candidates = []; const pushCandidate = (candidatePath, isAbsolute = false) => { if (!candidatePath) return; const normalized = isAbsolute || path.isAbsolute(candidatePath) ? candidatePath : path.join(execDir, candidatePath); if (!candidates.includes(normalized)) { candidates.push(normalized); } }; // 允许前端显式传入候选路径数组 if (Array.isArray(params.candidatePaths)) { params.candidatePaths.forEach((p) => pushCandidate(p)); } if (path.isAbsolute(targetName)) { pushCandidate(targetName, true); } else { if (isPackaged) { pushCandidate(path.join(execDir, targetName), true); pushCandidate(path.join(resourcesDir, targetName), true); pushCandidate(path.join(resourcesDir, 'extraResources', targetName), true); } else { pushCandidate(targetName); pushCandidate(path.join('build', 'extraResources', targetName)); pushCandidate(path.join('extraResources', targetName)); } } console.log('======='); console.log(candidates); const resolvedPath = candidates.find((filePath) => filePath && fs.existsSync(filePath)); if (!resolvedPath) { throw new Error(`未找到工具:${targetName}`); } // 如果已经有正在运行的子进程,则先关闭旧进程,再重新启动新的(保证始终只有一个进程,同时刷新页面参数) if (this.app.externalToolProcess && !this.app.externalToolProcess.killed) { try { this.app.externalToolProcess.kill(); } catch (e) { console.warn('关闭旧 externalTool 进程失败(忽略继续启动新进程):', e); } this.app.externalToolProcess = null; } const child = spawn(resolvedPath, args, { cwd: path.dirname(resolvedPath), detached: true, stdio: 'ignore', windowsHide: false }); // 记录子进程,方便后续复用/判断是否已退出 this.app.externalToolProcess = child; child.on('exit', () => { if (this.app.externalToolProcess === child) { this.app.externalToolProcess = null; } }); child.unref(); return { code: 0, data: { path: resolvedPath, reused: false } }; } catch (error) { console.error('runExternalTool error:', error); return { code: 1, msg: error.message || '运行外部工具失败' }; } } /** * 所有方法接收两个参数 * @param args 前端传的参数 * @param event - ipc通信时才有值。详情见:控制器文档 */ /** * upload */ async shellFun (params) { // 如果是打开路径操作,确保路径存在 if (params.action === 'openMkPath') { try { // 确保目录存在,如果不存在就创建 if (!fs.existsSync(params.params)) { fs.mkdirSync(params.params, { recursive: true }); } shell.openPath(params.params) return; } catch (err) { console.error('创建目录失败:', err); // 即使创建目录失败,也尝试打开路径(可能已经存在) } } shell[params.action](params.params) } async openMain (config) { const { id, url } = config; if (this.app.electron[id]) { const win = this.app.electron[id]; // 切换到指定的 URL await win.loadURL(url); win.focus(); win.show(); return; } const win = new BrowserWindow({ ...config, webPreferences: { webSecurity: false, contextIsolation: false, // false -> 可在渲染进程中使用electron的api,true->需要bridge.js(contextBridge) nodeIntegration: true, // preload: path.join('../preload/preload.js','../preload/bridge.js'), }, }); await win.loadURL(config.url); // 设置窗口的 URL // 监听窗口关闭事件 if(configDeault.debug) win.webContents.openDevTools() // win.on('close', () => { delete this.app.electron[config.id]; // 删除窗口引用 }); this.app.electron[config.id] = win ; } setWindowClosable(payload = {}) { const id = payload.id const closable = payload.closable !== false const win = id && this.app.electron ? this.app.electron[id] : null if (!win || win.isDestroyed()) return { code: 1, msg: '窗口不存在' } try { win.setClosable(closable) return { code: 0 } } catch (error) { Log.error('设置窗口关闭状态失败:', error) return { code: 1, msg: error.message } } } async openDirectory(optiops={ title:"选择文件夹" }){ const filePaths = dialog.showOpenDialogSync({ title:optiops.title || '选择文件夹', properties: ['openDirectory'] }) if(filePaths[0]) return filePaths[0]; return filePaths } /** * 关闭所有子窗口 */ closeAllWindows() { try { // 获取所有窗口 const windows = this.app.electron; // 关闭除主窗口外的所有窗口 for (const [id, window] of Object.entries(windows)) { if (!['mainWindow','extra'].includes(id)) { // 保留主窗口 try { window.close(); delete this.app.electron[id]; } catch (error) { console.error(`关闭窗口 ${id} 失败:`, error); } } } console.log('所有子窗口已关闭'); return { success: true, message: '所有子窗口已关闭' }; } catch (error) { console.error('关闭所有窗口失败:', error); return { success: false, message: '关闭所有窗口失败: ' + error.message }; } } async openImage( optiops= { title:"选择图片", filters:[ { name: '支持JPG,png,gif', extensions: ['jpg','jpeg','png'] }, ], } ){ const filePaths = dialog.showOpenDialogSync({ title:optiops.title || '选择图片', properties:['openFile'], filters:optiops.filters || [ { name: '支持JPG,png,gif', extensions: ['jpg','jpeg','png'] }, ] }) const filePath = filePaths[0]; const fileBuffer = fs.readFileSync(filePath); const base64Image = fileBuffer.toString('base64'); // 获取文件扩展名 const extension = path.extname(filePath).toLowerCase().replace('.', ''); // 根据扩展名确定 MIME 类型 let mimeType = ''; switch (extension) { case 'jpg': case 'jpeg': mimeType = 'image/jpeg'; break; case 'png': mimeType = 'image/png'; break; case 'gif': mimeType = 'image/gif'; break; default: mimeType = 'application/octet-stream'; // 默认 MIME 类型 break; } // 构建 data URL const dataUrl = `data:${mimeType};base64,${base64Image}`; return { filePath:filePath, base64Image:dataUrl }; } /** * 将前端生成的详情图数据写入 EXE 同级目录下的 output 文件夹。 * @param {{ bundles: Array }} payload */ async saveGeneratedImages (payload = {}) { try { const { app } = require('electron'); const bundles = payload.bundles || []; if (!Array.isArray(bundles) || !bundles.length) { return { code: 1, msg: '无可保存的图片数据' }; } // 运行目录:打包后为 EXE 所在目录,开发环境为项目根目录 const isPackaged = app.isPackaged; const exeDir = isPackaged ? path.dirname(app.getPath('exe')) : process.cwd(); const baseOutputDir = path.join(exeDir, 'output'); if (!fs.existsSync(baseOutputDir)) { fs.mkdirSync(baseOutputDir, { recursive: true }); } let fileCount = 0; const saveDataUrlToFile = (dataUrl, targetPath) => { if (!dataUrl || typeof dataUrl !== 'string') return; const match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/); const base64Data = match ? match[1] : dataUrl; const buffer = Buffer.from(base64Data, 'base64'); const dir = path.dirname(targetPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } fs.writeFileSync(targetPath, buffer); fileCount += 1; }; bundles.forEach(bundle => { if (!bundle) return; const styleNo = (bundle.styleNo || bundle.styleKey || 'UNKNOWN').toString().replace(/[\\\/:*?"<>|]/g, '_'); const styleDir = path.join(baseOutputDir, styleNo); if (!fs.existsSync(styleDir)) { fs.mkdirSync(styleDir, { recursive: true }); } // 单画布图片 (bundle.images || []).forEach((img, idx) => { if (!img || typeof img.dataUrl !== 'string') return; if (img.dataUrl === 'model' || img.dataUrl === 'scene') return; if (!img.dataUrl.startsWith('data:image')) return; const fileName = `canvas_${img.canvasIndex || 0}_img_${idx}.jpg`; const targetPath = path.join(styleDir, fileName); saveDataUrlToFile(img.dataUrl, targetPath); }); // 合成长图 if (bundle.combined && bundle.combined.dataUrl) { const combinedPath = path.join(styleDir, 'all_canvases.jpg'); saveDataUrlToFile(bundle.combined.dataUrl, combinedPath); } }); return { code: 0, data: { outputDir: baseOutputDir, fileCount, }, }; } catch (error) { console.error('saveGeneratedImages error:', error); return { code: 1, msg: error.message || '保存生成图片失败', }; } } async saveRemoteImage(payload = {}) { try { const url = payload.url if (!url) return { code: 1, msg: '没有可保存的图片' } const defaultName = payload.defaultName || `湛江场景图_${Date.now()}.png` const dest = dialog.showSaveDialogSync({ title: '保存图片到本地', defaultPath: defaultName, filters: [ { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'webp'] }, ], }) if (!dest) return { code: 1, msg: '已取消保存' } await this._writeImageToFile(url, dest) return { code: 0, msg: '保存成功', path: dest } } catch (error) { Log.error('保存图片失败:', error) return { code: 1, msg: error.message || '保存图片失败' } } } async saveRemoteImages(payload = {}) { try { const items = payload.items || [] if (!items.length) return { code: 1, msg: '没有可保存的图片' } const dir = dialog.showOpenDialogSync({ title: '选择保存文件夹', properties: ['openDirectory', 'createDirectory'], }) if (!dir || !dir[0]) return { code: 1, msg: '已取消保存' } const folder = dir[0] let count = 0 for (let i = 0; i < items.length; i++) { const item = items[i] if (!item || !item.url) continue let ext = path.extname(String(item.url || '').split('?')[0] || '') if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext.toLowerCase())) ext = '.png' const dest = path.join(folder, this._safeFileName(item.name || `湛江场景图_${i + 1}`) + ext) await this._writeImageToFile(item.url, dest) count += 1 } return { code: 0, msg: '保存成功', path: folder, count } } catch (error) { Log.error('批量保存图片失败:', error) return { code: 1, msg: error.message || '保存图片失败' } } } _safeFileName(name) { return String(name || '湛江场景图').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80) } _writeImageToFile(url, dest) { const fsPromises = fs.promises if (typeof url === 'string' && url.startsWith('data:image')) { const match = url.match(/^data:image\/\w+;base64,(.+)$/) const base64Data = match ? match[1] : url return fsPromises.writeFile(dest, Buffer.from(base64Data, 'base64')) } if (typeof url === 'string' && !/^https?:\/\//i.test(url)) { const localPath = url.replace(/^file:\/\//i, '') return fsPromises.copyFile(localPath, dest) } const http = require('http') const https = require('https') return new Promise((resolve, reject) => { const fetchOnce = (target, hops = 0) => { if (hops > 5) { reject(new Error('下载跳转次数过多')) return } const client = target.startsWith('https') ? https : http const req = client.get(target, (res) => { if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { fetchOnce(res.headers.location, hops + 1) return } if (res.statusCode !== 200) { reject(new Error('下载失败: ' + res.statusCode)) return } const chunks = [] res.on('data', (chunk) => chunks.push(chunk)) res.on('end', () => { fs.writeFile(dest, Buffer.concat(chunks), (err) => err ? reject(err) : resolve()) }) }) req.on('error', reject) } fetchOnce(url) }) } async openFile(optiops= { title:"选择文件", filters:[ { name: '支持JPG', extensions: ['jpg','jpeg'] }, ], }){ const filePaths = dialog.showOpenDialogSync({ title:optiops.title || '选择文件', properties: ['openFile'], filters: optiops.filters || [ { name: '选择文件' }, ] }) if(filePaths[0]) return filePaths[0]; return filePaths } getAppConfig(){ const config = readConfigFile() const appPath = path.join(app.getAppPath(), '../..'); const pyPath = path.join(path.dirname(appPath), 'extraResources', 'py'); return { ...config, userDataPath: app.getPath('userData'), appPath: appPath, pyPath:pyPath, } } // 字体管理相关方法 async downloadFont(args, event) { try { console.log('🔤 IPC downloadFont called with args:', args, 'event:', event) const { url, fontName } = args; console.log('🔤 IPC downloadFont parsed:', { url, fontName }) Log.info('downloadFontdownloadFontdownloadFontdownloadFont:', url, fontName) const https = require('https'); const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); // 获取字体缓存目录 const userDataPath = app.getPath('userData'); const fontCacheDir = path.join(userDataPath, 'fonts'); // 确保字体缓存目录存在 if (!fs.existsSync(fontCacheDir)) { fs.mkdirSync(fontCacheDir, { recursive: true }); } // 生成字体文件名(使用URL的哈希值避免文件名冲突) const urlHash = crypto.createHash('md5').update(url).digest('hex').substring(0, 8); const fontFileName = `${fontName.replace(/[^a-zA-Z0-9]/g, '_')}_${urlHash}.ttf`; const localFontPath = path.join(fontCacheDir, fontFileName); // 检查本地是否已有字体文件 if (fs.existsSync(localFontPath)) { console.log('字体已存在:', localFontPath); return { success: true, path: localFontPath }; } console.log('开始下载字体:', fontName, '到:', localFontPath); // 下载字体文件 const protocol = url.startsWith('https:') ? https : http; return new Promise((resolve, reject) => { const request = protocol.get(url, (response) => { if (response.statusCode !== 200) { reject(new Error(`Failed to download font: ${response.statusCode}`)); return; } const fileStream = fs.createWriteStream(localFontPath); response.pipe(fileStream); fileStream.on('finish', () => { fileStream.close(); console.log('字体下载完成:', localFontPath); resolve({ success: true, path: localFontPath }); }); fileStream.on('error', (error) => { fs.unlinkSync(localFontPath); // 删除失败的文件 reject(error); }); }); request.on('error', (error) => { reject(error); }); // 设置超时 request.setTimeout(30000, () => { request.abort(); reject(new Error('Font download timeout')); }); }); } catch (error) { Log.info('downloadFont Error:', error); return { success: false, error: error.message }; } } async loadFontFromCache(args, event) { try { const { fontName, fontUrl } = args; const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); // 获取字体缓存目录 const userDataPath = app.getPath('userData'); const fontCacheDir = path.join(userDataPath, 'fonts'); // 生成字体文件名 const urlHash = crypto.createHash('md5').update(fontUrl).digest('hex').substring(0, 8); const fontFileName = `${fontName.replace(/[^a-zA-Z0-9]/g, '_')}_${urlHash}.ttf`; const localFontPath = path.join(fontCacheDir, fontFileName); // 检查本地是否已有字体文件 if (fs.existsSync(localFontPath)) { // 读取字体文件内容并转换为base64 const fontData = fs.readFileSync(localFontPath); const base64Data = fontData.toString('base64'); const dataUrl = `data:font/ttf;base64,${base64Data}`; return { success: true, dataUrl, path: localFontPath }; } return { success: false, message: 'Font not found in cache' }; } catch (error) { console.error('从缓存加载字体失败:', error); return { success: false, error: error.message }; } } getFontCachePath(args, event) { const path = require('path'); const userDataPath = app.getPath('userData'); return path.join(userDataPath, 'fonts'); } // 测试方法 async testIPC(args, event) { console.log('🧪 IPC test method called:', args) return { success: true, message: 'IPC is working', args } } async readFileImageForPath(filePath,maxWidth=1500){ const getMimeType = (fileName)=>{ const extension = path.extname(fileName).toLowerCase().replace('.', ''); let mimeType = ''; switch (extension) { case 'jpg': case 'jpeg': mimeType = 'image/jpeg'; break; case 'png': mimeType = 'image/png'; break; case 'gif': mimeType = 'image/gif'; break; case 'webp': mimeType = 'image/webp'; break; case 'avif': mimeType = 'image/avif'; break; default: mimeType = 'application/octet-stream'; break; } return mimeType; } try { const fileName = path.basename(filePath); const image = sharp(filePath); const metadata = await image.metadata(); let mimeType = getMimeType(fileName); // 调用下面定义的私有方法获取 MIME 类型 let fileBuffer; if (metadata.width > maxWidth) { // 如果宽度大于 1500px,压缩至 1500px 宽度,保持比例 fileBuffer = await image.resize(maxWidth).toBuffer(); } else { // 否则直接读取原图 fileBuffer = fs.readFileSync(filePath); } return { fileBuffer, fileName, mimeType }; } catch (error) { console.error('Error processing image:', error); throw error; } } } UtilsController.toString = () => '[class ExampleController]'; module.exports = UtilsController;