涉及技术栈:Vue + Electron
本期分享的是偏业务性,各位彦祖看个单纯思路就好,可能在真实业务中写法各有千秋
画笔、橡皮擦、撤销功能简易 demo 请移步gitee.com/song-xinyua…
业务背景:
在课堂上,老师会通过课件进行讲解,并会有对应的笔记记录。为了方便学生课后复习,这些课件和笔记在课后将被保存并发送给学生进行留存。整个流程如图所示。
前提:
在开始之前,我们需要先普及一下Electron中的进程概念。
在Electron框架中,主进程(Main Process)和渲染进程(Renderer Process)是两个核心概念,每个都有不同的职责和特性。以下是它们的主要区别:
主进程(Main Process)
职责:
负责创建和管理所有的渲染进程。
处理与操作系统相关的任务,如文件系统访问、原生菜单、对话框、剪贴板操作等。
负责应用的生命周期,管理应用事件(如启动、关闭等)。
运行环境:
运行在Node.js环境中,因此具有完全的Node.js API访问权限。
可以使用Electron的主进程API。
与渲染进程的通信:
使用IPC(进程间通信)模块进行通信,主要通过`ipcMain`(主进程)和`ipcRenderer`(渲染进程)模块。
可以通过`BrowserWindow`类创建并管理渲染进程。
单例:
Electron应用只有一个主进程,主进程启动后创建并管理多个渲染进程。
渲染进程(Renderer Process)
职责:
负责处理应用的用户界面。
每个Electron窗口(如`BrowserWindow`)都运行在一个独立的渲染进程中。
渲染进程主要用于渲染网页和处理用户交互。
运行环境:
运行在一个包含Node.js和Chromium环境的混合上下文中,因此可以同时访问Web APIs和Node.js APIs(通常为了安全会对Node.js访问进行限制)。
主要用于加载和渲染HTML、CSS和JavaScript文件。
与主进程的通信:
使用`ipcRenderer`模块与主进程通信。
可以通过`remote`模块(注意:从Electron 10起该模块被弃用,推荐使用IPC模式)访问主进程的一些功能。
多实例:
每个`BrowserWindow`实例都会创建一个新的渲染进程,因此一个应用可以有多个渲染进程,每个窗口或标签页有单独的渲染进程。
彦祖耐心看一下,这个功能挺有意思的,也不用一个逻辑一个逻辑去理解去看,大概逻辑扫一眼,主要就是主进程和渲染进程通信一下,然后就是操作图层和笔记的保存
概述
- 主进程:主要负责应用的后台逻辑和系统级操作,有且只有一个主进程,运行在Node.js环境。
- 渲染进程:主要负责用户界面的渲染和用户交互,可能有多个渲染进程,每个窗口都有自己的渲染进程,运行在Web和Node.js混合环境。
代码笔记
1、做笔记后点击保存板书
页面 demo
这块就干了两个事儿,和主进程通信获取路径,然后触发事件
<div
class="common-wrap"
@click="saveScreenshot"
>
<i class="common-icon scheduleGray"></i>
<span class="font">保存板书</span>
</div>
import { ipcRenderer } from 'electron'
mounted() {
ipcRenderer.on('screenshot-saved', (event, folderPath) => {
this.$emit('saveScreenshot', folderPath)
});
},
methods: {
saveScreenshot() {
if (!this.courseDatas.length) {
// 暂无课件导出
this.$message.warning('暂无课件导出')
return
}
ipcRenderer.send('save-screenshot', this.courseDatas)
},
}
主进程模块 handleIpcMain.js
ipcMain 和 ipcRenderer 是 Electron 的两个模块,用于在主进程和渲染进程之间进行通信。
// 主进程代码
import { ipcMain, dialog } from 'electron'
import path from 'path'
// handleIpcMain.js
const ipc = win => {
ipcMain.on('save-screenshot', async (event, courseDatas) => {
try {
// 打开一个对话框,让用户选择保存截图的目录
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory']
});
// 如果用户取消了选择,不执行任何操作
if (result.canceled) return;
// 获取用户选择的目录路径
const savePath = result.filePaths[0];
const folderPath = path.join(savePath);
// 告知渲染进程,截图保存路径已生成
event.sender.send('screenshot-saved', folderPath);
} catch (error) {
console.error('Error during save-screenshot process:', error);
}
});
}
export default ipc
主进程模块 main.js
app:控制应用生命周期的模块。BrowserWindow:创建和管理浏览器窗口的模块。ipcMain:主进程中的事件模块,用于接受来自渲染进程的异步和同步消息。screen:提供有关显示器的信息。version:从package.json导入应用的版本信息。ipc:导入自定义的handleIpcMain模块,用于处理 IPC 事件(需要自行实现)。
// main.js
import { app, BrowserWindow, ipcMain, screen } from 'electron'
const { version } = require('../../package.json')
import ipc from './handleIpcMain'
import path from 'path'
//创建窗口
async function createWindow() {
const exeName = path.basename(process.execPath)
app.setLoginItemSettings({
// 设置为true注册开机自起
openAtLogin: false,
openAsHidden: false, //macOs
path: process.execPath,
args: ['--processStart', `"${exeName}"`],
})
const { width, height } = screen.getPrimaryDisplay().workAreaSize
mainWindow = new BrowserWindow({
width: width,
height: height,
resizable: false, // 是否可以改变窗口大小
minimizable: true, // 是否可以最小化
maximizable: true, //是否可以最大化
useContentSize: true,
frame: false, // 无边框窗口
webPreferences: {
enableRemoteModule: true,
nodeIntegration: true,
contextIsolation: false,
defaultFontFamily: { sansSerif: 'Microsoft YaHei' },
backgroundThrottling: false,
webSecurity: false,
webviewTag: true,
},
show: false,
title:
process.env.NODE_ENV !== 'development' ? `xxxx ${version}` : `xxxx ${version}-测试版`,
})
// 启用自定义的 IPC 事件处理
ipc(mainWindow)
}
2、路径选择、以便更快捷的找到对应数据
到这里就实现了第一步、点击保存板书,渲染进程与主进程通信,用户选择路径信息,主进程讲路径信息发送给渲染进程
导出事件主函数
主要是开发起来涉及到多个分散点,需要考虑到只提供一个相关逻辑仅供参考
// 主动导出课件页 函数
async saveScreenshot (folderPath) {
let promises = []
this.saveCanvasData().then(
value => {
// 函数存到stroe里面,记录是什么操作
this.changeCourseState({ courseState: 'saved' })
this.changeCourse(this.nowIndex)
},
error => { },
)
setTimeout(async () => {
for (let i = 0; i < this.courseDatas.length; i++) {
promises.push(this.saveShowScheduleImage(i, this.courseDatas[i], folderPath))
}
let resultLocal
try {
resultLocal = await Promise.all(promises)
} catch (error) {
resultLocal = []
}
if (resultLocal.length) {
this.$message.success('导出成功')
} else {
this.$message.error('导出异常')
}
}, 300)
},
导出主页面
canvasPanel 这个组件是 页脚
courseware-content.vue 这个组件是 课件主体
保存笔记数据 (saveCanvasData):
saveCanvasData是一个保存用户笔记的函数,返回一个Promise对象。- 逻辑判定
this.currentPage && this.currentPage.state === false确保当前页面存在且处于某种状态(比如未保存)。 - 如果符合条件,程序会进行以下步骤:
-
preCourse用于存储当前的课程 ID。cutScreenHide()被调用以执行某种屏幕相关的操作(可能是暂停或隐藏)。- 使用
this.$nextTick延迟执行,以确保 DOM 完成更新后再继续。 setTimeout确保在 200 毫秒后调用this.$refs.canvasPanel.saveData保存数据。在保存数据成功后,调用cutScreenShow()恢复之前屏幕的操作状态。
- 若不符合条件,
pageTurnDiableFlag被重置为false并立刻resolve。
辅助屏幕控制 (cutScreenHide):
-
- 用于在特定操作时隐藏某些屏幕显示。
this.noscreening被设置为false,随后2秒后定时器将this.noscreening设为true。
更改课程 (changeCourse):
-
changeCourse方法用于切换当前显示的课程。this.setNowIndex({ nowIndex: index }):更新当前的课程索引。- 使用
this.$nextTick确保变更后的 DOM 更新完成,清除当前画布内容(通过调用this.$refs.canvasPanel.clear())。 - 加载新课程的数据:
this.$refs.canvasPanel.showDataById(currentCourse, ...),在数据加载完毕后,调用回调函数重置pageTurnDiableFlag。
<canvasPanel ref="canvasPanel" @saveScreenshot="saveScreenshot" />
<courseware-content ref="coursewareContent" />
// 截图函数封装
saveShowScheduleImage(screenIndex, dirName, folderPath) {
// screenIndex:当前屏幕的索引。
// dirName:存放截图的目录名称。
// folderPath:存放截图的文件路径。
return new Promise((resolve, reject) => {
// 从数组courseDatas中获取当前索引对应的页面信息
let page = this.courseDatas[screenIndex]
// 获取课程 ID。
let picId = page.courseId
// page.imgurl:获取图片的 URL
let url = page.imgurl
// 获取页面 ID。
let pageId = page.pageId
// 生成图片名称,对应当前的索引,确保名称在两位数时用前导零补齐
let picName = `0${String(screenIndex + 1).padStart(2, '0')}.png`
// 调用截图方法:
this.$refs.canvasPanel.saveShowScheduleImage(
picId,
url,
dirName,
picName,
localPath => {
this.changeCourseState({
courseState: true,
screenIndex: screenIndex,
})
let result = null
if (localPath) {
result = {
id: pageId,
localPath: localPath,
}
}
resolve(result)
},
() => {
resolve(null)
},
folderPath,
this.$route.query.lesson_name,
)
})
},
// 保存笔记数据
saveCanvasData() {
return new Promise((resolve, reject) => {
if (this.currentPage && this.currentPage.state === false) {
let preCourse = this.currentPage.courseId
this.cutScreenHide()
this.$nextTick(() => {
setTimeout(() => {
this.$refs.canvasPanel.saveData(preCourse, () => {
this.cutScreenShow()
resolve()
})
}, 200)
})
} else {
this.pageTurnDiableFlag = false
resolve()
}
})
},
cutScreenHide() {
this.noscreening = false
this.noscreeningTimer = window.setTimeout(() => {
this.noscreening = true
}, 3000)
},
// 实际跳页逻辑
changeCourse(index) {
this.setNowIndex({ nowIndex: index })
// TODO 预制互动信息读取
this.$nextTick(() => {
this.$refs.canvasPanel.clear()
let currentCourse = this.currentPage.courseId
this.$refs.canvasPanel.showDataById(currentCourse, res => {
this.pageTurnDiableFlag = false // 是否可以翻页标志
})
})
},
canvas.js
// canvas.js 页面 其实还是会讲这个方法拆出去,但是放到这儿会显得比较乱就这样写啦~
import { getCurrentWindow } from '@electron/remote'
this.win = getCurrentWindow() // window对象
this.historyDatas = {} // 整个课件数据存储
saveShowScheduleImage(id, url = null, dir, name, cb = null, errcb = null, folderPath, lesson_name) {
id:图像对应的 ID。
url:图像的 URL 地址(可选)。
dir:保存图像的目录。
name:图像文件名。
cb:成功回调函数(可选)。
errcb:失败回调函数(可选)。
folderPath:文件保存路径。
lesson_name:课程名称。
let pageData = this.historyDatas[id]
if (pageData && pageData.screenData) {
let screenData = pageData.screenData
if (screenData) {
this.saveShowSchedulePng(screenData, null, dir, name, cb, errcb, folderPath, lesson_name)
}
} else if (url) {
this.saveShowSchedulePng(null, url, dir, name, cb, errcb, folderPath, lesson_name)
} else {
if (errcb) {
errcb()
}
}
}
async saveShowSchedulePng(data, url, lectureTitle, baseFileName, cb, errcb, baseFolder, lesson_name) {
const name = lesson_name || ''
try {
const folderName = `${name}板书-${this.formatDate(new Date())}`;
const folderPath = path.resolve(baseFolder, folderName);
if (!fs.existsSync(folderPath)) {
fs.mkdirSync(folderPath, { recursive: true });
}
let fileIdx = baseFileName ? parseInt(baseFileName) : 1;
let fileName = `0${String(fileIdx).padStart(2, '0')}.jpg`;
let imagePath = path.resolve(folderPath, fileName);
// Ensure unique filename
let suffix = 0;
while (fs.existsSync(imagePath)) {
suffix++;
fileName = `0${String(fileIdx).padStart(2, '0')}(${suffix}).jpg`;
imagePath = path.resolve(folderPath, fileName);
}
if (url && !data) {
try {
await this.downloadImage(url, imagePath);
if (cb) cb(imagePath);
} catch (err) {
console.error(`Error downloading image from ${url}: ${err.message}`);
if (errcb) errcb(err);
}
} else if (data) {
const dataBuffer = data.toJPEG(100);
fs.writeFileSync(imagePath, dataBuffer);
if (cb) cb(imagePath);
} else {
if (cb) cb(null);
}
} catch (err) {
console.error(`Error in savePng: ${err.message}`);
if (errcb) errcb(err);
}
}
saveData(id, cb, index) {
// 保存笔记和课件背景数据,向课件发送消息
logger.info('保存笔记id', id)
let currentPageData = {
historyIndex: this.historyIndex,
eles: this.eles,
screenData: null,
index,
}
this.historyDatas[id] = currentPageData
if (currentPageData.index > 3) {
for (let i = 1; i < currentPageData.index - 2; i++) {
Object.keys(this.historyDatas).forEach(item => {
if (this.historyDatas[item].index === i) {
this.historyDatas[item].eles = null
}
})
}
}
let callback = cb || null
this.win.capturePage()
.then(e => {
currentPageData.screenData = e
if (callback) {
callback()
}
})
.catch(err => {
logger.error('截图失败', err)
logger.error('截图失败', JSON.stringify(err))
if (callback) {
callback()
}
})
}
showDataById(id) {
logger.info('打开笔记id', id)
let currentPageData = this.historyDatas[id] || {}
if (isNaN(this.historyIndex)) {
this.historyIndex = -1
}
}
store.js
// store courseware.js
import _ from 'lodash'
const state = {
currentCourseData: {}, // 所有的课件信息对象 { "currentLectureId": { 'course': [{ lectureUrl, name, blank, courseId, state, pagetype }, {}]} , 'blankNum': number, 'isold': true }
currentLectureId: 'init', // 标识当前课件是allCourseData的哪一个对象
nowIndex: -1, // 标识当前页是课件的那一页
}
changeCourseState({ commit }, payload) {
commit(mutTypes.CHANGE_COURSESTATE, payload)
},
// 修改当前课件页笔记存储状态
[mutTypes.CHANGE_COURSESTATE](state, { courseState, screenIndex }) {
const allDataTemp = _.cloneDeep(state.currentCourseData)
const tmpCurrentCourse = allDataTemp[state.currentLectureId]
let tmpIndex = state.nowIndex
if (screenIndex !== undefined) {
tmpIndex = parseInt(screenIndex)
}
if (tmpCurrentCourse && tmpCurrentCourse['course']) {
tmpCurrentCourse['course'][tmpIndex].state = courseState
state.currentCourseData = allDataTemp
}
}
3、查看保存文件、以当前课件+时间命名
4、正确展示所有课件和笔记板书、可重复保存
到此结束,各位彦祖 多嘴一句electron还是很强大的,他有着强大的生态系统、跨平台支持、强大的生态系统、多进程架构、高性能 还等什么呢彦祖,搞起来