优化登录流程,添加白名单管理功能,调整页面布局与样式
This commit is contained in:
15
src/app/admin/_server/profile.tsx
Normal file
15
src/app/admin/_server/profile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
src/app/admin/dashboard/page.tsx
Normal file
41
src/app/admin/dashboard/page.tsx
Normal 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
92
src/app/admin/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
383
src/app/admin/whitelist/page.tsx
Normal file
383
src/app/admin/whitelist/page.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user