完善提取页地区选择功能,添加城市数据和组合框组件

This commit is contained in:
2025-04-15 11:48:31 +08:00
parent 4315c8eba9
commit 3eebe48267
7 changed files with 2107 additions and 59 deletions

View 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>
)
}

View 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,
}