优化登录流程,添加白名单管理功能,调整页面布局与样式

This commit is contained in:
2025-04-07 15:42:09 +08:00
parent a16faadaab
commit a2c18a1be8
21 changed files with 1388 additions and 102 deletions

View File

@@ -0,0 +1,15 @@
import {ReactNode} from 'react'
import {cookies} from 'next/headers'
export type ProfileProps = {}
export default async function Profile(props: ProfileProps) {
const store = await cookies()
const info = store.get('auth_info')?.value
const data = info ? JSON.parse(info) : undefined
return (
<div>
{data?.payload.name}
</div>
)
}

View File

@@ -0,0 +1,41 @@
export type DashboardPageProps = {}
export default async function DashboardPage(props: DashboardPageProps) {
return (
<main className={`flex-auto overflow-hidden grid grid-cols-4 grid-rows-4 p-4 pt-0 gap-4`}>
{/* banner */}
<section className={`col-start-1 row-start-1 col-span-3 bg-red-200`}>
</section>
{/* 短效 */}
<section className={`col-start-1 row-start-2 bg-red-200`}>
</section>
{/* 长效 */}
<section className={`col-start-2 row-start-2 bg-red-200`}>
</section>
{/* 固定 */}
<section className={`col-start-3 row-start-2 bg-red-200`}>
</section>
{/* 图表 */}
<section className={`col-start-1 row-start-3 col-span-3 row-span-2 bg-red-200`}>
</section>
{/* 信息 */}
<section className={`col-start-4 row-start-1 row-span-2 bg-red-200`}>
</section>
{/* 通知 */}
<section className={`col-start-4 row-start-3 row-span-2 bg-red-200`}>
</section>
</main>
)
}

92
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,92 @@
import {ReactNode} from 'react'
import Image from 'next/image'
import logo from '@/assets/logo.webp'
import Profile from '@/app/admin/_server/profile'
import Wrap from '@/components/wrap'
import {merge} from '@/lib/utils'
import Link from 'next/link'
export type DashboardLayoutProps = {
children: ReactNode
}
export default async function DashboardLayout(props: DashboardLayoutProps) {
return (
<div className={`h-screen flex flex-col overflow-hidden`}>
{/* background */}
<div className={`-z-10 relative`}>
<div className={`absolute w-screen h-screen bg-gray-50`}></div>
<div className={`absolute w-[2000px] h-[2000px] -left-[1000px] -top-[1000px] bg-radial from-blue-50 from-10% to-transparent to-50%`}></div>
<div className={`absolute w-[2000px] h-[2000px] -right-[1000px] -top-[1000px] bg-radial from-blue-50 from-10% to-transparent to-50%`}></div>
</div>
{/* content */}
<header className={`flex-none basis-20 flex items-stretch`}>
{/* logo */}
<div className={`flex-none basis-60 flex items-center justify-center`}>
<Image src={logo} alt={`logo`} height={40}/>
</div>
{/* title */}
<div className={`flex-auto overflow-hidden flex items-center`}>
</div>
{/* profile */}
<div className={`flex-none basis-80 flex items-center justify-end`}>
<Profile/>
</div>
</header>
<div className={`flex-auto overflow-hidden flex items-stretch gap-4`}>
<nav className={merge(
`flex-none basis-60 rounded-tr-xl bg-white p-4`,
`flex flex-col overflow-auto`,
)}>
<NavItem href={'/admin/dashboard'} icon={`🏠`} label={`账户总览`}/>
<NavTitle label={`套餐管理`}/>
<NavItem href={`/admin/package`} icon={`🛒`} label={`购买套餐`}/>
<NavItem href={`/admin/package/my`} icon={`📦`} label={`我的套餐`}/>
<NavTitle label={`IP 管理`}/>
<NavItem href={`/admin/ip/extract`} icon={`📤`} label={`IP提取`}/>
<NavItem href={`/admin/ip/extract/record`} icon={`📜`} label={`IP提取记录`}/>
<NavItem href={`/admin/ip/view`} icon={`👁️`} label={`查看提取IP`}/>
<NavItem href={`/admin/ip/view/used`} icon={`🗂️`} label={`查看使用IP`}/>
<NavTitle label={`个人信息`}/>
<NavItem href={`/admin/profile`} icon={`📝`} label={`个人信息修改`}/>
<NavItem href={`/admin/bill`} icon={`💰`} label={`我的账单`}/>
<NavItem href={`/admin/identify`} icon={`🆔`} label={`实名认证`}/>
<NavItem href={`/admin/whitelist`} icon={`🔒`} label={`白名单`}/>
</nav>
{props.children}
</div>
</div>
)
}
function NavTitle(props: {
label: string
}) {
return (
<p className={`px-4 py-2 text-sm text-gray-500`}>
{props.label}
</p>
)
}
function NavItem(props: {
href: string
icon?: ReactNode
label: string
}) {
return (
<Link className={merge(
`px-4 py-2 flex items-center rounded-md`,
`hover:bg-gray-100`,
)} href={props.href}>
{props.icon}
<span className={`ml-2`}>{props.label}</span>
</Link>
)
}

