feat(project): init

This commit is contained in:
2026-03-10 17:06:22 +08:00
commit 1ae8509cce
70 changed files with 5141 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
.icon {
width: 22px;
height: 22px;
}
.dark {
@mixin dark {
display: none;
}
@mixin light {
display: block;
}
}
.light {
@mixin light {
display: none;
}
@mixin dark {
display: block;
}
}

View File

@@ -0,0 +1,353 @@
"use client"
import {
AppShell,
Box,
Burger,
Divider,
Flex,
Menu,
NavLink,
ScrollArea,
Space,
Stack,
Tooltip,
UnstyledButton,
useComputedColorScheme,
useMantineColorScheme,
} from "@mantine/core"
import { useDisclosure, useMediaQuery } from "@mantine/hooks"
import {
IconLogout,
IconMessageCircle,
IconMoon,
IconSettings,
IconSun,
IconUser,
} from "@tabler/icons-react"
import cx from "clsx"
import { Suspense, useEffect, useMemo, useState } from "react"
import actionToggleClasses from "./ActionToggle.module.css"
import headerLinkClasses from "./HeaderLink.module.css"
import { useUserStore } from "@/app/store/user"
import { usePathname, useRouter } from "next/navigation"
import { deleteCookie } from "../login/api"
import { MenuItem, withoutLayoutRoutes } from "./common"
import { ClientIcon } from "./components/ClientIcon"
import { LinksGroup } from "./components/NavbarLinks"
import { useAppLayoutStore } from "./store"
export default function AppLayout({
menu,
children,
}: {
menu: MenuItem[]
children: React.ReactNode
}) {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
const { updateIsOpen } = useAppLayoutStore.getState()
const { isOpen } = useAppLayoutStore()
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
}, [])
const { setColorScheme } = useMantineColorScheme()
const computedColorScheme = useComputedColorScheme("light", {
getInitialValueInEffect: true,
})
const pathname = usePathname()
const withoutLayout = useMemo(
() => withoutLayoutRoutes.includes(pathname),
[pathname]
)
const router = useRouter()
const activeHeaderMenu = useMemo(() => {
return menu?.find((child) => pathname.startsWith(`/${child.url}`))
}, [pathname, menu])
const { menuDefaultRouter } = useAppLayoutStore.getState()
const headItems = menu.map((item) => (
<a
key={item.url}
href={`#`}
className={headerLinkClasses.link}
data-active={activeHeaderMenu?.url === item.url || undefined}
onClick={(e) => {
e.preventDefault()
if (activeHeaderMenu?.url === item.url) return
let toPathname = menuDefaultRouter[`/${item.url}`] || `/${item.url}`
window.location.href = toPathname
// router.replace(toPathname)
}}>
<ClientIcon icon={item.icon} style={{ width: "1rem", height: "1rem" }} />
{item.title}
</a>
))
/**
* 递归渲染 MenuItem
*/
const renderMenuItem = (
item: MenuItem,
routerUrl?: string
): React.ReactNode => {
if (item.items && item.items.length > 0) {
// 有子菜单,递归渲染
return (
<Menu.Sub key={item.url}>
<Menu.Sub.Target>
<Menu.Sub.Item>{item.title}</Menu.Sub.Item>
</Menu.Sub.Target>
<Menu.Sub.Dropdown>
{item.items.map((sub) =>
renderMenuItem(sub, `${routerUrl}/${item.url}`)
)}
</Menu.Sub.Dropdown>
</Menu.Sub>
)
} else {
// 没有子菜单,普通 Menu.Item
return (
<Menu.Item key={item.url} p={0}>
<NavLink
noWrap={true}
onClick={() => {
router.push(`/${routerUrl}/${item.url}`)
}}
active={pathname === `/${routerUrl}/${item.url}`}
leftSection={
item.icon && (
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
)
}
label={item.title}></NavLink>
</Menu.Item>
)
}
}
const handleLogout = async () => {
useUserStore.getState().removeUserInfo()
process.env.NEXT_PUBLIC_OUTPUT_TYPE === "standalone" &&
(await deleteCookie())
router.push("/login")
}
const matches = useMediaQuery("(min-width: 48em)")
return isClient ? (
withoutLayout ? (
<Suspense fallback={<></>}>{children}</Suspense>
) : (
<AppShell
header={{ height: 55 }}
navbar={{
width: isOpen ? 200 : 40,
breakpoint: "sm",
collapsed: { mobile: !mobileOpened },
}}>
<AppShell.Header>
<Flex h="100%" px="md" align="center">
<Burger
opened={mobileOpened}
onClick={toggleMobile}
hiddenFrom="sm"
size="sm"
/>
<Flex flex={1} gap={0} h="100%" align="center">
<Box w={184} visibleFrom="sm">
</Box>
<Space w={"1rem"} hiddenFrom="sm" />
{headItems}
</Flex>
<Flex justify="center" align="center" w={"max-content"}>
<UnstyledButton
display="none"
onClick={() =>
setColorScheme(
computedColorScheme === "light" ? "dark" : "light"
)
}
aria-label="Toggle color scheme">
<IconSun
className={cx(
actionToggleClasses.icon,
actionToggleClasses.light
)}
stroke={1.5}
/>
<IconMoon
className={cx(
actionToggleClasses.icon,
actionToggleClasses.dark
)}
stroke={1.5}
/>
</UnstyledButton>
<Divider orientation="vertical" mx={"0.5rem"} display="none" />
<Menu shadow="md" width={200}>
<Menu.Target>
<UnstyledButton>
<IconUser
style={{ display: "block" }}
size={22}
stroke={1.5}
/>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label></Menu.Label>
<Menu.Item leftSection={<IconSettings size={14} />}>
</Menu.Item>
<Menu.Item leftSection={<IconMessageCircle size={14} />}>
</Menu.Item>
<Menu.Divider />
<Menu.Label></Menu.Label>
<Menu.Item
leftSection={<IconLogout size={14} />}
onClick={handleLogout}>
退
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
</Flex>
</AppShell.Header>
<AppShell.Navbar style={{ transition: "width 0.2s ease" }}>
<Stack justify="start" gap={4} flex={1}>
<ScrollArea h="calc(100vh - 95px)">
{activeHeaderMenu?.items?.map((item) => {
if (item.items?.length) {
if (matches) {
if (isOpen) {
return (
<LinksGroup
key={item.url}
item={item}
toggleMobile={toggleMobile}
routerUrl={activeHeaderMenu.url}
initiallyOpened={true}
/>
)
} else {
return (
<Menu
width={"auto"}
key={item.url + "desktop"}
position="right-start"
trigger="hover"
openDelay={100}
closeDelay={200}>
<Menu.Target>
<NavLink
noWrap={true}
leftSection={
item.icon && (
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
)
}
label={item.title}
/>
</Menu.Target>
<Menu.Dropdown>
{item.items?.map((sub) =>
renderMenuItem(
sub,
`${activeHeaderMenu.url}/${item.url}`
)
)}
</Menu.Dropdown>
</Menu>
)
}
} else {
return (
<Box key={item.url + "mobile"} hiddenFrom="sm">
<LinksGroup
item={item}
toggleMobile={toggleMobile}
routerUrl={activeHeaderMenu.url}
initiallyOpened={true}
/>
</Box>
)
}
} else {
return (
<Tooltip
key={item.url}
events={{
hover: !isOpen && !mobileOpened,
focus: false,
touch: false,
}}
label={item.title || ""}
position="right"
openDelay={100}
closeDelay={200}
transitionProps={{ duration: 100 }}>
<NavLink
// href={`/${activeHeaderMenu.url}/${item.url}`}
onClick={() => {
toggleMobile()
router.push(`/${activeHeaderMenu.url}/${item.url}`)
}}
noWrap={true}
active={pathname.startsWith(
`/${activeHeaderMenu.url}/${item.url}`
)}
leftSection={
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
}
label={item.title}
/>
</Tooltip>
)
}
})}
</ScrollArea>
</Stack>
<Stack justify="center" gap={0}>
<Flex
justify={isOpen ? "flex-end" : "center"}
align="center"
mb={"sm"}>
<Burger
opened={false}
onClick={() => updateIsOpen(!isOpen)}
size="sm"
visibleFrom="sm"
mr={isOpen ? "sm" : undefined}
/>
</Flex>
</Stack>
</AppShell.Navbar>
<AppShell.Main
bg={computedColorScheme === "light" ? "gray.0" : "gray.8"}>
<Suspense fallback={<></>}>{children}</Suspense>
</AppShell.Main>
</AppShell>
)
) : null
}

