15 Commits

Author SHA1 Message Date
0d4de2fcf1 feat: replace logo in main 2026-03-25 11:49:28 +08:00
3a3bbbc0fc feat: replace icon 2026-03-25 11:45:54 +08:00
e1f8dac74d feat: add icon 2026-03-25 11:14:55 +08:00
64e92c769d feat: optimize SEO based on GSC data
- Fix broken hreflang: /en/ ghost page removed, all hreflang point to canonical /
- Add canonical URL tag
- Remove emoji from page titles
- Rewrite title/description with target keywords (LaTeX OCR, math formula recognition, handwriting math)
- Add JSON-LD WebApplication structured data schema
- Update og:image to clean URL without OSS params, add og:image dimensions
- Fix favicon reference from vite.svg to favicon.png
- Add public/sitemap.xml with hreflang annotations
- Add public/robots.txt pointing to sitemap
- Update seoHelper.ts keywords for both zh/en to match search intent
- Add CLAUDE.md project documentation
2026-03-24 23:50:12 +08:00
fba4541fa5 fix: user info api repeat call 2026-03-09 21:46:26 +08:00
a797b2b0d7 fix: all call user info 2026-03-09 21:13:01 +08:00
liuyuanchuang
cd479da0eb optimize register error tip 2026-03-06 15:01:34 +08:00
liuyuanchuang
f70a9a85c8 feat: add google oauth 2026-03-06 14:30:30 +08:00
liuyuanchuang
bc4b547e03 Merge branch 'main' of https://code.texpixel.com/YogeLiu/doc_ai_frontend 2026-02-06 22:33:58 +08:00
liuyuanchuang
e4c6a09cf8 feat: rm dist 2026-02-05 18:23:17 +08:00
liuyuanchuang
2b1da79bbc feat: add toast for no content 2026-02-05 18:22:30 +08:00
564aaec581 Merge pull request 'feat: add track point && rm omml' (#1) from test into main
Reviewed-on: #1
2026-02-05 13:49:19 +08:00
liuyuanchuang
d562d67203 feat: add track point 2026-01-27 23:44:45 +08:00
liuyuanchuang
7c5409a6c7 feat: add deploy script 2026-01-26 07:10:58 +08:00
liuyuanchuang
42850c4460 feat: add translate 2026-01-24 13:53:50 +08:00
53 changed files with 5777 additions and 500 deletions

1
.gitignore vendored
View File

@@ -22,3 +22,4 @@ dist-ssr
*.sw? *.sw?
.env .env
/dist /dist
app.cloud/

76
CLAUDE.md Normal file
View File

@@ -0,0 +1,76 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
# Development server
npm run dev
# Build
npm run build # production
npm run build:dev # development build (VITE_ENV=development)
npm run build:prod # production build (VITE_ENV=production)
# Type checking
npm run typecheck
# Linting
npm run lint
# Unit tests (vitest)
npm run test # run once
npm run test:watch # watch mode
# E2E tests (playwright)
npm run test:e2e
```
To run a single unit test file:
```bash
npx vitest run src/components/__tests__/AuthModal.test.tsx
```
## Architecture
### Provider Tree
`main.tsx` wraps everything in: `BrowserRouter → AuthProvider → LanguageProvider → AppRouter`
### Routing
Only two routes (`src/routes/AppRouter.tsx`):
- `/``App` (main app)
- `/auth/google/callback``AuthCallbackPage` (Google OAuth callback handler)
### Auth System
- **`AuthContext.tsx`** — React context providing `signIn`, `signUp`, `beginGoogleOAuth`, `completeGoogleOAuth`, `signOut`. Uses `useReducer` with `authMachine.ts`.
- **`authMachine.ts`** — Pure reducer + state types (`AuthPhase`, `AuthState`, `AuthAction`). No side effects — all async logic lives in `AuthContext`.
- **`authService.ts`** — Calls backend API for login/register/Google OAuth/user info. Decodes JWT payload client-side to extract `UserInfo`. Handles session restore from `localStorage`.
- **`lib/api.ts`** — `http` client wrapper with `tokenManager` (stores JWT in `localStorage` under key `texpixel_token`). Adds `Authorization` header automatically. Throws `ApiError` for non-success business codes.
### Environment / API
- **`src/config/env.ts`** — Switches API base URL based on `VITE_ENV` env var:
- `development`: `https://cloud.texpixel.com:10443/doc_ai/v1`
- `production`: `https://api.texpixel.com/doc_ai/v1`
- The API uses a custom response envelope: `{ request_id, code, message, data }`. Success is `code === 200` (some endpoints also accept `0`).
### Main App Flow (`App.tsx`)
Three-panel layout: `LeftSidebar | FilePreview | ResultPanel`
Core data flow:
1. On auth, `loadFiles()` fetches paginated task history from `/task/list`
2. File upload goes: compute MD5 → get OSS pre-signed URL (`/oss/signature_url`) → PUT to OSS → create task (`/formula/recognition`) → poll every 2s for result
3. Results cached in `resultsCache` ref (keyed by `task_id`) to avoid redundant API calls
4. Guest users get 3 free uploads tracked via `localStorage` (`texpixel_guest_usage_count`)
### Internationalization
`LanguageContext.tsx` supports `en` / `zh`. Language is auto-detected via IP (`lib/ipLocation.ts`) on first visit, persisted to `localStorage` on manual switch. All UI strings come from `lib/translations.ts` via `const { t } = useLanguage()`.
### Key Type Definitions
- `src/types/api.ts` — All API request/response types, `TaskStatus` enum, `ApiErrorCode` enum, `ApiErrorMessages` map
- `src/types/index.ts` — Internal app types (`FileRecord`, `RecognitionResult`)
### Tests
- Unit tests: `src/components/__tests__/` and `src/contexts/__tests__/`, run with vitest + jsdom + `@testing-library/react`
- E2E tests: `e2e/` directory, run with Playwright
- Setup file: `src/test/setup.ts`

203
deploy_dev.sh Executable file
View File

@@ -0,0 +1,203 @@
#!/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"
# Sudo 密码(如果需要,建议配置无密码 sudo 更安全)
# 配置无密码 sudo: 在服务器上运行: echo "username ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/username
SUDO_PASSWORD="1231"
# 打印带颜色的消息
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}" "SSH_SUDO_PASSWORD='${SUDO_PASSWORD}' SSH_DEPLOY_PATH='${DEPLOY_PATH}' SSH_DEPLOY_NAME='${DEPLOY_NAME}' bash -s" << 'SSH_EOF'
set -e
DEPLOY_PATH="${SSH_DEPLOY_PATH}"
DEPLOY_NAME="${SSH_DEPLOY_NAME}"
SUDO_PASSWORD="${SSH_SUDO_PASSWORD}"
# 检查部署目录是否存在
if [ ! -d "${DEPLOY_PATH}" ]; then
echo "错误:部署目录 ${DEPLOY_PATH} 不存在,请检查路径是否正确"
exit 1
fi
# 检查是否有权限写入(尝试创建测试文件)
if ! touch "${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
echo "提示:没有直接写入权限,将使用 sudo 执行操作"
USE_SUDO=1
else
rm -f "${DEPLOY_PATH}/.deploy_test"
USE_SUDO=0
fi
# 备份旧版本(如果存在)
if [ -d "${DEPLOY_PATH}/${DEPLOY_NAME}" ]; then
echo "备份旧版本..."
if [ "$USE_SUDO" = "1" ]; then
echo "${SUDO_PASSWORD}" | sudo -S rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
echo "${SUDO_PASSWORD}" | sudo -S mv "${DEPLOY_PATH}/${DEPLOY_NAME}" "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" || { echo "错误:备份失败,权限不足"; exit 1; }
else
rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
mv "${DEPLOY_PATH}/${DEPLOY_NAME}" "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" || { echo "错误:备份失败"; exit 1; }
fi
fi
# 移动新版本到部署目录(覆盖现有目录)
if [ -d ~/${DEPLOY_NAME} ]; then
echo "移动新版本到部署目录..."
if [ "$USE_SUDO" = "1" ]; then
echo "${SUDO_PASSWORD}" | sudo -S mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
else
mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败"; exit 1; }
fi
echo "部署完成!"
else
echo "错误:找不到 ~/${DEPLOY_NAME} 目录"
exit 1
fi
# 重新加载 nginx如果配置了
if command -v nginx &> /dev/null; then
echo "重新加载 nginx..."
echo "${SUDO_PASSWORD}" | sudo -S nginx -t && echo "${SUDO_PASSWORD}" | sudo -S nginx -s reload || echo "警告nginx 重新加载失败,请手动检查"
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
View 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

View 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
View 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)

57
e2e/auth-email.spec.ts Normal file
View File

@@ -0,0 +1,57 @@
import { expect, test } from '@playwright/test';
const jwtPayload = Buffer.from(
JSON.stringify({ user_id: 7, email: 'user@example.com', exp: 1999999999, iat: 1111111 })
)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const token = `header.${jwtPayload}.sig`;
test('email login should authenticate and display user email', async ({ page }) => {
await page.route('**/user/login', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_login',
code: 200,
message: 'ok',
data: {
token,
expires_at: 1999999999,
},
}),
});
});
await page.route('**/task/list**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_tasks',
code: 200,
message: 'ok',
data: {
task_list: [],
total: 0,
},
}),
});
});
await page.goto('/');
const loginButton = page.getByRole('button', { name: /Login|登录/ }).first();
await loginButton.click();
await page.fill('#auth-email', 'user@example.com');
await page.fill('#auth-password', '123456');
await page.locator('button[type="submit"]').click();
await expect(page.getByText('user@example.com')).toBeVisible();
});

80
e2e/auth-oauth.spec.ts Normal file
View File

@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
const jwtPayload = Buffer.from(
JSON.stringify({ user_id: 9, email: 'oauth@example.com', exp: 1999999999, iat: 1111111 })
)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
const token = `header.${jwtPayload}.sig`;
test('google oauth callback with valid state should complete login', async ({ page }) => {
await page.route('**/user/oauth/google/url**', async (route, request) => {
const url = new URL(request.url());
const state = url.searchParams.get('state') ?? '';
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_oauth_url',
code: 200,
message: 'ok',
data: {
auth_url: `http://127.0.0.1:4173/auth/google/callback?code=oauth_code&state=${state}`,
},
}),
});
});
await page.route('**/user/oauth/google/callback', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_oauth_callback',
code: 200,
message: 'ok',
data: {
token,
expires_at: 1999999999,
},
}),
});
});
await page.route('**/task/list**', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
request_id: 'req_tasks',
code: 200,
message: 'ok',
data: {
task_list: [],
total: 0,
},
}),
});
});
await page.goto('/');
await page.getByRole('button', { name: /Login|登录/ }).first().click();
await page.getByRole('button', { name: /Google/ }).click();
await expect(page.getByText('oauth@example.com')).toBeVisible();
});
test('google oauth callback with invalid state should show error', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
sessionStorage.setItem('texpixel_oauth_state', 'expected_state');
});
await page.goto('/auth/google/callback?code=fake_code&state=wrong_state');
await expect(page.getByText('OAuth state 校验失败')).toBeVisible();
});

View File

@@ -1,35 +1,99 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="apple-touch-icon" href="/texpixel-app-icon.svg" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>⚡️ TexPixel - 公式识别工具</title>
<!-- hreflang: same URL serves both languages (SPA), point both to canonical -->
<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/" />
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
<!-- Title -->
<title>TexPixel - AI Math Formula Recognition | LaTeX, MathML OCR Tool</title>
<!-- SEO Meta Tags --> <!-- SEO Meta Tags -->
<meta name="description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" /> <meta name="description" content="Free AI-powered math formula recognition tool. Convert handwritten or printed math formulas in images to LaTeX, MathML, and Markdown instantly. Supports PDF and image files." />
<meta name="keywords" <meta name="keywords" content="math formula recognition,LaTeX OCR,handwriting math recognition,formula to latex,math OCR,MathML converter,handwritten equation recognition,公式识别,数学公式OCR,手写公式识别,LaTeX转换,texpixel" />
content="公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,handwriting,formula,recognition,ocr,handwriting,recognition,math,handwriting,free" />
<meta name="author" content="TexPixel Team" /> <meta name="author" content="TexPixel Team" />
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<!-- Open Graph Meta Tags --> <!-- Open Graph -->
<meta property="og:title" content="TexPixel - 公式识别工具" /> <meta property="og:title" content="TexPixel - AI Math Formula Recognition Tool" />
<meta property="og:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" /> <meta property="og:description" content="Free AI-powered tool to convert handwritten or printed math formulas to LaTeX, MathML, and Markdown. Upload an image or PDF and get results instantly." />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://texpixel.com/" /> <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:image" content="https://cdn.texpixel.com/public/og-cover.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:locale" content="en_US" />
<meta property="og:locale:alternate" content="zh_CN" />
<meta property="og:site_name" content="TexPixel" />
<!-- Twitter Card Meta Tags --> <!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="TexPixel - 公式识别工具" /> <meta name="twitter:title" content="TexPixel - AI Math Formula Recognition Tool" />
<meta name="twitter:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别。" /> <meta name="twitter:description" content="Convert handwritten or printed math formulas to LaTeX, MathML, and Markdown for free. Upload image or PDF — results in seconds." />
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" /> <meta name="twitter:image" content="https://cdn.texpixel.com/public/og-cover.png" />
<meta name="twitter:site" content="@TexPixel" />
<!-- baidu --> <!-- Baidu Verification -->
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" /> <meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "TexPixel",
"url": "https://texpixel.com/",
"description": "AI-powered math formula recognition tool that converts handwritten or printed formulas in images to LaTeX, MathML, and Markdown.",
"applicationCategory": "UtilitiesApplication",
"operatingSystem": "Web",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"featureList": [
"Handwritten math formula recognition",
"Printed formula OCR",
"LaTeX output",
"MathML output",
"Markdown output",
"PDF support",
"Image support"
],
"inLanguage": ["en", "zh-CN"],
"publisher": {
"@type": "Organization",
"name": "TexPixel",
"url": "https://texpixel.com/"
}
}
</script>
<!-- Language Detection Script -->
<script>
(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';
if (isZh) {
document.title = 'TexPixel - AI 数学公式识别工具 | LaTeX、MathML OCR';
document.querySelector('meta[name="description"]').setAttribute('content',
'免费 AI 数学公式识别工具,支持手写和印刷体公式识别,一键将图片或 PDF 中的数学公式转换为 LaTeX、MathML 和 Markdown 格式。');
}
})();
</script>
</head> </head>
<body> <body>

