mirror of
https://github.com/laoxong/nofx.git
synced 2026-06-07 11:17:56 +08:00
feat(ui): add password strength validation and toggle visibility in registration and reset password forms (#773)
Co-authored-by: tinkle-community <tinklefund@gmail.com>
This commit is contained in:
Generated
+10
@@ -16,6 +16,7 @@
|
||||
"lucide-react": "^0.552.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-password-checklist": "^1.8.1",
|
||||
"recharts": "^2.15.2",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
@@ -6982,6 +6983,15 @@
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||
},
|
||||
"node_modules/react-password-checklist": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-password-checklist/-/react-password-checklist-1.8.1.tgz",
|
||||
"integrity": "sha512-QHIU/OejxoH4/cIfYLHaHLb+yYc8mtL0Vr4HTmULxQg3ZNdI9Ni/yYf7pwLBgsUh4sseKCV/GzzYHWpHqejTGw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": ">16.0.0-alpha || >17.0.0-alpha || >18.0.0-alpha"
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"lucide-react": "^0.552.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-password-checklist": "^1.8.1",
|
||||
"recharts": "^2.15.2",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import HeaderBar from './landing/HeaderBar'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from './ui/input'
|
||||
|
||||
export function LoginPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -10,6 +12,7 @@ export function LoginPage() {
|
||||
const [step, setStep] = useState<'login' | 'otp'>('login')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [otpCode, setOtpCode] = useState('')
|
||||
const [userID, setUserID] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -172,16 +175,10 @@ export function LoginPage() {
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
@@ -194,19 +191,26 @@ export function LoginPage() {
|
||||
>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pr-10"
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-right mt-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,6 +4,9 @@ import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { getSystemConfig } from '../lib/config'
|
||||
import HeaderBar from './landing/HeaderBar'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { Input } from './ui/input'
|
||||
import PasswordChecklist from 'react-password-checklist'
|
||||
|
||||
export function RegisterPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -22,6 +25,9 @@ export function RegisterPage() {
|
||||
const [qrCodeURL, setQrCodeURL] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [passwordValid, setPasswordValid] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 获取系统配置,检查是否开启内测模式
|
||||
@@ -38,13 +44,10 @@ export function RegisterPage() {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError(t('passwordMismatch', language))
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError(t('passwordTooShort', language))
|
||||
// 客户端强校验:长度>=8,包含大小写、数字、特殊字符,且两次一致
|
||||
const strong = isStrongPassword(password)
|
||||
if (!strong || password !== confirmPassword) {
|
||||
setError(t('passwordNotMeetRequirements', language))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -149,16 +152,10 @@ export function RegisterPage() {
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
@@ -171,19 +168,26 @@ export function RegisterPage() {
|
||||
>
|
||||
{t('password', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
}}
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pr-10"
|
||||
placeholder={t('passwordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showPassword ? '隐藏密码' : '显示密码'}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -193,18 +197,65 @@ export function RegisterPage() {
|
||||
>
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: 'var(--brand-black)',
|
||||
border: '1px solid var(--panel-border)',
|
||||
color: 'var(--brand-light-gray)',
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pr-10"
|
||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={showConfirmPassword ? '隐藏密码' : '显示密码'}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowConfirmPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center rounded bg-transparent p-0 m-0 border-0 outline-none focus:outline-none focus:ring-0 appearance-none cursor-pointer btn-icon"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff size={18} />
|
||||
) : (
|
||||
<Eye size={18} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码规则清单(通过才允许提交) */}
|
||||
<div
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<div
|
||||
className="mb-1"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('passwordRequirements', language)}
|
||||
</div>
|
||||
<PasswordChecklist
|
||||
rules={[
|
||||
'minLength',
|
||||
'capital',
|
||||
'lowercase',
|
||||
'number',
|
||||
'specialChar',
|
||||
'match',
|
||||
]}
|
||||
minLength={8}
|
||||
value={password}
|
||||
valueAgain={confirmPassword}
|
||||
messages={{
|
||||
minLength: t('passwordRuleMinLength', language),
|
||||
capital: t('passwordRuleUppercase', language),
|
||||
lowercase: t('passwordRuleLowercase', language),
|
||||
number: t('passwordRuleNumber', language),
|
||||
specialChar: t('passwordRuleSpecial', language),
|
||||
match: t('passwordRuleMatch', language),
|
||||
}}
|
||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
||||
required
|
||||
className="space-y-1"
|
||||
onChange={(isValid) => setPasswordValid(isValid)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -256,7 +307,9 @@ export function RegisterPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (betaMode && !betaCode.trim())}
|
||||
disabled={
|
||||
loading || (betaMode && !betaCode.trim()) || !passwordValid
|
||||
}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{
|
||||
background: 'var(--brand-yellow)',
|
||||
@@ -500,3 +553,13 @@ export function RegisterPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 本地密码强度校验(与 UI 规则一致)
|
||||
function isStrongPassword(pwd: string): boolean {
|
||||
if (!pwd || pwd.length < 8) return false
|
||||
const hasUpper = /[A-Z]/.test(pwd)
|
||||
const hasLower = /[a-z]/.test(pwd)
|
||||
const hasNumber = /\d/.test(pwd)
|
||||
const hasSpecial = /[@#$%!&*?]/.test(pwd)
|
||||
return hasUpper && hasLower && hasNumber && hasSpecial
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useLanguage } from '../contexts/LanguageContext'
|
||||
import { t } from '../i18n/translations'
|
||||
import { Header } from './Header'
|
||||
import { ArrowLeft, KeyRound, Eye, EyeOff } from 'lucide-react'
|
||||
import PasswordChecklist from 'react-password-checklist'
|
||||
import { Input } from './ui/input'
|
||||
|
||||
export function ResetPasswordPage() {
|
||||
const { language } = useLanguage()
|
||||
@@ -17,6 +19,7 @@ export function ResetPasswordPage() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
|
||||
const [passwordValid, setPasswordValid] = useState(false)
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -112,16 +115,10 @@ export function ResetPasswordPage() {
|
||||
>
|
||||
{t('email', language)}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
placeholder={t('emailPlaceholder', language)}
|
||||
required
|
||||
/>
|
||||
@@ -135,24 +132,20 @@ export function ResetPasswordPage() {
|
||||
{t('newPassword', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 pr-10 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
className="pr-10"
|
||||
placeholder={t('newPasswordPlaceholder', language)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300"
|
||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
@@ -171,26 +164,22 @@ export function ResetPasswordPage() {
|
||||
{t('confirmPassword', language)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
<Input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 pr-10 rounded"
|
||||
style={{
|
||||
background: '#0B0E11',
|
||||
border: '1px solid #2B3139',
|
||||
color: '#EAECEF',
|
||||
}}
|
||||
className="pr-10"
|
||||
placeholder={t('confirmPasswordPlaceholder', language)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() =>
|
||||
setShowConfirmPassword(!showConfirmPassword)
|
||||
}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-300"
|
||||
className="absolute inset-y-0 right-2 w-8 h-10 flex items-center justify-center btn-icon"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
@@ -201,6 +190,42 @@ export function ResetPasswordPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码强度检查(必须通过才允许提交) */}
|
||||
<div
|
||||
className="mt-1 text-xs"
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<div
|
||||
className="mb-1"
|
||||
style={{ color: 'var(--brand-light-gray)' }}
|
||||
>
|
||||
{t('passwordRequirements', language)}
|
||||
</div>
|
||||
<PasswordChecklist
|
||||
rules={[
|
||||
'minLength',
|
||||
'capital',
|
||||
'lowercase',
|
||||
'number',
|
||||
'specialChar',
|
||||
'match',
|
||||
]}
|
||||
minLength={8}
|
||||
value={newPassword}
|
||||
valueAgain={confirmPassword}
|
||||
messages={{
|
||||
minLength: t('passwordRuleMinLength', language),
|
||||
capital: t('passwordRuleUppercase', language),
|
||||
lowercase: t('passwordRuleLowercase', language),
|
||||
number: t('passwordRuleNumber', language),
|
||||
specialChar: t('passwordRuleSpecial', language),
|
||||
match: t('passwordRuleMatch', language),
|
||||
}}
|
||||
className="space-y-1"
|
||||
onChange={(isValid) => setPasswordValid(isValid)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
className="block text-sm font-semibold mb-2"
|
||||
@@ -246,7 +271,7 @@ export function ResetPasswordPage() {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || otpCode.length !== 6}
|
||||
disabled={loading || otpCode.length !== 6 || !passwordValid}
|
||||
className="w-full px-4 py-2 rounded text-sm font-semibold transition-all hover:scale-105 disabled:opacity-50"
|
||||
style={{ background: '#F0B90B', color: '#000' }}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../../lib/cn'
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type = 'text', ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded px-3 py-2 text-sm',
|
||||
'bg-[var(--brand-black)] border border-[var(--panel-border)]',
|
||||
'text-[var(--brand-light-gray)] focus:outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
@@ -364,12 +364,23 @@ export const translations = {
|
||||
// Login & Register
|
||||
login: 'Sign In',
|
||||
register: 'Sign Up',
|
||||
username: 'Username',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
usernamePlaceholder: 'your username',
|
||||
emailPlaceholder: 'your@email.com',
|
||||
passwordPlaceholder: 'Enter your password',
|
||||
confirmPasswordPlaceholder: 'Re-enter your password',
|
||||
passwordRequirements: 'Password requirements',
|
||||
passwordRuleMinLength: 'Minimum 8 characters',
|
||||
passwordRuleUppercase: 'At least 1 uppercase letter',
|
||||
passwordRuleLowercase: 'At least 1 lowercase letter',
|
||||
passwordRuleNumber: 'At least 1 number',
|
||||
passwordRuleSpecial: 'At least 1 special character (@#$%!&*?)',
|
||||
passwordRuleMatch: 'Passwords match',
|
||||
passwordNotMeetRequirements:
|
||||
'Password does not meet the security requirements',
|
||||
otpPlaceholder: '000000',
|
||||
loginTitle: 'Sign in to your account',
|
||||
registerTitle: 'Create a new account',
|
||||
@@ -419,6 +430,12 @@ export const translations = {
|
||||
registrationFailed: 'Registration failed',
|
||||
verificationFailed: 'OTP verification failed',
|
||||
invalidCredentials: 'Invalid email or password',
|
||||
weak: 'Weak',
|
||||
medium: 'Medium',
|
||||
strong: 'Strong',
|
||||
passwordStrength: 'Password strength',
|
||||
passwordStrengthHint:
|
||||
'Use at least 8 characters with mix of letters, numbers and symbols',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
emailRequired: 'Email is required',
|
||||
passwordRequired: 'Password is required',
|
||||
@@ -1089,12 +1106,22 @@ export const translations = {
|
||||
// Login & Register
|
||||
login: '登录',
|
||||
register: '注册',
|
||||
username: '用户名',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
confirmPassword: '确认密码',
|
||||
usernamePlaceholder: '请输入用户名',
|
||||
emailPlaceholder: '请输入邮箱地址',
|
||||
passwordPlaceholder: '请输入密码(至少6位)',
|
||||
confirmPasswordPlaceholder: '请再次输入密码',
|
||||
passwordRequirements: '密码要求',
|
||||
passwordRuleMinLength: '至少 8 位',
|
||||
passwordRuleUppercase: '至少 1 个大写字母',
|
||||
passwordRuleLowercase: '至少 1 个小写字母',
|
||||
passwordRuleNumber: '至少 1 个数字',
|
||||
passwordRuleSpecial: '至少 1 个特殊字符(@#$%!&*?)',
|
||||
passwordRuleMatch: '两次密码一致',
|
||||
passwordNotMeetRequirements: '密码不符合安全要求',
|
||||
otpPlaceholder: '000000',
|
||||
loginTitle: '登录到您的账户',
|
||||
registerTitle: '创建新账户',
|
||||
@@ -1140,6 +1167,11 @@ export const translations = {
|
||||
registrationFailed: '注册失败',
|
||||
verificationFailed: 'OTP验证失败',
|
||||
invalidCredentials: '邮箱或密码错误',
|
||||
weak: '弱',
|
||||
medium: '中',
|
||||
strong: '强',
|
||||
passwordStrength: '密码强度',
|
||||
passwordStrengthHint: '建议至少8位,包含大小写、数字和符号',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
emailRequired: '请输入邮箱',
|
||||
passwordRequired: '请输入密码',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type ClassValue } from 'clsx'
|
||||
import { clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user