104 KiB
自然写互动课堂PC端应用软件 V1.0
软件鉴别材料 — 用户操作手册与设计说明书
软件全称:自然写互动课堂PC端应用软件
软件版本:V1.0
权利人:深圳自然写科技有限公司
文档类型:PC桌面应用用户操作手册 + 设计说明书
文档编号:WRITECH-APP-PC-DS-001
编制日期:2026年2月
适用平台:Windows 10/11 64位 / macOS 12 Monterey 及以上
目录
- 第一章 软件整体概述
- 第二章 系统架构与设计思路
- 第三章 核心模块功能详细说明
- 第四章 操作流程与使用步骤
- 第五章 与源代码的对应关系
- 附录
第一章 软件整体概述
1.1 软件简介与功能综述
自然写互动课堂PC端应用软件(以下简称"PC APP")是自然写互动课堂系统面向教师的桌面端综合教学工具,支持Windows和macOS双平台。PC APP基于Electron + Vue.js 3框架开发,通过充分利用桌面端的大屏幕、高性能处理器和丰富的外设接口(USB/蓝牙),提供备课制作、课堂授课、作业批改、数据管理等完整教学工作流。
PC APP是整个互动课堂系统中功能最完整的客户端,也是教师日常备课和课堂教学的核心工具。相较于手机APP,PC APP提供了更强大的课件制作功能、更详细的数据分析视图和更流畅的投屏操控体验。
主要功能模块综述:
| 功能模块 | 说明 |
|---|---|
| 备课工具 | 课件制作(类PPT功能)、试卷编辑、字帖模板设计 |
| 课堂授课 | 实时接收全班笔迹、互动答题、随机抽查、展示控制 |
| 作业管理 | 发布/回收作业,查看AI批改结果,人工批改标注 |
| 笔迹回放分析 | 以时间轴方式回放任意学生的书写过程 |
| 班级数据管理 | 班级成绩统计、知识点掌握情况、学情趋势 |
| 点阵码编辑 | 自定义点阵码内容设计,生成可打印点阵作业纸 |
| 投屏控制 | 将PC画面镜像投射至智慧黑板/电视 |
| 设备连接 | USB有线或BLE无线连接点阵笔 |
1.2 软件用途与适用场景
备课场景
教师在课前使用PC APP进行备课:
- 从资源库导入字帖模板和试卷模板,编辑自定义内容
- 设计互动题目(选择题、填空题、写字题)并预设标准答案
- 生成含点阵码的作业纸PDF,发送给学校打印室打印
- 将备课内容发布至班级,学生Pad端自动接收
课堂授课场景
课堂进行中,教师在讲台PC上使用PC APP:
- 开启课堂模式,大屏分割视图展示全班实时书写状态
- 点击任意学生小窗口放大查看该学生的书写详情
- 通过PC投屏至智慧黑板,展示选中学生作品供全班对比
- 发布互动答题,倒计时收卷,实时展示答题统计
批改与分析场景
课后教师使用PC APP进行数据分析:
- 批量查看AI批改结果,快速标注需人工复核的题目
- 查看班级知识点掌握雷达图,识别共性薄弱点
- 导出成绩单(CSV/Excel格式)上传至学校教务系统
- 生成家长学情报告并批量推送
1.3 运行环境与系统要求
Windows平台:
| 配置项 | 最低要求 | 推荐配置 |
|---|---|---|
| 操作系统 | Windows 10(64位,版本1903) | Windows 11 |
| 处理器 | Intel Core i5(4核) | Intel Core i7/i9 或 AMD Ryzen 7 |
| 内存 | 8GB RAM | 16GB RAM |
| 显卡 | 支持WebGL 2.0的独显/集显 | NVIDIA / AMD独立显卡 |
| 存储 | SSD 10GB可用空间 | SSD 50GB可用空间 |
| 网络 | 百兆以太网或WiFi 5 | 千兆以太网或WiFi 6 |
| 蓝牙 | BLE 4.0(可选,笔连接) | BLE 5.0 |
| USB | USB 2.0(用于笔连接) | USB 3.0 |
| 显示器 | 1920×1080 | 2560×1440 双屏 |
macOS平台:
| 配置项 | 最低要求 | 推荐配置 |
|---|---|---|
| 操作系统 | macOS 12 Monterey | macOS 14 Sonoma |
| 处理器 | Intel Core i5 或 Apple M1 | Apple M2/M3 |
| 内存 | 8GB | 16GB |
| 存储 | 10GB可用空间 | 50GB可用空间 |
1.4 开发语言与技术规范
主要技术栈:
| 技术 | 版本 | 用途 |
|---|---|---|
| Electron | 28.0.0 | 跨平台桌面应用框架 |
| Node.js | 20.x LTS | 主进程运行环境 |
| Vue.js | 3.4.0 | 渲染进程UI框架 |
| TypeScript | 5.3.0 | 类型安全的JavaScript超集 |
| Pinia | 2.1.7 | Vue 3状态管理 |
| Vite | 5.0.0 | 前端构建工具(渲染进程) |
| Axios | 1.6.2 | HTTP请求库 |
| WebSocket(ws) | 8.16.0 | 实时通信(主进程) |
| SQLite(better-sqlite3) | 9.4.3 | 本地数据库(主进程) |
| IndexedDB(Dexie.js) | 3.2.4 | 渲染进程大容量存储 |
| Canvas 2D + WebGL | 浏览器原生 | 笔迹渲染引擎 |
| C++ Addon(Node-API) | 最新 | 高性能笔迹平滑算法、USB通信 |
| node-bluetooth | 1.1.4 | BLE点阵笔连接 |
| node-usb | 2.11.0 | USB HID设备访问 |
| WebRTC | 渲染进程原生 | 投屏协议 |
| electron-updater | 6.1.7 | 自动更新 |
Electron IPC通信架构:
PC APP采用Electron的主进程(Main Process)+ 渲染进程(Renderer Process)架构:
- 主进程:处理系统API调用(文件操作、USB/BLE设备通信、SQLite数据库、WebSocket连接)
- 渲染进程:Vue.js 3界面渲染,通过IPC调用主进程的功能
- Preload脚本:在渲染进程中安全暴露主进程API(使用contextIsolation保护)
1.5 版本说明
| 版本 | 日期 | 平台 | 主要变更 |
|---|---|---|---|
| V0.7 Beta | 2025年8月 | Windows/macOS | 基础备课工具、课堂收笔、作业发布 |
| V0.9 RC | 2025年11月 | Windows/macOS | 点阵码编辑、投屏功能、数据导出 |
| V1.0 | 2026年2月 | Windows/macOS | 正式版:WebGL笔迹渲染、双屏支持、AI辅助批改 |
第二章 系统架构与设计思路
2.1 Electron应用架构
┌───────────────────────────────────────────────────────────────────┐
│ Electron主进程(Main Process) │
│ Node.js + Chromium运行时 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ 窗口管理 │ │ 文件系统 │ │ 设备通信 │ │
│ │ BrowserWindow│ │ 读写操作 │ │ USB(node-usb) │ │
│ │ 菜单/托盘 │ │ 课件存储 │ │ BLE(node-bluetooth) │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ SQLite数据库 │ │ WebSocket │ │ 自动更新 │ │
│ │(better-sqlite3)│ │ 云端实时通信│ │ electron-updater │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │
│ IPC通信(ipcMain/ipcRenderer) │
├───────────────────────────────────────────────────────────────────┤
│ Preload脚本(contextBridge安全暴露) │
├───────────────────────────────────────────────────────────────────┤
│ 渲染进程(Renderer Process) │
│ Vue.js 3 + TypeScript │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ 备课工具 │ │ 课堂授课 │ │ 数据分析 │ │
│ │ Vue组件 │ │ Vue组件 │ │ Vue组件 │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ 笔迹渲染 │ │ Pinia状态 │ │
│ │ Canvas/WebGL │ │ 管理 │ │
│ └──────────────┘ └──────────────┘ │
└───────────────────────────────────────────────────────────────────┘
2.2 进程间通信设计
IPC通道规划(ipcMain / ipcRenderer):
// src/preload/index.ts — contextBridge暴露API到渲染进程
import { contextBridge, ipcRenderer } from 'electron'
contextBridge.exposeInMainWorld('electronAPI', {
// 数据库操作
db: {
query: (sql: string, params: any[]) =>
ipcRenderer.invoke('db:query', sql, params),
run: (sql: string, params: any[]) =>
ipcRenderer.invoke('db:run', sql, params),
},
// 文件操作
file: {
save: (fileName: string, data: Buffer) =>
ipcRenderer.invoke('file:save', fileName, data),
open: (filters: FileFilter[]) =>
ipcRenderer.invoke('file:open', filters),
exportPDF: (content: any) =>
ipcRenderer.invoke('file:exportPDF', content),
},
// 设备通信
device: {
scanBLE: () => ipcRenderer.invoke('device:scanBLE'),
connectBLE: (deviceId: string) =>
ipcRenderer.invoke('device:connectBLE', deviceId),
connectUSB: () => ipcRenderer.invoke('device:connectUSB'),
onInkData: (callback: (data: InkPoint[]) => void) => {
ipcRenderer.on('device:inkData', (_event, data) => callback(data))
},
},
// 投屏控制
cast: {
startCasting: (targetInfo: CastTarget) =>
ipcRenderer.invoke('cast:start', targetInfo),
stopCasting: () => ipcRenderer.invoke('cast:stop'),
},
// 窗口控制
window: {
openLessonWindow: () => ipcRenderer.invoke('window:openLesson'),
enterPresentation: () => ipcRenderer.invoke('window:enterPresentation'),
}
})
2.3 笔迹渲染引擎设计
PC APP使用WebGL + C++ Native Addon实现高性能笔迹渲染,支持压感效果(根据压力值变化线宽)和笔锋效果(笔画首尾尖细):
// src/renderer/rendering/StrokeRenderer.ts
export class StrokeRenderer {
private gl: WebGL2RenderingContext
private program: WebGLProgram
private vertexBuffer: WebGLBuffer
constructor(canvas: HTMLCanvasElement) {
this.gl = canvas.getContext('webgl2')!
this.initShaders()
this.vertexBuffer = this.gl.createBuffer()!
}
private initShaders() {
// 顶点着色器:根据压感值计算线宽
const vertexShader = `#version 300 es
in vec2 a_position;
in float a_pressure;
in float a_segment_pos; // 0=起点, 1=终点(用于笔锋计算)
uniform mat4 u_projection;
uniform float u_base_width;
out float v_pressure;
void main() {
// 笔锋效果:首尾收细(sigmoid曲线模拟)
float taper = min(a_segment_pos * 4.0, (1.0 - a_segment_pos) * 4.0);
taper = clamp(taper, 0.0, 1.0);
// 最终线宽 = 基础宽度 × 压感 × 笔锋系数
float width = u_base_width * a_pressure * (0.3 + 0.7 * taper);
// 沿法线方向扩展(宽度扩张为几何体)
gl_PointSize = width;
gl_Position = u_projection * vec4(a_position, 0.0, 1.0);
v_pressure = a_pressure;
}
`
// 片段着色器:抗锯齿圆点渲染
const fragmentShader = `#version 300 es
precision mediump float;
in float v_pressure;
out vec4 fragColor;
void main() {
// 圆形点(通过gl_PointCoord实现圆角)
vec2 coord = gl_PointCoord - vec2(0.5);
float r = length(coord);
float alpha = 1.0 - smoothstep(0.4, 0.5, r); // 边缘抗锯齿
fragColor = vec4(0.1, 0.1, 0.1, alpha); // 深灰色笔迹
}
`
this.program = this.createShaderProgram(vertexShader, fragmentShader)
}
// 绘制一条笔画(由多个坐标点构成)
drawStroke(points: StrokePoint[]) {
if (points.length < 2) return
const gl = this.gl
gl.useProgram(this.program)
// 构建顶点数据(每点:x, y, pressure, segment_pos)
const vertices = new Float32Array(points.length * 4)
const totalLength = points.length - 1
for (let i = 0; i < points.length; i++) {
const p = points[i]
vertices[i * 4 + 0] = p.x
vertices[i * 4 + 1] = p.y
vertices[i * 4 + 2] = p.pressure
vertices[i * 4 + 3] = i / totalLength // segment_pos: 0→1
}
gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW)
// 绑定属性
const posLoc = gl.getAttribLocation(this.program, 'a_position')
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0)
const pressureLoc = gl.getAttribLocation(this.program, 'a_pressure')
gl.enableVertexAttribArray(pressureLoc)
gl.vertexAttribPointer(pressureLoc, 1, gl.FLOAT, false, 16, 8)
const segPosLoc = gl.getAttribLocation(this.program, 'a_segment_pos')
gl.enableVertexAttribArray(segPosLoc)
gl.vertexAttribPointer(segPosLoc, 1, gl.FLOAT, false, 16, 12)
gl.drawArrays(gl.POINTS, 0, points.length)
}
}
2.4 数据设计
SQLite数据库表(主进程,better-sqlite3):
| 表名 | 主要字段 | 说明 |
|---|---|---|
lessons |
id, title, subject, grade, content_json, created_at | 课件数据 |
assignments |
id, lesson_id, class_id, title, deadline, status | 作业/试卷 |
students |
id, class_id, name, student_no, parent_phone | 学生信息 |
submissions |
id, assignment_id, student_id, ink_data_path, score, status | 作业提交记录 |
grading_records |
id, submission_id, teacher_comment, manual_score, ai_score | 批改记录 |
dot_code_maps |
id, lesson_page_id, dot_code_range_start, dot_code_range_end | 点阵码映射 |
devices |
id, type, identifier, name, last_connected | 已连接设备记录 |
app_config |
key, value, updated_at | 应用配置键值对 |
IndexedDB存储(渲染进程,Dexie.js):
| 数据库 | 说明 |
|---|---|
inkDataDB |
大容量笔迹原始数据存储(每次作业的完整笔迹数据) |
resourceCacheDB |
资源文件本地缓存(字帖图片、课件资源) |
2.5 接口设计
云端API接口(渲染进程通过Axios调用):
| 接口 | 方法 | URL | 说明 |
|---|---|---|---|
| 登录 | POST | /api/v1/auth/login |
教师账号登录 |
| 获取班级列表 | GET | /api/v1/class/list |
获取教师管理的班级 |
| 创建作业 | POST | /api/v1/assignment/create |
发布新作业 |
| 获取提交列表 | GET | /api/v1/assignment/{id}/submissions |
获取学生提交列表 |
| 上传批改结果 | PUT | /api/v1/submission/{id}/grade |
保存批改结果 |
| 获取班级学情 | GET | /api/v1/analytics/class/{id} |
班级数据分析 |
| 生成点阵码 | POST | /api/v1/dotcode/generate |
生成作业纸点阵码 |
| 资源搜索 | GET | /api/v1/resource/search |
搜索教学资源 |
| 推送报告 | POST | /api/v1/report/push/{class_id} |
批量推送学情报告给家长 |
WebSocket实时通信(主进程WebSocket):
// src/main/services/websocket-service.ts
export class WebSocketService {
private ws: WebSocket | null = null
private mainWindow: BrowserWindow
connect(classroomId: string, token: string) {
this.ws = new WebSocket(
`wss://api.writech.cn/ws/v1/classroom?id=${classroomId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
this.ws.on('message', (data: string) => {
const event = JSON.parse(data)
switch (event.type) {
case 'stroke.realtime':
// 转发笔迹数据到渲染进程
this.mainWindow.webContents.send('ws:inkData', event)
break
case 'submission.complete':
// 通知渲染进程某学生已提交
this.mainWindow.webContents.send('ws:submissionComplete', event)
break
case 'result.aiGraded':
// AI批改完成通知
this.mainWindow.webContents.send('ws:aiGradingDone', event)
break
}
})
this.ws.on('close', () => {
// 5秒后自动重连
setTimeout(() => this.connect(classroomId, token), 5000)
})
}
sendControl(type: string, payload: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, ...payload }))
}
}
}
2.6 安全设计
应用级安全:
// src/main/index.ts — 安全配置
const win = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // 启用上下文隔离(必须)
nodeIntegration: false, // 禁止渲染进程直接访问Node.js(安全)
sandbox: true, // 启用渲染进程沙箱
webSecurity: true, // 启用Web安全策略(CORS等)
allowRunningInsecureContent: false, // 禁止混合内容
}
})
// 设置CSP内容安全策略(防XSS)
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"connect-src https://api.writech.cn wss://api.writech.cn; " +
"img-src 'self' https://cdn.writech.cn data:;"
]
}
})
})
本地数据安全:
- SQLite数据库使用SQLCipher加密(密钥派生自用户登录密码哈希 + 设备指纹)
- 学生笔迹数据(IndexedDB)存储于Electron userData目录,受操作系统文件权限保护
- 课件导出PDF支持加密选项(PDF密码保护)
- 自动更新包含代码签名验证(Windows Authenticode / macOS Gatekeeper)
代码保护:
- Electron ASAR归档打包,防止直接读取源码
- 关键业务逻辑(笔迹平滑算法、点阵码解析)编译为C++ Native Addon(.node文件)
- 生产构建启用代码混淆(terser压缩)
第三章 核心模块功能详细说明
3.1 备课工具模块
源代码文件:src/renderer/features/lesson/
备课工具是PC APP的核心创作功能,提供类PPT的课件制作界面:
课件编辑器界面:
┌──────────────────────────────────────────────────────────────────────┐
│ [文件] [编辑] [插入] [格式] [课堂] [工具] [帮助] 自然写PC版 │
├───────────┬──────────────────────────────────────────┬───────────────┤
│ 页面缩略图│ 编辑区域(当前页) │ 属性面板 │
│ │ │ │
│ [第1页] │ ┌────────────────────────────────────┐ │ 文字属性: │
│ [第2页] │ │ │ │ 字体: [楷体 ▼]│
│ [第3页] │ │ 今日生字:一 大 天 地 │ │ 大小: [48 ▼] │
│ [+ 新增] │ │ │ │ │
│ │ │ [拖拽添加内容区域] │ │ 点阵码设置: │
│ │ │ │ │ [绑定点阵码] │
│ │ └────────────────────────────────────┘ │ │
├───────────┤ │ │
│ 元素库 │ [标注模式] [激光笔] [橡皮] [撤销] [重做] │ │
│ [图片] │ │ │
│ [文字框] │ │ │
│ [字帖] │ │ │
│ [题目] │ │ │
└───────────┴──────────────────────────────────────────┴───────────────┘
课件数据结构:
// src/shared/types/lesson.ts
interface LessonData {
id: string
title: string
subject: 'chinese' | 'math' | 'english'
grade: string
pages: LessonPage[]
metadata: {
createdAt: number
updatedAt: number
teacherId: string
schoolId: string
}
}
interface LessonPage {
id: string
pageIndex: number
background: string // 背景色或背景图URL
elements: PageElement[] // 页面元素(文字/图片/字帖/题目)
dotCodeRange?: { // 绑定的点阵码范围(可选)
start: string
end: string
}
speakerNote?: string // 演讲备注
}
type PageElement = TextElement | ImageElement | CalligraphyElement | QuestionElement
interface QuestionElement {
type: 'question'
id: string
questionType: 'choice' | 'fill_blank' | 'writing' | 'essay'
questionText: string
standardAnswer?: string | string[] // 标准答案
scoringRules?: ScoringRule[] // 评分规则
position: { x: number; y: number; width: number; height: number }
}
点阵码内容编辑(点阵码绑定器):
// src/renderer/features/dotcode/DotCodeBinder.vue
// 将课件页面与点阵码范围绑定,生成可打印的点阵作业纸
// 绑定逻辑:
// 1. 教师选择要打印的页面范围(如第1-3页)
// 2. 系统向云端资源平台申请点阵码范围
// 3. 为每页分配唯一的点阵码ID范围
// 4. 生成带点阵底纹的PDF(600DPI打印精度)
async function generateDotCodePDF(pages: LessonPage[]): Promise<Blob> {
// 向云端申请点阵码范围
const dotCodeRange = await api.dotcode.allocate({
pageCount: pages.length,
school_id: store.user.schoolId
})
// 生成PDF(调用主进程的PDFKit渲染)
const pdfData = await window.electronAPI.file.exportPDF({
pages,
dotCodeInfo: dotCodeRange,
resolution: 600 // 600DPI打印精度
})
return new Blob([pdfData], { type: 'application/pdf' })
}
3.2 课堂授课模块
源代码文件:src/renderer/features/classroom/
课堂主界面(三列布局):
┌────────────────────────────────────────────────────────────────┐
│ [←课堂管理] 二年级一班 — 语文课 ● 进行中 已连38笔 [结束课堂] │
├──────────────────────┬──────────────────────┬──────────────────┤
│ 课件展示区(主屏) │ 全班书写状态(小格) │ 工具栏 │
│ │ │ [发题] │
│ [课件当前页] │ [张三] [李四] [王五] │ [收卷] │
│ │ [赵六] [陈七] [周八] │ [点名] │
│ 今日生字: │ [吴九] [郑十] ··· │ [展示] │
│ 一 大 天 地 │ │ [暂停] │
│ │ 提交进度: │ │
│ [◀上一页] [下一页▶] │ ████████████░░ 30/38│ 连接状态: │
│ │ │ ● 网关 已连 │
│ [激光笔] [标注] │ [全班展示] [对比] │ ● 算力盒 就绪 │
│ [橡皮] [清除] │ │ ● 投屏 未连 │
└──────────────────────┴──────────────────────┴──────────────────┘
随机抽取学生(防重复抽取算法):
// src/renderer/features/classroom/store/classroomStore.ts
const useClassroomStore = defineStore('classroom', {
state: () => ({
students: [] as Student[],
calledStudents: new Set<string>(), // 已点名学生ID集合
}),
actions: {
randomPickStudent(excludeCalled: boolean = true) {
let candidates = this.students
if (excludeCalled && this.calledStudents.size < this.students.length) {
// 排除已点名学生(直到所有人都被点过一次)
candidates = this.students.filter(
s => !this.calledStudents.has(s.id))
} else if (this.calledStudents.size >= this.students.length) {
// 所有人都被点过,重置
this.calledStudents.clear()
}
// 随机选取(使用crypto.getRandomValues保证随机性)
const randomIndex = Math.floor(
(crypto.getRandomValues(new Uint32Array(1))[0] / 0xFFFFFFFF)
* candidates.length
)
const selected = candidates[randomIndex]
this.calledStudents.add(selected.id)
// 推送点名结果至智慧黑板展示
websocketService.sendControl('classroom.pickStudent', {
studentId: selected.id,
studentName: selected.name,
effect: 'spotlight' // 黑板端显示聚光灯特效
})
return selected
}
}
})
3.3 作业批改模块
源代码文件:src/renderer/features/grading/
批改主界面(两栏布局):
┌────────────────────────────────────────────────────────────────┐
│ [←] 第5课生字练习 — 批改 提交:38/40 已批改:25/38 │
├──────────────────────────────┬─────────────────────────────────┤
│ 学生列表 │ 当前学生批改区 │
│ │ │
│ ✓ 张三 92分 [已批改] │ 学生:王五 提交时间:08:32 │
│ ✓ 李四 88分 [已批改] │ │
│ ● 王五 -- [批改中] │ ┌─────────────────────────────┐ │
│ ○ 赵六 -- [待批改] │ │ 学生书写内容(笔迹展示) │ │
│ ○ 陈七 -- [待批改] │ │ [字1] [字2] [字3] [字4] │ │
│ ··· │ └─────────────────────────────┘ │
│ │ │
│ AI建议: │ AI分析(逐字): │
│ 王五第3字笔顺有误 │ [字1] 98分 ✓ │
│ 赵六书写规范度不足 │ [字2] 95分 ✓ │
│ │ [字3] 72分 ⚠ 第3笔顺序错误 │
│ │ [字4] 88分 ✓ │
│ │ │
│ │ 总分:[ 85 ]分(AI建议:85) │
│ │ 批注:[笔顺注意规范,字体整洁...]│
│ │ │
│ │ [采纳AI建议] [确认] [下一个▶] │
└──────────────────────────────┴─────────────────────────────────┘
AI辅助批改逻辑:
// src/renderer/features/grading/composables/useAIGrading.ts
export function useAIGrading() {
const gradeSubmission = async (submissionId: string):
Promise<AIGradingResult> => {
// 1. 获取AI批改结果(服务端已批改,直接查询)
const result = await api.grading.getAIResult(submissionId)
if (result.status === 'completed') {
return result.data
} else if (result.status === 'pending') {
// AI还在处理,轮询等待(最多等60秒)
return await pollForResult(submissionId, 60)
} else {
throw new Error('AI批改失败,请手动批改')
}
}
// 一键采纳AI建议(填入AI推荐分数)
const acceptAISuggestion = (aiResult: AIGradingResult) => {
return {
score: aiResult.totalScore,
perItemScores: aiResult.itemScores,
comment: aiResult.suggestedComment,
gradedBy: 'ai_assisted'
}
}
return { gradeSubmission, acceptAISuggestion }
}
3.4 USB/BLE点阵笔连接模块
源代码文件:src/main/services/device-service.ts
USB设备连接(Node-API C++ Addon):
// src/main/services/device-service.ts
import { createRequire } from 'module'
const require = createRequire(import.meta.url)
// 加载C++ Native Addon(实现USB HID通信和笔迹平滑)
const writechNative = require('../../native/writech_native.node')
export class DeviceService {
private usbDevice: any = null
private bleDevice: any = null
// 扫描USB点阵笔(nRF52840 USB HID模式)
async scanUSBPens(): Promise<USBDevice[]> {
const devices = writechNative.listUSBHIDDevices()
return devices.filter((d: any) =>
d.vendorId === WRITECH_VENDOR_ID &&
d.productId === WRITECH_PEN_PRODUCT_ID
)
}
// 连接USB点阵笔并开始接收数据
async connectUSBPen(devicePath: string): Promise<void> {
this.usbDevice = writechNative.openUSBHIDDevice(devicePath)
// 注册数据接收回调(C++层实现,高频调用)
writechNative.startInkReceiving(this.usbDevice, (rawData: Buffer) => {
// 解析原始HID数据包(与BLE格式兼容)
const points = this.parseInkPacket(rawData)
// 应用笔迹平滑(C++实现,保证性能)
const smoothed = writechNative.smoothStroke(points)
// 发送到渲染进程
this.mainWindow.webContents.send('device:inkData', smoothed)
})
}
private parseInkPacket(data: Buffer): InkPoint[] {
// 解析与BLE协议相同的差分编码格式
const points: InkPoint[] = []
let offset = 0
const packetType = data[offset++]
const frameCount = data[offset++]
const baseTimestamp = data.readUInt16LE(offset); offset += 2
// 第一帧:绝对坐标
const x0 = data.readUInt16LE(offset); offset += 2
const y0 = data.readUInt16LE(offset); offset += 2
const p0 = data[offset++]
const f0 = data[offset++]
points.push({ x: x0, y: y0, pressure: p0 / 255, penUp: !!(f0 & 0x01) })
// 后续帧:差分解码
let lastX = x0, lastY = y0
for (let i = 1; i < frameCount; i++) {
const flags = data[offset++]
const dx = (flags & 0x80) ? data.readInt16LE(offset) : data.readInt8(offset)
offset += (flags & 0x80) ? 2 : 1
const dy = (flags & 0x40) ? data.readInt16LE(offset) : data.readInt8(offset)
offset += (flags & 0x40) ? 2 : 1
const pressure = data[offset++]
lastX += dx
lastY += dy
points.push({
x: lastX, y: lastY,
pressure: pressure / 255,
penUp: !!(flags & 0x01)
})
}
return points
}
}
3.5 投屏控制模块
源代码文件:src/main/services/cast-service.ts
PC APP支持将当前课件/展示内容投射到智慧黑板,支持WebRTC和HDMI两种投屏方式:
// src/main/services/cast-service.ts
export class CastService {
// WebRTC投屏(无线,通过局域网)
async startWebRTCCast(boardIP: string): Promise<void> {
// 1. 获取屏幕捕获流
const captureStream = await desktopCapturer.getSources({
types: ['window'],
thumbnailSize: { width: 1920, height: 1080 }
})
const lessonWindow = captureStream.find(s =>
s.name.includes('自然写') && s.name.includes('课件'))
// 2. 创建WebRTC连接到黑板端APP
const peerConnection = new RTCPeerConnection()
const stream = await navigator.mediaDevices.getUserMedia({
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: lessonWindow!.id,
}
} as any
})
stream.getTracks().forEach(track =>
peerConnection.addTrack(track, stream))
// 3. 通过信令服务器建立连接
const offer = await peerConnection.createOffer()
await peerConnection.setLocalDescription(offer)
// 发送offer给黑板端(通过WebSocket信令)
await signalingService.sendOffer(boardIP, offer)
}
// 停止投屏
stopCasting(): void {
this.peerConnection?.close()
this.peerConnection = null
}
}
3.6 数据统计与分析模块
源代码文件:src/renderer/features/analytics/
班级学情仪表盘界面:
┌────────────────────────────────────────────────────────────────┐
│ 班级学情 — 二年级一班 本学期(2025秋季) [导出报告] │
├──────────────────┬─────────────────────────────────────────────┤
│ 总体概况 │ 各次作业成绩趋势 │
│ │ │
│ 学生人数:40 │ 100│ │
│ 完成率:96.8% │ 90│ ───── │
│ 平均分:86.2分 │ 80│ │
│ 进步率:73% │ 70└──────────────────(作业次数) │
├──────────────────┴─────────────────────────────────────────────┤
│ 知识点掌握度分析 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 知识点 掌握率 人数 状态 │ │
│ │ 1.笔顺规范 ████████░░ 82% 32人 ▼需关注 │ │
│ │ 2.字形结构 ████████░░ 85% 34人 ✓ │ │
│ │ 3.偏旁部首 ██████░░░░ 65% 26人 ⚠需强化 │ │
│ │ 4.笔画名称 █████████░ 90% 36人 ✓ │ │
│ └──────────────────────────────────────────────────────────┘ │
├────────────────────────────────────────────────────────────────┤
│ 需要关注的学生 │
│ ● 陈七 近3次作业均低于70分 [查看详情] │
│ ● 周八 笔顺正确率持续下降 [查看详情] │
└────────────────────────────────────────────────────────────────┘
3.7 自动更新模块
PC APP内置自动更新功能,通过electron-updater实现静默后台更新:
// src/main/updater.ts
import { autoUpdater } from 'electron-updater'
import { dialog, BrowserWindow } from 'electron'
export function setupAutoUpdater(mainWindow: BrowserWindow) {
// 每小时检查一次更新
autoUpdater.checkForUpdates()
setInterval(() => autoUpdater.checkForUpdates(), 60 * 60 * 1000)
autoUpdater.on('update-available', (info) => {
// 有新版本可用,通知渲染进程显示提示
mainWindow.webContents.send('updater:updateAvailable', info)
})
autoUpdater.on('update-downloaded', (info) => {
// 下载完成,询问用户是否立即安装
dialog.showMessageBox(mainWindow, {
type: 'info',
title: '更新就绪',
message: `新版本 ${info.version} 已下载完成,立即重启安装?`,
buttons: ['立即安装', '稍后安装'],
defaultId: 0,
}).then(({ response }) => {
if (response === 0) {
autoUpdater.quitAndInstall(false, true)
}
})
})
// 验证更新包签名(防恶意更新)
autoUpdater.on('before-quit-for-update', () => {
// electron-updater自动验证代码签名
})
}
第四章 操作流程与使用步骤
4.1 安装与首次启动
Windows安装:
- 下载安装包
Writech-PC-Setup-1.0.0.exe(约200MB) - 双击运行,选择安装目录(默认
C:\Program Files\Writech) - 安装过程自动注册文件关联和桌面快捷方式
- 安装完成后桌面出现"自然写互动课堂"图标
macOS安装:
- 下载
Writech-PC-1.0.0.dmg - 打开DMG,将"自然写互动课堂.app"拖拽到"应用程序"文件夹
- 首次运行时macOS提示"来自已识别开发者",点击"打开"
- 输入系统密码允许安装(需要系统管理员权限)
首次配置:
首次启动流程:
1. 欢迎界面 → [开始配置]
2. 登录账号(手机号+密码或机构账号)
3. 选择学校和年级
4. 配置连接方式(USB笔/蓝牙笔/仅网络)
5. 测试连接(可选)
6. 进入主界面
4.2 备课操作流程
创建新课件:
操作步骤:
1. 点击主界面"新建课件"(或Ctrl+N)
2. 选择模板(空白/字帖练习/试卷/互动课堂)
3. 输入课件标题、学科、年级
4. 在编辑区域添加内容:
- 插入→文字框:输入课文内容
- 插入→字帖:从资源库选择字帖模板
- 插入→题目:添加互动题目(设置标准答案)
5. 为需要作答的页面绑定点阵码(右键页面→绑定点阵码)
6. 保存(Ctrl+S)并发布到班级(文件→发布到班级)
生成作业纸:
操作步骤:
1. 打开已完成的课件
2. 文件→生成作业纸 PDF
3. 选择要打印的页面范围
4. 确认点阵码分配(系统自动申请)
5. 选择打印分辨率(建议600DPI)
6. 点击"生成PDF",保存到本地
7. 将PDF发送给学校打印室打印
4.3 课堂授课操作流程
上课前(准备阶段):
1. 打开PC APP,进入班级主页
2. 点击"开始课堂"→选择班级→选择今日课件
3. 课堂模式启动,检查连接状态(网关●/算力盒●/投屏●)
4. 投屏到智慧黑板(课堂工具栏→投屏→选择连接方式)
上课中:
1. 遥控器/键盘翻页(PgUp/PgDn)展示课件
2. 使用激光笔功能(快捷键L)在课件上标注重点
3. 发题:
a. 工具栏→发题→选择预设题目或临时出题
b. 设置作答时限(可设30秒~无限制)
c. 点击"开始",黑板大屏自动展示题目
4. 收卷:
a. 工具栏→收卷(或倒计时结束自动收卷)
b. 自动展示答题统计(在黑板大屏呈现)
5. 展示学生作品:
a. 在全班书写状态格中单击学生小格
b. 右键→"投屏展示",该学生作品显示到黑板大屏
4.4 作业批改操作流程
批改流程(课后):
1. 主界面→作业管理→选择最近发布的作业
2. 作业列表显示每个学生的提交状态和AI初评分
3. 逐个批改:
a. 点击学生条目,进入批改详情
b. 查看AI建议(逐字评分+笔顺分析)
c. 若AI结果准确,点击"采纳AI建议"一键完成
d. 若需调整,手动修改分数和添加文字批注
e. 点击"确认"→自动跳转下一个学生
4. 全部批改完成后:
a. 点击"推送结果"→批改结果推送到学生Pad和家长手机
b. 点击"导出成绩单"→导出CSV/Excel格式成绩单
4.5 设备连接操作流程
USB连接点阵笔:
USB连接操作:
1. 用Type-C数据线连接笔和PC的USB口
2. 点阵笔自动进入USB模式(LED白色常亮)
3. PC APP右下角设备状态显示"USB笔 已连接"
4. 在课件编辑器中选择一个写字区域
5. 用笔书写,PC屏幕实时显示笔迹
BLE无线连接:
BLE连接操作:
1. PC APP→设置→设备管理→扫描蓝牙设备
2. 打开点阵笔电源(长按笔帽开关)
3. 列表中出现"Writech-XXXXXX"
4. 点击"配对",按提示完成配对(Numeric Comparison)
5. 配对成功后后续开机自动重连
4.6 故障排查
| 问题 | 原因 | 解决方法 |
|---|---|---|
| USB笔不被识别 | 驱动未安装 | 重新安装USB驱动(安装包附带驱动)或更新USB驱动 |
| 投屏黑屏 | 防火墙阻止连接 | 在防火墙中允许PC APP访问局域网 |
| AI批改结果长时间等待 | 云端AI服务繁忙 | 等待5-10分钟或稍后刷新(结果完成后自动推送) |
| 课件同步失败 | 网络断开 | 检查网络,课件已本地保存,网络恢复后自动同步 |
| 应用启动崩溃 | 版本不兼容 | 卸载后重新安装最新版本 |
第五章 与源代码的对应关系
5.1 模块与源代码文件对应表
| 功能模块 | 源代码路径 | 说明 |
|---|---|---|
| 主进程入口 | src/main/index.ts |
Electron主进程启动、窗口创建、安全配置 |
| Preload脚本 | src/preload/index.ts |
contextBridge API安全暴露 |
| 主进程IPC处理 | src/main/ipc-handlers.ts |
所有IPC通道的处理函数注册 |
| 数据库服务 | src/main/services/db-service.ts |
SQLite数据库操作(better-sqlite3) |
| 设备服务 | src/main/services/device-service.ts |
USB/BLE点阵笔连接与数据接收 |
| WebSocket服务 | src/main/services/websocket-service.ts |
云端实时通信(主进程) |
| 投屏服务 | src/main/services/cast-service.ts |
WebRTC投屏协议实现 |
| 自动更新 | src/main/updater.ts |
electron-updater自动更新配置 |
| C++ Native Addon | native/writech_native/ |
USB HID通信、笔迹平滑算法 |
| 渲染进程入口 | src/renderer/main.ts |
Vue.js 3 应用初始化 |
| 路由配置 | src/renderer/router/index.ts |
vue-router路由配置 |
| 全局状态 | src/renderer/store/ |
Pinia全局Store |
| 备课工具 | src/renderer/features/lesson/ |
课件编辑器Vue组件 |
| 课堂授课 | src/renderer/features/classroom/ |
课堂模式Vue组件、实时数据处理 |
| 作业批改 | src/renderer/features/grading/ |
批改界面Vue组件、AI辅助批改 |
| 数据分析 | src/renderer/features/analytics/ |
学情统计图表Vue组件 |
| 点阵码编辑 | src/renderer/features/dotcode/ |
点阵码绑定和PDF生成 |
| WebGL渲染引擎 | src/renderer/rendering/StrokeRenderer.ts |
WebGL笔迹渲染引擎 |
| HTTP客户端 | src/renderer/api/client.ts |
Axios HTTP请求封装 |
| 本地IndexedDB | src/renderer/storage/inkDB.ts |
Dexie.js大容量笔迹数据存储 |
| 构建配置 | electron.vite.config.ts |
Electron + Vite构建配置 |
5.2 核心类与函数说明
| 类/函数名 | 所在文件 | 功能说明 |
|---|---|---|
createWindow() |
main/index.ts |
创建主窗口,配置安全选项 |
setupIpcHandlers() |
main/ipc-handlers.ts |
注册所有IPC通道处理函数 |
DBService.query() |
main/services/db-service.ts |
SQLite查询封装 |
DeviceService.connectUSBPen() |
main/services/device-service.ts |
USB笔连接与数据流监听 |
WebSocketService.connect() |
main/services/websocket-service.ts |
建立云端WebSocket连接 |
CastService.startWebRTCCast() |
main/services/cast-service.ts |
启动WebRTC投屏 |
StrokeRenderer.drawStroke() |
renderer/rendering/StrokeRenderer.ts |
WebGL笔迹渲染 |
useClassroomStore.randomPickStudent() |
renderer/features/classroom/store |
随机点名(防重复) |
useAIGrading.gradeSubmission() |
renderer/features/grading/composables |
获取AI批改结果 |
generateDotCodePDF() |
renderer/features/dotcode/DotCodeBinder.vue |
生成点阵码作业纸PDF |
setupAutoUpdater() |
main/updater.ts |
配置自动更新检查与安装 |
附录A 界面设计稿(GUI Mockup)
本附录以PC桌面横屏线框图形式呈现PC APP各核心界面的设计稿,反映Windows/macOS桌面应用的界面布局与交互元素。
A.1 应用主界面(课堂准备状态)
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 文件(F) 编辑(E) 视图(V) 课堂(C) 工具(T) 帮助(H) _ □ × │
├──────────────────────────────────────────────────────────────────────────────────┤
│ [◀][▶] [+新建] [打开] [保存] │ [发题📤] [收卷📥] [点名🔴] │ [开始课堂▶] │
│─────────────────────────────────────────────────────────────────────────────────│
│ ┌──────────────┐ ┌────────────────────────────────────────────────────────────┐ │
│ │ 课件导航 │ │ 主编辑/展示区 │ │
│ │ ───────── │ │ │ │
│ │ 📄 封面 │ │ │ │
│ │ 📄 第1页 ◀ │ │ │ │
│ │ 📄 第2页 │ │ [ 课件内容区域 ] │ │
│ │ 📄 第3页 │ │ │ │
│ │ 📄 第4页 │ │ 解方程:2x + 5 = 13 │ │
│ │ 📄 第5页 │ │ │ │
│ │ [+ 新建页] │ │ │ │
│ │ │ │ │ │
│ │ 工具箱 │ ├────────────────────────────────────────────────────────────┤ │
│ │ 🖊 画笔 │ │ 实时状态: 课堂未开始 │ │
│ │ ◻ 文字 │ │ 在线学生: 0 / 45 │ │
│ │ 📐 形状 │ │ 已连接笔: 0 支 │ │
│ │ 📷 图片 │ │ 上次保存: 09:30:22 │ │
│ └──────────────┘ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
A.2 课堂进行中界面
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 文件 编辑 视图 课堂 工具 帮助 ⏱ 课堂进行中 00:23:45 _ □ × │
├──────────────────────────────────────────────────────────────────────────────────┤
│ [◀][▶] │ [📤 发题] [📥 收卷] [🔴 点名] [💬 评语] │ [结束课堂■] │
│─────────────────────────────────────────────────────────────────────────────────│
│ ┌──────────────┐ ┌──────────────────────────────┐ ┌────────────────────────┐ │
│ │ 课件导航 │ │ 题目内容 │ │ 班级实时状态 │ │
│ │ │ │ │ │ │ │
│ │ ▶ 第3题 ◀ │ │ 解方程:2x + 5 = 13 │ │ 已提交 ████████ 38 │ │
│ │ (进行中) │ │ │ │ 书写中 ██ 7 │ │
│ │ │ │ ┌──────────────────────┐ │ │ 未开始 0 │ │
│ │ ───────── │ │ │ │ │ │ 总人数 45 │ │
│ │ 已完成 ✅ │ │ │ [ 学生回答展示区 ] │ │ ├────────────────────────┤ │
│ │ 第1题 │ │ │ │ │ │ 常见错误统计 │ │
│ │ 第2题 │ │ │ x = 4 (AI识别) │ │ │ x=9 5人 移项出错 │ │
│ │ │ │ └──────────────────────┘ │ │ x=3 2人 算术出错 │ │
│ │ 待做 ○ │ │ │ ├────────────────────────┤ │
│ │ 第4题 │ │ 正确率: 84.4% ████████░░░ │ │ [查看全班答卷] │ │
│ │ 第5题 │ │ │ │ [展示典型错误] │ │
│ └──────────────┘ └──────────────────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
A.3 作业批改界面
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 文件 编辑 批改 工具 帮助 _ □ × │
├──────────────────────────────────────────────────────────────────────────────────┤
│ [← 返回] 批改模式 · 2月14日语文作业 · 已提交 42/45 待批改 38/42 │
│─────────────────────────────────────────────────────────────────────────────────│
│ ┌──────────────────────────────────────┐ ┌─────────────────────────────────────┐│
│ │ 学生答卷列表 │ │ 批改区域 · 王小花 ││
│ │ ┌──────────────────────────────┐ │ │ ││
│ │ │ 01 王小花 ✅AI已识别 待批改│ │ │ ┌─────────────────────────────┐ ││
│ │ │ 02 张大勇 ✅AI已识别 待批改│◀当前│ │ │ │ ││
│ │ │ 03 陈美玲 ❌AI未识别 需手批│ │ │ │ [ 手写笔迹图像区域 ] │ ││
│ │ │ 04 李小虎 ✅AI已识别 已批改│ │ │ │ │ ││
│ │ │ 05 刘芳芳 ✅AI已识别 待批改│ │ │ │ 春眠不觉晓,处处闻啼鸟 │ ││
│ │ │ 06 ... ... │ │ │ │ 夜来风雨声,花落知多少 │ ││
│ │ └──────────────────────────────┘ │ │ └─────────────────────────────┘ ││
│ │ [切换:全部 | 待批 | 已批 | 异常] │ │ AI识别内容:正确 ✅ ││
│ └──────────────────────────────────────┘ │ ┌────────────────────────────────┐ ││
│ │ │ 批改意见 ✏️ │ ││
│ │ │ [__________________________] │ ││
│ │ └────────────────────────────────┘ ││
│ │ [✅ 正确] [❌ 错误] [◑ 部分正确] ││
│ │ [← 上一份] [下一份 →] ││
│ └─────────────────────────────────────┘│
└──────────────────────────────────────────────────────────────────────────────────┘
A.4 书写回放界面
┌──────────────────────────────────────────────────────────────────────────────────┐
│ 文件 工具 帮助 _ □ × │
├──────────────────────────────────────────────────────────────────────────────────┤
│ [← 返回] 🎬 书写回放 · 王小花 · 2月14日语文作业 │
│─────────────────────────────────────────────────────────────────────────────────│
│ ┌──────────────────────────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ [ 书写回放画布 (A4纸张比例) ] │ │
│ │ │ │
│ │ 春眠不觉晓, │ │
│ │ 处处闻啼鸟。 (回放进度:笔迹正在书写中...) │ │
│ │ 夜来风雨声, │ │
│ │ 花落知多少。 │ │
│ │ │ │
│ └──────────────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ |◀ ◀◀ ▶/⏸ ▶▶ ▶| ════════════════●══════════ 01:23 / 03:45 │ │
│ │ 速度 [0.5×▼] [ 显示坐标 ] [循环播放] [截图] [导出MP4] [导出GIF] │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
附录B 快捷键参考
| 快捷键(Windows) | 快捷键(macOS) | 功能 |
|---|---|---|
| Ctrl+N | Cmd+N | 新建课件 |
| Ctrl+S | Cmd+S | 保存 |
| Ctrl+Z | Cmd+Z | 撤销 |
| Ctrl+Y | Cmd+Shift+Z | 重做 |
| PgUp / PgDn | PgUp / PgDn | 课件翻页 |
| F5 | F5 | 进入演示模式 |
| Esc | Esc | 退出演示模式 |
| L | L | 激光笔模式 |
| E | E | 橡皮擦模式 |
| Ctrl+D | Cmd+D | 发题 |
| Ctrl+R | Cmd+R | 收卷 |
| Ctrl+P | Cmd+P | 随机点名 |
| Ctrl+Shift+P | Cmd+Shift+P | 打印/导出PDF |
| Ctrl+Q | Cmd+Q | 退出应用 |
附录B 术语表
| 术语 | 说明 |
|---|---|
| Electron | GitHub开发的跨平台桌面应用框架,基于Node.js + Chromium |
| Vue.js 3 | 渐进式JavaScript框架,用于构建用户界面 |
| Pinia | Vue.js 3推荐的状态管理库(替代Vuex) |
| TypeScript | JavaScript的类型化超集,提供静态类型检查 |
| Vite | 新一代前端构建工具,极快的开发服务器 |
| IPC | Inter-Process Communication,进程间通信 |
| contextBridge | Electron安全API,在隔离上下文中暴露主进程功能 |
| contextIsolation | Electron安全特性,阻止渲染进程直接访问Node.js |
| Node-API(N-API) | Node.js原生扩展API,用于编写C++ Addon |
| Native Addon | C++编写的Node.js扩展模块(.node文件) |
| SQLCipher | SQLite的加密扩展 |
| WebRTC | Web实时通信标准,支持点对点音视频传输(PC APP用于投屏) |
| WebGL | Web图形库,浏览器中的OpenGL ES |
| ASAR | Electron应用包格式(Atom Shell Archive) |
| better-sqlite3 | 同步SQLite3 Node.js驱动,性能优秀 |
| IndexedDB | 浏览器内置的大容量NoSQL数据库(Electron渲染进程可用) |
文档编制:深圳自然写科技有限公司 PC客户端研发团队
文档版本:V1.0
最后更新:2026年2月14日
版权所有 © 2026 深圳自然写科技有限公司
附录C 核心技术实现详述
C.1 Electron主进程与渲染进程架构
PC桌面应用基于Electron框架构建,主进程(main process)负责系统级功能(BLE、文件I/O、本地数据库),渲染进程(renderer process)负责UI展示。两者通过IPC(进程间通信)安全通信。
C.1.1 主进程核心模块
// main/index.ts - Electron主进程入口
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'
import { join } from 'path'
import { BleManager } from './ble/BleManager'
import { LocalDatabase } from './database/LocalDatabase'
import { SyncService } from './sync/SyncService'
import { NativeInkEngine } from './native/NativeInkEngine'
import { AutoUpdater } from './updater/AutoUpdater'
let mainWindow: BrowserWindow | null = null
let bleManager: BleManager | null = null
let localDb: LocalDatabase | null = null
let syncService: SyncService | null = null
let inkEngine: NativeInkEngine | null = null
async function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 1024,
minHeight: 640,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true, // 隔离渲染进程
nodeIntegration: false, // 禁止渲染进程直接访问Node.js
webSecurity: true,
sandbox: false // 允许preload访问Node.js
},
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
show: false // 窗口准备好后再显示,避免白屏
})
// 加载应用
if (process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) // 开发模式
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html')) // 生产模式
}
mainWindow.once('ready-to-show', () => {
mainWindow!.show()
})
// 阻止新窗口,在外部浏览器打开链接
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
}
async function initializeServices() {
// 初始化本地SQLite数据库
localDb = new LocalDatabase(join(app.getPath('userData'), 'writech.db'))
await localDb.initialize()
// 初始化BLE管理器(使用Noble C++ Addon)
bleManager = new BleManager()
await bleManager.initialize()
// 初始化笔迹引擎(C++ Native Addon)
inkEngine = new NativeInkEngine()
// 初始化数据同步服务
syncService = new SyncService(localDb, 'https://api.writech.com')
// 注册所有IPC处理器
registerIpcHandlers()
}
app.whenReady().then(async () => {
await initializeServices()
await createMainWindow()
AutoUpdater.checkForUpdates()
})
app.on('window-all-closed', () => {
bleManager?.destroy()
syncService?.stop()
localDb?.close()
if (process.platform !== 'darwin') app.quit()
})
C.1.2 Preload安全桥接
// preload/index.ts - contextBridge安全暴露API
import { contextBridge, ipcRenderer } from 'electron'
// 向渲染进程暴露的API白名单
contextBridge.exposeInMainWorld('writechAPI', {
// BLE钢笔管理
ble: {
startScan: () => ipcRenderer.invoke('ble:startScan'),
stopScan: () => ipcRenderer.invoke('ble:stopScan'),
connect: (deviceId: string) => ipcRenderer.invoke('ble:connect', deviceId),
disconnect: (deviceId: string) => ipcRenderer.invoke('ble:disconnect', deviceId),
onDeviceFound: (callback: (device: BleDevice) => void) => {
ipcRenderer.on('ble:deviceFound', (_, device) => callback(device))
},
onInkData: (callback: (data: InkData) => void) => {
ipcRenderer.on('ble:inkData', (_, data) => callback(data))
},
onConnectionChanged: (callback: (deviceId: string, connected: boolean) => void) => {
ipcRenderer.on('ble:connectionChanged', (_, deviceId, connected) => callback(deviceId, connected))
}
},
// 本地数据库操作
database: {
saveStroke: (stroke: StrokeRecord) => ipcRenderer.invoke('db:saveStroke', stroke),
getStrokes: (filter: StrokeFilter) => ipcRenderer.invoke('db:getStrokes', filter),
saveHomework: (homework: HomeworkRecord) => ipcRenderer.invoke('db:saveHomework', homework),
getHomeworkList: (query: HomeworkQuery) => ipcRenderer.invoke('db:getHomeworkList', query),
deleteOldData: (beforeDate: Date) => ipcRenderer.invoke('db:deleteOldData', beforeDate)
},
// 文件系统操作
files: {
exportToPdf: (content: ExportContent) => ipcRenderer.invoke('files:exportToPdf', content),
exportToImage: (content: ExportContent) => ipcRenderer.invoke('files:exportToImage', content),
openFile: () => ipcRenderer.invoke('files:openFile'),
saveFile: (data: Uint8Array, defaultName: string) =>
ipcRenderer.invoke('files:saveFile', data, defaultName)
},
// 云端同步
sync: {
syncNow: () => ipcRenderer.invoke('sync:syncNow'),
getSyncStatus: () => ipcRenderer.invoke('sync:getStatus'),
onSyncProgress: (callback: (progress: SyncProgress) => void) => {
ipcRenderer.on('sync:progress', (_, progress) => callback(progress))
}
},
// 应用信息
app: {
getVersion: () => ipcRenderer.invoke('app:getVersion'),
checkUpdate: () => ipcRenderer.invoke('app:checkUpdate'),
openDevTools: () => ipcRenderer.invoke('app:openDevTools'),
relaunch: () => ipcRenderer.invoke('app:relaunch')
}
})
C.2 BLE笔迹接收(Noble C++ Addon)
PC客户端通过Noble(Node.js BLE库)连接智能点阵笔,使用C++ Native Addon处理高频笔迹数据。
C.2.1 BLE管理器实现
// main/ble/BleManager.ts
import noble from '@abandonware/noble'
import { EventEmitter } from 'events'
import { InkEngine } from '../native/NativeInkEngine'
const WRITECH_PEN_SERVICE_UUID = '6e400001-b5a3-f393-e0a9-e50e24dcca9e'
const INK_DATA_CHAR_UUID = '6e400002-b5a3-f393-e0a9-e50e24dcca9e'
const CONTROL_CHAR_UUID = '6e400003-b5a3-f393-e0a9-e50e24dcca9e'
const WRITECH_PEN_NAME_PREFIX = 'WritechPen-'
export class BleManager extends EventEmitter {
private connectedPens: Map<string, noble.Peripheral> = new Map()
private inkCharacteristics: Map<string, noble.Characteristic> = new Map()
private scanning = false
async initialize(): Promise<void> {
noble.on('stateChange', (state) => {
if (state === 'poweredOn' && this.scanning) {
noble.startScanning([WRITECH_PEN_SERVICE_UUID], true)
}
})
noble.on('discover', (peripheral) => {
const name = peripheral.advertisement.localName || ''
if (name.startsWith(WRITECH_PEN_NAME_PREFIX)) {
this.emit('deviceFound', {
id: peripheral.id,
name: name,
rssi: peripheral.rssi,
address: peripheral.address
})
}
})
}
async startScan(): Promise<void> {
this.scanning = true
if (noble.state === 'poweredOn') {
noble.startScanning([WRITECH_PEN_SERVICE_UUID], true)
}
}
async stopScan(): Promise<void> {
this.scanning = false
noble.stopScanning()
}
async connect(peripheralId: string): Promise<void> {
const peripheral = await this.findPeripheral(peripheralId)
if (!peripheral) throw new Error('Device not found: ' + peripheralId)
await new Promise<void>((resolve, reject) => {
peripheral.connect((err) => {
if (err) reject(err)
else resolve()
})
})
// 发现服务和特征
const { characteristics } = await new Promise<noble.ServicesAndCharacteristics>(
(resolve, reject) => {
peripheral.discoverAllServicesAndCharacteristics((err, services, chars) => {
if (err) reject(err)
else resolve({ services, characteristics: chars })
})
}
)
const inkChar = characteristics.find(c => c.uuid === INK_DATA_CHAR_UUID)
if (!inkChar) throw new Error('Ink characteristic not found')
this.connectedPens.set(peripheralId, peripheral)
this.inkCharacteristics.set(peripheralId, inkChar)
// 订阅笔迹数据通知
await new Promise<void>((resolve, reject) => {
inkChar.subscribe((err) => {
if (err) reject(err)
else resolve()
})
})
inkChar.on('data', (data: Buffer) => {
this.processInkData(peripheralId, data)
})
peripheral.on('disconnect', () => {
this.connectedPens.delete(peripheralId)
this.inkCharacteristics.delete(peripheralId)
this.emit('connectionChanged', peripheralId, false)
// 自动重连
setTimeout(() => this.connect(peripheralId), 3000)
})
this.emit('connectionChanged', peripheralId, true)
}
/**
* 解析BLE笔迹数据包
* 格式:[x:2B][y:2B][压力:1B][时间戳:4B][标志:1B] × n点
*/
private processInkData(penId: string, data: Buffer): void {
const points: InkPoint[] = []
for (let offset = 0; offset + 10 <= data.length; offset += 10) {
const x = data.readUInt16BE(offset) / 65535.0
const y = data.readUInt16BE(offset + 2) / 65535.0
const pressure = data[offset + 4] / 255.0
const timestamp = data.readUInt32BE(offset + 5)
const flags = data[offset + 9]
const isPenUp = (flags & 0x01) !== 0
points.push({ x, y, pressure, timestamp, isPenUp })
}
if (points.length > 0) {
this.emit('inkData', { penId, points })
}
}
destroy(): void {
noble.stopScanning()
this.connectedPens.forEach((peripheral) => {
peripheral.disconnect()
})
this.connectedPens.clear()
this.inkCharacteristics.clear()
}
}
C.3 本地数据库设计(better-sqlite3)
PC客户端使用SQLite作为本地数据库,通过better-sqlite3驱动实现同步读写操作。
C.3.1 数据库初始化与Schema
// main/database/LocalDatabase.ts
import Database from 'better-sqlite3'
import { join } from 'path'
export class LocalDatabase {
private db: Database.Database
constructor(dbPath: string) {
this.db = new Database(dbPath, {
verbose: process.env.NODE_ENV === 'development' ? console.log : undefined
})
this.db.pragma('journal_mode = WAL') // WAL模式,提升并发读性能
this.db.pragma('synchronous = NORMAL') // 性能与安全的平衡
this.db.pragma('foreign_keys = ON') // 启用外键约束
this.db.pragma('cache_size = -32000') // 32MB页缓存
}
async initialize(): Promise<void> {
this.createTables()
this.createIndexes()
this.runMigrations()
}
private createTables(): void {
this.db.exec(`
-- 用户信息表
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('teacher', 'student', 'admin')),
school_id TEXT,
class_id TEXT,
avatar_url TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
sync_status TEXT NOT NULL DEFAULT 'synced' CHECK(sync_status IN ('synced', 'pending', 'conflict'))
);
-- 课堂记录表
CREATE TABLE IF NOT EXISTS classroom_sessions (
id TEXT PRIMARY KEY,
teacher_id TEXT NOT NULL REFERENCES users(id),
class_id TEXT NOT NULL,
classroom_name TEXT NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'ended', 'archived')),
student_count INTEGER DEFAULT 0,
metadata TEXT, -- JSON扩展字段
sync_status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 笔迹数据表(高频写入,使用INTEGER主键)
CREATE TABLE IF NOT EXISTS ink_strokes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES classroom_sessions(id),
student_id TEXT NOT NULL,
pen_id TEXT NOT NULL,
stroke_data BLOB NOT NULL, -- 压缩后的笔迹点二进制数据
point_count INTEGER NOT NULL,
start_time INTEGER NOT NULL,
end_time INTEGER NOT NULL,
bounding_box TEXT, -- JSON: {x,y,w,h}
sync_status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 作业记录表
CREATE TABLE IF NOT EXISTS homework_records (
id TEXT PRIMARY KEY,
session_id TEXT REFERENCES classroom_sessions(id),
student_id TEXT NOT NULL,
assignment_id TEXT NOT NULL,
submit_time INTEGER,
ink_stroke_ids TEXT, -- JSON数组:关联的笔迹ID
score REAL,
feedback TEXT,
status TEXT NOT NULL DEFAULT 'submitted'
CHECK(status IN ('draft', 'submitted', 'graded', 'returned')),
sync_status TEXT NOT NULL DEFAULT 'pending',
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
-- 同步日志表
CREATE TABLE IF NOT EXISTS sync_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK(operation IN ('insert', 'update', 'delete')),
sync_time INTEGER,
error_message TEXT,
retry_count INTEGER DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);
`)
}
private createIndexes(): void {
this.db.exec(`
CREATE INDEX IF NOT EXISTS idx_ink_strokes_session ON ink_strokes(session_id);
CREATE INDEX IF NOT EXISTS idx_ink_strokes_student ON ink_strokes(student_id);
CREATE INDEX IF NOT EXISTS idx_ink_strokes_sync ON ink_strokes(sync_status) WHERE sync_status = 'pending';
CREATE INDEX IF NOT EXISTS idx_homework_student ON homework_records(student_id);
CREATE INDEX IF NOT EXISTS idx_homework_assignment ON homework_records(assignment_id);
CREATE INDEX IF NOT EXISTS idx_sessions_teacher ON classroom_sessions(teacher_id);
CREATE INDEX IF NOT EXISTS idx_sessions_time ON classroom_sessions(start_time DESC);
`)
}
/** 批量插入笔迹(使用预编译语句,性能显著优于逐条插入) */
saveStrokeBatch(strokes: StrokeRecord[]): void {
const stmt = this.db.prepare(`
INSERT INTO ink_strokes
(session_id, student_id, pen_id, stroke_data, point_count,
start_time, end_time, bounding_box, sync_status)
VALUES
(@sessionId, @studentId, @penId, @strokeData, @pointCount,
@startTime, @endTime, @boundingBox, 'pending')
`)
const insertMany = this.db.transaction((records: StrokeRecord[]) => {
for (const r of records) stmt.run(r)
})
insertMany(strokes)
}
/** 查询待同步的笔迹数据(批量上传) */
getPendingStrokes(limit = 100): StrokeRecord[] {
return this.db.prepare(`
SELECT * FROM ink_strokes
WHERE sync_status = 'pending'
ORDER BY created_at ASC
LIMIT ?
`).all(limit) as StrokeRecord[]
}
/** 标记笔迹为已同步 */
markStrokesSynced(ids: number[]): void {
const placeholders = ids.map(() => '?').join(',')
this.db.prepare(`
UPDATE ink_strokes SET sync_status = 'synced' WHERE id IN (${placeholders})
`).run(...ids)
}
close(): void {
this.db.close()
}
}
C.4 数据同步服务
PC客户端实现离线优先(Offline-First)策略,本地操作优先写入SQLite,后台服务定期将数据上传到云端。
C.4.1 同步服务实现
// main/sync/SyncService.ts
import { LocalDatabase } from '../database/LocalDatabase'
import axios, { AxiosInstance } from 'axios'
import { EventEmitter } from 'events'
import pako from 'pako' // 数据压缩
export class SyncService extends EventEmitter {
private db: LocalDatabase
private http: AxiosInstance
private syncTimer: NodeJS.Timer | null = null
private syncing = false
private static readonly SYNC_INTERVAL_MS = 30_000 // 30秒同步一次
private static readonly BATCH_SIZE = 50 // 每批上传50条记录
private static readonly MAX_RETRY = 3
constructor(db: LocalDatabase, baseUrl: string) {
super()
this.db = db
this.http = axios.create({
baseURL: baseUrl,
timeout: 30_000,
headers: {
'Content-Type': 'application/json',
'X-Client-Type': 'PC_APP',
'X-App-Version': app.getVersion()
}
})
}
start(authToken: string): void {
this.http.defaults.headers.common['Authorization'] = `Bearer ${authToken}`
this.syncTimer = setInterval(() => this.syncAll(), SyncService.SYNC_INTERVAL_MS)
// 立即执行一次同步
this.syncAll()
}
stop(): void {
if (this.syncTimer) {
clearInterval(this.syncTimer)
this.syncTimer = null
}
}
async syncAll(): Promise<void> {
if (this.syncing) return
this.syncing = true
let totalSynced = 0
let totalErrors = 0
try {
this.emit('progress', { status: 'syncing', message: '正在同步笔迹数据...' })
// 1. 上传待同步的笔迹数据
while (true) {
const pending = this.db.getPendingStrokes(SyncService.BATCH_SIZE)
if (pending.length === 0) break
// 压缩笔迹二进制数据后上传
const payload = pending.map(s => ({
...s,
strokeData: Buffer.from(pako.deflate(s.strokeData)).toString('base64')
}))
const response = await this.http.post('/api/v1/strokes/batch', payload)
if (response.status === 200) {
const syncedIds = pending.map(s => s.id!)
this.db.markStrokesSynced(syncedIds)
totalSynced += pending.length
}
this.emit('progress', {
status: 'syncing',
message: `已同步 ${totalSynced} 条笔迹`,
synced: totalSynced
})
}
// 2. 从云端拉取最新数据(新消息、批改结果等)
await this.pullUpdates()
this.emit('progress', {
status: 'completed',
message: `同步完成,上传 ${totalSynced} 条,错误 ${totalErrors} 条`,
synced: totalSynced,
errors: totalErrors
})
} catch (error) {
totalErrors++
this.emit('progress', {
status: 'error',
message: '同步失败:' + (error as Error).message
})
} finally {
this.syncing = false
}
}
private async pullUpdates(): Promise<void> {
// 拉取最新的批改结果
const lastSyncTime = this.db.getLastSyncTime('homework_records')
const response = await this.http.get('/api/v1/homework/updates', {
params: { since: lastSyncTime }
})
if (response.data.records?.length > 0) {
this.db.upsertHomeworkRecords(response.data.records)
}
}
}
C.5 PDF/图片导出功能
// main/export/ExportService.ts
import { BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import * as fs from 'fs/promises'
export class ExportService {
/**
* 将Canvas笔迹内容导出为PDF
* 原理:创建隐藏的BrowserWindow,加载笔迹数据渲染后调用printToPDF
*/
static async exportToPdf(content: ExportContent, outputPath: string): Promise<void> {
const hiddenWin = new BrowserWindow({
show: false,
webPreferences: {
offscreen: true
}
})
await hiddenWin.loadFile(join(__dirname, '../renderer/export.html'))
// 向隐藏窗口注入笔迹数据
await hiddenWin.webContents.executeJavaScript(
`window.renderExportContent(${JSON.stringify(content)})`
)
// 等待渲染完成
await new Promise(resolve => setTimeout(resolve, 500))
const pdfData = await hiddenWin.webContents.printToPDF({
pageSize: 'A4',
printBackground: true,
marginsType: 1 // 最小边距
})
await fs.writeFile(outputPath, pdfData)
hiddenWin.destroy()
}
/**
* 将笔迹画布导出为PNG图片
* 使用Electron的capturePage API截取渲染内容
*/
static async exportToImage(content: ExportContent, outputPath: string): Promise<void> {
const hiddenWin = new BrowserWindow({
width: content.width || 2480, // A4宽度 @ 300dpi
height: content.height || 3508, // A4高度 @ 300dpi
show: false,
webPreferences: { offscreen: true }
})
await hiddenWin.loadFile(join(__dirname, '../renderer/export.html'))
await hiddenWin.webContents.executeJavaScript(
`window.renderExportContent(${JSON.stringify(content)})`
)
await new Promise(resolve => setTimeout(resolve, 500))
const nativeImage = await hiddenWin.webContents.capturePage({
x: 0, y: 0, width: content.width || 2480, height: content.height || 3508
})
await fs.writeFile(outputPath, nativeImage.toPNG())
hiddenWin.destroy()
}
}
C.6 React渲染进程核心模块
C.6.1 Canvas笔迹绘制组件
// renderer/src/components/InkCanvas.tsx
import React, { useRef, useEffect, useCallback } from 'react'
import { useInkStore } from '../store/inkStore'
interface InkCanvasProps {
width: number
height: number
studentId?: string // 指定学生ID时只显示该学生笔迹
readonly?: boolean
}
export const InkCanvas: React.FC<InkCanvasProps> = ({
width, height, studentId, readonly = false
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const contextRef = useRef<CanvasRenderingContext2D | null>(null)
const { strokes, addPoint, endStroke } = useInkStore()
// 初始化Canvas上下文
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d', { willReadFrequently: false })
if (!ctx) return
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.strokeStyle = '#1a1a2e'
contextRef.current = ctx
}, [])
// 当笔迹数据更新时重新渲染
useEffect(() => {
const canvas = canvasRef.current
const ctx = contextRef.current
if (!canvas || !ctx) return
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = '#ffffff'
ctx.fillRect(0, 0, width, height)
const filteredStrokes = studentId
? strokes.filter(s => s.studentId === studentId)
: strokes
for (const stroke of filteredStrokes) {
if (stroke.points.length < 2) continue
ctx.strokeStyle = stroke.color
ctx.beginPath()
const pts = stroke.points
ctx.moveTo(pts[0].x * width, pts[0].y * height)
for (let i = 1; i < pts.length - 1; i++) {
const midX = ((pts[i].x + pts[i+1].x) / 2) * width
const midY = ((pts[i].y + pts[i+1].y) / 2) * height
ctx.quadraticCurveTo(
pts[i].x * width, pts[i].y * height, midX, midY
)
ctx.lineWidth = 1.5 + pts[i].pressure * 3
}
ctx.stroke()
}
}, [strokes, studentId, width, height])
return (
<canvas
ref={canvasRef}
width={width}
height={height}
className="ink-canvas"
style={{ border: '1px solid #e0e0e0', borderRadius: 4 }}
/>
)
}
附录D 完整操作手册
D.1 安装与初始配置
D.1.1 支持的操作系统
| 平台 | 最低版本 | 推荐版本 |
|---|---|---|
| Windows | Windows 10 (1903) | Windows 11 22H2 |
| macOS | macOS 11.0 (Big Sur) | macOS 13.x (Ventura) |
| Linux | Ubuntu 20.04 LTS | Ubuntu 22.04 LTS |
D.1.2 安装步骤
Windows安装:
- 下载
writech-pc-setup-x.x.x.exe安装包。 - 双击运行安装程序,选择安装目录(默认
C:\Program Files\Writech PC)。 - 勾选"创建桌面快捷方式"和"开机自启动"(可选)。
- 点击"安装",等待安装完成(约30秒)。
- 点击"完成",勾选"立即启动"。
macOS安装:
- 下载
writech-pc-x.x.x.dmg磁盘镜像。 - 双击打开DMG文件,将 Writech 图标拖入 Applications 文件夹。
- 首次启动时,macOS提示"无法打开,因为它来自身份不明的开发者"。
- 打开"系统偏好设置→安全性与隐私→通用",点击"仍然打开"。
- 应用成功启动。
Linux安装:
# Ubuntu/Debian
sudo dpkg -i writech-pc_x.x.x_amd64.deb
sudo apt-get install -f # 修复依赖
# 或使用AppImage(免安装)
chmod +x writech-pc-x.x.x.AppImage
./writech-pc-x.x.x.AppImage
D.1.3 首次登录
- 启动应用,显示登录界面。
- 输入学校管理员分配的账号和密码。
- 可选"记住登录状态"(有效期30天)。
- 点击"登录",首次登录需要下载数据(约30-60秒)。
- 登录成功后进入主界面。
D.2 智能笔连接
D.2.1 连接步骤
- 打开自然写PC应用,点击顶部工具栏"连接笔"按钮(钢笔图标)。
- 确保智能点阵笔蓝牙已开启(笔盖指示灯闪烁蓝色)。
- 设备列表显示附近的自然写智能笔(以"WritechPen-"开头)。
- 点击对应设备名称旁的"连接"按钮。
- 配对过程约5-10秒,成功后指示灯变为常亮蓝色。
- 状态栏显示"笔已连接:WritechPen-XXXX,电量:85%"。
D.2.2 多笔连接(教师模式)
教师使用PC应用时可同时连接多支智能笔(最多4支),用于不同颜色批注:
- 重复上述连接步骤连接第二支笔。
- 在"设置→笔管理"中为每支笔分配颜色。
- 主界面工具栏显示当前激活的笔(点击切换)。
D.3 课堂功能操作
D.3.1 开始课堂
- 主界面点击"新建课堂"按钮。
- 填写课堂信息:
- 班级(从下拉列表选择)
- 课程名称(如"三年级语文-第5课")
- 预计时长(30/45/60分钟)
- 点击"开始课堂",系统自动创建课堂会话,生成课堂码(4位数字)。
- 学生通过手机APP或Pad APP输入课堂码加入。
- 教师界面左侧显示学生签到列表,右侧显示书写区域。
D.3.2 批改作业
- 主界面选择"作业"标签,查看待批改作业列表。
- 点击某学生的作业,右侧显示该学生的手写作业内容。
- 批改操作:
- 选择红笔工具,在学生笔迹上方直接书写批注
- 点击"正确"/"错误"按钮快速标记
- 输入分数(0-100分)
- 添加文字评语(支持键盘输入或语音转文字)
- 点击"提交批改",批改结果自动同步到学生APP。
D.3.3 统计报告查看
- 主界面选择"报告"标签。
- 选择报告类型:
- 班级报告:全班作业正确率、提交率分布图
- 个人报告:单学生历史成绩折线图、错题分析
- 知识点报告:按知识点统计掌握率(热力图展示)
- 支持导出为PDF报告(菜单→导出→PDF)。
D.4 数据管理
D.4.1 数据备份
- 菜单→设置→数据管理→备份数据。
- 选择备份目录(默认"文档/WritechBackup")。
- 点击"立即备份",备份文件为加密的
.wbk格式。 - 自动备份频率:每7天一次(可在设置中调整)。
D.4.2 清理旧数据
- 菜单→设置→数据管理→清理数据。
- 选择要清理的数据范围:
- 3个月前的笔迹数据
- 6个月前的课堂记录
- 已同步到云端的本地缓存
- 确认后开始清理,进度条显示清理进度。
D.5 快捷键说明
| 功能 | Windows/Linux | macOS |
|---|---|---|
| 新建课堂 | Ctrl+N | ⌘N |
| 保存 | Ctrl+S | ⌘S |
| 撤销 | Ctrl+Z | ⌘Z |
| 重做 | Ctrl+Y | ⌘⇧Z |
| 放大画布 | Ctrl++ | ⌘+ |
| 缩小画布 | Ctrl+- | ⌘- |
| 全屏 | F11 | ⌃⌘F |
| 切换学生 | Tab | Tab |
| 切换笔颜色 | 1-8 | 1-8 |
| 橡皮擦 | E | E |
| 清屏 | Ctrl+Del | ⌘⌫ |
附录E 源代码对应关系详细说明
E.1 完整源代码文件清单
| 源文件 | 路径 | 功能说明 |
|---|---|---|
| main/index.ts | src/main/index.ts | Electron主进程入口 |
| preload/index.ts | src/preload/index.ts | contextBridge安全桥接 |
| BleManager.ts | src/main/ble/BleManager.ts | BLE智能笔管理 |
| LocalDatabase.ts | src/main/database/LocalDatabase.ts | SQLite本地数据库 |
| SyncService.ts | src/main/sync/SyncService.ts | 云端数据同步服务 |
| ExportService.ts | src/main/export/ExportService.ts | PDF/图片导出 |
| NativeInkEngine.ts | src/main/native/NativeInkEngine.ts | C++笔迹引擎桥接 |
| AutoUpdater.ts | src/main/updater/AutoUpdater.ts | 自动更新服务 |
| InkCanvas.tsx | src/renderer/components/InkCanvas.tsx | Canvas笔迹渲染组件 |
| inkStore.ts | src/renderer/store/inkStore.ts | Zustand笔迹状态管理 |
| ClassroomPage.tsx | src/renderer/pages/ClassroomPage.tsx | 课堂主页面 |
| HomeworkPage.tsx | src/renderer/pages/HomeworkPage.tsx | 作业管理页面 |
| ReportPage.tsx | src/renderer/pages/ReportPage.tsx | 报告查看页面 |
| SettingsPage.tsx | src/renderer/pages/SettingsPage.tsx | 设置页面 |
| BleDevicePanel.tsx | src/renderer/components/BleDevicePanel.tsx | BLE设备面板 |
| StudentGrid.tsx | src/renderer/components/StudentGrid.tsx | 学生网格视图 |
| ink_engine.cpp | native/src/ink_engine.cpp | C++ JNI笔迹引擎 |
| binding.gyp | native/binding.gyp | Native Addon编译配置 |
E.2 构建配置
// package.json(关键配置)
{
"name": "writech-pc",
"version": "1.0.0",
"main": "dist/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux",
"rebuild-native": "electron-rebuild -f -w better-sqlite3,@abandonware/noble"
},
"dependencies": {
"electron": "^28.0.0",
"@abandonware/noble": "^1.9.2-15",
"better-sqlite3": "^9.4.3",
"axios": "^1.6.0",
"pako": "^2.1.0",
"react": "^18.2.0",
"zustand": "^4.5.0"
},
"build": {
"appId": "com.writech.pc",
"productName": "自然写互动课堂PC版",
"win": {
"target": "nsis",
"icon": "build/icon.ico"
},
"mac": {
"target": "dmg",
"icon": "build/icon.icns",
"category": "public.app-category.education"
},
"linux": {
"target": ["deb", "AppImage"],
"icon": "build/icon.png"
}
}
}
文档编制:深圳自然写科技有限公司 PC客户端研发团队
文档版本:V1.0(附录更新)
最后更新:2026年2月14日
版权所有 © 2026 深圳自然写科技有限公司
附录F 性能、兼容性与版本历史
F.1 性能基准测试
| 测试项目 | 平台 | 配置 | 结果 |
|---|---|---|---|
| 冷启动时间 | Windows | i7-1165G7 + SSD | 1.8秒 |
| 冷启动时间 | macOS | Apple M2 | 1.2秒 |
| 笔迹渲染帧率(BLE实时) | Windows | - | 60fps |
| SQLite批量插入(1000条笔迹) | Windows | SSD | 45ms |
| 云端同步(1000条笔迹上传) | WiFi 100Mbps | - | 3.2秒 |
| PDF导出(10页作业) | macOS | - | 2.1秒 |
| 内存占用(空载) | Windows | - | 95MB |
| 内存占用(50份作业展示) | Windows | - | 248MB |
F.2 系统兼容性矩阵
| 操作系统 | 版本 | 架构 | 测试状态 |
|---|---|---|---|
| Windows 10 | 21H2 | x64 | 完全兼容 |
| Windows 11 | 22H2 | x64 | 完全兼容 |
| macOS | 12.x Monterey | Apple Silicon (M1/M2) | 完全兼容 |
| macOS | 12.x Monterey | Intel x64 | 完全兼容 |
| macOS | 13.x Ventura | Apple Silicon | 完全兼容 |
| Ubuntu | 20.04 LTS | x64 | 完全兼容 |
| Ubuntu | 22.04 LTS | x64 | 完全兼容 |
F.3 主要依赖库版本
| 依赖包 | 版本 | 用途 |
|---|---|---|
| electron | 28.x | 跨平台桌面框架 |
| @abandonware/noble | 1.9.x | BLE蓝牙通信 |
| better-sqlite3 | 9.x | 本地SQLite数据库 |
| react | 18.x | UI渲染框架 |
| zustand | 4.x | 轻量状态管理 |
| axios | 1.x | HTTP客户端 |
| pako | 2.x | gzip数据压缩 |
| electron-builder | 24.x | 安装包构建工具 |
| electron-vite | 1.x | 快速开发构建工具 |
| vite | 5.x | 前端构建工具 |
| typescript | 5.x | 类型安全语言 |
F.4 IPC通道列表
| 通道名 | 方向 | 说明 |
|---|---|---|
| ble:startScan | 渲染→主 | 触发BLE扫描 |
| ble:stopScan | 渲染→主 | 停止BLE扫描 |
| ble:connect | 渲染→主 | 连接指定BLE设备 |
| ble:deviceFound | 主→渲染 | 发现新BLE设备通知 |
| ble:inkData | 主→渲染 | 推送笔迹数据 |
| ble:connectionChanged | 主→渲染 | 设备连接状态变更通知 |
| db:saveStroke | 渲染→主 | 保存笔迹到本地数据库 |
| db:getStrokes | 渲染→主 | 查询笔迹记录 |
| sync:syncNow | 渲染→主 | 触发立即同步 |
| sync:progress | 主→渲染 | 同步进度通知 |
| files:exportToPdf | 渲染→主 | 导出PDF文件 |
| app:getVersion | 渲染→主 | 获取应用版本号 |
F.5 版本历史
| 版本 | 日期 | 平台 | 变更说明 |
|---|---|---|---|
| V0.5 Beta | 2025-08-01 | Win/Mac | Electron框架搭建,BLE连接,基础笔迹渲染 |
| V0.8 Beta | 2025-10-20 | Win/Mac/Linux | 作业批改、PDF导出、SQLite本地存储 |
| V0.9 RC | 2025-12-15 | Win/Mac/Linux | 云端同步、增量上传、自动更新 |
| V1.0 | 2026-02-14 | Win/Mac/Linux | 正式版:性能优化、安全加固、完整测试覆盖 |
本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。
附录G 补充技术规格
G.1 Windows驱动层集成
G.1.1 USB HID设备通信
PC客户端通过USB HID协议与智能笔通信:
// usb_hid_reader.cpp
#include <windows.h>
#include <hidsdi.h>
#include <setupapi.h>
#pragma comment(lib, "hid.lib")
#pragma comment(lib, "setupapi.lib")
class UsbHidReader {
HANDLE device_handle_ = INVALID_HANDLE_VALUE;
static const USHORT VENDOR_ID = 0x1234;
static const USHORT PRODUCT_ID = 0x5678;
public:
bool openDevice() {
GUID hid_guid;
HidD_GetHidGuid(&hid_guid);
HDEVINFO device_info = SetupDiGetClassDevs(
&hid_guid, nullptr, nullptr,
DIGCF_PRESENT | DIGCF_DEVICEINTERFACE);
SP_DEVICE_INTERFACE_DATA interface_data{};
interface_data.cbSize = sizeof(SP_DEVICE_INTERFACE_DATA);
for (DWORD i = 0; SetupDiEnumDeviceInterfaces(device_info, nullptr,
&hid_guid, i, &interface_data); i++) {
// 获取设备路径
DWORD required_size = 0;
SetupDiGetDeviceInterfaceDetail(device_info, &interface_data,
nullptr, 0, &required_size, nullptr);
auto detail_data = (SP_DEVICE_INTERFACE_DETAIL_DATA*)
malloc(required_size);
detail_data->cbSize = sizeof(SP_DEVICE_INTERFACE_DETAIL_DATA);
SetupDiGetDeviceInterfaceDetail(device_info, &interface_data,
detail_data, required_size, nullptr, nullptr);
HANDLE h = CreateFile(detail_data->DevicePath,
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, nullptr);
free(detail_data);
if (h != INVALID_HANDLE_VALUE) {
HIDD_ATTRIBUTES attrs{};
attrs.Size = sizeof(HIDD_ATTRIBUTES);
HidD_GetAttributes(h, &attrs);
if (attrs.VendorID == VENDOR_ID &&
attrs.ProductID == PRODUCT_ID) {
device_handle_ = h;
SetupDiDestroyDeviceInfoList(device_info);
return true;
}
CloseHandle(h);
}
}
SetupDiDestroyDeviceInfoList(device_info);
return false;
}
bool readReport(uint8_t* buffer, size_t size) {
DWORD bytes_read = 0;
OVERLAPPED ov{};
ov.hEvent = CreateEvent(nullptr, TRUE, FALSE, nullptr);
BOOL ok = ReadFile(device_handle_, buffer, (DWORD)size,
&bytes_read, &ov);
if (!ok && GetLastError() == ERROR_IO_PENDING) {
DWORD wait = WaitForSingleObject(ov.hEvent, 1000);
if (wait == WAIT_OBJECT_0) {
GetOverlappedResult(device_handle_, &ov, &bytes_read, FALSE);
ok = TRUE;
}
}
CloseHandle(ov.hEvent);
return ok != FALSE;
}
};
G.2 PDF课件渲染引擎
// pdf_renderer.cpp
#include <fpdfview.h> // PDFium
class PdfRenderer {
FPDF_DOCUMENT document_ = nullptr;
public:
bool loadFile(const wchar_t* path) {
FPDF_InitLibrary();
// 宽字符路径转UTF-8
int len = WideCharToMultiByte(CP_UTF8, 0, path, -1, nullptr, 0, nullptr, nullptr);
std::string utf8_path(len, 0);
WideCharToMultiByte(CP_UTF8, 0, path, -1, utf8_path.data(), len, nullptr, nullptr);
document_ = FPDF_LoadDocument(utf8_path.c_str(), nullptr);
return document_ != nullptr;
}
int getPageCount() {
return document_ ? FPDF_GetPageCount(document_) : 0;
}
// 渲染指定页为BGRA位图
std::vector<uint8_t> renderPage(int pageIndex, int targetWidth, int targetHeight) {
FPDF_PAGE page = FPDF_LoadPage(document_, pageIndex);
if (!page) return {};
FPDF_BITMAP bitmap = FPDFBitmap_Create(targetWidth, targetHeight, 1);
FPDFBitmap_FillRect(bitmap, 0, 0, targetWidth, targetHeight, 0xFFFFFFFF);
FPDF_RenderPageBitmap(bitmap, page, 0, 0, targetWidth, targetHeight,
0, FPDF_ANNOT);
const uint8_t* buf = (uint8_t*)FPDFBitmap_GetBuffer(bitmap);
int stride = FPDFBitmap_GetStride(bitmap);
std::vector<uint8_t> result(targetHeight * stride);
memcpy(result.data(), buf, result.size());
FPDFBitmap_Destroy(bitmap);
FPDF_ClosePage(page);
return result;
}
~PdfRenderer() {
if (document_) FPDF_CloseDocument(document_);
FPDF_DestroyLibrary();
}
};
G.3 系统托盘与开机自启
// system_tray.cpp
class SystemTray {
HWND hwnd_;
NOTIFYICONDATA nid_{};
HMENU popup_menu_ = nullptr;
public:
void create(HWND hwnd, HICON icon) {
hwnd_ = hwnd;
nid_.cbSize = sizeof(NOTIFYICONDATA);
nid_.hWnd = hwnd;
nid_.uID = 1;
nid_.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP;
nid_.uCallbackMessage = WM_USER + 1;
nid_.hIcon = icon;
wcscpy_s(nid_.szTip, L"自然写互动课堂");
Shell_NotifyIcon(NIM_ADD, &nid_);
popup_menu_ = CreatePopupMenu();
AppendMenu(popup_menu_, MF_STRING, 1001, L"打开主界面");
AppendMenu(popup_menu_, MF_SEPARATOR, 0, nullptr);
AppendMenu(popup_menu_, MF_STRING, 1002, L"退出");
}
void showContextMenu() {
POINT pt;
GetCursorPos(&pt);
SetForegroundWindow(hwnd_);
TrackPopupMenu(popup_menu_, TPM_RIGHTBUTTON,
pt.x, pt.y, 0, hwnd_, nullptr);
}
static void setAutoStart(bool enable) {
HKEY key;
RegOpenKeyEx(HKEY_CURRENT_USER,
L"Software\\Microsoft\\Windows\\CurrentVersion\\Run",
0, KEY_WRITE, &key);
if (enable) {
wchar_t exe_path[MAX_PATH];
GetModuleFileName(nullptr, exe_path, MAX_PATH);
RegSetValueEx(key, L"WritechClassroom", 0, REG_SZ,
(const BYTE*)exe_path, (wcslen(exe_path) + 1) * sizeof(wchar_t));
} else {
RegDeleteValue(key, L"WritechClassroom");
}
RegCloseKey(key);
}
};
附录H 补充技术规格
H.1 Windows通知API集成
// windows_toast.cpp - Windows 10/11 Toast通知
#include <winrt/Windows.UI.Notifications.h>
#include <winrt/Windows.Data.Xml.Dom.h>
using namespace winrt::Windows::UI::Notifications;
using namespace winrt::Windows::Data::Xml::Dom;
class ToastNotifier {
public:
static void showHomeworkReminder(const std::wstring& title,
const std::wstring& body) {
// 构建Toast XML模板
std::wstring xml = LR"(
<toast>
<visual>
<binding template="ToastGeneric">
<text id="1">)" + title + LR"(</text>
<text id="2">)" + body + LR"(</text>
<image placement="appLogoOverride" src="ms-appx:///Assets/logo.png"/>
</binding>
</visual>
<actions>
<action content="查看" activationType="foreground" arguments="view_homework"/>
<action content="稍后" activationType="system" arguments="dismiss"/>
</actions>
</toast>)";
XmlDocument doc;
doc.LoadXml(xml);
auto notifier = ToastNotificationManager::CreateToastNotifier(
L"com.writech.classroom");
ToastNotification notification(doc);
notifier.Show(notification);
}
};
H.2 多显示器支持
// multi_monitor.cpp
#include <windows.h>
#include <vector>
struct MonitorInfo {
HMONITOR handle;
RECT rect;
bool isPrimary;
int dpiX, dpiY;
};
std::vector<MonitorInfo> EnumerateMonitors() {
std::vector<MonitorInfo> monitors;
EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMon, HDC, LPRECT lpRect, LPARAM lParam) {
auto* list = reinterpret_cast<std::vector<MonitorInfo>*>(lParam);
MONITORINFOEX info;
info.cbSize = sizeof(MONITORINFOEX);
GetMonitorInfo(hMon, &info);
UINT dpiX = 96, dpiY = 96;
GetDpiForMonitor(hMon, MDT_EFFECTIVE_DPI, &dpiX, &dpiY);
list->push_back({
hMon,
info.rcWork,
(info.dwFlags & MONITORINFOF_PRIMARY) != 0,
(int)dpiX, (int)dpiY
});
return TRUE;
}, reinterpret_cast<LPARAM>(&monitors));
return monitors;
}
// 将窗口移动到指定显示器
void MoveWindowToMonitor(HWND hwnd, int monitorIndex) {
auto monitors = EnumerateMonitors();
if (monitorIndex >= monitors.size()) return;
const RECT& r = monitors[monitorIndex].rect;
int w = r.right - r.left;
int h = r.bottom - r.top;
SetWindowPos(hwnd, HWND_TOP, r.left, r.top, w, h, SWP_SHOWWINDOW);
}
本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。