View File

@@ -0,0 +1,27 @@
.link {
display: flex;
height: 100%;
justify-content: center;
align-items: center;
padding: 8px 16px;
gap: 0.5rem;
text-decoration: none;
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
font-size: var(--mantine-font-size-lg);
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@mixin hover {
background-color: light-dark(
var(--mantine-color-gray-0),
var(--mantine-color-dark-6)
);
}
[data-mantine-color-scheme] &[data-active] {
background-color: var(--mantine-primary-color-light);
color: var(--mantine-primary-color-light-color);
}
}

View File

@@ -0,0 +1,57 @@
"use client"
import { useAppLayoutStore } from "@/components/layout/store"
// import { useLoginStore } from "../login/store"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect, useMemo } from "react"
import { MenuItem } from "./common"
interface ComponentProps {
menu: MenuItem[] | undefined
}
export function PathnameRecorder({ menu }: ComponentProps) {
const pathname = usePathname()
// const { setFingerprint } = useLoginStore()
// useEffect(() => {
// if (pathname.startsWith("/login")) {
// return
// }
// setFingerprint("")
// }, [pathname, setFingerprint])
const searchParams = useSearchParams() // 查询参数
const queryParams = Array.from(searchParams.entries()).reduce(
(acc, [key, value]) => {
acc[key] = value // 或者 acc[key] = value.split(',') 如果值是数组
return acc
},
{} as Record<string, string>
)
// 将查询参数对象转换为查询字符串
const queryString = new URLSearchParams(queryParams).toString()
const sideNavMenu = useMemo(() => {
return menu?.find((item) => pathname.startsWith(`/${item.url}`))
}, [menu, pathname])
const { updateMenuDefaultRouter } = useAppLayoutStore()
useEffect(() => {
let key = process.env.NEXT_PUBLIC_BASE_PATH
? process.env.NEXT_PUBLIC_BASE_PATH
: `/${sideNavMenu?.url}`
pathname.startsWith(`/${sideNavMenu?.url}`) &&
sideNavMenu?.url &&
`/${sideNavMenu?.url}` !== pathname &&
updateMenuDefaultRouter(
key,
queryString
? `${process.env.NEXT_PUBLIC_BASE_PATH}${pathname}?${queryString}`
: `${process.env.NEXT_PUBLIC_BASE_PATH}${pathname}`
)
}, [queryString, pathname, sideNavMenu?.url, updateMenuDefaultRouter])
return <></>
}

View File

@@ -0,0 +1,55 @@
export interface MenuItem {
url: string
title: string
icon?: string
isActive?: boolean
items?: MenuItem[]
}
export const withoutLayoutRoutes = ["/login"]
const currentComponentList: MenuItem[] = [
{
title: "图片标注",
url: "project",
icon: "SystemIcon",
items: [
{
title: "个人项目",
url: "own",
icon: "PersonalMenuIcon",
},
{
title: "项目设置",
url: "setting",
icon: "PersonalMenuIcon",
},
],
},
]
export const componentList: MenuItem[] = [
{
title: "个人中心",
url: "person",
icon: "PersonalMenuIcon",
},
...currentComponentList,
{
title: "视频标注",
url: "video",
icon: "SystemIcon",
},
{
title: "点云标注",
url: "pointcloud",
icon: "SystemIcon",
},
{
title: "管理中心",
url: "admin",
icon: "SystemIcon",
},
]
export const showList: MenuItem[] = []

View File

@@ -0,0 +1,29 @@
// import { type LucideIcon } from "lucide-react";
import {
AuthMenuIcon,
PersonalMenuIcon,
SystemIcon,
TaskMenuIcon,
} from "./icons/custom"
import React from "react"
export const ClientIcon = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"button"> & {
icon?: string
}
>(({ icon, ...props }, _ref) => {
if (icon) {
const IconComponent = {
SystemIcon,
PersonalMenuIcon,
TaskMenuIcon,
AuthMenuIcon,
}[icon]
return IconComponent ? <IconComponent style={{ ...props.style }} /> : null
} else {
return null
}
})

View File

@@ -0,0 +1,85 @@
"use client"
import { useAppLayoutStore } from "@/components/layout/store"
import { NavLink, Space } from "@mantine/core"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { MenuItem } from "../common"
import { ClientIcon } from "./ClientIcon"
interface LinksGroupProps {
item: MenuItem
offset?: number
initiallyOpened?: boolean
routerUrl?: string
toggleMobile?: () => void
}
export function LinksGroup({
item,
offset = 0,
initiallyOpened,
routerUrl,
toggleMobile,
}: LinksGroupProps) {
const hasLinks = Array.isArray(item.items) && item.items.length > 0
const pathname = usePathname()
const router = useRouter()
const { updateLinksOpenStatus } = useAppLayoutStore()
const linksOpenStatus = useAppLayoutStore.getState().linksOpenStatus
let storeOpened = linksOpenStatus[`${routerUrl}/${item.url}`] || false
const [opened, setOpened] = useState(initiallyOpened && storeOpened)
let childrenOffset = 24 + (offset || 0)
const children = hasLinks
? item.items!.map((subItem) => (
<LinksGroup
key={subItem.url}
item={subItem}
offset={childrenOffset}
initiallyOpened={initiallyOpened}
routerUrl={`${routerUrl}/${item.url}`}
toggleMobile={toggleMobile}
/>
))
: null
return (
<>
<NavLink
// href={`/${routerUrl}/${item.url}`}
onClick={() => {
setOpened(!opened)
updateLinksOpenStatus(`${routerUrl}/${item.url}`, !opened)
!hasLinks && router.push(`/${routerUrl}/${item.url}`)
!hasLinks && toggleMobile && toggleMobile()
}}
opened={opened}
label={item.title}
noWrap={true}
active={
hasLinks ? false : pathname.startsWith(`/${routerUrl}/${item.url}`)
}
leftSection={
<>
<Space w={offset} />
{item.icon && (
<ClientIcon
icon={item.icon}
style={{ width: "1rem", height: "1rem" }}
/>
)}
</>
}
childrenOffset={0}>
{children}
</NavLink>
</>
)
}

