diff --git a/package.json b/package.json
index e9274fa..7f54b43 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/App.tsx b/src/App.tsx
index 544badc..de28c38 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,15 @@
+import { Header, Main, Footer } from "@/views";
+import { ToastContainer } from "react-toastify";
+
function App() {
- return
App
;
+ return (
+
+
+
+
+
+
+ );
}
export default App;
diff --git a/src/api/auth.ts b/src/api/auth.ts
new file mode 100644
index 0000000..62bda1d
--- /dev/null
+++ b/src/api/auth.ts
@@ -0,0 +1,15 @@
+import type { Request, Response } from "@/types";
+import { r } from "./request";
+
+const LoginApi = async ({
+ path = "auth/login",
+ data,
+}: Request): Promise => {
+ const res: Response = await r.post(path, data);
+ if (res.code === 0) {
+ localStorage.setItem("token", res.data?.token);
+ }
+ return res;
+};
+
+export { LoginApi };
diff --git a/src/api/cateage.ts b/src/api/cateage.ts
new file mode 100644
index 0000000..1477361
--- /dev/null
+++ b/src/api/cateage.ts
@@ -0,0 +1,14 @@
+import type { Request, Response } from "@/types";
+import { r } from "./request";
+
+const CateageApi = async ({
+ path = "cateage/",
+}: Request): Promise => {
+ const res: Response = await r.get(path);
+ if (res.code !== 0) {
+ throw new Error(res.msg || "请求失败");
+ }
+ return res;
+};
+
+export { CateageApi };
diff --git a/src/api/index.ts b/src/api/index.ts
new file mode 100644
index 0000000..ae640b4
--- /dev/null
+++ b/src/api/index.ts
@@ -0,0 +1,5 @@
+import { LoginApi } from "./auth";
+import { MeApi } from "./user";
+import { CateageApi } from "./cateage";
+
+export { LoginApi, MeApi, CateageApi };
diff --git a/src/api/request.ts b/src/api/request.ts
new file mode 100644
index 0000000..6b143c4
--- /dev/null
+++ b/src/api/request.ts
@@ -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 };
diff --git a/src/api/user.ts b/src/api/user.ts
new file mode 100644
index 0000000..a5cae03
--- /dev/null
+++ b/src/api/user.ts
@@ -0,0 +1,12 @@
+import type { Request, Response } from "@/types";
+import { r } from "./request";
+
+const MeApi = async ({ path = "user/me" }: Request): Promise => {
+ const res: Response = await r.get(path);
+ if (res.code !== 0) {
+ throw new Error(res.msg || "请求失败");
+ }
+ return res;
+};
+
+export { MeApi };
diff --git a/src/assets/dark.svg b/src/assets/dark.svg
new file mode 100644
index 0000000..20a6fc7
--- /dev/null
+++ b/src/assets/dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/light.svg b/src/assets/light.svg
new file mode 100644
index 0000000..1e740ee
--- /dev/null
+++ b/src/assets/light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/assets/user.svg b/src/assets/user.svg
new file mode 100644
index 0000000..63a4770
--- /dev/null
+++ b/src/assets/user.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/Login.tsx b/src/components/Login.tsx
new file mode 100644
index 0000000..8320167
--- /dev/null
+++ b/src/components/Login.tsx
@@ -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>;
+}
+
+const Login: FC = ({ 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 (
+
+ );
+};
+
+export default Login;
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
new file mode 100644
index 0000000..21409a0
--- /dev/null
+++ b/src/components/ui/button.tsx
@@ -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 & {
+ asChild?: boolean
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 0000000..681ad98
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,92 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..6cb123b
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -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) {
+ return
+}
+
+function DialogTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogClose({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: React.ComponentProps & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogTitle({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
new file mode 100644
index 0000000..eaed9ba
--- /dev/null
+++ b/src/components/ui/dropdown-menu.tsx
@@ -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) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 0000000..8916905
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/index.css b/src/index.css
index 1fe4301..4404675 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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);
diff --git a/src/stores/index.ts b/src/stores/index.ts
new file mode 100644
index 0000000..eefbc5b
--- /dev/null
+++ b/src/stores/index.ts
@@ -0,0 +1,4 @@
+import useThemeStore from "./themeStore";
+import useUserStore from "./userStore";
+
+export { useThemeStore, useUserStore };
diff --git a/src/stores/themeStore.ts b/src/stores/themeStore.ts
new file mode 100644
index 0000000..6b630f0
--- /dev/null
+++ b/src/stores/themeStore.ts
@@ -0,0 +1,17 @@
+import { create } from "zustand";
+
+interface ThemeState {
+ isDarkMode: boolean;
+ toggleTheme: () => void;
+}
+
+const useThemeStore = create((set) => ({
+ isDarkMode: false,
+ toggleTheme: () =>
+ set((state: ThemeState) => {
+ localStorage.setItem("theme", !state.isDarkMode ? "dark" : "");
+ return { isDarkMode: !state.isDarkMode };
+ }),
+}));
+
+export default useThemeStore;
diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts
new file mode 100644
index 0000000..ba3e016
--- /dev/null
+++ b/src/stores/userStore.ts
@@ -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()(
+ persist(
+ (set) => ({
+ user: null,
+ setUser: (user: User) => set({ user }),
+ clearUser: () => set({ user: null }),
+ }),
+ {
+ name: "user-storage",
+ },
+ ),
+);
+
+export default useUserStore;
diff --git a/src/types/cateage.ts b/src/types/cateage.ts
new file mode 100644
index 0000000..8788c0c
--- /dev/null
+++ b/src/types/cateage.ts
@@ -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;
+}
diff --git a/src/types/http.ts b/src/types/http.ts
new file mode 100644
index 0000000..8e827e6
--- /dev/null
+++ b/src/types/http.ts
@@ -0,0 +1,10 @@
+export interface Request {
+ path?: string;
+ data?: any;
+}
+
+export interface Response {
+ code: number;
+ msg: string;
+ data: any;
+}
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..43addfc
--- /dev/null
+++ b/src/types/index.ts
@@ -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 };
diff --git a/src/types/user.ts b/src/types/user.ts
new file mode 100644
index 0000000..3d82d50
--- /dev/null
+++ b/src/types/user.ts
@@ -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;
+}
diff --git a/src/utils/message.ts b/src/utils/message.ts
new file mode 100644
index 0000000..ee456f3
--- /dev/null
+++ b/src/utils/message.ts
@@ -0,0 +1,8 @@
+import { toast } from "react-toastify";
+
+export const message = (msg: string) => {
+ toast(msg, {
+ autoClose: 2000,
+ position: "bottom-right",
+ });
+};
diff --git a/src/views/Footer.tsx b/src/views/Footer.tsx
new file mode 100644
index 0000000..96097fe
--- /dev/null
+++ b/src/views/Footer.tsx
@@ -0,0 +1,12 @@
+import { Card } from "@/components/ui/card";
+import type { FC } from "react";
+
+interface FooterProps {
+ className?: string;
+}
+
+const Footer: FC = ({ className = "" }) => {
+ return Copyright © 2025 Zhilv;
+};
+
+export default Footer;
diff --git a/src/views/Header.tsx b/src/views/Header.tsx
new file mode 100644
index 0000000..c12127c
--- /dev/null
+++ b/src/views/Header.tsx
@@ -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 = ({ 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 (
+
+ {title}
+
+

+
+ {user ? (
+
+
+
+ {user.username}
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export default Header;
diff --git a/src/views/Main.tsx b/src/views/Main.tsx
new file mode 100644
index 0000000..fad8608
--- /dev/null
+++ b/src/views/Main.tsx
@@ -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 = ({ className = "" }) => {
+ const [cateageWithSites, setCateageWithSites] = useState();
+
+ useEffect(() => {
+ const getData = async () => {
+ const res = await CateageApi({});
+ setCateageWithSites(res.data);
+ };
+ getData();
+ }, []);
+
+ return (
+
+ {cateageWithSites?.map((cateage) => (
+
+ ))}
+
+ );
+};
+
+export default Main;
diff --git a/src/views/index.ts b/src/views/index.ts
new file mode 100644
index 0000000..e866814
--- /dev/null
+++ b/src/views/index.ts
@@ -0,0 +1,5 @@
+import Header from "./Header";
+import Main from "./Main";
+import Footer from "./Footer";
+
+export { Header, Main, Footer };