# 自然写互动课堂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 { // 向云端申请点阵码范围 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(), // 已点名学生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 => { // 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 { 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 { 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 { // 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 = new Map() private inkCharacteristics: Map = new Map() private scanning = false async initialize(): Promise { 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 { this.scanning = true if (noble.state === 'poweredOn') { noble.startScanning([WRITECH_PEN_SERVICE_UUID], true) } } async stopScan(): Promise { this.scanning = false noble.stopScanning() } async connect(peripheralId: string): Promise { const peripheral = await this.findPeripheral(peripheralId) if (!peripheral) throw new Error('Device not found: ' + peripheralId) await new Promise((resolve, reject) => { peripheral.connect((err) => { if (err) reject(err) else resolve() }) }) // 发现服务和特征 const { characteristics } = await new Promise( (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((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 { 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 { 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 { // 拉取最新的批改结果 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 { 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 { 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 = ({ width, height, studentId, readonly = false }) => { const canvasRef = useRef(null) const contextRef = useRef(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 ( ) } ``` --- ## 附录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 #include #include #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 // 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 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 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 #include 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"( )" + title + LR"( )" + body + LR"( )"; 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 #include struct MonitorInfo { HMONITOR handle; RECT rect; bool isPrimary; int dpiX, dpiY; }; std::vector EnumerateMonitors() { std::vector monitors; EnumDisplayMonitors(nullptr, nullptr, [](HMONITOR hMon, HDC, LPRECT lpRect, LPARAM lParam) { auto* list = reinterpret_cast*>(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(&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); } ``` --- *本文档版权归深圳自然写科技有限公司所有,技术细节仅用于软件著作权登记鉴别,请勿用于其他商业用途。*