Compare commits
8 Commits
6747205bd0
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e90fca5ab1 | ||
|
|
bc4b547e03 | ||
|
|
e4c6a09cf8 | ||
|
|
2b1da79bbc | ||
| 564aaec581 | |||
|
|
d562d67203 | ||
|
|
7c5409a6c7 | ||
|
|
42850c4460 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
||||
*.sw?
|
||||
.env
|
||||
/dist
|
||||
app.cloud/
|
||||
|
||||
202
deploy_dev.sh
Executable file
202
deploy_dev.sh
Executable file
@@ -0,0 +1,202 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Document AI Frontend 部署脚本
|
||||
# 功能:构建项目并部署到 ubuntu 服务器
|
||||
|
||||
set -e # 遇到错误立即退出
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 服务器配置
|
||||
ubuntu_HOST="ubuntu"
|
||||
DEPLOY_PATH="/var/www"
|
||||
DEPLOY_NAME="app.cloud"
|
||||
|
||||
# 打印带颜色的消息
|
||||
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}..."
|
||||
|
||||
# 上传构建产物(app.cloud 目录)
|
||||
print_info "上传 ${DEPLOY_NAME} 目录到 ${server}..."
|
||||
scp_output=$(scp -r ${DEPLOY_NAME} ${server}:~ 2>&1)
|
||||
scp_exit_code=$?
|
||||
if [ $scp_exit_code -eq 0 ]; then
|
||||
print_success "文件上传成功"
|
||||
else
|
||||
print_error "文件上传失败,请检查 SSH 连接和权限"
|
||||
echo "$scp_output" | sed 's/^/ /'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# SSH 执行部署操作(非交互模式)
|
||||
print_info "在 ${server} 上执行部署操作..."
|
||||
print_info "部署路径: ${DEPLOY_PATH}/${DEPLOY_NAME}"
|
||||
ssh_output=$(ssh ${server} bash << SSH_EOF
|
||||
set -e
|
||||
DEPLOY_PATH="${DEPLOY_PATH}"
|
||||
DEPLOY_NAME="${DEPLOY_NAME}"
|
||||
|
||||
# 检查部署目录是否存在
|
||||
if [ ! -d "\${DEPLOY_PATH}" ]; then
|
||||
echo "错误:部署目录 \${DEPLOY_PATH} 不存在,请检查路径是否正确"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否有权限写入,若无则尝试免密 sudo(sudo -n)
|
||||
SUDO_CMD=""
|
||||
if ! touch "\${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
|
||||
if sudo -n true 2>/dev/null; then
|
||||
echo "提示:没有直接写入权限,使用 sudo -n 执行部署操作"
|
||||
SUDO_CMD="sudo -n"
|
||||
else
|
||||
echo "错误:没有写入权限,且 sudo 需要密码(非交互部署无法输入)"
|
||||
echo "请执行以下任一方案后重试:"
|
||||
echo " 1) 将部署目录改为当前用户可写目录(例如 /home/\$USER/www)"
|
||||
echo " 2) 为当前用户配置免密 sudo(NOPASSWD)"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
rm -f "\${DEPLOY_PATH}/.deploy_test"
|
||||
echo "提示:检测到部署目录可直接写入"
|
||||
fi
|
||||
|
||||
# 备份旧版本(如果存在)
|
||||
if [ -d "\${DEPLOY_PATH}/\${DEPLOY_NAME}" ]; then
|
||||
echo "备份旧版本..."
|
||||
\$SUDO_CMD rm -rf "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" 2>/dev/null || true
|
||||
\$SUDO_CMD mv "\${DEPLOY_PATH}/\${DEPLOY_NAME}" "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" || { echo "错误:备份失败,权限不足"; exit 1; }
|
||||
fi
|
||||
|
||||
# 移动新版本到部署目录(覆盖现有目录)
|
||||
if [ -d ~/\${DEPLOY_NAME} ]; then
|
||||
echo "移动新版本到部署目录..."
|
||||
\$SUDO_CMD mv ~/\${DEPLOY_NAME} "\${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
|
||||
echo "部署完成!"
|
||||
else
|
||||
echo "错误:找不到 ~/\${DEPLOY_NAME} 目录"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 重新加载 nginx(如果配置了)
|
||||
if command -v nginx &> /dev/null; then
|
||||
echo "重新加载 nginx..."
|
||||
if [ -n "\$SUDO_CMD" ]; then
|
||||
\$SUDO_CMD nginx -t && \$SUDO_CMD nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||
else
|
||||
nginx -t && nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||
fi
|
||||
fi
|
||||
SSH_EOF
|
||||
)
|
||||
|
||||
ssh_exit_code=$?
|
||||
|
||||
# 显示 SSH 输出
|
||||
if [ -n "$ssh_output" ]; then
|
||||
echo "$ssh_output" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [ $ssh_exit_code -eq 0 ]; then
|
||||
print_success "${server} 部署成功!"
|
||||
else
|
||||
print_error "${server} 部署失败!"
|
||||
print_error "请检查:"
|
||||
print_error " 1. SSH 连接是否正常"
|
||||
print_error " 2. 部署目录 ${DEPLOY_PATH} 是否存在"
|
||||
print_error " 3. 是否有 sudo 权限(如果需要)"
|
||||
print_error " 4. 上传的文件 ~/${DEPLOY_NAME} 是否存在"
|
||||
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:dev; then
|
||||
print_success "构建完成!"
|
||||
else
|
||||
print_error "构建失败!"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 检查 dist 目录是否存在
|
||||
if [ ! -d "dist" ]; then
|
||||
print_error "dist 目录不存在,构建可能失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 重命名 dist 为 app.cloud
|
||||
print_info "重命名 dist 为 ${DEPLOY_NAME}..."
|
||||
if [ -d "${DEPLOY_NAME}" ]; then
|
||||
rm -rf "${DEPLOY_NAME}"
|
||||
fi
|
||||
mv dist "${DEPLOY_NAME}"
|
||||
print_success "重命名完成"
|
||||
echo ""
|
||||
|
||||
# 步骤2: 部署到 ubuntu
|
||||
print_info "步骤 2/2: 部署到 ubuntu..."
|
||||
if deploy_to_server ${ubuntu_HOST}; then
|
||||
print_success "ubuntu 部署完成"
|
||||
else
|
||||
print_error "ubuntu 部署失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 完成
|
||||
print_info "清理临时文件..."
|
||||
# 可以选择是否删除本地的 app.cloud 目录
|
||||
# rm -rf ${DEPLOY_NAME}
|
||||
|
||||
print_success "=========================================="
|
||||
print_success "部署完成!"
|
||||
print_success "=========================================="
|
||||
}
|
||||
|
||||
# 运行主函数
|
||||
main
|
||||
145
deploy_prod.sh
Executable file
145
deploy_prod.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:prod; 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
|
||||
140
docs/MULTILANG_IMPLEMENTATION.md
Normal file
140
docs/MULTILANG_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 多语言功能实现总结
|
||||
|
||||
## 📋 实现内容
|
||||
|
||||
### 1. HTML 文档改进 (`index.html`)
|
||||
- ✅ 添加 `hreflang` 标签支持多语言 SEO
|
||||
- ✅ 添加双语 meta 标签(description, keywords)
|
||||
- ✅ 添加 Open Graph 和 Twitter Cards 多语言支持
|
||||
- ✅ 添加动态语言检测脚本
|
||||
- ✅ 优化 `og:locale` 和 `og:locale:alternate` 标签
|
||||
|
||||
### 2. SEO 辅助工具 (`src/lib/seoHelper.ts`)
|
||||
- ✅ 创建 `updatePageMeta()` 函数动态更新 meta 标签
|
||||
- ✅ 创建 `getSEOContent()` 函数获取语言特定的 SEO 内容
|
||||
- ✅ 支持更新标题、描述、关键词、OG 标签等
|
||||
|
||||
### 3. 语言上下文增强 (`src/contexts/LanguageContext.tsx`)
|
||||
- ✅ 集成 `updatePageMeta()` 在语言切换时自动更新页面
|
||||
- ✅ 在初始加载时根据用户偏好更新 meta 标签
|
||||
- ✅ 在 IP 检测后更新 meta 标签
|
||||
|
||||
### 4. 文档和测试
|
||||
- ✅ 创建详细的多语言支持文档 (`MULTILANG_README.md`)
|
||||
- ✅ 创建浏览器测试脚本 (`test-multilang.js`)
|
||||
- ✅ 包含最佳实践和使用示例
|
||||
|
||||
## 🎯 功能特性
|
||||
|
||||
### 自动语言检测优先级
|
||||
1. 用户在 localStorage 中保存的选择(最高优先级)
|
||||
2. IP 地理位置检测
|
||||
3. 浏览器语言设置(回退)
|
||||
|
||||
### SEO 优化
|
||||
- **搜索引擎**: 完整的 hreflang 支持
|
||||
- **社交媒体**: Open Graph 和 Twitter Cards
|
||||
- **元数据**: 动态更新标题、描述、关键词
|
||||
|
||||
### 用户体验
|
||||
- 一键语言切换(在导航栏)
|
||||
- 无刷新页面更新
|
||||
- 保持用户选择跨会话
|
||||
|
||||
## 📝 使用示例
|
||||
|
||||
### 切换语言
|
||||
```typescript
|
||||
import { useLanguage } from './contexts/LanguageContext';
|
||||
|
||||
function Component() {
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
|
||||
return (
|
||||
<button onClick={() => setLanguage('en')}>
|
||||
{t.common.switchLanguage}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新翻译
|
||||
在 `src/lib/translations.ts` 中:
|
||||
```typescript
|
||||
export const translations = {
|
||||
en: {
|
||||
newFeature: {
|
||||
title: 'New Feature',
|
||||
}
|
||||
},
|
||||
zh: {
|
||||
newFeature: {
|
||||
title: '新功能',
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 更新 SEO 内容
|
||||
在 `src/lib/seoHelper.ts` 中:
|
||||
```typescript
|
||||
const seoContent: Record<Language, SEOContent> = {
|
||||
en: {
|
||||
title: 'Your English Title',
|
||||
description: 'Your English description',
|
||||
keywords: 'english, keywords',
|
||||
},
|
||||
zh: {
|
||||
title: '您的中文标题',
|
||||
description: '您的中文描述',
|
||||
keywords: '中文,关键词',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 🧪 测试清单
|
||||
|
||||
- [ ] 页面首次加载时语言检测正确
|
||||
- [ ] 切换语言时标题更新
|
||||
- [ ] 切换语言时 meta 标签更新
|
||||
- [ ] HTML lang 属性同步更新
|
||||
- [ ] localStorage 正确保存用户选择
|
||||
- [ ] 所有 UI 文本正确翻译
|
||||
- [ ] Open Graph 预览正确显示(使用 [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/))
|
||||
- [ ] Twitter Card 预览正确显示(使用 [Twitter Card Validator](https://cards-dev.twitter.com/validator))
|
||||
|
||||
## 🚀 部署注意事项
|
||||
|
||||
### 服务器配置
|
||||
如果使用基于 URL 的语言路由(如 `/en`, `/zh`),需要配置服务器重写规则:
|
||||
|
||||
**Nginx 示例**:
|
||||
```nginx
|
||||
location ~ ^/(en|zh) {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
**Apache 示例**:
|
||||
```apache
|
||||
RewriteEngine On
|
||||
RewriteRule ^(en|zh)/ /index.html [L]
|
||||
```
|
||||
|
||||
### CDN 缓存
|
||||
确保 CDN 不会缓存带有错误语言的页面:
|
||||
- 使用 `Vary: Accept-Language` 头
|
||||
- 或在 URL 中包含语言参数
|
||||
|
||||
## 🔗 相关资源
|
||||
|
||||
- [Google 国际化指南](https://developers.google.com/search/docs/specialty/international/localized-versions)
|
||||
- [Open Graph Protocol](https://ogp.me/)
|
||||
- [Twitter Cards 文档](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
|
||||
- [hreflang 最佳实践](https://support.google.com/webmasters/answer/189077)
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如有问题或建议,请联系:
|
||||
- Email: yogecoder@gmail.com
|
||||
- QQ 群: 1018282100
|
||||
174
docs/MULTILANG_README.md
Normal file
174
docs/MULTILANG_README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 多语言支持说明 / Multi-language Support
|
||||
|
||||
## 概述 / Overview
|
||||
|
||||
TexPixel 现在支持完整的中英文双语切换,包括:
|
||||
- 动态页面标题和 meta 标签更新
|
||||
- SEO 优化的多语言支持
|
||||
- 智能语言检测(基于 IP 和浏览器偏好)
|
||||
|
||||
TexPixel now supports complete bilingual switching between Chinese and English, including:
|
||||
- Dynamic page title and meta tag updates
|
||||
- SEO-optimized multi-language support
|
||||
- Intelligent language detection (based on IP and browser preferences)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性 / Features
|
||||
|
||||
### 1. 自动语言检测 / Automatic Language Detection
|
||||
|
||||
应用会按以下优先级检测用户语言:
|
||||
1. localStorage 中保存的用户选择
|
||||
2. IP 地理位置检测
|
||||
3. 浏览器语言设置
|
||||
|
||||
The app detects user language in the following order of priority:
|
||||
1. User's saved preference in localStorage
|
||||
2. IP geolocation detection
|
||||
3. Browser language settings
|
||||
|
||||
### 2. SEO 优化 / SEO Optimization
|
||||
|
||||
- **hreflang 标签**:告知搜索引擎不同语言版本的页面
|
||||
- **多语言 meta 标签**:description、keywords、og:locale 等
|
||||
- **动态标题更新**:切换语言时自动更新页面标题
|
||||
|
||||
Features include:
|
||||
- **hreflang tags**: Inform search engines about different language versions
|
||||
- **Multilingual meta tags**: description, keywords, og:locale, etc.
|
||||
- **Dynamic title updates**: Automatically update page title when switching languages
|
||||
|
||||
### 3. Open Graph 和 Twitter Cards
|
||||
|
||||
支持社交媒体分享的多语言 meta 标签:
|
||||
- Facebook (Open Graph)
|
||||
- Twitter Cards
|
||||
- 其他支持 OG 协议的平台
|
||||
|
||||
Multilingual meta tags for social media sharing:
|
||||
- Facebook (Open Graph)
|
||||
- Twitter Cards
|
||||
- Other platforms supporting OG protocol
|
||||
|
||||
---
|
||||
|
||||
## 技术实现 / Technical Implementation
|
||||
|
||||
### 文件结构 / File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── seoHelper.ts # SEO 元数据管理 / SEO metadata management
|
||||
│ ├── translations.ts # 翻译文本 / Translation texts
|
||||
│ └── ipLocation.ts # IP 定位 / IP location detection
|
||||
├── contexts/
|
||||
│ └── LanguageContext.tsx # 语言上下文 / Language context
|
||||
└── components/
|
||||
└── Navbar.tsx # 语言切换器 / Language switcher
|
||||
```
|
||||
|
||||
### 核心函数 / Core Functions
|
||||
|
||||
#### `updatePageMeta(language: Language)`
|
||||
更新页面的所有 SEO 相关元数据,包括:
|
||||
- document.title
|
||||
- HTML lang 属性
|
||||
- meta description
|
||||
- meta keywords
|
||||
- Open Graph 标签
|
||||
- Twitter Card 标签
|
||||
|
||||
Updates all SEO-related metadata on the page, including:
|
||||
- document.title
|
||||
- HTML lang attribute
|
||||
- meta description
|
||||
- meta keywords
|
||||
- Open Graph tags
|
||||
- Twitter Card tags
|
||||
|
||||
#### `getSEOContent(language: Language)`
|
||||
获取指定语言的 SEO 内容(标题、描述、关键词)
|
||||
|
||||
Get SEO content for a specific language (title, description, keywords)
|
||||
|
||||
---
|
||||
|
||||
## 使用方法 / Usage
|
||||
|
||||
### 在组件中使用语言功能 / Using Language Features in Components
|
||||
|
||||
```typescript
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
function MyComponent() {
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t.common.title}</h1>
|
||||
<button onClick={() => setLanguage('en')}>English</button>
|
||||
<button onClick={() => setLanguage('zh')}>中文</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的翻译文本 / Adding New Translation Texts
|
||||
|
||||
在 `src/lib/translations.ts` 中添加:
|
||||
|
||||
```typescript
|
||||
export const translations = {
|
||||
en: {
|
||||
myFeature: {
|
||||
title: 'My Feature',
|
||||
description: 'Feature description',
|
||||
}
|
||||
},
|
||||
zh: {
|
||||
myFeature: {
|
||||
title: '我的功能',
|
||||
description: '功能描述',
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践 / Best Practices
|
||||
|
||||
1. **始终提供双语内容** / Always provide bilingual content
|
||||
- 确保所有用户可见的文本都有中英文翻译
|
||||
- Ensure all user-visible text has Chinese and English translations
|
||||
|
||||
2. **保持 SEO 元数据最新** / Keep SEO metadata up-to-date
|
||||
- 在 `seoHelper.ts` 中维护准确的页面描述和关键词
|
||||
- Maintain accurate page descriptions and keywords in `seoHelper.ts`
|
||||
|
||||
3. **测试语言切换** / Test language switching
|
||||
- 确保切换语言时页面标题和 meta 标签正确更新
|
||||
- Ensure page title and meta tags update correctly when switching languages
|
||||
|
||||
4. **考虑 RTL 语言** / Consider RTL languages
|
||||
- 虽然目前只支持中英文,但代码架构支持未来添加其他语言
|
||||
- While currently supporting only Chinese and English, the architecture supports adding other languages in the future
|
||||
|
||||
---
|
||||
|
||||
## 路线图 / Roadmap
|
||||
|
||||
- [ ] 添加更多语言支持(日语、韩语等)/ Add more language support (Japanese, Korean, etc.)
|
||||
- [ ] 实现 URL 路由多语言 (/en, /zh) / Implement URL routing for languages
|
||||
- [ ] 服务端渲染 (SSR) 支持 / Server-side rendering (SSR) support
|
||||
- [ ] 语言特定的日期和数字格式化 / Language-specific date and number formatting
|
||||
|
||||
---
|
||||
|
||||
## 相关链接 / Related Links
|
||||
|
||||
- [hreflang 标签最佳实践](https://developers.google.com/search/docs/specialty/international/localized-versions)
|
||||
- [Open Graph Protocol](https://ogp.me/)
|
||||
- [Twitter Cards Documentation](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
|
||||
78
index.html
78
index.html
@@ -1,35 +1,89 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
|
||||
<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>
|
||||
<link rel="canonical" href="https://texpixel.com/" />
|
||||
<link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/" />
|
||||
<link rel="alternate" hreflang="en" href="https://texpixel.com/en/" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
|
||||
<!-- Dynamic Title (will be updated by app) -->
|
||||
<title>⚡️ TexPixel - 公式识别工具 | Formula Recognition Tool</title>
|
||||
|
||||
<!-- SEO Meta Tags - Chinese (Default) -->
|
||||
<meta name="description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。Online formula recognition tool supporting printed and handwritten math formulas." />
|
||||
<meta name="keywords"
|
||||
content="公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,handwriting,formula,recognition,ocr,handwriting,recognition,math,handwriting,free" />
|
||||
content="公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,formula recognition,math formula,handwriting recognition,latex,mathml,markdown,texpixel,TexPixel,混合文字识别,document recognition" />
|
||||
<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="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
|
||||
<!-- Open Graph Meta Tags - Bilingual -->
|
||||
<meta property="og:title" content="TexPixel - 公式识别工具 | Formula Recognition Tool" />
|
||||
<meta property="og:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别。Online formula recognition tool supporting printed and handwritten math formulas." />
|
||||
<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" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
<meta property="og:locale:alternate" content="en_US" />
|
||||
<meta property="og:site_name" content="TexPixel" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<!-- Twitter Card Meta Tags - Bilingual -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="TexPixel - 公式识别工具" />
|
||||
<meta name="twitter:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别。" />
|
||||
<meta name="twitter:title" content="TexPixel - Formula Recognition Tool | 公式识别工具" />
|
||||
<meta name="twitter:description" content="Online formula recognition tool supporting printed and handwritten math formulas. 支持印刷体和手写体数学公式识别。" />
|
||||
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
<meta name="twitter:site" content="@TexPixel" />
|
||||
|
||||
<!-- baidu -->
|
||||
<!-- Baidu Verification -->
|
||||
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
|
||||
|
||||
<!-- Structured Data -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "TexPixel",
|
||||
"url": "https://texpixel.com/",
|
||||
"inLanguage": ["zh-CN", "en"],
|
||||
"description": "Formula recognition tool for converting images and PDFs into LaTeX, MathML, and Markdown."
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "SoftwareApplication",
|
||||
"name": "TexPixel",
|
||||
"applicationCategory": "BusinessApplication",
|
||||
"operatingSystem": "Web",
|
||||
"url": "https://texpixel.com/",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD"
|
||||
},
|
||||
"description": "Online OCR and formula recognition for printed and handwritten mathematical expressions."
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Language Detection Script -->
|
||||
<script>
|
||||
// Update HTML lang attribute based on user preference or browser language
|
||||
(function() {
|
||||
const savedLang = localStorage.getItem('language');
|
||||
const browserLang = navigator.language.toLowerCase();
|
||||
const isZh = savedLang === 'zh' || (!savedLang && browserLang.startsWith('zh'));
|
||||
document.documentElement.lang = isZh ? 'zh-CN' : 'en';
|
||||
|
||||
// Update page title based on language
|
||||
if (!isZh) {
|
||||
document.title = '⚡️ TexPixel - Formula Recognition Tool';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
27
package-lock.json
generated
27
package-lock.json
generated
@@ -18,6 +18,7 @@
|
||||
"mathml2omml": "^0.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
@@ -2804,6 +2805,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/goober": {
|
||||
"version": "2.1.18",
|
||||
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz",
|
||||
"integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"csstype": "^3.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
@@ -4575,6 +4585,23 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hot-toast": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.1.3",
|
||||
"goober": "^2.1.16"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16",
|
||||
"react-dom": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"mathml2omml": "^0.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
|
||||
167
public/en/index.html
Normal file
167
public/en/index.html
Normal file
@@ -0,0 +1,167 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TexPixel - Formula Recognition Tool for LaTeX, MathML, and Markdown</title>
|
||||
<meta name="description" content="TexPixel converts printed and handwritten math formulas from images and PDFs into LaTeX, MathML, and Markdown." />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="author" content="TexPixel Team" />
|
||||
<link rel="canonical" href="https://texpixel.com/en/" />
|
||||
<link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/" />
|
||||
<link rel="alternate" hreflang="en" href="https://texpixel.com/en/" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="TexPixel" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="zh_CN" />
|
||||
<meta property="og:url" content="https://texpixel.com/en/" />
|
||||
<meta property="og:title" content="TexPixel - Formula Recognition Tool" />
|
||||
<meta property="og:description" content="Extract formulas from images and PDFs to editable LaTeX, MathML, and Markdown." />
|
||||
<meta property="og:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:site" content="@TexPixel" />
|
||||
<meta name="twitter:title" content="TexPixel - Formula Recognition Tool" />
|
||||
<meta name="twitter:description" content="Convert mathematical content from images and PDFs into LaTeX, MathML, and Markdown." />
|
||||
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
"name": "TexPixel English",
|
||||
"url": "https://texpixel.com/en/",
|
||||
"inLanguage": "en",
|
||||
"description": "Formula recognition landing page in English."
|
||||
}
|
||||
</script>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "What can TexPixel recognize?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "TexPixel recognizes printed and handwritten mathematical formulas from images and PDF pages."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Which output formats are supported?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "TexPixel supports LaTeX, MathML, and Markdown outputs for downstream editing and publishing."
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": "Do I need to install software?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "No installation is required. TexPixel works as a web application."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f7f8fc;
|
||||
--text: #111827;
|
||||
--muted: #4b5563;
|
||||
--accent: #0f766e;
|
||||
--accent-hover: #0d5f59;
|
||||
--card: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--text);
|
||||
background: radial-gradient(circle at 20% 20%, #eefbf8 0%, var(--bg) 40%, #f6f8ff 100%);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.wrap {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 20px 64px;
|
||||
}
|
||||
.hero, .section {
|
||||
background: var(--card);
|
||||
border-radius: 14px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.2;
|
||||
}
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
}
|
||||
p { margin: 0 0 14px; color: var(--muted); }
|
||||
ul { margin: 0; padding-left: 20px; color: var(--muted); }
|
||||
li { margin-bottom: 8px; }
|
||||
.cta {
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.cta:hover { background: var(--accent-hover); }
|
||||
.small { font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="hero">
|
||||
<h1>Formula Recognition for Real Math Workflows</h1>
|
||||
<p>TexPixel converts formulas from screenshots, photos, and PDF pages into editable text formats for researchers, students, and engineering teams.</p>
|
||||
<a class="cta" id="open-app" href="/">Open TexPixel App</a>
|
||||
<p class="small">The app opens at the main product URL and defaults to English for this entry point.</p>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Core Capabilities</h2>
|
||||
<ul>
|
||||
<li>Recognize printed and handwritten formulas from image or PDF input.</li>
|
||||
<li>Export to LaTeX for papers, MathML for web workflows, and Markdown for docs.</li>
|
||||
<li>Use the browser-based workflow without local software installation.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>FAQ</h2>
|
||||
<p><strong>Is TexPixel browser-based?</strong><br />Yes. You can upload files and get output directly in the web app.</p>
|
||||
<p><strong>What content type works best?</strong><br />Clean scans and high-contrast screenshots improve recognition quality.</p>
|
||||
<p><strong>Can I reuse output in technical documents?</strong><br />Yes. LaTeX and Markdown outputs are intended for editing and reuse.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var openApp = document.getElementById('open-app');
|
||||
if (!openApp) return;
|
||||
openApp.addEventListener('click', function () {
|
||||
try {
|
||||
localStorage.setItem('language', 'en');
|
||||
} catch (err) {
|
||||
// Keep navigation working even if storage is unavailable.
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
public/llms.txt
Normal file
14
public/llms.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
# TexPixel
|
||||
|
||||
TexPixel is a web tool for converting images and PDFs into editable mathematical formats such as LaTeX, MathML, and Markdown.
|
||||
|
||||
Canonical URL: https://texpixel.com/
|
||||
Primary languages: zh-CN, en
|
||||
|
||||
## Preferred page for citation
|
||||
- https://texpixel.com/
|
||||
- https://texpixel.com/en/
|
||||
|
||||
## Crawl guidance
|
||||
- Public product content is allowed for indexing.
|
||||
- Avoid API and authenticated/private areas.
|
||||
7
public/robots.txt
Normal file
7
public/robots.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /auth/
|
||||
Disallow: /admin/
|
||||
|
||||
Sitemap: https://texpixel.com/sitemap.xml
|
||||
15
public/sitemap.xml
Normal file
15
public/sitemap.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://texpixel.com/</loc>
|
||||
<lastmod>2026-02-25</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://texpixel.com/en/</loc>
|
||||
<lastmod>2026-02-25</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
39
src/App.tsx
39
src/App.tsx
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import { useLanguage } from './contexts/LanguageContext';
|
||||
import { uploadService } from './lib/uploadService';
|
||||
import { FileRecord, RecognitionResult } from './types';
|
||||
import { TaskStatus, TaskHistoryItem } from './types/api';
|
||||
@@ -8,15 +9,18 @@ import Navbar from './components/Navbar';
|
||||
import FilePreview from './components/FilePreview';
|
||||
import ResultPanel from './components/ResultPanel';
|
||||
import UploadModal from './components/UploadModal';
|
||||
import UserGuide from './components/UserGuide';
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
|
||||
function App() {
|
||||
const { user, initializing } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
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 [showUserGuide, setShowUserGuide] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Pagination state
|
||||
@@ -41,6 +45,20 @@ function App() {
|
||||
|
||||
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(() => {
|
||||
if (!initializing && user && !hasLoadedFiles.current) {
|
||||
hasLoadedFiles.current = true;
|
||||
@@ -162,7 +180,7 @@ function App() {
|
||||
markdown_content: item.markdown,
|
||||
latex_content: item.latex,
|
||||
mathml_content: item.mathml,
|
||||
mathml_word_content: item.mathml_mw,
|
||||
mml: item.mml,
|
||||
rendered_image_path: item.image_blob || null,
|
||||
created_at: item.created_at,
|
||||
};
|
||||
@@ -269,7 +287,7 @@ function App() {
|
||||
markdown_content: result.markdown,
|
||||
latex_content: result.latex,
|
||||
mathml_content: result.mathml,
|
||||
mathml_word_content: result.mathml_mw,
|
||||
mml: result.mml,
|
||||
rendered_image_path: result.image_blob || null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -326,7 +344,7 @@ function App() {
|
||||
markdown_content: result.markdown,
|
||||
latex_content: result.latex,
|
||||
mathml_content: result.mathml,
|
||||
mathml_word_content: result.mathml_mw,
|
||||
mml: result.mml,
|
||||
rendered_image_path: result.image_blob || null,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
@@ -352,7 +370,7 @@ function App() {
|
||||
return f;
|
||||
}));
|
||||
|
||||
alert('Task timeout: Recognition took too long.');
|
||||
alert(t.alerts.taskTimeout);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -366,7 +384,7 @@ function App() {
|
||||
if (f.id === fileId) return { ...f, status: 'failed' };
|
||||
return f;
|
||||
}));
|
||||
alert('Task timeout or network error.');
|
||||
alert(t.alerts.networkError);
|
||||
}
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
@@ -415,7 +433,7 @@ function App() {
|
||||
}
|
||||
} catch (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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -426,7 +444,7 @@ function App() {
|
||||
<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>
|
||||
<p className="text-gray-600">{t.common.loading}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -484,11 +502,16 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<UserGuide
|
||||
isOpen={showUserGuide}
|
||||
onClose={() => setShowUserGuide(false)}
|
||||
/>
|
||||
|
||||
{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>
|
||||
<p className="text-gray-900 font-medium">{t.common.processing}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface AuthModalProps {
|
||||
onClose: () => void;
|
||||
@@ -8,6 +9,7 @@ interface AuthModalProps {
|
||||
|
||||
export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
const { signIn, signUp } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
const [email, setEmail] = 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="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{isSignUp ? '注册账号' : '登录账号'}
|
||||
{isSignUp ? t.auth.signUpTitle : t.auth.signInTitle}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -54,7 +56,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
邮箱
|
||||
{t.auth.email}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -68,7 +70,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
密码
|
||||
{t.auth.password}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -83,7 +85,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
|
||||
{error && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -92,7 +94,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
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 ? '注册' : '登录'}
|
||||
{isSignUp ? t.auth.signUp : t.auth.signIn}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -101,7 +103,7 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
onClick={() => setIsSignUp(!isSignUp)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{isSignUp ? '已有账号?去登录' : '没有账号?去注册'}
|
||||
{isSignUp ? t.auth.hasAccount : t.auth.noAccount}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2, LucideIcon } from 'lucide-react';
|
||||
import { RecognitionResult } from '../types';
|
||||
import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter';
|
||||
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
|
||||
import { API_BASE_URL } from '../config/env';
|
||||
import { tokenManager } from '../lib/api';
|
||||
import { trackExportEvent } from '../lib/analytics';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
interface ExportSidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -24,6 +26,7 @@ interface ExportOption {
|
||||
}
|
||||
|
||||
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
|
||||
const { t } = useLanguage();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [exportingId, setExportingId] = useState<string | null>(null);
|
||||
|
||||
@@ -37,14 +40,25 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
category: 'Code',
|
||||
getContent: (r) => r.markdown_content
|
||||
},
|
||||
{
|
||||
id: 'latex',
|
||||
label: 'LaTeX',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.latex_content
|
||||
},
|
||||
{
|
||||
id: 'latex_inline',
|
||||
label: 'LaTeX (Inline)',
|
||||
category: 'Code',
|
||||
getContent: (r) => {
|
||||
if (!r.latex_content) return null;
|
||||
// Remove existing \[ \] and wrap with \( \)
|
||||
const content = r.latex_content.replace(/^\\\[/, '').replace(/\\\]$/, '').trim();
|
||||
// Remove existing delimiters like \[ \], \( \), $$, or $
|
||||
let content = r.latex_content.trim();
|
||||
content = content.replace(/^\\\[/, '').replace(/\\\]$/, '');
|
||||
content = content.replace(/^\\\(/, '').replace(/\\\)$/, '');
|
||||
content = content.replace(/^\$\$/, '').replace(/\$\$$/, '');
|
||||
content = content.replace(/^\$/, '').replace(/\$$/, '');
|
||||
content = content.trim();
|
||||
return `\\(${content}\\)`;
|
||||
}
|
||||
},
|
||||
@@ -52,7 +66,17 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
id: 'latex_display',
|
||||
label: 'LaTeX (Display)',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.latex_content
|
||||
getContent: (r) => {
|
||||
if (!r.latex_content) return null;
|
||||
// Remove existing delimiters like \[ \], \( \), $$, or $
|
||||
let content = r.latex_content.trim();
|
||||
content = content.replace(/^\\\[/, '').replace(/\\\]$/, '');
|
||||
content = content.replace(/^\\\(/, '').replace(/\\\)$/, '');
|
||||
content = content.replace(/^\$\$/, '').replace(/\$\$$/, '');
|
||||
content = content.replace(/^\$/, '').replace(/\$$/, '');
|
||||
content = content.trim();
|
||||
return `\\[${content}\\]`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mathml',
|
||||
@@ -61,10 +85,10 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
getContent: (r) => r.mathml_content
|
||||
},
|
||||
{
|
||||
id: 'mathml_word',
|
||||
label: 'Word MathML',
|
||||
id: 'mathml_mml',
|
||||
label: 'MathML (MML)',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.mathml_word_content
|
||||
getContent: (r) => r.mml
|
||||
},
|
||||
// Image Category
|
||||
{
|
||||
@@ -127,7 +151,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err);
|
||||
alert('导出失败,请重试');
|
||||
alert(t.export.failed);
|
||||
} finally {
|
||||
setExportingId(null);
|
||||
}
|
||||
@@ -160,13 +184,22 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error('Failed to generate image:', err);
|
||||
alert(`生成图片失败: ${err}`);
|
||||
alert(`${t.export.imageFailed}: ${err}`);
|
||||
} finally {
|
||||
setExportingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = async (option: ExportOption) => {
|
||||
// Analytics tracking
|
||||
if (result?.id) {
|
||||
trackExportEvent(
|
||||
result.id,
|
||||
option.id,
|
||||
exportOptions.map(o => o.id)
|
||||
);
|
||||
}
|
||||
|
||||
// Handle DOCX export via API
|
||||
if (option.id === 'docx') {
|
||||
await handleFileExport('docx');
|
||||
@@ -179,22 +212,33 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
return;
|
||||
}
|
||||
|
||||
let content = option.getContent(result);
|
||||
const content = option.getContent(result);
|
||||
|
||||
// Fallback: If Word MathML is missing, try to convert from MathML
|
||||
if (option.id === 'mathml_word' && !content && result.mathml_content) {
|
||||
try {
|
||||
const omml = await convertMathmlToOmml(result.mathml_content);
|
||||
if (omml) {
|
||||
content = wrapOmmlForClipboard(omml);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to convert MathML to OMML:', err);
|
||||
}
|
||||
// Check if content is empty and show toast
|
||||
if (!content) {
|
||||
toast.error(t.export.noContent, {
|
||||
duration: 2000,
|
||||
position: 'top-center',
|
||||
style: {
|
||||
background: '#fff',
|
||||
color: '#1f2937',
|
||||
padding: '16px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 40px rgba(59, 130, 246, 0.15), 0 2px 8px rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid #dbeafe',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
maxWidth: '900px',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content) return;
|
||||
|
||||
setExportingId(option.id);
|
||||
|
||||
try {
|
||||
@@ -222,19 +266,58 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
|
||||
} catch (err) {
|
||||
console.error('Action failed:', err);
|
||||
toast.error(t.export.failed, {
|
||||
duration: 3000,
|
||||
position: 'top-center',
|
||||
});
|
||||
} finally {
|
||||
setExportingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const categories: { id: ExportCategory; icon: LucideIcon; label: string }[] = [
|
||||
{ id: 'Code', icon: Code2, label: 'Code' },
|
||||
{ id: 'Image', icon: ImageIcon, label: 'Image' },
|
||||
{ id: 'File', icon: FileText, label: 'File' },
|
||||
{ id: 'Code', icon: Code2, label: t.export.categories.code },
|
||||
{ id: 'Image', icon: ImageIcon, label: t.export.categories.image },
|
||||
{ id: 'File', icon: FileText, label: t.export.categories.file },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast Container with custom configuration */}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#fff',
|
||||
color: '#1f2937',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 40px rgba(59, 130, 246, 0.15), 0 2px 8px rgba(59, 130, 246, 0.1)',
|
||||
maxWidth: '420px',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
style: {
|
||||
border: '1px solid #d1fae5',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#3b82f6',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
style: {
|
||||
border: '1px solid #dbeafe',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
@@ -251,7 +334,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">
|
||||
<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">
|
||||
<X size={20} className="text-gray-500" />
|
||||
</button>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight, File as FileIcon, MinusCircle, PlusCircle } from 'lucide-react';
|
||||
import { FileRecord } from '../types';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface FilePreviewProps {
|
||||
file: FileRecord | null;
|
||||
}
|
||||
|
||||
export default function FilePreview({ file }: FilePreviewProps) {
|
||||
const { t } = useLanguage();
|
||||
const [zoom, setZoom] = useState(100);
|
||||
const [page, setPage] = useState(1);
|
||||
const totalPages = 1;
|
||||
@@ -16,13 +18,13 @@ export default function FilePreview({ file }: FilePreviewProps) {
|
||||
|
||||
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="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">
|
||||
<FileIcon size={48} className="text-gray-900" />
|
||||
</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">
|
||||
Click, Drop, or Paste a file to start parsing
|
||||
{t.sidebar.uploadInstruction}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -68,7 +70,7 @@ export default function FilePreview({ file }: FilePreviewProps) {
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
|
||||
title="缩小"
|
||||
title={t.common.preview}
|
||||
>
|
||||
<MinusCircle size={16} />
|
||||
</button>
|
||||
@@ -78,7 +80,7 @@ export default function FilePreview({ file }: FilePreviewProps) {
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
|
||||
title="放大"
|
||||
title={t.common.preview}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { useLanguage } from '../contexts/LanguageContext';
|
||||
import { FileRecord } from '../types';
|
||||
import AuthModal from './AuthModal';
|
||||
|
||||
@@ -30,11 +31,13 @@ export default function LeftSidebar({
|
||||
onLoadMore,
|
||||
}: LeftSidebarProps) {
|
||||
const { user, signOut } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ... (rest of the logic remains the same)
|
||||
// Handle scroll to load more
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!listRef.current || loadingMore || !hasMore) return;
|
||||
@@ -116,13 +119,13 @@ export default function LeftSidebar({
|
||||
<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"
|
||||
title={t.common.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">
|
||||
<button className="p-2 text-gray-400 hover:text-gray-900 transition-colors" title={t.common.history}>
|
||||
<History size={20} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -130,7 +133,7 @@ export default function LeftSidebar({
|
||||
<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'}
|
||||
title={user ? 'Signed In' : t.common.login}
|
||||
>
|
||||
<LogIn size={20} />
|
||||
</button>
|
||||
@@ -145,8 +148,8 @@ export default function LeftSidebar({
|
||||
<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>
|
||||
<h2 className="text-lg font-bold text-gray-900 leading-tight">{t.sidebar.title}</h2>
|
||||
<p className="text-xs text-gray-500 mt-1">{t.sidebar.subtitle}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
@@ -156,7 +159,7 @@ export default function LeftSidebar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-2">
|
||||
<div className="mb-2" id="sidebar-upload-area">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
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">
|
||||
<Upload size={24} />
|
||||
</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">
|
||||
<MousePointerClick className="w-3.5 h-3.5" />
|
||||
<span>Click</span>
|
||||
<span>{t.common.click}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileUp className="w-3.5 h-3.5" />
|
||||
<span>Drop</span>
|
||||
<span>{t.common.drop}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||
<span>Paste</span>
|
||||
<span>{t.common.paste}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,10 +204,10 @@ export default function LeftSidebar({
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<Clock size={14} />
|
||||
<span>History</span>
|
||||
<span>{t.sidebar.historyHeader}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -214,12 +218,12 @@ export default function LeftSidebar({
|
||||
{!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
|
||||
{t.sidebar.pleaseLogin}
|
||||
</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
|
||||
{t.sidebar.noHistory}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -256,13 +260,13 @@ export default function LeftSidebar({
|
||||
{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>
|
||||
<span className="ml-2 text-xs">{t.common.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
|
||||
{t.sidebar.noMore}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -285,7 +289,7 @@ export default function LeftSidebar({
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
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} />
|
||||
</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"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
Login / Register
|
||||
{t.common.login}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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() {
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
const [showContact, setShowContact] = useState(false);
|
||||
const [showReward, setShowReward] = useState(false);
|
||||
const [showLangMenu, setShowLangMenu] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const langMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleCopyQQ = async () => {
|
||||
await navigator.clipboard.writeText('1018282100');
|
||||
@@ -19,6 +23,9 @@ export default function Navbar() {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setShowContact(false);
|
||||
}
|
||||
if (langMenuRef.current && !langMenuRef.current.contains(event.target as Node)) {
|
||||
setShowLangMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
@@ -37,8 +44,59 @@ export default function Navbar() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right: Reward & Contact Buttons */}
|
||||
{/* Right: Actions */}
|
||||
<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 */}
|
||||
<div className="relative">
|
||||
<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"
|
||||
>
|
||||
<Heart size={14} className="fill-white" />
|
||||
<span>赞赏</span>
|
||||
<span>{t.common.reward}</span>
|
||||
</button>
|
||||
|
||||
{/* Reward Modal */}
|
||||
@@ -60,7 +118,7 @@ export default function Navbar() {
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<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
|
||||
onClick={() => setShowReward(false)}
|
||||
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">
|
||||
<img
|
||||
src="https://cdn.texpixel.com/public/rewardcode.png"
|
||||
alt="微信赞赏码"
|
||||
alt={t.navbar.rewardTitle}
|
||||
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>
|
||||
{t.navbar.rewardThanks}<br />
|
||||
<span className="text-xs text-gray-400 mt-1 block">{t.navbar.rewardSubtitle}</span>
|
||||
</p>
|
||||
</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"
|
||||
>
|
||||
<MessageCircle size={14} />
|
||||
<span>Contact Us</span>
|
||||
<span>{t.common.contactUs}</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
|
||||
@@ -109,7 +167,7 @@ export default function Navbar() {
|
||||
<Mail size={16} className="text-blue-600" />
|
||||
</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>
|
||||
</a>
|
||||
@@ -126,7 +184,7 @@ export default function Navbar() {
|
||||
</div>
|
||||
<div>
|
||||
<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 className="text-sm font-medium text-gray-900">1018282100</div>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import rehypeKatex from 'rehype-katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { RecognitionResult } from '../types';
|
||||
import ExportSidebar from './ExportSidebar';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface ResultPanelProps {
|
||||
result: RecognitionResult | null;
|
||||
@@ -67,6 +68,7 @@ function preprocessLatex(content: string): string {
|
||||
}
|
||||
|
||||
export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
|
||||
const { t } = useLanguage();
|
||||
const [isExportSidebarOpen, setIsExportSidebarOpen] = useState(false);
|
||||
|
||||
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="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...'}
|
||||
{fileStatus === 'pending' ? t.resultPanel.waitingQueue : t.resultPanel.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.'}
|
||||
? t.resultPanel.queueSubtitle
|
||||
: t.resultPanel.processingSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<Code2 size={48} className="text-gray-900" />
|
||||
</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">
|
||||
After uploading the file, Texpixel will automatically recognize and display the result here
|
||||
{t.resultPanel.waitingSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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>
|
||||
<h2 className="text-lg font-bold text-gray-900">{t.resultPanel.markdown}</h2>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="export-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
|
||||
{t.common.export}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { Upload, X, MousePointerClick, FileUp, ClipboardPaste } from 'lucide-react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface UploadModalProps {
|
||||
onClose: () => void;
|
||||
@@ -7,6 +8,7 @@ interface UploadModalProps {
|
||||
}
|
||||
|
||||
export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
|
||||
const { t } = useLanguage();
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
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="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>
|
||||
<h2 className="text-2xl font-bold text-gray-900">{t.uploadModal.title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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} />
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-1">{t.sidebar.uploadInstruction}</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -108,22 +111,22 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
Support JPG, PNG format
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
{t.uploadModal.supportFormats}
|
||||
</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>
|
||||
<span>{t.common.click}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<FileUp className="w-3.5 h-3.5" />
|
||||
<span>Drop</span>
|
||||
<span>{t.common.drop}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ClipboardPaste className="w-3.5 h-3.5" />
|
||||
<span>Paste</span>
|
||||
<span>{t.common.paste}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
69
src/contexts/LanguageContext.tsx
Normal file
69
src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { translations, Language, TranslationKey } from '../lib/translations';
|
||||
import { detectLanguageByIP } from '../lib/ipLocation';
|
||||
import { updatePageMeta } from '../lib/seoHelper';
|
||||
|
||||
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') {
|
||||
updatePageMeta(saved); // Update meta tags on initial load
|
||||
return;
|
||||
}
|
||||
|
||||
// 异步检测IP并设置语言
|
||||
detectLanguageByIP()
|
||||
.then((detectedLang) => {
|
||||
setLanguageState(detectedLang);
|
||||
updatePageMeta(detectedLang); // Update meta tags after detection
|
||||
// 注意:这里不保存到 localStorage,让用户首次访问时使用IP检测的结果
|
||||
// 如果用户手动切换语言,才会保存到 localStorage
|
||||
})
|
||||
.catch((error) => {
|
||||
// IP检测失败时,保持使用浏览器语言检测的结果
|
||||
console.warn('Failed to detect language by IP:', error);
|
||||
updatePageMeta(language); // Update with fallback language
|
||||
});
|
||||
}, []); // 仅在组件挂载时执行一次
|
||||
|
||||
const setLanguage = (lang: Language) => {
|
||||
setLanguageState(lang);
|
||||
localStorage.setItem('language', lang);
|
||||
updatePageMeta(lang); // Update page metadata when language changes
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
63
src/lib/analytics.ts
Normal file
63
src/lib/analytics.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import http from './api';
|
||||
|
||||
interface AnalyticsPayload {
|
||||
task_no: string;
|
||||
event_name: string;
|
||||
properties: Record<string, any>;
|
||||
meta_data?: Record<string, any>;
|
||||
device_info: {
|
||||
ip: string;
|
||||
"use-agent": string;
|
||||
browser: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const trackExportEvent = (
|
||||
taskNo: string,
|
||||
selectedOption: string,
|
||||
availableOptions: string[]
|
||||
) => {
|
||||
try {
|
||||
const payload: AnalyticsPayload = {
|
||||
task_no: taskNo,
|
||||
event_name: 'export_selected_event',
|
||||
properties: {
|
||||
option: availableOptions,
|
||||
selected: selectedOption
|
||||
},
|
||||
meta_data: {
|
||||
task_no: taskNo
|
||||
},
|
||||
device_info: {
|
||||
ip: '',
|
||||
"use-agent": navigator.userAgent,
|
||||
browser: getBrowserName()
|
||||
}
|
||||
};
|
||||
|
||||
// Fire and forget - do not await
|
||||
http.post('/analytics/track', payload).catch(err => {
|
||||
// Silently ignore errors to not block business flow
|
||||
console.debug('Analytics tracking failed:', err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('Analytics error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
function getBrowserName(): string {
|
||||
const userAgent = navigator.userAgent;
|
||||
if (userAgent.match(/chrome|chromium|crios/i)) {
|
||||
return "Chrome";
|
||||
} else if (userAgent.match(/firefox|fxios/i)) {
|
||||
return "Firefox";
|
||||
} else if (userAgent.match(/safari/i)) {
|
||||
return "Safari";
|
||||
} else if (userAgent.match(/opr\//i)) {
|
||||
return "Opera";
|
||||
} else if (userAgent.match(/edg/i)) {
|
||||
return "Edge";
|
||||
} else {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -63,54 +63,6 @@ function renderLatexToHtml(latex: string, fontSize: number): string {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders Markdown with LaTeX to HTML
|
||||
* For complex Markdown, we extract and render math blocks separately
|
||||
*/
|
||||
function renderMarkdownToHtml(markdown: string, fontSize: number): string {
|
||||
// Simple approach: extract LaTeX blocks and render them
|
||||
// For full Markdown support, you'd use a Markdown parser
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// Replace display math $$ ... $$
|
||||
html = html.replace(/\$\$([\s\S]*?)\$\$/g, (_, latex) => {
|
||||
try {
|
||||
return `<div class="math-block">${katex.renderToString(latex.trim(), {
|
||||
throwOnError: false,
|
||||
displayMode: true,
|
||||
output: 'html',
|
||||
strict: false,
|
||||
})}</div>`;
|
||||
} catch {
|
||||
return `<div class="math-block" style="color: red;">Error: ${latex}</div>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Replace inline math $ ... $
|
||||
html = html.replace(/\$([^$\n]+)\$/g, (_, latex) => {
|
||||
try {
|
||||
return katex.renderToString(latex.trim(), {
|
||||
throwOnError: false,
|
||||
displayMode: false,
|
||||
output: 'html',
|
||||
strict: false,
|
||||
});
|
||||
} catch {
|
||||
return `<span style="color: red;">Error: ${latex}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Basic Markdown: newlines to <br>
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
|
||||
return `
|
||||
<div style="font-size: ${fontSize}px; line-height: 1.8; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
${html}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for all KaTeX fonts to be loaded
|
||||
*/
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -93,16 +93,35 @@ Where:
|
||||
</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>`,
|
||||
mml: `<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block">
|
||||
<mml:mrow>
|
||||
<mml:mi>x</mml:mi>
|
||||
<mml:mo>=</mml:mo>
|
||||
<mml:mfrac>
|
||||
<mml:mrow>
|
||||
<mml:mo>-</mml:mo>
|
||||
<mml:mi>b</mml:mi>
|
||||
<mml:mo>±</mml:mo>
|
||||
<mml:msqrt>
|
||||
<mml:mrow>
|
||||
<mml:msup>
|
||||
<mml:mi>b</mml:mi>
|
||||
<mml:mn>2</mml:mn>
|
||||
</mml:msup>
|
||||
<mml:mo>-</mml:mo>
|
||||
<mml:mn>4</mml:mn>
|
||||
<mml:mi>a</mml:mi>
|
||||
<mml:mi>c</mml:mi>
|
||||
</mml:mrow>
|
||||
</mml:msqrt>
|
||||
</mml:mrow>
|
||||
<mml:mrow>
|
||||
<mml:mn>2</mml:mn>
|
||||
<mml:mi>a</mml:mi>
|
||||
</mml:mrow>
|
||||
</mml:mfrac>
|
||||
</mml:mrow>
|
||||
</mml:math>`,
|
||||
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -161,7 +180,7 @@ Where:
|
||||
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>`,
|
||||
mml: `<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>E</mml:mi><mml:mo>=</mml:mo><mml:mi>m</mml:mi><mml:msup><mml:mi>c</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math>`,
|
||||
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(),
|
||||
};
|
||||
|
||||
80
src/lib/seoHelper.ts
Normal file
80
src/lib/seoHelper.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Language } from './translations';
|
||||
|
||||
interface SEOContent {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
const seoContent: Record<Language, SEOContent> = {
|
||||
zh: {
|
||||
title: '⚡️ TexPixel - 公式识别工具',
|
||||
description: '在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。',
|
||||
keywords: '公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,混合文字识别,texpixel,TexPixel',
|
||||
},
|
||||
en: {
|
||||
title: '⚡️ TexPixel - Formula Recognition Tool',
|
||||
description: 'Online formula recognition tool supporting printed and handwritten math formulas. Convert images to LaTeX, MathML, and Markdown quickly and accurately.',
|
||||
keywords: 'formula recognition,math formula,OCR,handwriting recognition,latex,mathml,markdown,AI recognition,math tool,free,texpixel,TexPixel,document recognition',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Update document metadata based on current language
|
||||
*/
|
||||
export function updatePageMeta(language: Language): void {
|
||||
const content = seoContent[language];
|
||||
|
||||
// Update title
|
||||
document.title = content.title;
|
||||
|
||||
// Update HTML lang attribute
|
||||
document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en';
|
||||
|
||||
// Update meta description
|
||||
const metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (metaDescription) {
|
||||
metaDescription.setAttribute('content', content.description);
|
||||
}
|
||||
|
||||
// Update meta keywords
|
||||
const metaKeywords = document.querySelector('meta[name="keywords"]');
|
||||
if (metaKeywords) {
|
||||
metaKeywords.setAttribute('content', content.keywords);
|
||||
}
|
||||
|
||||
// Update Open Graph meta tags
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]');
|
||||
if (ogTitle) {
|
||||
ogTitle.setAttribute('content', content.title);
|
||||
}
|
||||
|
||||
const ogDescription = document.querySelector('meta[property="og:description"]');
|
||||
if (ogDescription) {
|
||||
ogDescription.setAttribute('content', content.description);
|
||||
}
|
||||
|
||||
// Update Twitter Card meta tags
|
||||
const twitterTitle = document.querySelector('meta[name="twitter:title"]');
|
||||
if (twitterTitle) {
|
||||
twitterTitle.setAttribute('content', content.title);
|
||||
}
|
||||
|
||||
const twitterDescription = document.querySelector('meta[name="twitter:description"]');
|
||||
if (twitterDescription) {
|
||||
twitterDescription.setAttribute('content', content.description);
|
||||
}
|
||||
|
||||
// Update og:locale
|
||||
const ogLocale = document.querySelector('meta[property="og:locale"]');
|
||||
if (ogLocale) {
|
||||
ogLocale.setAttribute('content', language === 'zh' ? 'zh_CN' : 'en_US');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SEO content for a specific language
|
||||
*/
|
||||
export function getSEOContent(language: Language): SEOContent {
|
||||
return seoContent[language];
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export type Database = {
|
||||
markdown_content: string | null;
|
||||
latex_content: string | null;
|
||||
mathml_content: string | null;
|
||||
mathml_word_content: string | null;
|
||||
mml: string | null;
|
||||
rendered_image_path: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -71,7 +71,7 @@ export type Database = {
|
||||
markdown_content?: string | null;
|
||||
latex_content?: string | null;
|
||||
mathml_content?: string | null;
|
||||
mathml_word_content?: string | null;
|
||||
mml?: string | null;
|
||||
rendered_image_path?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
@@ -81,7 +81,7 @@ export type Database = {
|
||||
markdown_content?: string | null;
|
||||
latex_content?: string | null;
|
||||
mathml_content?: string | null;
|
||||
mathml_word_content?: string | null;
|
||||
mml?: string | null;
|
||||
rendered_image_path?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
197
src/lib/translations.ts
Normal file
197
src/lib/translations.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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',
|
||||
noContent: 'Mixed text and formulas do not support LaTeX/MathML export. Please download DOCX format instead.',
|
||||
noContentShort: 'Not supported for mixed content',
|
||||
},
|
||||
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: '生成图片失败',
|
||||
noContent: '混合文字内容不支持 LaTeX/MathML 导出,请下载 DOCX 文件。',
|
||||
noContentShort: '混合内容不支持',
|
||||
},
|
||||
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;
|
||||
42
src/main.tsx
42
src/main.tsx
@@ -3,11 +3,39 @@ import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
// 错误处理:捕获未处理的错误
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('Global error:', event.error);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
try {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
<App />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to render app:', error);
|
||||
rootElement.innerHTML = `
|
||||
<div style="padding: 20px; font-family: sans-serif;">
|
||||
<h1>应用启动失败</h1>
|
||||
<p>错误信息: ${error instanceof Error ? error.message : String(error)}</p>
|
||||
<p>请检查浏览器控制台获取更多信息。</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export interface RecognitionResultData {
|
||||
latex: string;
|
||||
markdown: string;
|
||||
mathml: string;
|
||||
mathml_mw: string; // MathML for Word
|
||||
mml: string; // MathML with mml: prefix
|
||||
image_blob: string; // Base64 or URL? assuming string content
|
||||
docx_url: string;
|
||||
pdf_url: string;
|
||||
@@ -96,7 +96,7 @@ export interface TaskHistoryItem {
|
||||
latex: string;
|
||||
markdown: string;
|
||||
mathml: string;
|
||||
mathml_mw: string;
|
||||
mml: string;
|
||||
image_blob: string;
|
||||
docx_url: string;
|
||||
pdf_url: string;
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface RecognitionResult {
|
||||
markdown_content: string | null;
|
||||
latex_content: string | null;
|
||||
mathml_content: string | null;
|
||||
mathml_word_content: string | null;
|
||||
mml: string | null;
|
||||
rendered_image_path: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export type ExportFormat =
|
||||
| 'markdown'
|
||||
| 'latex'
|
||||
| 'mathml'
|
||||
| 'mathml-word'
|
||||
| 'image'
|
||||
| 'docx'
|
||||
| 'pdf'
|
||||
|
||||
@@ -8,8 +8,13 @@ export default defineConfig({
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
build: {
|
||||
// 确保生成带哈希的文件名(默认已启用)
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// 确保文件名包含哈希,便于缓存管理
|
||||
entryFileNames: 'assets/[name]-[hash].js',
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||
manualChunks: {
|
||||
// React 核心
|
||||
'vendor-react': ['react', 'react-dom'],
|
||||
|
||||
Reference in New Issue
Block a user