实现页面模板与用户管理页面基本功能

Signed-off-by: luorijun <luorijun@outlook.com>
This commit is contained in:
2025-12-29 18:01:16 +08:00
parent f950906f00
commit 37aaff439a
21 changed files with 1117 additions and 158 deletions

7
src/actions/user.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { PageRecord } from "@/lib/api"
import type { User } from "@/models/user"
import { callByUser } from "./base"
export async function getPageUsers(params: { page: number; size: number }) {
return callByUser<PageRecord<User>>("/api/user/list", params)
}

View File

@@ -6,6 +6,7 @@ import {
ChevronsRight,
ClipboardList,
Code,
ComputerIcon,
Database,
DollarSign,
FileText,
@@ -21,9 +22,16 @@ import {
import Link from "next/link"
import { usePathname } from "next/navigation"
import { createContext, type ReactNode, useContext, useState } from "react"
import { twJoin } from "tailwind-merge"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
// Navigation Context
interface NavigationContextType {
@@ -44,19 +52,19 @@ const useNavigation = () => {
return context
}
// NavigationGroup Component
interface NavigationGroupProps {
// NavGroup Component
interface NavGroupProps {
title: string
children: ReactNode
}
function NavigationGroup({ title, children }: NavigationGroupProps) {
function NavGroup({ title, children }: NavGroupProps) {
const { collapsed } = useNavigation()
return (
<div className="px-3">
{!collapsed && (
<h3 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">
<h3 className="px-3 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{title}
</h3>
)}
@@ -65,40 +73,51 @@ function NavigationGroup({ title, children }: NavigationGroupProps) {
)
}
// NavigationItem Component
interface NavigationItemProps {
// NavItem Component
interface NavItemProps {
href: string
icon: LucideIcon
label: string
}
function NavigationItem({ href, icon: Icon, label }: NavigationItemProps) {
function NavItem({ href, icon: Icon, label }: NavItemProps) {
const { collapsed, isActive } = useNavigation()
const active = isActive(href)
return (
<li>
<Link
href={href}
className={`flex items-center px-3 py-2 rounded-md transition-colors ${
active
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<Icon
className={`h-5 w-5 ${active ? "text-blue-600" : "text-gray-500"}`}
/>
{!collapsed && (
<span className="ml-3 font-medium text-sm">{label}</span>
)}
</Link>
</li>
const linkContent = (
<Link
href={href}
className={`flex items-center ${
collapsed ? "justify-center w-10 h-10" : "px-3 py-2"
} rounded-md transition-colors ${
active
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
}`}
>
<Icon className={`h-5 w-5 ${collapsed ? "" : "flex-shrink-0"}`} />
{!collapsed && <span className="ml-3 font-medium text-sm">{label}</span>}
</Link>
)
if (collapsed) {
return (
<li>
<Tooltip>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
<TooltipContent side="right">
<p>{label}</p>
</TooltipContent>
</Tooltip>
</li>
)
}
return <li>{linkContent}</li>
}
// NavigationSeparator Component
function NavigationSeparator() {
// NavSeparator Component
function NavSeparator() {
const { collapsed } = useNavigation()
if (collapsed) return null
@@ -126,128 +145,92 @@ export default function Navigation() {
}
return (
<NavigationContext.Provider value={contextValue}>
<aside
className={`bg-white border-r border-gray-200 transition-all duration-300 ease-in-out flex flex-col ${
collapsed ? "w-20" : "w-64"
}`}
>
{/* Logo */}
<div className="h-16 flex items-center px-5 border-b border-gray-200">
{!collapsed ? (
<span className="text-xl font-bold tracking-wide text-gray-800">
</span>
) : (
<span className="text-xl font-bold mx-auto text-gray-800"></span>
<TooltipProvider delayDuration={0}>
<NavigationContext.Provider value={contextValue}>
<aside
className={twJoin(
"bg-background border-r border-border transition-all duration-300 ease-in-out flex flex-col",
collapsed ? "w-16" : "w-64",
)}
</div>
{/* Navigation Menu */}
<ScrollArea className="flex-1 py-4">
<nav className="space-y-4">
{/* 概览 */}
<NavigationGroup title="概览">
<NavigationItem href="/" icon={Home} label="首页" />
<NavigationItem
href="/statistics"
icon={BarChart3}
label="数据统计"
/>
</NavigationGroup>
<NavigationSeparator />
{/* IP 资源 */}
<NavigationGroup title="IP 资源">
<NavigationItem
href="/proxy/nodes"
icon={Globe}
label="节点列表"
/>
<NavigationItem
href="/proxy/pools"
icon={Server}
label="IP池管理"
/>
<NavigationItem
href="/proxy/sources"
icon={Database}
label="代理源管理"
/>
</NavigationGroup>
<NavigationSeparator />
{/* 客户 */}
<NavigationGroup title="客户">
<NavigationItem href="/clients" icon={Users} label="客户管理" />
<NavigationItem
href="/packages"
icon={Package}
label="套餐管理"
/>
<NavigationItem
href="/orders"
icon={ClipboardList}
label="订单管理"
/>
</NavigationGroup>
<NavigationSeparator />
{/* 运营 */}
<NavigationGroup title="运营">
<NavigationItem
href="/api/management"
icon={Code}
label="API管理"
/>
<NavigationItem
href="/traffic"
icon={Activity}
label="流量监控"
/>
<NavigationItem
href="/billing"
icon={DollarSign}
label="计费系统"
/>
</NavigationGroup>
<NavigationSeparator />
{/* 系统 */}
<NavigationGroup title="系统">
<NavigationItem
href="/settings"
icon={Settings}
label="系统设置"
/>
<NavigationItem href="/security" icon={Shield} label="安全管理" />
<NavigationItem href="/logs" icon={FileText} label="系统日志" />
</NavigationGroup>
</nav>
</ScrollArea>
{/* Toggle Button */}
<div className="p-4 border-t border-gray-200 mt-auto">
<Button
variant="ghost"
onClick={() => setCollapsed(!collapsed)}
className="w-full justify-center text-gray-600 hover:bg-gray-100"
>
{collapsed ? (
<ChevronsRight className="h-5 w-5" />
>
{/* Logo */}
<div className="h-16 flex items-center justify-center border-b border-border">
{!collapsed ? (
<span className="text-xl font-bold tracking-wide text-foreground">
</span>
) : (
<>
<ChevronsLeft className="h-5 w-5" />
<span className="ml-2 text-sm"></span>
</>
<span className="text-xl font-bold mx-auto text-foreground">
<ComputerIcon />
</span>
)}
</Button>
</div>
</aside>
</NavigationContext.Provider>
</div>
{/* Navigation Menu */}
<ScrollArea className="flex-1 py-3">
<nav className="space-y-3">
{/* 概览 */}
<NavGroup title="概览">
<NavItem href="/" icon={Home} label="首页" />
<NavItem href="/statistics" icon={BarChart3} label="数据统计" />
</NavGroup>
<NavSeparator />
{/* IP 资源 */}
<NavGroup title="IP 资源">
<NavItem href="/proxy/nodes" icon={Globe} label="节点列表" />
<NavItem href="/proxy/pools" icon={Server} label="IP池管理" />
</NavGroup>
<NavSeparator />
{/* 客户 */}
<NavGroup title="客户">
<NavItem href="/user" icon={Users} label="客户管理" />
<NavItem href="/resources" icon={Package} label="套餐管理" />
<NavItem href="/orders" icon={ClipboardList} label="订单管理" />
</NavGroup>
<NavSeparator />
{/* 运营 */}
<NavGroup title="运营">
<NavItem href="/api/management" icon={Code} label="API管理" />
<NavItem href="/traffic" icon={Activity} label="流量监控" />
<NavItem href="/billing" icon={DollarSign} label="计费系统" />
</NavGroup>
<NavSeparator />
{/* 系统 */}
<NavGroup title="系统">
<NavItem href="/settings" icon={Settings} label="系统设置" />
<NavItem href="/security" icon={Shield} label="安全管理" />
<NavItem href="/logs" icon={FileText} label="系统日志" />
</NavGroup>
</nav>
</ScrollArea>
{/* Toggle Button */}
<div className="p-4 border-t border-border mt-auto">
<Button
variant="ghost"
onClick={() => setCollapsed(!collapsed)}
className="w-full justify-center text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{collapsed ? (
<ChevronsRight className="h-5 w-5" />
) : (
<>
<ChevronsLeft className="h-5 w-5" />
<span className="ml-2 text-sm"></span>
</>
)}
</Button>
</div>
</aside>
</NavigationContext.Provider>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,24 @@
"use client"
import { getPageUsers } from "@/actions/user"
import { DataTable, useDataTable } from "@/components/data-table"
import type { User } from "@/models/user"
export default function UserPage() {
const table = useDataTable<User>((page, size) => getPageUsers({ page, size }))
return (
<div>
<DataTable<User>
{...table}
columns={[
{ header: "ID", accessorKey: "id" },
{ header: "姓名", accessorKey: "name" },
{ header: "邮箱", accessorKey: "email" },
{ header: "角色", accessorKey: "role" },
{ header: "状态", accessorKey: "status" },
{ header: "创建时间", accessorKey: "createdAt" },
{ header: "更新时间", accessorKey: "updatedAt" },
]}
/>
</div>
)
}

View File

@@ -0,0 +1,120 @@
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 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,
state: {
pagination: {
pageIndex: props.pagination.page,
pageSize: props.pagination.size,
},
columnFilters: [],
},
})
return (
<div className="flex flex-col gap-3">
{/* 数据表 */}
<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>
{/* 分页器 */}
<Pagination {...props.pagination} />
</div>
)
}

View File

@@ -0,0 +1,64 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { useStatus } from "@/hooks/data"
import type { ApiResponse, PageRecord } from "@/lib/api"
export function useDataTable<T>(
fetch: (page: number, size: number) => Promise<ApiResponse<PageRecord<T>>>,
) {
const [status, setStatus] = useStatus()
const [data, setData] = useState<T[]>([])
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [total, setTotal] = useState(0)
const refresh = useCallback(
async (page: number, size: number) => {
setStatus("load")
try {
const resp = await fetch(page, size)
if (!resp.success) {
throw new Error("获取数据失败")
}
setData(resp.data.list)
setPage(resp.data.page)
setSize(resp.data.size)
setTotal(resp.data.total)
setStatus("done")
} catch (error) {
toast.error(error instanceof Error ? error.message : "未知错误")
setStatus("fail")
}
},
[fetch, setStatus],
)
const onPageChange = (page: number) => {
setPage(page)
}
const onSizeChange = (size: number) => {
setPage(1)
setSize(size)
}
useEffect(() => {
refresh(page, size).then()
}, [refresh, page, size])
return {
status,
data,
pagination: {
page,
size,
total,
onPageChange,
onSizeChange,
},
}
}

View File

@@ -0,0 +1,2 @@
export * from "./component"
export * from "./hooks"

View File

@@ -0,0 +1,306 @@
"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 justify-end 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 bg-card">
<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,
PaginationLayout,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import type * as React from "react"
import { cn } from "@/lib/utils"
@@ -43,7 +43,7 @@ function ScrollBar({
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
className,
)}
{...props}
>

View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...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(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</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"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
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
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{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("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none 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
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</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("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
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(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
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 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background 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-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

5
src/hooks/data.ts Normal file
View File

@@ -0,0 +1,5 @@
import { useState } from "react"
export function useStatus() {
return useState<"load" | "fail" | "done">("load")
}

View File

@@ -30,9 +30,6 @@ type ExtraReq<T extends (...args: never) => unknown> = T extends (
type ExtraResp<T extends (...args: never) => unknown> =
Awaited<ReturnType<T>> extends ApiResponse<infer D> ? D : never
// 预定义错误
const UnauthorizedError = new Error("未授权访问")
export {
API_BASE_URL,
CLIENT_ID,
@@ -41,5 +38,4 @@ export {
type PageRecord,
type ExtraReq,
type ExtraResp,
UnauthorizedError,
}

22
src/models/user.ts Normal file
View File

@@ -0,0 +1,22 @@
export type User = {
id: number
admin_id: number
phone: string
has_password: boolean
username: string
email: string
name: string
avatar: string
status: number
balance: number
id_type: number
id_no: string
id_token: string
contact_qq: string
contact_wechat: string
last_login: Date
last_login_host: string
last_login_agent: string
created_at: Date
updated_at: Date
}