7
src/actions/user.ts
Normal file
7
src/actions/user.ts
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
24
src/app/(root)/user/page.tsx
Normal file
24
src/app/(root)/user/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
src/components/data-table/component.tsx
Normal file
120
src/components/data-table/component.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
src/components/data-table/hooks.ts
Normal file
64
src/components/data-table/hooks.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
2
src/components/data-table/index.ts
Normal file
2
src/components/data-table/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./component"
|
||||
export * from "./hooks"
|
||||
306
src/components/ui/pagination.tsx
Normal file
306
src/components/ui/pagination.tsx
Normal 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,
|
||||
}
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
190
src/components/ui/select.tsx
Normal file
190
src/components/ui/select.tsx
Normal 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
116
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal 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
5
src/hooks/data.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useState } from "react"
|
||||
|
||||
export function useStatus() {
|
||||
return useState<"load" | "fail" | "done">("load")
|
||||
}
|
||||
@@ -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
22
src/models/user.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user