Compare commits
2 Commits
6747205bd0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c5409a6c7 | ||
|
|
42850c4460 |
145
deploy.sh
Executable file
145
deploy.sh
Executable file
@@ -0,0 +1,145 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Document AI Frontend 部署脚本
|
||||||
|
# 功能:构建项目并部署到 ecs 服务器
|
||||||
|
|
||||||
|
set -e # 遇到错误立即退出
|
||||||
|
|
||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
ECS_HOST="ecs"
|
||||||
|
DEPLOY_PATH="/texpixel"
|
||||||
|
|
||||||
|
# 打印带颜色的消息
|
||||||
|
print_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查命令是否存在
|
||||||
|
check_command() {
|
||||||
|
if ! command -v $1 &> /dev/null; then
|
||||||
|
print_error "$1 命令未找到,请先安装"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 部署到服务器
|
||||||
|
deploy_to_server() {
|
||||||
|
local server=$1
|
||||||
|
print_info "开始部署到 ${server}..."
|
||||||
|
|
||||||
|
# 上传构建产物
|
||||||
|
print_info "上传 dist 目录到 ${server}..."
|
||||||
|
if scp -r dist ${server}:~ > /dev/null 2>&1; then
|
||||||
|
print_success "文件上传成功"
|
||||||
|
else
|
||||||
|
print_error "文件上传失败"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SSH 执行部署操作
|
||||||
|
print_info "在 ${server} 上执行部署操作..."
|
||||||
|
ssh ${server} << EOF
|
||||||
|
set -e
|
||||||
|
cd ${DEPLOY_PATH}
|
||||||
|
|
||||||
|
# 备份旧版本
|
||||||
|
if [ -d "dist" ]; then
|
||||||
|
echo "备份旧版本..."
|
||||||
|
rm -rf dist_bak/
|
||||||
|
mv dist dist_bak
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 移动新版本
|
||||||
|
if [ -d ~/dist ]; then
|
||||||
|
mv ~/dist .
|
||||||
|
echo "部署完成!"
|
||||||
|
else
|
||||||
|
echo "错误:找不到 ~/dist 目录"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 重新加载 nginx(如果配置了)
|
||||||
|
if command -v nginx &> /dev/null; then
|
||||||
|
echo "重新加载 nginx..."
|
||||||
|
nginx -t && nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||||
|
fi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
print_success "${server} 部署成功!"
|
||||||
|
else
|
||||||
|
print_error "${server} 部署失败!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 主函数
|
||||||
|
main() {
|
||||||
|
print_info "=========================================="
|
||||||
|
print_info "Document AI Frontend 部署脚本"
|
||||||
|
print_info "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查必要的命令
|
||||||
|
print_info "检查环境..."
|
||||||
|
check_command "npm"
|
||||||
|
check_command "scp"
|
||||||
|
check_command "ssh"
|
||||||
|
|
||||||
|
# 步骤1: 构建项目
|
||||||
|
print_info "步骤 1/2: 构建项目..."
|
||||||
|
if npm run build; then
|
||||||
|
print_success "构建完成!"
|
||||||
|
else
|
||||||
|
print_error "构建失败!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查 dist 目录是否存在
|
||||||
|
if [ ! -d "dist" ]; then
|
||||||
|
print_error "dist 目录不存在,构建可能失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 步骤2: 部署到 ecs
|
||||||
|
print_info "步骤 2/2: 部署到 ecs..."
|
||||||
|
if deploy_to_server ${ECS_HOST}; then
|
||||||
|
print_success "ecs 部署完成"
|
||||||
|
else
|
||||||
|
print_error "ecs 部署失败"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 完成
|
||||||
|
print_info "清理临时文件..."
|
||||||
|
# 可以选择是否删除本地的 dist 目录
|
||||||
|
# rm -rf dist
|
||||||
|
|
||||||
|
print_success "=========================================="
|
||||||
|
print_success "部署完成!"
|
||||||
|
print_success "=========================================="
|
||||||
|
}
|
||||||
|
|
||||||
|
# 运行主函数
|
||||||
|
main
|
||||||
33
src/App.tsx
33
src/App.tsx
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
|
import { useLanguage } from './contexts/LanguageContext';
|
||||||
import { uploadService } from './lib/uploadService';
|
import { uploadService } from './lib/uploadService';
|
||||||
import { FileRecord, RecognitionResult } from './types';
|
import { FileRecord, RecognitionResult } from './types';
|
||||||
import { TaskStatus, TaskHistoryItem } from './types/api';
|
import { TaskStatus, TaskHistoryItem } from './types/api';
|
||||||
@@ -8,15 +9,18 @@ import Navbar from './components/Navbar';
|
|||||||
import FilePreview from './components/FilePreview';
|
import FilePreview from './components/FilePreview';
|
||||||
import ResultPanel from './components/ResultPanel';
|
import ResultPanel from './components/ResultPanel';
|
||||||
import UploadModal from './components/UploadModal';
|
import UploadModal from './components/UploadModal';
|
||||||
|
import UserGuide from './components/UserGuide';
|
||||||
|
|
||||||
const PAGE_SIZE = 6;
|
const PAGE_SIZE = 6;
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { user, initializing } = useAuth();
|
const { user, initializing } = useAuth();
|
||||||
|
const { t } = useLanguage();
|
||||||
const [files, setFiles] = useState<FileRecord[]>([]);
|
const [files, setFiles] = useState<FileRecord[]>([]);
|
||||||
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
|
||||||
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
|
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
const [showUserGuide, setShowUserGuide] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
@@ -41,6 +45,20 @@ function App() {
|
|||||||
|
|
||||||
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
|
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleStartGuide = () => setShowUserGuide(true);
|
||||||
|
window.addEventListener('start-user-guide', handleStartGuide);
|
||||||
|
|
||||||
|
// Check for first-time user
|
||||||
|
const hasSeenGuide = localStorage.getItem('hasSeenGuide');
|
||||||
|
if (!hasSeenGuide) {
|
||||||
|
setTimeout(() => setShowUserGuide(true), 1500);
|
||||||
|
localStorage.setItem('hasSeenGuide', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => window.removeEventListener('start-user-guide', handleStartGuide);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initializing && user && !hasLoadedFiles.current) {
|
if (!initializing && user && !hasLoadedFiles.current) {
|
||||||
hasLoadedFiles.current = true;
|
hasLoadedFiles.current = true;
|
||||||
@@ -352,7 +370,7 @@ function App() {
|
|||||||
return f;
|
return f;
|
||||||
}));
|
}));
|
||||||
|
|
||||||
alert('Task timeout: Recognition took too long.');
|
alert(t.alerts.taskTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -366,7 +384,7 @@ function App() {
|
|||||||
if (f.id === fileId) return { ...f, status: 'failed' };
|
if (f.id === fileId) return { ...f, status: 'failed' };
|
||||||
return f;
|
return f;
|
||||||
}));
|
}));
|
||||||
alert('Task timeout or network error.');
|
alert(t.alerts.networkError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 2000); // Poll every 2 seconds
|
}, 2000); // Poll every 2 seconds
|
||||||
@@ -415,7 +433,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading files:', error);
|
console.error('Error uploading files:', error);
|
||||||
alert('Upload failed: ' + (error instanceof Error ? error.message : 'Unknown error'));
|
alert(`${t.alerts.uploadFailed}: ` + (error instanceof Error ? error.message : 'Unknown error'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -426,7 +444,7 @@ function App() {
|
|||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||||
<div className="text-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>
|
<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>
|
<p className="text-gray-600">{t.common.loading}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -484,11 +502,16 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<UserGuide
|
||||||
|
isOpen={showUserGuide}
|
||||||
|
onClose={() => setShowUserGuide(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
|
<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="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>
|
<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>
|
<p className="text-gray-900 font-medium">{t.common.processing}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
interface AuthModalProps {
|
interface AuthModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -8,6 +9,7 @@ interface AuthModalProps {
|
|||||||
|
|
||||||
export default function AuthModal({ onClose }: AuthModalProps) {
|
export default function AuthModal({ onClose }: AuthModalProps) {
|
||||||
const { signIn, signUp } = useAuth();
|
const { signIn, signUp } = useAuth();
|
||||||
|
const { t } = useLanguage();
|
||||||
const [isSignUp, setIsSignUp] = useState(false);
|
const [isSignUp, setIsSignUp] = useState(false);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -41,7 +43,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
|||||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-gray-900">
|
||||||
{isSignUp ? '注册账号' : '登录账号'}
|
{isSignUp ? t.auth.signUpTitle : t.auth.signInTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -54,7 +56,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
邮箱
|
{t.auth.email}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -68,7 +70,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
密码
|
{t.auth.password}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -83,7 +85,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
|
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
|
||||||
错误: {error}
|
{t.auth.error}: {error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -92,7 +94,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
|||||||
disabled={loading}
|
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"
|
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 ? '注册' : '登录'}
|
{isSignUp ? t.auth.signUp : t.auth.signIn}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -101,7 +103,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
|||||||
onClick={() => setIsSignUp(!isSignUp)}
|
onClick={() => setIsSignUp(!isSignUp)}
|
||||||
className="text-sm text-blue-600 hover:text-blue-700"
|
className="text-sm text-blue-600 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
{isSignUp ? '已有账号?去登录' : '没有账号?去注册'}
|
{isSignUp ? t.auth.hasAccount : t.auth.noAccount}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter'
|
|||||||
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
|
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
|
||||||
import { API_BASE_URL } from '../config/env';
|
import { API_BASE_URL } from '../config/env';
|
||||||
import { tokenManager } from '../lib/api';
|
import { tokenManager } from '../lib/api';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
interface ExportSidebarProps {
|
interface ExportSidebarProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -24,6 +25,7 @@ interface ExportOption {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
|
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
const [exportingId, setExportingId] = useState<string | null>(null);
|
const [exportingId, setExportingId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Export failed:', err);
|
console.error('Export failed:', err);
|
||||||
alert('导出失败,请重试');
|
alert(t.export.failed);
|
||||||
} finally {
|
} finally {
|
||||||
setExportingId(null);
|
setExportingId(null);
|
||||||
}
|
}
|
||||||
@@ -160,7 +162,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to generate image:', err);
|
console.error('Failed to generate image:', err);
|
||||||
alert(`生成图片失败: ${err}`);
|
alert(`${t.export.imageFailed}: ${err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setExportingId(null);
|
setExportingId(null);
|
||||||
}
|
}
|
||||||
@@ -228,9 +230,9 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
|||||||
};
|
};
|
||||||
|
|
||||||
const categories: { id: ExportCategory; icon: LucideIcon; label: string }[] = [
|
const categories: { id: ExportCategory; icon: LucideIcon; label: string }[] = [
|
||||||
{ id: 'Code', icon: Code2, label: 'Code' },
|
{ id: 'Code', icon: Code2, label: t.export.categories.code },
|
||||||
{ id: 'Image', icon: ImageIcon, label: 'Image' },
|
{ id: 'Image', icon: ImageIcon, label: t.export.categories.image },
|
||||||
{ id: 'File', icon: FileText, label: 'File' },
|
{ id: 'File', icon: FileText, label: t.export.categories.file },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -251,7 +253,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 shrink-0">
|
<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>
|
<h2 className="text-lg font-bold text-gray-900">{t.export.title}</h2>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
||||||
<X size={20} className="text-gray-500" />
|
<X size={20} className="text-gray-500" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronLeft, ChevronRight, File as FileIcon, MinusCircle, PlusCircle } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, File as FileIcon, MinusCircle, PlusCircle } from 'lucide-react';
|
||||||
import { FileRecord } from '../types';
|
import { FileRecord } from '../types';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
file: FileRecord | null;
|
file: FileRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilePreview({ file }: FilePreviewProps) {
|
export default function FilePreview({ file }: FilePreviewProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [zoom, setZoom] = useState(100);
|
const [zoom, setZoom] = useState(100);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const totalPages = 1;
|
const totalPages = 1;
|
||||||
@@ -16,13 +18,13 @@ export default function FilePreview({ file }: FilePreviewProps) {
|
|||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
return (
|
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="flex-1 flex flex-col items-center justify-center bg-white p-8 text-center border border-white border-solid" id="file-preview-empty">
|
||||||
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
|
<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" />
|
<FileIcon size={48} className="text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Upload file</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t.common.upload}</h3>
|
||||||
<p className="text-gray-500 max-w-xs">
|
<p className="text-gray-500 max-w-xs">
|
||||||
Click, Drop, or Paste a file to start parsing
|
{t.sidebar.uploadInstruction}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -68,7 +70,7 @@ export default function FilePreview({ file }: FilePreviewProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
|
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
|
||||||
title="缩小"
|
title={t.common.preview}
|
||||||
>
|
>
|
||||||
<MinusCircle size={16} />
|
<MinusCircle size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -78,7 +80,7 @@ export default function FilePreview({ file }: FilePreviewProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleZoomIn}
|
onClick={handleZoomIn}
|
||||||
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
|
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
|
||||||
title="放大"
|
title={t.common.preview}
|
||||||
>
|
>
|
||||||
<PlusCircle size={16} />
|
<PlusCircle size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
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 { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, Settings, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { FileRecord } from '../types';
|
import { FileRecord } from '../types';
|
||||||
import AuthModal from './AuthModal';
|
import AuthModal from './AuthModal';
|
||||||
|
|
||||||
@@ -30,11 +31,13 @@ export default function LeftSidebar({
|
|||||||
onLoadMore,
|
onLoadMore,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
|
const { t } = useLanguage();
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// ... (rest of the logic remains the same)
|
||||||
// Handle scroll to load more
|
// Handle scroll to load more
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (!listRef.current || loadingMore || !hasMore) return;
|
if (!listRef.current || loadingMore || !hasMore) return;
|
||||||
@@ -116,13 +119,13 @@ export default function LeftSidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={onUploadClick}
|
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"
|
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"
|
title={t.common.upload}
|
||||||
>
|
>
|
||||||
<Upload size={20} />
|
<Upload size={20} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex-1 w-full flex flex-col items-center gap-4">
|
<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">
|
<button className="p-2 text-gray-400 hover:text-gray-900 transition-colors" title={t.common.history}>
|
||||||
<History size={20} />
|
<History size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -130,7 +133,7 @@ export default function LeftSidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => !user && setShowAuthModal(true)}
|
onClick={() => !user && setShowAuthModal(true)}
|
||||||
className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto"
|
className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto"
|
||||||
title={user ? 'Signed In' : 'Sign In'}
|
title={user ? 'Signed In' : t.common.login}
|
||||||
>
|
>
|
||||||
<LogIn size={20} />
|
<LogIn size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -145,8 +148,8 @@ export default function LeftSidebar({
|
|||||||
<div className="p-6 pb-4">
|
<div className="p-6 pb-4">
|
||||||
<div className="flex items-start justify-between mb-6">
|
<div className="flex items-start justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-bold text-gray-900 leading-tight">Formula Recognize</h2>
|
<h2 className="text-lg font-bold text-gray-900 leading-tight">{t.sidebar.title}</h2>
|
||||||
<p className="text-xs text-gray-500 mt-1">Support handwriting and printed formulas</p>
|
<p className="text-xs text-gray-500 mt-1">{t.sidebar.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
@@ -156,7 +159,7 @@ export default function LeftSidebar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-2">
|
<div className="mb-2" id="sidebar-upload-area">
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
@@ -181,18 +184,19 @@ export default function LeftSidebar({
|
|||||||
<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">
|
<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} />
|
<Upload size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-gray-400">
|
<p className="text-xs text-gray-500 mb-2">{t.sidebar.uploadInstruction}</p>
|
||||||
|
<div className="flex items-center justify-center gap-4 text-xs text-gray-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MousePointerClick className="w-3.5 h-3.5" />
|
<MousePointerClick className="w-3.5 h-3.5" />
|
||||||
<span>Click</span>
|
<span>{t.common.click}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FileUp className="w-3.5 h-3.5" />
|
<FileUp className="w-3.5 h-3.5" />
|
||||||
<span>Drop</span>
|
<span>{t.common.drop}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||||
<span>Paste</span>
|
<span>{t.common.paste}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,10 +204,10 @@ export default function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Middle Area: History */}
|
{/* Middle Area: History */}
|
||||||
<div className="flex-1 overflow-hidden flex flex-col px-4">
|
<div className="flex-1 overflow-hidden flex flex-col px-4" id="sidebar-history">
|
||||||
<div className="flex items-center gap-2 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
|
<div className="flex items-center gap-2 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
|
||||||
<Clock size={14} />
|
<Clock size={14} />
|
||||||
<span>History</span>
|
<span>{t.sidebar.historyHeader}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -214,12 +218,12 @@ export default function LeftSidebar({
|
|||||||
{!user ? (
|
{!user ? (
|
||||||
<div className="text-center py-12 text-gray-400 text-sm">
|
<div className="text-center py-12 text-gray-400 text-sm">
|
||||||
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
||||||
Please login to view history
|
{t.sidebar.pleaseLogin}
|
||||||
</div>
|
</div>
|
||||||
) : files.length === 0 ? (
|
) : files.length === 0 ? (
|
||||||
<div className="text-center py-12 text-gray-400 text-sm">
|
<div className="text-center py-12 text-gray-400 text-sm">
|
||||||
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
|
||||||
No history records
|
{t.sidebar.noHistory}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -256,13 +260,13 @@ export default function LeftSidebar({
|
|||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<div className="flex items-center justify-center py-3 text-gray-400">
|
<div className="flex items-center justify-center py-3 text-gray-400">
|
||||||
<Loader2 size={18} className="animate-spin" />
|
<Loader2 size={18} className="animate-spin" />
|
||||||
<span className="ml-2 text-xs">Loading...</span>
|
<span className="ml-2 text-xs">{t.common.loading}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* End of list indicator */}
|
{/* End of list indicator */}
|
||||||
{!hasMore && files.length > 0 && (
|
{!hasMore && files.length > 0 && (
|
||||||
<div className="text-center py-3 text-xs text-gray-400">
|
<div className="text-center py-3 text-xs text-gray-400">
|
||||||
No more records
|
{t.sidebar.noMore}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -285,7 +289,7 @@ export default function LeftSidebar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
|
||||||
title="Logout"
|
title={t.common.logout}
|
||||||
>
|
>
|
||||||
<LogOut size={16} />
|
<LogOut size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -296,7 +300,7 @@ export default function LeftSidebar({
|
|||||||
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"
|
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 size={18} />
|
||||||
Login / Register
|
{t.common.login}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X } from 'lucide-react';
|
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X, Languages, HelpCircle } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
const { language, setLanguage, t } = useLanguage();
|
||||||
const [showContact, setShowContact] = useState(false);
|
const [showContact, setShowContact] = useState(false);
|
||||||
const [showReward, setShowReward] = useState(false);
|
const [showReward, setShowReward] = useState(false);
|
||||||
|
const [showLangMenu, setShowLangMenu] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const handleCopyQQ = async () => {
|
const handleCopyQQ = async () => {
|
||||||
await navigator.clipboard.writeText('1018282100');
|
await navigator.clipboard.writeText('1018282100');
|
||||||
@@ -19,6 +23,9 @@ export default function Navbar() {
|
|||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
setShowContact(false);
|
setShowContact(false);
|
||||||
}
|
}
|
||||||
|
if (langMenuRef.current && !langMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setShowLangMenu(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
@@ -37,8 +44,59 @@ export default function Navbar() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Reward & Contact Buttons */}
|
{/* Right: Actions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Language Switcher */}
|
||||||
|
<div className="relative" ref={langMenuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLangMenu(!showLangMenu)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
title="Switch Language"
|
||||||
|
>
|
||||||
|
<Languages size={18} />
|
||||||
|
<span className="hidden sm:inline">{language === 'en' ? 'English' : '简体中文'}</span>
|
||||||
|
<ChevronDown size={14} className={`transition-transform duration-200 ${showLangMenu ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showLangMenu && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-32 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLanguage('en');
|
||||||
|
setShowLangMenu(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === 'en' ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
|
English
|
||||||
|
{language === 'en' && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setLanguage('zh');
|
||||||
|
setShowLangMenu(false);
|
||||||
|
}}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === 'zh' ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
|
||||||
|
>
|
||||||
|
简体中文
|
||||||
|
{language === 'zh' && <Check size={14} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Guide Button */}
|
||||||
|
<button
|
||||||
|
id="guide-button"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
// This will be handled in App.tsx via a custom event or shared state
|
||||||
|
window.dispatchEvent(new CustomEvent('start-user-guide'));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HelpCircle size={18} />
|
||||||
|
<span className="hidden sm:inline">{t.common.guide}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Reward Button */}
|
{/* Reward Button */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
@@ -46,7 +104,7 @@ export default function Navbar() {
|
|||||||
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"
|
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" />
|
<Heart size={14} className="fill-white" />
|
||||||
<span>赞赏</span>
|
<span>{t.common.reward}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Reward Modal */}
|
{/* Reward Modal */}
|
||||||
@@ -60,7 +118,7 @@ export default function Navbar() {
|
|||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<span className="text-lg font-bold text-gray-900">微信赞赏码</span>
|
<span className="text-lg font-bold text-gray-900">{t.navbar.rewardTitle}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowReward(false)}
|
onClick={() => setShowReward(false)}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
@@ -71,12 +129,12 @@ export default function Navbar() {
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<img
|
<img
|
||||||
src="https://cdn.texpixel.com/public/rewardcode.png"
|
src="https://cdn.texpixel.com/public/rewardcode.png"
|
||||||
alt="微信赞赏码"
|
alt={t.navbar.rewardTitle}
|
||||||
className="w-64 h-64 object-contain rounded-lg shadow-sm"
|
className="w-64 h-64 object-contain rounded-lg shadow-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-sm text-gray-500 text-center mt-4">
|
<p className="text-sm text-gray-500 text-center mt-4">
|
||||||
感谢您的支持与鼓励 ❤️<br />
|
{t.navbar.rewardThanks}<br />
|
||||||
<span className="text-xs text-gray-400 mt-1 block">您的支持是我们持续更新的动力</span>
|
<span className="text-xs text-gray-400 mt-1 block">{t.navbar.rewardSubtitle}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +149,7 @@ export default function Navbar() {
|
|||||||
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"
|
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} />
|
<MessageCircle size={14} />
|
||||||
<span>Contact Us</span>
|
<span>{t.common.contactUs}</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
size={14}
|
size={14}
|
||||||
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
|
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
|
||||||
@@ -109,7 +167,7 @@ export default function Navbar() {
|
|||||||
<Mail size={16} className="text-blue-600" />
|
<Mail size={16} className="text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500">Email</div>
|
<div className="text-xs text-gray-500">{t.common.email}</div>
|
||||||
<div className="text-sm font-medium text-gray-900">yogecoder@gmail.com</div>
|
<div className="text-sm font-medium text-gray-900">yogecoder@gmail.com</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -126,7 +184,7 @@ export default function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className={`text-xs transition-colors ${copied ? 'text-green-600 font-medium' : 'text-gray-500'}`}>
|
<div className={`text-xs transition-colors ${copied ? 'text-green-600 font-medium' : 'text-gray-500'}`}>
|
||||||
{copied ? 'Copied!' : 'QQ Group (Click to Copy)'}
|
{copied ? t.common.copied : t.common.qqGroup}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm font-medium text-gray-900">1018282100</div>
|
<div className="text-sm font-medium text-gray-900">1018282100</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import rehypeKatex from 'rehype-katex';
|
|||||||
import 'katex/dist/katex.min.css';
|
import 'katex/dist/katex.min.css';
|
||||||
import { RecognitionResult } from '../types';
|
import { RecognitionResult } from '../types';
|
||||||
import ExportSidebar from './ExportSidebar';
|
import ExportSidebar from './ExportSidebar';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
interface ResultPanelProps {
|
interface ResultPanelProps {
|
||||||
result: RecognitionResult | null;
|
result: RecognitionResult | null;
|
||||||
@@ -67,6 +68,7 @@ function preprocessLatex(content: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
|
export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [isExportSidebarOpen, setIsExportSidebarOpen] = useState(false);
|
const [isExportSidebarOpen, setIsExportSidebarOpen] = useState(false);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -75,44 +77,45 @@ export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
|
|||||||
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8">
|
<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>
|
<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">
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
{fileStatus === 'pending' ? 'Waiting in queue...' : 'Analyzing...'}
|
{fileStatus === 'pending' ? t.resultPanel.waitingQueue : t.resultPanel.analyzing}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-gray-500 max-w-sm">
|
<p className="text-gray-500 max-w-sm">
|
||||||
{fileStatus === 'pending'
|
{fileStatus === 'pending'
|
||||||
? 'Your file is in the queue, please wait.'
|
? t.resultPanel.queueSubtitle
|
||||||
: 'Texpixel is processing your file, this may take a moment.'}
|
: t.resultPanel.processingSubtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8">
|
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8" id="result-empty-state">
|
||||||
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
|
<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" />
|
<Code2 size={48} className="text-gray-900" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Waiting for recognition result</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">{t.resultPanel.waitingTitle}</h3>
|
||||||
<p className="text-gray-500 max-w-sm">
|
<p className="text-gray-500 max-w-sm">
|
||||||
After uploading the file, Texpixel will automatically recognize and display the result here
|
{t.resultPanel.waitingSubtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-white relative overflow-hidden">
|
<div className="flex flex-col h-full bg-white relative overflow-hidden" id="result-panel-content">
|
||||||
{/* Top Header */}
|
{/* 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="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">
|
<div className="flex items-center gap-4">
|
||||||
<h2 className="text-lg font-bold text-gray-900">Markdown</h2>
|
<h2 className="text-lg font-bold text-gray-900">{t.resultPanel.markdown}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
id="export-button"
|
||||||
onClick={() => setIsExportSidebarOpen(true)}
|
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' : ''}`}
|
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} />
|
<Download size={16} />
|
||||||
Export
|
{t.common.export}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { Upload, X, MousePointerClick, FileUp, ClipboardPaste } from 'lucide-react';
|
import { Upload, X, MousePointerClick, FileUp, ClipboardPaste } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
interface UploadModalProps {
|
interface UploadModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -7,6 +8,7 @@ interface UploadModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
|
export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [dragActive, setDragActive] = useState(false);
|
const [dragActive, setDragActive] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -75,7 +77,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
|
|||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
<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="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">上传文件</h2>
|
<h2 className="text-2xl font-bold text-gray-900">{t.uploadModal.title}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
@@ -99,6 +101,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
|
|||||||
<Upload size={32} />
|
<Upload size={32} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-600 mb-1">{t.sidebar.uploadInstruction}</p>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -108,22 +111,22 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 mt-4">
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
Support JPG, PNG format
|
{t.uploadModal.supportFormats}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-4 mt-6 text-xs text-gray-400">
|
<div className="flex items-center justify-center gap-4 mt-6 text-xs text-gray-400">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MousePointerClick className="w-3.5 h-3.5" />
|
<MousePointerClick className="w-3.5 h-3.5" />
|
||||||
<span>Click</span>
|
<span>{t.common.click}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FileUp className="w-3.5 h-3.5" />
|
<FileUp className="w-3.5 h-3.5" />
|
||||||
<span>Drop</span>
|
<span>{t.common.drop}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||||
<span>Paste</span>
|
<span>{t.common.paste}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
174
src/components/UserGuide.tsx
Normal file
174
src/components/UserGuide.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { X, ChevronRight, ChevronLeft } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
|
interface Step {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
position: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [highlightStyle, setHighlightStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const steps: Step[] = [
|
||||||
|
{
|
||||||
|
id: 'sidebar-upload-area',
|
||||||
|
title: t.guide.step1Title,
|
||||||
|
content: t.guide.step1Content,
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sidebar-history',
|
||||||
|
title: t.guide.step2Title,
|
||||||
|
content: t.guide.step2Content,
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file-preview-empty',
|
||||||
|
title: t.guide.step3Title,
|
||||||
|
content: t.guide.step3Content,
|
||||||
|
position: 'right',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'result-empty-state',
|
||||||
|
title: t.guide.step4Title,
|
||||||
|
content: t.guide.step4Content,
|
||||||
|
position: 'left',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'export-button',
|
||||||
|
title: t.guide.stepExportTitle,
|
||||||
|
content: t.guide.stepExportContent,
|
||||||
|
position: 'left',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const updateHighlight = useCallback(() => {
|
||||||
|
if (!isOpen || steps.length === 0) return;
|
||||||
|
|
||||||
|
const element = document.getElementById(steps[currentStep].id);
|
||||||
|
if (element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
setHighlightStyle({
|
||||||
|
top: rect.top - 8,
|
||||||
|
left: rect.left - 8,
|
||||||
|
width: rect.width + 16,
|
||||||
|
height: rect.height + 16,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
} else {
|
||||||
|
setHighlightStyle({ opacity: 0 });
|
||||||
|
}
|
||||||
|
}, [currentStep, isOpen, steps, t.guide]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
updateHighlight();
|
||||||
|
window.addEventListener('resize', updateHighlight);
|
||||||
|
}
|
||||||
|
return () => window.removeEventListener('resize', updateHighlight);
|
||||||
|
}, [isOpen, updateHighlight]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentStep < steps.length - 1) {
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
} else {
|
||||||
|
onClose();
|
||||||
|
setCurrentStep(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (currentStep > 0) {
|
||||||
|
setCurrentStep(currentStep - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[100] pointer-events-none">
|
||||||
|
{/* Backdrop with hole */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 pointer-events-auto" onClick={onClose} style={{
|
||||||
|
clipPath: highlightStyle.top !== undefined ? `polygon(
|
||||||
|
0% 0%, 0% 100%,
|
||||||
|
${highlightStyle.left}px 100%,
|
||||||
|
${highlightStyle.left}px ${highlightStyle.top}px,
|
||||||
|
${(highlightStyle.left as number) + (highlightStyle.width as number)}px ${highlightStyle.top}px,
|
||||||
|
${(highlightStyle.left as number) + (highlightStyle.width as number)}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
|
||||||
|
${highlightStyle.left}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
|
||||||
|
${highlightStyle.left}px 100%,
|
||||||
|
100% 100%, 100% 0%
|
||||||
|
)` : 'none'
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{/* Highlight border */}
|
||||||
|
<div
|
||||||
|
className="absolute border-2 border-blue-500 rounded-xl transition-all duration-300 shadow-[0_0_0_9999px_rgba(0,0,0,0.5)]"
|
||||||
|
style={highlightStyle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tooltip */}
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-auto bg-white rounded-xl shadow-2xl p-6 w-80 transition-all duration-300 animate-in fade-in zoom-in-95"
|
||||||
|
style={highlightStyle.top !== undefined ? {
|
||||||
|
top: steps[currentStep].position === 'bottom'
|
||||||
|
? (highlightStyle.top as number) + (highlightStyle.height as number) + 16
|
||||||
|
: steps[currentStep].position === 'top'
|
||||||
|
? (highlightStyle.top as number) - 200 // approximation
|
||||||
|
: (highlightStyle.top as number),
|
||||||
|
left: steps[currentStep].position === 'right'
|
||||||
|
? (highlightStyle.left as number) + (highlightStyle.width as number) + 16
|
||||||
|
: steps[currentStep].position === 'left'
|
||||||
|
? (highlightStyle.left as number) - 336
|
||||||
|
: (highlightStyle.left as number),
|
||||||
|
} : {
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="text-xs font-bold text-blue-600 uppercase tracking-wider">
|
||||||
|
Step {currentStep + 1} of {steps.length}
|
||||||
|
</span>
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mt-1">{steps[currentStep].title}</h3>
|
||||||
|
<p className="text-sm text-gray-600 mt-2 leading-relaxed">
|
||||||
|
{steps[currentStep].content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-6">
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={currentStep === 0}
|
||||||
|
className={`flex items-center gap-1 text-sm font-medium ${currentStep === 0 ? 'text-gray-300 cursor-not-allowed' : 'text-gray-600 hover:text-gray-900'}`}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} />
|
||||||
|
{t.guide.prev}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors flex items-center gap-1"
|
||||||
|
>
|
||||||
|
{currentStep === steps.length - 1 ? t.guide.finish : t.guide.next}
|
||||||
|
{currentStep < steps.length - 1 && <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/contexts/LanguageContext.tsx
Normal file
64
src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||||
|
import { translations, Language, TranslationKey } from '../lib/translations';
|
||||||
|
import { detectLanguageByIP } from '../lib/ipLocation';
|
||||||
|
|
||||||
|
interface LanguageContextType {
|
||||||
|
language: Language;
|
||||||
|
setLanguage: (lang: Language) => void;
|
||||||
|
t: TranslationKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
// 初始化语言:优先使用 localStorage,否则使用浏览器语言作为临时值
|
||||||
|
const [language, setLanguageState] = useState<Language>(() => {
|
||||||
|
const saved = localStorage.getItem('language');
|
||||||
|
if (saved === 'en' || saved === 'zh') return saved;
|
||||||
|
// 临时使用浏览器语言,后续会被IP检测覆盖(如果没有保存的语言)
|
||||||
|
return navigator.language.startsWith('zh') ? 'zh' : 'en';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检测IP地理位置并设置语言(仅在首次加载且没有保存的语言时)
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem('language');
|
||||||
|
|
||||||
|
// 如果用户已经手动选择过语言,则不进行IP检测
|
||||||
|
if (saved === 'en' || saved === 'zh') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步检测IP并设置语言
|
||||||
|
detectLanguageByIP()
|
||||||
|
.then((detectedLang) => {
|
||||||
|
setLanguageState(detectedLang);
|
||||||
|
// 注意:这里不保存到 localStorage,让用户首次访问时使用IP检测的结果
|
||||||
|
// 如果用户手动切换语言,才会保存到 localStorage
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
// IP检测失败时,保持使用浏览器语言检测的结果
|
||||||
|
console.warn('Failed to detect language by IP:', error);
|
||||||
|
});
|
||||||
|
}, []); // 仅在组件挂载时执行一次
|
||||||
|
|
||||||
|
const setLanguage = (lang: Language) => {
|
||||||
|
setLanguageState(lang);
|
||||||
|
localStorage.setItem('language', lang);
|
||||||
|
};
|
||||||
|
|
||||||
|
const t = translations[language];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLanguage = () => {
|
||||||
|
const context = useContext(LanguageContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
78
src/lib/ipLocation.ts
Normal file
78
src/lib/ipLocation.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* IP 地理位置检测工具
|
||||||
|
* 用于根据用户IP地址判断语言偏好
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface IPLocationResponse {
|
||||||
|
country_code?: string;
|
||||||
|
country?: string;
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据IP地址检测用户所在国家/地区
|
||||||
|
* 使用免费的 ipapi.co 服务(无需API key)
|
||||||
|
*
|
||||||
|
* @returns Promise<string | null> 返回国家代码(如 'CN', 'US'),失败返回 null
|
||||||
|
*/
|
||||||
|
export async function detectCountryByIP(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
// 使用 ipapi.co 免费服务(无需API key,有速率限制但足够使用)
|
||||||
|
const response = await fetch('https://ipapi.co/json/', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('IP location detection failed:', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: IPLocationResponse = await response.json();
|
||||||
|
|
||||||
|
if (data.error || !data.country_code) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.country_code.toUpperCase();
|
||||||
|
} catch (error) {
|
||||||
|
// 静默失败,不影响用户体验
|
||||||
|
console.warn('IP location detection error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据国家代码判断应该使用的语言
|
||||||
|
*
|
||||||
|
* @param countryCode 国家代码(如 'CN', 'TW', 'HK', 'SG' 等)
|
||||||
|
* @returns 'zh' | 'en' 推荐的语言
|
||||||
|
*/
|
||||||
|
export function getLanguageByCountry(countryCode: string | null): 'zh' | 'en' {
|
||||||
|
if (!countryCode) {
|
||||||
|
return 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 中文地区列表
|
||||||
|
const chineseRegions = [
|
||||||
|
'CN', // 中国大陆
|
||||||
|
'TW', // 台湾
|
||||||
|
'HK', // 香港
|
||||||
|
'MO', // 澳门
|
||||||
|
'SG', // 新加坡(主要使用中文)
|
||||||
|
];
|
||||||
|
|
||||||
|
return chineseRegions.includes(countryCode) ? 'zh' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测用户IP并返回推荐语言
|
||||||
|
*
|
||||||
|
* @returns Promise<'zh' | 'en'> 推荐的语言
|
||||||
|
*/
|
||||||
|
export async function detectLanguageByIP(): Promise<'zh' | 'en'> {
|
||||||
|
const countryCode = await detectCountryByIP();
|
||||||
|
return getLanguageByCountry(countryCode);
|
||||||
|
}
|
||||||
193
src/lib/translations.ts
Normal file
193
src/lib/translations.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
export const translations = {
|
||||||
|
en: {
|
||||||
|
common: {
|
||||||
|
upload: 'Upload',
|
||||||
|
history: 'History',
|
||||||
|
login: 'Login / Register',
|
||||||
|
logout: 'Logout',
|
||||||
|
loading: 'Loading...',
|
||||||
|
processing: 'Processing...',
|
||||||
|
cancel: 'Cancel',
|
||||||
|
copy: 'Copy',
|
||||||
|
copied: 'Copied!',
|
||||||
|
download: 'Download',
|
||||||
|
export: 'Export',
|
||||||
|
preview: 'Preview',
|
||||||
|
email: 'Email',
|
||||||
|
contactUs: 'Contact Us',
|
||||||
|
reward: 'Reward',
|
||||||
|
qqGroup: 'QQ Group (Click to Copy)',
|
||||||
|
guide: 'User Guide',
|
||||||
|
click: 'Click',
|
||||||
|
drop: 'Drop',
|
||||||
|
paste: 'Paste',
|
||||||
|
},
|
||||||
|
navbar: {
|
||||||
|
rewardTitle: 'WeChat Reward',
|
||||||
|
rewardThanks: 'Thank you for your support and encouragement ❤️',
|
||||||
|
rewardSubtitle: 'Your support is our motivation for continuous updates',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
title: 'Formula Recognize',
|
||||||
|
subtitle: 'Support handwriting and printed formulas',
|
||||||
|
uploadInstruction: 'Click, Drop, or Paste a file to start parsing',
|
||||||
|
pleaseLogin: 'Please login to view history',
|
||||||
|
noHistory: 'No history records',
|
||||||
|
noMore: 'No more records',
|
||||||
|
historyHeader: 'History',
|
||||||
|
},
|
||||||
|
uploadModal: {
|
||||||
|
title: 'Upload File',
|
||||||
|
supportFormats: 'Support JPG, PNG, PDF format',
|
||||||
|
},
|
||||||
|
resultPanel: {
|
||||||
|
waitingTitle: 'Waiting for recognition result',
|
||||||
|
waitingSubtitle: 'After uploading the file, Texpixel will automatically recognize and display the result here',
|
||||||
|
analyzing: 'Analyzing...',
|
||||||
|
waitingQueue: 'Waiting in queue...',
|
||||||
|
queueSubtitle: 'Your file is in the queue, please wait.',
|
||||||
|
processingSubtitle: 'Texpixel is processing your file, this may take a moment.',
|
||||||
|
markdown: 'Markdown',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
signIn: 'Login',
|
||||||
|
signUp: 'Register',
|
||||||
|
signInTitle: 'Login Account',
|
||||||
|
signUpTitle: 'Register Account',
|
||||||
|
email: 'Email',
|
||||||
|
password: 'Password',
|
||||||
|
error: 'Error',
|
||||||
|
genericError: 'An error occurred, please try again',
|
||||||
|
hasAccount: 'Already have an account? Login',
|
||||||
|
noAccount: 'No account? Register',
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
title: 'Export',
|
||||||
|
categories: {
|
||||||
|
code: 'Code',
|
||||||
|
image: 'Image',
|
||||||
|
file: 'File',
|
||||||
|
},
|
||||||
|
failed: 'Export failed, please try again',
|
||||||
|
imageFailed: 'Failed to generate image',
|
||||||
|
},
|
||||||
|
guide: {
|
||||||
|
next: 'Next',
|
||||||
|
prev: 'Back',
|
||||||
|
finish: 'Finish',
|
||||||
|
skip: 'Skip',
|
||||||
|
step1Title: 'Upload Area',
|
||||||
|
step1Content: 'Click or drag and drop your formula images/PDFs here to start recognition.',
|
||||||
|
step2Title: 'File History',
|
||||||
|
step2Content: 'Your recognized files will appear here. Login to sync across devices.',
|
||||||
|
step3Title: 'Preview Area',
|
||||||
|
step3Content: 'The original file you uploaded will be displayed here for comparison.',
|
||||||
|
step4Title: 'Recognition Result',
|
||||||
|
step4Content: 'The recognition results (Markdown/LaTeX) will be shown here.',
|
||||||
|
stepExportTitle: 'Export Result',
|
||||||
|
stepExportContent: 'You can export the recognition results to various formats such as Markdown, LaTeX, Word, or Image.',
|
||||||
|
},
|
||||||
|
alerts: {
|
||||||
|
taskTimeout: 'Task timeout: Recognition took too long.',
|
||||||
|
networkError: 'Task timeout or network error.',
|
||||||
|
uploadFailed: 'Upload failed',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
common: {
|
||||||
|
upload: '上传',
|
||||||
|
history: '历史记录',
|
||||||
|
login: '登录 / 注册',
|
||||||
|
logout: '退出登录',
|
||||||
|
loading: '加载中...',
|
||||||
|
processing: '处理中...',
|
||||||
|
cancel: '取消',
|
||||||
|
copy: '复制',
|
||||||
|
copied: '已复制!',
|
||||||
|
download: '下载',
|
||||||
|
export: '导出',
|
||||||
|
preview: '预览',
|
||||||
|
email: '邮箱',
|
||||||
|
contactUs: '联系我们',
|
||||||
|
reward: '赞赏',
|
||||||
|
qqGroup: 'QQ 群 (点击复制)',
|
||||||
|
guide: '使用引导',
|
||||||
|
click: '点击',
|
||||||
|
drop: '拖拽',
|
||||||
|
paste: '粘贴',
|
||||||
|
},
|
||||||
|
navbar: {
|
||||||
|
rewardTitle: '微信赞赏码',
|
||||||
|
rewardThanks: '感谢您的支持与鼓励 ❤️',
|
||||||
|
rewardSubtitle: '您的支持是我们持续更新的动力',
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
title: '文档识别',
|
||||||
|
subtitle: '支持手写和印刷体文档识别',
|
||||||
|
uploadInstruction: '点击、拖拽或粘贴文件开始解析',
|
||||||
|
pleaseLogin: '请登录后查看历史记录',
|
||||||
|
noHistory: '暂无历史记录',
|
||||||
|
noMore: '没有更多记录了',
|
||||||
|
historyHeader: '历史记录',
|
||||||
|
},
|
||||||
|
uploadModal: {
|
||||||
|
title: '上传文件',
|
||||||
|
supportFormats: '支持 JPG, PNG 格式',
|
||||||
|
},
|
||||||
|
resultPanel: {
|
||||||
|
waitingTitle: '等待识别结果',
|
||||||
|
waitingSubtitle: '上传文件后,TexPixel 将自动识别并在此显示结果',
|
||||||
|
analyzing: '解析中...',
|
||||||
|
waitingQueue: '排队中...',
|
||||||
|
queueSubtitle: '您的文件正在排队,请稍候。',
|
||||||
|
processingSubtitle: 'TexPixel 正在处理您的文件,请稍候。',
|
||||||
|
markdown: 'Markdown',
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
signIn: '登录',
|
||||||
|
signUp: '注册',
|
||||||
|
signInTitle: '登录账号',
|
||||||
|
signUpTitle: '注册账号',
|
||||||
|
email: '邮箱',
|
||||||
|
password: '密码',
|
||||||
|
error: '错误',
|
||||||
|
genericError: '发生错误,请重试',
|
||||||
|
hasAccount: '已有账号?去登录',
|
||||||
|
noAccount: '没有账号?去注册',
|
||||||
|
},
|
||||||
|
export: {
|
||||||
|
title: '导出',
|
||||||
|
categories: {
|
||||||
|
code: '代码',
|
||||||
|
image: '图片',
|
||||||
|
file: '文件',
|
||||||
|
},
|
||||||
|
failed: '导出失败,请重试',
|
||||||
|
imageFailed: '生成图片失败',
|
||||||
|
},
|
||||||
|
guide: {
|
||||||
|
next: '下一步',
|
||||||
|
prev: '上一步',
|
||||||
|
finish: '完成',
|
||||||
|
skip: '跳过',
|
||||||
|
step1Title: '上传区域',
|
||||||
|
step1Content: '点击此处或将公式/文档图片 粘贴或者拖拽到这里开始识别。',
|
||||||
|
step2Title: '历史记录',
|
||||||
|
step2Content: '识别过的文件会显示在这里。登录后可以跨设备同步。',
|
||||||
|
step3Title: '预览区域',
|
||||||
|
step3Content: '这里会显示您上传的原始文件,方便对比。',
|
||||||
|
step4Title: '识别结果',
|
||||||
|
step4Content: '这里会显示识别出的 Markdown/LaTeX 结果。',
|
||||||
|
stepExportTitle: '导出结果',
|
||||||
|
stepExportContent: '您可以将识别结果导出为多种格式,如 Markdown、LaTeX、Word 或图片。',
|
||||||
|
},
|
||||||
|
alerts: {
|
||||||
|
taskTimeout: '任务超时:识别时间过长。',
|
||||||
|
networkError: '任务超时或网络错误。',
|
||||||
|
uploadFailed: '上传失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Language = 'en' | 'zh';
|
||||||
|
export type TranslationKey = typeof translations.en;
|
||||||
@@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
|
import { LanguageProvider } from './contexts/LanguageContext';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<LanguageProvider>
|
||||||
|
<App />
|
||||||
|
</LanguageProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ export default defineConfig({
|
|||||||
exclude: ['lucide-react'],
|
exclude: ['lucide-react'],
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
// 确保生成带哈希的文件名(默认已启用)
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
// 确保文件名包含哈希,便于缓存管理
|
||||||
|
entryFileNames: 'assets/[name]-[hash].js',
|
||||||
|
chunkFileNames: 'assets/[name]-[hash].js',
|
||||||
|
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||||
manualChunks: {
|
manualChunks: {
|
||||||
// React 核心
|
// React 核心
|
||||||
'vendor-react': ['react', 'react-dom'],
|
'vendor-react': ['react', 'react-dom'],
|
||||||
|
|||||||
Reference in New Issue
Block a user