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 ( +
+ setIsOpen(!isOpen)}> + + Login View + +
+ 用户名: + setUsername(e.target.value)} + placeholder="请输入用户名" + /> +
+
+ 密码: + setPassword(e.target.value)} + placeholder="请输入密码" + /> +
+ +
+
+
+ ); +}; + +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}
+
+ Theme Icon +
+ {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) => ( +
+ + {cateage.name} + +
+ {cateage.sites.map((site) => ( + + + + + {site.name} + + + + ))} +
+
+ ))} +
+ ); +}; + +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 };