View File

@@ -0,0 +1,86 @@
import { SVGProps } from "react"
export function SystemIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<path
d="M11.7284 13.9936H4.40328C4.33652 13.9924 4.2702 14.0045 4.20818 14.0293C4.14616 14.0541 4.08969 14.0909 4.04206 14.1378C3.99444 14.1846 3.95661 14.2405 3.93078 14.3021C3.90496 14.3637 3.89166 14.4299 3.89166 14.4967C3.89166 14.5636 3.90496 14.6297 3.93078 14.6914C3.95661 14.753 3.99444 14.8089 4.04206 14.8557C4.08969 14.9026 4.14616 14.9394 4.20818 14.9642C4.2702 14.989 4.33652 15.0011 4.40328 14.9999H11.7356C11.8017 15.0003 11.8672 14.9876 11.9284 14.9624C11.9896 14.9373 12.0451 14.9002 12.0919 14.8535C12.1387 14.8067 12.1757 14.751 12.2008 14.6898C12.226 14.6286 12.2387 14.563 12.2383 14.4968C12.2311 14.2235 12.0084 13.9936 11.7284 13.9936ZM14.3001 1H1.70284C1.51665 1 1.33807 1.07395 1.2063 1.20561C1.07453 1.33728 1.00033 1.5159 1 1.70227L1 12.3011C1.00029 12.4875 1.07447 12.6661 1.20624 12.7978C1.33802 12.9295 1.51662 13.0035 1.70284 13.0035H14.3001C14.4864 13.0035 14.665 12.9295 14.7967 12.7978C14.9285 12.6661 15.0027 12.4875 15.003 12.3011V1.70227C15.0026 1.5159 14.9284 1.33728 14.7967 1.20561C14.6649 1.07395 14.4863 1 14.3001 1ZM12.4325 7.62061C12.4098 7.72479 12.3593 7.82086 12.2863 7.89853C12.2133 7.97621 12.1206 8.03259 12.0181 8.06164L11.8866 8.09447C11.5312 8.19946 11.2295 8.43678 11.0436 8.75762C10.8577 9.07846 10.8016 9.45842 10.887 9.81935L10.9339 9.95098C10.9597 10.054 10.9573 10.162 10.9271 10.2638C10.8969 10.3655 10.8399 10.4573 10.7621 10.5294C10.598 10.6552 10.4243 10.7678 10.2424 10.866C10.0638 10.9708 9.87926 11.0653 9.68985 11.149C9.58837 11.1804 9.48026 11.1835 9.37715 11.158C9.27404 11.1325 9.17982 11.0793 9.10462 11.0042L9.00592 10.8989C8.73758 10.6422 8.38074 10.499 8.00961 10.499C7.63848 10.499 7.28164 10.6422 7.0133 10.8989L6.91459 11.0042C6.83727 11.0759 6.7424 11.1258 6.6396 11.1489C6.5368 11.172 6.42971 11.1675 6.32921 11.1359C6.13739 11.0574 5.95258 10.9627 5.77678 10.8529C5.595 10.7549 5.42129 10.6426 5.25731 10.5171C5.17964 10.4448 5.12278 10.353 5.0927 10.2513C5.06261 10.1495 5.0604 10.0415 5.08628 9.93863L5.1258 9.80684C5.20852 9.44598 5.15048 9.06712 4.96354 8.74764C4.7766 8.42817 4.47486 8.19219 4.11996 8.0879L3.98845 8.05507C3.78416 7.99582 3.62017 7.83119 3.57331 7.62061C3.54931 7.41519 3.53835 7.20846 3.54051 7.00166C3.535 6.79486 3.54597 6.58794 3.57331 6.38288C3.59835 6.2805 3.6498 6.18648 3.72252 6.11024C3.79523 6.034 3.88666 5.9782 3.98767 5.94841L4.12574 5.91558C4.86263 5.69827 5.29666 4.94081 5.11955 4.19039L5.08019 4.05876C5.05463 3.95571 5.057 3.8477 5.08706 3.74587C5.11712 3.64405 5.17379 3.5521 5.25121 3.47952C5.41508 3.35185 5.58878 3.23737 5.77069 3.13714C5.94903 3.03251 6.13316 2.93809 6.32218 2.85433C6.52616 2.78851 6.74966 2.84776 6.90756 2.99925L7.00612 3.10447C7.27278 3.36126 7.62806 3.5053 7.99809 3.50664C8.36813 3.50798 8.72444 3.36652 8.99295 3.11166L9.09166 3.00629C9.16791 2.93288 9.26212 2.88086 9.36482 2.85545C9.46752 2.83003 9.5751 2.83213 9.67673 2.86152C9.86856 2.94005 10.0534 3.03477 10.2292 3.14465C10.411 3.24265 10.5847 3.35494 10.7488 3.48046C10.8263 3.55298 10.8831 3.64489 10.9133 3.74671C10.9435 3.84853 10.946 3.95658 10.9206 4.05969L10.8737 4.19133C10.6896 4.93456 11.1302 5.69827 11.8668 5.91558L12.0116 5.94841C12.1137 5.97605 12.2062 6.03116 12.2793 6.10775C12.3523 6.18435 12.4029 6.2795 12.4258 6.38288C12.4531 6.58794 12.4642 6.79485 12.4587 7.00166C12.4698 7.20834 12.461 7.41561 12.4325 7.62061Z"
fill="currentColor"
/>
<path
d="M6.56703 8.2953C6.94761 8.67625 7.46378 8.89026 8.00199 8.89026C8.5402 8.89026 9.05637 8.67625 9.43694 8.2953C9.81751 7.91436 10.0313 7.39769 10.0313 6.85895C10.0313 6.32021 9.81751 5.80354 9.43694 5.42259C9.05637 5.04165 8.5402 4.82764 8.00199 4.82764C7.46378 4.82764 6.94761 5.04165 6.56703 5.42259C6.18646 5.80354 5.97266 6.32021 5.97266 6.85895C5.97266 7.39769 6.18646 7.91436 6.56703 8.2953Z"
fill="currentColor"
/>
</svg>
)
}
export const PersonalMenuIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="1927"
width="14"
height="14"
fill="currentColor"
{...props}>
<path
d="M511.976 24.429a487.618 487.618 0 0 1 487.619 487.619 487.619 487.619 0 1 1-487.619-487.62z m60.953 518.095H451.024a182.86 182.86 0 0 0-129.3 53.557 182.86 182.86 0 0 0-53.557 129.3h487.619l-0.305-10.728a182.857 182.857 0 0 0-182.552-172.129z m-60.953-304.762a121.906 121.906 0 0 0-86.199 208.104 121.9 121.9 0 0 0 172.399 0 121.905 121.905 0 0 0-86.2-208.104z"
p-id="1928"></path>
</svg>
)
export const TaskMenuIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<g clipPath="url(#clip0_16390_8144)">
<path
d="M9.97792 0.0650635H1.99082C1.15724 0.0650635 0.480469 0.741832 0.480469 1.57541V10.4126C0.480469 12.3562 2.06097 13.9347 4.00255 13.9347H11.9897C12.8232 13.9347 13.5 13.2579 13.5 12.4243V3.58715C13.5021 1.64556 11.9216 0.0650635 9.97792 0.0650635ZM7.35957 9.53156H4.10984C3.74257 9.53156 3.44546 9.23444 3.44546 8.86717C3.44546 8.4999 3.74257 8.20072 4.10984 8.20072H7.35957C7.72684 8.20072 8.02396 8.49784 8.02396 8.86717C8.02602 9.23238 7.72684 9.53156 7.35957 9.53156ZM9.87063 5.71236H4.10984C3.74257 5.71236 3.44546 5.41524 3.44546 5.04797C3.44546 4.6807 3.74257 4.38359 4.10984 4.38359H9.87063C10.2379 4.38359 10.535 4.6807 10.535 5.04797C10.535 5.41524 10.2379 5.71236 9.87063 5.71236Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_16390_8144">
<rect width="14" height="14" fill="white" />
</clipPath>
</defs>
</svg>
)
export const AuthMenuIcon = (props: SVGProps<SVGSVGElement>) => (
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}>
<g clipPath="url(#clip0_16390_8141)">
<path
d="M12.786 1.72289L7.35971 0L1.93526 1.72289C1.73132 1.78725 1.59355 1.97702 1.59535 2.19167V9.04915C1.60621 9.09573 1.862 10.2246 2.9711 11.3177C3.82441 12.1585 5.87122 13.2392 6.76401 13.7079C6.90344 13.7813 6.96977 13.8081 7.0287 13.8402L7.29354 13.9726C7.36333 14.0101 7.48859 14.0084 7.55838 13.9726L7.82322 13.8402C8.34551 13.5844 10.7625 12.2892 11.7483 11.3177C12.8594 10.2246 13.1152 9.09573 13.1259 9.04915V2.19167C13.1294 1.97702 12.9899 1.78725 12.786 1.72289ZM7.4277 12.9099L7.42408 12.9081H7.43297C7.4295 12.9099 7.4277 12.9099 7.4277 12.9099ZM8.72114 8.9257H7.84447V10.1602C7.84447 10.4465 7.61189 10.679 7.32565 10.679C7.03941 10.679 6.80682 10.4465 6.80682 10.1602V6.97566C6.80682 6.96495 6.80863 6.95606 6.80863 6.94702C6.71382 6.91838 6.61901 6.88431 6.5242 6.8415C6.20404 6.69484 5.93031 6.43181 5.74777 6.13305C5.55815 5.82179 5.45791 5.43169 5.48836 5.06676C5.52408 4.64999 5.67436 4.28672 5.94101 3.96294C6.40256 3.40297 7.25058 3.17581 7.93039 3.42437C8.31507 3.56576 8.62814 3.80196 8.86434 4.13463C9.0719 4.4263 9.17741 4.78941 9.18646 5.14545C9.18646 5.15615 9.18827 5.16504 9.18646 5.1759C9.18646 5.18841 9.18646 5.20092 9.18465 5.21343C9.15963 6.00418 8.61578 6.75016 7.84282 6.95606V7.86664H8.71948C9.00573 7.86664 9.23831 8.09923 9.23831 8.38547V8.40687H9.24012C9.23997 8.69312 9.00739 8.9257 8.72114 8.9257Z"
fill="currentColor"
/>
<path
d="M7.95696 5.64603C7.96223 5.63713 7.96766 5.6299 7.96947 5.62809C8.0003 5.58081 8.02839 5.53179 8.05358 5.48127C8.07679 5.4151 8.09473 5.34893 8.10905 5.27914C8.11085 5.24522 8.11266 5.20935 8.11447 5.17528C8.11447 5.14122 8.11266 5.10549 8.10905 5.07158C8.09653 5.0036 8.07679 4.93562 8.05358 4.86945C8.0327 4.82734 8.01001 4.78615 7.98559 4.74599C7.98017 4.74057 7.96766 4.72278 7.94625 4.68872C7.92666 4.66716 7.90691 4.64395 7.88551 4.62435C7.86049 4.59933 7.83365 4.57431 7.80502 4.55094C7.79612 4.54567 7.78904 4.54024 7.78708 4.53843C7.73985 4.5076 7.69088 4.47952 7.64041 4.45432C7.57424 4.43111 7.50807 4.41317 7.44009 4.39885C7.3703 4.39358 7.30051 4.39358 7.23072 4.39885C7.16274 4.41137 7.09476 4.43111 7.0304 4.45432C6.98829 4.4752 6.9471 4.49789 6.90694 4.52231C6.90152 4.52773 6.88373 4.54024 6.84967 4.56165C6.82811 4.58124 6.8049 4.60099 6.7853 4.62239C6.76028 4.64741 6.73526 4.67424 6.71189 4.70288C6.70662 4.71178 6.70119 4.71901 6.69938 4.72082C6.66855 4.76805 6.64047 4.81702 6.61527 4.86749C6.59206 4.93366 6.57412 4.99983 6.5598 5.06781C6.55453 5.1376 6.55453 5.20739 6.5598 5.27718C6.57231 5.34516 6.59206 5.41314 6.61527 5.47751C6.63668 5.52046 6.65823 5.55981 6.68325 5.60096C6.68868 5.60638 6.70119 5.62417 6.7226 5.65823C6.74219 5.67979 6.76194 5.703 6.78334 5.7226C6.80836 5.74762 6.83519 5.77264 6.86383 5.79601C6.87273 5.80128 6.87996 5.80671 6.88177 5.80852C6.9282 5.83896 6.97839 5.8676 7.02844 5.89263C7.09461 5.91584 7.16078 5.93378 7.22876 5.9481C7.29855 5.95352 7.36834 5.95352 7.43813 5.9481C7.50611 5.93559 7.57409 5.91584 7.63845 5.89263C7.68141 5.87122 7.72076 5.84967 7.76191 5.82465C7.76733 5.81922 7.78512 5.80671 7.81918 5.7853C7.8424 5.76737 7.86395 5.74777 7.88355 5.72622C7.90872 5.7015 7.93374 5.67466 7.95696 5.64603Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_16390_8141">
<rect width="14" height="14" fill="white" />
</clipPath>
</defs>
</svg>
)

