2584 lines
104 KiB
Markdown
2584 lines
104 KiB
Markdown
# 自然写互动课堂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):**
|
||
|
||
```typescript
|
||
// 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实现高性能笔迹渲染,支持压感效果(根据压力值变化线宽)和笔锋效果(笔画首尾尖细):
|
||
|
||
```typescript
|
||
// 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):**
|
||
|
||
```typescript
|
||
// 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 安全设计
|
||
|
||
**应用级安全:**
|
||
|
||
```typescript
|
||
// 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 ▼] │
|
||
│ [+ 新增] │ │ │ │ │
|
||
│ │ │ [拖拽添加内容区域] │ │ 点阵码设置: │
|
||
│ │ │ │ │ [绑定点阵码] │
|
||
│ │ └────────────────────────────────────┘ │ │
|
||
├───────────┤ │ │
|
||
│ 元素库 │ [标注模式] [激光笔] [橡皮] [撤销] [重做] │ │
|
||
│ [图片] │ │ │
|
||
│ [文字框] │ │ │
|
||
│ [字帖] │ │ │
|
||
│ [题目] │ │ │
|
||
└───────────┴──────────────────────────────────────────┴───────────────┘
|
||
```
|
||
|
||
**课件数据结构:**
|
||
|
||
```typescript
|
||
// 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 }
|
||
}
|
||
```
|
||
|
||
**点阵码内容编辑(点阵码绑定器):**
|
||
|
||
```typescript
|
||
// 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│ 连接状态: │
|
||
│ │ │ ● 网关 已连 │
|
||
│ [激光笔] [标注] │ [全班展示] [对比] │ ● 算力盒 就绪 │
|
||
│ [橡皮] [清除] │ │ ● 投屏 未连 │
|
||
└──────────────────────┴──────────────────────┴──────────────────┘
|
||
```
|
||
|
||
**随机抽取学生(防重复抽取算法):**
|
||
|
||
```typescript
|
||
// 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辅助批改逻辑:**
|
||
|
||
```typescript
|
||
// 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):**
|
||
|
||
```typescript
|
||
// 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两种投屏方式:
|
||
|
||
```typescript
|
||
// 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`实现静默后台更新:
|
||
|
||
```typescript
|
||
// 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安装:**
|
||
|
||
1. 下载安装包 `Writech-PC-Setup-1.0.0.exe`(约200MB)
|
||
2. 双击运行,选择安装目录(默认`C:\Program Files\Writech`)
|
||
3. 安装过程自动注册文件关联和桌面快捷方式
|
||
4. 安装完成后桌面出现"自然写互动课堂"图标
|
||
|
||
**macOS安装:**
|
||
|
||
1. 下载 `Writech-PC-1.0.0.dmg`
|
||
2. 打开DMG,将"自然写互动课堂.app"拖拽到"应用程序"文件夹
|
||
3. 首次运行时macOS提示"来自已识别开发者",点击"打开"
|
||
4. 输入系统密码允许安装(需要系统管理员权限)
|
||
|
||
**首次配置:**
|
||
|
||
```
|
||
首次启动流程:
|
||
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 主进程核心模块
|
||
|
||
```javascript
|
||
// 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安全桥接
|
||
|
||
```javascript
|
||
// 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管理器实现
|
||
|
||
```typescript
|
||
// 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
|
||
|
||
```typescript
|
||
// 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 同步服务实现
|
||
|
||
```typescript
|
||
// 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/图片导出功能
|
||
|
||
```typescript
|
||
// 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笔迹绘制组件
|
||
|
||
```typescript
|
||
// 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安装:**
|
||
1. 下载 `writech-pc-setup-x.x.x.exe` 安装包。
|
||
2. 双击运行安装程序,选择安装目录(默认 `C:\Program Files\Writech PC`)。
|
||
3. 勾选"创建桌面快捷方式"和"开机自启动"(可选)。
|
||
4. 点击"安装",等待安装完成(约30秒)。
|
||
5. 点击"完成",勾选"立即启动"。
|
||
|
||
**macOS安装:**
|
||
1. 下载 `writech-pc-x.x.x.dmg` 磁盘镜像。
|
||
2. 双击打开DMG文件,将 Writech 图标拖入 Applications 文件夹。
|
||
3. 首次启动时,macOS提示"无法打开,因为它来自身份不明的开发者"。
|
||
4. 打开"系统偏好设置→安全性与隐私→通用",点击"仍然打开"。
|
||
5. 应用成功启动。
|
||
|
||
**Linux安装:**
|
||
```bash
|
||
# 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 首次登录
|
||
|
||
1. 启动应用,显示登录界面。
|
||
2. 输入学校管理员分配的账号和密码。
|
||
3. 可选"记住登录状态"(有效期30天)。
|
||
4. 点击"登录",首次登录需要下载数据(约30-60秒)。
|
||
5. 登录成功后进入主界面。
|
||
|
||
### D.2 智能笔连接
|
||
|
||
#### D.2.1 连接步骤
|
||
|
||
1. 打开自然写PC应用,点击顶部工具栏"连接笔"按钮(钢笔图标)。
|
||
2. 确保智能点阵笔蓝牙已开启(笔盖指示灯闪烁蓝色)。
|
||
3. 设备列表显示附近的自然写智能笔(以"WritechPen-"开头)。
|
||
4. 点击对应设备名称旁的"连接"按钮。
|
||
5. 配对过程约5-10秒,成功后指示灯变为常亮蓝色。
|
||
6. 状态栏显示"笔已连接:WritechPen-XXXX,电量:85%"。
|
||
|
||
#### D.2.2 多笔连接(教师模式)
|
||
|
||
教师使用PC应用时可同时连接多支智能笔(最多4支),用于不同颜色批注:
|
||
1. 重复上述连接步骤连接第二支笔。
|
||
2. 在"设置→笔管理"中为每支笔分配颜色。
|
||
3. 主界面工具栏显示当前激活的笔(点击切换)。
|
||
|
||
### D.3 课堂功能操作
|
||
|
||
#### D.3.1 开始课堂
|
||
|
||
1. 主界面点击"新建课堂"按钮。
|
||
2. 填写课堂信息:
|
||
- 班级(从下拉列表选择)
|
||
- 课程名称(如"三年级语文-第5课")
|
||
- 预计时长(30/45/60分钟)
|
||
3. 点击"开始课堂",系统自动创建课堂会话,生成课堂码(4位数字)。
|
||
4. 学生通过手机APP或Pad APP输入课堂码加入。
|
||
5. 教师界面左侧显示学生签到列表,右侧显示书写区域。
|
||
|
||
#### D.3.2 批改作业
|
||
|
||
1. 主界面选择"作业"标签,查看待批改作业列表。
|
||
2. 点击某学生的作业,右侧显示该学生的手写作业内容。
|
||
3. 批改操作:
|
||
- 选择红笔工具,在学生笔迹上方直接书写批注
|
||
- 点击"正确"/"错误"按钮快速标记
|
||
- 输入分数(0-100分)
|
||
- 添加文字评语(支持键盘输入或语音转文字)
|
||
4. 点击"提交批改",批改结果自动同步到学生APP。
|
||
|
||
#### D.3.3 统计报告查看
|
||
|
||
1. 主界面选择"报告"标签。
|
||
2. 选择报告类型:
|
||
- 班级报告:全班作业正确率、提交率分布图
|
||
- 个人报告:单学生历史成绩折线图、错题分析
|
||
- 知识点报告:按知识点统计掌握率(热力图展示)
|
||
3. 支持导出为PDF报告(菜单→导出→PDF)。
|
||
|
||
### D.4 数据管理
|
||
|
||
#### D.4.1 数据备份
|
||
|
||
1. 菜单→设置→数据管理→备份数据。
|
||
2. 选择备份目录(默认"文档/WritechBackup")。
|
||
3. 点击"立即备份",备份文件为加密的 `.wbk` 格式。
|
||
4. 自动备份频率:每7天一次(可在设置中调整)。
|
||
|
||
#### D.4.2 清理旧数据
|
||
|
||
1. 菜单→设置→数据管理→清理数据。
|
||
2. 选择要清理的数据范围:
|
||
- 3个月前的笔迹数据
|
||
- 6个月前的课堂记录
|
||
- 已同步到云端的本地缓存
|
||
3. 确认后开始清理,进度条显示清理进度。
|
||
|
||
### 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 构建配置
|
||
|
||
```json
|
||
// 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协议与智能笔通信:
|
||
|
||
```cpp
|
||
// 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课件渲染引擎
|
||
|
||
```cpp
|
||
// 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 系统托盘与开机自启
|
||
|
||
```cpp
|
||
// 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集成
|
||
|
||
```cpp
|
||
// 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 多显示器支持
|
||
|
||
```cpp
|
||
// 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);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
*本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。*
|