feat: add reward code

This commit is contained in:
2025-12-22 17:37:41 +08:00
commit 1226bbe724
34 changed files with 8857 additions and 0 deletions

156
src/lib/api.ts Normal file
View File

@@ -0,0 +1,156 @@
/**
* HTTP 客户端封装
* 统一处理请求/响应拦截、错误处理、Token 管理
*/
import { API_BASE_URL } from '../config/env';
import type { ApiResponse } from '../types/api';
// Token 存储键名
const TOKEN_KEY = 'texpixel_token';
const TOKEN_EXPIRES_KEY = 'texpixel_token_expires';
const USER_EMAIL_KEY = 'texpixel_user_email';
/**
* Token 管理工具
*/
export const tokenManager = {
getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
},
setToken(token: string, expiresAt: number, email?: string): void {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(TOKEN_EXPIRES_KEY, expiresAt.toString());
if (email) {
localStorage.setItem(USER_EMAIL_KEY, email);
}
},
removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_EXPIRES_KEY);
localStorage.removeItem(USER_EMAIL_KEY);
},
getEmail(): string | null {
return localStorage.getItem(USER_EMAIL_KEY);
},
isTokenValid(): boolean {
const token = this.getToken();
const expiresAt = localStorage.getItem(TOKEN_EXPIRES_KEY);
if (!token || !expiresAt) return false;
// 提前 5 分钟判定为过期
const expiresTimestamp = parseInt(expiresAt, 10) * 1000;
return Date.now() < expiresTimestamp - 5 * 60 * 1000;
},
getExpiresAt(): number | null {
const expiresAt = localStorage.getItem(TOKEN_EXPIRES_KEY);
return expiresAt ? parseInt(expiresAt, 10) : null;
},
};
/**
* 自定义 API 错误类
*/
export class ApiError extends Error {
constructor(
public code: number,
message: string,
public requestId?: string
) {
super(message);
this.name = 'ApiError';
}
}
/**
* 请求配置类型
*/
interface RequestConfig extends RequestInit {
skipAuth?: boolean;
}
/**
* 发起 HTTP 请求
*/
async function request<T>(
endpoint: string,
config: RequestConfig = {}
): Promise<ApiResponse<T>> {
const { skipAuth = false, headers: customHeaders, ...restConfig } = config;
const headers: HeadersInit = {
'Content-Type': 'application/json',
...customHeaders,
};
// 自动添加 Authorization header
if (!skipAuth) {
const token = tokenManager.getToken();
if (token) {
(headers as Record<string, string>)['Authorization'] = token;
}
}
const url = `${API_BASE_URL}${endpoint}`;
try {
const response = await fetch(url, {
...restConfig,
headers,
});
const data: ApiResponse<T> = await response.json();
// 统一处理业务错误
if (data.code !== 200) {
throw new ApiError(data.code, data.message, data.request_id);
}
return data;
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
// 网络错误或其他错误
throw new ApiError(-1, '网络错误,请检查网络连接');
}
}
/**
* HTTP 方法快捷函数
*/
export const http = {
get<T>(endpoint: string, config?: RequestConfig): Promise<ApiResponse<T>> {
return request<T>(endpoint, { ...config, method: 'GET' });
},
post<T>(endpoint: string, body?: unknown, config?: RequestConfig): Promise<ApiResponse<T>> {
return request<T>(endpoint, {
...config,
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
});
},
put<T>(endpoint: string, body?: unknown, config?: RequestConfig): Promise<ApiResponse<T>> {
return request<T>(endpoint, {
...config,
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
});
},
delete<T>(endpoint: string, config?: RequestConfig): Promise<ApiResponse<T>> {
return request<T>(endpoint, { ...config, method: 'DELETE' });
},
};
export default http;

159
src/lib/authService.ts Normal file
View File

