- 修复内置模板验证问题(builtin-video-material等) - 添加模板验证工具 lib/template-validator.ts - 创建模板系统优化需求文档(30个详细需求) - 优化上传API的模板验证逻辑 - 改进错误提示信息的清晰度 - 添加字段池和分类管理相关需求 - 完善自定义字段的创建、验证、存储逻辑 - 清理过期的 .specstory 和 .trae 文件
424 lines
16 KiB
TypeScript
424 lines
16 KiB
TypeScript
'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'
|
||
|
||
// 默认登录页 Logo:优先使用构建时环境变量,否则使用内置静态资源
|
||
const DEFAULT_LOGIN_LOGO =
|
||
process.env.NEXT_PUBLIC_DEFAULT_LOGIN_LOGO_URL || '/logo/logo2.png'
|
||
|
||
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(DEFAULT_LOGIN_LOGO)
|
||
const [siteName, setSiteName] = useState('FLNXI素材库')
|
||
|
||
// Logo 加载状态
|
||
const [logoError, setLogoError] = useState(false)
|
||
const [logoRetryCount, setLogoRetryCount] = useState(0)
|
||
|
||
useEffect(() => {
|
||
if (!loginLogoUrl) return
|
||
|
||
// 对于以 / 开头的相对路径(如 /logo/logo1.png),直接认为是站内静态资源,不做 URL 解析
|
||
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(
|
||
`[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 || DEFAULT_LOGIN_LOGO)
|
||
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>
|
||
)
|
||
}
|
||
|
||
|