software copyright
This commit is contained in:
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* 自然写互动课堂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;
|
||||
@@ -0,0 +1,708 @@
|
||||
/**
|
||||
* 自然写互动课堂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;
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* 自然写互动课堂PC端应用软件 V1.0
|
||||
*
|
||||
* device_manager.ts - USB/BLE设备管理
|
||||
*
|
||||
* 功能说明:
|
||||
* - USB HID点阵笔连接管理
|
||||
* - BLE蓝牙点阵笔扫描与连接
|
||||
* - 设备数据解析(7字节紧凑坐标解码)
|
||||
* - 设备热插拔监听
|
||||
* - 多设备并行管理
|
||||
*/
|
||||
|
||||
/* ======================== 类型定义 ======================== */
|
||||
|
||||
/** 设备连接方式 */
|
||||
enum DeviceInterface {
|
||||
USB_HID = 'usb',
|
||||
BLE = 'ble'
|
||||
}
|
||||
|
||||
/** 设备状态 */
|
||||
enum DeviceStatus {
|
||||
DISCONNECTED = 'disconnected',
|
||||
CONNECTING = 'connecting',
|
||||
CONNECTED = 'connected',
|
||||
ERROR = 'error'
|
||||
}
|
||||
|
||||
/** 点阵笔设备信息 */
|
||||
interface PenDevice {
|
||||
id: string; /* 设备唯一ID */
|
||||
name: string; /* 设备名称 */
|
||||
macAddress: string; /* MAC地址 */
|
||||
interface: DeviceInterface; /* 连接方式 */
|
||||
status: DeviceStatus; /* 连接状态 */
|
||||
battery: number; /* 电量百分比 */
|
||||
firmwareVersion: string; /* 固件版本 */
|
||||
lastConnected: number; /* 最后连接时间戳 */
|
||||
}
|
||||
|
||||
/** 笔迹坐标点 */
|
||||
interface StrokePoint {
|
||||
x: number; /* X坐标(毫米) */
|
||||
y: number; /* Y坐标(毫米) */
|
||||
pressure: number; /* 压力值(0-1) */
|
||||
timestamp: number; /* 时间戳(毫秒) */
|
||||
penDown: boolean; /* 落笔标志 */
|
||||
}
|
||||
|
||||
/** 设备事件回调 */
|
||||
interface DeviceEventCallbacks {
|
||||
onDeviceDiscovered: (device: PenDevice) => void;
|
||||
onDeviceConnected: (device: PenDevice) => void;
|
||||
onDeviceDisconnected: (deviceId: string) => void;
|
||||
onStrokeData: (deviceId: string, points: StrokePoint[]) => void;
|
||||
onBatteryUpdate: (deviceId: string, level: number) => void;
|
||||
onError: (deviceId: string, error: string) => void;
|
||||
}
|
||||
|
||||
/* ======================== USB HID常量 ======================== */
|
||||
|
||||
/** 自然写点阵笔USB VendorID */
|
||||
const WRITECH_USB_VID = 0x1234;
|
||||
/** 自然写点阵笔USB ProductID */
|
||||
const WRITECH_USB_PID = 0x5678;
|
||||
/** USB HID报文最大长度 */
|
||||
const USB_REPORT_SIZE = 64;
|
||||
/** USB轮询间隔(毫秒) */
|
||||
const USB_POLL_INTERVAL = 5;
|
||||
|
||||
/* ======================== BLE常量 ======================== */
|
||||
|
||||
/** 自然写笔迹服务UUID */
|
||||
const BLE_SERVICE_UUID = '0000ffe0-0000-1000-8000-00805f9b34fb';
|
||||
/** 笔迹数据特征UUID(Notify) */
|
||||
const BLE_STROKE_CHAR_UUID = '0000ffe1-0000-1000-8000-00805f9b34fb';
|
||||
/** 电量特征UUID */
|
||||
const BLE_BATTERY_CHAR_UUID = '0000ffe2-0000-1000-8000-00805f9b34fb';
|
||||
/** 控制特征UUID(Write) */
|
||||
const BLE_CONTROL_CHAR_UUID = '0000ffe3-0000-1000-8000-00805f9b34fb';
|
||||
|
||||
/* ======================== 坐标解码 ======================== */
|
||||
|
||||
/**
|
||||
* 解码7字节紧凑坐标编码
|
||||
* 编码格式: 20位X + 20位Y + 12位压力 + 4位标志
|
||||
*/
|
||||
function decodeCompactPoint(data: Buffer, offset: number): StrokePoint {
|
||||
/* 提取20位X坐标 */
|
||||
const rawX = (data[offset] << 12) |
|
||||
(data[offset + 1] << 4) |
|
||||
((data[offset + 2] >> 4) & 0x0F);
|
||||
|
||||
/* 提取20位Y坐标 */
|
||||
const rawY = ((data[offset + 2] & 0x0F) << 16) |
|
||||
(data[offset + 3] << 8) |
|
||||
data[offset + 4];
|
||||
|
||||
/* 提取12位压力值 */
|
||||
const rawPressure = (data[offset + 5] << 4) |
|
||||
((data[offset + 6] >> 4) & 0x0F);
|
||||
|
||||
/* 提取4位标志 */
|
||||
const flags = data[offset + 6] & 0x0F;
|
||||
|
||||
return {
|
||||
x: rawX * 0.3, /* 点阵码单位转毫米 */
|
||||
y: rawY * 0.3,
|
||||
pressure: rawPressure / 4095, /* 归一化到0-1 */
|
||||
timestamp: Date.now(),
|
||||
penDown: (flags & 0x01) !== 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算CRC-16 CCITT校验
|
||||
*/
|
||||
function crc16CCITT(data: Buffer, length: number): number {
|
||||
let crc = 0xFFFF;
|
||||
for (let i = 0; i < length; i++) {
|
||||
crc ^= data[i] << 8;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
if (crc & 0x8000) {
|
||||
crc = ((crc << 1) ^ 0x1021) & 0xFFFF;
|
||||
} else {
|
||||
crc = (crc << 1) & 0xFFFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
/* ======================== 设备管理器 ======================== */
|
||||
|
||||
/**
|
||||
* 点阵笔设备管理器
|
||||
* 统一管理USB和BLE连接的点阵笔设备
|
||||
*/
|
||||
class DeviceManager {
|
||||
/** 已连接设备列表 */
|
||||
private devices: Map<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 };
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 自然写互动课堂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}`);
|
||||
});
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* 自然写互动课堂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
|
||||
};
|
||||
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* 自然写互动课堂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>
|
||||
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 自然写互动课堂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
|
||||
};
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user