新增通道列表查询页面

This commit is contained in:
2025-04-28 17:41:54 +08:00
parent b20ec85db9
commit b44888a6d7
16 changed files with 290 additions and 117 deletions

View File

@@ -1,6 +1,8 @@
## TODO
检查时间范围选择,限定到一定范围内
将翻页操作反映在路由历史中,可以通过后退返回到上一个翻页状态
使用 pure js 的包代替 canvas加快编译速度

View File

@@ -1,7 +1,28 @@
'use server'
import {callByUser} from '@/actions/base'
import {Channel} from '@/lib/models'
import {PageRecord} from '@/lib/api'
async function createChannels(params: {
export async function listChannels(props: {
page: number
size: number
auth_type?: number
prov?: string
city?: string
isp?: string
expiration?: Date
}) {
return callByUser<PageRecord<Channel>>('/api/channel/list', props)
}
type CreateChannelsResp = {
host: string
port: string
username?: string
password?: string
}
export async function createChannels(params: {
resource_id: number
protocol: number
auth_type: number
@@ -12,14 +33,3 @@ async function createChannels(params: {
}) {
return callByUser<CreateChannelsResp[]>('/api/channel/create', params)
}
type CreateChannelsResp = {
host: string
port: string
username?: string
password?: string
}
export {
createChannels,
}

View File

@@ -52,7 +52,7 @@ export default function Navbar(props: NavbarProps) {
<NavItem href={`/admin/resources`} icon={`📦`} label={`套餐管理`}/>
<NavTitle label={`IP 管理`}/>
<NavItem href={`/admin/extract`} icon={`📤`} label={`提取 IP`}/>
<NavItem href={`/admin`} icon={`👁️`} label={`IP 管理`}/>
<NavItem href={`/admin/channels`} icon={`👁️`} label={`IP 管理`}/>
<NavItem href={`/admin`} icon={`📜`} label={`提取记录`}/>
<NavItem href={`/admin`} icon={`🗂️`} label={`使用记录`}/>
</section>

View File

@@ -9,19 +9,10 @@ export type ProfileProps = {}
export default function Profile(props: ProfileProps) {
const refreshProfile = useProfileStore(store => store.refreshProfile)
const router = useRouter()
const doLogout = async () => {
try {
const resp = await logout()
if (resp.success) {
await refreshProfile()
router.push('/')
}
}
catch (e) {
toast.error('退出登录失败', {
description: (e as Error).message,
})
const resp = await logout()
if (resp.success) {
await refreshProfile()
}
}

View File

@@ -0,0 +1,184 @@
'use client'
import {useEffect, useState} from 'react'
import {useStatus} from '@/lib/states'
import {PageRecord} from '@/lib/api'
import {Channel} from '@/lib/models'
import Page from '@/components/page'
import DataTable from '@/components/data-table'
import {toast} from 'sonner'
import {listChannels} from '@/actions/channel'
import {format} from 'date-fns'
import {Form, FormField} from '@/components/ui/form'
import {z} from 'zod'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import DatePicker from '@/components/date-picker'
import {Button} from '@/components/ui/button'
import {EraserIcon, SearchIcon} from 'lucide-react'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
export type ChannelsPageProps = {}
export default function ChannelsPage(props: ChannelsPageProps) {
// ======================
// data
// ======================
const [status, setStatus] = useStatus()
const [data, setData] = useState<PageRecord<Channel>>({
page: 1,
size: 10,
total: 0,
list: [],
})
const refresh = async (page: number, size: number) => {
try {
setStatus('load')
// 筛选条件
const filter = filterForm.getValues()
const auth_type = filter.auth_type ? parseInt(filter.auth_type) : undefined
// 请求数据
console.log({
page, size, ...filter, auth_type,
})
const resp = await listChannels({
page, size, ...filter, auth_type,
})
if (!resp.success) {
throw new Error(resp.message)
}
// 更新数据
setData(resp.data)
setStatus('done')
}
catch (e) {
setStatus('fail')
console.error(e)
toast.error('获取提取结果失败', {
description: (e as Error).message,
})
}
}
useEffect(() => {
refresh(data.page, data.size).then()
}, [])
// ======================
// filter
// ======================
const filterSchema = z.object({
auth_type: z.enum(['0', '1', '2']),
expire_after: z.date().optional(),
expire_before: z.date().optional(),
})
type FilterSchema = z.infer<typeof filterSchema>
const filterForm = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
auth_type: '0',
expire_after: undefined,
expire_before: undefined,
},
})
const filterHandler = filterForm.handleSubmit(async value => {
await refresh(data.page, data.size)
})
// ======================
// render
// ======================
return (
<Page>
<section className={`flex justify-between`}>
<div></div>
<Form form={filterForm} handler={filterHandler} className={`flex-none flex gap-4 items-end`}>
<FormField<FilterSchema, 'auth_type'> name={`auth_type`} label={<span className={`text-sm`}></span>}>
{({field}) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className={`h-9 w-36`}>
<SelectValue placeholder={`选择认证方式`}/>
</SelectTrigger>
<SelectContent>
<SelectItem value={'0'}></SelectItem>
<SelectItem value={'1'}>IP </SelectItem>
<SelectItem value={'2'}></SelectItem>
</SelectContent>
</Select>
)}
</FormField>
<fieldset className={`flex flex-col gap-2 items-start`}>
<div>
<legend className={`block text-sm`}></legend>
</div>
<div className={`flex gap-1 items-center`}>
<FormField<FilterSchema, 'expire_after'> name={`expire_after`}>
{({field}) => (
<DatePicker placeholder={`选择开始时间`} {...field} format={`yyyy-MM-dd`}/>
)}
</FormField>
<span>-</span>
<FormField<FilterSchema, 'expire_before'> name={`expire_before`}>
{({field}) => (
<DatePicker placeholder={`选择结束时间`} {...field} format={`yyyy-MM-dd`}/>
)}
</FormField>
</div>
</fieldset>
<Button className={`h-9`}>
<SearchIcon/>
</Button>
<Button theme={`outline`} className={`h-9`} onClick={() => filterForm.reset()}>
<EraserIcon/>
</Button>
</Form>
</section>
<DataTable
status={status}
data={data.list}
pagination={{
page: data.page,
size: data.size,
total: data.total,
onPageChange: (page) => refresh(page, data.size),
onSizeChange: (size) => refresh(1, size),
}}
columns={[
{
header: '代理地址', cell: ({row}) => `${row.original.proxy_host}:${row.original.proxy_port}`,
},
{
header: '认证方式', cell: ({row}) => {
return <div className={`flex flex-col gap-1`}>
{row.original.auth_ip && (<>
<span className={`text-weak`}>IP </span>
<span>{row.original.user_host}</span>
</>)}
{row.original.auth_pass && (<>
<span className={`text-weak`}></span>
<span>{row.original.username}:{row.original.password}</span>
</>)}
</div>
},
},
{
header: '过期时间', cell: ({row}) => format(row.original.expiration, 'yyyy-MM-dd HH:mm:ss'),
},
{
header: '操作', cell: ({row}) => <span>-</span>,
},
]}
/>
</Page>
)
}

View File

@@ -18,6 +18,7 @@ import * as qrcode from 'qrcode'
import Link from 'next/link'
import RechargeModal from '@/components/composites/purchase/_client/recharge'
import {User} from '@/lib/models'
import { Label } from '@/components/ui/label'
export type ProfilePageProps = {}
@@ -454,7 +455,7 @@ function ChangePhoneDialog(props: {
<Form form={form} onSubmit={onSubmit} className="space-y-4 py-4">
{currentPhone && (
<div className="space-y-1">
<FormLabel></FormLabel>
<Label></Label>
<Input value={maskPhone(currentPhone)} disabled/>
</div>
)}

View File

@@ -136,7 +136,7 @@ export default function ResourcesPage(props: ResourcesPageProps) {
<FormField name={`type`} label={<span className={`text-sm`}></span>}>
{({id, field}) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className={`w-24`}>
<SelectTrigger className={`w-24 h-9`}>
<SelectValue placeholder={`选择套餐类型`}/>
</SelectTrigger>
<SelectContent>

View File

@@ -1,9 +1,8 @@
'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 {useEffect, useRef, useState} from 'react'
import {PageRecord} from '@/lib/api'
import {useStatus} from '@/lib/states'
import {toast} from 'sonner'
@@ -21,8 +20,6 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {Pagination} from '@/components/ui/pagination'
import {Checkbox} from '@/components/ui/checkbox'
import Page from '@/components/page'
import DataTable from '@/components/data-table'

View File

@@ -187,7 +187,7 @@ export default function Extract(props: ExtractProps) {
</div>
) : resources.map((resource, i) => (<>
<SelectItem
key={`${resource.id}-${i}`} value={String(resource.id)} className={`p-3`}>
key={`${resource.id}`} value={String(resource.id)} className={`p-3`}>
<div className={`flex flex-col gap-2 w-72`}>
{resource.type === 1 && resource.pss.type === 1 && (<>
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm`}>

View File

@@ -35,7 +35,7 @@ export default function DataTable<T extends Record<string, unknown>>(props: Data
return (<>
{/* 数据表*/}
<div className={`border rounded-md relative bg-card`}>
<div className={`rounded-md relative bg-card`}>
<TableRoot>
<TableHeader>
{table.getHeaderGroups().map(group => (

View File

@@ -35,7 +35,7 @@ export default function DatePicker(props: DatePickerProps) {
)}
>
<CalendarIcon/>
<span className={`text-sm`}>
<span className={merge(`text-sm`, !props.value && 'text-weak')}>
{props.value
? format(props.value, props.format || 'yyyy-MM-dd HH:mm:ss')
: props.placeholder || '选择日期'

View File

@@ -1,4 +1,4 @@
import {ComponentProps, ReactNode} from 'react'
import {ComponentProps} from 'react'
import {merge} from '@/lib/utils'
export type PageProps = {

View File

@@ -1,5 +1,4 @@
'use client'
import * as LabelPrimitive from '@radix-ui/react-label'
import {Slot} from '@radix-ui/react-slot'
import {
@@ -8,13 +7,13 @@ import {
ControllerProps,
SubmitHandler,
FieldValues, useFormContext, FieldPath, UseFormReturn, ControllerRenderProps,
ControllerFieldState, UseFormStateReturn, FieldError, FieldErrors, SubmitErrorHandler,
ControllerFieldState, UseFormStateReturn, FieldError, SubmitErrorHandler,
} from 'react-hook-form'
import {merge} from '@/lib/utils'
import {Label} from '@/components/ui/label'
import React, {BaseSyntheticEvent, ComponentProps, createContext, ReactNode, useContext, useId} from 'react'
import React, {ComponentProps, createContext, ReactNode, useContext, useId} from 'react'
type FormProps<T extends FieldValues> = {
form: UseFormReturn<T>
@@ -60,13 +59,6 @@ type FormFieldProps<
}) => ReactNode
} & Omit<ControllerProps<V, N>, 'control' | 'render'>
type FormFieldContext = {
id: string
error?: FieldError
}
const FormFieldContext = createContext<FormFieldContext | null>(null)
function FormField<
V extends FieldValues = FieldValues,
N extends FieldPath<V> = FieldPath<V>,
@@ -76,68 +68,48 @@ function FormField<
return (
<Controller<V, N> name={props.name} control={form.control} render={({field, fieldState, formState}) => (
<div data-slot="form-field" className={merge('grid gap-2', props.className)}>
<FormFieldContext value={{id: id, error: fieldState.error}}>
{!!props.label &&
<Label
data-slot="form-label"
data-fail={!!fieldState.error}
className={merge('data-[error=true]:text-fail')}
htmlFor={id}>
{props.label}
</Label>
}
{!!props.label &&
<FormLabel error={fieldState.error}>
{props.label}
</FormLabel>
}
<Slot
data-slot="form-control"
aria-invalid={!!fieldState.error}
aria-describedby={
!!fieldState.error
? `${id}-description`
: `${id}-description ${id}-message`
}>
{props.children({id, field, fieldState, formState})}
</Slot>
<Slot
data-slot="form-control"
aria-invalid={!!fieldState.error}
aria-describedby={
!!fieldState.error
? `${id}-description`
: `${id}-description ${id}-message`
}>
{props.children({id, field, fieldState, formState})}
</Slot>
{!fieldState.error ? null : (
<p
data-slot="form-message"
className={merge('text-fail text-sm')}>
{fieldState.error?.message}
</p>
)}
</FormFieldContext>
{!fieldState.error ? null : (
<FormMessage error={fieldState.error}/>
)}
</div>
)}
/>
)}/>
)
}
const useFormField = () => {
const context = useContext(FormFieldContext)
if (!context) {
throw new Error('FormField components must be used within a FormField component')
}
return context
type FormState = {
error?: FieldError
}
function FormLabel({className, ...props}: ComponentProps<typeof LabelPrimitive.Root>) {
const {id, error} = useFormField()
function FormLabel({className, id, error, ...props}: ComponentProps<typeof LabelPrimitive.Root> & FormState) {
return (
<Label
data-slot="form-label"
data-fail={!!error}
className={merge('data-[error=true]:text-fail', className)}
className={merge('data-[fail=true]:text-fail', className)}
htmlFor={id}
{...props}
/>
)
}
function FormDescription({className, ...props}: ComponentProps<'p'>) {
const {id} = useFormField()
function FormDescription({className, id, error, ...props}: ComponentProps<'p'> & FormState) {
return (
<p
data-slot="form-description"
@@ -148,8 +120,7 @@ function FormDescription({className, ...props}: ComponentProps<'p'>) {
)
}
function FormMessage({className, ...props}: ComponentProps<'p'>) {
const {id, error} = useFormField()
function FormMessage({className, id, error, ...props}: ComponentProps<'p'> & FormState) {
const body = error ? String(error?.message ?? '') : props.children
if (!body) {

View File

@@ -37,7 +37,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={merge(
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground ',
'border-input data-[placeholder]:text-weak [&_svg:not([class*=\'text-\'])]:text-muted-foreground ',
'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-fail/20 dark:aria-invalid:ring-fail/40 ',
'aria-invalid:border-fail 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 transition-[color] ',

View File

@@ -1,10 +1,10 @@
"use client"
'use client'
import * as React from "react"
import * as React from 'react'
import { merge } from "@/lib/utils"
import {merge} from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<"table">) {
function Table({className, ...props}: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
@@ -12,79 +12,79 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
>
<table
data-slot="table"
className={merge("w-full caption-bottom text-sm", className)}
className={merge('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
function TableHeader({className, ...props}: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={merge("[&_tr]:border-b", className)}
className={merge('[&_tr]:border-b', className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
function TableBody({className, ...props}: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={merge("[&_tr:last-child]:border-0", className)}
className={merge('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
function TableFooter({className, ...props}: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={merge(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
function TableRow({className, ...props}: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={merge(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
function TableHead({className, ...props}: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={merge(
"text-weak h-10 px-2 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
'text-weak h-10 px-2 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
function TableCell({className, ...props}: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={merge(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
@@ -94,11 +94,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={merge("text-muted-foreground mt-4 text-sm", className)}
className={merge('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
)

View File

@@ -1,5 +1,3 @@
import ResourcesPage from '@/app/admin/resources/page'
export type User = {
id: number
admin_id: number
@@ -106,3 +104,22 @@ export type Refund = {
created_at: Date
updated_at: Date
}
export type Channel = {
id: number
user_id: number
proxy_id: number
node_id: number
proxy_host: string
proxy_port: number
user_host: string
node_host: string
auth_ip: boolean
auth_pass: boolean
protocol: number
username: string
password: string
expiration: Date
created_at: Date
updated_at: Date
}