Files
web/src/app/admin/whitelist/page.tsx

377 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
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 {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 Page from '@/components/page'
import DataTable from '@/components/data-table'
import {format, parseISO} from 'date-fns'
import {getClientIp} from '@/actions/ip'
const schema = z.object({
host: z.string().min(1, {message: 'IP地址不能为空'}),
remark: z.string().optional(),
})
type SchemaType = z.infer<typeof schema>
export type WhitelistPageProps = {}
const MAX_WHITELIST_COUNT = 5
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})
console.log(resp, '白名单resp')
if (!resp.success) {
throw new Error(resp.message)
}
setStatus('done')
setData(resp.data)
}
catch (e) {
setStatus('fail')
toast.error('加载数据失败', {
description: e instanceof Error ? e.message : String(e),
})
}
finally {
setWait(false)
}
}
// ======================
// 弹窗
// ======================
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.success) {
throw new Error(resp.message)
}
await refresh(1, data.size)
toggleDialog(false)
toast.success('添加成功')
}
// 编辑白名单
else {
if (!dialogData) {
throw new Error('编辑数据出错')
}
const resp = await updateWhitelist({...value, id: dialogData.id})
if (!resp.success) {
throw new Error(resp.message)
}
await refresh(1, data.size)
toggleDialog(false)
toast.success('编辑成功')
}
}
catch (e) {
toast.error(dialogType === 'add' ? '添加失败' : '编辑失败', {
description: e instanceof Error ? e.message : String(e),
})
}
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>
}
const getCurrentIP = async () => {
setWait(true)
try {
const result = await getClientIp()
if (result.ip) {
form.setValue('host', result.ip)
toast.success('已获取当前IP地址')
}
else {
toast.error('获取失败', {
description: result.error || '请手动输入IP地址',
})
}
}
finally {
setWait(false)
}
}
return (
<Page>
{/* 全局操作 */}
<section>
<Button onClick={() => openDialog('add')} disabled={wait || data.total >= MAX_WHITELIST_COUNT}>
<Plus/>
{data.total >= MAX_WHITELIST_COUNT && '(已达上限)'}
</Button>
</section>
{/* 数据表 */}
<DataTable
status={status}
data={data.list}
pagination={{
total: data.total,
page: data.page,
size: data.size,
onPageChange: (page: number) => refresh(page, data.size),
onSizeChange: (size: number) => refresh(1, size),
}}
columns={[
{
header: `IP 地址`, accessorKey: 'ip',
},
{
header: `备注`, accessorKey: 'remark',
},
{
header: `添加时间`, cell: ({row}) => format(parseISO(row.original.created_at), 'yyyy-MM-dd HH:mm'),
},
{
id: 'actions', header: `操作`, cell: ({row}) => (
<div className="flex gap-2">
<Button
className="h-9 w-9"
theme="outline"
onClick={() => openDialog('edit', row.original)}
disabled={wait}
>
<Edit className="w-4 h-4"/>
</Button>
<Button
className="h-9 w-9"
onClick={() => confirmRemove(row.original.id)}
theme="fail"
disabled={wait}
>
<Trash2 className="w-4 h-4"/>
</Button>
</div>
),
},
]}
/>
{/* 编辑表单 */}
<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}) => (
<div className="flex gap-2">
<Input
{...field}
id={id}
placeholder="输入IP地址"
className="flex-1"
/>
<Button
type="button"
onClick={getCurrentIP}
disabled={wait}
className="shrink-0"
theme="outline"
>
{wait ? <Loader2 className="w-4 h-4 animate-spin"/> : '获取当前IP'}
</Button>
</div>
)}
</FormField>
<FormField name="remark" label="备注">
{({id, field}) => (
<Textarea {...field} id={id} placeholder="输入备注信息(可选)" disabled={wait}/>
)}
</FormField>
<DialogFooter className="gap-4 mt-4">
<Button theme="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 theme="outline" onClick={() => setAlertVisible(false)}></Button>
<Button theme="fail" onClick={() => remove()}></Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Page>
)
// endregion
}