flnxi-Wu/app/login/page.tsx
WuYongJie d1c6efc79d
Some checks failed
CI Check / check (push) Has been cancelled
feat: NAS 部署与 Harbor 推送流程
2025-12-03 14:39:06 +08:00

413 lines
16 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 { signIn } from 'next-auth/react'
import { useRouter } from 'next/navigation'
import Image from 'next/image'
import Link from 'next/link'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showDingtalkModal, setShowDingtalkModal] = useState(false)
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
const [siteName, setSiteName] = useState('FLNXI素材库')
// Logo 加载状态
const [logoError, setLogoError] = useState(false)
const [logoRetryCount, setLogoRetryCount] = useState(0)
useEffect(() => {
if (!loginLogoUrl) 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(
`[Login] Logo 地址 ${loginLogoUrl} 指向本地服务,但当前访问域名为 ${currentHost},自动降级为占位符`
)
setLogoError(true)
}
}
} catch (error) {
console.warn('[Login] Logo 地址解析失败,自动降级为占位符:', loginLogoUrl, error)
setLogoError(true)
}
}, [loginLogoUrl])
// 钉钉配置(从环境变量或配置文件获取)
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
// 加载系统设置
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json()
setLoginLogoUrl(settings.loginLogoUrl || process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '')
setSiteName(settings.siteName || 'FLNXI素材库')
}
} catch (error) {
console.error('加载系统设置失败:', error)
}
}
loadSettings()
}, [])
// 页面加载时识别访问环境
useEffect(() => {
const envDomains = (process.env.NEXT_PUBLIC_PUBLIC_DOMAINS || '').split(',').map(s => s.trim()).filter(Boolean)
const isPublicDomain = envDomains.length > 0 ? envDomains.includes(window.location.hostname) : false
const isIntranet = window.location.hostname.includes('192.168.')
if (isPublicDomain) {
console.log('🌐 检测到公网访问 (' + window.location.hostname + ')')
} else if (isIntranet) {
console.log('🏠 检测到内网访问 (' + window.location.hostname + ')')
}
// 注意:内网检测已改为后端实现(/api/network-status)
// 登录成功后会在首页自动调用后端API判断是否需要显示切换提示
}, [])
// 处理从内网跳转来的钉钉登录请求
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search)
const dingtalkRedirect = urlParams.get('dingtalk_redirect')
if (dingtalkRedirect === 'true') {
console.log('🔄 检测到内网钉钉登录请求,自动触发登录...')
// 清理URL参数
window.history.replaceState({}, '', '/login')
// 等待页面加载完成后触发钉钉登录
const timer = setTimeout(() => {
document.getElementById('dingtalk-login-btn')?.click()
}, 500)
return () => clearTimeout(timer)
}
}, [])
// 处理钉钉登录成功后的自动登录(内网场景)
useEffect(() => {
const handleDingtalkAutoLogin = async () => {
const urlParams = new URLSearchParams(window.location.search)
const dingtalkSuccess = urlParams.get('dingtalk_success')
const userEmail = urlParams.get('email')
if (dingtalkSuccess === 'true' && userEmail) {
setLoading(true)
try {
// 在内网域名下创建 session
const result = await signIn('credentials', {
email: decodeURIComponent(userEmail),
password: 'dingtalk_auto_login',
redirect: false,
})
if (result?.error) {
setError('登录失败,请重试')
} else {
// 清除 URL 参数并跳转到首页
window.history.replaceState({}, '', '/login')
router.push('/')
router.refresh()
}
} catch (error) {
console.error('自动登录失败:', error)
setError('登录失败,请重试')
} finally {
setLoading(false)
}
}
}
handleDingtalkAutoLogin()
}, [router])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
if (result?.error) {
setError(result.error)
} else {
router.push('/')
router.refresh()
}
} catch (error) {
console.error('Login error:', error)
setError('登录失败,请稍后再试')
} finally {
setLoading(false)
}
}
const handleDingtalkLogin = () => {
// 强制使用外网域名作为回调地址,支持内网访问
const redirectUrl = process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_URL || `${window.location.origin}/dingtalk-callback`
// 在state中携带原始访问地址信息
const isIntranet = window.location.hostname.includes('192.168.')
const originUrl = isIntranet ? window.location.origin : ''
const stateData = JSON.stringify({ origin: originUrl })
const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?` +
`redirect_uri=${encodeURIComponent(redirectUrl)}` +
`&client_id=${DINGTALK_CLIENT_ID}` +
`&scope=openid` +
`&response_type=code` +
`&state=${encodeURIComponent(stateData)}` +
`&prompt=consent`
// 内网访问:由于混合内容限制,直接跳转到公网域名登录
if (isIntranet) {
console.log('🔄 内网环境,跳转到公网域名进行钉钉登录...')
const portal = process.env.NEXT_PUBLIC_PORTAL_BASE_URL || window.location.origin
window.location.href = `${portal}/login?dingtalk_redirect=true&return_url=${encodeURIComponent(originUrl)}`
return
}
// 公网访问:使用iframe模式
setShowDingtalkModal(true)
setTimeout(() => {
const iframe = document.getElementById('dingtalkIframe') as HTMLIFrameElement
if (iframe) {
iframe.src = dingtalkAuthUrl
}
}, 100)
}
// 监听来自 iframe 的消息
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
if (event.data && event.data.type === 'dingtalk_login_success') {
setShowDingtalkModal(false)
// 创建 NextAuth session
try {
const result = await signIn('credentials', {
email: event.data.userInfo.email,
password: 'dingtalk_auto_login',
redirect: false,
})
if (result?.error) {
setError('登录失败,请重试')
} else {
// 登录成功,智能跳转
const originUrl = event.data.originUrl
if (originUrl && originUrl.includes('192.168.')) {
// 从内网发起的登录,跳回内网
window.location.href = originUrl
} else if (window.location.hostname.includes('192.168.')) {
// 当前在内网,刷新页面
window.location.href = window.location.origin
} else {
// 外网访问,跳转到首页
router.push('/')
router.refresh()
}
}
} catch (error) {
console.error('创建session失败:', error)
setError('登录失败,请重试')
}
} else if (event.data && event.data.type === 'dingtalk_need_complete_profile') {
setShowDingtalkModal(false)
// 跳转到信息完善页面
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
router.push(`/complete-profile?userInfo=${userInfoParam}`)
} else if (event.data && event.data.type === 'dingtalk_login_error') {
setShowDingtalkModal(false)
setError(event.data.message || '钉钉登录失败')
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [router])
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">
{loginLogoUrl && !logoError ? (
<Image
key={logoRetryCount}
src={loginLogoUrl + (logoRetryCount > 0 ? `?retry=${logoRetryCount}` : '')}
alt={siteName}
width={80}
height={80}
unoptimized
className="rounded-apple shadow-apple"
onError={(e) => {
const img = e.target as HTMLImageElement
setLogoRetryCount((prevCount) => {
const newCount = prevCount + 1
if (newCount <= 3) {
// 重试加载(最多3次)
console.warn(`登录页 Logo 加载失败,正在重试 (${newCount}/3):`, loginLogoUrl)
setTimeout(() => {
img.src = loginLogoUrl + `?retry=${newCount}`
}, 1000 * newCount) // 递增延迟:1s, 2s, 3s
return newCount
} else {
// 重试失败,记录错误并显示占位符
console.error('登录页 Logo 加载失败,已重试3次:', loginLogoUrl)
setLogoError(true)
img.style.display = 'none'
return prevCount
}
})
}}
/>
) : (
<div className="w-20 h-20 rounded-apple shadow-apple bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center">
<span className="text-3xl font-bold text-white">{siteName.charAt(0)}</span>
</div>
)}
</div>
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">{siteName}</h1>
<p className="text-slate-600 dark:text-slate-400 mt-2">欢迎回来,请登录您的账号</p>
</div>
{/* Login Form */}
<div className="card-apple p-8">
<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>
)}
<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="input-apple"
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="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-apple"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
className="w-full btn-primary"
disabled={loading}
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<div className="mt-6">
{/* 分隔线 */}
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-slate-300 dark:border-slate-600"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">或</span>
</div>
</div>
{/* 钉钉登录按钮 */}
<button
id="dingtalk-login-btn"
type="button"
onClick={handleDingtalkLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 border border-slate-300 dark:border-slate-600 rounded-xl shadow-sm bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
>
<svg style={{ width: '28px', height: '28px' }} viewBox="0 0 1024 1024">
<path d="M512 2C230.2 2 2 230.2 2 512s228.2 510 510 510 510-228.2 510-510S793.3 2 512 2z m235.9 442c-1 4.6-3.6 10.8-7.2 19.1l-0.5 0.5c-21.6 45.8-77.3 135.5-77.3 135.5l-0.5-0.5-16.5 28.3h78.8L574.3 826.8l34-136h-61.8l21.6-90.2c-17.5 4.1-38.1 9.8-62.3 18 0 0-33 19.1-94.8-37.1 0 0-41.7-37.1-17.5-45.8 10.3-4.1 50-8.8 81.4-12.9 42.2-5.7 68.5-8.8 68.5-8.8s-130.3 2.1-161.2-3.1c-30.9-4.6-70.1-56.7-78.3-102 0 0-12.9-24.7 27.8-12.9 40.2 11.8 209.2 45.8 209.2 45.8S321.4 375 307 358.5c-14.4-16.5-42.8-89.6-39.2-134.5 0 0 1.5-11.3 12.9-8.2 0 0 161.8 74.2 272.5 114.4C664.5 371.4 760.8 392 747.9 444z" fill="#3296FA"></path>
</svg>
<span className="text-lg font-medium text-slate-700 dark:text-slate-300">钉钉登录</span>
</button>
</div>
<div className="mt-6 text-center">
<p className="text-sm text-slate-600 dark:text-slate-400">
还没有账号?{' '}
<Link
href="/register"
className="text-slate-900 dark:text-slate-100 hover:text-slate-700 dark:hover:text-slate-300 font-medium underline"
>
立即注册
</Link>
</p>
</div>
</div>
{/* 钉钉登录 iframe 弹窗 */}
{showDingtalkModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
onClick={() => setShowDingtalkModal(false)}
>
<div
className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-slate-200 dark:border-slate-700">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">钉钉扫码登录</h3>
<button
onClick={() => setShowDingtalkModal(false)}
className="text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-4">
<iframe
id="dingtalkIframe"
className="w-full h-96 border-0 rounded-lg"
title="钉钉登录"
/>
</div>
</div>
</div>
)}
</div>
</div>
)
}