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

512
src/App.tsx Normal file
View File

@@ -0,0 +1,512 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useAuth } from './contexts/AuthContext';
import { uploadService } from './lib/uploadService';
import { FileRecord, RecognitionResult } from './types';
import { TaskStatus, TaskHistoryItem } from './types/api';
import LeftSidebar from './components/LeftSidebar';
import Navbar from './components/Navbar';
import FilePreview from './components/FilePreview';
import ResultPanel from './components/ResultPanel';
import UploadModal from './components/UploadModal';
const PAGE_SIZE = 6;
function App() {
const { user, initializing } = useAuth();
const [files, setFiles] = useState<FileRecord[]>([]);
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [loading, setLoading] = useState(false);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
// Layout state
const [sidebarWidth, setSidebarWidth] = useState(320);
const [isResizing, setIsResizing] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
// Polling intervals refs to clear them on unmount
const pollingIntervals = useRef<Record<string, NodeJS.Timeout>>({});
// Ref to track latest selectedFileId for use in polling callbacks (avoids stale closure)
const selectedFileIdRef = useRef<string | null>(null);
// Cache for recognition results (keyed by task_id/file_id)
const resultsCache = useRef<Record<string, RecognitionResult>>({});
// Ref to prevent double loading on mount (React 18 Strict Mode / dependency changes)
const hasLoadedFiles = useRef(false);
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
useEffect(() => {
if (!initializing && user && !hasLoadedFiles.current) {
hasLoadedFiles.current = true;
loadFiles();
}
// Reset when user logs out
if (!user) {
hasLoadedFiles.current = false;
setFiles([]);
setSelectedFileId(null);
setCurrentPage(1);
setHasMore(false);
}
}, [initializing, user]);
useEffect(() => {
// Keep ref in sync with state for polling callbacks
selectedFileIdRef.current = selectedFileId;
if (selectedFileId) {
loadResult(selectedFileId);
} else {
setSelectedResult(null);
}
}, [selectedFileId]);
// Cleanup polling on unmount
useEffect(() => {
return () => {
Object.values(pollingIntervals.current).forEach(clearInterval);
pollingIntervals.current = {};
};
}, []);
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
// If modal is open, let the modal handle paste events to avoid double upload
if (showUploadModal) return;
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/') || items[i].type === 'application/pdf') {
const file = items[i].getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
handleUpload(files);
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [user, showUploadModal]);
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
mouseDownEvent.preventDefault();
setIsResizing(true);
}, []);
const stopResizing = useCallback(() => {
setIsResizing(false);
}, []);
const resize = useCallback(
(mouseMoveEvent: MouseEvent) => {
if (isResizing) {
const newWidth = mouseMoveEvent.clientX;
if (newWidth >= 280 && newWidth <= 400) {
setSidebarWidth(newWidth);
}
}
},
[isResizing]
);
useEffect(() => {
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', stopResizing);
return () => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', stopResizing);
};
}, [resize, stopResizing]);
// Convert API TaskHistoryItem to internal FileRecord format
const convertToFileRecord = (item: TaskHistoryItem): FileRecord => {
// Map numeric status enum to internal string status
const statusMap: Record<number, FileRecord['status']> = {
[TaskStatus.Pending]: 'pending',
[TaskStatus.Processing]: 'processing',
[TaskStatus.Completed]: 'completed',
[TaskStatus.Failed]: 'failed',
};
return {
id: item.task_id,
user_id: user?.id || null,
filename: item.file_name,
file_path: item.origin_url, // Already signed OSS URL for preview
file_type: 'image/jpeg', // Default, could be derived from file_name
file_size: 0, // Not provided by API
thumbnail_path: null,
status: statusMap[item.status] || 'pending',
created_at: item.created_at,
updated_at: item.created_at,
};
};
// Convert API TaskHistoryItem to internal RecognitionResult format
const convertToRecognitionResult = (item: TaskHistoryItem): RecognitionResult => {
return {
id: item.task_id,
file_id: item.task_id,
markdown_content: item.markdown,
latex_content: item.latex,
mathml_content: item.mathml,
mathml_word_content: item.mathml_mw,
rendered_image_path: item.image_blob || null,
created_at: item.created_at,
};
};
const loadFiles = async () => {
// Don't fetch if user is not logged in
if (!user) return;
setLoading(true);
try {
// Fetch first page only
const data = await uploadService.getTaskList('FORMULA', 1, PAGE_SIZE);
const taskList = data.task_list || [];
const total = data.total || 0;
// Update pagination state
setCurrentPage(1);
setHasMore(taskList.length < total);
if (taskList.length > 0) {
// Convert API data to internal format
const fileRecords = taskList.map(convertToFileRecord);
setFiles(fileRecords);
// Cache all results from the history (they already contain full data)
taskList.forEach(item => {
if (item.status === TaskStatus.Completed) {
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
}
});
// Auto-select first file if none selected
if (!selectedFileId) {
setSelectedFileId(fileRecords[0].id);
}
} else {
setFiles([]);
}
} catch (error) {
console.error('Error loading files:', error);
setFiles([]);
} finally {
setLoading(false);
}
};
const loadMoreFiles = async () => {
// Don't fetch if user is not logged in or already loading
if (!user || loadingMore || !hasMore) return;
setLoadingMore(true);
try {
const nextPage = currentPage + 1;
const data = await uploadService.getTaskList('FORMULA', nextPage, PAGE_SIZE);
const taskList = data.task_list || [];
const total = data.total || 0;
if (taskList.length > 0) {
const newFileRecords = taskList.map(convertToFileRecord);
// Use functional update to get correct total count
setFiles(prev => {
const newFiles = [...prev, ...newFileRecords];
// Check if there are more pages based on new total
setHasMore(newFiles.length < total);
return newFiles;
});
// Cache results
taskList.forEach(item => {
if (item.status === TaskStatus.Completed) {
resultsCache.current[item.task_id] = convertToRecognitionResult(item);
}
});
setCurrentPage(nextPage);
} else {
setHasMore(false);
}
} catch (error) {
console.error('Error loading more files:', error);
} finally {
setLoadingMore(false);
}
};
const loadResult = async (fileId: string) => {
try {
// First check the local cache (populated from history API)
const cachedResult = resultsCache.current[fileId];
if (cachedResult) {
setSelectedResult(cachedResult);
return;
}
// If not in cache, try to fetch from API (for tasks still processing)
try {
const result = await uploadService.getTaskResult(fileId);
if (result.status === TaskStatus.Completed) {
const recognitionResult: RecognitionResult = {
id: fileId,
file_id: fileId,
markdown_content: result.markdown,
latex_content: result.latex,
mathml_content: result.mathml,
mathml_word_content: result.mathml_mw,
rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString(),
};
resultsCache.current[fileId] = recognitionResult;
setSelectedResult(recognitionResult);
} else {
setSelectedResult(null);
}
} catch {
// Task might not exist or still processing
setSelectedResult(null);
}
} catch (error) {
console.error('Error loading result:', error);
setSelectedResult(null);
}
};
const startPolling = (taskNo: string, fileId: string) => {
if (pollingIntervals.current[taskNo]) return;
let attempts = 0;
const maxAttempts = 15;
pollingIntervals.current[taskNo] = setInterval(async () => {
attempts++;
try {
const result = await uploadService.getTaskResult(taskNo);
// Update status in file list
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) {
let status: FileRecord['status'] = 'processing';
if (result.status === TaskStatus.Completed) status = 'completed';
else if (result.status === TaskStatus.Failed) status = 'failed';
return { ...f, status };
}
return f;
}));
if (result.status === TaskStatus.Completed || result.status === TaskStatus.Failed) {
// Stop polling
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
if (result.status === TaskStatus.Completed) {
// ... (keep existing completion logic)
// Convert API result to internal RecognitionResult format
const recognitionResult: RecognitionResult = {
id: fileId,
file_id: fileId,
markdown_content: result.markdown,
latex_content: result.latex,
mathml_content: result.mathml,
mathml_word_content: result.mathml_mw,
rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString()
};
// Cache the result for later retrieval
resultsCache.current[fileId] = recognitionResult;
// Update UI if this file is currently selected
if (selectedFileIdRef.current === fileId) {
setSelectedResult(recognitionResult);
}
}
} else if (attempts >= maxAttempts) {
// Timeout: Max attempts reached
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
// Mark as failed in UI
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) {
return { ...f, status: 'failed' };
}
return f;
}));
alert('Task timeout: Recognition took too long.');
}
} catch (error) {
console.error('Polling error:', error);
// Don't stop polling immediately on network error, but maybe counting errors?
// For simplicity, keep polling until max attempts.
if (attempts >= maxAttempts) {
clearInterval(pollingIntervals.current[taskNo]);
delete pollingIntervals.current[taskNo];
setFiles(prevFiles => prevFiles.map(f => {
if (f.id === fileId) return { ...f, status: 'failed' };
return f;
}));
alert('Task timeout or network error.');
}
}
}, 1500); // Poll every 2 seconds
};
const handleUpload = async (uploadFiles: File[]) => {
setLoading(true);
try {
for (const file of uploadFiles) {
// 1. Upload file to OSS (or check duplicate)
const fileHash = await uploadService.calculateMD5(file);
const signatureData = await uploadService.uploadFile(file);
// 2. Create recognition task
const taskData = await uploadService.createRecognitionTask(
signatureData.path,
fileHash,
file.name
);
// Use task_no if available, or file ID from path, or fallback
const fileId = taskData.task_no || crypto.randomUUID();
const newFile: FileRecord = {
id: fileId,
user_id: user?.id || null,
filename: file.name,
// Use local object URL for immediate preview since OSS URL requires signing
file_path: URL.createObjectURL(file),
file_type: file.type,
file_size: file.size,
thumbnail_path: null,
status: 'processing',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Add new file to the list (prepend)
setFiles(prevFiles => [newFile, ...prevFiles]);
setSelectedFileId(newFile.id);
// Start polling for this task
if (taskData.task_no) {
startPolling(taskData.task_no, fileId);
}
}
} catch (error) {
console.error('Error uploading files:', error);
alert('Upload failed: ' + (error instanceof Error ? error.message : 'Unknown error'));
} finally {
setLoading(false);
}
};
if (initializing) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-gray-50 font-sans text-gray-900 overflow-hidden">
<Navbar />
<div className="flex-1 flex overflow-hidden">
{/* Left Sidebar */}
<div
ref={sidebarRef}
className="flex-shrink-0 bg-white border-r border-gray-200 relative transition-all duration-300 ease-in-out"
style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}
>
<LeftSidebar
files={files}
selectedFileId={selectedFileId}
onFileSelect={setSelectedFileId}
onUploadClick={() => setShowUploadModal(true)}
isCollapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
onUploadFiles={handleUpload}
hasMore={hasMore}
loadingMore={loadingMore}
onLoadMore={loadMoreFiles}
/>
{/* Resize Handle */}
{!sidebarCollapsed && (
<div
className="absolute right-0 top-0 w-1 h-full cursor-col-resize hover:bg-blue-400 z-50 opacity-0 hover:opacity-100 transition-opacity"
onMouseDown={startResizing}
/>
)}
</div>
{/* Middle Content: File Preview */}
<div className="flex-1 flex min-w-0 flex-col bg-gray-100/50">
<FilePreview file={selectedFile} />
</div>
{/* Right Result: Recognition Result */}
<div className="flex-1 flex min-w-0 flex-col bg-white">
<ResultPanel
result={selectedResult}
fileStatus={selectedFile?.status}
/>
</div>
{showUploadModal && (
<UploadModal
onClose={() => setShowUploadModal(false)}
onUpload={handleUpload}
/>
)}
{loading && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-8">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-900 font-medium">Processing...</p>
</div>
</div>
)}
</div>
{/* ICP Footer */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 py-2 px-4 text-center">
<a
href="https://beian.miit.gov.cn"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
ICP备2025152973号
</a>
</div>
</div>
);
}
export default App;

