Files
Nexus/frontend-v2/src/features/dashboard/components/GlobalSearchPage.tsx
T
2026-07-08 22:31:31 +08:00

267 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useSuspenseQuery } from '@tanstack/react-query'
import { ArrowUpRight, BookOpen, Database, Filter, KeyRound, Search, Server, ShieldCheck, Sparkles, TimerReset } from 'lucide-react'
import { Badge } from '~components/ui/Badge'
import { Panel } from '~components/ui/Panel'
import { hasSensitiveMarker } from '@/lib/redaction'
import { cn } from '@/lib/utils'
import { fetchGlobalSearchData } from '../api/dashboardApi'
import type { GlobalSearchData, GlobalSearchEntity, GlobalSearchItem } from '../api/dashboardApi'
import type { ServerAsset } from '../data/dashboardData'
interface GlobalSearchPageProps {
servers: ServerAsset[]
total: number
mode: 'live' | 'fallback'
}
type SearchGroupKey = 'servers' | 'scripts' | 'credentials' | 'schedules'
type Tone = 'green' | 'blue' | 'amber' | 'red' | 'slate' | 'purple'
interface SearchGroupMeta {
key: SearchGroupKey
title: string
subtitle: string
icon: typeof Server
tone: Tone
legacyPath: string
}
const groups: SearchGroupMeta[] = [
{ key: 'servers', title: '服务器', subtitle: '名称、域名、分组、在线状态', icon: Server, tone: 'blue', legacyPath: '/app/servers' },
{ key: 'scripts', title: '脚本库', subtitle: '脚本名称、分类、描述', icon: BookOpen, tone: 'purple', legacyPath: '/app/scripts' },
{ key: 'credentials', title: '凭据元数据', subtitle: '只显示名称、类型、主机,不展示密码', icon: KeyRound, tone: 'amber', legacyPath: '/app/settings' },
{ key: 'schedules', title: '计划任务', subtitle: '计划名称、Cron 表达式、启停状态', icon: TimerReset, tone: 'green', legacyPath: '/app/tasks' },
]
function normalizeQuery(value: string): string {
return value.trim().replace(/\s+/g, ' ').slice(0, 100)
}
function itemTitle(item: GlobalSearchItem, entity: GlobalSearchEntity): string {
if (entity === 'servers') return String(item.name || item.domain || '未命名服务器')
if (entity === 'scripts') return String(item.name || '未命名脚本')
if (entity === 'credentials') return String(item.name || '未命名凭据')
return String(item.name || '未命名计划')
}
function itemSubtitle(item: GlobalSearchItem, entity: GlobalSearchEntity): string {
if (entity === 'servers') return [item.domain, item.category, item.is_online ? '在线' : '离线/未知'].filter(Boolean).join(' · ')
if (entity === 'scripts') return [item.category, '只读跳转'].filter(Boolean).join(' · ')
if (entity === 'credentials') return [item.db_type, item.host ? '主机:' + String(item.host) : undefined, '不展示敏感值'].filter(Boolean).join(' · ')
return [item.cron_expr, item.enabled === true ? '已启用' : item.enabled === false ? '已停用' : undefined].filter(Boolean).join(' · ')
}
function itemRouteHint(entity: GlobalSearchEntity, item: GlobalSearchItem): string {
if (entity === 'servers') return '/app/servers' + (item.id ? '?highlight=' + encodeURIComponent(String(item.id)) : '')
if (entity === 'scripts') return '/app/scripts'
if (entity === 'credentials') return '/app/settings'
return '/app/tasks'
}
function visibleItems(data: GlobalSearchData, key: SearchGroupKey): GlobalSearchItem[] {
return data[key].slice(0, 10)
}
function queryFingerprint(value: string): string {
let hash = 2166136261
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index)
hash = Math.imul(hash, 16777619)
}
return value.length + ':' + (hash >>> 0).toString(36)
}
export function GlobalSearchPage({ servers, total, mode }: GlobalSearchPageProps): JSX.Element {
const [rawQuery, setRawQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const normalizedQuery = normalizeQuery(debouncedQuery)
const blockedQuery = hasSensitiveMarker(normalizedQuery)
const normalizedQueryKey = blockedQuery ? 'blocked-sensitive-query' : queryFingerprint(normalizedQuery)
useEffect(() => {
const handle = window.setTimeout(() => setDebouncedQuery(rawQuery), 350)
return () => window.clearTimeout(handle)
}, [rawQuery])
const { data } = useSuspenseQuery({
queryKey: ['global-search', normalizedQueryKey],
queryFn: () => fetchGlobalSearchData(blockedQuery ? '' : normalizedQuery),
staleTime: 0,
gcTime: 0,
})
const localMatches = useMemo(() => {
const query = normalizeQuery(rawQuery).toLowerCase()
if (query.length < 2 || hasSensitiveMarker(query)) return []
return servers
.filter((server) => [server.name, server.ip, server.region, server.risk, server.ttl].some((value) => String(value || '').toLowerCase().includes(query)))
.slice(0, 8)
}, [rawQuery, servers])
const hasQuery = normalizedQuery.length >= 2 && !blockedQuery
const hasServerFallback = localMatches.length > 0 && visibleItems(data, 'servers').length === 0
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLInputElement>): void => {
setRawQuery(event.target.value)
}, [])
return (
<div id="global-search" className="space-y-6 px-5 pb-5 lg:px-10 lg:pb-10">
<Panel className="relative overflow-hidden p-6 lg:p-8">
<div className="absolute -right-20 -top-20 h-72 w-72 rounded-full bg-cyan-400/10 blur-3xl" />
<div className="relative grid gap-6 xl:grid-cols-[minmax(0,1fr)_24rem]">
<div>
<Badge tone="blue" dot className="px-4 py-2">全局只读搜索</Badge>
<h1 className="mt-5 text-3xl font-black tracking-tight text-white md:text-4xl">跨服务器 / 脚本 / 凭据元数据 / 计划任务快速定位</h1>
<p className="mt-3 max-w-3xl text-sm font-semibold leading-7 text-slate-400">
复用后端 <span className="font-mono text-cyan-200">GET /api/search/</span>,输入防通配符膨胀由后端 escape 兜底。V2 当前只读展示搜索结果,不展示密码、CookieToken、密钥类字段、私钥或宝塔 tmp_login
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<label className="focus-within:ring-cyan-300/40 flex min-h-14 flex-1 items-center gap-3 rounded-3xl border border-white/10 bg-slate-950/70 px-5 ring-0 transition focus-within:ring-4">
<Search className="h-5 w-5 text-cyan-300" />
<input
value={rawQuery}
onChange={handleQueryChange}
placeholder="搜索 IP、服务器名、脚本、凭据名称、计划任务..."
className="h-12 flex-1 bg-transparent text-sm font-bold text-white outline-none placeholder:text-slate-600"
/>
</label>
<a href="/app/" className="focus-ring inline-flex items-center justify-center gap-2 rounded-3xl border border-white/10 bg-white/[0.04] px-5 py-4 text-sm font-black text-cyan-100 hover:bg-cyan-400/10">
<ArrowUpRight className="h-4 w-4" /> 旧后台完整搜索
</a>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
<MetricCard icon={Sparkles} label="搜索模式" value={data.mode === 'live' ? '真实 API' : '演示兜底'} tone="blue" />
<MetricCard icon={Database} label="结果总数" value={String(data.total)} tone="green" />
<MetricCard icon={ShieldCheck} label="资产来源" value={mode === 'live' ? String(total) + ' 台' : '演示数据'} tone="purple" />
</div>
</div>
</Panel>
{data.warning ? <Notice tone="amber" title="已切换演示数据" message={data.warning} /> : null}
{!hasQuery ? <Notice tone="blue" title="输入提示" message="请输入至少 2 个字符开始搜索;前端会做 350ms 防抖,避免每次按键都请求后端。" /> : null}
{hasServerFallback ? <Notice tone="green" title="本地资产命中" message="后端搜索没有返回服务器结果,但 V2 当前已加载资产表中有匹配项,下面会单独显示本地命中。" /> : null}
{hasServerFallback ? (
<Panel className="p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h2 className="text-xl font-black text-white">本地已加载服务器命中</h2>
<p className="mt-1 text-sm font-semibold text-slate-500">来自当前 Dashboard 资产缓存,只读展示,不触发写操作。</p>
</div>
<Badge tone="blue">{localMatches.length} </Badge>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{localMatches.map((server) => (
<div key={server.id} className="rounded-3xl border border-white/10 bg-white/[0.03] p-4">
<div className="font-black text-white">{server.name}</div>
<div className="mt-1 font-mono text-xs text-slate-500">{server.ip}</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge tone={server.online ? 'green' : 'slate'}>{server.online ? '在线' : '离线/未知'}</Badge>
<Badge tone="slate">{server.region}</Badge>
</div>
</div>
))}
</div>
</Panel>
) : null}
<section className="grid gap-5 xl:grid-cols-2">
{groups.map((group) => (
<SearchGroupPanel key={group.key} group={group} items={visibleItems(data, group.key)} query={normalizedQuery} />
))}
</section>
</div>
)
}
interface MetricCardProps {
icon: typeof Search
label: string
value: string
tone: Tone
}
function MetricCard({ icon: Icon, label, value, tone }: MetricCardProps): JSX.Element {
const toneClass = tone === 'green'
? 'from-emerald-400/20 to-emerald-400/5 text-emerald-200'
: tone === 'purple'
? 'from-violet-400/20 to-violet-400/5 text-violet-200'
: 'from-cyan-400/20 to-cyan-400/5 text-cyan-200'
return (
<div className={cn('rounded-3xl border border-white/10 bg-gradient-to-br p-5', toneClass)}>
<Icon className="h-5 w-5" />
<div className="mt-4 text-xs font-black uppercase tracking-[0.2em] text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-black text-white">{value}</div>
</div>
)
}
interface NoticeProps {
tone: Tone
title: string
message: string
}
function Notice({ tone, title, message }: NoticeProps): JSX.Element {
const className = tone === 'green'
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-100'
: tone === 'blue'
? 'border-cyan-400/20 bg-cyan-400/10 text-cyan-100'
: 'border-amber-400/20 bg-amber-400/10 text-amber-100'
return (
<div className={cn('rounded-3xl border px-5 py-4 text-sm font-semibold', className)}>
<span className="font-black">{title}</span>{message}
</div>
)
}
interface SearchGroupPanelProps {
group: SearchGroupMeta
items: GlobalSearchItem[]
query: string
}
function SearchGroupPanel({ group, items, query }: SearchGroupPanelProps): JSX.Element {
const Icon = group.icon
return (
<Panel className="overflow-hidden p-0">
<div className="flex items-start justify-between gap-3 border-b border-white/10 p-6">
<div className="flex items-start gap-4">
<div className="grid h-12 w-12 place-items-center rounded-2xl border border-white/10 bg-white/[0.04] text-cyan-200">
<Icon className="h-5 w-5" />
</div>
<div>
<h2 className="text-xl font-black text-white">{group.title}</h2>
<p className="mt-1 text-sm font-semibold text-slate-500">{group.subtitle}</p>
</div>
</div>
<Badge tone={group.tone}>{items.length} </Badge>
</div>
<div className="space-y-3 p-4">
{items.length > 0 ? items.map((item) => (
<a
key={group.key + '-' + String(item.id)}
href={itemRouteHint(group.key, item)}
className="focus-ring group flex items-start justify-between gap-3 rounded-3xl border border-white/10 bg-slate-950/40 p-4 transition hover:border-cyan-300/30 hover:bg-cyan-400/10"
>
<div className="min-w-0">
<div className="truncate text-sm font-black text-white">{itemTitle(item, group.key)}</div>
<div className="mt-1 line-clamp-2 text-xs font-semibold leading-5 text-slate-500">{itemSubtitle(item, group.key)}</div>
</div>
<ArrowUpRight className="mt-1 h-4 w-4 shrink-0 text-slate-600 transition group-hover:text-cyan-200" />
</a>
)) : (
<div className="rounded-3xl border border-dashed border-white/10 bg-white/[0.02] px-5 py-8 text-center">
<Filter className="mx-auto h-6 w-6 text-slate-600" />
<div className="mt-3 text-sm font-black text-slate-400">{query.length >= 2 ? '暂无命中' : '等待输入'}</div>
<p className="mt-1 text-xs font-semibold leading-5 text-slate-600">{query.length >= 2 ? '可以换一个关键词,或打开旧后台继续使用完整功能。' : '输入至少 2 个字符后开始查询。'}</p>
</div>
)}
</div>
</Panel>
)
}