Compare commits
6 Commits
7c5409a6c7
...
feature/go
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f70a9a85c8 | ||
|
|
bc4b547e03 | ||
|
|
e4c6a09cf8 | ||
|
|
2b1da79bbc | ||
| 564aaec581 | |||
|
|
d562d67203 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,3 +22,4 @@ dist-ssr
|
||||
*.sw?
|
||||
.env
|
||||
/dist
|
||||
app.cloud/
|
||||
|
||||
203
deploy_dev.sh
Executable file
203
deploy_dev.sh
Executable file
@@ -0,0 +1,203 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Document AI Frontend 部署脚本
|
||||
# 功能:构建项目并部署到 ubuntu 服务器
|
||||
|
||||
set -e # 遇到错误立即退出
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 服务器配置
|
||||
ubuntu_HOST="ubuntu"
|
||||
DEPLOY_PATH="/var/www"
|
||||
DEPLOY_NAME="app.cloud"
|
||||
# Sudo 密码(如果需要,建议配置无密码 sudo 更安全)
|
||||
# 配置无密码 sudo: 在服务器上运行: echo "username ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/username
|
||||
SUDO_PASSWORD="1231"
|
||||
|
||||
# 打印带颜色的消息
|
||||
print_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# 检查命令是否存在
|
||||
check_command() {
|
||||
if ! command -v $1 &> /dev/null; then
|
||||
print_error "$1 命令未找到,请先安装"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 部署到服务器
|
||||
deploy_to_server() {
|
||||
local server=$1
|
||||
print_info "开始部署到 ${server}..."
|
||||
|
||||
# 上传构建产物(app.cloud 目录)
|
||||
print_info "上传 ${DEPLOY_NAME} 目录到 ${server}..."
|
||||
scp_output=$(scp -r ${DEPLOY_NAME} ${server}:~ 2>&1)
|
||||
scp_exit_code=$?
|
||||
if [ $scp_exit_code -eq 0 ]; then
|
||||
print_success "文件上传成功"
|
||||
else
|
||||
print_error "文件上传失败,请检查 SSH 连接和权限"
|
||||
echo "$scp_output" | sed 's/^/ /'
|
||||
return 1
|
||||
fi
|
||||
|
||||
# SSH 执行部署操作
|
||||
print_info "在 ${server} 上执行部署操作..."
|
||||
print_info "部署路径: ${DEPLOY_PATH}/${DEPLOY_NAME}"
|
||||
# 注意:密码通过环境变量传递,避免在命令行中暴露
|
||||
ssh_output=$(ssh "${server}" "SSH_SUDO_PASSWORD='${SUDO_PASSWORD}' SSH_DEPLOY_PATH='${DEPLOY_PATH}' SSH_DEPLOY_NAME='${DEPLOY_NAME}' bash -s" << 'SSH_EOF'
|
||||
set -e
|
||||
DEPLOY_PATH="${SSH_DEPLOY_PATH}"
|
||||
DEPLOY_NAME="${SSH_DEPLOY_NAME}"
|
||||
SUDO_PASSWORD="${SSH_SUDO_PASSWORD}"
|
||||
|
||||
# 检查部署目录是否存在
|
||||
if [ ! -d "${DEPLOY_PATH}" ]; then
|
||||
echo "错误:部署目录 ${DEPLOY_PATH} 不存在,请检查路径是否正确"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查是否有权限写入(尝试创建测试文件)
|
||||
if ! touch "${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
|
||||
echo "提示:没有直接写入权限,将使用 sudo 执行操作"
|
||||
USE_SUDO=1
|
||||
else
|
||||
rm -f "${DEPLOY_PATH}/.deploy_test"
|
||||
USE_SUDO=0
|
||||
fi
|
||||
|
||||
# 备份旧版本(如果存在)
|
||||
if [ -d "${DEPLOY_PATH}/${DEPLOY_NAME}" ]; then
|
||||
echo "备份旧版本..."
|
||||
if [ "$USE_SUDO" = "1" ]; then
|
||||
echo "${SUDO_PASSWORD}" | sudo -S rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
|
||||
echo "${SUDO_PASSWORD}" | sudo -S mv "${DEPLOY_PATH}/${DEPLOY_NAME}" "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" || { echo "错误:备份失败,权限不足"; exit 1; }
|
||||
else
|
||||
rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
|
||||
mv "${DEPLOY_PATH}/${DEPLOY_NAME}" "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" || { echo "错误:备份失败"; exit 1; }
|
||||
fi
|
||||
fi
|
||||
|
||||
# 移动新版本到部署目录(覆盖现有目录)
|
||||
if [ -d ~/${DEPLOY_NAME} ]; then
|
||||
echo "移动新版本到部署目录..."
|
||||
if [ "$USE_SUDO" = "1" ]; then
|
||||
echo "${SUDO_PASSWORD}" | sudo -S mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
|
||||
else
|
||||
mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败"; exit 1; }
|
||||
fi
|
||||
echo "部署完成!"
|
||||
else
|
||||
echo "错误:找不到 ~/${DEPLOY_NAME} 目录"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 重新加载 nginx(如果配置了)
|
||||
if command -v nginx &> /dev/null; then
|
||||
echo "重新加载 nginx..."
|
||||
echo "${SUDO_PASSWORD}" | sudo -S nginx -t && echo "${SUDO_PASSWORD}" | sudo -S nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||
fi
|
||||
SSH_EOF
|
||||
)
|
||||
|
||||
ssh_exit_code=$?
|
||||
|
||||
# 显示 SSH 输出
|
||||
if [ -n "$ssh_output" ]; then
|
||||
echo "$ssh_output" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [ $ssh_exit_code -eq 0 ]; then
|
||||
print_success "${server} 部署成功!"
|
||||
else
|
||||
print_error "${server} 部署失败!"
|
||||
print_error "请检查:"
|
||||
print_error " 1. SSH 连接是否正常"
|
||||
print_error " 2. 部署目录 ${DEPLOY_PATH} 是否存在"
|
||||
print_error " 3. 是否有 sudo 权限(如果需要)"
|
||||
print_error " 4. 上传的文件 ~/${DEPLOY_NAME} 是否存在"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
print_info "=========================================="
|
||||
print_info "Document AI Frontend 部署脚本"
|
||||
print_info "=========================================="
|
||||
echo ""
|
||||
|
||||
# 检查必要的命令
|
||||
print_info "检查环境..."
|
||||
check_command "npm"
|
||||
check_command "scp"
|
||||
check_command "ssh"
|
||||
|
||||
# 步骤1: 构建项目
|
||||
print_info "步骤 1/2: 构建项目(测试环境)..."
|
||||
if npm run build:dev; then
|
||||
print_success "构建完成!"
|
||||
else
|
||||
print_error "构建失败!"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 检查 dist 目录是否存在
|
||||
if [ ! -d "dist" ]; then
|
||||
print_error "dist 目录不存在,构建可能失败"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 重命名 dist 为 app.cloud
|
||||
print_info "重命名 dist 为 ${DEPLOY_NAME}..."
|
||||
if [ -d "${DEPLOY_NAME}" ]; then
|
||||
rm -rf "${DEPLOY_NAME}"
|
||||
fi
|
||||
mv dist "${DEPLOY_NAME}"
|
||||
print_success "重命名完成"
|
||||
echo ""
|
||||
|
||||
# 步骤2: 部署到 ubuntu
|
||||
print_info "步骤 2/2: 部署到 ubuntu..."
|
||||
if deploy_to_server ${ubuntu_HOST}; then
|
||||
print_success "ubuntu 部署完成"
|
||||
else
|
||||
print_error "ubuntu 部署失败"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# 完成
|
||||
print_info "清理临时文件..."
|
||||
# 可以选择是否删除本地的 app.cloud 目录
|
||||
# rm -rf ${DEPLOY_NAME}
|
||||
|
||||
print_success "=========================================="
|
||||
print_success "部署完成!"
|
||||
print_success "=========================================="
|
||||
}
|
||||
|
||||
# 运行主函数
|
||||
main
|
||||
@@ -106,8 +106,8 @@ main() {
|
||||
check_command "ssh"
|
||||
|
||||
# 步骤1: 构建项目
|
||||
print_info "步骤 1/2: 构建项目..."
|
||||
if npm run build; then
|
||||
print_info "步骤 1/2: 构建项目(生产环境)..."
|
||||
if npm run build:prod; then
|
||||
print_success "构建完成!"
|
||||
else
|
||||
print_error "构建失败!"
|
||||
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>
|
||||
|
||||
<!-- SEO Meta Tags -->
|
||||
<meta name="description" content="在线公式识别工具,支持印刷体和手写体数学公式识别,快速准确地将图片中的数学公式转换为可编辑文本。" />
|
||||
<!-- 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 - 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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
63
src/App.tsx
63
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,
|
||||
};
|
||||
@@ -287,7 +313,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 +370,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 +417,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 +463,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 +503,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 +549,12 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAuthModal && (
|
||||
<AuthModal
|
||||
onClose={() => setShowAuthModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UserGuide
|
||||
isOpen={showUserGuide}
|
||||
onClose={() => setShowUserGuide(false)}
|
||||
|
||||
@@ -1,41 +1,55 @@
|
||||
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 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('');
|
||||
|
||||
try {
|
||||
const { error } = isSignUp
|
||||
? await signUp(email, password)
|
||||
: await signIn(email, password);
|
||||
if (mode === 'signup') {
|
||||
if (password.length < 6) {
|
||||
setLocalError(t.auth.passwordHint);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
} else {
|
||||
if (password !== confirmPassword) {
|
||||
setLocalError(t.auth.passwordMismatch);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = mode === 'signup' ? await signUp(email, password) : await signIn(email, password);
|
||||
|
||||
if (!result.error) {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError('发生错误,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoogleOAuth = async () => {
|
||||
await beginGoogleOAuth();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -43,36 +57,72 @@ 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
|
||||
type="button"
|
||||
onClick={() => setMode('signin')}
|
||||
aria-pressed={mode === 'signin'}
|
||||
disabled={isBusy}
|
||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
||||
mode === 'signin' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{t.auth.signIn}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('signup')}
|
||||
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">
|
||||
<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"
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{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)}
|
||||
@@ -80,32 +130,72 @@ export default function AuthModal({ onClose }: AuthModalProps) {
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
{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)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={6}
|
||||
disabled={isBusy}
|
||||
/>
|
||||
</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,11 +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;
|
||||
@@ -39,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}\\)`;
|
||||
}
|
||||
},
|
||||
@@ -54,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',
|
||||
@@ -63,10 +85,10 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
getContent: (r) => r.mathml_content
|
||||
},
|
||||
{
|
||||
id: 'mathml_word',
|
||||
label: 'Word MathML',
|
||||
id: 'mathml_mml',
|
||||
label: 'MathML (MML)',
|
||||
category: 'Code',
|
||||
getContent: (r) => r.mathml_word_content
|
||||
getContent: (r) => r.mml
|
||||
},
|
||||
// Image Category
|
||||
{
|
||||
@@ -169,6 +191,15 @@ export default function ExportSidebar({ isOpen, onClose, result }: ExportSidebar
|
||||
};
|
||||
|
||||
const handleAction = async (option: ExportOption) => {
|
||||
// Analytics tracking
|
||||
if (result?.id) {
|
||||
trackExportEvent(
|
||||
result.id,
|
||||
option.id,
|
||||
exportOptions.map(o => o.id)
|
||||
);
|
||||
}
|
||||
|
||||
// Handle DOCX export via API
|
||||
if (option.id === 'docx') {
|
||||
await handleFileExport('docx');
|
||||
@@ -181,21 +212,32 @@ 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);
|
||||
// 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;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to convert MathML to OMML:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) return;
|
||||
|
||||
setExportingId(option.id);
|
||||
|
||||
@@ -224,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);
|
||||
}
|
||||
@@ -237,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}
|
||||
>
|
||||
@@ -164,7 +189,13 @@ export default function LeftSidebar({
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
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
|
||||
|
||||
112
src/components/__tests__/App.test.tsx
Normal file
112
src/components/__tests__/App.test.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import App from '../../App';
|
||||
|
||||
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: () => <div>preview</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();
|
||||
});
|
||||
});
|
||||
103
src/components/__tests__/AuthModal.test.tsx
Normal file
103
src/components/__tests__/AuthModal.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AuthModal from '../AuthModal';
|
||||
|
||||
const useAuthMock = vi.fn();
|
||||
|
||||
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: '两次输入的密码不一致。',
|
||||
oauthRedirecting: '正在跳转 Google...',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AuthModal', () => {
|
||||
it('renders google oauth button', () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
signIn: vi.fn().mockResolvedValue({ error: null }),
|
||||
signUp: vi.fn().mockResolvedValue({ error: null }),
|
||||
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
|
||||
authPhase: 'idle',
|
||||
authError: null,
|
||||
});
|
||||
|
||||
render(<AuthModal onClose={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Google' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables inputs and submit while oauth redirecting', () => {
|
||||
useAuthMock.mockReturnValue({
|
||||
signIn: vi.fn().mockResolvedValue({ error: null }),
|
||||
signUp: vi.fn().mockResolvedValue({ error: null }),
|
||||
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
|
||||
authPhase: 'oauth_redirecting',
|
||||
authError: null,
|
||||
});
|
||||
|
||||
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({
|
||||
signIn: vi.fn().mockResolvedValue({ error: null }),
|
||||
signUp: vi.fn().mockResolvedValue({ error: null }),
|
||||
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
|
||||
authPhase: 'idle',
|
||||
authError: null,
|
||||
});
|
||||
|
||||
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({
|
||||
signIn: vi.fn().mockResolvedValue({ error: null }),
|
||||
signUp: vi.fn().mockResolvedValue({ error: null }),
|
||||
beginGoogleOAuth: vi.fn().mockResolvedValue({ error: null }),
|
||||
authPhase: 'idle',
|
||||
authError: null,
|
||||
});
|
||||
|
||||
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,207 @@
|
||||
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 }>>();
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
// 直接在 useState 初始化函数中同步恢复会话
|
||||
const [user, setUser] = useState<UserInfo | null>(() => {
|
||||
try {
|
||||
const session = authService.restoreSession();
|
||||
return session ? session.user : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const [token, setToken] = useState<string | null>(() => {
|
||||
try {
|
||||
const session = authService.restoreSession();
|
||||
return session ? session.token : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initializing, setInitializing] = useState(false); // 不需要初始化过程了,因为是同步的
|
||||
|
||||
// 不再需要 useEffect 里的 restoreSession
|
||||
|
||||
|
||||
/**
|
||||
* 从错误对象中提取用户友好的错误消息
|
||||
*/
|
||||
const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||
// 检查是否是 ApiError(通过 code 属性判断,避免 instanceof 在热更新时失效)
|
||||
function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const apiError = error as { code: number; message: string };
|
||||
const apiError = error as { code: number; message?: string };
|
||||
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
|
||||
}
|
||||
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 }) {
|
||||
const restoredSession = authService.restoreSession();
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
authReducer,
|
||||
createInitialAuthState(restoredSession ? { user: restoredSession.user, token: restoredSession.token } : null)
|
||||
);
|
||||
|
||||
/**
|
||||
* 登录
|
||||
*/
|
||||
const signIn = useCallback(async (email: string, password: string) => {
|
||||
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) };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const beginGoogleOAuth = useCallback(async () => {
|
||||
dispatch({ type: 'OAUTH_REDIRECT_START' });
|
||||
|
||||
try {
|
||||
const stateToken = createOAuthState();
|
||||
const redirectUri = `${window.location.origin}/auth/google/callback`;
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, stateToken);
|
||||
sessionStorage.setItem(OAUTH_POST_LOGIN_REDIRECT_KEY, window.location.href);
|
||||
|
||||
const { authUrl } = await authService.getGoogleOAuthUrl(redirectUri, stateToken);
|
||||
window.location.assign(authUrl);
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Google 登录失败');
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: message } });
|
||||
return { error: new Error(message) };
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeGoogleOAuth = useCallback(async (params: GoogleOAuthCallbackRequest) => {
|
||||
const requestKey = params.code;
|
||||
const existing = oauthExchangeInFlight.get(requestKey);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_START' });
|
||||
|
||||
try {
|
||||
const expectedState = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
if (!expectedState || expectedState !== params.state) {
|
||||
const invalidStateMessage = 'OAuth state 校验失败';
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: invalidStateMessage } });
|
||||
return { error: new Error(invalidStateMessage) };
|
||||
}
|
||||
|
||||
const result = await authService.exchangeGoogleCode(params);
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_SUCCESS', payload: { user: result.user, token: result.token } });
|
||||
return { error: null };
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error, 'Google 登录失败');
|
||||
dispatch({ type: 'OAUTH_EXCHANGE_FAIL', payload: { error: message } });
|
||||
return { error: new Error(message) };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
oauthExchangeInFlight.delete(requestKey);
|
||||
}
|
||||
})();
|
||||
|
||||
oauthExchangeInFlight.set(requestKey, promise);
|
||||
return promise;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
const signOut = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
authService.logout();
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
dispatch({ type: 'SIGN_OUT' });
|
||||
}, []);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
initializing,
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const syncUserProfile = async () => {
|
||||
if (!state.user || !state.token) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profile = await authService.getUserInfo();
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'UPDATE_USER',
|
||||
payload: {
|
||||
user: mergeUserProfile(state.user, profile),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Keep token-derived identity if profile sync fails.
|
||||
}
|
||||
};
|
||||
|
||||
void syncUserProfile();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [state.token, state.user]);
|
||||
|
||||
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: !!user && !!token,
|
||||
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;
|
||||
}
|
||||
}
|
||||
63
src/lib/analytics.ts
Normal file
63
src/lib/analytics.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import http from './api';
|
||||
|
||||
interface AnalyticsPayload {
|
||||
task_no: string;
|
||||
event_name: string;
|
||||
properties: Record<string, any>;
|
||||
meta_data?: Record<string, any>;
|
||||
device_info: {
|
||||
ip: string;
|
||||
"use-agent": string;
|
||||
browser: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const trackExportEvent = (
|
||||
taskNo: string,
|
||||
selectedOption: string,
|
||||
availableOptions: string[]
|
||||
) => {
|
||||
try {
|
||||
const payload: AnalyticsPayload = {
|
||||
task_no: taskNo,
|
||||
event_name: 'export_selected_event',
|
||||
properties: {
|
||||
option: availableOptions,
|
||||
selected: selectedOption
|
||||
},
|
||||
meta_data: {
|
||||
task_no: taskNo
|
||||
},
|
||||
device_info: {
|
||||
ip: '',
|
||||
"use-agent": navigator.userAgent,
|
||||
browser: getBrowserName()
|
||||
}
|
||||
};
|
||||
|
||||
// Fire and forget - do not await
|
||||
http.post('/analytics/track', payload).catch(err => {
|
||||
// Silently ignore errors to not block business flow
|
||||
console.debug('Analytics tracking failed:', err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug('Analytics error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
function getBrowserName(): string {
|
||||
const userAgent = navigator.userAgent;
|
||||
if (userAgent.match(/chrome|chromium|crios/i)) {
|
||||
return "Chrome";
|
||||
} else if (userAgent.match(/firefox|fxios/i)) {
|
||||
return "Firefox";
|
||||
} else if (userAgent.match(/safari/i)) {
|
||||
return "Safari";
|
||||
} else if (userAgent.match(/opr\//i)) {
|
||||
return "Opera";
|
||||
} else if (userAgent.match(/edg/i)) {
|
||||
return "Edge";
|
||||
} else {
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
@@ -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,15 @@ 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.',
|
||||
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 +79,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 +165,15 @@ export const translations = {
|
||||
genericError: '发生错误,请重试',
|
||||
hasAccount: '已有账号?去登录',
|
||||
noAccount: '没有账号?去注册',
|
||||
continueWithGoogle: 'Google',
|
||||
emailHint: '仅用于登录和同步记录。',
|
||||
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
|
||||
confirmPassword: '确认密码',
|
||||
passwordMismatch: '两次输入的密码不一致。',
|
||||
oauthRedirecting: '正在跳转 Google...',
|
||||
oauthExchanging: '正在完成 Google 登录...',
|
||||
invalidOAuthState: 'OAuth 状态校验失败,请重试',
|
||||
oauthFailed: 'Google 登录失败,请重试',
|
||||
},
|
||||
export: {
|
||||
title: '导出',
|
||||
@@ -164,6 +184,8 @@ export const translations = {
|
||||
},
|
||||
failed: '导出失败,请重试',
|
||||
imageFailed: '生成图片失败',
|
||||
noContent: '混合文字内容不支持 LaTeX/MathML 导出,请下载 DOCX 文件。',
|
||||
noContentShort: '混合内容不支持',
|
||||
},
|
||||
guide: {
|
||||
next: '下一步',
|
||||
|
||||
36
src/main.tsx
36
src/main.tsx
@@ -1,16 +1,44 @@
|
||||
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';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
// 错误处理:捕获未处理的错误
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('Global error:', event.error);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
try {
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<LanguageProvider>
|
||||
<App />
|
||||
<AppRouter />
|
||||
</LanguageProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to render app:', error);
|
||||
rootElement.innerHTML = `
|
||||
<div style="padding: 20px; font-family: sans-serif;">
|
||||
<h1>应用启动失败</h1>
|
||||
<p>错误信息: ${error instanceof Error ? error.message : String(error)}</p>
|
||||
<p>请检查浏览器控制台获取更多信息。</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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'
|
||||
|
||||
12
vitest.config.ts
Normal file
12
vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
globals: true,
|
||||
exclude: ['e2e/**', 'node_modules/**', 'dist/**'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user