@@ -0,0 +1,159 @@
/**
* 认证服务
* 处理用户登录、注册、登出等认证相关操作
*/
import { http, tokenManager, ApiError } from './api';
import type { AuthData, LoginRequest, RegisterRequest, UserInfo } from '../types/api';
// 重新导出 ApiErrorMessages 以便使用
export { ApiErrorMessages } from '../types/api';
/**
* 从 JWT Token 解析用户信息
*/
function parseJwtPayload(token: string): UserInfo | null {
try {
// 移除 Bearer 前缀
const actualToken = token.replace('Bearer ', '');
const base64Payload = actualToken.split('.')[1];
const payload = JSON.parse(atob(base64Payload));
return payload as UserInfo;
} catch {
return null;
}
}
/**
* 认证服务
*/
export const authService = {
/**
* 用户登录
*/
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
if (!response.data) {
throw new ApiError(-1, '登录失败,请重试');
}
const { token, expires_at } = response.data;
// 存储 Token 和 email
tokenManager.setToken(token, expires_at, credentials.email);
// 解析用户信息
const user = parseJwtPayload(token);
if (!user) {
throw new ApiError(-1, 'Token 解析失败');
}
// 补充 email 和 id 兼容字段
const userWithEmail: UserInfo = {
...user,
email: credentials.email,
id: String(user.user_id),
};
return {
user: userWithEmail,
token,
expiresAt: expires_at,
};
},
/**
* 用户注册
* 注意:业务错误码 (code !== 200) 已在 api.ts 中统一处理,会抛出 ApiError
*/
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
if (!response.data) {
throw new ApiError(-1, '注册失败,请重试');
}
const { token, expires_at } = response.data;
// 存储 Token 和 email
tokenManager.setToken(token, expires_at, credentials.email);
// 解析用户信息
const user = parseJwtPayload(token);
if (!user) {
throw new ApiError(-1, 'Token 解析失败');
}
// 补充 email 和 id 兼容字段
const userWithEmail: UserInfo = {
...user,
email: credentials.email,
id: String(user.user_id),
};
return {
user: userWithEmail,
token,
expiresAt: expires_at,
};
},
/**
* 用户登出
*/
logout(): void {
tokenManager.removeToken();
},
/**
* 检查是否已登录
*/
isAuthenticated(): boolean {
return tokenManager.isTokenValid();
},
/**
* 获取当前存储的 Token
*/
getToken(): string | null {
return tokenManager.getToken();
},
/**
* 从存储的 Token 恢复用户会话
*/
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
const token = tokenManager.getToken();
const expiresAt = tokenManager.getExpiresAt();
const email = tokenManager.getEmail();
if (!token || !expiresAt || !tokenManager.isTokenValid()) {
tokenManager.removeToken();
return null;
}
const parsedUser = parseJwtPayload(token);
if (!parsedUser) {
tokenManager.removeToken();
return null;
}
// 补充 email 和 id 兼容字段
const user: UserInfo = {
...parsedUser,
email: email || '',
id: String(parsedUser.user_id),
};
return {
user,
token,
expiresAt,
};
},
};
export { ApiError };
export default authService;

192
src/lib/mockService.ts Normal file
View File