View File

@@ -0,0 +1,383 @@
'use client'
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table'
import {createWhitelist, removeWhitelist, listWhitelist, Whitelist, updateWhitelist} from '@/actions/whitelist'
import {Button} from '@/components/ui/button'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
import {useCallback, useEffect, useRef, useState} from 'react'
import {PageRecord} from '@/lib/api'
import {useStatus} from '@/lib/states'
import {toast} from 'sonner'
import {Form, FormField} from '@/components/ui/form'
import {useForm} from 'react-hook-form'
import {Input} from '@/components/ui/input'
import {Textarea} from '@/components/ui/textarea'
import {zodResolver} from '@hookform/resolvers/zod'
import {z} from 'zod'
import {Edit, Plus, Trash2, Loader2} from 'lucide-react'
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription, AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {Pagination} from '@/components/ui/pagination'
import {Checkbox} from '@/components/ui/checkbox'
const schema = z.object({
host: z.string().min(1, {message: 'IP地址不能为空'}),
remark: z.string().optional(),
})
type SchemaType = z.infer<typeof schema>
export type WhitelistPageProps = {}
export default function WhitelistPage(props: WhitelistPageProps) {
const [wait, setWait] = useState(false)
// ======================
// 数据
// ======================
const [status, setStatus] = useStatus()
const [data, setData] = useState<PageRecord<Whitelist>>({
page: 1,
size: 10,
total: 0,
list: [],
})
const refresh = async (page: number, size: number) => {
setStatus('load')
setWait(true)
try {
const resp = await listWhitelist({page, size})
if (resp && resp.success) {
setStatus('done')
setData(resp.data)
}
else {
setStatus('fail')
toast.error('加载数据失败')
}
}
catch (error) {
setStatus('fail')
toast.error('加载数据失败')
}
finally {
setWait(false)
}
}
// 处理分页
const changePage = (newPage: number) => {
refresh(newPage, data.size).then()
}
// 处理每页数量变化
const changeSize = (newSize: number) => {
refresh(1, newSize).then()
}
// ======================
// 弹窗
// ======================
const [dialogVisible, setDialogVisible] = useState(false)
const [dialogType, setDialogType] = useState<'add' | 'edit'>('add')
const [dialogData, setDialogData] = useState<Whitelist>()
const openDialog = (type: 'add' | 'edit', data?: Whitelist) => {
form.reset({
host: data?.host || '',
remark: data?.remark || '',
})
setDialogVisible(true)
setDialogType(type)
setDialogData(data)
}
const toggleDialog = (open: boolean) => {
setDialogVisible(open)
if (!open) {
form.reset()
}
}
const [alertVisible, setAlertVisible] = useState(false)
const removeId = useRef<number | undefined>(undefined)
const confirmRemove = (id?: number) => {
setAlertVisible(true)
if (id) {
removeId.current = id
}
else {
removeId.current = undefined
}
}
const remove = async () => {
setWait(true)
try {
const ids = removeId.current === undefined
? Array.from(selection).map(id => ({id}))
: [{id: removeId.current}]
const resp = await removeWhitelist(ids)
if (resp && resp.success) {
setAlertVisible(false)
toast.success('删除成功')
await refresh(data.page, data.size)
}
else {
toast.error('删除失败')
}
}
catch (error) {
toast.error('删除失败')
}
finally {
setWait(false)
}
}
const [selection, setSelection] = useState(new Set<number>())
const toggleSelect = (id: number) => {
if (selection.has(id)) {
selection.delete(id)
}
else {
selection.add(id)
}
setSelection(new Set(selection))
}
const toggleSelectAll = () => {
if (selection.size === data.list.length) {
setSelection(new Set())
}
else {
const newSelection = new Set<number>()
data.list.forEach(item => {
newSelection.add(item.id)
})
setSelection(newSelection)
}
}
// ======================
// 表单
// ======================
const form = useForm<SchemaType>({
resolver: zodResolver(schema),
defaultValues: {
host: '',
remark: '',
},
})
const onSubmit = async (value: SchemaType) => {
setWait(true)
try {
if (dialogType === 'add') {
// 添加白名单
const resp = await createWhitelist(value)
if (resp && resp.success) {
await refresh(data.page, data.size)
toggleDialog(false)
toast.success('添加成功')
}
else {
toast.error('添加失败')
}
}
else {
// 编辑白名单
if (!dialogData) {
toast.error('编辑失败')
return
}
const resp = await updateWhitelist({...value, id: dialogData.id})
if (resp && resp.success) {
await refresh(data.page, data.size)
toggleDialog(false)
toast.success('编辑成功')
}
else {
toast.error('编辑失败')
}
}
}
catch (error) {
toast.error(dialogType === 'add' ? '添加失败' : '编辑失败')
}
finally {
setWait(false)
}
}
// ======================
// region render
// ======================
useEffect(() => {
refresh(1, 10).then()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (status === 'fail') {
return <div className="flex items-center justify-center h-full"></div>
}
return (
<main className={`flex-auto bg-white rounded-tl-lg p-4 flex flex-col gap-4`}>
{/* 全局操作 */}
<section>
<Button onClick={() => openDialog('add')} disabled={wait}>
<Plus/>
</Button>
<Button
variant={`danger`}
className={`ml-2`}
disabled={selection.size === 0 || wait}
onClick={() => confirmRemove()}>
<Trash2/>
</Button>
</section>
{/* 数据表 */}
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className={`w-12 text-center px-0`}>
<Checkbox checked={selection.size === data.list.length} onClick={() => toggleSelectAll()}/>
</TableHead>
<TableHead>IP</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{wait && data.list.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto"/>
</TableCell>
</TableRow>
) : data.list.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
</TableCell>
</TableRow>
) : (
data.list.map(item => (
<TableRow key={item.id}>
<TableCell className={`w-12 text-center px-0`}>
<Checkbox checked={selection.has(item.id)} onClick={() => toggleSelect(item.id)}/>
</TableCell>
<TableCell className="font-medium">{item.host}</TableCell>
<TableCell>{item.createdAt}</TableCell>
<TableCell>{item.remark || '无'}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
className={`h-9 w-9`}
variant="outline"
onClick={() => openDialog('edit', item)}
disabled={wait}
>
<Edit className="w-4 h-4"/>
</Button>
<Button
className={`h-9 w-9`}
onClick={() => confirmRemove(item.id)}
variant={`danger`}
disabled={wait}
>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页器 */}
{data.total > 0 && (
<Pagination
page={data.page}
size={data.size}
total={data.total}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={changePage}
onPageSizeChange={changeSize}
/>
)}
{/* 编辑表单 */}
<Dialog open={dialogVisible} onOpenChange={toggleDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{dialogType === 'add' ? '添加白名单' : '编辑白名单'}
</DialogTitle>
</DialogHeader>
<Form<SchemaType>
className={`flex flex-col gap-4 py-4`}
form={form}
onSubmit={onSubmit}>
<FormField name={`host`} label={`IP地址`}>
{({id, field}) => (
<Input {...field} id={id} placeholder="输入IP地址"/>
)}
</FormField>
<FormField name={`remark`} label={`备注`}>
{({id, field}) => (
<Textarea {...field} id={id} placeholder="输入备注信息(可选)" disabled={wait}/>
)}
</FormField>
<DialogFooter className={`gap-4 mt-4`}>
<Button variant={`outline`} type="button" onClick={() => toggleDialog(false)} disabled={wait}></Button>
<Button type={`submit`} disabled={wait}>
{wait && <Loader2 className="w-4 h-4 mr-2 animate-spin"/>}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
{/* 删除表单 */}
<AlertDialog open={alertVisible} onOpenChange={setAlertVisible}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<Button variant="outline" onClick={() => setAlertVisible(false)}></Button>
<Button variant="danger" onClick={() => remove()}></Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</main>
)
// endregion
}