重构项目结构,将数据层集中在 lib 包中;resource 类型更新,支持多个子套餐类型分别表示;新增长效套餐的购买流程,以及已购查询功能

This commit is contained in:
2025-05-22 14:59:22 +08:00
parent 9652181fe4
commit dc83c83cfb
29 changed files with 1827 additions and 1143 deletions

View File

@@ -6,44 +6,41 @@ 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} from 'react-hook-form'
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 {useEffect, useMemo, useRef, useState} from 'react'
import {memo, useEffect, useRef, useState} from 'react'
import {useStatus} from '@/lib/states'
import {allResource} from '@/actions/resource'
import {Resource, name} from '@/lib/models'
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'
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 [resources, setResources] = useState<Resource[]>([])
const [status, setStatus] = useStatus()
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>
const form = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: {
@@ -59,74 +56,233 @@ export default function Extract(props: ExtractProps) {
},
})
const regionType = form.watch('regionType')
// ======================
// render
// ======================
const resource = form.watch('resource')
const prov = form.watch('prov')
const city = form.watch('city')
const isp = form.watch('isp')
const proto = form.watch('proto')
const authType = form.watch('authType')
const distinct = form.watch('distinct')
const formatType = form.watch('format')
const separator = form.watch('separator')
const breaker = form.watch('breaker')
const count = form.watch('count')
return (
<Form form={form} className={merge(
`bg-white flex flex-col gap-4 rounded-md`,
props.className,
)}
>
<Alert variant={`warn`}>
<CircleAlert/>
<AlertTitle>IP前需要将本机IP添加到白名单后才可使用</AlertTitle>
</Alert>
const params = useMemo(() => {
const sp = new URLSearchParams()
if (resource) sp.set('i', String(resource))
if (authType) sp.set('t', authType)
if (proto != 'all') sp.set('x', proto)
<FormFields/>
if (prov) sp.set('a', prov)
if (city) sp.set('b', city)
<ApplyLink/>
</Form>
)
}
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))
const FormFields = memo(() => {
return (
<div className={`flex flex-col gap-4`}>
{/* 选择套餐 */}
<SelectResource/>
return `/proxies?${sp.toString()}`
}, [resource, authType, proto, isp, distinct, formatType, separator, breaker, count, prov, city])
const type = useRef<'copy' | 'open'>('open')
const onSubmit = async (values: z.infer<typeof schema>) => {
switch (type.current) {
case 'copy':
const url = new URL(window.location.href).origin
const text = `${url}${params}`
{/* 地区筛选 */}
<SelectRegion/>
// 使用 clipboard API 复制链接
let copied = false
try {
await navigator.clipboard.writeText(text)
copied = true
}
catch (e) {
console.log('剪贴板 API 调用失败,尝试备选方案')
}
{/* 运营商筛选 */}
<div className="flex items-center">
<FormField name="isp" label={`运营商筛选`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-all`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 w-40 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 w-40 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 w-40 text-sm`}>
<RadioGroupItem value="3" id={`${id}-v-unicom`}/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
// 使用 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)
}
{/* 协议类型 */}
<div className="flex items-center">
<FormField name="proto" label={`协议类型`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-all`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 w-40 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 w-40 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 w-40 text-sm`}>
<RadioGroupItem value="3" id={`${id}-v-socks5`} className="mr-2"/>
<span>SOCKS5</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
toast.success('链接已复制到剪贴板')
break
case 'open':
window.open(params, '_blank')
break
}
}
{/* 认证方式 */}
<div className="flex items-center">
<FormField name="authType" label={`协议类型`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-http`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 去重选项 */}
<div className="flex items-center">
<FormField name="distinct" label={`去重选项`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-true`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="0" id={`${id}-v-false`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 导出格式 */}
<div className="flex items-center">
<FormField name="format" label={`导出格式`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
>
<FormLabel htmlFor={`${id}-v-txt`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="json" id={`${id}-v-json`} className="mr-2"/>
<span>JSON </span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 分隔符 */}
<div className="flex items-center">
<FormField name="separator" label={`分隔符`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-comma`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 w-40 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 w-40 text-sm`}>
<RadioGroupItem value="9" id={`${id}-v-space`} className="mr-2"/>
<span> ( \t )</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 换行符 */}
<div className="flex items-center">
<FormField name="breaker" label={`换行符`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-newline2`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 w-40 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 w-40 text-sm`}>
<RadioGroupItem value="13" id={`${id}-v-newline3`} className="mr-2"/>
<span> ( \r )</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 提取数量 */}
<div className="flex items-center">
<FormField name="count" label={`提取数量`}>
{({id, field}) => (
<Input
{...field}
id={id}
type="number"
onChange={e => field.onChange(Number(e.target.value))}
className="h-10 w-84"
placeholder="输入提取数量"
/>
)}
</FormField>
</div>
</div>
)
})
FormFields.displayName = 'FormFields'
function SelectResource() {
const [resources, setResources] = useState<Resource[]>([])
const [status, setStatus] = useStatus()
const getResources = async () => {
setStatus('load')
try {
@@ -134,11 +290,13 @@ export default function Extract(props: ExtractProps) {
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')
}
}
@@ -148,352 +306,267 @@ export default function Extract(props: ExtractProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ======================
// render
// ======================
return (
<Form
form={form}
onSubmit={onSubmit}
onError={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>
)),
})
}}
className={merge(
`bg-white flex flex-col gap-4 rounded-md`,
props.className,
)}
>
<Alert variant={`warn`}>
<CircleAlert/>
<AlertTitle>IP前需要将本机IP添加到白名单后才可使用</AlertTitle>
</Alert>
<div className={`flex flex-col gap-4`}>
{/* 选择套餐 */}
<div className="flex items-center">
<FormField name="resource" label={`选择套餐`}>
{({field}) => (
<Select
value={field.value ? String(field.value) : undefined}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className={`min-h-10 h-auto w-84`}>
<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>
</>)}
<div className="flex items-center">
<FormField name="resource" label={`选择套餐`}>
{({field}) => (
<Select
value={field.value ? String(field.value) : undefined}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className={`min-h-10 h-auto w-84`}>
<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>
</SelectItem>
{i < resources.length - 1 && <SelectSeparator className={`m-2`}/>}
</>))
}
</SelectContent>
</Select>
)}
</FormField>
</div>
{/* 地区筛选 */}
<div className="flex flex-col gap-4">
<FormField name="regionType" label={`地区筛选`}>
{({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 border rounded-md flex items-center w-40 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 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="specific" id={`${id}-v-specific`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{regionType === 'specific' && (
<Combobox
className={`w-84`}
placeholder={`请选择地区`}
options={cities.options}
value={[prov || '', city || '']}
onChange={value => {
form.setValue('prov', value[0])
form.setValue('city', value[1])
}}
/>
)}
</div>
{/* 运营商筛选 */}
<div className="flex items-center">
<FormField name="isp" label={`运营商筛选`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-all`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 w-40 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 w-40 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 w-40 text-sm`}>
<RadioGroupItem value="3" id={`${id}-v-unicom`}/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 协议类型 */}
<div className="flex items-center">
<FormField name="proto" label={`协议类型`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-all`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 w-40 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 w-40 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 w-40 text-sm`}>
<RadioGroupItem value="3" id={`${id}-v-socks5`} className="mr-2"/>
<span>SOCKS5</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 认证方式 */}
<div className="flex items-center">
<FormField name="authType" label={`协议类型`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-http`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 去重选项 */}
<div className="flex items-center">
<FormField name="distinct" label={`去重选项`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-true`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="0" id={`${id}-v-false`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 导出格式 */}
<div className="flex items-center">
<FormField name="format" label={`导出格式`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
>
<FormLabel htmlFor={`${id}-v-txt`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="json" id={`${id}-v-json`} className="mr-2"/>
<span>JSON </span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 分隔符 */}
<div className="flex items-center">
<FormField name="separator" label={`分隔符`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-comma`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 w-40 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 w-40 text-sm`}>
<RadioGroupItem value="9" id={`${id}-v-space`} className="mr-2"/>
<span> ( \t )</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 换行符 */}
<div className="flex items-center">
<FormField name="breaker" label={`换行符`}>
{({id, field}) => (
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4">
<FormLabel htmlFor={`${id}-v-newline2`} className={`px-3 h-10 border rounded-md flex items-center w-40 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 w-40 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 w-40 text-sm`}>
<RadioGroupItem value="13" id={`${id}-v-newline3`} className="mr-2"/>
<span> ( \r )</span>
</FormLabel>
</RadioGroup>
)}
</FormField>
</div>
{/* 提取数量 */}
<div className="flex items-center">
<FormField name="count" label={`提取数量`}>
{({id, field}) => (
<Input
{...field}
id={id}
type="number"
onChange={e => field.onChange(Number(e.target.value))}
className="h-10 w-84"
placeholder="输入提取数量"
/>
)}
</FormField>
</div>
</div>
<div className={merge(
`flex flex-col gap-4 sticky bottom-0 bg-muted p-4`,
`rounded-lg`,
)}>
{/* 展示链接地址 */}
<div className={`bg-neutral-900 text-white p-4 rounded-md break-all`}>
{params}
</div>
{/* 操作 */}
<div className="flex gap-4">
<Button
type="submit"
onClick={() => type.current = 'copy'}
>
<CopyIcon/>
<span></span>
</Button>
<Button
type="submit"
onClick={() => type.current = 'open'}
>
<ExternalLinkIcon/>
<span></span>
</Button>
</div>
</div>
</Form>
<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-2`}/>}
</>))}
</SelectContent>
</Select>
)}
</FormField>
</div>
)
}
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">
<FormField name="regionType" label={`地区筛选`}>
{({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 border rounded-md flex items-center w-40 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 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="specific" id={`${id}-v-specific`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{regionType === 'specific' && (
<Combobox
className={`w-84`}
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 sticky bottom-0 bg-muted p-4`,
`rounded-lg`,
)}>
{/* 展示链接地址 */}
<div className={`bg-neutral-900 text-white 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
}
}