View File

@@ -0,0 +1,59 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface AppLayoutState {
isOpen: boolean
updateIsOpen: (newIsOpen: boolean) => void
menuDefaultRouter: {
[key: string]: string
}
updateMenuDefaultRouter: (headerMenu: string, defaultRouter: string) => void
linksOpenStatus: {
[key: string]: boolean
}
updateLinksOpenStatus: (key: string, opened: boolean) => void
resetAppLayoutStore: () => void
}
const initialAppLayoutState = {
isOpen: true,
headerMenu: "admin",
menuDefaultRouter: {},
linksOpenStatus: {},
}
export const useAppLayoutStore = create(
persist<AppLayoutState>(
(set) => ({
isOpen: initialAppLayoutState.isOpen,
updateIsOpen: (newIsOpen) =>
set((state) => {
return { ...state, isOpen: newIsOpen }
}),
menuDefaultRouter: initialAppLayoutState.menuDefaultRouter,
updateMenuDefaultRouter: (headerMenu, defaultRouter) =>
set((state) => {
return {
...state,
menuDefaultRouter: Object.assign({}, state.menuDefaultRouter, {
[headerMenu]: defaultRouter,
}),
}
}),
linksOpenStatus: initialAppLayoutState.linksOpenStatus,
updateLinksOpenStatus: (key, opened) =>
set((state) => {
return {
...state,
linksOpenStatus: Object.assign({}, state.linksOpenStatus, {
[key]: opened,
}),
}
}),
resetAppLayoutStore: () => set(initialAppLayoutState),
}),
{
name: "app-layout-store",
}
)
)

