5949 lines
275 KiB
TypeScript
5949 lines
275 KiB
TypeScript
import type { ColumnDef } from '@tanstack/react-table'
|
||
import axios from 'axios'
|
||
import {
|
||
ArrowLeft,
|
||
Bot,
|
||
BrushCleaning,
|
||
CheckCircle2,
|
||
CircleHelp,
|
||
Copy,
|
||
DatabaseZap,
|
||
Eye,
|
||
FileText,
|
||
Globe2,
|
||
ImageUp,
|
||
ListChecks,
|
||
Radio,
|
||
Redo2,
|
||
RefreshCw,
|
||
Save,
|
||
Search,
|
||
Send,
|
||
Settings2,
|
||
ShieldAlert,
|
||
EyeOff,
|
||
Sparkles,
|
||
Square,
|
||
TestTube2,
|
||
Trash2,
|
||
X,
|
||
} from 'lucide-react'
|
||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
|
||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||
import { AdminLayout } from '../components/layout/AdminLayout'
|
||
import { DataTable } from '../components/data-table/DataTable'
|
||
import { Button } from '../components/ui/button'
|
||
import { ConfirmDialog, Dialog } from '../components/ui/dialog'
|
||
import { Textarea } from '../components/ui/input'
|
||
import { AdminSwitch } from '../components/ui/switch'
|
||
import { ControlGroup as TactileControlGroup } from '../../components/tactile-ui'
|
||
import { useToast } from '../components/ui/toast'
|
||
import { DetailPanel, EmptyState, PageFrame, Panel, StatCell, StatusText, SummaryStrip } from '../patterns/patterns'
|
||
|
||
type AnyRecord = Record<string, unknown>
|
||
type Tone = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'running' | 'ai'
|
||
|
||
type TableRecord = AnyRecord & {
|
||
__endpointKey: string
|
||
__endpointLabel: string
|
||
__rowId: string
|
||
__title: string
|
||
__module: string
|
||
__status: string
|
||
__metric: string
|
||
__time: string
|
||
}
|
||
|
||
type SectionConfig = {
|
||
key: string
|
||
label: string
|
||
url?: string
|
||
params?: AnyRecord
|
||
map: (payload: unknown) => AnyRecord[]
|
||
endpoints?: Array<{
|
||
key: string
|
||
label: string
|
||
url: string
|
||
params?: AnyRecord
|
||
map: (payload: unknown) => AnyRecord[]
|
||
}>
|
||
}
|
||
|
||
type ModuleConfig = {
|
||
title: string
|
||
description: string
|
||
listTitle: string
|
||
listDescription: string
|
||
viewMode?: 'management' | 'information'
|
||
detailTitle: string
|
||
sections: SectionConfig[]
|
||
actions: Array<{ label: string; icon: ReactNode; to: string }>
|
||
columns?: Array<ColumnDef<TableRecord>>
|
||
}
|
||
|
||
type SectionState = {
|
||
section: SectionConfig
|
||
ok: boolean
|
||
rows: TableRecord[]
|
||
raw: unknown
|
||
error?: string
|
||
}
|
||
|
||
type DatasourceFilters = {
|
||
product: string
|
||
module: string
|
||
isActive: string
|
||
runStatus: string
|
||
dataStatus: string
|
||
}
|
||
|
||
type CollectionQueueStatus = 'queued' | 'running' | 'cancelling' | 'success' | 'failed' | 'skipped' | 'cancelled'
|
||
|
||
type CollectionQueueItem = {
|
||
key: string
|
||
sourceId: string
|
||
source?: string
|
||
name: string
|
||
taskId?: number | string | null
|
||
taskType?: string
|
||
status: CollectionQueueStatus
|
||
phase?: string
|
||
phaseMessage?: string
|
||
progress?: number | null
|
||
recordsProcessed?: number | null
|
||
totalRecords?: number | null
|
||
reason?: string
|
||
error?: string
|
||
createdAt: number
|
||
updatedAt: number
|
||
completedAt?: number
|
||
}
|
||
|
||
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
|
||
product: '',
|
||
module: '',
|
||
isActive: 'true',
|
||
runStatus: '',
|
||
dataStatus: '',
|
||
}
|
||
|
||
const DATASOURCE_TERMINAL_STATUSES = new Set(['success', 'completed', 'failed', 'cancelled'])
|
||
const COLLECTION_QUEUE_ACTIVE_STATUSES = new Set<CollectionQueueStatus>(['queued', 'running', 'cancelling'])
|
||
const TASK_ACTIVE_STATUSES = new Set(['queued', 'pending', 'running', 'cancelling'])
|
||
|
||
interface PlaygroundApiMessage {
|
||
id: string
|
||
role: 'system' | 'user' | 'assistant'
|
||
status?: 'pending' | 'thinking' | 'answering' | 'done' | 'stopped' | 'error'
|
||
title?: string
|
||
content: string
|
||
thinking_content?: string
|
||
provider?: string | null
|
||
model?: string | null
|
||
request_id?: string | null
|
||
created_at: string
|
||
updated_at: string
|
||
}
|
||
|
||
interface PlaygroundThreadResponse {
|
||
session?: {
|
||
state?: {
|
||
selectedPresetKey?: string
|
||
title?: string
|
||
objective?: string
|
||
constraints?: string
|
||
inputValue?: string
|
||
}
|
||
}
|
||
messages: PlaygroundApiMessage[]
|
||
}
|
||
|
||
interface PlaygroundActionResponse {
|
||
messages: PlaygroundApiMessage[]
|
||
active_message_id?: string | null
|
||
session?: PlaygroundThreadResponse['session']
|
||
}
|
||
|
||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||
|
||
function apiPath(path: string) {
|
||
if (path.startsWith('/api/')) return path
|
||
return `${API_BASE_URL}${path}`
|
||
}
|
||
|
||
function isObjectRecord(value: unknown): value is AnyRecord {
|
||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
||
}
|
||
|
||
function objectAt(payload: unknown, key: string): AnyRecord {
|
||
return isObjectRecord(payload) && isObjectRecord(payload[key]) ? payload[key] as AnyRecord : {}
|
||
}
|
||
|
||
function arrayAt(payload: unknown, key: string): AnyRecord[] {
|
||
if (!isObjectRecord(payload)) return []
|
||
const value = payload[key]
|
||
return Array.isArray(value) ? value.filter(isObjectRecord) : []
|
||
}
|
||
|
||
function dataArray(payload: unknown): AnyRecord[] {
|
||
if (Array.isArray(payload)) return payload.filter(isObjectRecord)
|
||
return arrayAt(payload, 'data')
|
||
}
|
||
|
||
function singleRow(payload: unknown, key: string, fallback: AnyRecord = {}): AnyRecord[] {
|
||
const row = objectAt(payload, key)
|
||
return [Object.keys(row).length ? row : fallback]
|
||
}
|
||
|
||
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 pick(record: AnyRecord, keys: string[], fallback = '-') {
|
||
for (const key of keys) {
|
||
const value = record[key]
|
||
if (value !== null && value !== undefined && value !== '') return text(value, fallback)
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
function statusTone(value: string): Tone {
|
||
const lower = value.toLowerCase()
|
||
if (/(默认|default)/.test(lower)) return 'info'
|
||
if (/(配置错误|校验失败|连接失败|failed|error|critical|down|danger|unresolved)/.test(lower)) return 'danger'
|
||
if (/(未配置|未启用|停用|禁用|disabled|false|missing|empty|none|可选|optional|-)/.test(lower)) return 'neutral'
|
||
if (/(已配置|configured|running|active|enabled|success|ok|healthy|connected|resolved|ack|true|valid|已读取|已上传|已提交|可用|启用)/.test(lower)) return 'success'
|
||
if (/(pending|queued|warning|degraded|partial|waiting|unknown)/.test(lower)) return 'warning'
|
||
if (/(ai|brief|model|provider|prompt)/.test(lower)) return 'ai'
|
||
if (/(loading|sync|collect|live|stream|删除中|清缓存中|刷新中|任务中|cancelling)/.test(lower)) return 'running'
|
||
return 'neutral'
|
||
}
|
||
|
||
function formatRaw(value: unknown) {
|
||
try {
|
||
return JSON.stringify(value, null, 2)
|
||
} catch {
|
||
return String(value)
|
||
}
|
||
}
|
||
|
||
function cleanRecord(record: AnyRecord) {
|
||
return Object.fromEntries(Object.entries(record).filter(([key]) => !key.startsWith('__')))
|
||
}
|
||
|
||
function recordTitle(record: AnyRecord) {
|
||
return pick(record, ['__title', 'name', 'title', 'id', 'key', 'provider', 'source_id', 'collector_id'], '记录')
|
||
}
|
||
|
||
function recordStatus(record: AnyRecord) {
|
||
return pick(record, ['__status', 'run_status', 'last_status', 'status', 'state', 'severity', 'level', 'enabled', 'active', 'connected', 'is_running'])
|
||
}
|
||
|
||
function recordMetric(record: AnyRecord) {
|
||
return pick(record, ['__metric', 'collected_records', 'record_count', 'records_processed', 'count', 'total', 'value', 'records', 'events', 'score', 'latency_ms', 'health'])
|
||
}
|
||
|
||
function datasourceMetric(record: AnyRecord) {
|
||
if (typeof record.collected_records === 'number') return `${record.collected_records} records`
|
||
if (typeof record.records_processed === 'number' && typeof record.total_records === 'number') {
|
||
return `${record.records_processed}/${record.total_records} records`
|
||
}
|
||
if (typeof record.records_processed === 'number') return `${record.records_processed} records`
|
||
return pick(record, ['record_count', 'count', 'total', 'value', 'records'], '-')
|
||
}
|
||
|
||
function activeDatasourceTaskType(record: AnyRecord) {
|
||
return text(record.task_type, 'collect')
|
||
}
|
||
|
||
function activeDatasourceTaskStatus(record: AnyRecord) {
|
||
return text(record.task_status || record.status || record.phase, '').toLowerCase()
|
||
}
|
||
|
||
function hasActiveDatasourceTask(record: AnyRecord) {
|
||
return record.is_task_active === true || TASK_ACTIVE_STATUSES.has(activeDatasourceTaskStatus(record))
|
||
}
|
||
|
||
function isCollectTaskActive(record: AnyRecord) {
|
||
return hasActiveDatasourceTask(record) && activeDatasourceTaskType(record) === 'collect'
|
||
}
|
||
|
||
function datasourceStatus(record: AnyRecord) {
|
||
if (isCollectTaskActive(record)) return 'running'
|
||
return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle')
|
||
}
|
||
|
||
function datasourceDisplayStatus(record: AnyRecord) {
|
||
if (!hasActiveDatasourceTask(record)) return datasourceStatus(record)
|
||
const taskType = activeDatasourceTaskType(record)
|
||
if (taskType === 'clear_data') return '删除中'
|
||
if (taskType === 'clear_cache') return '清缓存中'
|
||
if (taskType === 'earth_refresh') return '刷新中'
|
||
if (taskType === 'collect') return activeDatasourceTaskStatus(record) === 'queued' ? '排队中' : '运行中'
|
||
return '任务中'
|
||
}
|
||
|
||
function queueStatusFromTask(statusValue: unknown, isRunning?: unknown): CollectionQueueStatus {
|
||
const status = text(statusValue, '').toLowerCase()
|
||
if (status === 'success' || status === 'completed') return 'success'
|
||
if (status === 'failed' || status === 'error') return 'failed'
|
||
if (status === 'cancelled' || status === 'canceled') return 'cancelled'
|
||
if (status === 'skipped') return 'skipped'
|
||
if (status === 'queued' || status === 'pending') return 'queued'
|
||
if (status === 'cancelling') return 'cancelling'
|
||
if (isRunning === true || status === 'running' || status === 'collecting') return 'running'
|
||
return 'queued'
|
||
}
|
||
|
||
function queueItemKey(item: AnyRecord) {
|
||
const taskId = text(item.task_id || item.taskId, '')
|
||
if (taskId) return `task:${taskId}`
|
||
const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '')
|
||
if (sourceId) return `source:${sourceId}`
|
||
const source = text(item.collector_name || item.source, '')
|
||
return source ? `source-name:${source}` : `queue:${Date.now()}`
|
||
}
|
||
|
||
function isActiveQueueStatus(status: CollectionQueueStatus) {
|
||
return COLLECTION_QUEUE_ACTIVE_STATUSES.has(status)
|
||
}
|
||
|
||
function queueProgress(item: CollectionQueueItem) {
|
||
if (typeof item.progress === 'number') return Math.max(0, Math.min(100, Math.round(item.progress)))
|
||
if (item.status === 'success' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'skipped') return 100
|
||
return 0
|
||
}
|
||
|
||
function taskTypeLabel(taskType?: string) {
|
||
const labels: Record<string, string> = {
|
||
collect: '采集',
|
||
clear_data: '删除',
|
||
clear_cache: '清缓存',
|
||
earth_refresh: '刷新',
|
||
}
|
||
return labels[text(taskType, 'collect')] || '任务'
|
||
}
|
||
|
||
function queueStatusLabel(status: CollectionQueueStatus, taskType?: string) {
|
||
const noun = taskTypeLabel(taskType)
|
||
if (status === 'queued') return `${noun}排队中`
|
||
if (status === 'running') return `${noun}中`
|
||
if (status === 'cancelling') return `停止${noun}中`
|
||
const labels: Record<CollectionQueueStatus, string> = {
|
||
queued: `${noun}排队中`,
|
||
running: `${noun}中`,
|
||
cancelling: `停止${noun}中`,
|
||
success: '已完成',
|
||
failed: '失败',
|
||
skipped: '跳过',
|
||
cancelled: '已取消',
|
||
}
|
||
return labels[status]
|
||
}
|
||
|
||
function queueReasonLabel(reason = '') {
|
||
const labels: Record<string, string> = {
|
||
disabled: '已停用',
|
||
already_running: '已有任务运行',
|
||
within_frequency_window: '未到采集间隔',
|
||
trigger_failed: '触发失败',
|
||
}
|
||
return labels[reason] || reason || '-'
|
||
}
|
||
|
||
function formatDuration(startedAt: number, endedAt = Date.now()) {
|
||
const seconds = Math.max(0, Math.round((endedAt - startedAt) / 1000))
|
||
const minutes = Math.floor(seconds / 60)
|
||
const rest = seconds % 60
|
||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`
|
||
}
|
||
|
||
function datasourceTableRow(row: AnyRecord) {
|
||
return {
|
||
...row,
|
||
__module: pick(row, ['module', 'source'], '数据源'),
|
||
__status: datasourceDisplayStatus(row),
|
||
__metric: datasourceMetric(row),
|
||
__time: pick(row, ['last_run_at', 'last_run'], '-'),
|
||
}
|
||
}
|
||
|
||
function makeAction(label: string, icon: ReactNode, to: string) {
|
||
return { label, icon, to }
|
||
}
|
||
|
||
function enrichRows(state: SectionState, rows: AnyRecord[]): TableRecord[] {
|
||
return rows.map((row, index) => ({
|
||
...row,
|
||
__endpointKey: pick(row, ['__endpointKey'], state.section.key),
|
||
__endpointLabel: pick(row, ['__endpointLabel'], state.section.label),
|
||
__rowId: `${state.section.key}-${pick(row, ['id', 'key', 'name', 'source_id', 'alert_id', 'task_key', 'provider'], String(index))}`,
|
||
__title: pick(row, ['__title', 'name', 'title', 'id', 'key', 'provider', 'source_id', 'task_key'], state.section.label),
|
||
__module: pick(row, ['__module', 'module', 'source', 'category', 'type'], state.section.label),
|
||
__status: pick(row, ['__status', 'run_status', 'last_status', 'status', 'state', 'severity', 'level', 'enabled', 'active', 'connected', 'is_running'], '-'),
|
||
__metric: pick(row, ['__metric', 'collected_records', 'record_count', 'records_processed', 'count', 'total', 'value', 'records', 'events', 'score', 'latency_ms', 'health'], '-'),
|
||
__time: pick(row, ['__time', 'updated_at', 'last_updated', 'created_at', 'completed_at', 'timestamp', 'time', 'last_seen', 'last_run_at'], '-'),
|
||
}))
|
||
}
|
||
|
||
function normalizeSectionState(section: SectionConfig, payload: unknown): SectionState {
|
||
const base: SectionState = { section, ok: true, rows: [], raw: payload }
|
||
base.rows = enrichRows(base, section.map(payload))
|
||
return base
|
||
}
|
||
|
||
function parseJsonDraft(draft: string, fallback: unknown = {}) {
|
||
try {
|
||
return JSON.parse(draft)
|
||
} catch {
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
function setDraftField(draft: string, key: string, value: unknown) {
|
||
const current = parseJsonDraft(draft, {})
|
||
const next = isObjectRecord(current) ? { ...current, [key]: value } : { [key]: value }
|
||
return JSON.stringify(next, null, 2)
|
||
}
|
||
|
||
function EditableRecordForm({
|
||
draft,
|
||
onDraftChange,
|
||
hiddenKeys = [],
|
||
}: {
|
||
draft: string
|
||
onDraftChange: (value: string) => void
|
||
hiddenKeys?: string[]
|
||
}) {
|
||
const parsed = parseJsonDraft(draft, {})
|
||
if (!isObjectRecord(parsed)) {
|
||
return (
|
||
<Textarea
|
||
className="an-json-editor"
|
||
value={draft}
|
||
onChange={(event) => onDraftChange(event.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
)
|
||
}
|
||
|
||
const entries = Object.entries(parsed)
|
||
.filter(([key]) => !key.startsWith('__') && !hiddenKeys.includes(key))
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
|
||
return (
|
||
<div className="an-record-form">
|
||
{entries.map(([key, value]) => {
|
||
const inputId = `field-${key}`
|
||
if (typeof value === 'boolean') {
|
||
return (
|
||
<label key={key} className="an-checkbox-row">
|
||
<input
|
||
type="checkbox"
|
||
checked={value}
|
||
onChange={(event) => onDraftChange(setDraftField(draft, key, event.target.checked))}
|
||
/>
|
||
{fieldLabelElement(key)}
|
||
</label>
|
||
)
|
||
}
|
||
if (typeof value === 'number') {
|
||
return (
|
||
<label key={key} className="an-field" htmlFor={inputId}>
|
||
{fieldLabelElement(key)}
|
||
<input
|
||
id={inputId}
|
||
className="an-input"
|
||
type="number"
|
||
value={Number.isFinite(value) ? value : 0}
|
||
onChange={(event) => onDraftChange(setDraftField(draft, key, Number(event.target.value)))}
|
||
/>
|
||
</label>
|
||
)
|
||
}
|
||
if (typeof value === 'string' || value === null || value === undefined) {
|
||
return (
|
||
<label key={key} className="an-field" htmlFor={inputId}>
|
||
{fieldLabelElement(key)}
|
||
<input
|
||
id={inputId}
|
||
className="an-input"
|
||
value={text(value, '')}
|
||
onChange={(event) => onDraftChange(setDraftField(draft, key, event.target.value))}
|
||
/>
|
||
</label>
|
||
)
|
||
}
|
||
return (
|
||
<label key={key} className="an-field an-field--wide" htmlFor={inputId}>
|
||
{fieldLabelElement(key)}
|
||
<Textarea
|
||
id={inputId}
|
||
className="an-json-editor an-json-editor--compact"
|
||
value={formatRaw(value)}
|
||
onChange={(event) => {
|
||
try {
|
||
onDraftChange(setDraftField(draft, key, JSON.parse(event.target.value)))
|
||
} catch {
|
||
onDraftChange(setDraftField(draft, key, event.target.value))
|
||
}
|
||
}}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function sectionSummary(states: SectionState[]) {
|
||
const online = states.filter((state) => state.ok).length
|
||
const rows = states.reduce((sum, state) => sum + state.rows.length, 0)
|
||
const failing = states.filter((state) => !state.ok).length
|
||
return { online, rows, failing }
|
||
}
|
||
|
||
function DetailFields({ record }: { record: AnyRecord }) {
|
||
const entries = Object.entries(cleanRecord(record))
|
||
.filter(([key]) => !MARKDOWN_CONTENT_KEYS.has(key))
|
||
.filter(([, value]) => value === null || ['string', 'number', 'boolean'].includes(typeof value))
|
||
.slice(0, 14)
|
||
|
||
if (!entries.length) return null
|
||
return (
|
||
<dl className="an-detail-list">
|
||
{entries.map(([key, value]) => (
|
||
<div key={key}>
|
||
<dt>{fieldLabel(key)}</dt>
|
||
<dd>{semanticLabel(value)}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
)
|
||
}
|
||
|
||
const MARKDOWN_CONTENT_KEYS = new Set(['content_markdown', 'markdown', 'answer_markdown', 'brief_markdown', 'rendered_markdown'])
|
||
|
||
function markdownContent(record: AnyRecord) {
|
||
for (const key of MARKDOWN_CONTENT_KEYS) {
|
||
const value = record[key]
|
||
if (typeof value === 'string' && value.trim()) return value
|
||
}
|
||
return ''
|
||
}
|
||
|
||
function DetailMarkdownDocument({ record }: { record: AnyRecord }) {
|
||
const markdown = markdownContent(record)
|
||
if (!markdown) return null
|
||
return (
|
||
<section className="an-markdown-doc">
|
||
<MarkdownRenderer markdown={markdown} className="an-markdown-doc__renderer" />
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function defaultColumns(onSelect: (record: TableRecord) => void): Array<ColumnDef<TableRecord>> {
|
||
return [
|
||
{
|
||
id: 'name',
|
||
header: '名称',
|
||
size: 260,
|
||
cell: ({ row }) => (
|
||
<button type="button" className="an-table-link" onClick={() => onSelect(row.original)}>
|
||
{recordTitle(row.original)}
|
||
</button>
|
||
),
|
||
},
|
||
{ id: 'module', header: '模块', size: 150, cell: ({ row }) => row.original.__module },
|
||
{
|
||
id: 'status',
|
||
header: '状态',
|
||
size: 130,
|
||
cell: ({ row }) => <StatusText tone={statusTone(recordStatus(row.original))}>{recordStatus(row.original)}</StatusText>,
|
||
},
|
||
{ id: 'metric', header: '指标', size: 220, cell: ({ row }) => <span className="an-muted-text">{semanticLabel(recordMetric(row.original))}</span> },
|
||
{ id: 'updated', header: '更新时间', size: 180, cell: ({ row }) => row.original.__time },
|
||
]
|
||
}
|
||
|
||
function ModuleTable({
|
||
rows,
|
||
selected,
|
||
onSelect,
|
||
columns,
|
||
selection,
|
||
loading,
|
||
}: {
|
||
rows: TableRecord[]
|
||
selected: TableRecord | null
|
||
onSelect: (record: TableRecord) => void
|
||
columns?: Array<ColumnDef<TableRecord>>
|
||
selection?: {
|
||
selectedRowIds: Set<string>
|
||
onToggleAllVisible: (rowIds: string[]) => void
|
||
onToggleRow: (rowId: string, row: TableRecord) => void
|
||
getCheckboxLabel?: (row: TableRecord) => string
|
||
isRowSelectable?: (row: TableRecord) => boolean
|
||
}
|
||
loading?: boolean
|
||
}) {
|
||
const tableColumns = useMemo(() => columns || defaultColumns(onSelect), [columns, onSelect])
|
||
return (
|
||
<DataTable
|
||
className="an-resource-table"
|
||
columns={tableColumns}
|
||
data={rows}
|
||
emptyText="当前模块暂无数据"
|
||
getRowClassName={(row) => row.__rowId === selected?.__rowId ? 'is-selected' : undefined}
|
||
getRowId={(row) => row.__rowId}
|
||
loading={loading}
|
||
onRowClick={onSelect}
|
||
selection={selection}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function SectionTabs({
|
||
sections,
|
||
states,
|
||
activeKey,
|
||
onChange,
|
||
}: {
|
||
sections: SectionConfig[]
|
||
states: SectionState[]
|
||
activeKey: string
|
||
onChange: (key: string) => void
|
||
}) {
|
||
return (
|
||
<div className="an-section-tabs" role="tablist" aria-label="模块分区">
|
||
{sections.map((section) => {
|
||
const state = states.find((item) => item.section.key === section.key)
|
||
const active = activeKey === section.key
|
||
return (
|
||
<button
|
||
key={section.key}
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={active}
|
||
className={active ? 'an-section-tab is-active' : 'an-section-tab'}
|
||
data-admin-search-target={`section:${section.key}`}
|
||
data-admin-search-text={section.label}
|
||
onClick={() => onChange(section.key)}
|
||
>
|
||
<span className={state ? state.ok ? 'an-section-tab__dot an-section-tab__dot--success' : 'an-section-tab__dot an-section-tab__dot--danger' : 'an-section-tab__dot an-section-tab__dot--neutral'} />
|
||
<span>{section.label}</span>
|
||
<strong>{state ? state.rows.length : '-'}</strong>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type HierarchyGroup = {
|
||
key: string
|
||
label: string
|
||
description?: string
|
||
status?: string
|
||
count?: number
|
||
record: AnyRecord
|
||
children?: HierarchyGroup[]
|
||
}
|
||
|
||
type TvSourceKind = 'builtin' | 'collected' | 'custom'
|
||
|
||
const DEFAULT_TV_SOURCE_ID = 'cgtn-en'
|
||
|
||
type FieldConfig = {
|
||
key: string
|
||
label: string
|
||
type?: 'text' | 'number' | 'boolean' | 'textarea' | 'select' | 'secret'
|
||
options?: Array<{ value: string; label: string }>
|
||
placeholder?: string
|
||
disabled?: boolean
|
||
help?: string
|
||
wide?: boolean
|
||
secretVisible?: boolean
|
||
onToggleSecret?: (visible: boolean) => void
|
||
}
|
||
|
||
const fieldLabels: Record<string, string> = {
|
||
key: '键',
|
||
label: '标签',
|
||
title: '标题',
|
||
kicker: '眉标',
|
||
version: '版本',
|
||
default_source_id: '默认频道',
|
||
auto_fallback: '自动回退',
|
||
id: '标识',
|
||
name: '名称',
|
||
provider: '提供方',
|
||
default_provider: '默认提供方',
|
||
region: '地区',
|
||
language: '语言',
|
||
source_type: '播放类型',
|
||
embed_url: '嵌入地址',
|
||
stream_url: '播放流地址',
|
||
homepage_url: '主页地址',
|
||
poster_url: '封面地址',
|
||
youtube_video_id: 'YouTube 视频 ID',
|
||
youtube_channel: 'YouTube 频道',
|
||
is_enabled: '启用',
|
||
is_fallback: '可回退',
|
||
sort_order: '排序',
|
||
notes: '备注',
|
||
logo_src: 'Logo 地址',
|
||
title_src: '标题图地址',
|
||
title_text: '标题文字',
|
||
subtitle: '副标题',
|
||
description: '描述',
|
||
aria_label: '无障碍标签',
|
||
title_alt: '标题图替代文本',
|
||
updated_at: '更新时间',
|
||
created_at: '创建时间',
|
||
frequency_minutes: '采集间隔(分钟)',
|
||
priority: '优先级',
|
||
is_active: '启用',
|
||
enabled: '启用',
|
||
active: '启用',
|
||
status: '状态',
|
||
state: '状态',
|
||
source: '来源',
|
||
display_name: '显示名称',
|
||
module: '层级',
|
||
product: '产品',
|
||
frequency: '频率',
|
||
last_run_at: '上次执行',
|
||
next_run_at: '下次执行',
|
||
last_status: '上次状态',
|
||
last_error: '上次错误',
|
||
is_free: '免费',
|
||
requires_credentials: '需要凭证',
|
||
credential_provider: '凭证提供方',
|
||
credential_status: '凭证状态',
|
||
default_url: '默认地址',
|
||
endpoint: '接口地址',
|
||
url: '地址',
|
||
headers: '请求头',
|
||
config: '配置参数',
|
||
auth_type: '认证方式',
|
||
auth_config: '认证配置',
|
||
auth_configured: '认证状态',
|
||
datasource_config_id: '采集器配置',
|
||
target_schema: '目标 Schema',
|
||
sample_payload: '采样 Payload',
|
||
mapping_json: '映射 JSON',
|
||
sample_payload_hash: '采样 Hash',
|
||
validation_status: '校验状态',
|
||
is_overridden: '覆盖内置配置',
|
||
service_url: '服务地址',
|
||
service_token: '服务令牌',
|
||
provider_api: '接口协议',
|
||
base_url: '基础地址',
|
||
model: '模型',
|
||
api_key: 'API Key',
|
||
client_id: 'Client ID',
|
||
client_secret: 'Client Secret',
|
||
max_tokens: '最大 Token',
|
||
anthropic_version: 'Anthropic 版本',
|
||
timeout_seconds: '超时(秒)',
|
||
retry_attempts: '重试次数',
|
||
providers: '供应商配置',
|
||
models: '模型列表',
|
||
max_results: '最大结果数',
|
||
endpoint_path: '接口路径',
|
||
search_depth: '搜索深度',
|
||
engine: '搜索引擎',
|
||
include_answer: '包含答案',
|
||
include_raw_content: '包含原始内容',
|
||
include_text: '包含正文',
|
||
categories: '分类',
|
||
engines: '搜索引擎列表',
|
||
search_path: '搜索路径',
|
||
scrape_path: '抓取路径',
|
||
scrape_formats: '抓取格式',
|
||
languages: '语言',
|
||
output_format: '输出格式',
|
||
max_file_size_mb: '最大文件(MB)',
|
||
system_name: '系统名称',
|
||
refresh_interval: '刷新间隔(秒)',
|
||
data_retention_days: '数据保留天数',
|
||
max_concurrent_tasks: '最大并发任务数',
|
||
auto_refresh: '自动刷新',
|
||
demo_mode: '演示模式',
|
||
email_enabled: '启用邮件通知',
|
||
email_address: '通知邮箱',
|
||
critical_alerts: '严重告警通知',
|
||
warning_alerts: '警告告警通知',
|
||
daily_summary: '每日摘要',
|
||
session_timeout: '会话超时(分钟)',
|
||
max_login_attempts: '最大登录尝试次数',
|
||
password_policy: '密码策略',
|
||
host: '主机',
|
||
port: '端口',
|
||
username: '用户名',
|
||
password: '密码',
|
||
use_tls: '使用 TLS',
|
||
use_ssl: '使用 SSL',
|
||
from_email: '发件邮箱',
|
||
from_name: '发件人名称',
|
||
logo_alt: 'Logo 替代文本',
|
||
meta: '信息条目',
|
||
credits: '出品信息',
|
||
links: '链接',
|
||
}
|
||
|
||
const fieldHelp: Record<string, string> = {
|
||
demo_mode: '开启后访问智能星球会直接显示 OOBE 引导,不再要求首次采集条件,也会忽略本机“先浏览”临时跳过。',
|
||
}
|
||
|
||
function fieldLabel(key: string) {
|
||
return fieldLabels[key] || key.replace(/_/g, ' ')
|
||
}
|
||
|
||
function fieldLabelElement(key: string) {
|
||
const help = fieldHelp[key]
|
||
if (!help) return fieldLabel(key)
|
||
return (
|
||
<span className="an-field-label-help" title={help}>
|
||
<span>{fieldLabel(key)}</span>
|
||
<CircleHelp size={13} aria-hidden="true" />
|
||
</span>
|
||
)
|
||
}
|
||
|
||
function semanticLabel(value: unknown) {
|
||
const raw = text(value, '')
|
||
const lower = raw.toLowerCase()
|
||
const labels: Record<string, string> = {
|
||
success: '成功',
|
||
failed: '失败',
|
||
running: '运行中',
|
||
pending: '等待中',
|
||
queued: '排队中',
|
||
idle: '空闲',
|
||
done: '完成',
|
||
stopped: '已停止',
|
||
unknown: '未知',
|
||
valid: '有效',
|
||
invalid: '无效',
|
||
draft: '草稿',
|
||
preset: '预设',
|
||
saved: '已保存',
|
||
brief: '简报',
|
||
enabled: '启用',
|
||
active: '启用',
|
||
disabled: '停用',
|
||
true: '是',
|
||
false: '否',
|
||
ok: '正常',
|
||
empty: '空',
|
||
missing: '缺失',
|
||
source_unavailable: '日志源不可用',
|
||
docker_unavailable: 'Docker 不可用',
|
||
system_db: '系统事件',
|
||
audit_db: '审计事件',
|
||
}
|
||
return labels[lower] || raw || '-'
|
||
}
|
||
|
||
function normalizeStatusLabel(value: unknown, fallback = '默认') {
|
||
const raw = text(value, fallback).trim()
|
||
const lower = raw.toLowerCase()
|
||
const semantic = semanticLabel(raw)
|
||
if (semantic !== raw) return semantic
|
||
if (raw === '-' || /(none|null|undefined|unknown)/.test(lower)) return '默认'
|
||
if (/(default|默认)/.test(lower)) return '默认'
|
||
if (/(configured|valid|healthy|active|enabled|success|true|ok|running|connected|已配置|启用|可用|已读取)/.test(lower)) return '已配置'
|
||
if (/(disabled|false|missing|empty|未配置|停用|禁用|可选)/.test(lower)) return '未配置'
|
||
if (/(failed|error|invalid|critical|配置错误|失败|错误)/.test(lower)) return '配置错误'
|
||
return raw
|
||
}
|
||
|
||
function statusPriority(status: unknown) {
|
||
const normalized = normalizeStatusLabel(status)
|
||
if (normalized === '默认') return 0
|
||
if (normalized === '配置错误') return 1
|
||
if (normalized === '已配置') return 2
|
||
if (normalized === '未配置') return 3
|
||
return 4
|
||
}
|
||
|
||
function sortGroupsByStatus<T extends { status?: string; label: string; children?: HierarchyGroup[] }>(groups: T[]): T[] {
|
||
return groups.map((group): T => {
|
||
if (!Array.isArray(group.children)) return group
|
||
return { ...group, children: sortGroupsByStatus(group.children) } as T
|
||
}).sort((a, b) => {
|
||
const statusDelta = statusPriority(a.status) - statusPriority(b.status)
|
||
return statusDelta || a.label.localeCompare(b.label)
|
||
})
|
||
}
|
||
|
||
function listPayload(payload: unknown, keys: string[] = []) {
|
||
if (Array.isArray(payload)) return payload.filter(isObjectRecord)
|
||
if (!isObjectRecord(payload)) return []
|
||
for (const key of keys) {
|
||
const nested = payload[key]
|
||
if (Array.isArray(nested)) return nested.filter(isObjectRecord)
|
||
}
|
||
if (Array.isArray(payload.data)) return payload.data.filter(isObjectRecord)
|
||
if (Array.isArray(payload.items)) return payload.items.filter(isObjectRecord)
|
||
if (Array.isArray(payload.results)) return payload.results.filter(isObjectRecord)
|
||
if (Array.isArray(payload.schemas)) return payload.schemas.filter(isObjectRecord)
|
||
if (Array.isArray(payload.mappings)) return payload.mappings.filter(isObjectRecord)
|
||
return []
|
||
}
|
||
|
||
function mappingRows(payload: unknown) {
|
||
return listPayload(payload, ['mappings', 'items', 'data']).map((row) => ({
|
||
...row,
|
||
__module: '映射模板',
|
||
__status: normalizeStatusLabel(row.validation_status || row.status || row.is_active, '未配置'),
|
||
__metric: pick(row, ['target_schema', 'sample_payload_hash'], '-'),
|
||
}))
|
||
}
|
||
|
||
function targetSchemaRows(payload: unknown) {
|
||
return listPayload(payload, ['schemas', 'target_schemas', 'items', 'data']).map((row) => ({
|
||
...row,
|
||
__module: '目标 Schema',
|
||
__status: normalizeStatusLabel(row.status || row.is_active, '已配置'),
|
||
__metric: `${Object.keys(row).length} 字段`,
|
||
}))
|
||
}
|
||
|
||
const datasourceSecretKeys = new Set(['api_key', 'client_secret', 'password', 'token', 'access_token', 'bearer_token'])
|
||
const datasourceCredentialObjectKeys = new Set(['auth_config', 'auth_configured'])
|
||
|
||
function normalizeDatasourceConfigPayload(payload: AnyRecord) {
|
||
const next = { ...payload }
|
||
const authConfig = isObjectRecord(next.auth_config) ? { ...next.auth_config } : {}
|
||
const apiKey = text(next.api_key, '').trim()
|
||
const apiKeyName = text(next.api_key_name, '').trim()
|
||
const apiKeyLocation = text(next.api_key_location, '').trim()
|
||
const clientId = text(next.client_id, '').trim()
|
||
const clientSecret = text(next.client_secret, '').trim()
|
||
const username = text(next.username, '').trim()
|
||
const password = text(next.password, '').trim()
|
||
const sourceName = text(next.name || next.source, '').trim()
|
||
if (apiKey && !isMaskedSecretDraft(apiKey)) {
|
||
authConfig.api_key = apiKey
|
||
}
|
||
if (clientId) authConfig.client_id = clientId
|
||
if (clientSecret && !isMaskedSecretDraft(clientSecret)) authConfig.client_secret = clientSecret
|
||
if (username) authConfig.username = username
|
||
if (password && !isMaskedSecretDraft(password)) authConfig.password = password
|
||
if (apiKeyName) {
|
||
authConfig.key_name = apiKeyName
|
||
authConfig.param_name = apiKeyName
|
||
}
|
||
if (apiKeyLocation) authConfig.location = apiKeyLocation
|
||
delete next.api_key
|
||
delete next.api_key_name
|
||
delete next.api_key_location
|
||
delete next.client_id
|
||
delete next.client_secret
|
||
delete next.username
|
||
delete next.password
|
||
if (sourceName === 'barentswatch_vessels') next.auth_type = 'oauth_client'
|
||
if (sourceName === 'spacetrack_tle') next.auth_type = 'basic'
|
||
next.auth_config = authConfig
|
||
return next
|
||
}
|
||
|
||
function datasourceConfigCredentialKind(record: AnyRecord) {
|
||
const provider = text(record.credential_provider, '').toLowerCase()
|
||
const name = text(record.name || record.source, '').toLowerCase()
|
||
if (provider === 'barentswatch' || name === 'barentswatch_vessels') return 'oauth_client'
|
||
if (provider === 'aisstream' || name === 'aisstream_vessels') return 'api_key'
|
||
if (provider === 'spacetrack' || name === 'spacetrack_tle') return 'basic'
|
||
if (record.requires_credentials || record.api_key !== undefined) return 'api_key'
|
||
return ''
|
||
}
|
||
|
||
function shouldHideDatasourceConfigObjectField(record: AnyRecord, endpoint: string, key: string) {
|
||
return endpoint === 'configsAll' && datasourceCredentialObjectKeys.has(key) && Boolean(datasourceConfigCredentialKind(record))
|
||
}
|
||
|
||
function credentialGuideProvider(record: AnyRecord) {
|
||
const provider = text(record.credential_provider || record.provider, '').trim().toLowerCase()
|
||
if (['barentswatch', 'aisstream'].includes(provider)) return provider
|
||
if (provider) return provider
|
||
const name = text(record.source || record.source_id || record.name, '').trim().toLowerCase()
|
||
if (name.includes('barentswatch')) return 'barentswatch'
|
||
if (name.includes('aisstream')) return 'aisstream'
|
||
return name.replace(/\s+/g, '_')
|
||
}
|
||
|
||
function datasourceRows(payload: unknown) {
|
||
return dataArray(payload).map(datasourceTableRow)
|
||
}
|
||
|
||
function datasourceFiltersFromSearch(search: string): DatasourceFilters {
|
||
const params = new URLSearchParams(search)
|
||
return {
|
||
product: params.get('product') || DEFAULT_DATASOURCE_FILTERS.product,
|
||
module: params.get('module') || DEFAULT_DATASOURCE_FILTERS.module,
|
||
isActive: params.get('is_active') ?? DEFAULT_DATASOURCE_FILTERS.isActive,
|
||
runStatus: params.get('run_status') || DEFAULT_DATASOURCE_FILTERS.runStatus,
|
||
dataStatus: params.get('data_status') || DEFAULT_DATASOURCE_FILTERS.dataStatus,
|
||
}
|
||
}
|
||
|
||
function datasourceFiltersToParams(filters: DatasourceFilters) {
|
||
const params: AnyRecord = { include_endpoint: false }
|
||
if (filters.product) params.product = filters.product
|
||
if (filters.module) params.module = filters.module
|
||
if (filters.isActive) params.is_active = filters.isActive === 'true'
|
||
if (filters.runStatus) params.run_status = filters.runStatus
|
||
if (filters.dataStatus === 'collected') params.collected = true
|
||
if (filters.dataStatus === 'uncollected') params.collected = false
|
||
return params
|
||
}
|
||
|
||
function datasourceRowMatchesFilters(row: AnyRecord, filters: DatasourceFilters) {
|
||
if (filters.product && text(row.product, '') !== filters.product) return false
|
||
if (filters.module && text(row.module, '') !== filters.module) return false
|
||
if (filters.isActive === 'true' && row.is_active === false) return false
|
||
if (filters.isActive === 'false' && row.is_active !== false) return false
|
||
if (filters.dataStatus === 'collected' && !row.has_collected_data && Number(row.collected_records || 0) <= 0) return false
|
||
if (filters.dataStatus === 'uncollected' && (row.has_collected_data || Number(row.collected_records || 0) > 0)) return false
|
||
if (filters.runStatus) {
|
||
const status = datasourceStatus(row)
|
||
if (filters.runStatus === 'running' && !row.is_running) return false
|
||
if (filters.runStatus === 'not_run' && status !== 'idle') return false
|
||
if (!['running', 'not_run'].includes(filters.runStatus) && status !== filters.runStatus) return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
function snapshotRows(payload: unknown) {
|
||
const snapshots = dataArray(payload).map((row) => ({
|
||
...row,
|
||
__title: pick(row, ['source', 'datasource_name', 'id'], '采集快照'),
|
||
__module: '采集快照',
|
||
__status: pick(row, ['status', 'is_current'], '-'),
|
||
__metric: typeof row.record_count === 'number' ? `${row.record_count} records` : pick(row, ['record_count'], '-'),
|
||
__time: pick(row, ['completed_at', 'started_at', 'created_at'], '-'),
|
||
}))
|
||
const grouped = new Map<string, AnyRecord[]>()
|
||
snapshots.forEach((row) => {
|
||
const key = pick(row, ['source', 'datasource_id', 'datasource_name'], pick(row, ['id'], 'unknown'))
|
||
grouped.set(key, [...(grouped.get(key) || []), row])
|
||
})
|
||
return Array.from(grouped.entries()).map(([key, group]) => {
|
||
const ordered = sortSnapshotsByTime(group)
|
||
const current = ordered.find((snapshot) => snapshot.is_current === true) || ordered[0] || {}
|
||
const source = pick(current, ['source'], key)
|
||
const title = pick(current, ['source', 'datasource_name'], key)
|
||
return {
|
||
...current,
|
||
source,
|
||
__rowId: `snapshot-source-${source}`,
|
||
__title: title,
|
||
__module: '采集快照',
|
||
__status: pick(current, ['status', 'is_current'], '-'),
|
||
__metric: `${ordered.length} 个快照`,
|
||
__time: pick(current, ['completed_at', 'started_at', 'created_at'], '-'),
|
||
__snapshots: ordered,
|
||
__snapshotSourceKey: source,
|
||
}
|
||
})
|
||
}
|
||
|
||
function snapshotTime(record: AnyRecord) {
|
||
return pick(record, ['completed_at', 'started_at', 'created_at', 'reference_date'], '')
|
||
}
|
||
|
||
function snapshotTimestamp(record: AnyRecord) {
|
||
const raw = snapshotTime(record)
|
||
const parsed = raw ? Date.parse(raw) : Number.NaN
|
||
if (Number.isFinite(parsed)) return parsed
|
||
const id = Number(record.id || 0)
|
||
return Number.isFinite(id) ? id : 0
|
||
}
|
||
|
||
function sortSnapshotsByTime(rows: AnyRecord[]) {
|
||
return [...rows].sort((a, b) => snapshotTimestamp(b) - snapshotTimestamp(a))
|
||
}
|
||
|
||
function snapshotId(record: AnyRecord) {
|
||
return pick(record, ['id', 'snapshot_key', 'task_id'], '')
|
||
}
|
||
|
||
function formatSnapshotTime(record: AnyRecord) {
|
||
const raw = snapshotTime(record)
|
||
if (!raw) return '无时间'
|
||
const parsed = new Date(raw)
|
||
if (Number.isNaN(parsed.getTime())) return raw
|
||
return parsed.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
hour12: false,
|
||
})
|
||
}
|
||
|
||
function snapshotOptionLabel(record: AnyRecord) {
|
||
const current = record.is_current === true ? '当前 · ' : ''
|
||
const status = semanticLabel(recordStatus(record))
|
||
const count = typeof record.record_count === 'number' ? `${record.record_count} records` : pick(record, ['record_count'], '0 records')
|
||
return `${current}${formatSnapshotTime(record)} · ${status} · ${count}`
|
||
}
|
||
|
||
const emptyRows = () => []
|
||
|
||
const placeholderSectionKeys = new Set(['basemap', 'layer_resources', 'models_3d', 'news_anchor_strategy'])
|
||
|
||
function isPlaceholderSection(key: string) {
|
||
return placeholderSectionKeys.has(key)
|
||
}
|
||
|
||
function tvEmbeddedUrl(source: AnyRecord) {
|
||
if (!source) return ''
|
||
const sourceType = text(source.source_type, '')
|
||
if (sourceType === 'youtube' && text(source.youtube_video_id, '')) {
|
||
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(text(source.youtube_video_id, ''))}?autoplay=1&mute=1&playsinline=1&rel=0`
|
||
}
|
||
if (sourceType === 'external' || sourceType === 'video' || sourceType === 'hls') return ''
|
||
return text(source.embed_url || source.homepage_url, '')
|
||
}
|
||
|
||
function tvProxyVideoUrl(source: AnyRecord) {
|
||
const sourceType = text(source.source_type, '')
|
||
if (sourceType !== 'video' && sourceType !== 'hls') return ''
|
||
const sourceUrl = text(source.stream_url || source.embed_url, '')
|
||
return sourceUrl ? `${apiPath('/tv/proxy')}?url=${encodeURIComponent(sourceUrl)}` : ''
|
||
}
|
||
|
||
function tvExternalUrl(source: AnyRecord) {
|
||
return text(source.homepage_url || source.youtube_channel || source.embed_url || source.stream_url, '')
|
||
}
|
||
|
||
function tvSettingsFromRaw(raw: unknown) {
|
||
if (isObjectRecord(raw) && Array.isArray(raw.endpoints)) {
|
||
return objectAt(endpointData(raw, 'settingsTv'), 'tv')
|
||
}
|
||
return objectAt(raw, 'tv')
|
||
}
|
||
|
||
function publicTvFromRaw(raw: unknown) {
|
||
return isObjectRecord(raw) && Array.isArray(raw.endpoints) ? endpointData(raw, 'publicTv') : {}
|
||
}
|
||
|
||
function tvDefaultSourceId(raw: unknown) {
|
||
const settingsTv = tvSettingsFromRaw(raw)
|
||
const publicTv = publicTvFromRaw(raw)
|
||
return text(settingsTv.default_source_id || (publicTv as AnyRecord).default_source_id || DEFAULT_TV_SOURCE_ID, DEFAULT_TV_SOURCE_ID)
|
||
}
|
||
|
||
function tvSourceKind(source: AnyRecord): TvSourceKind {
|
||
if (text(source.collector_source, '') !== '') return 'collected'
|
||
const id = text(source.id, '')
|
||
if (id.startsWith('manual-tv-') || id.startsWith('custom-tv-')) return 'custom'
|
||
return 'builtin'
|
||
}
|
||
|
||
function tvSourceKindLabel(source: AnyRecord) {
|
||
const kind = text(source.__tvKind, '') || tvSourceKind(source)
|
||
if (kind === 'collected') return '采集'
|
||
if (kind === 'custom') return '自定义'
|
||
return '内置'
|
||
}
|
||
|
||
function tvSourceStatusLabel(source: AnyRecord, defaultSourceId = '') {
|
||
if (source.__isDraft) return '新建'
|
||
if (defaultSourceId && text(source.id, '') === defaultSourceId) return '默认'
|
||
const rawStatus = text(source.status || source.__status, '')
|
||
if (/(error|failed|invalid|错误|失败)/i.test(rawStatus)) return '错误'
|
||
return source.is_enabled === false ? '停用' : '启用'
|
||
}
|
||
|
||
function normalizeTvAdminSource(source: AnyRecord, options: { fromSettings?: boolean; defaultSourceId?: string } = {}) {
|
||
const normalized = { ...source }
|
||
const kind = tvSourceKind(normalized)
|
||
normalized.__tvKind = kind
|
||
normalized.__tvPersistedInSettings = Boolean(options.fromSettings)
|
||
normalized.__module = tvSourceKindLabel(normalized)
|
||
normalized.__status = tvSourceStatusLabel(normalized, options.defaultSourceId)
|
||
normalized.__title = pick(normalized, ['name', 'id'], 'TV Source')
|
||
return normalized
|
||
}
|
||
|
||
function tvAdminSources(raw: unknown) {
|
||
const settingsTv = tvSettingsFromRaw(raw)
|
||
const publicTv = publicTvFromRaw(raw)
|
||
const defaultSourceId = tvDefaultSourceId(raw)
|
||
const configuredSources = Array.isArray(settingsTv.sources) ? settingsTv.sources.filter(isObjectRecord) : []
|
||
const publicSources = Array.isArray((publicTv as AnyRecord).sources) ? ((publicTv as AnyRecord).sources as unknown[]).filter(isObjectRecord) : []
|
||
const byId = new Map<string, AnyRecord>()
|
||
configuredSources.forEach((source) => {
|
||
byId.set(text(source.id, ''), normalizeTvAdminSource(source, { fromSettings: true, defaultSourceId }))
|
||
})
|
||
publicSources.forEach((source) => {
|
||
const id = text(source.id, '')
|
||
if (!id || byId.has(id)) return
|
||
byId.set(id, normalizeTvAdminSource(source, { fromSettings: false, defaultSourceId }))
|
||
})
|
||
return Array.from(byId.values()).sort((a, b) => {
|
||
const left = Number(a.sort_order ?? 9999)
|
||
const right = Number(b.sort_order ?? 9999)
|
||
if (left !== right) return left - right
|
||
return text(a.name, '').localeCompare(text(b.name, ''))
|
||
})
|
||
}
|
||
|
||
function makeNewTvSourceGroup(index: number): HierarchyGroup {
|
||
return {
|
||
key: 'tv:new-source',
|
||
label: '新增直播源',
|
||
description: '自定义',
|
||
status: '新建',
|
||
count: 12,
|
||
record: {
|
||
id: `manual-tv-${Date.now()}`,
|
||
name: `新闻直播源 ${index}`,
|
||
provider: 'Manual',
|
||
region: 'Global',
|
||
language: 'und',
|
||
source_type: 'hls',
|
||
stream_url: '',
|
||
embed_url: '',
|
||
homepage_url: '',
|
||
poster_url: '',
|
||
youtube_video_id: '',
|
||
youtube_channel: '',
|
||
is_enabled: true,
|
||
is_fallback: false,
|
||
sort_order: index * 10,
|
||
collector_source: null,
|
||
notes: '',
|
||
__tvKind: 'custom',
|
||
__isDraft: true,
|
||
__module: '自定义',
|
||
__status: '新建',
|
||
},
|
||
}
|
||
}
|
||
|
||
function makeNewDatasourceConfigGroup(index: number): HierarchyGroup {
|
||
const name = `custom_collector_${index}`
|
||
return {
|
||
key: 'collection:new-config',
|
||
label: '新增采集器',
|
||
description: '采集器配置',
|
||
status: '新建',
|
||
count: 12,
|
||
record: {
|
||
name,
|
||
description: '',
|
||
source_type: 'api',
|
||
endpoint: '',
|
||
auth_type: 'none',
|
||
api_key: '',
|
||
api_key_name: 'X-API-Key',
|
||
api_key_location: 'header',
|
||
auth_config: {},
|
||
headers: {},
|
||
config: { timeout: 30, retry: 3 },
|
||
is_active: true,
|
||
__sourceEndpoint: 'configsAll',
|
||
__sourceLabel: '采集器配置',
|
||
__isDraft: true,
|
||
__module: '采集器配置',
|
||
__status: '新建',
|
||
},
|
||
}
|
||
}
|
||
|
||
function makeNewMappingGroup(index: number, datasourceConfigId = ''): HierarchyGroup {
|
||
return {
|
||
key: 'collection:new-mapping',
|
||
label: '新增 Schema 映射',
|
||
description: '映射模板',
|
||
status: '新建',
|
||
count: 7,
|
||
record: {
|
||
datasource_config_id: datasourceConfigId,
|
||
target_schema: 'geo_points',
|
||
mapping_json: { fields: {} },
|
||
sample_payload: { id: 'sample', name: `Sample ${index}` },
|
||
sample_payload_hash: '',
|
||
validation_status: 'draft',
|
||
is_active: false,
|
||
__sourceEndpoint: 'mappings',
|
||
__sourceLabel: '映射模板',
|
||
__isDraft: true,
|
||
__module: '映射模板',
|
||
__status: '新建',
|
||
},
|
||
}
|
||
}
|
||
|
||
function EarthBrandPreview({ record }: { record: AnyRecord }) {
|
||
const logoSrc = text(record.logo_src, '/earth/assets/brand/earth-logo.png')
|
||
const titleSrc = text(record.title_src, '')
|
||
const titleText = text(record.title_text, '智能星球计划')
|
||
const titleAlt = text(record.title_alt, titleText)
|
||
const subtitle = text(record.subtitle, '现实层宇宙全息感知系统')
|
||
const description = text(record.description, '卫星 · 海底光缆 · 算力基础设施')
|
||
const ariaLabel = text(record.aria_label, '智能星球计划品牌标识')
|
||
|
||
return (
|
||
<div className="an-earth-brand-preview" aria-label="Earth 左上角品牌实际渲染预览">
|
||
<div className="an-earth-brand-preview__space">
|
||
<div className="earth-left-column">
|
||
<div className="hud-panel hud-panel-brand">
|
||
<div className="earth-brand earth-brand--zh" aria-label={ariaLabel}>
|
||
<img className="earth-brand__logo" src={logoSrc} alt="" aria-hidden="true" />
|
||
<div className="earth-brand__copy">
|
||
{titleSrc ? (
|
||
<img className="earth-brand__title" src={titleSrc} alt={titleAlt} />
|
||
) : (
|
||
<div className="earth-brand__title-text">{titleText}</div>
|
||
)}
|
||
<div className="earth-brand__meta">
|
||
<span className="earth-brand__subtitle">{subtitle}</span>
|
||
<span className="earth-brand__description">{description}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="an-earth-brand-preview__globe" aria-hidden="true" />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TVEarthPreview({ source, tv }: { source: AnyRecord; tv: AnyRecord }) {
|
||
const embeddedUrl = tvEmbeddedUrl(source)
|
||
const videoUrl = tvProxyVideoUrl(source)
|
||
const externalUrl = tvExternalUrl(source)
|
||
const playable = Boolean(embeddedUrl || videoUrl)
|
||
const sourceCount = Array.isArray(tv.sources) ? tv.sources.length : Number(tv.source_count || 0)
|
||
const latestUpdatedAt = text(tv.latest_updated_at || tv.generated_at, '')
|
||
const latestLabel = latestUpdatedAt
|
||
? `最近同步 ${new Date(latestUpdatedAt).toLocaleString('zh-CN', { hour12: false })}`
|
||
: '尚未同步'
|
||
const statusLabel = playable ? '直播加载中' : externalUrl ? '当前频道仅支持外部打开' : '暂无可播放直播源'
|
||
const statusClass = playable ? 'tv-panel-tag tv-panel-tag--status' : externalUrl ? 'tv-panel-tag tv-panel-tag--warning' : 'tv-panel-tag tv-panel-tag--error'
|
||
|
||
return (
|
||
<div className="an-tv-earth-preview" aria-label="Earth TV 实际渲染预览">
|
||
<div className="hud-panel hud-panel-media">
|
||
<div className="hud-panel__header">
|
||
<div className="hud-panel__title-group">
|
||
<span className="hud-panel-title hud-panel__title tv-panel-header-title">Live 新闻</span>
|
||
</div>
|
||
<div className="tv-panel-header-controls tv-panel-header-controls--live">
|
||
<select className="tv-panel-select" value={text(source.id, '')} aria-label="选择新闻直播源" onChange={() => undefined}>
|
||
<option value={text(source.id, '')}>
|
||
{text(source.name, '暂无可用频道')}
|
||
{text(source.id, '') === text(tv.default_source_id, '') ? ' · 默认' : ''}
|
||
</option>
|
||
</select>
|
||
<div className="tv-panel-toolbar-actions">
|
||
<button className="hud-panel__action hud-panel__action--refresh" type="button" title="刷新直播源" aria-label="刷新直播源">
|
||
<RefreshCw size={14} />
|
||
</button>
|
||
<button className="hud-panel__action hud-panel__action--external" type="button" title="访问官网" aria-label="访问官网" disabled={!externalUrl}>
|
||
<Globe2 size={14} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="tv-panel-content">
|
||
<section className="tv-tab-pane tv-tab-pane--active">
|
||
<div className="tv-panel-meta-wrap">
|
||
<div className="tv-panel-meta">
|
||
<div className="tv-panel-title-row">
|
||
<div className="tv-panel-title">{text(source.name, '暂无可用频道')}</div>
|
||
<span className="tv-panel-tag">{source.collector_source ? '采集' : '内置'}</span>
|
||
<span className={statusClass}>{statusLabel}</span>
|
||
</div>
|
||
<div className="tv-panel-subtitle">
|
||
{text(source.provider, '-')}{' · '}{text(source.region, '-')}{' · '}{text(source.language, '-')}{' · '}{text(source.source_type, '-')}
|
||
</div>
|
||
<div className="tv-panel-catalog">共 {sourceCount || 0} 个频道 · {latestLabel}</div>
|
||
<div className="tv-panel-notes">{text(source.notes, '支持后台配置默认源与采集器补充源。')}</div>
|
||
</div>
|
||
</div>
|
||
<div className="tv-panel-player">
|
||
{!playable ? (
|
||
<div className="tv-panel-empty">
|
||
{externalUrl ? '当前频道仅支持跳转官网或外部播放器打开。' : '暂无可播放直播源,请先在系统配置中添加频道。'}
|
||
</div>
|
||
) : null}
|
||
{embeddedUrl ? (
|
||
<iframe
|
||
className="tv-panel-iframe"
|
||
title="新闻直播"
|
||
src={embeddedUrl}
|
||
referrerPolicy="strict-origin-when-cross-origin"
|
||
allow="autoplay; fullscreen; picture-in-picture"
|
||
/>
|
||
) : null}
|
||
{videoUrl ? (
|
||
<video className="tv-panel-video" src={videoUrl} controls autoPlay muted playsInline poster={text(source.poster_url, '') || undefined} />
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function endpointData(raw: unknown, key: string) {
|
||
const endpoints = isObjectRecord(raw) && Array.isArray(raw.endpoints) ? raw.endpoints.filter(isObjectRecord) : []
|
||
return endpoints.find((endpoint) => endpoint.key === key)?.data
|
||
}
|
||
|
||
function draftRecord(draft: string, fallback: AnyRecord = {}) {
|
||
const parsed = parseJsonDraft(draft, fallback)
|
||
return isObjectRecord(parsed) ? parsed : fallback
|
||
}
|
||
|
||
function setNestedDraftField(draft: string, fallback: AnyRecord, key: string, value: unknown) {
|
||
const current = draftRecord(draft, fallback)
|
||
return JSON.stringify({ ...current, [key]: value }, null, 2)
|
||
}
|
||
|
||
function stripInternalFields(record: AnyRecord) {
|
||
return Object.fromEntries(Object.entries(record).filter(([key]) => !key.startsWith('__')))
|
||
}
|
||
|
||
function omitKeys(record: AnyRecord, keys: string[]) {
|
||
const blocked = new Set(keys)
|
||
return Object.fromEntries(Object.entries(record).filter(([key]) => !blocked.has(key)))
|
||
}
|
||
|
||
function hasConfiguredSecret(value: unknown) {
|
||
if (isObjectRecord(value)) return Boolean(value.configured || value.preview)
|
||
const raw = text(value, '').trim()
|
||
return Boolean(raw && !isMaskedSecretDraft(raw))
|
||
}
|
||
|
||
function hasRuntimeSecret(value: unknown) {
|
||
if (isObjectRecord(value)) return Boolean(value.configured || value.preview)
|
||
return Boolean(text(value, '').trim())
|
||
}
|
||
|
||
function hasConfiguredCredential(...values: unknown[]) {
|
||
return values.some(hasConfiguredSecret)
|
||
}
|
||
|
||
function secretSource(value: unknown) {
|
||
return isObjectRecord(value) ? text(value.source, '').toLowerCase() : ''
|
||
}
|
||
|
||
function isFallbackSecret(value: unknown) {
|
||
const source = secretSource(value)
|
||
return Boolean(source && /(env|environment|fallback|backend_env)/.test(source))
|
||
}
|
||
|
||
function hasStoredSecret(value: unknown) {
|
||
if (isObjectRecord(value)) {
|
||
if (isFallbackSecret(value)) return false
|
||
return hasConfiguredSecret(value)
|
||
}
|
||
const raw = text(value, '').trim()
|
||
return Boolean(raw && !isMaskedSecretDraft(raw))
|
||
}
|
||
|
||
function secretPreview(value: unknown) {
|
||
if (isObjectRecord(value)) {
|
||
if (value.configured) return maskSecretPreview(text(value.preview, '••••••••'), secretPreviewPrefix(value))
|
||
return ''
|
||
}
|
||
const raw = text(value, '')
|
||
return raw ? maskSecretPreview(raw) : ''
|
||
}
|
||
|
||
function secretPreviewPrefix(value: AnyRecord) {
|
||
return text(
|
||
value.prefix ||
|
||
value.key_prefix ||
|
||
value.preview_prefix ||
|
||
value.public_prefix ||
|
||
value.secret_prefix ||
|
||
'',
|
||
'',
|
||
).replace(/[-\s]+$/, '')
|
||
}
|
||
|
||
function maskSecretPreview(value: string, fallbackPrefix = '') {
|
||
const raw = value.trim()
|
||
if (!raw) return ''
|
||
const hyphenIndex = raw.indexOf('-')
|
||
if (hyphenIndex > 0) return `${raw.slice(0, hyphenIndex)}-${'•'.repeat(10)}`
|
||
const prefix = fallbackPrefix.trim().replace(/-+$/, '')
|
||
if (prefix) return `${prefix}-${'•'.repeat(10)}`
|
||
if (raw.startsWith('••••') || raw.startsWith('****')) return raw
|
||
return '•'.repeat(Math.min(Math.max(raw.length, 8), 16))
|
||
}
|
||
|
||
function actionErrorMessage(error: unknown, fallback = '接口请求失败') {
|
||
if (axios.isAxiosError(error)) {
|
||
const detail = error.response?.data?.detail || error.response?.data?.message
|
||
return describeResponseValue(detail, error.message || fallback)
|
||
}
|
||
return error instanceof Error ? error.message : fallback
|
||
}
|
||
|
||
function describeResponseValue(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)
|
||
try {
|
||
return JSON.stringify(value)
|
||
} catch {
|
||
return fallback
|
||
}
|
||
}
|
||
|
||
function connectionResult(data: unknown): { ok: boolean; message: string } {
|
||
if (!isObjectRecord(data)) return { ok: true, message: '当前配置可以连通。' }
|
||
const explicit = data.success ?? data.connected ?? data.ok ?? data.valid
|
||
const detail = describeResponseValue(data.message || data.detail || data.error || data.status, '')
|
||
const lower = detail.toLowerCase()
|
||
const hasFailureText = /(failed|fail|error|invalid|unauthorized|forbidden|auth|api key|apikey|not configured|missing|失败|错误|无效|未配置|鉴权|认证)/.test(lower)
|
||
if (explicit === false || hasFailureText) {
|
||
return { ok: false, message: detail || '当前配置连通性检查失败。' }
|
||
}
|
||
if (explicit === true) {
|
||
return { ok: true, message: detail || '当前配置可以连通。' }
|
||
}
|
||
return { ok: !hasFailureText, message: detail || '当前配置可以连通。' }
|
||
}
|
||
|
||
function isSecretDraftUnchanged(nextSecret: unknown, savedPreview?: unknown, revealedSecret?: unknown) {
|
||
const draft = text(nextSecret, '').trim()
|
||
const preview = text(savedPreview, '').trim()
|
||
const revealed = text(revealedSecret, '').trim()
|
||
if (!draft) return true
|
||
return (
|
||
Boolean(preview && draft === preview) ||
|
||
Boolean(revealed && draft === revealed) ||
|
||
isMaskedSecretDraft(draft)
|
||
)
|
||
}
|
||
|
||
function isMaskedSecretDraft(value: string) {
|
||
const draft = value.trim()
|
||
if (!draft) return false
|
||
if (draft.startsWith('••••') || draft.startsWith('****')) return true
|
||
const hyphenIndex = draft.indexOf('-')
|
||
const secretPart = hyphenIndex > 0 ? draft.slice(hyphenIndex + 1) : draft
|
||
return Boolean(secretPart) && Array.from(secretPart).every((char) => char === '*' || char === '•' || /\s/.test(char))
|
||
}
|
||
|
||
function sanitizeSecretDrafts(payload: AnyRecord, baseline: AnyRecord, keys: string[], revealed: AnyRecord = {}) {
|
||
const next = { ...payload }
|
||
keys.forEach((key) => {
|
||
if (isSecretDraftUnchanged(next[key], baseline[key], revealed[key])) next[key] = ''
|
||
})
|
||
return next
|
||
}
|
||
|
||
function escapeSearchSelector(value: string) {
|
||
if (typeof CSS !== 'undefined' && CSS.escape) return CSS.escape(value)
|
||
return value.replace(/["\\]/g, '\\$&')
|
||
}
|
||
|
||
function clearAdminSearchHighlight() {
|
||
document.querySelectorAll('.an-search-hit').forEach((element) => element.classList.remove('an-search-hit'))
|
||
document.querySelectorAll('.an-search-mark').forEach((element) => element.classList.remove('an-search-mark'))
|
||
document.querySelectorAll('.an-search-dimmed').forEach((element) => element.classList.remove('an-search-dimmed'))
|
||
}
|
||
|
||
function findAdminSearchElement({ target, field, highlight }: { target?: string; field?: string; highlight?: string }) {
|
||
const selectors = [
|
||
target && field ? `[data-admin-search-target="${escapeSearchSelector(`${target}:field:${field}`)}"]` : '',
|
||
field ? `[data-admin-search-field="${escapeSearchSelector(field)}"]` : '',
|
||
target ? `[data-admin-search-target="${escapeSearchSelector(target)}"]` : '',
|
||
].filter(Boolean)
|
||
for (const selector of selectors) {
|
||
const element = document.querySelector(selector)
|
||
if (element instanceof HTMLElement) return element
|
||
}
|
||
const needle = text(highlight || field || target, '').toLowerCase()
|
||
if (!needle) return null
|
||
const marked = Array.from(document.querySelectorAll<HTMLElement>('[data-admin-search-text]'))
|
||
.find((element) => text(element.dataset.adminSearchText, '').toLowerCase().includes(needle))
|
||
return marked || null
|
||
}
|
||
|
||
function selectedRuntimeProvider(config: AnyRecord) {
|
||
return text(config.provider || config.default_provider, '')
|
||
}
|
||
|
||
function credentialStatus({
|
||
explicitStatus,
|
||
configured,
|
||
isDefault,
|
||
}: {
|
||
explicitStatus?: unknown
|
||
configured: boolean
|
||
isDefault?: boolean
|
||
}) {
|
||
const normalized = normalizeStatusLabel(explicitStatus, '')
|
||
if (normalized === '配置错误') return '配置错误'
|
||
if (normalized === '未配置') return '未配置'
|
||
if (configured) return isDefault ? '默认' : '已配置'
|
||
return '未配置'
|
||
}
|
||
|
||
const TV_COMPACT_FIELD_KEYS = new Set([
|
||
'id',
|
||
'name',
|
||
'provider',
|
||
'region',
|
||
'language',
|
||
'source_type',
|
||
'is_enabled',
|
||
'is_fallback',
|
||
'sort_order',
|
||
'notes',
|
||
])
|
||
|
||
function FieldGrid({
|
||
record,
|
||
draft,
|
||
onDraftChange,
|
||
fields,
|
||
searchGroupKey,
|
||
}: {
|
||
record: AnyRecord
|
||
draft: string
|
||
onDraftChange: (value: string) => void
|
||
fields: FieldConfig[]
|
||
searchGroupKey?: string
|
||
}) {
|
||
const current = draftRecord(draft, record)
|
||
return (
|
||
<div className="an-field-grid">
|
||
{fields.map((field) => {
|
||
const value = current[field.key]
|
||
const className = field.wide ? 'an-field an-field--wide' : 'an-field'
|
||
const searchTarget = searchGroupKey ? `${searchGroupKey}:field:${field.key}` : `field:${field.key}`
|
||
const searchText = [field.label, field.key, text(value, '')].filter(Boolean).join(' ')
|
||
if (field.type === 'boolean') {
|
||
return (
|
||
<label key={field.key} className="an-checkbox-row an-field--wide" data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||
<input
|
||
type="checkbox"
|
||
checked={Boolean(value)}
|
||
disabled={field.disabled}
|
||
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.checked))}
|
||
/>
|
||
{field.label}
|
||
{field.help ? <small>{field.help}</small> : null}
|
||
</label>
|
||
)
|
||
}
|
||
if (field.type === 'select') {
|
||
return (
|
||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||
<span>{field.label}</span>
|
||
<select
|
||
className="an-input"
|
||
value={text(value, '')}
|
||
disabled={field.disabled}
|
||
onChange={(event) => onDraftChange(setNestedDraftField(draft, record, field.key, event.target.value))}
|
||
>
|
||
{(field.options || []).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||
</select>
|
||
</label>
|
||
)
|
||
}
|
||
if (field.type === 'textarea') {
|
||
return (
|
||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||
<span>{field.label}</span>
|
||
<Textarea
|
||
value={isObjectRecord(value) || Array.isArray(value) ? formatRaw(value) : text(value, '')}
|
||
placeholder={field.placeholder}
|
||
disabled={field.disabled}
|
||
onChange={(event) => {
|
||
const nextValue = event.target.value
|
||
try {
|
||
onDraftChange(setNestedDraftField(draft, record, field.key, JSON.parse(nextValue)))
|
||
} catch {
|
||
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue))
|
||
}
|
||
}}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
)
|
||
}
|
||
if (field.type === 'secret') {
|
||
const secretValue = text(value, '')
|
||
const secretInputType = field.secretVisible ? 'text' : 'password'
|
||
const displayValue = field.secretVisible && isMaskedSecretDraft(secretValue)
|
||
? `${secretValue} 已保存密钥不回传明文,输入新值可替换`
|
||
: secretValue
|
||
return (
|
||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||
<span>{field.label}</span>
|
||
<div className="an-secret-input">
|
||
<input
|
||
className="an-input an-secret-input__control"
|
||
type={secretInputType}
|
||
value={displayValue}
|
||
placeholder={field.placeholder}
|
||
disabled={field.disabled}
|
||
autoComplete="new-password"
|
||
onChange={(event) => {
|
||
const nextValue = event.target.value
|
||
const suffix = ' 已保存密钥不回传明文,输入新值可替换'
|
||
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue.endsWith(suffix) ? secretValue : nextValue))
|
||
}}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="an-secret-input__toggle"
|
||
title={field.secretVisible ? `隐藏${field.label}` : `显示${field.label}`}
|
||
aria-label={field.secretVisible ? `隐藏${field.label}` : `显示${field.label}`}
|
||
onClick={() => field.onToggleSecret?.(!field.secretVisible)}
|
||
>
|
||
{field.secretVisible ? <EyeOff size={14} /> : <Eye size={14} />}
|
||
</button>
|
||
</div>
|
||
</label>
|
||
)
|
||
}
|
||
return (
|
||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||
<span>{field.label}</span>
|
||
<input
|
||
className="an-input"
|
||
type={field.type === 'number' ? 'number' : 'text'}
|
||
value={text(value, '')}
|
||
placeholder={field.placeholder}
|
||
disabled={field.disabled}
|
||
onChange={(event) => onDraftChange(setNestedDraftField(
|
||
draft,
|
||
record,
|
||
field.key,
|
||
field.type === 'number' ? Number(event.target.value) : event.target.value,
|
||
))}
|
||
/>
|
||
{field.help ? <small>{field.help}</small> : null}
|
||
</label>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function GroupList({
|
||
groups,
|
||
activeKey,
|
||
onSelect,
|
||
footer,
|
||
renderGroupControl,
|
||
}: {
|
||
groups: HierarchyGroup[]
|
||
activeKey: string
|
||
onSelect: (group: HierarchyGroup) => void
|
||
footer?: ReactNode
|
||
renderGroupControl?: (group: HierarchyGroup) => ReactNode
|
||
}) {
|
||
return (
|
||
<Panel className="an-hierarchy-list">
|
||
<Scrollbar className="an-hierarchy-list__scroll">
|
||
<div className="an-hierarchy-list__items">
|
||
{groups.map((group) => {
|
||
const nested = group.children || []
|
||
if (nested.length) {
|
||
return (
|
||
<div key={group.key} className="an-hierarchy-group-set">
|
||
<div className="an-hierarchy-group-set__title">
|
||
<span>
|
||
<strong>{group.label}</strong>
|
||
{group.description ? <small>{group.description}</small> : null}
|
||
</span>
|
||
{renderGroupControl?.(group)}
|
||
</div>
|
||
{nested.map((child) => (
|
||
<button
|
||
key={child.key}
|
||
type="button"
|
||
className={activeKey === child.key ? 'an-hierarchy-group is-active is-child' : 'an-hierarchy-group is-child'}
|
||
data-admin-search-target={child.key}
|
||
data-admin-search-text={[child.label, child.description, child.status].filter(Boolean).join(' ')}
|
||
onClick={() => onSelect(child)}
|
||
>
|
||
<span>
|
||
<strong>{child.label}</strong>
|
||
{child.description ? <small>{child.description}</small> : null}
|
||
</span>
|
||
<span className="an-hierarchy-group__meta">
|
||
{child.status ? <StatusText tone={statusTone(child.status)}>{child.status}</StatusText> : null}
|
||
{typeof child.count === 'number' ? <em>{child.count}</em> : null}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<button
|
||
key={group.key}
|
||
type="button"
|
||
className={activeKey === group.key ? 'an-hierarchy-group is-active' : 'an-hierarchy-group'}
|
||
data-admin-search-target={group.key}
|
||
data-admin-search-text={[group.label, group.description, group.status].filter(Boolean).join(' ')}
|
||
onClick={() => onSelect(group)}
|
||
>
|
||
<span>
|
||
<strong>{group.label}</strong>
|
||
{group.description ? <small>{group.description}</small> : null}
|
||
</span>
|
||
<span className="an-hierarchy-group__meta">
|
||
{group.status ? <StatusText tone={statusTone(group.status)}>{group.status}</StatusText> : null}
|
||
{typeof group.count === 'number' ? <em>{group.count}</em> : null}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
{footer ? <div className="an-hierarchy-list__footer">{footer}</div> : null}
|
||
</div>
|
||
</Scrollbar>
|
||
</Panel>
|
||
)
|
||
}
|
||
|
||
const PLAYGROUND_SESSION_KEY = 'default'
|
||
const BRAND_ASSET_ACCEPT = '.png,.jpg,.jpeg,.webp,.svg'
|
||
const BRAND_ASSET_SUFFIXES = ['png', 'jpg', 'jpeg', 'webp', 'svg']
|
||
const PLAYGROUND_PRESETS = [
|
||
{
|
||
key: 'bgp-brief',
|
||
label: 'BGP 简报',
|
||
title: 'BGP 告警态势简报',
|
||
objective: '总结当前告警的主要风险、优先级与建议动作。',
|
||
constraints: '结论要简洁\n优先给出操作建议',
|
||
input: '出现新的高危告警\n部分观测站最近 24h 事件增多\n请先给出风险摘要,再列出建议动作。',
|
||
},
|
||
{
|
||
key: 'datasource-health',
|
||
label: '数据源健康',
|
||
title: '采集器健康检查说明',
|
||
objective: '判断当前采集器失败是否属于上游接口失效、限流、结构变更或临时波动。',
|
||
constraints: '区分事实与推断\n先给排障优先级',
|
||
input: '最近 3 次任务失败\n部分数据源响应时间抬升\n个别接口返回结构不稳定\n请帮我先做排障优先级排序。',
|
||
},
|
||
{
|
||
key: 'link-smoke',
|
||
label: '链路探测',
|
||
title: 'AI 链路探测',
|
||
objective: '验证 backend -> aiprovider -> model provider 调用链路是否正常。',
|
||
constraints: '输出简洁\n包含一段明确结论',
|
||
input: '当前从 Playground 发起测试,希望确认 provider 配置和返回结构正常,请直接给我链路结论。',
|
||
},
|
||
]
|
||
|
||
function PlaygroundLite() {
|
||
const { toast } = useToast()
|
||
const [messages, setMessages] = useState<PlaygroundApiMessage[]>([])
|
||
const [selectedPresetKey, setSelectedPresetKey] = useState(PLAYGROUND_PRESETS[0].key)
|
||
const [title, setTitle] = useState(PLAYGROUND_PRESETS[0].title)
|
||
const [objective, setObjective] = useState(PLAYGROUND_PRESETS[0].objective)
|
||
const [constraints, setConstraints] = useState(PLAYGROUND_PRESETS[0].constraints)
|
||
const [inputValue, setInputValue] = useState(PLAYGROUND_PRESETS[0].input)
|
||
const [loading, setLoading] = useState(false)
|
||
const [sending, setSending] = useState(false)
|
||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||
const [editingContent, setEditingContent] = useState('')
|
||
|
||
const activeAssistant = useMemo(
|
||
() => messages.find((message) => message.role === 'assistant' && ['pending', 'thinking', 'answering'].includes(message.status || '')),
|
||
[messages],
|
||
)
|
||
|
||
const applyThread = (payload: PlaygroundThreadResponse | PlaygroundActionResponse | null) => {
|
||
if (!payload) return
|
||
setMessages(payload.messages || [])
|
||
const state = payload.session?.state
|
||
if (state) {
|
||
setSelectedPresetKey(state.selectedPresetKey || PLAYGROUND_PRESETS[0].key)
|
||
setTitle(state.title || PLAYGROUND_PRESETS[0].title)
|
||
setObjective(state.objective || PLAYGROUND_PRESETS[0].objective)
|
||
setConstraints(state.constraints || '')
|
||
setInputValue(state.inputValue || '')
|
||
}
|
||
}
|
||
|
||
const fetchThread = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const response = await axios.get<PlaygroundThreadResponse | null>(apiPath('/ai/playground/thread'), {
|
||
params: { session_key: PLAYGROUND_SESSION_KEY },
|
||
})
|
||
applyThread(response.data)
|
||
} catch (error) {
|
||
toast({ title: 'Playground 加载失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [toast])
|
||
|
||
useEffect(() => {
|
||
void fetchThread()
|
||
}, [fetchThread])
|
||
|
||
useEffect(() => {
|
||
if (!activeAssistant) return undefined
|
||
const timer = window.setInterval(() => void fetchThread(), 1200)
|
||
return () => window.clearInterval(timer)
|
||
}, [activeAssistant, fetchThread])
|
||
|
||
const postAction = async (path: string, payload: unknown, successTitle: string) => {
|
||
setSending(true)
|
||
try {
|
||
const response = await axios.post<PlaygroundActionResponse>(apiPath(path), payload)
|
||
applyThread(response.data)
|
||
toast({ title: successTitle, tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: `${successTitle}失败`, description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
const sendMessage = async () => {
|
||
const trimmed = inputValue.trim()
|
||
if (!title.trim() || !objective.trim() || !trimmed) {
|
||
toast({ title: '请补齐标题、目标和输入内容', tone: 'error' })
|
||
return
|
||
}
|
||
await postAction('/ai/playground/messages', {
|
||
session_key: PLAYGROUND_SESSION_KEY,
|
||
title,
|
||
objective,
|
||
constraints,
|
||
input: trimmed,
|
||
selected_preset_key: selectedPresetKey,
|
||
help_expanded: true,
|
||
}, '已发送')
|
||
}
|
||
|
||
const stopMessage = async () => {
|
||
if (!activeAssistant) return
|
||
await postAction('/ai/playground/messages/stop', {
|
||
session_key: PLAYGROUND_SESSION_KEY,
|
||
message_id: activeAssistant.id,
|
||
}, '已停止')
|
||
}
|
||
|
||
const resendMessage = async (messageId: string) => {
|
||
await postAction('/ai/playground/messages/resend', {
|
||
session_key: PLAYGROUND_SESSION_KEY,
|
||
user_message_id: messageId,
|
||
}, '已重试')
|
||
}
|
||
|
||
const saveEdit = async (messageId: string) => {
|
||
const trimmed = editingContent.trim()
|
||
if (!trimmed) {
|
||
toast({ title: 'Prompt 不能为空', tone: 'error' })
|
||
return
|
||
}
|
||
setSending(true)
|
||
try {
|
||
await axios.post(apiPath('/ai/playground/messages/edit'), {
|
||
session_key: PLAYGROUND_SESSION_KEY,
|
||
user_message_id: messageId,
|
||
content: trimmed,
|
||
})
|
||
await postAction('/ai/playground/messages/resend', {
|
||
session_key: PLAYGROUND_SESSION_KEY,
|
||
user_message_id: messageId,
|
||
}, 'Prompt 已更新')
|
||
setEditingMessageId(null)
|
||
} catch (error) {
|
||
toast({ title: '修改失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setSending(false)
|
||
}
|
||
}
|
||
|
||
const applyPreset = (key: string) => {
|
||
const preset = PLAYGROUND_PRESETS.find((item) => item.key === key) || PLAYGROUND_PRESETS[0]
|
||
setSelectedPresetKey(preset.key)
|
||
setTitle(preset.title)
|
||
setObjective(preset.objective)
|
||
setConstraints(preset.constraints)
|
||
setInputValue(preset.input)
|
||
}
|
||
|
||
return (
|
||
<div className="an-playground">
|
||
<Panel className="an-playground__settings">
|
||
<div className="an-panel-heading">
|
||
<div>
|
||
<h2>Playground 设置</h2>
|
||
<p>会话写入后端,刷新后保留线程状态。</p>
|
||
</div>
|
||
<Button size="icon" variant="subtle" onClick={() => void fetchThread()} loading={loading} aria-label="刷新线程" title="刷新线程">
|
||
<RefreshCw size={15} />
|
||
</Button>
|
||
</div>
|
||
<div className="an-form an-panel-body">
|
||
<label className="an-field">
|
||
<span>预设</span>
|
||
<select className="an-input" value={selectedPresetKey} onChange={(event) => applyPreset(event.target.value)}>
|
||
{PLAYGROUND_PRESETS.map((preset) => <option key={preset.key} value={preset.key}>{preset.label}</option>)}
|
||
</select>
|
||
</label>
|
||
<label className="an-field">
|
||
<span>标题</span>
|
||
<input className="an-input" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||
</label>
|
||
<label className="an-field">
|
||
<span>目标</span>
|
||
<Textarea value={objective} onChange={(event) => setObjective(event.target.value)} />
|
||
</label>
|
||
<label className="an-field">
|
||
<span>约束</span>
|
||
<Textarea value={constraints} onChange={(event) => setConstraints(event.target.value)} />
|
||
</label>
|
||
</div>
|
||
</Panel>
|
||
|
||
<Panel className="an-playground__chat">
|
||
<div className="an-panel-heading">
|
||
<div>
|
||
<h2>会话</h2>
|
||
<p>{messages.length} 条消息</p>
|
||
</div>
|
||
<div className="an-toolbar">
|
||
{activeAssistant ? <StatusText tone="running">生成中</StatusText> : <StatusText tone="success">就绪</StatusText>}
|
||
{activeAssistant ? (
|
||
<Button size="icon" variant="danger" onClick={() => void stopMessage()} loading={sending} aria-label="停止生成" title="停止生成">
|
||
<Square size={15} />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="an-panel-body">
|
||
<Scrollbar className="an-playground__messages">
|
||
{messages.length ? messages.map((message) => (
|
||
<article key={message.id} className={`an-playground-message an-playground-message--${message.role}`}>
|
||
<header>
|
||
<strong>{message.role === 'assistant' ? 'AI' : message.role === 'user' ? '我' : '系统'}</strong>
|
||
<StatusText tone={message.status === 'error' ? 'danger' : message.status === 'stopped' ? 'warning' : message.status === 'done' ? 'success' : 'running'}>{message.status || 'done'}</StatusText>
|
||
</header>
|
||
{editingMessageId === message.id ? (
|
||
<div className="an-playground-edit">
|
||
<Textarea value={editingContent} onChange={(event) => setEditingContent(event.target.value)} />
|
||
<div className="an-toolbar">
|
||
<Button size="sm" variant="subtle" onClick={() => setEditingMessageId(null)}>取消</Button>
|
||
<Button size="sm" variant="primary" onClick={() => void saveEdit(message.id)} loading={sending}>保存并重试</Button>
|
||
</div>
|
||
</div>
|
||
) : message.role === 'assistant' ? (
|
||
<MarkdownRenderer
|
||
markdown={message.content || message.thinking_content || '-'}
|
||
className="an-playground-markdown"
|
||
/>
|
||
) : (
|
||
<pre>{message.content || message.thinking_content || '-'}</pre>
|
||
)}
|
||
{message.role === 'user' && editingMessageId !== message.id ? (
|
||
<div className="an-row-actions">
|
||
<Button size="icon" variant="subtle" onClick={() => void resendMessage(message.id)} loading={sending} aria-label="重试" title="重试">
|
||
<Redo2 size={14} />
|
||
</Button>
|
||
<Button size="icon" variant="subtle" onClick={() => {
|
||
setEditingMessageId(message.id)
|
||
setEditingContent(message.content)
|
||
}} aria-label="编辑" title="编辑">
|
||
<Settings2 size={14} />
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</article>
|
||
)) : <EmptyState title="暂无会话" description="发送一条消息开始测试 AI 链路。" />}
|
||
</Scrollbar>
|
||
<div className="an-playground-composer">
|
||
<Textarea value={inputValue} onChange={(event) => setInputValue(event.target.value)} placeholder="输入要发送给 AI 的内容" />
|
||
<Button variant="primary" onClick={() => void sendMessage()} loading={sending || Boolean(activeAssistant)}>
|
||
<Send size={15} />发送
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Panel>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||
const { toast } = useToast()
|
||
const location = useLocation()
|
||
const navigate = useNavigate()
|
||
const [states, setStates] = useState<SectionState[]>([])
|
||
const [activeSectionKey, setActiveSectionKey] = useState(config.sections[0]?.key || '')
|
||
const [datasourceFilters, setDatasourceFilters] = useState<DatasourceFilters>(() => datasourceFiltersFromSearch(location.search))
|
||
const datasourceFiltersRef = useRef(datasourceFilters)
|
||
const [activeGroupKey, setActiveGroupKey] = useState('')
|
||
const [hierarchyDraft, setHierarchyDraft] = useState('')
|
||
const [tvDraftGroup, setTvDraftGroup] = useState<HierarchyGroup | null>(null)
|
||
const [collectionDraftGroup, setCollectionDraftGroup] = useState<HierarchyGroup | null>(null)
|
||
const [snapshotSelectionBySource, setSnapshotSelectionBySource] = useState<Record<string, string>>({})
|
||
const [loading, setLoading] = useState(false)
|
||
const [actionLoading, setActionLoading] = useState(false)
|
||
const [rowActionLoading, setRowActionLoading] = useState<Record<string, boolean>>({})
|
||
const [collectionQueue, setCollectionQueue] = useState<CollectionQueueItem[]>([])
|
||
const [collectionQueueOpen, setCollectionQueueOpen] = useState(false)
|
||
const collectionQueueRef = useRef<HTMLDivElement>(null)
|
||
const [datasourceSelectedRowIds, setDatasourceSelectedRowIds] = useState<Set<string>>(() => new Set())
|
||
const pendingDatasourceTasksRef = useRef<Record<string, { sourceId: string; source?: string; name: string; taskId?: number | string | null; completed?: boolean }>>({})
|
||
const completedDatasourceTasksRef = useRef<Set<string>>(new Set())
|
||
const datasourcePollTimersRef = useRef<Record<string, number>>({})
|
||
const [selected, setSelected] = useState<TableRecord | null>(null)
|
||
const [selectedHistory, setSelectedHistory] = useState<TableRecord[]>([])
|
||
const [mobileResourceDetailOpen, setMobileResourceDetailOpen] = useState(false)
|
||
const [mobileHierarchyDetailOpen, setMobileHierarchyDetailOpen] = useState(false)
|
||
const [detailWidth, setDetailWidth] = useState(360)
|
||
const [advancedJson, setAdvancedJson] = useState('')
|
||
const [revealedSecrets, setRevealedSecrets] = useState<Record<string, AnyRecord>>({})
|
||
const [visibleSecretFields, setVisibleSecretFields] = useState<Record<string, boolean>>({})
|
||
const [smtpTestEmail, setSmtpTestEmail] = useState('')
|
||
const [brandUploadFile, setBrandUploadFile] = useState<File | null>(null)
|
||
const [resolveTarget, setResolveTarget] = useState<TableRecord | null>(null)
|
||
const [resolutionText, setResolutionText] = useState('已处理')
|
||
const [credentialGuide, setCredentialGuide] = useState<AnyRecord | null>(null)
|
||
const [credentialGuideOpen, setCredentialGuideOpen] = useState(false)
|
||
const [credentialGuidePosition, setCredentialGuidePosition] = useState<{ x: number; y: number } | null>(null)
|
||
const [credentialGuideBusy, setCredentialGuideBusy] = useState<'read' | 'generate' | 'reset' | null>(null)
|
||
const [confirmAction, setConfirmAction] = useState<null | {
|
||
title: string
|
||
description: string
|
||
danger?: boolean
|
||
confirmLabel?: string
|
||
run: () => Promise<void>
|
||
}>(null)
|
||
|
||
const startDetailResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
|
||
event.preventDefault()
|
||
const startX = event.clientX
|
||
const startWidth = detailWidth
|
||
const handleMove = (moveEvent: PointerEvent) => {
|
||
setDetailWidth(Math.min(640, Math.max(300, startWidth - (moveEvent.clientX - startX))))
|
||
}
|
||
const stopMove = () => {
|
||
document.body.classList.remove('an-is-resizing')
|
||
window.removeEventListener('pointermove', handleMove)
|
||
window.removeEventListener('pointerup', stopMove)
|
||
}
|
||
document.body.classList.add('an-is-resizing')
|
||
window.addEventListener('pointermove', handleMove)
|
||
window.addEventListener('pointerup', stopMove, { once: true })
|
||
}
|
||
|
||
const startCredentialGuideDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||
event.preventDefault()
|
||
const dialog = event.currentTarget.closest<HTMLElement>('.an-guide-modal')
|
||
if (!dialog) return
|
||
const rect = dialog.getBoundingClientRect()
|
||
const offsetX = event.clientX - rect.left
|
||
const offsetY = event.clientY - rect.top
|
||
setCredentialGuidePosition({ x: rect.left, y: rect.top })
|
||
const handleMove = (moveEvent: PointerEvent) => {
|
||
const nextX = Math.min(window.innerWidth - 80, Math.max(8, moveEvent.clientX - offsetX))
|
||
const nextY = Math.min(window.innerHeight - 56, Math.max(8, moveEvent.clientY - offsetY))
|
||
setCredentialGuidePosition({ x: nextX, y: nextY })
|
||
}
|
||
const stopMove = () => {
|
||
document.body.classList.remove('an-is-resizing')
|
||
window.removeEventListener('pointermove', handleMove)
|
||
window.removeEventListener('pointerup', stopMove)
|
||
}
|
||
document.body.classList.add('an-is-resizing')
|
||
window.addEventListener('pointermove', handleMove)
|
||
window.addEventListener('pointerup', stopMove, { once: true })
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!collectionQueueOpen) return
|
||
const handlePointerDown = (event: MouseEvent) => {
|
||
if (collectionQueueRef.current?.contains(event.target as Node)) return
|
||
setCollectionQueueOpen(false)
|
||
}
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') setCollectionQueueOpen(false)
|
||
}
|
||
document.addEventListener('mousedown', handlePointerDown)
|
||
document.addEventListener('keydown', handleKeyDown)
|
||
return () => {
|
||
document.removeEventListener('mousedown', handlePointerDown)
|
||
document.removeEventListener('keydown', handleKeyDown)
|
||
}
|
||
}, [collectionQueueOpen])
|
||
|
||
useEffect(() => {
|
||
datasourceFiltersRef.current = datasourceFilters
|
||
if (config === configs.datasources) {
|
||
setStates((current) => current.filter((state) => state.section.key !== 'builtin'))
|
||
}
|
||
}, [config, datasourceFilters])
|
||
|
||
useEffect(() => {
|
||
if (config !== configs.datasources) return
|
||
const next = datasourceFiltersFromSearch(location.search)
|
||
const current = datasourceFiltersRef.current
|
||
const unchanged = current.product === next.product &&
|
||
current.module === next.module &&
|
||
current.isActive === next.isActive &&
|
||
current.runStatus === next.runStatus &&
|
||
current.dataStatus === next.dataStatus
|
||
if (!unchanged) setDatasourceSelectedRowIds(new Set())
|
||
setDatasourceFilters((filters) => unchanged ? filters : next)
|
||
}, [config, location.search])
|
||
|
||
const setDatasourceFilter = (key: keyof DatasourceFilters, value: string) => {
|
||
const next = { ...datasourceFiltersRef.current, [key]: value }
|
||
setDatasourceSelectedRowIds(new Set())
|
||
setDatasourceFilters(next)
|
||
const params = new URLSearchParams(location.search)
|
||
const queryKeyByFilter: Record<keyof DatasourceFilters, string> = {
|
||
product: 'product',
|
||
module: 'module',
|
||
isActive: 'is_active',
|
||
runStatus: 'run_status',
|
||
dataStatus: 'data_status',
|
||
}
|
||
;(Object.keys(queryKeyByFilter) as Array<keyof DatasourceFilters>).forEach((filterKey) => {
|
||
const queryKey = queryKeyByFilter[filterKey]
|
||
const defaultValue = DEFAULT_DATASOURCE_FILTERS[filterKey]
|
||
const nextValue = next[filterKey]
|
||
if (!nextValue || nextValue === defaultValue) {
|
||
params.delete(queryKey)
|
||
} else {
|
||
params.set(queryKey, nextValue)
|
||
}
|
||
})
|
||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true })
|
||
}
|
||
|
||
const sectionRequestParams = useCallback((section: SectionConfig, baseParams?: AnyRecord) => {
|
||
if (config === configs.datasources && section.key === 'builtin') {
|
||
return { ...(baseParams || {}), ...datasourceFiltersToParams(datasourceFiltersRef.current) }
|
||
}
|
||
return baseParams
|
||
}, [config, datasourceFilters])
|
||
|
||
const load = useCallback(async (sectionKey = activeSectionKey) => {
|
||
const section = config.sections.find((item) => item.key === sectionKey) ?? config.sections[0]
|
||
if (!section) return
|
||
if (config === configs.datasources && section.key === 'builtin') {
|
||
setDatasourceSelectedRowIds(new Set())
|
||
}
|
||
setLoading(true)
|
||
try {
|
||
const result = await (async (): Promise<SectionState> => {
|
||
try {
|
||
if (section.endpoints?.length) {
|
||
const endpointResults = await Promise.all(section.endpoints.map(async (endpoint) => {
|
||
try {
|
||
const response = await axios.get(apiPath(endpoint.url), { params: sectionRequestParams(section, endpoint.params) })
|
||
return {
|
||
endpoint,
|
||
payload: response.data,
|
||
rows: endpoint.map(response.data),
|
||
error: '',
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
endpoint,
|
||
payload: null,
|
||
rows: [],
|
||
error: error instanceof Error ? error.message : '接口请求失败',
|
||
}
|
||
}
|
||
}))
|
||
const rows: AnyRecord[] = []
|
||
endpointResults.forEach((item) => {
|
||
item.rows.forEach((row) => {
|
||
rows.push({
|
||
...row,
|
||
__endpointKey: item.endpoint.key,
|
||
__endpointLabel: item.endpoint.label,
|
||
})
|
||
})
|
||
})
|
||
return normalizeSectionState(section, {
|
||
endpoints: endpointResults.map((item) => ({
|
||
key: item.endpoint.key,
|
||
label: item.endpoint.label,
|
||
data: item.payload,
|
||
error: item.error,
|
||
})),
|
||
data: rows,
|
||
})
|
||
}
|
||
if (!section.url) {
|
||
return normalizeSectionState(section, { data: [] })
|
||
}
|
||
const response = await axios.get(apiPath(section.url), { params: sectionRequestParams(section, section.params) })
|
||
return normalizeSectionState(section, response.data)
|
||
} catch (error) {
|
||
return {
|
||
section,
|
||
ok: false,
|
||
rows: [],
|
||
raw: null,
|
||
error: error instanceof Error ? error.message : '接口请求失败',
|
||
}
|
||
}
|
||
})()
|
||
setStates((current) => {
|
||
const next = current.filter((state) => state.section.key !== result.section.key)
|
||
next.push(result)
|
||
return next.sort((a, b) => config.sections.findIndex((item) => item.key === a.section.key) - config.sections.findIndex((item) => item.key === b.section.key))
|
||
})
|
||
if (section.key === activeSectionKey) {
|
||
setSelected((current) => {
|
||
if (!current) return null
|
||
return result.rows.find((row) => row.__rowId === current.__rowId) ?? null
|
||
})
|
||
}
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [activeSectionKey, config.sections, sectionRequestParams])
|
||
|
||
const datasourceBuiltinSection = useMemo(
|
||
() => config.sections.find((section) => section.key === 'builtin') || config.sections[0],
|
||
[config.sections],
|
||
)
|
||
|
||
const normalizeDatasourceTableRecord = useCallback((row: AnyRecord): TableRecord => {
|
||
const section = datasourceBuiltinSection || { key: 'builtin', label: '内置源', url: '', map: datasourceRows }
|
||
const normalized = enrichRows({ section, ok: true, rows: [], raw: row }, [datasourceTableRow(row)])
|
||
return normalized[0]
|
||
}, [datasourceBuiltinSection])
|
||
|
||
const isSameDatasourceRow = useCallback((row: AnyRecord, sourceId: string, source?: string) => {
|
||
const rowId = text(row.id, '')
|
||
const rowSource = text(row.source || row.collector_name || row.collector_class, '')
|
||
return Boolean(sourceId && rowId === sourceId) || Boolean(source && rowSource === source)
|
||
}, [])
|
||
|
||
const upsertCollectionQueueItem = useCallback((item: CollectionQueueItem) => {
|
||
setCollectionQueue((current) => {
|
||
const index = current.findIndex((existing) => (
|
||
existing.key === item.key
|
||
|| (item.taskId && existing.taskId === item.taskId)
|
||
|| (isActiveQueueStatus(existing.status) && item.sourceId && existing.sourceId === item.sourceId)
|
||
|| (isActiveQueueStatus(existing.status) && item.source && existing.source === item.source)
|
||
))
|
||
if (index < 0) return [item, ...current]
|
||
const next = [...current]
|
||
next[index] = { ...next[index], ...item, key: next[index].key, createdAt: next[index].createdAt || item.createdAt }
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const patchCollectionQueueFromTask = useCallback((payload: AnyRecord) => {
|
||
const sourceId = text(payload.datasource_id || payload.source_id || payload.id, '')
|
||
const source = text(payload.collector_name || payload.source, '')
|
||
const taskId = payload.task_id as number | string | null | undefined
|
||
const status = queueStatusFromTask(payload.status || payload.phase, payload.is_running)
|
||
setCollectionQueue((current) => current.map((item) => {
|
||
const matched = (taskId && item.taskId === taskId) || (sourceId && item.sourceId === sourceId) || (source && item.source === source)
|
||
if (!matched) return item
|
||
const terminal = ['success', 'failed', 'cancelled', 'skipped'].includes(status)
|
||
return {
|
||
...item,
|
||
taskId: taskId ?? item.taskId,
|
||
taskType: text(payload.task_type, item.taskType || ''),
|
||
sourceId: sourceId || item.sourceId,
|
||
source: source || item.source,
|
||
status,
|
||
phase: text(payload.phase, item.phase || ''),
|
||
phaseMessage: text(payload.phase_message, item.phaseMessage || ''),
|
||
progress: typeof payload.progress === 'number' ? payload.progress : item.progress,
|
||
recordsProcessed: typeof payload.records_processed === 'number' ? payload.records_processed : item.recordsProcessed,
|
||
totalRecords: typeof payload.total_records === 'number' ? payload.total_records : item.totalRecords,
|
||
error: text(payload.error_message, item.error || ''),
|
||
updatedAt: Date.now(),
|
||
completedAt: terminal ? Date.now() : item.completedAt,
|
||
}
|
||
}))
|
||
}, [])
|
||
|
||
const addBatchQueueResult = useCallback((payload: AnyRecord) => {
|
||
const now = Date.now()
|
||
const toItems = (items: unknown, status: CollectionQueueStatus): CollectionQueueItem[] => (
|
||
Array.isArray(items) ? items.filter(isObjectRecord).map((item) => ({
|
||
key: queueItemKey(item),
|
||
sourceId: text(item.id || item.source_id || item.datasource_id, ''),
|
||
source: text(item.source || item.collector_name, ''),
|
||
name: text(item.name || item.source || item.collector_name, '数据源'),
|
||
taskId: item.task_id as number | string | null | undefined,
|
||
taskType: text(item.task_type, 'collect'),
|
||
status,
|
||
phase: status === 'queued' ? 'queued' : undefined,
|
||
phaseMessage: status === 'queued' ? '等待任务创建' : queueReasonLabel(text(item.reason, '')),
|
||
progress: status === 'queued' ? 0 : 100,
|
||
reason: text(item.reason, ''),
|
||
error: text(item.error || item.message, ''),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
completedAt: status === 'queued' || status === 'running' ? undefined : now,
|
||
})) : []
|
||
)
|
||
const nextItems = [
|
||
...toItems(payload.triggered, 'queued'),
|
||
...toItems(payload.skipped, 'skipped'),
|
||
...toItems(payload.failed, 'failed'),
|
||
]
|
||
nextItems.forEach(upsertCollectionQueueItem)
|
||
if (nextItems.length) setCollectionQueueOpen(true)
|
||
}, [upsertCollectionQueueItem])
|
||
|
||
const updateDatasourceRow = useCallback((row: AnyRecord, options: { removeIfFilteredOut?: boolean } = {}) => {
|
||
if (config !== configs.datasources) return
|
||
const normalized = normalizeDatasourceTableRecord(row)
|
||
const shouldKeep = !options.removeIfFilteredOut || datasourceRowMatchesFilters(normalized, datasourceFiltersRef.current)
|
||
setStates((currentStates) => currentStates.map((state) => {
|
||
if (state.section.key !== 'builtin') return state
|
||
let matched = false
|
||
const rows = state.rows.flatMap((existing) => {
|
||
if (!isSameDatasourceRow(existing, text(normalized.id, ''), text(normalized.source, ''))) {
|
||
return [existing]
|
||
}
|
||
matched = true
|
||
return shouldKeep ? [{ ...existing, ...normalized }] : []
|
||
})
|
||
if (!matched && shouldKeep) rows.push(normalized)
|
||
return { ...state, rows, raw: isObjectRecord(state.raw) ? { ...state.raw, data: rows, total: rows.length } : state.raw }
|
||
}))
|
||
setSelected((current) => {
|
||
if (!current || !isSameDatasourceRow(current, text(normalized.id, ''), text(normalized.source, ''))) return current
|
||
return shouldKeep ? { ...current, ...normalized } : null
|
||
})
|
||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord])
|
||
|
||
const mergeDatasourceTaskUpdate = useCallback((payload: AnyRecord) => {
|
||
if (config !== configs.datasources) return
|
||
const sourceId = text(payload.datasource_id || payload.source_id, '')
|
||
const source = text(payload.collector_name || payload.source, '')
|
||
if (!sourceId && !source) return
|
||
const taskStatus = text(payload.status, payload.phase ? text(payload.phase, '') : '')
|
||
const taskType = text(payload.task_type, 'collect')
|
||
const taskActive = TASK_ACTIVE_STATUSES.has(taskStatus)
|
||
const rowPatch: AnyRecord = {
|
||
id: sourceId || undefined,
|
||
source: source || undefined,
|
||
collector_name: source || undefined,
|
||
task_id: payload.task_id,
|
||
task_type: taskType,
|
||
task_status: taskStatus,
|
||
is_task_active: taskActive,
|
||
is_running: taskActive && taskType === 'collect',
|
||
status: taskStatus,
|
||
last_status: taskType === 'collect' || DATASOURCE_TERMINAL_STATUSES.has(taskStatus) ? taskStatus : undefined,
|
||
progress: payload.progress,
|
||
phase: payload.phase,
|
||
phase_progress: payload.phase_progress,
|
||
phase_message: payload.phase_message,
|
||
phase_current: payload.phase_current,
|
||
phase_total: payload.phase_total,
|
||
phase_unit: payload.phase_unit,
|
||
records_processed: payload.records_processed,
|
||
total_records: payload.total_records,
|
||
error_message: payload.error_message,
|
||
last_run_at: payload.completed_at || payload.started_at,
|
||
}
|
||
setStates((currentStates) => currentStates.map((state) => {
|
||
if (state.section.key !== 'builtin') return state
|
||
return {
|
||
...state,
|
||
rows: state.rows.map((row) => {
|
||
if (!isSameDatasourceRow(row, sourceId, source)) return row
|
||
return normalizeDatasourceTableRecord({ ...row, ...rowPatch, id: row.id, source: row.source })
|
||
}),
|
||
}
|
||
}))
|
||
setSelected((current) => {
|
||
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
|
||
return normalizeDatasourceTableRecord({ ...current, ...rowPatch, id: current.id, source: current.source })
|
||
})
|
||
patchCollectionQueueFromTask(payload)
|
||
}, [config, isSameDatasourceRow, normalizeDatasourceTableRecord, patchCollectionQueueFromTask])
|
||
|
||
const finalizeDatasourceTask = useCallback(async (payload: AnyRecord) => {
|
||
const sourceId = text(payload.datasource_id || payload.source_id, '')
|
||
const source = text(payload.collector_name || payload.source, '')
|
||
const taskId = text(payload.task_id, sourceId || source)
|
||
const pendingKey = taskId || sourceId || source
|
||
if (!pendingKey || completedDatasourceTasksRef.current.has(pendingKey)) return
|
||
completedDatasourceTasksRef.current.add(pendingKey)
|
||
const pendingEntry = pendingDatasourceTasksRef.current[pendingKey]
|
||
? [pendingKey, pendingDatasourceTasksRef.current[pendingKey]] as const
|
||
: Object.entries(pendingDatasourceTasksRef.current).find(([, item]) => isSameDatasourceRow({ id: item.sourceId, source: item.source }, sourceId, source))
|
||
const pending = pendingEntry?.[1]
|
||
const lookupId = sourceId || pending?.sourceId || source
|
||
if (!lookupId) return
|
||
|
||
let finalRow: AnyRecord = payload
|
||
try {
|
||
const response = await axios.get(apiPath(`/datasources/${encodeURIComponent(lookupId)}/row`), { params: { include_endpoint: false } })
|
||
const row = objectAt(response.data, 'data')
|
||
if (Object.keys(row).length) finalRow = row
|
||
updateDatasourceRow(finalRow, { removeIfFilteredOut: true })
|
||
} catch {
|
||
updateDatasourceRow({ ...payload, id: sourceId || pending?.sourceId, source: source || pending?.source }, { removeIfFilteredOut: true })
|
||
}
|
||
|
||
if (pending) {
|
||
const finalStatus = datasourceStatus(finalRow)
|
||
const titleName = pending.name || text(finalRow.name || finalRow.source, '数据源')
|
||
const finalMetric = datasourceMetric(finalRow)
|
||
if (['success', 'completed'].includes(finalStatus)) {
|
||
toast({ title: `${titleName} 采集完成`, description: finalMetric !== '-' ? `最终指标:${finalMetric}` : undefined, tone: 'success' })
|
||
} else if (finalStatus === 'failed') {
|
||
toast({ title: `${titleName} 采集失败`, description: text(finalRow.error_message || payload.error_message, '请查看任务状态详情。'), tone: 'error' })
|
||
} else if (finalStatus === 'cancelled') {
|
||
toast({ title: `${titleName} 采集已取消` })
|
||
}
|
||
}
|
||
delete pendingDatasourceTasksRef.current[pendingEntry?.[0] || pendingKey]
|
||
}, [isSameDatasourceRow, toast, updateDatasourceRow])
|
||
|
||
const handleDatasourceTaskMessage = useCallback((message: { channel?: string; payload?: Record<string, unknown> }) => {
|
||
if (config !== configs.datasources || message.channel !== 'datasource_tasks' || !isObjectRecord(message.payload)) return
|
||
const payload = message.payload
|
||
mergeDatasourceTaskUpdate(payload)
|
||
const status = text(payload.status, '')
|
||
if (DATASOURCE_TERMINAL_STATUSES.has(status)) {
|
||
void finalizeDatasourceTask(payload)
|
||
}
|
||
}, [config, finalizeDatasourceTask, mergeDatasourceTaskUpdate])
|
||
|
||
const datasourceSocket = useWebSocket({
|
||
autoConnect: config === configs.datasources,
|
||
autoSubscribe: config === configs.datasources ? ['datasource_tasks'] : [],
|
||
onMessage: handleDatasourceTaskMessage,
|
||
})
|
||
|
||
useEffect(() => () => {
|
||
Object.values(datasourcePollTimersRef.current).forEach((timer) => window.clearTimeout(timer))
|
||
datasourcePollTimersRef.current = {}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
setStates([])
|
||
setActiveSectionKey(config.sections[0]?.key || '')
|
||
setSelected(null)
|
||
setActiveGroupKey('')
|
||
setHierarchyDraft('')
|
||
setTvDraftGroup(null)
|
||
setCollectionDraftGroup(null)
|
||
}, [config])
|
||
|
||
useEffect(() => {
|
||
if (states.some((state) => state.section.key === activeSectionKey)) return
|
||
void load(activeSectionKey)
|
||
}, [activeSectionKey, load, states])
|
||
|
||
useEffect(() => {
|
||
setAdvancedJson(selected ? formatRaw(cleanRecord(selected)) : '')
|
||
}, [selected])
|
||
|
||
const activeSection = useMemo(() => config.sections.find((section) => section.key === activeSectionKey) ?? config.sections[0], [activeSectionKey, config.sections])
|
||
const activeState = useMemo(() => states.find((state) => state.section.key === activeSection?.key), [activeSection?.key, states])
|
||
const rows = activeState?.rows ?? []
|
||
const isDatasourceBuiltinSection = config === configs.datasources && activeSection?.key === 'builtin'
|
||
const selectedDatasourceRows = useMemo(
|
||
() => isDatasourceBuiltinSection ? rows.filter((row) => datasourceSelectedRowIds.has(row.__rowId)) : [],
|
||
[datasourceSelectedRowIds, isDatasourceBuiltinSection, rows],
|
||
)
|
||
const selectedDatasourceIds = useMemo(
|
||
() => selectedDatasourceRows
|
||
.map((row) => Number(row.id))
|
||
.filter((id) => Number.isFinite(id) && id > 0),
|
||
[selectedDatasourceRows],
|
||
)
|
||
const toggleDatasourceSelection = useCallback((rowId: string) => {
|
||
setDatasourceSelectedRowIds((current) => {
|
||
const next = new Set(current)
|
||
if (next.has(rowId)) {
|
||
next.delete(rowId)
|
||
} else {
|
||
next.add(rowId)
|
||
}
|
||
return next
|
||
})
|
||
}, [])
|
||
const toggleAllVisibleDatasourceSelection = useCallback((rowIds: string[]) => {
|
||
setDatasourceSelectedRowIds((current) => {
|
||
const next = new Set(current)
|
||
const allSelected = rowIds.length > 0 && rowIds.every((rowId) => next.has(rowId))
|
||
rowIds.forEach((rowId) => {
|
||
if (allSelected) {
|
||
next.delete(rowId)
|
||
} else {
|
||
next.add(rowId)
|
||
}
|
||
})
|
||
return next
|
||
})
|
||
}, [])
|
||
const summary = sectionSummary(states)
|
||
const collectionQueueSummary = useMemo(() => {
|
||
const running = collectionQueue.filter((item) => isActiveQueueStatus(item.status)).length
|
||
const completed = collectionQueue.filter((item) => item.status === 'success').length
|
||
const failed = collectionQueue.filter((item) => item.status === 'failed').length
|
||
const skipped = collectionQueue.filter((item) => item.status === 'skipped' || item.status === 'cancelled').length
|
||
const terminal = completed + failed + skipped
|
||
const total = collectionQueue.length
|
||
const progress = total ? Math.round((terminal / total) * 100) : 0
|
||
return { total, running, completed, failed, skipped, progress }
|
||
}, [collectionQueue])
|
||
const activeCollectQueueItems = useMemo(
|
||
() => collectionQueue.filter((item) => isActiveQueueStatus(item.status) && text(item.taskType, 'collect') === 'collect'),
|
||
[collectionQueue],
|
||
)
|
||
const selectedActiveCollectQueueItems = useMemo(
|
||
() => activeCollectQueueItems.filter((item) => selectedDatasourceRows.some((row) => isSameDatasourceRow(row, item.sourceId, item.source))),
|
||
[activeCollectQueueItems, isSameDatasourceRow, selectedDatasourceRows],
|
||
)
|
||
const isPlaygroundSection = config === configs.ai && activeSection?.key === 'playground'
|
||
const isHierarchySection = config.viewMode === 'management' && !isPlaygroundSection
|
||
const searchIntent = useMemo(() => {
|
||
const params = new URLSearchParams(location.search)
|
||
return {
|
||
section: params.get('section') || '',
|
||
target: params.get('target') || '',
|
||
field: params.get('field') || '',
|
||
highlight: params.get('highlight') || '',
|
||
}
|
||
}, [location.search])
|
||
|
||
const handleSectionChange = (key: string) => {
|
||
setActiveSectionKey(key)
|
||
setDatasourceSelectedRowIds(new Set())
|
||
setActiveGroupKey('')
|
||
setHierarchyDraft('')
|
||
setTvDraftGroup(null)
|
||
setSelected(null)
|
||
setSelectedHistory([])
|
||
setMobileResourceDetailOpen(false)
|
||
setMobileHierarchyDetailOpen(false)
|
||
}
|
||
|
||
const openResourceDetail = (record: TableRecord | null) => {
|
||
setSelected(record)
|
||
setSelectedHistory([])
|
||
setMobileResourceDetailOpen(Boolean(record))
|
||
}
|
||
|
||
const goBackResourceDetail = () => {
|
||
setSelectedHistory((history) => {
|
||
const previous = history[history.length - 1]
|
||
if (previous) setSelected(previous)
|
||
return history.slice(0, -1)
|
||
})
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!searchIntent.section || !config.sections.some((section) => section.key === searchIntent.section)) return
|
||
if (activeSectionKey === searchIntent.section) return
|
||
setActiveSectionKey(searchIntent.section)
|
||
setActiveGroupKey('')
|
||
setHierarchyDraft('')
|
||
setTvDraftGroup(null)
|
||
setSelected(null)
|
||
setSelectedHistory([])
|
||
setMobileResourceDetailOpen(false)
|
||
setMobileHierarchyDetailOpen(false)
|
||
}, [activeSectionKey, config.sections, searchIntent.section])
|
||
|
||
useEffect(() => {
|
||
if (!searchIntent.target || !isHierarchySection) return
|
||
setActiveGroupKey(searchIntent.target)
|
||
setHierarchyDraft('')
|
||
setSelected(null)
|
||
setSelectedHistory([])
|
||
setMobileHierarchyDetailOpen(true)
|
||
}, [isHierarchySection, searchIntent.target])
|
||
|
||
useEffect(() => {
|
||
if (!searchIntent.section && !searchIntent.target && !searchIntent.field && !searchIntent.highlight) return
|
||
const frameId = window.requestAnimationFrame(() => {
|
||
window.requestAnimationFrame(() => {
|
||
clearAdminSearchHighlight()
|
||
const sectionElement = searchIntent.section
|
||
? document.querySelector(`[data-admin-search-target="${escapeSearchSelector(`section:${searchIntent.section}`)}"]`)
|
||
: null
|
||
const element = findAdminSearchElement(searchIntent) || (sectionElement instanceof HTMLElement ? sectionElement : null)
|
||
const root = document.querySelector('.admin__content-inner')
|
||
if (root instanceof HTMLElement) root.classList.add('an-search-dimmed')
|
||
if (element) {
|
||
element.classList.add('an-search-hit', 'an-search-mark')
|
||
element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' })
|
||
}
|
||
})
|
||
})
|
||
const clear = () => {
|
||
clearAdminSearchHighlight()
|
||
const params = new URLSearchParams(location.search)
|
||
;['section', 'target', 'field', 'highlight'].forEach((key) => params.delete(key))
|
||
navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true })
|
||
}
|
||
const clickTimer = window.setTimeout(() => window.addEventListener('pointerdown', clear, { once: true }), 180)
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === 'Escape') clear()
|
||
}
|
||
window.addEventListener('keydown', onKeyDown)
|
||
return () => {
|
||
window.cancelAnimationFrame(frameId)
|
||
window.clearTimeout(clickTimer)
|
||
window.removeEventListener('keydown', onKeyDown)
|
||
}
|
||
}, [location.pathname, location.search, navigate, searchIntent])
|
||
|
||
const requestAction = async (
|
||
label: string,
|
||
method: 'post' | 'put' | 'delete' | 'get',
|
||
path: string,
|
||
body?: unknown,
|
||
refreshOrOptions: boolean | { refresh?: boolean; successDescription?: string; successTitle?: string } = true,
|
||
) => {
|
||
const options = typeof refreshOrOptions === 'boolean'
|
||
? { refresh: refreshOrOptions }
|
||
: refreshOrOptions
|
||
const refresh = options.refresh ?? true
|
||
setActionLoading(true)
|
||
try {
|
||
await axios.request({ method, url: apiPath(path), data: body })
|
||
toast({
|
||
title: options.successTitle || `${label}已完成`,
|
||
description: options.successDescription || '后端已接受本次变更。',
|
||
tone: 'success',
|
||
})
|
||
if (refresh) await load()
|
||
} catch (error) {
|
||
toast({
|
||
title: `${label}失败`,
|
||
description: actionErrorMessage(error),
|
||
tone: 'error',
|
||
})
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const testDatasourceConfigDraft = async (record: AnyRecord, options: { builtin?: boolean } = {}) => {
|
||
const payload = normalizeDatasourceConfigPayload(stripInternalFields(record))
|
||
await testConnection(
|
||
'连接测试',
|
||
options.builtin ? '/datasources/configs/builtin/connect' : '/datasources/configs/test',
|
||
payload,
|
||
)
|
||
}
|
||
|
||
const testConnection = async (label: string, path: string, body: unknown) => {
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.post(apiPath(path), body)
|
||
const result = connectionResult(response.data)
|
||
toast({
|
||
title: `${label}${result.ok ? '连通性正常' : '连通性失败'}`,
|
||
description: result.message,
|
||
tone: result.ok ? 'success' : 'error',
|
||
})
|
||
} catch (error) {
|
||
toast({
|
||
title: `${label}连通性失败`,
|
||
description: actionErrorMessage(error),
|
||
tone: 'error',
|
||
})
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const runConfirmedAction = async () => {
|
||
if (!confirmAction) return
|
||
const action = confirmAction
|
||
setConfirmAction(null)
|
||
try {
|
||
await action.run()
|
||
} catch {
|
||
// requestAction owns toast/error state.
|
||
}
|
||
}
|
||
|
||
const triggerSelectedDatasources = async () => {
|
||
const sourceIds = selectedDatasourceIds
|
||
if (!sourceIds.length) {
|
||
toast({ title: '请先勾选数据源', description: '勾选内置源后,主触发按钮会只触发所选数据源。', tone: 'error' })
|
||
return
|
||
}
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.post(apiPath('/datasources/trigger-batch'), {
|
||
source_ids: sourceIds,
|
||
force: false,
|
||
})
|
||
addBatchQueueResult(response.data)
|
||
arrayAt(response.data, 'triggered').forEach((item) => {
|
||
const sourceId = text(item.id || item.source_id || item.datasource_id, '')
|
||
const record = rows.find((row) => text(row.id, '') === sourceId || text(row.source, '') === text(item.source, ''))
|
||
if (record) scheduleDatasourceTaskPoll(record, item.task_id as number | string | null | undefined)
|
||
})
|
||
replaceSelectedWithPayload('trigger-batch', '批量触发结果', [{
|
||
...response.data,
|
||
__title: '批量触发结果',
|
||
__module: '批量任务',
|
||
__status: '已提交',
|
||
__metric: `${sourceIds.length} 个数据源`,
|
||
}])
|
||
setDatasourceSelectedRowIds(new Set())
|
||
toast({ title: '已触发所选数据源', description: `${sourceIds.length} 个数据源已提交。`, tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '触发已选失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const cancelCollectionQueueItems = async (items: CollectionQueueItem[], label = '停止采集') => {
|
||
const cancellableItems = items.filter((item) => item.taskId && item.sourceId && isActiveQueueStatus(item.status))
|
||
if (!cancellableItems.length) {
|
||
toast({ title: '没有可停止的任务', description: '当前活跃任务缺少后端任务编号或已经结束。' })
|
||
return
|
||
}
|
||
setActionLoading(true)
|
||
try {
|
||
const results = await Promise.allSettled(cancellableItems.map((item) => (
|
||
axios.post(apiPath(`/datasources/${encodeURIComponent(item.sourceId)}/tasks/${encodeURIComponent(String(item.taskId))}/cancel`))
|
||
)))
|
||
const succeeded = results.filter((result) => result.status === 'fulfilled').length
|
||
const failed = results.length - succeeded
|
||
const now = Date.now()
|
||
cancellableItems.forEach((item, index) => {
|
||
if (results[index]?.status !== 'fulfilled') return
|
||
upsertCollectionQueueItem({
|
||
...item,
|
||
status: 'cancelling',
|
||
phase: 'cancelling',
|
||
phaseMessage: '正在停止任务',
|
||
updatedAt: now,
|
||
})
|
||
})
|
||
toast({
|
||
title: `${label}请求已提交`,
|
||
description: failed ? `${succeeded} 个已提交,${failed} 个提交失败。` : `${succeeded} 个任务正在停止。`,
|
||
tone: failed ? 'error' : 'success',
|
||
})
|
||
} catch (error) {
|
||
toast({ title: `${label}失败`, description: actionErrorMessage(error), tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const triggerDatasourcePrimaryAction = async () => {
|
||
if (selectedDatasourceIds.length && selectedActiveCollectQueueItems.length) {
|
||
await cancelCollectionQueueItems(selectedActiveCollectQueueItems, '停止已选')
|
||
return
|
||
}
|
||
if (!selectedDatasourceIds.length && activeCollectQueueItems.length) {
|
||
await cancelCollectionQueueItems(activeCollectQueueItems, '停止采集')
|
||
return
|
||
}
|
||
if (selectedDatasourceIds.length) {
|
||
await triggerSelectedDatasources()
|
||
return
|
||
}
|
||
await triggerAllDatasources()
|
||
}
|
||
|
||
const triggerAllDatasources = async () => {
|
||
setActionLoading(true)
|
||
try {
|
||
toast({ title: '正在提交采集队列', description: '触发全部会按后端调度规则跳过未到采集间隔的源。' })
|
||
const response = await axios.post(apiPath('/datasources/trigger-all'))
|
||
addBatchQueueResult(response.data)
|
||
arrayAt(response.data, 'triggered').forEach((item) => {
|
||
const sourceId = text(item.id || item.source_id || item.datasource_id, '')
|
||
const record = rows.find((row) => text(row.id, '') === sourceId || text(row.source, '') === text(item.source, ''))
|
||
if (record) scheduleDatasourceTaskPoll(record, item.task_id as number | string | null | undefined)
|
||
})
|
||
toast({ title: '采集队列已提交', description: `${arrayAt(response.data, 'triggered').length} 个任务已进入队列。`, tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '触发全部失败', description: actionErrorMessage(error), tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const scheduleDatasourceTaskPoll = (record: TableRecord, taskId?: number | string | null) => {
|
||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
const pollKey = text(taskId, id)
|
||
if (datasourcePollTimersRef.current[pollKey]) {
|
||
window.clearTimeout(datasourcePollTimersRef.current[pollKey])
|
||
}
|
||
const poll = async () => {
|
||
try {
|
||
const response = await axios.get(apiPath(`/datasources/${encodeURIComponent(id)}/task-status`), {
|
||
params: taskId ? { task_id: taskId } : undefined,
|
||
})
|
||
const payload = {
|
||
...response.data,
|
||
datasource_id: Number(id) || record.id,
|
||
source: record.source,
|
||
collector_name: record.source,
|
||
}
|
||
mergeDatasourceTaskUpdate(payload)
|
||
const status = text(response.data?.status, '')
|
||
if (DATASOURCE_TERMINAL_STATUSES.has(status)) {
|
||
delete datasourcePollTimersRef.current[pollKey]
|
||
await finalizeDatasourceTask(payload)
|
||
return
|
||
}
|
||
datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 5000 : 1500)
|
||
} catch {
|
||
datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, 3000)
|
||
}
|
||
}
|
||
datasourcePollTimersRef.current[pollKey] = window.setTimeout(poll, datasourceSocket.connected ? 3000 : 900)
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (config !== configs.datasources) return
|
||
const builtinState = states.find((state) => state.section.key === 'builtin')
|
||
if (!builtinState?.rows.length) return
|
||
builtinState.rows.forEach((row) => {
|
||
const status = datasourceStatus(row)
|
||
if (!['running', 'pending', 'queued'].includes(status)) return
|
||
const sourceId = text(row.id || row.source_id, '')
|
||
const source = text(row.source || row.collector_name, '')
|
||
const taskId = row.task_id as number | string | null | undefined
|
||
const taskType = text(row.task_type, 'collect')
|
||
upsertCollectionQueueItem({
|
||
key: queueItemKey({ id: sourceId, source, task_id: taskId }),
|
||
sourceId,
|
||
source,
|
||
name: recordTitle(row),
|
||
taskId,
|
||
taskType,
|
||
status: status === 'running' ? 'running' : 'queued',
|
||
phase: text(row.phase, status),
|
||
phaseMessage: text(row.phase_message || row.last_status, '后端任务仍在运行'),
|
||
progress: typeof row.progress === 'number' ? row.progress : 0,
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
})
|
||
scheduleDatasourceTaskPoll(row, taskId)
|
||
})
|
||
}, [config, states, upsertCollectionQueueItem])
|
||
|
||
const clearDatasourceData = async (record: TableRecord) => {
|
||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setActionLoading(true)
|
||
try {
|
||
toast({ title: '正在删除数据库数据', description: '删除可能需要一点时间,当前页面仍可继续操作。' })
|
||
const response = await axios.delete(apiPath(`/datasources/${encodeURIComponent(id)}/data`))
|
||
const taskId = response.data?.task_id
|
||
upsertCollectionQueueItem({
|
||
...queueItemFromDatasourceRow(record, taskId),
|
||
taskType: 'clear_data',
|
||
phaseMessage: '删除任务已提交',
|
||
})
|
||
setCollectionQueueOpen(true)
|
||
scheduleDatasourceTaskPoll(record, taskId)
|
||
toast({
|
||
title: '数据库清理已入队',
|
||
description: `任务 ${text(taskId, '-')} 会异步删除采集记录并刷新智能星球。`,
|
||
tone: 'success',
|
||
})
|
||
} catch (error) {
|
||
toast({ title: '清理数据库数据失败', description: actionErrorMessage(error), tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const clearDatasourceCache = async (record: TableRecord) => {
|
||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setActionLoading(true)
|
||
try {
|
||
toast({ title: '正在清理展示缓存', description: '缓存清理已在后台执行。' })
|
||
const response = await axios.delete(apiPath(`/datasources/${encodeURIComponent(id)}/cache`))
|
||
const taskId = response.data?.task_id
|
||
upsertCollectionQueueItem({
|
||
...queueItemFromDatasourceRow(record, taskId),
|
||
taskType: 'clear_cache',
|
||
phaseMessage: '缓存清理任务已提交',
|
||
})
|
||
setCollectionQueueOpen(true)
|
||
scheduleDatasourceTaskPoll(record, taskId)
|
||
toast({
|
||
title: '展示缓存清理已入队',
|
||
description: `任务 ${text(taskId, '-')} 会异步清理缓存并刷新智能星球。`,
|
||
tone: 'success',
|
||
})
|
||
} catch (error) {
|
||
toast({ title: '清理展示缓存失败', description: actionErrorMessage(error), tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const triggerDatasourceWithPrecheck = async (record: TableRecord) => {
|
||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setRowActionLoading((current) => ({ ...current, [id]: true }))
|
||
try {
|
||
const statusResponse = await axios.get(apiPath(`/datasources/${encodeURIComponent(id)}/task-status`))
|
||
if (statusResponse.data?.is_running) {
|
||
setConfirmAction({
|
||
title: '当前任务未完成',
|
||
description: `当前阶段:${text(statusResponse.data.phase_message || statusResponse.data.phase, '未知')}。确认后会强制取消当前采集,并重新开始采集。`,
|
||
danger: true,
|
||
confirmLabel: '强制重新采集',
|
||
run: () => triggerDatasource(record, true),
|
||
})
|
||
return
|
||
}
|
||
await triggerDatasource(record, false)
|
||
} catch (error) {
|
||
toast({ title: '触发采集失败', description: actionErrorMessage(error), tone: 'error' })
|
||
} finally {
|
||
setRowActionLoading((current) => ({ ...current, [id]: false }))
|
||
}
|
||
}
|
||
|
||
const triggerDatasource = async (record: TableRecord, force: boolean) => {
|
||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setRowActionLoading((current) => ({ ...current, [id]: true }))
|
||
try {
|
||
const response = await axios.post(apiPath(`/datasources/${encodeURIComponent(id)}/trigger`), null, {
|
||
params: { force },
|
||
validateStatus: (status) => status < 500,
|
||
})
|
||
if (response.status === 409 && isObjectRecord(response.data?.detail) && response.data.detail.reason === 'running_task_in_progress') {
|
||
const detail = response.data.detail
|
||
setConfirmAction({
|
||
title: '当前任务未完成',
|
||
description: `${text(detail.message, '当前采集任务仍在运行。')} 当前阶段:${text(detail.phase_message || detail.phase, '未知')}。`,
|
||
danger: true,
|
||
confirmLabel: '强制重新采集',
|
||
run: () => triggerDatasource(record, true),
|
||
})
|
||
return
|
||
}
|
||
if (response.status >= 400) {
|
||
toast({ title: '触发采集失败', description: actionErrorMessage({ response } as unknown), tone: 'error' })
|
||
return
|
||
}
|
||
const taskId = response.data?.task_id
|
||
const pendingKey = text(taskId, id)
|
||
pendingDatasourceTasksRef.current[pendingKey] = {
|
||
sourceId: id,
|
||
source: text(record.source || response.data?.collector_name, ''),
|
||
name: recordTitle(record),
|
||
taskId,
|
||
}
|
||
upsertCollectionQueueItem(queueItemFromDatasourceRow(record, taskId, {
|
||
source: text(record.source || response.data?.collector_name, ''),
|
||
taskType: 'collect',
|
||
}))
|
||
setCollectionQueueOpen(true)
|
||
completedDatasourceTasksRef.current.delete(pendingKey)
|
||
updateDatasourceRow({
|
||
...record,
|
||
id: record.id || Number(id) || id,
|
||
source: record.source || response.data?.collector_name,
|
||
task_id: taskId,
|
||
task_type: 'collect',
|
||
is_running: true,
|
||
status: 'running',
|
||
last_status: 'running',
|
||
phase: 'queued',
|
||
phase_message: '任务已提交',
|
||
progress: 0,
|
||
error_message: null,
|
||
})
|
||
replaceSelectedWithPayload('task-status', '任务状态', [{
|
||
...response.data,
|
||
__title: `${recordTitle(record)} 任务状态`,
|
||
__module: '任务状态',
|
||
__status: '已提交',
|
||
__metric: response.data?.task_id || '-',
|
||
}])
|
||
toast({ title: force ? '已强制重新触发' : '任务已触发', tone: 'success' })
|
||
scheduleDatasourceTaskPoll(record, taskId)
|
||
} catch (error) {
|
||
toast({ title: '触发采集失败', description: actionErrorMessage(error), tone: 'error' })
|
||
} finally {
|
||
setRowActionLoading((current) => ({ ...current, [id]: false }))
|
||
}
|
||
}
|
||
|
||
const submitResolveAlert = async () => {
|
||
if (!resolveTarget) return
|
||
const alertId = pick(resolveTarget, ['id', 'alert_id'], '')
|
||
if (!alertId) return
|
||
await requestAction('解决告警', 'post', `/alerts/${encodeURIComponent(alertId)}/resolve`, {
|
||
resolution: resolutionText.trim() || '已处理',
|
||
})
|
||
setResolveTarget(null)
|
||
}
|
||
|
||
const uploadBrandAsset = async () => {
|
||
if (!brandUploadFile) {
|
||
toast({ title: '请选择上传文件', tone: 'error' })
|
||
return
|
||
}
|
||
const suffix = brandUploadFile.name.split('.').pop()?.toLowerCase() || ''
|
||
if (!BRAND_ASSET_SUFFIXES.includes(suffix)) {
|
||
toast({ title: '文件类型不支持', description: `仅支持 ${BRAND_ASSET_SUFFIXES.join(', ')}。`, tone: 'error' })
|
||
return
|
||
}
|
||
const formData = new FormData()
|
||
formData.append('file', brandUploadFile)
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.post(apiPath('/earth/brand/assets'), formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' },
|
||
})
|
||
replaceSelectedWithPayload('brand-upload', '品牌资产上传', [{
|
||
...response.data,
|
||
__title: '品牌资产上传结果',
|
||
__module: '品牌资产',
|
||
__status: '已上传',
|
||
__metric: brandUploadFile.name,
|
||
}])
|
||
setBrandUploadFile(null)
|
||
toast({ title: '品牌资产已上传', tone: 'success' })
|
||
await load()
|
||
} catch (error) {
|
||
toast({ title: '品牌资产上传失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const selectBrandUploadFile = (file: File | null | undefined) => {
|
||
if (!file) {
|
||
setBrandUploadFile(null)
|
||
return
|
||
}
|
||
const suffix = file.name.split('.').pop()?.toLowerCase() || ''
|
||
if (!BRAND_ASSET_SUFFIXES.includes(suffix)) {
|
||
toast({ title: '文件类型不支持', description: `仅支持 ${BRAND_ASSET_SUFFIXES.join(', ')}。`, tone: 'error' })
|
||
return
|
||
}
|
||
setBrandUploadFile(file)
|
||
}
|
||
|
||
const saveAdvancedJson = async () => {
|
||
if (!selected) return
|
||
let payload: unknown
|
||
try {
|
||
payload = JSON.parse(advancedJson)
|
||
} catch {
|
||
toast({ title: 'JSON 格式错误', description: '请修正高级编辑内容后再保存。', tone: 'error' })
|
||
return
|
||
}
|
||
|
||
const endpointKey = selected.__endpointKey
|
||
const id = pick(selected, ['config_id', 'id', 'task_key', 'provider'], '')
|
||
if (config === configs.settings) {
|
||
const settingsPaths: Record<string, string> = {
|
||
system: '/settings/system',
|
||
notifications: '/settings/notifications',
|
||
security: '/settings/security',
|
||
smtp: '/settings/smtp',
|
||
integrations: '/settings/integrations',
|
||
collectors: `/settings/collectors/${encodeURIComponent(id)}`,
|
||
}
|
||
const path = settingsPaths[endpointKey]
|
||
if (path) {
|
||
await requestAction(`保存${selected.__endpointLabel}`, 'put', path, payload)
|
||
return
|
||
}
|
||
}
|
||
if (config === configs.earthContent && endpointKey === 'tv') {
|
||
await requestAction('保存 TV 配置', 'put', '/settings/tv', payload)
|
||
return
|
||
}
|
||
if (config === configs.collection && id && /configs/.test(endpointKey)) {
|
||
await requestAction('保存采集器配置', 'put', `/datasources/configs/${encodeURIComponent(id)}`, isObjectRecord(payload) ? normalizeDatasourceConfigPayload(payload) : payload)
|
||
return
|
||
}
|
||
if (config === configs.earthContent && endpointKey === 'brand') {
|
||
await requestAction('保存品牌配置', 'put', '/earth/brand', payload)
|
||
return
|
||
}
|
||
if (config === configs.earthContent && endpointKey === 'boundaries') {
|
||
await requestAction('保存边界配置', 'put', '/earth/boundaries/config', { config: payload })
|
||
return
|
||
}
|
||
if (config === configs.ai && endpointKey === 'prompts' && id) {
|
||
await requestAction('保存 Prompt', 'put', `/settings/ai-prompts/${encodeURIComponent(id)}`, payload)
|
||
return
|
||
}
|
||
if (config === configs.ai && endpointKey === 'integrations') {
|
||
await requestAction('保存 AI 集成配置', 'put', '/settings/integrations', payload)
|
||
return
|
||
}
|
||
toast({ title: '只读记录', description: '该实体只提供只读详情。', tone: 'error' })
|
||
}
|
||
|
||
const copySelected = async () => {
|
||
if (!selected) return
|
||
await navigator.clipboard.writeText(formatRaw(cleanRecord(selected)))
|
||
toast({ title: '已复制详情', description: '当前记录 JSON 已写入剪贴板。', tone: 'success' })
|
||
}
|
||
|
||
const fetchLogSnapshot = async () => {
|
||
if (!selected) return
|
||
const sourceId = pick(selected, ['source_id', 'id', 'key'], '')
|
||
if (!sourceId) return
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.get(apiPath(`/system/logs/${encodeURIComponent(sourceId)}`), { params: { limit: 200 } })
|
||
setSelected(enrichRows({ section: { key: 'logSnapshot', label: '日志快照', url: '', map: emptyRows }, ok: true, rows: [], raw: response.data }, [{
|
||
...response.data,
|
||
__title: `${sourceId} 快照`,
|
||
__module: '日志快照',
|
||
__status: '已读取',
|
||
__metric: `${Array.isArray((response.data as AnyRecord).lines) ? ((response.data as AnyRecord).lines as unknown[]).length : 0} 行`,
|
||
}])[0])
|
||
setSelectedHistory((history) => selected ? [...history, selected] : history)
|
||
setMobileResourceDetailOpen(true)
|
||
toast({ title: '日志快照已读取', description: '已拉取最近 200 行日志。', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '读取日志失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const replaceSelectedWithPayload = (sectionKey: string, sectionLabel: string, rows: AnyRecord[]) => {
|
||
const section = { key: sectionKey, label: sectionLabel, url: '', map: emptyRows }
|
||
const normalized = enrichRows({ section, ok: true, rows: [], raw: rows }, rows)
|
||
setSelectedHistory((history) => selected ? [...history, selected] : history)
|
||
setSelected(normalized[0] ?? null)
|
||
setMobileResourceDetailOpen(Boolean(normalized[0]))
|
||
}
|
||
|
||
const loadDatasourceDetail = async (record: TableRecord) => {
|
||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setActionLoading(true)
|
||
try {
|
||
const [detailRes, statsRes, taskRes, configRes] = await Promise.allSettled([
|
||
axios.get(apiPath(`/datasources/${encodeURIComponent(id)}`)),
|
||
axios.get(apiPath(`/datasources/${encodeURIComponent(id)}/stats`)),
|
||
axios.get(apiPath(`/datasources/${encodeURIComponent(id)}/task-status`)),
|
||
axios.get(apiPath(`/datasources/configs/${encodeURIComponent(id)}`)),
|
||
])
|
||
replaceSelectedWithPayload('datasource-detail', '数据源详情', [{
|
||
...cleanRecord(record),
|
||
detail: detailRes.status === 'fulfilled' ? detailRes.value.data : null,
|
||
stats: statsRes.status === 'fulfilled' ? statsRes.value.data : null,
|
||
task_status: taskRes.status === 'fulfilled' ? taskRes.value.data : null,
|
||
config_override: configRes.status === 'fulfilled' ? configRes.value.data : null,
|
||
__title: recordTitle(record),
|
||
__module: '数据源详情',
|
||
__status: taskRes.status === 'fulfilled' ? recordStatus(taskRes.value.data) : recordStatus(record),
|
||
__metric: statsRes.status === 'fulfilled' ? recordMetric(statsRes.value.data) : recordMetric(record),
|
||
}])
|
||
toast({ title: '详情已刷新', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '详情加载失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadTaskStatus = async (record: TableRecord) => {
|
||
const id = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.get(apiPath(`/datasources/${encodeURIComponent(id)}/task-status`))
|
||
replaceSelectedWithPayload('task-status', '任务状态', [{
|
||
...response.data,
|
||
__title: `${recordTitle(record)} 任务状态`,
|
||
__module: '任务状态',
|
||
__status: recordStatus(response.data),
|
||
__metric: recordMetric(response.data),
|
||
}])
|
||
toast({ title: '任务状态已刷新', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '任务状态加载失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadStreamStatus = async (record: TableRecord) => {
|
||
const id = pick(record, ['config_id', 'id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.get(apiPath(`/datasources/${encodeURIComponent(id)}/stream-status`))
|
||
replaceSelectedWithPayload('stream-status', '运行状态', [{
|
||
...response.data,
|
||
__title: `${recordTitle(record)} 运行状态`,
|
||
__module: '运行状态',
|
||
__status: recordStatus(response.data),
|
||
__metric: recordMetric(response.data),
|
||
}])
|
||
toast({ title: '运行状态已刷新', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '运行状态加载失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const fetchCustomSample = async (record: TableRecord) => {
|
||
const id = pick(record, ['config_id', 'id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.post(apiPath('/datasources/configs/custom/sample'), {
|
||
datasource_config_id: Number(id),
|
||
})
|
||
replaceSelectedWithPayload('custom-sample', '采样数据', [{
|
||
...response.data,
|
||
__title: `${recordTitle(record)} 采样数据`,
|
||
__module: '采样数据',
|
||
__status: response.data?.success ? 'success' : 'unknown',
|
||
__metric: response.data?.sample_payload_hash || '-',
|
||
}])
|
||
toast({ title: '采样数据已读取', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '采样失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const saveMappingTemplate = async (record: TableRecord, activate = false) => {
|
||
const id = pick(record, ['id', 'mapping_id'], '')
|
||
if (!id) return
|
||
let payload: AnyRecord
|
||
try {
|
||
payload = JSON.parse(advancedJson) as AnyRecord
|
||
} catch {
|
||
toast({ title: 'JSON 格式错误', description: '请修正映射内容后再保存。', tone: 'error' })
|
||
return
|
||
}
|
||
const body = {
|
||
target_schema: payload.target_schema,
|
||
mapping_json: payload.mapping_json,
|
||
sample_payload_hash: payload.sample_payload_hash,
|
||
validation_status: activate ? 'valid' : payload.validation_status,
|
||
is_active: activate ? true : payload.is_active,
|
||
}
|
||
await requestAction(activate ? '启用映射' : '保存映射', 'put', `/datasources/mappings/${encodeURIComponent(id)}`, body)
|
||
}
|
||
|
||
const previewMappingTemplate = async (record: TableRecord) => {
|
||
let payload: AnyRecord
|
||
try {
|
||
payload = JSON.parse(advancedJson) as AnyRecord
|
||
} catch {
|
||
toast({ title: 'JSON 格式错误', description: '请修正映射内容后再预览。', tone: 'error' })
|
||
return
|
||
}
|
||
if (!payload.sample_payload || !payload.mapping_json || !payload.target_schema) {
|
||
toast({ title: '缺少预览参数', description: '需要采样 Payload、映射 JSON 和目标 Schema。', tone: 'error' })
|
||
return
|
||
}
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.post(apiPath('/datasources/mappings/preview'), {
|
||
sample_payload: payload.sample_payload,
|
||
target_schema: payload.target_schema,
|
||
mapping_json: payload.mapping_json,
|
||
limit: 20,
|
||
})
|
||
replaceSelectedWithPayload('mapping-preview', '映射预览', [{
|
||
...response.data,
|
||
__title: `${recordTitle(record)} 预览`,
|
||
__module: '映射预览',
|
||
__status: response.data?.success ? 'success' : 'failed',
|
||
__metric: response.data?.sample_payload_hash || '-',
|
||
}])
|
||
toast({ title: '映射预览已生成', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '映射预览失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadBGPDetail = async (record: TableRecord) => {
|
||
const endpointKey = record.__endpointKey
|
||
const id = pick(record, ['id', 'event_id', 'anomaly_id', 'incident_id'], '')
|
||
if (!id) return
|
||
const detailPaths: Record<string, string> = {
|
||
events: `/bgp/events/${encodeURIComponent(id)}`,
|
||
anomalies: `/bgp/anomalies/${encodeURIComponent(id)}`,
|
||
incidents: `/bgp/incidents/${encodeURIComponent(id)}`,
|
||
briefs: `/ai/bgp/briefs/${encodeURIComponent(id)}`,
|
||
}
|
||
const path = detailPaths[endpointKey]
|
||
if (!path) return
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.get(apiPath(path))
|
||
replaceSelectedWithPayload(`${endpointKey}-detail`, `${record.__endpointLabel}详情`, [{
|
||
...response.data,
|
||
__title: recordTitle(response.data),
|
||
__module: `${record.__endpointLabel}详情`,
|
||
__status: recordStatus(response.data),
|
||
__metric: recordMetric(response.data),
|
||
}])
|
||
toast({ title: '详情已加载', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '详情加载失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const collectCollectorLocation = async (record: TableRecord) => {
|
||
const id = pick(record, ['id', 'collector_id', 'source_id', 'key', 'name'], '')
|
||
if (!id) return
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.post(apiPath(`/bgp/collectors/${encodeURIComponent(id)}/collect-location`))
|
||
replaceSelectedWithPayload('collector-location', 'Collector 位置采集', [{
|
||
...response.data,
|
||
__title: `${recordTitle(record)} 位置采集`,
|
||
__module: 'Collector 位置',
|
||
__status: recordStatus(response.data) === '-' ? '已提交' : recordStatus(response.data),
|
||
__metric: recordMetric(response.data),
|
||
}])
|
||
toast({ title: 'Collector 位置采集已提交', tone: 'success' })
|
||
await load()
|
||
} catch (error) {
|
||
toast({ title: 'Collector 位置采集失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadCredentialGuide = async (provider: string, action: 'read' | 'generate' | 'reset' = 'read') => {
|
||
const guideProvider = provider.trim().toLowerCase().replace(/\s+/g, '_')
|
||
if (!guideProvider) {
|
||
setCredentialGuide({
|
||
provider: '当前采集器',
|
||
title: '暂无凭证教程',
|
||
markdown: '',
|
||
source: 'missing',
|
||
verification_status: 'missing',
|
||
})
|
||
setCredentialGuideOpen(true)
|
||
return
|
||
}
|
||
setActionLoading(true)
|
||
setCredentialGuideBusy(action)
|
||
try {
|
||
const path = action === 'read'
|
||
? `/settings/credential-guides/${encodeURIComponent(guideProvider)}`
|
||
: `/settings/credential-guides/${encodeURIComponent(guideProvider)}/${action}`
|
||
const response = action === 'read' ? await axios.get(apiPath(path)) : await axios.post(apiPath(path))
|
||
setCredentialGuide({
|
||
...(isObjectRecord(response.data.guide) ? response.data.guide : {}),
|
||
provider: guideProvider,
|
||
})
|
||
setCredentialGuideOpen(true)
|
||
const guide = isObjectRecord(response.data.guide) ? response.data.guide : {}
|
||
const noSearchEvidence = text(guide.verification_status, '') === 'unverified_no_search_evidence'
|
||
if (action !== 'read') {
|
||
toast({
|
||
title: action === 'generate'
|
||
? noSearchEvidence
|
||
? '未生成新教程'
|
||
: '教程已生成'
|
||
: '教程已重置',
|
||
description: noSearchEvidence ? '没有可用 WebSearch 证据,已保留默认/当前教程。' : undefined,
|
||
tone: noSearchEvidence ? 'default' : 'success',
|
||
})
|
||
}
|
||
} catch (error) {
|
||
if (action === 'read' && axios.isAxiosError(error) && error.response?.status === 404) {
|
||
setCredentialGuide({
|
||
provider: guideProvider,
|
||
title: `${guideProvider} 凭证教程`,
|
||
markdown: '',
|
||
source: 'missing',
|
||
verification_status: 'missing',
|
||
verification_error: actionErrorMessage(error),
|
||
})
|
||
setCredentialGuideOpen(true)
|
||
} else {
|
||
toast({ title: '凭证教程操作失败', description: actionErrorMessage(error), tone: 'error' })
|
||
}
|
||
} finally {
|
||
setActionLoading(false)
|
||
setCredentialGuideBusy(null)
|
||
}
|
||
}
|
||
|
||
const loadEarthLayerCacheStatus = async () => {
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.get(apiPath('/system/cache/earth-layers'))
|
||
replaceSelectedWithPayload('earth-layer-cache', '智能星球图层缓存', [{
|
||
...response.data,
|
||
__title: '智能星球图层缓存',
|
||
__module: '缓存',
|
||
__status: '已读取',
|
||
__metric: recordMetric(response.data),
|
||
}])
|
||
toast({ title: '缓存状态已读取', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '缓存状态读取失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadIntegrationSecrets = async (kind: 'ai_provider' | 'web_search' | 'ocr', provider?: string) => {
|
||
const cacheKey = `${kind}:${provider || 'default'}`
|
||
if (revealedSecrets[cacheKey]) return revealedSecrets[cacheKey]
|
||
const path = kind === 'ai_provider'
|
||
? '/settings/integrations/ai-provider/secrets'
|
||
: kind === 'web_search'
|
||
? '/settings/integrations/web-search/secrets'
|
||
: '/settings/integrations/ocr/secrets'
|
||
const response = await axios.get(apiPath(path), {
|
||
params: provider ? { provider } : undefined,
|
||
})
|
||
const content = isObjectRecord(response.data) ? response.data : {}
|
||
setRevealedSecrets((prev) => ({ ...prev, [cacheKey]: content }))
|
||
return content
|
||
}
|
||
|
||
const toggleSecretField = async ({
|
||
fieldKey,
|
||
kind,
|
||
provider,
|
||
baseline,
|
||
valueKey,
|
||
}: {
|
||
fieldKey: string
|
||
kind: 'ai_provider' | 'web_search' | 'ocr'
|
||
provider?: string
|
||
baseline: AnyRecord
|
||
valueKey: string
|
||
}) => {
|
||
const visible = !visibleSecretFields[fieldKey]
|
||
if (!visible) {
|
||
setHierarchyDraft(setNestedDraftField(hierarchyDraft, baseline, valueKey, secretPreview(baseline[valueKey])))
|
||
setVisibleSecretFields((prev) => ({ ...prev, [fieldKey]: false }))
|
||
return
|
||
}
|
||
const currentDraft = draftRecord(hierarchyDraft, baseline)
|
||
const draftSecret = text(currentDraft[valueKey], '').trim()
|
||
const baselineSecretPreview = secretPreview(baseline[valueKey])
|
||
const hasNewDraftSecret = Boolean(
|
||
draftSecret &&
|
||
!isMaskedSecretDraft(draftSecret) &&
|
||
draftSecret !== baselineSecretPreview,
|
||
)
|
||
if (hasNewDraftSecret) {
|
||
setVisibleSecretFields((prev) => ({ ...prev, [fieldKey]: true }))
|
||
return
|
||
}
|
||
if (!hasRuntimeSecret(baseline[valueKey])) {
|
||
toast({ title: '未配置密钥', description: '当前项没有落库密钥,也没有匹配当前 provider 的 fallback。', tone: 'default' })
|
||
setVisibleSecretFields((prev) => ({ ...prev, [fieldKey]: false }))
|
||
return
|
||
}
|
||
setActionLoading(true)
|
||
try {
|
||
const secrets = await loadIntegrationSecrets(kind, provider)
|
||
const nextValue = text(secrets[valueKey], '')
|
||
if (nextValue) {
|
||
setHierarchyDraft(setNestedDraftField(hierarchyDraft, baseline, valueKey, nextValue))
|
||
}
|
||
setVisibleSecretFields((prev) => ({ ...prev, [fieldKey]: true }))
|
||
} catch (error) {
|
||
toast({ title: '读取密钥失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const findIntegrations = () => {
|
||
for (const state of states) {
|
||
const fromEndpoint = endpointData(state.raw, 'integrations')
|
||
const endpointIntegrations = objectAt(fromEndpoint, 'integrations')
|
||
if (Object.keys(endpointIntegrations).length) return endpointIntegrations
|
||
const directIntegrations = objectAt(state.raw, 'integrations')
|
||
if (Object.keys(directIntegrations).length) return directIntegrations
|
||
}
|
||
return {}
|
||
}
|
||
|
||
const buildExternalIntegrationPayload = (overrides: Partial<Record<'ai_provider' | 'web_search' | 'ocr', AnyRecord>> = {}) => {
|
||
const integrations = findIntegrations()
|
||
const aiProvider = objectAt(integrations, 'ai_provider')
|
||
const webSearch = objectAt(integrations, 'web_search')
|
||
const ocr = objectAt(integrations, 'ocr')
|
||
const barentswatch = objectAt(integrations, 'barentswatch')
|
||
return {
|
||
ai_provider: {
|
||
service_url: text(aiProvider.service_url, ''),
|
||
service_token: '',
|
||
default_provider: text(aiProvider.default_provider || aiProvider.provider, 'minimax'),
|
||
provider: text(aiProvider.provider || aiProvider.default_provider, 'minimax'),
|
||
provider_api: text(aiProvider.provider_api, 'anthropic-messages'),
|
||
base_url: text(aiProvider.base_url, ''),
|
||
model: text(aiProvider.model, ''),
|
||
api_key: '',
|
||
max_tokens: Number(aiProvider.max_tokens || 1200),
|
||
anthropic_version: text(aiProvider.anthropic_version, '2023-06-01'),
|
||
timeout_seconds: Number(aiProvider.timeout_seconds || 60),
|
||
retry_attempts: Number(aiProvider.retry_attempts || 2),
|
||
...(overrides.ai_provider || {}),
|
||
},
|
||
barentswatch: {
|
||
endpoint: text(barentswatch.endpoint, ''),
|
||
client_id: text(barentswatch.client_id, ''),
|
||
client_secret: '',
|
||
},
|
||
web_search: {
|
||
enabled: Boolean(webSearch.enabled),
|
||
default_provider: text(webSearch.default_provider || webSearch.provider, 'tavily'),
|
||
provider: text(webSearch.provider || webSearch.default_provider, 'tavily'),
|
||
base_url: text(webSearch.base_url, ''),
|
||
api_key: '',
|
||
max_results: Number(webSearch.max_results || 5),
|
||
timeout_seconds: Number(webSearch.timeout_seconds || 20),
|
||
endpoint_path: text(webSearch.endpoint_path, ''),
|
||
search_depth: text(webSearch.search_depth, 'basic'),
|
||
engine: text(webSearch.engine, 'google'),
|
||
include_answer: Boolean(webSearch.include_answer),
|
||
include_raw_content: Boolean(webSearch.include_raw_content),
|
||
include_text: Boolean(webSearch.include_text),
|
||
categories: text(webSearch.categories, 'general'),
|
||
engines: Array.isArray(webSearch.engines) ? webSearch.engines : [],
|
||
search_path: text(webSearch.search_path, ''),
|
||
scrape_path: text(webSearch.scrape_path, ''),
|
||
scrape_formats: Array.isArray(webSearch.scrape_formats) ? webSearch.scrape_formats : ['markdown'],
|
||
...(overrides.web_search || {}),
|
||
},
|
||
ocr: {
|
||
enabled: Boolean(ocr.enabled),
|
||
provider: text(ocr.provider, 'paddleocr'),
|
||
base_url: text(ocr.base_url, ''),
|
||
api_key: '',
|
||
model: text(ocr.model, ''),
|
||
languages: Array.isArray(ocr.languages) ? ocr.languages : ['zh', 'en'],
|
||
timeout_seconds: Number(ocr.timeout_seconds || 30),
|
||
max_file_size_mb: Number(ocr.max_file_size_mb || 20),
|
||
output_format: text(ocr.output_format, 'markdown'),
|
||
...(overrides.ocr || {}),
|
||
},
|
||
}
|
||
}
|
||
|
||
const saveHierarchySettings = async (path: string, payload: unknown, label: string) => {
|
||
await requestAction(label, 'put', path, payload)
|
||
}
|
||
|
||
const saveHierarchyIntegrations = async (overrides: Partial<Record<'ai_provider' | 'web_search' | 'ocr', AnyRecord>>, label: string) => {
|
||
await requestAction(label, 'put', '/settings/integrations', buildExternalIntegrationPayload(overrides))
|
||
}
|
||
|
||
const getActiveHierarchyGroup = (groups: HierarchyGroup[]) => {
|
||
const flat = groups.flatMap((group) => group.children?.length ? group.children : [group])
|
||
return flat.find((group) => group.key === activeGroupKey) || flat[0] || null
|
||
}
|
||
|
||
const selectHierarchyGroup = (group: HierarchyGroup, revealMobile = true) => {
|
||
setActiveGroupKey(group.key)
|
||
setHierarchyDraft(formatRaw(group.record))
|
||
setSelected(null)
|
||
setVisibleSecretFields({})
|
||
setMobileHierarchyDetailOpen(revealMobile)
|
||
}
|
||
|
||
const providerPresetRows = (state: SectionState | undefined) => dataArray(endpointData(state?.raw, 'providerPresets'))
|
||
const webSearchPresetRows = (state: SectionState | undefined) => dataArray(endpointData(state?.raw, 'webSearchPresets'))
|
||
|
||
const renderAIHierarchy = () => {
|
||
if (!activeState || !activeSection) return null
|
||
const integrations = findIntegrations()
|
||
const aiProvider = objectAt(integrations, 'ai_provider')
|
||
const providers = objectAt(aiProvider, 'providers')
|
||
const webSearch = objectAt(integrations, 'web_search')
|
||
const webProviders = objectAt(webSearch, 'providers')
|
||
const ocr = objectAt(integrations, 'ocr')
|
||
const providerPresets = providerPresetRows(activeState)
|
||
const webPresets = webSearchPresetRows(activeState)
|
||
|
||
let groups: HierarchyGroup[] = []
|
||
if (activeSection.key === 'integrations') {
|
||
const runtimeProvider = selectedRuntimeProvider(aiProvider)
|
||
const providerIds = Array.from(new Set([
|
||
text(runtimeProvider, 'minimax'),
|
||
...Object.keys(providers),
|
||
...providerPresets.map((preset) => text(preset.provider, '')).filter(Boolean),
|
||
])).filter(Boolean)
|
||
groups = providerIds.map((providerId) => {
|
||
const preset = providerPresets.find((item) => text(item.provider, '') === providerId) || {}
|
||
const providerConfig: AnyRecord = { ...objectAt(providers, providerId), provider: providerId }
|
||
const isDefaultProvider = providerId === text(aiProvider.default_provider || runtimeProvider, '')
|
||
const canUseRuntimeConfig = providerId === runtimeProvider
|
||
const providerOwnSecretConfigured = hasStoredSecret(providerConfig.api_key)
|
||
const providerScopedRuntimeSecretConfigured = !providerOwnSecretConfigured && isFallbackSecret(providerConfig.api_key) && hasRuntimeSecret(providerConfig.api_key)
|
||
const providerRuntimeSecretConfigured = !providerOwnSecretConfigured && !providerScopedRuntimeSecretConfigured && canUseRuntimeConfig && hasRuntimeSecret(aiProvider.api_key)
|
||
const providerSecret = providerOwnSecretConfigured
|
||
? providerConfig.api_key
|
||
: providerScopedRuntimeSecretConfigured
|
||
? providerConfig.api_key
|
||
: providerRuntimeSecretConfigured
|
||
? aiProvider.api_key
|
||
: undefined
|
||
const providerConfigured = providerOwnSecretConfigured || providerScopedRuntimeSecretConfigured || providerRuntimeSecretConfigured
|
||
const providerSecretIsRuntime = Boolean(providerSecret && isFallbackSecret(providerSecret))
|
||
const record = {
|
||
service_url: text(aiProvider.service_url, ''),
|
||
service_token: secretPreview(aiProvider.service_token),
|
||
default_provider: providerId,
|
||
provider: providerId,
|
||
provider_api: text(providerConfig.provider_api || (canUseRuntimeConfig ? aiProvider.provider_api : '') || preset.provider_api, 'anthropic-messages'),
|
||
base_url: text(providerConfig.base_url || (canUseRuntimeConfig ? aiProvider.base_url : '') || preset.base_url, ''),
|
||
model: text(providerConfig.model || (canUseRuntimeConfig ? aiProvider.model : '') || preset.model, ''),
|
||
api_key: secretPreview(providerSecret),
|
||
max_tokens: Number(providerConfig.max_tokens || (canUseRuntimeConfig ? aiProvider.max_tokens : 0) || 1200),
|
||
anthropic_version: text(providerConfig.anthropic_version, '2023-06-01'),
|
||
timeout_seconds: Number(providerConfig.timeout_seconds || (canUseRuntimeConfig ? aiProvider.timeout_seconds : 0) || 60),
|
||
retry_attempts: Number(providerConfig.retry_attempts || (canUseRuntimeConfig ? aiProvider.retry_attempts : 0) || 2),
|
||
models: Array.isArray(preset.models) ? preset.models : [],
|
||
__fallbackSecrets: providerSecretIsRuntime || (providerRuntimeSecretConfigured && !providerOwnSecretConfigured) ? ['api_key'] : [],
|
||
__defaultDraft: {
|
||
service_url: text(aiProvider.service_url, ''),
|
||
service_token: secretPreview(aiProvider.service_token),
|
||
default_provider: providerId,
|
||
provider: providerId,
|
||
provider_api: text(preset.provider_api || providerConfig.provider_api, 'anthropic-messages'),
|
||
base_url: text(preset.base_url, ''),
|
||
model: text(preset.model, ''),
|
||
api_key: '',
|
||
max_tokens: Number(preset.max_tokens || aiProvider.max_tokens || 1200),
|
||
anthropic_version: text(preset.anthropic_version, '2023-06-01'),
|
||
timeout_seconds: Number(aiProvider.timeout_seconds || 60),
|
||
retry_attempts: Number(aiProvider.retry_attempts || 2),
|
||
models: Array.isArray(preset.models) ? preset.models : [],
|
||
},
|
||
}
|
||
return {
|
||
key: `provider:${providerId}`,
|
||
label: providerId,
|
||
description: text(preset.label || preset.provider_api || record.provider_api, '模型供应商'),
|
||
status: credentialStatus({
|
||
explicitStatus: providerConfig.status,
|
||
configured: providerConfigured,
|
||
isDefault: isDefaultProvider,
|
||
}),
|
||
count: record.models.length,
|
||
record,
|
||
}
|
||
})
|
||
groups = sortGroupsByStatus(groups)
|
||
} else if (activeSection.key === 'tools') {
|
||
const runtimeWebProvider = selectedRuntimeProvider(webSearch)
|
||
const webProviderIds = Array.from(new Set([
|
||
text(runtimeWebProvider, 'tavily'),
|
||
...Object.keys(webProviders),
|
||
...webPresets.map((preset) => text(preset.provider, '')).filter(Boolean),
|
||
])).filter(Boolean)
|
||
groups = [
|
||
{
|
||
key: 'tool:web_search',
|
||
label: 'Web Search',
|
||
description: '每个搜索 provider 有独立 API 与高级参数',
|
||
record: {},
|
||
children: sortGroupsByStatus(webProviderIds.map((providerId) => {
|
||
const preset = webPresets.find((item) => text(item.provider, '') === providerId) || {}
|
||
const providerConfig: AnyRecord = { ...objectAt(webProviders, providerId), provider: providerId }
|
||
const isDefaultProvider = providerId === text(webSearch.default_provider || runtimeWebProvider, '')
|
||
const canUseRuntimeConfig = providerId === runtimeWebProvider
|
||
const providerOwnSecretConfigured = hasStoredSecret(providerConfig.api_key)
|
||
const providerScopedRuntimeSecretConfigured = !providerOwnSecretConfigured && isFallbackSecret(providerConfig.api_key) && hasRuntimeSecret(providerConfig.api_key)
|
||
const providerRuntimeSecretConfigured = !providerOwnSecretConfigured && !providerScopedRuntimeSecretConfigured && canUseRuntimeConfig && hasRuntimeSecret(webSearch.api_key)
|
||
const providerSecret = providerOwnSecretConfigured
|
||
? providerConfig.api_key
|
||
: providerScopedRuntimeSecretConfigured
|
||
? providerConfig.api_key
|
||
: providerRuntimeSecretConfigured
|
||
? webSearch.api_key
|
||
: undefined
|
||
const providerConfigured = providerOwnSecretConfigured || providerScopedRuntimeSecretConfigured || providerRuntimeSecretConfigured
|
||
const providerSecretIsRuntime = Boolean(providerSecret && isFallbackSecret(providerSecret))
|
||
return {
|
||
key: `web_search:${providerId}`,
|
||
label: providerId,
|
||
description: text(preset.label, 'Web Search Provider'),
|
||
status: credentialStatus({
|
||
explicitStatus: providerConfig.status,
|
||
configured: providerConfigured,
|
||
isDefault: isDefaultProvider,
|
||
}),
|
||
record: {
|
||
enabled: Boolean(webSearch.enabled),
|
||
default_provider: providerId,
|
||
provider: providerId,
|
||
base_url: text(providerConfig.base_url || (canUseRuntimeConfig ? webSearch.base_url : '') || preset.base_url, ''),
|
||
api_key: secretPreview(providerSecret),
|
||
max_results: Number(providerConfig.max_results || (canUseRuntimeConfig ? webSearch.max_results : 0) || 5),
|
||
timeout_seconds: Number(providerConfig.timeout_seconds || (canUseRuntimeConfig ? webSearch.timeout_seconds : 0) || 20),
|
||
endpoint_path: text(providerConfig.endpoint_path || (canUseRuntimeConfig ? webSearch.endpoint_path : ''), ''),
|
||
search_depth: text(providerConfig.search_depth || (canUseRuntimeConfig ? webSearch.search_depth : ''), 'basic'),
|
||
engine: text(providerConfig.engine || (canUseRuntimeConfig ? webSearch.engine : ''), 'google'),
|
||
include_answer: Boolean(providerConfig.include_answer),
|
||
include_raw_content: Boolean(providerConfig.include_raw_content),
|
||
include_text: Boolean(providerConfig.include_text),
|
||
categories: text(providerConfig.categories || (canUseRuntimeConfig ? webSearch.categories : ''), 'general'),
|
||
search_path: text(providerConfig.search_path || (canUseRuntimeConfig ? webSearch.search_path : ''), ''),
|
||
scrape_path: text(providerConfig.scrape_path || (canUseRuntimeConfig ? webSearch.scrape_path : ''), ''),
|
||
__fallbackSecrets: providerSecretIsRuntime || (providerRuntimeSecretConfigured && !providerOwnSecretConfigured) ? ['api_key'] : [],
|
||
__defaultDraft: {
|
||
enabled: Boolean(webSearch.enabled),
|
||
default_provider: providerId,
|
||
provider: providerId,
|
||
base_url: text(preset.base_url, ''),
|
||
api_key: '',
|
||
max_results: Number(preset.max_results || 5),
|
||
timeout_seconds: Number(preset.timeout_seconds || 20),
|
||
endpoint_path: text(preset.endpoint_path, ''),
|
||
search_depth: text(preset.search_depth, 'basic'),
|
||
engine: text(preset.engine, 'google'),
|
||
include_answer: Boolean(preset.include_answer),
|
||
include_raw_content: Boolean(preset.include_raw_content),
|
||
include_text: Boolean(preset.include_text),
|
||
categories: text(preset.categories, 'general'),
|
||
search_path: text(preset.search_path, ''),
|
||
scrape_path: text(preset.scrape_path, ''),
|
||
},
|
||
},
|
||
}
|
||
})),
|
||
},
|
||
{
|
||
key: 'tool:ocr',
|
||
label: 'OCR',
|
||
description: 'OCR provider、模型、语言和文件限制',
|
||
record: {},
|
||
children: [{
|
||
key: 'ocr:config',
|
||
label: text(ocr.provider, 'paddleocr'),
|
||
description: 'OCR 配置',
|
||
status: credentialStatus({
|
||
explicitStatus: ocr.status,
|
||
configured: hasConfiguredCredential(ocr.api_key),
|
||
isDefault: Boolean(ocr.enabled),
|
||
}),
|
||
record: {
|
||
enabled: Boolean(ocr.enabled),
|
||
provider: text(ocr.provider, 'paddleocr'),
|
||
base_url: text(ocr.base_url, ''),
|
||
api_key: secretPreview(ocr.api_key),
|
||
model: text(ocr.model, ''),
|
||
languages: Array.isArray(ocr.languages) ? ocr.languages.join(',') : 'zh,en',
|
||
timeout_seconds: Number(ocr.timeout_seconds || 30),
|
||
max_file_size_mb: Number(ocr.max_file_size_mb || 20),
|
||
output_format: text(ocr.output_format, 'markdown'),
|
||
__defaultDraft: {
|
||
enabled: Boolean(ocr.enabled),
|
||
provider: 'paddleocr',
|
||
base_url: '',
|
||
api_key: '',
|
||
model: '',
|
||
languages: 'zh,en',
|
||
timeout_seconds: 30,
|
||
max_file_size_mb: 20,
|
||
output_format: 'markdown',
|
||
},
|
||
},
|
||
}],
|
||
},
|
||
]
|
||
} else if (activeSection.key === 'prompts') {
|
||
const rows = activeState.rows
|
||
const byGroup = rows.reduce<Record<string, TableRecord[]>>((acc, row) => {
|
||
const group = pick(row, ['group'], 'default')
|
||
acc[group] = [...(acc[group] || []), row]
|
||
return acc
|
||
}, {})
|
||
groups = Object.entries(byGroup).map(([group, prompts]) => ({
|
||
key: `prompt-group:${group}`,
|
||
label: group,
|
||
description: `${prompts.length} 个入口`,
|
||
record: {},
|
||
children: sortGroupsByStatus(prompts.map((prompt) => ({
|
||
key: `prompt:${pick(prompt, ['key', 'id'], recordTitle(prompt))}`,
|
||
label: pick(prompt, ['label', 'key'], recordTitle(prompt)),
|
||
description: pick(prompt, ['key'], ''),
|
||
status: pick(prompt, ['is_custom'], '') === 'true' ? '自定义' : '默认',
|
||
record: cleanRecord(prompt),
|
||
}))),
|
||
}))
|
||
}
|
||
|
||
const activeGroup = getActiveHierarchyGroup(groups)
|
||
if (activeGroup && !hierarchyDraft) {
|
||
window.queueMicrotask(() => selectHierarchyGroup(activeGroup, false))
|
||
}
|
||
const record = activeGroup ? draftRecord(hierarchyDraft, activeGroup.record) : {}
|
||
const activeProviderId = text(record.provider || record.default_provider, '')
|
||
const isActiveDefault = activeSection.key === 'integrations'
|
||
? Boolean(activeProviderId && activeProviderId === text(aiProvider.default_provider || selectedRuntimeProvider(aiProvider), ''))
|
||
: activeGroup?.key.startsWith('web_search:')
|
||
? Boolean(activeProviderId && activeProviderId === text(webSearch.default_provider || selectedRuntimeProvider(webSearch), ''))
|
||
: false
|
||
const secretFieldKey = (key: string) => `${activeGroup?.key || 'none'}:${key}`
|
||
const revealedFor = (kind: 'ai_provider' | 'web_search' | 'ocr') => revealedSecrets[`${kind}:${text(record.provider || record.default_provider, '') || 'default'}`] || {}
|
||
const toggleActiveSecret = (key: 'api_key' | 'service_token', kind: 'ai_provider' | 'web_search' | 'ocr') => () => {
|
||
if (!activeGroup) return
|
||
void toggleSecretField({
|
||
fieldKey: secretFieldKey(key),
|
||
kind,
|
||
provider: text(record.provider || record.default_provider, ''),
|
||
baseline: activeGroup.record,
|
||
valueKey: key,
|
||
})
|
||
}
|
||
const providerFields: FieldConfig[] = [
|
||
{ key: 'provider', label: '供应商' },
|
||
{ key: 'provider_api', label: '协议适配', type: 'select', options: [
|
||
{ value: 'openai-completions', label: 'OpenAI Chat Completions' },
|
||
{ value: 'anthropic-messages', label: 'Anthropic Messages' },
|
||
{ value: 'ollama-generate', label: 'Ollama Generate' },
|
||
] },
|
||
{ key: 'base_url', label: 'LLM 基础地址', wide: true },
|
||
{ key: 'model', label: '默认模型' },
|
||
{
|
||
key: 'api_key',
|
||
label: 'LLM API Key',
|
||
type: 'secret',
|
||
placeholder: '输入新的 LLM API Key',
|
||
secretVisible: Boolean(visibleSecretFields[secretFieldKey('api_key')]),
|
||
onToggleSecret: toggleActiveSecret('api_key', 'ai_provider'),
|
||
},
|
||
{ key: 'max_tokens', label: '最大输出 Tokens', type: 'number' },
|
||
{ key: 'anthropic_version', label: 'Anthropic 版本' },
|
||
{ key: 'timeout_seconds', label: '超时(秒)', type: 'number' },
|
||
{ key: 'retry_attempts', label: '重试次数', type: 'number' },
|
||
{ key: 'service_url', label: '代理地址', wide: true },
|
||
{
|
||
key: 'service_token',
|
||
label: '代理 Token',
|
||
type: 'secret',
|
||
placeholder: '输入新的代理 Token',
|
||
secretVisible: Boolean(visibleSecretFields[secretFieldKey('service_token')]),
|
||
onToggleSecret: toggleActiveSecret('service_token', 'ai_provider'),
|
||
},
|
||
]
|
||
const webFields: FieldConfig[] = [
|
||
{ key: 'provider', label: '搜索供应商' },
|
||
{ key: 'base_url', label: 'API 基础地址', wide: true },
|
||
{
|
||
key: 'api_key',
|
||
label: 'WebSearch API Key',
|
||
type: 'secret',
|
||
placeholder: '输入新的 WebSearch API Key',
|
||
secretVisible: Boolean(visibleSecretFields[secretFieldKey('api_key')]),
|
||
onToggleSecret: toggleActiveSecret('api_key', 'web_search'),
|
||
},
|
||
{ key: 'max_results', label: '最大结果数', type: 'number' },
|
||
{ key: 'timeout_seconds', label: '超时(秒)', type: 'number' },
|
||
{ key: 'endpoint_path', label: '接口路径' },
|
||
{ key: 'search_depth', label: '搜索深度' },
|
||
{ key: 'engine', label: 'SerpAPI 引擎' },
|
||
{ key: 'categories', label: 'SearXNG 分类' },
|
||
{ key: 'search_path', label: 'Firecrawl 搜索路径' },
|
||
{ key: 'scrape_path', label: 'Firecrawl 抓取路径' },
|
||
{ key: 'include_answer', label: '包含答案', type: 'boolean' },
|
||
{ key: 'include_raw_content', label: '包含原始内容', type: 'boolean' },
|
||
{ key: 'include_text', label: '包含正文', type: 'boolean' },
|
||
]
|
||
const ocrFields: FieldConfig[] = [
|
||
{ key: 'provider', label: 'OCR 供应商', type: 'select', options: [
|
||
{ value: 'paddleocr', label: 'PaddleOCR / Local' },
|
||
{ value: 'tesseract', label: 'Tesseract / Local' },
|
||
{ value: 'azure', label: 'Azure AI Vision' },
|
||
{ value: 'google_vision', label: 'Google Cloud Vision' },
|
||
{ value: 'custom', label: 'Custom HTTP OCR' },
|
||
] },
|
||
{ key: 'base_url', label: 'OCR 基础地址', wide: true },
|
||
{
|
||
key: 'api_key',
|
||
label: 'OCR API Key',
|
||
type: 'secret',
|
||
placeholder: '输入新的 OCR API Key',
|
||
secretVisible: Boolean(visibleSecretFields[secretFieldKey('api_key')]),
|
||
onToggleSecret: toggleActiveSecret('api_key', 'ocr'),
|
||
},
|
||
{ key: 'model', label: '模型 / Engine' },
|
||
{ key: 'languages', label: '识别语言' },
|
||
{ key: 'timeout_seconds', label: '超时(秒)', type: 'number' },
|
||
{ key: 'max_file_size_mb', label: '最大文件(MB)', type: 'number' },
|
||
{ key: 'output_format', label: '输出格式', type: 'select', options: [
|
||
{ value: 'markdown', label: 'Markdown' },
|
||
{ value: 'text', label: 'Plain Text' },
|
||
{ value: 'json', label: 'JSON Blocks' },
|
||
] },
|
||
]
|
||
const promptFields: FieldConfig[] = [
|
||
{ key: 'system_prompt', label: 'System Prompt', type: 'textarea', wide: true },
|
||
{ key: 'prompt', label: '任务提示词', type: 'textarea', wide: true },
|
||
]
|
||
const fields = activeSection.key === 'integrations'
|
||
? providerFields
|
||
: activeGroup?.key.startsWith('web_search:')
|
||
? webFields
|
||
: activeGroup?.key.startsWith('ocr:')
|
||
? ocrFields
|
||
: promptFields
|
||
|
||
const saveCurrent = async () => {
|
||
if (!activeGroup) return
|
||
const payload = stripInternalFields(draftRecord(hierarchyDraft, activeGroup.record))
|
||
if (activeSection.key === 'integrations') {
|
||
const sanitized = sanitizeSecretDrafts(payload, activeGroup.record, ['api_key', 'service_token'], revealedFor('ai_provider'))
|
||
await saveHierarchyIntegrations({
|
||
ai_provider: omitKeys(sanitized, ['default_provider']),
|
||
}, '保存模型供应商')
|
||
return
|
||
}
|
||
if (activeGroup.key.startsWith('web_search:')) {
|
||
const sanitized = sanitizeSecretDrafts(payload, activeGroup.record, ['api_key'], revealedFor('web_search'))
|
||
await saveHierarchyIntegrations({
|
||
web_search: omitKeys(sanitized, ['default_provider']),
|
||
}, '保存 Web Search')
|
||
return
|
||
}
|
||
if (activeGroup.key.startsWith('ocr:')) {
|
||
const sanitized = sanitizeSecretDrafts(payload, activeGroup.record, ['api_key'], revealedFor('ocr'))
|
||
const next = { ...sanitized, languages: text(sanitized.languages, 'zh,en').split(',').map((item) => item.trim()).filter(Boolean) }
|
||
await saveHierarchyIntegrations({ ocr: next }, '保存 OCR')
|
||
return
|
||
}
|
||
if (activeGroup.key.startsWith('prompt:')) {
|
||
const promptKey = text(payload.key || activeGroup.record.key, '')
|
||
await requestAction('保存 Prompt', 'put', `/settings/ai-prompts/${encodeURIComponent(promptKey)}`, {
|
||
system_prompt: text(payload.system_prompt, ''),
|
||
prompt: text(payload.prompt, ''),
|
||
})
|
||
}
|
||
}
|
||
const restoreActiveDefaults = () => {
|
||
if (!activeGroup) return
|
||
const defaultDraft = isObjectRecord(activeGroup.record.__defaultDraft)
|
||
? activeGroup.record.__defaultDraft
|
||
: activeGroup.record
|
||
setHierarchyDraft(formatRaw(stripInternalFields(defaultDraft)))
|
||
toast({ title: '已恢复默认配置', description: '当前表单已回到默认值,保存后生效。', tone: 'success' })
|
||
}
|
||
const setActiveAsDefault = async () => {
|
||
if (!activeGroup) return
|
||
const payload = stripInternalFields(draftRecord(hierarchyDraft, activeGroup.record))
|
||
const provider = text(payload.provider || payload.default_provider, '')
|
||
if (!provider) return
|
||
if (activeSection.key === 'integrations') {
|
||
await saveHierarchyIntegrations({ ai_provider: { ...sanitizeSecretDrafts(payload, activeGroup.record, ['api_key', 'service_token'], revealedFor('ai_provider')), default_provider: provider, provider } }, '设为默认模型供应商')
|
||
return
|
||
}
|
||
if (activeGroup.key.startsWith('web_search:')) {
|
||
await saveHierarchyIntegrations({ web_search: { ...sanitizeSecretDrafts(payload, activeGroup.record, ['api_key'], revealedFor('web_search')), default_provider: provider, provider } }, '设为默认 Web Search')
|
||
}
|
||
}
|
||
const renderToolSwitch = (group: HierarchyGroup) => {
|
||
if (group.key === 'tool:web_search') {
|
||
const checked = activeGroup?.key.startsWith('web_search:') ? Boolean(record.enabled) : Boolean(webSearch.enabled)
|
||
return (
|
||
<AdminSwitch
|
||
checked={checked}
|
||
label={checked ? '停用 WebSearch' : '启用 WebSearch'}
|
||
onCheckedChange={(next) => {
|
||
const firstChild = group.children?.[0]
|
||
if (!activeGroup?.key.startsWith('web_search:') && firstChild) selectHierarchyGroup(firstChild)
|
||
const fallback = activeGroup?.key.startsWith('web_search:') ? activeGroup.record : firstChild?.record || {}
|
||
setHierarchyDraft(setNestedDraftField(hierarchyDraft, fallback, 'enabled', next))
|
||
toast({
|
||
title: next ? 'WebSearch 已在草稿中启用' : 'WebSearch 已在草稿中停用',
|
||
description: '点击右上角“保存”后才会生效;直接刷新页面会恢复为上次保存的状态。',
|
||
tone: 'default',
|
||
})
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
if (group.key === 'tool:ocr') {
|
||
const checked = activeGroup?.key.startsWith('ocr:') ? Boolean(record.enabled) : Boolean(ocr.enabled)
|
||
return (
|
||
<AdminSwitch
|
||
checked={checked}
|
||
label={checked ? '停用 OCR' : '启用 OCR'}
|
||
onCheckedChange={(next) => {
|
||
const firstChild = group.children?.[0]
|
||
if (!activeGroup?.key.startsWith('ocr:') && firstChild) selectHierarchyGroup(firstChild)
|
||
const fallback = activeGroup?.key.startsWith('ocr:') ? activeGroup.record : firstChild?.record || {}
|
||
setHierarchyDraft(setNestedDraftField(hierarchyDraft, fallback, 'enabled', next))
|
||
}}
|
||
/>
|
||
)
|
||
}
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div className={`an-hierarchy-layout${mobileHierarchyDetailOpen ? ' is-mobile-detail-open' : ''}`}>
|
||
<GroupList groups={groups} activeKey={activeGroup?.key || ''} onSelect={selectHierarchyGroup} renderGroupControl={activeSection.key === 'tools' ? renderToolSwitch : undefined} />
|
||
<Panel className="an-hierarchy-detail">
|
||
<div className="an-mobile-detail-bar">
|
||
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||
<strong>{activeGroup?.label || activeSection.label}</strong>
|
||
</div>
|
||
<div className="an-panel-heading">
|
||
<div>
|
||
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={[activeGroup?.label, activeGroup?.description].filter(Boolean).join(' ')}>{activeGroup?.label || activeSection.label}</h2>
|
||
<p>{activeGroup?.description || '选择左侧父级后编辑它的子配置。'}</p>
|
||
</div>
|
||
<div className="an-toolbar">
|
||
{activeSection.key === 'integrations' && activeGroup ? (
|
||
<Button size="icon" variant="subtle" title="刷新当前 Provider 的模型配置" aria-label="刷新当前 Provider 的模型配置" onClick={() => void requestAction('刷新模型列表', 'post', `/settings/integrations/ai-provider/presets/${encodeURIComponent(text(record.provider, ''))}/refresh`, undefined, { refresh: false, successDescription: '模型预设已刷新,当前表单未保存。' })} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||
) : null}
|
||
{activeGroup?.key.startsWith('web_search:') ? (
|
||
<>
|
||
<Button size="icon" variant="subtle" title={isActiveDefault ? '当前已是默认' : '设为默认'} aria-label={isActiveDefault ? '当前已是默认' : '设为默认'} onClick={() => void setActiveAsDefault()} loading={actionLoading} disabled={isActiveDefault}><CheckCircle2 size={15} /></Button>
|
||
<Button size="icon" variant="subtle" title="恢复默认配置" aria-label="恢复默认配置" onClick={restoreActiveDefaults}><Redo2 size={15} /></Button>
|
||
<Button size="icon" variant="subtle" icon="test" title="测试 Web Search 连通性" aria-label="测试 Web Search 连通性" onClick={() => void testConnection('Web Search', '/settings/integrations/web-search/connect', sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key'], revealedFor('web_search')))} loading={actionLoading} />
|
||
</>
|
||
) : null}
|
||
{activeSection.key === 'integrations' ? (
|
||
<>
|
||
<Button size="icon" variant="subtle" title={isActiveDefault ? '当前已是默认' : '设为默认'} aria-label={isActiveDefault ? '当前已是默认' : '设为默认'} onClick={() => void setActiveAsDefault()} loading={actionLoading} disabled={isActiveDefault}><CheckCircle2 size={15} /></Button>
|
||
<Button size="icon" variant="subtle" title="恢复默认配置" aria-label="恢复默认配置" onClick={restoreActiveDefaults}><Redo2 size={15} /></Button>
|
||
<Button size="icon" variant="subtle" icon="test" title="测试 AI Provider 连通性" aria-label="测试 AI Provider 连通性" onClick={() => void testConnection('AI Provider', '/settings/integrations/ai-provider/connect', sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key', 'service_token'], revealedFor('ai_provider')))} loading={actionLoading} />
|
||
</>
|
||
) : null}
|
||
{activeGroup?.key.startsWith('ocr:') ? (
|
||
<>
|
||
<Button size="icon" variant="subtle" title="恢复默认配置" aria-label="恢复默认配置" onClick={restoreActiveDefaults}><Redo2 size={15} /></Button>
|
||
</>
|
||
) : null}
|
||
{activeGroup?.key.startsWith('prompt:') ? (
|
||
<Button size="icon" variant="danger" title="重置 Prompt" aria-label="重置 Prompt" onClick={() => setConfirmAction({
|
||
title: '重置 Prompt',
|
||
description: `确认将 ${activeGroup.label} 重置为默认内容?`,
|
||
danger: true,
|
||
confirmLabel: '重置',
|
||
run: () => requestAction('重置 Prompt', 'post', `/settings/ai-prompts/${encodeURIComponent(text(record.key, ''))}/reset`),
|
||
})}><RefreshCw size={15} /></Button>
|
||
) : null}
|
||
<Button variant="primary" onClick={() => void saveCurrent()} loading={actionLoading}><Save size={15} />保存</Button>
|
||
</div>
|
||
</div>
|
||
<div className="an-panel-body">
|
||
<Scrollbar className="an-hierarchy-form-scroll">
|
||
{activeGroup ? (
|
||
<div className="an-hierarchy-form">
|
||
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={fields} searchGroupKey={activeGroup.key} />
|
||
{activeSection.key === 'integrations' && Array.isArray(record.models) && record.models.length ? (
|
||
<div className="an-sub-list">
|
||
<strong>可选模型</strong>
|
||
<div>{(record.models as unknown[]).map((model) => <button key={String(model)} type="button" data-admin-search-target={`${activeGroup.key}:model:${String(model)}`} data-admin-search-text={String(model)} onClick={() => setHierarchyDraft(setNestedDraftField(hierarchyDraft, activeGroup.record, 'model', String(model)))}>{String(model)}</button>)}</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : <EmptyState title="暂无分组" description="当前分区没有可配置项。" />}
|
||
</Scrollbar>
|
||
</div>
|
||
</Panel>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const renderConfigHierarchy = () => {
|
||
if (!activeState || !activeSection) return null
|
||
let groups: HierarchyGroup[] = activeState.rows.map((row) => ({
|
||
key: row.__rowId,
|
||
label: recordTitle(row),
|
||
description: row.__module,
|
||
status: normalizeStatusLabel(recordStatus(row)),
|
||
count: Object.keys(cleanRecord(row)).length,
|
||
record: { ...cleanRecord(row), __sourceEndpoint: row.__endpointKey, __sourceLabel: row.__endpointLabel },
|
||
}))
|
||
if (config === configs.collection && activeSection.key === 'collector_credentials') {
|
||
const endpointGroups = [
|
||
{ key: 'configsAll', label: '采集器配置', description: '连接、采样、运行和凭证配置' },
|
||
{ key: 'mappings', label: '映射模板', description: '采样 payload 到目标 Schema 的字段映射' },
|
||
{ key: 'targetSchemas', label: '目标 Schema', description: '采集数据落库目标结构' },
|
||
]
|
||
groups = endpointGroups
|
||
.map((endpoint) => {
|
||
const children = activeState.rows
|
||
.filter((row) => row.__endpointKey === endpoint.key)
|
||
.map((row) => ({
|
||
key: row.__rowId,
|
||
label: recordTitle(row),
|
||
description: row.__module,
|
||
status: normalizeStatusLabel(recordStatus(row)),
|
||
count: Object.keys(cleanRecord(row)).length,
|
||
record: { ...cleanRecord(row), __sourceEndpoint: row.__endpointKey, __sourceLabel: row.__endpointLabel },
|
||
}))
|
||
return {
|
||
key: `collection-group:${endpoint.key}`,
|
||
label: endpoint.label,
|
||
description: endpoint.description,
|
||
status: children.length ? '已配置' : '未配置',
|
||
count: children.length,
|
||
record: {},
|
||
children: sortGroupsByStatus(children),
|
||
}
|
||
})
|
||
.map((group) => {
|
||
if (!collectionDraftGroup) return group
|
||
const draftEndpoint = text(collectionDraftGroup.record.__sourceEndpoint, '')
|
||
if (draftEndpoint !== group.key.replace('collection-group:', '')) return group
|
||
return {
|
||
...group,
|
||
status: '新建',
|
||
count: group.children.length + 1,
|
||
children: [...group.children, collectionDraftGroup],
|
||
}
|
||
})
|
||
.filter((group) => group.children.length)
|
||
}
|
||
if (config === configs.earthContent && activeSection.key === 'tv') {
|
||
const defaultSourceId = tvDefaultSourceId(activeState.raw)
|
||
groups = sortGroupsByStatus(tvAdminSources(activeState.raw).map((source) => ({
|
||
key: `tv:${text(source.id, '')}`,
|
||
label: recordTitle(source),
|
||
description: tvSourceKindLabel(source),
|
||
status: tvSourceStatusLabel(source, defaultSourceId),
|
||
count: Object.keys(cleanRecord(source)).length,
|
||
record: source,
|
||
})))
|
||
if (tvDraftGroup) groups.push(tvDraftGroup)
|
||
}
|
||
if (config === configs.collection && activeSection.key === 'collection_history') {
|
||
groups = activeState.rows.map((row) => ({
|
||
key: row.__rowId,
|
||
label: recordTitle(row),
|
||
description: '采集快照',
|
||
status: normalizeStatusLabel(row.is_current === true ? '当前' : recordStatus(row)),
|
||
count: Array.isArray(row.__snapshots) ? row.__snapshots.length : 1,
|
||
record: { ...cleanRecord(row), __sourceEndpoint: row.__endpointKey, __sourceLabel: row.__endpointLabel, __snapshots: row.__snapshots, __snapshotSourceKey: row.__snapshotSourceKey },
|
||
}))
|
||
}
|
||
const activeGroup = getActiveHierarchyGroup(groups)
|
||
if (activeGroup && !hierarchyDraft) {
|
||
window.queueMicrotask(() => selectHierarchyGroup(activeGroup, false))
|
||
}
|
||
const record = activeGroup ? draftRecord(hierarchyDraft, activeGroup.record) : {}
|
||
const snapshotTimeline = activeGroup && config === configs.collection && activeSection.key === 'collection_history' && Array.isArray(activeGroup.record.__snapshots)
|
||
? (activeGroup.record.__snapshots as unknown[]).filter(isObjectRecord)
|
||
: []
|
||
const snapshotSourceKey = text(activeGroup?.record.__snapshotSourceKey || activeGroup?.record.source, activeGroup?.key || '')
|
||
const selectedSnapshotKey = snapshotSelectionBySource[snapshotSourceKey] || ''
|
||
const activeSnapshot = snapshotTimeline.find((snapshot) => snapshotId(snapshot) === selectedSnapshotKey)
|
||
|| snapshotTimeline.find((snapshot) => snapshot.is_current === true)
|
||
|| snapshotTimeline[0]
|
||
|| null
|
||
const activeTableRecord = activeGroup && activeSection ? ({
|
||
...record,
|
||
__endpointKey: activeSection.key,
|
||
__endpointLabel: activeSection.label,
|
||
__rowId: activeGroup.key,
|
||
__title: activeGroup.label,
|
||
__module: activeGroup.description || activeSection.label,
|
||
__status: activeGroup.status || recordStatus(record),
|
||
__metric: recordMetric(record),
|
||
__time: '',
|
||
} as TableRecord) : null
|
||
const activeSourceEndpoint = text(activeGroup?.record.__sourceEndpoint, activeSection.key)
|
||
const hierarchySecretFieldKey = (key: string) => `${activeGroup?.key || 'none'}:${key}`
|
||
const revealDatasourceConfigSecret = async (key: string) => {
|
||
if (!activeGroup) return
|
||
const baseline = activeGroup.record
|
||
const fieldKey = hierarchySecretFieldKey(key)
|
||
const visible = !visibleSecretFields[fieldKey]
|
||
if (!visible) {
|
||
setHierarchyDraft(setNestedDraftField(hierarchyDraft, baseline, key, secretPreview(baseline[key])))
|
||
setVisibleSecretFields((current) => ({ ...current, [fieldKey]: false }))
|
||
return
|
||
}
|
||
const currentDraft = draftRecord(hierarchyDraft, baseline)
|
||
const draftSecret = text(currentDraft[key], '').trim()
|
||
const baselineSecretPreview = secretPreview(baseline[key])
|
||
const hasNewDraftSecret = Boolean(
|
||
draftSecret &&
|
||
!isMaskedSecretDraft(draftSecret) &&
|
||
draftSecret !== baselineSecretPreview,
|
||
)
|
||
if (hasNewDraftSecret) {
|
||
setVisibleSecretFields((current) => ({ ...current, [fieldKey]: true }))
|
||
return
|
||
}
|
||
const sourceName = text(baseline.name || baseline.source, '').trim()
|
||
if (!sourceName) return
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.get(apiPath('/datasources/configs/secrets'), {
|
||
params: { name: sourceName },
|
||
})
|
||
const secrets = isObjectRecord(response.data) ? response.data : {}
|
||
setHierarchyDraft(setNestedDraftField(hierarchyDraft, baseline, key, text(secrets[key], '')))
|
||
setVisibleSecretFields((current) => ({ ...current, [fieldKey]: true }))
|
||
} catch (error) {
|
||
toast({ title: '读取凭证失败', description: actionErrorMessage(error), tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
const scalarFields = Object.entries(activeGroup?.record || {})
|
||
.filter(([key, value]) => !key.startsWith('__') && ['string', 'number', 'boolean'].includes(typeof value))
|
||
.map(([key, value]): FieldConfig => ({
|
||
key,
|
||
label: fieldLabel(key),
|
||
type: datasourceSecretKeys.has(key) ? 'secret' : key === 'api_key_location' ? 'select' : typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : 'text',
|
||
options: key === 'api_key_location' ? [{ value: 'header', label: 'Header' }, { value: 'query', label: 'Query' }] : undefined,
|
||
disabled: config === configs.earthContent && activeSection.key === 'about' && key === 'version',
|
||
help: config === configs.earthContent && activeSection.key === 'about' && key === 'version'
|
||
? '版本号来自当前系统版本,只读展示,不会随关于信息提交。'
|
||
: undefined,
|
||
wide: datasourceSecretKeys.has(key),
|
||
secretVisible: Boolean(visibleSecretFields[hierarchySecretFieldKey(key)]),
|
||
onToggleSecret: datasourceSecretKeys.has(key)
|
||
? () => {
|
||
if (activeSourceEndpoint === 'configsAll') void revealDatasourceConfigSecret(key)
|
||
else setVisibleSecretFields((current) => ({ ...current, [hierarchySecretFieldKey(key)]: !current[hierarchySecretFieldKey(key)] }))
|
||
}
|
||
: undefined,
|
||
}))
|
||
const objectFields = Object.entries(activeGroup?.record || {})
|
||
.filter(([key, value]) => !key.startsWith('__') && !shouldHideDatasourceConfigObjectField(activeGroup?.record || {}, activeSourceEndpoint, key) && value !== null && typeof value === 'object')
|
||
.map(([key]): FieldConfig => ({ key, label: fieldLabel(key), type: 'textarea', wide: true }))
|
||
const fields = [...scalarFields, ...objectFields]
|
||
|
||
const saveCurrent = async () => {
|
||
if (!activeGroup) return
|
||
const payload = draftRecord(hierarchyDraft, activeGroup.record)
|
||
if (config === configs.settings) {
|
||
if (activeSection.key === 'integrations') {
|
||
const key = text(activeGroup.record.__title || activeGroup.record.key || activeGroup.record.provider, '')
|
||
if (key === 'ai_provider') {
|
||
await saveHierarchyIntegrations({ ai_provider: payload }, '保存 AI Provider')
|
||
return
|
||
}
|
||
if (key === 'web_search') {
|
||
await saveHierarchyIntegrations({ web_search: payload }, '保存 Web Search')
|
||
return
|
||
}
|
||
if (key === 'ocr') {
|
||
await saveHierarchyIntegrations({ ocr: payload }, '保存 OCR')
|
||
return
|
||
}
|
||
}
|
||
const pathBySection: Record<string, string> = {
|
||
system: '/settings/system',
|
||
notifications: '/settings/notifications',
|
||
security: '/settings/security',
|
||
smtp: '/settings/smtp',
|
||
integrations: '/settings/integrations',
|
||
}
|
||
if (activeSection.key === 'collectors') {
|
||
const id = pick(activeGroup.record, ['id'], '')
|
||
await saveHierarchySettings(`/settings/collectors/${encodeURIComponent(id)}`, {
|
||
is_active: Boolean(payload.is_active),
|
||
priority: text(payload.priority, 'P1'),
|
||
frequency_minutes: Number(payload.frequency_minutes || 60),
|
||
}, '保存采集器设置')
|
||
return
|
||
}
|
||
const path = pathBySection[activeSection.key]
|
||
if (path) {
|
||
await saveHierarchySettings(path, payload, `保存${activeSection.label}`)
|
||
}
|
||
return
|
||
}
|
||
if (config === configs.earthContent) {
|
||
if (activeSection.key === 'brand') {
|
||
await saveHierarchySettings('/earth/brand', payload, '保存品牌配置')
|
||
return
|
||
}
|
||
if (activeSection.key === 'about') {
|
||
const aboutPayload = { ...payload }
|
||
delete aboutPayload.version
|
||
await saveHierarchySettings('/earth/about', aboutPayload, '保存关于配置')
|
||
return
|
||
}
|
||
if (activeGroup.key.includes('boundaries')) {
|
||
await saveHierarchySettings('/earth/boundaries/config', { config: payload }, '保存边界配置')
|
||
return
|
||
}
|
||
if (activeSection.key === 'tv') {
|
||
const tv = tvSettingsFromRaw(activeState.raw)
|
||
const sources = Array.isArray(tv.sources) ? tv.sources.filter(isObjectRecord) : []
|
||
const sourceId = text(payload.id, '') || `manual-tv-${Date.now()}`
|
||
const nextSource = cleanRecord(payload)
|
||
nextSource.id = sourceId
|
||
delete nextSource.default_source_id
|
||
delete nextSource.auto_fallback
|
||
delete nextSource.__tvKind
|
||
delete nextSource.__isDraft
|
||
delete nextSource.__tvPersistedInSettings
|
||
const normalizedSources: AnyRecord[] = Boolean(nextSource.is_fallback)
|
||
? sources.map((source) => ({ ...source, is_fallback: false }))
|
||
: sources
|
||
const nextSources = activeGroup.key === 'tv:new-source'
|
||
? [...normalizedSources, nextSource]
|
||
: normalizedSources.map((source) => text(source.id, '') === sourceId ? nextSource : source)
|
||
if (activeGroup.key !== 'tv:new-source' && !normalizedSources.some((source) => text(source.id, '') === sourceId)) {
|
||
nextSources.push(nextSource)
|
||
}
|
||
const nextDefaultSourceId = Boolean(nextSource.is_enabled) === false && text(tv.default_source_id, '') === sourceId
|
||
? text(nextSources.find((source) => text(source.id, '') !== sourceId && source.is_enabled !== false)?.id, '')
|
||
: text(payload.default_source_id || tv.default_source_id || sourceId, '')
|
||
await saveHierarchySettings('/settings/tv', {
|
||
...tv,
|
||
sources: nextSources,
|
||
default_source_id: nextDefaultSourceId,
|
||
auto_fallback: Boolean(payload.auto_fallback ?? tv.auto_fallback),
|
||
}, '保存 TV 配置')
|
||
if (activeGroup.key === 'tv:new-source') {
|
||
setTvDraftGroup(null)
|
||
setActiveGroupKey(`tv:${sourceId}`)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
if (config === configs.collection) {
|
||
const id = pick(activeGroup.record, ['config_id', 'id', 'source_id', 'key', 'name'], '')
|
||
const sourceEndpoint = text(activeGroup.record.__sourceEndpoint, activeSection.key)
|
||
if (activeSection.key === 'collector_credentials' && sourceEndpoint === 'configsAll' && activeGroup.record.__isDraft) {
|
||
await requestAction('创建采集器配置', 'post', '/datasources/configs', normalizeDatasourceConfigPayload(payload))
|
||
setCollectionDraftGroup(null)
|
||
setActiveGroupKey('')
|
||
return
|
||
}
|
||
if (activeSection.key === 'collector_credentials' && sourceEndpoint === 'configsAll') {
|
||
const normalizedPayload = normalizeDatasourceConfigPayload(payload)
|
||
const configId = Number(activeGroup.record.config_id || activeGroup.record.id)
|
||
if (Number.isFinite(configId) && configId > 0) {
|
||
await saveHierarchySettings(`/datasources/configs/${encodeURIComponent(String(configId))}`, normalizedPayload, '保存采集器配置')
|
||
} else {
|
||
await requestAction('创建采集器配置', 'post', '/datasources/configs', normalizedPayload)
|
||
}
|
||
return
|
||
}
|
||
if (activeSection.key === 'collector_credentials' && sourceEndpoint === 'mappings' && activeGroup.record.__isDraft) {
|
||
const datasourceConfigId = Number(payload.datasource_config_id)
|
||
if (!Number.isFinite(datasourceConfigId) || datasourceConfigId <= 0 || !payload.target_schema || !payload.mapping_json) {
|
||
toast({ title: '请补齐 Schema 映射参数', description: '需要采集器配置 ID、目标 Schema 和映射 JSON。', tone: 'error' })
|
||
return
|
||
}
|
||
await requestAction('创建 Schema 映射', 'post', '/datasources/mappings', {
|
||
datasource_config_id: datasourceConfigId,
|
||
target_schema: payload.target_schema,
|
||
mapping_json: payload.mapping_json,
|
||
sample_payload: payload.sample_payload || undefined,
|
||
sample_payload_hash: text(payload.sample_payload_hash, '') || undefined,
|
||
validation_status: text(payload.validation_status, 'draft'),
|
||
is_active: Boolean(payload.is_active),
|
||
})
|
||
setCollectionDraftGroup(null)
|
||
setActiveGroupKey('')
|
||
return
|
||
}
|
||
if (activeSection.key === 'collector_credentials' && sourceEndpoint === 'mappings') {
|
||
const mappingId = pick(activeGroup.record, ['id', 'mapping_id'], '')
|
||
if (!mappingId) return
|
||
await requestAction('保存映射模板', 'put', `/datasources/mappings/${encodeURIComponent(mappingId)}`, {
|
||
target_schema: payload.target_schema,
|
||
mapping_json: payload.mapping_json,
|
||
sample_payload_hash: payload.sample_payload_hash,
|
||
validation_status: payload.validation_status,
|
||
is_active: payload.is_active,
|
||
})
|
||
return
|
||
}
|
||
if (activeSection.key === 'collectors' && id) {
|
||
await saveHierarchySettings(`/settings/collectors/${encodeURIComponent(id)}`, {
|
||
is_active: Boolean(payload.is_active),
|
||
priority: text(payload.priority, 'P1'),
|
||
frequency_minutes: Number(payload.frequency_minutes || 60),
|
||
}, '保存采集调度')
|
||
return
|
||
}
|
||
if (activeSection.key === 'mappings') {
|
||
const mappingId = pick(activeGroup.record, ['id', 'mapping_id'], '')
|
||
if (!mappingId) return
|
||
await requestAction('保存映射模板', 'put', `/datasources/mappings/${encodeURIComponent(mappingId)}`, {
|
||
target_schema: payload.target_schema,
|
||
mapping_json: payload.mapping_json,
|
||
sample_payload_hash: payload.sample_payload_hash,
|
||
validation_status: payload.validation_status,
|
||
is_active: payload.is_active,
|
||
})
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
const previewActiveMapping = async () => {
|
||
if (!activeGroup) return
|
||
const payload = draftRecord(hierarchyDraft, activeGroup.record)
|
||
if (!payload.sample_payload || !payload.mapping_json || !payload.target_schema) {
|
||
toast({ title: '缺少预览参数', description: '需要采样 Payload、映射 JSON 和目标 Schema。', tone: 'error' })
|
||
return
|
||
}
|
||
setActionLoading(true)
|
||
try {
|
||
const response = await axios.post(apiPath('/datasources/mappings/preview'), {
|
||
sample_payload: payload.sample_payload,
|
||
target_schema: payload.target_schema,
|
||
mapping_json: payload.mapping_json,
|
||
limit: 20,
|
||
})
|
||
replaceSelectedWithPayload('mapping-preview', '映射预览', [{
|
||
...response.data,
|
||
__title: `${activeGroup.label} 预览`,
|
||
__module: '映射预览',
|
||
__status: response.data?.success ? 'success' : 'failed',
|
||
__metric: response.data?.sample_payload_hash || '-',
|
||
}])
|
||
toast({ title: '映射预览已生成', tone: 'success' })
|
||
} catch (error) {
|
||
toast({ title: '映射预览失败', description: error instanceof Error ? error.message : '接口请求失败', tone: 'error' })
|
||
} finally {
|
||
setActionLoading(false)
|
||
}
|
||
}
|
||
|
||
const activateActiveMapping = async () => {
|
||
if (!activeGroup) return
|
||
const payload = draftRecord(hierarchyDraft, activeGroup.record)
|
||
const mappingId = pick(activeGroup.record, ['id', 'mapping_id'], '')
|
||
if (!mappingId) return
|
||
await requestAction('启用映射模板', 'put', `/datasources/mappings/${encodeURIComponent(mappingId)}`, {
|
||
target_schema: payload.target_schema,
|
||
mapping_json: payload.mapping_json,
|
||
sample_payload_hash: payload.sample_payload_hash,
|
||
validation_status: 'valid',
|
||
is_active: true,
|
||
})
|
||
}
|
||
const hierarchyFooter = config === configs.collection && activeSection.key === 'collector_credentials' ? (
|
||
<TactileControlGroup className="an-hierarchy-list__footer-actions">
|
||
<Button size="icon" variant="subtle" icon="plus" title="新增采集器配置" aria-label="新增采集器配置" onClick={() => {
|
||
const group = makeNewDatasourceConfigGroup(activeState.rows.filter((row) => row.__endpointKey === 'configsAll').length + 1)
|
||
setCollectionDraftGroup(group)
|
||
selectHierarchyGroup(group)
|
||
}} />
|
||
<Button size="icon" variant="subtle" icon="settings" title="新增 Schema 映射" aria-label="新增 Schema 映射" onClick={() => {
|
||
const datasourceId = activeSourceEndpoint === 'configsAll'
|
||
? pick(activeGroup?.record || {}, ['id', 'config_id'], '')
|
||
: ''
|
||
const group = makeNewMappingGroup(activeState.rows.filter((row) => row.__endpointKey === 'mappings').length + 1, datasourceId)
|
||
setCollectionDraftGroup(group)
|
||
selectHierarchyGroup(group)
|
||
}} />
|
||
</TactileControlGroup>
|
||
) : config === configs.earthContent && activeSection.key === 'tv' ? (
|
||
<TactileControlGroup className="an-hierarchy-list__footer-actions">
|
||
<Button size="icon" variant="subtle" icon="plus" title="新增直播源" aria-label="新增直播源" onClick={() => {
|
||
const group = makeNewTvSourceGroup(tvAdminSources(activeState.raw).length + 1)
|
||
setTvDraftGroup(group)
|
||
selectHierarchyGroup(group)
|
||
}} />
|
||
</TactileControlGroup>
|
||
) : null
|
||
|
||
return (
|
||
<div className={`an-hierarchy-layout${mobileHierarchyDetailOpen ? ' is-mobile-detail-open' : ''}`}>
|
||
<GroupList groups={groups} activeKey={activeGroup?.key || ''} onSelect={selectHierarchyGroup} footer={hierarchyFooter} />
|
||
<Panel className="an-hierarchy-detail">
|
||
<div className="an-mobile-detail-bar">
|
||
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||
<strong>{activeGroup?.label || activeSection.label}</strong>
|
||
</div>
|
||
<div className="an-panel-heading">
|
||
<div>
|
||
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={[activeGroup?.label, activeGroup?.description].filter(Boolean).join(' ')}>{activeGroup?.label || activeSection.label}</h2>
|
||
<p>{activeGroup?.description || '选择左侧父级后编辑它的子配置。'}</p>
|
||
</div>
|
||
<div className="an-toolbar">
|
||
{config === configs.earthContent && activeSection.key === 'brand' ? (
|
||
<>
|
||
<label
|
||
className="an-file-action an-file-action--drop"
|
||
onDragOver={(event) => event.preventDefault()}
|
||
onDrop={(event) => {
|
||
event.preventDefault()
|
||
selectBrandUploadFile(event.dataTransfer.files?.[0])
|
||
}}
|
||
title={`支持 ${BRAND_ASSET_SUFFIXES.join(', ')}`}
|
||
>
|
||
<input type="file" accept={BRAND_ASSET_ACCEPT} onChange={(event) => selectBrandUploadFile(event.target.files?.[0])} />
|
||
<span><ImageUp size={15} />{brandUploadFile ? brandUploadFile.name : '选择/拖入资产'}</span>
|
||
<small>{BRAND_ASSET_SUFFIXES.join(' / ')}</small>
|
||
</label>
|
||
<Button variant="primary" onClick={() => void uploadBrandAsset()} loading={actionLoading} disabled={!brandUploadFile}><ImageUp size={15} />上传</Button>
|
||
<Button size="icon" variant="subtle" title="重置品牌配置" aria-label="重置品牌配置" onClick={() => setConfirmAction({
|
||
title: '重置品牌配置',
|
||
description: '确认恢复默认智能星球品牌配置?当前自定义配置会被清空。',
|
||
danger: true,
|
||
confirmLabel: '重置',
|
||
run: () => requestAction('重置品牌配置', 'delete', '/earth/brand'),
|
||
})} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||
<Button size="icon" variant="danger" title="删除品牌配置" aria-label="删除品牌配置" onClick={() => setConfirmAction({
|
||
title: '删除品牌配置',
|
||
description: '确认删除智能星球品牌配置?',
|
||
danger: true,
|
||
confirmLabel: '删除',
|
||
run: () => requestAction('删除品牌配置', 'delete', '/earth/brand'),
|
||
})} loading={actionLoading}><Trash2 size={15} /></Button>
|
||
</>
|
||
) : null}
|
||
{config === configs.earthContent && activeSection.key === 'about' ? (
|
||
<Button size="icon" variant="subtle" title="恢复默认关于信息" aria-label="恢复默认关于信息" onClick={() => setConfirmAction({
|
||
title: '恢复默认关于信息',
|
||
description: '确认恢复智能星球关于卡片的默认内容?',
|
||
danger: false,
|
||
confirmLabel: '恢复',
|
||
run: () => requestAction('恢复默认关于信息', 'delete', '/earth/about'),
|
||
})} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||
) : null}
|
||
{config === configs.earthContent && activeSection.key === 'earth_assets' ? (
|
||
<Button variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前表单未保存。' })} loading={actionLoading}>构建</Button>
|
||
) : null}
|
||
{config === configs.earthContent && activeSection.key === 'tv' && activeGroup ? (
|
||
<>
|
||
<Button size="icon" variant="subtle" title={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} aria-label={text(record.id, '') === tvDefaultSourceId(activeState.raw) ? '当前已是默认频道' : '设为默认频道'} onClick={() => {
|
||
const tv = tvSettingsFromRaw(activeState.raw)
|
||
const sources = Array.isArray(tv.sources) ? tv.sources.filter(isObjectRecord) : []
|
||
const sourceId = text(record.id, '')
|
||
const nextSources = sources.some((source) => text(source.id, '') === sourceId)
|
||
? sources
|
||
: [...sources, cleanRecord(record)]
|
||
void saveHierarchySettings('/settings/tv', {
|
||
...tv,
|
||
sources: nextSources.map((source) => {
|
||
if (text(source.id, '') !== sourceId) return source
|
||
return { ...source, is_enabled: true }
|
||
}),
|
||
default_source_id: sourceId || tvDefaultSourceId(activeState.raw),
|
||
auto_fallback: Boolean(record.auto_fallback ?? tv.auto_fallback),
|
||
}, '设置默认 TV 频道')
|
||
}} loading={actionLoading} disabled={activeGroup.key === 'tv:new-source' || text(record.id, '') === tvDefaultSourceId(activeState.raw)}><CheckCircle2 size={15} /></Button>
|
||
<Button size="icon" variant="subtle" title="恢复当前表单" aria-label="恢复当前表单" onClick={() => {
|
||
setHierarchyDraft(formatRaw(activeGroup.record))
|
||
toast({ title: '已恢复当前项', description: '表单已恢复到加载时状态。', tone: 'success' })
|
||
}}><Redo2 size={15} /></Button>
|
||
{activeGroup.key !== 'tv:new-source' && tvSourceKind(record) !== 'builtin' ? (
|
||
<Button size="icon" variant="danger" title="删除直播源" aria-label="删除直播源" onClick={() => setConfirmAction({
|
||
title: '删除直播源',
|
||
description: `确认删除 ${recordTitle(record)}?`,
|
||
danger: true,
|
||
confirmLabel: '删除',
|
||
run: async () => {
|
||
const tv = tvSettingsFromRaw(activeState.raw)
|
||
const sources = Array.isArray(tv.sources) ? tv.sources.filter(isObjectRecord) : []
|
||
const sourceId = text(record.id, '')
|
||
let nextSources = sources.filter((source) => text(source.id, '') !== sourceId)
|
||
if (!record.__tvPersistedInSettings && tvSourceKind(record) === 'collected') {
|
||
nextSources = [...nextSources, { ...cleanRecord(record), id: sourceId, is_enabled: false }]
|
||
}
|
||
await saveHierarchySettings('/settings/tv', {
|
||
...tv,
|
||
sources: nextSources,
|
||
default_source_id: text(tv.default_source_id, '') === sourceId
|
||
? text(nextSources.find((source) => source.is_enabled !== false)?.id, '')
|
||
: text(tv.default_source_id, ''),
|
||
}, '删除直播源')
|
||
setActiveGroupKey('')
|
||
},
|
||
})} loading={actionLoading}><Trash2 size={15} /></Button>
|
||
) : null}
|
||
</>
|
||
) : null}
|
||
{config === configs.settings && activeSection.key === 'smtp' ? (
|
||
<>
|
||
<label className="an-inline-field">
|
||
<span>测试收件人</span>
|
||
<input className="an-input" type="email" value={smtpTestEmail} onChange={(event) => setSmtpTestEmail(event.target.value)} placeholder="admin@example.com" />
|
||
</label>
|
||
<Button size="icon" variant="subtle" title="测试 SMTP" aria-label="测试 SMTP" onClick={() => {
|
||
if (!smtpTestEmail.trim()) {
|
||
toast({ title: '请输入测试收件人', description: 'SMTP 测试需要 to 邮箱。', tone: 'error' })
|
||
return
|
||
}
|
||
void requestAction('测试 SMTP', 'post', '/settings/smtp/test', { to: smtpTestEmail.trim(), settings: record }, { refresh: false, successDescription: '测试邮件请求已发送,未保存 SMTP 表单。' })
|
||
}} loading={actionLoading}><TestTube2 size={15} /></Button>
|
||
</>
|
||
) : null}
|
||
{config === configs.settings && activeSection.key === 'collectors' ? (
|
||
<>
|
||
<Button size="icon" variant="subtle" title="凭证教程" aria-label="凭证教程" onClick={() => void loadCredentialGuide(text(record.credential_provider || record.provider, 'barentswatch'))} loading={actionLoading}><FileText size={15} /></Button>
|
||
</>
|
||
) : null}
|
||
{config === configs.collection && activeSection.key === 'collector_credentials' && activeSourceEndpoint === 'configsAll' && !activeGroup?.record.__isDraft ? (
|
||
<>
|
||
<Button size="icon" variant="subtle" title="凭证教程" aria-label="凭证教程" onClick={() => {
|
||
const provider = credentialGuideProvider(record)
|
||
void loadCredentialGuide(provider)
|
||
}} loading={actionLoading}><FileText size={15} /></Button>
|
||
</>
|
||
) : null}
|
||
{config === configs.collection && activeSection.key === 'collector_credentials' && activeSourceEndpoint === 'configsAll' && activeTableRecord && !activeGroup?.record.__isDraft ? (
|
||
<>
|
||
<Button size="icon" variant="subtle" icon="connect" title="连接测试" aria-label="连接测试" onClick={() => void testDatasourceConfigDraft(record, { builtin: true })} loading={actionLoading} />
|
||
<Button size="icon" variant="subtle" title="采样" aria-label="采样" onClick={() => void fetchCustomSample(activeTableRecord)} loading={actionLoading}><Search size={15} /></Button>
|
||
<Button size="icon" variant="subtle" icon="start" title="运行" aria-label="运行" onClick={() => void requestAction('启动采集', 'post', `/datasources/${encodeURIComponent(pick(activeGroup?.record || {}, ['config_id', 'id', 'source_id', 'key', 'name'], ''))}/run-mapped`, undefined, { refresh: false, successDescription: '采集任务已提交,当前配置未保存。' })} loading={actionLoading} />
|
||
<Button size="icon" variant="danger" icon="stop" title="停止" aria-label="停止" onClick={() => void requestAction('停止采集', 'post', `/datasources/${encodeURIComponent(pick(activeGroup?.record || {}, ['config_id', 'id', 'source_id', 'key', 'name'], ''))}/stop-mapped`, undefined, { refresh: false, successDescription: '停止请求已提交,当前配置未保存。' })} loading={actionLoading} />
|
||
<Button size="icon" variant="subtle" title="运行状态" aria-label="运行状态" onClick={() => void loadStreamStatus(activeTableRecord)} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||
</>
|
||
) : null}
|
||
{config === configs.collection && ((activeSection.key === 'mappings') || (activeSection.key === 'collector_credentials' && activeSourceEndpoint === 'mappings')) && !activeGroup?.record.__isDraft ? (
|
||
<>
|
||
<Button size="icon" variant="subtle" title="预览映射" aria-label="预览映射" onClick={() => void previewActiveMapping()} loading={actionLoading}><Eye size={15} /></Button>
|
||
<Button size="icon" variant="subtle" icon="start" title="启用映射" aria-label="启用映射" onClick={() => void activateActiveMapping()} loading={actionLoading} />
|
||
</>
|
||
) : null}
|
||
{config === configs.collection && activeSection.key === 'collection_history' ? null : (
|
||
<>
|
||
{config === configs.earthContent && activeSection.key === 'tv' && activeGroup?.key === 'tv:new-source' ? (
|
||
<Button variant="subtle" onClick={() => {
|
||
setTvDraftGroup(null)
|
||
setHierarchyDraft('')
|
||
setActiveGroupKey('')
|
||
toast({ title: '已取消新增直播源', tone: 'success' })
|
||
}}><X size={15} />取消</Button>
|
||
) : null}
|
||
{config === configs.collection && activeSection.key === 'collector_credentials' && activeGroup?.record.__isDraft ? (
|
||
<Button variant="subtle" onClick={() => {
|
||
setCollectionDraftGroup(null)
|
||
setHierarchyDraft('')
|
||
setActiveGroupKey('')
|
||
toast({ title: '已取消新增配置', tone: 'success' })
|
||
}}><X size={15} />取消</Button>
|
||
) : null}
|
||
<Button variant="primary" onClick={() => void saveCurrent()} loading={actionLoading}><Save size={15} />保存</Button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="an-panel-body">
|
||
<Scrollbar className="an-hierarchy-form-scroll">
|
||
{activeGroup ? (
|
||
<div className="an-hierarchy-form">
|
||
{config === configs.collection && activeSection.key === 'collection_history' ? (
|
||
<div className="an-time-capsule">
|
||
<section className="an-field-cluster">
|
||
<div className="an-field-cluster__heading">
|
||
<h3>Time Capsule</h3>
|
||
<p>{snapshotTimeline.length} 个历史快照,选择后查看该版本详情。</p>
|
||
</div>
|
||
<div className="an-time-capsule__controls">
|
||
<label className="an-field an-field--wide">
|
||
<span>快照版本</span>
|
||
<select
|
||
className="an-input"
|
||
value={activeSnapshot ? snapshotId(activeSnapshot) : ''}
|
||
onChange={(event) => setSnapshotSelectionBySource((current) => ({
|
||
...current,
|
||
[snapshotSourceKey]: event.target.value,
|
||
}))}
|
||
>
|
||
{snapshotTimeline.map((snapshot) => (
|
||
<option key={snapshotId(snapshot)} value={snapshotId(snapshot)}>
|
||
{snapshotOptionLabel(snapshot)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
{activeSnapshot ? (
|
||
<StatusText tone={statusTone(recordStatus(activeSnapshot))}>
|
||
{semanticLabel(recordStatus(activeSnapshot))}
|
||
</StatusText>
|
||
) : null}
|
||
</div>
|
||
</section>
|
||
{activeSnapshot ? (
|
||
<>
|
||
<section className="an-field-cluster">
|
||
<div className="an-field-cluster__heading">
|
||
<h3>{pick(activeSnapshot, ['snapshot_key', 'source', 'id'], '采集快照')}</h3>
|
||
<p>{formatSnapshotTime(activeSnapshot)}</p>
|
||
</div>
|
||
<DetailFields record={activeSnapshot} />
|
||
</section>
|
||
{isObjectRecord(activeSnapshot.summary) && Object.keys(activeSnapshot.summary).length ? (
|
||
<section className="an-field-cluster">
|
||
<div className="an-field-cluster__heading">
|
||
<h3>快照摘要</h3>
|
||
<p>采集任务写入的 summary。</p>
|
||
</div>
|
||
<Scrollbar className="an-code-scroll">
|
||
<pre className="an-json-view">{formatRaw(activeSnapshot.summary)}</pre>
|
||
</Scrollbar>
|
||
</section>
|
||
) : null}
|
||
<details className="an-advanced-editor">
|
||
<summary>元数据 / 原始字段</summary>
|
||
<Textarea value={formatRaw(cleanRecord(activeSnapshot))} readOnly spellCheck={false} />
|
||
</details>
|
||
</>
|
||
) : (
|
||
<EmptyState title="暂无快照" description="当前采集源没有可查看的历史版本。" />
|
||
)}
|
||
</div>
|
||
) : null}
|
||
{config === configs.earthContent && activeSection.key === 'brand' ? (
|
||
<EarthBrandPreview record={record} />
|
||
) : null}
|
||
{config === configs.collection && activeSection.key === 'collection_history' ? null : config === configs.earthContent && activeSection.key === 'tv' ? (
|
||
<>
|
||
<div className="an-tv-edit-layout">
|
||
<TVEarthPreview source={record} tv={{ ...(tvSettingsFromRaw(activeState.raw) as AnyRecord), sources: tvAdminSources(activeState.raw), source_count: tvAdminSources(activeState.raw).length }} />
|
||
<section className="an-field-cluster an-field-cluster--compact">
|
||
<div className="an-field-cluster__heading">
|
||
<h3>基础字段</h3>
|
||
<p>频道身份、状态和排序。</p>
|
||
</div>
|
||
<FieldGrid
|
||
record={activeGroup.record}
|
||
draft={hierarchyDraft}
|
||
onDraftChange={setHierarchyDraft}
|
||
fields={fields.filter((field) => TV_COMPACT_FIELD_KEYS.has(field.key))}
|
||
searchGroupKey={activeGroup.key}
|
||
/>
|
||
</section>
|
||
</div>
|
||
<section className="an-field-cluster">
|
||
<div className="an-field-cluster__heading">
|
||
<h3>播放与扩展</h3>
|
||
<p>播放地址、封面、YouTube 信息和其他高级字段。</p>
|
||
</div>
|
||
<FieldGrid
|
||
record={activeGroup.record}
|
||
draft={hierarchyDraft}
|
||
onDraftChange={setHierarchyDraft}
|
||
fields={fields.filter((field) => !TV_COMPACT_FIELD_KEYS.has(field.key))}
|
||
searchGroupKey={activeGroup.key}
|
||
/>
|
||
</section>
|
||
</>
|
||
) : (
|
||
<FieldGrid record={activeGroup.record} draft={hierarchyDraft} onDraftChange={setHierarchyDraft} fields={fields} searchGroupKey={activeGroup.key} />
|
||
)}
|
||
{config === configs.collection && activeSection.key === 'collection_history' ? null : (
|
||
<details className="an-advanced-editor">
|
||
<summary>元数据 / 原始字段</summary>
|
||
<Textarea value={hierarchyDraft || formatRaw(activeGroup.record)} onChange={(event) => setHierarchyDraft(event.target.value)} spellCheck={false} />
|
||
</details>
|
||
)}
|
||
</div>
|
||
) : <EmptyState title={isPlaceholderSection(activeSection.key) ? '后端能力未提供' : '暂无分组'} description={isPlaceholderSection(activeSection.key) ? '当前分区没有可用后端能力,控制台不混入其他配置或假数据。' : '当前分区没有可配置项。'} />}
|
||
</Scrollbar>
|
||
</div>
|
||
</Panel>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const renderRecordActions = () => {
|
||
if (!selected) return null
|
||
const actions: ReactNode[] = []
|
||
const endpointKey = selected.__endpointKey
|
||
const id = pick(selected, ['config_id', 'id', 'source_id', 'key', 'name'], '')
|
||
|
||
if (config === configs.datasources && endpointKey === 'builtin' && id) {
|
||
const isDatasourceActive = selected.is_active !== false
|
||
const toggle = isDatasourceActive ? 'disable' : 'enable'
|
||
const toggleLabel = isDatasourceActive ? '停用数据源调度' : '启用数据源调度'
|
||
const rowLoading = Boolean(rowActionLoading[id])
|
||
const queueItem = collectionQueue.find((item) => isSameDatasourceRow(selected, item.sourceId || '', item.source || '') && isActiveQueueStatus(item.status))
|
||
const activeCollectItem = queueItem?.taskType === 'collect' ? queueItem : null
|
||
const activeDeleteItem = queueItem?.taskType === 'clear_data' ? queueItem : null
|
||
const activeTaskId = queueItem?.taskId || selected.task_id
|
||
const isCollecting = isCollectTaskActive(selected) || Boolean(activeCollectItem)
|
||
actions.push(
|
||
<Button key="load-detail" size="icon" variant="subtle" icon="detail" onClick={() => void loadDatasourceDetail(selected)} loading={actionLoading} title="详情/统计" aria-label="详情/统计" />,
|
||
isCollecting && (activeCollectItem?.taskId || activeTaskId) ? (
|
||
<Button key="trigger" variant="danger" onClick={() => void cancelQueueItem(queueItemFromDatasourceRow(selected, (activeCollectItem?.taskId || activeTaskId) as number | string, {
|
||
taskType: 'collect',
|
||
status: activeCollectItem?.status || 'running',
|
||
phase: text(selected.phase, queueItem?.phase || 'running'),
|
||
phaseMessage: text(selected.phase_message, queueItem?.phaseMessage || '后端任务仍在运行'),
|
||
progress: typeof selected.progress === 'number' ? selected.progress : queueItem?.progress,
|
||
createdAt: queueItem?.createdAt || Date.now(),
|
||
updatedAt: Date.now(),
|
||
}))} loading={rowLoading}>
|
||
<Square size={14} />
|
||
停止采集
|
||
</Button>
|
||
) : (
|
||
<Button key="trigger" variant="primary" icon="trigger" onClick={() => void triggerDatasourceWithPrecheck(selected)} loading={rowLoading}>触发采集</Button>
|
||
),
|
||
<Button key="toggle" size="icon" variant={toggle === 'enable' ? 'subtle' : 'danger'} icon={toggle === 'enable' ? 'start' : 'stop'} onClick={() => void requestAction(toggleLabel, 'post', `/datasources/${encodeURIComponent(id)}/${toggle}`)} loading={actionLoading} title={toggleLabel} aria-label={toggleLabel} />,
|
||
<Button key="task-status" size="icon" variant="subtle" icon="status" onClick={() => void loadTaskStatus(selected)} loading={actionLoading} title="任务状态" aria-label="任务状态" />,
|
||
<Button key="clear-cache" size="icon" variant="subtle" onClick={() => setConfirmAction({
|
||
title: '清理数据源缓存',
|
||
description: `确认清理 ${recordTitle(selected)} 的智能星球展示缓存?这不会删除数据库里的采集数据。`,
|
||
danger: false,
|
||
confirmLabel: '清理缓存',
|
||
run: () => clearDatasourceCache(selected),
|
||
})} loading={actionLoading} title="清理缓存" aria-label="清理缓存"><BrushCleaning size={15} /></Button>,
|
||
activeDeleteItem ? (
|
||
<Button key="delete-data" size="icon" variant="subtle" onClick={() => void cancelQueueItem(activeDeleteItem)} loading={actionLoading} title="停止删除" aria-label="停止删除"><X size={15} /></Button>
|
||
) : (
|
||
<Button key="delete-data" size="icon" variant="danger" onClick={() => setConfirmAction({
|
||
title: '清理数据库数据',
|
||
description: `确认删除 ${recordTitle(selected)} 的已采集数据库记录?这不会清理智能星球展示缓存,此操作不可恢复。`,
|
||
danger: true,
|
||
confirmLabel: '删除数据',
|
||
run: () => clearDatasourceData(selected),
|
||
})} loading={actionLoading} title="清理数据库数据" aria-label="清理数据库数据"><Trash2 size={15} /></Button>
|
||
),
|
||
)
|
||
}
|
||
|
||
if (config === configs.datasources && endpointKey === 'realtime' && id) {
|
||
const active = pick(selected, ['enabled', 'active', 'running', 'status'], '').toLowerCase()
|
||
const action = active === 'true' || active === 'running' || active === 'active' || active === 'enabled' ? 'stop' : 'start'
|
||
actions.push(
|
||
<Button key="realtime" variant={action === 'start' ? 'primary' : 'danger'} icon={action === 'start' ? 'start' : 'stop'} onClick={() => void requestAction(action === 'start' ? '启动实时源' : '停止实时源', 'post', `/realtime-sources/${encodeURIComponent(id)}/${action}`, undefined, { successDescription: '实时源操作已提交,不会保存当前表单。' })} loading={actionLoading}>实时启停</Button>,
|
||
<Button key="realtime-restart" size="icon" variant="subtle" icon="refresh" onClick={() => void requestAction('重启实时源', 'post', `/realtime-sources/${encodeURIComponent(id)}/restart`, undefined, { successDescription: '重启请求已提交,不会保存当前表单。' })} loading={actionLoading} title="重启" aria-label="重启" />,
|
||
)
|
||
}
|
||
|
||
if (config === configs.datasources && endpointKey === 'custom' && id) {
|
||
actions.push(
|
||
<Button key="custom-test" size="icon" variant="subtle" icon="connect" onClick={() => void testDatasourceConfigDraft(cleanRecord(selected))} loading={actionLoading} title="连接测试" aria-label="连接测试" />,
|
||
<Button key="custom-sample" size="icon" variant="subtle" icon="sample" onClick={() => void fetchCustomSample(selected)} loading={actionLoading} title="采样" aria-label="采样" />,
|
||
<Button key="custom-run" size="icon" variant="subtle" icon="start" onClick={() => void requestAction('启动采集', 'post', `/datasources/${encodeURIComponent(id)}/run-mapped`, undefined, { refresh: false, successDescription: '采集任务已提交,当前配置未保存。' })} loading={actionLoading} title="运行" aria-label="运行" />,
|
||
<Button key="custom-stop" size="icon" variant="danger" icon="stop" onClick={() => void requestAction('停止采集', 'post', `/datasources/${encodeURIComponent(id)}/stop-mapped`, undefined, { refresh: false, successDescription: '停止请求已提交,当前配置未保存。' })} loading={actionLoading} title="停止" aria-label="停止" />,
|
||
<Button key="custom-status" size="icon" variant="subtle" icon="status" onClick={() => void loadStreamStatus(selected)} loading={actionLoading} title="运行状态" aria-label="运行状态" />,
|
||
<Button key="custom-edit" asChild size="icon" variant="subtle" title="到采集管理编辑" aria-label="到采集管理编辑">
|
||
<Link to="/collection-management"><Settings2 size={15} /></Link>
|
||
</Button>,
|
||
)
|
||
}
|
||
|
||
if ((config === configs.systemAlerts || config === configs.situationalAlerts) && id) {
|
||
actions.push(
|
||
<Button key="ack" size="icon" variant="subtle" onClick={() => void requestAction('确认告警', 'post', `/alerts/${encodeURIComponent(id)}/acknowledge`)} loading={actionLoading} title="确认告警" aria-label="确认告警"><CheckCircle2 size={15} /></Button>,
|
||
<Button key="resolve" variant="primary" onClick={() => {
|
||
setResolutionText('已处理')
|
||
setResolveTarget(selected)
|
||
}} loading={actionLoading}><ShieldAlert size={15} />解决</Button>,
|
||
)
|
||
}
|
||
|
||
if (config === configs.logs && endpointKey === 'sources') {
|
||
actions.push(<Button key="snapshot" variant="primary" onClick={fetchLogSnapshot} loading={actionLoading}><FileText size={15} />读取快照</Button>)
|
||
}
|
||
|
||
if (config === configs.collection && id && /configs/.test(endpointKey)) {
|
||
const useBuiltinConnectivity = endpointKey === 'configsAll'
|
||
actions.push(
|
||
<Button key="save-config" variant="primary" onClick={saveAdvancedJson} loading={actionLoading}><Save size={15} />保存</Button>,
|
||
<Button key="test-config" size="icon" variant="subtle" icon="connect" onClick={() => void testDatasourceConfigDraft(cleanRecord(selected), { builtin: useBuiltinConnectivity })} loading={actionLoading} title="连接测试" aria-label="连接测试" />,
|
||
<Button key="sample-config" size="icon" variant="subtle" icon="sample" onClick={() => void fetchCustomSample(selected)} loading={actionLoading} title="采样" aria-label="采样" />,
|
||
<Button key="run-config" size="icon" variant="subtle" icon="start" onClick={() => void requestAction('启动采集', 'post', `/datasources/${encodeURIComponent(id)}/run-mapped`, undefined, { refresh: false, successDescription: '采集任务已提交,当前配置未保存。' })} loading={actionLoading} title="运行" aria-label="运行" />,
|
||
<Button key="stop-config" size="icon" variant="danger" icon="stop" onClick={() => void requestAction('停止采集', 'post', `/datasources/${encodeURIComponent(id)}/stop-mapped`, undefined, { refresh: false, successDescription: '停止请求已提交,当前配置未保存。' })} loading={actionLoading} title="停止" aria-label="停止" />,
|
||
<Button key="stream-status" size="icon" variant="subtle" icon="status" onClick={() => void loadStreamStatus(selected)} loading={actionLoading} title="状态" aria-label="状态" />,
|
||
<Button key="delete-config" variant="danger" onClick={() => setConfirmAction({
|
||
title: '删除采集配置',
|
||
description: `确认删除 ${recordTitle(selected)}?`,
|
||
danger: true,
|
||
confirmLabel: '删除',
|
||
run: () => requestAction('删除采集配置', 'delete', `/datasources/configs/${encodeURIComponent(id)}`),
|
||
})} loading={actionLoading}><Trash2 size={15} />删除</Button>,
|
||
)
|
||
}
|
||
|
||
if (config === configs.collection && endpointKey === 'mappings' && id) {
|
||
actions.push(
|
||
<Button key="save-mapping" variant="primary" onClick={() => void saveMappingTemplate(selected)} loading={actionLoading}><Save size={15} />保存</Button>,
|
||
<Button key="activate-mapping" variant="subtle" icon="start" onClick={() => void saveMappingTemplate(selected, true)} loading={actionLoading}>启用</Button>,
|
||
<Button key="preview-mapping" variant="subtle" onClick={() => void previewMappingTemplate(selected)} loading={actionLoading}><Eye size={15} />预览</Button>,
|
||
)
|
||
}
|
||
|
||
if (config === configs.ai && endpointKey === 'providerPresets' && id) {
|
||
actions.push(<Button key="refresh-preset" variant="primary" onClick={() => void requestAction('刷新模型列表', 'post', `/settings/integrations/ai-provider/presets/${encodeURIComponent(id)}/refresh`, undefined, { refresh: false, successDescription: '模型预设已刷新,未保存 provider 表单。' })} loading={actionLoading}><RefreshCw size={15} />刷新模型</Button>)
|
||
}
|
||
|
||
if (config === configs.ai && endpointKey === 'integrations') {
|
||
const integrationKey = pick(selected, ['__title', 'key', 'provider'], '')
|
||
if (integrationKey === 'ai_provider') {
|
||
actions.push(
|
||
<Button key="connect-ai" size="icon" variant="subtle" icon="test" title="测试 AI Provider 连通性" aria-label="测试 AI Provider 连通性" onClick={() => void testConnection('AI Provider', '/settings/integrations/ai-provider/connect', cleanRecord(selected))} loading={actionLoading} />,
|
||
)
|
||
}
|
||
if (integrationKey === 'web_search') {
|
||
actions.push(
|
||
<Button key="connect-web" size="icon" variant="subtle" icon="test" title="测试 Web Search 连通性" aria-label="测试 Web Search 连通性" onClick={() => void testConnection('Web Search', '/settings/integrations/web-search/connect', cleanRecord(selected))} loading={actionLoading} />,
|
||
)
|
||
}
|
||
}
|
||
|
||
if (config === configs.ai && endpointKey === 'prompts' && id) {
|
||
actions.push(<Button key="reset-prompt" variant="danger" onClick={() => setConfirmAction({
|
||
title: '重置 Prompt',
|
||
description: `确认将 ${recordTitle(selected)} 重置为默认内容?`,
|
||
danger: true,
|
||
confirmLabel: '重置',
|
||
run: () => requestAction('重置 Prompt', 'post', `/settings/ai-prompts/${encodeURIComponent(id)}/reset`),
|
||
})} loading={actionLoading}><RefreshCw size={15} />重置</Button>)
|
||
}
|
||
|
||
if (config === configs.earthContent && endpointKey === 'brand') {
|
||
actions.push(
|
||
<label key="brand-upload" className="an-file-action">
|
||
<input type="file" onChange={(event) => setBrandUploadFile(event.target.files?.[0] ?? null)} />
|
||
<span><ImageUp size={15} />{brandUploadFile ? brandUploadFile.name : '选择资产'}</span>
|
||
</label>,
|
||
<Button key="brand-upload-run" variant="primary" onClick={() => void uploadBrandAsset()} loading={actionLoading} disabled={!brandUploadFile}><ImageUp size={15} />上传</Button>,
|
||
<Button key="delete-brand" variant="danger" onClick={() => setConfirmAction({
|
||
title: '删除品牌配置',
|
||
description: '确认删除智能星球品牌配置?',
|
||
danger: true,
|
||
confirmLabel: '删除',
|
||
run: () => requestAction('删除品牌配置', 'delete', '/earth/brand'),
|
||
})} loading={actionLoading}><Trash2 size={15} />删除</Button>,
|
||
<Button key="reset-brand" variant="subtle" onClick={() => setConfirmAction({
|
||
title: '重置品牌配置',
|
||
description: '确认恢复默认智能星球品牌配置?当前自定义配置会被清空。',
|
||
danger: true,
|
||
confirmLabel: '重置',
|
||
run: () => requestAction('重置品牌配置', 'delete', '/earth/brand'),
|
||
})} loading={actionLoading}><RefreshCw size={15} />重置</Button>,
|
||
)
|
||
}
|
||
|
||
if (config === configs.earthContent && endpointKey === 'boundaries') {
|
||
actions.push(<Button key="build-boundaries" variant="primary" icon="trigger" onClick={() => void requestAction('启动边界构建', 'post', '/earth/boundaries/build', undefined, { refresh: false, successDescription: '边界构建任务已提交,当前配置未保存。' })} loading={actionLoading}>构建</Button>)
|
||
}
|
||
|
||
if (config === configs.bgp && endpointKey === 'collectors' && id) {
|
||
actions.push(<Button key="collect-location" variant="primary" icon="trigger" onClick={() => void collectCollectorLocation(selected)} loading={actionLoading}>采集位置</Button>)
|
||
}
|
||
|
||
if ((config === configs.bgp || config === configs.bgpAlerts) && ['events', 'anomalies', 'incidents', 'briefs'].includes(endpointKey) && id) {
|
||
actions.push(<Button key="bgp-detail" variant="subtle" onClick={() => void loadBGPDetail(selected)} loading={actionLoading}><Eye size={15} />拉取详情</Button>)
|
||
}
|
||
|
||
if ((config === configs.settings) || (config === configs.earthContent && ['brand', 'boundaries', 'tv'].includes(endpointKey)) || (config === configs.ai && ['prompts', 'integrations'].includes(endpointKey))) {
|
||
actions.push(<Button key="save-json" variant="primary" onClick={saveAdvancedJson} loading={actionLoading}><Save size={15} />保存</Button>)
|
||
}
|
||
|
||
if (config === configs.settings && endpointKey === 'collectors') {
|
||
const provider = pick(selected, ['credential_provider', 'provider'], 'barentswatch')
|
||
actions.push(
|
||
<Button key="guide" variant="subtle" onClick={() => void loadCredentialGuide(provider)} loading={actionLoading}><FileText size={15} />凭证教程</Button>,
|
||
)
|
||
}
|
||
|
||
if (actions.length === 0) return null
|
||
return <div className="an-detail-actions">{actions}</div>
|
||
}
|
||
|
||
const supportsStructuredSave = (record: TableRecord | null) => {
|
||
if (!record) return false
|
||
const endpointKey = record.__endpointKey
|
||
return (
|
||
config === configs.settings ||
|
||
(config === configs.earthContent && ['brand', 'boundaries', 'tv'].includes(endpointKey)) ||
|
||
(config === configs.ai && ['integrations', 'prompts'].includes(endpointKey)) ||
|
||
(config === configs.collection && (endpointKey === 'mappings' || /configs/.test(endpointKey)))
|
||
)
|
||
}
|
||
|
||
const renderStructuredEditor = () => {
|
||
if (!selected || !supportsStructuredSave(selected)) return null
|
||
return (
|
||
<section className="an-form-panel">
|
||
<div className="an-form-panel__header">
|
||
<div>
|
||
<strong>配置表单</strong>
|
||
<span>常用字段直接编辑;复杂对象保留结构化子字段,不再把整条记录只丢进 JSON。</span>
|
||
</div>
|
||
<Button size="sm" variant="primary" onClick={saveAdvancedJson} loading={actionLoading}>保存</Button>
|
||
</div>
|
||
<EditableRecordForm
|
||
draft={advancedJson}
|
||
onDraftChange={setAdvancedJson}
|
||
hiddenKeys={['created_at', 'updated_at', 'id']}
|
||
/>
|
||
<details className="an-advanced-editor">
|
||
<summary>查看完整 JSON</summary>
|
||
<Textarea value={advancedJson} onChange={(event) => setAdvancedJson(event.target.value)} spellCheck={false} />
|
||
</details>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
const renderModuleActions = () => {
|
||
const actions: ReactNode[] = []
|
||
if (config === configs.datasources) {
|
||
const selectedCount = selectedDatasourceIds.length
|
||
const selectedRunningCount = selectedActiveCollectQueueItems.length
|
||
const allRunningCount = activeCollectQueueItems.length
|
||
const isStopAction = selectedCount ? selectedRunningCount > 0 : allRunningCount > 0
|
||
const label = isStopAction
|
||
? selectedCount ? `停止已选 ${selectedRunningCount}` : '停止采集'
|
||
: selectedCount ? `触发已选 ${selectedCount}` : '触发全部'
|
||
const title = isStopAction
|
||
? selectedCount ? `停止已选中的 ${selectedRunningCount} 个采集任务` : `停止 ${allRunningCount} 个采集任务`
|
||
: selectedCount ? `触发已选 ${selectedCount} 个数据源` : '触发全部数据源'
|
||
actions.push(
|
||
<Button
|
||
key="trigger-primary"
|
||
variant="primary"
|
||
onClick={() => void triggerDatasourcePrimaryAction()}
|
||
loading={actionLoading}
|
||
title={title}
|
||
>
|
||
{isStopAction ? <Square size={15} /> : <Radio size={15} />}
|
||
{label}
|
||
</Button>,
|
||
)
|
||
}
|
||
if (config === configs.collection) {
|
||
return actions
|
||
}
|
||
if (config === configs.bgp || config === configs.bgpAlerts) {
|
||
actions.push(
|
||
<Button key="generate-bgp-brief" variant="primary" onClick={() => void requestAction('生成 BGP 简报', 'post', '/ai/bgp/brief', { incident_limit: 5, anomaly_limit: 6, collector_limit: 5 }, { refresh: false, successDescription: 'AI 简报生成任务已提交,不会保存当前页面配置。' })} loading={actionLoading} title="生成 BGP AI 简报">
|
||
<Sparkles size={15} />生成简报
|
||
</Button>,
|
||
)
|
||
}
|
||
if (config === configs.systemAlerts) {
|
||
actions.push(
|
||
<Button key="generate-alert-brief" variant="primary" onClick={() => void requestAction('生成告警简报', 'post', '/ai/alerts/brief', {}, { refresh: false, successDescription: 'AI 简报生成任务已提交,不会保存当前页面配置。' })} loading={actionLoading} title="生成系统告警 AI 简报">
|
||
<Sparkles size={15} />AI 简报
|
||
</Button>,
|
||
)
|
||
}
|
||
if (config === configs.situationalAlerts) {
|
||
actions.push(
|
||
<Button key="generate-situational-brief" variant="primary" onClick={() => void requestAction('生成态势简报', 'post', '/ai/situational-alerts/brief', {}, { refresh: false, successDescription: 'AI 简报生成任务已提交,不会保存当前页面配置。' })} loading={actionLoading} title="生成态势告警 AI 简报">
|
||
<Sparkles size={15} />AI 简报
|
||
</Button>,
|
||
)
|
||
}
|
||
if (config === configs.earthContent) {
|
||
actions.push(
|
||
<Button key="cache-status" size="icon" variant="subtle" onClick={() => void loadEarthLayerCacheStatus()} loading={actionLoading} title="查看智能星球图层缓存" aria-label="查看智能星球图层缓存">
|
||
<DatabaseZap size={15} />
|
||
</Button>,
|
||
<Button key="cache-clear" size="icon" variant="danger" onClick={() => setConfirmAction({
|
||
title: '清理智能星球图层缓存',
|
||
description: '确认清理智能星球图层缓存?清理后下次访问会重新生成。',
|
||
danger: true,
|
||
confirmLabel: '清理',
|
||
run: () => requestAction('清理智能星球图层缓存', 'delete', '/system/cache/earth-layers', undefined, { refresh: false, successDescription: '缓存清理请求已提交,不会保存当前页面配置。' }),
|
||
})} loading={actionLoading} title="清理智能星球图层缓存" aria-label="清理智能星球图层缓存">
|
||
<Trash2 size={15} />
|
||
</Button>,
|
||
)
|
||
}
|
||
if (config === configs.logs && selected) {
|
||
actions.push(
|
||
<Button key="stop-tail" size="icon" variant="subtle" onClick={() => setSelected(null)} title="关闭日志快照" aria-label="关闭日志快照">
|
||
<Square size={15} />
|
||
</Button>,
|
||
)
|
||
}
|
||
return actions
|
||
}
|
||
|
||
const renderDatasourceFilters = () => {
|
||
if (config !== configs.datasources || activeSection?.key !== 'builtin') return null
|
||
return (
|
||
<div className="an-filter-grid an-filter-grid--datasources">
|
||
<label className="an-field an-field--inline-filter">
|
||
<select className="an-input" value={datasourceFilters.product} onChange={(event) => setDatasourceFilter('product', event.target.value)}>
|
||
<option value="">全部产品域</option>
|
||
<option value="vessels">船舶</option>
|
||
<option value="cables">海缆</option>
|
||
<option value="satellites">卫星</option>
|
||
<option value="bgp">BGP</option>
|
||
<option value="compute">算力</option>
|
||
<option value="ai">AI</option>
|
||
<option value="media">媒体</option>
|
||
<option value="other">其他</option>
|
||
</select>
|
||
</label>
|
||
<label className="an-field an-field--inline-filter">
|
||
<select className="an-input" value={datasourceFilters.module} onChange={(event) => setDatasourceFilter('module', event.target.value)}>
|
||
<option value="">全部层级</option>
|
||
<option value="L1">L1</option>
|
||
<option value="L2">L2</option>
|
||
<option value="L3">L3</option>
|
||
<option value="L4">L4</option>
|
||
</select>
|
||
</label>
|
||
<label className="an-field an-field--inline-filter">
|
||
<select className="an-input" value={datasourceFilters.isActive} onChange={(event) => setDatasourceFilter('isActive', event.target.value)}>
|
||
<option value="">全部启用状态</option>
|
||
<option value="true">已启用</option>
|
||
<option value="false">已停用</option>
|
||
</select>
|
||
</label>
|
||
<label className="an-field an-field--inline-filter">
|
||
<select className="an-input" value={datasourceFilters.runStatus} onChange={(event) => setDatasourceFilter('runStatus', event.target.value)}>
|
||
<option value="">全部执行状态</option>
|
||
<option value="running">运行中</option>
|
||
<option value="success">成功</option>
|
||
<option value="failed">失败</option>
|
||
<option value="cancelled">已取消</option>
|
||
<option value="not_run">未执行</option>
|
||
</select>
|
||
</label>
|
||
<label className="an-field an-field--inline-filter">
|
||
<select className="an-input" value={datasourceFilters.dataStatus} onChange={(event) => setDatasourceFilter('dataStatus', event.target.value)}>
|
||
<option value="">全部数据状态</option>
|
||
<option value="collected">已采集</option>
|
||
<option value="uncollected">未采集</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const renderDatasourceTaskSummary = () => {
|
||
if (config !== configs.datasources || !selected || selected.__endpointKey !== 'builtin') return null
|
||
const sourceId = text(selected.id || selected.source_id, '')
|
||
const source = text(selected.source || selected.collector_name, '')
|
||
const queueItem = collectionQueue.find((item) => isSameDatasourceRow(selected, item.sourceId || '', item.source || '') && isActiveQueueStatus(item.status))
|
||
const status = queueItem?.status || datasourceStatus(selected)
|
||
const taskId = queueItem?.taskId || selected.task_id
|
||
return (
|
||
<section className="an-task-summary">
|
||
<div>
|
||
<strong>采集任务</strong>
|
||
<p>{queueItem?.phaseMessage || text(selected.last_status || selected.phase_message, '当前没有运行中的任务。')}</p>
|
||
</div>
|
||
<StatusText tone={statusTone(status)}>{queueStatusLabel(status as CollectionQueueStatus, queueItem?.taskType || text(selected.task_type, 'collect'))}</StatusText>
|
||
<dl>
|
||
<dt>数据源</dt><dd>{source || sourceId || '-'}</dd>
|
||
<dt>任务</dt><dd>{text(taskId, '-')}</dd>
|
||
<dt>进度</dt><dd>{queueItem ? `${queueProgress(queueItem)}%` : '-'}</dd>
|
||
<dt>更新时间</dt><dd>{queueItem?.updatedAt ? new Date(queueItem.updatedAt).toLocaleTimeString() : text(selected.last_run_at, '-')}</dd>
|
||
</dl>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
const focusDatasourceTableRow = (rowId: string) => {
|
||
window.requestAnimationFrame(() => {
|
||
window.requestAnimationFrame(() => {
|
||
const row = document.querySelector(`[data-row-id="${escapeSearchSelector(rowId)}"]`)
|
||
if (!(row instanceof HTMLElement)) return
|
||
row.classList.add('an-data-table-row-hit')
|
||
row.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' })
|
||
window.setTimeout(() => row.classList.remove('an-data-table-row-hit'), 2200)
|
||
})
|
||
})
|
||
}
|
||
|
||
const jumpToQueueRecord = async (item: CollectionQueueItem) => {
|
||
if (activeSectionKey !== 'builtin') {
|
||
setActiveSectionKey('builtin')
|
||
}
|
||
const builtinRows = states.find((state) => state.section.key === 'builtin')?.rows || []
|
||
let record = [...rows, ...builtinRows].find((row) => isSameDatasourceRow(row, item.sourceId || '', item.source || ''))
|
||
if (!record && (item.sourceId || item.source)) {
|
||
const lookupId = item.sourceId || item.source || ''
|
||
try {
|
||
const response = await axios.get(apiPath(`/datasources/${encodeURIComponent(lookupId)}/row`), { params: { include_endpoint: false } })
|
||
const row = objectAt(response.data, 'data')
|
||
if (Object.keys(row).length) {
|
||
record = normalizeDatasourceTableRecord(row)
|
||
updateDatasourceRow(row)
|
||
}
|
||
} catch {
|
||
// The toast below gives the user a clear recovery path.
|
||
}
|
||
}
|
||
if (!record) {
|
||
toast({ title: '当前分区未找到该数据源', description: '可切回内置源或刷新后再查看。' })
|
||
return
|
||
}
|
||
openResourceDetail(record)
|
||
focusDatasourceTableRow(record.__rowId)
|
||
}
|
||
|
||
const retryQueueItem = (item: CollectionQueueItem) => {
|
||
const record = rows.find((row) => isSameDatasourceRow(row, item.sourceId || '', item.source || ''))
|
||
if (!record) {
|
||
toast({ title: '无法重试', description: '当前列表中没有找到对应数据源。', tone: 'error' })
|
||
return
|
||
}
|
||
void triggerDatasourceWithPrecheck(record)
|
||
}
|
||
|
||
const queueItemFromDatasourceRow = (
|
||
record: AnyRecord,
|
||
taskId: number | string | null | undefined,
|
||
overrides: Partial<CollectionQueueItem> = {},
|
||
): CollectionQueueItem => {
|
||
const sourceId = pick(record, ['id', 'source_id', 'key', 'name'], '')
|
||
return {
|
||
key: queueItemKey({ id: sourceId, source: record.source, task_id: taskId }),
|
||
sourceId,
|
||
source: text(record.source || record.collector_name, ''),
|
||
name: recordTitle(record),
|
||
taskId,
|
||
taskType: text(record.task_type, 'collect'),
|
||
status: 'queued',
|
||
phase: 'queued',
|
||
phaseMessage: '任务已提交',
|
||
progress: 0,
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now(),
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
const cancelQueueItem = async (item: CollectionQueueItem) => {
|
||
await cancelCollectionQueueItems([item], `停止${taskTypeLabel(item.taskType)}`)
|
||
}
|
||
|
||
const renderCollectionQueuePanel = () => {
|
||
if (config !== configs.datasources) return null
|
||
const groups: Array<{ key: string; title: string; items: CollectionQueueItem[] }> = [
|
||
{ key: 'running', title: '进行中', items: collectionQueue.filter((item) => isActiveQueueStatus(item.status)) },
|
||
{ key: 'failed', title: '失败', items: collectionQueue.filter((item) => item.status === 'failed') },
|
||
{ key: 'completed', title: '完成', items: collectionQueue.filter((item) => item.status === 'success') },
|
||
{ key: 'skipped', title: '跳过', items: collectionQueue.filter((item) => item.status === 'skipped' || item.status === 'cancelled') },
|
||
]
|
||
return (
|
||
<div className="an-collection-queue">
|
||
<div className="an-collection-queue__bar">
|
||
<div className="an-collection-queue__summary">
|
||
<strong>数据源任务</strong>
|
||
<span>{collectionQueueSummary.progress}%</span>
|
||
<span>进行 {collectionQueueSummary.running}</span>
|
||
<span>完成 {collectionQueueSummary.completed}</span>
|
||
<span>失败 {collectionQueueSummary.failed}</span>
|
||
<span>跳过 {collectionQueueSummary.skipped}</span>
|
||
</div>
|
||
<div className="an-collection-queue__actions">
|
||
{collectionQueueSummary.running === 0 ? (
|
||
<Button size="icon" variant="subtle" title="清空已结束队列项" aria-label="清空已结束队列项" onClick={() => setCollectionQueue((items) => items.filter((item) => isActiveQueueStatus(item.status)))}>
|
||
<X size={14} />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
<div className="an-collection-queue__track" aria-hidden="true">
|
||
<span style={{ width: `${collectionQueueSummary.progress}%` }} />
|
||
</div>
|
||
{collectionQueueSummary.total ? (
|
||
<div className="an-collection-queue__panel">
|
||
{groups.map((group) => (
|
||
<section key={group.key} className="an-collection-queue__group">
|
||
<h3>{group.title}<span>{group.items.length}</span></h3>
|
||
{group.items.length ? (
|
||
<div className="an-collection-queue__items">
|
||
{group.items.map((item) => (
|
||
<article key={item.key} className={`an-collection-queue__item is-${item.status}`}>
|
||
<div>
|
||
<strong>{item.name}</strong>
|
||
<p>{item.phaseMessage || item.error || queueStatusLabel(item.status, item.taskType)}{item.taskId ? ` · task ${item.taskId}` : ''}{item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}</p>
|
||
</div>
|
||
<span>{queueProgress(item)}%</span>
|
||
<div className="an-collection-queue__item-actions">
|
||
<Button size="icon" variant="subtle" title="查看数据源" aria-label="查看数据源" onClick={() => void jumpToQueueRecord(item)}>
|
||
<Eye size={14} />
|
||
</Button>
|
||
{item.status === 'failed' ? (
|
||
<Button size="icon" variant="subtle" icon="trigger" title="重试" aria-label="重试" onClick={() => retryQueueItem(item)} />
|
||
) : null}
|
||
{isActiveQueueStatus(item.status) ? (
|
||
<Button size="icon" variant="subtle" title="取消任务" aria-label="取消任务" onClick={() => void cancelQueueItem(item)}>
|
||
<X size={14} />
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</article>
|
||
))}
|
||
</div>
|
||
) : <p className="an-collection-queue__empty">暂无{group.title}任务</p>}
|
||
</section>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="an-collection-queue__empty an-collection-queue__empty--panel">暂无数据源任务</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const renderCollectionQueueAction = () => {
|
||
if (config !== configs.datasources) return null
|
||
const hasQueue = collectionQueueSummary.total > 0
|
||
const queueLabel = hasQueue
|
||
? `数据源任务 ${collectionQueueSummary.progress}%,进行 ${collectionQueueSummary.running},完成 ${collectionQueueSummary.completed},失败 ${collectionQueueSummary.failed},跳过 ${collectionQueueSummary.skipped}`
|
||
: '数据源任务,暂无任务'
|
||
return (
|
||
<div className="an-collection-queue-anchor" ref={collectionQueueRef}>
|
||
<Button
|
||
size="icon"
|
||
variant="subtle"
|
||
title={queueLabel}
|
||
aria-label={queueLabel}
|
||
aria-expanded={collectionQueueOpen}
|
||
onClick={() => setCollectionQueueOpen((open) => !open)}
|
||
>
|
||
{hasQueue ? (
|
||
<span
|
||
className="an-collection-queue-trigger an-collection-queue-trigger--progress"
|
||
style={{ '--queue-progress': `${collectionQueueSummary.progress}%` } as CSSProperties}
|
||
aria-hidden="true"
|
||
/>
|
||
) : (
|
||
<ListChecks size={15} />
|
||
)}
|
||
</Button>
|
||
{collectionQueueOpen ? (
|
||
<div className="an-collection-queue-popover">
|
||
{renderCollectionQueuePanel()}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const moduleActions = renderModuleActions()
|
||
const credentialGuideProviderName = credentialGuide ? text(credentialGuide.provider, 'barentswatch') : ''
|
||
const credentialGuideMarkdown = credentialGuide
|
||
? text(credentialGuide.markdown || credentialGuide.content_markdown || credentialGuide.content, '')
|
||
: ''
|
||
const credentialGuideMissing = credentialGuide
|
||
? !credentialGuideMarkdown.trim() || text(credentialGuide.source, '') === 'missing' || text(credentialGuide.verification_status, '') === 'missing'
|
||
: false
|
||
const credentialGuideSources = Array.isArray(credentialGuide?.sources)
|
||
? credentialGuide.sources.filter(isObjectRecord)
|
||
: []
|
||
const credentialGuideStyle = credentialGuidePosition
|
||
? ({ left: credentialGuidePosition.x, top: credentialGuidePosition.y, transform: 'none' } as CSSProperties)
|
||
: undefined
|
||
|
||
return (
|
||
<AdminLayout>
|
||
<PageFrame
|
||
title={config.title}
|
||
description={config.description}
|
||
className="an-resource-page"
|
||
actions={(
|
||
<>
|
||
<Button size="icon" variant="subtle" onClick={() => void load()} loading={loading} title="刷新" aria-label="刷新"><RefreshCw size={15} /></Button>
|
||
{renderCollectionQueueAction()}
|
||
{moduleActions}
|
||
{config.actions.map((action) => (
|
||
<Button key={action.label} asChild size="icon" variant="subtle" title={action.label} aria-label={action.label}>
|
||
<Link to={action.to}>{action.icon}</Link>
|
||
</Button>
|
||
))}
|
||
</>
|
||
)}
|
||
>
|
||
<div className="an-page-grid">
|
||
<SummaryStrip>
|
||
<StatCell label="接口在线" value={`${summary.online}/${states.length || 1}`} hint="已加载分区" />
|
||
<StatCell label="记录数" value={summary.rows} hint="已加载分区汇总" />
|
||
<StatCell label="异常接口" value={summary.failing} hint="失败时页面仍可操作" />
|
||
</SummaryStrip>
|
||
|
||
<SectionTabs sections={config.sections} states={states} activeKey={activeSection?.key || ''} onChange={handleSectionChange} />
|
||
{renderDatasourceFilters()}
|
||
</div>
|
||
|
||
{isPlaygroundSection ? (
|
||
<PlaygroundLite />
|
||
) : isHierarchySection ? (
|
||
config === configs.ai ? renderAIHierarchy() : renderConfigHierarchy()
|
||
) : (
|
||
<div className={`an-resource-layout${mobileResourceDetailOpen ? ' is-mobile-detail-open' : ''}`} style={{ '--an-detail-width': `${detailWidth}px` } as CSSProperties}>
|
||
<Panel className="an-resource-main">
|
||
<div className="an-panel-heading">
|
||
<div>
|
||
<h2>{activeSection?.label || config.listTitle}</h2>
|
||
<p>{config.listDescription}</p>
|
||
</div>
|
||
<StatusText tone={loading ? 'running' : activeState?.ok === false ? 'danger' : 'success'}>{loading ? '同步中' : activeState?.ok === false ? '接口失败' : '已就绪'}</StatusText>
|
||
</div>
|
||
<div className="an-panel-body">
|
||
{loading || rows.length > 0 ? (
|
||
<ModuleTable
|
||
rows={rows}
|
||
selected={selected}
|
||
onSelect={openResourceDetail}
|
||
columns={config.columns}
|
||
loading={loading}
|
||
selection={isDatasourceBuiltinSection ? {
|
||
selectedRowIds: datasourceSelectedRowIds,
|
||
onToggleAllVisible: toggleAllVisibleDatasourceSelection,
|
||
onToggleRow: toggleDatasourceSelection,
|
||
getCheckboxLabel: (row) => `选择${recordTitle(row)}`,
|
||
} : undefined}
|
||
/>
|
||
) : (
|
||
<EmptyState title={loading ? '正在加载数据' : '当前分区暂无记录'} description="切换上方分区可精准查看不同配置和接口。" />
|
||
)}
|
||
</div>
|
||
</Panel>
|
||
|
||
<button
|
||
type="button"
|
||
className="an-detail-resizer"
|
||
onPointerDown={startDetailResize}
|
||
aria-label="拖动调整详情宽度"
|
||
title="拖动调整详情宽度"
|
||
/>
|
||
|
||
<div className="an-resource-side">
|
||
<div className="an-mobile-detail-bar">
|
||
<Button size="icon" variant="subtle" title="返回列表" aria-label="返回列表" onClick={() => setMobileResourceDetailOpen(false)}><ArrowLeft size={15} /></Button>
|
||
<strong>{selected ? recordTitle(selected) : config.detailTitle}</strong>
|
||
</div>
|
||
<DetailPanel title={config.detailTitle} description={selected ? selected.__endpointLabel : '未选择记录'}>
|
||
{selected ? (
|
||
<div className="an-detail-content">
|
||
<header className="an-detail-title">
|
||
<div className="an-detail-title__main">
|
||
{selectedHistory.length ? (
|
||
<Button size="icon" variant="subtle" icon="back" title="返回上一级详情" aria-label="返回上一级详情" onClick={goBackResourceDetail} />
|
||
) : null}
|
||
<h3>{recordTitle(selected)}</h3>
|
||
</div>
|
||
<StatusText tone={statusTone(recordStatus(selected))}>{recordStatus(selected)}</StatusText>
|
||
</header>
|
||
{renderRecordActions()}
|
||
{renderDatasourceTaskSummary()}
|
||
<DetailMarkdownDocument record={selected} />
|
||
{renderStructuredEditor() || <DetailFields record={selected} />}
|
||
<div className="an-code-section">
|
||
<div className="an-code-section__header">
|
||
<strong>{selected.__endpointKey === 'logSnapshot' ? '日志正文' : '原始数据'}</strong>
|
||
<Button size="icon" variant="subtle" onClick={copySelected} title="复制原始数据" aria-label="复制原始数据"><Copy size={14} /></Button>
|
||
</div>
|
||
<Scrollbar className="an-code-scroll">
|
||
<pre className="an-json-view">{selected.__endpointKey === 'logSnapshot' && Array.isArray(selected.lines) ? (selected.lines as unknown[]).join('\n') : formatRaw(cleanRecord(selected))}</pre>
|
||
</Scrollbar>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<EmptyState title="选择一条记录" description="详情会在右侧完整滚动显示,不会挤压主表区域。" />
|
||
)}
|
||
</DetailPanel>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</PageFrame>
|
||
|
||
{credentialGuideOpen && credentialGuide ? (
|
||
<div className="an-guide-modal" style={credentialGuideStyle} role="dialog" aria-modal="false" aria-labelledby="an-guide-modal-title">
|
||
<div className="an-guide-modal__header">
|
||
<div className="an-guide-modal__drag" onPointerDown={startCredentialGuideDrag}>
|
||
<h2 id="an-guide-modal-title">{credentialGuideProviderName} 凭证教程</h2>
|
||
<p>{text(credentialGuide.source, 'default') === 'ai' ? 'AI 生成教程' : '默认教程'}</p>
|
||
</div>
|
||
<div className="an-toolbar">
|
||
<Button size="icon" variant="subtle" title="教程不好用,重新生成" aria-label="教程不好用,重新生成" onClick={() => void loadCredentialGuide(credentialGuideProviderName, 'generate')} loading={actionLoading}><Sparkles size={15} /></Button>
|
||
<Button size="icon" variant="subtle" title="重置为默认教程" aria-label="重置为默认教程" onClick={() => setConfirmAction({
|
||
title: '重置凭证教程',
|
||
description: `确认将 ${credentialGuideProviderName} 凭证教程恢复默认?`,
|
||
danger: true,
|
||
confirmLabel: '重置',
|
||
run: () => loadCredentialGuide(credentialGuideProviderName, 'reset'),
|
||
})} loading={actionLoading}><RefreshCw size={15} /></Button>
|
||
<Button size="icon" variant="subtle" title="关闭" aria-label="关闭" onClick={() => setCredentialGuideOpen(false)}>×</Button>
|
||
</div>
|
||
</div>
|
||
<Scrollbar className="an-guide-modal__body">
|
||
{text(credentialGuide.verification_status, '') === 'unverified_no_search_evidence' ? (
|
||
<div className="an-guide-alert an-guide-alert--warning">
|
||
<strong>没有可用 WebSearch 证据,已保留默认教程。</strong>
|
||
{credentialGuide.verification_error ? <p>{text(credentialGuide.verification_error, '')}</p> : null}
|
||
</div>
|
||
) : null}
|
||
{credentialGuideMissing ? (
|
||
<div className="an-guide-empty">
|
||
<FileText size={30} aria-hidden="true" />
|
||
<h3>暂无凭证教程</h3>
|
||
<p>当前采集器还没有可展示的凭证教程。可以在这里直接生成,生成后会保存在后端并回到这个弹窗展示。</p>
|
||
{credentialGuide.verification_error ? <span>{text(credentialGuide.verification_error, '')}</span> : null}
|
||
<Button variant="primary" onClick={() => void loadCredentialGuide(credentialGuideProviderName, 'generate')} loading={actionLoading}>
|
||
<Sparkles size={15} />
|
||
生成教程
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<div className="an-guide-markdown an-markdown-doc">
|
||
<MarkdownRenderer markdown={credentialGuideMarkdown} className="an-markdown-doc__renderer" />
|
||
</div>
|
||
)}
|
||
{credentialGuideSources.length ? (
|
||
<section className="an-guide-sources">
|
||
<h3>搜索证据</h3>
|
||
{credentialGuideSources.slice(0, 5).map((source, index) => {
|
||
const url = text(source.url, '')
|
||
const title = text(source.title, url || `来源 ${index + 1}`)
|
||
return url ? (
|
||
<a key={url || title} href={url} target="_blank" rel="noreferrer">{title}</a>
|
||
) : (
|
||
<span key={title}>{title}</span>
|
||
)
|
||
})}
|
||
</section>
|
||
) : null}
|
||
</Scrollbar>
|
||
{credentialGuideBusy ? (
|
||
<div className="an-guide-modal__busy" role="status" aria-live="polite">
|
||
<div className="an-guide-modal__spinner" aria-hidden="true" />
|
||
<strong>
|
||
{credentialGuideBusy === 'generate'
|
||
? '正在生成凭证教程'
|
||
: credentialGuideBusy === 'reset'
|
||
? '正在重置凭证教程'
|
||
: '正在读取凭证教程'}
|
||
</strong>
|
||
<span>{credentialGuideBusy === 'generate' ? '正在搜索证据并调用 AI,请稍等。' : '正在同步后端状态。'}</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
<Dialog
|
||
open={Boolean(resolveTarget)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setResolveTarget(null)
|
||
}}
|
||
title="处理告警"
|
||
description={resolveTarget ? `为 ${recordTitle(resolveTarget)} 填写处理说明。` : undefined}
|
||
width={520}
|
||
footer={(
|
||
<>
|
||
<Button variant="subtle" onClick={() => setResolveTarget(null)} disabled={actionLoading}>取消</Button>
|
||
<Button variant="primary" onClick={() => void submitResolveAlert()} loading={actionLoading}><ShieldAlert size={15} />标记解决</Button>
|
||
</>
|
||
)}
|
||
>
|
||
<label className="an-field">
|
||
<span>处理说明</span>
|
||
<Textarea value={resolutionText} onChange={(event) => setResolutionText(event.target.value)} />
|
||
</label>
|
||
</Dialog>
|
||
|
||
<ConfirmDialog
|
||
open={Boolean(confirmAction)}
|
||
onOpenChange={(open) => {
|
||
if (!open) setConfirmAction(null)
|
||
}}
|
||
title={confirmAction?.title || '确认操作'}
|
||
description={confirmAction?.description}
|
||
danger={confirmAction?.danger}
|
||
confirmLabel={confirmAction?.confirmLabel}
|
||
loading={actionLoading}
|
||
onConfirm={() => void runConfirmedAction()}
|
||
/>
|
||
</AdminLayout>
|
||
)
|
||
}
|
||
|
||
const makeNamedRows = (payload: unknown, key: string) => dataArray(payload).map((row) => ({ ...row, __module: key }))
|
||
const objectSection = (payload: unknown, key: string, title: string) => {
|
||
const row = objectAt(payload, key)
|
||
const direct = Object.keys(row).length ? row : (isObjectRecord(payload) ? payload : {})
|
||
return [{ ...direct, __title: title, __module: title, __status: Object.keys(direct).length ? '已配置' : '未配置', __metric: `${Object.keys(direct).length} 字段` }]
|
||
}
|
||
const directObject = (payload: unknown, title: string, module: string) => [{ ...(isObjectRecord(payload) ? payload : {}), __title: title, __module: module, __status: Object.keys(isObjectRecord(payload) ? payload : {}).length ? '已读取' : '空', __metric: `${Object.keys(isObjectRecord(payload) ? payload : {}).length} 字段` }]
|
||
|
||
const configs = {
|
||
datasources: {
|
||
title: '数据源',
|
||
description: '统一查看内置源、自定义源、实时源与任务状态,保留触发、启停和连接状态入口。',
|
||
listTitle: '数据源列表',
|
||
listDescription: '仅展示数据源相关接口,不混入其他设置对象。',
|
||
sections: [
|
||
{ key: 'builtin', label: '内置源', url: '/datasources', map: datasourceRows },
|
||
{ key: 'custom', label: '自定义源', url: '/datasources/configs', map: (payload) => makeNamedRows(payload, '自定义源') },
|
||
{ key: 'realtime', label: '实时源', url: '/realtime-sources', map: (payload) => makeNamedRows(payload, '实时源') },
|
||
],
|
||
actions: [makeAction('采集管理', <Settings2 size={15} />, '/collection-management')],
|
||
detailTitle: '数据源详情',
|
||
},
|
||
bgp: {
|
||
title: 'BGP 观测',
|
||
description: '查看采集器、事故、异常、事件与 AI 简报;这是信息观测页,采用列表加详情。',
|
||
listTitle: 'BGP 事件与简报',
|
||
listDescription: '按 BGP 实体聚合展示,保留事件、异常、事故和简报语义。',
|
||
sections: [
|
||
{ key: 'overview', label: '概览摘要', url: '/bgp/overview/summary', map: (payload) => directObject(payload, 'BGP 概览', '概览') },
|
||
{ key: 'collectors', label: '采集器', url: '/bgp/collectors', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'Collector' })) },
|
||
{ key: 'incidents', label: '事故', url: '/bgp/incidents', params: { page_size: 30 }, map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'Incident' })) },
|
||
{ key: 'anomalies', label: '异常', url: '/bgp/anomalies', params: { page_size: 30 }, map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'Anomaly' })) },
|
||
{ key: 'events', label: '事件', url: '/bgp/events', params: { page_size: 30 }, map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'Event' })) },
|
||
{ key: 'briefs', label: 'AI 简报', url: '/ai/bgp/briefs', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'AI Brief', __status: 'brief' })) },
|
||
],
|
||
actions: [],
|
||
detailTitle: 'BGP 详情',
|
||
},
|
||
systemAlerts: {
|
||
title: '系统告警',
|
||
description: '系统告警、确认处理、AI 摘要和处置状态集中到一张低噪声列表。',
|
||
listTitle: '系统告警列表',
|
||
listDescription: '显示系统告警记录和统计,不混入 BGP 概览以外的数据。',
|
||
sections: [
|
||
{ key: 'alerts', label: '系统告警', url: '/alerts', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: '系统告警' })) },
|
||
{ key: 'stats', label: '告警统计', url: '/alerts/stats', map: (payload) => directObject(payload, '告警统计', '统计') },
|
||
],
|
||
actions: [makeAction('BGP 告警', <Radio size={15} />, '/alerts/bgp')],
|
||
detailTitle: '告警详情',
|
||
},
|
||
bgpAlerts: {
|
||
title: 'BGP 告警',
|
||
description: '聚合 BGP 事故、异常和 AI 简报,突出严重度、影响范围和事件链路。',
|
||
listTitle: 'BGP 告警列表',
|
||
listDescription: '只展示 BGP 事故、异常与简报。',
|
||
sections: [
|
||
{ key: 'incidents', label: 'BGP 事故', url: '/bgp/incidents', params: { page_size: 50 }, map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'Incident' })) },
|
||
{ key: 'anomalies', label: 'BGP 异常', url: '/bgp/anomalies', params: { page_size: 50 }, map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'Anomaly' })) },
|
||
{ key: 'briefs', label: 'BGP 简报', url: '/ai/bgp/briefs', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'AI Brief', __status: 'brief' })) },
|
||
],
|
||
actions: [makeAction('观测台', <Globe2 size={15} />, '/bgp')],
|
||
detailTitle: 'BGP 告警详情',
|
||
},
|
||
situationalAlerts: {
|
||
title: '态势告警',
|
||
description: '查看态势统计、严重度、AI 简报入口和处理状态。',
|
||
listTitle: '态势告警列表',
|
||
listDescription: '显示态势统计与告警记录。',
|
||
sections: [
|
||
{ key: 'stats', label: '态势统计', url: '/alerts/stats', map: (payload) => directObject(payload, '态势统计', '统计') },
|
||
{ key: 'alerts', label: '告警记录', url: '/alerts', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: '态势告警' })) },
|
||
],
|
||
actions: [makeAction('系统告警', <ShieldAlert size={15} />, '/alerts/system')],
|
||
detailTitle: '态势详情',
|
||
},
|
||
ai: {
|
||
title: 'AI',
|
||
description: '管理模型供应商、工具调用、提示词和 Playground。',
|
||
listTitle: 'AI 分区',
|
||
listDescription: '配置类页面采用分层结构:先选父级,再编辑子配置。',
|
||
viewMode: 'management',
|
||
sections: [
|
||
{
|
||
key: 'integrations',
|
||
label: '模型供应商',
|
||
map: (payload) => dataArray(payload),
|
||
endpoints: [
|
||
{ key: 'integrations', label: '供应商配置', url: '/settings/integrations', map: (payload) => [{ ...objectAt(objectAt(payload, 'integrations'), 'ai_provider'), __title: 'ai_provider', __module: '模型供应商', __status: pick(objectAt(objectAt(payload, 'integrations'), 'ai_provider'), ['default_provider'], '未配置') }] },
|
||
{ key: 'providerStatus', label: '供应商状态', url: '/ai/provider/status', map: (payload) => directObject(payload, '供应商运行状态', '模型供应商') },
|
||
{ key: 'providerPresets', label: '模型预设', url: '/settings/integrations/ai-provider/presets', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: '模型预设', __status: 'preset' })) },
|
||
],
|
||
},
|
||
{
|
||
key: 'tools',
|
||
label: '工具调用',
|
||
map: (payload) => dataArray(payload),
|
||
endpoints: [
|
||
{ key: 'integrations', label: '工具配置', url: '/settings/integrations', map: (payload) => ['web_search', 'ocr'].map((key) => ({ ...objectAt(objectAt(payload, 'integrations'), key), __title: key, __module: '工具调用', __status: pick(objectAt(objectAt(payload, 'integrations'), key), ['enabled'], '未配置') })) },
|
||
{ key: 'webSearchPresets', label: 'Web Search 预设', url: '/settings/integrations/web-search/presets', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: 'Web Search 预设', __status: 'preset' })) },
|
||
],
|
||
},
|
||
{ key: 'prompts', label: '提示词', url: '/settings/ai-prompts', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: '提示词', __status: pick(row, ['source'], 'prompt') })) },
|
||
{ key: 'playground', label: 'Playground', url: '/ai/playground/session', map: (payload) => isObjectRecord(payload) ? [{ ...payload, __title: 'Playground 会话', __module: 'Playground', __status: 'saved' }] : [] },
|
||
],
|
||
actions: [makeAction('设置', <Settings2 size={15} />, `/admin/${'settings'}`)],
|
||
detailTitle: 'AI 配置详情',
|
||
},
|
||
earthContent: {
|
||
title: '智能星球内容',
|
||
description: '管理智能星球品牌、边界、电视内容和内容资产。',
|
||
listTitle: '智能星球内容配置',
|
||
listDescription: '只展示智能星球品牌、边界构建和电视内容配置。',
|
||
viewMode: 'management',
|
||
sections: [
|
||
{ key: 'brand', label: '品牌标识', url: '/earth/brand', map: (payload) => singleRow(payload, 'brand', { __title: '品牌配置', __module: '品牌' }).map((row) => ({ ...row, __title: '品牌配置', __module: '品牌', __status: '已读取' })) },
|
||
{ key: 'about', label: '关于', url: '/earth/about', map: (payload) => singleRow(payload, 'about', { __title: '关于配置', __module: '关于' }).map((row) => ({ ...row, __title: '关于配置', __module: '关于', __status: '已读取' })) },
|
||
{
|
||
key: 'earth_assets',
|
||
label: '国界精度',
|
||
map: (payload) => dataArray(payload),
|
||
endpoints: [
|
||
{ key: 'boundaries', label: '边界状态', url: '/earth/boundaries/status', map: (payload) => [isObjectRecord(payload) ? { ...payload, __title: '边界状态', __module: '边界', __status: pick(payload, ['status'], '已读取') } : { __title: '边界状态', __module: '边界', __status: '空' }] },
|
||
{ key: 'boundaryBuild', label: '边界构建', url: '/earth/boundaries/build/status', map: (payload) => [isObjectRecord(payload) ? { ...payload, __title: '边界构建任务', __module: '边界', __status: pick(payload, ['status'], '已读取') } : { __title: '边界构建任务', __module: '边界', __status: '空' }] },
|
||
],
|
||
},
|
||
{
|
||
key: 'tv',
|
||
label: '电视直播',
|
||
map: (payload) => tvAdminSources(payload),
|
||
endpoints: [
|
||
{ key: 'settingsTv', label: '配置源', url: '/settings/tv', map: (payload) => tvAdminSources({ endpoints: [{ key: 'settingsTv', data: payload }] }) },
|
||
{ key: 'publicTv', label: '采集源', url: '/tv/streams', map: emptyRows },
|
||
],
|
||
},
|
||
{ key: 'basemap', label: '底图资源', map: emptyRows },
|
||
{ key: 'layer_resources', label: '图层资源', map: emptyRows },
|
||
{ key: 'models_3d', label: '3D 模型', map: emptyRows },
|
||
{ key: 'news_anchor_strategy', label: '新闻锚点策略', map: emptyRows },
|
||
],
|
||
actions: [makeAction('打开智能星球', <Globe2 size={15} />, '/earth')],
|
||
detailTitle: '智能星球配置详情',
|
||
},
|
||
collection: {
|
||
title: '采集管理',
|
||
description: '管理采集器、采集调度和采集历史 / 快照。',
|
||
listTitle: '采集配置列表',
|
||
listDescription: '先选择采集器、采集调度或采集历史 / 快照,再编辑当前对象。',
|
||
viewMode: 'management',
|
||
sections: [
|
||
{
|
||
key: 'collector_credentials',
|
||
label: '采集器',
|
||
map: (payload) => dataArray(payload),
|
||
endpoints: [
|
||
{ key: 'configsAll', label: '采集器配置', url: '/datasources/configs/all', map: (payload) => dataArray(payload).map((row) => ({
|
||
...row,
|
||
...(datasourceConfigCredentialKind(row) === 'oauth_client' ? {
|
||
client_id: text(objectAt(row, 'auth_config').client_id, ''),
|
||
client_secret: Boolean(objectAt(row, 'auth_configured').client_secret) ? '••••••••' : '',
|
||
} : datasourceConfigCredentialKind(row) === 'basic' ? {
|
||
username: text(objectAt(row, 'auth_config').username, ''),
|
||
password: Boolean(objectAt(row, 'auth_configured').password) ? '••••••••' : '',
|
||
} : {
|
||
api_key: Boolean(objectAt(row, 'auth_configured').api_key) ? '••••••••' : '',
|
||
api_key_name: text(objectAt(row, 'auth_config').key_name || objectAt(row, 'auth_config').param_name, 'X-API-Key'),
|
||
api_key_location: text(objectAt(row, 'auth_config').location || objectAt(row, 'auth_config').in, 'header'),
|
||
}),
|
||
auth_type: row.name === 'barentswatch_vessels' ? 'oauth_client' : row.name === 'spacetrack_tle' ? 'basic' : row.auth_type,
|
||
__module: '采集器配置',
|
||
__status: row.requires_credentials && datasourceConfigCredentialKind(row) === 'oauth_client' && (!objectAt(row, 'auth_config').client_id || !objectAt(row, 'auth_configured').client_secret)
|
||
? '未配置'
|
||
: row.requires_credentials && datasourceConfigCredentialKind(row) === 'basic' && (!objectAt(row, 'auth_config').username || !objectAt(row, 'auth_configured').password)
|
||
? '未配置'
|
||
: row.requires_credentials && datasourceConfigCredentialKind(row) === 'api_key' && !objectAt(row, 'auth_configured').api_key
|
||
? '未配置'
|
||
: row.is_active ? '已配置' : '未启用',
|
||
})) },
|
||
{ key: 'mappings', label: '映射模板', url: '/datasources/mappings', map: mappingRows },
|
||
{ key: 'targetSchemas', label: '目标 Schema', url: '/datasources/target-schemas', map: targetSchemaRows },
|
||
],
|
||
},
|
||
{ key: 'collectors', label: '采集调度', url: '/settings/collectors', map: (payload) => arrayAt(payload, 'collectors').map((row) => ({ ...row, __module: '采集调度', __status: pick(row, ['is_active'], '-') })) },
|
||
{ key: 'collection_history', label: '采集历史 / 快照', url: '/datasources/snapshots', params: { limit: 200 }, map: snapshotRows },
|
||
],
|
||
actions: [makeAction('数据源', <DatabaseZap size={15} />, '/datasources')],
|
||
detailTitle: '采集器详情',
|
||
},
|
||
logs: {
|
||
title: '日志',
|
||
description: '查看日志源、读取快照、复制原始输出,按控制台阅读方式组织。',
|
||
listTitle: '日志源',
|
||
listDescription: '先选择日志源,再读取对应日志快照。',
|
||
sections: [
|
||
{ key: 'sources', label: '日志源', url: '/system/logs/sources', map: (payload) => arrayAt(payload, 'items').map((row) => ({ ...row, __module: '日志源', __status: pick(row, ['available', 'enabled'], '可读取') })) },
|
||
],
|
||
actions: [makeAction('搜索', <Search size={15} />, '/logs')],
|
||
detailTitle: '日志详情',
|
||
},
|
||
settings: {
|
||
title: '设置',
|
||
description: '管理系统显示、通知策略、安全策略和 SMTP 邮件。',
|
||
listTitle: '设置分区',
|
||
listDescription: '仅展示系统设置分区;AI 集成和采集器调度分别在对应模块管理。',
|
||
viewMode: 'management',
|
||
sections: [
|
||
{ key: 'system', label: '系统显示', url: '/settings/system', map: (payload) => objectSection(payload, 'system', '系统显示') },
|
||
{ key: 'notifications', label: '通知策略', url: '/settings/notifications', map: (payload) => objectSection(payload, 'notifications', '通知策略') },
|
||
{ key: 'security', label: '安全策略', url: '/settings/security', map: (payload) => objectSection(payload, 'security', '安全策略') },
|
||
{ key: 'smtp', label: 'SMTP 邮件', url: '/settings/smtp', map: (payload) => objectSection(payload, 'smtp', 'SMTP 邮件') },
|
||
],
|
||
actions: [makeAction('AI 集成', <Bot size={15} />, '/ai'), makeAction('采集调度', <DatabaseZap size={15} />, '/collection-management')],
|
||
detailTitle: '设置详情',
|
||
},
|
||
} satisfies Record<string, ModuleConfig>
|
||
|
||
export function DataSources() {
|
||
return <ModuleConsole config={configs.datasources} />
|
||
}
|
||
|
||
export function BGP() {
|
||
return <ModuleConsole config={configs.bgp} />
|
||
}
|
||
|
||
export function SystemAlerts() {
|
||
return <ModuleConsole config={configs.systemAlerts} />
|
||
}
|
||
|
||
export function BGPAlerts() {
|
||
return <ModuleConsole config={configs.bgpAlerts} />
|
||
}
|
||
|
||
export function SituationalAlerts() {
|
||
return <ModuleConsole config={configs.situationalAlerts} />
|
||
}
|
||
|
||
export function AI() {
|
||
return <ModuleConsole config={configs.ai} />
|
||
}
|
||
|
||
export function EarthContent() {
|
||
return <ModuleConsole config={configs.earthContent} />
|
||
}
|
||
|
||
export function CollectionManagement() {
|
||
return <ModuleConsole config={configs.collection} />
|
||
}
|
||
|
||
export function Logs() {
|
||
return <ModuleConsole config={configs.logs} />
|
||
}
|
||
|
||
export function Settings() {
|
||
return <ModuleConsole config={configs.settings} />
|
||
}
|