feat(nav,site): 完善导航界面并完成登录功能

完善了导航界面,优化了布局和样式。
完成了用户登录功能,包括表单验证。
This commit is contained in:
2025-11-16 22:20:23 +08:00
parent 7cc246fbf3
commit 696ec0689b
29 changed files with 1011 additions and 3 deletions

View File

@@ -11,12 +11,17 @@
"prepare": "husky" "prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^6.30.2", "react-router-dom": "^6.30.2",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwind-scrollbar": "^4.0.2", "tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.1.17" "tailwindcss": "^4.1.17"
@@ -41,7 +46,8 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.46.3", "typescript-eslint": "^8.46.3",
"vite": "^7.2.2" "vite": "^7.2.2",
"zustand": "^5.0.8"
}, },
"config": { "config": {
"commitizen": { "commitizen": {

View File

@@ -1,5 +1,15 @@
import { Header, Main, Footer } from "@/views";
import { ToastContainer } from "react-toastify";
function App() { function App() {
return <div>App</div>; return (
<div className="w-full h-full bg-linear-to-br from-sky-400 dark:from-pink-800 via-teal-400 dark:via-rose-800 to-lime-400 dark:to-fuchsia-800 flex flex-col py-4">
<Header className="flex-2 p-2 my-2 mx-5 flex-row items-center bg-cyan-300/50 dark:bg-gray-700/50" />
<Main className="flex-30 p-2 mt-2 mx-5 bg-cyan-300/50 dark:bg-gray-700/50 overflow-x-hidden overflow-y-auto scrollbar scrollbar-thumb-teal-400 dark:scrollbar-thumb-fuchsia-800 [&::-webkit-scrollbar-thumb]:rounded-full" />
<Footer className="flex-1 p-2 my-2 mx-5 bg-cyan-300/50 dark:bg-gray-700/50" />
<ToastContainer />
</div>
);
} }
export default App; export default App;

15
src/api/auth.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { Request, Response } from "@/types";
import { r } from "./request";
const LoginApi = async ({
path = "auth/login",
data,
}: Request): Promise<Response> => {
const res: Response = await r.post(path, data);
if (res.code === 0) {
localStorage.setItem("token", res.data?.token);
}
return res;
};
export { LoginApi };

14
src/api/cateage.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Request, Response } from "@/types";
import { r } from "./request";
const CateageApi = async ({
path = "cateage/",
}: Request): Promise<Response> => {
const res: Response = await r.get(path);
if (res.code !== 0) {
throw new Error(res.msg || "请求失败");
}
return res;
};
export { CateageApi };

5
src/api/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import { LoginApi } from "./auth";
import { MeApi } from "./user";
import { CateageApi } from "./cateage";
export { LoginApi, MeApi, CateageApi };

26
src/api/request.ts Normal file
View File

@@ -0,0 +1,26 @@
import axios from "axios";
const instance = axios.create({
baseURL: "/api/v1",
timeout: 3000,
headers: {
"Content-Type": "application/json",
},
});
instance.interceptors.request.use(
(config) => {
const token = localStorage.getItem("token");
if (!token) return config;
config.headers["Authorization"] = `${token}`;
return config;
},
(error) => Promise.reject(error),
);
instance.interceptors.response.use(
(res) => res.data,
(error) => error.response.data,
);
export { instance as r };

12
src/api/user.ts Normal file
View File

@@ -0,0 +1,12 @@
import type { Request, Response } from "@/types";
import { r } from "./request";
const MeApi = async ({ path = "user/me" }: Request): Promise<Response> => {
const res: Response = await r.get(path);
if (res.code !== 0) {
throw new Error(res.msg || "请求失败");
}
return res;
};
export { MeApi };

1
src/assets/dark.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763281630260" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="12209" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M551.41 326.72h-74.14c-10.24 0-18.56-8.3-18.56-18.54 0-10.24 8.31-18.54 18.56-18.54h74.14c10.24 0 18.56 8.3 18.56 18.54 0 10.24-8.32 18.54-18.56 18.54z" fill="#F6BB42" p-id="12210"></path><path d="M773.93 697.54c-245.78 0-445.01-199.23-445.01-445 0-55.23 10.08-108.13 28.48-156.94C189.08 159.05 69.34 321.61 69.34 512.14c0 245.76 199.23 445 445.01 445 190.51 0 353.07-119.74 416.51-288.07-48.8 18.4-101.7 28.47-156.93 28.47z" fill="#FFCE54" p-id="12211"></path><path d="M551.41 920.05c-245.75 0-444.98-199.23-444.98-445 0-113.41 42.44-216.91 112.26-295.48-91.61 81.5-149.35 200.28-149.35 332.57 0 245.76 199.23 445 445.01 445 132.26 0 251.05-57.74 332.55-149.37-78.57 69.85-182.08 112.28-295.49 112.28z" fill="#F6BB42" p-id="12212"></path><path d="M736.84 308.17c0 10.24-8.31 18.54-18.55 18.54-10.22 0-18.53-8.3-18.53-18.54 0-10.24 8.32-18.54 18.53-18.54 10.24 0 18.55 8.3 18.55 18.54z" fill="#4A89DC" p-id="12213"></path><path d="M644.14 493.59c0 10.23-8.31 18.54-18.56 18.54-10.24 0-18.53-8.32-18.53-18.54 0-10.24 8.29-18.54 18.53-18.54 10.25 0 18.56 8.3 18.56 18.54z" fill="#48CFAD" p-id="12214"></path><path d="M959.33 270.79c0 10.24-8.31 18.54-18.53 18.54-10.24 0-18.56-8.3-18.56-18.54 0-10.24 8.31-18.54 18.56-18.54 10.22 0 18.53 8.29 18.53 18.54z" fill="#ED5564" p-id="12215"></path><path d="M625.58 85.37c0 10.24-8.29 18.54-18.53 18.54-10.24 0-18.56-8.3-18.56-18.54 0-10.23 8.31-18.53 18.56-18.53 10.25 0 18.53 8.3 18.53 18.53z" fill="#AC92EB" p-id="12216"></path><path d="M514.35 234c-10.24 0-18.56 8.31-18.56 18.54V363.8c0 10.23 8.31 18.53 18.56 18.53 10.22 0 18.53-8.3 18.53-18.53V252.54c0-10.23-8.31-18.54-18.53-18.54z" fill="#FFCE54" p-id="12217"></path><path d="M829.55 437.96c-10.24 0-18.53 8.3-18.53 18.54v111.24c0 10.24 8.29 18.54 18.53 18.54 10.24 0 18.53-8.3 18.53-18.54V456.51c-0.01-10.25-8.29-18.55-18.53-18.55z" fill="#F6BB42" p-id="12218"></path><path d="M866.63 530.67h-74.17c-10.24 0-18.53-8.3-18.53-18.53 0-10.24 8.29-18.54 18.53-18.54h74.17c10.24 0 18.53 8.3 18.53 18.54 0 10.22-8.28 18.53-18.53 18.53z" fill="#FFCE54" p-id="12219"></path></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

1
src/assets/light.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763282237098" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13395" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M514.844444 510.103704m-201.955555 0a201.955556 201.955556 0 1 0 403.911111 0 201.955556 201.955556 0 1 0-403.911111 0Z" fill="#FABE2C" p-id="13396"></path><path d="M486.4 93.866667v148.859259c0 36.977778 56.888889 36.977778 56.888889 0V93.866667c0-36.977778-56.888889-36.977778-56.888889 0zM486.4 777.481481v148.85926c0 36.977778 56.888889 36.977778 56.888889 0V777.481481c0-36.977778-56.888889-36.977778-56.888889 0zM931.081481 481.659259H782.222222c-36.977778 0-36.977778 56.888889 0 56.888889h148.859259c36.977778 0 36.977778-56.888889 0-56.888889zM247.466667 481.659259H98.607407c-36.977778 0-36.977778 56.888889 0 56.888889h148.85926c36.977778 0 36.977778-56.888889 0-56.888889z" fill="#FABE2C" p-id="13397"></path><path d="M788.859259 195.318519L683.614815 300.562963c-25.6 25.6 14.222222 66.37037 40.77037 40.77037l105.244445-105.244444c25.6-26.548148-15.17037-66.37037-40.770371-40.77037zM305.303704 678.874074L200.059259 784.118519c-25.6 25.6 14.222222 66.37037 40.770371 40.77037l105.244444-105.244445c25.6-26.548148-14.222222-67.318519-40.77037-40.77037z" fill="#FABE2C" p-id="13398"></path><path d="M829.62963 784.118519L724.385185 678.874074c-25.6-25.6-66.37037 14.222222-40.77037 40.77037L788.859259 824.888889c25.6 25.6 66.37037-15.17037 40.770371-40.77037zM346.074074 300.562963L240.82963 195.318519c-25.6-25.6-66.37037 14.222222-40.770371 40.77037L305.303704 341.333333c26.548148 25.6 66.37037-15.17037 40.77037-40.77037z" fill="#FABE2C" p-id="13399"></path><path d="M514.844444 510.103704m-158.34074 0a158.340741 158.340741 0 1 0 316.681481 0 158.340741 158.340741 0 1 0-316.681481 0Z" fill="#FFD578" p-id="13400"></path><path d="M514.844444 510.103704m-107.14074 0a107.140741 107.140741 0 1 0 214.281481 0 107.140741 107.140741 0 1 0-214.281481 0Z" fill="#FABE2C" p-id="13401"></path></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

1
src/assets/user.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1763293824366" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="14400" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M511.913993 941.605241c-255.612968 0-385.311608-57.452713-385.311608-170.810012 0-80.846632 133.654964-133.998992 266.621871-151.88846L393.224257 602.049387c-79.986561-55.904586-118.86175-153.436587-118.86175-297.240383 0-139.33143 87.211154-222.586259 233.423148-222.586259l7.912649 0c146.211994 0 233.423148 83.254829 233.423148 222.586259 0 54.184445 0 214.67361-117.829666 297.412397l-0.344028 16.685369c132.966907 18.061482 266.105829 71.041828 266.105829 151.716445C897.225601 884.152528 767.526961 941.605241 511.913993 941.605241zM507.957668 141.567613c-79.470519 0-174.250294 28.382328-174.250294 163.241391 0 129.698639 34.230808 213.469511 104.584579 255.784982 8.944734 5.332437 14.277171 14.965228 14.277171 25.286074l0 59.344868c0 15.309256-11.524945 28.0383-26.662187 29.414413-144.319839 14.449185-239.959684 67.429531-239.959684 95.983874 0 92.199563 177.346548 111.637158 325.966739 111.637158 148.792206 0 325.966739-19.26558 325.966739-111.637158 0-28.726356-95.639845-81.534688-239.959684-95.983874-15.48127-1.548127-27.006215-14.621199-26.662187-30.102469l1.376113-59.344868c0.172014-10.148833 5.676466-19.437594 14.277171-24.770032 70.525785-42.487485 103.208466-123.678145 103.208466-255.784982 0-135.031077-94.779775-163.241391-174.250294-163.241391L507.957668 141.567613 507.957668 141.567613z" fill="#575B66" p-id="14401"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

84
src/components/Login.tsx Normal file
View File

@@ -0,0 +1,84 @@
import { useState, type Dispatch, type FC, type SetStateAction } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Button } from "./ui/button";
import { LoginApi, MeApi } from "@/api";
import { message } from "@/utils/message";
import { useUserStore } from "@/stores";
interface LoginProps {
className?: string;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
const Login: FC<LoginProps> = ({ className = "", isOpen, setIsOpen }) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const { user, setUser } = useUserStore();
const handlerLogin = async () => {
// console.log(username, password);
const data = { username, password };
const res = await LoginApi({ data });
if (res.code === 0) {
const res2 = await MeApi({});
if (res2.code !== 0) {
message(res2.msg);
return;
}
setUser(res2.data);
setIsOpen(false);
setUsername("");
setPassword("");
message("登录成功!");
} else {
message(res.msg);
}
};
return (
<div className={className}>
<Dialog open={isOpen} onOpenChange={() => setIsOpen(!isOpen)}>
<DialogContent>
<DialogTitle>Login View</DialogTitle>
<DialogDescription></DialogDescription>
<div className="flex-row flex items-center space-x-4">
<span className="flex-1">: </span>
<Input
className="flex-7"
autoComplete="off"
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="请输入用户名"
/>
</div>
<div className="flex-row flex items-center space-x-4">
<span className="flex-1">: </span>
<Input
className="flex-7"
autoComplete="off"
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="请输入密码"
/>
</div>
<Button className="mt-5" onClick={handlerLogin}>
</Button>
</DialogContent>
</Dialog>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -2,8 +2,13 @@
@import "tw-animate-css"; @import "tw-animate-css";
@plugin 'tailwind-scrollbar'; @plugin 'tailwind-scrollbar';
html,
body,
#root {
@apply flex h-full w-full items-center justify-center text-center;
}
:root { :root {
@apply flex h-full w-full items-center justify-center;
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);

4
src/stores/index.ts Normal file
View File

@@ -0,0 +1,4 @@
import useThemeStore from "./themeStore";
import useUserStore from "./userStore";
export { useThemeStore, useUserStore };

17
src/stores/themeStore.ts Normal file
View File

@@ -0,0 +1,17 @@
import { create } from "zustand";
interface ThemeState {
isDarkMode: boolean;
toggleTheme: () => void;
}
const useThemeStore = create<ThemeState>((set) => ({
isDarkMode: false,
toggleTheme: () =>
set((state: ThemeState) => {
localStorage.setItem("theme", !state.isDarkMode ? "dark" : "");
return { isDarkMode: !state.isDarkMode };
}),
}));
export default useThemeStore;

24
src/stores/userStore.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { User } from "@/types";
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface UserState {
user: User | null;
setUser: (user: User) => void;
clearUser: () => void;
}
const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
setUser: (user: User) => set({ user }),
clearUser: () => set({ user: null }),
}),
{
name: "user-storage",
},
),
);
export default useUserStore;