2321
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,9 @@
"build:dev": "VITE_ENV=development vite build", "build:dev": "VITE_ENV=development vite build",
"build:prod": "VITE_ENV=production vite build", "build:prod": "VITE_ENV=production vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit -p tsconfig.app.json" "typecheck": "tsc --noEmit -p tsconfig.app.json"
}, },
@@ -23,7 +26,9 @@
"mathml2omml": "^0.5.0", "mathml2omml": "^0.5.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hot-toast": "^2.6.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^7.13.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"remark-breaks": "^4.0.0", "remark-breaks": "^4.0.0",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
@@ -32,7 +37,11 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.9.1", "@eslint/js": "^9.9.1",
"@playwright/test": "^1.58.2",
"@tailwindcss/typography": "^0.5.19", "@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.5", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
@@ -41,10 +50,12 @@
"eslint-plugin-react-hooks": "^5.1.0-rc.0", "eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11", "eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0", "globals": "^15.9.0",
"jsdom": "^28.1.0",
"postcss": "^8.4.35", "postcss": "^8.4.35",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.5.3", "typescript": "^5.5.3",
"typescript-eslint": "^8.3.0", "typescript-eslint": "^8.3.0",
"vite": "^5.4.2" "vite": "^5.4.2",
"vitest": "^4.0.18"
} }
} }

