Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
294 lines
9.7 KiB
TypeScript
294 lines
9.7 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, Suspense } from 'react'
|
||
import { useRouter, useSearchParams } from 'next/navigation'
|
||
import Image from 'next/image'
|
||
import BrandLogo from '@/components/BrandLogo'
|
||
import { parseApiError, parseFetchError } from '@/lib/error-handler'
|
||
import { DEFAULT_LOGIN_LOGO_URL, DEFAULT_SITE_NAME } from '@/lib/brand-assets'
|
||
|
||
interface Department {
|
||
id: string
|
||
name: string
|
||
}
|
||
|
||
function CompleteProfileContent() {
|
||
const router = useRouter()
|
||
const searchParams = useSearchParams()
|
||
|
||
const [department, setDepartment] = useState('')
|
||
const [departments, setDepartments] = useState<Department[]>([])
|
||
const [loading, setLoading] = useState(false)
|
||
const [error, setError] = useState('')
|
||
const [success, setSuccess] = useState('')
|
||
const [userInfo, setUserInfo] = useState<any>(null)
|
||
|
||
// 系统设置
|
||
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO_URL)
|
||
const [siteName, setSiteName] = useState(DEFAULT_SITE_NAME)
|
||
|
||
useEffect(() => {
|
||
// 加载系统设置
|
||
const loadSettings = async () => {
|
||
try {
|
||
const response = await fetch('/api/system-settings')
|
||
if (response.ok) {
|
||
const settings = await response.json()
|
||
setLoginLogoUrl(settings.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL)
|
||
setSiteName(settings.siteName || DEFAULT_SITE_NAME)
|
||
}
|
||
} catch (error) {
|
||
console.error('加载系统设置失败:', error)
|
||
}
|
||
}
|
||
|
||
// 加载部门列表
|
||
const loadDepartments = async () => {
|
||
try {
|
||
const response = await fetch('/api/departments')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setDepartments(data || [])
|
||
}
|
||
} catch (error) {
|
||
console.error('加载部门列表失败:', error)
|
||
}
|
||
}
|
||
|
||
// 从URL参数获取用户信息
|
||
const userInfoParam = searchParams?.get('userInfo')
|
||
if (userInfoParam) {
|
||
try {
|
||
const info = JSON.parse(decodeURIComponent(userInfoParam))
|
||
setUserInfo(info)
|
||
} catch (error) {
|
||
console.error('解析用户信息失败:', error)
|
||
setError('无效的用户信息,请重新登录')
|
||
}
|
||
} else {
|
||
setError('缺少用户信息,请重新登录')
|
||
}
|
||
|
||
loadSettings()
|
||
loadDepartments()
|
||
}, [searchParams])
|
||
|
||
const handleSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
setError('')
|
||
|
||
if (!department) {
|
||
setError('请选择部门')
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
setError('') // 清空错误信息
|
||
setSuccess('') // 清空成功信息
|
||
|
||
try {
|
||
// 调用API完善用户信息(只需要选择部门)
|
||
const response = await fetch('/api/dingtalk/complete-profile', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
userId: userInfo?.id,
|
||
department,
|
||
}),
|
||
})
|
||
|
||
const data = await response.json()
|
||
|
||
if (!response.ok || !data.success) {
|
||
throw new Error(data.message || '信息提交失败')
|
||
}
|
||
|
||
// 如果是账号关联(关联到已有账号),提示用户使用原账号密码登录
|
||
if (data.isAccountLink) {
|
||
setSuccess('账号关联成功!正在跳转到登录页面,您可以使用原账号密码登录或继续使用钉钉扫码登录。')
|
||
setTimeout(() => {
|
||
// 智能跳转:保持在内网或外网环境
|
||
if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {
|
||
window.location.href = `${window.location.origin}/login`
|
||
} else {
|
||
router.push('/login')
|
||
}
|
||
}, 3000)
|
||
return
|
||
}
|
||
|
||
// 检查新用户是否已激活
|
||
if (data.user && !data.user.isActive) {
|
||
setSuccess('信息提交成功!您的账号需要管理员激活后才能登录使用。')
|
||
setTimeout(() => {
|
||
// 智能跳转:保持在内网或外网环境
|
||
if (typeof window !== 'undefined' && window.location.hostname.includes('192.168.')) {
|
||
window.location.href = `${window.location.origin}/login`
|
||
} else {
|
||
router.push('/login')
|
||
}
|
||
}, 3000)
|
||
return
|
||
}
|
||
|
||
setSuccess('信息提交成功')
|
||
router.push('/')
|
||
router.refresh()
|
||
} catch (error: any) {
|
||
console.error('提交失败:', error)
|
||
const { message } = parseApiError(error)
|
||
setError(message)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
if (error && !userInfo) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
||
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8">
|
||
<div className="text-center">
|
||
<div className="mb-4">
|
||
<svg className="mx-auto h-12 w-12 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||
</svg>
|
||
</div>
|
||
<h2 className="text-xl font-semibold mb-2 text-red-600">出错了</h2>
|
||
<p className="text-gray-600 mb-4">{error}</p>
|
||
<button
|
||
onClick={() => router.push('/login')}
|
||
className="btn-primary"
|
||
>
|
||
返回登录
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100 p-4">
|
||
<div className="max-w-md w-full">
|
||
{/* Logo */}
|
||
<div className="text-center mb-8">
|
||
<div className="flex justify-center mb-4">
|
||
<BrandLogo
|
||
src={loginLogoUrl}
|
||
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
|
||
alt={siteName}
|
||
width={80}
|
||
height={80}
|
||
unoptimized
|
||
className="rounded-apple shadow-apple"
|
||
/>
|
||
</div>
|
||
<h1 className="text-3xl font-bold text-gray-900">完善个人信息</h1>
|
||
<p className="text-gray-600 mt-2">首次登录,请完善您的用户信息</p>
|
||
</div>
|
||
|
||
{/* Form */}
|
||
<div className="card-apple p-8">
|
||
{userInfo && (
|
||
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||
<div className="flex items-center gap-3">
|
||
{userInfo.avatar && (
|
||
<Image
|
||
src={userInfo.avatar}
|
||
alt={userInfo.realName}
|
||
width={48}
|
||
height={48}
|
||
className="rounded-full"
|
||
unoptimized
|
||
/>
|
||
)}
|
||
<div>
|
||
<p className="text-sm text-gray-600">钉钉账号</p>
|
||
<p className="font-medium text-gray-900">{userInfo.realName}</p>
|
||
</div>
|
||
</div>
|
||
</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-apple text-sm">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{success && (
|
||
<div className="bg-green-50 border border-green-200 text-green-600 px-4 py-3 rounded-apple text-sm">
|
||
{success}
|
||
</div>
|
||
)}
|
||
|
||
{/* 显示钉钉用户名(只读) */}
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
用户名
|
||
</label>
|
||
<div className="input-apple bg-gray-50 text-gray-700 cursor-not-allowed">
|
||
{userInfo?.username || userInfo?.realName || '加载中...'}
|
||
</div>
|
||
<p className="mt-1 text-xs text-gray-500">使用您的钉钉姓名作为系统用户名</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||
所属部门 *
|
||
</label>
|
||
<select
|
||
value={department}
|
||
onChange={(e) => setDepartment(e.target.value)}
|
||
className="input-apple"
|
||
required
|
||
>
|
||
<option value="">请选择部门</option>
|
||
{departments.map((dept) => (
|
||
<option key={dept.id} value={dept.name}>
|
||
{dept.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
className="w-full btn-primary"
|
||
disabled={loading}
|
||
>
|
||
{loading ? '提交中...' : '完成并登录'}
|
||
</button>
|
||
</form>
|
||
|
||
<div className="mt-6 text-center">
|
||
<button
|
||
onClick={() => router.push('/login')}
|
||
className="text-sm text-gray-600 hover:text-gray-900"
|
||
>
|
||
返回登录
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function CompleteProfilePage() {
|
||
return (
|
||
<Suspense fallback={
|
||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
||
<div className="text-center">
|
||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
|
||
<p className="mt-4 text-gray-600">加载中...</p>
|
||
</div>
|
||
</div>
|
||
}>
|
||
<CompleteProfileContent />
|
||
</Suspense>
|
||
)
|
||
}
|
||
|