19
src/types/cateage.ts Normal file
View File

@@ -0,0 +1,19 @@
export interface Cateage {
name: string;
id: string;
created_at?: string;
updated_at?: string;
anchor_id: number;
sites: Site[];
}
export interface Site {
name: string;
cateage_id: number;
describe?: string;
created_at?: string;
updated_at?: string;
url: string;
cors: boolean;
icon: string;
}

10
src/types/http.ts Normal file
View File

@@ -0,0 +1,10 @@
export interface Request {
path?: string;
data?: any;
}
export interface Response {
code: number;
msg: string;
data: any;
}

5
src/types/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { Request, Response } from "./http";
import type { User } from "./user";
import type { Cateage, Site } from "./cateage";
export type { Request, Response, User, Cateage, Site };

12
src/types/user.ts Normal file
View File

@@ -0,0 +1,12 @@
export interface User {
id: number;
created_at: string;
updated_at: string;
username: string;
email: string;
is_admin: boolean;
disabled: boolean;
sso_id: string;
verified: boolean;
points: number;
}

8
src/utils/message.ts Normal file
View File

@@ -0,0 +1,8 @@
import { toast } from "react-toastify";
export const message = (msg: string) => {
toast(msg, {
autoClose: 2000,
position: "bottom-right",
});
};

