Files
labelmain/components/login/index.tsx
2026-02-03 18:05:47 +08:00

506 lines
15 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
Box,
TextInput,
PasswordInput,
Paper,
Text,
Group,
Button,
LoadingOverlay,
Stack,
Modal,
Flex,
BackgroundImage,
Tabs,
FloatingIndicator,
Input,
useComputedColorScheme,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { useDisclosure } from "@mantine/hooks"
import { DigitalIcon } from "./resource/icons/digital"
import tabClasses from "./Tab.module.css"
import {
getQueryVariable,
validatePhoneNumber,
} from "@/components/login/libs/common"
import { getVerifyCode, passwdLogin, verificationLogin } from "./api"
import QrLogin from "./feishu/qr-login"
import { APP, PLATFORM, TENANT } from "./libs/common"
import FingerprintJS from "@fingerprintjs/fingerprintjs"
import { useLoginStore } from "./store"
// import { useSearchParams } from "next/navigation"
import Feishu from "./feishu/auto-login"
import LightBgImage from "./resource/images/light-login-bg.png"
import DarkBgImage from "./resource/images/dark-login-bg.png"
import LogoDigImage from "./resource/images/logo-dig.png"
import loginBg2Light from "./resource/images/login-bg2-light.png"
import loginBg2Dark from "./resource/images/login-bg2-dark.png"
const HeaderIcon = DigitalIcon
const headerTitle = "管理系统平台"
export default function LoginPage({ useUserStore }: { useUserStore: any }) {
const colorScheme = useComputedColorScheme()
// const params = useSearchParams().get("auto")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
// 创建 Mantine Form 实例
const accountForm = useForm({
// 初始值
initialValues: {
username: "", // 账户名
password: "", // 密码
},
// 验证规则 - 基于 rules 对象
validate: {
username: (value) => {
if (!value) {
return "账户名不能为空"
}
if (value.length < 1) {
return "账户名至少1个字符"
}
// if (value.length > 20) {
// return "账户名不能超过20个字符"
// }
// if (!/^[a-zA-Z0-9_]+$/.test(value)) {
// return "账户名只能包含字母、数字和下划线"
// }
return null
},
password: (value) => {
if (!value) {
return "密码不能为空"
}
// if (value.length < 6) {
// return "密码至少6个字符"
// }
// if (value.length > 30) {
// return "密码不能超过30个字符"
// }
// if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) {
// return "密码需包含大小写字母和数字"
// }
return null
},
},
// 验证触发时机
validateInputOnChange: true, // 输入时验证
validateInputOnBlur: true, // 失焦时验证
clearInputErrorOnChange: true, // 输入时清除错误
})
const phoneForm = useForm({
// 初始值
initialValues: {
phone: "", // 手机号
code: "", // 验证码
},
// 验证规则 - 基于 rules 对象
validate: {
phone: (value) => {
if (!value) {
return "手机号不能为空"
}
if (value.length !== 11) {
return "手机号必须为11位"
}
return null
},
code: (value) => {
if (!value) {
return "验证码不能为空"
}
if (value.length !== 6) {
return "验证码必须为6位"
}
return null
},
},
// 验证触发时机
validateInputOnChange: true, // 输入时验证
validateInputOnBlur: true, // 失焦时验证
clearInputErrorOnChange: true, // 输入时清除错误
})
const [opened, { open, close }] = useDisclosure(false)
const [activeTab, setActiveTab] = useState<string>("account")
// const [fingerprint, setFingerprint] = useState("")
const { setFingerprint } = useLoginStore()
const fingerprint = useLoginStore.getState().fingerprint
const fn = useCallback(async () => {
const fpPromise = await FingerprintJS.load()
fpPromise.get().then((result) => {
const visitorId = result.visitorId
setFingerprint(visitorId)
})
}, [setFingerprint])
useEffect(() => {
fn()
}, [fn])
useEffect(() => {
if (getQueryVariable("code")) {
setActiveTab("feishu")
}
}, [])
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
const [controlsRefs, setControlsRefs] = useState<
Record<string, HTMLButtonElement | null>
>({})
const setControlRef = (val: string) => (node: HTMLButtonElement) => {
controlsRefs[val] = node
setControlsRefs(controlsRefs)
}
const { setUserInfo, refreshToken } = useUserStore()
// 表单提交处理
const handleAccountSubmit = accountForm.onSubmit(
async (values) => {
// 验证成功时的处理
setLoading(true)
setError(null)
try {
let isAdmin = values.username === "admin"
let phone = values.username.startsWith("+86")
? values.username
: `+86${values.username}`
const params = {
account: isAdmin ? values.username : phone,
password: values.password,
tenant: TENANT,
platform: PLATFORM,
app: APP,
fingerprint,
}
try {
const res = await passwdLogin(params)
const info = res.message
setUserInfo(info.tenant, info.user_info, info.rbac)
refreshToken({
access_token: info.access_token || "",
refresh_token: info.refresh_token || "",
})
window.location.href = "/"
} catch (error) {
if (error) {
const message =
error instanceof Error ? error.message : "登录失败,请重试"
setError(message)
open() // 显示错误弹窗
return
}
}
} catch (err) {
const message = err instanceof Error ? err.message : "登录失败,请重试"
setError(message)
open() // 显示错误弹窗
} finally {
setLoading(false)
}
},
(errors) => {
console.log("验证错误:", errors)
}
)
const [codeTime, setCodeTime] = useState<number>(0)
const handleCountDown = () => {
setCodeTime(60)
let clean = setInterval(() => {
setCodeTime((time) => {
if (time === 0) {
clearInterval(clean)
return 0
}
return time - 1
})
}, 1000)
}
const handleGetCode = async () => {
if (!validatePhoneNumber(phoneForm.values.phone)) {
setError("请输入正确的手机号")
open() // 显示错误弹窗
return
} else {
try {
handleCountDown()
getVerifyCode({
phone: phoneForm.values.phone.startsWith("+86")
? phoneForm.values.phone
: `+86${phoneForm.values.phone}`,
}).then((res) => {
if (res?.code === 200) {
setError("验证码已发送")
open() // 显示错误弹窗
} else {
typeof res?.message === "string" &&
setError(res?.message || "验证码发送失败")
open() // 显示错误弹窗
}
})
} catch (error) {
const message =
error instanceof Error ? error.message : "获取验证码失败,请重试"
setError(message)
open() // 显示错误弹窗
}
}
}
// 手机号登录表单提交处理
const handlePhoneSubmit = phoneForm.onSubmit(
async (values) => {
// 验证成功时的处理
setLoading(true)
setError(null)
try {
const params = {
phone: values.phone.startsWith("+86")
? values.phone
: `+86${values.phone}`,
verify_code: values.code,
tenant: TENANT,
platform: PLATFORM,
app: APP,
fingerprint,
}
const res = await verificationLogin(params)
const info = res.message
setUserInfo(info.tenant, info.user_info, info.rbac)
refreshToken({
access_token: info.access_token || "",
refresh_token: info.refresh_token || "",
})
window.location.href = "/"
} catch (err) {
const message = err instanceof Error ? err.message : "登录失败,请重试"
setError(message)
open() // 显示错误弹窗
} finally {
setLoading(false)
}
},
(errors) => {
console.log("验证错误:", errors)
}
)
const bgImage = useMemo(
() => (colorScheme !== "dark" ? LightBgImage : DarkBgImage),
[colorScheme]
)
const stackBg2Image = useMemo(
() => (colorScheme === "dark" ? loginBg2Dark : loginBg2Light),
[colorScheme]
)
const isFeishu = useMemo(() => {
if (typeof window !== "undefined") return !!(window.h5sdk && window.tt)
else return false
}, [])
return (
<>
<Modal opened={opened} onClose={close} title="消息提示">
<Flex gap={12} align="center">
<Text size="sm">{error}</Text>
</Flex>
<Group mt="md">
<Button variant="outline" size="sm" onClick={close}>
</Button>
</Group>
</Modal>
<Stack align="center" justify="center" h={"100vh"} w="100%">
{isClient && (
<BackgroundImage
src={bgImage.src}
w="100%"
h="100%"
style={{ position: "absolute", top: 0, left: 0, zIndex: -1 }}
/>
)}
<Paper
withBorder
shadow="md"
radius="md"
h={520}
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" }}>
<Box
w="100%"
h="100%"
style={{ position: "absolute", top: 0, left: 0 }}>
{isClient && (
<BackgroundImage src={stackBg2Image.src} w="100%" h="100%" />
)}
</Box>
<Flex
justify="center"
align="center"
h="100%"
style={{ zIndex: 11 }}>
<BackgroundImage
src={LogoDigImage.src}
radius="xs"
w={432}
h={310}
visibleFrom="md"></BackgroundImage>
</Flex>
</Stack>
<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>
<Tabs
variant="none"
value={activeTab}
onChange={(value) => setActiveTab(value || "")}>
<Tabs.List ref={setRootRef} className={tabClasses.list}>
<Tabs.Tab
value="account"
ref={setControlRef("account")}
className={tabClasses.tab}>
</Tabs.Tab>
<Tabs.Tab
value="phone"
ref={setControlRef("phone")}
className={""}>
</Tabs.Tab>
<Tabs.Tab
value="feishu"
ref={setControlRef("feishu")}
className={tabClasses.tab}>
</Tabs.Tab>
<FloatingIndicator
target={activeTab ? controlsRefs[activeTab] : null}
parent={rootRef}
className={tabClasses.indicator}
/>
</Tabs.List>
<Tabs.Panel value="account">
<form onSubmit={handleAccountSubmit}>
<LoadingOverlay visible={loading} />
{/* 账户名输入 */}
<TextInput
withAsterisk
label="账户名"
placeholder="请输入账户名"
{...accountForm.getInputProps("username")}
/>
{/* 密码输入 */}
<PasswordInput
withAsterisk
label="密码"
placeholder="请输入密码"
mt="md"
{...accountForm.getInputProps("password")}
/>
{/* 登录按钮 */}
<Button type="submit" fullWidth mt="xl" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</Tabs.Panel>
<Tabs.Panel value="phone">
<form onSubmit={handlePhoneSubmit}>
<LoadingOverlay visible={loading} />
{/* 手机号输入 */}
<TextInput
withAsterisk
label="手机号"
placeholder="请输入手机号"
{...phoneForm.getInputProps("phone")}
/>
{/* 验证码输入 */}
<Input.Wrapper
label="验证码"
withAsterisk
mt="md"
error={phoneForm.errors.code}>
<Flex justify={"space-between"} align={"end"}>
<Input
placeholder="请输入验证码"
{...phoneForm.getInputProps("code")}
/>
<Button
type="button"
disabled={
!validatePhoneNumber(phoneForm.values.phone) ||
!!codeTime
}
onClick={handleGetCode}>
{codeTime !== 0
? `${codeTime}秒后获取`
: "获取验证码"}
</Button>
</Flex>
</Input.Wrapper>
{/* 登录按钮 */}
<Button type="submit" fullWidth mt="xl" disabled={loading}>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</Tabs.Panel>
<Tabs.Panel value="feishu">
{isClient && <QrLogin useUserStore={useUserStore}></QrLogin>}
</Tabs.Panel>
</Tabs>
</Stack>
</Flex>
</Paper>
</Stack>
{isFeishu && (
<Stack
h={"100vh"}
w={"100vw"}
bg="white"
style={{ position: "absolute", top: 0, zIndex: 99999 }}>
<Feishu useUserStore={useUserStore} />
</Stack>
)}
</>
)
}