Files
web/src/components/composites/extract/index.tsx

617 lines
22 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 {z} from 'zod'
import {zodResolver} from '@hookform/resolvers/zod'
import {Form, FormField, FormLabel} from '@/components/ui/form'
import {RadioGroup, RadioGroupItem} from '@/components/ui/radio-group'
import {Input} from '@/components/ui/input'
import {Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue} from '@/components/ui/select'
import {Button} from '@/components/ui/button'
import {useForm, useFormContext} from 'react-hook-form'
import {Alert, AlertTitle} from '@/components/ui/alert'
import {Box, CircleAlert, CopyIcon, ExternalLinkIcon, Loader, Timer} from 'lucide-react'
import {memo, ReactNode, useEffect, useRef, useState} from 'react'
import {useStatus} from '@/lib/states'
import {allResource} from '@/actions/resource'
import {Resource} from '@/lib/models'
import {format, intlFormatDistance} from 'date-fns'
import {toast} from 'sonner'
import {merge} from '@/lib/utils'
import {Combobox} from '@/components/ui/combobox'
import cities from './_assets/cities.json'
import ExtractDocs from '@/components/docs/extract.mdx'
import Markdown from '@/components/markdown'
const schema = z.object({
resource: z.number({required_error: '请选择套餐'}),
prov: z.string().optional(),
city: z.string().optional(),
regionType: z.enum(['unlimited', 'specific']).default('unlimited'),
isp: z.enum(['all', '1', '2', '3'], {required_error: '请选择运营商'}),
proto: z.enum(['all', '1', '2', '3'], {required_error: '请选择协议'}),
authType: z.enum(['1', '2'], {required_error: '请选择认证方式'}),
distinct: z.enum(['1', '0'], {required_error: '请选择去重选项'}),
format: z.enum(['text', 'json'], {required_error: '请选择导出格式'}),
separator: z.string({required_error: '请选择分隔符'}),
breaker: z.string({required_error: '请选择换行符'}),
count: z.number({required_error: '请输入有效的数量'}).min(1),
})
type Schema = z.infer<typeof schema>
type ExtractProps = {
className?: string
}
export default function Extract(props: ExtractProps) {
const form = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: {
regionType: 'unlimited',
isp: 'all',
proto: 'all',
authType: '1',
count: 1,
distinct: '1',
format: 'text',
breaker: '13,10',
separator: '124',
},
})
// ======================
// render
// ======================
return (
<Form
form={form}
className={merge(
`flex flex-col gap-6`,
)}
>
<CardSection>
<Alert variant="warn" >
<CircleAlert/>
<AlertTitle>IP前需要将本机IP添加到白名单后才可使用</AlertTitle>
</Alert>
<FormFields/>
</CardSection>
<CardSection>
<ApplyLink/>
</CardSection>
<CardSection>
<Markdown>
<ExtractDocs/>
</Markdown>
</CardSection>
</Form>
)
}
function CardSection(props: {
children: ReactNode
}) {
return (
<div className="flex flex-col gap-4 p-4 md:p-6 bg-white rounded-lg">
{props.children}
</div>
)
}
const FormFields = memo(() => {
return (
<div className="flex flex-col gap-6 items-stretch max-w-[calc(160px*4+1rem*3)]">
{/* 选择套餐 */}
<SelectResource/>
{/* 地区筛选 */}
<SelectRegion/>
{/* 运营商筛选 */}
<FormField name="isp" label="运营商筛选" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid grid-cols-2 md:grid-cols-4 gap-4"
>
<FormLabel htmlFor={`${id}-v-all`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="all" id={`${id}-v-all`}/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-telecom`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="1" id={`${id}-v-telecom`}/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-mobile`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="2" id={`${id}-v-mobile`}/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-unicom`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="3" id={`${id}-v-unicom`}/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{/* 协议类型 */}
<FormField name="proto" label="协议类型" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormLabel htmlFor={`${id}-v-all`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="all" id={`${id}-v-all`} className="mr-2"/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-http`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="1" id={`${id}-v-http`} className="mr-2"/>
<span>HTTP</span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-https`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
<span>HTTPS</span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-socks5`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="3" id={`${id}-v-socks5`} className="mr-2"/>
<span>SOCKS5</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{/* 认证方式 */}
<FormField name="authType" className="md:max-w-[calc(160px*2+1rem)]" label="认证方式" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-http`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="1" id={`${id}-v-http`} className="mr-2"/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-https`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{/* 去重选项 */}
<FormField name="distinct" className="md:max-w-[calc(160px*2+1rem)]" label="去重选项" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-true`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="1" id={`${id}-v-true`} className="mr-2"/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-false`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="0" id={`${id}-v-false`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{/* 导出格式 */}
<FormField name="format" className="md:max-w-[calc(160px*2+1rem)]" label="导出格式" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
>
<FormLabel htmlFor={`${id}-v-txt`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="text" id={`${id}-v-txt`} className="mr-2"/>
<span>TXT </span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-json`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="json" id={`${id}-v-json`} className="mr-2"/>
<span>JSON </span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{/* 分隔符 */}
<FormField name="separator" className="md:max-w-[calc(160px*3+1rem*2)]" label="分隔符" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormLabel htmlFor={`${id}-v-comma`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="124" id={`${id}-v-comma`} className="mr-2"/>
<span>线 ( | )</span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-semicolon`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="58" id={`${id}-v-semicolon`} className="mr-2"/>
<span> ( : )</span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-space`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="9" id={`${id}-v-space`} className="mr-2"/>
<span> ( \t )</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{/* 换行符 */}
<FormField name="breaker" className="md:max-w-[calc(160px*3+1rem*2)]" label="换行符" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="grid grid-cols-2 md:grid-cols-3 gap-4">
<FormLabel htmlFor={`${id}-v-newline2`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="13,10" id={`${id}-v-newline2`} className="mr-2"/>
<span> ( \r\n )</span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-newline`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="10" id={`${id}-v-newline`} className="mr-2"/>
<span> ( \n )</span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-newline3`} className="px-3 h-10 border rounded-md flex items-center text-sm">
<RadioGroupItem value="13" id={`${id}-v-newline3`} className="mr-2"/>
<span> ( \r )</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{/* 提取数量 */}
<FormField name="count" className="max-w[160px*2+1rem]" label="提取数量" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<Input
{...field}
id={id}
type="number"
onChange={e => field.onChange(Number(e.target.value))}
className="h-10"
placeholder="输入提取数量"
/>
)}
</FormField>
</div>
)
})
FormFields.displayName = 'FormFields'
function SelectResource() {
const [resources, setResources] = useState<Resource[]>([])
const [status, setStatus] = useStatus()
const getResources = async () => {
setStatus('load')
try {
const resp = await allResource()
if (!resp.success) {
throw new Error('Unable to fetch packages.')
}
console.log(resp.data)
setResources(resp.data)
setStatus('done')
}
catch (error) {
console.error('Error fetching packages:', error)
toast.error('获取套餐失败,请稍后再试')
setStatus('fail')
}
}
useEffect(() => {
getResources().then()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<FormField name="resource" className="md:max-w-[calc(160px*2+1rem)]" label="选择套餐" classNames={{label: 'max-md:text-sm'}}>
{({field}) => (
<Select
value={field.value ? String(field.value) : undefined}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className="min-h-10 h-auto w-full">
<SelectValue placeholder="选择套餐"/>
</SelectTrigger>
<SelectContent>
{status === 'load' ? (
<div className="p-4 flex gap-1 items-center">
<Loader className="animate-spin" size={20}/>
<span>...</span>
</div>
) : resources.length === 0 ? (
<div className="p-4 flex gap-1 items-center">
<Loader className="animate-spin" size={20}/>
<span></span>
</div>
) : resources.map((resource, i) => (
<>
<SelectItem
key={`${resource.id}`}
value={String(resource.id)}
className="p-3">
<div className="flex flex-col gap-2 w-72">
{resource.type === 1 && resource.short.type === 1 && (
<>
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.short.expire, 'yyyy-MM-dd HH:mm')}
</span>
<span>{intlFormatDistance(resource.short.expire, new Date())}</span>
</div>
</>
)}
{resource.type === 1 && resource.short.type === 2 && (
<>
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.short.used}
{' '}
/
{resource.short.quota}
</span>
<span>
{resource.short.quota - resource.short.used}
</span>
</div>
</>
)}
{resource.type === 2 && resource.long.type === 1 && (
<>
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.long.expire, 'yyyy-MM-dd HH:mm')}
</span>
<span>{intlFormatDistance(resource.long.expire, new Date())}</span>
</div>
</>
)}
{resource.type === 2 && resource.long.type === 2 && (
<>
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.long.used}
{' '}
/
{resource.long.quota}
</span>
<span>
{resource.long.quota - resource.long.used}
</span>
</div>
</>
)}
</div>
</SelectItem>
{i < resources.length - 1 && <SelectSeparator className="m-1"/>}
</>
))}
</SelectContent>
</Select>
)}
</FormField>
)
}
function SelectRegion() {
const form = useFormContext<Schema>()
const regionType = form.watch('regionType')
const prov = form.watch('prov')
const city = form.watch('city')
return (
<div className="flex flex-col gap-4 md:max-w-[calc(160px*2+1rem)]">
<FormField name="regionType" label="地区筛选" classNames={{label: 'max-md:text-sm'}}>
{({id, field}) => (
<RadioGroup
onValueChange={(e) => {
field.onChange(e)
if (e === 'unlimited') {
form.setValue('prov', '')
form.setValue('city', '')
}
}}
defaultValue={field.value}
className="flex gap-4"
>
<FormLabel htmlFor={`${id}-v-unlimited`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="unlimited" id={`${id}-v-unlimited`} className="mr-2"/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-specific`} className="px-3 h-10 flex-1 border rounded-md flex items-center text-sm">
<RadioGroupItem value="specific" id={`${id}-v-specific`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{regionType === 'specific' && (
<Combobox
placeholder="请选择地区"
options={cities.options}
value={[prov || '', city || '']}
onChange={(value) => {
form.setValue('prov', value[0])
form.setValue('city', value[1])
}}
/>
)}
</div>
)
}
function ApplyLink() {
const form = useFormContext<Schema>()
const values = form.watch()
const type = useRef<'copy' | 'open'>('open')
const handler = form.handleSubmit(
async (values: z.infer<typeof schema>) => {
const params = link(values)
switch (type.current) {
case 'copy':
const url = new URL(window.location.href).origin
const text = `${url}${params}`
// 使用 clipboard API 复制链接
let copied = false
try {
await navigator.clipboard.writeText(text)
copied = true
}
catch (e) {
console.log('剪贴板 API 调用失败,尝试备选方案')
}
// 使用 document.execCommand 作为备选方案
if (!copied) {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
toast.success('链接已复制到剪贴板')
break
case 'open':
window.open(params, '_blank')
break
}
},
(errors) => {
const desc: (string | undefined)[] = []
Object.entries(errors).forEach(([field, error]) => {
if (error.message) {
desc.push(error.message)
}
})
toast.error('请完成填写:', {
description: desc.map((msg, i) => (
<span key={i}>
-
{msg}
</span>
)),
})
},
)
return (
<div className={merge(
`flex flex-col gap-4`,
`rounded-lg`,
)}>
<h4>API </h4>
{/* 展示链接地址 */}
<div className="bg-secondary p-4 rounded-md break-all">
{link(values)}
</div>
{/* 操作 */}
<div className="flex gap-4">
<Button
type="button"
onClick={async () => {
type.current = 'copy'
await handler()
}}
>
<CopyIcon/>
<span></span>
</Button>
<Button
type="button"
onClick={async () => {
type.current = 'open'
await handler()
}}
>
<ExternalLinkIcon/>
<span></span>
</Button>
</div>
</div>
)
}
function link(values: Schema) {
const {resource, prov, city, isp, proto, authType, distinct, format: formatType, separator, breaker, count} = values
const sp = new URLSearchParams()
if (resource) sp.set('i', String(resource))
if (authType) sp.set('t', authType)
if (proto != 'all') sp.set('x', proto)
if (prov) sp.set('a', prov)
if (city) sp.set('b', city)
if (isp != 'all') sp.set('s', isp)
sp.set('d', distinct)
sp.set('rt', formatType)
sp.set('rs', separator)
sp.set('rb', breaker)
sp.set('n', String(count))
return `/proxies?${sp.toString()}`
}
function name(resource: Resource) {
switch (resource.type) {
case 1:
// 短效套餐
switch (resource.short.type) {
case 1:
return `短效包时 ${resource.short.live / 60} 分钟`
case 2:
return `短效包量 ${resource.short.live / 60} 分钟`
}
break
case 2:
// 长效套餐
switch (resource.long.type) {
case 1:
return `长效包时 ${resource.long.live} 小时`
case 2:
return `长效包量 ${resource.long.live} 小时`
}
break
}
}