Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a797b2b0d7 | |||
|
|
cd479da0eb | ||
|
|
f70a9a85c8 | ||
|
|
bc4b547e03 | ||
|
|
e4c6a09cf8 | ||
|
|
2b1da79bbc | ||
| 564aaec581 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
||||
*.sw?
|
||||
.env
|
||||
/dist
|
||||
app.cloud/
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>⚡️ TexPixel - 公式识别工具</title>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
|
||||
<meta name="keywords"
|
||||
content="公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,handwriting,formula,recognition,ocr,handwriting,recognition,math,handwriting,free" />
|
||||
<meta name="author" content="TexPixel Team" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="TexPixel - 公式识别工具" />
|
||||
<meta property="og:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://texpixel.com/" />
|
||||
<meta property="og:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="TexPixel - 公式识别工具" />
|
||||
<meta name="twitter:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别。" />
|
||||
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
|
||||
<!-- baidu -->
|
||||
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-NjWMZQkP.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-react-C6WG4Va-.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-katex-p018AHG0.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-markdown-C0b4qDwm.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DKZ56yfB.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -66,57 +66,57 @@ deploy_to_server() {
|
||||
print_info "在 ${server} 上执行部署操作..."
|
||||
print_info "部署路径: ${DEPLOY_PATH}/${DEPLOY_NAME}"
|
||||
# 注意:密码通过环境变量传递,避免在命令行中暴露
|
||||
ssh_output=$(SSH_SUDO_PASSWORD="${SUDO_PASSWORD}" ssh ${server} bash << SSH_EOF
|
||||
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="${DEPLOY_PATH}"
|
||||
DEPLOY_NAME="${DEPLOY_NAME}"
|
||||
SUDO_PASSWORD="\${SSH_SUDO_PASSWORD}"
|
||||
DEPLOY_PATH="${SSH_DEPLOY_PATH}"
|
||||
DEPLOY_NAME="${SSH_DEPLOY_NAME}"
|
||||
SUDO_PASSWORD="${SSH_SUDO_PASSWORD}"
|
||||
|
||||
# 检查部署目录是否存在
|
||||
if [ ! -d "\${DEPLOY_PATH}" ]; then
|
||||
echo "错误:部署目录 \${DEPLOY_PATH} 不存在,请检查路径是否正确"
|
||||
if [ ! -d "${DEPLOY_PATH}" ]; then
|
||||
echo "错误:部署目录 ${DEPLOY_PATH} 不存在,请检查路径是否正确"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否有权限写入(尝试创建测试文件)
|
||||
if ! touch "\${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
|
||||
if ! touch "${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
|
||||
echo "提示:没有直接写入权限,将使用 sudo 执行操作"
|
||||
USE_SUDO=1
|
||||
else
|
||||
rm -f "\${DEPLOY_PATH}/.deploy_test"
|
||||
rm -f "${DEPLOY_PATH}/.deploy_test"
|
||||
USE_SUDO=0
|
||||
fi
|
||||
|
||||
# 备份旧版本(如果存在)
|
||||
if [ -d "\${DEPLOY_PATH}/\${DEPLOY_NAME}" ]; then
|
||||
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; }
|
||||
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; }
|
||||
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
|
||||
if [ -d ~/${DEPLOY_NAME} ]; then
|
||||
echo "移动新版本到部署目录..."
|
||||
if [ "\$USE_SUDO" = "1" ]; then
|
||||
echo "\${SUDO_PASSWORD}" | sudo -S mv ~/\${DEPLOY_NAME} "\${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
|
||||
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; }
|
||||
mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败"; exit 1; }
|
||||
fi
|
||||
echo "部署完成!"
|
||||
else
|
||||
echo "错误:找不到 ~/\${DEPLOY_NAME} 目录"
|
||||
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 重新加载失败,请手动检查"
|
||||
echo "${SUDO_PASSWORD}" | sudo -S nginx -t && echo "${SUDO_PASSWORD}" | sudo -S nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||
fi
|
||||
SSH_EOF
|
||||
)
|
||||
|
||||
140
docs/MULTILANG_IMPLEMENTATION.md
Normal file
140
docs/MULTILANG_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 多语言功能实现总结
|
||||
|
||||
## 📋 实现内容
|
||||
|
||||
### 1. HTML 文档改进 (`index.html`)
|
||||
- ✅ 添加 `hreflang` 标签支持多语言 SEO
|
||||
- ✅ 添加双语 meta 标签(description, keywords)
|
||||
- ✅ 添加 Open Graph 和 Twitter Cards 多语言支持
|
||||
- ✅ 添加动态语言检测脚本
|
||||
- ✅ 优化 `og:locale` 和 `og:locale:alternate` 标签
|
||||
|
||||
### 2. SEO 辅助工具 (`src/lib/seoHelper.ts`)
|
||||
- ✅ 创建 `updatePageMeta()` 函数动态更新 meta 标签
|
||||
- ✅ 创建 `getSEOContent()` 函数获取语言特定的 SEO 内容
|
||||
- ✅ 支持更新标题、描述、关键词、OG 标签等
|
||||
|
||||
### 3. 语言上下文增强 (`src/contexts/LanguageContext.tsx`)
|
||||
- ✅ 集成 `updatePageMeta()` 在语言切换时自动更新页面
|
||||
- ✅ 在初始加载时根据用户偏好更新 meta 标签
|
||||
- ✅ 在 IP 检测后更新 meta 标签
|
||||
|
||||
### 4. 文档和测试
|
||||
- ✅ 创建详细的多语言支持文档 (`MULTILANG_README.md`)
|
||||
- ✅ 创建浏览器测试脚本 (`test-multilang.js`)
|
||||
- ✅ 包含最佳实践和使用示例
|
||||
|
||||
## 🎯 功能特性
|
||||
|
||||
### 自动语言检测优先级
|
||||
1. 用户在 localStorage 中保存的选择(最高优先级)
|
||||
2. IP 地理位置检测
|
||||
3. 浏览器语言设置(回退)
|
||||
|
||||
### SEO 优化
|
||||
- **搜索引擎**: 完整的 hreflang 支持
|
||||
- **社交媒体**: Open Graph 和 Twitter Cards
|
||||
- **元数据**: 动态更新标题、描述、关键词
|
||||
|
||||
### 用户体验
|
||||
- 一键语言切换(在导航栏)
|
||||
- 无刷新页面更新
|
||||
- 保持用户选择跨会话
|
||||
|
||||
## 📝 使用示例
|
||||
|
||||
### 切换语言
|
||||
```typescript
|
||||
import { useLanguage } from './contexts/LanguageContext';
|
||||
|
||||
function Component() {
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
|
||||
return (
|
||||
<button onClick={() => setLanguage('en')}>
|
||||
{t.common.switchLanguage}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新翻译
|
||||
在 `src/lib/translations.ts` 中:
|
||||
```typescript
|
||||
export const translations = {
|
||||
en: {
|
||||
newFeature: {
|
||||
title: 'New Feature',
|
||||
}
|
||||
},
|
||||
zh: {
|
||||
newFeature: {
|
||||
title: '新功能',
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 更新 SEO 内容
|
||||
在 `src/lib/seoHelper.ts` 中:
|
||||
```typescript
|
||||
const seoContent: Record<Language, SEOContent> = {
|
||||
en: {
|
||||
title: 'Your English Title',
|
||||
description: 'Your English description',
|
||||
keywords: 'english, keywords',
|
||||
},
|
||||
zh: {
|
||||
title: '您的中文标题',
|
||||
description: '您的中文描述',
|
||||
keywords: '中文,关键词',
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 🧪 测试清单
|
||||
|
||||
- [ ] 页面首次加载时语言检测正确
|
||||
- [ ] 切换语言时标题更新
|
||||
- [ ] 切换语言时 meta 标签更新
|
||||
- [ ] HTML lang 属性同步更新
|
||||
- [ ] localStorage 正确保存用户选择
|
||||
- [ ] 所有 UI 文本正确翻译
|
||||
- [ ] Open Graph 预览正确显示(使用 [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/))
|
||||
- [ ] Twitter Card 预览正确显示(使用 [Twitter Card Validator](https://cards-dev.twitter.com/validator))
|
||||
|
||||
## 🚀 部署注意事项
|
||||
|
||||
### 服务器配置
|
||||
如果使用基于 URL 的语言路由(如 `/en`, `/zh`),需要配置服务器重写规则:
|
||||
|
||||
**Nginx 示例**:
|
||||
```nginx
|
||||
location ~ ^/(en|zh) {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
**Apache 示例**:
|
||||
```apache
|
||||
RewriteEngine On
|
||||
RewriteRule ^(en|zh)/ /index.html [L]
|
||||
```
|
||||
|
||||
### CDN 缓存
|
||||
确保 CDN 不会缓存带有错误语言的页面:
|
||||
- 使用 `Vary: Accept-Language` 头
|
||||
- 或在 URL 中包含语言参数
|
||||
|
||||
## 🔗 相关资源
|
||||
|
||||
- [Google 国际化指南](https://developers.google.com/search/docs/specialty/international/localized-versions)
|
||||
- [Open Graph Protocol](https://ogp.me/)
|
||||
- [Twitter Cards 文档](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
|
||||
- [hreflang 最佳实践](https://support.google.com/webmasters/answer/189077)
|
||||
|
||||
## 📞 支持
|
||||
|
||||
如有问题或建议,请联系:
|
||||
- Email: yogecoder@gmail.com
|
||||
- QQ 群: 1018282100
|
||||
174
docs/MULTILANG_README.md
Normal file
174
docs/MULTILANG_README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 多语言支持说明 / Multi-language Support
|
||||
|
||||
## 概述 / Overview
|
||||
|
||||
TexPixel 现在支持完整的中英文双语切换,包括:
|
||||
- 动态页面标题和 meta 标签更新
|
||||
- SEO 优化的多语言支持
|
||||
- 智能语言检测(基于 IP 和浏览器偏好)
|
||||
|
||||
TexPixel now supports complete bilingual switching between Chinese and English, including:
|
||||
- Dynamic page title and meta tag updates
|
||||
- SEO-optimized multi-language support
|
||||
- Intelligent language detection (based on IP and browser preferences)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性 / Features
|
||||
|
||||
### 1. 自动语言检测 / Automatic Language Detection
|
||||
|
||||
应用会按以下优先级检测用户语言:
|
||||
1. localStorage 中保存的用户选择
|
||||
2. IP 地理位置检测
|
||||
3. 浏览器语言设置
|
||||
|
||||
The app detects user language in the following order of priority:
|
||||
1. User's saved preference in localStorage
|
||||
2. IP geolocation detection
|
||||
3. Browser language settings
|
||||
|
||||
### 2. SEO 优化 / SEO Optimization
|
||||
|
||||
- **hreflang 标签**:告知搜索引擎不同语言版本的页面
|
||||
- **多语言 meta 标签**:description、keywords、og:locale 等
|
||||
- **动态标题更新**:切换语言时自动更新页面标题
|
||||
|
||||
Features include:
|
||||
- **hreflang tags**: Inform search engines about different language versions
|
||||
- **Multilingual meta tags**: description, keywords, og:locale, etc.
|
||||
- **Dynamic title updates**: Automatically update page title when switching languages
|
||||
|
||||
### 3. Open Graph 和 Twitter Cards
|
||||
|
||||
支持社交媒体分享的多语言 meta 标签:
|
||||
- Facebook (Open Graph)
|
||||
- Twitter Cards
|
||||
- 其他支持 OG 协议的平台
|
||||
|
||||
Multilingual meta tags for social media sharing:
|
||||
- Facebook (Open Graph)
|
||||
- Twitter Cards
|
||||
- Other platforms supporting OG protocol
|
||||
|
||||
---
|
||||
|
||||
## 技术实现 / Technical Implementation
|
||||
|
||||
### 文件结构 / File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── seoHelper.ts # SEO 元数据管理 / SEO metadata management
|
||||
│ ├── translations.ts # 翻译文本 / Translation texts
|
||||
│ └── ipLocation.ts # IP 定位 / IP location detection
|
||||
├── contexts/
|
||||
│ └── LanguageContext.tsx # 语言上下文 / Language context
|
||||
└── components/
|
||||
└── Navbar.tsx # 语言切换器 / Language switcher
|
||||
```
|
||||
|
||||
### 核心函数 / Core Functions
|
||||
|
||||
#### `updatePageMeta(language: Language)`
|
||||
更新页面的所有 SEO 相关元数据,包括:
|
||||
- document.title
|
||||
- HTML lang 属性
|
||||
- meta description
|
||||
- meta keywords
|
||||
- Open Graph 标签
|
||||
- Twitter Card 标签
|
||||
|
||||
Updates all SEO-related metadata on the page, including:
|
||||
- document.title
|
||||
- HTML lang attribute
|
||||
- meta description
|
||||
- meta keywords
|
||||
- Open Graph tags
|
||||
- Twitter Card tags
|
||||
|
||||
#### `getSEOContent(language: Language)`
|
||||
获取指定语言的 SEO 内容(标题、描述、关键词)
|
||||
|
||||
Get SEO content for a specific language (title, description, keywords)
|
||||
|
||||
---
|
||||
|
||||
## 使用方法 / Usage
|
||||
|
||||
### 在组件中使用语言功能 / Using Language Features in Components
|
||||
|
||||
```typescript
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
function MyComponent() {
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t.common.title}</h1>
|
||||
<button onClick={() => setLanguage('en')}>English</button>
|
||||
<button onClick={() => setLanguage('zh')}>中文</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 添加新的翻译文本 / Adding New Translation Texts
|
||||
|
||||
在 `src/lib/translations.ts` 中添加:
|
||||
|
||||
```typescript
|
||||
export const translations = {
|
||||
en: {
|
||||
myFeature: {
|
||||
title: 'My Feature',
|
||||
description: 'Feature description',
|
||||
}
|
||||
},
|
||||
zh: {
|
||||
myFeature: {
|
||||
title: '我的功能',
|
||||
description: '功能描述',
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 最佳实践 / Best Practices
|
||||
|
||||
1. **始终提供双语内容** / Always provide bilingual content
|
||||
- 确保所有用户可见的文本都有中英文翻译
|
||||
- Ensure all user-visible text has Chinese and English translations
|
||||
|
||||
2. **保持 SEO 元数据最新** / Keep SEO metadata up-to-date
|
||||
- 在 `seoHelper.ts` 中维护准确的页面描述和关键词
|
||||
- Maintain accurate page descriptions and keywords in `seoHelper.ts`
|
||||
|
||||
3. **测试语言切换** / Test language switching
|
||||
- 确保切换语言时页面标题和 meta 标签正确更新
|
||||
- Ensure page title and meta tags update correctly when switching languages
|
||||
|
||||
4. **考虑 RTL 语言** / Consider RTL languages
|
||||
- 虽然目前只支持中英文,但代码架构支持未来添加其他语言
|
||||
- While currently supporting only Chinese and English, the architecture supports adding other languages in the future
|
||||
|
||||
---
|
||||
|
||||
## 路线图 / Roadmap
|
||||
|
||||
- [ ] 添加更多语言支持(日语、韩语等)/ Add more language support (Japanese, Korean, etc.)
|
||||
- [ ] 实现 URL 路由多语言 (/en, /zh) / Implement URL routing for languages
|
||||
- [ ] 服务端渲染 (SSR) 支持 / Server-side rendering (SSR) support
|
||||
- [ ] 语言特定的日期和数字格式化 / Language-specific date and number formatting
|
||||
|
||||
---
|
||||
|
||||
## 相关链接 / Related Links
|
||||
|
||||
- [hreflang 标签最佳实践](https://developers.google.com/search/docs/specialty/international/localized-versions)
|
||||
- [Open Graph Protocol](https://ogp.me/)
|
||||
- [Twitter Cards Documentation](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/abouts-cards)
|
||||
57
e2e/auth-email.spec.ts
Normal file
57
e2e/auth-email.spec.ts
Normal 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
80
e2e/auth-oauth.spec.ts
Normal 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();
|
||||
});
|
||||
51
index.html
51
index.html
@@ -1,35 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>⚡️ TexPixel - 公式识别工具</title>
|
||||
|
||||
<!-- Multi-language Support -->
|
||||
<link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/" />
|
||||
<link rel="alternate" hreflang="en" href="https://texpixel.com/en" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
|
||||
|
||||
<!-- Dynamic Title (will be updated by app) -->
|
||||
<title>⚡️ TexPixel - 公式识别工具 | Formula Recognition Tool</title>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
|
||||
<!-- SEO Meta Tags - Chinese (Default) -->
|
||||
<meta name="description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。Online formula recognition tool supporting printed and handwritten math formulas." />
|
||||
<meta name="keywords"
|
||||
content="公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,handwriting,formula,recognition,ocr,handwriting,recognition,math,handwriting,free" />
|
||||
content="公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,formula recognition,math formula,handwriting recognition,latex,mathml,markdown,texpixel,TexPixel,混合文字识别,document recognition" />
|
||||
<meta name="author" content="TexPixel Team" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="TexPixel - 公式识别工具" />
|
||||
<meta property="og:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
|
||||
<!-- Open Graph Meta Tags - Bilingual -->
|
||||
<meta property="og:title" content="TexPixel - 公式识别工具 | Formula Recognition Tool" />
|
||||
<meta property="og:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别。Online formula recognition tool supporting printed and handwritten math formulas." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://texpixel.com/" />
|
||||
<meta property="og:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
<meta property="og:locale:alternate" content="en_US" />
|
||||
<meta property="og:site_name" content="TexPixel" />
|
||||
|
||||
<!-- Twitter Card Meta Tags -->
|
||||
<!-- Twitter Card Meta Tags - Bilingual -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="TexPixel - 公式识别工具" />
|
||||
<meta name="twitter:description" content="在线公式识别工具,支持印刷体和手写体数学公式识别。" />
|
||||
<meta name="twitter:title" content="TexPixel - Formula Recognition Tool | 公式识别工具" />
|
||||
<meta name="twitter:description" content="Online formula recognition tool supporting printed and handwritten math formulas. 支持印刷体和手写体数学公式识别。" />
|
||||
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||
<meta name="twitter:site" content="@TexPixel" />
|
||||
|
||||
<!-- baidu -->
|
||||
<!-- Baidu Verification -->
|
||||
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
|
||||
|
||||
<!-- Language Detection Script -->
|
||||
<script>
|
||||
// Update HTML lang attribute based on user preference or browser language
|
||||
(function() {
|
||||
const savedLang = localStorage.getItem('language');
|
||||
const browserLang = navigator.language.toLowerCase();
|
||||
const isZh = savedLang === 'zh' || (!savedLang && browserLang.startsWith('zh'));
|
||||
document.documentElement.lang = isZh ? 'zh-CN' : 'en';
|
||||
|
||||
// Update page title based on language
|
||||
if (!isZh) {
|
||||
document.title = '⚡️ TexPixel - Formula Recognition Tool';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
2321
package-lock.json
generated
2321
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -9,6 +9,9 @@
|
||||
"build:dev": "VITE_ENV=development vite build",
|
||||
"build:prod": "VITE_ENV=production vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
@@ -23,7 +26,9 @@
|
||||
"mathml2omml": "^0.5.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.13.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
@@ -32,7 +37,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@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-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
@@ -41,10 +50,12 @@
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"jsdom": "^28.1.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
"vite": "^5.4.2",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
|
||||
24
playwright.config.ts
Normal file
24
playwright.config.ts
Normal 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
79
src/App.tsx
79
src/App.tsx
@@ -10,8 +10,11 @@ import FilePreview from './components/FilePreview';
|
||||
import ResultPanel from './components/ResultPanel';
|
||||
import UploadModal from './components/UploadModal';
|
||||
import UserGuide from './components/UserGuide';
|
||||
import AuthModal from './components/AuthModal';
|
||||
|
||||
const PAGE_SIZE = 6;
|
||||
const GUEST_USAGE_LIMIT = 3;
|
||||
const GUEST_USAGE_COUNT_KEY = 'texpixel_guest_usage_count';
|
||||
|
||||
function App() {
|
||||
const { user, initializing } = useAuth();
|
||||
@@ -21,7 +24,13 @@ function App() {
|
||||
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [showUserGuide, setShowUserGuide] = useState(false);
|
||||
const [showAuthModal, setShowAuthModal] = 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
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -44,6 +53,19 @@ function App() {
|
||||
const hasLoadedFiles = useRef(false);
|
||||
|
||||
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);
|
||||
@@ -97,6 +119,10 @@ function App() {
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
// If modal is open, let the modal handle paste events to avoid double upload
|
||||
if (showUploadModal) return;
|
||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
||||
openAuthModal();
|
||||
return;
|
||||
}
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
@@ -116,7 +142,7 @@ function App() {
|
||||
|
||||
document.addEventListener('paste', handlePaste);
|
||||
return () => document.removeEventListener('paste', handlePaste);
|
||||
}, [user, showUploadModal]);
|
||||
}, [guestUsageCount, openAuthModal, showUploadModal, user]);
|
||||
|
||||
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
|
||||
mouseDownEvent.preventDefault();
|
||||
@@ -180,7 +206,7 @@ function App() {
|
||||
markdown_content: item.markdown,
|
||||
latex_content: item.latex,
|
||||
mathml_content: item.mathml,
|
||||
mathml_word_content: item.mathml_mw,
|
||||
mml: item.mml,
|
||||
rendered_image_path: item.image_blob || null,
|
||||
created_at: item.created_at,
|
||||
};
|
||||
@@ -213,10 +239,6 @@ function App() {
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-select first file if none selected
|
||||
if (!selectedFileId) {
|
||||
setSelectedFileId(fileRecords[0].id);
|
||||
}
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
@@ -287,7 +309,7 @@ function App() {
|
||||
markdown_content: result.markdown,
|
||||
latex_content: result.latex,
|
||||
mathml_content: result.mathml,
|
||||
mathml_word_content: result.mathml_mw,
|
||||
mml: result.mml,
|
||||
rendered_image_path: result.image_blob || null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -344,7 +366,7 @@ function App() {
|
||||
markdown_content: result.markdown,
|
||||
latex_content: result.latex,
|
||||
mathml_content: result.mathml,
|
||||
mathml_word_content: result.mathml_mw,
|
||||
mml: result.mml,
|
||||
rendered_image_path: result.image_blob || null,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
@@ -391,8 +413,15 @@ function App() {
|
||||
};
|
||||
|
||||
const handleUpload = async (uploadFiles: File[]) => {
|
||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
||||
openAuthModal();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
let successfulUploads = 0;
|
||||
|
||||
for (const file of uploadFiles) {
|
||||
// 1. Upload file to OSS (or check duplicate)
|
||||
const fileHash = await uploadService.calculateMD5(file);
|
||||
@@ -430,6 +459,12 @@ function App() {
|
||||
if (taskData.task_no) {
|
||||
startPolling(taskData.task_no, fileId);
|
||||
}
|
||||
|
||||
successfulUploads += 1;
|
||||
}
|
||||
|
||||
if (!user && successfulUploads > 0) {
|
||||
incrementGuestUsage();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading files:', error);
|
||||
@@ -464,7 +499,15 @@ function App() {
|
||||
files={files}
|
||||
selectedFileId={selectedFileId}
|
||||
onFileSelect={setSelectedFileId}
|
||||
onUploadClick={() => setShowUploadModal(true)}
|
||||
onUploadClick={() => {
|
||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
||||
openAuthModal();
|
||||
return;
|
||||
}
|
||||
setShowUploadModal(true);
|
||||
}}
|
||||
canUploadAnonymously={canUploadAnonymously}
|
||||
onRequireAuth={openAuthModal}
|
||||
isCollapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
onUploadFiles={handleUpload}
|
||||
@@ -502,6 +545,12 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAuthModal && (
|
||||
<AuthModal
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UserGuide
|
||||
isOpen={showUserGuide}
|
||||
onClose={() => setShowUserGuide(false)}
|
||||
@@ -516,18 +565,6 @@ function App() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,41 +1,73 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
interface AuthModalProps {
|
||||
onClose: () => void;
|
||||
mandatory?: boolean;
|
||||
}
|
||||
|
||||
export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
const { signIn, signUp } = useAuth();
|
||||
export default function AuthModal({ onClose, mandatory = false }: AuthModalProps) {
|
||||
const { signIn, signUp, beginGoogleOAuth, authPhase, authError } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [isSignUp, setIsSignUp] = useState(false);
|
||||
|
||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = 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) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
setLocalError('');
|
||||
const nextFieldErrors: { email?: string; password?: string; confirmPassword?: string } = {};
|
||||
const normalizedEmail = email.trim();
|
||||
|
||||
try {
|
||||
const { error } = isSignUp
|
||||
? await signUp(email, password)
|
||||
: await signIn(email, password);
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('发生错误,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (!normalizedEmail) {
|
||||
nextFieldErrors.email = t.auth.emailRequired;
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
||||
nextFieldErrors.email = t.auth.emailInvalid;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
nextFieldErrors.password = t.auth.passwordRequired;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleOAuth = async () => {
|
||||
await beginGoogleOAuth();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -43,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="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{isSignUp ? t.auth.signUpTitle : t.auth.signInTitle}
|
||||
{mode === 'signup' ? t.auth.signUpTitle : t.auth.signInTitle}
|
||||
</h2>
|
||||
{!mandatory && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
aria-label="close"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg bg-gray-100 p-1 mb-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<X size={20} />
|
||||
{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} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||
<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>
|
||||
<input
|
||||
id="auth-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onChange={(e) => {
|
||||
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"
|
||||
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>
|
||||
<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>
|
||||
<input
|
||||
id="auth-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
onChange={(e) => {
|
||||
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="••••••••"
|
||||
required
|
||||
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>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
|
||||
{t.auth.error}: {error}
|
||||
{mode === 'signup' && (
|
||||
<div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{isSignUp ? t.auth.signUp : t.auth.signIn}
|
||||
{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>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<button
|
||||
onClick={() => setIsSignUp(!isSignUp)}
|
||||
className="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{isSignUp ? t.auth.hasAccount : t.auth.noAccount}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Check, Copy, Download, Code2, Image as ImageIcon, FileText, Loader2, LucideIcon } from 'lucide-react';
|
||||
import { RecognitionResult } from '../types';
|
||||
import { convertMathmlToOmml, wrapOmmlForClipboard } from '../lib/ommlConverter';
|
||||
import { generateImageFromElement, copyImageToClipboard, downloadImage } from '../lib/imageGenerator';
|
||||
import { API_BASE_URL } from '../config/env';
|
||||
import { tokenManager } from '../lib/api';
|
||||
import { trackExportEvent } from '../lib/analytics';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast, { Toaster } from 'react-hot-toast';
|
||||
|
||||
interface ExportSidebarProps {
|
||||
isOpen: boolean;
|
||||
@@ -25,87 +25,6 @@ interface ExportOption {
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
// Helper function to add mml: prefix to MathML
|
||||
const addMMLPrefix = (mathml: string): string | null => {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(mathml, 'application/xml');
|
||||
|
||||
// Check for parse errors
|
||||
const parseError = xmlDoc.getElementsByTagName("parsererror");
|
||||
if (parseError.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create new document with mml namespace
|
||||
const newDoc = document.implementation.createDocument(
|
||||
'http://www.w3.org/1998/Math/MathML',
|
||||
'mml:math',
|
||||
null
|
||||
);
|
||||
|
||||
const newMathElement = newDoc.documentElement;
|
||||
|
||||
// Copy display attribute if present
|
||||
const displayAttr = xmlDoc.documentElement.getAttribute('display');
|
||||
if (displayAttr) {
|
||||
newMathElement.setAttribute('display', displayAttr);
|
||||
}
|
||||
|
||||
// Recursive function to process nodes
|
||||
const processNode = (node: Node, newParent: Element) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = node as Element;
|
||||
// Create new element with mml: prefix in the target document
|
||||
const newElement = newDoc.createElementNS(
|
||||
'http://www.w3.org/1998/Math/MathML',
|
||||
'mml:' + element.localName
|
||||
);
|
||||
|
||||
// Copy attributes
|
||||
for (let i = 0; i < element.attributes.length; i++) {
|
||||
const attr = element.attributes[i];
|
||||
// Skip xmlns attributes as we handle them explicitly
|
||||
if (attr.name.startsWith('xmlns')) continue;
|
||||
|
||||
newElement.setAttributeNS(
|
||||
attr.namespaceURI,
|
||||
attr.name,
|
||||
attr.value
|
||||
);
|
||||
}
|
||||
|
||||
// Process children
|
||||
Array.from(element.childNodes).forEach(child => {
|
||||
processNode(child, newElement);
|
||||
});
|
||||
|
||||
newParent.appendChild(newElement);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
newParent.appendChild(newDoc.createTextNode(node.nodeValue || ''));
|
||||
}
|
||||
};
|
||||
|
||||
// Process all children of the root math element
|
||||
Array.from(xmlDoc.documentElement.childNodes).forEach(child => {
|
||||
processNode(child, newMathElement);
|
||||
});
|
||||
|
||||
// Serialize
|
||||
const serializer = new XMLSerializer();
|
||||
let prefixedMathML = serializer.serializeToString(newDoc);
|
||||
|
||||
// Clean up xmlns
|
||||
prefixedMathML = prefixedMathML.replace(/ xmlns(:mml)?="[^"]*"/g, '');
|
||||
prefixedMathML = prefixedMathML.replace(/<mml:math>/, '<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML">');
|
||||
|
||||
return prefixedMathML;
|
||||
} catch (err) {
|
||||
console.error('Failed to process MathML:', err);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebarProps) {
|
||||
const { t } = useLanguage();
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
@@ -121,14 +40,25 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
category: 'Code',
|
||||
getContent: (r) => r.markdown_content
|
||||
},
|
||||
{
|
||||
id: 'latex',
|
||||
label: 'LaTeX',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.latex_content
|
||||
},
|
||||
{
|
||||
id: 'latex_inline',
|
||||
label: 'LaTeX (Inline)',
|
||||
category: 'Code',
|
||||
getContent: (r) => {
|
||||
if (!r.latex_content) return null;
|
||||
// Remove existing \[ \] and wrap with \( \)
|
||||
const content = r.latex_content.replace(/^\\\[/, '').replace(/\\\]$/, '').trim();
|
||||
// Remove existing delimiters like \[ \], \( \), $$, or $
|
||||
let content = r.latex_content.trim();
|
||||
content = content.replace(/^\\\[/, '').replace(/\\\]$/, '');
|
||||
content = content.replace(/^\\\(/, '').replace(/\\\)$/, '');
|
||||
content = content.replace(/^\$\$/, '').replace(/\$\$$/, '');
|
||||
content = content.replace(/^\$/, '').replace(/\$$/, '');
|
||||
content = content.trim();
|
||||
return `\\(${content}\\)`;
|
||||
}
|
||||
},
|
||||
@@ -136,7 +66,17 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
id: 'latex_display',
|
||||
label: 'LaTeX (Display)',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.latex_content
|
||||
getContent: (r) => {
|
||||
if (!r.latex_content) return null;
|
||||
// Remove existing delimiters like \[ \], \( \), $$, or $
|
||||
let content = r.latex_content.trim();
|
||||
content = content.replace(/^\\\[/, '').replace(/\\\]$/, '');
|
||||
content = content.replace(/^\\\(/, '').replace(/\\\)$/, '');
|
||||
content = content.replace(/^\$\$/, '').replace(/\$\$$/, '');
|
||||
content = content.replace(/^\$/, '').replace(/\$$/, '');
|
||||
content = content.trim();
|
||||
return `\\[${content}\\]`;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'mathml',
|
||||
@@ -148,13 +88,7 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
id: 'mathml_mml',
|
||||
label: 'MathML (MML)',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.mathml_content ? addMMLPrefix(r.mathml_content) : null
|
||||
},
|
||||
{
|
||||
id: 'mathml_word',
|
||||
label: 'Word MathML',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.mathml_word_content
|
||||
getContent: (r) => r.mml
|
||||
},
|
||||
// Image Category
|
||||
{
|
||||
@@ -278,22 +212,33 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
return;
|
||||
}
|
||||
|
||||
let content = option.getContent(result);
|
||||
const content = option.getContent(result);
|
||||
|
||||
// Fallback: If Word MathML is missing, try to convert from MathML
|
||||
if (option.id === 'mathml_word' && !content && result.mathml_content) {
|
||||
try {
|
||||
const omml = await convertMathmlToOmml(result.mathml_content);
|
||||
if (omml) {
|
||||
content = wrapOmmlForClipboard(omml);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to convert MathML to OMML:', err);
|
||||
}
|
||||
// Check if content is empty and show toast
|
||||
if (!content) {
|
||||
toast.error(t.export.noContent, {
|
||||
duration: 2000,
|
||||
position: 'top-center',
|
||||
style: {
|
||||
background: '#fff',
|
||||
color: '#1f2937',
|
||||
padding: '16px 20px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 40px rgba(59, 130, 246, 0.15), 0 2px 8px rgba(59, 130, 246, 0.1)',
|
||||
border: '1px solid #dbeafe',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
maxWidth: '900px',
|
||||
lineHeight: '1.5',
|
||||
},
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content) return;
|
||||
|
||||
setExportingId(option.id);
|
||||
|
||||
try {
|
||||
@@ -321,6 +266,10 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
|
||||
} catch (err) {
|
||||
console.error('Action failed:', err);
|
||||
toast.error(t.export.failed, {
|
||||
duration: 3000,
|
||||
position: 'top-center',
|
||||
});
|
||||
} finally {
|
||||
setExportingId(null);
|
||||
}
|
||||
@@ -334,6 +283,41 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Toast Container with custom configuration */}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#fff',
|
||||
color: '#1f2937',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 40px rgba(59, 130, 246, 0.15), 0 2px 8px rgba(59, 130, 246, 0.1)',
|
||||
maxWidth: '420px',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
style: {
|
||||
border: '1px solid #d1fae5',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#3b82f6',
|
||||
secondary: '#ffffff',
|
||||
},
|
||||
style: {
|
||||
border: '1px solid #dbeafe',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Backdrop */}
|
||||
{isOpen && (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useLanguage } from '../contexts/LanguageContext';
|
||||
import { FileRecord } from '../types';
|
||||
@@ -10,6 +10,8 @@ interface LeftSidebarProps {
|
||||
selectedFileId: string | null;
|
||||
onFileSelect: (fileId: string) => void;
|
||||
onUploadClick: () => void;
|
||||
canUploadAnonymously: boolean;
|
||||
onRequireAuth: () => void;
|
||||
isCollapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
onUploadFiles: (files: File[]) => void;
|
||||
@@ -23,6 +25,8 @@ export default function LeftSidebar({
|
||||
selectedFileId,
|
||||
onFileSelect,
|
||||
onUploadClick,
|
||||
canUploadAnonymously,
|
||||
onRequireAuth,
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
onUploadFiles,
|
||||
@@ -30,12 +34,19 @@ export default function LeftSidebar({
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: LeftSidebarProps) {
|
||||
const { user, signOut } = useAuth();
|
||||
const { user, signOut, isAuthenticated } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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
|
||||
@@ -84,6 +95,10 @@ export default function LeftSidebar({
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user && !canUploadAnonymously) {
|
||||
onRequireAuth();
|
||||
return;
|
||||
}
|
||||
setIsDragging(false);
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
@@ -97,6 +112,10 @@ export default function LeftSidebar({
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!user && !canUploadAnonymously) {
|
||||
onRequireAuth();
|
||||
return;
|
||||
}
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
onUploadFiles(Array.from(e.target.files));
|
||||
}
|
||||
@@ -117,7 +136,13 @@ export default function LeftSidebar({
|
||||
</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"
|
||||
title={t.common.upload}
|
||||
>
|
||||
@@ -162,9 +187,15 @@ export default function LeftSidebar({
|
||||
<div className="mb-2" id="sidebar-upload-area">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => {
|
||||
if (!user && !canUploadAnonymously) {
|
||||
onRequireAuth();
|
||||
return;
|
||||
}
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className={`
|
||||
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group
|
||||
${isDragging
|
||||
@@ -284,7 +315,7 @@ export default function LeftSidebar({
|
||||
</svg>
|
||||
</div>
|
||||
<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>
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
|
||||
@@ -91,11 +91,9 @@ export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClos
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] pointer-events-none">
|
||||
{/* Backdrop with hole */}
|
||||
<div className="absolute inset-0 bg-black/60 pointer-events-auto" onClick={onClose} style={{
|
||||
clipPath: highlightStyle.top !== undefined ? `polygon(
|
||||
const backdropClipPath =
|
||||
highlightStyle.top !== undefined
|
||||
? `polygon(
|
||||
0% 0%, 0% 100%,
|
||||
${highlightStyle.left}px 100%,
|
||||
${highlightStyle.left}px ${highlightStyle.top}px,
|
||||
@@ -104,8 +102,17 @@ export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClos
|
||||
${highlightStyle.left}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
|
||||
${highlightStyle.left}px 100%,
|
||||
100% 100%, 100% 0%
|
||||
)` : 'none'
|
||||
}} />
|
||||
)`
|
||||
: '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
|
||||
|
||||
158
src/components/__tests__/App.test.tsx
Normal file
158
src/components/__tests__/App.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
104
src/components/__tests__/AuthModal.test.tsx
Normal file
104
src/components/__tests__/AuthModal.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,122 +1,213 @@
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { createContext, useContext, useMemo, ReactNode, useCallback, useEffect, useReducer } from 'react';
|
||||
import { authService } from '../lib/authService';
|
||||
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 {
|
||||
user: UserInfo | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
initializing: boolean; // 新增初始化状态
|
||||
initializing: boolean;
|
||||
authPhase: AuthPhase;
|
||||
authError: string | null;
|
||||
signIn: (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>;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const oauthExchangeInFlight = new Map<string, Promise<{ error: Error | null }>>();
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const apiError = error as { code: number; message?: string };
|
||||
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
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 }) {
|
||||
// 直接在 useState 初始化函数中同步恢复会话
|
||||
const [user, setUser] = useState<UserInfo | null>(() => {
|
||||
try {
|
||||
const session = authService.restoreSession();
|
||||
return session ? session.user : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const restoredSession = authService.restoreSession();
|
||||
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
try {
|
||||
const session = authService.restoreSession();
|
||||
return session ? session.token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const [state, dispatch] = useReducer(
|
||||
authReducer,
|
||||
createInitialAuthState(restoredSession ? { user: restoredSession.user, token: restoredSession.token } : null)
|
||||
);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initializing, setInitializing] = useState(false); // 不需要初始化过程了,因为是同步的
|
||||
|
||||
// 不再需要 useEffect 里的 restoreSession
|
||||
|
||||
|
||||
/**
|
||||
* 从错误对象中提取用户友好的错误消息
|
||||
*/
|
||||
const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||
// 检查是否是 ApiError(通过 code 属性判断,避免 instanceof 在热更新时失效)
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const apiError = error as { code: number; message: string };
|
||||
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
const signIn = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
dispatch({ type: 'EMAIL_SIGNIN_START' });
|
||||
|
||||
try {
|
||||
const result = await authService.login({ email, password });
|
||||
setUser(result.user);
|
||||
setToken(result.token);
|
||||
dispatch({ type: 'EMAIL_SIGNIN_SUCCESS', payload: { user: result.user, token: result.token } });
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, '登录失败');
|
||||
dispatch({ type: 'EMAIL_SIGNIN_FAIL', payload: { error: message } });
|
||||
return { error: new Error(message) };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
const signUp = useCallback(async (email: string, password: string) => {
|
||||
setLoading(true);
|
||||
dispatch({ type: 'EMAIL_SIGNUP_START' });
|
||||
|
||||
try {
|
||||
const result = await authService.register({ email, password });
|
||||
setUser(result.user);
|
||||
setToken(result.token);
|
||||
dispatch({ type: 'EMAIL_SIGNUP_SUCCESS', payload: { user: result.user, token: result.token } });
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, '注册失败');
|
||||
dispatch({ type: 'EMAIL_SIGNUP_FAIL', payload: { error: message } });
|
||||
return { error: new Error(message) };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
const signOut = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const beginGoogleOAuth = useCallback(async () => {
|
||||
dispatch({ type: 'OAUTH_REDIRECT_START' });
|
||||
|
||||
try {
|
||||
authService.logout();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
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 value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
initializing,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
isAuthenticated: !!user && !!token,
|
||||
};
|
||||
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) };
|
||||
} finally {
|
||||
oauthExchangeInFlight.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
oauthExchangeInFlight.set(requestKey, promise);
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
authService.logout();
|
||||
dispatch({ type: 'SIGN_OUT' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let hasSynced = false;
|
||||
|
||||
const syncUserProfile = async () => {
|
||||
const currentUser = state.user;
|
||||
const currentToken = state.token;
|
||||
|
||||
if (!currentUser || !currentToken || hasSynced) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasSynced = true;
|
||||
|
||||
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.
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
signUp,
|
||||
beginGoogleOAuth,
|
||||
completeGoogleOAuth,
|
||||
signOut,
|
||||
isAuthenticated: !!state.user && !!state.token,
|
||||
};
|
||||
}, [beginGoogleOAuth, completeGoogleOAuth, signIn, signOut, signUp, state]);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -25,6 +26,7 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
|
||||
// 如果用户已经手动选择过语言,则不进行IP检测
|
||||
if (saved === 'en' || saved === 'zh') {
|
||||
updatePageMeta(saved); // Update meta tags on initial load
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,18 +34,21 @@ export const LanguageProvider: React.FC<{ children: React.ReactNode }> = ({ chil
|
||||
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];
|
||||
|
||||
207
src/contexts/__tests__/AuthContext.oauth.test.tsx
Normal file
207
src/contexts/__tests__/AuthContext.oauth.test.tsx
Normal 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
135
src/contexts/authMachine.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export class ApiError extends Error {
|
||||
*/
|
||||
interface RequestConfig extends RequestInit {
|
||||
skipAuth?: boolean;
|
||||
successCodes?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +83,7 @@ async function request<T>(
|
||||
endpoint: string,
|
||||
config: RequestConfig = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
const { skipAuth = false, headers: customHeaders, ...restConfig } = config;
|
||||
const { skipAuth = false, successCodes = [200], headers: customHeaders, ...restConfig } = config;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -108,7 +109,7 @@ async function request<T>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -153,4 +154,3 @@ export const http = {
|
||||
};
|
||||
|
||||
export default http;
|
||||
|
||||
|
||||
@@ -1,36 +1,61 @@
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理用户登录、注册、登出等认证相关操作
|
||||
* 处理用户登录、注册、OAuth、登出等认证相关操作
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* 从 JWT Token 解析用户信息
|
||||
*/
|
||||
function parseJwtPayload(token: string): UserInfo | null {
|
||||
function decodeJwtPayload(token: string): UserInfo | null {
|
||||
try {
|
||||
// 移除 Bearer 前缀
|
||||
const actualToken = token.replace('Bearer ', '');
|
||||
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;
|
||||
} catch {
|
||||
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 = {
|
||||
/**
|
||||
* 用户登录
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
|
||||
|
||||
@@ -38,35 +63,9 @@ export const authService = {
|
||||
throw new ApiError(-1, '登录失败,请重试');
|
||||
}
|
||||
|
||||
const { token, expires_at } = response.data;
|
||||
|
||||
// 存储 Token 和 email
|
||||
tokenManager.setToken(token, expires_at, credentials.email);
|
||||
|
||||
// 解析用户信息
|
||||
const user = parseJwtPayload(token);
|
||||
if (!user) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const userWithEmail: UserInfo = {
|
||||
...user,
|
||||
email: credentials.email,
|
||||
id: String(user.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: userWithEmail,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
return buildSession(response.data, credentials.email);
|
||||
},
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
* 注意:业务错误码 (code !== 200) 已在 api.ts 中统一处理,会抛出 ApiError
|
||||
*/
|
||||
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
|
||||
|
||||
@@ -74,55 +73,58 @@ export const authService = {
|
||||
throw new ApiError(-1, '注册失败,请重试');
|
||||
}
|
||||
|
||||
const { token, expires_at } = response.data;
|
||||
|
||||
// 存储 Token 和 email
|
||||
tokenManager.setToken(token, expires_at, credentials.email);
|
||||
|
||||
// 解析用户信息
|
||||
const user = parseJwtPayload(token);
|
||||
if (!user) {
|
||||
throw new ApiError(-1, 'Token 解析失败');
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const userWithEmail: UserInfo = {
|
||||
...user,
|
||||
email: credentials.email,
|
||||
id: String(user.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user: userWithEmail,
|
||||
token,
|
||||
expiresAt: expires_at,
|
||||
};
|
||||
return buildSession(response.data, 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()}`, {
|
||||
skipAuth: true,
|
||||
});
|
||||
|
||||
if (!response.data?.auth_url) {
|
||||
throw new ApiError(-1, '获取 Google 授权地址失败');
|
||||
}
|
||||
|
||||
return { authUrl: response.data.auth_url };
|
||||
},
|
||||
|
||||
async exchangeGoogleCode(
|
||||
payload: GoogleOAuthCallbackRequest
|
||||
): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||
const response = await http.post<AuthData>('/user/oauth/google/callback', payload, { skipAuth: true });
|
||||
|
||||
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 {
|
||||
tokenManager.removeToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查是否已登录
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return tokenManager.isTokenValid();
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取当前存储的 Token
|
||||
*/
|
||||
getToken(): string | null {
|
||||
return tokenManager.getToken();
|
||||
},
|
||||
|
||||
/**
|
||||
* 从存储的 Token 恢复用户会话
|
||||
*/
|
||||
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
|
||||
const token = tokenManager.getToken();
|
||||
const expiresAt = tokenManager.getExpiresAt();
|
||||
@@ -133,21 +135,14 @@ export const authService = {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedUser = parseJwtPayload(token);
|
||||
const parsedUser = decodeJwtPayload(token);
|
||||
if (!parsedUser) {
|
||||
tokenManager.removeToken();
|
||||
return null;
|
||||
}
|
||||
|
||||
// 补充 email 和 id 兼容字段
|
||||
const user: UserInfo = {
|
||||
...parsedUser,
|
||||
email: email || '',
|
||||
id: String(parsedUser.user_id),
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
user: normalizeUser(parsedUser, email || ''),
|
||||
token,
|
||||
expiresAt,
|
||||
};
|
||||
@@ -156,4 +151,3 @@ export const authService = {
|
||||
|
||||
export { ApiError };
|
||||
export default authService;
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -93,16 +93,35 @@ Where:
|
||||
</mfrac>
|
||||
</mrow>
|
||||
</math>`,
|
||||
mathml_word_content: `<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math">
|
||||
<m:f>
|
||||
<m:num>
|
||||
<m:r><m:t>-b ± √(b²-4ac)</m:t></m:r>
|
||||
</m:num>
|
||||
<m:den>
|
||||
<m:r><m:t>2a</m:t></m:r>
|
||||
</m:den>
|
||||
</m:f>
|
||||
</m:oMath>`,
|
||||
mml: `<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML" display="block">
|
||||
<mml:mrow>
|
||||
<mml:mi>x</mml:mi>
|
||||
<mml:mo>=</mml:mo>
|
||||
<mml:mfrac>
|
||||
<mml:mrow>
|
||||
<mml:mo>-</mml:mo>
|
||||
<mml:mi>b</mml:mi>
|
||||
<mml:mo>±</mml:mo>
|
||||
<mml:msqrt>
|
||||
<mml:mrow>
|
||||
<mml:msup>
|
||||
<mml:mi>b</mml:mi>
|
||||
<mml:mn>2</mml:mn>
|
||||
</mml:msup>
|
||||
<mml:mo>-</mml:mo>
|
||||
<mml:mn>4</mml:mn>
|
||||
<mml:mi>a</mml:mi>
|
||||
<mml:mi>c</mml:mi>
|
||||
</mml:mrow>
|
||||
</mml:msqrt>
|
||||
</mml:mrow>
|
||||
<mml:mrow>
|
||||
<mml:mn>2</mml:mn>
|
||||
<mml:mi>a</mml:mi>
|
||||
</mml:mrow>
|
||||
</mml:mfrac>
|
||||
</mml:mrow>
|
||||
</mml:math>`,
|
||||
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800',
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -161,7 +180,7 @@ Where:
|
||||
markdown_content: `# Analysis for ${filename}\n\nThis is a mock analysis result generated for the uploaded file.\n\n$$ E = mc^2 $$\n\nDetected content matches widely known physics formulas.`,
|
||||
latex_content: `\\documentclass{article}\n\\begin{document}\nSection{${filename}}\n\n\\[ E = mc^2 \\]\n\n\\end{document}`,
|
||||
mathml_content: `<math><mi>E</mi><mo>=</mo><mi>m</mi><msup><mi>c</mi><mn>2</mn></msup></math>`,
|
||||
mathml_word_content: `<m:oMath><m:r><m:t>E=mc^2</m:t></m:r></m:oMath>`,
|
||||
mml: `<mml:math xmlns:mml="http://www.w3.org/1998/Math/MathML"><mml:mi>E</mml:mi><mml:mo>=</mml:mo><mml:mi>m</mml:mi><mml:msup><mml:mi>c</mml:mi><mml:mn>2</mml:mn></mml:msup></mml:math>`,
|
||||
rendered_image_path: 'https://images.pexels.com/photos/3729557/pexels-photo-3729557.jpeg?auto=compress&cs=tinysrgb&w=800', // Placeholder
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
80
src/lib/seoHelper.ts
Normal file
80
src/lib/seoHelper.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Language } from './translations';
|
||||
|
||||
interface SEOContent {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
const seoContent: Record<Language, SEOContent> = {
|
||||
zh: {
|
||||
title: '⚡️ TexPixel - 公式识别工具',
|
||||
description: '在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。',
|
||||
keywords: '公式识别,数学公式,OCR,手写公式识别,印刷体识别,AI识别,数学工具,免费,混合文字识别,texpixel,TexPixel',
|
||||
},
|
||||
en: {
|
||||
title: '⚡️ TexPixel - Formula Recognition Tool',
|
||||
description: 'Online formula recognition tool supporting printed and handwritten math formulas. Convert images to LaTeX, MathML, and Markdown quickly and accurately.',
|
||||
keywords: 'formula recognition,math formula,OCR,handwriting recognition,latex,mathml,markdown,AI recognition,math tool,free,texpixel,TexPixel,document recognition',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Update document metadata based on current language
|
||||
*/
|
||||
export function updatePageMeta(language: Language): void {
|
||||
const content = seoContent[language];
|
||||
|
||||
// Update title
|
||||
document.title = content.title;
|
||||
|
||||
// Update HTML lang attribute
|
||||
document.documentElement.lang = language === 'zh' ? 'zh-CN' : 'en';
|
||||
|
||||
// Update meta description
|
||||
const metaDescription = document.querySelector('meta[name="description"]');
|
||||
if (metaDescription) {
|
||||
metaDescription.setAttribute('content', content.description);
|
||||
}
|
||||
|
||||
// Update meta keywords
|
||||
const metaKeywords = document.querySelector('meta[name="keywords"]');
|
||||
if (metaKeywords) {
|
||||
metaKeywords.setAttribute('content', content.keywords);
|
||||
}
|
||||
|
||||
// Update Open Graph meta tags
|
||||
const ogTitle = document.querySelector('meta[property="og:title"]');
|
||||
if (ogTitle) {
|
||||
ogTitle.setAttribute('content', content.title);
|
||||
}
|
||||
|
||||
const ogDescription = document.querySelector('meta[property="og:description"]');
|
||||
if (ogDescription) {
|
||||
ogDescription.setAttribute('content', content.description);
|
||||
}
|
||||
|
||||
// Update Twitter Card meta tags
|
||||
const twitterTitle = document.querySelector('meta[name="twitter:title"]');
|
||||
if (twitterTitle) {
|
||||
twitterTitle.setAttribute('content', content.title);
|
||||
}
|
||||
|
||||
const twitterDescription = document.querySelector('meta[name="twitter:description"]');
|
||||
if (twitterDescription) {
|
||||
twitterDescription.setAttribute('content', content.description);
|
||||
}
|
||||
|
||||
// Update og:locale
|
||||
const ogLocale = document.querySelector('meta[property="og:locale"]');
|
||||
if (ogLocale) {
|
||||
ogLocale.setAttribute('content', language === 'zh' ? 'zh_CN' : 'en_US');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get SEO content for a specific language
|
||||
*/
|
||||
export function getSEOContent(language: Language): SEOContent {
|
||||
return seoContent[language];
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export type Database = {
|
||||
markdown_content: string | null;
|
||||
latex_content: string | null;
|
||||
mathml_content: string | null;
|
||||
mathml_word_content: string | null;
|
||||
mml: string | null;
|
||||
rendered_image_path: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -71,7 +71,7 @@ export type Database = {
|
||||
markdown_content?: string | null;
|
||||
latex_content?: string | null;
|
||||
mathml_content?: string | null;
|
||||
mathml_word_content?: string | null;
|
||||
mml?: string | null;
|
||||
rendered_image_path?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
@@ -81,7 +81,7 @@ export type Database = {
|
||||
markdown_content?: string | null;
|
||||
latex_content?: string | null;
|
||||
mathml_content?: string | null;
|
||||
mathml_word_content?: string | null;
|
||||
mml?: string | null;
|
||||
rendered_image_path?: string | null;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
@@ -60,6 +60,18 @@ export const translations = {
|
||||
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',
|
||||
@@ -70,6 +82,8 @@ export const translations = {
|
||||
},
|
||||
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',
|
||||
@@ -154,6 +168,18 @@ export const translations = {
|
||||
genericError: '发生错误,请重试',
|
||||
hasAccount: '已有账号?去登录',
|
||||
noAccount: '没有账号?去注册',
|
||||
continueWithGoogle: 'Google',
|
||||
emailHint: '仅用于登录和同步记录。',
|
||||
emailRequired: '请输入邮箱地址。',
|
||||
emailInvalid: '请输入有效的邮箱地址。',
|
||||
passwordRequired: '请输入密码。',
|
||||
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
|
||||
confirmPassword: '确认密码',
|
||||
passwordMismatch: '两次输入的密码不一致。',
|
||||
oauthRedirecting: '正在跳转 Google...',
|
||||
oauthExchanging: '正在完成 Google 登录...',
|
||||
invalidOAuthState: 'OAuth 状态校验失败,请重试',
|
||||
oauthFailed: 'Google 登录失败,请重试',
|
||||
},
|
||||
export: {
|
||||
title: '导出',
|
||||
@@ -164,6 +190,8 @@ export const translations = {
|
||||
},
|
||||
failed: '导出失败,请重试',
|
||||
imageFailed: '生成图片失败',
|
||||
noContent: '混合文字内容不支持 LaTeX/MathML 导出,请下载 DOCX 文件。',
|
||||
noContentShort: '混合内容不支持',
|
||||
},
|
||||
guide: {
|
||||
next: '下一步',
|
||||
|
||||
15
src/main.tsx
15
src/main.tsx
@@ -1,9 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import AppRouter from './routes/AppRouter';
|
||||
|
||||
// 错误处理:捕获未处理的错误
|
||||
window.addEventListener('error', (event) => {
|
||||
@@ -22,11 +23,13 @@ if (!rootElement) {
|
||||
try {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
<App />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
<AppRouter />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
81
src/pages/AuthCallbackPage.tsx
Normal file
81
src/pages/AuthCallbackPage.tsx
Normal 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
12
src/routes/AppRouter.tsx
Normal 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
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -14,10 +14,26 @@ export interface AuthData {
|
||||
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 返回)
|
||||
export interface UserInfo {
|
||||
user_id: number;
|
||||
email: string;
|
||||
username?: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
// 兼容字段,方便代码使用
|
||||
@@ -79,7 +95,7 @@ export interface RecognitionResultData {
|
||||
latex: string;
|
||||
markdown: string;
|
||||
mathml: string;
|
||||
mathml_mw: string; // MathML for Word
|
||||
mml: string; // MathML with mml: prefix
|
||||
image_blob: string; // Base64 or URL? assuming string content
|
||||
docx_url: string;
|
||||
pdf_url: string;
|
||||
@@ -96,7 +112,7 @@ export interface TaskHistoryItem {
|
||||
latex: string;
|
||||
markdown: string;
|
||||
mathml: string;
|
||||
mathml_mw: string;
|
||||
mml: string;
|
||||
image_blob: string;
|
||||
docx_url: string;
|
||||
pdf_url: string;
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface RecognitionResult {
|
||||
markdown_content: string | null;
|
||||
latex_content: string | null;
|
||||
mathml_content: string | null;
|
||||
mathml_word_content: string | null;
|
||||
mml: string | null;
|
||||
rendered_image_path: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export type ExportFormat =
|
||||
| 'markdown'
|
||||
| 'latex'
|
||||
| 'mathml'
|
||||
| 'mathml-word'
|
||||
| 'image'
|
||||
| 'docx'
|
||||
| 'pdf'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user