首页页面

This commit is contained in:
Eamon
2026-07-30 13:55:28 +08:00
parent 0361853732
commit 0d5e28c785
48 changed files with 2141 additions and 294 deletions

2
.env
View File

@@ -1 +1,3 @@
VITE_API_BASE_URL=http://192.168.0.15:8080
VITE_CLIENT_ID=web
VITE_CLIENT_SECRET=web

View File

@@ -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=="],

View File

@@ -11,6 +11,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",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/admin/assets/b11.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
src/admin/assets/b12.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/admin/assets/b3.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 884 B

BIN
src/admin/assets/b4.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

BIN
src/admin/assets/b5.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

BIN
src/admin/assets/b6.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 996 B

BIN
src/admin/assets/b7.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

BIN
src/admin/assets/b8.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

BIN
src/admin/assets/b9.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,5 @@
import Page from "@/components/page"
export default function ChannelsPage() {
return <Page>IP管理</Page>
}

View File

@@ -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}

View File

@@ -0,0 +1,5 @@
import Page from "@/components/page"
export default function CouponPage() {
return <Page></Page>
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
import Page from "@/components/page"
export default function FundsPage() {
return <Page></Page>
}

View 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
}

View File

@@ -1,20 +1,20 @@
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 (
<LayoutProvider>
<Effects />
<div className="relative h-dvh overflow-hidden">
<Shell navbar={navbar}>
<Shell>
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
<Navbar navbar={navbar} setNavbar={setNavbar} />
<Navbar />
</div>
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
<Header navbar={navbar} toggleNavbar={toggleNavbar} />
<Header />
</div>
<svg
@@ -38,13 +38,13 @@ export default function AdminLayout() {
/>
</svg>
<Mask navbar={navbar} setNavbar={setNavbar} />
<Mask />
</Shell>
{/* 用 Outlet 替代 {props.children} */}
<Content navbar={navbar}>
<Content>
<Outlet />
</Content>
</div>
</LayoutProvider>
)
}

View File

@@ -0,0 +1,5 @@
import Page from "@/components/page"
export default function WhitelistPage() {
return <Page></Page>
}

101
src/api/auth.ts Normal file
View 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
View 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 }

View 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}</>
}

View 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>
)
}

View 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} />}
</>
)
}

View File

@@ -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}
/>

View 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 }

View 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,
}

View 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
View 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,
}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -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 />

View File

@@ -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"
>

View File

@@ -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
View 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
View 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 })
}

View File

@@ -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 /> },
],
},
])

View File

@@ -23,10 +23,8 @@
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
}
},
"include": ["src"],
"include": ["src"]
}

View File

@@ -5,7 +5,7 @@
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"ignoreDeprecations":"6.0",
"ignoreDeprecations": "6.0",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]

View File

@@ -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()
],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
})