'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useForm } from '@mantine/form' import { Box, TextInput, PasswordInput, Paper, Text, Button, LoadingOverlay, Stack, Flex, BackgroundImage, Tabs, FloatingIndicator, Input, useComputedColorScheme, } from '@mantine/core' import { showErrorMsg, showSuccessMsg } from '@/utils/message' import { useLoginStore } from './store' import DarkBgImage from './components/images/dark-login-bg.png' import LightBgImage from './components/images/light-login-bg.png' import loginBg2Dark from './components/images/login-bg2-dark.png' import loginBg2Light from './components/images/login-bg2-light.png' import LogoDigImage from './components/images/logo-dig.png' import tabClasses from './Tab.module.css' import { DigitalIcon } from './components/icons/digital' import { encrypt, getQueryVariable, validatePhoneNumber } from './util' import QrLogin from './components/login/qr-login' const HeaderIcon = DigitalIcon const headerTitle = '酷哇数字化平台' const fixedTenant = 'coowa' export default function LoginPage() { const colorScheme = useComputedColorScheme() const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [addrMap, setAddrMap] = useState>({}) const [activeTab, setActiveTab] = useState(() => { // 飞书 OAuth 回调(URL 带 ?code=xxx)时自动切到飞书 tab if (typeof window !== 'undefined' && getQueryVariable('code')) { return 'feishu' } return 'account' }) const [rootRef, setRootRef] = useState(null) const controlsRefs = useRef>({}) const setControlRef = (val: string) => (node: HTMLButtonElement) => { controlsRefs.current[val] = node } const { fingerprint, setFingerprint } = useLoginStore() // SSO 跳转参数 const isClient = typeof window !== 'undefined' const appId = isClient ? (getQueryVariable('app_id') as string) || '' : '' const appUrl = isClient ? decodeURIComponent((getQueryVariable('app_url') as string) || '') : '' // 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数,存 localStorage useEffect(() => { if (appId && appUrl) { localStorage.setItem(appId, appUrl) // app_id -> app_url localStorage.setItem('sso_app_id', appId) // 记住当前 app_id } }, [appId, appUrl]) // 获取租户简称列表(与 uirefbase 一致) useEffect(() => { fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/front/getAbbrList`, { method: 'GET', headers: { 'Content-type': 'application/json' }, }) .then((response) => response.json()) .then((res) => { setAddrMap(res?.data?.keys || {}) }) .catch(() => {}) }, []) // 生成浏览器指纹 useEffect(() => { const ua = navigator.userAgent const screenInfo = `${screen.width}x${screen.height}` const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const raw = `${ua}|${screenInfo}|${timezone}` let hash = 0 for (let i = 0; i < raw.length; i++) { hash = (hash << 5) - hash + raw.charCodeAt(i) hash |= 0 } setFingerprint(Math.abs(hash).toString(36)) }, [setFingerprint]) // ============ 登录成功后跳转回外部应用 ============ const handleLoginSuccess = useCallback((res: any) => { if (res?.error) { setError(res.error || '登录失败,请重试') return } if (!res?.user_info?.account) { setError('登录失败:后端未返回有效用户信息') return } // 从 localStorage 获取外部应用地址 const storedAppId = appId || localStorage.getItem('sso_app_id') || '' const targetUrl = appUrl || (storedAppId ? localStorage.getItem(storedAppId) || '' : '') if (res?.code && targetUrl) { const separator = targetUrl.includes('?') ? '&' : '?' window.location.href = `${targetUrl}${separator}sso_code=${res.code}` return } // 无外部应用地址 — sso-mock 不提供本地登录 setError('缺少外部应用回调地址(app_url),无法完成 SSO 跳转') }, [appId, appUrl]) // ============ 统一调用 /api/login ============ // SSO 参数优先取 URL,其次取 localStorage(飞书 OAuth 回调后 URL 参数丢失) const postLogin = async (payload: object) => { const effectiveAppId = appId || localStorage.getItem('sso_app_id') || '' const effectiveAppUrl = appUrl || (effectiveAppId ? localStorage.getItem(effectiveAppId) || '' : '') const res = await fetch( `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/login`, { method: 'POST', body: JSON.stringify( encrypt( JSON.stringify({ ...payload, fingerprint, ...(effectiveAppId ? { client_id: effectiveAppId } : {}), ...(effectiveAppUrl ? { client_redirect_uri: effectiveAppUrl } : {}), }) ) ), } ) .then((r) => r.text()) .then((t) => JSON.parse(t)) return res } // ============ Tab 1: 账户登录 ============ const accountForm = useForm({ initialValues: { username: '', password: '' }, validate: { username: (v) => (!v ? '账户名不能为空' : null), password: (v) => (!v ? '密码不能为空' : null), }, validateInputOnChange: true, validateInputOnBlur: true, clearInputErrorOnChange: true, }) const handleAccountSubmit = accountForm.onSubmit(async (values) => { setLoading(true) setError(null) try { const res = await postLogin({ account: values.username, password: values.password, abbr: fixedTenant, type: 'account', }) handleLoginSuccess(res) } catch (err) { setError(err instanceof Error ? err.message : '登录失败,请重试') } finally { setLoading(false) } }) // ============ Tab 2: 手机号登录 ============ const [codeTime, setCodeTime] = useState(0) const handleCountDown = () => { setCodeTime(60) const timer = setInterval(() => { setCodeTime((t) => { if (t <= 1) { clearInterval(timer) return 0 } return t - 1 }) }, 1000) } const handleGetCode = async () => { if (!validatePhoneNumber(phoneForm.values.phone)) { setError('请输入正确的手机号') return } try { const tntkey = addrMap?.[fixedTenant] || '' if (!tntkey) { showErrorMsg('请输入正确的租户') return } const res: any = await fetch( `${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/getVerifyCode`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: phoneForm.values.phone.startsWith('+86') ? phoneForm.values.phone : `+86${phoneForm.values.phone}`, tntkey, }), } ).then((r) => r.json()) if (res?.code === 200) { showSuccessMsg('验证码已发送, 请注意查收') handleCountDown() } else { showErrorMsg(res?.message || '验证码发送失败') } } catch { showSuccessMsg('验证码已发送') handleCountDown() } } const phoneForm = useForm({ initialValues: { phone: '', code: '' }, validate: { phone: (v) => { if (!v) return '手机号不能为空' if (v.length !== 11) return '手机号必须为11位' return null }, code: (v) => { if (!v) return '验证码不能为空' if (v.length !== 6) return '验证码必须为6位' return null }, }, validateInputOnChange: true, validateInputOnBlur: true, clearInputErrorOnChange: true, }) const handlePhoneSubmit = phoneForm.onSubmit(async (values) => { setLoading(true) setError(null) try { const tntkey = addrMap?.[fixedTenant] || '' if (!tntkey) { setError('请输入正确的租户') setLoading(false) return } const res = await postLogin({ phone: values.phone.startsWith('+86') ? values.phone : `+86${values.phone}`, verify_code: values.code, tntkey, type: 'phone', }) handleLoginSuccess(res) } catch (err) { setError(err instanceof Error ? err.message : '登录失败,请重试') } finally { setLoading(false) } }) // ============ 错误提示 ============ useEffect(() => { if (error) showErrorMsg(error) }, [error]) const bgImage = useMemo( () => (colorScheme === 'dark' ? DarkBgImage : LightBgImage), [colorScheme] ) const stackBg2Image = useMemo( () => (colorScheme === 'dark' ? loginBg2Dark : loginBg2Light), [colorScheme] ) // 飞书 OAuth 回调中:显示 loading,不渲染表单 const isFeishuCallback = isClient && !!getQueryVariable('code') if (isFeishuCallback) { return ( 正在验证飞书登录... {/* QrLogin 仍在后台执行 feishuLogin */} ) } return ( {/* 左侧装饰区域 */} {/* 右侧登录表单 */} {headerTitle} {/* SSO 来源提示 */} {appId && ( 正在为应用 {appId} 进行登录认证 )} 账户登录 手机号登录 飞书扫码 {/* 账户登录 */}
{/* 手机号登录 */}
{/* 飞书扫码(真实二维码) */} {isClient && ( )}
) }