首页页面
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": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "biome check --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"radix-ui": "^1.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12"
|
||||
},
|
||||
"packageManager": "bun@1.3.2"
|
||||
"name": "juip-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "biome check --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"radix-ui": "^1.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"globals": "^17.6.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12"
|
||||
},
|
||||
"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 {
|
||||
IdCard,
|
||||
MessageCircleMoreIcon,
|
||||
Archive,
|
||||
CalendarClock,
|
||||
CircleDollarSignIcon,
|
||||
ClipboardClock,
|
||||
ClipboardList,
|
||||
Clock1,
|
||||
FolderCode,
|
||||
LayoutDashboard,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
UserRound,
|
||||
ReceiptJapaneseYenIcon,
|
||||
Router,
|
||||
ShoppingBag,
|
||||
TicketPercent,
|
||||
UserCheck,
|
||||
} from "lucide-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
@@ -16,19 +26,20 @@ import {
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import logoAvatar from "./assets/logo-avatar.svg"
|
||||
import { useLayout } from "./layout-context"
|
||||
|
||||
export function Shell(props: { children: ReactNode; navbar: boolean }) {
|
||||
const { navbar } = props
|
||||
export function Shell(props: { children: ReactNode }) {
|
||||
const { navbar } = useLayout()
|
||||
return (
|
||||
<div
|
||||
data-expand={navbar}
|
||||
className={cn(
|
||||
`transition-[grid-template-columns] duration-300 ease-in-out`,
|
||||
`w-full h-full grid`,
|
||||
`grid-rows-[64px_1fr]`,
|
||||
`data-[expand=true]:grid-cols-[200px_1fr]`,
|
||||
`data-[expand=false]:grid-cols-[0px_1fr]`,
|
||||
`md:data-[expand=false]:grid-cols-[64px_1fr]`,
|
||||
"transition-[grid-template-columns] duration-300 ease-in-out",
|
||||
"w-full h-full grid",
|
||||
"grid-rows-[64px_1fr]",
|
||||
"data-[expand=true]:grid-cols-[200px_1fr]",
|
||||
"data-[expand=false]:grid-cols-[0px_1fr]",
|
||||
"md:data-[expand=false]:grid-cols-[64px_1fr]",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
@@ -36,49 +47,45 @@ export function Shell(props: { children: ReactNode; navbar: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function Mask(props: {
|
||||
navbar: boolean
|
||||
setNavbar: (v: boolean) => void
|
||||
}) {
|
||||
const { navbar, setNavbar } = props
|
||||
export function Content(props: { children: ReactNode }) {
|
||||
const { navbar } = useLayout()
|
||||
return (
|
||||
<div
|
||||
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 (
|
||||
<button
|
||||
data-expand={navbar}
|
||||
className={cn(
|
||||
`lg:hidden`,
|
||||
`transition-opacity duration-300 ease-in-out`,
|
||||
`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]:pointer-events-auto data-[expand=false]:pointer-events-none`,
|
||||
"lg:hidden",
|
||||
"transition-opacity duration-300 ease-in-out",
|
||||
"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]:pointer-events-auto data-[expand=false]:pointer-events-none",
|
||||
)}
|
||||
onClick={() => setNavbar(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Content(props: { children: ReactNode; navbar: boolean }) {
|
||||
const { navbar } = props
|
||||
export function Header() {
|
||||
const { navbar, toggleNavbar } = useLayout()
|
||||
return (
|
||||
<div
|
||||
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`}>
|
||||
<header className="flex-none h-16 overflow-hidden flex items-stretch">
|
||||
<div className="flex-auto flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -87,26 +94,6 @@ export function Header(props: { navbar: boolean; toggleNavbar: () => void }) {
|
||||
>
|
||||
{navbar ? <PanelLeftCloseIcon /> : <PanelLeftOpenIcon />}
|
||||
</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 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() {
|
||||
return <div className="flex items-center gap-2">{"用户"}</div>
|
||||
return <div className="flex items-center gap-2">用户</div>
|
||||
}
|
||||
|
||||
export function Navbar(props: {
|
||||
navbar: boolean
|
||||
setNavbar: (v: boolean) => void
|
||||
}) {
|
||||
const { navbar, setNavbar } = props
|
||||
const closeNavBarIfMobile = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
setNavbar(false)
|
||||
}
|
||||
}
|
||||
export function Navbar() {
|
||||
const { navbar } = useLayout()
|
||||
|
||||
return (
|
||||
<nav
|
||||
@@ -146,65 +125,102 @@ export function Navbar(props: {
|
||||
`data-[expand=true]:basis-52 data-[expand=false]:basis-16`,
|
||||
)}
|
||||
>
|
||||
{/* logo */}
|
||||
<Link
|
||||
to="/"
|
||||
className={`flex-none h-16 flex items-center justify-center`}
|
||||
onClick={closeNavBarIfMobile}
|
||||
>
|
||||
<Link to="/" className="flex-none h-16 flex items-center justify-center">
|
||||
<img src={logoAvatar} alt="logo" className="w-10 h-10 object-contain" />
|
||||
</Link>
|
||||
|
||||
{/* routes */}
|
||||
<section
|
||||
className={cn(
|
||||
`transition-[padding] duration-300 ease-in-out`,
|
||||
`flex-auto overflow-x-hidden overflow-y-auto pb-4`,
|
||||
`group-data-[expand=true]:px-4 group-data-[expand=false]:px-3`,
|
||||
"transition-[padding] duration-300 ease-in-out",
|
||||
"flex-auto overflow-x-hidden overflow-y-auto pb-4",
|
||||
"group-data-[expand=true]:px-4 group-data-[expand=false]:px-3",
|
||||
)}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<NavItem
|
||||
href="/admin"
|
||||
icon={<UserRound size={20} />}
|
||||
icon={<LayoutDashboard size={20} />}
|
||||
label="账户总览"
|
||||
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>
|
||||
</section>
|
||||
</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: {
|
||||
href: string
|
||||
icon?: ReactNode
|
||||
@@ -212,6 +228,7 @@ function NavItem(props: {
|
||||
expand?: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { setNavbar } = useLayout()
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!props.expand) {
|
||||
@@ -219,6 +236,12 @@ function NavItem(props: {
|
||||
}
|
||||
}
|
||||
|
||||
const closeNavBarIfMobile = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
setNavbar(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip open={open} onOpenChange={handleOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -230,6 +253,7 @@ function NavItem(props: {
|
||||
`group-data-[expand=true]:px-4`,
|
||||
)}
|
||||
to={props.href}
|
||||
onClick={closeNavBarIfMobile}
|
||||
>
|
||||
<span className="flex-none w-10 h-10 flex items-center justify-center">
|
||||
{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 { 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() {
|
||||
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 { Content, Header, Mask, Navbar, Shell } from "./clients"
|
||||
import Effects from "./effects"
|
||||
import { LayoutProvider } from "./layout-context"
|
||||
|
||||
export default function AdminLayout() {
|
||||
const [navbar, setNavbar] = useState(true)
|
||||
|
||||
const toggleNavbar = () => setNavbar(!navbar)
|
||||
return (
|
||||
<div className="relative h-dvh overflow-hidden">
|
||||
<Shell navbar={navbar}>
|
||||
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
|
||||
<Navbar navbar={navbar} setNavbar={setNavbar} />
|
||||
</div>
|
||||
<LayoutProvider>
|
||||
<Effects />
|
||||
<div className="relative h-dvh overflow-hidden">
|
||||
<Shell>
|
||||
<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">
|
||||
<Header navbar={navbar} toggleNavbar={toggleNavbar} />
|
||||
</div>
|
||||
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
|
||||
<Header />
|
||||
</div>
|
||||
|
||||
<svg
|
||||
className="col-start-2 row-start-2 w-full h-full z-20 pointer-events-none"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<mask id="top-left-rounded-mask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<circle cx="16" cy="16" r="16" 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="16" y="16" width="100%" height="100%" fill="black" />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="fill-card"
|
||||
mask="url(#top-left-rounded-mask)"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
className="col-start-2 row-start-2 w-full h-full z-20 pointer-events-none"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<defs>
|
||||
<mask id="top-left-rounded-mask">
|
||||
<rect width="100%" height="100%" fill="white" />
|
||||
<circle cx="16" cy="16" r="16" 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="16" y="16" width="100%" height="100%" fill="black" />
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="fill-card"
|
||||
mask="url(#top-left-rounded-mask)"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<Mask navbar={navbar} setNavbar={setNavbar} />
|
||||
</Shell>
|
||||
<Mask />
|
||||
</Shell>
|
||||
|
||||
{/* 用 Outlet 替代 {props.children} */}
|
||||
<Content navbar={navbar}>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</div>
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</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 { 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({
|
||||
...props
|
||||
@@ -38,7 +37,7 @@ function DialogOverlay({
|
||||
data-slot="dialog-overlay"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -60,7 +59,7 @@ function DialogContent({
|
||||
data-slot="dialog-content"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -72,8 +71,7 @@ function DialogContent({
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -106,7 +104,7 @@ function DialogFooter({
|
||||
data-slot="dialog-footer"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -129,7 +127,7 @@ function DialogTitle({
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -145,7 +143,7 @@ function DialogDescription({
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...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"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
"use client"
|
||||
|
||||
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({
|
||||
delayDuration = 0,
|
||||
@@ -19,7 +21,11 @@ function TooltipProvider({
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
@@ -40,13 +46,16 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
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",
|
||||
className
|
||||
"bg-card border animate-in fade-in-0 zoom-in-95 ",
|
||||
"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}
|
||||
>
|
||||
{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.Portal>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ export type WrapProps = {
|
||||
|
||||
export default function Wrap(props: WrapProps) {
|
||||
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}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function Header() {
|
||||
<NavItem to="/ipline">IP线路表</NavItem>
|
||||
<NavItem to="/softDownload">软件下载</NavItem>
|
||||
<NavItem to="/help">帮助/教程</NavItem>
|
||||
<NavItem to="/client">下载客户端</NavItem>
|
||||
{/* <NavItem to="/client">下载客户端</NavItem> */}
|
||||
</ul>
|
||||
</nav>
|
||||
<ProfileOrLogin />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import Wrap from "@/components/wrap"
|
||||
import banner from "./_assets/banner.webp"
|
||||
import s1 from "./_assets/s1.webp"
|
||||
@@ -16,6 +17,11 @@ import s12 from "./_assets/s12.webp"
|
||||
import Common from "./_components/header/common"
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleFreeTrial = () => {
|
||||
navigate("/login")
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Common />
|
||||
@@ -51,6 +57,7 @@ export default function HomePage() {
|
||||
<div className="mt-12 flex justify-center md:justify-start">
|
||||
<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"
|
||||
>
|
||||
免费试用
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react"
|
||||
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 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 Channels from "@/admin/channels/page"
|
||||
import Dashboard from "@/admin/dashboard/page"
|
||||
import Funds from "@/admin/funds/page"
|
||||
import AdminLayout from "@/admin/layout"
|
||||
import Whitelist from "@/admin/whitelist/page"
|
||||
import AuthGuard from "@/components/authGuard"
|
||||
import HomePage from "@/home"
|
||||
import ClientPage from "@/home/client"
|
||||
import HelpPage from "@/home/help"
|
||||
@@ -28,7 +32,19 @@ export const router = createBrowserRouter([
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
element: <AdminLayout />,
|
||||
children: [{ index: true, element: <Dashboard /> }],
|
||||
element: (
|
||||
<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": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations":"6.0",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "esnext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"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 path from "path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
resolve: {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||