377 lines
10 KiB
TypeScript
377 lines
10 KiB
TypeScript
'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
|
||
}
|