View File

@@ -0,0 +1,36 @@
.list {
position: relative;
margin-bottom: var(--mantine-spacing-md);
}
.indicator {
/* background-color: var(--mantine-color-white); */
/* border-radius: var(--mantine-radius-md); */
border-bottom: 2px solid var(--mantine-color-blue-6);
top: 2px;
/* box-shadow: var(--mantine-shadow-sm); */
@mixin dark {
/* background-color: var(--mantine-color-dark-6); */
border-color: var(--mantine-color-blue-4);
}
}
.tab {
z-index: 1;
font-weight: 500;
transition: color 100ms ease;
color: var(--mantine-color-gray-7);
&[data-active] {
color: var(--mantine-color-black);
}
@mixin dark {
color: var(--mantine-color-dark-1);
&[data-active] {
color: var(--mantine-color-white);
}
}
}

View File

@@ -0,0 +1,66 @@
import httpFetch from "@/api/fetch"
import { ResultData } from "@/api/typing"
import { Login } from "./types"
const BASE_PATH =
process.env.NEXT_PUBLIC_ENV === "production"
? "https://basis.soft.cowarobot.com"
: "https://basis.softtest.cowarobot.com"
// 获取验证码
export const getVerifyCode = (params: { phone: string }) => {
return httpFetch<ResultData<{}>>({
url: BASE_PATH + `/api/v1/basis/login/get_verify_code`,
method: "POST",
body: JSON.stringify(params),
})
}
// 账户登录
export const passwdLogin = (params: Login.ReqPasswdLogin) => {
return httpFetch<ResultData<Login.ResLogin>>({
url: BASE_PATH + `/api/v1/basis/login/passwd`,
method: "POST",
body: JSON.stringify(params),
isLogin: true,
})
}
// 验证码登录
export const verificationLogin = (params: Login.ReqVerificationLogin) => {
return httpFetch<ResultData<Login.ResLogin>>({
url: BASE_PATH + `/api/v1/basis/login/verification`,
method: "POST",
body: JSON.stringify(params),
isLogin: true,
})
}
// 飞书登录
export const fetchFeishuLogin = (params: Login.ReqFeishuLogin) => {
return httpFetch<ResultData<Login.ResLogin>>({
url: BASE_PATH + `/api/v1/basis/login/feishu`,
method: "POST",
body: JSON.stringify(params),
isLogin: true,
})
}
// 刷新token
export const refreshKey = (params: { token: string }) => {
return httpFetch<ResultData<Login.ResRefreshKey>>({
url: BASE_PATH + `/api/v1/basis/key/refresh`,
method: "POST",
body: JSON.stringify(params),
isRefresh: true,
})
}
// 删除cookie
export const deleteCookie = () => {
return httpFetch<ResultData<string>>({
url: `/api/session`,
method: "DELETE",
isDeleteCookie: true,
})
}

View File

@@ -0,0 +1,65 @@
export namespace Login {
export interface ReqPasswdLogin {
account?: string
phone?: string
password: string
tenant: string
platform: string
app: string
fingerprint: string
}
export interface ReqVerificationLogin {
phone: string
verify_code: string
tenant: string
platform: string
app: string
fingerprint: string
}
export interface ReqFeishuLogin {
code: string
redirect_uri: string
tenant: string
platform: string
app: string
app_id: string
app_secret: string
fingerprint: string
}
export interface RbacDataItem {
app: string
cat: string
platform: string
tenant: string
permission: {
[x: string]: {
[y: string]: [boolean, any, string]
}
}
}
export interface ResLogin {
access_token: string
cats: Array<{
cat: string
cat_id: number
}>
is_super: boolean
tenant: string
user_info: {
[x: string]: string
}
rbac: {
data: RbacDataItem[]
}
refresh_token: string
}
export interface ResRefreshKey {
access: string
refresh: string
}
}

View File

@@ -0,0 +1,102 @@
import { APP, PLATFORM, TENANT } from "@/components/login/libs/common"
import { useLoginStore } from "@/components/login/store"
import { useRouter } from "next/navigation"
import { useCallback, useEffect } from "react"
import { fetchFeishuLogin } from "../api"
import { DigitalIcon } from "../resource/icons/digital"
import { APP_ID, APP_SECRECT } from "./util"
const HeaderIcon = DigitalIcon
const FeishuAutoLogin = ({ useUserStore }: { useUserStore: any }) => {
const fingerprint = useLoginStore.getState().fingerprint
const list = window.location.href.split("/")
const http_header =
window.location.protocol.toLowerCase() === "https:" ? "https" : "http"
const redirect_url =
http_header + "://" + list[2] + `${process.env.NEXT_PUBLIC_BASE_PATH}/login`
const { setUserInfo, refreshToken } = useUserStore()
const router = useRouter()
const autoLogin = useCallback(
(
fingerprint: any,
redirect_url: any,
router: any,
setUserInfo: (arg0: any, arg1: any, arg2: any) => void
) => {
if (!window.h5sdk || !window.tt) {
console.error("Feishu H5 SDK is not available")
setTimeout(() => {
autoLogin(fingerprint, redirect_url, router, setUserInfo)
}, 500)
return
}
// 确保每次进到这个页面是未登录,然后触发自动登录逻辑
window.h5sdk.ready(async () => {
console.log("Feishu H5 SDK is ready, requesting auth code")
window.tt.requestAuthCode({
appId: APP_ID,
success: async (res: any) => {
try {
const params = {
code: res.code,
redirect_uri: redirect_url,
tenant: TENANT,
platform: PLATFORM,
app: APP,
fingerprint,
app_id: APP_ID,
app_secret: APP_SECRECT,
}
const response = await fetchFeishuLogin(params)
const info = response.message
setUserInfo(info.tenant, info.user_info, info.rbac)
refreshToken({
access_token: info.access_token || "",
refresh_token: info.refresh_token || "",
})
window.location.href = `${process.env.NEXT_PUBLIC_BASE_PATH}/`
} catch (err) {
console.log(err)
}
},
fail: (err: any) => {
console.error("Failed to get auth code from Feishu:", err)
alert(JSON.stringify(err))
},
})
})
},
[refreshToken]
)
const isFeishu = useCallback(() => !!(window.h5sdk && window.tt), [])
useEffect(() => {
console.log("Feishu auto login effect triggered")
autoLogin(fingerprint, redirect_url, router, setUserInfo)
}, [fingerprint, redirect_url, router, setUserInfo, autoLogin, isFeishu])
return (
<div
style={{
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
flexDirection: "column",
gap: 20,
}}>
<HeaderIcon />
<span>...</span>
</div>
)
}
export default FeishuAutoLogin

