Compare commits
1 Commits
main
...
feature/se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e90fca5ab1 |
@@ -16,9 +16,6 @@ NC='\033[0m' # No Color
|
|||||||
ubuntu_HOST="ubuntu"
|
ubuntu_HOST="ubuntu"
|
||||||
DEPLOY_PATH="/var/www"
|
DEPLOY_PATH="/var/www"
|
||||||
DEPLOY_NAME="app.cloud"
|
DEPLOY_NAME="app.cloud"
|
||||||
# Sudo 密码(如果需要,建议配置无密码 sudo 更安全)
|
|
||||||
# 配置无密码 sudo: 在服务器上运行: echo "username ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/username
|
|
||||||
SUDO_PASSWORD="1231"
|
|
||||||
|
|
||||||
# 打印带颜色的消息
|
# 打印带颜色的消息
|
||||||
print_info() {
|
print_info() {
|
||||||
@@ -62,61 +59,63 @@ deploy_to_server() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# SSH 执行部署操作
|
# SSH 执行部署操作(非交互模式)
|
||||||
print_info "在 ${server} 上执行部署操作..."
|
print_info "在 ${server} 上执行部署操作..."
|
||||||
print_info "部署路径: ${DEPLOY_PATH}/${DEPLOY_NAME}"
|
print_info "部署路径: ${DEPLOY_PATH}/${DEPLOY_NAME}"
|
||||||
# 注意:密码通过环境变量传递,避免在命令行中暴露
|
ssh_output=$(ssh ${server} bash << SSH_EOF
|
||||||
ssh_output=$(ssh "${server}" "SSH_SUDO_PASSWORD='${SUDO_PASSWORD}' SSH_DEPLOY_PATH='${DEPLOY_PATH}' SSH_DEPLOY_NAME='${DEPLOY_NAME}' bash -s" << 'SSH_EOF'
|
|
||||||
set -e
|
set -e
|
||||||
DEPLOY_PATH="${SSH_DEPLOY_PATH}"
|
DEPLOY_PATH="${DEPLOY_PATH}"
|
||||||
DEPLOY_NAME="${SSH_DEPLOY_NAME}"
|
DEPLOY_NAME="${DEPLOY_NAME}"
|
||||||
SUDO_PASSWORD="${SSH_SUDO_PASSWORD}"
|
|
||||||
|
|
||||||
# 检查部署目录是否存在
|
# 检查部署目录是否存在
|
||||||
if [ ! -d "${DEPLOY_PATH}" ]; then
|
if [ ! -d "\${DEPLOY_PATH}" ]; then
|
||||||
echo "错误:部署目录 ${DEPLOY_PATH} 不存在,请检查路径是否正确"
|
echo "错误:部署目录 \${DEPLOY_PATH} 不存在,请检查路径是否正确"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 检查是否有权限写入(尝试创建测试文件)
|
# 检查是否有权限写入,若无则尝试免密 sudo(sudo -n)
|
||||||
if ! touch "${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
|
SUDO_CMD=""
|
||||||
echo "提示:没有直接写入权限,将使用 sudo 执行操作"
|
if ! touch "\${DEPLOY_PATH}/.deploy_test" 2>/dev/null; then
|
||||||
USE_SUDO=1
|
if sudo -n true 2>/dev/null; then
|
||||||
|
echo "提示:没有直接写入权限,使用 sudo -n 执行部署操作"
|
||||||
|
SUDO_CMD="sudo -n"
|
||||||
|
else
|
||||||
|
echo "错误:没有写入权限,且 sudo 需要密码(非交互部署无法输入)"
|
||||||
|
echo "请执行以下任一方案后重试:"
|
||||||
|
echo " 1) 将部署目录改为当前用户可写目录(例如 /home/\$USER/www)"
|
||||||
|
echo " 2) 为当前用户配置免密 sudo(NOPASSWD)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
rm -f "${DEPLOY_PATH}/.deploy_test"
|
rm -f "\${DEPLOY_PATH}/.deploy_test"
|
||||||
USE_SUDO=0
|
echo "提示:检测到部署目录可直接写入"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 备份旧版本(如果存在)
|
# 备份旧版本(如果存在)
|
||||||
if [ -d "${DEPLOY_PATH}/${DEPLOY_NAME}" ]; then
|
if [ -d "\${DEPLOY_PATH}/\${DEPLOY_NAME}" ]; then
|
||||||
echo "备份旧版本..."
|
echo "备份旧版本..."
|
||||||
if [ "$USE_SUDO" = "1" ]; then
|
\$SUDO_CMD rm -rf "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" 2>/dev/null || true
|
||||||
echo "${SUDO_PASSWORD}" | sudo -S rm -rf "${DEPLOY_PATH}/${DEPLOY_NAME}_bak" 2>/dev/null || true
|
\$SUDO_CMD mv "\${DEPLOY_PATH}/\${DEPLOY_NAME}" "\${DEPLOY_PATH}/\${DEPLOY_NAME}_bak" || { echo "错误:备份失败,权限不足"; exit 1; }
|
||||||
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
|
fi
|
||||||
|
|
||||||
# 移动新版本到部署目录(覆盖现有目录)
|
# 移动新版本到部署目录(覆盖现有目录)
|
||||||
if [ -d ~/${DEPLOY_NAME} ]; then
|
if [ -d ~/\${DEPLOY_NAME} ]; then
|
||||||
echo "移动新版本到部署目录..."
|
echo "移动新版本到部署目录..."
|
||||||
if [ "$USE_SUDO" = "1" ]; then
|
\$SUDO_CMD mv ~/\${DEPLOY_NAME} "\${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
|
||||||
echo "${SUDO_PASSWORD}" | sudo -S mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败,权限不足"; exit 1; }
|
|
||||||
else
|
|
||||||
mv ~/${DEPLOY_NAME} "${DEPLOY_PATH}/" || { echo "错误:移动文件失败"; exit 1; }
|
|
||||||
fi
|
|
||||||
echo "部署完成!"
|
echo "部署完成!"
|
||||||
else
|
else
|
||||||
echo "错误:找不到 ~/${DEPLOY_NAME} 目录"
|
echo "错误:找不到 ~/\${DEPLOY_NAME} 目录"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 重新加载 nginx(如果配置了)
|
# 重新加载 nginx(如果配置了)
|
||||||
if command -v nginx &> /dev/null; then
|
if command -v nginx &> /dev/null; then
|
||||||
echo "重新加载 nginx..."
|
echo "重新加载 nginx..."
|
||||||
echo "${SUDO_PASSWORD}" | sudo -S nginx -t && echo "${SUDO_PASSWORD}" | sudo -S nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
if [ -n "\$SUDO_CMD" ]; then
|
||||||
|
\$SUDO_CMD nginx -t && \$SUDO_CMD nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||||
|
else
|
||||||
|
nginx -t && nginx -s reload || echo "警告:nginx 重新加载失败,请手动检查"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
SSH_EOF
|
SSH_EOF
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
35
index.html
35
index.html
@@ -5,10 +5,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="canonical" href="https://texpixel.com/" />
|
||||||
<!-- Multi-language Support -->
|
|
||||||
<link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/" />
|
<link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/" />
|
||||||
<link rel="alternate" hreflang="en" href="https://texpixel.com/en" />
|
<link rel="alternate" hreflang="en" href="https://texpixel.com/en/" />
|
||||||
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
|
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
|
||||||
|
|
||||||
<!-- Dynamic Title (will be updated by app) -->
|
<!-- Dynamic Title (will be updated by app) -->
|
||||||
@@ -41,6 +40,34 @@
|
|||||||
<!-- Baidu Verification -->
|
<!-- Baidu Verification -->
|
||||||
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
|
<meta name="baidu-site-verification" content="codeva-8zU93DeGgH" />
|
||||||
|
|
||||||
|
<!-- Structured Data -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "TexPixel",
|
||||||
|
"url": "https://texpixel.com/",
|
||||||
|
"inLanguage": ["zh-CN", "en"],
|
||||||
|
"description": "Formula recognition tool for converting images and PDFs into LaTeX, MathML, and Markdown."
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "TexPixel",
|
||||||
|
"applicationCategory": "BusinessApplication",
|
||||||
|
"operatingSystem": "Web",
|
||||||
|
"url": "https://texpixel.com/",
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "USD"
|
||||||
|
},
|
||||||
|
"description": "Online OCR and formula recognition for printed and handwritten mathematical expressions."
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<!-- Language Detection Script -->
|
<!-- Language Detection Script -->
|
||||||
<script>
|
<script>
|
||||||
// Update HTML lang attribute based on user preference or browser language
|
// Update HTML lang attribute based on user preference or browser language
|
||||||
@@ -64,4 +91,4 @@
|
|||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
2292
package-lock.json
generated
2292
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -9,9 +9,6 @@
|
|||||||
"build:dev": "VITE_ENV=development vite build",
|
"build:dev": "VITE_ENV=development vite build",
|
||||||
"build:prod": "VITE_ENV=production vite build",
|
"build:prod": "VITE_ENV=production vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "vitest run",
|
|
||||||
"test:watch": "vitest",
|
|
||||||
"test:e2e": "playwright test",
|
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit -p tsconfig.app.json"
|
"typecheck": "tsc --noEmit -p tsconfig.app.json"
|
||||||
},
|
},
|
||||||
@@ -28,7 +25,6 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.13.1",
|
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
@@ -37,11 +33,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.9.1",
|
"@eslint/js": "^9.9.1",
|
||||||
"@playwright/test": "^1.58.2",
|
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
|
||||||
"@testing-library/react": "^16.3.2",
|
|
||||||
"@testing-library/user-event": "^14.6.1",
|
|
||||||
"@types/react": "^18.3.5",
|
"@types/react": "^18.3.5",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
@@ -50,12 +42,10 @@
|
|||||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.11",
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.9.0",
|
||||||
"jsdom": "^28.1.0",
|
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.5.3",
|
||||||
"typescript-eslint": "^8.3.0",
|
"typescript-eslint": "^8.3.0",
|
||||||
"vite": "^5.4.2",
|
"vite": "^5.4.2"
|
||||||
"vitest": "^4.0.18"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
167
public/en/index.html
Normal file
167
public/en/index.html
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>TexPixel - Formula Recognition Tool for LaTeX, MathML, and Markdown</title>
|
||||||
|
<meta name="description" content="TexPixel converts printed and handwritten math formulas from images and PDFs into LaTeX, MathML, and Markdown." />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
<meta name="author" content="TexPixel Team" />
|
||||||
|
<link rel="canonical" href="https://texpixel.com/en/" />
|
||||||
|
<link rel="alternate" hreflang="zh-CN" href="https://texpixel.com/" />
|
||||||
|
<link rel="alternate" hreflang="en" href="https://texpixel.com/en/" />
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://texpixel.com/" />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="TexPixel" />
|
||||||
|
<meta property="og:locale" content="en_US" />
|
||||||
|
<meta property="og:locale:alternate" content="zh_CN" />
|
||||||
|
<meta property="og:url" content="https://texpixel.com/en/" />
|
||||||
|
<meta property="og:title" content="TexPixel - Formula Recognition Tool" />
|
||||||
|
<meta property="og:description" content="Extract formulas from images and PDFs to editable LaTeX, MathML, and Markdown." />
|
||||||
|
<meta property="og:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:site" content="@TexPixel" />
|
||||||
|
<meta name="twitter:title" content="TexPixel - Formula Recognition Tool" />
|
||||||
|
<meta name="twitter:description" content="Convert mathematical content from images and PDFs into LaTeX, MathML, and Markdown." />
|
||||||
|
<meta name="twitter:image" content="https://cdn.texpixel.com/public/logo.png?x-oss-process=image/resize,w_600" />
|
||||||
|
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "TexPixel English",
|
||||||
|
"url": "https://texpixel.com/en/",
|
||||||
|
"inLanguage": "en",
|
||||||
|
"description": "Formula recognition landing page in English."
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": [
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "What can TexPixel recognize?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "TexPixel recognizes printed and handwritten mathematical formulas from images and PDF pages."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Which output formats are supported?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "TexPixel supports LaTeX, MathML, and Markdown outputs for downstream editing and publishing."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Do I need to install software?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "No installation is required. TexPixel works as a web application."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f7f8fc;
|
||||||
|
--text: #111827;
|
||||||
|
--muted: #4b5563;
|
||||||
|
--accent: #0f766e;
|
||||||
|
--accent-hover: #0d5f59;
|
||||||
|
--card: #ffffff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background: radial-gradient(circle at 20% 20%, #eefbf8 0%, var(--bg) 40%, #f6f8ff 100%);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.wrap {
|
||||||
|
max-width: 980px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 48px 20px 64px;
|
||||||
|
}
|
||||||
|
.hero, .section {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 28px;
|
||||||
|
box-shadow: 0 10px 30px rgba(17, 24, 39, 0.06);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: clamp(28px, 4vw, 42px);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
p { margin: 0 0 14px; color: var(--muted); }
|
||||||
|
ul { margin: 0; padding-left: 20px; color: var(--muted); }
|
||||||
|
li { margin-bottom: 8px; }
|
||||||
|
.cta {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.cta:hover { background: var(--accent-hover); }
|
||||||
|
.small { font-size: 14px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="wrap">
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Formula Recognition for Real Math Workflows</h1>
|
||||||
|
<p>TexPixel converts formulas from screenshots, photos, and PDF pages into editable text formats for researchers, students, and engineering teams.</p>
|
||||||
|
<a class="cta" id="open-app" href="/">Open TexPixel App</a>
|
||||||
|
<p class="small">The app opens at the main product URL and defaults to English for this entry point.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>Core Capabilities</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Recognize printed and handwritten formulas from image or PDF input.</li>
|
||||||
|
<li>Export to LaTeX for papers, MathML for web workflows, and Markdown for docs.</li>
|
||||||
|
<li>Use the browser-based workflow without local software installation.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<h2>FAQ</h2>
|
||||||
|
<p><strong>Is TexPixel browser-based?</strong><br />Yes. You can upload files and get output directly in the web app.</p>
|
||||||
|
<p><strong>What content type works best?</strong><br />Clean scans and high-contrast screenshots improve recognition quality.</p>
|
||||||
|
<p><strong>Can I reuse output in technical documents?</strong><br />Yes. LaTeX and Markdown outputs are intended for editing and reuse.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var openApp = document.getElementById('open-app');
|
||||||
|
if (!openApp) return;
|
||||||
|
openApp.addEventListener('click', function () {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('language', 'en');
|
||||||
|
} catch (err) {
|
||||||
|
// Keep navigation working even if storage is unavailable.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
public/llms.txt
Normal file
14
public/llms.txt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# TexPixel
|
||||||
|
|
||||||
|
TexPixel is a web tool for converting images and PDFs into editable mathematical formats such as LaTeX, MathML, and Markdown.
|
||||||
|
|
||||||
|
Canonical URL: https://texpixel.com/
|
||||||
|
Primary languages: zh-CN, en
|
||||||
|
|
||||||
|
## Preferred page for citation
|
||||||
|
- https://texpixel.com/
|
||||||
|
- https://texpixel.com/en/
|
||||||
|
|
||||||
|
## Crawl guidance
|
||||||
|
- Public product content is allowed for indexing.
|
||||||
|
- Avoid API and authenticated/private areas.
|
||||||
7
public/robots.txt
Normal file
7
public/robots.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Disallow: /api/
|
||||||
|
Disallow: /auth/
|
||||||
|
Disallow: /admin/
|
||||||
|
|
||||||
|
Sitemap: https://texpixel.com/sitemap.xml
|
||||||
15
public/sitemap.xml
Normal file
15
public/sitemap.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://texpixel.com/</loc>
|
||||||
|
<lastmod>2026-02-25</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://texpixel.com/en/</loc>
|
||||||
|
<lastmod>2026-02-25</lastmod>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>0.9</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
73
src/App.tsx
73
src/App.tsx
@@ -10,11 +10,8 @@ import FilePreview from './components/FilePreview';
|
|||||||
import ResultPanel from './components/ResultPanel';
|
import ResultPanel from './components/ResultPanel';
|
||||||
import UploadModal from './components/UploadModal';
|
import UploadModal from './components/UploadModal';
|
||||||
import UserGuide from './components/UserGuide';
|
import UserGuide from './components/UserGuide';
|
||||||
import AuthModal from './components/AuthModal';
|
|
||||||
|
|
||||||
const PAGE_SIZE = 6;
|
const PAGE_SIZE = 6;
|
||||||
const GUEST_USAGE_LIMIT = 3;
|
|
||||||
const GUEST_USAGE_COUNT_KEY = 'texpixel_guest_usage_count';
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { user, initializing } = useAuth();
|
const { user, initializing } = useAuth();
|
||||||
@@ -24,13 +21,7 @@ function App() {
|
|||||||
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
|
const [selectedResult, setSelectedResult] = useState<RecognitionResult | null>(null);
|
||||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
const [showUserGuide, setShowUserGuide] = useState(false);
|
const [showUserGuide, setShowUserGuide] = useState(false);
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [guestUsageCount, setGuestUsageCount] = useState<number>(() => {
|
|
||||||
const storedCount = localStorage.getItem(GUEST_USAGE_COUNT_KEY);
|
|
||||||
const parsedCount = storedCount ? Number.parseInt(storedCount, 10) : 0;
|
|
||||||
return Number.isFinite(parsedCount) ? parsedCount : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pagination state
|
// Pagination state
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -53,19 +44,6 @@ function App() {
|
|||||||
const hasLoadedFiles = useRef(false);
|
const hasLoadedFiles = useRef(false);
|
||||||
|
|
||||||
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
|
const selectedFile = files.find((f) => f.id === selectedFileId) || null;
|
||||||
const canUploadAnonymously = !user && guestUsageCount < GUEST_USAGE_LIMIT;
|
|
||||||
|
|
||||||
const openAuthModal = useCallback(() => {
|
|
||||||
setShowAuthModal(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const incrementGuestUsage = useCallback(() => {
|
|
||||||
setGuestUsageCount((prev) => {
|
|
||||||
const nextCount = prev + 1;
|
|
||||||
localStorage.setItem(GUEST_USAGE_COUNT_KEY, String(nextCount));
|
|
||||||
return nextCount;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleStartGuide = () => setShowUserGuide(true);
|
const handleStartGuide = () => setShowUserGuide(true);
|
||||||
@@ -119,10 +97,6 @@ function App() {
|
|||||||
const handlePaste = (e: ClipboardEvent) => {
|
const handlePaste = (e: ClipboardEvent) => {
|
||||||
// If modal is open, let the modal handle paste events to avoid double upload
|
// If modal is open, let the modal handle paste events to avoid double upload
|
||||||
if (showUploadModal) return;
|
if (showUploadModal) return;
|
||||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
|
||||||
openAuthModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
if (!items) return;
|
if (!items) return;
|
||||||
@@ -142,7 +116,7 @@ function App() {
|
|||||||
|
|
||||||
document.addEventListener('paste', handlePaste);
|
document.addEventListener('paste', handlePaste);
|
||||||
return () => document.removeEventListener('paste', handlePaste);
|
return () => document.removeEventListener('paste', handlePaste);
|
||||||
}, [guestUsageCount, openAuthModal, showUploadModal, user]);
|
}, [user, showUploadModal]);
|
||||||
|
|
||||||
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
|
const startResizing = useCallback((mouseDownEvent: React.MouseEvent) => {
|
||||||
mouseDownEvent.preventDefault();
|
mouseDownEvent.preventDefault();
|
||||||
@@ -239,6 +213,10 @@ function App() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-select first file if none selected
|
||||||
|
if (!selectedFileId) {
|
||||||
|
setSelectedFileId(fileRecords[0].id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setFiles([]);
|
setFiles([]);
|
||||||
}
|
}
|
||||||
@@ -413,15 +391,8 @@ function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpload = async (uploadFiles: File[]) => {
|
const handleUpload = async (uploadFiles: File[]) => {
|
||||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
|
||||||
openAuthModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
let successfulUploads = 0;
|
|
||||||
|
|
||||||
for (const file of uploadFiles) {
|
for (const file of uploadFiles) {
|
||||||
// 1. Upload file to OSS (or check duplicate)
|
// 1. Upload file to OSS (or check duplicate)
|
||||||
const fileHash = await uploadService.calculateMD5(file);
|
const fileHash = await uploadService.calculateMD5(file);
|
||||||
@@ -459,12 +430,6 @@ function App() {
|
|||||||
if (taskData.task_no) {
|
if (taskData.task_no) {
|
||||||
startPolling(taskData.task_no, fileId);
|
startPolling(taskData.task_no, fileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
successfulUploads += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user && successfulUploads > 0) {
|
|
||||||
incrementGuestUsage();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error uploading files:', error);
|
console.error('Error uploading files:', error);
|
||||||
@@ -499,15 +464,7 @@ function App() {
|
|||||||
files={files}
|
files={files}
|
||||||
selectedFileId={selectedFileId}
|
selectedFileId={selectedFileId}
|
||||||
onFileSelect={setSelectedFileId}
|
onFileSelect={setSelectedFileId}
|
||||||
onUploadClick={() => {
|
onUploadClick={() => setShowUploadModal(true)}
|
||||||
if (!user && guestUsageCount >= GUEST_USAGE_LIMIT) {
|
|
||||||
openAuthModal();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setShowUploadModal(true);
|
|
||||||
}}
|
|
||||||
canUploadAnonymously={canUploadAnonymously}
|
|
||||||
onRequireAuth={openAuthModal}
|
|
||||||
isCollapsed={sidebarCollapsed}
|
isCollapsed={sidebarCollapsed}
|
||||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
onUploadFiles={handleUpload}
|
onUploadFiles={handleUpload}
|
||||||
@@ -545,12 +502,6 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showAuthModal && (
|
|
||||||
<AuthModal
|
|
||||||
onClose={() => setShowAuthModal(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<UserGuide
|
<UserGuide
|
||||||
isOpen={showUserGuide}
|
isOpen={showUserGuide}
|
||||||
onClose={() => setShowUserGuide(false)}
|
onClose={() => setShowUserGuide(false)}
|
||||||
@@ -565,6 +516,18 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ICP Footer */}
|
||||||
|
<div className="flex-shrink-0 bg-white border-t border-gray-200 py-2 px-4 text-center">
|
||||||
|
<a
|
||||||
|
href="https://beian.miit.gov.cn"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
京ICP备2025152973号
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,73 +1,41 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
interface AuthModalProps {
|
interface AuthModalProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
mandatory?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthModal({ onClose, mandatory = false }: AuthModalProps) {
|
export default function AuthModal({ onClose }: AuthModalProps) {
|
||||||
const { signIn, signUp, beginGoogleOAuth, authPhase, authError } = useAuth();
|
const { signIn, signUp } = useAuth();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const [isSignUp, setIsSignUp] = useState(false);
|
||||||
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [loading, setLoading] = useState(false);
|
||||||
const [localError, setLocalError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [fieldErrors, setFieldErrors] = useState<{ email?: string; password?: string; confirmPassword?: string }>({});
|
|
||||||
|
|
||||||
const isBusy = useMemo(
|
|
||||||
() => ['email_signing_in', 'email_signing_up', 'oauth_redirecting', 'oauth_exchanging'].includes(authPhase),
|
|
||||||
[authPhase]
|
|
||||||
);
|
|
||||||
|
|
||||||
const submitText = mode === 'signup' ? t.auth.signUp : t.auth.signIn;
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLocalError('');
|
setError('');
|
||||||
const nextFieldErrors: { email?: string; password?: string; confirmPassword?: string } = {};
|
setLoading(true);
|
||||||
const normalizedEmail = email.trim();
|
|
||||||
|
|
||||||
if (!normalizedEmail) {
|
try {
|
||||||
nextFieldErrors.email = t.auth.emailRequired;
|
const { error } = isSignUp
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail)) {
|
? await signUp(email, password)
|
||||||
nextFieldErrors.email = t.auth.emailInvalid;
|
: await signIn(email, password);
|
||||||
}
|
|
||||||
|
|
||||||
if (!password) {
|
if (error) {
|
||||||
nextFieldErrors.password = t.auth.passwordRequired;
|
setError(error.message);
|
||||||
}
|
} else {
|
||||||
|
onClose();
|
||||||
if (mode === 'signup') {
|
|
||||||
if (password && password.length < 6) {
|
|
||||||
nextFieldErrors.password = t.auth.passwordHint;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirmPassword) {
|
|
||||||
nextFieldErrors.confirmPassword = t.auth.passwordRequired;
|
|
||||||
} else if (password !== confirmPassword) {
|
|
||||||
nextFieldErrors.confirmPassword = t.auth.passwordMismatch;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('发生错误,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFieldErrors(nextFieldErrors);
|
|
||||||
if (Object.keys(nextFieldErrors).length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = mode === 'signup' ? await signUp(normalizedEmail, password) : await signIn(normalizedEmail, password);
|
|
||||||
|
|
||||||
if (!result.error) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoogleOAuth = async () => {
|
|
||||||
await beginGoogleOAuth();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -75,177 +43,69 @@ export default function AuthModal({ onClose, mandatory = false }: AuthModalProps
|
|||||||
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-2xl font-bold text-gray-900">
|
<h2 className="text-2xl font-bold text-gray-900">
|
||||||
{mode === 'signup' ? t.auth.signUpTitle : t.auth.signInTitle}
|
{isSignUp ? t.auth.signUpTitle : t.auth.signInTitle}
|
||||||
</h2>
|
</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
|
<button
|
||||||
type="button"
|
onClick={onClose}
|
||||||
onClick={() => {
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
setMode('signin');
|
|
||||||
setFieldErrors({});
|
|
||||||
setLocalError('');
|
|
||||||
}}
|
|
||||||
aria-pressed={mode === 'signin'}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
mode === 'signin' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t.auth.signIn}
|
<X size={20} />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setMode('signup');
|
|
||||||
setFieldErrors({});
|
|
||||||
setLocalError('');
|
|
||||||
}}
|
|
||||||
aria-pressed={mode === 'signup'}
|
|
||||||
disabled={isBusy}
|
|
||||||
className={`rounded-md px-3 py-2 text-sm font-medium transition-colors ${
|
|
||||||
mode === 'signup' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-600 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.auth.signUp}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="auth-email" className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{t.auth.email}
|
{t.auth.email}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="auth-email"
|
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => {
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
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"
|
||||||
if (fieldErrors.email) {
|
|
||||||
setFieldErrors((prev) => ({ ...prev, email: undefined }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
|
||||||
fieldErrors.email ? 'border-red-400' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
required
|
required
|
||||||
disabled={isBusy}
|
|
||||||
/>
|
/>
|
||||||
{fieldErrors.email && <p className="mt-1 text-xs text-red-600">{fieldErrors.email}</p>}
|
|
||||||
{mode === 'signup' && (
|
|
||||||
<p className="mt-1 text-xs text-gray-500">{t.auth.emailHint}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="auth-password" className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
{t.auth.password}
|
{t.auth.password}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="auth-password"
|
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => {
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
setPassword(e.target.value);
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
if (fieldErrors.password) {
|
|
||||||
setFieldErrors((prev) => ({ ...prev, password: undefined }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
|
||||||
fieldErrors.password ? 'border-red-400' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={6}
|
||||||
disabled={isBusy}
|
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password && <p className="mt-1 text-xs text-red-600">{fieldErrors.password}</p>}
|
|
||||||
{mode === 'signup' && (
|
|
||||||
<p className="mt-1 text-xs text-gray-500">{t.auth.passwordHint}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'signup' && (
|
{error && (
|
||||||
<div>
|
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded-lg text-sm font-medium animate-pulse">
|
||||||
<label htmlFor="auth-password-confirm" className="block text-sm font-medium text-gray-700 mb-1">
|
{t.auth.error}: {error}
|
||||||
{t.auth.confirmPassword}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="auth-password-confirm"
|
|
||||||
type="password"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => {
|
|
||||||
setConfirmPassword(e.target.value);
|
|
||||||
if (fieldErrors.confirmPassword) {
|
|
||||||
setFieldErrors((prev) => ({ ...prev, confirmPassword: undefined }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent ${
|
|
||||||
fieldErrors.confirmPassword ? 'border-red-400' : 'border-gray-300'
|
|
||||||
}`}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
minLength={6}
|
|
||||||
disabled={isBusy}
|
|
||||||
/>
|
|
||||||
{fieldErrors.confirmPassword && <p className="mt-1 text-xs text-red-600">{fieldErrors.confirmPassword}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(localError || authError) && (
|
|
||||||
<div className="p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm font-medium">
|
|
||||||
{t.auth.error}: {localError || authError}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isBusy}
|
disabled={loading}
|
||||||
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait"
|
className="w-full py-3 px-4 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait"
|
||||||
>
|
>
|
||||||
{submitText}
|
{isSignUp ? t.auth.signUp : t.auth.signIn}
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="relative py-1">
|
|
||||||
<div className="absolute inset-0 flex items-center" aria-hidden="true">
|
|
||||||
<div className="w-full border-t border-gray-200" />
|
|
||||||
</div>
|
|
||||||
<div className="relative flex justify-center text-xs">
|
|
||||||
<span className="bg-white px-2 text-gray-400">OR</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleGoogleOAuth}
|
|
||||||
disabled={isBusy}
|
|
||||||
className="w-full py-3 px-4 border border-gray-300 text-gray-800 rounded-lg hover:bg-gray-50 transition-colors font-medium disabled:opacity-80 disabled:cursor-wait inline-flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src="https://upload.wikimedia.org/wikipedia/commons/7/7e/Gmail_icon_%282020%29.svg"
|
|
||||||
alt=""
|
|
||||||
aria-hidden="true"
|
|
||||||
className="w-[18px] h-[18px]"
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
/>
|
|
||||||
{authPhase === 'oauth_redirecting' ? t.auth.oauthRedirecting : t.auth.continueWithGoogle}
|
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsSignUp(!isSignUp)}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
{isSignUp ? t.auth.hasAccount : t.auth.noAccount}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
import { Upload, LogIn, LogOut, FileText, Clock, ChevronLeft, ChevronRight, Settings, History, MousePointerClick, FileUp, ClipboardPaste, Loader2 } from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { FileRecord } from '../types';
|
import { FileRecord } from '../types';
|
||||||
@@ -10,8 +10,6 @@ interface LeftSidebarProps {
|
|||||||
selectedFileId: string | null;
|
selectedFileId: string | null;
|
||||||
onFileSelect: (fileId: string) => void;
|
onFileSelect: (fileId: string) => void;
|
||||||
onUploadClick: () => void;
|
onUploadClick: () => void;
|
||||||
canUploadAnonymously: boolean;
|
|
||||||
onRequireAuth: () => void;
|
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
onUploadFiles: (files: File[]) => void;
|
onUploadFiles: (files: File[]) => void;
|
||||||
@@ -25,8 +23,6 @@ export default function LeftSidebar({
|
|||||||
selectedFileId,
|
selectedFileId,
|
||||||
onFileSelect,
|
onFileSelect,
|
||||||
onUploadClick,
|
onUploadClick,
|
||||||
canUploadAnonymously,
|
|
||||||
onRequireAuth,
|
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onUploadFiles,
|
onUploadFiles,
|
||||||
@@ -34,19 +30,12 @@ export default function LeftSidebar({
|
|||||||
loadingMore,
|
loadingMore,
|
||||||
onLoadMore,
|
onLoadMore,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
const { user, signOut, isAuthenticated } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [showAuthModal, setShowAuthModal] = useState(false);
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const displayName = user?.username?.trim() || user?.email || '';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
setShowAuthModal(false);
|
|
||||||
}
|
|
||||||
}, [isAuthenticated]);
|
|
||||||
|
|
||||||
// ... (rest of the logic remains the same)
|
// ... (rest of the logic remains the same)
|
||||||
// Handle scroll to load more
|
// Handle scroll to load more
|
||||||
@@ -95,10 +84,6 @@ export default function LeftSidebar({
|
|||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!user && !canUploadAnonymously) {
|
|
||||||
onRequireAuth();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||||
@@ -112,10 +97,6 @@ export default function LeftSidebar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!user && !canUploadAnonymously) {
|
|
||||||
onRequireAuth();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
onUploadFiles(Array.from(e.target.files));
|
onUploadFiles(Array.from(e.target.files));
|
||||||
}
|
}
|
||||||
@@ -136,13 +117,7 @@ export default function LeftSidebar({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={onUploadClick}
|
||||||
if (!user && !canUploadAnonymously) {
|
|
||||||
onRequireAuth();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onUploadClick();
|
|
||||||
}}
|
|
||||||
className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20 transition-all mb-6"
|
className="p-3 rounded-xl bg-blue-600 text-white hover:bg-blue-700 shadow-lg shadow-blue-600/20 transition-all mb-6"
|
||||||
title={t.common.upload}
|
title={t.common.upload}
|
||||||
>
|
>
|
||||||
@@ -187,15 +162,9 @@ export default function LeftSidebar({
|
|||||||
<div className="mb-2" id="sidebar-upload-area">
|
<div className="mb-2" id="sidebar-upload-area">
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onClick={() => {
|
onClick={() => fileInputRef.current?.click()}
|
||||||
if (!user && !canUploadAnonymously) {
|
|
||||||
onRequireAuth();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
}}
|
|
||||||
className={`
|
className={`
|
||||||
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group
|
border-2 border-dashed rounded-xl p-6 text-center cursor-pointer transition-all duration-200 group
|
||||||
${isDragging
|
${isDragging
|
||||||
@@ -315,7 +284,7 @@ export default function LeftSidebar({
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">{displayName}</p>
|
<p className="text-sm font-medium text-gray-900 truncate">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => signOut()}
|
onClick={() => signOut()}
|
||||||
|
|||||||
@@ -91,9 +91,11 @@ export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClos
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const backdropClipPath =
|
return (
|
||||||
highlightStyle.top !== undefined
|
<div className="fixed inset-0 z-[100] pointer-events-none">
|
||||||
? `polygon(
|
{/* Backdrop with hole */}
|
||||||
|
<div className="absolute inset-0 bg-black/60 pointer-events-auto" onClick={onClose} style={{
|
||||||
|
clipPath: highlightStyle.top !== undefined ? `polygon(
|
||||||
0% 0%, 0% 100%,
|
0% 0%, 0% 100%,
|
||||||
${highlightStyle.left}px 100%,
|
${highlightStyle.left}px 100%,
|
||||||
${highlightStyle.left}px ${highlightStyle.top}px,
|
${highlightStyle.left}px ${highlightStyle.top}px,
|
||||||
@@ -102,17 +104,8 @@ export default function UserGuide({ isOpen, onClose }: { isOpen: boolean; onClos
|
|||||||
${highlightStyle.left}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
|
${highlightStyle.left}px ${(highlightStyle.top as number) + (highlightStyle.height as number)}px,
|
||||||
${highlightStyle.left}px 100%,
|
${highlightStyle.left}px 100%,
|
||||||
100% 100%, 100% 0%
|
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 */}
|
{/* Highlight border */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import App from '../../App';
|
|
||||||
import { uploadService } from '../../lib/uploadService';
|
|
||||||
|
|
||||||
const { useAuthMock } = vi.hoisted(() => ({
|
|
||||||
useAuthMock: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../contexts/AuthContext', () => ({
|
|
||||||
useAuth: () => useAuthMock(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../contexts/LanguageContext', () => ({
|
|
||||||
useLanguage: () => ({
|
|
||||||
t: {
|
|
||||||
common: { loading: '加载中', processing: '处理中' },
|
|
||||||
alerts: {
|
|
||||||
taskTimeout: '超时',
|
|
||||||
networkError: '网络错误',
|
|
||||||
uploadFailed: '上传失败',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../lib/uploadService', () => ({
|
|
||||||
uploadService: {
|
|
||||||
getTaskList: vi.fn().mockResolvedValue({ task_list: [], total: 0 }),
|
|
||||||
getTaskResult: vi.fn(),
|
|
||||||
calculateMD5: vi.fn(),
|
|
||||||
uploadFile: vi.fn(),
|
|
||||||
createRecognitionTask: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../components/Navbar', () => ({
|
|
||||||
default: () => <div>navbar</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../components/LeftSidebar', () => ({
|
|
||||||
default: ({
|
|
||||||
onUploadClick,
|
|
||||||
onRequireAuth,
|
|
||||||
canUploadAnonymously,
|
|
||||||
}: {
|
|
||||||
onUploadClick: () => void;
|
|
||||||
onRequireAuth: () => void;
|
|
||||||
canUploadAnonymously: boolean;
|
|
||||||
}) => (
|
|
||||||
<div>
|
|
||||||
<button onClick={onUploadClick}>open-upload</button>
|
|
||||||
<button onClick={onRequireAuth}>open-auth</button>
|
|
||||||
<span>{canUploadAnonymously ? 'guest-allowed' : 'guest-blocked'}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../components/FilePreview', () => ({
|
|
||||||
default: ({ file }: { file: { id: string } | null }) => <div>{file ? `preview:${file.id}` : 'preview-empty'}</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../components/ResultPanel', () => ({
|
|
||||||
default: () => <div>result</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../components/UploadModal', () => ({
|
|
||||||
default: () => <div>upload-modal</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../components/UserGuide', () => ({
|
|
||||||
default: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../components/AuthModal', () => ({
|
|
||||||
default: () => <div>auth-modal</div>,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('App anonymous usage limit', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
localStorage.clear();
|
|
||||||
localStorage.setItem('hasSeenGuide', 'true');
|
|
||||||
useAuthMock.mockReturnValue({
|
|
||||||
user: null,
|
|
||||||
initializing: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows anonymous upload before the limit', () => {
|
|
||||||
localStorage.setItem('texpixel_guest_usage_count', '2');
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
expect(screen.getByText('guest-allowed')).toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByText('open-upload'));
|
|
||||||
|
|
||||||
expect(screen.getByText('upload-modal')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forces login after three anonymous uses', () => {
|
|
||||||
localStorage.setItem('texpixel_guest_usage_count', '3');
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
expect(screen.getByText('guest-blocked')).toBeInTheDocument();
|
|
||||||
fireEvent.click(screen.getByText('open-upload'));
|
|
||||||
|
|
||||||
expect(screen.getByText('auth-modal')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('upload-modal')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('App initial selection', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
localStorage.clear();
|
|
||||||
localStorage.setItem('hasSeenGuide', 'true');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not auto-select the first history record on initial load', async () => {
|
|
||||||
useAuthMock.mockReturnValue({
|
|
||||||
user: { id: 'u1' },
|
|
||||||
initializing: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mocked(uploadService.getTaskList).mockResolvedValue({
|
|
||||||
total: 1,
|
|
||||||
task_list: [
|
|
||||||
{
|
|
||||||
task_id: 'task-1',
|
|
||||||
file_name: 'sample.png',
|
|
||||||
status: 2,
|
|
||||||
origin_url: 'https://example.com/sample.png',
|
|
||||||
task_type: 'FORMULA',
|
|
||||||
created_at: '2026-03-06T00:00:00Z',
|
|
||||||
latex: '',
|
|
||||||
markdown: 'content',
|
|
||||||
mathml: '',
|
|
||||||
mml: '',
|
|
||||||
image_blob: '',
|
|
||||||
docx_url: '',
|
|
||||||
pdf_url: '',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<App />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(uploadService.getTaskList).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(screen.getByText('preview-empty')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('preview:task-1')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import AuthModal from '../AuthModal';
|
|
||||||
|
|
||||||
const useAuthMock = vi.fn();
|
|
||||||
const signInMock = vi.fn().mockResolvedValue({ error: null });
|
|
||||||
const signUpMock = vi.fn().mockResolvedValue({ error: null });
|
|
||||||
const beginGoogleOAuthMock = vi.fn().mockResolvedValue({ error: null });
|
|
||||||
|
|
||||||
vi.mock('../../contexts/AuthContext', () => ({
|
|
||||||
useAuth: () => useAuthMock(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../../contexts/LanguageContext', () => ({
|
|
||||||
useLanguage: () => ({
|
|
||||||
t: {
|
|
||||||
auth: {
|
|
||||||
signIn: '登录',
|
|
||||||
signUp: '注册',
|
|
||||||
signInTitle: '登录账号',
|
|
||||||
signUpTitle: '注册账号',
|
|
||||||
email: '邮箱',
|
|
||||||
password: '密码',
|
|
||||||
error: '错误',
|
|
||||||
genericError: '发生错误,请重试',
|
|
||||||
hasAccount: '已有账号?去登录',
|
|
||||||
noAccount: '没有账号?去注册',
|
|
||||||
continueWithGoogle: 'Google',
|
|
||||||
emailHint: '仅用于登录和同步记录。',
|
|
||||||
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
|
|
||||||
confirmPassword: '确认密码',
|
|
||||||
passwordMismatch: '两次输入的密码不一致。',
|
|
||||||
emailRequired: '请输入邮箱地址。',
|
|
||||||
emailInvalid: '请输入有效的邮箱地址。',
|
|
||||||
passwordRequired: '请输入密码。',
|
|
||||||
oauthRedirecting: '正在跳转 Google...',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const createAuthState = (overrides?: Record<string, unknown>) => ({
|
|
||||||
signIn: signInMock,
|
|
||||||
signUp: signUpMock,
|
|
||||||
beginGoogleOAuth: beginGoogleOAuthMock,
|
|
||||||
authPhase: 'idle',
|
|
||||||
authError: null,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AuthModal', () => {
|
|
||||||
it('shows email required message for empty signin submit', async () => {
|
|
||||||
useAuthMock.mockReturnValue(createAuthState());
|
|
||||||
render(<AuthModal onClose={vi.fn()} />);
|
|
||||||
|
|
||||||
fireEvent.click(document.querySelector('button[type="submit"]') as HTMLButtonElement);
|
|
||||||
|
|
||||||
expect(await screen.findByText('请输入邮箱地址。')).toBeInTheDocument();
|
|
||||||
expect(signInMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders google oauth button', () => {
|
|
||||||
useAuthMock.mockReturnValue(createAuthState());
|
|
||||||
|
|
||||||
render(<AuthModal onClose={vi.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: 'Google' })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('disables inputs and submit while oauth redirecting', () => {
|
|
||||||
useAuthMock.mockReturnValue(createAuthState({ authPhase: 'oauth_redirecting' }));
|
|
||||||
|
|
||||||
render(<AuthModal onClose={vi.fn()} />);
|
|
||||||
|
|
||||||
const emailInput = screen.getByLabelText('邮箱');
|
|
||||||
const submitButton = document.querySelector('button[type="submit"]') as HTMLButtonElement;
|
|
||||||
|
|
||||||
expect(emailInput).toBeDisabled();
|
|
||||||
expect(submitButton).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('switches between signin and signup with segmented tabs', () => {
|
|
||||||
useAuthMock.mockReturnValue(createAuthState());
|
|
||||||
|
|
||||||
render(<AuthModal onClose={vi.fn()} />);
|
|
||||||
|
|
||||||
const signupTab = screen.getByRole('button', { name: '注册', pressed: false });
|
|
||||||
fireEvent.click(signupTab);
|
|
||||||
|
|
||||||
expect(screen.getByRole('button', { name: '注册', pressed: true })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows friendlier signup guidance', () => {
|
|
||||||
useAuthMock.mockReturnValue(createAuthState());
|
|
||||||
|
|
||||||
render(<AuthModal onClose={vi.fn()} />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: '注册', pressed: false }));
|
|
||||||
|
|
||||||
expect(screen.getByText(/密码至少 6 位/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/仅用于登录和同步记录/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,213 +1,122 @@
|
|||||||
import { createContext, useContext, useMemo, ReactNode, useCallback, useEffect, useReducer } from 'react';
|
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||||
import { authService } from '../lib/authService';
|
import { authService } from '../lib/authService';
|
||||||
import { ApiErrorMessages } from '../types/api';
|
import { ApiErrorMessages } from '../types/api';
|
||||||
import type { GoogleOAuthCallbackRequest, UserInfo } from '../types/api';
|
import type { UserInfo } from '../types/api';
|
||||||
import { authReducer, createInitialAuthState, type AuthPhase } from './authMachine';
|
|
||||||
|
|
||||||
export const OAUTH_STATE_KEY = 'texpixel_oauth_state';
|
|
||||||
export const OAUTH_POST_LOGIN_REDIRECT_KEY = 'texpixel_post_login_redirect';
|
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: UserInfo | null;
|
user: UserInfo | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
initializing: boolean;
|
initializing: boolean; // 新增初始化状态
|
||||||
authPhase: AuthPhase;
|
|
||||||
authError: string | null;
|
|
||||||
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
|
signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
|
||||||
signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
|
signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
|
||||||
beginGoogleOAuth: () => Promise<{ error: Error | null }>;
|
|
||||||
completeGoogleOAuth: (params: GoogleOAuthCallbackRequest) => Promise<{ error: Error | null }>;
|
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
const oauthExchangeInFlight = new Map<string, Promise<{ error: Error | null }>>();
|
|
||||||
|
|
||||||
function getErrorMessage(error: unknown, fallback: string): string {
|
|
||||||
if (error && typeof error === 'object' && 'code' in error) {
|
|
||||||
const apiError = error as { code: number; message?: string };
|
|
||||||
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
|
|
||||||
}
|
|
||||||
if (error instanceof Error) {
|
|
||||||
return error.message;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOAuthState(): string {
|
|
||||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeUserProfile(user: UserInfo, profile: { username: string; email: string }): UserInfo {
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
username: profile.username || user.username || '',
|
|
||||||
email: profile.email || user.email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const restoredSession = authService.restoreSession();
|
// 直接在 useState 初始化函数中同步恢复会话
|
||||||
|
const [user, setUser] = useState<UserInfo | null>(() => {
|
||||||
|
try {
|
||||||
|
const session = authService.restoreSession();
|
||||||
|
return session ? session.user : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const [state, dispatch] = useReducer(
|
const [token, setToken] = useState<string | null>(() => {
|
||||||
authReducer,
|
try {
|
||||||
createInitialAuthState(restoredSession ? { user: restoredSession.user, token: restoredSession.token } : null)
|
const session = authService.restoreSession();
|
||||||
);
|
return session ? session.token : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [initializing, setInitializing] = useState(false); // 不需要初始化过程了,因为是同步的
|
||||||
|
|
||||||
|
// 不再需要 useEffect 里的 restoreSession
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从错误对象中提取用户友好的错误消息
|
||||||
|
*/
|
||||||
|
const getErrorMessage = (error: unknown, fallback: string): string => {
|
||||||
|
// 检查是否是 ApiError(通过 code 属性判断,避免 instanceof 在热更新时失效)
|
||||||
|
if (error && typeof error === 'object' && 'code' in error) {
|
||||||
|
const apiError = error as { code: number; message: string };
|
||||||
|
return ApiErrorMessages[apiError.code] || apiError.message || fallback;
|
||||||
|
}
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录
|
||||||
|
*/
|
||||||
const signIn = useCallback(async (email: string, password: string) => {
|
const signIn = useCallback(async (email: string, password: string) => {
|
||||||
dispatch({ type: 'EMAIL_SIGNIN_START' });
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authService.login({ email, password });
|
const result = await authService.login({ email, password });
|
||||||
dispatch({ type: 'EMAIL_SIGNIN_SUCCESS', payload: { user: result.user, token: result.token } });
|
setUser(result.user);
|
||||||
|
setToken(result.token);
|
||||||
return { error: null };
|
return { error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error, '登录失败');
|
const message = getErrorMessage(error, '登录失败');
|
||||||
dispatch({ type: 'EMAIL_SIGNIN_FAIL', payload: { error: message } });
|
|
||||||
return { error: new Error(message) };
|
return { error: new Error(message) };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册
|
||||||
|
*/
|
||||||
const signUp = useCallback(async (email: string, password: string) => {
|
const signUp = useCallback(async (email: string, password: string) => {
|
||||||
dispatch({ type: 'EMAIL_SIGNUP_START' });
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await authService.register({ email, password });
|
const result = await authService.register({ email, password });
|
||||||
dispatch({ type: 'EMAIL_SIGNUP_SUCCESS', payload: { user: result.user, token: result.token } });
|
setUser(result.user);
|
||||||
|
setToken(result.token);
|
||||||
return { error: null };
|
return { error: null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error, '注册失败');
|
const message = getErrorMessage(error, '注册失败');
|
||||||
dispatch({ type: 'EMAIL_SIGNUP_FAIL', payload: { error: message } });
|
|
||||||
return { error: new Error(message) };
|
return { error: new Error(message) };
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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 {
|
|
||||||
oauthExchangeInFlight.delete(requestKey);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
oauthExchangeInFlight.set(requestKey, promise);
|
|
||||||
return promise;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const signOut = useCallback(async () => {
|
const signOut = useCallback(async () => {
|
||||||
authService.logout();
|
setLoading(true);
|
||||||
dispatch({ type: 'SIGN_OUT' });
|
try {
|
||||||
|
authService.logout();
|
||||||
|
setUser(null);
|
||||||
|
setToken(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const value: AuthContextType = {
|
||||||
let cancelled = false;
|
user,
|
||||||
let hasSynced = false;
|
token,
|
||||||
|
loading,
|
||||||
const syncUserProfile = async () => {
|
initializing,
|
||||||
const currentUser = state.user;
|
signIn,
|
||||||
const currentToken = state.token;
|
signUp,
|
||||||
|
signOut,
|
||||||
if (!currentUser || !currentToken || hasSynced) {
|
isAuthenticated: !!user && !!token,
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
|
||||||
hasSynced = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const profile = await authService.getUserInfo();
|
|
||||||
if (cancelled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({
|
|
||||||
type: 'UPDATE_USER',
|
|
||||||
payload: {
|
|
||||||
user: mergeUserProfile(currentUser, profile),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Keep token-derived identity if profile sync fails.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void syncUserProfile();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [state.token]);
|
|
||||||
|
|
||||||
const value = useMemo<AuthContextType>(() => {
|
|
||||||
const loadingPhases: AuthPhase[] = [
|
|
||||||
'email_signing_in',
|
|
||||||
'email_signing_up',
|
|
||||||
'oauth_redirecting',
|
|
||||||
'oauth_exchanging',
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: state.user,
|
|
||||||
token: state.token,
|
|
||||||
loading: loadingPhases.includes(state.authPhase),
|
|
||||||
initializing: state.initializing,
|
|
||||||
authPhase: state.authPhase,
|
|
||||||
authError: state.authError,
|
|
||||||
signIn,
|
|
||||||
signUp,
|
|
||||||
beginGoogleOAuth,
|
|
||||||
completeGoogleOAuth,
|
|
||||||
signOut,
|
|
||||||
isAuthenticated: !!state.user && !!state.token,
|
|
||||||
};
|
|
||||||
}, [beginGoogleOAuth, completeGoogleOAuth, signIn, signOut, signUp, state]);
|
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import type { UserInfo } from '../types/api';
|
|
||||||
|
|
||||||
export type AuthPhase =
|
|
||||||
| 'idle'
|
|
||||||
| 'email_signing_in'
|
|
||||||
| 'email_signing_up'
|
|
||||||
| 'oauth_redirecting'
|
|
||||||
| 'oauth_exchanging'
|
|
||||||
| 'authenticated'
|
|
||||||
| 'error';
|
|
||||||
|
|
||||||
export interface AuthState {
|
|
||||||
user: UserInfo | null;
|
|
||||||
token: string | null;
|
|
||||||
authPhase: AuthPhase;
|
|
||||||
authError: string | null;
|
|
||||||
initializing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AuthAction =
|
|
||||||
| { type: 'RESTORE_SESSION'; payload: { user: UserInfo | null; token: string | null } }
|
|
||||||
| { type: 'EMAIL_SIGNIN_START' }
|
|
||||||
| { type: 'EMAIL_SIGNIN_SUCCESS'; payload: { user: UserInfo; token: string } }
|
|
||||||
| { type: 'EMAIL_SIGNIN_FAIL'; payload: { error: string } }
|
|
||||||
| { type: 'EMAIL_SIGNUP_START' }
|
|
||||||
| { type: 'EMAIL_SIGNUP_SUCCESS'; payload: { user: UserInfo; token: string } }
|
|
||||||
| { type: 'EMAIL_SIGNUP_FAIL'; payload: { error: string } }
|
|
||||||
| { type: 'OAUTH_REDIRECT_START' }
|
|
||||||
| { type: 'OAUTH_EXCHANGE_START' }
|
|
||||||
| { type: 'OAUTH_EXCHANGE_SUCCESS'; payload: { user: UserInfo; token: string } }
|
|
||||||
| { type: 'OAUTH_EXCHANGE_FAIL'; payload: { error: string } }
|
|
||||||
| { type: 'UPDATE_USER'; payload: { user: UserInfo } }
|
|
||||||
| { type: 'SIGN_OUT' };
|
|
||||||
|
|
||||||
export function createInitialAuthState(session: { user: UserInfo; token: string } | null): AuthState {
|
|
||||||
if (session) {
|
|
||||||
return {
|
|
||||||
user: session.user,
|
|
||||||
token: session.token,
|
|
||||||
authPhase: 'authenticated',
|
|
||||||
authError: null,
|
|
||||||
initializing: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: null,
|
|
||||||
token: null,
|
|
||||||
authPhase: 'idle',
|
|
||||||
authError: null,
|
|
||||||
initializing: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function authReducer(state: AuthState, action: AuthAction): AuthState {
|
|
||||||
switch (action.type) {
|
|
||||||
case 'RESTORE_SESSION': {
|
|
||||||
if (action.payload.user && action.payload.token) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
user: action.payload.user,
|
|
||||||
token: action.payload.token,
|
|
||||||
authPhase: 'authenticated',
|
|
||||||
authError: null,
|
|
||||||
initializing: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
user: null,
|
|
||||||
token: null,
|
|
||||||
authPhase: 'idle',
|
|
||||||
authError: null,
|
|
||||||
initializing: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'EMAIL_SIGNIN_START':
|
|
||||||
return { ...state, authPhase: 'email_signing_in', authError: null };
|
|
||||||
case 'EMAIL_SIGNIN_SUCCESS':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
user: action.payload.user,
|
|
||||||
token: action.payload.token,
|
|
||||||
authPhase: 'authenticated',
|
|
||||||
authError: null,
|
|
||||||
};
|
|
||||||
case 'EMAIL_SIGNIN_FAIL':
|
|
||||||
return { ...state, authPhase: 'error', authError: action.payload.error };
|
|
||||||
|
|
||||||
case 'EMAIL_SIGNUP_START':
|
|
||||||
return { ...state, authPhase: 'email_signing_up', authError: null };
|
|
||||||
case 'EMAIL_SIGNUP_SUCCESS':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
user: action.payload.user,
|
|
||||||
token: action.payload.token,
|
|
||||||
authPhase: 'authenticated',
|
|
||||||
authError: null,
|
|
||||||
};
|
|
||||||
case 'EMAIL_SIGNUP_FAIL':
|
|
||||||
return { ...state, authPhase: 'error', authError: action.payload.error };
|
|
||||||
|
|
||||||
case 'OAUTH_REDIRECT_START':
|
|
||||||
return { ...state, authPhase: 'oauth_redirecting', authError: null };
|
|
||||||
case 'OAUTH_EXCHANGE_START':
|
|
||||||
return { ...state, authPhase: 'oauth_exchanging', authError: null };
|
|
||||||
case 'OAUTH_EXCHANGE_SUCCESS':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
user: action.payload.user,
|
|
||||||
token: action.payload.token,
|
|
||||||
authPhase: 'authenticated',
|
|
||||||
authError: null,
|
|
||||||
};
|
|
||||||
case 'OAUTH_EXCHANGE_FAIL':
|
|
||||||
return { ...state, authPhase: 'error', authError: action.payload.error };
|
|
||||||
|
|
||||||
case 'UPDATE_USER':
|
|
||||||
return { ...state, user: action.payload.user };
|
|
||||||
|
|
||||||
case 'SIGN_OUT':
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
user: null,
|
|
||||||
token: null,
|
|
||||||
authPhase: 'idle',
|
|
||||||
authError: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
default:
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,7 +73,6 @@ export class ApiError extends Error {
|
|||||||
*/
|
*/
|
||||||
interface RequestConfig extends RequestInit {
|
interface RequestConfig extends RequestInit {
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
successCodes?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,7 +82,7 @@ async function request<T>(
|
|||||||
endpoint: string,
|
endpoint: string,
|
||||||
config: RequestConfig = {}
|
config: RequestConfig = {}
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
const { skipAuth = false, successCodes = [200], headers: customHeaders, ...restConfig } = config;
|
const { skipAuth = false, headers: customHeaders, ...restConfig } = config;
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const headers: HeadersInit = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -109,7 +108,7 @@ async function request<T>(
|
|||||||
const data: ApiResponse<T> = await response.json();
|
const data: ApiResponse<T> = await response.json();
|
||||||
|
|
||||||
// 统一处理业务错误
|
// 统一处理业务错误
|
||||||
if (!successCodes.includes(data.code)) {
|
if (data.code !== 200) {
|
||||||
throw new ApiError(data.code, data.message, data.request_id);
|
throw new ApiError(data.code, data.message, data.request_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,3 +153,4 @@ export const http = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default http;
|
export default http;
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +1,36 @@
|
|||||||
/**
|
/**
|
||||||
* 认证服务
|
* 认证服务
|
||||||
* 处理用户登录、注册、OAuth、登出等认证相关操作
|
* 处理用户登录、注册、登出等认证相关操作
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { http, tokenManager, ApiError } from './api';
|
import { http, tokenManager, ApiError } from './api';
|
||||||
import type {
|
import type { AuthData, LoginRequest, RegisterRequest, UserInfo } from '../types/api';
|
||||||
AuthData,
|
|
||||||
GoogleAuthUrlData,
|
|
||||||
GoogleOAuthCallbackRequest,
|
|
||||||
LoginRequest,
|
|
||||||
RegisterRequest,
|
|
||||||
UserInfoData,
|
|
||||||
UserInfo,
|
|
||||||
} from '../types/api';
|
|
||||||
|
|
||||||
|
// 重新导出 ApiErrorMessages 以便使用
|
||||||
export { ApiErrorMessages } from '../types/api';
|
export { ApiErrorMessages } from '../types/api';
|
||||||
|
|
||||||
function decodeJwtPayload(token: string): UserInfo | null {
|
/**
|
||||||
|
* 从 JWT Token 解析用户信息
|
||||||
|
*/
|
||||||
|
function parseJwtPayload(token: string): UserInfo | null {
|
||||||
try {
|
try {
|
||||||
|
// 移除 Bearer 前缀
|
||||||
const actualToken = token.replace('Bearer ', '');
|
const actualToken = token.replace('Bearer ', '');
|
||||||
const base64Payload = actualToken.split('.')[1];
|
const base64Payload = actualToken.split('.')[1];
|
||||||
const normalized = base64Payload.replace(/-/g, '+').replace(/_/g, '/');
|
const payload = JSON.parse(atob(base64Payload));
|
||||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
|
||||||
const payload = JSON.parse(atob(padded));
|
|
||||||
return payload as UserInfo;
|
return payload as UserInfo;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUser(payload: UserInfo, emailHint?: string): UserInfo {
|
/**
|
||||||
return {
|
* 认证服务
|
||||||
...payload,
|
*/
|
||||||
email: payload.email || emailHint || '',
|
|
||||||
id: String(payload.user_id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSession(authData: AuthData, emailHint?: string): { user: UserInfo; token: string; expiresAt: number } {
|
|
||||||
const { token, expires_at } = authData;
|
|
||||||
const parsedUser = decodeJwtPayload(token);
|
|
||||||
|
|
||||||
if (!parsedUser) {
|
|
||||||
throw new ApiError(-1, 'Token 解析失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = normalizeUser(parsedUser, emailHint);
|
|
||||||
tokenManager.setToken(token, expires_at, user.email || undefined);
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
token,
|
|
||||||
expiresAt: expires_at,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const authService = {
|
export const authService = {
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*/
|
||||||
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
async login(credentials: LoginRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||||
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
|
const response = await http.post<AuthData>('/user/login', credentials, { skipAuth: true });
|
||||||
|
|
||||||
@@ -63,9 +38,35 @@ export const authService = {
|
|||||||
throw new ApiError(-1, '登录失败,请重试');
|
throw new ApiError(-1, '登录失败,请重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildSession(response.data, credentials.email);
|
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,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册
|
||||||
|
* 注意:业务错误码 (code !== 200) 已在 api.ts 中统一处理,会抛出 ApiError
|
||||||
|
*/
|
||||||
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
async register(credentials: RegisterRequest): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
||||||
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
|
const response = await http.post<AuthData>('/user/register', credentials, { skipAuth: true });
|
||||||
|
|
||||||
@@ -73,58 +74,55 @@ export const authService = {
|
|||||||
throw new ApiError(-1, '注册失败,请重试');
|
throw new ApiError(-1, '注册失败,请重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildSession(response.data, credentials.email);
|
const { token, expires_at } = response.data;
|
||||||
},
|
|
||||||
|
|
||||||
async getGoogleOAuthUrl(redirectUri: string, state: string): Promise<{ authUrl: string }> {
|
// 存储 Token 和 email
|
||||||
const query = new URLSearchParams({ redirect_uri: redirectUri, state });
|
tokenManager.setToken(token, expires_at, credentials.email);
|
||||||
const response = await http.get<GoogleAuthUrlData>(`/user/oauth/google/url?${query.toString()}`, {
|
|
||||||
skipAuth: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.data?.auth_url) {
|
// 解析用户信息
|
||||||
throw new ApiError(-1, '获取 Google 授权地址失败');
|
const user = parseJwtPayload(token);
|
||||||
|
if (!user) {
|
||||||
|
throw new ApiError(-1, 'Token 解析失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { authUrl: response.data.auth_url };
|
// 补充 email 和 id 兼容字段
|
||||||
},
|
const userWithEmail: UserInfo = {
|
||||||
|
...user,
|
||||||
async exchangeGoogleCode(
|
email: credentials.email,
|
||||||
payload: GoogleOAuthCallbackRequest
|
id: String(user.user_id),
|
||||||
): Promise<{ user: UserInfo; token: string; expiresAt: number }> {
|
};
|
||||||
const response = await http.post<AuthData>('/user/oauth/google/callback', payload, { skipAuth: true });
|
|
||||||
|
return {
|
||||||
if (!response.data) {
|
user: userWithEmail,
|
||||||
throw new ApiError(-1, 'Google 登录失败,请重试');
|
token,
|
||||||
}
|
expiresAt: expires_at,
|
||||||
|
};
|
||||||
return buildSession(response.data);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUserInfo(): Promise<UserInfoData> {
|
|
||||||
const response = await http.get<UserInfoData>('/user/info', {
|
|
||||||
successCodes: [0, 200],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.data) {
|
|
||||||
throw new ApiError(-1, '获取用户信息失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登出
|
||||||
|
*/
|
||||||
logout(): void {
|
logout(): void {
|
||||||
tokenManager.removeToken();
|
tokenManager.removeToken();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否已登录
|
||||||
|
*/
|
||||||
isAuthenticated(): boolean {
|
isAuthenticated(): boolean {
|
||||||
return tokenManager.isTokenValid();
|
return tokenManager.isTokenValid();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前存储的 Token
|
||||||
|
*/
|
||||||
getToken(): string | null {
|
getToken(): string | null {
|
||||||
return tokenManager.getToken();
|
return tokenManager.getToken();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从存储的 Token 恢复用户会话
|
||||||
|
*/
|
||||||
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
|
restoreSession(): { user: UserInfo; token: string; expiresAt: number } | null {
|
||||||
const token = tokenManager.getToken();
|
const token = tokenManager.getToken();
|
||||||
const expiresAt = tokenManager.getExpiresAt();
|
const expiresAt = tokenManager.getExpiresAt();
|
||||||
@@ -135,14 +133,21 @@ export const authService = {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedUser = decodeJwtPayload(token);
|
const parsedUser = parseJwtPayload(token);
|
||||||
if (!parsedUser) {
|
if (!parsedUser) {
|
||||||
tokenManager.removeToken();
|
tokenManager.removeToken();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 补充 email 和 id 兼容字段
|
||||||
|
const user: UserInfo = {
|
||||||
|
...parsedUser,
|
||||||
|
email: email || '',
|
||||||
|
id: String(parsedUser.user_id),
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: normalizeUser(parsedUser, email || ''),
|
user,
|
||||||
token,
|
token,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
};
|
};
|
||||||
@@ -151,3 +156,4 @@ export const authService = {
|
|||||||
|
|
||||||
export { ApiError };
|
export { ApiError };
|
||||||
export default authService;
|
export default authService;
|
||||||
|
|
||||||
|
|||||||
@@ -60,18 +60,6 @@ export const translations = {
|
|||||||
genericError: 'An error occurred, please try again',
|
genericError: 'An error occurred, please try again',
|
||||||
hasAccount: 'Already have an account? Login',
|
hasAccount: 'Already have an account? Login',
|
||||||
noAccount: 'No account? Register',
|
noAccount: 'No account? Register',
|
||||||
continueWithGoogle: 'Google',
|
|
||||||
emailHint: 'Used only for sign-in and history sync.',
|
|
||||||
emailRequired: 'Please enter your email address.',
|
|
||||||
emailInvalid: 'Please enter a valid email address.',
|
|
||||||
passwordRequired: 'Please enter your password.',
|
|
||||||
passwordHint: 'Use at least 6 characters. Letters and numbers are recommended.',
|
|
||||||
confirmPassword: 'Confirm Password',
|
|
||||||
passwordMismatch: 'The two passwords do not match.',
|
|
||||||
oauthRedirecting: 'Redirecting to Google...',
|
|
||||||
oauthExchanging: 'Completing Google sign-in...',
|
|
||||||
invalidOAuthState: 'Invalid OAuth state, please retry.',
|
|
||||||
oauthFailed: 'Google sign-in failed, please retry.',
|
|
||||||
},
|
},
|
||||||
export: {
|
export: {
|
||||||
title: 'Export',
|
title: 'Export',
|
||||||
@@ -168,18 +156,6 @@ export const translations = {
|
|||||||
genericError: '发生错误,请重试',
|
genericError: '发生错误,请重试',
|
||||||
hasAccount: '已有账号?去登录',
|
hasAccount: '已有账号?去登录',
|
||||||
noAccount: '没有账号?去注册',
|
noAccount: '没有账号?去注册',
|
||||||
continueWithGoogle: 'Google',
|
|
||||||
emailHint: '仅用于登录和同步记录。',
|
|
||||||
emailRequired: '请输入邮箱地址。',
|
|
||||||
emailInvalid: '请输入有效的邮箱地址。',
|
|
||||||
passwordRequired: '请输入密码。',
|
|
||||||
passwordHint: '密码至少 6 位,建议使用字母和数字组合。',
|
|
||||||
confirmPassword: '确认密码',
|
|
||||||
passwordMismatch: '两次输入的密码不一致。',
|
|
||||||
oauthRedirecting: '正在跳转 Google...',
|
|
||||||
oauthExchanging: '正在完成 Google 登录...',
|
|
||||||
invalidOAuthState: 'OAuth 状态校验失败,请重试',
|
|
||||||
oauthFailed: 'Google 登录失败,请重试',
|
|
||||||
},
|
},
|
||||||
export: {
|
export: {
|
||||||
title: '导出',
|
title: '导出',
|
||||||
|
|||||||
15
src/main.tsx
15
src/main.tsx
@@ -1,10 +1,9 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
import { LanguageProvider } from './contexts/LanguageContext';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
|
||||||
import AppRouter from './routes/AppRouter';
|
|
||||||
|
|
||||||
// 错误处理:捕获未处理的错误
|
// 错误处理:捕获未处理的错误
|
||||||
window.addEventListener('error', (event) => {
|
window.addEventListener('error', (event) => {
|
||||||
@@ -23,13 +22,11 @@ if (!rootElement) {
|
|||||||
try {
|
try {
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<AuthProvider>
|
||||||
<AuthProvider>
|
<LanguageProvider>
|
||||||
<LanguageProvider>
|
<App />
|
||||||
<AppRouter />
|
</LanguageProvider>
|
||||||
</LanguageProvider>
|
</AuthProvider>
|
||||||
</AuthProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</StrictMode>
|
</StrictMode>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
@@ -14,26 +14,10 @@ export interface AuthData {
|
|||||||
expires_at: number;
|
expires_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserInfoData {
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GoogleAuthUrlData {
|
|
||||||
auth_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GoogleOAuthCallbackRequest {
|
|
||||||
code: string;
|
|
||||||
state: string;
|
|
||||||
redirect_uri: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户信息(从 token 解析或 API 返回)
|
// 用户信息(从 token 解析或 API 返回)
|
||||||
export interface UserInfo {
|
export interface UserInfo {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
email: string;
|
email: string;
|
||||||
username?: string;
|
|
||||||
exp: number;
|
exp: number;
|
||||||
iat: number;
|
iat: number;
|
||||||
// 兼容字段,方便代码使用
|
// 兼容字段,方便代码使用
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
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