12
src/views/Footer.tsx Normal file
View File

@@ -0,0 +1,12 @@
import { Card } from "@/components/ui/card";
import type { FC } from "react";
interface FooterProps {
className?: string;
}
const Footer: FC<FooterProps> = ({ className = "" }) => {
return <Card className={className}>Copyright &copy; 2025 Zhilv</Card>;
};
export default Footer;

87
src/views/Header.tsx Normal file
View File

@@ -0,0 +1,87 @@
import { Card } from "@/components/ui/card";
import { useThemeStore, useUserStore } from "@/stores";
import { useEffect, useState, type FC } from "react";
import light from "@/assets/light.svg";
import dark from "@/assets/dark.svg";
import Login from "@/components/Login";
import UserSvg from "@/assets/user.svg";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { message } from "@/utils/message";
interface HeaderProps {
className?: string;
title?: string;
}
const Header: FC<HeaderProps> = ({ className = "", title = "导航站" }) => {
const { isDarkMode, toggleTheme } = useThemeStore();
const [isOpen, setIsOpen] = useState(false);
const { user, clearUser } = useUserStore();
useEffect(() => {
const theme = localStorage.getItem("theme");
if (theme === "dark") {
toggleTheme();
}
}, [toggleTheme]);
useEffect(() => {
console.log(user);
if (isDarkMode) {
document.body.classList.add("dark");
localStorage.setItem("theme", "dark");
} else {
document.body.classList.remove("dark");
localStorage.setItem("theme", "light");
}
}, [isDarkMode]);
const handlerLogin = () => {
setIsOpen(!isOpen);
};
const handlerLogout = () => {
message("退出成功");
clearUser();
};
return (
<Card className={className}>
<div className="flex-1 text-2xl">{title}</div>
<div className="flex items-center">
<img
onClick={toggleTheme}
src={isDarkMode ? dark : light}
alt="Theme Icon"
className="size-10 font-bold"
/>
</div>
{user ? (
<DropdownMenu>
<DropdownMenuTrigger className="flex-row flex items-center">
<img className="size-10" src={UserSvg} alt="" />
<span className="flex">{user.username}</span>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<button onClick={handlerLogout}>Logout</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button onClick={handlerLogin} className="mr-1 items-center">
login
</button>
)}
<Login isOpen={isOpen} setIsOpen={setIsOpen} />
</Card>
);
};
export default Header;