View File

@@ -0,0 +1,93 @@
import {
APP,
PLATFORM,
TENANT,
getQueryVariable,
} from "@/components/login/libs/common"
import { useLoginStore } from "@/components/login/store"
import { Flex } from "@mantine/core"
import { useSearchParams } from "next/navigation"
import { useCallback, useEffect, useState } from "react"
import { fetchFeishuLogin } from "../api"
import { APP_ID, APP_SECRECT, qr_load, qr_login } from "./util"
const QrLogin = ({ useUserStore }: { useUserStore: any }) => {
const fingerprint = useLoginStore.getState().fingerprint
const list = window.location.href.split("/")
const http_header =
window.location.protocol.toLowerCase() === "https:" ? "https" : "http"
const redirect_url =
http_header + "://" + list[2] + `${process.env.NEXT_PUBLIC_BASE_PATH}/login`
const searchParams = useSearchParams()
const renderCode = useCallback(async () => {
await qr_load()
qr_login(
APP_ID,
redirect_url,
getQueryVariable("app_id") || searchParams.get("app_id") || ""
)
}, [redirect_url, searchParams])
const [times, setTimes] = useState(0)
const callbackUrl = useCallback(() => {
times !== 2 && setTimes(times + 1)
}, [times])
const { setUserInfo, refreshToken } = useUserStore()
const feishuLogin = useCallback(async () => {
try {
const params = {
code: getQueryVariable("code") || "",
redirect_uri: redirect_url,
tenant: TENANT,
platform: PLATFORM,
app: APP,
fingerprint,
app_id: APP_ID,
app_secret: APP_SECRECT,
}
const res = await fetchFeishuLogin(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 = `${process.env.NEXT_PUBLIC_BASE_PATH}/`
} catch (err) {
console.log(err)
}
}, [fingerprint, redirect_url, refreshToken, setUserInfo])
useEffect(() => {
if (times === 1) {
renderCode()
if (getQueryVariable("code")) {
fingerprint && feishuLogin()
}
}
}, [feishuLogin, fingerprint, renderCode, times])
useEffect(() => {
callbackUrl()
}, [callbackUrl])
return (
<Flex align="center" justify="center" w={"100%"} h={"100%"}>
<div
id={"login_container"}
style={{
width: "200px",
height: "210px",
display: "flex",
justifyContent: "center",
scale: 0.7,
}}></div>
</Flex>
)
}
export default QrLogin

8
components/login/feishu/type.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
interface Window {
tt: any
h5sdk: any
}
interface window {
[key: string]: any
}

View File

@@ -0,0 +1,51 @@
// 飞书脚本
export function qr_load() {
return new Promise<void>((resolve, reject) => {
const script = document.createElement("script")
script.type = "text/javascript"
script.src =
"https://sf3-cn.feishucdn.com/obj/feishu-static/lark/passport/qrcode/LarkSSOSDKWebQRCode-1.0.2.js"
script.onerror = reject
script.onload = () => {
resolve()
}
document.head.appendChild(script)
})
}
export async function qr_login(
appId: string,
redirect_url: string,
state: string
) {
const gotoUrl = `https://passport.feishu.cn/suite/passport/oauth/authorize?client_id=${appId}&redirect_uri=${encodeURIComponent(
redirect_url
)}&response_type=code&state=${encodeURIComponent(state)}`
const QRLogin = (<any>window).QRLogin
const QRLoginObj = QRLogin({
id: "login_container",
goto: gotoUrl,
style: `width: 270px;height: 270px;border: 0;border-radius:12px;background-color: #E4F2FF;
background-image:
radial-gradient(at 47% 33%, hsl(212.37, 72%, 59%) 0, transparent 59%),
radial-gradient(at 82% 65%, hsl(198.00, 100%, 50%) 0, transparent 55%);`,
})
const handleMessage = function (event: any) {
const origin = event.origin
if (
QRLoginObj.matchOrigin(origin) &&
window.location.href.indexOf("login") > -1
) {
const loginTmpCode = event.data
window.location.href = `${gotoUrl}&tmp_code=${loginTmpCode}`
}
}
if (typeof window.addEventListener !== "undefined") {
window.addEventListener("message", handleMessage, false)
} else if (typeof (<any>window).attachEvent !== "undefined") {
;(<any>window).attachEvent("onmessage", handleMessage)
}
}
export const APP_ID = "cli_a56577acb2f3d00c"
export const APP_SECRECT = "168xtvkFLnVbZd1Ih5T5agKue3JxJ87V"

505
components/login/index.tsx Normal file
View File

@@ -0,0 +1,505 @@
"use client"
import {
getQueryVariable,
validatePhoneNumber,
} from "@/components/login/libs/common"
import FingerprintJS from "@fingerprintjs/fingerprintjs"
import {
BackgroundImage,
Box,
Button,
Flex,
FloatingIndicator,
Group,
Input,
LoadingOverlay,
Modal,
Paper,
PasswordInput,
Stack,
Tabs,
Text,
TextInput,
useComputedColorScheme,
} from "@mantine/core"
import { useForm } from "@mantine/form"
import { useDisclosure } from "@mantine/hooks"
import { useCallback, useEffect, useMemo, useState } from "react"
import { getVerifyCode, passwdLogin, verificationLogin } from "./api"
import QrLogin from "./feishu/qr-login"
import { APP, PLATFORM, TENANT } from "./libs/common"
import { DigitalIcon } from "./resource/icons/digital"
import { useLoginStore } from "./store"
import tabClasses from "./Tab.module.css"
// import { useSearchParams } from "next/navigation"
import Feishu from "./feishu/auto-login"
import DarkBgImage from "./resource/images/dark-login-bg.png"
import LightBgImage from "./resource/images/light-login-bg.png"
import loginBg2Dark from "./resource/images/login-bg2-dark.png"
import loginBg2Light from "./resource/images/login-bg2-light.png"
import LogoDigImage from "./resource/images/logo-dig.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 = `${process.env.NEXT_PUBLIC_BASE_PATH}/`
} 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>
)}
</>
)
}

View File

@@ -0,0 +1,21 @@
export const validatePhoneNumber = (str: string) => {
const reg = /^[1][3,4,5,6,7,8,9][0-9]{9}$/
return reg.test(str)
}
// 获取url中的某个参数值
export const getQueryVariable = (variable: string) => {
const query = window.location.search.substring(1)
const vars = query.split("&")
for (let i = 0; i < vars.length; i++) {
const pair = vars[i].split("=")
if (pair[0] === variable) {
return pair[1]
}
}
return false
}
export const APP = "basis"
export const PLATFORM = "标注平台"
export const TENANT = "cowarobot"

View File

