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

107 KiB
Raw Blame History

自然写互动课堂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

/**
 * 自然写互动课堂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

/**
 * 自然写互动课堂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

/**
 * 自然写互动课堂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

/**
 * 自然写互动课堂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

/**
 * 自然写互动课堂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

/**
 * 自然写互动课堂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
    };
});