56
src/views/Main.tsx Normal file
View File

@@ -0,0 +1,56 @@
import { CateageApi } from "@/api";
import { Card } from "@/components/ui/card";
import type { Cateage } from "@/types";
import { useEffect, useState, type FC } from "react";
interface MainProps {
className?: string;
}
const Main: FC<MainProps> = ({ className = "" }) => {
const [cateageWithSites, setCateageWithSites] = useState<Cateage[]>();
useEffect(() => {
const getData = async () => {
const res = await CateageApi({});
setCateageWithSites(res.data);
};
getData();
}, []);
return (
<Card className={className}>
{cateageWithSites?.map((cateage) => (
<div className="pt-5 flex-col">
<a className="text-xl flex mx-15" href={`#${cateage.anchor_id}`}>
{cateage.name}
</a>
<div className="flex-wrap px-10 py-5 justify-items-center gap-4 grid-cols-[repeat(auto-fill,minmax(20rem,4fr))] grid">
{cateage.sites.map((site) => (
<a
href={site.url}
target="_blank"
className="shadow-lg hover:-translate-y-1 hover:shadow-gray-900/20 dark:hover:shadow-gray-100/20"
>
<Card className="w-80 h-20 bg-gray-300/50 dark:bg-gray-700/50 flex-row items-center justify-center">
<img
src={
site.cors ? `/api/v1/proxy?url=${site.icon}` : site.icon
}
alt=""
className="size-8 ml-5"
/>
<span className="flex-1 text-center text-wrap">
{site.name}
</span>
</Card>
</a>
))}
</div>
</div>
))}
</Card>
);
};
export default Main;

5
src/views/index.ts Normal file
View File

@@ -0,0 +1,5 @@
import Header from "./Header";
import Main from "./Main";
import Footer from "./Footer";
export { Header, Main, Footer };