Files
planet/frontend/src/admin/pages/PlainResourcePages.tsx
rayd1o eb4c4b7904
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.66.2
2026-05-26 08:45:33 +08:00

5949 lines
275 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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} />
}