24
playwright.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: 0,
reporter: 'list',
use: {
baseURL: 'http://127.0.0.1:4173',
trace: 'on-first-retry',
},
webServer: {
command: 'npm run dev -- --host 127.0.0.1 --port 4173',
url: 'http://127.0.0.1:4173',
reuseExistingServer: true,
timeout: 120000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

68
public/favicon.svg Normal file
View File

@@ -0,0 +1,68 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Light mode: dark lines -->
<linearGradient id="l1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="20%" stop-color="#111111" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.9"/>
</linearGradient>
<linearGradient id="l2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="24%" stop-color="#111111" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.6"/>
</linearGradient>
<linearGradient id="l3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="22%" stop-color="#111111" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="l4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="28%" stop-color="#111111" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.18"/>
</linearGradient>
<!-- Dark mode: white lines -->
<linearGradient id="d1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="20%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.95"/>
</linearGradient>
<linearGradient id="d2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="24%" stop-color="#ffffff" stop-opacity="0.65"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.65"/>
</linearGradient>
<linearGradient id="d3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="22%" stop-color="#ffffff" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.4"/>
</linearGradient>
<linearGradient id="d4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.22"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.22"/>
</linearGradient>
</defs>
<style>
.light-icon { display: block; }
.dark-icon { display: none; }
@media (prefers-color-scheme: dark) {
.light-icon { display: none; }
.dark-icon { display: block; }
}
</style>
<!-- Light mode group -->
<g class="light-icon">
<rect x="3" y="7" width="24" height="2.2" rx="1.1" fill="url(#l1)"/>
<rect x="4" y="12.5" width="18" height="2.2" rx="1.1" fill="url(#l2)"/>
<rect x="3" y="18" width="20" height="2.2" rx="1.1" fill="url(#l3)"/>
<rect x="5" y="23.5" width="14" height="2.2" rx="1.1" fill="url(#l4)"/>
</g>
<!-- Dark mode group -->
<g class="dark-icon">
<rect x="3" y="7" width="24" height="2.2" rx="1.1" fill="url(#d1)"/>
<rect x="4" y="12.5" width="18" height="2.2" rx="1.1" fill="url(#d2)"/>
<rect x="3" y="18" width="20" height="2.2" rx="1.1" fill="url(#d3)"/>
<rect x="5" y="23.5" width="14" height="2.2" rx="1.1" fill="url(#d4)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

4
public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://texpixel.com/sitemap.xml

17
public/site.webmanifest Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "TexPixel - AI Math Formula Recognition",
"short_name": "TexPixel",
"description": "Free AI-powered math formula recognition tool. Convert handwritten or printed math formulas to LaTeX, MathML, and Markdown.",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#6366f1",
"icons": [
{
"src": "/texpixel-app-icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}

13
public/sitemap.xml Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://texpixel.com/</loc>
<lastmod>2026-03-11</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
<xhtml:link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/"/>
<xhtml:link rel="alternate" hreflang="en" href="https://texpixel.com/"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://texpixel.com/"/>
</url>
</urlset>

View File

@@ -0,0 +1,29 @@
<svg width="1024" height="1024" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="line1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="25%" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="line2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.62"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.62"/>
</linearGradient>
<linearGradient id="line3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.38"/>
</linearGradient>
<linearGradient id="line4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="35%" stop-color="#ffffff" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#000000"/>
<rect x="24" y="42" width="74" height="3" rx="1.5" fill="url(#line1)"/>
<rect x="26" y="56" width="56" height="3" rx="1.5" fill="url(#line2)"/>
<rect x="24" y="70" width="64" height="3" rx="1.5" fill="url(#line3)"/>
<rect x="28" y="84" width="44" height="3" rx="1.5" fill="url(#line4)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,29 @@
<svg width="1024" height="1024" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="line1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="25%" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="line2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.62"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.62"/>
</linearGradient>
<linearGradient id="line3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.38"/>
</linearGradient>
<linearGradient id="line4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="35%" stop-color="#ffffff" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#000000"/>
<rect x="24" y="42" width="74" height="3" rx="1.5" fill="url(#line1)"/>
<rect x="26" y="56" width="56" height="3" rx="1.5" fill="url(#line2)"/>
<rect x="24" y="70" width="64" height="3" rx="1.5" fill="url(#line3)"/>
<rect x="28" y="84" width="44" height="3" rx="1.5" fill="url(#line4)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useAuth } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext';
import { useLanguage } from './contexts/LanguageContext';
import { uploadService } from './lib/uploadService'; import { uploadService } from './lib/uploadService';
import { FileRecord, RecognitionResult } from './types'; import { FileRecord, RecognitionResult } from './types';
import { TaskStatus, TaskHistoryItem } from './types/api'; import { TaskStatus, TaskHistoryItem } from './types/api';
@@ -8,16 +9,28 @@ import Navbar from './components/Navbar';
import FilePreview from './components/FilePreview'; import FilePreview from './components/FilePreview';
import ResultPanel from './components/ResultPanel'; import ResultPanel from './components/ResultPanel';
import UploadModal from './components/UploadModal'; import UploadModal from './components/UploadModal';
import UserGuide from './components/UserGuide';
import AuthModal from './components/AuthModal';
const PAGE_SIZE = 6; const PAGE_SIZE = 6;
const GUEST_USAGE_LIMIT = 3;
const GUEST_USAGE_COUNT_KEY = 'texpixel_guest_usage_count';
function App() { function App() {
const { user, initializing } = useAuth(); const { user, initializing } = useAuth();
const { t } = useLanguage();
const [files, setFiles] = useState<FileRecord[]>([]); const [files, setFiles] = useState<FileRecord[]>([]);
const [selectedFileId, setSelectedFileId] = useState<string | null>(null); const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null); const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
const [showUploadModal, setShowUploadModal] = useState(false); const [showUploadModal, setShowUploadModal] = useState(false);
const [showUserGuide, setShowUserGuide] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [guestUsageCount, setGuestUsageCount] = useState<number>(() => {
const storedCount = localStorage.getItem(GUEST_USAGE_COUNT_KEY);
const parsedCount = storedCount ? Number.parseInt(storedCount, 10) : 0;
return Number.isFinite(parsedCount) ? parsedCount : 0;
});
// Pagination state // Pagination state
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
@@ -40,6 +53,33 @@ function App() {
const hasLoadedFiles = useRef(false); const hasLoadedFiles = useRef(false);
const selectedFile = files.find((f) => f.id === selectedFileId) || null; const selectedFile = files.find((f) => f.id === selectedFileId) || null;
const canUploadAnonymously = !user && guestUsageCount < GUEST_USAGE_LIMIT;
const openAuthModal = useCallback(() => {
setShowAuthModal(true);
}, []);
const incrementGuestUsage = useCallback(() => {
setGuestUsageCount((prev) => {
const nextCount = prev + 1;
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount));
return nextCount;
});
}, []);
useEffect(() => {
const handleStartGuide = () => setShowUserGuide(true);
window.addEventListener('start-user-guide', handleStartGuide);
// Check for first-time user
const hasSeenGuide = localStorage.getItem('hasSeenGuide');
if (!hasSeenGuide) {
setTimeout(() => setShowUserGuide(true), 1500);
localStorage.setItem('hasSeenGuide', 'true');
}
return () => window.removeEventListener('start-user-guide', handleStartGuide);
}, []);
useEffect(() => { useEffect(() => {
if (!initializing && user && !hasLoadedFiles.current) { if (!initializing && user && !hasLoadedFiles.current) {
@@ -79,6 +119,10 @@ function App() {
const handlePaste = (e: ClipboardEvent) => { const handlePaste = (e: ClipboardEvent) => {
// If modal is open, let the modal handle paste events to avoid double upload // If modal is open, let the modal handle paste events to avoid double upload
if (showUploadModal) return; if (showUploadModal) return;
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
if (!items) return; if (!items) return;
@@ -98,7 +142,7 @@ function App() {
document.addEventListener('paste', handlePaste); document.addEventListener('paste', handlePaste);
return () => document.removeEventListener('paste', handlePaste); return () => document.removeEventListener('paste', handlePaste);
}, [user, showUploadModal]); }, [guestUsageCount, openAuthModal, showUploadModal, user]);
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => { const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
mouseDownEvent.preventDefault(); mouseDownEvent.preventDefault();
@@ -162,7 +206,7 @@ function App() {
markdown_content: item.markdown, markdown_content: item.markdown,
latex_content: item.latex, latex_content: item.latex,
mathml_content: item.mathml, mathml_content: item.mathml,
mathml_word_content: item.mathml_mw, mml: item.mml,
rendered_image_path: item.image_blob || null, rendered_image_path: item.image_blob || null,
created_at: item.created_at, created_at: item.created_at,
}; };
@@ -195,10 +239,6 @@ function App() {
} }
}); });
// Auto-select first file if none selected
if (!selectedFileId) {
setSelectedFileId(fileRecords[0].id);
}
} else { } else {
setFiles([]); setFiles([]);
} }
@@ -269,7 +309,7 @@ function App() {
markdown_content: result.markdown, markdown_content: result.markdown,
latex_content: result.latex, latex_content: result.latex,
mathml_content: result.mathml, mathml_content: result.mathml,
mathml_word_content: result.mathml_mw, mml: result.mml,
rendered_image_path: result.image_blob || null, rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}; };
@@ -326,7 +366,7 @@ function App() {
markdown_content: result.markdown, markdown_content: result.markdown,
latex_content: result.latex, latex_content: result.latex,
mathml_content: result.mathml, mathml_content: result.mathml,
mathml_word_content: result.mathml_mw, mml: result.mml,
rendered_image_path: result.image_blob || null, rendered_image_path: result.image_blob || null,
created_at: new Date().toISOString() created_at: new Date().toISOString()
}; };
@@ -352,7 +392,7 @@ function App() {
return f; return f;
})); }));
alert('Task timeout: Recognition took too long.'); alert(t.alerts.taskTimeout);
} }
} catch (error) { } catch (error) {
@@ -366,15 +406,22 @@ function App() {
if (f.id === fileId) return { ...f, status: 'failed' }; if (f.id === fileId) return { ...f, status: 'failed' };
return f; return f;
})); }));
alert('Task timeout or network error.'); alert(t.alerts.networkError);
} }
} }
}, 2000); // Poll every 2 seconds }, 2000); // Poll every 2 seconds
}; };
const handleUpload = async (uploadFiles: File[]) => { const handleUpload = async (uploadFiles: File[]) => {
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
setLoading(true); setLoading(true);
try { try {
let successfulUploads = 0;
for (const file of uploadFiles) { for (const file of uploadFiles) {
// 1. Upload file to OSS (or check duplicate) // 1. Upload file to OSS (or check duplicate)
const fileHash = await uploadService.calculateMD5(file); const fileHash = await uploadService.calculateMD5(file);
@@ -412,10 +459,16 @@ function App() {
if (taskData.task_no) { if (taskData.task_no) {
startPolling(taskData.task_no, fileId); startPolling(taskData.task_no, fileId);
} }
successfulUploads += 1;
}
if (!user && successfulUploads > 0) {
incrementGuestUsage();
} }
} catch (error) { } catch (error) {
console.error('Error uploading files:', error); console.error('Error uploading files:', error);
alert('Upload failed: ' + (error instanceof Error ? error.message : 'Unknown error')); alert(`${t.alerts.uploadFailed}: ` + (error instanceof Error ? error.message : 'Unknown error'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -426,7 +479,7 @@ function App() {
<div className="min-h-screen bg-gray-50 flex items-center justify-center"> <div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div> <div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-600">Loading...</p> <p className="text-gray-600">{t.common.loading}</p>
</div> </div>
</div> </div>
); );
@@ -446,7 +499,15 @@ function App() {
files={files} files={files}
selectedFileId={selectedFileId} selectedFileId={selectedFileId}
onFileSelect={setSelectedFileId} onFileSelect={setSelectedFileId}
onUploadClick={() => setShowUploadModal(true)} onUploadClick={() => {
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
openAuthModal();
return;
}
setShowUploadModal(true);
}}
canUploadAnonymously={canUploadAnonymously}
onRequireAuth={openAuthModal}
isCollapsed={sidebarCollapsed} isCollapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)} onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
onUploadFiles={handleUpload} onUploadFiles={handleUpload}
@@ -484,27 +545,26 @@ function App() {
/> />
)} )}
{showAuthModal && (
<AuthModal
onClose={() => setShowAuthModal(false)}
/>
)}
<UserGuide
isOpen={showUserGuide}
onClose={() => setShowUserGuide(false)}
/>
{loading && ( {loading && (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-xl p-8"> <div className="bg-white rounded-xl shadow-xl p-8">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div> <div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p className="text-gray-900 font-medium">Processing...</p> <p className="text-gray-900 font-medium">{t.common.processing}</p>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* ICP Footer */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 py-2 px-4 text-center">
<a
href="https://beian.miit.gov.cn"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
ICP备2025152973号
</a>
</div>
</div> </div>
); );
} }

View File

@@ -1,39 +1,73 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
interface AuthModalProps { interface AuthModalProps {
onClose: () => void; onClose: () => void;
mandatory?: boolean;
} }
export default function AuthModal({ onClose }: AuthModalProps) { export default function AuthModal({ onClose, mandatory = false }: AuthModalProps) {
const { signIn, signUp } = useAuth(); const { signIn, signUp, beginGoogleOAuth, authPhase, authError } = useAuth();
const [isSignUp, setIsSignUp] = useState(false); const { t } = useLanguage();
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState(''); const [localError, setLocalError] = useState('');
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
const isBusy = useMemo(
() => ['email_signing_in', 'email_signing_up', 'oauth_redirecting', 'oauth_exchanging'].includes(authPhase),
[authPhase]
);
const submitText = mode === 'signup' ? t.auth.signUp : t.auth.signIn;
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setLocalError('');
setLoading(true); const nextFieldErrors: { email?: string; password?: string; confirmPassword?: string } = {};
const normalizedEmail = email.trim();
try { if (!normalizedEmail) {
const { error } = isSignUp nextFieldErrors.email = t.auth.emailRequired;
? await signUp(email, password) } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
: await signIn(email, password); nextFieldErrors.email = t.auth.emailInvalid;
}
if (error) { if (!password) {
setError(error.message); nextFieldErrors.password = t.auth.passwordRequired;
} else { }
if (mode === 'signup') {
if (password && password.length < 6) {
nextFieldErrors.password = t.auth.passwordHint;
}
if (!confirmPassword) {
nextFieldErrors.confirmPassword = t.auth.passwordRequired;
} else if (password !== confirmPassword) {
nextFieldErrors.confirmPassword = t.auth.passwordMismatch;
}
}
setFieldErrors(nextFieldErrors);
if (Object.keys(nextFieldErrors).length > 0) {
return;
}
const result = mode === 'signup' ? await signUp(normalizedEmail, password) : await signIn(normalizedEmail, password);
if (!result.error) {
onClose(); onClose();
} }
} catch (err) { };
setError('发生错误,请重试');
} finally { const handleGoogleOAuth = async () => {
setLoading(false); await beginGoogleOAuth();
}
}; };
return ( return (
@@ -41,69 +75,177 @@ export default function AuthModal({ onClose }: AuthModalProps) {
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6"> <div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900"> <h2 className="text-2xl font-bold text-gray-900">
{isSignUp ? '注册账号' : '登录账号'} {mode === 'signup' ? t.auth.signUpTitle : t.auth.signInTitle}
</h2> </h2>
{!mandatory && (
<button <button
type="button"
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
aria-label="close"
disabled={isBusy}
> >
<X size={20} /> <X size={20} />
</button> </button>
)}
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 mb-4">
<button
type="button"
onClick={() => {
setMode('signin');
setFieldErrors({});
setLocalError('');
}}
aria-pressed={mode === 'signin'}
disabled={isBusy}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
mode === 'signin' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
{t.auth.signIn}
</button>
<button
type="button"
onClick={() => {
setMode('signup');
setFieldErrors({});
setLocalError('');
}}
aria-pressed={mode === 'signup'}
disabled={isBusy}
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
mode === 'signup' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
{t.auth.signUp}
</button>
</div>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="auth-email" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.email}
</label> </label>
<input <input
id="auth-email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => {
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" setEmail(e.target.value);
if (fieldErrors.email) {
setFieldErrors((prev) => ({ ...prev, email: undefined }));
}
}}
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
fieldErrors.email ? 'border-red-400' : 'border-gray-300'
}`}
placeholder="your@email.com" placeholder="your@email.com"
required required
disabled={isBusy}
/> />
{fieldErrors.email && <p className="mt-1 text-xs text-red-600">{fieldErrors.email}</p>}
{mode === 'signup' && (
<p className="mt-1 text-xs text-gray-500">{t.auth.emailHint}</p>
)}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label htmlFor="auth-password" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.password}
</label> </label>
<input <input
id="auth-password"
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => {
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" setPassword(e.target.value);
if (fieldErrors.password) {
setFieldErrors((prev) => ({ ...prev, password: undefined }));
}
}}
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
fieldErrors.password ? 'border-red-400' : 'border-gray-300'
}`}
placeholder="••••••••" placeholder="••••••••"
required required
minLength={6} minLength={6}
disabled={isBusy}
/> />
{fieldErrors.password && <p className="mt-1 text-xs text-red-600">{fieldErrors.password}</p>}
{mode === 'signup' && (
<p className="mt-1 text-xs text-gray-500">{t.auth.passwordHint}</p>
)}
</div> </div>
{error && ( {mode === 'signup' && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse"> <div>
: {error} <label htmlFor="auth-password-confirm" className="block text-sm font-medium text-gray-700 mb-1">
{t.auth.confirmPassword}
</label>
<input
id="auth-password-confirm"
type="password"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
if (fieldErrors.confirmPassword) {
setFieldErrors((prev) => ({ ...prev, confirmPassword: undefined }));
}
}}
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
fieldErrors.confirmPassword ? 'border-red-400' : 'border-gray-300'
}`}
placeholder="••••••••"
required
minLength={6}
disabled={isBusy}
/>
{fieldErrors.confirmPassword && <p className="mt-1 text-xs text-red-600">{fieldErrors.confirmPassword}</p>}
</div>
)}
{(localError || authError) && (
<div className="p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm font-medium">
{t.auth.error}: {localError || authError}
</div> </div>
)} )}
<button <button
type="submit" type="submit"
disabled={loading} disabled={isBusy}
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait" className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait"
> >
{isSignUp ? '注册' : '登录'} {submitText}
</button>
<div className="relative py-1">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="w-full border-t border-gray-200" />
</div>
<div className="relative flex justify-center text-xs">
<span className="bg-white px-2 text-gray-400">OR</span>
</div>
</div>
<button
type="button"
onClick={handleGoogleOAuth}
disabled={isBusy}
className="w-full py-3 px-4 border border-gray-300 text-gray-800 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait inline-flex items-center justify-center gap-2"
>
<img
src="https://upload.wikimedia.org/wikipedia/commons/7/7e/Gmail_icon_%282020%29.svg"
alt=""
aria-hidden="true"
className="w-[18px] h-[18px]"
loading="lazy"
decoding="async"
/>
{authPhase === 'oauth_redirecting' ? t.auth.oauthRedirecting : t.auth.continueWithGoogle}
</button> </button>
</form> </form>
<div className="mt-4 text-center">
<button
onClick={() => setIsSignUp(!isSignUp)}
className="text-sm text-blue-600 hover:text-blue-700"
>
{isSignUp ? '已有账号?去登录' : '没有账号?去注册'}
</button>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2, LucideIcon } from 'lucide-react'; import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2, LucideIcon } from 'lucide-react';
import { RecognitionResult } from '../types'; import { RecognitionResult } from '../types';
import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter';
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator'; import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
import { API_BASE_URL } from '../config/env'; import { API_BASE_URL } from '../config/env';
import { tokenManager } from '../lib/api'; import { tokenManager } from '../lib/api';
import { trackExportEvent } from '../lib/analytics';
import { useLanguage } from '../contexts/LanguageContext';
import toast, { Toaster } from 'react-hot-toast';
interface ExportSidebarProps { interface ExportSidebarProps {
isOpen: boolean; isOpen: boolean;
@@ -24,6 +26,7 @@ interface ExportOption {
} }
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) { export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
const { t } = useLanguage();
const [copiedId, setCopiedId] = useState<string | null>(null); const [copiedId, setCopiedId] = useState<string | null>(null);
const [exportingId, setExportingId] = useState<string | null>(null); const [exportingId, setExportingId] = useState<string | null>(null);
@@ -37,14 +40,25 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
category: 'Code', category: 'Code',
getContent: (r) => r.markdown_content getContent: (r) => r.markdown_content
}, },
{
id: 'latex',
label: 'LaTeX',
category: 'Code',
getContent: (r) => r.latex_content
},
{ {
id: 'latex_inline', id: 'latex_inline',
label: 'LaTeX (Inline)', label: 'LaTeX (Inline)',
category: 'Code', category: 'Code',
getContent: (r) => { getContent: (r) => {
if (!r.latex_content) return null; if (!r.latex_content) return null;
// Remove existing \[ \] and wrap with \( \) // Remove existing delimiters like \[ \], \( \), $$, or $
const content = r.latex_content.replace(/^\\\[/, '').replace(/\\\]$/, '').trim(); 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}\\)`; return `\\(${content}\\)`;
} }
}, },
@@ -52,7 +66,17 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
id: 'latex_display', id: 'latex_display',
label: 'LaTeX (Display)', label: 'LaTeX (Display)',
category: 'Code', 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', id: 'mathml',
@@ -61,10 +85,10 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
getContent: (r) => r.mathml_content getContent: (r) => r.mathml_content
}, },
{ {
id: 'mathml_word', id: 'mathml_mml',
label: 'Word MathML', label: 'MathML (MML)',
category: 'Code', category: 'Code',
getContent: (r) => r.mathml_word_content getContent: (r) => r.mml
}, },
// Image Category // Image Category
{ {
@@ -127,7 +151,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
}, 1000); }, 1000);
} catch (err) { } catch (err) {
console.error('Export failed:', err); console.error('Export failed:', err);
alert('导出失败,请重试'); alert(t.export.failed);
} finally { } finally {
setExportingId(null); setExportingId(null);
} }
@@ -160,13 +184,22 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
}, 1000); }, 1000);
} catch (err) { } catch (err) {
console.error('Failed to generate image:', err); console.error('Failed to generate image:', err);
alert(`生成图片失败: ${err}`); alert(`${t.export.imageFailed}: ${err}`);
} finally { } finally {
setExportingId(null); setExportingId(null);
} }
}; };
const handleAction = async (option: ExportOption) => { 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 // Handle DOCX export via API
if (option.id === 'docx') { if (option.id === 'docx') {
await handleFileExport('docx'); await handleFileExport('docx');
@@ -179,21 +212,32 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
return; return;
} }
let content = option.getContent(result); const content = option.getContent(result);
// Fallback: If Word MathML is missing, try to convert from MathML // Check if content is empty and show toast
if (option.id === 'mathml_word' && !content && result.mathml_content) { if (!content) {
try { toast.error(t.export.noContent, {
const omml = await convertMathmlToOmml(result.mathml_content); duration: 2000,
if (omml) { position: 'top-center',
content = wrapOmmlForClipboard(omml); 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;
} }
} catch (err) {
console.error('Failed to convert MathML to OMML:', err);
}
}
if (!content) return;
setExportingId(option.id); setExportingId(option.id);
@@ -222,19 +266,58 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
} catch (err) { } catch (err) {
console.error('Action failed:', err); console.error('Action failed:', err);
toast.error(t.export.failed, {
duration: 3000,
position: 'top-center',
});
} finally { } finally {
setExportingId(null); setExportingId(null);
} }
}; };
const categories: { id: ExportCategory; icon: LucideIcon; label: string }[] = [ const categories: { id: ExportCategory; icon: LucideIcon; label: string }[] = [
{ id: 'Code', icon: Code2, label: 'Code' }, { id: 'Code', icon: Code2, label: t.export.categories.code },
{ id: 'Image', icon: ImageIcon, label: 'Image' }, { id: 'Image', icon: ImageIcon, label: t.export.categories.image },
{ id: 'File', icon: FileText, label: 'File' }, { id: 'File', icon: FileText, label: t.export.categories.file },
]; ];
return ( return (
<> <>
{/* 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 */} {/* Backdrop */}
{isOpen && ( {isOpen && (
<div <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"> <div className="flex items-center justify-between px-6 py-5 border-b border-gray-100 shrink-0">
<h2 className="text-lg font-bold text-gray-900">Export</h2> <h2 className="text-lg font-bold text-gray-900">{t.export.title}</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors"> <button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
<X size={20} className="text-gray-500" /> <X size={20} className="text-gray-500" />
</button> </button>

View File

@@ -1,12 +1,14 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronLeft, ChevronRight, File as FileIcon, MinusCircle, PlusCircle } from 'lucide-react'; import { ChevronLeft, ChevronRight, File as FileIcon, MinusCircle, PlusCircle } from 'lucide-react';
import { FileRecord } from '../types'; import { FileRecord } from '../types';
import { useLanguage } from '../contexts/LanguageContext';
interface FilePreviewProps { interface FilePreviewProps {
file: FileRecord | null; file: FileRecord | null;
} }
export default function FilePreview({ file }: FilePreviewProps) { export default function FilePreview({ file }: FilePreviewProps) {
const { t } = useLanguage();
const [zoom, setZoom] = useState(100); const [zoom, setZoom] = useState(100);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const totalPages = 1; const totalPages = 1;
@@ -16,13 +18,13 @@ export default function FilePreview({ file }: FilePreviewProps) {
if (!file) { if (!file) {
return ( return (
<div className="flex-1 flex flex-col items-center justify-center bg-white p-8 text-center border border-white border-solid"> <div className="flex-1 flex flex-col items-center justify-center bg-white p-8 text-center border border-white border-solid" id="file-preview-empty">
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner"> <div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
<FileIcon size={48} className="text-gray-900" /> <FileIcon size={48} className="text-gray-900" />
</div> </div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Upload file</h3> <h3 className="text-xl font-semibold text-gray-900 mb-2">{t.common.upload}</h3>
<p className="text-gray-500 max-w-xs"> <p className="text-gray-500 max-w-xs">
Click, Drop, or Paste a file to start parsing {t.sidebar.uploadInstruction}
</p> </p>
</div> </div>
); );
@@ -68,7 +70,7 @@ export default function FilePreview({ file }: FilePreviewProps) {
<button <button
onClick={handleZoomOut} onClick={handleZoomOut}
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all" className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
title="缩小" title={t.common.preview}
> >
<MinusCircle size={16} /> <MinusCircle size={16} />
</button> </button>
@@ -78,7 +80,7 @@ export default function FilePreview({ file }: FilePreviewProps) {
<button <button
onClick={handleZoomIn} onClick={handleZoomIn}
className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all" className="p-1 hover:bg-white hover:shadow-sm rounded-md text-gray-500 transition-all"
title="放大" title={t.common.preview}
> >
<PlusCircle size={16} /> <PlusCircle size={16} />
</button> </button>

View File

@@ -1,6 +1,7 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, Settings, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react'; import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import { FileRecord } from '../types'; import { FileRecord } from '../types';
import AuthModal from './AuthModal'; import AuthModal from './AuthModal';
@@ -9,6 +10,8 @@ interface LeftSidebarProps {
selectedFileId: string | null; selectedFileId: string | null;
onFileSelect: (fileId: string) => void; onFileSelect: (fileId: string) => void;
onUploadClick: () => void; onUploadClick: () => void;
canUploadAnonymously: boolean;
onRequireAuth: () => void;
isCollapsed: boolean; isCollapsed: boolean;
onToggleCollapse: () => void; onToggleCollapse: () => void;
onUploadFiles: (files: File[]) => void; onUploadFiles: (files: File[]) => void;
@@ -22,6 +25,8 @@ export default function LeftSidebar({
selectedFileId, selectedFileId,
onFileSelect, onFileSelect,
onUploadClick, onUploadClick,
canUploadAnonymously,
onRequireAuth,
isCollapsed, isCollapsed,
onToggleCollapse, onToggleCollapse,
onUploadFiles, onUploadFiles,
@@ -29,12 +34,21 @@ export default function LeftSidebar({
loadingMore, loadingMore,
onLoadMore, onLoadMore,
}: LeftSidebarProps) { }: LeftSidebarProps) {
const { user, signOut } = useAuth(); const { user, signOut, isAuthenticated } = useAuth();
const { t } = useLanguage();
const [showAuthModal, setShowAuthModal] = useState(false); const [showAuthModal, setShowAuthModal] = useState(false);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const displayName = user?.username?.trim() || user?.email || '';
useEffect(() => {
if (isAuthenticated) {
setShowAuthModal(false);
}
}, [isAuthenticated]);
// ... (rest of the logic remains the same)
// Handle scroll to load more // Handle scroll to load more
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (!listRef.current || loadingMore || !hasMore) return; if (!listRef.current || loadingMore || !hasMore) return;
@@ -81,6 +95,10 @@ export default function LeftSidebar({
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
if (!user && !canUploadAnonymously) {
onRequireAuth();
return;
}
setIsDragging(false); setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files); const droppedFiles = Array.from(e.dataTransfer.files);
@@ -94,6 +112,10 @@ export default function LeftSidebar({
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!user && !canUploadAnonymously) {
onRequireAuth();
return;
}
if (e.target.files && e.target.files.length > 0) { if (e.target.files && e.target.files.length > 0) {
onUploadFiles(Array.from(e.target.files)); onUploadFiles(Array.from(e.target.files));
} }
@@ -114,15 +136,21 @@ export default function LeftSidebar({
</button> </button>
<button <button
onClick={onUploadClick} onClick={() => {
if (!user && !canUploadAnonymously) {
onRequireAuth();
return;
}
onUploadClick();
}}
className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20 transition-all mb-6" className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20 transition-all mb-6"
title="Upload" title={t.common.upload}
> >
<Upload size={20} /> <Upload size={20} />
</button> </button>
<div className="flex-1 w-full flex flex-col items-center gap-4"> <div className="flex-1 w-full flex flex-col items-center gap-4">
<button className="p-2 text-gray-400 hover:text-gray-900 transition-colors" title="History"> <button className="p-2 text-gray-400 hover:text-gray-900 transition-colors" title={t.common.history}>
<History size={20} /> <History size={20} />
</button> </button>
</div> </div>
@@ -130,7 +158,7 @@ export default function LeftSidebar({
<button <button
onClick={() => !user && setShowAuthModal(true)} onClick={() => !user && setShowAuthModal(true)}
className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto" className="p-3 rounded-lg text-gray-600 hover:bg-gray-200 transition-colors mt-auto"
title={user ? 'Signed In' : 'Sign In'} title={user ? 'Signed In' : t.common.login}
> >
<LogIn size={20} /> <LogIn size={20} />
</button> </button>
@@ -145,8 +173,8 @@ export default function LeftSidebar({
<div className="p-6 pb-4"> <div className="p-6 pb-4">
<div className="flex items-start justify-between mb-6"> <div className="flex items-start justify-between mb-6">
<div> <div>
<h2 className="text-lg font-bold text-gray-900 leading-tight">Formula Recognize</h2> <h2 className="text-lg font-bold text-gray-900 leading-tight">{t.sidebar.title}</h2>
<p className="text-xs text-gray-500 mt-1">Support handwriting and printed formulas</p> <p className="text-xs text-gray-500 mt-1">{t.sidebar.subtitle}</p>
</div> </div>
<button <button
onClick={onToggleCollapse} onClick={onToggleCollapse}
@@ -156,12 +184,18 @@ export default function LeftSidebar({
</button> </button>
</div> </div>
<div className="mb-2"> <div className="mb-2" id="sidebar-upload-area">
<div <div
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()} onClick={() => {
if (!user && !canUploadAnonymously) {
onRequireAuth();
return;
}
fileInputRef.current?.click();
}}
className={` className={`
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group
${isDragging ${isDragging
@@ -181,18 +215,19 @@ export default function LeftSidebar({
<div className="w-12 h-12 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform"> <div className="w-12 h-12 bg-gray-100 text-gray-600 rounded-full flex items-center justify-center mx-auto mb-3 group-hover:scale-110 transition-transform">
<Upload size={24} /> <Upload size={24} />
</div> </div>
<div className="flex items-center justify-center gap-4 mt-2 text-xs text-gray-400"> <p className="text-xs text-gray-500 mb-2">{t.sidebar.uploadInstruction}</p>
<div className="flex items-center justify-center gap-4 text-xs text-gray-400">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<MousePointerClick className="w-3.5 h-3.5" /> <MousePointerClick className="w-3.5 h-3.5" />
<span>Click</span> <span>{t.common.click}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FileUp className="w-3.5 h-3.5" /> <FileUp className="w-3.5 h-3.5" />
<span>Drop</span> <span>{t.common.drop}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ClipboardPaste className="w-3.5 h-3.5" /> <ClipboardPaste className="w-3.5 h-3.5" />
<span>Paste</span> <span>{t.common.paste}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -200,10 +235,10 @@ export default function LeftSidebar({
</div> </div>
{/* Middle Area: History */} {/* Middle Area: History */}
<div className="flex-1 overflow-hidden flex flex-col px-4"> <div className="flex-1 overflow-hidden flex flex-col px-4" id="sidebar-history">
<div className="flex items-center gap-2 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2"> <div className="flex items-center gap-2 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3 px-2">
<Clock size={14} /> <Clock size={14} />
<span>History</span> <span>{t.sidebar.historyHeader}</span>
</div> </div>
<div <div
@@ -214,12 +249,12 @@ export default function LeftSidebar({
{!user ? ( {!user ? (
<div className="text-center py-12 text-gray-400 text-sm"> <div className="text-center py-12 text-gray-400 text-sm">
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div> <div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
Please login to view history {t.sidebar.pleaseLogin}
</div> </div>
) : files.length === 0 ? ( ) : files.length === 0 ? (
<div className="text-center py-12 text-gray-400 text-sm"> <div className="text-center py-12 text-gray-400 text-sm">
<div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div> <div className="mb-2 opacity-50"><FileText size={40} className="mx-auto" /></div>
No history records {t.sidebar.noHistory}
</div> </div>
) : ( ) : (
<> <>
@@ -256,13 +291,13 @@ export default function LeftSidebar({
{loadingMore && ( {loadingMore && (
<div className="flex items-center justify-center py-3 text-gray-400"> <div className="flex items-center justify-center py-3 text-gray-400">
<Loader2 size={18} className="animate-spin" /> <Loader2 size={18} className="animate-spin" />
<span className="ml-2 text-xs">Loading...</span> <span className="ml-2 text-xs">{t.common.loading}</span>
</div> </div>
)} )}
{/* End of list indicator */} {/* End of list indicator */}
{!hasMore && files.length > 0 && ( {!hasMore && files.length > 0 && (
<div className="text-center py-3 text-xs text-gray-400"> <div className="text-center py-3 text-xs text-gray-400">
No more records {t.sidebar.noMore}
</div> </div>
)} )}
</> </>
@@ -280,12 +315,12 @@ export default function LeftSidebar({
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{user.email}</p> <p className="text-sm font-medium text-gray-900 truncate">{displayName}</p>
</div> </div>
<button <button
onClick={() => signOut()} onClick={() => signOut()}
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors" className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Logout" title={t.common.logout}
> >
<LogOut size={16} /> <LogOut size={16} />
</button> </button>
@@ -296,7 +331,7 @@ export default function LeftSidebar({
className="w-full py-2.5 px-4 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors flex items-center justify-center gap-2 text-sm font-medium shadow-lg shadow-gray-900/10" className="w-full py-2.5 px-4 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors flex items-center justify-center gap-2 text-sm font-medium shadow-lg shadow-gray-900/10"
> >
<LogIn size={18} /> <LogIn size={18} />
Login / Register {t.common.login}
</button> </button>
)} )}
</div> </div>

View File

@@ -1,11 +1,15 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X } from 'lucide-react'; import { Mail, Users, MessageCircle, ChevronDown, Check, Heart, X, Languages, HelpCircle } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
export default function Navbar() { export default function Navbar() {
const { language, setLanguage, t } = useLanguage();
const [showContact, setShowContact] = useState(false); const [showContact, setShowContact] = useState(false);
const [showReward, setShowReward] = useState(false); const [showReward, setShowReward] = useState(false);
const [showLangMenu, setShowLangMenu] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const langMenuRef = useRef<HTMLDivElement>(null);
const handleCopyQQ = async () => { const handleCopyQQ = async () => {
await navigator.clipboard.writeText('1018282100'); await navigator.clipboard.writeText('1018282100');
@@ -19,6 +23,9 @@ export default function Navbar() {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setShowContact(false); setShowContact(false);
} }
if (langMenuRef.current && !langMenuRef.current.contains(event.target as Node)) {
setShowLangMenu(false);
}
}; };
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
@@ -29,16 +36,65 @@ export default function Navbar() {
<div className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0 z-[60] relative"> <div className="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-6 flex-shrink-0 z-[60] relative">
{/* Left: Logo */} {/* Left: Logo */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white font-serif italic text-lg shadow-blue-600/30 shadow-md"> <img src="/texpixel-app-icon.svg" alt="TexPixel" className="w-8 h-8" />
T
</span>
<span className="text-xl font-bold text-gray-900 tracking-tight"> <span className="text-xl font-bold text-gray-900 tracking-tight">
TexPixel TexPixel
</span> </span>
</div> </div>
{/* Right: Reward & Contact Buttons */} {/* Right: Actions */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Language Switcher */}
<div className="relative" ref={langMenuRef}>
<button
onClick={() => setShowLangMenu(!showLangMenu)}
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
title="Switch Language"
>
<Languages size={18} />
<span className="hidden sm:inline">{language === 'en' ? 'English' : '简体中文'}</span>
<ChevronDown size={14} className={`transition-transform duration-200 ${showLangMenu ? 'rotate-180' : ''}`} />
</button>
{showLangMenu && (
<div className="absolute right-0 top-full mt-2 w-32 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<button
onClick={() => {
setLanguage('en');
setShowLangMenu(false);
}}
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === 'en' ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
>
English
{language === 'en' && <Check size={14} />}
</button>
<button
onClick={() => {
setLanguage('zh');
setShowLangMenu(false);
}}
className={`w-full flex items-center justify-between px-4 py-2 text-sm transition-colors hover:bg-gray-50 ${language === 'zh' ? 'text-blue-600 font-medium' : 'text-gray-700'}`}
>
{language === 'zh' && <Check size={14} />}
</button>
</div>
)}
</div>
{/* User Guide Button */}
<button
id="guide-button"
className="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg text-gray-700 text-sm font-medium transition-colors"
onClick={() => {
// This will be handled in App.tsx via a custom event or shared state
window.dispatchEvent(new CustomEvent('start-user-guide'));
}}
>
<HelpCircle size={18} />
<span className="hidden sm:inline">{t.common.guide}</span>
</button>
{/* Reward Button */} {/* Reward Button */}
<div className="relative"> <div className="relative">
<button <button
@@ -46,7 +102,7 @@ export default function Navbar() {
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-rose-500 to-pink-500 hover:from-rose-600 hover:to-pink-600 rounded-lg text-white text-sm font-medium transition-all shadow-sm hover:shadow-md" className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-rose-500 to-pink-500 hover:from-rose-600 hover:to-pink-600 rounded-lg text-white text-sm font-medium transition-all shadow-sm hover:shadow-md"
> >
<Heart size={14} className="fill-white" /> <Heart size={14} className="fill-white" />
<span></span> <span>{t.common.reward}</span>
</button> </button>
{/* Reward Modal */} {/* Reward Modal */}
@@ -60,7 +116,7 @@ export default function Navbar() {
onClick={e => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<span className="text-lg font-bold text-gray-900"></span> <span className="text-lg font-bold text-gray-900">{t.navbar.rewardTitle}</span>
<button <button
onClick={() => setShowReward(false)} onClick={() => setShowReward(false)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
@@ -71,12 +127,12 @@ export default function Navbar() {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<img <img
src="https://cdn.texpixel.com/public/rewardcode.png" src="https://cdn.texpixel.com/public/rewardcode.png"
alt="微信赞赏码" alt={t.navbar.rewardTitle}
className="w-64 h-64 object-contain rounded-lg shadow-sm" className="w-64 h-64 object-contain rounded-lg shadow-sm"
/> />
<p className="text-sm text-gray-500 text-center mt-4"> <p className="text-sm text-gray-500 text-center mt-4">
<br /> {t.navbar.rewardThanks}<br />
<span className="text-xs text-gray-400 mt-1 block"></span> <span className="text-xs text-gray-400 mt-1 block">{t.navbar.rewardSubtitle}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -91,7 +147,7 @@ export default function Navbar() {
className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 text-sm font-medium transition-colors" className="flex items-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-gray-700 text-sm font-medium transition-colors"
> >
<MessageCircle size={14} /> <MessageCircle size={14} />
<span>Contact Us</span> <span>{t.common.contactUs}</span>
<ChevronDown <ChevronDown
size={14} size={14}
className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`} className={`transition-transform duration-200 ${showContact ? 'rotate-180' : ''}`}
@@ -109,7 +165,7 @@ export default function Navbar() {
<Mail size={16} className="text-blue-600" /> <Mail size={16} className="text-blue-600" />
</div> </div>
<div> <div>
<div className="text-xs text-gray-500">Email</div> <div className="text-xs text-gray-500">{t.common.email}</div>
<div className="text-sm font-medium text-gray-900">yogecoder@gmail.com</div> <div className="text-sm font-medium text-gray-900">yogecoder@gmail.com</div>
</div> </div>
</a> </a>
@@ -126,7 +182,7 @@ export default function Navbar() {
</div> </div>
<div> <div>
<div className={`text-xs transition-colors ${copied ? 'text-green-600 font-medium' : 'text-gray-500'}`}> <div className={`text-xs transition-colors ${copied ? 'text-green-600 font-medium' : 'text-gray-500'}`}>
{copied ? 'Copied!' : 'QQ Group (Click to Copy)'} {copied ? t.common.copied : t.common.qqGroup}
</div> </div>
<div className="text-sm font-medium text-gray-900">1018282100</div> <div className="text-sm font-medium text-gray-900">1018282100</div>
</div> </div>

View File

@@ -7,6 +7,7 @@ import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css'; import 'katex/dist/katex.min.css';
import { RecognitionResult } from '../types'; import { RecognitionResult } from '../types';
import ExportSidebar from './ExportSidebar'; import ExportSidebar from './ExportSidebar';
import { useLanguage } from '../contexts/LanguageContext';
interface ResultPanelProps { interface ResultPanelProps {
result: RecognitionResult | null; result: RecognitionResult | null;
@@ -67,6 +68,7 @@ function preprocessLatex(content: string): string {
} }
export default function ResultPanel({ result, fileStatus }: ResultPanelProps) { export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
const { t } = useLanguage();
const [isExportSidebarOpen, setIsExportSidebarOpen] = useState(false); const [isExportSidebarOpen, setIsExportSidebarOpen] = useState(false);
if (!result) { if (!result) {
@@ -75,44 +77,45 @@ export default function ResultPanel({ result, fileStatus }: ResultPanelProps) {
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8"> <div className="h-full flex flex-col items-center justify-center bg-white text-center p-8">
<div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mb-6"></div> <div className="w-16 h-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mb-6"></div>
<h3 className="text-xl font-semibold text-gray-900 mb-2"> <h3 className="text-xl font-semibold text-gray-900 mb-2">
{fileStatus === 'pending' ? 'Waiting in queue...' : 'Analyzing...'} {fileStatus === 'pending' ? t.resultPanel.waitingQueue : t.resultPanel.analyzing}
</h3> </h3>
<p className="text-gray-500 max-w-sm"> <p className="text-gray-500 max-w-sm">
{fileStatus === 'pending' {fileStatus === 'pending'
? 'Your file is in the queue, please wait.' ? t.resultPanel.queueSubtitle
: 'Texpixel is processing your file, this may take a moment.'} : t.resultPanel.processingSubtitle}
</p> </p>
</div> </div>
); );
} }
return ( return (
<div className="h-full flex flex-col items-center justify-center bg-white text-center p-8"> <div className="h-full flex flex-col items-center justify-center bg-white text-center p-8" id="result-empty-state">
<div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner"> <div className="w-32 h-32 bg-gray-100 rounded-full flex items-center justify-center mb-6 shadow-inner">
<Code2 size={48} className="text-gray-900" /> <Code2 size={48} className="text-gray-900" />
</div> </div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">Waiting for recognition result</h3> <h3 className="text-xl font-semibold text-gray-900 mb-2">{t.resultPanel.waitingTitle}</h3>
<p className="text-gray-500 max-w-sm"> <p className="text-gray-500 max-w-sm">
After uploading the file, Texpixel will automatically recognize and display the result here {t.resultPanel.waitingSubtitle}
</p> </p>
</div> </div>
); );
} }
return ( return (
<div className="flex flex-col h-full bg-white relative overflow-hidden"> <div className="flex flex-col h-full bg-white relative overflow-hidden" id="result-panel-content">
{/* Top Header */} {/* Top Header */}
<div className="h-16 px-6 border-b border-gray-200 flex items-center justify-between bg-white shrink-0 z-10"> <div className="h-16 px-6 border-b border-gray-200 flex items-center justify-between bg-white shrink-0 z-10">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<h2 className="text-lg font-bold text-gray-900">Markdown</h2> <h2 className="text-lg font-bold text-gray-900">{t.resultPanel.markdown}</h2>
</div> </div>
<button <button
id="export-button"
onClick={() => setIsExportSidebarOpen(true)} onClick={() => setIsExportSidebarOpen(true)}
className={`px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors flex items-center gap-2 shadow-sm ${isExportSidebarOpen ? 'opacity-0 pointer-events-none' : ''}`} className={`px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors flex items-center gap-2 shadow-sm ${isExportSidebarOpen ? 'opacity-0 pointer-events-none' : ''}`}
> >
<Download size={16} /> <Download size={16} />
Export {t.common.export}
</button> </button>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useCallback, useState, useEffect, useRef } from 'react'; import { useCallback, useState, useEffect, useRef } from 'react';
import { Upload, X, MousePointerClick, FileUp, ClipboardPaste } from 'lucide-react'; import { Upload, X, MousePointerClick, FileUp, ClipboardPaste } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
interface UploadModalProps { interface UploadModalProps {
onClose: () => void; onClose: () => void;
@@ -7,6 +8,7 @@ interface UploadModalProps {
} }
export default function UploadModal({ onClose, onUpload }: UploadModalProps) { export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
const { t } = useLanguage();
const [dragActive, setDragActive] = useState(false); const [dragActive, setDragActive] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -75,7 +77,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6"> <div className="bg-white rounded-xl shadow-xl max-w-2xl w-full p-6">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-gray-900"></h2> <h2 className="text-2xl font-bold text-gray-900">{t.uploadModal.title}</h2>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors" className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
@@ -99,6 +101,7 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
<Upload size={32} /> <Upload size={32} />
</div> </div>
<p className="text-sm text-gray-600 mb-1">{t.sidebar.uploadInstruction}</p>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -108,22 +111,22 @@ export default function UploadModal({ onClose, onUpload }: UploadModalProps) {
className="hidden" className="hidden"
/> />
<p className="text-xs text-gray-500 mt-4"> <p className="text-xs text-gray-500 mb-4">
Support JPG, PNG format {t.uploadModal.supportFormats}
</p> </p>
<div className="flex items-center justify-center gap-4 mt-6 text-xs text-gray-400"> <div className="flex items-center justify-center gap-4 mt-6 text-xs text-gray-400">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<MousePointerClick className="w-3.5 h-3.5" /> <MousePointerClick className="w-3.5 h-3.5" />
<span>Click</span> <span>{t.common.click}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FileUp className="w-3.5 h-3.5" /> <FileUp className="w-3.5 h-3.5" />
<span>Drop</span> <span>{t.common.drop}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ClipboardPaste className="w-3.5 h-3.5" /> <ClipboardPaste className="w-3.5 h-3.5" />
<span>Paste</span> <span>{t.common.paste}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,181 @@
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);
}
};
const backdropClipPath =
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';
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: backdropClipPath }}
/>
{/* 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>
);
}

View File

@@ -0,0 +1,158 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from '../../App';
import { uploadService } from '../../lib/uploadService';
const { useAuthMock } = vi.hoisted(() => ({
useAuthMock: vi.fn(),
}));
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => useAuthMock(),
}));
vi.mock('../../contexts/LanguageContext', () => ({
useLanguage: () => ({
t: {
common: { loading: '加载中', processing: '处理中' },
alerts: {
taskTimeout: '超时',
networkError: '网络错误',
uploadFailed: '上传失败',
},
},
}),
}));
vi.mock('../../lib/uploadService', () => ({
uploadService: {
getTaskList: vi.fn().mockResolvedValue({ task_list: [], total: 0 }),
getTaskResult: vi.fn(),
calculateMD5: vi.fn(),
uploadFile: vi.fn(),
createRecognitionTask: vi.fn(),
},
}));
vi.mock('../../components/Navbar', () => ({
default: () => <div>navbar</div>,
}));
vi.mock('../../components/LeftSidebar', () => ({
default: ({
onUploadClick,
onRequireAuth,
canUploadAnonymously,
}: {
onUploadClick: () => void;
onRequireAuth: () => void;
canUploadAnonymously: boolean;
}) => (
<div>
<button onClick={onUploadClick}>open-upload</button>
<button onClick={onRequireAuth}>open-auth</button>
<span>{canUploadAnonymously ? 'guest-allowed' : 'guest-blocked'}</span>
</div>
),
}));
vi.mock('../../components/FilePreview', () => ({
default: ({ file }: { file: { id: string } | null }) => <div>{file ? `preview:${file.id}` : 'preview-empty'}</div>,
}));
vi.mock('../../components/ResultPanel', () => ({
default: () => <div>result</div>,
}));
vi.mock('../../components/UploadModal', () => ({
default: () => <div>upload-modal</div>,
}));
vi.mock('../../components/UserGuide', () => ({
default: () => null,
}));
vi.mock('../../components/AuthModal', () => ({
default: () => <div>auth-modal</div>,
}));
describe('App anonymous usage limit', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
localStorage.setItem('hasSeenGuide', 'true');
useAuthMock.mockReturnValue({
user: null,
initializing: false,
});
});
it('allows anonymous upload before the limit', () => {
localStorage.setItem('texpixel_guest_usage_count', '2');
render(<App />);
expect(screen.getByText('guest-allowed')).toBeInTheDocument();
fireEvent.click(screen.getByText('open-upload'));
expect(screen.getByText('upload-modal')).toBeInTheDocument();
});
it('forces login after three anonymous uses', () => {
localStorage.setItem('texpixel_guest_usage_count', '3');
render(<App />);
expect(screen.getByText('guest-blocked')).toBeInTheDocument();
fireEvent.click(screen.getByText('open-upload'));
expect(screen.getByText('auth-modal')).toBeInTheDocument();
expect(screen.queryByText('upload-modal')).not.toBeInTheDocument();
});
});
describe('App initial selection', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
localStorage.setItem('hasSeenGuide', 'true');
});
it('does not auto-select the first history record on initial load', async () => {
useAuthMock.mockReturnValue({
user: { id: 'u1' },
initializing: false,
});
vi.mocked(uploadService.getTaskList).mockResolvedValue({
total: 1,
task_list: [
{
task_id: 'task-1',
file_name: 'sample.png',
status: 2,
origin_url: 'https://example.com/sample.png',
task_type: 'FORMULA',
created_at: '2026-03-06T00:00:00Z',
latex: '',
markdown: 'content',
mathml: '',
mml: '',
image_blob: '',
docx_url: '',
pdf_url: '',
},
],
});
render(<App />);
await waitFor(() => {
expect(uploadService.getTaskList).toHaveBeenCalled();
});
expect(screen.getByText('preview-empty')).toBeInTheDocument();
expect(screen.queryByText('preview:task-1')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,104 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import AuthModal from '../AuthModal';
const useAuthMock = vi.fn();
const signInMock = vi.fn().mockResolvedValue({ error: null });
const signUpMock = vi.fn().mockResolvedValue({ error: null });
const beginGoogleOAuthMock = vi.fn().mockResolvedValue({ error: null });
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => useAuthMock(),
}));
vi.mock('../../contexts/LanguageContext', () => ({
useLanguage: () => ({
t: {
auth: {
signIn: '登录',
signUp: '注册',
signInTitle: '登录账号',
signUpTitle: '注册账号',
email: '邮箱',
password: '密码',
error: '错误',
genericError: '发生错误,请重试',
hasAccount: '已有账号?去登录',
noAccount: '没有账号?去注册',
continueWithGoogle: 'Google',
emailHint: '仅用于登录和同步记录。',
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致。',
emailRequired: '请输入邮箱地址。',
emailInvalid: '请输入有效的邮箱地址。',
passwordRequired: '请输入密码。',
oauthRedirecting: '正在跳转 Google...',
},
},
}),
}));
const createAuthState = (overrides?: Record<string, unknown>) => ({
signIn: signInMock,
signUp: signUpMock,
beginGoogleOAuth: beginGoogleOAuthMock,
authPhase: 'idle',
authError: null,
...overrides,
});
describe('AuthModal', () => {
it('shows email required message for empty signin submit', async () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
fireEvent.click(document.querySelector('button[type="submit"]') as HTMLButtonElement);
expect(await screen.findByText('请输入邮箱地址。')).toBeInTheDocument();
expect(signInMock).not.toHaveBeenCalled();
});
it('renders google oauth button', () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
expect(screen.getByRole('button', { name: 'Google' })).toBeInTheDocument();
});
it('disables inputs and submit while oauth redirecting', () => {
useAuthMock.mockReturnValue(createAuthState({ authPhase: 'oauth_redirecting' }));
render(<AuthModal onClose={vi.fn()} />);
const emailInput = screen.getByLabelText('邮箱');
const submitButton = document.querySelector('button[type="submit"]') as HTMLButtonElement;
expect(emailInput).toBeDisabled();
expect(submitButton).toBeDisabled();
});
it('switches between signin and signup with segmented tabs', () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
const signupTab = screen.getByRole('button', { name: '注册', pressed: false });
fireEvent.click(signupTab);
expect(screen.getByRole('button', { name: '注册', pressed: true })).toBeInTheDocument();
});
it('shows friendlier signup guidance', () => {
useAuthMock.mockReturnValue(createAuthState());
render(<AuthModal onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: '注册', pressed: false }));
expect(screen.getByText(/密码至少 6 位/i)).toBeInTheDocument();
expect(screen.getByText(/仅用于登录和同步记录/i)).toBeInTheDocument();
});
});

View File

@@ -1,122 +1,222 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react'; import { createContext, useContext, useMemo, ReactNode, useCallback, useEffect, useReducer, useRef } from 'react';
import { authService } from '../lib/authService'; import { authService } from '../lib/authService';
import { ApiErrorMessages } from '../types/api'; import { ApiErrorMessages } from '../types/api';
import type { UserInfo } from '../types/api'; import type { GoogleOAuthCallbackRequest, UserInfo } from '../types/api';
import { authReducer, createInitialAuthState, type AuthPhase } from './authMachine';
export const OAUTH_STATE_KEY = 'texpixel_oauth_state';
export const OAUTH_POST_LOGIN_REDIRECT_KEY = 'texpixel_post_login_redirect';
interface AuthContextType { interface AuthContextType {
user: UserInfo | null; user: UserInfo | null;
token: string | null; token: string | null;
loading: boolean; loading: boolean;
initializing: boolean; // 新增初始化状态 initializing: boolean;
authPhase: AuthPhase;
authError: string | null;
signIn: (email: string, password: string) => Promise<{ error: Error | null }>; signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
signUp: (email: string, password: string) => Promise<{ error: Error | null }>; signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
beginGoogleOAuth: () => Promise<{ error: Error | null }>;
completeGoogleOAuth: (params: GoogleOAuthCallbackRequest) => Promise<{ error: Error | null }>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
isAuthenticated: boolean; isAuthenticated: boolean;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const oauthExchangeInFlight = new Map<string, Promise<{ error: Error | null }>>();
export function AuthProvider({ children }: { children: ReactNode }) { function getErrorMessage(error: unknown, fallback: string): string {
// 直接在 useState 初始化函数中同步恢复会话
const [user, setUser] = useState<UserInfo | null>(() => {
try {
const session = authService.restoreSession();
return session ? session.user : null;
} catch {
return null;
}
});
const [token, setToken] = useState<string | null>(() => {
try {
const session = authService.restoreSession();
return session ? session.token : null;
} catch {
return null;
}
});
const [loading, setLoading] = useState(false);
const [initializing, setInitializing] = useState(false); // 不需要初始化过程了,因为是同步的
// 不再需要 useEffect 里的 restoreSession
/**
* 从错误对象中提取用户友好的错误消息
*/
const getErrorMessage = (error: unknown, fallback: string): string => {
// 检查是否是 ApiError通过 code 属性判断,避免 instanceof 在热更新时失效)
if (error && typeof error === 'object' && 'code' in error) { if (error && typeof error === 'object' && 'code' in error) {
const apiError = error as { code: number; message: string }; const apiError = error as { code: number; message?: string };
return ApiErrorMessages[apiError.code] || apiError.message || fallback; return ApiErrorMessages[apiError.code] || apiError.message || fallback;
} }
if (error instanceof Error) { if (error instanceof Error) {
return error.message; return error.message;
} }
return fallback; return fallback;
}; }
function createOAuthState(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function mergeUserProfile(user: UserInfo, profile: { username: string; email: string }): UserInfo {
return {
...user,
username: profile.username || user.username || '',
email: profile.email || user.email,
};
}
export function AuthProvider({ children }: { children: ReactNode }) {
const restoredSession = authService.restoreSession();
const [state, dispatch] = useReducer(
authReducer,
createInitialAuthState(restoredSession ? { user: restoredSession.user, token: restoredSession.token } : null)
);
/**
* 登录
*/
const signIn = useCallback(async (email: string, password: string) => { const signIn = useCallback(async (email: string, password: string) => {
setLoading(true); dispatch({ type: 'EMAIL_SIGNIN_START' });
try { try {
const result = await authService.login({ email, password }); const result = await authService.login({ email, password });
setUser(result.user); dispatch({ type: 'EMAIL_SIGNIN_SUCCESS', payload: { user: result.user, token: result.token } });
setToken(result.token);
return { error: null }; return { error: null };
} catch (error) { } catch (error) {
const message = getErrorMessage(error, '登录失败'); const message = getErrorMessage(error, '登录失败');
dispatch({ type: 'EMAIL_SIGNIN_FAIL', payload: { error: message } });
return { error: new Error(message) }; return { error: new Error(message) };
} finally {
setLoading(false);
} }
}, []); }, []);
/**
* 注册
*/
const signUp = useCallback(async (email: string, password: string) => { const signUp = useCallback(async (email: string, password: string) => {
setLoading(true); dispatch({ type: 'EMAIL_SIGNUP_START' });
try { try {
const result = await authService.register({ email, password }); const result = await authService.register({ email, password });
setUser(result.user); dispatch({ type: 'EMAIL_SIGNUP_SUCCESS', payload: { user: result.user, token: result.token } });
setToken(result.token);
return { error: null }; return { error: null };
} catch (error) { } catch (error) {
const message = getErrorMessage(error, '注册失败'); const message = getErrorMessage(error, '注册失败');
dispatch({ type: 'EMAIL_SIGNUP_FAIL', payload: { error: message } });
return { error: new Error(message) };
}
}, []);
const beginGoogleOAuth = useCallback(async () => {
dispatch({ type: 'OAUTH_REDIRECT_START' });
try {
const stateToken = createOAuthState();
const redirectUri = `${window.location.origin}/auth/google/callback`;
sessionStorage.setItem(OAUTH_STATE_KEY, stateToken);
sessionStorage.setItem(OAUTH_POST_LOGIN_REDIRECT_KEY, window.location.href);
const { authUrl } = await authService.getGoogleOAuthUrl(redirectUri, stateToken);
window.location.assign(authUrl);
return { error: null };
} catch (error) {
const message = getErrorMessage(error, 'Google 登录失败');
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: message } });
return { error: new Error(message) };
}
}, []);
const completeGoogleOAuth = useCallback(async (params: GoogleOAuthCallbackRequest) => {
const requestKey = params.code;
const existing = oauthExchangeInFlight.get(requestKey);
if (existing) {
return existing;
}
const promise = (async () => {
dispatch({ type: 'OAUTH_EXCHANGE_START' });
try {
const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY);
if (!expectedState || expectedState !== params.state) {
const invalidStateMessage = 'OAuth state 校验失败';
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: invalidStateMessage } });
return { error: new Error(invalidStateMessage) };
}
const result = await authService.exchangeGoogleCode(params);
sessionStorage.removeItem(OAUTH_STATE_KEY);
dispatch({ type: 'OAUTH_EXCHANGE_SUCCESS', payload: { user: result.user, token: result.token } });
return { error: null };
} catch (error) {
const message = getErrorMessage(error, 'Google 登录失败');
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: message } });
return { error: new Error(message) }; return { error: new Error(message) };
} finally { } finally {
setLoading(false); oauthExchangeInFlight.delete(requestKey);
} }
})();
oauthExchangeInFlight.set(requestKey, promise);
return promise;
}, []); }, []);
/**
* 登出
*/
const signOut = useCallback(async () => { const signOut = useCallback(async () => {
setLoading(true);
try {
authService.logout(); authService.logout();
setUser(null); dispatch({ type: 'SIGN_OUT' });
setToken(null);
} finally {
setLoading(false);
}
}, []); }, []);
const value: AuthContextType = { const syncedTokenRef = useRef<string | null>(null);
user,
token, useEffect(() => {
loading, const currentUser = state.user;
initializing, const currentToken = state.token;
if (!currentUser || !currentToken) {
return;
}
// 已经为当前 token 同步过,跳过(防止 StrictMode 双调用或 effect 重复执行)
if (syncedTokenRef.current === currentToken) {
return;
}
syncedTokenRef.current = currentToken;
let cancelled = false;
const syncUserProfile = async () => {
try {
const profile = await authService.getUserInfo();
if (cancelled) {
return;
}
dispatch({
type: 'UPDATE_USER',
payload: {
user: mergeUserProfile(currentUser, profile),
},
});
} catch {
// Keep token-derived identity if profile sync fails.
if (!cancelled) {
// 请求失败时重置,允许下次挂载时重试
syncedTokenRef.current = null;
}
}
};
void syncUserProfile();
return () => {
cancelled = true;
};
}, [state.token]);
const value = useMemo<AuthContextType>(() => {
const loadingPhases: AuthPhase[] = [
'email_signing_in',
'email_signing_up',
'oauth_redirecting',
'oauth_exchanging',
];
return {
user: state.user,
token: state.token,
loading: loadingPhases.includes(state.authPhase),
initializing: state.initializing,
authPhase: state.authPhase,
authError: state.authError,
signIn, signIn,
signUp, signUp,
beginGoogleOAuth,
completeGoogleOAuth,
signOut, signOut,
isAuthenticated: !!user && !!token, isAuthenticated: !!state.user && !!state.token,
}; };
}, [beginGoogleOAuth, completeGoogleOAuth, signIn, signOut, signUp, state]);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
} }

View 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;
};

View File

@@ -0,0 +1,207 @@
import { useEffect } from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { AuthProvider, useAuth } from '../AuthContext';
const {
loginMock,
registerMock,
logoutMock,
restoreSessionMock,
getGoogleOAuthUrlMock,
exchangeGoogleCodeMock,
} = vi.hoisted(() => ({
loginMock: vi.fn(),
registerMock: vi.fn(),
logoutMock: vi.fn(),
restoreSessionMock: vi.fn(() => null),
getGoogleOAuthUrlMock: vi.fn(),
exchangeGoogleCodeMock: vi.fn(),
}));
vi.mock('../../lib/authService', () => ({
authService: {
login: loginMock,
register: registerMock,
logout: logoutMock,
restoreSession: restoreSessionMock,
getGoogleOAuthUrl: getGoogleOAuthUrlMock,
exchangeGoogleCode: exchangeGoogleCodeMock,
},
}));
function Harness({ onReady }: { onReady: (ctx: ReturnType<typeof useAuth>) => void }) {
const auth = useAuth();
useEffect(() => {
onReady(auth);
}, [auth, onReady]);
return null;
}
function renderWithProvider(onReady: (ctx: ReturnType<typeof useAuth>) => void) {
return render(
<AuthProvider>
<Harness onReady={onReady} />
</AuthProvider>
);
}
describe('AuthContext OAuth flow', () => {
beforeEach(() => {
vi.clearAllMocks();
sessionStorage.clear();
localStorage.clear();
restoreSessionMock.mockReturnValue(null);
});
it('beginGoogleOAuth writes state and redirect then redirects browser', async () => {
getGoogleOAuthUrlMock.mockResolvedValue({ authUrl: 'https://accounts.google.com/o/oauth2/v2/auth' });
let ctxRef: ReturnType<typeof useAuth> | null = null;
renderWithProvider((ctx) => {
ctxRef = ctx;
});
await waitFor(() => {
expect(ctxRef).toBeTruthy();
});
await act(async () => {
await (ctxRef as ReturnType<typeof useAuth>).beginGoogleOAuth();
});
expect(sessionStorage.getItem('texpixel_oauth_state')).toBeTruthy();
expect(sessionStorage.getItem('texpixel_post_login_redirect')).toBe(window.location.href);
expect(getGoogleOAuthUrlMock).toHaveBeenCalledTimes(1);
});
it('completeGoogleOAuth rejects when state mismatches', async () => {
sessionStorage.setItem('texpixel_oauth_state', 'expected_state');
let ctxRef: ReturnType<typeof useAuth> | null = null;
renderWithProvider((ctx) => {
ctxRef = ctx;
});
await waitFor(() => {
expect(ctxRef).toBeTruthy();
});
let result: { error: Error | null } = { error: null };
await act(async () => {
result = await (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
code: 'abc',
state: 'wrong_state',
redirect_uri: 'http://localhost:5173/auth/google/callback',
});
});
expect(result.error).toBeTruthy();
expect(exchangeGoogleCodeMock).not.toHaveBeenCalled();
expect(localStorage.getItem('texpixel_token')).toBeNull();
});
it('completeGoogleOAuth stores session on success', async () => {
sessionStorage.setItem('texpixel_oauth_state', 'state_ok');
exchangeGoogleCodeMock.mockImplementation(async () => {
localStorage.setItem('texpixel_token', 'Bearer header.payload.sig');
return {
token: 'Bearer header.payload.sig',
expiresAt: 1999999999,
user: {
user_id: 7,
id: '7',
email: 'oauth@example.com',
exp: 1999999999,
iat: 1111111,
},
};
});
let ctxRef: ReturnType<typeof useAuth> | null = null;
renderWithProvider((ctx) => {
ctxRef = ctx;
});
await waitFor(() => {
expect(ctxRef).toBeTruthy();
});
let result: { error: Error | null } = { error: null };
await act(async () => {
result = await (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
code: 'code_ok',
state: 'state_ok',
redirect_uri: 'http://localhost:5173/auth/google/callback',
});
});
expect(result.error).toBeNull();
await waitFor(() => {
expect((ctxRef as ReturnType<typeof useAuth>).isAuthenticated).toBe(true);
});
expect(localStorage.getItem('texpixel_token')).toBe('Bearer header.payload.sig');
});
it('completeGoogleOAuth deduplicates same code requests', async () => {
sessionStorage.setItem('texpixel_oauth_state', 'state_ok');
exchangeGoogleCodeMock.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => {
localStorage.setItem('texpixel_token', 'Bearer header.payload.sig');
resolve({
token: 'Bearer header.payload.sig',
expiresAt: 1999999999,
user: {
user_id: 7,
id: '7',
email: 'oauth@example.com',
exp: 1999999999,
iat: 1111111,
},
});
}, 20);
})
);
let ctxRef: ReturnType<typeof useAuth> | null = null;
renderWithProvider((ctx) => {
ctxRef = ctx;
});
await waitFor(() => {
expect(ctxRef).toBeTruthy();
});
let result1: { error: Error | null } = { error: null };
let result2: { error: Error | null } = { error: null };
await act(async () => {
const p1 = (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
code: 'same_code',
state: 'state_ok',
redirect_uri: 'http://localhost:5173/auth/google/callback',
});
const p2 = (ctxRef as ReturnType<typeof useAuth>).completeGoogleOAuth({
code: 'same_code',
state: 'state_ok',
redirect_uri: 'http://localhost:5173/auth/google/callback',
});
[result1, result2] = await Promise.all([p1, p2]);
});
expect(result1.error).toBeNull();
expect(result2.error).toBeNull();
expect(exchangeGoogleCodeMock).toHaveBeenCalledTimes(1);
});
});

135
src/contexts/authMachine.ts Normal file
View File

@@ -0,0 +1,135 @@
import type { UserInfo } from '../types/api';
export type AuthPhase =
| 'idle'
| 'email_signing_in'
| 'email_signing_up'
| 'oauth_redirecting'
| 'oauth_exchanging'
| 'authenticated'
| 'error';
export interface AuthState {
user: UserInfo | null;
token: string | null;
authPhase: AuthPhase;
authError: string | null;
initializing: boolean;
}
export type AuthAction =
| { type: 'RESTORE_SESSION'; payload: { user: UserInfo | null; token: string | null } }
| { type: 'EMAIL_SIGNIN_START' }
| { type: 'EMAIL_SIGNIN_SUCCESS'; payload: { user: UserInfo; token: string } }
| { type: 'EMAIL_SIGNIN_FAIL'; payload: { error: string } }
| { type: 'EMAIL_SIGNUP_START' }
| { type: 'EMAIL_SIGNUP_SUCCESS'; payload: { user: UserInfo; token: string } }
| { type: 'EMAIL_SIGNUP_FAIL'; payload: { error: string } }
| { type: 'OAUTH_REDIRECT_START' }
| { type: 'OAUTH_EXCHANGE_START' }
| { type: 'OAUTH_EXCHANGE_SUCCESS'; payload: { user: UserInfo; token: string } }
| { type: 'OAUTH_EXCHANGE_FAIL'; payload: { error: string } }
| { type: 'UPDATE_USER'; payload: { user: UserInfo } }
| { type: 'SIGN_OUT' };
export function createInitialAuthState(session: { user: UserInfo; token: string } | null): AuthState {
if (session) {
return {
user: session.user,
token: session.token,
authPhase: 'authenticated',
authError: null,
initializing: false,
};
}
return {
user: null,
token: null,
authPhase: 'idle',
authError: null,
initializing: false,
};
}
export function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
case 'RESTORE_SESSION': {
if (action.payload.user && action.payload.token) {
return {
...state,
user: action.payload.user,
token: action.payload.token,
authPhase: 'authenticated',
authError: null,
initializing: false,
};
}
return {
...state,
user: null,
token: null,
authPhase: 'idle',
authError: null,
initializing: false,
};
}
case 'EMAIL_SIGNIN_START':
return { ...state, authPhase: 'email_signing_in', authError: null };
case 'EMAIL_SIGNIN_SUCCESS':
return {
...state,
user: action.payload.user,
token: action.payload.token,
authPhase: 'authenticated',
authError: null,
};
case 'EMAIL_SIGNIN_FAIL':
return { ...state, authPhase: 'error', authError: action.payload.error };
case 'EMAIL_SIGNUP_START':
return { ...state, authPhase: 'email_signing_up', authError: null };
case 'EMAIL_SIGNUP_SUCCESS':
return {
...state,
user: action.payload.user,
token: action.payload.token,
authPhase: 'authenticated',
authError: null,
};
case 'EMAIL_SIGNUP_FAIL':
return { ...state, authPhase: 'error', authError: action.payload.error };
case 'OAUTH_REDIRECT_START':
return { ...state, authPhase: 'oauth_redirecting', authError: null };
case 'OAUTH_EXCHANGE_START':
return { ...state, authPhase: 'oauth_exchanging', authError: null };
case 'OAUTH_EXCHANGE_SUCCESS':
return {
...state,
user: action.payload.user,
token: action.payload.token,
authPhase: 'authenticated',
authError: null,
};
case 'OAUTH_EXCHANGE_FAIL':
return { ...state, authPhase: 'error', authError: action.payload.error };
case 'UPDATE_USER':
return { ...state, user: action.payload.user };
case 'SIGN_OUT':
return {
...state,
user: null,
token: null,
authPhase: 'idle',
authError: null,
};
default:
return state;
}
}

63
src/lib/analytics.ts Normal file
View 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";
}
}

View File

@@ -73,6 +73,7 @@ export class ApiError extends Error {
*/ */
interface RequestConfig extends RequestInit { interface RequestConfig extends RequestInit {
skipAuth?: boolean; skipAuth?: boolean;
successCodes?: number[];
} }
/** /**
@@ -82,7 +83,7 @@ async function request<T>(
endpoint: string, endpoint: string,
config: RequestConfig = {} config: RequestConfig = {}
): Promise<ApiResponse<T>> { ): Promise<ApiResponse<T>> {
const { skipAuth = false, headers: customHeaders, ...restConfig } = config; const { skipAuth = false, successCodes = [200], headers: customHeaders, ...restConfig } = config;
const headers: HeadersInit = { const headers: HeadersInit = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -108,7 +109,7 @@ async function request<T>(
const data: ApiResponse<T> = await response.json(); const data: ApiResponse<T> = await response.json();
// 统一处理业务错误 // 统一处理业务错误
if (data.code !== 200) { if (!successCodes.includes(data.code)) {
throw new ApiError(data.code, data.message, data.request_id); throw new ApiError(data.code, data.message, data.request_id);
} }
@@ -153,4 +154,3 @@ export const http = {
}; };
export default http; export default http;

View File

@@ -1,36 +1,61 @@
/** /**
* 认证服务 * 认证服务
* 处理用户登录、注册、登出等认证相关操作 * 处理用户登录、注册、OAuth、登出等认证相关操作
*/ */
import { http, tokenManager, ApiError } from './api'; import { http, tokenManager, ApiError } from './api';
import type { AuthData, LoginRequest, RegisterRequest, UserInfo } from '../types/api'; import type {
AuthData,
GoogleAuthUrlData,
GoogleOAuthCallbackRequest,
LoginRequest,
RegisterRequest,
UserInfoData,
UserInfo,
} from '../types/api';
// 重新导出 ApiErrorMessages 以便使用
export { ApiErrorMessages } from '../types/api'; export { ApiErrorMessages } from '../types/api';
/** function decodeJwtPayload(token: string): UserInfo | null {
* 从 JWT Token 解析用户信息
*/
function parseJwtPayload(token: string): UserInfo | null {
try { try {
// 移除 Bearer 前缀
const actualToken = token.replace('Bearer ', ''); const actualToken = token.replace('Bearer ', '');
const base64Payload = actualToken.split('.')[1]; const base64Payload = actualToken.split('.')[1];
const payload = JSON.parse(atob(base64Payload)); const normalized = base64Payload.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
const payload = JSON.parse(atob(padded));
return payload as UserInfo; return payload as UserInfo;
} catch { } catch {
return null; return null;
} }
} }
/** function normalizeUser(payload: UserInfo, emailHint?: string): UserInfo {
* 认证服务 return {
*/ ...payload,
email: payload.email || emailHint || '',
id: String(payload.user_id),
};
}
function buildSession(authData: AuthData, emailHint?: string): { user: UserInfo; token: string; expiresAt: number } {
const { token, expires_at } = authData;
const parsedUser = decodeJwtPayload(token);
if (!parsedUser) {
throw new ApiError(-1, 'Token 解析失败');
}
const user = normalizeUser(parsedUser, emailHint);
tokenManager.setToken(token, expires_at, user.email || undefined);
return {
user,
token,
expiresAt: expires_at,
};
}
export const authService = { export const authService = {
/**
* 用户登录
*/
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> { async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true }); const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
@@ -38,35 +63,9 @@ export const authService = {
throw new ApiError(-1, '登录失败,请重试'); throw new ApiError(-1, '登录失败,请重试');
} }
const { token, expires_at } = response.data; return buildSession(response.data, credentials.email);
// 存储 Token 和 email
tokenManager.setToken(token, expires_at, credentials.email);
// 解析用户信息
const user = parseJwtPayload(token);
if (!user) {
throw new ApiError(-1, 'Token 解析失败');
}
// 补充 email 和 id 兼容字段
const userWithEmail: UserInfo = {
...user,
email: credentials.email,
id: String(user.user_id),
};
return {
user: userWithEmail,
token,
expiresAt: expires_at,
};
}, },
/**
* 用户注册
* 注意:业务错误码 (code !== 200) 已在 api.ts 中统一处理,会抛出 ApiError
*/
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> { async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true }); const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
@@ -74,55 +73,58 @@ export const authService = {
throw new ApiError(-1, '注册失败,请重试'); throw new ApiError(-1, '注册失败,请重试');
} }
const { token, expires_at } = response.data; return buildSession(response.data, credentials.email);
},
// 存储 Token 和 email
tokenManager.setToken(token, expires_at, credentials.email); async getGoogleOAuthUrl(redirectUri: string, state: string): Promise<{ authUrl: string }> {
const query = new URLSearchParams({ redirect_uri: redirectUri, state });
// 解析用户信息 const response = await http.get<GoogleAuthUrlData>(`/user/oauth/google/url?${query.toString()}`, {
const user = parseJwtPayload(token); skipAuth: true,
if (!user) { });
throw new ApiError(-1, 'Token 解析失败');
} if (!response.data?.auth_url) {
throw new ApiError(-1, '获取 Google 授权地址失败');
// 补充 email 和 id 兼容字段 }
const userWithEmail: UserInfo = {
...user, return { authUrl: response.data.auth_url };
email: credentials.email, },
id: String(user.user_id),
}; async exchangeGoogleCode(
payload: GoogleOAuthCallbackRequest
return { ): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
user: userWithEmail, const response = await http.post<AuthData>('/user/oauth/google/callback', payload, { skipAuth: true });
token,
expiresAt: expires_at, if (!response.data) {
}; throw new ApiError(-1, 'Google 登录失败,请重试');
}
return buildSession(response.data);
},
async getUserInfo(): Promise<UserInfoData> {
const response = await http.get<UserInfoData>('/user/info', {
successCodes: [0, 200],
});
if (!response.data) {
throw new ApiError(-1, '获取用户信息失败');
}
return response.data;
}, },
/**
* 用户登出
*/
logout(): void { logout(): void {
tokenManager.removeToken(); tokenManager.removeToken();
}, },
/**
* 检查是否已登录
*/
isAuthenticated(): boolean { isAuthenticated(): boolean {
return tokenManager.isTokenValid(); return tokenManager.isTokenValid();
}, },
/**
* 获取当前存储的 Token
*/
getToken(): string | null { getToken(): string | null {
return tokenManager.getToken(); return tokenManager.getToken();
}, },
/**
* 从存储的 Token 恢复用户会话
*/
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null { restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
const token = tokenManager.getToken(); const token = tokenManager.getToken();
const expiresAt = tokenManager.getExpiresAt(); const expiresAt = tokenManager.getExpiresAt();
@@ -133,21 +135,14 @@ export const authService = {
return null; return null;
} }
const parsedUser = parseJwtPayload(token); const parsedUser = decodeJwtPayload(token);
if (!parsedUser) { if (!parsedUser) {
tokenManager.removeToken(); tokenManager.removeToken();
return null; return null;
} }
// 补充 email 和 id 兼容字段
const user: UserInfo = {
...parsedUser,
email: email || '',
id: String(parsedUser.user_id),
};
return { return {
user, user: normalizeUser(parsedUser, email || ''),
token, token,
expiresAt, expiresAt,
}; };
@@ -156,4 +151,3 @@ export const authService = {
export { ApiError }; export { ApiError };
export default authService; export default authService;

View File

@@ -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 * Waits for all KaTeX fonts to be loaded
*/ */

78
src/lib/ipLocation.ts Normal file
View 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);
}

View File

@@ -93,16 +93,35 @@ Where:
</mfrac> </mfrac>
</mrow> </mrow>
</math>`, </math>`,
mathml_word_content: `<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"> mml: `<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block">
<m:f> <mml:mrow>
<m:num> <mml:mi>x</mml:mi>
<m:r><m:t>-b ± √(b²-4ac)</m:t></m:r> <mml:mo>=</mml:mo>
</m:num> <mml:mfrac>
<m:den> <mml:mrow>
<m:r><m:t>2a</m:t></m:r> <mml:mo>-</mml:mo>
</m:den> <mml:mi>b</mml:mi>
</m:f> <mml:mo>±</mml:mo>
</m:oMath>`, <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', rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800',
created_at: new Date().toISOString(), 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.`, 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}`, 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_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 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(), created_at: new Date().toISOString(),
}; };

80
src/lib/seoHelper.ts Normal file
View 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 - AI 数学公式识别工具 | LaTeX、MathML OCR',
description: '免费 AI 数学公式识别工具,支持手写和印刷体公式识别,一键将图片或 PDF 中的数学公式转换为 LaTeX、MathML 和 Markdown 格式。',
keywords: '数学公式识别,LaTeX OCR,手写公式识别,公式转LaTeX,数学OCR,MathML转换,手写方程识别,公式识别,数学公式OCR,texpixel',
},
en: {
title: 'TexPixel - AI Math Formula Recognition | LaTeX, MathML OCR Tool',
description: 'Free AI-powered math formula recognition tool. Convert handwritten or printed math formulas in images to LaTeX, MathML, and Markdown instantly. Supports PDF and image files.',
keywords: 'math formula recognition,LaTeX OCR,handwriting math recognition,formula to latex,math OCR,MathML converter,handwritten equation recognition,texpixel',
},
};
/**
* 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];
}

View File

@@ -61,7 +61,7 @@ export type Database = {
markdown_content: string | null; markdown_content: string | null;
latex_content: string | null; latex_content: string | null;
mathml_content: string | null; mathml_content: string | null;
mathml_word_content: string | null; mml: string | null;
rendered_image_path: string | null; rendered_image_path: string | null;
created_at: string; created_at: string;
}; };
@@ -71,7 +71,7 @@ export type Database = {
markdown_content?: string | null; markdown_content?: string | null;
latex_content?: string | null; latex_content?: string | null;
mathml_content?: string | null; mathml_content?: string | null;
mathml_word_content?: string | null; mml?: string | null;
rendered_image_path?: string | null; rendered_image_path?: string | null;
created_at?: string; created_at?: string;
}; };
@@ -81,7 +81,7 @@ export type Database = {
markdown_content?: string | null; markdown_content?: string | null;
latex_content?: string | null; latex_content?: string | null;
mathml_content?: string | null; mathml_content?: string | null;
mathml_word_content?: string | null; mml?: string | null;
rendered_image_path?: string | null; rendered_image_path?: string | null;
created_at?: string; created_at?: string;
}; };

221
src/lib/translations.ts Normal file
View File

@@ -0,0 +1,221 @@
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',
continueWithGoogle: 'Google',
emailHint: 'Used only for sign-in and history sync.',
emailRequired: 'Please enter your email address.',
emailInvalid: 'Please enter a valid email address.',
passwordRequired: 'Please enter your password.',
passwordHint: 'Use at least 6 characters. Letters and numbers are recommended.',
confirmPassword: 'Confirm Password',
passwordMismatch: 'The two passwords do not match.',
oauthRedirecting: 'Redirecting to Google...',
oauthExchanging: 'Completing Google sign-in...',
invalidOAuthState: 'Invalid OAuth state, please retry.',
oauthFailed: 'Google sign-in failed, please retry.',
},
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: '没有账号?去注册',
continueWithGoogle: 'Google',
emailHint: '仅用于登录和同步记录。',
emailRequired: '请输入邮箱地址。',
emailInvalid: '请输入有效的邮箱地址。',
passwordRequired: '请输入密码。',
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
confirmPassword: '确认密码',
passwordMismatch: '两次输入的密码不一致。',
oauthRedirecting: '正在跳转 Google...',
oauthExchanging: '正在完成 Google 登录...',
invalidOAuthState: 'OAuth 状态校验失败,请重试',
oauthFailed: 'Google 登录失败,请重试',
},
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;

View File

@@ -1,13 +1,44 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css'; import './index.css';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { LanguageProvider } from './contexts/LanguageContext';
import { BrowserRouter } from 'react-router-dom';
import AppRouter from './routes/AppRouter';
createRoot(document.getElementById('root')!).render( // 错误处理:捕获未处理的错误
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> <StrictMode>
<BrowserRouter>
<AuthProvider> <AuthProvider>
<App /> <LanguageProvider>
<AppRouter />
</LanguageProvider>
</AuthProvider> </AuthProvider>
</BrowserRouter>
</StrictMode> </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>
`;
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth, OAUTH_POST_LOGIN_REDIRECT_KEY } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
function toInternalPath(urlOrPath: string): string {
try {
const parsed = new URL(urlOrPath, window.location.origin);
if (parsed.origin !== window.location.origin) {
return '/';
}
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
} catch {
return '/';
}
}
export default function AuthCallbackPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { completeGoogleOAuth } = useAuth();
const { t } = useLanguage();
const [error, setError] = useState<string | null>(null);
const code = useMemo(() => searchParams.get('code') ?? '', [searchParams]);
const state = useMemo(() => searchParams.get('state') ?? '', [searchParams]);
useEffect(() => {
let mounted = true;
const run = async () => {
if (!code || !state) {
if (mounted) {
setError(t.auth.oauthFailed);
}
return;
}
const redirectUri = `${window.location.origin}/auth/google/callback`;
const result = await completeGoogleOAuth({ code, state, redirect_uri: redirectUri });
if (result.error) {
if (mounted) {
setError(result.error.message || t.auth.oauthFailed);
}
return;
}
const redirectTarget = sessionStorage.getItem(OAUTH_POST_LOGIN_REDIRECT_KEY) || '/';
navigate(toInternalPath(redirectTarget), { replace: true });
};
run();
return () => {
mounted = false;
};
}, [code, completeGoogleOAuth, navigate, state, t.auth.oauthFailed]);
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 text-center">
<h1 className="text-xl font-bold text-gray-900 mb-3">Google OAuth</h1>
{!error && <p className="text-gray-600">{t.auth.oauthExchanging}</p>}
{error && (
<>
<p className="text-red-600 text-sm mb-4">{error}</p>
<button
type="button"
onClick={() => navigate('/', { replace: true })}
className="px-4 py-2 bg-gray-900 text-white rounded-lg hover:bg-gray-800 transition-colors"
>
Back Home
</button>
</>
)}
</div>
</div>
);
}

12
src/routes/AppRouter.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Routes, Route } from 'react-router-dom';
import App from '../App';
import AuthCallbackPage from '../pages/AuthCallbackPage';
export default function AppRouter() {
return (
<Routes>
<Route path="/" element={<App />} />
<Route path="/auth/google/callback" element={<AuthCallbackPage />} />
</Routes>
);
}

1
src/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest';

View File

@@ -14,10 +14,26 @@ export interface AuthData {
expires_at: number; expires_at: number;
} }
export interface UserInfoData {
username: string;
email: string;
}
export interface GoogleAuthUrlData {
auth_url: string;
}
export interface GoogleOAuthCallbackRequest {
code: string;
state: string;
redirect_uri: string;
}
// 用户信息(从 token 解析或 API 返回) // 用户信息(从 token 解析或 API 返回)
export interface UserInfo { export interface UserInfo {
user_id: number; user_id: number;
email: string; email: string;
username?: string;
exp: number; exp: number;
iat: number; iat: number;
// 兼容字段,方便代码使用 // 兼容字段,方便代码使用
@@ -79,7 +95,7 @@ export interface RecognitionResultData {
latex: string; latex: string;
markdown: string; markdown: string;
mathml: string; mathml: string;
mathml_mw: string; // MathML for Word mml: string; // MathML with mml: prefix
image_blob: string; // Base64 or URL? assuming string content image_blob: string; // Base64 or URL? assuming string content
docx_url: string; docx_url: string;
pdf_url: string; pdf_url: string;
@@ -96,7 +112,7 @@ export interface TaskHistoryItem {
latex: string; latex: string;
markdown: string; markdown: string;
mathml: string; mathml: string;
mathml_mw: string; mml: string;
image_blob: string; image_blob: string;
docx_url: string; docx_url: string;
pdf_url: string; pdf_url: string;

View File

@@ -17,7 +17,7 @@ export interface RecognitionResult {
markdown_content: string | null; markdown_content: string | null;
latex_content: string | null; latex_content: string | null;
mathml_content: string | null; mathml_content: string | null;
mathml_word_content: string | null; mml: string | null;
rendered_image_path: string | null; rendered_image_path: string | null;
created_at: string; created_at: string;
} }
@@ -26,7 +26,6 @@ export type ExportFormat =
| 'markdown' | 'markdown'
| 'latex' | 'latex'
| 'mathml' | 'mathml'
| 'mathml-word'
| 'image' | 'image'
| 'docx' | 'docx'
| 'pdf' | 'pdf'

View File

@@ -0,0 +1,29 @@
<svg width="1024" height="1024" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="line1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="25%" stop-color="#ffffff" stop-opacity="0.92"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="line2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="30%" stop-color="#ffffff" stop-opacity="0.62"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.62"/>
</linearGradient>
<linearGradient id="line3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.38"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.38"/>
</linearGradient>
<linearGradient id="line4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="35%" stop-color="#ffffff" stop-opacity="0.2"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.2"/>
</linearGradient>
</defs>
<rect x="4" y="4" width="120" height="120" rx="28" fill="#000000"/>
<rect x="24" y="42" width="74" height="3" rx="1.5" fill="url(#line1)"/>
<rect x="26" y="56" width="56" height="3" rx="1.5" fill="url(#line2)"/>
<rect x="24" y="70" width="64" height="3" rx="1.5" fill="url(#line3)"/>
<rect x="28" y="84" width="44" height="3" rx="1.5" fill="url(#line4)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,28 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="line1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="20%" stop-color="#ffffff" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.95"/>
</linearGradient>
<linearGradient id="line2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="24%" stop-color="#ffffff" stop-opacity="0.65"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.65"/>
</linearGradient>
<linearGradient id="line3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="22%" stop-color="#ffffff" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.4"/>
</linearGradient>
<linearGradient id="line4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0"/>
<stop offset="28%" stop-color="#ffffff" stop-opacity="0.22"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0.22"/>
</linearGradient>
</defs>
<rect x="3" y="7" width="24" height="2.2" rx="1.1" fill="url(#line1)"/>
<rect x="4" y="12.5" width="18" height="2.2" rx="1.1" fill="url(#line2)"/>
<rect x="3" y="18" width="20" height="2.2" rx="1.1" fill="url(#line3)"/>
<rect x="5" y="23.5" width="14" height="2.2" rx="1.1" fill="url(#line4)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,28 @@
<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="line1" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="20%" stop-color="#111111" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.9"/>
</linearGradient>
<linearGradient id="line2" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="24%" stop-color="#111111" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.6"/>
</linearGradient>
<linearGradient id="line3" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="22%" stop-color="#111111" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.35"/>
</linearGradient>
<linearGradient id="line4" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#111111" stop-opacity="0"/>
<stop offset="28%" stop-color="#111111" stop-opacity="0.18"/>
<stop offset="100%" stop-color="#111111" stop-opacity="0.18"/>
</linearGradient>
</defs>
<rect x="3" y="7" width="24" height="2.2" rx="1.1" fill="url(#line1)"/>
<rect x="4" y="12.5" width="18" height="2.2" rx="1.1" fill="url(#line2)"/>
<rect x="3" y="18" width="20" height="2.2" rx="1.1" fill="url(#line3)"/>
<rect x="5" y="23.5" width="14" height="2.2" rx="1.1" fill="url(#line4)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -8,8 +8,13 @@ export default defineConfig({
exclude: ['lucide-react'], exclude: ['lucide-react'],
}, },
build: { build: {
// 确保生成带哈希的文件名(默认已启用)
rollupOptions: { rollupOptions: {
output: { output: {
// 确保文件名包含哈希,便于缓存管理
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
manualChunks: { manualChunks: {
// React 核心 // React 核心
'vendor-react': ['react', 'react-dom'], 'vendor-react': ['react', 'react-dom'],

12
vitest.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
exclude: ['e2e/**', 'node_modules/**', 'dist/**'],
},
});