@@ -0,0 +1,192 @@
import { FileRecord, RecognitionResult } from '../types';
// Mock Data Store
class MockStore {
private files: FileRecord[] = [];
private results: Record<string, RecognitionResult> = {};
private currentUser = { id: 'mock-user-id', email: 'demo@texpixel.ai' };
constructor() {
// Add some initial mock data
this.addInitialData();
}
private addInitialData() {
const fileId = 'mock-file-1';
const file: FileRecord = {
id: fileId,
user_id: this.currentUser.id,
filename: 'math-formula-example.jpg',
file_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800',
file_type: 'image/jpeg',
file_size: 1024 * 500, // 500KB
thumbnail_path: null,
status: 'completed',
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 1 day ago
updated_at: new Date().toISOString(),
};
const result: RecognitionResult = {
id: 'mock-result-1',
file_id: fileId,
markdown_content: `# Quadratic Formula
The quadratic formula is a fundamental equation in algebra.
$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$
Where:
- $a$, $b$, and $c$ are coefficients
- $x$ represents the solutions
## Example
For the equation $2x^2 + 5x - 3 = 0$:
$$x = \\frac{-5 \\pm \\sqrt{25 - 4(2)(-3)}}{4} = \\frac{-5 \\pm \\sqrt{49}}{4} = \\frac{-5 \\pm 7}{4}$$
Solutions: $x_1 = 0.5$, $x_2 = -3$`,
latex_content: `\\documentclass{article}
\\begin{document}
\\section*{Quadratic Formula}
The quadratic formula is a fundamental equation in algebra.
\\[
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
\\]
Where:
\\begin{itemize}
\\item $a$, $b$, and $c$ are coefficients
\\item $x$ represents the solutions
\\end{itemize}
\\end{document}`,
mathml_content: `<math xmlns="http://www.w3.org/1998/Math/MathML" display="block">
<mrow>
<mi>x</mi>
<mo>=</mo>
<mfrac>
<mrow>
<mo>-</mo>
<mi>b</mi>
<mo>±</mo>
<msqrt>
<mrow>
<msup>
<mi>b</mi>
<mn>2</mn>
</msup>
<mo>-</mo>
<mn>4</mn>
<mi>a</mi>
<mi>c</mi>
</mrow>
</msqrt>
</mrow>
<mrow>
<mn>2</mn>
<mi>a</mi>
</mrow>
</mfrac>
</mrow>
</math>`,
mathml_word_content: `<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
<m:f>
<m:num>
<m:r><m:t>-b ± √(b²-4ac)</m:t></m:r>
</m:num>
<m:den>
<m:r><m:t>2a</m:t></m:r>
</m:den>
</m:f>
</m:oMath>`,
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800',
created_at: new Date().toISOString(),
};
this.files.push(file);
this.results[fileId] = result;
}
// File Operations
async getFiles(userId?: string | null): Promise<FileRecord[]> {
await this.delay(500); // Simulate network latency
if (userId) {
return this.files.filter(f => f.user_id === userId);
}
return this.files.filter(f => f.user_id === null); // Anonymous files
}
async uploadFile(file: File, userId?: string | null): Promise<FileRecord> {
await this.delay(1000); // Simulate upload time
const newFile: FileRecord = {
id: crypto.randomUUID(),
user_id: userId || null,
filename: file.name,
file_path: URL.createObjectURL(file), // Local blob URL
file_type: file.type,
file_size: file.size,
thumbnail_path: null,
status: 'completed',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
this.files.unshift(newFile); // Add to beginning
this.generateMockResult(newFile.id, file.name);
return newFile;
}
// Method to add a file record manually (e.g. after real upload)
addFileRecord(fileRecord: FileRecord) {
this.files.unshift(fileRecord);
// Don't generate mock result - real API result will be set after polling completes
}
// Result Operations
async getResult(fileId: string): Promise<RecognitionResult | null> {
await this.delay(300);
return this.results[fileId] || null;
}
private generateMockResult(fileId: string, filename: string) {
const mockResult: RecognitionResult = {
id: crypto.randomUUID(),
file_id: fileId,
markdown_content: `# Analysis for ${filename}\n\nThis is a mock analysis result generated for the uploaded file.\n\n$$ E = mc^2 $$\n\nDetected content matches widely known physics formulas.`,
latex_content: `\\documentclass{article}\n\\begin{document}\nSection{${filename}}\n\n\\[ E = mc^2 \\]\n\n\\end{document}`,
mathml_content: `<math><mi>E</mi><mo>=</mo><mi>m</mi><msup><mi>c</mi><mn>2</mn></msup></math>`,
mathml_word_content: `<m:oMath><m:r><m:t>E=mc^2</m:t></m:r></m:oMath>`,
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800', // Placeholder
created_at: new Date().toISOString(),
};
this.results[fileId] = mockResult;
}
// Auth Operations
async getCurrentUser() {
return this.currentUser;
}
async signIn() {
await this.delay(500);
return { user: this.currentUser, error: null };
}
async signOut() {
await this.delay(200);
return { error: null };
}
private delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
export const mockService = new MockStore();

91
src/lib/supabase.ts Normal file
View File

@@ -0,0 +1,91 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
console.error('Missing Supabase environment variables. Please check your .env file.');
}
// Fallback to dummy values to prevent app crash, but API calls will fail
export const supabase = createClient(
supabaseUrl || 'https://placeholder.supabase.co',
supabaseAnonKey || 'placeholder'
);
export type Database = {
public: {
Tables: {
files: {
Row: {
id: string;
user_id: string | null;
filename: string;
file_path: string;
file_type: string;
file_size: number;
thumbnail_path: string | null;
status: string;
created_at: string;
updated_at: string;
};
Insert: {
id?: string;
user_id?: string | null;
filename: string;
file_path: string;
file_type: string;
file_size?: number;
thumbnail_path?: string | null;
status?: string;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
user_id?: string | null;
filename?: string;
file_path?: string;
file_type?: string;
file_size?: number;
thumbnail_path?: string | null;
status?: string;
created_at?: string;
updated_at?: string;
};
};
recognition_results: {
Row: {
id: string;
file_id: string;
markdown_content: string | null;
latex_content: string | null;
mathml_content: string | null;
mathml_word_content: string | null;
rendered_image_path: string | null;
created_at: string;
};
Insert: {
id?: string;
file_id: string;
markdown_content?: string | null;
latex_content?: string | null;
mathml_content?: string | null;
mathml_word_content?: string | null;
rendered_image_path?: string | null;
created_at?: string;
};
Update: {
id?: string;
file_id?: string;
markdown_content?: string | null;
latex_content?: string | null;
mathml_content?: string | null;
mathml_word_content?: string | null;
rendered_image_path?: string | null;
created_at?: string;
};
};
};
};
};

202
src/lib/uploadService.ts Normal file
View File

@@ -0,0 +1,202 @@
import imageCompression from 'browser-image-compression';
import SparkMD5 from 'spark-md5';
import http from './api';
import { OssSignatureData, OssSignatureRequest, RecognitionTaskData, RecognitionTaskRequest, RecognitionResultData, TaskListData } from '../types/api';
/**
* 文件上传服务
*/
export const uploadService = {
/**
* 处理并上传文件
*/
async uploadFile(file: File): Promise<OssSignatureData> {
// ... (保留之前的 uploadFile 逻辑)
// 1. 验证文件
if (!this.validateFile(file)) {
throw new Error('不支持的文件类型或文件大小超过限制');
}
// 2. 计算文件 Hash (使用原始文件,确保去重逻辑基于用户源文件)
// 压缩后的文件 Hash 可能会因为压缩过程的微小差异(如时间戳元数据)而不同
const fileHash = await this.calculateMD5(file);
// 3. 压缩图片 (如果是图片且超过一定大小)
let processedFile = file;
if (file.type.startsWith('image/')) {
try {
processedFile = await this.compressImage(file);
} catch (error) {
console.warn('图片压缩失败,尝试使用原文件上传', error);
}
}
// 4. 获取上传签名
const signatureData = await this.getOssSignature({
file_hash: fileHash,
file_name: processedFile.name, // 使用处理后的文件名(通常相同)
});
// 5. 如果需要上传 (repeat = false),则上传到 OSS (上传处理后的文件)
if (!signatureData.data?.repeat && signatureData.data?.sign_url) {
await this.uploadToOss(signatureData.data.sign_url, processedFile);
}
if (!signatureData.data) {
throw new Error('获取上传签名失败');
}
return signatureData.data;
},
/**
* 创建公式识别任务
*/
async createRecognitionTask(path: string, fileHash: string, fileName: string): Promise<RecognitionTaskData> {
const data: RecognitionTaskRequest = {
file_url: path,
file_hash: fileHash,
file_name: fileName,
task_type: 'FORMULA'
};
return http.post<RecognitionTaskData>('/formula/recognition', data).then(res => {
if (!res.data) throw new Error('创建任务失败: 无返回数据');
return res.data;
});
},
/**
* 获取任务结果
*/
async getTaskResult(taskNo: string): Promise<RecognitionResultData> {
return http.get<RecognitionResultData>(`/formula/recognition/${taskNo}`).then(res => {
if (!res.data) throw new Error('获取结果失败: 无返回数据');
return res.data;
});
},
/**
* 获取任务历史记录列表
*/
async getTaskList(taskType: 'FORMULA' = 'FORMULA', page: number = 1, pageSize: number = 5): Promise<TaskListData> {
return http.get<TaskListData>(`/task/list?task_type=${taskType}&page=${page}&page_size=${pageSize}`).then(res => {
if (!res.data) throw new Error('获取历史记录失败: 无返回数据');
return res.data;
});
},
// ... (保留其他 helper 方法: validateFile, compressImage, calculateMD5, getOssSignature, uploadToOss)
/**
* 验证文件
*/
validateFile(file: File): boolean {
const validTypes = ['image/jpeg', 'image/png', 'image/jpg'];
// 检查类型 (虽然 input accept 做了限制,但这里再检查一次)
// 注意:有些 jpg 文件的 type 可能是 image/jpeg
if (!file.type.startsWith('image/')) {
// 用户提到支持 jpg, png。
// 暂时只允许图片,虽然 UI 代码里有 application/pdf但用户需求只提到了图片。
// 根据用户需求:支持 jpg, png 图片上传
if (validTypes.indexOf(file.type) === -1 && !file.name.match(/\.(jpg|jpeg|png)$/i)) {
return false;
}
}
// 5MB 限制 (前端压缩前的检查,压缩后应该更小)
// 但如果用户传了个很大的文件,压缩可能也花很久。
// 用户说 "文件大小显示5M, 前端进行压缩处理"可能指压缩目标是5M或者限制原文件5M?
// 通常是限制上传大小。如果原文件很大压缩后小于5M也可以。
// 这里暂时不做严格的源文件大小限制,交给压缩处理,或者设置一个合理的上限比如 20MB。
return true;
},
/**
* 压缩图片
*/
async compressImage(file: File): Promise<File> {
const options = {
maxSizeMB: 5, // 限制最大 5MB
maxWidthOrHeight: 1920, // 限制最大宽/高,防止过大图片
useWebWorker: true,
initialQuality: 0.8,
};
try {
return await imageCompression(file, options);
} catch (error) {
console.error('Image compression error:', error);
throw error;
}
},
/**
* 计算文件 MD5
*/
calculateMD5(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const blobSlice = File.prototype.slice || (File.prototype as any).mozSlice || (File.prototype as any).webkitSlice;
const chunkSize = 2097152; // 2MB
const chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
const spark = new SparkMD5.ArrayBuffer();
const fileReader = new FileReader();
fileReader.onload = function (e) {
if (e.target?.result) {
spark.append(e.target.result as ArrayBuffer);
}
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
resolve(spark.end());
}
};
fileReader.onerror = function () {
reject('File read failed');
};
function loadNext() {
const start = currentChunk * chunkSize;
const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
});
},
/**
* 获取 OSS 签名
*/
async getOssSignature(data: OssSignatureRequest) {
return http.post<OssSignatureData>('/oss/signature_url', data);
},
/**
* 上传文件到 OSS
*/
async uploadToOss(url: string, file: File): Promise<void> {
try {
// OSS 直接 PUT 上传,不需要额外的 headers (除非签名里有要求,通常 Content-Type 需要匹配)
// 注意Content-Type 需要根据文件实际类型设置,或者 application/octet-stream
// 这里使用文件类型
await fetch(url, {
method: 'PUT',
body: file,
headers: {
// 有些 OSS 签名会绑定 Content-Type如果不一致会报错。
// 这里尽量使用 file.type如果为空则设为 application/octet-stream
// 如果签名不限制 Content-Type则无所谓。
// 按照常规 OSS PutObject建议带上 type
'Content-Type': file.type || 'application/octet-stream',
}
});
} catch (error) {
console.error('Upload to OSS failed:', error);
throw new Error('文件上传失败');
}
}
};