Files
2026-03-22 15:24:40 +08:00

334 lines
9.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 自然写互动课堂PC端应用软件 V1.0
*
* 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
};