首页页面
4
.env
@@ -1 +1,3 @@
|
|||||||
VITE_API_BASE_URL=http://192.168.0.15:8080
|
VITE_API_BASE_URL=http://192.168.0.15:8080
|
||||||
|
VITE_CLIENT_ID=web
|
||||||
|
VITE_CLIENT_SECRET=web
|
||||||
5
bun.lock
@@ -7,6 +7,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
@@ -342,6 +343,10 @@
|
|||||||
|
|
||||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.0.tgz", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
|
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.0", "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.0.tgz", { "dependencies": { "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw=="],
|
||||||
|
|
||||||
|
"@tanstack/react-table": ["@tanstack/react-table@8.21.3", "https://registry.npmmirror.com/@tanstack/react-table/-/react-table-8.21.3.tgz", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="],
|
||||||
|
|
||||||
|
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "https://registry.npmmirror.com/@tanstack/table-core/-/table-core-8.21.3.tgz", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||||
|
|
||||||
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||||
|
|
||||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||||
|
|||||||
71
package.json
@@ -1,37 +1,38 @@
|
|||||||
{
|
{
|
||||||
"name": "juip-web",
|
"name": "juip-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "biome check --write"
|
"lint": "biome check --write"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
"@tailwindcss/vite": "^4.3.0",
|
"@tailwindcss/vite": "^4.3.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"clsx": "^2.1.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"lucide-react": "^1.17.0",
|
"clsx": "^2.1.1",
|
||||||
"radix-ui": "^1.5.0",
|
"lucide-react": "^1.17.0",
|
||||||
"react": "^19.2.6",
|
"radix-ui": "^1.5.0",
|
||||||
"react-dom": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-router-dom": "^7.17.0",
|
"react-dom": "^19.2.6",
|
||||||
"shadcn": "^4.11.0",
|
"react-router-dom": "^7.17.0",
|
||||||
"tailwind-merge": "^3.6.0",
|
"shadcn": "^4.11.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tailwindcss": "^4.3.0",
|
||||||
},
|
"tw-animate-css": "^1.4.0"
|
||||||
"devDependencies": {
|
},
|
||||||
"@biomejs/biome": "2.4.16",
|
"devDependencies": {
|
||||||
"@types/node": "^25.9.2",
|
"@biomejs/biome": "2.4.16",
|
||||||
"@types/react": "^19.2.14",
|
"@types/node": "^25.9.2",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react": "^19.2.14",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@types/react-dom": "^19.2.3",
|
||||||
"globals": "^17.6.0",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"typescript": "~6.0.2",
|
"globals": "^17.6.0",
|
||||||
"vite": "^8.0.12"
|
"typescript": "~6.0.2",
|
||||||
},
|
"vite": "^8.0.12"
|
||||||
"packageManager": "bun@1.3.2"
|
},
|
||||||
|
"packageManager": "bun@1.3.2"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 106 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
BIN
src/admin/assets/b10.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/admin/assets/b11.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/admin/assets/b12.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/admin/assets/b3.webp
Normal file
|
After Width: | Height: | Size: 884 B |
BIN
src/admin/assets/b4.webp
Normal file
|
After Width: | Height: | Size: 980 B |
BIN
src/admin/assets/b5.webp
Normal file
|
After Width: | Height: | Size: 958 B |
BIN
src/admin/assets/b6.webp
Normal file
|
After Width: | Height: | Size: 996 B |
BIN
src/admin/assets/b7.webp
Normal file
|
After Width: | Height: | Size: 1008 B |
BIN
src/admin/assets/b8.webp
Normal file
|
After Width: | Height: | Size: 964 B |
BIN
src/admin/assets/b9.webp
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/admin/assets/banner.webp
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
src/admin/assets/banner2.webp
Normal file
|
After Width: | Height: | Size: 13 KiB |
5
src/admin/channels/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
export default function ChannelsPage() {
|
||||||
|
return <Page>IP管理</Page>
|
||||||
|
}
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
IdCard,
|
Archive,
|
||||||
MessageCircleMoreIcon,
|
CalendarClock,
|
||||||
|
CircleDollarSignIcon,
|
||||||
|
ClipboardClock,
|
||||||
|
ClipboardList,
|
||||||
|
Clock1,
|
||||||
|
FolderCode,
|
||||||
|
LayoutDashboard,
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
UserRound,
|
ReceiptJapaneseYenIcon,
|
||||||
|
Router,
|
||||||
|
ShoppingBag,
|
||||||
|
TicketPercent,
|
||||||
|
UserCheck,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { type ReactNode, useState } from "react"
|
import { type ReactNode, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
@@ -16,19 +26,20 @@ import {
|
|||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import logoAvatar from "./assets/logo-avatar.svg"
|
import logoAvatar from "./assets/logo-avatar.svg"
|
||||||
|
import { useLayout } from "./layout-context"
|
||||||
|
|
||||||
export function Shell(props: { children: ReactNode; navbar: boolean }) {
|
export function Shell(props: { children: ReactNode }) {
|
||||||
const { navbar } = props
|
const { navbar } = useLayout()
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-expand={navbar}
|
data-expand={navbar}
|
||||||
className={cn(
|
className={cn(
|
||||||
`transition-[grid-template-columns] duration-300 ease-in-out`,
|
"transition-[grid-template-columns] duration-300 ease-in-out",
|
||||||
`w-full h-full grid`,
|
"w-full h-full grid",
|
||||||
`grid-rows-[64px_1fr]`,
|
"grid-rows-[64px_1fr]",
|
||||||
`data-[expand=true]:grid-cols-[200px_1fr]`,
|
"data-[expand=true]:grid-cols-[200px_1fr]",
|
||||||
`data-[expand=false]:grid-cols-[0px_1fr]`,
|
"data-[expand=false]:grid-cols-[0px_1fr]",
|
||||||
`md:data-[expand=false]:grid-cols-[64px_1fr]`,
|
"md:data-[expand=false]:grid-cols-[64px_1fr]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
@@ -36,49 +47,45 @@ export function Shell(props: { children: ReactNode; navbar: boolean }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Mask(props: {
|
export function Content(props: { children: ReactNode }) {
|
||||||
navbar: boolean
|
const { navbar } = useLayout()
|
||||||
setNavbar: (v: boolean) => void
|
return (
|
||||||
}) {
|
<div
|
||||||
const { navbar, setNavbar } = props
|
data-expand={navbar}
|
||||||
|
className={cn(
|
||||||
|
"transition-[margin] duration-300 ease-in-out",
|
||||||
|
"absolute inset-0 overflow-hidden",
|
||||||
|
"mt-16",
|
||||||
|
"md:ml-16",
|
||||||
|
"lg:data-[expand=true]:ml-[200px]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Mask() {
|
||||||
|
const { navbar, setNavbar } = useLayout()
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
data-expand={navbar}
|
data-expand={navbar}
|
||||||
className={cn(
|
className={cn(
|
||||||
`lg:hidden`,
|
"lg:hidden",
|
||||||
`transition-opacity duration-300 ease-in-out`,
|
"transition-opacity duration-300 ease-in-out",
|
||||||
`col-start-1 row-start-1 col-span-2 row-span-2 bg-black/50 z-10`,
|
"col-start-1 row-start-1 col-span-2 row-span-2 bg-black/50 z-10",
|
||||||
`data-[expand=true]:opacity-100 data-[expand=false]:opacity-0`,
|
"data-[expand=true]:opacity-100 data-[expand=false]:opacity-0",
|
||||||
`data-[expand=true]:pointer-events-auto data-[expand=false]:pointer-events-none`,
|
"data-[expand=true]:pointer-events-auto data-[expand=false]:pointer-events-none",
|
||||||
)}
|
)}
|
||||||
onClick={() => setNavbar(false)}
|
onClick={() => setNavbar(false)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Content(props: { children: ReactNode; navbar: boolean }) {
|
export function Header() {
|
||||||
const { navbar } = props
|
const { navbar, toggleNavbar } = useLayout()
|
||||||
return (
|
return (
|
||||||
<div
|
<header className="flex-none h-16 overflow-hidden flex items-stretch">
|
||||||
data-expand={navbar}
|
|
||||||
className={cn(
|
|
||||||
`transition-[margin] duration-300 ease-in-out`,
|
|
||||||
`absolute inset-0 overflow-hidden`,
|
|
||||||
`mt-16`,
|
|
||||||
`md:ml-16`,
|
|
||||||
`lg:data-[expand=true]:ml-50`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header(props: { navbar: boolean; toggleNavbar: () => void }) {
|
|
||||||
const { navbar, toggleNavbar } = props
|
|
||||||
return (
|
|
||||||
<header className={`flex-none h-16 overflow-hidden flex items-stretch`}>
|
|
||||||
<div className="flex-auto flex items-center gap-2">
|
<div className="flex-auto flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -87,26 +94,6 @@ export function Header(props: { navbar: boolean; toggleNavbar: () => void }) {
|
|||||||
>
|
>
|
||||||
{navbar ? <PanelLeftCloseIcon /> : <PanelLeftOpenIcon />}
|
{navbar ? <PanelLeftCloseIcon /> : <PanelLeftOpenIcon />}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="max-md:hidden h-5 w-px bg-gray-300 mx-2" />
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/admin/identify"
|
|
||||||
className="max-md:hidden flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
|
||||||
>
|
|
||||||
<IdCard size={16} />
|
|
||||||
<span>实名认证</span>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<div className="max-md:hidden h-5 w-px bg-gray-300 mx-2" />
|
|
||||||
<a
|
|
||||||
href="https://wpa1.qq.com/K0s0cvwf?_type=wpa&qidian=true"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="max-md:hidden flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 transition-colors mr-2"
|
|
||||||
>
|
|
||||||
<MessageCircleMoreIcon size={16} />
|
|
||||||
<span>联系专属客服</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
||||||
@@ -123,19 +110,11 @@ export function Header(props: { navbar: boolean; toggleNavbar: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserCenter() {
|
function UserCenter() {
|
||||||
return <div className="flex items-center gap-2">{"用户"}</div>
|
return <div className="flex items-center gap-2">用户</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Navbar(props: {
|
export function Navbar() {
|
||||||
navbar: boolean
|
const { navbar } = useLayout()
|
||||||
setNavbar: (v: boolean) => void
|
|
||||||
}) {
|
|
||||||
const { navbar, setNavbar } = props
|
|
||||||
const closeNavBarIfMobile = () => {
|
|
||||||
if (window.innerWidth < 768) {
|
|
||||||
setNavbar(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav
|
<nav
|
||||||
@@ -146,65 +125,102 @@ export function Navbar(props: {
|
|||||||
`data-[expand=true]:basis-52 data-[expand=false]:basis-16`,
|
`data-[expand=true]:basis-52 data-[expand=false]:basis-16`,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* logo */}
|
<Link to="/" className="flex-none h-16 flex items-center justify-center">
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className={`flex-none h-16 flex items-center justify-center`}
|
|
||||||
onClick={closeNavBarIfMobile}
|
|
||||||
>
|
|
||||||
<img src={logoAvatar} alt="logo" className="w-10 h-10 object-contain" />
|
<img src={logoAvatar} alt="logo" className="w-10 h-10 object-contain" />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* routes */}
|
|
||||||
<section
|
<section
|
||||||
className={cn(
|
className={cn(
|
||||||
`transition-[padding] duration-300 ease-in-out`,
|
"transition-[padding] duration-300 ease-in-out",
|
||||||
`flex-auto overflow-x-hidden overflow-y-auto pb-4`,
|
"flex-auto overflow-x-hidden overflow-y-auto pb-4",
|
||||||
`group-data-[expand=true]:px-4 group-data-[expand=false]:px-3`,
|
"group-data-[expand=true]:px-4 group-data-[expand=false]:px-3",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin"
|
href="/admin"
|
||||||
icon={<UserRound size={20} />}
|
icon={<LayoutDashboard size={20} />}
|
||||||
label="账户总览"
|
label="账户总览"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavTitle label="快速开始" />
|
<NavItem
|
||||||
|
href="/admin/whitelist"
|
||||||
|
icon={<UserCheck size={20} />}
|
||||||
|
label="白名单"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/channels"
|
||||||
|
icon={<FolderCode size={20} />}
|
||||||
|
label="IP账号管理"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/funds"
|
||||||
|
icon={<CircleDollarSignIcon size={20} />}
|
||||||
|
label="资金明细"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/channels"
|
||||||
|
icon={<ShoppingBag size={20} />}
|
||||||
|
label="订单管理"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/channels"
|
||||||
|
icon={<ClipboardList size={20} />}
|
||||||
|
label="退货管理"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/coupon"
|
||||||
|
icon={<TicketPercent size={20} />}
|
||||||
|
label="优惠券"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/channels"
|
||||||
|
icon={<ReceiptJapaneseYenIcon size={20} />}
|
||||||
|
label="提现管理"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/record"
|
||||||
|
icon={<Archive size={20} />}
|
||||||
|
label="充值记录"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/resources_short"
|
||||||
|
icon={<Clock1 size={20} />}
|
||||||
|
label="短效套餐"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/resources_long"
|
||||||
|
icon={<CalendarClock size={20} />}
|
||||||
|
label="长效管理"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/channels"
|
||||||
|
icon={<Router size={20} />}
|
||||||
|
label="软路由"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/channels"
|
||||||
|
icon={<ClipboardClock size={20} />}
|
||||||
|
label="使用记录"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</section>
|
</section>
|
||||||
</nav>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NavTitle(props: { label: string }) {
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
`transition-[height] duration-300 ease-in-out`,
|
|
||||||
`text-sm text-gray-500 whitespace-nowrap flex items-center relative`,
|
|
||||||
`group-data-[expand=true]:h-9`,
|
|
||||||
`group-data-[expand=false]:h-4`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
`transition-opacity duration-200 ease-in-out absolute mx-4`,
|
|
||||||
`group-data-[expand=true]:delay-100 group-data-[expand=true]:opacity-100 group-data-[expand=false]:opacity-0`,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
`transition-opacity duration-200 ease-in-out absolute w-full border-b block`,
|
|
||||||
`group-data-[expand=false]:delay-100 group-data-[expand=false]:opacity-100 group-data-[expand=true]:opacity-0`,
|
|
||||||
)}
|
|
||||||
></span>
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavItem(props: {
|
function NavItem(props: {
|
||||||
href: string
|
href: string
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
@@ -212,6 +228,7 @@ function NavItem(props: {
|
|||||||
expand?: boolean
|
expand?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const { setNavbar } = useLayout()
|
||||||
|
|
||||||
const handleOpenChange = (open: boolean) => {
|
const handleOpenChange = (open: boolean) => {
|
||||||
if (!props.expand) {
|
if (!props.expand) {
|
||||||
@@ -219,6 +236,12 @@ function NavItem(props: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const closeNavBarIfMobile = () => {
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
setNavbar(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip open={open} onOpenChange={handleOpenChange}>
|
<Tooltip open={open} onOpenChange={handleOpenChange}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -230,6 +253,7 @@ function NavItem(props: {
|
|||||||
`group-data-[expand=true]:px-4`,
|
`group-data-[expand=true]:px-4`,
|
||||||
)}
|
)}
|
||||||
to={props.href}
|
to={props.href}
|
||||||
|
onClick={closeNavBarIfMobile}
|
||||||
>
|
>
|
||||||
<span className="flex-none w-10 h-10 flex items-center justify-center">
|
<span className="flex-none w-10 h-10 flex items-center justify-center">
|
||||||
{props.icon}
|
{props.icon}
|
||||||
|
|||||||
5
src/admin/coupon/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
export default function CouponPage() {
|
||||||
|
return <Page>优惠券</Page>
|
||||||
|
}
|
||||||
@@ -1,5 +1,415 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { EditInfoDialog } from "@/components/composites/dialogs/editInfoDialog"
|
||||||
|
import DataTable from "@/components/data-table"
|
||||||
import Page from "@/components/page"
|
import Page from "@/components/page"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import b3 from "../assets/b3.webp"
|
||||||
|
import b5 from "../assets/b5.webp"
|
||||||
|
import b6 from "../assets/b6.webp"
|
||||||
|
import b7 from "../assets/b7.webp"
|
||||||
|
import b8 from "../assets/b8.webp"
|
||||||
|
import b9 from "../assets/b9.webp"
|
||||||
|
import b10 from "../assets/b10.webp"
|
||||||
|
import b11 from "../assets/b11.webp"
|
||||||
|
import b12 from "../assets/b12.webp"
|
||||||
|
import banner from "../assets/banner.webp"
|
||||||
|
import banner2 from "../assets/banner2.webp"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
return <Page>账户总览页面</Page>
|
const balanceData = {
|
||||||
|
balance: 68.6,
|
||||||
|
hCoin: 68.6,
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountInfo = {
|
||||||
|
phone: "191****1234",
|
||||||
|
qq: "123456123",
|
||||||
|
wechat: "123456123",
|
||||||
|
email: "123456123@qq.com",
|
||||||
|
taobao: "123456123",
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentOrders = [
|
||||||
|
{
|
||||||
|
date: "2025.04.01",
|
||||||
|
product: "极光IP",
|
||||||
|
plan: "天卡(尊享)",
|
||||||
|
price: 3.0,
|
||||||
|
connections: 1,
|
||||||
|
account: "jx1234",
|
||||||
|
orderAmount: 3.0,
|
||||||
|
paidAmount: 3.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2025.04.01",
|
||||||
|
product: "极光IP",
|
||||||
|
plan: "天卡(尊享)",
|
||||||
|
price: 3.0,
|
||||||
|
connections: 1,
|
||||||
|
account: "jx1234",
|
||||||
|
orderAmount: 3.0,
|
||||||
|
paidAmount: 3.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2025.04.01",
|
||||||
|
product: "极光IP",
|
||||||
|
plan: "天卡(尊享)",
|
||||||
|
price: 3.0,
|
||||||
|
connections: 1,
|
||||||
|
account: "jx1234",
|
||||||
|
orderAmount: 3.0,
|
||||||
|
paidAmount: 3.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: "2025.04.01",
|
||||||
|
product: "极光IP",
|
||||||
|
plan: "天卡(尊享)",
|
||||||
|
price: 3.0,
|
||||||
|
connections: 1,
|
||||||
|
account: "jx1234",
|
||||||
|
orderAmount: 3.0,
|
||||||
|
paidAmount: 3.0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const consumptionData = {
|
||||||
|
today: 2365,
|
||||||
|
month: 312365,
|
||||||
|
todayRefund: 2365,
|
||||||
|
year: 256562,
|
||||||
|
monthRefund: 30,
|
||||||
|
todayRecharge: 50,
|
||||||
|
monthRecharge: 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAccountData = {
|
||||||
|
using: 30,
|
||||||
|
total: 50,
|
||||||
|
expired: 20,
|
||||||
|
yearConsumption: 2365,
|
||||||
|
}
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
const handleSaveInfo = (data: any) => {
|
||||||
|
console.log("保存的信息:", data)
|
||||||
|
// 这里调用你的 API 保存数据
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Page
|
||||||
|
className={cn(
|
||||||
|
"grid gap-4 p-4",
|
||||||
|
"grid-cols-1",
|
||||||
|
"md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_300px]",
|
||||||
|
"md:grid-rows-[150px_200px_minmax(150px,1fr)_minmax(150px,1fr)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* banner */}
|
||||||
|
<section className="flex md:row-start-1 md:col-start-1 md:col-span-3 gap-4 max-md:flex-col">
|
||||||
|
<div className="flex flex-1 min-w-0 items-center justify-between overflow-hidden rounded-lg p-5 bg-linear-to-b from-blue-200 to-blue-50">
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-500">
|
||||||
|
可用余额(元)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-4 sm:gap-8">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold">
|
||||||
|
{balanceData.balance}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 sm:gap-4">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
去提现
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
去充值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={banner}
|
||||||
|
alt="banner"
|
||||||
|
className="w-16 h-16 sm:w-24 sm:h-24 object-contain shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-w-0 items-center justify-between overflow-hidden rounded-lg p-5 bg-linear-to-b from-cyan-200 to-cyan-50">
|
||||||
|
<div className="flex flex-col gap-4 min-w-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-500">可用H币(个)</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-4 sm:gap-8">
|
||||||
|
<p className="text-2xl sm:text-3xl font-bold">
|
||||||
|
{balanceData.hCoin}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
去充值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
src={banner2}
|
||||||
|
alt="banner2"
|
||||||
|
className="w-16 h-16 sm:w-24 sm:h-24 object-contain shrink-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 最近订单 */}
|
||||||
|
<div className="md:row-start-2 md:col-start-1 md:col-span-3 bg-white rounded-lg p-4 flex flex-col min-h-0">
|
||||||
|
<h3 className="font-semibold text-lg mb-4 shrink-0">最近订单</h3>
|
||||||
|
<div className="flex-1 overflow-auto min-h-0">
|
||||||
|
<DataTable
|
||||||
|
data={recentOrders}
|
||||||
|
status="done"
|
||||||
|
columns={[
|
||||||
|
{ accessorKey: "date", header: "日期" },
|
||||||
|
{ accessorKey: "product", header: "产品" },
|
||||||
|
{ accessorKey: "plan", header: "套餐" },
|
||||||
|
{
|
||||||
|
accessorKey: "price",
|
||||||
|
header: "单价(元)",
|
||||||
|
cell: ({ row }) => row.original.price.toFixed(2),
|
||||||
|
},
|
||||||
|
{ accessorKey: "connections", header: "总连接数" },
|
||||||
|
{ accessorKey: "account", header: "账号" },
|
||||||
|
{
|
||||||
|
accessorKey: "orderAmount",
|
||||||
|
header: "订单金额(元)",
|
||||||
|
cell: ({ row }) => row.original.orderAmount.toFixed(2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "paidAmount",
|
||||||
|
header: "实付金额(元)",
|
||||||
|
cell: ({ row }) => row.original.paidAmount.toFixed(2),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 消费信息 */}
|
||||||
|
<div className="md:col-start-1 md:row-start-3 md:col-span-3 md:row-span-2 bg-white rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-semibold text-lg">消费信息</h3>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
|
||||||
|
<img
|
||||||
|
src={b3}
|
||||||
|
alt="今日消费"
|
||||||
|
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">今日消费</p>
|
||||||
|
<p className="text-lg sm:text-2xl font-bold text-blue-600">
|
||||||
|
{consumptionData.today}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-cyan-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
|
||||||
|
<img
|
||||||
|
src={b5}
|
||||||
|
alt="当月消费"
|
||||||
|
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">当月消费</p>
|
||||||
|
<p className="text-lg sm:text-2xl font-bold text-cyan-600">
|
||||||
|
{consumptionData.month}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-orange-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
|
||||||
|
<img
|
||||||
|
src={b6}
|
||||||
|
alt="今日退款"
|
||||||
|
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">今日退款</p>
|
||||||
|
<p className="text-lg sm:text-2xl font-bold text-orange-600">
|
||||||
|
{consumptionData.todayRefund}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-fuchsia-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
|
||||||
|
<img
|
||||||
|
src={b7}
|
||||||
|
alt="本年消费"
|
||||||
|
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">本年消费</p>
|
||||||
|
<p className="text-lg sm:text-2xl font-bold text-fuchsia-600">
|
||||||
|
{consumptionData.year}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
|
||||||
|
<img
|
||||||
|
src={b8}
|
||||||
|
alt="今日充值"
|
||||||
|
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">今日充值</p>
|
||||||
|
<p className="text-lg sm:text-2xl font-bold text-amber-600">
|
||||||
|
{consumptionData.todayRecharge}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-emerald-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
|
||||||
|
<img
|
||||||
|
src={b9}
|
||||||
|
alt="当月充值"
|
||||||
|
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">当月充值</p>
|
||||||
|
<p className="text-lg sm:text-2xl font-bold text-emerald-600">
|
||||||
|
{consumptionData.monthRecharge}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-purple-50 rounded-lg p-3 sm:p-4 flex items-center gap-2 sm:gap-3">
|
||||||
|
<img
|
||||||
|
src={b6}
|
||||||
|
alt="当月退款"
|
||||||
|
className="w-8 h-8 sm:w-10 sm:h-10 shrink-0"
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">当月退款</p>
|
||||||
|
<p className="text-lg sm:text-2xl font-bold text-purple-600">
|
||||||
|
{consumptionData.monthRefund}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 账号信息 */}
|
||||||
|
<div className="md:col-start-4 md:row-start-1 md:row-span-2 bg-white rounded-lg p-4 flex flex-col">
|
||||||
|
<h3 className="font-semibold text-lg shrink-0">账号信息</h3>
|
||||||
|
<div className="mt-4 flex justify-between items-start gap-3 flex-auto">
|
||||||
|
<div className="flex flex-col items-center gap-4 shrink-0">
|
||||||
|
<span className="text-base font-medium">{accountInfo.phone}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-20 text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700 border border-blue-600"
|
||||||
|
>
|
||||||
|
去实名
|
||||||
|
</Button>
|
||||||
|
<div className="w-20 h-20 border border-gray-200 rounded">
|
||||||
|
<img
|
||||||
|
src="/qrcode-placeholder.png"
|
||||||
|
alt="账号二维码"
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2.5 text-sm flex-1">
|
||||||
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||||
|
<span className="text-gray-400 w-20 shrink-0">密码</span>
|
||||||
|
<span className="text-gray-700 font-mono tracking-wide">
|
||||||
|
********
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||||
|
<span className="text-gray-400 w-20 shrink-0">QQ</span>
|
||||||
|
<span className="text-gray-700">
|
||||||
|
{accountInfo.qq || "未绑定"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||||
|
<span className="text-gray-400 w-20 shrink-0">微信号</span>
|
||||||
|
<span className="text-gray-700">
|
||||||
|
{accountInfo.wechat || "未绑定"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||||
|
<span className="text-gray-400 w-20 shrink-0">淘宝会员</span>
|
||||||
|
<span className="text-gray-700">
|
||||||
|
{accountInfo.taobao || "未绑定"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
||||||
|
<span className="text-gray-400 w-20 shrink-0">邮箱</span>
|
||||||
|
<span className="text-gray-700 truncate">
|
||||||
|
{accountInfo.email || "未绑定"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs bg-white text-blue-600 hover:bg-white/90 hover:text-blue-700 border-transparent hover:border-blue-200"
|
||||||
|
onClick={() => setEditDialogOpen(true)}
|
||||||
|
>
|
||||||
|
编辑信息
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* IP账号 */}
|
||||||
|
<div className="md:col-start-4 md:row-start-3 md:row-span-2 bg-white rounded-lg p-4">
|
||||||
|
<h3 className="font-semibold text-lg mb-6">IP账号</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="w-16 h-16 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={b10}
|
||||||
|
alt="使用中图标"
|
||||||
|
className="w-10 h-10 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold">{ipAccountData.using}</p>
|
||||||
|
<p className="text-gray-500">使用中</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="w-16 h-16 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={b12}
|
||||||
|
alt="总个数图标"
|
||||||
|
className="w-10 h-10 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold">{ipAccountData.total}</p>
|
||||||
|
<p className="text-gray-500">总个数</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="w-16 h-16 flex items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={b11}
|
||||||
|
alt="已过期图标"
|
||||||
|
className="w-10 h-10 object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-3xl font-bold">{ipAccountData.expired}</p>
|
||||||
|
<p className="text-gray-500">已过期</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditInfoDialog
|
||||||
|
open={editDialogOpen}
|
||||||
|
onOpenChange={setEditDialogOpen}
|
||||||
|
onSave={handleSaveInfo}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/admin/effects.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { type ReactNode, useEffect } from "react"
|
||||||
|
import { useLayout } from "./layout-context"
|
||||||
|
|
||||||
|
type Breakpoints = {
|
||||||
|
sm: boolean
|
||||||
|
md: boolean
|
||||||
|
lg: boolean
|
||||||
|
xl: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakpoints: Breakpoints = { sm: false, md: false, lg: false, xl: false }
|
||||||
|
|
||||||
|
export function useBreakpoint() {
|
||||||
|
return breakpoints
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bp = breakpoints
|
||||||
|
|
||||||
|
export default function Effects(props: { children?: ReactNode }) {
|
||||||
|
const { setNavbar } = useLayout()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sm = window.matchMedia("(width >= 40rem)")
|
||||||
|
const md = window.matchMedia("(width >= 48rem)")
|
||||||
|
const lg = window.matchMedia("(width >= 64rem)")
|
||||||
|
const xl = window.matchMedia("(width >= 80rem)")
|
||||||
|
|
||||||
|
breakpoints.sm = sm.matches
|
||||||
|
breakpoints.md = md.matches
|
||||||
|
breakpoints.lg = lg.matches
|
||||||
|
breakpoints.xl = xl.matches
|
||||||
|
|
||||||
|
setNavbar(md.matches)
|
||||||
|
|
||||||
|
sm.addEventListener("change", (e) => {
|
||||||
|
breakpoints.sm = e.matches
|
||||||
|
})
|
||||||
|
md.addEventListener("change", (e) => {
|
||||||
|
breakpoints.md = e.matches
|
||||||
|
setNavbar(e.matches)
|
||||||
|
})
|
||||||
|
lg.addEventListener("change", (e) => {
|
||||||
|
breakpoints.lg = e.matches
|
||||||
|
})
|
||||||
|
xl.addEventListener("change", (e) => {
|
||||||
|
breakpoints.xl = e.matches
|
||||||
|
})
|
||||||
|
|
||||||
|
const html = document.documentElement
|
||||||
|
const originalFontSize = html.style.fontSize
|
||||||
|
html.style.fontSize = "16px"
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
html.style.fontSize = originalFontSize
|
||||||
|
}
|
||||||
|
}, [setNavbar])
|
||||||
|
|
||||||
|
return <>{props.children}</>
|
||||||
|
}
|
||||||
5
src/admin/funds/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
export default function FundsPage() {
|
||||||
|
return <Page>资金明细</Page>
|
||||||
|
}
|
||||||
27
src/admin/layout-context.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createContext, type ReactNode, useContext, useState } from "react"
|
||||||
|
|
||||||
|
type LayoutState = {
|
||||||
|
navbar: boolean
|
||||||
|
setNavbar: (navbar: boolean) => void
|
||||||
|
toggleNavbar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const LayoutContext = createContext<LayoutState | null>(null)
|
||||||
|
|
||||||
|
export function LayoutProvider(props: { children: ReactNode }) {
|
||||||
|
const [navbar, setNavbar] = useState(false)
|
||||||
|
|
||||||
|
const toggleNavbar = () => setNavbar(!navbar)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LayoutContext value={{ navbar, setNavbar, toggleNavbar }}>
|
||||||
|
{props.children}
|
||||||
|
</LayoutContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLayout() {
|
||||||
|
const ctx = useContext(LayoutContext)
|
||||||
|
if (!ctx) throw new Error("useLayout must be used within LayoutProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
@@ -1,50 +1,50 @@
|
|||||||
import { useState } from "react"
|
|
||||||
import { Outlet } from "react-router-dom"
|
import { Outlet } from "react-router-dom"
|
||||||
import { Content, Header, Mask, Navbar, Shell } from "./clients"
|
import { Content, Header, Mask, Navbar, Shell } from "./clients"
|
||||||
|
import Effects from "./effects"
|
||||||
|
import { LayoutProvider } from "./layout-context"
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const [navbar, setNavbar] = useState(true)
|
|
||||||
|
|
||||||
const toggleNavbar = () => setNavbar(!navbar)
|
|
||||||
return (
|
return (
|
||||||
<div className="relative h-dvh overflow-hidden">
|
<LayoutProvider>
|
||||||
<Shell navbar={navbar}>
|
<Effects />
|
||||||
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
|
<div className="relative h-dvh overflow-hidden">
|
||||||
<Navbar navbar={navbar} setNavbar={setNavbar} />
|
<Shell>
|
||||||
</div>
|
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
|
||||||
|
<Navbar />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
|
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
|
||||||
<Header navbar={navbar} toggleNavbar={toggleNavbar} />
|
<Header />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
className="col-start-2 row-start-2 w-full h-full z-20 pointer-events-none"
|
className="col-start-2 row-start-2 w-full h-full z-20 pointer-events-none"
|
||||||
preserveAspectRatio="none"
|
preserveAspectRatio="none"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<mask id="top-left-rounded-mask">
|
<mask id="top-left-rounded-mask">
|
||||||
<rect width="100%" height="100%" fill="white" />
|
<rect width="100%" height="100%" fill="white" />
|
||||||
<circle cx="16" cy="16" r="16" fill="black" />
|
<circle cx="16" cy="16" r="16" fill="black" />
|
||||||
<rect x="16" y="0" width="100%" height="32" fill="black" />
|
<rect x="16" y="0" width="100%" height="32" fill="black" />
|
||||||
<rect x="0" y="16" width="32" height="100%" fill="black" />
|
<rect x="0" y="16" width="32" height="100%" fill="black" />
|
||||||
<rect x="16" y="16" width="100%" height="100%" fill="black" />
|
<rect x="16" y="16" width="100%" height="100%" fill="black" />
|
||||||
</mask>
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
<rect
|
<rect
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
className="fill-card"
|
className="fill-card"
|
||||||
mask="url(#top-left-rounded-mask)"
|
mask="url(#top-left-rounded-mask)"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
<Mask navbar={navbar} setNavbar={setNavbar} />
|
<Mask />
|
||||||
</Shell>
|
</Shell>
|
||||||
|
|
||||||
{/* 用 Outlet 替代 {props.children} */}
|
<Content>
|
||||||
<Content navbar={navbar}>
|
<Outlet />
|
||||||
<Outlet />
|
</Content>
|
||||||
</Content>
|
</div>
|
||||||
</div>
|
</LayoutProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
5
src/admin/whitelist/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
export default function WhitelistPage() {
|
||||||
|
return <Page>白名单</Page>
|
||||||
|
}
|
||||||
101
src/api/auth.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import type { ApiResponse } from "@/lib/api"
|
||||||
|
import { callByDevice, callByUser } from "./base"
|
||||||
|
|
||||||
|
// 用户类型
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoginMode = "phone_code" | "password"
|
||||||
|
|
||||||
|
export async function login(props: {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
remember: boolean
|
||||||
|
mode: LoginMode
|
||||||
|
}): Promise<ApiResponse> {
|
||||||
|
// 使用 callByDevice 去拿 Token (设备级认证)
|
||||||
|
const result = await callByDevice<{
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expires_in: number
|
||||||
|
}>("/api/auth/token", {
|
||||||
|
...props,
|
||||||
|
grant_type: "password",
|
||||||
|
login_type: props.mode,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) return result
|
||||||
|
|
||||||
|
// 登录成功,存入 localStorage
|
||||||
|
const { access_token, refresh_token, expires_in } = result.data
|
||||||
|
localStorage.setItem("auth_token", access_token)
|
||||||
|
localStorage.setItem("auth_refresh", refresh_token)
|
||||||
|
localStorage.setItem(
|
||||||
|
"auth_expires_in",
|
||||||
|
String(Date.now() + expires_in * 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, data: undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const accessToken = localStorage.getItem("auth_token")
|
||||||
|
const refreshToken = localStorage.getItem("auth_refresh")
|
||||||
|
|
||||||
|
if (accessToken && refreshToken) {
|
||||||
|
await callByUser("/api/auth/revoke", {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除前端本地缓存
|
||||||
|
localStorage.removeItem("auth_token")
|
||||||
|
localStorage.removeItem("auth_refresh")
|
||||||
|
localStorage.removeItem("auth_expires_in")
|
||||||
|
|
||||||
|
// 强制跳转到登录页
|
||||||
|
window.location.href = "/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户信息 (校验 Token 是否有效)
|
||||||
|
export async function getProfile() {
|
||||||
|
return await callByUser<User>("/api/auth/introspect")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新 Token
|
||||||
|
export async function refreshAuth(): Promise<{
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
} | null> {
|
||||||
|
const refreshToken = localStorage.getItem("auth_refresh")
|
||||||
|
if (!refreshToken) return null
|
||||||
|
|
||||||
|
const resp = await callByDevice<{
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
expires_in: number
|
||||||
|
}>(`/api/auth/token`, {
|
||||||
|
grant_type: "refresh_token",
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!resp.success) {
|
||||||
|
localStorage.removeItem("auth_refresh")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新成功,更新本地缓存
|
||||||
|
const { access_token, refresh_token, expires_in } = resp.data
|
||||||
|
localStorage.setItem("auth_token", access_token)
|
||||||
|
localStorage.setItem("auth_refresh", refresh_token)
|
||||||
|
localStorage.setItem(
|
||||||
|
"auth_expires_in",
|
||||||
|
String(Date.now() + expires_in * 1000),
|
||||||
|
)
|
||||||
|
|
||||||
|
return { access_token, refresh_token }
|
||||||
|
}
|
||||||
158
src/api/base.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
API_BASE_URL,
|
||||||
|
type ApiResponse,
|
||||||
|
CLIENT_ID,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
UnauthorizedError,
|
||||||
|
} from "@/lib/api"
|
||||||
|
|
||||||
|
let deviceToken: string | null = null
|
||||||
|
let deviceTokenExpire: Date | null = null
|
||||||
|
|
||||||
|
async function call<T = undefined>(
|
||||||
|
endpoint: string,
|
||||||
|
body?: string,
|
||||||
|
auth?: string,
|
||||||
|
): Promise<ApiResponse<T>> {
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
if (auth) headers["Authorization"] = auth
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 如果是 刷新Token 的接口本身报 401,说明 RefreshToken 彻底失效,直接抛出错误
|
||||||
|
if (
|
||||||
|
endpoint.includes("/auth/token") ||
|
||||||
|
endpoint.includes("/auth/revoke")
|
||||||
|
) {
|
||||||
|
throw UnauthorizedError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 动态引入 auth.ts 里的刷新函数,避免循环引用报错
|
||||||
|
const { refreshAuth } = await import("./auth")
|
||||||
|
|
||||||
|
// 尝试刷新 Token
|
||||||
|
const newTokens = await refreshAuth()
|
||||||
|
|
||||||
|
if (newTokens) {
|
||||||
|
// 刷新成功,用新 Token 重试当前请求(递归调用)
|
||||||
|
return call(endpoint, body, `Bearer ${newTokens.access_token}`)
|
||||||
|
} else {
|
||||||
|
// 刷新失败,抛出 401 错误,让上层处理跳转
|
||||||
|
throw UnauthorizedError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("Content-Type") ?? "text/plain"
|
||||||
|
|
||||||
|
if (contentType.includes("text/plain")) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: text || "请求失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, data: undefined as T }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
const json = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: json.message || json.error_description || "请求失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: true, data: json }
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`无法解析响应数据: ${contentType}`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error("后端请求异常:", e)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 500,
|
||||||
|
message: (e as Error).message || "网络错误",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// Public
|
||||||
|
// ======================
|
||||||
|
async function callPublic<R = undefined>(
|
||||||
|
endpoint: string,
|
||||||
|
data?: unknown,
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
|
return call(endpoint, data ? JSON.stringify(data) : undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// Device
|
||||||
|
// ======================
|
||||||
|
async function callByDevice<R = undefined>(
|
||||||
|
endpoint: string,
|
||||||
|
data?: unknown,
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
|
// 检查内存中的 Token 是否过期
|
||||||
|
if (!deviceToken || !deviceTokenExpire || new Date() >= deviceTokenExpire) {
|
||||||
|
// 用 btoa 生成 Basic 认证头
|
||||||
|
const basic = btoa(`${CLIENT_ID}:${CLIENT_SECRET}`)
|
||||||
|
const resp = await call<{
|
||||||
|
access_token: string
|
||||||
|
expires_in: number
|
||||||
|
}>(
|
||||||
|
"/api/auth/token",
|
||||||
|
JSON.stringify({ grant_type: "client_credentials" }),
|
||||||
|
`Basic ${basic}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!resp.success) {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
deviceToken = resp.data.access_token
|
||||||
|
// 提前 60 秒过期,防止临界点失效
|
||||||
|
deviceTokenExpire = new Date(
|
||||||
|
Date.now() + (resp.data.expires_in - 60) * 1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return call(
|
||||||
|
endpoint,
|
||||||
|
data ? JSON.stringify(data) : undefined,
|
||||||
|
`Bearer ${deviceToken}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// User
|
||||||
|
// ======================
|
||||||
|
async function callByUser<R = undefined>(
|
||||||
|
endpoint: string,
|
||||||
|
data?: unknown,
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
|
// 这里我们假设用户登录后的 Token 存在 localStorage 里
|
||||||
|
// 如果你是用 Cookie 存 Token,这里用 js-cookie 的 get 方法替换即可
|
||||||
|
const token = localStorage.getItem("auth_token")
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw UnauthorizedError
|
||||||
|
}
|
||||||
|
return call(
|
||||||
|
endpoint,
|
||||||
|
data ? JSON.stringify(data) : undefined,
|
||||||
|
`Bearer ${token}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { callByDevice, callByUser, callPublic }
|
||||||
63
src/components/authGuard.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Navigate, useLocation } from "react-router-dom"
|
||||||
|
import { getProfile, refreshAuth } from "@/api/auth"
|
||||||
|
|
||||||
|
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const location = useLocation()
|
||||||
|
const [isValid, setIsValid] = useState<boolean | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = async () => {
|
||||||
|
const accessToken = localStorage.getItem("auth_token")
|
||||||
|
const refreshToken = localStorage.getItem("auth_refresh")
|
||||||
|
|
||||||
|
// 没 Token,判定无效
|
||||||
|
if (!accessToken && !refreshToken) {
|
||||||
|
setIsValid(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试用现有 AccessToken 获取用户信息
|
||||||
|
const profileResp = await getProfile()
|
||||||
|
if (profileResp.success) {
|
||||||
|
setIsValid(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessToken 无效 尝试用 RefreshToken 刷新
|
||||||
|
if (refreshToken) {
|
||||||
|
const newTokens = await refreshAuth()
|
||||||
|
if (newTokens) {
|
||||||
|
setIsValid(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新失败或无 RefreshToken,为未登录
|
||||||
|
localStorage.removeItem("auth_token")
|
||||||
|
localStorage.removeItem("auth_refresh")
|
||||||
|
localStorage.removeItem("auth_expires_in")
|
||||||
|
setIsValid(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAuth()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (isValid === null)
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-screen">
|
||||||
|
验证身份中...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// if (!isValid) {
|
||||||
|
// return (
|
||||||
|
// <Navigate
|
||||||
|
// to={`/login?redirect=${encodeURIComponent(location.pathname)}`}
|
||||||
|
// replace
|
||||||
|
// />
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
149
src/components/composites/dialogs/editInfoDialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { Eye, EyeOff } from "lucide-react"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
interface EditInfoDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
initialData?: {
|
||||||
|
username: string
|
||||||
|
taobao: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
wechat: string
|
||||||
|
qq: string
|
||||||
|
}
|
||||||
|
onSave?: (data: any) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditInfoDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
initialData = {
|
||||||
|
username: "191****1234",
|
||||||
|
taobao: "191****1234",
|
||||||
|
password: "********",
|
||||||
|
email: "123456123@qq.com",
|
||||||
|
wechat: "",
|
||||||
|
qq: "",
|
||||||
|
},
|
||||||
|
onSave,
|
||||||
|
}: EditInfoDialogProps) {
|
||||||
|
const [formData, setFormData] = useState(initialData)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
|
const handleChange = (field: string, value: string) => {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
onSave?.(formData)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[480px] p-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-semibold text-center">
|
||||||
|
编辑信息
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 mt-2">
|
||||||
|
{/* 用户名 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-gray-600">用户名</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.username}
|
||||||
|
onChange={e => handleChange("username", e.target.value)}
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 淘宝会员名 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-gray-600">淘宝会员名</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.taobao}
|
||||||
|
onChange={e => handleChange("taobao", e.target.value)}
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 密码 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-gray-600">密码</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
value={formData.password}
|
||||||
|
onChange={e => handleChange("password", e.target.value)}
|
||||||
|
className="h-11 bg-gray-50 border-gray-200 pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 邮箱 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-gray-600">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.email}
|
||||||
|
onChange={e => handleChange("email", e.target.value)}
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 微信 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-gray-600">微信</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.wechat}
|
||||||
|
onChange={e => handleChange("wechat", e.target.value)}
|
||||||
|
placeholder="请输入"
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QQ */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-sm text-gray-600">QQ</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.qq}
|
||||||
|
onChange={e => handleChange("qq", e.target.value)}
|
||||||
|
placeholder="请输入"
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 保存按钮 */}
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
src/components/data-table.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
type ColumnDef,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { Loader } from "lucide-react"
|
||||||
|
import { Pagination, type PaginationProps } from "@/components/ui/pagination"
|
||||||
|
import {
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
Table as TableRoot,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export type DataTableProps<T> = {
|
||||||
|
data: T[]
|
||||||
|
status: "load" | "done" | "fail"
|
||||||
|
columns: ColumnDef<T>[]
|
||||||
|
pagination?: PaginationProps
|
||||||
|
classNames?: {
|
||||||
|
headRow?: string
|
||||||
|
dataRow?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataTable<T extends Record<string, unknown>>(
|
||||||
|
props: DataTableProps<T>,
|
||||||
|
) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: props.data,
|
||||||
|
columns: props.columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
manualPagination: true,
|
||||||
|
rowCount: props.pagination?.total ?? props.data.length,
|
||||||
|
state: {
|
||||||
|
pagination: {
|
||||||
|
pageIndex: props.pagination?.page ?? 0,
|
||||||
|
pageSize: props.pagination?.size ?? props.data.length,
|
||||||
|
},
|
||||||
|
columnFilters: [],
|
||||||
|
},
|
||||||
|
autoResetPageIndex: false,
|
||||||
|
})
|
||||||
|
console.log("data length:", props.data.length)
|
||||||
|
console.log("pageSize:", props.pagination?.size ?? props.data.length)
|
||||||
|
console.log("rows:", table.getRowModel().rows.length)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 数据表 */}
|
||||||
|
<div className="rounded-md relative bg-card">
|
||||||
|
<TableRoot>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map(group => (
|
||||||
|
<TableRow key={group.id}>
|
||||||
|
{group.headers.map(header => (
|
||||||
|
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext(),
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{props.status === "fail" ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={props.columns.length}
|
||||||
|
className="text-center text-fail"
|
||||||
|
>
|
||||||
|
加载失败
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : !props.data?.length ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={props.columns.length}
|
||||||
|
className="text-center"
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map(row => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className={cn("h-14", props.classNames?.dataRow)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map(cell => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext(),
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</TableRoot>
|
||||||
|
{props.status === "load" && (
|
||||||
|
<div className="absolute inset-0 bg-white/10 backdrop-blur-xs flex items-center justify-center gap-2 transition">
|
||||||
|
<Loader className="animate-spin" />
|
||||||
|
<span>加载中</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页器 */}
|
||||||
|
{props.pagination && <Pagination {...props.pagination} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import * as React from "react"
|
|
||||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/index"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||||
|
import type * as React from "react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { cn } from "@/lib/utils/index"
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
...props
|
...props
|
||||||
@@ -38,7 +37,7 @@ function DialogOverlay({
|
|||||||
data-slot="dialog-overlay"
|
data-slot="dialog-overlay"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -60,7 +59,7 @@ function DialogContent({
|
|||||||
data-slot="dialog-content"
|
data-slot="dialog-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -72,8 +71,7 @@ function DialogContent({
|
|||||||
className="absolute top-2 right-2"
|
className="absolute top-2 right-2"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon />
|
||||||
/>
|
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
@@ -106,7 +104,7 @@ function DialogFooter({
|
|||||||
data-slot="dialog-footer"
|
data-slot="dialog-footer"
|
||||||
className={cn(
|
className={cn(
|
||||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -129,7 +127,7 @@ function DialogTitle({
|
|||||||
data-slot="dialog-title"
|
data-slot="dialog-title"
|
||||||
className={cn(
|
className={cn(
|
||||||
"font-heading text-base leading-none font-medium",
|
"font-heading text-base leading-none font-medium",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -145,7 +143,7 @@ function DialogDescription({
|
|||||||
data-slot="dialog-description"
|
data-slot="dialog-description"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
22
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils/index"
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
304
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import type * as React from "react"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "./select"
|
||||||
|
|
||||||
|
export interface PaginationProps {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
total: number
|
||||||
|
sizeOptions?: number[]
|
||||||
|
onPageChange?: (page: number) => void
|
||||||
|
onSizeChange?: (size: number) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
total,
|
||||||
|
sizeOptions = [10, 20, 50, 100],
|
||||||
|
onPageChange,
|
||||||
|
onSizeChange,
|
||||||
|
className,
|
||||||
|
}: PaginationProps) {
|
||||||
|
const [currentPage, setCurrentPage] = useState(page)
|
||||||
|
const totalPages = Math.ceil(total / size)
|
||||||
|
|
||||||
|
// 同步外部 page 变化
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(page)
|
||||||
|
}, [page])
|
||||||
|
|
||||||
|
// 分页器逻辑
|
||||||
|
const generatePaginationItems = () => {
|
||||||
|
// 最多显示7个页码,其余用省略号
|
||||||
|
const SIBLINGS = 1 // 当前页左右各显示的页码数
|
||||||
|
const DOTS = -1 // 省略号标记
|
||||||
|
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
// 总页数少于7,全部显示
|
||||||
|
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否需要显示左边的省略号
|
||||||
|
const showLeftDots = currentPage > 2 + SIBLINGS
|
||||||
|
|
||||||
|
// 是否需要显示右边的省略号
|
||||||
|
const showRightDots = currentPage < totalPages - (2 + SIBLINGS)
|
||||||
|
|
||||||
|
if (showLeftDots && showRightDots) {
|
||||||
|
// 两边都有省略号
|
||||||
|
const leftSiblingIndex = Math.max(currentPage - SIBLINGS, 1)
|
||||||
|
const rightSiblingIndex = Math.min(currentPage + SIBLINGS, totalPages)
|
||||||
|
|
||||||
|
return [
|
||||||
|
1,
|
||||||
|
DOTS,
|
||||||
|
...Array.from(
|
||||||
|
{ length: rightSiblingIndex - leftSiblingIndex + 1 },
|
||||||
|
(_, i) => leftSiblingIndex + i,
|
||||||
|
),
|
||||||
|
DOTS,
|
||||||
|
totalPages,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!showLeftDots && showRightDots) {
|
||||||
|
// 只有右边有省略号
|
||||||
|
return [
|
||||||
|
...Array.from({ length: 3 + SIBLINGS * 2 }, (_, i) => i + 1),
|
||||||
|
DOTS,
|
||||||
|
totalPages,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showLeftDots && !showRightDots) {
|
||||||
|
// 只有左边有省略号
|
||||||
|
return [
|
||||||
|
1,
|
||||||
|
DOTS,
|
||||||
|
...Array.from(
|
||||||
|
{ length: 3 + SIBLINGS * 2 },
|
||||||
|
(_, i) => totalPages - (3 + SIBLINGS * 2) + i + 1,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
if (newPage < 1 || newPage > totalPages || newPage === currentPage) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCurrentPage(newPage)
|
||||||
|
onPageChange?.(newPage)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePageSizeChange = (newSize: string) => {
|
||||||
|
const parsedSize = parseInt(newSize, 10)
|
||||||
|
if (onSizeChange) {
|
||||||
|
onSizeChange(parsedSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const paginationItems = generatePaginationItems()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap items-center gap-4 ${className || ""}`}>
|
||||||
|
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
共 {total} 条记录,每页
|
||||||
|
<Select value={size.toString()} onValueChange={handlePageSizeChange}>
|
||||||
|
<SelectTrigger className="h-8 w-20">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sizeOptions.map(option => (
|
||||||
|
<SelectItem key={option} value={option.toString()}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
条
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PaginationLayout>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
className={
|
||||||
|
currentPage === 1 ? "opacity-50 pointer-events-none" : ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{paginationItems.map((pageNum, index) => {
|
||||||
|
if (pageNum === -1) {
|
||||||
|
return (
|
||||||
|
<PaginationItem key={`dots-${index}`}>
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PaginationItem key={pageNum}>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={pageNum === currentPage}
|
||||||
|
onClick={() => handlePageChange(pageNum)}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
className={
|
||||||
|
currentPage === totalPages
|
||||||
|
? "opacity-50 pointer-events-none"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</PaginationLayout>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationLayout({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"nav">) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
// role="navigation"
|
||||||
|
aria-label="pagination"
|
||||||
|
data-slot="pagination"
|
||||||
|
className={cn("flex-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"ul">) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
data-slot="pagination-content"
|
||||||
|
className={cn("flex flex-row items-center gap-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||||
|
return <li data-slot="pagination-item" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaginationLinkProps = {
|
||||||
|
isActive?: boolean
|
||||||
|
} & React.ComponentProps<"a">
|
||||||
|
|
||||||
|
function PaginationLink({
|
||||||
|
className,
|
||||||
|
isActive,
|
||||||
|
...props
|
||||||
|
}: PaginationLinkProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
data-slot="pagination-link"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-9 w-9 rounded-md border border-input hover:bg-secondary hover:text-secondary-foreground",
|
||||||
|
`bg-card`,
|
||||||
|
isActive &&
|
||||||
|
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationPrevious({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to previous page"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationNext({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PaginationLink>) {
|
||||||
|
return (
|
||||||
|
<PaginationLink
|
||||||
|
aria-label="Go to next page"
|
||||||
|
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</PaginationLink>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaginationEllipsis({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
data-slot="pagination-ellipsis"
|
||||||
|
className={cn("flex size-9 items-center justify-center", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon className="size-4" />
|
||||||
|
<span className="sr-only">More pages</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLayout,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
}
|
||||||
192
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
import { Select as SelectPrimitive } from "radix-ui"
|
||||||
|
import type * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils/index"
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "item-aligned",
|
||||||
|
align = "center",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={position === "item-aligned"}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
align={align}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
data-position={position}
|
||||||
|
className={cn(
|
||||||
|
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
|
||||||
|
position === "popper" && "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
114
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils/index"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import * as React from "react"
|
"use client"
|
||||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils/index"
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
function TooltipProvider({
|
function TooltipProvider({
|
||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
@@ -19,7 +21,11 @@ function TooltipProvider({
|
|||||||
function Tooltip({
|
function Tooltip({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
</TooltipProvider>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TooltipTrigger({
|
function TooltipTrigger({
|
||||||
@@ -40,13 +46,16 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"bg-card border animate-in fade-in-0 zoom-in-95 ",
|
||||||
className
|
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-sm text-balance",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
{/* <TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" /> */}
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type WrapProps = {
|
|||||||
|
|
||||||
export default function Wrap(props: WrapProps) {
|
export default function Wrap(props: WrapProps) {
|
||||||
return (
|
return (
|
||||||
<div className={`max-w-[1232px] w-full px-4 mx-auto ${props.className}`}>
|
<div className={`max-w-308 w-full px-4 mx-auto ${props.className}`}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function Header() {
|
|||||||
<NavItem to="/ipline">IP线路表</NavItem>
|
<NavItem to="/ipline">IP线路表</NavItem>
|
||||||
<NavItem to="/softDownload">软件下载</NavItem>
|
<NavItem to="/softDownload">软件下载</NavItem>
|
||||||
<NavItem to="/help">帮助/教程</NavItem>
|
<NavItem to="/help">帮助/教程</NavItem>
|
||||||
<NavItem to="/client">下载客户端</NavItem>
|
{/* <NavItem to="/client">下载客户端</NavItem> */}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<ProfileOrLogin />
|
<ProfileOrLogin />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
|
import { useNavigate } from "react-router-dom"
|
||||||
import Wrap from "@/components/wrap"
|
import Wrap from "@/components/wrap"
|
||||||
import banner from "./_assets/banner.webp"
|
import banner from "./_assets/banner.webp"
|
||||||
import s1 from "./_assets/s1.webp"
|
import s1 from "./_assets/s1.webp"
|
||||||
@@ -16,6 +17,11 @@ import s12 from "./_assets/s12.webp"
|
|||||||
import Common from "./_components/header/common"
|
import Common from "./_components/header/common"
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const handleFreeTrial = () => {
|
||||||
|
navigate("/login")
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Common />
|
<Common />
|
||||||
@@ -51,6 +57,7 @@ export default function HomePage() {
|
|||||||
<div className="mt-12 flex justify-center md:justify-start">
|
<div className="mt-12 flex justify-center md:justify-start">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={handleFreeTrial}
|
||||||
className="w-36 h-14 rounded-full shadow-lg bg-linear-to-r from-blue-500 to-cyan-400 text-white text-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
className="w-36 h-14 rounded-full shadow-lg bg-linear-to-r from-blue-500 to-cyan-400 text-white text-xl font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||||
>
|
>
|
||||||
免费试用
|
免费试用
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { Outlet, useLocation } from "react-router-dom"
|
import { Outlet, useLocation } from "react-router-dom"
|
||||||
import AnnouncementModal from "@/components/composites/dialogs/AnnouncementModal"
|
import AnnouncementModal from "@/components/composites/dialogs/announcementModal"
|
||||||
import Foot from "./foot"
|
import Foot from "./foot"
|
||||||
import Header from "./header"
|
import Header from "./header"
|
||||||
|
|
||||||
|
|||||||
51
src/lib/api.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// 定义后端服务URL和OAuth2配置
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
|
||||||
|
if (!API_BASE_URL) throw new Error("VITE_API_BASE_URL is not set")
|
||||||
|
|
||||||
|
const CLIENT_ID = import.meta.env.VITE_CLIENT_ID
|
||||||
|
if (!CLIENT_ID) throw new Error("VITE_CLIENT_ID is not set")
|
||||||
|
|
||||||
|
const CLIENT_SECRET = import.meta.env.VITE_CLIENT_SECRET
|
||||||
|
if (!CLIENT_SECRET) throw new Error("VITE_CLIENT_SECRET is not set")
|
||||||
|
|
||||||
|
// 统一的API响应类型
|
||||||
|
type ApiResponse<T = undefined> =
|
||||||
|
| {
|
||||||
|
success: false
|
||||||
|
status: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
data: T
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageRecord<T = unknown> = {
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
list: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具类型:用于提取请求参数和返回数据
|
||||||
|
type ExtraReq<T extends (...args: never) => unknown> = T extends (
|
||||||
|
...args: infer P
|
||||||
|
) => unknown
|
||||||
|
? P[0]
|
||||||
|
: never
|
||||||
|
type ExtraResp<T extends (...args: never) => unknown> =
|
||||||
|
Awaited<ReturnType<T>> extends ApiResponse<infer D> ? D : never
|
||||||
|
|
||||||
|
// 预定义错误
|
||||||
|
const UnauthorizedError = new Error("未授权访问")
|
||||||
|
|
||||||
|
export {
|
||||||
|
API_BASE_URL,
|
||||||
|
type ApiResponse,
|
||||||
|
CLIENT_ID,
|
||||||
|
CLIENT_SECRET,
|
||||||
|
type ExtraReq,
|
||||||
|
type ExtraResp,
|
||||||
|
type PageRecord,
|
||||||
|
UnauthorizedError,
|
||||||
|
}
|
||||||
13
src/lib/cap/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { callPublic } from "@/api/base"
|
||||||
|
|
||||||
|
// 获取验证码
|
||||||
|
// 假设你的后端接口为 /api/captcha/get,返回 { token: 'abc', base64: 'data:image/png...' }
|
||||||
|
export async function getCaptcha() {
|
||||||
|
return await callPublic<{ token: string; base64: string }>("/api/captcha/get")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验验证码
|
||||||
|
// 假设后端接口为 /api/captcha/verify,返回 { success: true/false }
|
||||||
|
export async function verifyCaptcha(token: string, answer: string) {
|
||||||
|
return await callPublic<boolean>("/api/captcha/verify", { token, answer })
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { createBrowserRouter } from "react-router-dom"
|
import { createBrowserRouter } from "react-router-dom"
|
||||||
|
import Channels from "@/admin/channels/page"
|
||||||
import Dashboard from "@/admin/dashboard/page"
|
import Dashboard from "@/admin/dashboard/page"
|
||||||
|
import Funds from "@/admin/funds/page"
|
||||||
import AdminLayout from "@/admin/layout"
|
import AdminLayout from "@/admin/layout"
|
||||||
|
import Whitelist from "@/admin/whitelist/page"
|
||||||
|
import AuthGuard from "@/components/authGuard"
|
||||||
import HomePage from "@/home"
|
import HomePage from "@/home"
|
||||||
import ClientPage from "@/home/client"
|
import ClientPage from "@/home/client"
|
||||||
import HelpPage from "@/home/help"
|
import HelpPage from "@/home/help"
|
||||||
@@ -28,7 +32,19 @@ export const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/admin",
|
path: "/admin",
|
||||||
element: <AdminLayout />,
|
element: (
|
||||||
children: [{ index: true, element: <Dashboard /> }],
|
<AuthGuard>
|
||||||
|
<AdminLayout />
|
||||||
|
</AuthGuard>
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{ index: true, element: <Dashboard /> },
|
||||||
|
{ path: "channels", element: <Channels /> },
|
||||||
|
{ path: "whitelist", element: <Whitelist /> },
|
||||||
|
{ path: "funds", element: <Funds /> },
|
||||||
|
{ path: "Channels", element: <Channels /> },
|
||||||
|
{ path: "Channels", element: <Channels /> },
|
||||||
|
{ path: "Channels", element: <Channels /> },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
"lib": ["ES2023", "DOM"],
|
"lib": ["ES2023", "DOM"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"ignoreDeprecations": "6.0",
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": [
|
"@/*": ["./src/*"]
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"ignoreDeprecations":"6.0",
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
"target": "es2023",
|
"target": "es2023",
|
||||||
"lib": ["ES2023"],
|
"lib": ["ES2023"],
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
/* Linting */
|
/* Linting */
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"erasableSyntaxOnly": true,
|
"erasableSyntaxOnly": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import react from "@vitejs/plugin-react"
|
import react from "@vitejs/plugin-react"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [react(), tailwindcss()],
|
||||||
react(),
|
resolve: {
|
||||||
tailwindcss()
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||