From f91b5d1cefa66599f10fd1c3a489f2147bb081f4 Mon Sep 17 00:00:00 2001 From: long-agent Date: Tue, 12 May 2026 20:34:30 +0800 Subject: [PATCH] feat: improve auth form UX --- .../admin/scripts/run-playwright-cdp-e2e.mjs | 6 ++ .../common/PasswordStrengthIndicator.tsx | 84 +++++++++++++++++++ .../src/pages/auth/LoginPage/LoginPage.tsx | 10 ++- .../pages/auth/RegisterPage/RegisterPage.tsx | 22 ++++- 4 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 frontend/admin/src/components/common/PasswordStrengthIndicator.tsx diff --git a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs index ae981bb..57c8cd2 100644 --- a/frontend/admin/scripts/run-playwright-cdp-e2e.mjs +++ b/frontend/admin/scripts/run-playwright-cdp-e2e.mjs @@ -1607,6 +1607,9 @@ async function verifyPublicRegistration(page) { page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(), password, ) + const agreementCheckbox = page.locator('.ant-form-item').filter({ has: page.locator('input[id="agreement"]') }).locator('.ant-checkbox').first() + await forceClick(agreementCheckbox) + await expect(agreementCheckbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 }) const registerResponsePromise = waitForResponseSafe(page, (response) => { return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST' }) @@ -1642,6 +1645,9 @@ async function verifyEmailActivationWorkflow(page) { page.locator(`input[placeholder="${TEXT.confirmPasswordPlaceholder}"]`).first(), password, ) + const agreementCheckbox = page.locator('.ant-form-item').filter({ has: page.locator('input[id="agreement"]') }).locator('.ant-checkbox').first() + await forceClick(agreementCheckbox) + await expect(agreementCheckbox).toHaveClass(/ant-checkbox-checked/, { timeout: 10 * 1000 }) const registerResponsePromise = waitForResponseSafe(page, (response) => { return response.url().includes('/api/v1/auth/register') && response.request().method() === 'POST' diff --git a/frontend/admin/src/components/common/PasswordStrengthIndicator.tsx b/frontend/admin/src/components/common/PasswordStrengthIndicator.tsx new file mode 100644 index 0000000..3e7f8ae --- /dev/null +++ b/frontend/admin/src/components/common/PasswordStrengthIndicator.tsx @@ -0,0 +1,84 @@ +/** + * PasswordStrengthIndicator - 密码强度指示器 + */ + +import { Progress } from 'antd' +import { useMemo } from 'react' + +interface PasswordStrengthIndicatorProps { + password: string +} + +function calculateStrength(password: string): { score: number; level: 'weak' | 'fair' | 'good' | 'strong' } { + if (!password) { + return { score: 0, level: 'weak' } + } + + let score = 0 + + // 长度检查 + if (password.length >= 8) score += 25 + if (password.length >= 12) score += 10 + if (password.length >= 16) score += 5 + + // 字符类型检查 + if (/[a-z]/.test(password)) score += 15 + if (/[A-Z]/.test(password)) score += 20 + if (/[0-9]/.test(password)) score += 20 + if (/[^a-zA-Z0-9]/.test(password)) score += 20 + + // 正则匹配检查 + if (/(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])/.test(password)) score += 5 + if (/(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])/.test(password)) score += 5 + + // 扣分项 + if (/^[a-zA-Z0-9]+$/.test(password)) score -= 10 // 纯字母数字 + if (/^[a-z]+$|^[A-Z]+$|^[0-9]+$/.test(password)) score -= 15 // 单一种类 + + // 限制范围 + score = Math.max(0, Math.min(100, score)) + + let level: 'weak' | 'fair' | 'good' | 'strong' + if (score < 30) level = 'weak' + else if (score < 60) level = 'fair' + else if (score < 80) level = 'good' + else level = 'strong' + + return { score, level } +} + +const strengthConfig = { + weak: { color: '#ff4d4f', text: '弱' }, + fair: { color: '#faad14', text: '中等' }, + good: { color: '#52c41a', text: '良好' }, + strong: { color: '#52c41a', text: '强' }, +} + +export function PasswordStrengthIndicator({ password }: PasswordStrengthIndicatorProps) { + const { score, level } = useMemo(() => calculateStrength(password), [password]) + + if (!password) { + return null + } + + const config = strengthConfig[level] + + return ( +
+
+ 密码强度 + {config.text} +
+ +
+ 建议:8位以上,包含大小写字母、数字和特殊字符 +
+
+ ) +} diff --git a/frontend/admin/src/pages/auth/LoginPage/LoginPage.tsx b/frontend/admin/src/pages/auth/LoginPage/LoginPage.tsx index 2d4cb27..b80ad5d 100644 --- a/frontend/admin/src/pages/auth/LoginPage/LoginPage.tsx +++ b/frontend/admin/src/pages/auth/LoginPage/LoginPage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Link, useLocation, useNavigate, useSearchParams } from 'react-router-dom' -import { Alert, Button, Divider, Form, Input, Space, Tabs, Typography, message } from 'antd' +import { Alert, Button, Checkbox, Divider, Form, Input, Space, Tabs, Typography, message } from 'antd' import { LockOutlined, MailOutlined, @@ -76,6 +76,7 @@ export function LoginPage() { const [capabilities, setCapabilities] = useState(DEFAULT_CAPABILITIES) const [pendingTOTP, setPendingTOTP] = useState<(PasswordLoginChallenge & { device_id?: string }) | null>(null) const [totpCode, setTotpCode] = useState('') + const [rememberMe, setRememberMe] = useState(false) const [emailForm] = Form.useForm() const [smsForm] = Form.useForm() @@ -328,6 +329,11 @@ export function LoginPage() { autoComplete="current-password" /> + + setRememberMe(e.target.checked)}> + 记住登录状态(7天免登录) + + diff --git a/frontend/admin/src/pages/auth/RegisterPage/RegisterPage.tsx b/frontend/admin/src/pages/auth/RegisterPage/RegisterPage.tsx index f710139..49e0611 100644 --- a/frontend/admin/src/pages/auth/RegisterPage/RegisterPage.tsx +++ b/frontend/admin/src/pages/auth/RegisterPage/RegisterPage.tsx @@ -8,9 +8,10 @@ import { SafetyOutlined, UserOutlined, } from '@ant-design/icons' -import { Alert, Button, Form, Input, Result, Space, Typography, message } from 'antd' +import { Alert, Button, Checkbox, Form, Input, Result, Space, Typography, message } from 'antd' import { AuthLayout } from '@/layouts' +import { PasswordStrengthIndicator } from '@/components/common/PasswordStrengthIndicator' import { getErrorMessage, isFormValidationError } from '@/lib/errors' import { getAuthCapabilities, register, sendSmsCode } from '@/services/auth' import type { AuthCapabilities, RegisterResponse } from '@/types' @@ -56,6 +57,7 @@ export function RegisterPage() { const [capabilities, setCapabilities] = useState(DEFAULT_CAPABILITIES) const [capabilitiesLoaded, setCapabilitiesLoaded] = useState(false) const [submitted, setSubmitted] = useState(null) + const [passwordValue, setPasswordValue] = useState('') useEffect(() => { if (smsCountdown <= 0) { @@ -291,8 +293,12 @@ export function RegisterPage() { placeholder="密码" size="large" autoComplete="new-password" + onChange={(e) => setPasswordValue(e.target.value)} /> + + + + + value ? Promise.resolve() : Promise.reject(new Error('请阅读并同意用户协议和隐私政策')), + }, + ]} + > + + 我已阅读并同意 《用户协议》《隐私政策》 + +