feat: add reward code
This commit is contained in:
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
||||
5
.bolt/prompt
Normal file
5
.bolt/prompt
Normal 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
24
.gitignore
vendored
Normal 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
|
||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal 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
40
index.html
Normal 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
5745
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
package.json
Normal file
47
package.json
Normal 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
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
512
src/App.tsx
Normal file
512
src/App.tsx
Normal 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;
|
||||
110
src/components/AuthModal.tsx
Normal file
110
src/components/AuthModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/components/ExportSidebar.tsx
Normal file
208
src/components/ExportSidebar.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
112
src/components/FilePreview.tsx
Normal file
112
src/components/FilePreview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
src/components/LeftSidebar.tsx
Normal file
310
src/components/LeftSidebar.tsx
Normal 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
148
src/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
148
src/components/ResultPanel.tsx
Normal file
148
src/components/ResultPanel.tsx
Normal 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 >
|
||||
);
|
||||
}
|
||||
133
src/components/UploadModal.tsx
Normal file
133
src/components/UploadModal.tsx
Normal 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
33
src/config/env.ts
Normal 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';
|
||||
|
||||
130
src/contexts/AuthContext.tsx
Normal file
130
src/contexts/AuthContext.tsx
Normal 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
27
src/index.css
Normal 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
156
src/lib/api.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* HTTP 客户端封装
|
||||
* 统一处理请求/响应拦截、错误处理、Token 管理
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from '../config/env';
|
||||
import type { ApiResponse } from '../types/api';
|
||||
|
||||
// Token 存储键名
|
||||
const TOKEN_KEY = 'texpixel_token';
|
||||
const TOKEN_EXPIRES_KEY = 'texpixel_token_expires';
|
||||
const USER_EMAIL_KEY = 'texpixel_user_email';
|
||||
|
||||
/**
|
||||
* Token 管理工具
|
||||
*/
|
||||
export const tokenManager = {
|
||||
getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
},
|
||||
|
||||
setToken(token: string, expiresAt: number, email?: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(TOKEN_EXPIRES_KEY, expiresAt.toString());
|
||||
if (email) {
|
||||
localStorage.setItem(USER_EMAIL_KEY, email);
|
||||
}
|
||||
},
|
||||
|
||||
removeToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(TOKEN_EXPIRES_KEY);
|
||||
localStorage.removeItem(USER_EMAIL_KEY);
|
||||
},
|
||||
|
||||
getEmail(): string | null {
|
||||
return localStorage.getItem(USER_EMAIL_KEY);
|
||||
},
|
||||
|
||||
isTokenValid(): boolean {
|
||||
const token = this.getToken();
|
||||
const expiresAt = localStorage.getItem(TOKEN_EXPIRES_KEY);
|
||||
|
||||
if (!token || !expiresAt) return false;
|
||||
|
||||
// 提前 5 分钟判定为过期
|
||||
const expiresTimestamp = parseInt(expiresAt, 10) * 1000;
|
||||
return Date.now() < expiresTimestamp - 5 * 60 * 1000;
|
||||
},
|
||||
|
||||
getExpiresAt(): number | null {
|
||||
const expiresAt = localStorage.getItem(TOKEN_EXPIRES_KEY);
|
||||
return expiresAt ? parseInt(expiresAt, 10) : null;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义 API 错误类
|
||||
*/
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public code: number,
|
||||
message: string,
|
||||
public requestId?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求配置类型
|
||||
*/
|
||||
interface RequestConfig extends RequestInit {
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发起 HTTP 请求
|
||||
*/
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
config: RequestConfig = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const { skipAuth = false, headers: customHeaders, ...restConfig } = config;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...customHeaders,
|
||||
};
|
||||
|
||||
// 自动添加 Authorization header
|
||||
if (!skipAuth) {
|
||||
const token = tokenManager.getToken();
|
||||
if (token) {
|
||||
(headers as Record<string, string>)['Authorization'] = token;
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...restConfig,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
// 统一处理业务错误
|
||||
if (data.code !== 200) {
|
||||
throw new ApiError(data.code, data.message, data.request_id);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 网络错误或其他错误
|
||||
throw new ApiError(-1, '网络错误,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 方法快捷函数
|
||||
*/
|
||||
export const http = {
|
||||
get<T>(endpoint: string, config?: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return request<T>(endpoint, { ...config, method: 'GET' });
|
||||
},
|
||||
|
||||
post<T>(endpoint: string, body?: unknown, config?: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
put<T>(endpoint: string, body?: unknown, config?: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
delete<T>(endpoint: string, config?: RequestConfig): Promise<ApiResponse<T>> {
|
||||
return request<T>(endpoint, { ...config, method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
export default http;
|
||||
|
||||
159
src/lib/authService.ts
Normal file
159
src/lib/authService.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理用户登录、注册、登出等认证相关操作
|
||||
*/
|
||||
|
||||
import { http, tokenManager, ApiError } from './api';
|
||||
import type { AuthData, LoginRequest, RegisterRequest, UserInfo } from '../types/api';
|
||||
|
||||
// 重新导出 ApiErrorMessages 以便使用
|
||||
export { ApiErrorMessages } from '../types/api';
|
||||
|
||||
/**
|
||||
* 从 JWT Token 解析用户信息
|
||||
*/
|
||||
function parseJwtPayload(token: string): UserInfo | null {
|
||||
try {
|
||||
// 移除 Bearer 前缀
|
||||
const actualToken = token.replace('Bearer ', '');
|
||||
const base64Payload = actualToken.split('.')[1];
|
||||
const payload = JSON.parse(atob(base64Payload));
|
||||
return payload as UserInfo;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证服务
|
||||
*/
|
||||
export const authService = {
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
|
||||
|
||||
if (!response.data) {
|
||||
throw new ApiError(-1, '登录失败,请重试');
|
||||
}
|
||||
|
||||
const { token, expires_at } = response.data;
|
||||
|
||||
// 存储 Token 和 email
|
||||
tokenManager.setToken(token, expires_at, credentials.email);
|
||||
|
||||
// 解析用户信息
|
||||
const user = parseJwtPayload(token);
|
||||
if (!user) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const userWithEmail: UserInfo = {
|
||||
...user,
|
||||
email: credentials.email,
|
||||
id: String(user.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: userWithEmail,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* 注意:业务错误码 (code !== 200) 已在 api.ts 中统一处理,会抛出 ApiError
|
||||
*/
|
||||
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
|
||||
|
||||
if (!response.data) {
|
||||
throw new ApiError(-1, '注册失败,请重试');
|
||||
}
|
||||
|
||||
const { token, expires_at } = response.data;
|
||||
|
||||
// 存储 Token 和 email
|
||||
tokenManager.setToken(token, expires_at, credentials.email);
|
||||
|
||||
// 解析用户信息
|
||||
const user = parseJwtPayload(token);
|
||||
if (!user) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const userWithEmail: UserInfo = {
|
||||
...user,
|
||||
email: credentials.email,
|
||||
id: String(user.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: userWithEmail,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户登出
|
||||
*/
|
||||
logout(): void {
|
||||
tokenManager.removeToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return tokenManager.isTokenValid();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前存储的 Token
|
||||
*/
|
||||
getToken(): string | null {
|
||||
return tokenManager.getToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 从存储的 Token 恢复用户会话
|
||||
*/
|
||||
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
|
||||
const token = tokenManager.getToken();
|
||||
const expiresAt = tokenManager.getExpiresAt();
|
||||
const email = tokenManager.getEmail();
|
||||
|
||||
if (!token || !expiresAt || !tokenManager.isTokenValid()) {
|
||||
tokenManager.removeToken();
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUser = parseJwtPayload(token);
|
||||
if (!parsedUser) {
|
||||
tokenManager.removeToken();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const user: UserInfo = {
|
||||
...parsedUser,
|
||||
email: email || '',
|
||||
id: String(parsedUser.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export { ApiError };
|
||||
export default authService;
|
||||
|
||||
192
src/lib/mockService.ts
Normal file
192
src/lib/mockService.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { FileRecord, RecognitionResult } from '../types';
|
||||
|
||||
// Mock Data Store
|
||||
class MockStore {
|
||||
private files: FileRecord[] = [];
|
||||
private results: Record<string, RecognitionResult> = {};
|
||||
private currentUser = { id: 'mock-user-id', email: 'demo@texpixel.ai' };
|
||||
|
||||
constructor() {
|
||||
// Add some initial mock data
|
||||
this.addInitialData();
|
||||
}
|
||||
|
||||
private addInitialData() {
|
||||
const fileId = 'mock-file-1';
|
||||
const file: FileRecord = {
|
||||
id: fileId,
|
||||
user_id: this.currentUser.id,
|
||||
filename: 'math-formula-example.jpg',
|
||||
file_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800',
|
||||
file_type: 'image/jpeg',
|
||||
file_size: 1024 * 500, // 500KB
|
||||
thumbnail_path: null,
|
||||
status: 'completed',
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(), // 1 day ago
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result: RecognitionResult = {
|
||||
id: 'mock-result-1',
|
||||
file_id: fileId,
|
||||
markdown_content: `# Quadratic Formula
|
||||
|
||||
The quadratic formula is a fundamental equation in algebra.
|
||||
|
||||
$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$
|
||||
|
||||
Where:
|
||||
- $a$, $b$, and $c$ are coefficients
|
||||
- $x$ represents the solutions
|
||||
|
||||
## Example
|
||||
|
||||
For the equation $2x^2 + 5x - 3 = 0$:
|
||||
|
||||
$$x = \\frac{-5 \\pm \\sqrt{25 - 4(2)(-3)}}{4} = \\frac{-5 \\pm \\sqrt{49}}{4} = \\frac{-5 \\pm 7}{4}$$
|
||||
|
||||
Solutions: $x_1 = 0.5$, $x_2 = -3$`,
|
||||
latex_content: `\\documentclass{article}
|
||||
\\begin{document}
|
||||
|
||||
\\section*{Quadratic Formula}
|
||||
|
||||
The quadratic formula is a fundamental equation in algebra.
|
||||
|
||||
\\[
|
||||
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
|
||||
\\]
|
||||
|
||||
Where:
|
||||
\\begin{itemize}
|
||||
\\item $a$, $b$, and $c$ are coefficients
|
||||
\\item $x$ represents the solutions
|
||||
\\end{itemize}
|
||||
|
||||
\\end{document}`,
|
||||
mathml_content: `<math xmlns="http://www.w3.org/1998/Math/MathML" display="block">
|
||||
<mrow>
|
||||
<mi>x</mi>
|
||||
<mo>=</mo>
|
||||
<mfrac>
|
||||
<mrow>
|
||||
<mo>-</mo>
|
||||
<mi>b</mi>
|
||||
<mo>±</mo>
|
||||
<msqrt>
|
||||
<mrow>
|
||||
<msup>
|
||||
<mi>b</mi>
|
||||
<mn>2</mn>
|
||||
</msup>
|
||||
<mo>-</mo>
|
||||
<mn>4</mn>
|
||||
<mi>a</mi>
|
||||
<mi>c</mi>
|
||||
</mrow>
|
||||
</msqrt>
|
||||
</mrow>
|
||||
<mrow>
|
||||
<mn>2</mn>
|
||||
<mi>a</mi>
|
||||
</mrow>
|
||||
</mfrac>
|
||||
</mrow>
|
||||
</math>`,
|
||||
mathml_word_content: `<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
|
||||
<m:f>
|
||||
<m:num>
|
||||
<m:r><m:t>-b ± √(b²-4ac)</m:t></m:r>
|
||||
</m:num>
|
||||
<m:den>
|
||||
<m:r><m:t>2a</m:t></m:r>
|
||||
</m:den>
|
||||
</m:f>
|
||||
</m:oMath>`,
|
||||
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.files.push(file);
|
||||
this.results[fileId] = result;
|
||||
}
|
||||
|
||||
// File Operations
|
||||
async getFiles(userId?: string | null): Promise<FileRecord[]> {
|
||||
await this.delay(500); // Simulate network latency
|
||||
if (userId) {
|
||||
return this.files.filter(f => f.user_id === userId);
|
||||
}
|
||||
return this.files.filter(f => f.user_id === null); // Anonymous files
|
||||
}
|
||||
|
||||
async uploadFile(file: File, userId?: string | null): Promise<FileRecord> {
|
||||
await this.delay(1000); // Simulate upload time
|
||||
|
||||
const newFile: FileRecord = {
|
||||
id: crypto.randomUUID(),
|
||||
user_id: userId || null,
|
||||
filename: file.name,
|
||||
file_path: URL.createObjectURL(file), // Local blob URL
|
||||
file_type: file.type,
|
||||
file_size: file.size,
|
||||
thumbnail_path: null,
|
||||
status: 'completed',
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.files.unshift(newFile); // Add to beginning
|
||||
this.generateMockResult(newFile.id, file.name);
|
||||
|
||||
return newFile;
|
||||
}
|
||||
|
||||
// Method to add a file record manually (e.g. after real upload)
|
||||
addFileRecord(fileRecord: FileRecord) {
|
||||
this.files.unshift(fileRecord);
|
||||
// Don't generate mock result - real API result will be set after polling completes
|
||||
}
|
||||
|
||||
// Result Operations
|
||||
async getResult(fileId: string): Promise<RecognitionResult | null> {
|
||||
await this.delay(300);
|
||||
return this.results[fileId] || null;
|
||||
}
|
||||
|
||||
private generateMockResult(fileId: string, filename: string) {
|
||||
const mockResult: RecognitionResult = {
|
||||
id: crypto.randomUUID(),
|
||||
file_id: fileId,
|
||||
markdown_content: `# Analysis for ${filename}\n\nThis is a mock analysis result generated for the uploaded file.\n\n$$ E = mc^2 $$\n\nDetected content matches widely known physics formulas.`,
|
||||
latex_content: `\\documentclass{article}\n\\begin{document}\nSection{${filename}}\n\n\\[ E = mc^2 \\]\n\n\\end{document}`,
|
||||
mathml_content: `<math><mi>E</mi><mo>=</mo><mi>m</mi><msup><mi>c</mi><mn>2</mn></msup></math>`,
|
||||
mathml_word_content: `<m:oMath><m:r><m:t>E=mc^2</m:t></m:r></m:oMath>`,
|
||||
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800', // Placeholder
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
this.results[fileId] = mockResult;
|
||||
}
|
||||
|
||||
// Auth Operations
|
||||
async getCurrentUser() {
|
||||
return this.currentUser;
|
||||
}
|
||||
|
||||
async signIn() {
|
||||
await this.delay(500);
|
||||
return { user: this.currentUser, error: null };
|
||||
}
|
||||
|
||||
async signOut() {
|
||||
await this.delay(200);
|
||||
return { error: null };
|
||||
}
|
||||
|
||||
private delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export const mockService = new MockStore();
|
||||
|
||||
91
src/lib/supabase.ts
Normal file
91
src/lib/supabase.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.error('Missing Supabase environment variables. Please check your .env file.');
|
||||
}
|
||||
|
||||
// Fallback to dummy values to prevent app crash, but API calls will fail
|
||||
export const supabase = createClient(
|
||||
supabaseUrl || 'https://placeholder.supabase.co',
|
||||
supabaseAnonKey || 'placeholder'
|
||||
);
|
||||
|
||||
export type Database = {
|
||||
public: {
|
||||
Tables: {
|
||||
files: {
|
||||
Row: {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
filename: string;
|
||||
file_path: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
thumbnail_path: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
user_id?: string | null;
|
||||
filename: string;
|
||||
file_path: string;
|
||||
file_type: string;
|
||||
file_size?: number;
|
||||
thumbnail_path?: string | null;
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
user_id?: string | null;
|
||||
filename?: string;
|
||||
file_path?: string;
|
||||
file_type?: string;
|
||||
file_size?: number;
|
||||
thumbnail_path?: string | null;
|
||||
status?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
recognition_results: {
|
||||
Row: {
|
||||
id: string;
|
||||
file_id: string;
|
||||
markdown_content: string | null;
|
||||
latex_content: string | null;
|
||||
mathml_content: string | null;
|
||||
mathml_word_content: string | null;
|
||||
rendered_image_path: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
Insert: {
|
||||
id?: string;
|
||||
file_id: string;
|
||||
markdown_content?: string | null;
|
||||
latex_content?: string | null;
|
||||
mathml_content?: string | null;
|
||||
mathml_word_content?: string | null;
|
||||
rendered_image_path?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
Update: {
|
||||
id?: string;
|
||||
file_id?: string;
|
||||
markdown_content?: string | null;
|
||||
latex_content?: string | null;
|
||||
mathml_content?: string | null;
|
||||
mathml_word_content?: string | null;
|
||||
rendered_image_path?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
202
src/lib/uploadService.ts
Normal file
202
src/lib/uploadService.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import imageCompression from 'browser-image-compression';
|
||||
import SparkMD5 from 'spark-md5';
|
||||
import http from './api';
|
||||
import { OssSignatureData, OssSignatureRequest, RecognitionTaskData, RecognitionTaskRequest, RecognitionResultData, TaskListData } from '../types/api';
|
||||
|
||||
/**
|
||||
* 文件上传服务
|
||||
*/
|
||||
export const uploadService = {
|
||||
/**
|
||||
* 处理并上传文件
|
||||
*/
|
||||
async uploadFile(file: File): Promise<OssSignatureData> {
|
||||
// ... (保留之前的 uploadFile 逻辑)
|
||||
// 1. 验证文件
|
||||
if (!this.validateFile(file)) {
|
||||
throw new Error('不支持的文件类型或文件大小超过限制');
|
||||
}
|
||||
|
||||
// 2. 计算文件 Hash (使用原始文件,确保去重逻辑基于用户源文件)
|
||||
// 压缩后的文件 Hash 可能会因为压缩过程的微小差异(如时间戳元数据)而不同
|
||||
const fileHash = await this.calculateMD5(file);
|
||||
|
||||
// 3. 压缩图片 (如果是图片且超过一定大小)
|
||||
let processedFile = file;
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
processedFile = await this.compressImage(file);
|
||||
} catch (error) {
|
||||
console.warn('图片压缩失败,尝试使用原文件上传', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 获取上传签名
|
||||
const signatureData = await this.getOssSignature({
|
||||
file_hash: fileHash,
|
||||
file_name: processedFile.name, // 使用处理后的文件名(通常相同)
|
||||
});
|
||||
|
||||
// 5. 如果需要上传 (repeat = false),则上传到 OSS (上传处理后的文件)
|
||||
if (!signatureData.data?.repeat && signatureData.data?.sign_url) {
|
||||
await this.uploadToOss(signatureData.data.sign_url, processedFile);
|
||||
}
|
||||
|
||||
if (!signatureData.data) {
|
||||
throw new Error('获取上传签名失败');
|
||||
}
|
||||
|
||||
return signatureData.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建公式识别任务
|
||||
*/
|
||||
async createRecognitionTask(path: string, fileHash: string, fileName: string): Promise<RecognitionTaskData> {
|
||||
const data: RecognitionTaskRequest = {
|
||||
file_url: path,
|
||||
file_hash: fileHash,
|
||||
file_name: fileName,
|
||||
task_type: 'FORMULA'
|
||||
};
|
||||
return http.post<RecognitionTaskData>('/formula/recognition', data).then(res => {
|
||||
if (!res.data) throw new Error('创建任务失败: 无返回数据');
|
||||
return res.data;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取任务结果
|
||||
*/
|
||||
async getTaskResult(taskNo: string): Promise<RecognitionResultData> {
|
||||
return http.get<RecognitionResultData>(`/formula/recognition/${taskNo}`).then(res => {
|
||||
if (!res.data) throw new Error('获取结果失败: 无返回数据');
|
||||
return res.data;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取任务历史记录列表
|
||||
*/
|
||||
async getTaskList(taskType: 'FORMULA' = 'FORMULA', page: number = 1, pageSize: number = 5): Promise<TaskListData> {
|
||||
return http.get<TaskListData>(`/task/list?task_type=${taskType}&page=${page}&page_size=${pageSize}`).then(res => {
|
||||
if (!res.data) throw new Error('获取历史记录失败: 无返回数据');
|
||||
return res.data;
|
||||
});
|
||||
},
|
||||
|
||||
// ... (保留其他 helper 方法: validateFile, compressImage, calculateMD5, getOssSignature, uploadToOss)
|
||||
/**
|
||||
* 验证文件
|
||||
*/
|
||||
validateFile(file: File): boolean {
|
||||
const validTypes = ['image/jpeg', 'image/png', 'image/jpg'];
|
||||
// 检查类型 (虽然 input accept 做了限制,但这里再检查一次)
|
||||
// 注意:有些 jpg 文件的 type 可能是 image/jpeg
|
||||
if (!file.type.startsWith('image/')) {
|
||||
// 用户提到支持 jpg, png。
|
||||
// 暂时只允许图片,虽然 UI 代码里有 application/pdf,但用户需求只提到了图片。
|
||||
// 根据用户需求:支持 jpg, png 图片上传
|
||||
if (validTypes.indexOf(file.type) === -1 && !file.name.match(/\.(jpg|jpeg|png)$/i)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 5MB 限制 (前端压缩前的检查,压缩后应该更小)
|
||||
// 但如果用户传了个很大的文件,压缩可能也花很久。
|
||||
// 用户说 "文件大小显示5M, 前端进行压缩处理",可能指压缩目标是5M,或者限制原文件5M?
|
||||
// 通常是限制上传大小。如果原文件很大,压缩后小于5M也可以。
|
||||
// 这里暂时不做严格的源文件大小限制,交给压缩处理,或者设置一个合理的上限比如 20MB。
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 压缩图片
|
||||
*/
|
||||
async compressImage(file: File): Promise<File> {
|
||||
const options = {
|
||||
maxSizeMB: 5, // 限制最大 5MB
|
||||
maxWidthOrHeight: 1920, // 限制最大宽/高,防止过大图片
|
||||
useWebWorker: true,
|
||||
initialQuality: 0.8,
|
||||
};
|
||||
|
||||
try {
|
||||
return await imageCompression(file, options);
|
||||
} catch (error) {
|
||||
console.error('Image compression error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 计算文件 MD5
|
||||
*/
|
||||
calculateMD5(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const blobSlice = File.prototype.slice || (File.prototype as any).mozSlice || (File.prototype as any).webkitSlice;
|
||||
const chunkSize = 2097152; // 2MB
|
||||
const chunks = Math.ceil(file.size / chunkSize);
|
||||
let currentChunk = 0;
|
||||
const spark = new SparkMD5.ArrayBuffer();
|
||||
const fileReader = new FileReader();
|
||||
|
||||
fileReader.onload = function (e) {
|
||||
if (e.target?.result) {
|
||||
spark.append(e.target.result as ArrayBuffer);
|
||||
}
|
||||
currentChunk++;
|
||||
|
||||
if (currentChunk < chunks) {
|
||||
loadNext();
|
||||
} else {
|
||||
resolve(spark.end());
|
||||
}
|
||||
};
|
||||
|
||||
fileReader.onerror = function () {
|
||||
reject('File read failed');
|
||||
};
|
||||
|
||||
function loadNext() {
|
||||
const start = currentChunk * chunkSize;
|
||||
const end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
|
||||
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
|
||||
}
|
||||
|
||||
loadNext();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取 OSS 签名
|
||||
*/
|
||||
async getOssSignature(data: OssSignatureRequest) {
|
||||
return http.post<OssSignatureData>('/oss/signature_url', data);
|
||||
},
|
||||
|
||||
/**
|
||||
* 上传文件到 OSS
|
||||
*/
|
||||
async uploadToOss(url: string, file: File): Promise<void> {
|
||||
try {
|
||||
// OSS 直接 PUT 上传,不需要额外的 headers (除非签名里有要求,通常 Content-Type 需要匹配)
|
||||
// 注意:Content-Type 需要根据文件实际类型设置,或者 application/octet-stream
|
||||
// 这里使用文件类型
|
||||
await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: file,
|
||||
headers: {
|
||||
// 有些 OSS 签名会绑定 Content-Type,如果不一致会报错。
|
||||
// 这里尽量使用 file.type,如果为空则设为 application/octet-stream
|
||||
// 如果签名不限制 Content-Type,则无所谓。
|
||||
// 按照常规 OSS PutObject,建议带上 type
|
||||
'Content-Type': file.type || 'application/octet-stream',
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload to OSS failed:', error);
|
||||
throw new Error('文件上传失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
13
src/main.tsx
Normal file
13
src/main.tsx
Normal 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
149
src/types/api.ts
Normal 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
35
src/types/index.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
10
tailwind.config.js
Normal file
10
tailwind.config.js
Normal 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
24
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal 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
26
vite.config.ts
Normal 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'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user