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:
v0
2025-09-23 07:09:47 +00:00
parent 7bb91b447e
commit ce0a716d02
30 changed files with 1682 additions and 2474 deletions

View File

@@ -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>

View File

@@ -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>

View File

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

View File

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

View File

@@ -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,

View File

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