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

3331 lines
107 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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
## 软件著作权鉴别材料 — 源程序
> **权利人**:深圳自然写科技有限公司
> **版本号**V1.0
---
## 源程序目录结构
```
08-writech-app-pc/
├── cast/
│ └── screen_cast.ts
├── database/
│ └── db_manager.ts
├── main/
│ ├── device_manager.ts
│ └── main.ts
└── renderer/
├── api/
│ └── cloud_api.ts
├── components/
│ └── StrokeCanvas.vue
└── store/
└── index.ts
```
---
## 源程序文件清单
### `cast/`
#### `cast/screen_cast.ts`
```typescript
/**
* 自然写互动课堂PC端应用软件 V1.0
* WebRTC投屏模块 - 实现PC端屏幕内容投射到智慧黑板/电视大屏
*
* 功能说明:
* 1. WebRTC点对点连接建立(ICE候选收集、STUN/TURN穿透)
* 2. 屏幕捕获与视频流编码(desktopCapturer API
* 3. 自适应码率控制(根据网络状况动态调整分辨率和帧率)
* 4. 信令服务通信(通过WebSocket交换SDP和ICE候选)
* 5. 多目标同时投屏(一个PC端可投射到多个大屏设备)
* 6. 投屏区域选择(全屏/窗口/自定义区域)
* 7. 音频同步传输(系统音频 + 麦克风输入混合)
* 8. 投屏安全控制(PIN码配对,防止未授权投屏)
*/
import { EventEmitter } from 'events';
import crypto from 'crypto';
/* ========== 类型定义 ========== */
/** 投屏目标设备信息 */
interface CastTarget {
deviceId: string; // 大屏设备唯一标识
deviceName: string; // 设备显示名称(如"教室1号黑板"
deviceType: 'board' | 'tv'; // 设备类型:智慧黑板 / 电视
ipAddress: string; // 设备IP地址
port: number; // 信令端口
status: 'discovered' | 'connecting' | 'connected' | 'disconnected';
peerConnection: any; // RTCPeerConnection实例
lastPingTime: number; // 最后心跳时间
}
/** 投屏配置参数 */
interface CastConfig {
maxWidth: number; // 最大投屏分辨率宽度
maxHeight: number; // 最大投屏分辨率高度
maxFrameRate: number; // 最大帧率
minBitrate: number; // 最低码率(kbps
maxBitrate: number; // 最高码率(kbps
enableAudio: boolean; // 是否传输音频
captureMode: 'screen' | 'window' | 'region'; // 捕获模式
stunServers: string[]; // STUN服务器列表
turnServer: string; // TURN中继服务器地址
turnUsername: string; // TURN认证用户名
turnCredential: string; // TURN认证密码
signalServerUrl: string; // 信令服务器WebSocket地址
pinCode: string; // 投屏PIN码(4位数字)
}
/** 投屏质量统计 */
interface CastQualityStats {
currentBitrate: number; // 当前码率(kbps
currentFps: number; // 当前帧率
packetLoss: number; // 丢包率(百分比)
roundTripTime: number; // 往返延迟(毫秒)
resolution: string; // 当前分辨率
encoderType: string; // 编码器类型
timestamp: number;
}
/** 信令消息格式 */
interface SignalMessage {
type: 'offer' | 'answer' | 'candidate' | 'pin_verify' | 'cast_stop' | 'quality_adjust';
fromDeviceId: string;
toDeviceId: string;
payload: any;
timestamp: number;
signature: string; // HMAC-SHA256消息签名
}
/* ========== 投屏管理器 ========== */
// 默认投屏配置
const DEFAULT_CAST_CONFIG: CastConfig = {
maxWidth: 1920,
maxHeight: 1080,
maxFrameRate: 30,
minBitrate: 500,
maxBitrate: 4000,
enableAudio: true,
captureMode: 'screen',
stunServers: ['stun:stun.writech.com:3478'],
turnServer: 'turn:turn.writech.com:3478',
turnUsername: '',
turnCredential: '',
signalServerUrl: 'wss://signal.writech.com/cast',
pinCode: ''
};
/**
* 投屏管理器 - 管理WebRTC投屏的完整生命周期
* 支持同时向多个大屏设备投射内容
*/
class ScreenCastManager extends EventEmitter {
private config: CastConfig;
private targets: Map<string, CastTarget> = new Map(); // 投屏目标设备列表
private localStream: MediaStream | null = null; // 本地媒体流
private signalSocket: WebSocket | null = null; // 信令WebSocket连接
private localDeviceId: string; // 本机设备标识
private statsTimers: Map<string, ReturnType<typeof setInterval>> = new Map();
private qualityHistory: CastQualityStats[] = []; // 质量统计历史
private isCapturing: boolean = false;
private hmacKey: string; // 消息签名密钥
constructor(config?: Partial<CastConfig>) {
super();
this.config = { ...DEFAULT_CAST_CONFIG, ...config };
// 使用机器MAC地址+时间戳生成唯一设备标识
this.localDeviceId = `pc_${crypto.randomBytes(4).toString('hex')}`;
this.hmacKey = crypto.randomBytes(16).toString('hex');
}
/**
* 初始化投屏管理器
* 建立信令服务器连接,准备接收设备发现消息
*/
async initialize(): Promise<void> {
try {
await this.connectSignalServer();
console.log('[ScreenCast] 投屏管理器初始化完成');
} catch (error) {
console.error('[ScreenCast] 初始化失败:', error);
throw error;
}
}
/**
* 连接信令服务器(通过WebSocket交换SDP和ICE候选)
* 支持断线自动重连(指数退避策略)
*/
private async connectSignalServer(): Promise<void> {
return new Promise((resolve, reject) => {
const url = `${this.config.signalServerUrl}?deviceId=${this.localDeviceId}&type=pc`;
this.signalSocket = new WebSocket(url);
this.signalSocket.onopen = () => {
console.log('[ScreenCast] 信令服务器连接成功');
resolve();
};
this.signalSocket.onmessage = (event: MessageEvent) => {
try {
const message: SignalMessage = JSON.parse(event.data);
this.handleSignalMessage(message);
} catch (error) {
console.error('[ScreenCast] 信令消息解析失败:', error);
}
};
this.signalSocket.onclose = () => {
console.warn('[ScreenCast] 信令连接断开,5秒后重连');
setTimeout(() => this.connectSignalServer(), 5000);
};
this.signalSocket.onerror = (error) => {
console.error('[ScreenCast] 信令连接错误:', error);
reject(error);
};
});
}
/**
* 处理信令消息分发
* 根据消息类型执行不同的操作(SDP交换/ICE候选/PIN验证等)
*/
private handleSignalMessage(message: SignalMessage): void {
// 验证消息签名(防止篡改)
if (message.signature && !this.verifyMessageSignature(message)) {
console.warn('[ScreenCast] 消息签名验证失败,丢弃:', message.type);
return;
}
switch (message.type) {
case 'answer':
this.handleRemoteAnswer(message.fromDeviceId, message.payload);
break;
case 'candidate':
this.handleRemoteCandidate(message.fromDeviceId, message.payload);
break;
case 'pin_verify':
this.handlePinVerifyResult(message.fromDeviceId, message.payload);
break;
case 'quality_adjust':
this.handleQualityAdjust(message.fromDeviceId, message.payload);
break;
case 'cast_stop':
this.handleRemoteStop(message.fromDeviceId);
break;
default:
console.warn('[ScreenCast] 未知信令类型:', message.type);
}
}
/**
* 开始屏幕捕获 - 使用Electron desktopCapturer API获取屏幕视频流
* 支持全屏、窗口、自定义区域三种捕获模式
*/
async startCapture(sourceId?: string): Promise<void> {
if (this.isCapturing) {
console.warn('[ScreenCast] 已在投屏中,请先停止当前投屏');
return;
}
try {
// 通过Electron desktopCapturer获取可用的屏幕/窗口源
const { desktopCapturer } = require('electron');
const sources = await desktopCapturer.getSources({
types: this.config.captureMode === 'window' ? ['window'] : ['screen'],
thumbnailSize: { width: 320, height: 180 }
});
if (sources.length === 0) {
throw new Error('未找到可用的屏幕源');
}
// 选择屏幕源(默认使用第一个或指定的源)
const selectedSource = sourceId
? sources.find((s: any) => s.id === sourceId) || sources[0]
: sources[0];
// 配置视频约束参数
const videoConstraints: any = {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: selectedSource.id,
maxWidth: this.config.maxWidth,
maxHeight: this.config.maxHeight,
maxFrameRate: this.config.maxFrameRate,
minFrameRate: 15
}
};
// 获取媒体流(视频 + 可选音频)
const stream = await (navigator.mediaDevices as any).getUserMedia({
video: videoConstraints,
audio: this.config.enableAudio ? {
mandatory: { chromeMediaSource: 'desktop' }
} : false
});
this.localStream = stream;
this.isCapturing = true;
this.emit('captureStarted', { sourceId: selectedSource.id, name: selectedSource.name });
console.log('[ScreenCast] 屏幕捕获已启动:', selectedSource.name);
} catch (error) {
console.error('[ScreenCast] 屏幕捕获失败:', error);
throw error;
}
}
/**
* 向指定大屏设备发起投屏连接
* 创建RTCPeerConnection,添加本地流,发送SDP Offer
*/
async castToDevice(deviceId: string, deviceName: string, ipAddress: string, port: number): Promise<void> {
if (!this.localStream) {
throw new Error('请先启动屏幕捕获');
}
// 创建投屏目标记录
const target: CastTarget = {
deviceId, deviceName,
deviceType: 'board',
ipAddress, port,
status: 'connecting',
peerConnection: null,
lastPingTime: Date.now()
};
// 配置ICE服务器(STUN + TURN
const iceConfig: RTCConfiguration = {
iceServers: [
{ urls: this.config.stunServers },
{
urls: this.config.turnServer,
username: this.config.turnUsername,
credential: this.config.turnCredential
}
],
iceCandidatePoolSize: 10
};
// 创建RTCPeerConnection
const pc = new RTCPeerConnection(iceConfig);
target.peerConnection = pc;
// 添加本地媒体流的所有轨道
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream!);
});
// 配置视频编码参数(优先使用H.264 High Profile
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings.length > 0) {
params.encodings[0].maxBitrate = this.config.maxBitrate * 1000;
params.encodings[0].maxFramerate = this.config.maxFrameRate;
await sender.setParameters(params);
}
}
// 监听ICE候选事件,发送给对端
pc.onicecandidate = (event) => {
if (event.candidate) {
this.sendSignalMessage({
type: 'candidate',
fromDeviceId: this.localDeviceId,
toDeviceId: deviceId,
payload: event.candidate.toJSON(),
timestamp: Date.now(),
signature: ''
});
}
};
// 监听连接状态变化
pc.onconnectionstatechange = () => {
console.log(`[ScreenCast] 连接状态[${deviceName}]:`, pc.connectionState);
switch (pc.connectionState) {
case 'connected':
target.status = 'connected';
this.startQualityMonitor(deviceId);
this.emit('deviceConnected', { deviceId, deviceName });
break;
case 'disconnected':
case 'failed':
target.status = 'disconnected';
this.stopQualityMonitor(deviceId);
this.emit('deviceDisconnected', { deviceId, deviceName });
break;
}
};
// 创建并发送SDP Offer
const offer = await pc.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: false
});
await pc.setLocalDescription(offer);
// 通过信令服务器发送Offer给大屏设备
this.sendSignalMessage({
type: 'offer',
fromDeviceId: this.localDeviceId,
toDeviceId: deviceId,
payload: { sdp: offer.sdp, type: offer.type, pinCode: this.config.pinCode },
timestamp: Date.now(),
signature: ''
});
this.targets.set(deviceId, target);
console.log(`[ScreenCast] 已向 ${deviceName} 发起投屏请求`);
}
/** 处理远端设备的SDP Answer */
private async handleRemoteAnswer(deviceId: string, payload: any): Promise<void> {
const target = this.targets.get(deviceId);
if (!target || !target.peerConnection) return;
try {
const answer = new RTCSessionDescription(payload);
await target.peerConnection.setRemoteDescription(answer);
console.log(`[ScreenCast] 收到 ${target.deviceName} 的Answer`);
} catch (error) {
console.error(`[ScreenCast] 设置RemoteDescription失败:`, error);
}
}
/** 处理远端ICE候选 */
private async handleRemoteCandidate(deviceId: string, payload: any): Promise<void> {
const target = this.targets.get(deviceId);
if (!target || !target.peerConnection) return;
try {
const candidate = new RTCIceCandidate(payload);
await target.peerConnection.addIceCandidate(candidate);
} catch (error) {
console.error('[ScreenCast] 添加ICE候选失败:', error);
}
}
/** 处理PIN码验证结果 */
private handlePinVerifyResult(deviceId: string, payload: { verified: boolean }): void {
if (!payload.verified) {
console.warn(`[ScreenCast] 设备 ${deviceId} PIN码验证失败`);
this.disconnectDevice(deviceId);
this.emit('pinVerifyFailed', { deviceId });
}
}
/** 处理远端质量调整请求(大屏端网络差时要求降低码率) */
private handleQualityAdjust(deviceId: string, payload: { maxBitrate?: number; maxFps?: number }): void {
const target = this.targets.get(deviceId);
if (!target || !target.peerConnection) return;
const sender = target.peerConnection.getSenders().find((s: any) => s.track?.kind === 'video');
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings.length > 0) {
if (payload.maxBitrate) {
params.encodings[0].maxBitrate = payload.maxBitrate * 1000;
}
if (payload.maxFps) {
params.encodings[0].maxFramerate = payload.maxFps;
}
sender.setParameters(params);
console.log(`[ScreenCast] 已调整投屏质量: 码率=${payload.maxBitrate}kbps, 帧率=${payload.maxFps}fps`);
}
}
}
/** 处理远端停止投屏请求 */
private handleRemoteStop(deviceId: string): void {
console.log(`[ScreenCast] 收到远端停止请求: ${deviceId}`);
this.disconnectDevice(deviceId);
}
/**
* 启动投屏质量监控
* 每3秒采集一次WebRTC连接统计信息
*/
private startQualityMonitor(deviceId: string): void {
const timer = setInterval(async () => {
const target = this.targets.get(deviceId);
if (!target || !target.peerConnection) {
this.stopQualityMonitor(deviceId);
return;
}
try {
const stats = await target.peerConnection.getStats();
let qualityStats: CastQualityStats = {
currentBitrate: 0, currentFps: 0,
packetLoss: 0, roundTripTime: 0,
resolution: '', encoderType: '',
timestamp: Date.now()
};
stats.forEach((report: any) => {
if (report.type === 'outbound-rtp' && report.kind === 'video') {
qualityStats.currentBitrate = Math.round((report.bytesSent * 8) / 1000);
qualityStats.currentFps = report.framesPerSecond || 0;
qualityStats.resolution = `${report.frameWidth}x${report.frameHeight}`;
qualityStats.encoderType = report.encoderImplementation || 'unknown';
}
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
qualityStats.roundTripTime = report.currentRoundTripTime * 1000;
}
if (report.type === 'remote-inbound-rtp') {
qualityStats.packetLoss = report.fractionLost * 100;
}
});
// 保存统计历史(最多保留1000条)
this.qualityHistory.push(qualityStats);
if (this.qualityHistory.length > 1000) {
this.qualityHistory.splice(0, this.qualityHistory.length - 1000);
}
// 自适应码率控制:丢包率过高时自动降低码率
if (qualityStats.packetLoss > 5) {
const reducedBitrate = Math.max(
this.config.minBitrate,
qualityStats.currentBitrate * 0.7
);
this.adjustBitrate(deviceId, reducedBitrate);
} else if (qualityStats.packetLoss < 1 && qualityStats.currentBitrate < this.config.maxBitrate) {
// 网络状况良好时逐步提高码率
const increasedBitrate = Math.min(
this.config.maxBitrate,
qualityStats.currentBitrate * 1.1
);
this.adjustBitrate(deviceId, increasedBitrate);
}
this.emit('qualityUpdate', { deviceId, stats: qualityStats });
} catch (error) {
console.error('[ScreenCast] 质量监控统计失败:', error);
}
}, 3000);
this.statsTimers.set(deviceId, timer);
}
/** 停止质量监控 */
private stopQualityMonitor(deviceId: string): void {
const timer = this.statsTimers.get(deviceId);
if (timer) {
clearInterval(timer);
this.statsTimers.delete(deviceId);
}
}
/** 动态调整视频码率 */
private adjustBitrate(deviceId: string, targetBitrate: number): void {
const target = this.targets.get(deviceId);
if (!target || !target.peerConnection) return;
const sender = target.peerConnection.getSenders().find((s: any) => s.track?.kind === 'video');
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings.length > 0) {
params.encodings[0].maxBitrate = Math.round(targetBitrate * 1000);
sender.setParameters(params).catch((e: Error) => {
console.error('[ScreenCast] 码率调整失败:', e.message);
});
}
}
}
/** 断开指定设备的投屏连接 */
disconnectDevice(deviceId: string): void {
const target = this.targets.get(deviceId);
if (!target) return;
// 关闭PeerConnection
if (target.peerConnection) {
target.peerConnection.close();
}
// 停止质量监控
this.stopQualityMonitor(deviceId);
// 通知对端
this.sendSignalMessage({
type: 'cast_stop',
fromDeviceId: this.localDeviceId,
toDeviceId: deviceId,
payload: {},
timestamp: Date.now(),
signature: ''
});
this.targets.delete(deviceId);
this.emit('deviceDisconnected', { deviceId, deviceName: target.deviceName });
console.log(`[ScreenCast] 已断开投屏: ${target.deviceName}`);
}
/** 停止所有投屏并释放资源 */
stopAllCasting(): void {
// 断开所有投屏目标
for (const deviceId of this.targets.keys()) {
this.disconnectDevice(deviceId);
}
// 停止屏幕捕获
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
this.localStream = null;
}
this.isCapturing = false;
this.emit('allCastingStopped');
console.log('[ScreenCast] 所有投屏已停止');
}
/** 发送信令消息(附加HMAC-SHA256签名) */
private sendSignalMessage(message: SignalMessage): void {
// 生成消息签名,防止信令被篡改
const content = `${message.type}:${message.fromDeviceId}:${message.toDeviceId}:${message.timestamp}`;
message.signature = crypto.createHmac('sha256', this.hmacKey).update(content).digest('hex');
if (this.signalSocket && this.signalSocket.readyState === WebSocket.OPEN) {
this.signalSocket.send(JSON.stringify(message));
} else {
console.warn('[ScreenCast] 信令连接不可用,消息发送失败');
}
}
/** 验证收到的信令消息签名 */
private verifyMessageSignature(message: SignalMessage): boolean {
const content = `${message.type}:${message.fromDeviceId}:${message.toDeviceId}:${message.timestamp}`;
const expected = crypto.createHmac('sha256', this.hmacKey).update(content).digest('hex');
return message.signature === expected;
}
/** 获取当前投屏状态汇总 */
getStatus(): { isCapturing: boolean; connectedDevices: number; targets: any[] } {
const targetList = Array.from(this.targets.values()).map(t => ({
deviceId: t.deviceId,
deviceName: t.deviceName,
status: t.status,
deviceType: t.deviceType
}));
return {
isCapturing: this.isCapturing,
connectedDevices: targetList.filter(t => t.status === 'connected').length,
targets: targetList
};
}
/** 销毁投屏管理器,释放所有资源 */
destroy(): void {
this.stopAllCasting();
if (this.signalSocket) {
this.signalSocket.close();
this.signalSocket = null;
}
this.qualityHistory = [];
this.removeAllListeners();
console.log('[ScreenCast] 投屏管理器已销毁');
}
}
export default ScreenCastManager;
```
### `database/`
#### `database/db_manager.ts`
```typescript
/**
* 自然写互动课堂PC端应用软件 V1.0
* 数据库管理模块 - 基于better-sqlite3实现SQLite本地数据持久化
*
* 功能说明:
* 1. 数据库初始化与版本迁移(Schema Migration
* 2. 学生笔迹数据的存储与检索(支持按学生/作业/时间维度查询)
* 3. 作业批改记录管理(AI批改 + 人工标注)
* 4. 班级/学生信息本地缓存(减少网络请求)
* 5. 点阵码映射关系维护(课件页面与点阵码对应)
* 6. 课件元数据索引(本地课件文件的管理信息)
* 7. 数据库文件加密(SQLCipher集成,防止本地数据泄露)
* 8. 自动备份与数据清理策略
*/
import path from 'path';
import fs from 'fs';
import { app } from 'electron';
import crypto from 'crypto';
/* ========== 类型定义 ========== */
/** 数据库配置接口 */
interface DatabaseConfig {
dbPath: string; // 数据库文件路径
encryptionKey: string; // 加密密钥(SQLCipher
maxBackups: number; // 最大备份数量
autoVacuumInterval: number; // 自动整理间隔(毫秒)
walMode: boolean; // 是否启用WAL模式
}
/** 学生笔迹记录 */
interface StrokeRecord {
id: string;
studentId: string;
studentName: string;
assignmentId: string;
pageIndex: number;
strokeData: string; // JSON序列化的笔迹坐标数据
thumbnailPath: string; // 缩略图文件路径
collectTime: number; // 采集时间戳
syncStatus: number; // 同步状态: 0=未同步, 1=已同步, 2=同步失败
fileSize: number; // 数据大小(字节)
}
/** 批改记录 */
interface GradeRecord {
id: string;
assignmentId: string;
studentId: string;
aiScore: number; // AI评分(0-100
teacherScore: number; // 教师评分(-1表示未批改)
aiAnnotation: string; // AI批改标注JSON
teacherAnnotation: string; // 教师手动标注JSON
gradeTime: number;
status: number; // 0=待批改, 1=AI已批, 2=教师已批
}
/** 班级信息 */
interface ClassInfo {
classId: string;
className: string;
grade: string;
teacherId: string;
studentCount: number;
lastSyncTime: number;
}
/** 学生信息 */
interface StudentInfo {
studentId: string;
studentName: string;
classId: string;
seatNumber: number;
penDeviceId: string; // 绑定的点阵笔设备ID
avatarPath: string;
}
/** 点阵码映射 */
interface DotCodeMapping {
dotCodeId: string; // 点阵码唯一标识
coursewareId: string; // 课件ID
pageIndex: number; // 对应页面索引
regionType: string; // 区域类型: 'answer'/'writing'/'drawing'
coordinates: string; // 区域坐标JSON
}
/** 课件元数据 */
interface CoursewareMeta {
coursewareId: string;
title: string;
type: string; // 'ppt'/'pdf'/'custom'
filePath: string; // 本地文件路径
pageCount: number;
fileSize: number;
createTime: number;
lastOpenTime: number;
cloudUrl: string; // 云端地址
syncStatus: number;
}
/** 迁移脚本定义 */
interface Migration {
version: number;
description: string;
sql: string;
}
/* ========== 数据库管理器 ========== */
// 数据库Schema版本号,每次表结构变更递增
const CURRENT_SCHEMA_VERSION = 5;
/**
* 数据库管理器 - 统一管理SQLite数据库的生命周期
* 采用单例模式确保全局唯一数据库连接
*/
class DatabaseManager {
private db: any = null; // better-sqlite3 数据库实例
private config: DatabaseConfig; // 数据库配置
private backupTimer: ReturnType<typeof setInterval> | null = null;
private vacuumTimer: ReturnType<typeof setInterval> | null = null;
private initialized: boolean = false;
constructor() {
// 默认配置:数据库存储在应用数据目录
const userDataPath = app.getPath('userData');
this.config = {
dbPath: path.join(userDataPath, 'writech_data.db'),
encryptionKey: this.loadOrCreateEncryptionKey(),
maxBackups: 5,
autoVacuumInterval: 24 * 60 * 60 * 1000, // 每24小时整理一次
walMode: true
};
}
/**
* 加载或创建数据库加密密钥
* 密钥存储在操作系统安全凭据管理器中(通过keytar)
* 首次运行时生成随机256位密钥
*/
private loadOrCreateEncryptionKey(): string {
const keyFilePath = path.join(app.getPath('userData'), '.db_key');
try {
if (fs.existsSync(keyFilePath)) {
return fs.readFileSync(keyFilePath, 'utf-8').trim();
}
// 生成256位随机密钥并保存
const newKey = crypto.randomBytes(32).toString('hex');
fs.writeFileSync(keyFilePath, newKey, { mode: 0o600 });
console.log('[DatabaseManager] 已生成新的数据库加密密钥');
return newKey;
} catch (error) {
console.error('[DatabaseManager] 密钥管理失败,使用默认密钥:', error);
return 'writech_default_key_2024';
}
}
/**
* 初始化数据库连接并执行迁移
* 启用WAL模式提高并发读写性能
* 设置SQLCipher加密密钥
*/
async initialize(): Promise<void> {
if (this.initialized) return;
try {
const Database = require('better-sqlite3');
const dbDir = path.dirname(this.config.dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
// 创建数据库连接(启用verbose日志用于调试)
this.db = new Database(this.config.dbPath, { verbose: undefined });
// 设置SQLCipher加密密钥
this.db.pragma(`key='${this.config.encryptionKey}'`);
// 启用WAL模式提高并发性能
if (this.config.walMode) {
this.db.pragma('journal_mode=WAL');
this.db.pragma('synchronous=NORMAL');
}
// 启用外键约束
this.db.pragma('foreign_keys=ON');
// 执行数据库迁移
this.runMigrations();
// 启动定时任务(备份 + 整理)
this.startAutoBackup();
this.startAutoVacuum();
this.initialized = true;
console.log('[DatabaseManager] 数据库初始化完成,版本:', CURRENT_SCHEMA_VERSION);
} catch (error) {
console.error('[DatabaseManager] 数据库初始化失败:', error);
throw error;
}
}
/**
* 获取所有迁移脚本列表
* 每个版本对应一个迁移脚本,按版本号顺序执行
*/
private getMigrations(): Migration[] {
return [
{
version: 1,
description: '创建基础表结构',
sql: `
-- 学生笔迹数据表
CREATE TABLE IF NOT EXISTS stroke_records (
id TEXT PRIMARY KEY,
student_id TEXT NOT NULL,
student_name TEXT NOT NULL,
assignment_id TEXT NOT NULL,
page_index INTEGER DEFAULT 0,
stroke_data TEXT NOT NULL,
thumbnail_path TEXT DEFAULT '',
collect_time INTEGER NOT NULL,
sync_status INTEGER DEFAULT 0,
file_size INTEGER DEFAULT 0,
created_at INTEGER DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_stroke_student ON stroke_records(student_id);
CREATE INDEX IF NOT EXISTS idx_stroke_assignment ON stroke_records(assignment_id);
CREATE INDEX IF NOT EXISTS idx_stroke_time ON stroke_records(collect_time);
-- 批改记录表
CREATE TABLE IF NOT EXISTS grade_records (
id TEXT PRIMARY KEY,
assignment_id TEXT NOT NULL,
student_id TEXT NOT NULL,
ai_score REAL DEFAULT -1,
teacher_score REAL DEFAULT -1,
ai_annotation TEXT DEFAULT '{}',
teacher_annotation TEXT DEFAULT '{}',
grade_time INTEGER NOT NULL,
status INTEGER DEFAULT 0,
created_at INTEGER DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_grade_assignment ON grade_records(assignment_id);
CREATE INDEX IF NOT EXISTS idx_grade_student ON grade_records(student_id);
`
},
{
version: 2,
description: '添加班级和学生信息表',
sql: `
-- 班级信息缓存表
CREATE TABLE IF NOT EXISTS class_info (
class_id TEXT PRIMARY KEY,
class_name TEXT NOT NULL,
grade TEXT DEFAULT '',
teacher_id TEXT NOT NULL,
student_count INTEGER DEFAULT 0,
last_sync_time INTEGER DEFAULT 0
);
-- 学生信息缓存表
CREATE TABLE IF NOT EXISTS student_info (
student_id TEXT PRIMARY KEY,
student_name TEXT NOT NULL,
class_id TEXT NOT NULL,
seat_number INTEGER DEFAULT 0,
pen_device_id TEXT DEFAULT '',
avatar_path TEXT DEFAULT '',
FOREIGN KEY (class_id) REFERENCES class_info(class_id)
);
CREATE INDEX IF NOT EXISTS idx_student_class ON student_info(class_id);
CREATE INDEX IF NOT EXISTS idx_student_pen ON student_info(pen_device_id);
`
},
{
version: 3,
description: '添加点阵码映射表',
sql: `
-- 点阵码映射关系表(课件页面与点阵码ID对应)
CREATE TABLE IF NOT EXISTS dot_code_mapping (
dot_code_id TEXT PRIMARY KEY,
courseware_id TEXT NOT NULL,
page_index INTEGER NOT NULL,
region_type TEXT DEFAULT 'answer',
coordinates TEXT DEFAULT '{}',
created_at INTEGER DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_dotcode_courseware ON dot_code_mapping(courseware_id);
`
},
{
version: 4,
description: '添加课件元数据表',
sql: `
-- 课件元数据索引表
CREATE TABLE IF NOT EXISTS courseware_meta (
courseware_id TEXT PRIMARY KEY,
title TEXT NOT NULL,
type TEXT DEFAULT 'custom',
file_path TEXT NOT NULL,
page_count INTEGER DEFAULT 0,
file_size INTEGER DEFAULT 0,
create_time INTEGER NOT NULL,
last_open_time INTEGER DEFAULT 0,
cloud_url TEXT DEFAULT '',
sync_status INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_courseware_type ON courseware_meta(type);
CREATE INDEX IF NOT EXISTS idx_courseware_time ON courseware_meta(last_open_time);
`
},
{
version: 5,
description: '添加同步日志表用于离线数据追踪',
sql: `
-- 数据同步日志表(记录所有待同步操作)
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
operation TEXT NOT NULL,
payload TEXT DEFAULT '{}',
sync_status INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
created_at INTEGER DEFAULT (strftime('%s','now')),
synced_at INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sync_status ON sync_log(sync_status);
`
}
];
}
/**
* 执行数据库迁移
* 检查当前版本号,依次执行未执行的迁移脚本
* 使用事务确保迁移的原子性
*/
private runMigrations(): void {
// 创建版本跟踪表
this.db.exec(`
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
description TEXT,
applied_at INTEGER DEFAULT (strftime('%s','now'))
);
`);
// 获取当前数据库版本
const row = this.db.prepare('SELECT MAX(version) as ver FROM schema_version').get();
const currentVersion = row?.ver || 0;
if (currentVersion >= CURRENT_SCHEMA_VERSION) {
console.log('[DatabaseManager] 数据库已是最新版本:', currentVersion);
return;
}
// 获取待执行的迁移脚本并按版本排序执行
const migrations = this.getMigrations().filter(m => m.version > currentVersion);
const runAll = this.db.transaction(() => {
for (const migration of migrations) {
console.log(`[DatabaseManager] 执行迁移 v${migration.version}: ${migration.description}`);
this.db.exec(migration.sql);
this.db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)')
.run(migration.version, migration.description);
}
});
runAll();
console.log(`[DatabaseManager] 迁移完成: v${currentVersion} -> v${CURRENT_SCHEMA_VERSION}`);
}
/* ========== 笔迹数据操作 ========== */
/** 保存学生笔迹记录(批量插入,提高写入性能) */
saveStrokeRecords(records: StrokeRecord[]): number {
const insertStmt = this.db.prepare(`
INSERT OR REPLACE INTO stroke_records
(id, student_id, student_name, assignment_id, page_index,
stroke_data, thumbnail_path, collect_time, sync_status, file_size)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
// 使用事务批量插入,避免逐条写入导致的性能问题
const insertMany = this.db.transaction((items: StrokeRecord[]) => {
let count = 0;
for (const r of items) {
insertStmt.run(
r.id, r.studentId, r.studentName, r.assignmentId,
r.pageIndex, r.strokeData, r.thumbnailPath,
r.collectTime, r.syncStatus, r.fileSize
);
count++;
}
// 同时记录同步日志
const logStmt = this.db.prepare(`
INSERT INTO sync_log (table_name, record_id, operation, payload)
VALUES ('stroke_records', ?, 'INSERT', ?)
`);
for (const r of items) {
logStmt.run(r.id, JSON.stringify({ assignmentId: r.assignmentId }));
}
return count;
});
return insertMany(records);
}
/** 按作业ID查询笔迹(支持分页) */
getStrokesByAssignment(assignmentId: string, page: number = 0, pageSize: number = 50): StrokeRecord[] {
const offset = page * pageSize;
return this.db.prepare(`
SELECT id, student_id as studentId, student_name as studentName,
assignment_id as assignmentId, page_index as pageIndex,
stroke_data as strokeData, thumbnail_path as thumbnailPath,
collect_time as collectTime, sync_status as syncStatus,
file_size as fileSize
FROM stroke_records
WHERE assignment_id = ?
ORDER BY collect_time DESC
LIMIT ? OFFSET ?
`).all(assignmentId, pageSize, offset);
}
/** 查询某学生的所有笔迹(用于学情分析) */
getStrokesByStudent(studentId: string, startTime?: number, endTime?: number): StrokeRecord[] {
let sql = `SELECT * FROM stroke_records WHERE student_id = ?`;
const params: any[] = [studentId];
if (startTime) {
sql += ' AND collect_time >= ?';
params.push(startTime);
}
if (endTime) {
sql += ' AND collect_time <= ?';
params.push(endTime);
}
sql += ' ORDER BY collect_time DESC';
return this.db.prepare(sql).all(...params);
}
/** 获取未同步的笔迹记录(用于断网重连后批量上传) */
getUnsyncedStrokes(limit: number = 100): StrokeRecord[] {
return this.db.prepare(`
SELECT * FROM stroke_records
WHERE sync_status = 0
ORDER BY collect_time ASC
LIMIT ?
`).all(limit);
}
/** 批量更新笔迹同步状态 */
updateStrokeSyncStatus(ids: string[], status: number): void {
const placeholders = ids.map(() => '?').join(',');
this.db.prepare(`
UPDATE stroke_records SET sync_status = ?
WHERE id IN (${placeholders})
`).run(status, ...ids);
}
/* ========== 批改记录操作 ========== */
/** 保存或更新批改记录 */
saveGradeRecord(record: GradeRecord): void {
this.db.prepare(`
INSERT OR REPLACE INTO grade_records
(id, assignment_id, student_id, ai_score, teacher_score,
ai_annotation, teacher_annotation, grade_time, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
record.id, record.assignmentId, record.studentId,
record.aiScore, record.teacherScore,
record.aiAnnotation, record.teacherAnnotation,
record.gradeTime, record.status
);
}
/** 查询作业的批改结果列表 */
getGradesByAssignment(assignmentId: string): GradeRecord[] {
return this.db.prepare(`
SELECT g.*, s.student_name as studentName
FROM grade_records g
LEFT JOIN student_info s ON g.student_id = s.student_id
WHERE g.assignment_id = ?
ORDER BY g.grade_time DESC
`).all(assignmentId);
}
/** 获取待教师批改的记录数 */
getPendingGradeCount(): number {
const row = this.db.prepare(`
SELECT COUNT(*) as cnt FROM grade_records WHERE status < 2
`).get();
return row?.cnt || 0;
}
/* ========== 班级/学生信息操作 ========== */
/** 批量同步班级信息(从云端拉取后缓存到本地) */
syncClassInfo(classes: ClassInfo[]): void {
const upsert = this.db.prepare(`
INSERT OR REPLACE INTO class_info
(class_id, class_name, grade, teacher_id, student_count, last_sync_time)
VALUES (?, ?, ?, ?, ?, ?)
`);
const syncAll = this.db.transaction((items: ClassInfo[]) => {
for (const c of items) {
upsert.run(c.classId, c.className, c.grade, c.teacherId, c.studentCount, Date.now());
}
});
syncAll(classes);
}
/** 批量同步学生信息 */
syncStudentInfo(students: StudentInfo[]): void {
const upsert = this.db.prepare(`
INSERT OR REPLACE INTO student_info
(student_id, student_name, class_id, seat_number, pen_device_id, avatar_path)
VALUES (?, ?, ?, ?, ?, ?)
`);
const syncAll = this.db.transaction((items: StudentInfo[]) => {
for (const s of items) {
upsert.run(s.studentId, s.studentName, s.classId, s.seatNumber, s.penDeviceId, s.avatarPath);
}
});
syncAll(students);
}
/** 按班级查询学生列表 */
getStudentsByClass(classId: string): StudentInfo[] {
return this.db.prepare(`
SELECT * FROM student_info WHERE class_id = ? ORDER BY seat_number
`).all(classId);
}
/** 通过点阵笔设备ID查找学生(用于实时笔迹识别) */
findStudentByPenDevice(penDeviceId: string): StudentInfo | undefined {
return this.db.prepare(`
SELECT * FROM student_info WHERE pen_device_id = ?
`).get(penDeviceId);
}
/* ========== 点阵码映射操作 ========== */
/** 保存点阵码映射关系 */
saveDotCodeMappings(mappings: DotCodeMapping[]): void {
const upsert = this.db.prepare(`
INSERT OR REPLACE INTO dot_code_mapping
(dot_code_id, courseware_id, page_index, region_type, coordinates)
VALUES (?, ?, ?, ?, ?)
`);
const saveAll = this.db.transaction((items: DotCodeMapping[]) => {
for (const m of items) {
upsert.run(m.dotCodeId, m.coursewareId, m.pageIndex, m.regionType, m.coordinates);
}
});
saveAll(mappings);
}
/** 根据点阵码ID查找对应的课件页面(笔迹数据落点定位) */
findPageByDotCode(dotCodeId: string): DotCodeMapping | undefined {
return this.db.prepare(`
SELECT * FROM dot_code_mapping WHERE dot_code_id = ?
`).get(dotCodeId);
}
/* ========== 课件元数据操作 ========== */
/** 保存课件元数据 */
saveCoursewareMeta(meta: CoursewareMeta): void {
this.db.prepare(`
INSERT OR REPLACE INTO courseware_meta
(courseware_id, title, type, file_path, page_count, file_size,
create_time, last_open_time, cloud_url, sync_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
meta.coursewareId, meta.title, meta.type, meta.filePath,
meta.pageCount, meta.fileSize, meta.createTime,
meta.lastOpenTime, meta.cloudUrl, meta.syncStatus
);
}
/** 获取最近打开的课件列表 */
getRecentCoursewares(limit: number = 20): CoursewareMeta[] {
return this.db.prepare(`
SELECT * FROM courseware_meta ORDER BY last_open_time DESC LIMIT ?
`).all(limit);
}
/* ========== 数据库维护操作 ========== */
/** 启动自动备份定时器(每6小时备份一次) */
private startAutoBackup(): void {
const BACKUP_INTERVAL = 6 * 60 * 60 * 1000; // 6小时
this.backupTimer = setInterval(() => {
this.createBackup();
}, BACKUP_INTERVAL);
}
/** 创建数据库备份文件 */
createBackup(): string {
const backupDir = path.join(path.dirname(this.config.dbPath), 'backups');
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
// 生成备份文件名(包含时间戳)
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupPath = path.join(backupDir, `writech_backup_${timestamp}.db`);
// 使用SQLite的backup API执行在线备份(不阻塞读写)
this.db.backup(backupPath);
console.log('[DatabaseManager] 数据库备份完成:', backupPath);
// 清理过期备份(保留最近N个)
this.cleanOldBackups(backupDir);
return backupPath;
}
/** 清理过期的备份文件 */
private cleanOldBackups(backupDir: string): void {
const files = fs.readdirSync(backupDir)
.filter(f => f.startsWith('writech_backup_'))
.sort()
.reverse();
// 删除超出最大数量的旧备份
for (let i = this.config.maxBackups; i < files.length; i++) {
const filePath = path.join(backupDir, files[i]);
fs.unlinkSync(filePath);
console.log('[DatabaseManager] 已清理过期备份:', files[i]);
}
}
/** 启动自动数据库整理(VACUUM) */
private startAutoVacuum(): void {
this.vacuumTimer = setInterval(() => {
try {
// 清理30天前已同步的笔迹原始数据(缩略图保留)
const threshold = Date.now() - 30 * 24 * 60 * 60 * 1000;
const result = this.db.prepare(`
DELETE FROM stroke_records
WHERE sync_status = 1 AND collect_time < ?
`).run(threshold);
if (result.changes > 0) {
console.log(`[DatabaseManager] 清理过期笔迹记录: ${result.changes}条`);
}
// 清理已同步的同步日志
this.db.prepare(`
DELETE FROM sync_log WHERE sync_status = 1 AND synced_at < ?
`).run(threshold);
// 执行VACUUM整理磁盘空间
this.db.exec('VACUUM');
console.log('[DatabaseManager] 数据库整理完成');
} catch (error) {
console.error('[DatabaseManager] 数据库整理失败:', error);
}
}, this.config.autoVacuumInterval);
}
/** 获取数据库统计信息(用于状态显示) */
getStatistics(): Record<string, number> {
const stats: Record<string, number> = {};
stats.strokeCount = this.db.prepare('SELECT COUNT(*) as c FROM stroke_records').get().c;
stats.gradeCount = this.db.prepare('SELECT COUNT(*) as c FROM grade_records').get().c;
stats.studentCount = this.db.prepare('SELECT COUNT(*) as c FROM student_info').get().c;
stats.coursewareCount = this.db.prepare('SELECT COUNT(*) as c FROM courseware_meta').get().c;
stats.unsyncedCount = this.db.prepare('SELECT COUNT(*) as c FROM sync_log WHERE sync_status=0').get().c;
// 计算数据库文件大小
try {
const stat = fs.statSync(this.config.dbPath);
stats.dbSizeBytes = stat.size;
} catch {
stats.dbSizeBytes = 0;
}
return stats;
}
/** 关闭数据库连接并清理资源 */
close(): void {
if (this.backupTimer) {
clearInterval(this.backupTimer);
this.backupTimer = null;
}
if (this.vacuumTimer) {
clearInterval(this.vacuumTimer);
this.vacuumTimer = null;
}
if (this.db) {
// 关闭前执行一次checkpoint确保WAL数据写入
try { this.db.pragma('wal_checkpoint(TRUNCATE)'); } catch {}
this.db.close();
this.db = null;
}
this.initialized = false;
console.log('[DatabaseManager] 数据库连接已关闭');
}
}
/* ========== 单例导出 ========== */
/** 全局数据库管理器实例 */
const dbManager = new DatabaseManager();
export default dbManager;
```
### `main/`
#### `main/device_manager.ts`
```typescript
/**
* 自然写互动课堂PC端应用软件 V1.0
*
* device_manager.ts - USB/BLE设备管理
*
* 功能说明:
* - USB HID点阵笔连接管理
* - BLE蓝牙点阵笔扫描与连接
* - 设备数据解析(7字节紧凑坐标解码)
* - 设备热插拔监听
* - 多设备并行管理
*/
/* ======================== 类型定义 ======================== */
/** 设备连接方式 */
enum DeviceInterface {
USB_HID = 'usb',
BLE = 'ble'
}
/** 设备状态 */
enum DeviceStatus {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
ERROR = 'error'
}
/** 点阵笔设备信息 */
interface PenDevice {
id: string; /* 设备唯一ID */
name: string; /* 设备名称 */
macAddress: string; /* MAC地址 */
interface: DeviceInterface; /* 连接方式 */
status: DeviceStatus; /* 连接状态 */
battery: number; /* 电量百分比 */
firmwareVersion: string; /* 固件版本 */
lastConnected: number; /* 最后连接时间戳 */
}
/** 笔迹坐标点 */
interface StrokePoint {
x: number; /* X坐标(毫米) */
y: number; /* Y坐标(毫米) */
pressure: number; /* 压力值(0-1) */
timestamp: number; /* 时间戳(毫秒) */
penDown: boolean; /* 落笔标志 */
}
/** 设备事件回调 */
interface DeviceEventCallbacks {
onDeviceDiscovered: (device: PenDevice) => void;
onDeviceConnected: (device: PenDevice) => void;
onDeviceDisconnected: (deviceId: string) => void;
onStrokeData: (deviceId: string, points: StrokePoint[]) => void;
onBatteryUpdate: (deviceId: string, level: number) => void;
onError: (deviceId: string, error: string) => void;
}
/* ======================== USB HID常量 ======================== */
/** 自然写点阵笔USB VendorID */
const WRITECH_USB_VID = 0x1234;
/** 自然写点阵笔USB ProductID */
const WRITECH_USB_PID = 0x5678;
/** USB HID报文最大长度 */
const USB_REPORT_SIZE = 64;
/** USB轮询间隔(毫秒) */
const USB_POLL_INTERVAL = 5;
/* ======================== BLE常量 ======================== */
/** 自然写笔迹服务UUID */
const BLE_SERVICE_UUID = '0000ffe0-0000-1000-8000-00805f9b34fb';
/** 笔迹数据特征UUIDNotify */
const BLE_STROKE_CHAR_UUID = '0000ffe1-0000-1000-8000-00805f9b34fb';
/** 电量特征UUID */
const BLE_BATTERY_CHAR_UUID = '0000ffe2-0000-1000-8000-00805f9b34fb';
/** 控制特征UUIDWrite */
const BLE_CONTROL_CHAR_UUID = '0000ffe3-0000-1000-8000-00805f9b34fb';
/* ======================== 坐标解码 ======================== */
/**
* 解码7字节紧凑坐标编码
* 编码格式: 20位X + 20位Y + 12位压力 + 4位标志
*/
function decodeCompactPoint(data: Buffer, offset: number): StrokePoint {
/* 提取20位X坐标 */
const rawX = (data[offset] << 12) |
(data[offset + 1] << 4) |
((data[offset + 2] >> 4) & 0x0F);
/* 提取20位Y坐标 */
const rawY = ((data[offset + 2] & 0x0F) << 16) |
(data[offset + 3] << 8) |
data[offset + 4];
/* 提取12位压力值 */
const rawPressure = (data[offset + 5] << 4) |
((data[offset + 6] >> 4) & 0x0F);
/* 提取4位标志 */
const flags = data[offset + 6] & 0x0F;
return {
x: rawX * 0.3, /* 点阵码单位转毫米 */
y: rawY * 0.3,
pressure: rawPressure / 4095, /* 归一化到0-1 */
timestamp: Date.now(),
penDown: (flags & 0x01) !== 0
};
}
/**
* 计算CRC-16 CCITT校验
*/
function crc16CCITT(data: Buffer, length: number): number {
let crc = 0xFFFF;
for (let i = 0; i < length; i++) {
crc ^= data[i] << 8;
for (let j = 0; j < 8; j++) {
if (crc & 0x8000) {
crc = ((crc << 1) ^ 0x1021) & 0xFFFF;
} else {
crc = (crc << 1) & 0xFFFF;
}
}
}
return crc;
}
/* ======================== 设备管理器 ======================== */
/**
* 点阵笔设备管理器
* 统一管理USB和BLE连接的点阵笔设备
*/
class DeviceManager {
/** 已连接设备列表 */
private devices: Map<string, PenDevice> = new Map();
/** 事件回调 */
private callbacks: DeviceEventCallbacks;
/** USB轮询定时器 */
private usbPollTimer: ReturnType<typeof setInterval> | null = null;
/** BLE扫描状态 */
private bleScanning: boolean = false;
/** 是否运行中 */
private running: boolean = false;
constructor(callbacks: DeviceEventCallbacks) {
this.callbacks = callbacks;
console.log('[设备管理] 初始化');
}
/* ==================== USB HID管理 ==================== */
/**
* 启动USB设备监听
* 使用node-usb库检测设备热插拔
*/
startUSBMonitor(): void {
console.log('[设备管理] 启动USB监听');
this.running = true;
/* 枚举已连接的USB设备 */
this.scanUSBDevices();
/* 监听USB热插拔事件
usb.on('attach', (device) => this.onUSBAttach(device));
usb.on('detach', (device) => this.onUSBDetach(device)); */
/* 启动USB数据轮询 */
this.usbPollTimer = setInterval(() => {
this.pollUSBData();
}, USB_POLL_INTERVAL);
}
/**
* 扫描已连接的USB HID设备
*/
private scanUSBDevices(): void {
/* const devices = HID.devices()
.filter(d => d.vendorId === WRITECH_USB_VID &&
d.productId === WRITECH_USB_PID); */
console.log('[设备管理] USB扫描完成');
}
/**
* USB设备接入处理
*/
private onUSBAttach(usbDevice: any): void {
const deviceId = `usb_${usbDevice.serialNumber || Date.now()}`;
const pen: PenDevice = {
id: deviceId,
name: `WritechPen-USB-${deviceId.slice(-4)}`,
macAddress: '',
interface: DeviceInterface.USB_HID,
status: DeviceStatus.CONNECTED,
battery: 100,
firmwareVersion: '1.0.0',
lastConnected: Date.now()
};
this.devices.set(deviceId, pen);
this.callbacks.onDeviceConnected(pen);
console.log(`[设备管理] USB设备接入: ${pen.name}`);
}
/**
* USB设备拔出处理
*/
private onUSBDetach(usbDevice: any): void {
const deviceId = `usb_${usbDevice.serialNumber || ''}`;
if (this.devices.has(deviceId)) {
this.devices.delete(deviceId);
this.callbacks.onDeviceDisconnected(deviceId);
console.log(`[设备管理] USB设备断开: ${deviceId}`);
}
}
/**
* 轮询USB设备数据
* 读取HID报文并解析坐标
*/
private pollUSBData(): void {
this.devices.forEach((device, deviceId) => {
if (device.interface !== DeviceInterface.USB_HID) return;
if (device.status !== DeviceStatus.CONNECTED) return;
/* const report = hidDevice.readSync();
if (report && report.length > 0) {
this.parseUSBReport(deviceId, Buffer.from(report));
} */
});
}
/**
* 解析USB HID报文
* 报文格式: [报文类型][数据长度][坐标数据...]
*/
private parseUSBReport(deviceId: string, report: Buffer): void {
const reportType = report[0];
const dataLen = report[1];
if (reportType === 0x01) {
/* 笔迹数据报文: 每11字节一个坐标点(7字节坐标+4字节时间戳) */
const points: StrokePoint[] = [];
const pointSize = 11;
for (let offset = 2; offset + pointSize <= 2 + dataLen; offset += pointSize) {
const point = decodeCompactPoint(report, offset);
/* 时间戳从报文中提取 */
point.timestamp = report.readUInt32LE(offset + 7);
points.push(point);
}
if (points.length > 0) {
this.callbacks.onStrokeData(deviceId, points);
}
} else if (reportType === 0x04) {
/* 电量报文 */
const battery = report[2];
this.callbacks.onBatteryUpdate(deviceId, battery);
}
}
/* ==================== BLE管理 ==================== */
/**
* 启动BLE蓝牙扫描
*/
startBLEScan(): void {
if (this.bleScanning) return;
console.log('[设备管理] 启动BLE扫描');
this.bleScanning = true;
/* noble.on('discover', (peripheral) => {
if (peripheral.advertisement.localName?.startsWith('WritechPen')) {
this.onBLEDiscover(peripheral);
}
});
noble.startScanning([BLE_SERVICE_UUID], true); */
}
/**
* 停止BLE扫描
*/
stopBLEScan(): void {
this.bleScanning = false;
/* noble.stopScanning(); */
console.log('[设备管理] BLE扫描已停止');
}
/**
* BLE设备发现回调
*/
private onBLEDiscover(peripheral: any): void {
const deviceId = `ble_${peripheral.address.replace(/:/g, '')}`;
if (this.devices.has(deviceId)) return;
const pen: PenDevice = {
id: deviceId,
name: peripheral.advertisement.localName || 'WritechPen',
macAddress: peripheral.address,
interface: DeviceInterface.BLE,
status: DeviceStatus.DISCONNECTED,
battery: 0,
firmwareVersion: '',
lastConnected: 0
};
this.callbacks.onDeviceDiscovered(pen);
console.log(`[设备管理] 发现BLE设备: ${pen.name} [${pen.macAddress}]`);
}
/**
* 连接BLE设备
*/
async connectBLE(deviceId: string): Promise<boolean> {
const device = this.devices.get(deviceId);
if (!device || device.interface !== DeviceInterface.BLE) {
return false;
}
device.status = DeviceStatus.CONNECTING;
console.log(`[设备管理] 连接BLE设备: ${device.name}`);
try {
/* peripheral.connect((err) => { ... });
peripheral.discoverServices([BLE_SERVICE_UUID], (err, services) => {
services[0].discoverCharacteristics([...], (err, chars) => {
// 订阅笔迹数据Notify
strokeChar.subscribe();
strokeChar.on('data', (data) => this.onBLEData(deviceId, data));
});
}); */
device.status = DeviceStatus.CONNECTED;
device.lastConnected = Date.now();
this.devices.set(deviceId, device);
this.callbacks.onDeviceConnected(device);
return true;
} catch (err: any) {
device.status = DeviceStatus.ERROR;
this.callbacks.onError(deviceId, err.message);
return false;
}
}
/**
* BLE数据接收回调
*/
private onBLEData(deviceId: string, data: Buffer): void {
/* BLE数据帧格式与USB类似:[帧头0xAA][类型][长度][数据...][CRC16] */
if (data[0] !== 0xAA) return;
const frameType = data[1];
const payloadLen = data[2];
/* CRC校验 */
const expectedCrc = data.readUInt16LE(3 + payloadLen);
const calcCrc = crc16CCITT(data.slice(0, 3 + payloadLen), 3 + payloadLen);
if (expectedCrc !== calcCrc) {
console.warn(`[设备管理] BLE数据CRC校验失败: ${deviceId}`);
return;
}
if (frameType === 0x01) {
/* 笔迹坐标数据 */
const points: StrokePoint[] = [];
const pointSize = 11;
for (let i = 3; i + pointSize <= 3 + payloadLen; i += pointSize) {
points.push(decodeCompactPoint(data, i));
}
if (points.length > 0) {
this.callbacks.onStrokeData(deviceId, points);
}
} else if (frameType === 0x04) {
/* 电量数据 */
this.callbacks.onBatteryUpdate(deviceId, data[3]);
}
}
/* ==================== 公共接口 ==================== */
/** 获取所有已连接设备 */
getConnectedDevices(): PenDevice[] {
return Array.from(this.devices.values())
.filter(d => d.status === DeviceStatus.CONNECTED);
}
/** 获取设备数量 */
getDeviceCount(): number {
return this.devices.size;
}
/** 断开指定设备 */
disconnect(deviceId: string): void {
const device = this.devices.get(deviceId);
if (device) {
device.status = DeviceStatus.DISCONNECTED;
this.callbacks.onDeviceDisconnected(deviceId);
console.log(`[设备管理] 断开设备: ${device.name}`);
}
}
/** 停止所有设备管理 */
shutdown(): void {
this.running = false;
if (this.usbPollTimer) {
clearInterval(this.usbPollTimer);
}
this.stopBLEScan();
this.devices.clear();
console.log('[设备管理] 已关闭');
}
}
export { DeviceManager, PenDevice, StrokePoint, DeviceStatus, DeviceInterface };
```
#### `main/main.ts`
```typescript
/**
* 自然写互动课堂PC端应用软件 V1.0
*
* main.ts - Electron主进程入口
*
* 功能说明:
* - Electron应用生命周期管理
* - 主窗口创建与配置
* - 系统托盘与菜单
* - IPC通信注册
* - 自动更新检测
* - 单实例锁定
* - 全局异常处理
*/
import { app, BrowserWindow, Menu, Tray, ipcMain, dialog, shell } from 'electron';
import * as path from 'path';
import * as fs from 'fs';
/* ======================== 应用配置 ======================== */
/** 应用版本号 */
const APP_VERSION = '1.0.0';
/** 应用名称 */
const APP_NAME = '自然写互动课堂';
/** 窗口默认尺寸 */
const DEFAULT_WIDTH = 1440;
const DEFAULT_HEIGHT = 900;
/** 最小窗口尺寸 */
const MIN_WIDTH = 1024;
const MIN_HEIGHT = 680;
/** 开发模式标志 */
const IS_DEV = process.env.NODE_ENV === 'development';
/* ======================== 全局变量 ======================== */
/** 主窗口实例 */
let mainWindow: BrowserWindow | null = null;
/** 系统托盘实例 */
let tray: Tray | null = null;
/** 窗口状态保存路径 */
const windowStatePath = path.join(app.getPath('userData'), 'window-state.json');
/* ======================== 窗口状态管理 ======================== */
/** 保存的窗口状态 */
interface WindowState {
x?: number;
y?: number;
width: number;
height: number;
isMaximized: boolean;
}
/**
* 加载上次保存的窗口状态
*/
function loadWindowState(): WindowState {
try {
if (fs.existsSync(windowStatePath)) {
const data = fs.readFileSync(windowStatePath, 'utf-8');
return JSON.parse(data);
}
} catch (err) {
console.error('[主进程] 加载窗口状态失败:', err);
}
return { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT, isMaximized: false };
}
/**
* 保存当前窗口状态
*/
function saveWindowState(win: BrowserWindow): void {
const bounds = win.getBounds();
const state: WindowState = {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height,
isMaximized: win.isMaximized()
};
try {
fs.writeFileSync(windowStatePath, JSON.stringify(state, null, 2));
} catch (err) {
console.error('[主进程] 保存窗口状态失败:', err);
}
}
/* ======================== 窗口创建 ======================== */
/**
* 创建主窗口
* 配置安全选项、预加载脚本和窗口参数
*/
function createMainWindow(): void {
const savedState = loadWindowState();
mainWindow = new BrowserWindow({
title: APP_NAME,
width: savedState.width,
height: savedState.height,
x: savedState.x,
y: savedState.y,
minWidth: MIN_WIDTH,
minHeight: MIN_HEIGHT,
show: false,
frame: true,
backgroundColor: '#ffffff',
webPreferences: {
/* 安全选项:渲染进程沙箱化 */
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
/* 预加载脚本路径 */
preload: path.join(__dirname, 'preload.js'),
/* 禁用远程模块 */
webSecurity: true,
/* 禁止打开新窗口 */
allowRunningInsecureContent: false
}
});
/* 加载渲染进程页面 */
if (IS_DEV) {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
}
/* 窗口就绪后显示(避免白屏闪烁) */
mainWindow.once('ready-to-show', () => {
if (savedState.isMaximized) {
mainWindow?.maximize();
}
mainWindow?.show();
console.log('[主进程] 主窗口已显示');
});
/* 窗口关闭前保存状态 */
mainWindow.on('close', (event) => {
if (mainWindow) {
saveWindowState(mainWindow);
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
/* 拦截外部链接在系统浏览器打开 */
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
console.log(`[主进程] 窗口创建完成: ${savedState.width}x${savedState.height}`);
}
/* ======================== 系统托盘 ======================== */
/**
* 创建系统托盘图标和菜单
*/
function createTray(): void {
const iconPath = path.join(__dirname, '../assets/tray-icon.png');
tray = new Tray(iconPath);
tray.setToolTip(APP_NAME);
const contextMenu = Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => mainWindow?.show() },
{ type: 'separator' },
{ label: '设备管理', click: () => sendToRenderer('navigate', '/devices') },
{ label: '设置', click: () => sendToRenderer('navigate', '/settings') },
{ type: 'separator' },
{ label: `版本 ${APP_VERSION}`, enabled: false },
{ label: '退出', click: () => app.quit() }
]);
tray.setContextMenu(contextMenu);
tray.on('click', () => mainWindow?.show());
}
/* ======================== IPC通信处理 ======================== */
/**
* 向渲染进程发送消息
*/
function sendToRenderer(channel: string, data: any): void {
mainWindow?.webContents.send(channel, data);
}
/**
* 注册IPC通信处理器
* 渲染进程通过IPC调用主进程的系统API
*/
function setupIpcHandlers(): void {
/* 获取应用信息 */
ipcMain.handle('app:getInfo', () => ({
version: APP_VERSION,
name: APP_NAME,
platform: process.platform,
arch: process.arch,
userDataPath: app.getPath('userData')
}));
/* 文件选择对话框 */
ipcMain.handle('dialog:openFile', async (_, options) => {
const result = await dialog.showOpenDialog(mainWindow!, {
title: options.title || '选择文件',
filters: options.filters || [{ name: '所有文件', extensions: ['*'] }],
properties: options.properties || ['openFile']
});
return result.filePaths;
});
/* 保存文件对话框 */
ipcMain.handle('dialog:saveFile', async (_, options) => {
const result = await dialog.showSaveDialog(mainWindow!, {
title: options.title || '保存文件',
defaultPath: options.defaultPath,
filters: options.filters || [{ name: '所有文件', extensions: ['*'] }]
});
return result.filePath;
});
/* 文件读取 */
ipcMain.handle('fs:readFile', async (_, filePath: string) => {
return fs.readFileSync(filePath, 'utf-8');
});
/* 文件写入 */
ipcMain.handle('fs:writeFile', async (_, filePath: string, content: string) => {
fs.writeFileSync(filePath, content, 'utf-8');
return true;
});
/* 打印功能 */
ipcMain.handle('print:start', async (_, options) => {
mainWindow?.webContents.print({
silent: options.silent || false,
printBackground: true,
copies: options.copies || 1,
pageSize: options.pageSize || 'A4'
});
});
/* 窗口控制 */
ipcMain.on('window:minimize', () => mainWindow?.minimize());
ipcMain.on('window:maximize', () => {
if (mainWindow?.isMaximized()) {
mainWindow.unmaximize();
} else {
mainWindow?.maximize();
}
});
ipcMain.on('window:close', () => mainWindow?.close());
console.log('[主进程] IPC处理器注册完成');
}
/* ======================== 自动更新 ======================== */
/**
* 检查应用更新
* 使用electron-updater检查并安装更新
*/
function checkForUpdates(): void {
if (IS_DEV) return;
console.log('[主进程] 检查应用更新...');
/* autoUpdater.checkForUpdatesAndNotify()
.then(result => { ... })
.catch(err => { ... }); */
/* autoUpdater.on('update-available', (info) => {
sendToRenderer('update:available', info);
});
autoUpdater.on('download-progress', (progress) => {
sendToRenderer('update:progress', progress);
});
autoUpdater.on('update-downloaded', (info) => {
sendToRenderer('update:downloaded', info);
}); */
}
/* ======================== 应用生命周期 ======================== */
/** 确保单实例运行 */
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
console.log('[主进程] 已有实例运行,退出');
app.quit();
}
app.on('second-instance', () => {
/* 用户尝试打开第二个实例时,聚焦已有窗口 */
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
/* 应用就绪 */
app.whenReady().then(() => {
console.log(`[主进程] ${APP_NAME} v${APP_VERSION} 启动`);
createMainWindow();
createTray();
setupIpcHandlers();
/* 延迟检查更新 */
setTimeout(checkForUpdates, 5000);
});
/* macOS特殊处理:所有窗口关闭后重新创建 */
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
});
/* 所有窗口关闭时退出(macOS除外) */
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
/* 全局异常处理 */
process.on('uncaughtException', (error) => {
console.error('[主进程] 未捕获异常:', error);
dialog.showErrorBox('应用错误', `发生未预期的错误:\n${error.message}`);
});
```
### `renderer/api/`
#### `renderer/api/cloud_api.ts`
```typescript
/**
* 自然写互动课堂PC端应用软件 V1.0
*
* cloud_api.ts - 云平台API通信层
*
* 功能说明:
* - HTTP REST API封装(Axios
* - JWT Token管理与自动刷新
* - 请求拦截器(签名/认证/日志)
* - 响应拦截器(错误处理/重试)
* - API类型定义
* - 离线请求队列
*/
/* ======================== 类型定义 ======================== */
/** 统一响应格式 */
interface ApiResponse<T = any> {
code: number;
msg: string;
data: T;
}
/** 分页参数 */
interface PageParams {
page: number;
size: number;
sort?: string;
}
/** 分页响应 */
interface PageResult<T> {
total: number;
pages: number;
current: number;
records: T[];
}
/** 用户信息 */
interface UserInfo {
userId: string;
name: string;
role: 'admin' | 'teacher' | 'student' | 'parent';
phone: string;
schoolId: string;
schoolName: string;
avatar: string;
}
/** 课堂信息 */
interface ClassroomInfo {
classroomId: string;
className: string;
grade: string;
teacherId: string;
teacherName: string;
studentCount: number;
gatewayId: string;
}
/** 作业信息 */
interface AssignmentInfo {
assignmentId: string;
title: string;
type: 'homework' | 'exam' | 'practice';
classId: string;
deadline: string;
status: 'draft' | 'published' | 'closed';
totalStudents: number;
submittedCount: number;
}
/** 学情报告 */
interface LearningReport {
studentId: string;
studentName: string;
subject: string;
overallScore: number;
writingScore: number;
strokeOrderAccuracy: number;
knowledgePoints: { name: string; mastery: number }[];
trend: { date: string; score: number }[];
}
/** 认证令牌 */
interface AuthTokens {
accessToken: string;
refreshToken: string;
expiresIn: number; /* 有效期(秒) */
tokenType: string;
}
/* ======================== 配置 ======================== */
/** API基础URL */
const API_BASE_URL = 'https://api.writech.cn';
/** 请求超时 */
const REQUEST_TIMEOUT = 30000;
/** Token刷新提前量(毫秒) */
const TOKEN_REFRESH_AHEAD = 5 * 60 * 1000;
/** 最大重试次数 */
const MAX_RETRIES = 3;
/* ======================== Token管理 ======================== */
/** 存储的Token信息 */
let currentTokens: AuthTokens | null = null;
/** Token过期时间戳 */
let tokenExpiresAt: number = 0;
/** 是否正在刷新Token */
let isRefreshing: boolean = false;
/** 等待Token刷新的请求队列 */
let refreshQueue: Array<(token: string) => void> = [];
/**
* 保存认证令牌
*/
function saveTokens(tokens: AuthTokens): void {
currentTokens = tokens;
tokenExpiresAt = Date.now() + tokens.expiresIn * 1000;
/* 持久化到electron-store */
console.log(`[API] Token已保存, 有效期至 ${new Date(tokenExpiresAt).toLocaleString()}`);
}
/**
* 获取当前Access Token
* 如果即将过期则自动刷新
*/
async function getValidToken(): Promise<string> {
if (!currentTokens) {
throw new Error('未登录');
}
/* 检查是否需要刷新 */
if (Date.now() + TOKEN_REFRESH_AHEAD > tokenExpiresAt) {
if (!isRefreshing) {
isRefreshing = true;
try {
const newTokens = await refreshToken(currentTokens.refreshToken);
saveTokens(newTokens);
/* 通知所有等待中的请求 */
refreshQueue.forEach(resolve => resolve(newTokens.accessToken));
refreshQueue = [];
} finally {
isRefreshing = false;
}
} else {
/* 等待正在进行的刷新完成 */
return new Promise<string>(resolve => {
refreshQueue.push(resolve);
});
}
}
return currentTokens.accessToken;
}
/* ======================== HTTP请求封装 ======================== */
/**
* 通用HTTP请求方法
*/
async function request<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
data?: any,
retryCount: number = 0
): Promise<ApiResponse<T>> {
const url = `${API_BASE_URL}${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
/* 添加认证头 */
try {
const token = await getValidToken();
headers['Authorization'] = `Bearer ${token}`;
} catch {
/* 登录接口不需要Token */
}
/* 添加请求签名 */
const timestamp = Date.now().toString();
headers['X-Timestamp'] = timestamp;
headers['X-Device-Id'] = getDeviceId();
try {
const response = await fetch(url, {
method,
headers,
body: data ? JSON.stringify(data) : undefined,
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
});
const json: ApiResponse<T> = await response.json();
/* 处理业务错误 */
if (json.code === 401 && retryCount < 1) {
/* Token过期,尝试刷新后重试 */
console.log('[API] Token过期, 刷新后重试');
if (currentTokens) {
const newTokens = await refreshToken(currentTokens.refreshToken);
saveTokens(newTokens);
return request<T>(method, path, data, retryCount + 1);
}
}
if (json.code !== 200 && json.code !== 0) {
console.warn(`[API] 业务错误: ${method} ${path} code=${json.code} msg=${json.msg}`);
}
return json;
} catch (error: any) {
console.error(`[API] 请求失败: ${method} ${path}`, error.message);
/* 网络错误重试 */
if (retryCount < MAX_RETRIES && isNetworkError(error)) {
const delay = Math.pow(2, retryCount) * 1000;
console.log(`[API] ${delay}ms后重试 (${retryCount + 1}/${MAX_RETRIES})`);
await sleep(delay);
return request<T>(method, path, data, retryCount + 1);
}
return { code: -1, msg: error.message || '网络错误', data: null as any };
}
}
function isNetworkError(error: any): boolean {
return error.name === 'TypeError' || error.name === 'AbortError';
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function getDeviceId(): string {
return 'PC-' + (typeof window !== 'undefined' ?
navigator.userAgent.slice(-8) : 'unknown');
}
/* ======================== API方法 ======================== */
/** 用户登录 */
async function login(username: string, password: string): Promise<ApiResponse<AuthTokens>> {
const result = await request<AuthTokens>('POST', '/api/v1/auth/login', {
username, password, device_type: 'pc'
});
if (result.code === 200 && result.data) {
saveTokens(result.data);
}
return result;
}
/** 刷新Token */
async function refreshToken(token: string): Promise<AuthTokens> {
const resp = await fetch(`${API_BASE_URL}/api/v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: token })
});
const json: ApiResponse<AuthTokens> = await resp.json();
if (json.code !== 200 || !json.data) {
throw new Error('Token刷新失败');
}
return json.data;
}
/** 获取当前用户信息 */
async function getUserInfo(): Promise<ApiResponse<UserInfo>> {
return request<UserInfo>('GET', '/api/v1/user/me');
}
/** 获取班级列表 */
async function getClassrooms(): Promise<ApiResponse<ClassroomInfo[]>> {
return request<ClassroomInfo[]>('GET', '/api/v1/classroom/list');
}
/** 获取作业列表 */
async function getAssignments(classId: string, params: PageParams): Promise<ApiResponse<PageResult<AssignmentInfo>>> {
return request<PageResult<AssignmentInfo>>('GET',
`/api/v1/assignment/list?class_id=${classId}&page=${params.page}&size=${params.size}`);
}
/** 发布作业 */
async function publishAssignment(assignment: Partial<AssignmentInfo>): Promise<ApiResponse<{ assignmentId: string }>> {
return request<{ assignmentId: string }>('POST', '/api/v1/assignment/publish', assignment);
}
/** 上传笔迹数据 */
async function uploadStrokeData(assignmentId: string, studentId: string,
strokeData: any[]): Promise<ApiResponse<void>> {
return request<void>('POST', '/api/v1/stroke/upload', {
assignment_id: assignmentId,
student_id: studentId,
strokes: strokeData
});
}
/** 获取AI批改结果 */
async function getGradingResult(assignmentId: string): Promise<ApiResponse<any>> {
return request<any>('GET', `/api/v1/result/${assignmentId}`);
}
/** 获取学情报告 */
async function getLearningReport(studentId: string): Promise<ApiResponse<LearningReport>> {
return request<LearningReport>('GET', `/api/v1/report/student/${studentId}`);
}
/** 下载课件资源 */
async function getResourceDownloadUrl(resourceId: string): Promise<ApiResponse<{ url: string }>> {
return request<{ url: string }>('GET', `/api/v1/resource/download/${resourceId}`);
}
/** 退出登录 */
async function logout(): Promise<void> {
await request<void>('POST', '/api/v1/auth/logout');
currentTokens = null;
tokenExpiresAt = 0;
console.log('[API] 已退出登录');
}
/* ======================== 导出 ======================== */
export {
login, logout, getUserInfo, getClassrooms, getAssignments,
publishAssignment, uploadStrokeData, getGradingResult,
getLearningReport, getResourceDownloadUrl, saveTokens
};
export type {
ApiResponse, UserInfo, ClassroomInfo, AssignmentInfo,
LearningReport, AuthTokens, PageParams, PageResult
};
```
### `renderer/components/`
#### `renderer/components/StrokeCanvas.vue`
```
/**
* 自然写互动课堂PC端应用软件 V1.0
*
* StrokeCanvas.vue - 笔迹画布组件
*
* 功能说明:
* - Canvas 2D高性能笔迹渲染
* - 压力感应笔锋效果
* - 贝塞尔曲线平滑
* - 多图层渲染(背景+已完成笔画+当前笔画)
* - 笔迹回放动画
* - 缩放与平移手势
*/
<template>
<div class="stroke-canvas-container" ref="containerRef">
<!-- 背景层:课件/试卷图片 -->
<canvas ref="bgCanvas" class="canvas-layer canvas-bg"></canvas>
<!-- 笔迹层:已完成的笔画 -->
<canvas ref="strokeCanvas" class="canvas-layer canvas-stroke"></canvas>
<!-- 活动层:当前正在绘制的笔画 -->
<canvas ref="activeCanvas" class="canvas-layer canvas-active"></canvas>
<!-- 工具栏 -->
<div class="canvas-toolbar" v-if="showToolbar">
<button @click="setPenColor('#000000')" :class="{ active: penColor === '#000000' }">黑</button>
<button @click="setPenColor('#FF0000')" :class="{ active: penColor === '#FF0000' }">红</button>
<button @click="setPenColor('#0000FF')" :class="{ active: penColor === '#0000FF' }">蓝</button>
<button @click="toggleEraser" :class="{ active: eraserMode }">橡皮</button>
<button @click="undo">撤销</button>
<button @click="redo">重做</button>
<button @click="clearAll">清空</button>
</div>
<!-- 缩放控件 -->
<div class="zoom-controls">
<span class="zoom-label">{{ Math.round(scale * 100) }}%</span>
<button @click="zoomIn">+</button>
<button @click="zoomOut">-</button>
<button @click="resetZoom">适应</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
/* ======================== Props与Emits ======================== */
interface Props {
/** 画布宽度 */
width?: number;
/** 画布高度 */
height?: number;
/** 背景图片URL */
backgroundUrl?: string;
/** 是否显示工具栏 */
showToolbar?: boolean;
/** 是否只读模式(仅展示笔迹) */
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
width: 1920,
height: 1080,
showToolbar: true,
readonly: false
});
const emit = defineEmits<{
(e: 'stroke-complete', stroke: StrokeData): void;
(e: 'stroke-point', point: PointData): void;
}>();
/* ======================== 类型定义 ======================== */
interface PointData {
x: number;
y: number;
pressure: number;
timestamp: number;
}
interface StrokeData {
strokeId: string;
color: string;
width: number;
points: PointData[];
}
/* ======================== 响应式数据 ======================== */
/** DOM引用 */
const containerRef = ref<HTMLDivElement>();
const bgCanvas = ref<HTMLCanvasElement>();
const strokeCanvas = ref<HTMLCanvasElement>();
const activeCanvas = ref<HTMLCanvasElement>();
/** 画布上下文 */
let bgCtx: CanvasRenderingContext2D | null = null;
let strokeCtx: CanvasRenderingContext2D | null = null;
let activeCtx: CanvasRenderingContext2D | null = null;
/** 画笔状态 */
const penColor = ref('#000000');
const penWidth = ref(3);
const eraserMode = ref(false);
const scale = ref(1.0);
/** 当前笔画 */
let currentStroke: StrokeData | null = null;
/** 已完成笔画列表 */
const completedStrokes: StrokeData[] = [];
/** 撤销栈 */
const undoStack: StrokeData[] = [];
/** 重做栈 */
const redoStack: StrokeData[] = [];
/** 是否正在绘制 */
let isDrawing = false;
/* ======================== 平滑算法常量 ======================== */
/** 贝塞尔曲线平滑最小距离 */
const SMOOTH_MIN_DIST = 2;
/** 笔锋最小宽度比 */
const PEN_MIN_WIDTH_RATIO = 0.25;
/** 笔锋最大宽度比 */
const PEN_MAX_WIDTH_RATIO = 1.6;
/* ======================== 生命周期 ======================== */
onMounted(() => {
initCanvases();
if (props.backgroundUrl) {
loadBackground(props.backgroundUrl);
}
if (!props.readonly) {
setupInputHandlers();
}
});
onUnmounted(() => {
removeInputHandlers();
});
/* ======================== 画布初始化 ======================== */
/**
* 初始化三层画布
*/
function initCanvases(): void {
const canvases = [bgCanvas.value, strokeCanvas.value, activeCanvas.value];
canvases.forEach(canvas => {
if (canvas) {
canvas.width = props.width;
canvas.height = props.height;
}
});
bgCtx = bgCanvas.value?.getContext('2d') ?? null;
strokeCtx = strokeCanvas.value?.getContext('2d') ?? null;
activeCtx = activeCanvas.value?.getContext('2d') ?? null;
/* 笔迹层抗锯齿 */
if (strokeCtx) {
strokeCtx.lineCap = 'round';
strokeCtx.lineJoin = 'round';
}
if (activeCtx) {
activeCtx.lineCap = 'round';
activeCtx.lineJoin = 'round';
}
console.log(`[画布] 初始化: ${props.width}x${props.height}`);
}
/**
* 加载背景图片
*/
function loadBackground(url: string): void {
const img = new Image();
img.onload = () => {
bgCtx?.drawImage(img, 0, 0, props.width, props.height);
console.log(`[画布] 背景加载完成: ${url}`);
};
img.onerror = () => {
console.error(`[画布] 背景加载失败: ${url}`);
};
img.src = url;
}
/* ======================== 输入事件处理 ======================== */
function setupInputHandlers(): void {
const canvas = activeCanvas.value;
if (!canvas) return;
canvas.addEventListener('pointerdown', onPointerDown);
canvas.addEventListener('pointermove', onPointerMove);
canvas.addEventListener('pointerup', onPointerUp);
canvas.addEventListener('pointercancel', onPointerUp);
/* 禁止默认触摸行为(防止页面滚动) */
canvas.style.touchAction = 'none';
}
function removeInputHandlers(): void {
const canvas = activeCanvas.value;
if (!canvas) return;
canvas.removeEventListener('pointerdown', onPointerDown);
canvas.removeEventListener('pointermove', onPointerMove);
canvas.removeEventListener('pointerup', onPointerUp);
canvas.removeEventListener('pointercancel', onPointerUp);
}
/**
* 指针按下 - 开始新笔画
*/
function onPointerDown(e: PointerEvent): void {
if (props.readonly) return;
isDrawing = true;
const { canvasX, canvasY } = screenToCanvas(e.offsetX, e.offsetY);
const pressure = e.pressure || 0.5;
currentStroke = {
strokeId: `stroke_${Date.now()}`,
color: eraserMode.value ? '#FFFFFF' : penColor.value,
width: penWidth.value,
points: [{ x: canvasX, y: canvasY, pressure, timestamp: Date.now() }]
};
}
/**
* 指针移动 - 添加采样点并实时绘制
*/
function onPointerMove(e: PointerEvent): void {
if (!isDrawing || !currentStroke) return;
const { canvasX, canvasY } = screenToCanvas(e.offsetX, e.offsetY);
const pressure = e.pressure || 0.5;
const lastPt = currentStroke.points[currentStroke.points.length - 1];
const dx = canvasX - lastPt.x;
const dy = canvasY - lastPt.y;
const dist = Math.sqrt(dx * dx + dy * dy);
/* 距离过近跳过 */
if (dist < SMOOTH_MIN_DIST) return;
const point: PointData = { x: canvasX, y: canvasY, pressure, timestamp: Date.now() };
currentStroke.points.push(point);
emit('stroke-point', point);
/* 增量渲染最新线段 */
drawSegment(activeCtx!, lastPt, point, currentStroke.color, currentStroke.width);
}
/**
* 指针抬起 - 完成笔画
*/
function onPointerUp(e: PointerEvent): void {
if (!isDrawing || !currentStroke) return;
isDrawing = false;
if (currentStroke.points.length >= 2) {
completedStrokes.push(currentStroke);
undoStack.push(currentStroke);
redoStack.length = 0;
/* 将笔画绘制到笔迹层 */
drawFullStroke(strokeCtx!, currentStroke);
emit('stroke-complete', currentStroke);
}
/* 清空活动层 */
activeCtx?.clearRect(0, 0, props.width, props.height);
currentStroke = null;
}
/* ======================== 绘制函数 ======================== */
/**
* 绘制单个线段(带压力笔锋)
*/
function drawSegment(ctx: CanvasRenderingContext2D, from: PointData,
to: PointData, color: string, baseWidth: number): void {
/* 压力感应笔锋:宽度随压力变化 */
const widthRatio = PEN_MIN_WIDTH_RATIO +
(PEN_MAX_WIDTH_RATIO - PEN_MIN_WIDTH_RATIO) * to.pressure;
const lineWidth = baseWidth * widthRatio;
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
/**
* 绘制完整笔画(贝塞尔曲线平滑)
*/
function drawFullStroke(ctx: CanvasRenderingContext2D, stroke: StrokeData): void {
const points = stroke.points;
if (points.length < 2) return;
ctx.strokeStyle = stroke.color;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const widthRatio = PEN_MIN_WIDTH_RATIO +
(PEN_MAX_WIDTH_RATIO - PEN_MIN_WIDTH_RATIO) * curr.pressure;
ctx.lineWidth = stroke.width * widthRatio;
if (i >= 2) {
/* 二次贝塞尔曲线平滑 */
const prevPrev = points[i - 2];
const midX1 = (prevPrev.x + prev.x) / 2;
const midY1 = (prevPrev.y + prev.y) / 2;
const midX2 = (prev.x + curr.x) / 2;
const midY2 = (prev.y + curr.y) / 2;
ctx.beginPath();
ctx.moveTo(midX1, midY1);
ctx.quadraticCurveTo(prev.x, prev.y, midX2, midY2);
ctx.stroke();
} else {
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(curr.x, curr.y);
ctx.stroke();
}
}
}
/* ======================== 坐标转换 ======================== */
function screenToCanvas(sx: number, sy: number): { canvasX: number; canvasY: number } {
return {
canvasX: sx / scale.value,
canvasY: sy / scale.value
};
}
/* ======================== 工具栏操作 ======================== */
function setPenColor(color: string): void {
penColor.value = color;
eraserMode.value = false;
}
function toggleEraser(): void {
eraserMode.value = !eraserMode.value;
}
function undo(): void {
const stroke = undoStack.pop();
if (!stroke) return;
redoStack.push(stroke);
completedStrokes.splice(completedStrokes.indexOf(stroke), 1);
redrawAllStrokes();
}
function redo(): void {
const stroke = redoStack.pop();
if (!stroke) return;
undoStack.push(stroke);
completedStrokes.push(stroke);
redrawAllStrokes();
}
function clearAll(): void {
completedStrokes.length = 0;
undoStack.length = 0;
redoStack.length = 0;
strokeCtx?.clearRect(0, 0, props.width, props.height);
activeCtx?.clearRect(0, 0, props.width, props.height);
}
function redrawAllStrokes(): void {
strokeCtx?.clearRect(0, 0, props.width, props.height);
completedStrokes.forEach(stroke => {
drawFullStroke(strokeCtx!, stroke);
});
}
/* ======================== 缩放控制 ======================== */
function zoomIn(): void {
scale.value = Math.min(scale.value * 1.25, 3.0);
}
function zoomOut(): void {
scale.value = Math.max(scale.value / 1.25, 0.25);
}
function resetZoom(): void {
scale.value = 1.0;
}
/* ======================== 外部笔迹接收 ======================== */
/**
* 接收外部笔迹数据(学生端通过WebSocket推送)
*/
function addExternalStroke(stroke: StrokeData): void {
completedStrokes.push(stroke);
drawFullStroke(strokeCtx!, stroke);
}
/**
* 笔迹回放动画
*/
async function replayStrokes(strokes: StrokeData[], speedMultiplier: number = 1): Promise<void> {
for (const stroke of strokes) {
for (let i = 1; i < stroke.points.length; i++) {
const prev = stroke.points[i - 1];
const curr = stroke.points[i];
drawSegment(strokeCtx!, prev, curr, stroke.color, stroke.width);
const delay = (curr.timestamp - prev.timestamp) / speedMultiplier;
await new Promise(resolve => setTimeout(resolve, Math.max(delay, 5)));
}
}
}
/* 导出方法供父组件调用 */
defineExpose({ addExternalStroke, replayStrokes, clearAll, loadBackground });
</script>
<style scoped>
.stroke-canvas-container {
position: relative;
overflow: hidden;
background: #f5f5f5;
}
.canvas-layer {
position: absolute;
top: 0;
left: 0;
}
.canvas-bg { z-index: 1; }
.canvas-stroke { z-index: 2; }
.canvas-active { z-index: 3; cursor: crosshair; }
.canvas-toolbar {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
gap: 8px;
padding: 8px 16px;
background: rgba(255,255,255,0.95);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.canvas-toolbar button {
padding: 6px 14px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 13px;
}
.canvas-toolbar button.active {
background: #1976d2;
color: #fff;
border-color: #1976d2;
}
.zoom-controls {
position: absolute;
top: 16px;
right: 16px;
z-index: 10;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: rgba(255,255,255,0.9);
border-radius: 6px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1);
}
.zoom-label { font-size: 12px; color: #666; min-width: 36px; text-align: center; }
.zoom-controls button {
width: 28px;
height: 28px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 14px;
}
</style>
```
### `renderer/store/`
#### `renderer/store/index.ts`
```typescript
/**
* 自然写互动课堂PC端应用软件 V1.0
*
* index.ts - Pinia状态管理(全局Store
*
* 功能说明:
* - 用户认证状态管理
* - 课堂状态管理(当前课堂/学生列表/笔迹数据)
* - 设备连接状态管理
* - 作业批改状态管理
* - WebSocket实时数据同步
* - 持久化存储(electron-store
*/
import { defineStore } from 'pinia';
import { ref, computed, reactive } from 'vue';
/* ======================== 类型定义 ======================== */
/** 应用视图模式 */
type ViewMode = 'prepare' | 'lesson' | 'grade' | 'report';
/** 设备信息 */
interface DeviceState {
id: string;
name: string;
type: 'usb' | 'ble';
status: 'connected' | 'disconnected' | 'error';
battery: number;
}
/** 学生在线状态 */
interface StudentOnlineState {
studentId: string;
name: string;
penId: string;
online: boolean;
lastActive: number;
strokeCount: number;
}
/** 课堂互动数据 */
interface ClassroomLiveData {
classroomId: string;
className: string;
startTime: number;
onlineStudents: StudentOnlineState[];
totalStrokes: number;
isRecording: boolean;
}
/** 批改任务 */
interface GradeTask {
assignmentId: string;
studentId: string;
studentName: string;
status: 'pending' | 'ai_graded' | 'reviewed' | 'completed';
aiScore: number;
teacherScore: number;
feedback: string;
}
/* ======================== 用户Store ======================== */
/**
* 用户认证与信息状态管理
*/
export const useUserStore = defineStore('user', () => {
/** 是否已登录 */
const isLoggedIn = ref(false);
/** 当前用户信息 */
const userInfo = ref<{
userId: string;
name: string;
role: string;
phone: string;
schoolId: string;
schoolName: string;
avatar: string;
} | null>(null);
/** 登录时间 */
const loginTime = ref(0);
/** Token过期时间 */
const tokenExpiresAt = ref(0);
/** 用户角色显示名 */
const roleLabel = computed(() => {
const roleMap: Record<string, string> = {
admin: '管理员',
teacher: '教师',
student: '学生',
parent: '家长'
};
return roleMap[userInfo.value?.role || ''] || '未知';
});
/**
* 登录成功后设置用户状态
*/
function setLoggedIn(user: typeof userInfo.value, expiresAt: number): void {
isLoggedIn.value = true;
userInfo.value = user;
loginTime.value = Date.now();
tokenExpiresAt.value = expiresAt;
console.log(`[Store] 用户登录: ${user?.name} (${user?.role})`);
}
/**
* 退出登录
*/
function logout(): void {
isLoggedIn.value = false;
userInfo.value = null;
loginTime.value = 0;
tokenExpiresAt.value = 0;
console.log('[Store] 用户已退出');
}
return { isLoggedIn, userInfo, loginTime, tokenExpiresAt, roleLabel, setLoggedIn, logout };
});
/* ======================== 课堂Store ======================== */
/**
* 课堂状态管理
* 管理当前课堂的实时数据
*/
export const useClassroomStore = defineStore('classroom', () => {
/** 当前视图模式 */
const viewMode = ref<ViewMode>('prepare');
/** 当前课堂数据 */
const liveData = ref<ClassroomLiveData | null>(null);
/** 是否在课堂中 */
const isInClass = ref(false);
/** WebSocket连接状态 */
const wsConnected = ref(false);
/** 在线学生数 */
const onlineCount = computed(() =>
liveData.value?.onlineStudents.filter(s => s.online).length || 0
);
/** 总学生数 */
const totalStudents = computed(() =>
liveData.value?.onlineStudents.length || 0
);
/** 在线率 */
const onlineRate = computed(() => {
const total = totalStudents.value;
return total > 0 ? Math.round((onlineCount.value / total) * 100) : 0;
});
/**
* 开始课堂
*/
function startClass(classroomId: string, className: string, students: StudentOnlineState[]): void {
liveData.value = {
classroomId,
className,
startTime: Date.now(),
onlineStudents: students,
totalStrokes: 0,
isRecording: false
};
isInClass.value = true;
viewMode.value = 'lesson';
console.log(`[Store] 课堂开始: ${className}, 学生${students.length}人`);
}
/**
* 结束课堂
*/
function endClass(): void {
const duration = liveData.value ? Date.now() - liveData.value.startTime : 0;
console.log(`[Store] 课堂结束, 时长=${Math.round(duration / 60000)}分钟, ` +
`笔迹=${liveData.value?.totalStrokes}`);
isInClass.value = false;
liveData.value = null;
}
/**
* 更新学生在线状态
*/
function updateStudentStatus(studentId: string, online: boolean): void {
const student = liveData.value?.onlineStudents.find(s => s.studentId === studentId);
if (student) {
student.online = online;
student.lastActive = Date.now();
}
}
/**
* 累加笔迹数据计数
*/
function addStrokeCount(count: number): void {
if (liveData.value) {
liveData.value.totalStrokes += count;
}
}
/**
* 切换视图模式
*/
function setViewMode(mode: ViewMode): void {
viewMode.value = mode;
console.log(`[Store] 视图切换: ${mode}`);
}
return {
viewMode, liveData, isInClass, wsConnected,
onlineCount, totalStudents, onlineRate,
startClass, endClass, updateStudentStatus, addStrokeCount, setViewMode
};
});
/* ======================== 设备Store ======================== */
/**
* 设备连接状态管理
*/
export const useDeviceStore = defineStore('device', () => {
/** 已连接设备列表 */
const devices = ref<DeviceState[]>([]);
/** 正在扫描BLE */
const isScanning = ref(false);
/** 已连接设备数 */
const connectedCount = computed(() =>
devices.value.filter(d => d.status === 'connected').length
);
/**
* 添加或更新设备
*/
function upsertDevice(device: DeviceState): void {
const idx = devices.value.findIndex(d => d.id === device.id);
if (idx >= 0) {
devices.value[idx] = device;
} else {
devices.value.push(device);
}
}
/**
* 移除设备
*/
function removeDevice(deviceId: string): void {
devices.value = devices.value.filter(d => d.id !== deviceId);
}
/**
* 更新设备电量
*/
function updateBattery(deviceId: string, battery: number): void {
const device = devices.value.find(d => d.id === deviceId);
if (device) {
device.battery = battery;
}
}
return { devices, isScanning, connectedCount, upsertDevice, removeDevice, updateBattery };
});
/* ======================== 批改Store ======================== */
/**
* 作业批改状态管理
*/
export const useGradeStore = defineStore('grade', () => {
/** 当前批改的作业ID */
const currentAssignmentId = ref('');
/** 批改任务列表 */
const gradeTasks = ref<GradeTask[]>([]);
/** 当前批改的学生索引 */
const currentTaskIndex = ref(0);
/** 待批改数 */
const pendingCount = computed(() =>
gradeTasks.value.filter(t => t.status === 'ai_graded' || t.status === 'pending').length
);
/** 已完成数 */
const completedCount = computed(() =>
gradeTasks.value.filter(t => t.status === 'completed' || t.status === 'reviewed').length
);
/** 总体进度百分比 */
const progressPercent = computed(() => {
const total = gradeTasks.value.length;
return total > 0 ? Math.round((completedCount.value / total) * 100) : 0;
});
/** 当前批改任务 */
const currentTask = computed(() => gradeTasks.value[currentTaskIndex.value] || null);
/**
* 加载批改任务列表
*/
function loadTasks(assignmentId: string, tasks: GradeTask[]): void {
currentAssignmentId.value = assignmentId;
gradeTasks.value = tasks;
currentTaskIndex.value = 0;
console.log(`[Store] 加载批改任务: ${tasks.length}份作业`);
}
/**
* 提交教师批改结果
*/
function submitGrade(studentId: string, score: number, feedback: string): void {
const task = gradeTasks.value.find(t => t.studentId === studentId);
if (task) {
task.teacherScore = score;
task.feedback = feedback;
task.status = 'reviewed';
console.log(`[Store] 批改完成: ${task.studentName}, 分数=${score}`);
}
}
/**
* 切换到下一个待批改任务
*/
function nextTask(): boolean {
for (let i = currentTaskIndex.value + 1; i < gradeTasks.value.length; i++) {
if (gradeTasks.value[i].status !== 'completed' && gradeTasks.value[i].status !== 'reviewed') {
currentTaskIndex.value = i;
return true;
}
}
return false;
}
/**
* 切换到上一个任务
*/
function prevTask(): boolean {
if (currentTaskIndex.value > 0) {
currentTaskIndex.value--;
return true;
}
return false;
}
return {
currentAssignmentId, gradeTasks, currentTaskIndex,
pendingCount, completedCount, progressPercent, currentTask,
loadTasks, submitGrade, nextTask, prevTask
};
});
```