@@ -0,0 +1,95 @@
import { ResultData } from "@/api/typing"
import { Login } from "@/components/login/api/types"
import redis from "@/components/login/libs/redis"
import { encrypt, generateKeyFromEnv } from "@/components/login/libs/session"
import { cookies } from "next/headers"
export const handleLoginData = async (props: {
clientIP: string
fingerprint: string
data: ResultData<Login.ResLogin>
}) => {
const { clientIP, fingerprint, data } = props
const sessionKey = {
clientIP,
fingerprint: fingerprint,
}
const cookieStore = await cookies()
const oldSession = cookieStore.get("session")?.value || ""
if (oldSession) {
redis.del(JSON.parse(oldSession) as any)
}
const key = await generateKeyFromEnv(process.env.ENCRYPTION_KEY || "")
const encryptedSessionData = await encrypt(JSON.stringify(sessionKey), key)
// save redis
await redis.set(
encryptedSessionData,
JSON.stringify({
clientIP,
fingerprint: fingerprint,
user_name: data?.message?.user_info?.user_name,
access_token: data.message.access_token,
refresh_token: data.message.refresh_token,
}),
"EX",
172800
)
// set cookie
cookieStore.set("session", JSON.stringify(encryptedSessionData), {
httpOnly: true,
secure: process.env.NEXT_PUBLIC_COOKIE_ENV === "production",
maxAge: 60 * 60 * 24 * 30, // 30天
path: "/",
})
return {
...data,
message: {
...data.message,
access_token: "",
refresh_token: "",
},
}
}
export const handleRefreshToken = async (props: {
data: ResultData<Login.ResRefreshKey>
userData: any
}) => {
const { userData, data } = props
const { redisKey, ...rest } = userData
await redis.set(
JSON.parse(redisKey),
JSON.stringify({
...rest,
access_token: data?.message?.access,
refresh_token: data?.message?.refresh,
}),
"EX",
172800
)
return {
...data,
message: {
...data.message,
// access_token: "",
// refresh_token: "",
access: "",
refresh: "",
},
}
}
export const handleDeleteCookie = async () => {
const cookieStore = await cookies()
const sessionData = cookieStore.get("session")?.value || ""
// 删cookie和redis中用户
if (sessionData) {
await redis.del(JSON.parse(sessionData) as any)
cookieStore.delete("session")
}
}

View File

@@ -0,0 +1,23 @@
import Redis from "ioredis"
const redisConfig = {
port: process.env.NEXT_PUBLIC_REDIS_PORT
? Number(process.env.NEXT_PUBLIC_REDIS_PORT)
: 6379, // Redis port
host: process.env.NEXT_PUBLIC_REDIS_URL || "", // Redis host
username: process.env.NEXT_PUBLIC_REDIS_USERNAME || "", // needs Redis >= 6
password: process.env.NEXT_PUBLIC_REDIS_PASSWORD || "",
db: process.env.NEXT_PUBLIC_REDIS_DB
? Number(process.env.NEXT_PUBLIC_REDIS_DB)
: 0, // Defaults to 0
}
const redis = new Redis({
port: redisConfig.port, // Redis port
host: redisConfig.host, // Redis host
username: redisConfig.username, // needs Redis >= 6
password: redisConfig.password,
db: redisConfig.db, // Defaults to 0
})
export default redis

View File

@@ -0,0 +1,156 @@
"use server"
import { Base64 } from "js-base64"
import { headers } from "next/headers"
import redis from "./redis"
// 生成加密密钥
export async function generateKey(): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{
name: "AES-CBC",
length: 256,
},
true,
["encrypt", "decrypt"]
)
}
export async function generateKeyFromEnv(
envPassword: string
): Promise<CryptoKey> {
const encoder = new TextEncoder()
const password = encoder.encode(envPassword)
const keyMaterial = await crypto.subtle.importKey(
"raw",
password,
{
name: "PBKDF2",
},
false,
["deriveKey"]
)
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: new Uint8Array(16), // 随机盐值
iterations: 100000, // 迭代次数
hash: "SHA-256",
},
keyMaterial,
{
name: "AES-CBC",
length: 256,
},
true,
["encrypt", "decrypt"]
)
}
// 将ArrayBuffer转换为Uint8Array
function arrayBufferToUint8Array(buffer: ArrayBuffer) {
return new Uint8Array(buffer)
}
// 加密函数
export async function encrypt(data: any, key: CryptoKey): Promise<string> {
const encodedData = new TextEncoder().encode(JSON.stringify(data))
const iv = crypto.getRandomValues(new Uint8Array(16)) // 16 bytes for AES block size
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
key,
encodedData
)
// 将Uint8Array转换为Base64字符串以便存储和传输
const encryptedBase64 = Base64.fromUint8Array(
arrayBufferToUint8Array(encrypted)
)
const ivBase64 = Base64.fromUint8Array(new Uint8Array(iv)) // 使用js-base64进行编码
// 将IV和加密数据拼接在一起以便解密时使用
return `${ivBase64}::${encryptedBase64}`
}
// 解密函数
export async function decrypt(
encryptedData: string,
key: CryptoKey
): Promise<any> {
// 将加密数据从Base64字符串转换回Uint8Array
const [ivBase64, encryptedBase64] = encryptedData.split("::")
const ivArray = Buffer.from(ivBase64, "base64")
const encrypted = Buffer.from(encryptedBase64, "base64")
// 使用AES-CBC算法进行解密
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: ivArray,
},
key,
encrypted
)
// 将ArrayBuffer转换为字符串
return new TextDecoder().decode(decrypted)
}
// 校验用户信息
export async function genAccessToken(req: any) {
try {
// 校验用户信息
// const fingerprint = req.headers.get("Fingerprint")
const sessionData = req.cookies.get("session")?.value
// 无session 直接重定向到登录页面
if (!sessionData) {
return "客户端请求中,未包含用户标识"
}
let redisKey = JSON.parse(sessionData)
const key = await generateKeyFromEnv(process.env.ENCRYPTION_KEY || "")
const decryptedSessionData = JSON.parse(await decrypt(redisKey, key))
const fingerprint = JSON.parse(decryptedSessionData)?.fingerprint
// 获取用户信息
let userData: any = await redis.get(redisKey)
userData = JSON.parse(userData)
// redis中无用户信息 直接重定向到登录页面
if (!userData) {
return "未获取到用户信息"
}
const header = headers()
let clientIP =
(await header).get("x-forwarded-for")?.split(",")[0] ||
req?.socket?.remoteAddress
// ip v6地址
if (clientIP.startsWith("::")) {
clientIP = clientIP.slice(7)
}
// 登录ip与用户请求ip不匹配
if (
clientIP !== userData?.clientIP ||
fingerprint !== userData?.fingerprint
) {
return `客户端信息与缓存用户信息不一致 \n 客户端: ${clientIP}, ${fingerprint} | redis存储: ${userData?.clientIP}, ${userData?.fingerprint}`
}
return {
...userData,
redisKey: sessionData,
}
} catch (err: any) {
return `获取用户信息报错, 报错原因:${err?.message}`
}
}

View File

