software copyright
This commit is contained in:
@@ -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
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user