gongxiangp2/app/login/page.tsx
chenpei 6c6c282203 feat: 全站视觉统一白皮书语言 + 个人档案改造 + 顶栏平台化
视觉统一(Zen Minimal × Future Organic 白皮书语言,全站浅色单主题):
- 登录页:品牌→由壹点·品牌中心,白纸底+发丝线+柔光光晕,黑按钮
- 作品过稿:深灰头→白皮书,返回链接→返回创意资产库
- 绩效考核:桌面+移动双头部换白皮书,标题改绩效考核,返回改链接
- 组织架构:BoardShell+页面 76 处调色板全量替换(#eef1f4→#fafafa 等)
- admin 后台:MaterialsHeader(面包屑治理与设置),Tab 栏墨色高亮
- 深色主题废弃:globals.css 移除媒体查询,tailwind darkMode:'class' 不挂 .dark
- 设计文档同步更新:AGENTS.md UI 基线/DESIGN.md/PRODUCT.md/README.md/design-qa.md

个人档案(原个人中心):
- API 合并权威人员档案(getEmployeeByUserId → 花名/工号/部门/职位/M-P等级/主管/状态)
- 页面白皮书化:权威档案卡(只读 HR 字段)+ 账号区(头像上传)+ 素材/收藏

顶栏平台化:
- MaterialsHeader 双模式:首页(五板块导航+全套工具+菜单)/板块(面包屑)
- 菜单重排:我的档案/绩效考核/作品过稿看板‖更新记录/提交反馈‖治理与设置‖退出
- 菜单头身份行:花名+职位·部门(权威档案)
- 首页排序:01 创意资产库(全员)→ 02 组织管理(管理员)→ 03-05 规划中
- 素材库改名创意资产库,全站同步(索引/导航/卡片/按钮/页脚)
- 排行榜换白皮书(白底发丝线+琥珀前三名)

构建修复:
- tsconfig 排除 output/(本地审计临时文件不再卡类型检查)
- 存储边界门禁豁免 output/(与 .dockerignore 对齐)
2026-08-15 14:16:20 +08:00

391 lines
15 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 Link from 'next/link'
import BrandLogo from '@/components/BrandLogo'
import { DEFAULT_LOGIN_LOGO_URL } from '@/lib/brand-assets'
import { parseApiError } from '@/lib/error-handler'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { normalizeInternalRedirectPath } from '@/lib/safe-redirect'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
// 注意:钉钉登录现在使用新窗口模式,不再需要iframe弹窗
// 系统设置
const [loginLogoUrl, setLoginLogoUrl] = useState(DEFAULT_LOGIN_LOGO_URL)
// 钉钉配置(从环境变量或配置文件获取)
const DINGTALK_CLIENT_ID = process.env.NEXT_PUBLIC_DINGTALK_CLIENT_ID || 'ding1bs1gxwrcfu16x0f'
const publicAppOrigin = getPublicAppOrigin()
const dingtalkCallbackOrigin =
process.env.NEXT_PUBLIC_DINGTALK_CALLBACK_ORIGIN?.trim() || publicAppOrigin
const getLoginRedirectPath = () => {
if (typeof window === 'undefined') return '/'
return normalizeInternalRedirectPath(new URLSearchParams(window.location.search).get('redirect'))
}
useEffect(() => {
if (typeof window === 'undefined') return
const urlParams = new URLSearchParams(window.location.search)
if (
urlParams.get('dingtalk_redirect') === 'true' ||
urlParams.get('dingtalk_success') === 'true'
) {
return
}
const isDingtalkClient = /DingTalk/i.test(window.navigator.userAgent || '')
if (!isDingtalkClient) return
const redirectPath = normalizeInternalRedirectPath(urlParams.get('redirect'), '/')
window.location.replace(`/dingtalk-entry?redirect=${encodeURIComponent(redirectPath)}`)
}, [])
// 加载系统设置
useEffect(() => {
const loadSettings = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const settings = await response.json()
const logoUrl = settings.loginLogoUrl || DEFAULT_LOGIN_LOGO_URL
setLoginLogoUrl(logoUrl)
}
} catch (error) {
console.error('加载系统设置失败:', error)
setLoginLogoUrl(DEFAULT_LOGIN_LOGO_URL)
}
}
loadSettings()
}, [])
// 页面加载时识别访问环境
useEffect(() => {
// 不输出hostname信息,避免在浏览器控制台暴露内网信息
// 注意:内网检测已改为后端实现(/api/network-status)
// 登录成功后会在首页自动调用后端API判断是否需要显示切换提示
}, [])
// 处理从内网跳转来的钉钉登录请求
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search)
const dingtalkRedirect = urlParams.get('dingtalk_redirect')
if (dingtalkRedirect === 'true') {
const redirectPath = normalizeInternalRedirectPath(urlParams.get('redirect'))
console.log('🔄 检测到内网钉钉登录请求,自动触发登录...')
// 清理URL参数
window.history.replaceState({}, '', `/login?redirect=${encodeURIComponent(redirectPath)}`)
// 等待页面加载完成后触发钉钉登录
const timer = setTimeout(() => {
handleDingtalkLogin({ redirectPath, useCurrentWindow: true })
}, 500)
return () => clearTimeout(timer)
}
// 自动钉钉入口只应在页面首次打开时判断一次,后续渲染不重复拉起授权。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
console.log('🔐 开始登录流程...', { email, hasPassword: !!password })
// 验证输入
if (!email || !password) {
setError('请输入邮箱和密码')
return
}
setError('')
setLoading(true)
try {
console.log('📤 调用 signIn...')
const result = await signIn('credentials', {
email: email.trim(),
password,
redirect: false,
})
console.log('📥 signIn 返回结果:', result)
// 处理结果
if (result?.error) {
console.error('❌ 登录失败:', result.error)
setError(result.error === 'CredentialsSignin' ? '邮箱或密码错误' : result.error)
} else if (result?.ok !== false) {
// result.ok 为 true 或 undefined 都视为成功
console.log('✅ 登录成功,等待 cookie 设置完成...')
// 等待一小段时间,确保 NextAuth 的 cookie 已经设置完成
// 这样可以避免 middleware 检测不到 token 而重定向回登录页
// 增加延迟时间,确保 cookie 已经写入浏览器
await new Promise(resolve => setTimeout(resolve, 300))
console.log('🔄 开始跳转到首页...')
// 使用 window.location.href 强制跳转,确保跳转生效
// 使用完整的 URL 可以确保 cookie 正确传递
window.location.href = window.location.origin + getLoginRedirectPath()
// 注意:跳转后不需要 setLoading(false),因为页面会刷新
return
} else {
console.warn('⚠️ signIn 返回了意外的结果:', result)
setError('登录失败,请稍后再试')
}
} catch (error: any) {
console.error('❌ 登录异常:', error)
const { message } = parseApiError(error)
setError(message)
} finally {
setLoading(false)
}
}
const handleDingtalkLogin = async (options?: { redirectPath?: string; useCurrentWindow?: boolean }) => {
const redirectUrl = `${dingtalkCallbackOrigin}/dingtalk-callback`
const redirectPath = normalizeInternalRedirectPath(options?.redirectPath ?? getLoginRedirectPath())
const nonce = typeof window.crypto?.randomUUID === 'function'
? window.crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`
let handoffId = ''
// 内网入口先在本机写入短时 HttpOnly 绑定 Cookie。OAuth 回到公网后只能携带无语义 ID,
// 最终仍必须由同一浏览器带着该 Cookie 消费一次性票据,避免登录注入和开放重定向。
try {
const handoffResponse = await fetch('/api/dingtalk/session-transfer/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ redirectPath }),
})
const handoffData = handoffResponse.ok
? await handoffResponse.json().catch(() => null)
: null
if (handoffData?.enabled && typeof handoffData.handoffId === 'string') {
handoffId = handoffData.handoffId
}
} catch (error) {
// 交接不可用时仍允许原有公网钉钉登录,不能把用户卡在登录页。
console.warn('创建钉钉内网登录交接请求失败,将使用公网登录:', error)
}
const stateData = JSON.stringify({ redirect: redirectPath, nonce, handoffId })
const params = new URLSearchParams({
redirect_uri: redirectUrl,
client_id: DINGTALK_CLIENT_ID,
scope: 'openid',
response_type: 'code',
state: stateData,
})
if (!options?.useCurrentWindow) {
params.set('prompt', 'consent')
}
const dingtalkAuthUrl = `https://login.dingtalk.com/oauth2/auth?${params.toString()}`
if (options?.useCurrentWindow) {
window.location.href = dingtalkAuthUrl
return
}
// 使用新窗口打开钉钉登录,避免iframe的cookie限制问题
// 新窗口可以正常访问cookie,解决"授权失败"的问题
const width = 500
const height = 600
const left = (window.screen.width - width) / 2
const top = (window.screen.height - height) / 2
const popup = window.open(
dingtalkAuthUrl,
'钉钉登录',
`width=${width},height=${height},left=${left},top=${top},resizable=yes,scrollbars=yes`
)
if (!popup) {
setError('无法打开登录窗口,请检查浏览器弹窗设置')
return
}
// 监听新窗口的消息(从回调页面发送)
const callbackMessageOrigin = new URL(dingtalkCallbackOrigin).origin
const handleMessage = async (event: MessageEvent) => {
if (event.origin !== callbackMessageOrigin) {
return
}
if (!event.data || event.data.nonce !== nonce) {
return
}
if (event.data.type === 'dingtalk_login_success') {
popup?.close()
window.removeEventListener('message', handleMessage)
const targetPath = normalizeInternalRedirectPath(event.data.redirectUrl ?? redirectPath)
const handoff = event.data.sessionHandoff
const canReturnToCurrentOrigin =
typeof handoff?.ticket === 'string' &&
handoff.nonce === nonce &&
handoff.targetOrigin === window.location.origin
if (canReturnToCurrentOrigin) {
const ticketUrl = new URL('/api/dingtalk/session-ticket', window.location.origin)
ticketUrl.searchParams.set('ticket', handoff.ticket)
window.location.href = ticketUrl.toString()
return
}
window.location.href = new URL(targetPath, dingtalkCallbackOrigin).toString()
} else if (event.data.type === 'dingtalk_need_complete_profile') {
popup?.close()
window.removeEventListener('message', handleMessage)
const userInfoParam = encodeURIComponent(JSON.stringify(event.data.userInfo))
window.location.href = new URL(
`/complete-profile?userInfo=${userInfoParam}`,
dingtalkCallbackOrigin,
).toString()
} else if (event.data && event.data.type === 'dingtalk_login_error') {
popup?.close()
window.removeEventListener('message', handleMessage)
setError(event.data.message || '钉钉登录失败')
}
}
window.addEventListener('message', handleMessage)
// 检查新窗口是否被关闭(用户手动关闭)
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed)
window.removeEventListener('message', handleMessage)
}
}, 1000)
}
// 注意:钉钉登录现在使用新窗口模式,消息监听在 handleDingtalkLogin 中处理
// 这样可以避免iframe的cookie限制问题
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#fafafa] p-4">
{/* 柔光渐变光晕(平台首页同款氛围) */}
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 right-[-80px] h-[420px] w-[420px] rounded-full md:h-[520px] md:w-[520px]"
style={{
background:
'radial-gradient(circle at 38% 40%, #eef0fa 0%, #dfe3f6 32%, rgba(224,228,248,0.55) 55%, rgba(250,250,250,0) 72%)',
}}
/>
<div className="relative w-full max-w-md">
{/* 品牌 */}
<div className="mb-8 text-center">
<div className="mb-4 flex justify-center">
<BrandLogo
src={loginLogoUrl}
fallbackSrc={DEFAULT_LOGIN_LOGO_URL}
alt=""
width={72}
height={72}
unoptimized
className="rounded-xl border border-[#e7e8ea]"
/>
</div>
<h1 className="text-[28px] font-semibold tracking-tight text-[#1a1c1d]">由壹点·品牌中心</h1>
<p className="mt-2 text-[13px] text-[#6b7075]">欢迎回来,请登录您的账号</p>
</div>
{/* 登录表单 */}
<div className="rounded-xl border border-[#e7e8ea] bg-white 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-gray-700 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-gray-700 mb-2">
密码
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input-apple"
placeholder="••••••••"
required
/>
</div>
<button
type="submit"
className="min-h-11 w-full rounded-lg bg-[#1a1c1d] text-[14px] font-medium text-white transition-all hover:opacity-80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50"
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-[#e7e8ea]"></div>
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white px-2 text-[13px] text-[#86898e]">或</span>
</div>
</div>
{/* 钉钉登录按钮 */}
<button
id="dingtalk-login-btn"
type="button"
onClick={() => handleDingtalkLogin()}
className="flex w-full items-center justify-center gap-3 rounded-lg border border-[#e7e8ea] bg-white px-4 py-3 transition-colors hover:bg-[#fafafa]"
>
<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-gray-700">钉钉登录</span>
</button>
</div>
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
还没有账号?{' '}
<Link
href="/register"
className="text-gray-900 hover:text-gray-700 font-medium underline"
>
立即注册
</Link>
</p>
</div>
</div>
</div>
</div>
)
}