444 lines
19 KiB
TypeScript
444 lines
19 KiB
TypeScript
import axios from 'axios'
|
|
import {
|
|
AlertTriangle,
|
|
AppWindow,
|
|
Bot,
|
|
CircleGauge,
|
|
Database,
|
|
FileText,
|
|
Globe2,
|
|
HardDrive,
|
|
Network,
|
|
Search,
|
|
Settings,
|
|
ShieldAlert,
|
|
Users,
|
|
} from 'lucide-react'
|
|
import i18n from '../../i18n'
|
|
import { adminRoutes, getVisibleAdminRoutes } from '../routes/manifest'
|
|
import type { AdminSearchTarget } from './types'
|
|
|
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
|
|
|
function apiPath(path: string) {
|
|
if (path.startsWith('/api/')) return path
|
|
return `${API_BASE_URL}${path}`
|
|
}
|
|
|
|
function text(value: unknown, fallback = '') {
|
|
if (value === null || value === undefined || value === '') return fallback
|
|
if (typeof value === 'string') return value
|
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
return fallback
|
|
}
|
|
|
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
}
|
|
|
|
function objectAt(payload: unknown, key: string): Record<string, unknown> {
|
|
return isObjectRecord(payload) && isObjectRecord(payload[key]) ? payload[key] as Record<string, unknown> : {}
|
|
}
|
|
|
|
function dataArray(payload: unknown): Record<string, unknown>[] {
|
|
if (Array.isArray(payload)) return payload.filter(isObjectRecord)
|
|
if (!isObjectRecord(payload)) return []
|
|
const value = payload.data || payload.items || payload.results
|
|
return Array.isArray(value) ? value.filter(isObjectRecord) : []
|
|
}
|
|
|
|
function targetId(parts: Array<string | undefined>) {
|
|
return parts.filter(Boolean).join(':')
|
|
}
|
|
|
|
const labelKeys: Record<string, string> = {
|
|
'AI': 'admin.routes.ai',
|
|
'BGP观测': 'admin.routes.bgp',
|
|
'BGP': 'admin.sections.bgpOverview',
|
|
'BGP 事故': 'admin.sections.alerts',
|
|
'BGP 告警': 'admin.routes.bgpAlerts',
|
|
'Playground': 'admin.sections.aiPlayground',
|
|
'SMTP 邮件': 'admin.sections.smtp',
|
|
'工具调用': 'admin.sections.aiTools',
|
|
'提示词': 'admin.sections.aiPrompts',
|
|
'日志': 'admin.routes.logs',
|
|
'日志源': 'admin.sections.logsSources',
|
|
'智能星球内容': 'admin.routes.earthContent',
|
|
'模型供应商': 'admin.sections.aiIntegrations',
|
|
'模型预设': 'admin.sections.aiIntegrations',
|
|
'电视直播': 'admin.sections.tv',
|
|
'系统告警': 'admin.routes.systemAlerts',
|
|
'系统显示': 'admin.sections.settingsSystem',
|
|
'系统设置': 'admin.routes.settings',
|
|
'采集历史': 'admin.sections.collectionHistory',
|
|
'采集历史 / 快照': 'admin.sections.collectionHistory',
|
|
'采集器': 'admin.sections.collectorCredentials',
|
|
'采集数据': 'admin.routes.data',
|
|
'采集管理': 'admin.routes.collectionManagement',
|
|
'采集调度': 'admin.sections.collectors',
|
|
'数据源': 'admin.routes.datasources',
|
|
'用户': 'admin.routes.users',
|
|
'用户管理': 'admin.routes.users',
|
|
'告警记录': 'admin.sections.alerts',
|
|
'国界精度': 'admin.sections.earthAssets',
|
|
'品牌标识': 'admin.sections.earthBrand',
|
|
'态势告警': 'admin.routes.situationalAlerts',
|
|
'通知策略': 'admin.sections.notifications',
|
|
'安全策略': 'admin.sections.security',
|
|
'新闻源': 'admin.sections.newsSources',
|
|
'页面': 'admin.search.pageContext',
|
|
}
|
|
|
|
function translateLabel(label: string | undefined): string | undefined {
|
|
if (!label) return label
|
|
const key = labelKeys[label]
|
|
return key ? i18n.t(key) : label
|
|
}
|
|
|
|
function makeTarget(target: Omit<AdminSearchTarget, 'id'> & { id?: string }): AdminSearchTarget {
|
|
const routeLabel = translateLabel(target.routeLabel) || target.routeLabel
|
|
const sectionLabel = translateLabel(target.sectionLabel) || target.sectionLabel
|
|
const label = translateLabel(target.label) || target.label
|
|
const contextLabel = translateLabel(target.contextLabel) || target.contextLabel
|
|
|
|
return {
|
|
...target,
|
|
contextLabel,
|
|
id: target.id || targetId([target.routePath, target.sectionKey, target.groupKey, target.fieldKey, target.label]),
|
|
label,
|
|
routeLabel,
|
|
sectionLabel,
|
|
terms: Array.from(new Set([
|
|
label,
|
|
routeLabel,
|
|
sectionLabel,
|
|
contextLabel,
|
|
target.routeLabel,
|
|
target.sectionLabel,
|
|
target.contextLabel,
|
|
target.label,
|
|
target.routePath,
|
|
target.groupKey,
|
|
target.fieldKey,
|
|
target.tooltip,
|
|
target.ariaLabel,
|
|
target.highlightText,
|
|
...target.terms,
|
|
].filter(Boolean).map(String))),
|
|
}
|
|
}
|
|
|
|
const sectionTargets = [
|
|
{ routePath: '/ai', routeLabel: 'AI', icon: Bot, sections: [
|
|
{ key: 'integrations', label: '模型供应商', terms: ['AI Provider', 'provider设置', '供应商配置', 'LLM', '模型', 'deepseek', 'minimax', 'openai'] },
|
|
{ key: 'tools', label: '工具调用', terms: ['Web Search', 'OCR', 'tavily', 'searxng', 'firecrawl'] },
|
|
{ key: 'prompts', label: '提示词', terms: ['prompt', 'system prompt', 'AI prompts'] },
|
|
{ key: 'playground', label: 'Playground', terms: ['playground', '测试', '对话'] },
|
|
] },
|
|
{ routePath: '/earth-content', routeLabel: '智能星球内容', icon: Globe2, sections: [
|
|
{ key: 'brand', label: '品牌标识', terms: ['logo', '标题', 'subtitle'] },
|
|
{ key: 'earth_assets', label: '国界精度', terms: ['boundary', 'PMTiles', '边界'] },
|
|
{ key: 'tv', label: '电视直播', terms: ['TV', '直播源', '频道'] },
|
|
{ key: 'news_sources', label: '新闻源', terms: ['news', 'rss', '商业新闻', '电商', '财经', '新闻类型'] },
|
|
] },
|
|
{ routePath: '/collection-management', routeLabel: '采集管理', icon: Database, sections: [
|
|
{ key: 'collector_credentials', label: '采集器', terms: ['collector', 'credential', '凭证教程'] },
|
|
{ key: 'collectors', label: '采集调度', terms: ['schedule', 'frequency'] },
|
|
{ key: 'collection_history', label: '采集历史 / 快照', terms: ['history', 'snapshot'] },
|
|
] },
|
|
{ routePath: '/settings', routeLabel: '系统设置', icon: Settings, sections: [
|
|
{ key: 'system', label: '系统显示', terms: ['system', 'display'] },
|
|
{ key: 'notifications', label: '通知策略', terms: ['notification', 'email'] },
|
|
{ key: 'security', label: '安全策略', terms: ['security', 'password'] },
|
|
{ key: 'smtp', label: 'SMTP 邮件', terms: ['smtp', 'mail'] },
|
|
] },
|
|
{ routePath: '/logs', routeLabel: '日志', icon: FileText, sections: [
|
|
{ key: 'sources', label: '日志源', terms: ['log', 'tail', 'source'] },
|
|
] },
|
|
{ routePath: '/bgp', routeLabel: 'BGP观测', icon: Network, sections: [
|
|
{ key: 'overview', label: 'BGP', terms: ['bgp', '网络', '观测'] },
|
|
] },
|
|
{ routePath: '/alerts/system', routeLabel: '系统告警', icon: AlertTriangle, sections: [
|
|
{ key: 'alerts', label: '告警记录', terms: ['alert', 'system', 'resolve'] },
|
|
] },
|
|
{ routePath: '/alerts/bgp', routeLabel: 'BGP 告警', icon: ShieldAlert, sections: [
|
|
{ key: 'incidents', label: 'BGP 事故', terms: ['incident', 'anomaly', 'brief'] },
|
|
] },
|
|
{ routePath: '/alerts/situational', routeLabel: '态势告警', icon: Globe2, sections: [
|
|
{ key: 'alerts', label: '告警记录', terms: ['situational', '态势'] },
|
|
] },
|
|
]
|
|
|
|
const fieldTargets = [
|
|
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'integrations', sectionLabel: '模型供应商', labels: ['供应商', '协议适配', 'LLM 基础地址', '默认模型', 'LLM API Key', '最大输出 Tokens', 'Anthropic 版本', '超时(秒)', '重试次数', '代理地址', '代理 Token', '测试 AI Provider 连通性', '刷新当前 Provider 的模型配置', '显示 LLM API Key / Service Token', '设为默认', '恢复默认配置'] },
|
|
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'tools', sectionLabel: '工具调用', labels: ['搜索供应商', 'API 基础地址', 'WebSearch API Key', '最大结果数', '搜索深度', 'SerpAPI 引擎', 'SearXNG 分类', 'OCR 供应商', 'OCR API Key', '测试 Web Search 连通性'] },
|
|
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'prompts', sectionLabel: '提示词', labels: ['System Prompt', '任务提示词', '重置 Prompt'] },
|
|
{ routePath: '/collection-management', routeLabel: '采集管理', sectionKey: 'collector_credentials', sectionLabel: '采集器', labels: ['凭证教程', '生成凭证教程', '采集器配置', '映射模板', '目标 Schema'] },
|
|
{ routePath: '/earth-content', routeLabel: '智能星球内容', sectionKey: 'tv', sectionLabel: '电视直播', labels: ['默认频道', '自动回退', '直播源', '频道', '主页地址'] },
|
|
{ routePath: '/settings', routeLabel: '系统设置', sectionKey: 'smtp', sectionLabel: 'SMTP 邮件', labels: ['主机', '端口', '用户名', '密码', '使用 TLS', '发件邮箱'] },
|
|
]
|
|
|
|
const curatedDynamicLikeTargets = [
|
|
...['deepseek', 'minimax', 'openai', 'anthropic', 'ollama', 'qwen', 'moonshot', 'zhipu'].map((provider) => makeTarget({
|
|
routePath: '/ai',
|
|
routeLabel: 'AI',
|
|
sectionKey: 'integrations',
|
|
sectionLabel: '模型供应商',
|
|
groupKey: `provider:${provider}`,
|
|
label: provider,
|
|
contextLabel: 'AI / 模型供应商',
|
|
terms: [provider, 'AI Provider', 'LLM', '供应商配置', 'provider设置'],
|
|
highlightText: provider,
|
|
icon: Bot,
|
|
})),
|
|
...['tavily', 'searxng', 'serpapi', 'firecrawl'].map((provider) => makeTarget({
|
|
routePath: '/ai',
|
|
routeLabel: 'AI',
|
|
sectionKey: 'tools',
|
|
sectionLabel: '工具调用',
|
|
groupKey: `web_search:${provider}`,
|
|
label: provider,
|
|
contextLabel: 'AI / 工具调用 / Web Search',
|
|
terms: [provider, 'Web Search', '搜索供应商', '工具调用'],
|
|
highlightText: provider,
|
|
icon: Search,
|
|
})),
|
|
]
|
|
|
|
export function buildStaticAdminTargets(isSuperAdmin: boolean): AdminSearchTarget[] {
|
|
const visiblePaths = new Set(getVisibleAdminRoutes(isSuperAdmin).map((route) => route.path))
|
|
const routeTargets = adminRoutes
|
|
.filter((route) => visiblePaths.has(route.path))
|
|
.map((route) => makeTarget({
|
|
routePath: route.path,
|
|
routeLabel: i18n.t(route.labelKey),
|
|
label: i18n.t(route.labelKey),
|
|
contextLabel: i18n.t('admin.search.pageContext'),
|
|
terms: route.keywords,
|
|
icon: route.icon,
|
|
}))
|
|
|
|
const sections = sectionTargets
|
|
.filter((route) => visiblePaths.has(route.routePath))
|
|
.flatMap((route) => route.sections.map((section) => makeTarget({
|
|
routePath: route.routePath,
|
|
routeLabel: route.routeLabel,
|
|
sectionKey: section.key,
|
|
sectionLabel: section.label,
|
|
label: section.label,
|
|
contextLabel: route.routeLabel,
|
|
terms: section.terms,
|
|
highlightText: section.label,
|
|
icon: route.icon,
|
|
})))
|
|
|
|
const fields = fieldTargets
|
|
.filter((target) => visiblePaths.has(target.routePath))
|
|
.flatMap((target) => target.labels.map((label) => makeTarget({
|
|
routePath: target.routePath,
|
|
routeLabel: target.routeLabel,
|
|
sectionKey: target.sectionKey,
|
|
sectionLabel: target.sectionLabel,
|
|
fieldKey: label,
|
|
label,
|
|
contextLabel: `${target.routeLabel} / ${target.sectionLabel}`,
|
|
terms: [label, 'tooltip', 'title', 'aria-label'],
|
|
tooltip: label,
|
|
ariaLabel: label,
|
|
highlightText: label,
|
|
icon: Search,
|
|
})))
|
|
|
|
return [...routeTargets, ...sections, ...fields, ...curatedDynamicLikeTargets.filter((target) => visiblePaths.has(target.routePath))]
|
|
}
|
|
|
|
const dynamicEndpoints = [
|
|
{ routePath: '/datasources', routeLabel: '数据源', icon: Database, sectionKey: 'data', sectionLabel: '数据源', url: '/datasources/configs/all' },
|
|
{ routePath: '/data', routeLabel: '采集数据', icon: AppWindow, sectionKey: 'data', sectionLabel: '采集数据', url: '/datasources/data' },
|
|
{ routePath: '/logs', routeLabel: '系统日志', icon: FileText, sectionKey: 'sources', sectionLabel: '日志源', url: '/system/logs/sources' },
|
|
{ routePath: '/users', routeLabel: '用户管理', icon: Users, sectionKey: 'users', sectionLabel: '用户', url: '/users' },
|
|
{ routePath: '/admin', routeLabel: '仪表盘', icon: CircleGauge, sectionKey: 'overview', sectionLabel: '总览', url: '/health' },
|
|
{ routePath: '/collection-management', routeLabel: '采集管理', icon: HardDrive, sectionKey: 'collector_credentials', sectionLabel: '采集器', url: '/datasources/configs/all' },
|
|
{ routePath: '/earth-content', routeLabel: '智能星球内容', icon: Globe2, sectionKey: 'tv', sectionLabel: '电视直播', url: '/settings/tv' },
|
|
]
|
|
|
|
function flattenRecordValues(record: Record<string, unknown>, limit = 16) {
|
|
const values: string[] = []
|
|
const visit = (value: unknown) => {
|
|
if (values.length >= limit) return
|
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
const next = text(value, '').trim()
|
|
if (next && next.length <= 120) values.push(next)
|
|
return
|
|
}
|
|
if (Array.isArray(value)) value.slice(0, 6).forEach(visit)
|
|
else if (isObjectRecord(value)) Object.values(value).slice(0, 8).forEach(visit)
|
|
}
|
|
visit(record)
|
|
return Array.from(new Set(values))
|
|
}
|
|
|
|
export async function buildDynamicAdminTargets(isSuperAdmin: boolean): Promise<AdminSearchTarget[]> {
|
|
const visiblePaths = new Set(getVisibleAdminRoutes(isSuperAdmin).map((route) => route.path))
|
|
const targets: AdminSearchTarget[] = []
|
|
|
|
try {
|
|
const integrationsResponse = await axios.get(apiPath('/settings/integrations'))
|
|
const integrations = objectAt(integrationsResponse.data, 'integrations')
|
|
const aiProvider = objectAt(integrations, 'ai_provider')
|
|
const webSearch = objectAt(integrations, 'web_search')
|
|
const ocr = objectAt(integrations, 'ocr')
|
|
const providers = objectAt(aiProvider, 'providers')
|
|
const webProviders = objectAt(webSearch, 'providers')
|
|
const aiDefault = text(aiProvider.default_provider || aiProvider.provider, 'minimax')
|
|
const webDefault = text(webSearch.default_provider || webSearch.provider, 'tavily')
|
|
const providerIds = Array.from(new Set([aiDefault, ...Object.keys(providers)].filter(Boolean)))
|
|
const webIds = Array.from(new Set([webDefault, ...Object.keys(webProviders)].filter(Boolean)))
|
|
|
|
providerIds.forEach((provider) => {
|
|
targets.push(makeTarget({
|
|
routePath: '/ai',
|
|
routeLabel: 'AI',
|
|
sectionKey: 'integrations',
|
|
sectionLabel: '模型供应商',
|
|
groupKey: `provider:${provider}`,
|
|
label: provider,
|
|
contextLabel: 'AI / 模型供应商',
|
|
terms: [provider, 'AI Provider', 'LLM', ...flattenRecordValues({ ...objectAt(providers, provider), provider })],
|
|
highlightText: provider,
|
|
icon: Bot,
|
|
dynamic: true,
|
|
}))
|
|
})
|
|
|
|
webIds.forEach((provider) => {
|
|
targets.push(makeTarget({
|
|
routePath: '/ai',
|
|
routeLabel: 'AI',
|
|
sectionKey: 'tools',
|
|
sectionLabel: '工具调用',
|
|
groupKey: `web_search:${provider}`,
|
|
label: provider,
|
|
contextLabel: 'AI / 工具调用 / Web Search',
|
|
terms: [provider, 'Web Search', ...flattenRecordValues({ ...objectAt(webProviders, provider), provider })],
|
|
highlightText: provider,
|
|
icon: Search,
|
|
dynamic: true,
|
|
}))
|
|
})
|
|
|
|
targets.push(makeTarget({
|
|
routePath: '/ai',
|
|
routeLabel: 'AI',
|
|
sectionKey: 'tools',
|
|
sectionLabel: '工具调用',
|
|
groupKey: 'ocr:config',
|
|
label: text(ocr.provider, 'paddleocr'),
|
|
contextLabel: 'AI / 工具调用 / OCR',
|
|
terms: ['OCR', ...flattenRecordValues(ocr)],
|
|
highlightText: text(ocr.provider, 'OCR'),
|
|
icon: FileText,
|
|
dynamic: true,
|
|
}))
|
|
} catch {
|
|
// Dynamic search degrades to static targets when this optional index fails.
|
|
}
|
|
|
|
try {
|
|
const presetsResponse = await axios.get(apiPath('/settings/integrations/ai-provider/presets'))
|
|
dataArray(presetsResponse.data).forEach((preset) => {
|
|
const provider = text(preset.provider, '')
|
|
if (!provider) return
|
|
targets.push(makeTarget({
|
|
routePath: '/ai',
|
|
routeLabel: 'AI',
|
|
sectionKey: 'integrations',
|
|
sectionLabel: '模型供应商',
|
|
groupKey: `provider:${provider}`,
|
|
label: provider,
|
|
contextLabel: 'AI / 模型预设',
|
|
terms: flattenRecordValues(preset, 24),
|
|
highlightText: provider,
|
|
icon: Bot,
|
|
dynamic: true,
|
|
}))
|
|
})
|
|
} catch {
|
|
// Optional preset index.
|
|
}
|
|
|
|
try {
|
|
const promptsResponse = await axios.get(apiPath('/settings/ai-prompts'))
|
|
dataArray(promptsResponse.data).forEach((prompt) => {
|
|
const key = text(prompt.key || prompt.id || prompt.label, '')
|
|
const label = text(prompt.label || prompt.key, key)
|
|
if (!key || !label) return
|
|
targets.push(makeTarget({
|
|
routePath: '/ai',
|
|
routeLabel: 'AI',
|
|
sectionKey: 'prompts',
|
|
sectionLabel: '提示词',
|
|
groupKey: `prompt:${key}`,
|
|
label,
|
|
contextLabel: 'AI / 提示词',
|
|
terms: flattenRecordValues(prompt, 24),
|
|
highlightText: label,
|
|
icon: FileText,
|
|
dynamic: true,
|
|
}))
|
|
})
|
|
} catch {
|
|
// Optional prompt index.
|
|
}
|
|
|
|
const endpointResults = await Promise.allSettled(dynamicEndpoints
|
|
.filter((endpoint) => visiblePaths.has(endpoint.routePath))
|
|
.map(async (endpoint) => {
|
|
const response = await axios.get(apiPath(endpoint.url))
|
|
return { endpoint, rows: dataArray(response.data) }
|
|
}))
|
|
|
|
endpointResults.forEach((result) => {
|
|
if (result.status !== 'fulfilled') return
|
|
const { endpoint, rows } = result.value
|
|
rows.slice(0, 80).forEach((row, index) => {
|
|
const values = flattenRecordValues(row)
|
|
const label = text(row.name || row.title || row.id || row.key || row.provider || values[0], `${endpoint.sectionLabel} ${index + 1}`)
|
|
targets.push(makeTarget({
|
|
routePath: endpoint.routePath,
|
|
routeLabel: endpoint.routeLabel,
|
|
sectionKey: endpoint.sectionKey,
|
|
sectionLabel: endpoint.sectionLabel,
|
|
label,
|
|
contextLabel: `${endpoint.routeLabel} / ${endpoint.sectionLabel}`,
|
|
terms: values,
|
|
highlightText: label,
|
|
icon: endpoint.icon,
|
|
dynamic: true,
|
|
}))
|
|
})
|
|
})
|
|
|
|
return targets
|
|
}
|
|
|
|
export function searchAdminTargets(targets: AdminSearchTarget[], query: string) {
|
|
const normalized = query.trim().toLowerCase()
|
|
if (!normalized) return targets.slice(0, 12)
|
|
return targets
|
|
.map((target) => {
|
|
const terms = target.terms.map((term) => term.toLowerCase())
|
|
const haystack = terms.join(' ')
|
|
if (!haystack.includes(normalized)) return null
|
|
const exact = terms.some((term) => term === normalized)
|
|
const starts = terms.some((term) => term.startsWith(normalized))
|
|
const label = target.label.toLowerCase()
|
|
const score = label === normalized ? 0 : label.startsWith(normalized) ? 1 : exact ? 2 : starts ? 3 : target.groupKey ? 4 : 5
|
|
return { target, score }
|
|
})
|
|
.filter((item): item is { target: AdminSearchTarget; score: number } => item !== null)
|
|
.sort((a, b) => a.score - b.score || a.target.label.localeCompare(b.target.label))
|
|
.map((item) => item.target)
|
|
.slice(0, 20)
|
|
}
|