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