utils.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682
  1. 'use strict';
  2. const { Controller } = require('ee-core');
  3. const { shell } = require('electron');
  4. const Addon = require('ee-core/addon');
  5. const { dialog } = require('electron');
  6. const fs = require('fs');
  7. const path = require('path');
  8. const CoreWindow = require('ee-core/electron/window');
  9. const { BrowserWindow, Menu,app } = require('electron');
  10. const { spawn } = require('child_process');
  11. const Log = require('ee-core/log');
  12. const { readConfigFile } = require('../utils/config');
  13. const configDeault = readConfigFile();
  14. const errData = {
  15. msg :'请求失败,请联系管理员',
  16. code:999
  17. }
  18. const sharp = require('sharp'); // 确保安装:npm install sharp
  19. /**
  20. * example
  21. * @class
  22. */
  23. class UtilsController extends Controller {
  24. constructor(ctx) {
  25. super(ctx);
  26. }
  27. /**
  28. * 运行外部工具(如exe)
  29. * @param {{exeName?: string, exePath?: string, args?: string[]}} params
  30. */
  31. async runExternalTool (params = {}) {
  32. try {
  33. const { exeName, exePath, args = [] } = params;
  34. const targetName = exePath || exeName;
  35. if (!targetName) {
  36. throw new Error('缺少可执行文件名称');
  37. }
  38. const isPackaged = app.isPackaged;
  39. const execDir = isPackaged ? path.dirname(app.getPath('exe')) : path.join(app.getAppPath(), '.');
  40. const resourcesDir = process.resourcesPath;
  41. const candidates = [];
  42. const pushCandidate = (candidatePath, isAbsolute = false) => {
  43. if (!candidatePath) return;
  44. const normalized = isAbsolute || path.isAbsolute(candidatePath)
  45. ? candidatePath
  46. : path.join(execDir, candidatePath);
  47. if (!candidates.includes(normalized)) {
  48. candidates.push(normalized);
  49. }
  50. };
  51. // 允许前端显式传入候选路径数组
  52. if (Array.isArray(params.candidatePaths)) {
  53. params.candidatePaths.forEach((p) => pushCandidate(p));
  54. }
  55. if (path.isAbsolute(targetName)) {
  56. pushCandidate(targetName, true);
  57. } else {
  58. if (isPackaged) {
  59. pushCandidate(path.join(execDir, targetName), true);
  60. pushCandidate(path.join(resourcesDir, targetName), true);
  61. pushCandidate(path.join(resourcesDir, 'extraResources', targetName), true);
  62. } else {
  63. pushCandidate(targetName);
  64. pushCandidate(path.join('build', 'extraResources', targetName));
  65. pushCandidate(path.join('extraResources', targetName));
  66. }
  67. }
  68. console.log('=======');
  69. console.log(candidates);
  70. const resolvedPath = candidates.find((filePath) => filePath && fs.existsSync(filePath));
  71. if (!resolvedPath) {
  72. throw new Error(`未找到工具:${targetName}`);
  73. }
  74. // 如果已经有正在运行的子进程,则先关闭旧进程,再重新启动新的(保证始终只有一个进程,同时刷新页面参数)
  75. if (this.app.externalToolProcess && !this.app.externalToolProcess.killed) {
  76. try {
  77. this.app.externalToolProcess.kill();
  78. } catch (e) {
  79. console.warn('关闭旧 externalTool 进程失败(忽略继续启动新进程):', e);
  80. }
  81. this.app.externalToolProcess = null;
  82. }
  83. const child = spawn(resolvedPath, args, {
  84. cwd: path.dirname(resolvedPath),
  85. detached: true,
  86. stdio: 'ignore',
  87. windowsHide: false
  88. });
  89. // 记录子进程,方便后续复用/判断是否已退出
  90. this.app.externalToolProcess = child;
  91. child.on('exit', () => {
  92. if (this.app.externalToolProcess === child) {
  93. this.app.externalToolProcess = null;
  94. }
  95. });
  96. child.unref();
  97. return {
  98. code: 0,
  99. data: {
  100. path: resolvedPath,
  101. reused: false
  102. }
  103. };
  104. } catch (error) {
  105. console.error('runExternalTool error:', error);
  106. return {
  107. code: 1,
  108. msg: error.message || '运行外部工具失败'
  109. };
  110. }
  111. }
  112. /**
  113. * 所有方法接收两个参数
  114. * @param args 前端传的参数
  115. * @param event - ipc通信时才有值。详情见:控制器文档
  116. */
  117. /**
  118. * upload
  119. */
  120. async shellFun (params) {
  121. // 如果是打开路径操作,确保路径存在
  122. if (params.action === 'openMkPath') {
  123. try {
  124. // 确保目录存在,如果不存在就创建
  125. if (!fs.existsSync(params.params)) {
  126. fs.mkdirSync(params.params, { recursive: true });
  127. }
  128. shell.openPath(params.params)
  129. return;
  130. } catch (err) {
  131. console.error('创建目录失败:', err);
  132. // 即使创建目录失败,也尝试打开路径(可能已经存在)
  133. }
  134. }
  135. shell[params.action](params.params)
  136. }
  137. async openMain (config) {
  138. const { id, url } = config;
  139. if (this.app.electron[id]) {
  140. const win = this.app.electron[id];
  141. // 切换到指定的 URL
  142. await win.loadURL(url);
  143. win.focus();
  144. win.show();
  145. return;
  146. }
  147. const win = new BrowserWindow({
  148. ...config,
  149. webPreferences: {
  150. webSecurity: false,
  151. contextIsolation: false, // false -> 可在渲染进程中使用electron的api,true->需要bridge.js(contextBridge)
  152. nodeIntegration: true,
  153. // preload: path.join('../preload/preload.js','../preload/bridge.js'),
  154. },
  155. });
  156. // 先登记窗口,页面加载时发出的 socket 回包才能路由到这个窗口
  157. this.app.electron[config.id] = win ;
  158. win.on('close', () => {
  159. delete this.app.electron[config.id]; // 删除窗口引用
  160. });
  161. await win.loadURL(config.url); // 设置窗口的 URL
  162. if(configDeault.debug && !win.isDestroyed()) win.webContents.openDevTools()
  163. }
  164. setWindowClosable(payload = {}) {
  165. const id = payload.id
  166. const closable = payload.closable !== false
  167. const win = id && this.app.electron ? this.app.electron[id] : null
  168. if (!win || win.isDestroyed()) return { code: 1, msg: '窗口不存在' }
  169. try {
  170. win.setClosable(closable)
  171. return { code: 0 }
  172. } catch (error) {
  173. Log.error('设置窗口关闭状态失败:', error)
  174. return { code: 1, msg: error.message }
  175. }
  176. }
  177. async openDirectory(optiops={
  178. title:"选择文件夹"
  179. }){
  180. const filePaths = dialog.showOpenDialogSync({
  181. title:optiops.title || '选择文件夹',
  182. properties: ['openDirectory']
  183. })
  184. if(filePaths[0]) return filePaths[0];
  185. return filePaths
  186. }
  187. /**
  188. * 关闭所有子窗口
  189. */
  190. closeAllWindows() {
  191. try {
  192. // 获取所有窗口
  193. const windows = this.app.electron;
  194. // 关闭除主窗口外的所有窗口
  195. for (const [id, window] of Object.entries(windows)) {
  196. if (!['mainWindow','extra'].includes(id)) { // 保留主窗口
  197. try {
  198. window.close();
  199. delete this.app.electron[id];
  200. } catch (error) {
  201. console.error(`关闭窗口 ${id} 失败:`, error);
  202. }
  203. }
  204. }
  205. console.log('所有子窗口已关闭');
  206. return { success: true, message: '所有子窗口已关闭' };
  207. } catch (error) {
  208. console.error('关闭所有窗口失败:', error);
  209. return { success: false, message: '关闭所有窗口失败: ' + error.message };
  210. }
  211. }
  212. async openImage(
  213. optiops= {
  214. title:"选择图片",
  215. filters:[
  216. { name: '支持JPG,png,gif', extensions: ['jpg','jpeg','png'] },
  217. ],
  218. }
  219. ){
  220. const filePaths = dialog.showOpenDialogSync({
  221. title:optiops.title || '选择图片',
  222. properties:['openFile'],
  223. filters:optiops.filters || [
  224. { name: '支持JPG,png,gif', extensions: ['jpg','jpeg','png'] },
  225. ]
  226. })
  227. const filePath = filePaths[0];
  228. const fileBuffer = fs.readFileSync(filePath);
  229. const base64Image = fileBuffer.toString('base64');
  230. // 获取文件扩展名
  231. const extension = path.extname(filePath).toLowerCase().replace('.', '');
  232. // 根据扩展名确定 MIME 类型
  233. let mimeType = '';
  234. switch (extension) {
  235. case 'jpg':
  236. case 'jpeg':
  237. mimeType = 'image/jpeg';
  238. break;
  239. case 'png':
  240. mimeType = 'image/png';
  241. break;
  242. case 'gif':
  243. mimeType = 'image/gif';
  244. break;
  245. default:
  246. mimeType = 'application/octet-stream'; // 默认 MIME 类型
  247. break;
  248. }
  249. // 构建 data URL
  250. const dataUrl = `data:${mimeType};base64,${base64Image}`;
  251. return {
  252. filePath:filePath,
  253. base64Image:dataUrl
  254. };
  255. }
  256. /**
  257. * 将前端生成的详情图数据写入 EXE 同级目录下的 output 文件夹。
  258. * @param {{ bundles: Array }} payload
  259. */
  260. async saveGeneratedImages (payload = {}) {
  261. try {
  262. const { app } = require('electron');
  263. const bundles = payload.bundles || [];
  264. if (!Array.isArray(bundles) || !bundles.length) {
  265. return { code: 1, msg: '无可保存的图片数据' };
  266. }
  267. // 运行目录:打包后为 EXE 所在目录,开发环境为项目根目录
  268. const isPackaged = app.isPackaged;
  269. const exeDir = isPackaged ? path.dirname(app.getPath('exe')) : process.cwd();
  270. const baseOutputDir = path.join(exeDir, 'output');
  271. if (!fs.existsSync(baseOutputDir)) {
  272. fs.mkdirSync(baseOutputDir, { recursive: true });
  273. }
  274. let fileCount = 0;
  275. const saveDataUrlToFile = (dataUrl, targetPath) => {
  276. if (!dataUrl || typeof dataUrl !== 'string') return;
  277. const match = dataUrl.match(/^data:image\/\w+;base64,(.+)$/);
  278. const base64Data = match ? match[1] : dataUrl;
  279. const buffer = Buffer.from(base64Data, 'base64');
  280. const dir = path.dirname(targetPath);
  281. if (!fs.existsSync(dir)) {
  282. fs.mkdirSync(dir, { recursive: true });
  283. }
  284. fs.writeFileSync(targetPath, buffer);
  285. fileCount += 1;
  286. };
  287. bundles.forEach(bundle => {
  288. if (!bundle) return;
  289. const styleNo = (bundle.styleNo || bundle.styleKey || 'UNKNOWN').toString().replace(/[\\\/:*?"<>|]/g, '_');
  290. const styleDir = path.join(baseOutputDir, styleNo);
  291. if (!fs.existsSync(styleDir)) {
  292. fs.mkdirSync(styleDir, { recursive: true });
  293. }
  294. // 单画布图片
  295. (bundle.images || []).forEach((img, idx) => {
  296. if (!img || typeof img.dataUrl !== 'string') return;
  297. if (img.dataUrl === 'model' || img.dataUrl === 'scene') return;
  298. if (!img.dataUrl.startsWith('data:image')) return;
  299. const fileName = `canvas_${img.canvasIndex || 0}_img_${idx}.jpg`;
  300. const targetPath = path.join(styleDir, fileName);
  301. saveDataUrlToFile(img.dataUrl, targetPath);
  302. });
  303. // 合成长图
  304. if (bundle.combined && bundle.combined.dataUrl) {
  305. const combinedPath = path.join(styleDir, 'all_canvases.jpg');
  306. saveDataUrlToFile(bundle.combined.dataUrl, combinedPath);
  307. }
  308. });
  309. return {
  310. code: 0,
  311. data: {
  312. outputDir: baseOutputDir,
  313. fileCount,
  314. },
  315. };
  316. } catch (error) {
  317. console.error('saveGeneratedImages error:', error);
  318. return {
  319. code: 1,
  320. msg: error.message || '保存生成图片失败',
  321. };
  322. }
  323. }
  324. async saveRemoteImage(payload = {}) {
  325. try {
  326. const url = payload.url
  327. if (!url) return { code: 1, msg: '没有可保存的图片' }
  328. const defaultName = payload.defaultName || `湛江场景图_${Date.now()}.png`
  329. const dest = dialog.showSaveDialogSync({
  330. title: '保存图片到本地',
  331. defaultPath: defaultName,
  332. filters: [
  333. { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'webp'] },
  334. ],
  335. })
  336. if (!dest) return { code: 1, msg: '已取消保存' }
  337. await this._writeImageToFile(url, dest)
  338. return { code: 0, msg: '保存成功', path: dest }
  339. } catch (error) {
  340. Log.error('保存图片失败:', error)
  341. return { code: 1, msg: error.message || '保存图片失败' }
  342. }
  343. }
  344. async saveRemoteImages(payload = {}) {
  345. try {
  346. const items = payload.items || []
  347. if (!items.length) return { code: 1, msg: '没有可保存的图片' }
  348. const dir = dialog.showOpenDialogSync({
  349. title: '选择保存文件夹',
  350. properties: ['openDirectory', 'createDirectory'],
  351. })
  352. if (!dir || !dir[0]) return { code: 1, msg: '已取消保存' }
  353. const folder = dir[0]
  354. let count = 0
  355. for (let i = 0; i < items.length; i++) {
  356. const item = items[i]
  357. if (!item || !item.url) continue
  358. let ext = path.extname(String(item.url || '').split('?')[0] || '')
  359. if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext.toLowerCase())) ext = '.png'
  360. const dest = path.join(folder, this._safeFileName(item.name || `湛江场景图_${i + 1}`) + ext)
  361. await this._writeImageToFile(item.url, dest)
  362. count += 1
  363. }
  364. return { code: 0, msg: '保存成功', path: folder, count }
  365. } catch (error) {
  366. Log.error('批量保存图片失败:', error)
  367. return { code: 1, msg: error.message || '保存图片失败' }
  368. }
  369. }
  370. _safeFileName(name) {
  371. return String(name || '湛江场景图').replace(/[\\/:*?"<>|]/g, '_').slice(0, 80)
  372. }
  373. _writeImageToFile(url, dest) {
  374. const fsPromises = fs.promises
  375. if (typeof url === 'string' && url.startsWith('data:image')) {
  376. const match = url.match(/^data:image\/\w+;base64,(.+)$/)
  377. const base64Data = match ? match[1] : url
  378. return fsPromises.writeFile(dest, Buffer.from(base64Data, 'base64'))
  379. }
  380. if (typeof url === 'string' && !/^https?:\/\//i.test(url)) {
  381. const localPath = url.replace(/^file:\/\//i, '')
  382. return fsPromises.copyFile(localPath, dest)
  383. }
  384. const http = require('http')
  385. const https = require('https')
  386. return new Promise((resolve, reject) => {
  387. const fetchOnce = (target, hops = 0) => {
  388. if (hops > 5) {
  389. reject(new Error('下载跳转次数过多'))
  390. return
  391. }
  392. const client = target.startsWith('https') ? https : http
  393. const req = client.get(target, (res) => {
  394. if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
  395. fetchOnce(res.headers.location, hops + 1)
  396. return
  397. }
  398. if (res.statusCode !== 200) {
  399. reject(new Error('下载失败: ' + res.statusCode))
  400. return
  401. }
  402. const chunks = []
  403. res.on('data', (chunk) => chunks.push(chunk))
  404. res.on('end', () => {
  405. fs.writeFile(dest, Buffer.concat(chunks), (err) => err ? reject(err) : resolve())
  406. })
  407. })
  408. req.on('error', reject)
  409. }
  410. fetchOnce(url)
  411. })
  412. }
  413. async openFile(optiops= {
  414. title:"选择文件",
  415. filters:[
  416. { name: '支持JPG', extensions: ['jpg','jpeg'] },
  417. ],
  418. }){
  419. const filePaths = dialog.showOpenDialogSync({
  420. title:optiops.title || '选择文件',
  421. properties: ['openFile'],
  422. filters: optiops.filters || [
  423. { name: '选择文件' },
  424. ]
  425. })
  426. if(filePaths[0]) return filePaths[0];
  427. return filePaths
  428. }
  429. getAppConfig(){
  430. const config = readConfigFile()
  431. const appPath = path.join(app.getAppPath(), '../..');
  432. const pyPath = path.join(path.dirname(appPath), 'extraResources', 'py');
  433. return {
  434. ...config,
  435. userDataPath: app.getPath('userData'),
  436. appPath: appPath,
  437. pyPath:pyPath,
  438. }
  439. }
  440. // 字体管理相关方法
  441. async downloadFont(args, event) {
  442. try {
  443. console.log('🔤 IPC downloadFont called with args:', args, 'event:', event)
  444. const { url, fontName } = args;
  445. console.log('🔤 IPC downloadFont parsed:', { url, fontName })
  446. Log.info('downloadFontdownloadFontdownloadFontdownloadFont:', url, fontName)
  447. const https = require('https');
  448. const http = require('http');
  449. const fs = require('fs');
  450. const path = require('path');
  451. const crypto = require('crypto');
  452. // 获取字体缓存目录
  453. const userDataPath = app.getPath('userData');
  454. const fontCacheDir = path.join(userDataPath, 'fonts');
  455. // 确保字体缓存目录存在
  456. if (!fs.existsSync(fontCacheDir)) {
  457. fs.mkdirSync(fontCacheDir, { recursive: true });
  458. }
  459. // 生成字体文件名(使用URL的哈希值避免文件名冲突)
  460. const urlHash = crypto.createHash('md5').update(url).digest('hex').substring(0, 8);
  461. const fontFileName = `${fontName.replace(/[^a-zA-Z0-9]/g, '_')}_${urlHash}.ttf`;
  462. const localFontPath = path.join(fontCacheDir, fontFileName);
  463. // 检查本地是否已有字体文件
  464. if (fs.existsSync(localFontPath)) {
  465. console.log('字体已存在:', localFontPath);
  466. return { success: true, path: localFontPath };
  467. }
  468. console.log('开始下载字体:', fontName, '到:', localFontPath);
  469. // 下载字体文件
  470. const protocol = url.startsWith('https:') ? https : http;
  471. return new Promise((resolve, reject) => {
  472. const request = protocol.get(url, (response) => {
  473. if (response.statusCode !== 200) {
  474. reject(new Error(`Failed to download font: ${response.statusCode}`));
  475. return;
  476. }
  477. const fileStream = fs.createWriteStream(localFontPath);
  478. response.pipe(fileStream);
  479. fileStream.on('finish', () => {
  480. fileStream.close();
  481. console.log('字体下载完成:', localFontPath);
  482. resolve({ success: true, path: localFontPath });
  483. });
  484. fileStream.on('error', (error) => {
  485. fs.unlinkSync(localFontPath); // 删除失败的文件
  486. reject(error);
  487. });
  488. });
  489. request.on('error', (error) => {
  490. reject(error);
  491. });
  492. // 设置超时
  493. request.setTimeout(30000, () => {
  494. request.abort();
  495. reject(new Error('Font download timeout'));
  496. });
  497. });
  498. } catch (error) {
  499. Log.info('downloadFont Error:', error);
  500. return { success: false, error: error.message };
  501. }
  502. }
  503. async loadFontFromCache(args, event) {
  504. try {
  505. const { fontName, fontUrl } = args;
  506. const fs = require('fs');
  507. const path = require('path');
  508. const crypto = require('crypto');
  509. // 获取字体缓存目录
  510. const userDataPath = app.getPath('userData');
  511. const fontCacheDir = path.join(userDataPath, 'fonts');
  512. // 生成字体文件名
  513. const urlHash = crypto.createHash('md5').update(fontUrl).digest('hex').substring(0, 8);
  514. const fontFileName = `${fontName.replace(/[^a-zA-Z0-9]/g, '_')}_${urlHash}.ttf`;
  515. const localFontPath = path.join(fontCacheDir, fontFileName);
  516. // 检查本地是否已有字体文件
  517. if (fs.existsSync(localFontPath)) {
  518. // 读取字体文件内容并转换为base64
  519. const fontData = fs.readFileSync(localFontPath);
  520. const base64Data = fontData.toString('base64');
  521. const dataUrl = `data:font/ttf;base64,${base64Data}`;
  522. return { success: true, dataUrl, path: localFontPath };
  523. }
  524. return { success: false, message: 'Font not found in cache' };
  525. } catch (error) {
  526. console.error('从缓存加载字体失败:', error);
  527. return { success: false, error: error.message };
  528. }
  529. }
  530. getFontCachePath(args, event) {
  531. const path = require('path');
  532. const userDataPath = app.getPath('userData');
  533. return path.join(userDataPath, 'fonts');
  534. }
  535. // 测试方法
  536. async testIPC(args, event) {
  537. console.log('🧪 IPC test method called:', args)
  538. return { success: true, message: 'IPC is working', args }
  539. }
  540. async readFileImageForPath(filePath,maxWidth=1500){
  541. const getMimeType = (fileName)=>{
  542. const extension = path.extname(fileName).toLowerCase().replace('.', '');
  543. let mimeType = '';
  544. switch (extension) {
  545. case 'jpg':
  546. case 'jpeg':
  547. mimeType = 'image/jpeg';
  548. break;
  549. case 'png':
  550. mimeType = 'image/png';
  551. break;
  552. case 'gif':
  553. mimeType = 'image/gif';
  554. break;
  555. case 'webp':
  556. mimeType = 'image/webp';
  557. break;
  558. case 'avif':
  559. mimeType = 'image/avif';
  560. break;
  561. default:
  562. mimeType = 'application/octet-stream';
  563. break;
  564. }
  565. return mimeType;
  566. }
  567. try {
  568. const fileName = path.basename(filePath);
  569. const image = sharp(filePath);
  570. const metadata = await image.metadata();
  571. let mimeType = getMimeType(fileName); // 调用下面定义的私有方法获取 MIME 类型
  572. let fileBuffer;
  573. if (metadata.width > maxWidth) {
  574. // 如果宽度大于 1500px,压缩至 1500px 宽度,保持比例
  575. fileBuffer = await image.resize(maxWidth).toBuffer();
  576. } else {
  577. // 否则直接读取原图
  578. fileBuffer = fs.readFileSync(filePath);
  579. }
  580. return {
  581. fileBuffer,
  582. fileName,
  583. mimeType
  584. };
  585. } catch (error) {
  586. console.error('Error processing image:', error);
  587. throw error;
  588. }
  589. }
  590. }
  591. UtilsController.toString = () => '[class ExampleController]';
  592. module.exports = UtilsController;