# 自然写互动课堂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 = new Map(); // 投屏目标设备列表 private localStream: MediaStream | null = null; // 本地媒体流 private signalSocket: WebSocket | null = null; // 信令WebSocket连接 private localDeviceId: string; // 本机设备标识 private statsTimers: Map> = new Map(); private qualityHistory: CastQualityStats[] = []; // 质量统计历史 private isCapturing: boolean = false; private hmacKey: string; // 消息签名密钥 constructor(config?: Partial) { 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 { try { await this.connectSignalServer(); console.log('[ScreenCast] 投屏管理器初始化完成'); } catch (error) { console.error('[ScreenCast] 初始化失败:', error); throw error; } } /** * 连接信令服务器(通过WebSocket交换SDP和ICE候选) * 支持断线自动重连(指数退避策略) */ private async connectSignalServer(): Promise { 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 { 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 { 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 { 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 { 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 | null = null; private vacuumTimer: ReturnType | 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 { 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 { const stats: Record = {}; 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'; /** 笔迹数据特征UUID(Notify) */ const BLE_STROKE_CHAR_UUID = '0000ffe1-0000-1000-8000-00805f9b34fb'; /** 电量特征UUID */ const BLE_BATTERY_CHAR_UUID = '0000ffe2-0000-1000-8000-00805f9b34fb'; /** 控制特征UUID(Write) */ 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 = new Map(); /** 事件回调 */ private callbacks: DeviceEventCallbacks; /** USB轮询定时器 */ private usbPollTimer: ReturnType | 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 { 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 { code: number; msg: string; data: T; } /** 分页参数 */ interface PageParams { page: number; size: number; sort?: string; } /** 分页响应 */ interface PageResult { 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 { 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(resolve => { refreshQueue.push(resolve); }); } } return currentTokens.accessToken; } /* ======================== HTTP请求封装 ======================== */ /** * 通用HTTP请求方法 */ async function request( method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, data?: any, retryCount: number = 0 ): Promise> { const url = `${API_BASE_URL}${path}`; const headers: Record = { '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 = 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(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(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 { 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> { const result = await request('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 { 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 = await resp.json(); if (json.code !== 200 || !json.data) { throw new Error('Token刷新失败'); } return json.data; } /** 获取当前用户信息 */ async function getUserInfo(): Promise> { return request('GET', '/api/v1/user/me'); } /** 获取班级列表 */ async function getClassrooms(): Promise> { return request('GET', '/api/v1/classroom/list'); } /** 获取作业列表 */ async function getAssignments(classId: string, params: PageParams): Promise>> { return request>('GET', `/api/v1/assignment/list?class_id=${classId}&page=${params.page}&size=${params.size}`); } /** 发布作业 */ async function publishAssignment(assignment: Partial): Promise> { return request<{ assignmentId: string }>('POST', '/api/v1/assignment/publish', assignment); } /** 上传笔迹数据 */ async function uploadStrokeData(assignmentId: string, studentId: string, strokeData: any[]): Promise> { return request('POST', '/api/v1/stroke/upload', { assignment_id: assignmentId, student_id: studentId, strokes: strokeData }); } /** 获取AI批改结果 */ async function getGradingResult(assignmentId: string): Promise> { return request('GET', `/api/v1/result/${assignmentId}`); } /** 获取学情报告 */ async function getLearningReport(studentId: string): Promise> { return request('GET', `/api/v1/report/student/${studentId}`); } /** 下载课件资源 */ async function getResourceDownloadUrl(resourceId: string): Promise> { return request<{ url: string }>('GET', `/api/v1/resource/download/${resourceId}`); } /** 退出登录 */ async function logout(): Promise { await request('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高性能笔迹渲染 * - 压力感应笔锋效果 * - 贝塞尔曲线平滑 * - 多图层渲染(背景+已完成笔画+当前笔画) * - 笔迹回放动画 * - 缩放与平移手势 */ ``` ### `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 = { 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('prepare'); /** 当前课堂数据 */ const liveData = ref(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([]); /** 正在扫描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([]); /** 当前批改的学生索引 */ 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 }; }); ```