View File

@@ -0,0 +1,110 @@
import { useState } from 'react';
import { X } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
interface AuthModalProps {
onClose: () => void;
}
export default function AuthModal({ onClose }: AuthModalProps) {
const { signIn, signUp } = useAuth();
const [isSignUp, setIsSignUp] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const { error } = isSignUp
? await signUp(email, password)
: await signIn(email, password);
if (error) {
setError(error.message);
} else {
onClose();
}
} catch (err) {
setError('发生错误,请重试');
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900">
{isSignUp ? '注册账号' : '登录账号'}
</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="your@email.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
required
minLength={6}
/>
</div>
{error && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
: {error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait"
>
{isSignUp ? '注册' : '登录'}
</button>
</form>
<div className="mt-4 text-center">
<button
onClick={() => setIsSignUp(!isSignUp)}
className="text-sm text-blue-600 hover:text-blue-700"
>
{isSignUp ? '已有账号?去登录' : '没有账号?去注册'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useState } from 'react';
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, ChevronRight } from 'lucide-react';
import { RecognitionResult } from '../types';
interface ExportSidebarProps {
isOpen: boolean;
onClose: () => void;
result: RecognitionResult | null;
}
type ExportCategory = 'Code' | 'Image' | 'File';
interface ExportOption {
id: string;
label: string;
category: ExportCategory;
getContent: (result: RecognitionResult) => string | null;
isDownload?: boolean; // If true, triggers file download instead of copy
extension?: string;
}
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
const [copiedId, setCopiedId] = useState<string | null>(null);
if (!result) return null;
const exportOptions: ExportOption[] = [
// Code Category
{
id: 'markdown',
label: 'Markdown',
category: 'Code',
getContent: (r) => r.markdown_content
},
{
id: 'latex',
label: 'Latex',
category: 'Code',
getContent: (r) => r.latex_content
},
{
id: 'mathml',
label: 'MathML',
category: 'Code',
getContent: (r) => r.mathml_content
},
{
id: 'mathml_word',
label: 'Word MathML',
category: 'Code',
getContent: (r) => r.mathml_word_content
},
// Image Category
{
id: 'rendered_image',
label: 'Rendered Image',
category: 'Image',
getContent: (r) => r.rendered_image_path,
// User requested "Copy button" for Image.
// Special handling might be needed for actual image data copy,
// but standard clipboard writeText is for text.
// If we want to copy image data, we need to fetch it.
// For now, I'll stick to the pattern, but if it's a URL, copying the URL is the fallback.
// However, usually "Copy Image" implies the binary.
// Let's treat it as a special case in handleAction.
},
// File Category
{
id: 'docx',
label: 'DOCX',
category: 'File',
getContent: (r) => r.markdown_content, // Placeholder content for file conversion
isDownload: true,
extension: 'docx'
},
{
id: 'pdf',
label: 'PDF',
category: 'File',
getContent: (r) => r.markdown_content, // Placeholder
isDownload: true,
extension: 'pdf'
}
];
const handleAction = async (option: ExportOption) => {
const content = option.getContent(result);
if (!content) return;
try {
if (option.category === 'Image' && !option.isDownload) {
// Handle Image Copy
if (content.startsWith('http') || content.startsWith('blob:')) {
try {
const response = await fetch(content);
const blob = await response.blob();
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob
})
]);
} catch (err) {
console.error('Failed to copy image:', err);
// Fallback to copying URL
await navigator.clipboard.writeText(content);
}
} else {
await navigator.clipboard.writeText(content);
}
} else if (option.isDownload) {
// Simulate download
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `export.${option.extension}`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
// Standard Text Copy
await navigator.clipboard.writeText(content);
}
setCopiedId(option.id);
setTimeout(() => {
setCopiedId(null);
onClose(); // Auto close after action
}, 1000); // Small delay to show "Copied" state before closing
} catch (err) {
console.error('Action failed:', err);
}
};
const categories: { id: ExportCategory; icon: any; label: string }[] = [
{ id: 'Code', icon: Code2, label: 'Code' },
{ id: 'Image', icon: ImageIcon, label: 'Image' },
{ id: 'File', icon: FileText, label: 'File' },
];
return (
<>
{/* Backdrop */}
{isOpen && (
<div
className="absolute inset-0 bg-black/20 backdrop-blur-[1px] z-40 transition-opacity"
onClick={onClose}
/>
)}
{/* Sidebar Panel */}
<div
className={`
absolute top-0 right-0 bottom-0 w-80 bg-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out border-l border-gray-100 flex flex-col
${isOpen ? 'translate-x-0' : 'translate-x-full'}
`}
>
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 shrink-0">
<h2 className="text-lg font-bold text-gray-900">Export</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X size={20} className="text-gray-500" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-8">
{categories.map((category) => (
<div key={category.id} className="space-y-3">
<div className="flex items-center gap-2 text-gray-400 px-1">
<category.icon size={16} />
<span className="text-xs font-semibold uppercase tracking-wider">{category.label}</span>
</div>
<div className="space-y-2">
{exportOptions
.filter(opt => opt.category === category.id)
.map(option => (
<button
key={option.id}
onClick={() => handleAction(option)}
className="w-full flex items-center justify-between p-3 bg-gray-50 hover:bg-blue-50 hover:text-blue-600 border border-transparent hover:border-blue-200 rounded-lg group transition-all text-left"
>
<span className="text-sm font-medium text-gray-700 group-hover:text-blue-700">
{option.label}
</span>
<div className="text-gray-400 group-hover:text-blue-600">
{copiedId === option.id ? (
<Check size={16} className="text-green-500" />
) : option.isDownload ? (
<Download size={16} />
) : (
<Copy size={16} className="opacity-0 group-hover:opacity-100 transition-opacity" />
)}
</div>
</button>
))
}
</div>
</div>
))}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { ChevronLeft, ChevronRight, File as FileIcon, MinusCircle, PlusCircle } from 'lucide-react';
import { FileRecord } from '../types';
interface FilePreviewProps {
file: FileRecord | null;
}
export default function FilePreview({ file }: FilePreviewProps) {
const [zoom, setZoom] = useState(100);
const [page, setPage] = useState(1);
const totalPages = 1;
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 10, 200));
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 10, 50));
if (!file) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-white p-8 text-center border border-white border-solid">
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
<FileIcon size={48} className="text-gray-900" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Upload file</h3>
<p className="text-gray-500 max-w-xs">
Click, Drop, or Paste a file to start parsing
</p>
</div>
);
}
return (
<div className="flex flex-col h-full bg-gray-100/50">
{/* Top Bar */}
<div className="h-16 flex items-center justify-between px-6 bg-white border-b border-gray-200 z-10 relative">
<div className="flex items-center gap-3 overflow-hidden z-20">
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
<FileIcon size={18} />
</div>
<h2 className="text-sm font-semibold text-gray-900 truncate max-w-[200px]" title={file.filename}>
{file.filename}
</h2>
</div>
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 flex items-center gap-4 z-10">
{totalPages >= 1 && (
<div className="flex items-center gap-1">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:shadow-none transition-all"
>
<ChevronLeft size={16} />
</button>
<span className="text-xs font-medium text-gray-600 px-1 select-none min-w-[3rem] text-center">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:shadow-none transition-all"
>
<ChevronRight size={16} />
</button>
</div>
)}
<div className="flex items-center gap-1">
<button
onClick={handleZoomOut}
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
title="缩小"
>
<MinusCircle size={16} />
</button>
<span className="text-xs font-medium text-gray-600 min-w-[2.5rem] text-center select-none">
{zoom}%
</span>
<button
onClick={handleZoomIn}
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
title="放大"
>
<PlusCircle size={16} />
</button>
</div>
</div>
</div>
{/* Preview Content */}
<div className="flex-1 overflow-auto p-8 relative custom-scrollbar flex items-center justify-center border-r border-gray-200">
<div
className="bg-white shadow-2xl shadow-gray-200/50 transition-transform duration-200 ease-out origin-center max-w-full max-h-full flex items-center justify-center"
style={{ transform: `scale(${zoom / 100})` }}
>
{file.file_type === 'application/pdf' ? (
// Placeholder for PDF rendering - ideally use react-pdf or similar
<div className="w-[595px] h-[842px] flex items-center justify-center bg-gray-50 border border-gray-100">
<p className="text-gray-400">PDF Preview Not Implemented</p>
</div>
) : (
<img
src={file.file_path || 'https://images.pexels.com/photos/326514/pexels-photo-326514.jpeg?auto=compress&cs=tinysrgb&w=800'}
alt={file.filename}
className="max-w-full max-h-full object-contain block"
draggable={false}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,310 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, Settings, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { FileRecord } from '../types';
import AuthModal from './AuthModal';
interface LeftSidebarProps {
files: FileRecord[];
selectedFileId: string | null;
onFileSelect: (fileId: string) => void;
onUploadClick: () => void;
isCollapsed: boolean;
onToggleCollapse: () => void;
onUploadFiles: (files: File[]) => void;
hasMore: boolean;
loadingMore: boolean;
onLoadMore: () => void;
}
export default function LeftSidebar({
files,
selectedFileId,
onFileSelect,
onUploadClick,
isCollapsed,
onToggleCollapse,
onUploadFiles,
hasMore,
loadingMore,
onLoadMore,
}: LeftSidebarProps) {
const { user, signOut } = useAuth();
const [showAuthModal, setShowAuthModal] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Handle scroll to load more
const handleScroll = useCallback(() => {
if (!listRef.current || loadingMore || !hasMore) return;
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
// Trigger load more when scrolled to bottom (with 50px threshold)
if (scrollHeight - scrollTop - clientHeight < 50) {
onLoadMore();
}
}, [loadingMore, hasMore, onLoadMore]);
// Auto-load more if content doesn't fill the container
useEffect(() => {
if (!hasMore || loadingMore || !user || files.length === 0) return;
// Use requestAnimationFrame to ensure DOM has been updated
const checkAndLoadMore = () => {
requestAnimationFrame(() => {
if (!listRef.current) return;
const { scrollHeight, clientHeight } = listRef.current;
// If content doesn't fill container and there's more data, load more
if (scrollHeight <= clientHeight && hasMore && !loadingMore) {
onLoadMore();
}
});
};
// Small delay to ensure DOM is fully rendered
const timer = setTimeout(checkAndLoadMore, 100);
return () => clearTimeout(timer);
}, [files.length, hasMore, loadingMore, onLoadMore, user]);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
const validFiles = droppedFiles.filter(file =>
file.type.startsWith('image/') || file.type === 'application/pdf'
);
if (validFiles.length > 0) {
onUploadFiles(validFiles);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
onUploadFiles(Array.from(e.target.files));
}
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
if (isCollapsed) {
return (
<div className="h-full flex flex-col items-center py-4 bg-gray-50/50">
<button
onClick={onToggleCollapse}
className="p-2 mb-6 text-gray-500 hover:text-gray-900 hover:bg-gray-200 rounded-md transition-colors"
>
<ChevronRight size={20} />
</button>
<button
onClick={onUploadClick}
className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20 transition-all mb-6"
title="Upload"
>
<Upload size={20} />
</button>
<div className="flex-1 w-full flex flex-col items-center gap-4">
<button className="p-2 text-gray-400 hover:text-gray-900 transition-colors" title="History">
<History size={20} />
</button>
</div>
<button
onClick={() => !user && setShowAuthModal(true)}
className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto"
title={user ? 'Signed In' : 'Sign In'}
>
<LogIn size={20} />
</button>
</div>
);
}
return (
<>
<div className="flex flex-col h-full bg-white">
{/* Top Area: Title & Upload */}
<div className="p-6 pb-4">
<div className="flex items-start justify-between mb-6">
<div>
<h2 className="text-lg font-bold text-gray-900 leading-tight">Formula Recognize</h2>
<p className="text-xs text-gray-500 mt-1">Support handwriting and printed formulas</p>
</div>
<button
onClick={onToggleCollapse}
className="p-1.5 -mr-1.5 text-gray-400 hover:text-gray-900 hover:bg-gray-100 rounded-md transition-colors"
>
<ChevronLeft size={18} />
</button>
</div>
<div className="mb-2">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group
${isDragging
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-blue-400 hover:bg-gray-50'
}
`}
>
<input
type="file"
ref={fileInputRef}
className="hidden"
onChange={handleFileChange}
accept="image/*"
multiple
/>
<div className="w-12 h-12 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<Upload size={24} />
</div>
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-gray-400">
<div className="flex items-center gap-1">
<MousePointerClick className="w-3.5 h-3.5" />
<span>Click</span>
</div>
<div className="flex items-center gap-1">
<FileUp className="w-3.5 h-3.5" />
<span>Drop</span>
</div>
<div className="flex items-center gap-1">
<ClipboardPaste className="w-3.5 h-3.5" />
<span>Paste</span>
</div>
</div>
</div>
</div>
</div>
{/* Middle Area: History */}
<div className="flex-1 overflow-hidden flex flex-col px-4">
<div className="flex items-center gap-2 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
<Clock size={14} />
<span>History</span>
</div>
<div
ref={listRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto space-y-1 pr-2 -mr-2 custom-scrollbar"
>
{!user ? (
<div className="text-center py-12 text-gray-400 text-sm">
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
Please login to view history
</div>
) : files.length === 0 ? (
<div className="text-center py-12 text-gray-400 text-sm">
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
No history records
</div>
) : (
<>
{files.map((file) => (
<button
key={file.id}
onClick={() => onFileSelect(file.id)}
className={`w-full p-3 rounded-lg text-left transition-all border group relative ${selectedFileId === file.id
? 'bg-blue-50 border-blue-200 shadow-sm'
: 'bg-white border-transparent hover:bg-gray-50 hover:border-gray-100'
}`}
>
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg ${selectedFileId === file.id ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-500 group-hover:bg-gray-200'}`}>
<FileText size={18} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium truncate ${selectedFileId === file.id ? 'text-blue-900' : 'text-gray-700'}`}>
{file.filename}
</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-gray-400">
{new Date(file.created_at).toLocaleDateString()}
</span>
<span className={`w-1.5 h-1.5 rounded-full ${file.status === 'completed' ? 'bg-green-500' :
file.status === 'processing' ? 'bg-yellow-500' : 'bg-red-500'
}`} />
</div>
</div>
</div>
</button>
))}
{/* Loading more indicator */}
{loadingMore && (
<div className="flex items-center justify-center py-3 text-gray-400">
<Loader2 size={18} className="animate-spin" />
<span className="ml-2 text-xs">Loading...</span>
</div>
)}
{/* End of list indicator */}
{!hasMore && files.length > 0 && (
<div className="text-center py-3 text-xs text-gray-400">
No more records
</div>
)}
</>
)}
</div>
</div>
{/* Bottom Area: User/Login */}
<div className="p-4 border-t border-gray-100 bg-gray-50/30">
{user ? (
<div className="flex items-center gap-3 p-2 rounded-lg bg-white border border-gray-100 shadow-sm">
<div className="w-8 h-8 bg-gray-900 rounded-full flex items-center justify-center flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 12C14.21 12 16 10.21 16 8C16 5.79 14.21 4 12 4C9.79 4 8 5.79 8 8C8 10.21 9.79 12 12 12ZM12 14C9.33 14 4 15.34 4 18V20H20V18C20 15.34 14.67 14 12 14Z" fill="white" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{user.email}</p>
</div>
<button
onClick={() => signOut()}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Logout"
>
<LogOut size={16} />
</button>
</div>
) : (
<button
onClick={() => setShowAuthModal(true)}
className="w-full py-2.5 px-4 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors flex items-center justify-center gap-2 text-sm font-medium shadow-lg shadow-gray-900/10"
>
<LogIn size={18} />
Login / Register
</button>
)}
</div>
</div>
{showAuthModal && (
<AuthModal onClose={() => setShowAuthModal(false)} />
)}
</>
);
}

148
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,148 @@
import { useState, useRef, useEffect } from 'react';
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X } from 'lucide-react';
export default function Navbar() {
const [showContact, setShowContact] = useState(false);
const [showReward, setShowReward] = useState(false);
const [copied, setCopied] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const handleCopyQQ = async () => {
await navigator.clipboard.writeText('1018282100');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowContact(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0 z-[60] relative">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<span className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white font-serif italic text-lg shadow-blue-600/30 shadow-md">
T
</span>
<span className="text-xl font-bold text-gray-900 tracking-tight">
TexPixel
</span>
</div>
{/* Right: Reward & Contact Buttons */}
<div className="flex items-center gap-3">
{/* Reward Button */}
<div className="relative">
<button
onClick={() => setShowReward(!showReward)}
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-rose-500 to-pink-500 hover:from-rose-600 hover:to-pink-600 rounded-lg text-white text-sm font-medium transition-all shadow-sm hover:shadow-md"
>
<Heart size={14} className="fill-white" />
<span></span>
</button>
{/* Reward Modal */}
{showReward && (
<div
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[70] p-4"
onClick={() => setShowReward(false)}
>
<div
className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6 animate-in fade-in zoom-in-95 duration-200"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<span className="text-lg font-bold text-gray-900"></span>
<button
onClick={() => setShowReward(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} className="text-gray-500" />
</button>
</div>
<div className="flex flex-col items-center">
<img
src="https://cdn.texpixel.com/public/rewardcode.png"
alt="微信赞赏码"
className="w-64 h-64 object-contain rounded-lg shadow-sm"
/>
<p className="text-sm text-gray-500 text-center mt-4">
<br />
<span className="text-xs text-gray-400 mt-1 block"></span>
</p>
</div>
</div>
</div>
)}
</div>
{/* Contact Button with Dropdown */}
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowContact(!showContact)}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 text-sm font-medium transition-colors"
>
<MessageCircle size={14} />
<span>Contact Us</span>
<ChevronDown
size={14}
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
/>
</button>
{/* Contact Dropdown List */}
{showContact && (
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-2 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<a
href="mailto:yogecoder@gmail.com"
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-colors"
>
<div className="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<Mail size={16} className="text-blue-600" />
</div>
<div>
<div className="text-xs text-gray-500">Email</div>
<div className="text-sm font-medium text-gray-900">yogecoder@gmail.com</div>
</div>
</a>
<div
className={`flex items-center gap-3 px-4 py-3 hover:bg-gray-50 transition-all cursor-pointer ${copied ? 'bg-green-50' : ''}`}
onClick={handleCopyQQ}
>
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-colors ${copied ? 'bg-green-500' : 'bg-green-100'}`}>
{copied ? (
<Check size={16} className="text-white" />
) : (
<Users size={16} className="text-green-600" />
)}
</div>
<div>
<div className={`text-xs transition-colors ${copied ? 'text-green-600 font-medium' : 'text-gray-500'}`}>
{copied ? 'Copied!' : 'QQ Group (Click to Copy)'}
</div>
<div className="text-sm font-medium text-gray-900">1018282100</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react';
import { Download, Code2, Check, Copy } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { RecognitionResult } from '../types';
import ExportSidebar from './ExportSidebar';
interface ResultPanelProps {
result: RecognitionResult | null;
fileStatus?: 'pending' | 'processing' | 'completed' | 'failed';
}
/**
* Preprocess LaTeX content to fix common formatting issues
* that may cause KaTeX rendering to fail
*/
function preprocessLatex(content: string): string {
if (!content) return '';
let processed = content;
// Fix mixed display math delimiters: $$\[...\]$$ -> $$...$$
// This handles cases where \[ \] are incorrectly nested inside $$ $$
// Note: In JS replace(), $$ means "insert one $", so we need $$$$ to insert $$
processed = processed.replace(/\$\$\s*\\\[/g, '$$$$');
processed = processed.replace(/\\\]\s*\$\$/g, '$$$$');
// Also fix standalone \[ and \] that should be $$ for remark-math compatibility
// Replace \[ with $$ at the start of display math (not inside other math)
processed = processed.replace(/(?<!\$)\\\[(?!\$)/g, '$$$$');
processed = processed.replace(/(?<!\$)\\\](?!\$)/g, '$$$$');
// IMPORTANT: remark-math requires $$ to be on its own line for block math
// Ensure $$ followed by \begin has a newline: $$\begin -> $$\n\begin
processed = processed.replace(/\$\$([^\n$])/g, '$$$$\n$1');
// Ensure content followed by $$ has a newline: content$$ -> content\n$$
processed = processed.replace(/([^\n$])\$\$/g, '$1\n$$$$');
// Fix: \left \{ -> \left\{ (remove space between \left and delimiter)
processed = processed.replace(/\\left\s+\\/g, '\\left\\');
processed = processed.replace(/\\left\s+\{/g, '\\left\\{');
processed = processed.replace(/\\left\s+\[/g, '\\left[');
processed = processed.replace(/\\left\s+\(/g, '\\left(');
// Fix: \right \} -> \right\} (remove space between \right and delimiter)
processed = processed.replace(/\\right\s+\\/g, '\\right\\');
processed = processed.replace(/\\right\s+\}/g, '\\right\\}');
processed = processed.replace(/\\right\s+\]/g, '\\right]');
processed = processed.replace(/\\right\s+\)/g, '\\right)');
// Fix: \begin{matrix} with mismatched \left/\right -> use \begin{array}
// This is a more complex issue that requires proper \left/\right pairing
// For now, we'll try to convert problematic patterns
// Replace \left( ... \right. text \right) pattern with ( ... \right. text )
// This fixes the common mispairing issue
processed = processed.replace(/\\left\(([^)]*?)\\right\.\s*(\\text\{[^}]*\})\s*\\right\)/g, '($1\\right. $2)');
return processed;
}
export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
const [isExportSidebarOpen, setIsExportSidebarOpen] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
if (result?.markdown_content) {
await navigator.clipboard.writeText(result.markdown_content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (!result) {
if (fileStatus === 'processing' || fileStatus === 'pending') {
return (
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mb-6"></div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
{fileStatus === 'pending' ? 'Waiting in queue...' : 'Analyzing...'}
</h3>
<p className="text-gray-500 max-w-sm">
{fileStatus === 'pending'
? 'Your file is in the queue, please wait.'
: 'Texpixel is processing your file, this may take a moment.'}
</p>
</div>
);
}
return (
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8">
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
<Code2 size={48} className="text-gray-900" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Waiting for recognition result</h3>
<p className="text-gray-500 max-w-sm">
After uploading the file, Texpixel will automatically recognize and display the result here
</p>
</div>
);
}
return (
<div className="flex flex-col h-full bg-white relative overflow-hidden">
{/* Top Header */}
<div className="h-16 px-6 border-b border-gray-200 flex items-center justify-between bg-white shrink-0 z-10">
<div className="flex items-center gap-4">
<h2 className="text-lg font-bold text-gray-900">Markdown</h2>
</div>
<button
onClick={() => setIsExportSidebarOpen(true)}
className={`px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors flex items-center gap-2 shadow-sm ${isExportSidebarOpen ? 'opacity-0 pointer-events-none' : ''}`}
>
<Download size={16} />
Export
</button>
</div>
{/* Content Area - Rendered Markdown */}
{
<div className="flex-1 overflow-auto p-8 custom-scrollbar flex justify-center">
<div className="prose prose-blue max-w-3xl w-full prose-headings:font-bold prose-h1:text-2xl prose-h2:text-xl prose-p:leading-relaxed prose-pre:bg-gray-50 prose-pre:border prose-pre:border-gray-100 [&_.katex-display]:text-center">
<ReactMarkdown
remarkPlugins={[remarkMath]}
rehypePlugins={[[rehypeKatex, {
throwOnError: false,
errorColor: '#cc0000',
strict: false
}]]}
>
{preprocessLatex(result.markdown_content || '')}
</ReactMarkdown>
</div>
</div>
}
<ExportSidebar
isOpen={isExportSidebarOpen}
onClose={() => setIsExportSidebarOpen(false)}
result={result}
/>
</div >
);
}

View File

@@ -0,0 +1,133 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { Upload, X, MousePointerClick, FileUp, ClipboardPaste } from 'lucide-react';
interface UploadModalProps {
onClose: () => void;
onUpload: (files: File[]) => void;
}
export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
const files: File[] = [];
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/') || items[i].type === 'application/pdf') {
const file = items[i].getAsFile();
if (file) files.push(file);
}
}
if (files.length > 0) {
onUpload(files);
onClose();
}
};
document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste);
}, [onUpload, onClose]);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const files = Array.from(e.dataTransfer.files).filter((file) =>
file.type.startsWith('image/') || file.type === 'application/pdf'
);
if (files.length > 0) {
onUpload(files);
onClose();
}
}, [onUpload, onClose]);
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const files = Array.from(e.target.files).filter((file) =>
file.type.startsWith('image/') || file.type === 'application/pdf'
);
if (files.length > 0) {
onUpload(files);
onClose();
}
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900"></h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<X size={20} />
</button>
</div>
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors cursor-pointer group ${dragActive
? 'border-blue-500 bg-blue-50'
: 'border-gray-300 hover:border-blue-400 hover:bg-gray-50'
}`}
>
<div className="w-16 h-16 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
<Upload size={32} />
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept="image/*,application/pdf"
onChange={handleFileInput}
className="hidden"
/>
<p className="text-xs text-gray-500 mt-4">
Support JPG, PNG format
</p>
<div className="flex items-center justify-center gap-4 mt-6 text-xs text-gray-400">
<div className="flex items-center gap-1">
<MousePointerClick className="w-3.5 h-3.5" />
<span>Click</span>
</div>
<div className="flex items-center gap-1">
<FileUp className="w-3.5 h-3.5" />
<span>Drop</span>
</div>
<div className="flex items-center gap-1">
<ClipboardPaste className="w-3.5 h-3.5" />
<span>Paste</span>
</div>
</div>
</div>
</div>
</div>
);
}

33
src/config/env.ts Normal file
View File

@@ -0,0 +1,33 @@
/**
* 环境配置
* 根据 VITE_ENV 环境变量自动切换测试/生产环境
*/
type Environment = 'development' | 'production';
interface EnvConfig {
apiBaseUrl: string;
env: Environment;
}
const configs: Record<Environment, EnvConfig> = {
development: {
apiBaseUrl: 'https://cloud.texpixel.com:10443/doc_ai/v1',
env: 'development',
},
production: {
apiBaseUrl: 'https://api.texpixel.com/doc_ai/v1',
env: 'production',
},
};
// 从 Vite 环境变量获取当前环境,默认为 development
const currentEnv = (import.meta.env.VITE_ENV as Environment) || 'development';
export const env = configs[currentEnv] || configs.development;
// 便捷导出
export const API_BASE_URL = env.apiBaseUrl;
export const IS_DEV = env.env === 'development';
export const IS_PROD = env.env === 'production';

View File

@@ -0,0 +1,130 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { authService } from '../lib/authService';
import { ApiErrorMessages } from '../types/api';
import type { UserInfo } from '../types/api';
interface AuthContextType {
user: UserInfo | null;
token: string | null;
loading: boolean;
initializing: boolean; // 新增初始化状态
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
signOut: () => Promise<void>;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
// 直接在 useState 初始化函数中同步恢复会话
const [user, setUser] = useState<UserInfo | null>(() => {
try {
const session = authService.restoreSession();
return session ? session.user : null;
} catch {
return null;
}
});
const [token, setToken] = useState<string | null>(() => {
try {
const session = authService.restoreSession();
return session ? session.token : null;
} catch {
return null;
}
});
const [loading, setLoading] = useState(false);
const [initializing, setInitializing] = useState(false); // 不需要初始化过程了,因为是同步的
// 不再需要 useEffect 里的 restoreSession
/**
* 从错误对象中提取用户友好的错误消息
*/
const getErrorMessage = (error: unknown, fallback: string): string => {
// 检查是否是 ApiError通过 code 属性判断,避免 instanceof 在热更新时失效)
if (error && typeof error === 'object' && 'code' in error) {
const apiError = error as { code: number; message: string };
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
};
/**
* 登录
*/
const signIn = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
const result = await authService.login({ email, password });
setUser(result.user);
setToken(result.token);
return { error: null };
} catch (error) {
const message = getErrorMessage(error, '登录失败');
return { error: new Error(message) };
} finally {
setLoading(false);
}
}, []);
/**
* 注册
*/
const signUp = useCallback(async (email: string, password: string) => {
setLoading(true);
try {
const result = await authService.register({ email, password });
setUser(result.user);
setToken(result.token);
return { error: null };
} catch (error) {
const message = getErrorMessage(error, '注册失败');
return { error: new Error(message) };
} finally {
setLoading(false);
}
}, []);
/**
* 登出
*/
const signOut = useCallback(async () => {
setLoading(true);
try {
authService.logout();
setUser(null);
setToken(null);
} finally {
setLoading(false);
}
}, []);
const value: AuthContextType = {
user,
token,
loading,
initializing,
signIn,
signUp,
signOut,
isAuthenticated: !!user && !!token,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}

27
src/index.css Normal file
View File

@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer utilities {
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.3);
border-radius: 20px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.5);
}
}
body {
@apply antialiased text-gray-900 bg-gray-50;
}

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('文件上传失败');
}
}
};

13
src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { AuthProvider } from './contexts/AuthContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>
);

149
src/types/api.ts Normal file
View File

@@ -0,0 +1,149 @@
// API 响应类型定义
// 通用 API 响应结构
export interface ApiResponse<T = unknown> {
request_id: string;
code: number;
message: string;
data: T | null;
}
// 登录/注册成功响应数据
export interface AuthData {
token: string;
expires_at: number;
}
// 用户信息(从 token 解析或 API 返回)
export interface UserInfo {
user_id: number;
email: string;
exp: number;
iat: number;
// 兼容字段,方便代码使用
id: string;
}
// 登录请求参数
export interface LoginRequest {
email: string;
password: string;
}
// 注册请求参数
export interface RegisterRequest {
email: string;
password: string;
}
// OSS 签名响应数据
export interface OssSignatureData {
path: string;
repeat: boolean;
sign_url: string;
}
// OSS 签名请求参数
export interface OssSignatureRequest {
file_hash: string;
file_name: string;
}
// 创建识别任务请求参数
export interface RecognitionTaskRequest {
file_url: string;
file_hash: string;
file_name: string;
task_type: 'FORMULA'; // 目前只支持公式识别
}
// 识别任务基础响应数据
export interface RecognitionTaskData {
task_no: string;
status: number;
}
// 识别任务状态枚举
export enum TaskStatus {
Pending = 0,
Processing = 1,
Completed = 2,
Failed = 3,
}
// 识别任务详细结果
export interface RecognitionResultData {
task_no: string;
status: TaskStatus;
count: number;
latex: string;
markdown: string;
mathml: string;
mathml_mw: string; // MathML for Word
image_blob: string; // Base64 or URL? assuming string content
docx_url: string;
pdf_url: string;
}
// 任务历史记录项
export interface TaskHistoryItem {
task_id: string;
file_name: string;
status: TaskStatus; // 0=Pending, 1=Processing, 2=Completed, 3=Failed
origin_url: string; // 已签名的 OSS URL可直接用于预览
task_type: 'FORMULA';
created_at: string;
latex: string;
markdown: string;
mathml: string;
mathml_mw: string;
image_blob: string;
docx_url: string;
pdf_url: string;
}
// 任务历史记录列表响应
export interface TaskListData {
task_list: TaskHistoryItem[];
total: number;
}
// API 错误码定义
export enum ApiErrorCode {
// 通用错误码
SUCCESS = 200,
PARAM_ERROR = 400,
UNAUTHORIZED = 401,
FORBIDDEN = 403,
NOT_FOUND = 404,
INVALID_STATUS = 405,
DB_ERROR = 500,
SYSTEM_ERROR = 501,
// 业务错误码
TASK_NOT_COMPLETE = 1001,
RECORD_REPEAT = 1002,
SMS_CODE_ERROR = 1003,
EMAIL_EXISTS = 1004,
EMAIL_NOT_FOUND = 1005,
PASSWORD_MISMATCH = 1006,
}
// API 错误码消息映射(中文)
export const ApiErrorMessages: Record<number, string> = {
// 通用错误码
[ApiErrorCode.SUCCESS]: '操作成功',
[ApiErrorCode.PARAM_ERROR]: '参数错误',
[ApiErrorCode.UNAUTHORIZED]: '未授权,请先登录',
[ApiErrorCode.FORBIDDEN]: '无权限访问',
[ApiErrorCode.NOT_FOUND]: '资源不存在',
[ApiErrorCode.INVALID_STATUS]: '状态无效',
[ApiErrorCode.DB_ERROR]: '服务器错误,请稍后重试',
[ApiErrorCode.SYSTEM_ERROR]: '系统错误,请稍后重试',
// 业务错误码
[ApiErrorCode.TASK_NOT_COMPLETE]: '任务未完成',
[ApiErrorCode.RECORD_REPEAT]: '记录重复',
[ApiErrorCode.SMS_CODE_ERROR]: '验证码错误',
[ApiErrorCode.EMAIL_EXISTS]: '该邮箱已注册',
[ApiErrorCode.EMAIL_NOT_FOUND]: '该邮箱未注册',
[ApiErrorCode.PASSWORD_MISMATCH]: '密码错误',
};

35
src/types/index.ts Normal file
View File

@@ -0,0 +1,35 @@
export interface FileRecord {
id: string;
user_id: string | null;
filename: string;
file_path: string;
file_type: string;
file_size: number;
thumbnail_path: string | null;
status: 'pending' | 'processing' | 'completed' | 'failed';
created_at: string;
updated_at: string;
}
export interface RecognitionResult {
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;
}
export type ExportFormat =
| 'markdown'
| 'latex'
| 'mathml'
| 'mathml-word'
| 'image'
| 'docx'
| 'pdf'
| 'xlsx';
export type FormatCategory = 'code' | 'image' | 'file';

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />