flnxi-Wu/app/register/page.tsx
2026-05-30 14:48:38 +08:00

317 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
import { PASSWORD_POLICY_DESCRIPTION, validatePasswordStrength } from '@/lib/password-policy'
const DEFAULT_LOGIN_LOGO = process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || ''
type PublicSystemSettings = {
loginLogoUrl?: string | null
siteName?: string | null
}
export default function RegisterPage() {
const router = useRouter()
const searchParams = useSearchParams()
const inviteCode = searchParams.get('invite')?.trim() || ''
const [email, setEmail] = useState('')
const [username, setUsername] = useState('')
const [department, setDepartment] = useState('')
const [departments, setDepartments] = useState<Array<{ id: string; name: string }>>([])
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [logoError, setLogoError] = useState(false)
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO)
const [siteName, setSiteName] = useState('FLNXI素材库')
const [settingsLoaded, setSettingsLoaded] = useState(false)
const pageClass = 'min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-950 p-4'
const panelClass = 'w-full max-w-md rounded-lg border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 shadow-sm p-6'
const inputClass = 'w-full px-4 py-3 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 text-slate-900 dark:text-white placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-slate-900 dark:focus:border-slate-500 focus:ring-2 focus:ring-slate-200 dark:focus:ring-slate-700 outline-none transition-colors'
const buttonClass = 'w-full py-3 rounded-lg bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white font-medium transition-colors disabled:opacity-60'
const isRegistrationClosed = settingsLoaded && !inviteCode
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json() as PublicSystemSettings
setLoginLogoUrl(settings.loginLogoUrl || DEFAULT_LOGIN_LOGO)
setSiteName(settings.siteName || 'FLNXI素材库')
setLogoError(false)
}
} catch (error) {
console.error('加载系统设置失败:', error)
} finally {
setSettingsLoaded(true)
}
}
loadSettings()
}, [])
useEffect(() => {
if (!loginLogoUrl) return
if (loginLogoUrl.startsWith('/')) {
setLogoError(false)
return
}
try {
const parsed = new URL(loginLogoUrl)
const host = parsed.hostname.toLowerCase()
const isLocal = host === 'localhost' || host === '127.0.0.1'
if (isLocal && typeof window !== 'undefined') {
const currentHost = window.location.hostname.toLowerCase()
if (currentHost !== host) {
console.warn(
`[Register] Logo 地址 ${loginLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位符`
)
setLogoError(true)
}
}
} catch (error) {
console.warn('[Register] Logo 地址解析失败,自动降级为占位符:', loginLogoUrl, error)
setLogoError(true)
}
}, [loginLogoUrl])
useEffect(() => {
if (!settingsLoaded) return
if (!inviteCode) return
fetchDepartments()
}, [inviteCode, settingsLoaded])
const fetchDepartments = async () => {
try {
const response = await fetch('/api/departments')
if (response.ok) {
const data = await response.json()
setDepartments(data)
}
} catch (error) {
console.error('Error fetching departments:', error)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!inviteCode) {
setError('注册入口未开放,请使用邀请链接或联系管理员开通账号')
return
}
if (password !== confirmPassword) {
setError('两次输入的密码不一致')
return
}
const passwordValidation = validatePasswordStrength(password)
if (!passwordValidation.valid) {
setError(passwordValidation.error || '密码不符合安全要求')
return
}
setLoading(true)
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, username, department, password, ...(inviteCode ? { inviteCode } : {}) }),
})
const data = await response.json()
if (response.ok) {
router.push('/login?registered=true')
} else {
setError(data.error || '注册失败')
}
} catch (error) {
console.error('Registration error:', error)
setError('注册失败,请稍后再试')
} finally {
setLoading(false)
}
}
return (
<div className={pageClass}>
<div className="max-w-md w-full">
{/* Logo */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
{loginLogoUrl && !logoError ? (
<Image
src={loginLogoUrl}
alt={siteName}
width={80}
height={80}
unoptimized
className="rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm"
onError={() => setLogoError(true)}
/>
) : (
<div className="w-20 h-20 rounded-lg border border-slate-200 dark:border-slate-700 shadow-sm bg-slate-900 dark:bg-slate-700 flex items-center justify-center">
<span className="text-3xl font-bold text-white">{siteName.charAt(0) || 'F'}</span>
</div>
)}
</div>
<h1 className="text-2xl font-semibold text-slate-900 dark:text-slate-100">{siteName}</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">
{inviteCode ? '受邀加入团队素材库' : '请使用邀请链接加入团队'}
</p>
</div>
{/* Register Form */}
<div className={panelClass}>
{!settingsLoaded ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-400">
正在检查注册入口...
</div>
) : isRegistrationClosed ? (
<div className="space-y-5">
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-400">
注册入口未开放。请使用管理员发送的邀请链接,或联系管理员开通账号。
</div>
<Link
href="/login"
className="block w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
>
返回登录
</Link>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg text-sm">
{error}
</div>
)}
{!inviteCode && (
<p className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800/50 dark:text-slate-400">
如无管理员开放的注册入口,请使用邀请链接或联系管理员开通账号。
</p>
)}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
邮箱
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={inputClass}
placeholder="your@email.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
用户名
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className={inputClass}
placeholder="选择一个用户名"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
部门
</label>
<select
value={department}
onChange={(e) => setDepartment(e.target.value)}
className={inputClass}
>
<option value="">请选择部门</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.name}>
{dept.name}
</option>
))}
</select>
{departments.length === 0 && (
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">
暂无可选部门,请联系管理员添加
</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
密码
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className={inputClass}
placeholder={PASSWORD_POLICY_DESCRIPTION}
required
minLength={10}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
确认密码
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={inputClass}
placeholder="••••••••"
required
minLength={10}
/>
</div>
<button
type="submit"
className={buttonClass}
disabled={loading}
>
{loading ? '注册中...' : '注册'}
</button>
</form>
)}
<div className="mt-6 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400">
已有账号?{' '}
<Link
href="/login"
className="text-slate-900 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-300 font-medium underline"
>
立即登录
</Link>
</p>
</div>
</div>
</div>
</div>
)
}