Files
system-design/software-copyright/08-writech-app-pc/自然写互动课堂PC端应用软件-鉴别材料.md
T
2026-03-22 15:24:40 +08:00

2584 lines
104 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 自然写互动课堂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 1064位,版本1903 | Windows 11 |
| 处理器 | Intel Core i54核) | 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请求库 |
| WebSocketws | 8.16.0 | 实时通信(主进程) |
| SQLitebetter-sqlite3 | 9.4.3 | 本地数据库(主进程) |
| IndexedDBDexie.js | 3.2.4 | 渲染进程大容量存储 |
| Canvas 2D + WebGL | 浏览器原生 | 笔迹渲染引擎 |
| C++ AddonNode-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-APIN-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客户端通过NobleNode.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);
}
```
---
*本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。*