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

3
.bolt/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

5
.bolt/prompt Normal file
View File

@@ -0,0 +1,5 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
/dist

1
README.md Normal file
View File

@@ -0,0 +1 @@
doc_ai-frontend

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

40
index.html Normal file
View File

@@ -0,0 +1,40 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>⚡️ TexPixel - 公式识别工具</title>
<!-- SEO Meta Tags -->
<meta name="description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
<meta name="keywords"
content="公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,handwriting,formula,recognition,ocr,handwriting,recognition,math,handwriting,free" />
<meta name="author" content="TexPixel Team" />
<meta name="robots" content="index, follow" />
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="TexPixel - 公式识别工具" />
<meta property="og:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://texpixel.com/" />
<meta property="og:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="TexPixel - 公式识别工具" />
<meta name="twitter:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别。" />
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
<!-- baidu -->
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5745
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
package.json Normal file
View File

@@ -0,0 +1,47 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "VITE_ENV=development vite build",
"build:prod": "VITE_ENV=production vite build",
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.app.json"
},
"dependencies": {
"@supabase/supabase-js": "^2.57.4",
"@types/spark-md5": "^3.0.5",
"browser-image-compression": "^2.0.2",
"clsx": "^2.1.1",
"katex": "^0.16.27",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"rehype-katex": "^7.0.1",
"remark-math": "^6.0.0",
"spark-md5": "^3.0.2",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@tailwindcss/typography": "^0.5.19",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

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" />

10
tailwind.config.js Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/typography'),
],
};

24
tsconfig.app.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

26
vite.config.ts Normal file
View File

@@ -0,0 +1,26 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
build: {
rollupOptions: {
output: {
manualChunks: {
// React 核心
'vendor-react': ['react', 'react-dom'],
// Markdown 相关
'vendor-markdown': ['react-markdown', 'remark-math', 'rehype-katex'],
// KaTeX 单独分离(体积最大)
'vendor-katex': ['katex'],
// Supabase
'vendor-supabase': ['@supabase/supabase-js'],
},
},
},
},
});