fix: support default and named export for useDebounce hook
Ensure compatibility with both default and named imports for hook. #VERCEL_SKIP Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
This commit is contained in:
@@ -33,14 +33,6 @@ interface ApiEndpoint {
|
||||
authentication: "API Key" | "OAuth 2.0" | "None"
|
||||
}
|
||||
|
||||
function openInNewTab(url: string) {
|
||||
try {
|
||||
window.open(url, "_blank", "noopener,noreferrer")
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
export function ApiDocumentation() {
|
||||
const { toast } = useToast()
|
||||
const [activeTab, setActiveTab] = useState("user-data")
|
||||
@@ -624,19 +616,15 @@ export function ApiDocumentation() {
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 bg-transparent"
|
||||
onClick={() => openInNewTab("/api/openapi?download=1")}
|
||||
>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<FileJson className="h-4 w-4" />
|
||||
下载OpenAPI规范
|
||||
</Button>
|
||||
<Button variant="outline" className="gap-2 bg-transparent" onClick={() => openInNewTab("/api/openapi")}>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
下载SDK
|
||||
</Button>
|
||||
<Button className="gap-2" onClick={() => openInNewTab("/api/ingest")}>
|
||||
<Button className="gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
API测试工具
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
type Row = {
|
||||
id: string
|
||||
@@ -12,38 +12,9 @@ type Row = {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
type Pagination = { page: number; pageSize: number; total: number; totalPages: number }
|
||||
|
||||
// The API may return different shapes; normalize them here.
|
||||
function normalizeResponse(json: any): { items: Row[]; total: number; pagination?: Pagination } {
|
||||
// v2 shape: { success, data: { items, pagination, totalValue } }
|
||||
if (json && json.data && Array.isArray(json.data.items)) {
|
||||
const items = json.data.items as Row[]
|
||||
const total = json.data?.pagination?.total ?? json.total ?? items.length
|
||||
return { items, total, pagination: json.data.pagination }
|
||||
}
|
||||
|
||||
// v1 shape: { data: Row[], pagination }
|
||||
if (json && Array.isArray(json.data)) {
|
||||
const items = json.data as Row[]
|
||||
const total = json.pagination?.total ?? json.total ?? items.length
|
||||
return { items, total, pagination: json.pagination }
|
||||
}
|
||||
|
||||
// shape: { items: Row[], pagination }
|
||||
if (json && Array.isArray(json.items)) {
|
||||
const items = json.items as Row[]
|
||||
const total = json.pagination?.total ?? json.total ?? items.length
|
||||
return { items, total, pagination: json.pagination }
|
||||
}
|
||||
|
||||
// raw array
|
||||
if (Array.isArray(json)) {
|
||||
return { items: json as Row[], total: (json as Row[]).length }
|
||||
}
|
||||
|
||||
// unknown shape
|
||||
return { items: [], total: 0 }
|
||||
type ApiResp = {
|
||||
data: Row[]
|
||||
pagination: { page: number; pageSize: number; total: number; totalPages: number }
|
||||
}
|
||||
|
||||
export default function UserList({ queryString }: { queryString: string }) {
|
||||
@@ -55,26 +26,23 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
useEffect(() => {
|
||||
let aborted = false
|
||||
const controller = new AbortController()
|
||||
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`/api/users?${queryString}`, { signal: controller.signal, cache: "no-store" })
|
||||
const res = await fetch(`/api/users?${queryString}`, { signal: controller.signal, cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
|
||||
const json = await res.json()
|
||||
const { items, total } = normalizeResponse(json)
|
||||
const json: ApiResp = await res.json()
|
||||
if (!aborted) {
|
||||
setData(Array.isArray(items) ? items : [])
|
||||
setTotal(typeof total === "number" ? total : 0)
|
||||
setData(json.data || [])
|
||||
setTotal(json.pagination?.total || 0)
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!aborted) setError(e?.message || "未知错误")
|
||||
if (!aborted) setError(e?.message || '未知错误')
|
||||
} finally {
|
||||
if (!aborted) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
return () => {
|
||||
aborted = true
|
||||
@@ -82,7 +50,7 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
}
|
||||
}, [queryString])
|
||||
|
||||
const rows = useMemo(() => (Array.isArray(data) ? data : []), [data])
|
||||
const rows = useMemo(() => data, [data])
|
||||
|
||||
if (loading) {
|
||||
return <div className="rounded-lg border bg-white p-4">加载中...</div>
|
||||
@@ -119,22 +87,16 @@ export default function UserList({ queryString }: { queryString: string }) {
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(r.tags || []).slice(0, 3).map((t) => (
|
||||
<span key={t} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
|
||||
{t}
|
||||
</span>
|
||||
<span key={t} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">{t}</span>
|
||||
))}
|
||||
{(r.tags || []).length > 3 && (
|
||||
<span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>
|
||||
)}
|
||||
{(r.tags || []).length > 3 && <span className="px-2 py-0.5 rounded-full border">+{r.tags.length - 3}</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!rows.length && (
|
||||
<tr>
|
||||
<td className="px-3 py-8 text-center text-gray-500" colSpan={6}>
|
||||
无匹配数据
|
||||
</td>
|
||||
<td className="px-3 py-8 text-center text-gray-500" colSpan={6}>无匹配数据</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Home, Database, Users, TrendingUp, Bot } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { usePathname } from "next/navigation"
|
||||
|
||||
export default function BottomTabs() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const tabs = [
|
||||
{ href: "/", label: "首页", icon: Home },
|
||||
{ href: "/data-platform", label: "数据中台", icon: Database },
|
||||
{ href: "/user-portrait", label: "用户池", icon: Users }, // 改名为用户池
|
||||
{ href: "/user-assets", label: "用户资产", icon: TrendingUp },
|
||||
{ href: "/ai-assistant", label: "AI助手", icon: Bot },
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="md:hidden fixed bottom-0 inset-x-0 z-40 border-t bg-white/90 backdrop-blur">
|
||||
<ul className="mx-auto grid max-w-full grid-cols-5">
|
||||
{tabs.map((t) => {
|
||||
const active = pathname === t.href || (t.href !== "/" && pathname.startsWith(t.href))
|
||||
const Icon = t.icon
|
||||
return (
|
||||
<li key={t.href}>
|
||||
<Link
|
||||
href={t.href}
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center py-2 text-xs",
|
||||
active ? "text-slate-900" : "text-slate-600",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="mt-0.5 text-[10px]">{t.label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useMemo } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export default function TopNav({ activePath }: { activePath?: string }) {
|
||||
const items = useMemo(
|
||||
() => [
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/data-platform", label: "数据中台" },
|
||||
{ href: "/user-portrait", label: "用户画像" },
|
||||
{ href: "/ai-assistant", label: "AI 助手" },
|
||||
{ href: "/user-valuation", label: "用户资产估值" },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<nav className="flex h-14 items-center gap-2">
|
||||
<div className="mr-4 text-lg font-bold bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-transparent">
|
||||
卡若数据资产中台
|
||||
</div>
|
||||
<ul className="flex items-center gap-1">
|
||||
{items.map((it) => {
|
||||
const active = activePath === it.href || (it.href !== "/" && activePath?.startsWith(it.href))
|
||||
return (
|
||||
<li key={it.href}>
|
||||
<Link
|
||||
href={it.href}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded-md text-sm hover:bg-slate-100",
|
||||
active && "bg-slate-900 text-white hover:bg-slate-900",
|
||||
)}
|
||||
>
|
||||
{it.label}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
import * as React from 'react'
|
||||
import { type DialogProps } from '@radix-ui/react-dialog'
|
||||
import { Command as CommandPrimitive } from 'cmdk'
|
||||
import { Search } from 'lucide-react'
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog'
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
@@ -15,8 +15,8 @@ const Command = React.forwardRef<
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -44,8 +44,8 @@ const CommandInput = React.forwardRef<
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -60,7 +60,7 @@ const CommandList = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -87,8 +87,8 @@ const CommandGroup = React.forwardRef<
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -102,7 +102,7 @@ const CommandSeparator = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -131,14 +131,14 @@ const CommandShortcut = ({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
'ml-auto text-xs tracking-widest text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
CommandShortcut.displayName = 'CommandShortcut'
|
||||
|
||||
export {
|
||||
Command,
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { useEffect, useState } from "react"
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
export type FilterValues = {
|
||||
tags: string[]
|
||||
status: string[]
|
||||
status: Array<"活跃" | "沉睡" | "已封禁">
|
||||
rfm: [number, number]
|
||||
}
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
allTags: string[]
|
||||
@@ -21,122 +21,131 @@ type Props = {
|
||||
onApply: (v: FilterValues) => void
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = ["活跃", "沉睡", "已封禁"] as const
|
||||
|
||||
export default function FilterDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
allTags,
|
||||
value,
|
||||
onApply,
|
||||
}: Props) {
|
||||
export default function FilterDrawer({ open, onOpenChange, allTags, value, onApply }: Props) {
|
||||
const [local, setLocal] = useState<FilterValues>(value)
|
||||
|
||||
// 同步外部变更
|
||||
useMemo(() => setLocal(value), [value])
|
||||
useEffect(() => setLocal(value), [value, open])
|
||||
|
||||
const toggleArrayVal = (arr: string[], val: string, checked: boolean) =>
|
||||
checked ? Array.from(new Set([...arr, val])) : arr.filter((x) => x !== val)
|
||||
const toggleTag = (t: string, checked: boolean) => {
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
tags: checked ? Array.from(new Set([...prev.tags, t])) : prev.tags.filter((x) => x !== t),
|
||||
}))
|
||||
}
|
||||
|
||||
const toggleStatus = (s: "活跃" | "沉睡" | "已封禁", checked: boolean) => {
|
||||
setLocal((prev) => ({
|
||||
...prev,
|
||||
status: checked ? Array.from(new Set([...prev.status, s])) : prev.status.filter((x) => x !== s),
|
||||
}))
|
||||
}
|
||||
|
||||
const apply = () => {
|
||||
onApply(local)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
const init: FilterValues = { tags: [], status: [], rfm: [0, 100] }
|
||||
setLocal(init)
|
||||
onApply(init)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>筛选</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className={`fixed inset-0 z-50 ${open ? "" : "pointer-events-none"} aria-modal`}
|
||||
role="dialog"
|
||||
aria-hidden={!open}
|
||||
>
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 transition-opacity ${open ? "opacity-100" : "opacity-0"}`}
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<aside
|
||||
className={`absolute right-0 top-0 h-full w-full max-w-md bg-white shadow-xl transition-transform duration-300
|
||||
${open ? "translate-x-0" : "translate-x-full"}`}
|
||||
aria-label="筛选"
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<h2 className="text-lg font-semibold">筛选</h2>
|
||||
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} aria-label="关闭筛选">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 py-2">
|
||||
<section className="space-y-2">
|
||||
<Label>用户状态</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{STATUS_OPTIONS.map((s) => {
|
||||
const checked = local.status.includes(s)
|
||||
return (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(ck) =>
|
||||
setLocal((p) => ({ ...p, status: toggleArrayVal(p.status, s, !!ck) }))
|
||||
}
|
||||
/>
|
||||
<span>{s}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
<div className="p-4 space-y-6 overflow-y-auto h-[calc(100%-120px)]">
|
||||
{/* RFM 区间 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">RFM 区间</h3>
|
||||
<div className="grid grid-cols-2 gap-2 items-center">
|
||||
<div>
|
||||
<Label htmlFor="rfmMin" className="text-xs">最小值</Label>
|
||||
<Input
|
||||
id="rfmMin"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[0]}
|
||||
onChange={(e) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value) || 0))
|
||||
setLocal((p) => ({ ...p, rfm: [Math.min(v, p.rfm[1]), p.rfm[1]] }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="rfmMax" className="text-xs">最大值</Label>
|
||||
<Input
|
||||
id="rfmMax"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[1]}
|
||||
onChange={(e) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value) || 100))
|
||||
setLocal((p) => ({ ...p, rfm: [p.rfm[0], Math.max(v, p.rfm[0])] }))
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<Label>RFM 分数范围</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[0]}
|
||||
aria-label="RFM最小值"
|
||||
onChange={(e) =>
|
||||
setLocal((p) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value)))
|
||||
return { ...p, rfm: [v, Math.max(v, p.rfm[1])] }
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-muted-foreground">{'—'}</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={local.rfm[1]}
|
||||
aria-label="RFM最大值"
|
||||
onChange={(e) =>
|
||||
setLocal((p) => {
|
||||
const v = Math.max(0, Math.min(100, Number(e.target.value)))
|
||||
return { ...p, rfm: [Math.min(p.rfm[0], v), v] }
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* 状态 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">状态</h3>
|
||||
<div className="grid gap-2">
|
||||
{(["活跃", "沉睡", "已封禁"] as const).map((s) => (
|
||||
<label key={s} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={local.status.includes(s)} onCheckedChange={(ck) => toggleStatus(s, Boolean(ck))} />
|
||||
<span>{s}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<Label>标签</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-auto pr-1">
|
||||
{allTags.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground col-span-2">暂无标签</div>
|
||||
)}
|
||||
{allTags.map((t) => {
|
||||
const checked = local.tags.includes(t)
|
||||
return (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={(ck) =>
|
||||
setLocal((p) => ({ ...p, tags: toggleArrayVal(p.tags, t, !!ck) }))
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
{/* 标签 */}
|
||||
<section>
|
||||
<h3 className="text-sm font-medium mb-3">标签</h3>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-56 overflow-auto">
|
||||
{allTags.map((t) => (
|
||||
<label key={t} className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={local.tags.includes(t)} onCheckedChange={(ck) => toggleTag(t, Boolean(ck))} />
|
||||
<span className="truncate">{t}</span>
|
||||
</label>
|
||||
))}
|
||||
{!allTags.length && <div className="text-xs text-muted-foreground col-span-2">暂无标签数据</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onApply(local)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
应用筛选
|
||||
</Button>
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t flex items-center justify-between gap-2">
|
||||
<Button variant="outline" onClick={reset}>重置</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={apply}>应用</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user