chore(sso-portal): 整合近期重构、UX 改进、部署脚本与文档
项目改名 sso-mock → sso-portal: - 重命名仓库目录 sso-mock/ → sso-portal/ - package.json name 改 sso-portal - app/layout.tsx title/description 改 SSO Portal - 源码 [sso-mock] console.log 前缀、注释全部 → [sso-portal] - 文档(README、ADAPTATION_GUIDE)、env 同步 - 生产 svc 名 sso-mock-svc → sso-portal-svc(external-app 引用同步) SSO tenant 改环境变量驱动: - 新增 SSO_TENANT env(默认 cowarobot) - login/route.ts 顶部 const SSO_TENANT,ssoBody 显式使用 - callSsoApi 改回纯透传(不再硬编码覆盖 tenant) - external-app 同步用 SSO_TENANT,命名对齐 external-app 登录态改为基于 token: - page.tsx 读 token cookie 判定 authenticated(不再依赖 user cookie) - DashboardPage 用 authenticated 决定跳转,user 为 null 时优雅兜底展示 - 解决「200 后又跳回 SSO」bug(access_token 接口不返回 user) sso-portal 缺 SSO 参数体验优化: - 缺 app_id/app_url 时 Alert 提示 + 禁用所有输入和登录按钮 - components/login/index.tsx 加 missingSsoParams 检测 - Alert 样式两排完整显示 部署脚本(参考 uirefbase): - 新增 build.sh(git → pnpm install → build → docker build → tag → push) - 新增 .gitlab-ci.yml(main 分支触发 build.sh prod) - 新增 Dockerfile(standalone 模式,端口 5501) - package.json 加 env-cmd 依赖 + build:stage/build 脚本 - next.config.ts 保持 output: 'standalone' 代码清理: - 删除 utils/code-store.ts - 删除 app/api/auth/verify/(连同目录) - 移除旧的 codeStore / generateCode 逻辑 文档: - ADAPTATION_GUIDE.md:完整重写对齐真实后端三个 SSO 接口(/sso/account、/sso/phone/smscode、/sso/access_token) - 新增 INTEGRATION_GUIDE.md(从 external-app 迁移过来作为通用接入指南) - README.md 同步更新(sso-portal 仓库门面 + 文档索引) - external-app/README.md 加交叉链接指向 sso-portal 的接入文档
This commit is contained in:
@@ -12,7 +12,7 @@ import {
|
||||
} from '../../util'
|
||||
|
||||
/**
|
||||
* 飞书二维码登录组件(sso-mock 版本)
|
||||
* 飞书二维码登录组件(sso-portal 版本)
|
||||
*
|
||||
* 流程:
|
||||
* 1. 加载飞书 SDK,渲染真实二维码
|
||||
@@ -25,7 +25,7 @@ const QrLogin = ({
|
||||
}: {
|
||||
onLoginSuccess: (res: any) => void
|
||||
}) => {
|
||||
// 飞书 OAuth 回调地址:始终指向 sso-mock 自己的 /login
|
||||
// 飞书 OAuth 回调地址:始终指向 sso-portal 自己的 /login
|
||||
const redirect_url =
|
||||
window.location.protocol +
|
||||
'//' +
|
||||
@@ -47,7 +47,7 @@ const QrLogin = ({
|
||||
const renderCode = useCallback(async () => {
|
||||
await qr_load()
|
||||
// APP_ID: 飞书应用 ID
|
||||
// redirect_url: 飞书 OAuth 回调地址(sso-mock 自身)
|
||||
// redirect_url: 飞书 OAuth 回调地址(sso-portal 自身)
|
||||
// state: SSO 的 app_id(传给飞书,回调时原样返回)
|
||||
qr_login(APP_ID, redirect_url, ssoAppId)
|
||||
}, [redirect_url, ssoAppId])
|
||||
|
||||
@@ -1,361 +1,382 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useForm } from '@mantine/form'
|
||||
import { showErrorMsg, showSuccessMsg } from "@/utils/message";
|
||||
import {
|
||||
Box,
|
||||
TextInput,
|
||||
PasswordInput,
|
||||
Paper,
|
||||
Text,
|
||||
Button,
|
||||
LoadingOverlay,
|
||||
Stack,
|
||||
Flex,
|
||||
Alert,
|
||||
BackgroundImage,
|
||||
Tabs,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
FloatingIndicator,
|
||||
Input,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
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'
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { IconAlertCircle } from "@tabler/icons-react";
|
||||
import { DigitalIcon } from "./components/icons/digital";
|
||||
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 QrLogin from "./components/login/qr-login";
|
||||
import { useLoginStore } from "./store";
|
||||
import tabClasses from "./Tab.module.css";
|
||||
import { encrypt, getQueryVariable, validatePhoneNumber } from "./util";
|
||||
|
||||
const HeaderIcon = DigitalIcon
|
||||
const headerTitle = '酷哇数字化平台'
|
||||
const fixedTenant = 'coowa'
|
||||
const HeaderIcon = DigitalIcon;
|
||||
const headerTitle = "酷哇数字化平台";
|
||||
const fixedTenant = "coowa";
|
||||
|
||||
export default function LoginPage() {
|
||||
const colorScheme = useComputedColorScheme()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [addrMap, setAddrMap] = useState<Record<string, string>>({})
|
||||
const colorScheme = useComputedColorScheme();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [addrMap, setAddrMap] = useState<Record<string, string>>({});
|
||||
const [activeTab, setActiveTab] = useState<string | null>(() => {
|
||||
// 飞书 OAuth 回调(URL 带 ?code=xxx)时自动切到飞书 tab
|
||||
if (typeof window !== 'undefined' && getQueryVariable('code')) {
|
||||
return 'feishu'
|
||||
if (typeof window !== "undefined" && getQueryVariable("code")) {
|
||||
return "feishu";
|
||||
}
|
||||
return 'account'
|
||||
})
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
|
||||
const controlsRefs = useRef<Record<string, HTMLButtonElement | null>>({})
|
||||
return "account";
|
||||
});
|
||||
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||
const controlsRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const setControlRef = (val: string) => (node: HTMLButtonElement) => {
|
||||
controlsRefs.current[val] = node
|
||||
}
|
||||
controlsRefs.current[val] = node;
|
||||
};
|
||||
|
||||
const { fingerprint, setFingerprint } = useLoginStore()
|
||||
const { fingerprint, setFingerprint } = useLoginStore();
|
||||
|
||||
// SSO 跳转参数
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const appId = isClient ? (getQueryVariable('app_id') as string) || '' : ''
|
||||
const isClient = typeof window !== "undefined";
|
||||
const appId = isClient ? (getQueryVariable("app_id") as string) || "" : "";
|
||||
const appUrl = isClient
|
||||
? decodeURIComponent((getQueryVariable('app_url') as string) || '')
|
||||
: ''
|
||||
? decodeURIComponent((getQueryVariable("app_url") as string) || "")
|
||||
: "";
|
||||
|
||||
// 缺少 app_id 或 app_url 时不允许登录(sso-portal 自身不需要登录)
|
||||
const missingSsoParams = !appId || !appUrl;
|
||||
|
||||
// 持久化 SSO 参数:飞书 OAuth 跳转后会丢失 URL 参数,存 localStorage
|
||||
useEffect(() => {
|
||||
if (appId && appUrl) {
|
||||
localStorage.setItem(appId, appUrl) // app_id -> app_url
|
||||
localStorage.setItem('sso_app_id', appId) // 记住当前 app_id
|
||||
localStorage.setItem(appId, appUrl); // app_id -> app_url
|
||||
localStorage.setItem("sso_app_id", appId); // 记住当前 app_id
|
||||
}
|
||||
}, [appId, appUrl])
|
||||
}, [appId, appUrl]);
|
||||
|
||||
// 获取租户简称列表(与 uirefbase 一致)
|
||||
useEffect(() => {
|
||||
fetch(`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/front/getAbbrList`, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-type': 'application/json' },
|
||||
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 || {})
|
||||
setAddrMap(res?.data?.keys || {});
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
.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
|
||||
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
|
||||
hash = (hash << 5) - hash + raw.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
setFingerprint(Math.abs(hash).toString(36))
|
||||
}, [setFingerprint])
|
||||
setFingerprint(Math.abs(hash).toString(36));
|
||||
}, [setFingerprint]);
|
||||
|
||||
// ============ 登录成功后跳转回外部应用 ============
|
||||
const handleLoginSuccess = useCallback((res: any) => {
|
||||
if (res?.error) {
|
||||
setError(res.error || '登录失败,请重试')
|
||||
return
|
||||
}
|
||||
const handleLoginSuccess = useCallback(
|
||||
(res: any) => {
|
||||
if (res?.error) {
|
||||
setError(res.error || "登录失败,请重试");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!res?.user_info?.account) {
|
||||
setError('登录失败:后端未返回有效用户信息')
|
||||
return
|
||||
}
|
||||
// if (!res?.user_info?.account) {
|
||||
// setError('登录失败:后端未返回有效用户信息')
|
||||
// return
|
||||
// }
|
||||
|
||||
// 从 localStorage 获取外部应用地址
|
||||
const storedAppId = appId || localStorage.getItem('sso_app_id') || ''
|
||||
const targetUrl =
|
||||
appUrl || (storedAppId ? localStorage.getItem(storedAppId) || '' : '')
|
||||
// 从 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
|
||||
}
|
||||
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])
|
||||
// 无外部应用地址 — sso-portal 不提供本地登录
|
||||
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 effectiveAppId = appId || localStorage.getItem("sso_app_id") || "";
|
||||
const effectiveAppUrl =
|
||||
appUrl || (effectiveAppId ? localStorage.getItem(effectiveAppId) || '' : '')
|
||||
appUrl ||
|
||||
(effectiveAppId ? localStorage.getItem(effectiveAppId) || "" : "");
|
||||
|
||||
const res = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/login`,
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/login`,
|
||||
{
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
encrypt(
|
||||
JSON.stringify({
|
||||
...payload,
|
||||
fingerprint,
|
||||
...(effectiveAppId ? { client_id: effectiveAppId } : {}),
|
||||
...(effectiveAppUrl ? { client_redirect_uri: effectiveAppUrl } : {}),
|
||||
})
|
||||
)
|
||||
...(effectiveAppUrl
|
||||
? { client_redirect_uri: effectiveAppUrl }
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
.then((r) => r.text())
|
||||
.then((t) => JSON.parse(t))
|
||||
.then((t) => JSON.parse(t));
|
||||
|
||||
return res
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
// ============ Tab 1: 账户登录 ============
|
||||
const accountForm = useForm({
|
||||
initialValues: { username: '', password: '' },
|
||||
initialValues: { username: "", password: "" },
|
||||
validate: {
|
||||
username: (v) => (!v ? '账户名不能为空' : null),
|
||||
password: (v) => (!v ? '密码不能为空' : null),
|
||||
username: (v) => (!v ? "账户名不能为空" : null),
|
||||
password: (v) => (!v ? "密码不能为空" : null),
|
||||
},
|
||||
validateInputOnChange: true,
|
||||
validateInputOnBlur: true,
|
||||
clearInputErrorOnChange: true,
|
||||
})
|
||||
});
|
||||
|
||||
const handleAccountSubmit = accountForm.onSubmit(async (values) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await postLogin({
|
||||
account: values.username,
|
||||
password: values.password,
|
||||
abbr: fixedTenant,
|
||||
type: 'account',
|
||||
})
|
||||
handleLoginSuccess(res)
|
||||
type: "account",
|
||||
});
|
||||
handleLoginSuccess(res);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败,请重试')
|
||||
setError(err instanceof Error ? err.message : "登录失败,请重试");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ============ Tab 2: 手机号登录 ============
|
||||
const [codeTime, setCodeTime] = useState(0)
|
||||
const [codeTime, setCodeTime] = useState(0);
|
||||
|
||||
const handleCountDown = () => {
|
||||
setCodeTime(60)
|
||||
setCodeTime(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeTime((t) => {
|
||||
if (t <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return t - 1
|
||||
})
|
||||
}, 1000)
|
||||
}
|
||||
return t - 1;
|
||||
});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleGetCode = async () => {
|
||||
if (!validatePhoneNumber(phoneForm.values.phone)) {
|
||||
setError('请输入正确的手机号')
|
||||
return
|
||||
setError("请输入正确的手机号");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tntkey = addrMap?.[fixedTenant] || ''
|
||||
const tntkey = addrMap?.[fixedTenant] || "";
|
||||
if (!tntkey) {
|
||||
showErrorMsg('请输入正确的租户')
|
||||
return
|
||||
showErrorMsg("请输入正确的租户");
|
||||
return;
|
||||
}
|
||||
const res: any = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH || ''}/api/getVerifyCode`,
|
||||
`${process.env.NEXT_PUBLIC_BASE_PATH || ""}/api/getVerifyCode`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
phone: phoneForm.values.phone.startsWith('+86')
|
||||
phone: phoneForm.values.phone.startsWith("+86")
|
||||
? phoneForm.values.phone
|
||||
: `+86${phoneForm.values.phone}`,
|
||||
tntkey,
|
||||
}),
|
||||
}
|
||||
).then((r) => r.json())
|
||||
},
|
||||
).then((r) => r.json());
|
||||
|
||||
if (res?.code === 200) {
|
||||
showSuccessMsg('验证码已发送, 请注意查收')
|
||||
handleCountDown()
|
||||
showSuccessMsg("验证码已发送, 请注意查收");
|
||||
handleCountDown();
|
||||
} else {
|
||||
showErrorMsg(res?.message || '验证码发送失败')
|
||||
showErrorMsg(res?.message || "验证码发送失败");
|
||||
}
|
||||
} catch {
|
||||
showSuccessMsg('验证码已发送')
|
||||
handleCountDown()
|
||||
showSuccessMsg("验证码已发送");
|
||||
handleCountDown();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const phoneForm = useForm({
|
||||
initialValues: { phone: '', code: '' },
|
||||
initialValues: { phone: "", code: "" },
|
||||
validate: {
|
||||
phone: (v) => {
|
||||
if (!v) return '手机号不能为空'
|
||||
if (v.length !== 11) return '手机号必须为11位'
|
||||
return null
|
||||
if (!v) return "手机号不能为空";
|
||||
if (v.length !== 11) return "手机号必须为11位";
|
||||
return null;
|
||||
},
|
||||
code: (v) => {
|
||||
if (!v) return '验证码不能为空'
|
||||
if (v.length !== 6) return '验证码必须为6位'
|
||||
return null
|
||||
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)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tntkey = addrMap?.[fixedTenant] || ''
|
||||
const tntkey = addrMap?.[fixedTenant] || "";
|
||||
if (!tntkey) {
|
||||
setError('请输入正确的租户')
|
||||
setLoading(false)
|
||||
return
|
||||
setError("请输入正确的租户");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const res = await postLogin({
|
||||
phone: values.phone.startsWith('+86')
|
||||
phone: values.phone.startsWith("+86")
|
||||
? values.phone
|
||||
: `+86${values.phone}`,
|
||||
verify_code: values.code,
|
||||
tntkey,
|
||||
type: 'phone',
|
||||
})
|
||||
handleLoginSuccess(res)
|
||||
type: "phone",
|
||||
});
|
||||
handleLoginSuccess(res);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败,请重试')
|
||||
setError(err instanceof Error ? err.message : "登录失败,请重试");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// ============ 错误提示 ============
|
||||
useEffect(() => {
|
||||
if (error) showErrorMsg(error)
|
||||
}, [error])
|
||||
if (error) showErrorMsg(error);
|
||||
}, [error]);
|
||||
|
||||
const bgImage = useMemo(
|
||||
() => (colorScheme === 'dark' ? DarkBgImage : LightBgImage),
|
||||
[colorScheme]
|
||||
)
|
||||
() => (colorScheme === "dark" ? DarkBgImage : LightBgImage),
|
||||
[colorScheme],
|
||||
);
|
||||
const stackBg2Image = useMemo(
|
||||
() => (colorScheme === 'dark' ? loginBg2Dark : loginBg2Light),
|
||||
[colorScheme]
|
||||
)
|
||||
() => (colorScheme === "dark" ? loginBg2Dark : loginBg2Light),
|
||||
[colorScheme],
|
||||
);
|
||||
|
||||
// 飞书 OAuth 回调中:显示 loading,不渲染表单
|
||||
const isFeishuCallback = isClient && !!getQueryVariable('code')
|
||||
const isFeishuCallback = isClient && !!getQueryVariable("code");
|
||||
if (isFeishuCallback) {
|
||||
return (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
h={'100vh'}
|
||||
h={"100vh"}
|
||||
w="100%"
|
||||
bg={`url(${bgImage.src})`}
|
||||
bgsz="cover"
|
||||
bgp="center"
|
||||
bgr="no-repeat">
|
||||
bgr="no-repeat"
|
||||
>
|
||||
<Paper withBorder shadow="md" radius="md" p="xl">
|
||||
<Stack align="center" gap="md">
|
||||
<LoadingOverlay visible />
|
||||
<Text size="sm" c="dimmed">正在验证飞书登录...</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
正在验证飞书登录...
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
{/* QrLogin 仍在后台执行 feishuLogin */}
|
||||
<Box style={{ position: 'absolute', opacity: 0, pointerEvents: 'none' }}>
|
||||
<Box
|
||||
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
|
||||
>
|
||||
<QrLogin onLoginSuccess={handleLoginSuccess} />
|
||||
</Box>
|
||||
</Stack>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
h={'100vh'}
|
||||
h={"100vh"}
|
||||
w="100%"
|
||||
bg={`url(${bgImage.src})`}
|
||||
bgsz="cover"
|
||||
bgp="center"
|
||||
bgr="no-repeat">
|
||||
bgr="no-repeat"
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius="md"
|
||||
h={520}
|
||||
maw={'100%'}
|
||||
style={{ borderColor: 'rgba(255, 255, 255, 0.5)' }}>
|
||||
<Flex w="100%" h={'100%'}>
|
||||
maw={"100%"}
|
||||
style={{ borderColor: "rgba(255, 255, 255, 0.5)" }}
|
||||
>
|
||||
<Flex w="100%" h={"100%"}>
|
||||
{/* 左侧装饰区域 */}
|
||||
<Stack
|
||||
w={600}
|
||||
p={32}
|
||||
visibleFrom="md"
|
||||
style={{ position: 'relative' }}>
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Box
|
||||
w="100%"
|
||||
h="100%"
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}>
|
||||
style={{ position: "absolute", top: 0, left: 0 }}
|
||||
>
|
||||
<BackgroundImage src={stackBg2Image.src} w="100%" h="100%" />
|
||||
</Box>
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
h="100%"
|
||||
style={{ zIndex: 11 }}>
|
||||
style={{ zIndex: 11 }}
|
||||
>
|
||||
<BackgroundImage
|
||||
src={LogoDigImage.src}
|
||||
radius="xs"
|
||||
@@ -367,39 +388,59 @@ export default function LoginPage() {
|
||||
</Stack>
|
||||
|
||||
{/* 右侧登录表单 */}
|
||||
<Stack h={'100%'} w={380} p={'54px 32px'} gap={0}>
|
||||
<Flex mb={'2rem'} gap={8} justify="center" align={'center'}>
|
||||
<Stack h={"100%"} w={380} p={"54px 32px"} gap={0}>
|
||||
<Flex mb={"2rem"} gap={8} justify="center" align={"center"}>
|
||||
<HeaderIcon />
|
||||
<Text size="xxl">{headerTitle}</Text>
|
||||
</Flex>
|
||||
|
||||
{/* SSO 来源提示 */}
|
||||
{appId && (
|
||||
<Text size="xs" c="dimmed" ta="center" mb="sm">
|
||||
正在为应用 {appId} 进行登录认证
|
||||
</Text>
|
||||
{/* 缺少 SSO 参数时顶部提示;有参数时显示来源 */}
|
||||
{missingSsoParams ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
variant="light"
|
||||
mb="sm"
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
>
|
||||
<Text size="sm" style={{ lineHeight: 1.5 }}>
|
||||
缺少 SSO 参数(app_id / app_url),无法登录。
|
||||
<br />
|
||||
请从外部应用跳转到此页面。
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
appId && (
|
||||
<Text size="xs" c="dimmed" ta="center" mb="sm">
|
||||
正在为应用 {appId} 进行登录认证
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
|
||||
<Tabs variant="none" value={activeTab} onChange={setActiveTab}>
|
||||
<Tabs.List ref={setRootRef} className={tabClasses.list}>
|
||||
<Tabs.Tab
|
||||
value="account"
|
||||
ref={setControlRef('account')}
|
||||
className={tabClasses.tab}>
|
||||
ref={setControlRef("account")}
|
||||
className={tabClasses.tab}
|
||||
>
|
||||
账户登录
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="phone"
|
||||
ref={setControlRef('phone')}
|
||||
className={tabClasses.tab}>
|
||||
ref={setControlRef("phone")}
|
||||
className={tabClasses.tab}
|
||||
>
|
||||
手机号登录
|
||||
</Tabs.Tab>
|
||||
{/* TODO: 暂时隐藏飞书扫码 tab,恢复时取消下方注释 + 同步恢复 Tabs.Panel value="feishu" */}
|
||||
{/*
|
||||
<Tabs.Tab
|
||||
value="feishu"
|
||||
ref={setControlRef('feishu')}
|
||||
className={tabClasses.tab}>
|
||||
飞书扫码
|
||||
</Tabs.Tab>
|
||||
*/}
|
||||
<FloatingIndicator
|
||||
target={activeTab ? controlsRefs.current[activeTab] : null}
|
||||
parent={rootRef}
|
||||
@@ -411,24 +452,27 @@ export default function LoginPage() {
|
||||
<Tabs.Panel value="account">
|
||||
<form
|
||||
onSubmit={handleAccountSubmit}
|
||||
style={{ marginTop: '30px' }}>
|
||||
style={{ marginTop: "30px" }}
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label="账户名"
|
||||
placeholder="请输入账户名"
|
||||
mt="md"
|
||||
{...accountForm.getInputProps('username')}
|
||||
disabled={missingSsoParams}
|
||||
{...accountForm.getInputProps("username")}
|
||||
/>
|
||||
<PasswordInput
|
||||
withAsterisk
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
mt="md"
|
||||
{...accountForm.getInputProps('password')}
|
||||
disabled={missingSsoParams}
|
||||
{...accountForm.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth mt="xl" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
<Button type="submit" fullWidth mt="xl" disabled={loading || missingSsoParams}>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Tabs.Panel>
|
||||
@@ -437,54 +481,60 @@ export default function LoginPage() {
|
||||
<Tabs.Panel value="phone">
|
||||
<form
|
||||
onSubmit={handlePhoneSubmit}
|
||||
style={{ marginTop: '30px' }}>
|
||||
style={{ marginTop: "30px" }}
|
||||
>
|
||||
<LoadingOverlay visible={loading} />
|
||||
<TextInput
|
||||
withAsterisk
|
||||
label="手机号"
|
||||
placeholder="请输入手机号"
|
||||
mt="md"
|
||||
{...phoneForm.getInputProps('phone')}
|
||||
disabled={missingSsoParams}
|
||||
{...phoneForm.getInputProps("phone")}
|
||||
/>
|
||||
<Input.Wrapper
|
||||
label="验证码"
|
||||
withAsterisk
|
||||
mt="md"
|
||||
error={phoneForm.errors.code}>
|
||||
error={phoneForm.errors.code}
|
||||
>
|
||||
<Flex align="center" gap={18}>
|
||||
<Input
|
||||
placeholder="请输入验证码"
|
||||
style={{ flex: 1 }}
|
||||
{...phoneForm.getInputProps('code')}
|
||||
disabled={missingSsoParams}
|
||||
{...phoneForm.getInputProps("code")}
|
||||
/>
|
||||
<Button
|
||||
style={{ flexShrink: 0 }}
|
||||
type="button"
|
||||
disabled={
|
||||
!validatePhoneNumber(phoneForm.values.phone) ||
|
||||
!!codeTime
|
||||
!!codeTime ||
|
||||
missingSsoParams
|
||||
}
|
||||
onClick={handleGetCode}>
|
||||
{codeTime !== 0 ? `${codeTime}秒后获取` : '获取验证码'}
|
||||
onClick={handleGetCode}
|
||||
>
|
||||
{codeTime !== 0 ? `${codeTime}秒后获取` : "获取验证码"}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Input.Wrapper>
|
||||
<Button type="submit" fullWidth mt="xl" disabled={loading}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
<Button type="submit" fullWidth mt="xl" disabled={loading || missingSsoParams}>
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Tabs.Panel>
|
||||
|
||||
{/* 飞书扫码(真实二维码) */}
|
||||
<Tabs.Panel value="feishu">
|
||||
{isClient && (
|
||||
<QrLogin onLoginSuccess={handleLoginSuccess} />
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
{/* 飞书扫码(真实二维码) - 暂时隐藏 */}
|
||||
{false && (
|
||||
<Tabs.Panel value="feishu">
|
||||
{isClient && <QrLogin onLoginSuccess={handleLoginSuccess} />}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Flex>
|
||||
</Paper>
|
||||
</Stack>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user