main.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570
  1. // main.js (ESM 版本)
  2. import { app, BrowserWindow, Menu, ipcMain, shell, dialog } from 'electron';
  3. import { fileURLToPath } from 'url';
  4. import { dirname, join } from 'path';
  5. import { exec } from 'child_process';
  6. import { promisify } from 'util';
  7. import { writeLog } from './src/log/log-writer.js';
  8. import { tmpdir } from 'os';
  9. import { writeFileSync } from 'fs';
  10. // -------------- 构造 ESM 版 __dirname --------------
  11. const __filename = fileURLToPath(import.meta.url);
  12. const __dirname = dirname(__filename);
  13. const execAsync = promisify(exec);
  14. let win=null;
  15. // -------------- 单实例检查 ------------
  16. // 1. 请求单实例锁
  17. const gotTheLock = app.requestSingleInstanceLock();
  18. if (!gotTheLock) {
  19. // 抢锁失败 -> 立即退出
  20. app.quit();
  21. }
  22. // 2. 抢锁成功 -> 监听后续启动事件
  23. app.on('second-instance', (ev, cmd, cwd, additionalData) => {
  24. // 把已存在的窗口提到最前
  25. if (win) {
  26. if (win.isMinimized()) win.restore();
  27. win.focus();
  28. }
  29. // todo 也可以在这里解析 cmd 中的参数,做打开文件等操作
  30. });
  31. // -------------- 创建窗口 --------------
  32. function createWindow() {
  33. const isMac = process.platform === 'darwin';
  34. win = new BrowserWindow({
  35. show: false,
  36. frame: false,
  37. titleBarStyle: 'hidden',
  38. webPreferences: {
  39. nodeIntegration: false,
  40. contextIsolation: true,
  41. preload: join(__dirname, 'preload.js'),
  42. },
  43. });
  44. // 去掉应用菜单栏
  45. Menu.setApplicationMenu(null);
  46. if (!isMac) win.removeMenu();
  47. win.maximize();
  48. // 加载外置 H5 页面
  49. win.loadFile(join(process.cwd(), 'h5/index.html'));
  50. win.once('ready-to-show', () => win.show());
  51. //渲染进程死了,纪录日志
  52. win.webContents.on('render-process-gone', (event, details) => {
  53. writeLog('error',`渲染进程崩溃 ${details}`);
  54. // writeLog('error', JSON.stringify(details));
  55. });
  56. }
  57. // -------------- 应用生命周期 --------------
  58. app.whenReady().then(createWindow);
  59. app.on('window-all-closed', () => {
  60. if (process.platform !== 'darwin') app.quit();
  61. });
  62. app.on('activate', () => {
  63. if (BrowserWindow.getAllWindows().length === 0) createWindow();
  64. });
  65. // -------------- 系统操作函数 --------------
  66. const getSystemCommands = () => {
  67. const platform = process.platform;
  68. switch (platform) {
  69. case 'win32': // Windows
  70. return {
  71. shutdown: 'shutdown /s /t 0'
  72. };
  73. case 'darwin': // macOS
  74. return {
  75. shutdown: 'sudo shutdown -h now'
  76. };
  77. case 'linux': // Linux
  78. return {
  79. shutdown: 'systemctl poweroff'
  80. };
  81. default:
  82. return {
  83. shutdown: null
  84. };
  85. }
  86. };
  87. const executeSystemCommand = async (command, requiresAdmin = false) => {
  88. try {
  89. if (!command) {
  90. throw new Error('当前平台不支持该操作');
  91. }
  92. // Windows平台的权限检查
  93. if (process.platform === 'win32' && requiresAdmin) {
  94. // 检查是否以管理员身份运行
  95. try {
  96. await execAsync('net session', { timeout: 5000 });
  97. } catch (adminCheckError) {
  98. writeLog('warn', '操作可能需要管理员权限');
  99. return {
  100. success: false,
  101. error: '该操作需要管理员权限,请以管理员身份运行程序后重试',
  102. requiresAdmin: true
  103. };
  104. }
  105. }
  106. const { stdout, stderr } = await execAsync(command, { timeout: 10000 });
  107. writeLog('info', `系统命令执行成功: ${command}`);
  108. if (stderr && stderr.trim()) {
  109. writeLog('warn', `系统命令有警告信息: ${stderr}`);
  110. }
  111. return { success: true, stdout, stderr };
  112. } catch (error) {
  113. let errorMessage = error.message;
  114. // 特定错误类型的处理
  115. if (error.code === 'EACCES') {
  116. errorMessage = '权限不足,请以管理员身份运行程序';
  117. } else if (error.code === 'ENOENT') {
  118. errorMessage = '系统命令不存在或路径错误';
  119. } else if (error.code === 'ETIMEDOUT') {
  120. errorMessage = '操作超时,请重试';
  121. }
  122. writeLog('error', `系统命令执行失败: ${command}, 错误: ${errorMessage}`);
  123. return { success: false, error: errorMessage };
  124. }
  125. };
  126. // -------------- IPC --------------
  127. ipcMain.handle('write-log', async (_, level, msg) => {
  128. writeLog(level, msg);
  129. });
  130. // 退出应用
  131. ipcMain.handle('exit-close', async () => {
  132. writeLog('info', '用户选择关闭应用程序');
  133. app.quit();
  134. return { success: true };
  135. });
  136. // 关机
  137. ipcMain.handle('exit-shutdown', async () => {
  138. writeLog('info', '用户选择关机');
  139. const commands = getSystemCommands();
  140. // 关机通常需要管理员权限
  141. const requiresAdmin = true;
  142. return await executeSystemCommand(commands.shutdown, requiresAdmin);
  143. });
  144. // ========== 打印相关 IPC 处理器 ==========
  145. /**
  146. * 打印HTML内容
  147. */
  148. // 工具:一次性事件监听
  149. const once = (emitter, name) =>
  150. new Promise(resolve => emitter.once(name, resolve));
  151. // 工具:生成不二次编码的 data URL
  152. const makeDataURL = html =>
  153. `data:text/html;charset=utf-8,${Buffer.from(html, 'utf-8')}`;
  154. ipcMain.handle('print-film', async (event, options) => {
  155. let printWindow = null;
  156. try {
  157. writeLog('info', `开始打印,选项: ${JSON.stringify(options)}`);
  158. const { html, orientation, paperSize, silent, margins } = options;
  159. // 1. 创建隐藏窗口
  160. printWindow = new BrowserWindow({
  161. show: true, // true 仅调试时打开
  162. webPreferences: {
  163. nodeIntegration: false,
  164. contextIsolation: false,
  165. //sandbox: true
  166. }
  167. });
  168. // 2. 加载 HTML
  169. const loaded = once(printWindow.webContents, 'did-finish-load');
  170. await printWindow.loadURL(makeDataURL(html));
  171. await loaded;
  172. // 3. 打印参数
  173. const printOpts = {
  174. silent: Boolean(silent),
  175. printBackground: true,
  176. color: true,
  177. margins: {
  178. marginType: 'custom',
  179. top: margins?.top ?? 10,
  180. bottom: margins?.bottom ?? 10,
  181. left: margins?.left ?? 10,
  182. right: margins?.right ?? 10
  183. },
  184. landscape: orientation === 'landscape',
  185. pageSize: paperSize || 'A4'
  186. };
  187. // 4. 执行打印(带 60 秒超时)
  188. const [success, reason] = await Promise.race([
  189. new Promise(res => {
  190. printWindow.webContents.print(printOpts, (ok, err) => res([ok, err]));
  191. }),
  192. new Promise((_, rej) =>
  193. setTimeout(() => rej(new Error('打印超时(60s)')), 60_000)
  194. )
  195. ]);
  196. // 5. 清理窗口
  197. if (printWindow && !printWindow.isDestroyed()) {
  198. printWindow.close();
  199. printWindow = null;
  200. }
  201. if (success) {
  202. writeLog('info', '打印成功');
  203. return { success: true };
  204. }
  205. throw new Error(reason || '打印失败');
  206. } catch (err) {
  207. writeLog('error', `打印出错: ${err.message}`);
  208. // 确保异常时窗口也关掉
  209. if (printWindow && !printWindow.isDestroyed()) {
  210. printWindow.close();
  211. }
  212. return { success: false, error: err.message };
  213. }
  214. });
  215. /**
  216. * 打印HTML内容到文件(使用File System API)
  217. */
  218. ipcMain.handle('print-film-to-file', async (event, options) => {
  219. let printWindow = null;
  220. const tempFiles = [];
  221. try {
  222. writeLog('info', `开始文件系统打印,选项: ${JSON.stringify(options)}`);
  223. const { html, imageDataList, orientation, paperSize, silent, margins } = options;
  224. // 1. 创建临时图片文件
  225. const fs = await import('fs/promises');
  226. const os = await import('os');
  227. const path = await import('path');
  228. const { fileURLToPath, pathToFileURL } = await import('url');
  229. const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'electron-print-'));
  230. // 当前时间戳用于文件名
  231. const timestamp = Date.now();
  232. // 处理每个图片数据
  233. const imageUrlMap = new Map();
  234. for (let i = 0; i < imageDataList.length; i++) {
  235. const { canvasId, data: base64Data } = imageDataList[i];
  236. // 将base64转换为buffer
  237. const buffer = Buffer.from(base64Data, 'base64');
  238. // 生成临时文件路径
  239. const tempFilePath = path.join(tempDir, `print-image-${timestamp}-${i}.png`);
  240. // 写入文件
  241. await fs.writeFile(tempFilePath, buffer);
  242. tempFiles.push(tempFilePath);
  243. writeLog('info', `临时图片文件已保存: ${tempFilePath}`);
  244. // 使用Node.js内置方法构造file:// URL
  245. const fileUrl = pathToFileURL(tempFilePath).href;
  246. writeLog('info', `File URL构造: ${fileUrl}`);
  247. writeLog('info', `原始路径: ${tempFilePath}`);
  248. // 记录文件路径映射
  249. imageUrlMap.set(canvasId, fileUrl);
  250. }
  251. // 2. 生成最终HTML,将占位符替换为文件URL
  252. let finalHtml = html;
  253. for (const [canvasId, fileUrl] of imageUrlMap.entries()) {
  254. finalHtml = finalHtml.replace(
  255. new RegExp(`<img id="${canvasId}"[^>]*>`, 'g'),
  256. `<img id="${canvasId}" src="${fileUrl}" style="max-width: 100%;" alt="打印图片" />`
  257. );
  258. }
  259. writeLog('info', `HTML占位符已替换完成,临时文件数量: ${tempFiles.length}`);
  260. // 3. 包装完整的HTML文档
  261. const fullHtml = `
  262. <!DOCTYPE html>
  263. <html>
  264. <head>
  265. <meta charset="UTF-8">
  266. <title>打印胶片</title>
  267. <style>
  268. * {
  269. margin: 0;
  270. padding: 0;
  271. box-sizing: border-box;
  272. }
  273. body {
  274. width: 100%;
  275. height: 100%;
  276. background: white;
  277. }
  278. @page {
  279. size: ${paperSize} ${orientation};
  280. margin: ${margins?.top ?? 10}mm ${margins?.right ?? 10}mm ${margins?.bottom ?? 10}mm ${margins?.left ?? 10}mm;
  281. }
  282. .print-only {
  283. display: block !important;
  284. }
  285. .screen-only {
  286. display: none !important;
  287. }
  288. </style>
  289. </head>
  290. <body>
  291. 235689
  292. <div id="film-print-area">
  293. ${finalHtml}
  294. </div>
  295. </body>
  296. </html>
  297. `;
  298. // 4. 创建隐藏窗口(调试时可以设为true查看窗口内容)
  299. printWindow = new BrowserWindow({
  300. show: true, // 设为true调试内容
  301. webPreferences: {
  302. nodeIntegration: false,
  303. contextIsolation: false,
  304. }
  305. });
  306. writeLog('info', '打印窗口已创建,开始加载HTML');
  307. writeLog('info',`把完整的html文件保存起来:${fullHtml}`)
  308. // 5. 加载 HTML
  309. const loaded = once(printWindow.webContents, 'did-finish-load');
  310. // const htmlDataURL = makeDataURL(fullHtml);
  311. // await printWindow.loadURL(htmlDataURL);
  312. // 1. 把完整 HTML 写入临时文件
  313. const tmpHtml = join(tmpdir(), `electron-print-${Date.now()}.html`);
  314. await writeFileSync(tmpHtml, fullHtml, 'utf8');
  315. // 2. 用 file:// 协议加载
  316. await printWindow.loadURL(`file://${tmpHtml.replace(/\\/g, '/')}`);
  317. await loaded;
  318. writeLog('info', 'HTML加载完成,等待图片加载...');
  319. // 6. 等待所有图片加载完成(重要的是这个步骤)
  320. await new Promise((resolve, reject) => {
  321. let loadedCount = 0;
  322. const totalImages = imageDataList.length;
  323. if (totalImages === 0) {
  324. // 没有图片,直接继续
  325. resolve();
  326. return;
  327. }
  328. const checkLoaded = () => {
  329. loadedCount++;
  330. writeLog('info', `图片已加载 ${loadedCount}/${totalImages}`);
  331. if (loadedCount >= totalImages) {
  332. writeLog('info', '所有图片加载完成');
  333. resolve();
  334. }
  335. };
  336. printWindow.webContents.on('console-message', (event, level, message) => {
  337. // 监控图片加载的调试信息
  338. if (message.includes('图片加载完成')) {
  339. checkLoaded();
  340. }
  341. });
  342. // 设置超时(20秒)
  343. setTimeout(() => {
  344. writeLog('warn', `图片加载超时 (已加载 ${loadedCount}/${totalImages}),仍继续打印`);
  345. resolve();
  346. }, 10000);
  347. });
  348. // 7. 执行打印
  349. const printOpts = {
  350. silent: false,//Boolean(silent),
  351. printBackground: true,
  352. color: true,
  353. margins: {
  354. marginType: 'custom',
  355. top: margins?.top ?? 10,
  356. bottom: margins?.bottom ?? 10,
  357. left: margins?.left ?? 10,
  358. right: margins?.right ?? 10
  359. },
  360. landscape: orientation === 'landscape',
  361. pageSize: paperSize || 'A4'
  362. };
  363. writeLog('info', '开始执行打印');
  364. const [success, reason] = await Promise.race([
  365. new Promise( res => {
  366. // 使用 printDialog 显示对话框
  367. // printWindow.webContents.printDialog(printOpts);
  368. printWindow.webContents.print(printOpts, (ok, err) => res([ok, err]));
  369. }),
  370. new Promise((_, rej) =>
  371. setTimeout(() => rej(new Error('打印超时(60s)')), 60_000)
  372. )
  373. ]);
  374. // 8. 清理临时文件
  375. // try {
  376. // for (const tempFile of tempFiles) {
  377. // await fs.unlink(tempFile);
  378. // writeLog('info', `临时文件已删除: ${tempFile}`);
  379. // }
  380. // // 删除临时目录
  381. // await fs.rmdir(tempDir, { recursive: true });
  382. // writeLog('info', `临时目录已删除: ${tempDir}`);
  383. // } catch (cleanupError) {
  384. // writeLog('warn', `清理临时文件失败: ${cleanupError.message}`);
  385. // }
  386. if (success) {
  387. writeLog('info', '文件系统打印成功');
  388. return { success: true };
  389. }
  390. throw new Error(reason || '打印失败');
  391. } catch (err) {
  392. writeLog('error', `文件系统打印出错: ${err.message}`);
  393. // 清理资源
  394. if (printWindow && !printWindow.isDestroyed()) {
  395. printWindow.close();
  396. }
  397. // // 清理临时文件
  398. // try {
  399. // const fs = await import('fs/promises');
  400. // for (const tempFile of tempFiles) {
  401. // try {
  402. // await fs.unlink(tempFile);
  403. // } catch (e) {
  404. // // 忽略单个文件删除错误
  405. // }
  406. // }
  407. // } catch (cleanupError) {
  408. // writeLog('warn', `清理临时文件失败: ${cleanupError.message}`);
  409. // }
  410. return { success: false, error: err.message };
  411. }
  412. });
  413. /**
  414. * 导出为PDF
  415. */
  416. ipcMain.handle('print-to-pdf', async (event, options) => {
  417. try {
  418. writeLog('info', `开始导出PDF,选项: ${JSON.stringify(options)}`);
  419. const { html, orientation, paperSize, margins } = options;
  420. // 创建隐藏窗口
  421. const pdfWindow = new BrowserWindow({
  422. show: false,
  423. webPreferences: {
  424. nodeIntegration: false,
  425. contextIsolation: true,
  426. },
  427. });
  428. // 加载HTML内容
  429. await pdfWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
  430. // 等待加载完成
  431. await new Promise(resolve => {
  432. pdfWindow.webContents.on('did-finish-load', resolve);
  433. });
  434. // PDF选项
  435. const pdfOptions = {
  436. printBackground: true,
  437. landscape: orientation === 'landscape',
  438. pageSize: paperSize || 'A4',
  439. margins: {
  440. top: margins?.top || 10,
  441. bottom: margins?.bottom || 10,
  442. left: margins?.left || 10,
  443. right: margins?.right || 10,
  444. },
  445. };
  446. // 生成PDF
  447. const data = await pdfWindow.webContents.printToPDF(pdfOptions);
  448. // 关闭窗口
  449. pdfWindow.close();
  450. // 显示保存对话框
  451. const { filePath } = await dialog.showSaveDialog(win, {
  452. title: '保存PDF',
  453. defaultPath: `film-${Date.now()}.pdf`,
  454. filters: [
  455. { name: 'PDF文件', extensions: ['pdf'] }
  456. ]
  457. });
  458. if (filePath) {
  459. const fs = await import('fs/promises');
  460. await fs.writeFile(filePath, data);
  461. writeLog('info', `PDF已保存到: ${filePath}`);
  462. // 询问是否打开文件
  463. const { response } = await dialog.showMessageBox(win, {
  464. type: 'info',
  465. message: 'PDF导出成功',
  466. detail: `文件已保存到:\n${filePath}`,
  467. buttons: ['打开文件', '关闭'],
  468. defaultId: 0
  469. });
  470. if (response === 0) {
  471. shell.openPath(filePath);
  472. }
  473. return { success: true, filePath };
  474. }
  475. return { success: false, error: '用户取消保存' };
  476. } catch (error) {
  477. writeLog('error', `导出PDF出错: ${error.message}`);
  478. return { success: false, error: error.message };
  479. }
  480. });