完善提取页地区选择功能,添加城市数据和组合框组件
This commit is contained in:
1670
src/components/composites/extract/_assets/cities.json
Normal file
1670
src/components/composites/extract/_assets/cities.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,10 +14,10 @@ import {useStatus} from '@/lib/states'
|
||||
import {allResource} from '@/actions/resource'
|
||||
import {Resource, name} from '@/lib/models'
|
||||
import {format, intlFormatDistance} from 'date-fns'
|
||||
import {usePathname} from 'next/navigation'
|
||||
import {toast} from 'sonner'
|
||||
import {merge} from '@/lib/utils'
|
||||
|
||||
import {Combobox} from '@/components/ui/combobox'
|
||||
import cities from './_assets/cities.json'
|
||||
|
||||
type ExtractProps = {
|
||||
className?: string
|
||||
@@ -34,7 +34,7 @@ export default function Extract(props: ExtractProps) {
|
||||
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: '请选择认证方式'}),
|
||||
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: '请选择分隔符'}),
|
||||
@@ -78,8 +78,10 @@ export default function Extract(props: ExtractProps) {
|
||||
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)
|
||||
@@ -88,7 +90,7 @@ export default function Extract(props: ExtractProps) {
|
||||
sp.set('n', String(count))
|
||||
|
||||
return `/proxies?${sp.toString()}`
|
||||
}, [resource, prov, city, isp, proto, distinct, formatType, separator, breaker, count])
|
||||
}, [resource, authType, proto, isp, distinct, formatType, separator, breaker, count, prov, city])
|
||||
|
||||
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||
console.log(values)
|
||||
@@ -112,8 +114,17 @@ export default function Extract(props: ExtractProps) {
|
||||
|
||||
useEffect(() => {
|
||||
getResources().then()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// ======================
|
||||
// form 中间变量
|
||||
// ======================
|
||||
|
||||
// ======================
|
||||
// render
|
||||
// ======================
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
@@ -166,7 +177,7 @@ export default function Extract(props: ExtractProps) {
|
||||
</div>
|
||||
) : resources.map((resource, i) => (<>
|
||||
<SelectItem
|
||||
key={`${resource.id}`} value={String(resource.id)} className={`p-3`}>
|
||||
key={`${resource.id}-${i}`} 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`}>
|
||||
@@ -204,7 +215,13 @@ export default function Extract(props: ExtractProps) {
|
||||
<FormField name="regionType" label={`地区筛选`}>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(e) => {
|
||||
field.onChange(e)
|
||||
if (e === 'unlimited') {
|
||||
form.setValue('prov', '')
|
||||
form.setValue('city', '')
|
||||
}
|
||||
}}
|
||||
defaultValue={field.value}
|
||||
className="flex gap-4"
|
||||
>
|
||||
@@ -221,51 +238,16 @@ export default function Extract(props: ExtractProps) {
|
||||
</FormField>
|
||||
|
||||
{regionType === 'specific' && (
|
||||
<div className="flex gap-4">
|
||||
<FormField name="prov" label="">
|
||||
{({field}) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="选择省份"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bj">北京</SelectItem>
|
||||
<SelectItem value="sh">上海</SelectItem>
|
||||
<SelectItem value="gd">广东</SelectItem>
|
||||
{/* 更多省份... */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField name="city" label="">
|
||||
{({field}) => (
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
disabled={!form.watch('prov')}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="选择城市"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{form.watch('prov') === 'bj' && <SelectItem value="bj01">北京市</SelectItem>}
|
||||
{form.watch('prov') === 'sh' && <SelectItem value="sh01">上海市</SelectItem>}
|
||||
{form.watch('prov') === 'gd' && (
|
||||
<>
|
||||
<SelectItem value="gz">广州</SelectItem>
|
||||
<SelectItem value="sz">深圳</SelectItem>
|
||||
</>
|
||||
)}
|
||||
{/* 更多城市... */}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
<Combobox
|
||||
className={`w-84`}
|
||||
placeholder={`请选择地区`}
|
||||
options={cities.options}
|
||||
value={[prov || '', city || '']}
|
||||
onChange={value => {
|
||||
form.setValue('prov', value[0])
|
||||
form.setValue('city', value[1])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -463,7 +445,7 @@ export default function Extract(props: ExtractProps) {
|
||||
`border-t`,
|
||||
)}>
|
||||
{/* 展示链接地址 */}
|
||||
<div className={`bg-card text-card-foreground p-4 rounded-md`}>
|
||||
<div className={`bg-card text-card-foreground p-4 rounded-md break-all`}>
|
||||
{params}
|
||||
</div>
|
||||
|
||||
@@ -472,6 +454,7 @@ export default function Extract(props: ExtractProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
if (!form.formState.isValid) return
|
||||
const url = new URL(window.location.href).origin
|
||||
await navigator.clipboard.writeText(`${url}${params}`)
|
||||
toast.success('链接已复制到剪贴板')
|
||||
@@ -483,7 +466,9 @@ export default function Extract(props: ExtractProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={async () => {
|
||||
if (!form.formState.isValid) return
|
||||
window.open(params, '_blank')
|
||||
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon/>
|
||||
|
||||
193
src/components/ui/combobox.tsx
Normal file
193
src/components/ui/combobox.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {CheckIcon, ChevronsUpDown} from 'lucide-react'
|
||||
|
||||
import {merge} from '@/lib/utils'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {ReactNode, useRef, useState} from 'react'
|
||||
import {Input} from '@/components/ui/input'
|
||||
|
||||
type ComboboxItem = {
|
||||
value: string
|
||||
label: ReactNode
|
||||
alias?: string[]
|
||||
children?: ComboboxItem[]
|
||||
labelBuild?: (item: ComboboxItem) => string
|
||||
}
|
||||
|
||||
type ComboboxProps = {
|
||||
className?: string
|
||||
placeholder?: string
|
||||
options: ComboboxItem[]
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function Combobox(props: ComboboxProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const getLabel = (item: ComboboxItem) => {
|
||||
return typeof item.label === 'object' || typeof item.label === 'function'
|
||||
? item.labelBuild?.(item)
|
||||
: `${item?.label}`
|
||||
}
|
||||
|
||||
let items: ComboboxItem[] | undefined = props.options
|
||||
const label: string[] = []
|
||||
const values: string[] = []
|
||||
props.value?.forEach(value => {
|
||||
if (items) {
|
||||
const curr = items.find(item => item.value === value)
|
||||
if (curr) {
|
||||
items = curr.children
|
||||
values.push(curr.value)
|
||||
label.push(getLabel(curr) || '-')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const [wait, setWait] = useState(false)
|
||||
const [filter, setFilter] = useState<string>('')
|
||||
const [filtered, setFiltered] = useState<ComboboxItem[]>([])
|
||||
|
||||
const onFilter = async () => {
|
||||
if (wait) return
|
||||
const cond = filter.trim()
|
||||
console.log('onFilter', cond)
|
||||
setWait(true)
|
||||
if (cond.length > 0) {
|
||||
setFiltered(mapFilter(JSON.parse(JSON.stringify(props.options)), cond))
|
||||
}
|
||||
else {
|
||||
setFiltered(props.options)
|
||||
}
|
||||
console.log('onFilter end')
|
||||
setWait(false)
|
||||
}
|
||||
|
||||
const mapFilter = (items: ComboboxItem[], cond: string): ComboboxItem[] => {
|
||||
const nItems: ComboboxItem[] = []
|
||||
items.forEach(item => {
|
||||
const label = getLabel(item)
|
||||
const match = label?.includes(cond)
|
||||
|
||||
if (item.children?.length) {
|
||||
item.children = mapFilter(item.children, cond)
|
||||
}
|
||||
|
||||
if (match || item.children?.length) {
|
||||
nItems.push(item)
|
||||
}
|
||||
})
|
||||
return nItems
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={(status) => {
|
||||
setOpen(status)
|
||||
if (status) {
|
||||
setFiltered(props.options)
|
||||
setFilter('')
|
||||
}
|
||||
}}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
theme="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={merge(
|
||||
`flex justify-between`,
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{label.length
|
||||
? <span className={`text-sm`}>{label.join('/')}</span>
|
||||
: <span className={`text-sm text-weak`}>{props.placeholder}</span>
|
||||
}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50"/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={`p-0 rounded-lg h-[var(--radix-popover-content-available-height)] flex flex-col overflow-hidden`}
|
||||
align={`start`}
|
||||
collisionPadding={6}
|
||||
>
|
||||
<div className={`p-2 flex gap-2 flex-none`}>
|
||||
<Input
|
||||
className={`h-9 placeholder:text-weak placeholder:text-sm`}
|
||||
placeholder={`搜索地区`}
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
/>
|
||||
<Button className={`h-9`} onClick={onFilter} disabled={wait}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
<div className={`flex-auto overflow-auto p-2 pt-0`}>
|
||||
<OptionList options={filtered} value={values} onChange={value => {
|
||||
console.log(value.map(item => item.value))
|
||||
props.onChange?.(value.map(item => item.value))
|
||||
setOpen(false)
|
||||
}}/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionList(props: {
|
||||
options: ComboboxItem[]
|
||||
value: string[]
|
||||
onChange: (value: ComboboxItem[]) => void
|
||||
path?: ComboboxItem[]
|
||||
depth?: number
|
||||
}) {
|
||||
const depth = props.depth || 0
|
||||
const parent = props.path || []
|
||||
const indent = depth * 16
|
||||
|
||||
return (
|
||||
<ul style={{
|
||||
marginLeft: `${indent}px`,
|
||||
}}>
|
||||
{props.options.map((item, i) => {
|
||||
const path = [...parent, item]
|
||||
const pathValue = path.map((item) => item.value)
|
||||
const equal = pathValue.join(`.`) === props.value?.join('.')
|
||||
return (
|
||||
<li key={i}>
|
||||
<OptionItem key={`${i}`} item={item} active={equal} onChange={() => props.onChange?.(path)}/>
|
||||
{item.children?.length &&
|
||||
<OptionList depth={depth + 1} options={item.children} value={props.value} path={path} onChange={props.onChange}/>
|
||||
}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function OptionItem(props: {
|
||||
key: string
|
||||
item: ComboboxItem
|
||||
active: boolean
|
||||
onChange?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className={merge(
|
||||
`transition-colors, duration-100 ease-in-out`,
|
||||
`px-4 py-2 text-muted-foreground rounded-md`,
|
||||
`flex justify-between items-center`,
|
||||
`hover:bg-muted hover:text-foreground`,
|
||||
)} onClick={props.onChange}>
|
||||
{props.item.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
177
src/components/ui/command.tsx
Normal file
177
src/components/ui/command.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { merge } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={merge(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={merge(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={merge(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={merge(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={merge("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={merge(
|
||||
"data-[selected=true]:bg-muted data-[selected=true]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={merge(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
Reference in New Issue
Block a user