feat: add reward code
This commit is contained in:
156
src/lib/api.ts
Normal file
156
src/lib/api.ts
Normal 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
159
src/lib/authService.ts
Normal 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
192
src/lib/mockService.ts
Normal 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
91
src/lib/supabase.ts
Normal 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
202
src/lib/uploadService.ts
Normal 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('文件上传失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user