feat(nav,site): 完善导航界面并完成登录功能
完善了导航界面,优化了布局和样式。 完成了用户登录功能,包括表单验证。
This commit is contained in:
@@ -11,12 +11,17 @@
|
||||
"prepare": "husky"
|
||||
},
|
||||
"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",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^6.30.2",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-scrollbar": "^4.0.2",
|
||||
"tailwindcss": "^4.1.17"
|
||||
@@ -41,7 +46,8 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.3",
|
||||
"vite": "^7.2.2"
|
||||
"vite": "^7.2.2",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
|
||||
12
src/App.tsx
12
src/App.tsx
@@ -1,5 +1,15 @@
|
||||
import { Header, Main, Footer } from "@/views";
|
||||
import { ToastContainer } from "react-toastify";
|
||||
|
||||
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;
|
||||
|
||||
15
src/api/auth.ts
Normal file
15
src/api/auth.ts
Normal 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
14
src/api/cateage.ts
Normal 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
5
src/api/index.ts
Normal 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
26
src/api/request.ts
Normal 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
12
src/api/user.ts
Normal 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
1
src/assets/dark.svg
Normal 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
1
src/assets/light.svg
Normal 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
1
src/assets/user.svg
Normal 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
84
src/components/Login.tsx
Normal 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;
|
||||
60
src/components/ui/button.tsx
Normal file
60
src/components/ui/button.tsx
Normal 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 }
|
||||
92
src/components/ui/card.tsx
Normal file
92
src/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
141
src/components/ui/dialog.tsx
Normal file
141
src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal 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 }
|
||||
@@ -2,8 +2,13 @@
|
||||
@import "tw-animate-css";
|
||||
@plugin 'tailwind-scrollbar';
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@apply flex h-full w-full items-center justify-center text-center;
|
||||
}
|
||||
|
||||
:root {
|
||||
@apply flex h-full w-full items-center justify-center;
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
|
||||
4
src/stores/index.ts
Normal file
4
src/stores/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import useThemeStore from "./themeStore";
|
||||
import useUserStore from "./userStore";
|
||||
|
||||
export { useThemeStore, useUserStore };
|
||||
17
src/stores/themeStore.ts
Normal file
17
src/stores/themeStore.ts
Normal 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
24
src/stores/userStore.ts
Normal 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
19
src/types/cateage.ts
Normal 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
10
src/types/http.ts
Normal 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
5
src/types/index.ts
Normal 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
12
src/types/user.ts
Normal 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
8
src/utils/message.ts
Normal 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
12
src/views/Footer.tsx
Normal 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 © 2025 Zhilv</Card>;
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
87
src/views/Header.tsx
Normal file
87
src/views/Header.tsx
Normal 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
56
src/views/Main.tsx
Normal 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
5
src/views/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import Header from "./Header";
|
||||
import Main from "./Main";
|
||||
import Footer from "./Footer";
|
||||
|
||||
export { Header, Main, Footer };
|
||||
Reference in New Issue
Block a user