@@ -0,0 +1,84 @@
export const DigitalIcon = () => {
return (
<svg
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="6.4" fill="#1F65FF" />
<path
d="M18.8274 10.2358C17.1727 9.51456 15.2092 9.56087 13.526 10.5327C11.533 11.6833 10.4672 13.8231 10.5906 15.9739C9.43935 15.5275 8.1034 15.5877 6.95081 16.2531C6.41165 16.5644 5.96713 16.9766 5.62734 17.4522C4.91523 13.1111 6.89492 8.58834 10.9301 6.25862C14.0914 4.43344 17.7876 4.37384 20.8768 5.77385C21.2809 5.98109 21.6159 6.32477 21.8599 6.74734C22.5348 7.91635 22.1343 9.41116 20.9653 10.0861C20.2877 10.4772 19.5008 10.5072 18.8275 10.2356L18.8274 10.2358Z"
fill="white"
/>
<path
d="M27.1744 17.0864C26.7514 20.333 24.8747 23.3531 21.8203 25.1165C17.7851 27.4463 12.8784 26.8994 9.47497 24.1121C10.0567 24.0556 10.636 23.8768 11.1751 23.5655C12.3277 22.9 13.0478 21.7732 13.2368 20.5529C15.0378 21.7352 17.4238 21.8821 19.4168 20.7314C21.0984 19.7605 22.12 18.0854 22.324 16.2936C22.4243 15.5728 22.8441 14.9041 23.5231 14.5121C24.6921 13.8372 26.1869 14.2377 26.8619 15.4067C27.1674 15.9358 27.2528 16.4847 27.1744 17.0864Z"
fill="white"
/>
<path
d="M7.71583 17.3525C8.60142 16.8412 9.64534 16.852 10.4907 17.2896C11.1047 17.6075 11.6765 17.627 12.181 17.5259C12.3868 17.4776 12.5799 17.4028 12.7535 17.3026C13.3325 16.9683 13.7899 16.4562 13.9352 15.5682C13.9365 15.5428 13.9373 15.5167 13.9394 15.4914C14.0025 14.7188 14.4317 13.9883 15.1537 13.5713C16.3227 12.8966 17.817 13.2976 18.4919 14.4665C19.1666 15.6354 18.7668 17.1301 17.5981 17.805C16.8759 18.2218 16.0287 18.2283 15.328 17.8966C15.3057 17.8861 15.2842 17.8738 15.2621 17.8625C14.4195 17.5433 13.7469 17.6831 13.1674 18.0176C12.9954 18.1169 12.8343 18.2445 12.6905 18.3967C12.4263 18.6954 12.2051 19.0654 12.1036 19.5384C12.0736 19.6801 12.0535 19.8289 12.0464 19.9841C12.0437 20.0434 12.0389 20.1025 12.0325 20.1613C11.9371 21.0467 11.4348 21.8764 10.6045 22.3558L10.6032 22.3555L10.6037 22.3563C9.22241 23.1534 7.45586 22.6807 6.65814 21.2998C6.5878 21.178 6.53153 21.0511 6.48071 20.9242C6.43658 20.8141 6.39903 20.7031 6.36924 20.5905C6.2986 20.323 6.26795 20.0511 6.27515 19.781C6.27878 19.6429 6.28994 19.5056 6.31305 19.3701C6.34384 19.19 6.39063 19.013 6.4546 18.8418C6.61633 18.4088 6.88218 18.0125 7.24107 17.6943C7.31295 17.6306 7.38849 17.5698 7.46774 17.5127C7.5468 17.4557 7.62961 17.4023 7.71583 17.3525Z"
fill="url(#paint0_linear_3567_2455)"
/>
<mask
id="mask0_3567_2455"
maskUnits="userSpaceOnUse"
x="5"
y="4"
width="23"
height="23">
<path
d="M27.1711 17.0865C26.7481 20.3332 24.8714 23.3532 21.817 25.1167C17.7818 27.4464 12.8751 26.8995 9.47166 24.1122C10.0534 24.0557 10.6327 23.8769 11.1718 23.5656C12.3244 22.9002 13.0445 21.7733 13.2335 20.553C15.0344 21.7353 17.4205 21.8822 19.4135 20.7315C21.0951 19.7606 22.1167 18.0855 22.3207 16.2937C22.421 15.573 22.8408 14.9042 23.5198 14.5122C24.6888 13.8373 26.1836 14.2378 26.8586 15.4068C27.164 15.9359 27.2778 16.5301 27.1711 17.0865Z"
fill="white"
/>
<path
d="M18.8267 10.2362C17.1721 9.51488 15.2085 9.56119 13.5253 10.533C11.5324 11.6836 10.4665 13.8234 10.5899 15.9743C9.43868 15.5278 8.10273 15.588 6.95014 16.2534C6.41098 16.5647 5.96646 16.977 5.62667 17.4525C4.91456 13.1114 6.89424 8.58866 10.9294 6.25894C14.0907 4.43376 17.7869 4.37416 20.8761 5.77417L20.86 5.79569C21.2641 6.00294 21.6152 6.32509 21.8592 6.74766C22.5341 7.91667 22.1336 9.41148 20.9646 10.0864C20.2871 10.4776 19.5001 10.5075 18.8268 10.236L18.8267 10.2362Z"
fill="white"
/>
</mask>
<g mask="url(#mask0_3567_2455)">
<path
d="M13.235 20.5391C15.2733 22.042 20.2924 22.2388 22.0858 17.4017C21.7921 19.8898 20.1903 22.5922 17.7697 23.9897C15.0334 25.5695 11.9266 25.5141 9.46684 24.1086C11.4352 23.8134 12.813 22.6325 13.235 20.5391Z"
fill="url(#paint1_radial_3567_2455)"
fillOpacity="0.45"
/>
<path
d="M17.871 9.91452C14.523 9.11737 10.5125 11.4597 10.593 15.9775C8.61077 15.3494 6.8336 15.9721 5.6281 17.4562C5.72509 14.6902 7.29061 11.9514 9.93637 10.4239C12.2419 9.09279 15.6358 9.07102 17.871 9.91452Z"
fill="url(#paint2_radial_3567_2455)"
fillOpacity="0.45"
/>
</g>
<defs>
<linearGradient
id="paint0_linear_3567_2455"
x1="5.9905"
y1="21.684"
x2="19.3253"
y2="13.9852"
gradientUnits="userSpaceOnUse">
<stop stopColor="#FF6C6C" />
<stop offset="1" stopColor="#FFF1CE" />
</linearGradient>
<radialGradient
id="paint1_radial_3567_2455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(6.51419 24.5026) rotate(-22.4902) scale(16.7226 15.7844)">
<stop stopColor="white" stopOpacity="0" />
<stop offset="0.704134" stopColor="#2288FF" />
</radialGradient>
<radialGradient
id="paint2_radial_3567_2455"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(1.29495 15.4944) rotate(-22.703) scale(16.8219 15.438)">
<stop stopColor="white" stopOpacity="0" />
<stop offset="0.704134" stopColor="#2288FF" />
</radialGradient>
</defs>
</svg>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

26
components/login/store.ts Normal file
View File

@@ -0,0 +1,26 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
interface LoginState {
fingerprint: string
setFingerprint: (fingerprint: string) => void
}
const initialLoginState = {
fingerprint: "",
}
export const useLoginStore = create(
persist<LoginState>(
(set) => ({
fingerprint: initialLoginState.fingerprint,
setFingerprint: (fingerprint) =>
set((state) => {
return { ...state, fingerprint }
}),
}),
{
name: "login-store",
}
)
)