267 lines
13 KiB
TypeScript
267 lines
13 KiB
TypeScript
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 当前只读展示搜索结果,不展示密码、Cookie、Token、密钥类字段、私钥或宝塔 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>
|
||
)
|
||
}
|