Files
planet/frontend/src/admin/pages/PlainResourcePages.tsx
linkong d30f7d08c5
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.74.1
2026-06-30 18:54:34 +08:00

7648 lines
355 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,
Trash2,
X,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type DragEvent as ReactDragEvent, 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 { describeApiError, localizeApiMessage } from '../../i18n/api-errors'
import { legacyUiTextEnUS } from '../../i18n/legacy-ui'
import { useLocale, type SupportedLocale } from '../../i18n/locale'
import { AdminLayout } from '../components/layout/AdminLayout'
import { DataTable } from '../components/data-table/DataTable'
import { Button, type ButtonProps } 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
}
type DatasourceMetricBaseline = {
taskId: string
sourceId: string
source: string
taskType: string
count: number
}
type BrandAssetTargetKey = 'logo_src' | 'title_src'
const DEFAULT_DATASOURCE_FILTERS: DatasourceFilters = {
product: '',
module: '',
isActive: 'true',
runStatus: '',
dataStatus: '',
}
const DATASOURCE_FILTER_STORAGE_KEY = 'planet.admin.datasource.filters'
const DATASOURCE_FILTER_QUERY_KEYS = ['product', 'module', 'is_active', 'run_status', 'data_status']
const DATASOURCE_TERMINAL_STATUSES = new Set(['success', 'completed', 'failed', 'cancelled', 'canceled', 'stopped'])
const COLLECTION_QUEUE_ACTIVE_STATUSES = new Set<CollectionQueueStatus>(['queued', 'running', 'cancelling'])
const TASK_ACTIVE_STATUSES = new Set(['queued', 'pending', 'running', 'cancelling'])
const TASK_INACTIVE_STATUSES = new Set(['success', 'completed', 'failed', 'error', 'cancelled', 'canceled', 'stopped', 'idle'])
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.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
}
const legacyUiTextZhCN = Object.fromEntries(
Object.entries(legacyUiTextEnUS).map(([source, target]) => [target, source]),
)
const editableValueTextEnUS: Record<string, string> = {
'智能星球': 'Intelligent Planet',
'智能星球计划': 'Intelligent Planet Program',
'现实层宇宙全息感知系统': 'Reality Layer Situational Awareness System',
'卫星 · 海底光缆 · 算力基础设施': 'Satellites · Subsea Cables · Compute Infrastructure',
'智能星球计划品牌标识': 'Intelligent Planet Program brand banner',
'/earth/assets/brand/title-zh.png': '/earth/assets/brand/title-en.png',
}
const editableValueTextZhCN = Object.fromEntries(
Object.entries(editableValueTextEnUS).map(([source, target]) => [target, source]),
)
function localizeAdminText(value: string, locale: SupportedLocale) {
const trimmed = value.trim()
if (!trimmed) return value
const dictionary = locale === 'en-US' ? legacyUiTextEnUS : legacyUiTextZhCN
const translated = dictionary[trimmed]
if (translated) return value.replace(trimmed, translated)
if (trimmed.length > 240) return value
const entries = Object.entries(dictionary)
.filter(([source]) => source && trimmed.includes(source))
.sort(([left], [right]) => right.length - left.length)
if (!entries.length) return value
return entries.reduce((next, [source, target]) => next.split(source).join(target), value)
}
function localizeEditableValue(_key: string, value: unknown, locale: SupportedLocale) {
if (typeof value !== 'string') return value
const dictionary = locale === 'en-US' ? editableValueTextEnUS : editableValueTextZhCN
return dictionary[value] || localizeAdminText(value, locale)
}
function localizeDisplayValue(value: unknown, locale: SupportedLocale) {
if (value === null || value === undefined) return value
return localizeAdminText(text(localizeEditableValue('', value, locale), ''), locale)
}
function localizeSearchParts(parts: unknown[], locale: SupportedLocale) {
return parts
.map((part) => text(localizeDisplayValue(part, locale), ''))
.filter(Boolean)
.join(' ')
}
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 formatCountZh(value: number) {
if (!Number.isFinite(value)) return '-'
const count = Math.max(0, Math.round(value))
if (count >= 100000000) return `${(count / 100000000).toFixed(count >= 1000000000 ? 1 : 2).replace(/\.0+$/, '')} 亿条`
if (count >= 10000) return `${(count / 10000).toFixed(count >= 100000 ? 1 : 2).replace(/\.0+$/, '')} 万条`
return `${count.toLocaleString('zh-CN')}`
}
function datasourceRecordCount(record: AnyRecord) {
const candidates = [record.__metric_count, record.collected_records, record.record_count, record.records, record.count, record.total]
for (const value of candidates) {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value)
}
return null
}
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 (/(取消|停止|已停止|cancelled|canceled|stopped)/.test(lower)) return 'neutral'
if (/(未配置|未启用|停用|禁用|disabled|false|missing|empty|none|可选|optional|-)/.test(lower)) return 'neutral'
if (/(运行中|采集中|同步中|加载中|排队中|pending|queued|loading|sync|collect|live|stream|删除中|清缓存中|刷新中|任务中|cancelling)/.test(lower)) return 'running'
if (/(成功|完成|已完成|已配置|configured|running|active|enabled|success|completed|done|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'
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.__metric === 'string' && record.__metric) return record.__metric
if (typeof record.__metric_count === 'number') return formatCountZh(record.__metric_count)
if (typeof record.collected_records === 'number') return formatCountZh(record.collected_records)
if (typeof record.records_processed === 'number' && typeof record.total_records === 'number') {
return `${formatCountZh(record.records_processed)} / ${formatCountZh(record.total_records)}`
}
if (typeof record.records_processed === 'number') return formatCountZh(record.records_processed)
const count = datasourceRecordCount(record)
if (count !== null) return formatCountZh(count)
return pick(record, ['record_count', 'count', 'total', 'value', 'records'], '-')
}
function activeDatasourceTaskType(record: AnyRecord) {
return text(record.task_type, 'collect')
}
function activeDatasourceTaskStatus(record: AnyRecord) {
const candidates = [record.task_status, record.phase, record.status]
.map((value) => text(value, '').toLowerCase())
.filter(Boolean)
return candidates.find((status) => TASK_INACTIVE_STATUSES.has(status))
|| candidates.find((status) => TASK_ACTIVE_STATUSES.has(status))
|| text(record.last_status, '').toLowerCase()
|| candidates[0]
|| ''
}
function hasActiveDatasourceTask(record: AnyRecord) {
const status = activeDatasourceTaskStatus(record)
if (TASK_INACTIVE_STATUSES.has(status)) return false
return record.is_task_active === true || TASK_ACTIVE_STATUSES.has(status)
}
function isCollectTaskActive(record: AnyRecord) {
return hasActiveDatasourceTask(record) && activeDatasourceTaskType(record) === 'collect'
}
function datasourceStatus(record: AnyRecord) {
if (isCollectTaskActive(record)) return 'running'
const status = [record.task_status, record.phase, record.status, record.last_status]
.map((value) => text(value, '').toLowerCase())
.find((value) => value && TASK_INACTIVE_STATUSES.has(value))
if (status) return status
return text(record.last_status || record.status, record.is_active === false ? 'disabled' : 'idle')
}
function taskTerminalDisplayStatus(taskType: string, status: string) {
const type = text(taskType, 'collect')
const lower = text(status, '').toLowerCase()
const noun = taskTypeLabel(type)
if (lower === 'success' || lower === 'completed') return `${noun}成功`
if (lower === 'failed' || lower === 'error') return `${noun}失败`
if (lower === 'cancelled' || lower === 'canceled') return `${noun}已取消`
if (lower === 'stopped') return `${noun}已停止`
return ''
}
function datasourceDisplayStatus(record: AnyRecord) {
const taskType = activeDatasourceTaskType(record)
const status = activeDatasourceTaskStatus(record) || text(datasourceStatus(record), '').toLowerCase()
if (status === 'queued' || status === 'pending') return `${taskTypeLabel(taskType)}排队中`
if (status === 'running' || status === 'collecting') return `${taskTypeLabel(taskType)}`
if (status === 'cancelling') return `停止${taskTypeLabel(taskType)}`
if (!hasActiveDatasourceTask(record)) return datasourceStatus(record)
if (taskType === 'clear_data') return '删除中'
if (taskType === 'clear_cache') return '清缓存中'
if (taskType === 'earth_refresh') return '刷新中'
if (taskType === 'collect') return '采集中'
return `${taskTypeLabel(taskType)}`
}
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' || status === 'stopped') 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 taskType = text(item.task_type || item.taskType, 'collect')
const taskId = text(item.task_id || item.taskId, '')
if (taskId) return `task:${taskType}:${taskId}`
const sourceId = text(item.datasource_id || item.source_id || item.id || item.sourceId, '')
if (sourceId) return `source:${taskType}:${sourceId}`
const source = text(item.collector_name || item.source, '')
return source ? `source-name:${taskType}:${source}` : `queue:${taskType}:${Date.now()}`
}
function isSameQueueTaskType(left?: string, right?: string) {
return text(left, 'collect') === text(right, 'collect')
}
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 | string, taskType?: string) {
const noun = taskTypeLabel(taskType)
if (status === 'queued') return `${noun}排队中`
if (status === 'running') return `${noun}`
if (status === 'cancelling') return `停止${noun}`
const labels: Record<string, string> = {
queued: `${noun}排队中`,
running: `${noun}`,
cancelling: `停止${noun}`,
success: `${noun}成功`,
completed: `${noun}成功`,
failed: `${noun}失败`,
error: `${noun}失败`,
skipped: '跳过',
cancelled: `${noun}已取消`,
canceled: `${noun}已取消`,
stopped: `${noun}已停止`,
idle: '空闲',
}
return labels[status] || semanticLabel(status)
}
function queuePrimaryMessage(item: CollectionQueueItem) {
const type = text(item.taskType, 'collect')
if (item.status === 'queued') return queueStatusLabel('queued', type)
if (item.status === 'running') {
if (type === 'clear_data') return '正在删除数据'
if (type === 'clear_cache') return '正在清理缓存'
if (type === 'earth_refresh') return '正在刷新图层'
return '正在采集'
}
if (item.status === 'cancelling') {
if (type === 'clear_data') return '正在取消删除'
if (type === 'collect') return '正在停止采集'
return '正在取消任务'
}
if (item.status === 'success') {
if (type === 'clear_data') return '删除完成'
if (type === 'clear_cache') return '清缓存完成'
if (type === 'earth_refresh') return '刷新完成'
return '采集完成'
}
if (item.status === 'failed') {
if (type === 'clear_data') return '删除失败'
if (type === 'clear_cache') return '清缓存失败'
if (type === 'earth_refresh') return '刷新失败'
return '采集失败'
}
if (item.status === 'cancelled') {
if (type === 'clear_data') return '删除已取消'
if (type === 'collect') return '采集已取消'
return '任务已取消'
}
if (item.status === 'skipped') return item.reason ? queueReasonLabel(item.reason) : '已跳过'
return queueStatusLabel(item.status, type)
}
function snapshotStatus(record: AnyRecord) {
const status = text(record.status, '').toLowerCase()
if (status === 'running' && text(record.completed_at || record.completedAt, '')) return 'success'
if (status) return status
if (record.is_current === true) return '当前'
return '-'
}
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) {
const displayStatus = datasourceDisplayStatus(row)
const taskStatus = text(row.task_status || row.phase || row.status || row.last_status, '')
const terminalStatus = taskTerminalDisplayStatus(activeDatasourceTaskType(row), taskStatus)
return {
...row,
__module: pick(row, ['module', 'source'], '数据源'),
__status: terminalStatus || displayStatus,
__metric: datasourceMetric(row),
__time: pick(row, ['last_run_at', 'last_run'], '-'),
}
}
function recordDisplayStatus(record: AnyRecord) {
const endpointKey = text(record.__endpointKey, '')
if (endpointKey === 'builtin' || record.is_task_active !== undefined || record.task_type || record.task_status) {
const taskStatus = text(record.task_status || record.phase || record.status || record.last_status, '')
return taskTerminalDisplayStatus(activeDatasourceTaskType(record), taskStatus) || datasourceDisplayStatus(record)
}
return semanticLabel(recordStatus(record))
}
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 { locale } = useLocale()
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, locale)}
</label>
)
}
if (typeof value === 'number') {
return (
<label key={key} className="an-field" htmlFor={inputId}>
{fieldLabelElement(key, locale)}
<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) {
const displayValue = localizeEditableValue(key, text(value, ''), locale)
return (
<label key={key} className="an-field" htmlFor={inputId}>
{fieldLabelElement(key, locale)}
<input
id={inputId}
className="an-input"
value={text(displayValue, '')}
onChange={(event) => onDraftChange(setDraftField(draft, key, event.target.value))}
/>
</label>
)
}
return (
<label key={key} className="an-field an-field--wide" htmlFor={inputId}>
{fieldLabelElement(key, locale)}
<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 { locale } = useLocale()
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, locale)}</dt>
<dd>{localizeDisplayValue(semanticLabel(value), locale)}</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, locale: SupportedLocale): Array<ColumnDef<TableRecord>> {
return [
{
id: 'name',
header: localizeAdminText('名称', locale),
size: 260,
cell: ({ row }) => (
<button type="button" className="an-table-link" onClick={() => onSelect(row.original)}>
{localizeDisplayValue(recordTitle(row.original), locale)}
</button>
),
},
{ id: 'module', header: localizeAdminText('模块', locale), size: 150, cell: ({ row }) => localizeDisplayValue(row.original.__module, locale) },
{
id: 'status',
header: localizeAdminText('状态', locale),
size: 130,
cell: ({ row }) => {
const status = recordDisplayStatus(row.original)
return <StatusText tone={statusTone(status)}>{localizeDisplayValue(status, locale)}</StatusText>
},
},
{ id: 'metric', header: localizeAdminText('指标', locale), size: 220, cell: ({ row }) => <span className="an-muted-text">{localizeDisplayValue(semanticLabel(recordMetric(row.original)), locale)}</span> },
{ id: 'updated', header: localizeAdminText('更新时间', locale), size: 180, cell: ({ row }) => localizeDisplayValue(row.original.__time, locale) },
]
}
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 { locale } = useLocale()
const tableColumns = useMemo(() => columns || defaultColumns(onSelect, locale), [columns, locale, onSelect])
return (
<DataTable
className="an-resource-table"
columns={tableColumns}
data={rows}
emptyText={localizeAdminText('当前模块暂无数据', locale)}
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
}) {
const { locale } = useLocale()
return (
<div className="an-section-tabs" role="tablist" aria-label={localizeAdminText('模块分区', locale)}>
{sections.map((section) => {
const state = states.find((item) => item.section.key === section.key)
const active = activeKey === section.key
const label = localizeAdminText(section.label, locale)
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={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>{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'
const NEWS_FETCHABLE_SOURCE_TYPES = new Set(['rss', 'atom', 'aggregated'])
const NEWS_SOURCE_TYPE_OPTIONS = [
{ value: 'rss', label: 'RSS' },
{ value: 'atom', label: 'Atom' },
{ value: 'aggregated', label: 'Aggregated' },
{ value: 'reference', label: '参考链接' },
]
const NEWS_REGION_OPTIONS = [
{ value: 'global', label: '全球' },
{ value: 'china', label: '中国' },
{ value: 'us', label: '美国' },
{ value: 'americas', label: '美洲' },
{ value: 'europe', label: '欧洲' },
{ value: 'asia-pacific', label: '亚太' },
{ value: 'middle-east-africa', label: '中东与非洲' },
]
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
renderInput?: (props: {
disabled?: boolean
displayValue: unknown
fieldKey: string
onChange: (value: unknown) => void
placeholder?: string
value: unknown
}) => ReactNode
inputAction?: {
ariaLabel?: string
disabled?: boolean
icon?: ButtonProps['icon']
loading?: boolean
onClick: () => void
title: string
}
wide?: boolean
secretVisible?: boolean
onToggleSecret?: (visible: boolean) => void
}
function ConnectionTestInput({
action,
children,
}: {
action: NonNullable<FieldConfig['inputAction']>
children: ReactNode
}) {
return (
<div className="an-connection-test-input">
{children}
<Button
size="icon"
variant="subtle"
icon={action.icon || 'connect'}
title={action.title}
aria-label={action.ariaLabel || action.title}
disabled={action.disabled}
loading={action.loading}
onClick={action.onClick}
/>
</div>
)
}
const fieldLabels: Record<string, string> = {
key: '键',
label: '标签',
title: '标题',
kicker: '眉标',
version: '版本',
default_source_id: '默认频道',
auto_fallback: '自动回退',
id: '标识',
name: '名称',
provider: '提供方',
default_provider: '默认提供方',
region: '地区',
language: '语言',
source_type: '播放类型',
sourceType: '来源类型',
embed_url: '嵌入地址',
stream_url: '播放流地址',
homepage_url: '主页地址',
feed_directory_url: 'Feed 信息页',
feed_url: 'Feed 地址',
source_tags_text: '源属性标签',
default_category: '默认类型',
importance_weight: '重要度权重',
fetch_interval_minutes: '抓取间隔(分钟)',
timeout_seconds: '超时(秒)',
failure_threshold: '失败阈值',
cooldown_minutes: '熔断冷却(分钟)',
circuit_breaker: '熔断开关',
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: '来源',
feed_name: 'Feed 名称',
published_at: '发布时间',
category: '新闻类型',
tags_text: '标签',
summary: '摘要',
content: '正文',
latitude: '纬度',
longitude: '经度',
location_label: '位置标签',
enrichment_status: '处理状态',
translated: '已翻译',
verified: '已定位',
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 版本',
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, locale?: SupportedLocale) {
const label = fieldLabels[key] || key.replace(/_/g, ' ')
return locale ? localizeAdminText(label, locale) : label
}
function fieldLabelElement(key: string, locale?: SupportedLocale) {
const help = fieldHelp[key]
if (!help) return fieldLabel(key, locale)
return (
<span className="an-field-label-help" title={locale ? localizeAdminText(help, locale) : help}>
<span>{fieldLabel(key, locale)}</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: '成功',
completed: '完成',
failed: '失败',
error: '失败',
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 hasDatasourceFilterSearch(search: string) {
const params = new URLSearchParams(search)
return DATASOURCE_FILTER_QUERY_KEYS.some((key) => params.has(key))
}
function normalizeDatasourceFilters(value: Partial<DatasourceFilters> | null | undefined): DatasourceFilters {
return {
product: text(value?.product, DEFAULT_DATASOURCE_FILTERS.product),
module: text(value?.module, DEFAULT_DATASOURCE_FILTERS.module),
isActive: ['true', 'false', ''].includes(text(value?.isActive, '')) ? text(value?.isActive, DEFAULT_DATASOURCE_FILTERS.isActive) : DEFAULT_DATASOURCE_FILTERS.isActive,
runStatus: text(value?.runStatus, DEFAULT_DATASOURCE_FILTERS.runStatus),
dataStatus: text(value?.dataStatus, DEFAULT_DATASOURCE_FILTERS.dataStatus),
}
}
function loadStoredDatasourceFilters(): DatasourceFilters {
if (typeof window === 'undefined') return DEFAULT_DATASOURCE_FILTERS
try {
const raw = window.localStorage.getItem(DATASOURCE_FILTER_STORAGE_KEY)
if (!raw) return DEFAULT_DATASOURCE_FILTERS
return normalizeDatasourceFilters(JSON.parse(raw) as Partial<DatasourceFilters>)
} catch {
return DEFAULT_DATASOURCE_FILTERS
}
}
function storeDatasourceFilters(filters: DatasourceFilters) {
if (typeof window === 'undefined') return
window.localStorage.setItem(DATASOURCE_FILTER_STORAGE_KEY, JSON.stringify(filters))
}
function initialDatasourceFilters(search: string): DatasourceFilters {
return hasDatasourceFilterSearch(search) ? datasourceFiltersFromSearch(search) : loadStoredDatasourceFilters()
}
function datasourceFiltersEqual(left: DatasourceFilters, right: DatasourceFilters) {
return left.product === right.product &&
left.module === right.module &&
left.isActive === right.isActive &&
left.runStatus === right.runStatus &&
left.dataStatus === right.dataStatus
}
function datasourceFiltersSearch(filters: DatasourceFilters) {
const params = new URLSearchParams()
params.set('product', filters.product)
params.set('module', filters.module)
params.set('is_active', filters.isActive)
params.set('run_status', filters.runStatus)
params.set('data_status', filters.dataStatus)
return `?${params.toString()}`
}
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: snapshotStatus(row),
__metric: typeof row.record_count === 'number' ? formatCountZh(row.record_count) : 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: snapshotStatus(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(snapshotStatus(record))
const count = typeof record.record_count === 'number' ? formatCountZh(record.record_count) : pick(record, ['record_count'], '0 条')
return `${current}${formatSnapshotTime(record)} · ${status} · ${count}`
}
const emptyRows = () => []
const placeholderSectionKeys = new Set(['basemap', 'layer_resources', 'models_3d'])
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 newsSourcesPayload(raw: unknown): AnyRecord {
if (!isObjectRecord(raw)) return { sources: [], source_tags: [], categories: [], item_tag_rules: [], health: {} }
return {
...raw,
sources: Array.isArray(raw.sources) ? raw.sources.filter(isObjectRecord) : [],
source_tags: Array.isArray(raw.source_tags) ? raw.source_tags.filter(isObjectRecord) : [],
categories: Array.isArray(raw.categories) ? raw.categories.filter(isObjectRecord) : [],
item_tag_rules: Array.isArray(raw.item_tag_rules) ? raw.item_tag_rules.filter(isObjectRecord) : [],
health: isObjectRecord(raw.health) ? raw.health : {},
}
}
function newsSourceType(source: AnyRecord) {
return text(source.source_type, 'rss').toLowerCase()
}
function newsSourceStatusLabel(source: AnyRecord) {
if (source.__isDraft) return '新建'
const type = newsSourceType(source)
if (type === 'reference') return '参考'
if (source.enabled === false) return '停用'
return '启用'
}
function newsSourceHealthLabel(health: AnyRecord) {
const status = text(health.status, '')
if (!status) return '未测试'
if (status === 'ok') return '连通正常'
if (status === 'empty') return '无条目'
if (status === 'format_error') return '格式错误'
if (status === 'http_error') return 'HTTP 失败'
if (status === 'timeout') return '超时'
if (status === 'reference') return '参考链接'
return '连接失败'
}
function newsSourceHealthDescription(health: AnyRecord) {
if (!isObjectRecord(health) || !text(health.status, '')) return '尚未测试当前源。'
const count = Number(health.item_count ?? health.count ?? 0)
const statusCode = health.status_code ? `HTTP ${health.status_code}` : ''
const latency = health.latency_ms ? `${health.latency_ms}ms` : ''
const details = [newsSourceHealthLabel(health), statusCode, latency, Number.isFinite(count) ? `${count}` : ''].filter(Boolean).join(' · ')
return text(health.error, '') ? `${details}${text(health.error, '')}` : details
}
function newsSourceToEditor(source: AnyRecord, health: AnyRecord = {}) {
const policy = isObjectRecord(source.health_policy) ? source.health_policy : {}
const feedUrls = Array.isArray(source.feed_urls)
? source.feed_urls.map((url) => String(url).trim()).filter(Boolean)
: text(source.feed_url, '').split(/[\n,]/).map((url) => url.trim()).filter(Boolean)
const rawFeeds = Array.isArray(source.feeds) ? source.feeds.filter(isObjectRecord) : []
const feeds = rawFeeds.length
? rawFeeds.map((feed, index) => ({
id: text(feed.id, `feed-${index + 1}`),
name: text(feed.name, `Feed ${index + 1}`),
url: text(feed.url || feed.feed_url, ''),
type: text(feed.type || feed.source_type, newsSourceType(source)),
enabled: feed.enabled !== false,
default_category: text(feed.default_category, text(source.default_category, 'business')),
tags_text: Array.isArray(feed.tags) ? feed.tags.map((tag) => String(tag)).join(', ') : text(feed.tags, ''),
priority: Number(feed.priority ?? index + 1),
}))
: feedUrls.map((url, index) => ({
id: `feed-${index + 1}`,
name: feedUrls.length === 1 ? text(source.name, `Feed ${index + 1}`) : `Feed ${index + 1}`,
url,
type: newsSourceType(source),
enabled: source.enabled !== false,
default_category: text(source.default_category, 'business'),
tags_text: '',
priority: index + 1,
}))
return {
...source,
region: text(source.region, 'global'),
source_type: newsSourceType(source),
enabled: source.enabled !== false,
feed_urls_text: feedUrls.length ? feedUrls.join('\n') : text(source.feed_url, ''),
feeds,
default_category: text(source.default_category, 'business'),
source_tags_text: Array.isArray(source.source_tags) ? source.source_tags.map((tag) => String(tag)).join(', ') : '',
importance_weight: Number(source.importance_weight ?? 0),
fetch_interval_minutes: Number(policy.fetch_interval_minutes ?? source.fetch_interval_minutes ?? 45),
timeout_seconds: Number(policy.timeout_seconds ?? source.timeout_seconds ?? 12),
failure_threshold: Number(policy.failure_threshold ?? source.failure_threshold ?? 3),
cooldown_minutes: Number(policy.cooldown_minutes ?? source.cooldown_minutes ?? 30),
circuit_breaker: Boolean(policy.circuit_breaker ?? source.circuit_breaker ?? true),
__module: '新闻源',
__status: newsSourceStatusLabel(source),
__health: health,
__healthLabel: newsSourceHealthLabel(health),
__title: pick(source, ['name', 'id'], '新闻源'),
}
}
function newsSourceFromEditor(record: AnyRecord) {
const next = cleanRecord(record)
const rawFeeds = Array.isArray(next.feeds) ? next.feeds.filter(isObjectRecord) : []
const feeds = rawFeeds
.map((feed, index) => {
const url = text(feed.url || feed.feed_url, '').trim()
if (!url) return null
return {
id: text(feed.id, `feed-${index + 1}`),
name: text(feed.name, `Feed ${index + 1}`),
url,
type: text(feed.type || feed.source_type, text(next.source_type, 'rss')),
enabled: feed.enabled !== false,
default_category: text(feed.default_category, text(next.default_category, 'business')),
tags: text(feed.tags_text || feed.tags, '').split(/[,\n]/).map((item) => item.trim()).filter(Boolean),
priority: Number(feed.priority || index + 1),
}
})
.filter(isObjectRecord) as AnyRecord[]
const tags = text(next.source_tags_text, '')
.split(/[,\n]/)
.map((item) => item.trim())
.filter(Boolean)
next.source_tags = tags
next.feeds = feeds
const nextFeedUrls = feeds.map((feed) => text(feed.url, '')).filter(Boolean)
next.feed_urls = nextFeedUrls
next.feed_url = nextFeedUrls[0] || text(next.feed_url, '')
next.health_policy = {
fetch_interval_minutes: Number(next.fetch_interval_minutes || 45),
timeout_seconds: Number(next.timeout_seconds || 12),
failure_threshold: Number(next.failure_threshold || 3),
cooldown_minutes: Number(next.cooldown_minutes || 30),
circuit_breaker: Boolean(next.circuit_breaker),
}
delete next.source_tags_text
delete next.feed_urls_text
delete next.fetch_interval_minutes
delete next.timeout_seconds
delete next.failure_threshold
delete next.cooldown_minutes
delete next.circuit_breaker
delete next.__isDraft
return next
}
function newsSourceGroup(source: AnyRecord, healthPayload: AnyRecord = {}): HierarchyGroup {
const maybeHealth = healthPayload[text(source.id, '')]
const health: AnyRecord = isObjectRecord(maybeHealth) ? maybeHealth : {}
const editor: AnyRecord = newsSourceToEditor(source, health)
return {
key: `news-source:${text(editor.id, text(editor.name, 'unnamed'))}`,
label: pick(editor, ['name', 'id'], '新闻源'),
description: [text(editor.source_type, '').toUpperCase(), text(editor.region, ''), text(editor.default_category, ''), text(editor.__healthLabel, '')].filter(Boolean).join(' · '),
status: newsSourceStatusLabel(editor),
count: Array.isArray(editor.source_tags) ? editor.source_tags.length : text(editor.source_tags_text, '').split(/[,\n]/).filter(Boolean).length,
record: editor,
}
}
function makeNewNewsSourceGroup(index: number): HierarchyGroup {
return newsSourceGroup({
id: `custom-news-${Date.now()}`,
name: `新增新闻源 ${index}`,
region: 'global',
feed_url: '',
homepage_url: '',
feed_directory_url: '',
source_type: 'rss',
enabled: true,
default_category: 'business',
source_tags: ['business_news'],
importance_weight: 0,
health_policy: {
fetch_interval_minutes: 45,
timeout_seconds: 12,
failure_threshold: 3,
cooldown_minutes: 30,
circuit_breaker: true,
},
__isDraft: true,
})
}
function newsCategoryOptions(payload: AnyRecord) {
const categories = arrayAt(payload, 'categories')
const options = categories
.map((category) => ({ value: text(category.key || category.id, ''), label: text(category.label || category.name || category.key, '') }))
.filter((option) => option.value && option.label)
return options.length ? options : [
{ value: 'politics', label: '政治' },
{ value: 'business', label: '商业' },
{ value: 'ecommerce', label: '电商' },
{ value: 'finance', label: '金融' },
{ value: 'technology', label: '科技' },
{ value: 'other', label: '其他' },
]
}
function newsTagOptions(payload: AnyRecord) {
return arrayAt(payload, 'source_tags')
.map((tag) => ({ value: text(tag.key || tag.id, ''), label: text(tag.label || tag.name || tag.key, '') }))
.filter((option) => option.value && option.label)
}
function newsSourceValidationError(source: AnyRecord, existingSources: AnyRecord[], oldId = '') {
const id = text(source.id, '').trim()
const name = text(source.name, '').trim()
const type = newsSourceType(source)
if (!id) return '新闻源 ID 不能为空。'
if (!name) return '新闻源名称不能为空。'
if (existingSources.some((item) => text(item.id, '') === id && text(item.id, '') !== oldId)) return `新闻源 ID "${id}" 已存在。`
if (source.enabled !== false && !NEWS_FETCHABLE_SOURCE_TYPES.has(type)) return '参考链接不能启用抓取,请改为 RSS/Atom/Aggregated 或关闭启用。'
const feedUrls = Array.isArray(source.feed_urls) ? source.feed_urls.filter(Boolean) : []
const feeds = Array.isArray(source.feeds) ? source.feeds.filter(isObjectRecord).filter((feed) => text(feed.url || feed.feed_url, '').trim()) : []
if (NEWS_FETCHABLE_SOURCE_TYPES.has(type) && !feeds.length && !feedUrls.length && !text(source.feed_url || source.url, '').trim()) return 'RSS/Atom/Aggregated 新闻源需要至少一个 Feed 子项。'
return ''
}
function newsItemSourceType(item: AnyRecord) {
return text(item.source_type || item.feed_type, 'rss').toLowerCase()
}
function newsItemToEditor(item: AnyRecord): AnyRecord {
const tags = Array.isArray(item.item_tags)
? item.item_tags.map((tag) => String(tag)).filter(Boolean)
: Array.isArray(item.tags)
? item.tags.map((tag) => String(tag)).filter(Boolean)
: []
const sourceType = newsItemSourceType(item)
const editable = Boolean(item.editable || sourceType === 'manual' || text(item.id, '').startsWith('manual:'))
return {
...item,
title: text(item.title || item.display_title, ''),
summary: text(item.summary || item.display_summary, ''),
content: text(item.manual_content || item.content, ''),
source: text(item.source, editable ? '手动添加' : ''),
url: text(item.url, ''),
region: text(item.region, 'global'),
published_at: text(item.published_at, ''),
category: text(item.category, 'other'),
tags_text: tags.join(', '),
latitude: item.latitude ?? '',
longitude: item.longitude ?? '',
location_label: text(item.location_label, ''),
source_type: sourceType,
editable,
__module: sourceType === 'manual' ? '手动新闻' : '新闻条目',
__status: text(item.status || item.enrichment_status, editable ? 'pending' : ''),
__title: pick(item, ['title', 'display_title', 'id'], '新闻条目'),
}
}
function newsItemFromEditor(record: AnyRecord) {
const next = cleanRecord(record)
const latitudeText = text(next.latitude, '').trim()
const longitudeText = text(next.longitude, '').trim()
const location = latitudeText && longitudeText ? {
label: text(next.location_label, ''),
latitude: Number(latitudeText),
longitude: Number(longitudeText),
} : undefined
return {
title: text(next.title, '').trim(),
summary: text(next.summary, '').trim(),
content: text(next.content, '').trim(),
url: text(next.url, '').trim(),
source: text(next.source, '').trim(),
region: text(next.region, 'global'),
published_at: text(next.published_at, '').trim() || undefined,
category: text(next.category, 'other'),
tags: text(next.tags_text, '').split(/[,\n]/).map((item) => item.trim()).filter(Boolean),
location,
}
}
function newsItemValidationError(payload: AnyRecord) {
if (!text(payload.title, '').trim()) return '新闻标题不能为空。'
if (payload.location) {
const location = payload.location as AnyRecord
const latitude = Number(location.latitude)
const longitude = Number(location.longitude)
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return '坐标必须是数字。'
if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return '坐标超出范围。'
}
return ''
}
function newsContentGroup(group: AnyRecord): HierarchyGroup {
const groupType = text(group.group_type, 'rss')
const sourceType = text(group.source_type, groupType)
return {
key: `news-group:${text(group.id, text(group.name, 'unnamed'))}`,
label: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
description: [
groupType === 'manual' ? '手动新闻组' : 'RSS 来源',
sourceType.toUpperCase(),
text(group.region, ''),
].filter(Boolean).join(' · '),
status: group.editable === false ? '只读' : '可编辑',
count: Number(group.count || arrayAt(group, 'items').length || 0),
record: {
...group,
__module: groupType === 'manual' ? '手动新闻组' : 'RSS 来源',
__status: group.editable === false ? '只读' : '可编辑',
__title: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
},
}
}
function updateNewsFeedDraft(
draft: string,
fallback: AnyRecord,
index: number,
key: string,
value: unknown,
) {
const current = draftRecord(draft, fallback)
const feeds = Array.isArray(current.feeds) ? current.feeds.filter(isObjectRecord).map((feed) => ({ ...feed })) : []
feeds[index] = { ...(feeds[index] || {}), [key]: value }
return JSON.stringify({ ...current, feeds }, null, 2)
}
function removeNewsFeedDraft(draft: string, fallback: AnyRecord, index: number) {
const current = draftRecord(draft, fallback)
const feeds = Array.isArray(current.feeds) ? current.feeds.filter(isObjectRecord).filter((_, feedIndex) => feedIndex !== index) : []
return JSON.stringify({ ...current, feeds }, null, 2)
}
function addNewsFeedDraft(draft: string, fallback: AnyRecord) {
const current = draftRecord(draft, fallback)
const feeds = Array.isArray(current.feeds) ? current.feeds.filter(isObjectRecord) : []
const index = feeds.length + 1
return JSON.stringify({
...current,
feeds: [
...feeds,
{
id: `feed-${index}`,
name: `Feed ${index}`,
url: '',
type: text(current.source_type, 'rss'),
enabled: true,
default_category: text(current.default_category, 'business'),
tags_text: '',
priority: index,
},
],
}, null, 2)
}
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 { locale } = useLocale()
const logoSrc = text(record.logo_src, '/earth/assets/brand/earth-logo.png')
const titleSrc = text(localizeEditableValue('title_src', text(record.title_src, ''), locale), '')
const titleText = text(localizeEditableValue('title_text', text(record.title_text, '智能星球计划'), locale), '智能星球计划')
const titleAlt = text(record.title_alt, titleText)
const localizedTitleAlt = text(localizeEditableValue('title_alt', titleAlt, locale), titleText)
const subtitle = text(localizeEditableValue('subtitle', text(record.subtitle, '现实层宇宙全息感知系统'), locale), '')
const description = text(localizeEditableValue('description', text(record.description, '卫星 · 海底光缆 · 算力基础设施'), locale), '')
const ariaLabel = text(localizeEditableValue('aria_label', text(record.aria_label, '智能星球计划品牌标识'), locale), titleText)
const brandVariant = locale === 'en-US' ? 'en' : 'zh'
return (
<div className="an-earth-brand-preview" aria-label={localizeAdminText('Earth 左上角品牌实际渲染预览', locale)}>
<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--${brandVariant}`} 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={localizedTitleAlt} />
) : (
<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 = '接口请求失败') {
return describeApiError(error, 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: localizeApiMessage('', '当前配置可以连通。') }
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: localizeApiMessage(detail, '当前配置连通性检查失败。') }
}
if (explicit === true) {
return { ok: true, message: localizeApiMessage(detail, '当前配置可以连通。') }
}
return { ok: !hasFailureText, message: localizeApiMessage(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 { locale } = useLocale()
const current = draftRecord(draft, record)
return (
<div className="an-field-grid">
{fields.map((field) => {
const value = current[field.key]
const displayValue = localizeEditableValue(field.key, value, locale)
const label = localizeAdminText(field.label, locale)
const help = field.help ? localizeAdminText(field.help, locale) : ''
const placeholder = field.placeholder ? localizeAdminText(field.placeholder, locale) : undefined
const className = field.wide ? 'an-field an-field--wide' : 'an-field'
const searchTarget = searchGroupKey ? `${searchGroupKey}:field:${field.key}` : `field:${field.key}`
const searchText = [label, field.key, text(displayValue, '')].filter(Boolean).join(' ')
const commitFieldValue = (nextValue: unknown) => {
onDraftChange(setNestedDraftField(draft, record, field.key, nextValue))
}
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) => commitFieldValue(event.target.checked)}
/>
{label}
{help ? <small>{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>{label}</span>
<select
className="an-input"
value={text(value, '')}
disabled={field.disabled}
onChange={(event) => commitFieldValue(event.target.value)}
>
{(field.options || []).map((option) => (
<option key={option.value} value={option.value}>
{localizeAdminText(option.label, locale)}
</option>
))}
</select>
{help ? <small>{help}</small> : null}
</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>{label}</span>
<Textarea
value={isObjectRecord(value) || Array.isArray(value) ? formatRaw(value) : text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => {
const nextValue = event.target.value
try {
commitFieldValue(JSON.parse(nextValue))
} catch {
commitFieldValue(nextValue)
}
}}
spellCheck={false}
/>
{help ? <small>{help}</small> : null}
</label>
)
}
if (field.type === 'secret') {
const secretValue = text(value, '')
const secretInputType = field.secretVisible ? 'text' : 'password'
const displayValue = field.secretVisible && isMaskedSecretDraft(secretValue)
? `${secretValue} 已保存密钥不回传明文,输入新值可替换`
: secretValue
const savedSecretSuffix = localizeAdminText('已保存密钥不回传明文,输入新值可替换', locale)
return (
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{label}</span>
<div className="an-secret-input">
<input
className="an-input an-secret-input__control"
type={secretInputType}
value={field.secretVisible && isMaskedSecretDraft(secretValue) ? `${secretValue} ${savedSecretSuffix}` : displayValue}
placeholder={placeholder}
disabled={field.disabled}
autoComplete="new-password"
onChange={(event) => {
const nextValue = event.target.value
const sourceSuffix = ' 已保存密钥不回传明文,输入新值可替换'
const localizedSuffix = ` ${savedSecretSuffix}`
onDraftChange(setNestedDraftField(
draft,
record,
field.key,
nextValue.endsWith(sourceSuffix) || nextValue.endsWith(localizedSuffix) ? secretValue : nextValue,
))
}}
/>
<button
type="button"
className="an-secret-input__toggle"
title={field.secretVisible ? localizeAdminText(`隐藏${field.label}`, locale) : localizeAdminText(`显示${field.label}`, locale)}
aria-label={field.secretVisible ? localizeAdminText(`隐藏${field.label}`, locale) : localizeAdminText(`显示${field.label}`, locale)}
onClick={() => field.onToggleSecret?.(!field.secretVisible)}
>
{field.secretVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{help ? <small>{help}</small> : null}
</label>
)
}
if (field.renderInput) {
return (
<div key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{label}</span>
{field.renderInput({
disabled: field.disabled,
displayValue,
fieldKey: field.key,
onChange: commitFieldValue,
placeholder,
value,
})}
{help ? <small>{help}</small> : null}
</div>
)
}
const inputAction = field.inputAction
? {
...field.inputAction,
ariaLabel: field.inputAction.ariaLabel
? localizeAdminText(field.inputAction.ariaLabel, locale)
: undefined,
title: localizeAdminText(field.inputAction.title, locale),
}
: null
return (
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
<span>{label}</span>
{inputAction ? (
<ConnectionTestInput action={{ ...inputAction, disabled: field.disabled || inputAction.disabled }}>
<input
className="an-input an-connection-test-input__control"
type={field.type === 'number' ? 'number' : 'text'}
value={text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => commitFieldValue(field.type === 'number' ? Number(event.target.value) : event.target.value)}
/>
</ConnectionTestInput>
) : (
<input
className="an-input"
type={field.type === 'number' ? 'number' : 'text'}
value={text(displayValue, '')}
placeholder={placeholder}
disabled={field.disabled}
onChange={(event) => commitFieldValue(field.type === 'number' ? Number(event.target.value) : event.target.value)}
/>
)}
{help ? <small>{help}</small> : null}
</label>
)
})}
</div>
)
}
function GroupList({
groups,
activeKey,
onSelect,
footer,
header,
renderGroupControl,
}: {
groups: HierarchyGroup[]
activeKey: string
onSelect: (group: HierarchyGroup) => void
footer?: ReactNode
header?: ReactNode
renderGroupControl?: (group: HierarchyGroup) => ReactNode
}) {
const { locale } = useLocale()
return (
<Panel className="an-hierarchy-list">
{header ? <div className="an-hierarchy-list__header">{header}</div> : null}
<Scrollbar className="an-hierarchy-list__scroll">
<div className="an-hierarchy-list__items">
{groups.map((group) => {
const nested = group.children || []
const groupLabel = localizeDisplayValue(group.label, locale)
const groupDescription = group.description ? localizeDisplayValue(group.description, locale) : ''
const groupStatus = group.status ? localizeDisplayValue(group.status, locale) : ''
if (nested.length) {
return (
<div key={group.key} className="an-hierarchy-group-set">
<div className="an-hierarchy-group-set__title">
<span>
<strong>{groupLabel}</strong>
{groupDescription ? <small>{groupDescription}</small> : null}
</span>
{renderGroupControl?.(group)}
</div>
{nested.map((child) => {
const childLabel = localizeDisplayValue(child.label, locale)
const childDescription = child.description ? localizeDisplayValue(child.description, locale) : ''
const childStatus = child.status ? localizeDisplayValue(child.status, locale) : ''
return (
<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={localizeSearchParts([child.label, child.description, child.status], locale)}
onClick={() => onSelect(child)}
>
<span>
<strong>{childLabel}</strong>
{childDescription ? <small>{childDescription}</small> : null}
</span>
<span className="an-hierarchy-group__meta">
{child.status ? <StatusText tone={statusTone(child.status)}>{childStatus}</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={localizeSearchParts([group.label, group.description, group.status], locale)}
onClick={() => onSelect(group)}
>
<span>
<strong>{groupLabel}</strong>
{groupDescription ? <small>{groupDescription}</small> : null}
</span>
<span className="an-hierarchy-group__meta">
{group.status ? <StatusText tone={statusTone(group.status)}>{groupStatus}</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 BRAND_ASSET_TARGET_META: Record<BrandAssetTargetKey, {
copyLabel: string
dropLabel: string
uploadLabel: string
uploadTitle: string
}> = {
logo_src: {
copyLabel: '复制为 Logo',
dropLabel: '将图片拖到这里',
uploadLabel: '上传',
uploadTitle: '上传 Logo',
},
title_src: {
copyLabel: '复制为标题图',
dropLabel: '将图片拖到这里',
uploadLabel: '上传',
uploadTitle: '上传标题图片',
},
}
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 isBrandAssetTargetKey(value: string): value is BrandAssetTargetKey {
return value === 'logo_src' || value === 'title_src'
}
function isFileDrag(event: ReactDragEvent<HTMLElement>) {
return Array.from(event.dataTransfer.types).includes('Files')
}
function BrandAssetInput({
disabled,
onChange,
onFile,
placeholder,
target,
uploading,
value,
}: {
disabled?: boolean
onChange: (value: string) => void
onFile: (file: File) => void
placeholder?: string
target: BrandAssetTargetKey
uploading: boolean
value: string
}) {
const { locale } = useLocale()
const [dragging, setDragging] = useState(false)
const dragCounter = useRef(0)
const fileInputRef = useRef<HTMLInputElement>(null)
const meta = BRAND_ASSET_TARGET_META[target]
const suffixHelp = BRAND_ASSET_SUFFIXES.join(' / ')
const resetDrag = () => {
dragCounter.current = 0
setDragging(false)
}
const handleDragEnter = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
dragCounter.current += 1
setDragging(true)
}
const handleDragOver = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
}
const handleDragLeave = (event: ReactDragEvent<HTMLDivElement>) => {
if (!isFileDrag(event)) return
event.preventDefault()
dragCounter.current = Math.max(0, dragCounter.current - 1)
if (dragCounter.current === 0) setDragging(false)
}
const handleDrop = (event: ReactDragEvent<HTMLDivElement>) => {
event.preventDefault()
resetDrag()
const file = event.dataTransfer.files?.[0]
if (file) onFile(file)
}
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file) onFile(file)
}
return (
<div
className={dragging ? 'an-brand-asset-input is-dragging' : 'an-brand-asset-input'}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<input
className="an-brand-asset-input__control"
disabled={disabled || uploading}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="text"
value={value}
/>
<input
ref={fileInputRef}
accept={BRAND_ASSET_ACCEPT}
className="an-brand-asset-input__file"
disabled={disabled || uploading}
onChange={handleFileChange}
type="file"
/>
<Button
className="an-brand-asset-input__upload"
disabled={disabled || uploading}
onClick={() => fileInputRef.current?.click()}
size="sm"
tactile={{ height: 28, radius: 5, shadowSize: 0.55 }}
title={`${localizeAdminText(meta.uploadTitle, locale)} · ${suffixHelp}`}
type="button"
variant="primary"
>
{uploading ? localizeAdminText('上传中', locale) : localizeAdminText(meta.uploadLabel, locale)}
</Button>
{dragging ? (
<div className="an-brand-asset-input__drop-overlay" aria-hidden="true">
<span>{localizeAdminText(meta.dropLabel, locale)}</span>
<strong>{localizeAdminText(meta.copyLabel, locale)}</strong>
</div>
) : null}
</div>
)
}
function PlaygroundLite() {
const { toast } = useToast()
const { locale } = useLocale()
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}>{localizeAdminText(preset.label, locale)}</option>)}
</select>
</label>
<label className="an-field">
<span></span>
<input className="an-input" value={text(localizeDisplayValue(title, locale), '')} onChange={(event) => setTitle(event.target.value)} />
</label>
<label className="an-field">
<span></span>
<Textarea value={text(localizeDisplayValue(objective, locale), '')} onChange={(event) => setObjective(event.target.value)} />
</label>
<label className="an-field">
<span></span>
<Textarea value={text(localizeDisplayValue(constraints, locale), '')} 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={text(localizeDisplayValue(inputValue, locale), '')} onChange={(event) => setInputValue(event.target.value)} placeholder={localizeAdminText('输入要发送给 AI 的内容', locale)} />
<Button variant="primary" onClick={() => void sendMessage()} loading={sending || Boolean(activeAssistant)}>
<Send size={15} />{localizeAdminText('发送', locale)}
</Button>
</div>
</div>
</Panel>
</div>
)
}
function ModuleConsole({ config }: { config: ModuleConfig }) {
const { toast } = useToast()
const { locale } = useLocale()
const location = useLocation()
const navigate = useNavigate()
const [states, setStates] = useState<SectionState[]>([])
const [activeSectionKey, setActiveSectionKey] = useState(config.sections[0]?.key || '')
const [datasourceFilters, setDatasourceFilters] = useState<DatasourceFilters>(() => initialDatasourceFilters(location.search))
const datasourceFiltersRef = useRef(datasourceFilters)
const [activeGroupKey, setActiveGroupKey] = useState('')
const [hierarchyDraft, setHierarchyDraft] = useState('')
const [tvDraftGroup, setTvDraftGroup] = useState<HierarchyGroup | null>(null)
const [newsDraftGroup, setNewsDraftGroup] = useState<HierarchyGroup | null>(null)
const [newsItemEditorDraft, setNewsItemEditorDraft] = useState('')
const [newsItemEditorId, setNewsItemEditorId] = useState('')
const [newsImportDialogOpen, setNewsImportDialogOpen] = useState(false)
const [newsImportTargetGroupId, setNewsImportTargetGroupId] = useState('')
const [newsFilters, setNewsFilters] = useState({ status: 'all', sourceType: 'all', region: 'all', tag: 'all' })
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 datasourceMetricBaselinesRef = useRef<Record<string, DatasourceMetricBaseline>>({})
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 [brandAssetUploadingTarget, setBrandAssetUploadingTarget] = useState<BrandAssetTargetKey | null>(null)
const [newsImportFile, setNewsImportFile] = useState<File | null>(null)
const newsImportInputRef = useRef<HTMLInputElement>(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 = initialDatasourceFilters(location.search)
const current = datasourceFiltersRef.current
const unchanged = datasourceFiltersEqual(current, next)
if (!unchanged) setDatasourceSelectedRowIds(new Set())
storeDatasourceFilters(next)
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)
storeDatasourceFilters(next)
navigate({ pathname: location.pathname, search: datasourceFiltersSearch(next) }, { 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) && isSameQueueTaskType(existing.taskType, item.taskType) && item.sourceId && existing.sourceId === item.sourceId)
|| (isActiveQueueStatus(existing.status) && isSameQueueTaskType(existing.taskType, item.taskType) && 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)
const payloadTaskType = text(payload.task_type, '')
setCollectionQueue((current) => current.map((item) => {
const taskTypeMatched = !payloadTaskType || isSameQueueTaskType(item.taskType, payloadTaskType)
const matched = Boolean(taskId && item.taskId === taskId)
|| (taskTypeMatched && Boolean(sourceId && item.sourceId === sourceId))
|| (taskTypeMatched && Boolean(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,
}
const metricKey = text(payload.task_id, '') || sourceId || source
const recordsProcessed = typeof payload.records_processed === 'number'
? payload.records_processed
: Number.isFinite(Number(payload.records_processed))
? Number(payload.records_processed)
: null
const applyLiveMetric = (row: AnyRecord, next: AnyRecord) => {
if (!metricKey || recordsProcessed === null || recordsProcessed < 0) return next
if (taskType === 'clear_data') {
let baseline = datasourceMetricBaselinesRef.current[metricKey]
if (!baseline) {
baseline = {
taskId: metricKey,
sourceId,
source,
taskType,
count: datasourceRecordCount(row) ?? 0,
}
datasourceMetricBaselinesRef.current[metricKey] = baseline
}
const nextCount = Math.max(0, baseline.count - recordsProcessed)
return {
...next,
__metric_count: nextCount,
__metric: formatCountZh(nextCount),
collected_records: nextCount,
has_collected_data: nextCount > 0,
}
}
if (taskType === 'collect' && taskActive) {
return {
...next,
__metric: `已处理 ${formatCountZh(recordsProcessed)}`,
}
}
return next
}
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
const merged = applyLiveMetric(row, { ...row, ...rowPatch, id: row.id, source: row.source })
return normalizeDatasourceTableRecord(merged)
}),
}
}))
setSelected((current) => {
if (!current || !isSameDatasourceRow(current, sourceId, source)) return current
const merged = applyLiveMetric(current, { ...current, ...rowPatch, id: current.id, source: current.source })
return normalizeDatasourceTableRecord(merged)
})
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 datasourceMetricBaselinesRef.current[text(payload.task_id, '') || sourceId || source]
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)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
setNewsImportDialogOpen(false)
setNewsImportFile(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 testEarthNewsSource = async (record: AnyRecord) => {
const payload = stripInternalFields(record)
const sources = Array.isArray(payload.sources) ? payload.sources.filter(isObjectRecord) : []
const source = sources.find((item) => {
const sourceType = text(item.source_type, 'rss')
return item.enabled !== false && ['rss', 'atom', 'aggregated'].includes(sourceType)
}) || sources[0]
if (!source) {
toast({ title: '没有可测试的新闻源', description: '当前新闻源配置里没有 sources。' })
return
}
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-sources/test'), { source })
const result = response.data as AnyRecord
const count = Number(result.count || 0)
if (result.ok) {
toast({
title: '新闻源测试通过',
description: `${pick(source, ['name', 'id'], '新闻源')} 返回 ${Number.isFinite(count) ? count : 0} 条内容。`,
tone: 'success',
})
} else {
const health = isObjectRecord(result.health) ? result.health : {}
toast({
title: '新闻源测试失败',
description: newsSourceHealthDescription(health) || text(result.error, '该来源暂不可按 RSS/Atom 抓取。'),
tone: 'error',
})
}
await load(activeSectionKey)
} catch (error) {
toast({ title: '新闻源测试失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const testSingleEarthNewsSource = async (record: AnyRecord) => {
const source = newsSourceFromEditor(record)
const sourceType = newsSourceType(source)
if (!NEWS_FETCHABLE_SOURCE_TYPES.has(sourceType)) {
toast({
title: '参考链接不参与抓取',
description: '当前来源只作为官网、报告页或未来采集器线索保留,不会执行 RSS/Atom 测试。',
})
return
}
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-sources/test'), { source })
const result = response.data as AnyRecord
const count = Number(result.count || 0)
if (result.ok) {
toast({
title: '新闻源测试通过',
description: `${pick(source, ['name', 'id'], '新闻源')} 返回 ${Number.isFinite(count) ? count : 0} 条内容。`,
tone: 'success',
})
} else {
toast({
title: '新闻源测试失败',
description: text(result.error, '该来源暂不可按 RSS/Atom 抓取。'),
tone: 'error',
})
}
} catch (error) {
toast({ title: '新闻源测试失败', 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: `正在停止${taskTypeLabel(item.taskType)}`,
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)
}
const restoreActiveCollectionQueueTasks = useCallback(async () => {
if (config !== configs.datasources) return
try {
const response = await axios.get(apiPath('/tasks'), {
params: {
status: 'queued,running,cancelling',
page_size: 200,
},
})
dataArray(response.data).forEach((task) => {
const sourceId = text(task.datasource_id || task.source_id, '')
if (!sourceId) return
const taskId = task.id as number | string | null | undefined
const source = text(task.source || task.datasource_source, '')
const taskType = text(task.task_type, 'collect')
const record = {
id: sourceId,
source,
collector_name: source,
name: text(task.datasource_name || task.name || source, '数据源'),
task_id: taskId,
task_type: taskType,
task_status: task.status,
status: task.status,
phase: task.phase,
phase_message: task.phase_message,
progress: task.progress,
records_processed: task.records_processed,
total_records: task.total_records,
error_message: task.error_message,
__endpointKey: 'builtin',
__endpointLabel: '内置源',
__rowId: `active-task-${sourceId}-${taskId || taskType}`,
__title: text(task.datasource_name || task.name || source, '数据源'),
__module: '数据源任务',
__status: queueStatusLabel(queueStatusFromTask(task.status || task.phase, true), taskType),
__metric: taskId ? `task ${taskId}` : '-',
__time: text(task.started_at || task.completed_at, '-'),
}
upsertCollectionQueueItem(queueItemFromDatasourceRow(record, taskId, {
taskType,
status: queueStatusFromTask(task.status || task.phase, true),
phase: text(task.phase, text(task.status, '')),
phaseMessage: text(task.phase_message, ''),
progress: typeof task.progress === 'number' ? task.progress : 0,
recordsProcessed: typeof task.records_processed === 'number' ? task.records_processed : undefined,
totalRecords: typeof task.total_records === 'number' ? task.total_records : undefined,
}))
scheduleDatasourceTaskPoll(record, taskId)
})
} catch {
// Queue restore is best-effort; the table and explicit refresh still load normally.
}
}, [config, upsertCollectionQueueItem])
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, task_type: taskType }),
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])
useEffect(() => {
void restoreActiveCollectionQueueTasks()
}, [restoreActiveCollectionQueueTasks])
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 uploadBrandAssetFile = async (file: File | null | undefined, target: BrandAssetTargetKey) => {
if (!file) {
toast({ title: '请拖入图片文件', tone: 'error' })
return null
}
const suffix = file.name.split('.').pop()?.toLowerCase() || ''
if (!BRAND_ASSET_SUFFIXES.includes(suffix)) {
toast({ title: '文件类型不支持', description: `仅支持 ${BRAND_ASSET_SUFFIXES.join(', ')}`, tone: 'error' })
return null
}
const formData = new FormData()
formData.append('file', file)
setActionLoading(true)
setBrandAssetUploadingTarget(target)
try {
const response = await axios.post(apiPath('/earth/brand/assets'), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const assetUrl = text(response.data?.url, '')
if (!assetUrl) throw new Error('上传接口未返回资产 URL')
replaceSelectedWithPayload('brand-upload', '品牌资产上传', [{
...response.data,
__title: '品牌资产上传结果',
__module: target === 'logo_src' ? 'Logo' : '标题图片',
__status: '已上传',
__metric: file.name,
}])
toast({
title: target === 'logo_src' ? 'Logo 图片已上传' : '标题图片已上传',
description: assetUrl,
tone: 'success',
})
return assetUrl
} catch (error) {
toast({ title: '品牌资产上传失败', description: actionErrorMessage(error), tone: 'error' })
return null
} finally {
setActionLoading(false)
setBrandAssetUploadingTarget(null)
}
}
const createManualNewsGroup = async () => {
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-groups'), { name: '新建新闻组' })
const group = isObjectRecord(response.data?.group) ? response.data.group : {}
const groupId = text(group.id, '')
toast({ title: '新闻组已创建', tone: 'success' })
await load()
if (groupId) {
setActiveGroupKey(`news-group:${groupId}`)
setHierarchyDraft(formatRaw(group))
setMobileHierarchyDetailOpen(true)
}
} catch (error) {
toast({ title: '创建新闻组失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
const openManualNewsImportDialog = (groupId: string) => {
setNewsImportTargetGroupId(groupId)
setNewsImportFile(null)
setNewsImportDialogOpen(true)
}
const importManualNewsJson = async () => {
if (!newsImportFile) {
toast({ title: '请选择 JSON 文件', tone: 'error' })
return
}
if (!newsImportTargetGroupId) {
toast({ title: '请选择新闻组', description: 'JSON 导入需要在手动新闻组详情页中执行。', tone: 'error' })
return
}
if (!newsImportFile.name.toLowerCase().endsWith('.json')) {
toast({ title: '文件类型不支持', description: '首版只支持 JSON 数组文件。', tone: 'error' })
return
}
const formData = new FormData()
formData.append('file', newsImportFile)
formData.append('group_id', newsImportTargetGroupId)
setActionLoading(true)
try {
const response = await axios.post(apiPath('/earth/news-items/import'), formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
const result = response.data as AnyRecord
setNewsImportFile(null)
setNewsImportDialogOpen(false)
toast({
title: '新闻导入完成',
description: `新增 ${text(result.created, '0')} 条,更新 ${text(result.updated, '0')} 条,失败 ${text(result.failed, '0')} 条。`,
tone: Number(result.failed || 0) > 0 ? 'error' : 'success',
})
const activeKey = activeGroupKey
await load()
if (activeKey) setActiveGroupKey(activeKey)
} catch (error) {
toast({ title: '导入新闻失败', description: actionErrorMessage(error), tone: 'error' })
} finally {
setActionLoading(false)
}
}
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.earthContent && endpointKey === 'newsSources') {
await requestAction('保存新闻源配置', 'put', '/earth/news-sources', 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,
inputAction: activeGroup ? {
title: '测试 AI Provider 连通性',
icon: 'test',
loading: actionLoading,
onClick: () => void testConnection(
'AI Provider',
'/settings/integrations/ai-provider/connect',
sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key', 'service_token'], revealedFor('ai_provider')),
),
} : undefined,
},
{ 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 webSearchDisabled = activeGroup?.key.startsWith('web_search:') && !Boolean(record.enabled)
const webFields: FieldConfig[] = [
{ key: 'enabled', label: '启用 WebSearch', type: 'boolean' },
{ key: 'provider', label: '搜索供应商', disabled: webSearchDisabled },
{
key: 'base_url',
label: 'API 基础地址',
wide: true,
disabled: webSearchDisabled,
inputAction: activeGroup ? {
title: '测试 Web Search 连通性',
icon: 'test',
loading: actionLoading,
disabled: webSearchDisabled,
onClick: () => void testConnection(
'Web Search',
'/settings/integrations/web-search/connect',
sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key'], revealedFor('web_search')),
),
} : undefined,
},
{
key: 'api_key',
label: 'WebSearch API Key',
type: 'secret',
placeholder: '输入新的 WebSearch API Key',
disabled: webSearchDisabled,
secretVisible: Boolean(visibleSecretFields[secretFieldKey('api_key')]),
onToggleSecret: toggleActiveSecret('api_key', 'web_search'),
},
{ key: 'max_results', label: '最大结果数', type: 'number', disabled: webSearchDisabled },
{ key: 'timeout_seconds', label: '超时(秒)', type: 'number', disabled: webSearchDisabled },
{ key: 'endpoint_path', label: '接口路径', disabled: webSearchDisabled },
{ key: 'search_depth', label: '搜索深度', disabled: webSearchDisabled },
{ key: 'engine', label: 'SerpAPI 引擎', disabled: webSearchDisabled },
{ key: 'categories', label: 'SearXNG 分类', disabled: webSearchDisabled },
{ key: 'search_path', label: 'Firecrawl 搜索路径', disabled: webSearchDisabled },
{ key: 'scrape_path', label: 'Firecrawl 抓取路径', disabled: webSearchDisabled },
{ key: 'include_answer', label: '包含答案', type: 'boolean', disabled: webSearchDisabled },
{ key: 'include_raw_content', label: '包含原始内容', type: 'boolean', disabled: webSearchDisabled },
{ key: 'include_text', label: '包含正文', type: 'boolean', disabled: webSearchDisabled },
]
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={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</strong>
</div>
<div className="an-panel-heading">
<div>
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={localizeSearchParts([activeGroup?.label, activeGroup?.description], locale)}>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</h2>
<p>{localizeDisplayValue(activeGroup?.description || '选择左侧父级后编辑它的子配置。', locale)}</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>
</>
) : 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>
</>
) : 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} />{localizeAdminText('保存', locale)}</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>{localizeAdminText('可选模型', locale)}</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={localizeAdminText('暂无分组', locale)} description={localizeAdminText('当前分区没有可配置项。', locale)} />}
</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)
}
const newsPayload = config === configs.earthContent && activeSection.key === 'news_sources'
? newsSourcesPayload(activeState.raw)
: {}
const newsSettingsPayload = config === configs.earthContent
? newsSourcesPayload(states.find((state) => state.section.key === 'news_sources')?.raw)
: {}
if (config === configs.earthContent && activeSection.key === 'news_sources') {
const matchesNewsFilter = (group: HierarchyGroup) => {
const source = group.record
const type = newsSourceType(source)
const tags = text(source.source_tags_text, '').split(/[,\n]/).map((tag) => tag.trim()).filter(Boolean)
const status = newsSourceStatusLabel(source)
if (newsFilters.status !== 'all') {
if (newsFilters.status === 'enabled' && status !== '启用') return false
if (newsFilters.status === 'disabled' && status !== '停用') return false
if (newsFilters.status === 'reference' && type !== 'reference') return false
}
if (newsFilters.sourceType !== 'all' && type !== newsFilters.sourceType) return false
if (newsFilters.region !== 'all' && text(source.region, '') !== newsFilters.region) return false
if (newsFilters.tag !== 'all' && !tags.includes(newsFilters.tag)) return false
return true
}
const newsHealthPayload = isObjectRecord(newsPayload.health) ? newsPayload.health : {}
groups = sortGroupsByStatus(arrayAt(newsPayload, 'sources').filter(isObjectRecord).map((source) => newsSourceGroup(source, newsHealthPayload))).filter(matchesNewsFilter)
if (newsDraftGroup) groups.push(newsDraftGroup)
}
if (config === configs.earthContent && activeSection.key === 'news_items') {
groups = activeState.rows.map((row) => newsContentGroup(row))
}
if (config === configs.collection && activeSection.key === 'collection_history') {
groups = activeState.rows.map((row) => ({
key: row.__rowId,
label: recordTitle(row),
description: '采集快照',
status: normalizeStatusLabel(snapshotStatus(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 newsSourceFields: FieldConfig[] = config === configs.earthContent && activeSection.key === 'news_sources' ? [
{ key: 'id', label: '源 ID' },
{ key: 'name', label: '源名称' },
{ key: 'region', label: '区域', type: 'select', options: NEWS_REGION_OPTIONS },
{ key: 'source_type', label: '源类型', type: 'select', options: NEWS_SOURCE_TYPE_OPTIONS },
{ key: 'enabled', label: '启用抓取', type: 'boolean' },
{ key: 'homepage_url', label: '主页地址', help: '来源官网、栏目页或报告页,不作为抓取入口。' },
{ key: 'feed_directory_url', label: 'Feed 信息页', help: 'RSS 订阅中心或 Feed 聚合页,只用于人工查看,不参与抓取。' },
{ key: 'source_tags_text', label: '源属性标签', wide: true, help: '描述来源属性,不是媒体来源名;多个标签用逗号分隔,例如 business_news, ecommerce, china。' },
{ key: 'importance_weight', label: '重要度权重', type: 'number' },
{ key: 'fetch_interval_minutes', label: '抓取间隔(分钟)', type: 'number' },
{ key: 'timeout_seconds', label: '超时(秒)', type: 'number' },
{ key: 'failure_threshold', label: '失败阈值', type: 'number' },
{ key: 'cooldown_minutes', label: '熔断冷却(分钟)', type: 'number' },
{ key: 'circuit_breaker', label: '熔断开关', type: 'boolean' },
] : []
const newsItemReadonly = config === configs.earthContent
&& activeSection.key === 'news_items'
&& activeGroup
&& !activeGroup.record.__isDraft
&& activeGroup.record.editable === false
const newsItemFields: FieldConfig[] = config === configs.earthContent && activeSection.key === 'news_items' ? [
{ key: 'name', label: '组名 / 来源名', disabled: newsItemReadonly },
{ key: 'group_type', label: '组类型', disabled: true },
{ key: 'source_type', label: '来源类型', disabled: true },
{ key: 'count', label: '新闻数量', type: 'number', disabled: true },
] : []
const manualNewsItemFields: FieldConfig[] = [
{ key: 'title', label: '标题' },
{ key: 'summary', label: '摘要', type: 'textarea', wide: true },
{ key: 'content', label: '正文', type: 'textarea', wide: true },
{ key: 'source', label: '内容来源' },
{ key: 'url', label: '原文链接', wide: true },
{ key: 'region', label: '缺省区域', type: 'select', options: NEWS_REGION_OPTIONS.filter((option) => !['china', 'us'].includes(option.value)) },
{ key: 'published_at', label: '发布时间' },
{ key: 'category', label: '新闻类型', type: 'select', options: newsCategoryOptions(newsSettingsPayload) },
{ key: 'tags_text', label: '标签', wide: true },
{ key: 'latitude', label: '纬度', type: 'number' },
{ key: 'longitude', label: '经度', type: 'number' },
{ key: 'location_label', label: '位置标签' },
]
const fields = newsSourceFields.length ? newsSourceFields : newsItemFields.length ? newsItemFields : [...scalarFields, ...objectFields]
const uploadBrandAssetToDraft = async (file: File, target: BrandAssetTargetKey) => {
if (!activeGroup) return
const assetUrl = await uploadBrandAssetFile(file, target)
if (!assetUrl) return
setHierarchyDraft((current) => setNestedDraftField(current, activeGroup.record, target, assetUrl))
}
const editableFields = config === configs.earthContent && activeSection.key === 'brand'
? fields.map((field): FieldConfig => {
if (!isBrandAssetTargetKey(field.key)) return field
const target = field.key
return {
...field,
renderInput: ({ disabled, displayValue, onChange, placeholder }) => (
<BrandAssetInput
disabled={disabled}
onChange={(nextValue) => onChange(nextValue)}
onFile={(file) => void uploadBrandAssetToDraft(file, target)}
placeholder={placeholder}
target={target}
uploading={brandAssetUploadingTarget === target}
value={text(displayValue, '')}
/>
),
}
})
: fields
const renderNewsFeedEditor = () => {
if (!activeGroup) return null
const currentSource = draftRecord(hierarchyDraft, activeGroup.record)
const feeds = Array.isArray(currentSource.feeds) ? currentSource.feeds.filter(isObjectRecord) : []
const categories = newsCategoryOptions(newsPayload)
return (
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>Feed </h3>
<p> RSS/Atom/Aggregated </p>
</div>
<div className="an-news-feed-list">
{feeds.map((feed, index) => (
<div className="an-news-feed-card" key={`${text(feed.id, 'feed')}-${index}`}>
<div className="an-news-feed-card__header">
<strong>{text(feed.name, `Feed ${index + 1}`)}</strong>
<div className="an-news-feed-card__actions">
<AdminSwitch
checked={feed.enabled !== false}
label={feed.enabled === false ? '启用 Feed' : '停用 Feed'}
onCheckedChange={(next) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'enabled', next))}
/>
<Button
size="icon"
variant="subtle"
icon="test"
title="测试当前 Feed"
aria-label="测试当前 Feed"
onClick={() => void testSingleEarthNewsSource({ ...currentSource, feeds: [feed], feed_url: text(feed.url, ''), feed_urls: [text(feed.url, '')] })}
/>
<Button size="icon" variant="subtle" title="删除 Feed 子项" aria-label="删除 Feed 子项" onClick={() => setHierarchyDraft(removeNewsFeedDraft(hierarchyDraft, activeGroup.record, index))}>
<Trash2 size={14} />
</Button>
</div>
</div>
<div className="an-field-grid">
<label className="an-field">
<span>Feed ID</span>
<input className="an-input" value={text(feed.id, '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'id', event.target.value))} />
</label>
<label className="an-field">
<span>Feed </span>
<input className="an-input" value={text(localizeDisplayValue(feed.name, locale), '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'name', event.target.value))} />
</label>
<label className="an-field an-field--wide">
<span>Feed </span>
<input className="an-input" value={text(feed.url, '')} placeholder="https://example.com/feed.xml" onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'url', event.target.value))} />
</label>
<label className="an-field">
<span>Feed </span>
<select className="an-input" value={text(feed.type, 'rss')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'type', event.target.value))}>
{NEWS_SOURCE_TYPE_OPTIONS.filter((option) => option.value !== 'reference').map((option) => <option key={option.value} value={option.value}>{localizeAdminText(option.label, locale)}</option>)}
</select>
</label>
<label className="an-field">
<span></span>
<select className="an-input" value={text(feed.default_category, text(currentSource.default_category, 'business'))} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'default_category', event.target.value))}>
{categories.map((option) => <option key={option.value} value={option.value}>{localizeAdminText(option.label, locale)}</option>)}
</select>
</label>
<label className="an-field">
<span></span>
<input className="an-input" type="number" value={text(feed.priority, String(index + 1))} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'priority', Number(event.target.value)))} />
</label>
<label className="an-field">
<span>Feed </span>
<input className="an-input" value={text(feed.tags_text || (Array.isArray(feed.tags) ? feed.tags.join(', ') : ''), '')} onChange={(event) => setHierarchyDraft(updateNewsFeedDraft(hierarchyDraft, activeGroup.record, index, 'tags_text', event.target.value))} />
</label>
</div>
</div>
))}
{!feeds.length ? <EmptyState title="暂无 Feed 子项" description="添加一个真实 RSS/Atom/Aggregated 地址后才能抓取。" /> : null}
<Button size="icon" variant="subtle" title="新增 Feed 子项" aria-label="新增 Feed 子项" onClick={() => setHierarchyDraft(addNewsFeedDraft(hierarchyDraft, activeGroup.record))}>+</Button>
</div>
</section>
)
}
const startNewsItemEditor = (item?: AnyRecord) => {
const groupId = text(activeGroup?.record.id, '')
const baseline = item
? newsItemToEditor(item)
: newsItemToEditor({
id: '__new__',
title: '',
summary: '',
content: '',
source: '手动添加',
source_type: 'manual',
region: 'global',
category: 'other',
published_at: new Date().toISOString(),
editable: true,
group_id: groupId,
})
setNewsItemEditorId(text(baseline.id, '__new__'))
setNewsItemEditorDraft(formatRaw(baseline))
}
const saveNewsItemEditor = async () => {
if (!activeGroup) return
const groupId = text(activeGroup.record.id, '')
if (!groupId || text(activeGroup.record.group_type, '') !== 'manual') return
const current = draftRecord(newsItemEditorDraft, {})
const payload = { ...newsItemFromEditor(current), group_id: groupId }
const validationError = newsItemValidationError(payload)
if (validationError) {
toast({ title: '新闻内容不完整', description: validationError, tone: 'error' })
return
}
const itemId = text(current.id, newsItemEditorId)
const isNew = !itemId || itemId === '__new__'
await requestAction(
isNew ? '新增新闻内容' : '保存新闻内容',
isNew ? 'post' : 'put',
isNew ? '/earth/news-items' : `/earth/news-items/${encodeURIComponent(itemId)}`,
payload,
)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
}
const renderNewsContentGroupEditor = () => {
if (!activeGroup) return null
const group = record
const groupId = text(activeGroup.record.id, '')
const isManual = text(group.group_type, '') === 'manual'
const items = arrayAt(group, 'items')
const currentEditorRecord = newsItemEditorDraft ? draftRecord(newsItemEditorDraft, {}) : {}
return (
<>
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>{isManual ? '手动新闻组' : 'RSS 来源'}</h3>
<p>{isManual ? '组内可按条添加,也可以上传 JSON 数组批量导入。' : 'RSS 来源只读,新闻由抓取与增强链路维护。'}</p>
</div>
<FieldGrid
record={activeGroup.record}
draft={hierarchyDraft}
onDraftChange={setHierarchyDraft}
fields={fields}
searchGroupKey={activeGroup.key}
/>
{isManual ? (
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button size="icon" variant="subtle" icon="plus" title="单条添加" aria-label="单条添加" onClick={() => startNewsItemEditor()} />
<Button size="icon" variant="subtle" title="上传 JSON" aria-label="上传 JSON" onClick={() => openManualNewsImportDialog(groupId)}><ImageUp size={15} /></Button>
</TactileControlGroup>
) : null}
</section>
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3></h3>
<p>{items.length} /</p>
</div>
<div className="an-news-feed-list">
{items.length ? items.map((item, itemIndex) => {
const itemId = text(item.id, '')
return (
<div className="an-news-feed-card" key={itemId || `${text(item.title, 'news-item')}:${itemIndex}`}>
<div className="an-news-feed-card__header">
<strong>{pick(item, ['title', 'display_title', 'id'], '新闻条目')}</strong>
<div className="an-news-feed-card__actions">
<StatusText tone={item.verified ? 'success' : 'warning'}>{item.verified ? '已定位' : '待定位'}</StatusText>
{isManual ? (
<>
<Button size="icon" variant="subtle" title="编辑新闻" aria-label="编辑新闻" onClick={() => startNewsItemEditor(item)}><Redo2 size={14} /></Button>
<Button size="icon" variant="subtle" title="重新处理" aria-label="重新处理" onClick={() => void requestAction('重新处理新闻', 'post', `/earth/news-items/${encodeURIComponent(itemId)}/reprocess`, undefined, { refresh: true, successDescription: '新闻已重新进入清洗、翻译和定位队列。' })}><RefreshCw size={14} /></Button>
<Button size="icon" variant="danger" title="删除新闻" aria-label="删除新闻" onClick={() => setConfirmAction({
title: '删除新闻',
description: `确认删除 ${pick(item, ['title', 'id'], '新闻条目')}`,
danger: true,
confirmLabel: '删除',
run: async () => {
await requestAction('删除新闻', 'delete', `/earth/news-items/${encodeURIComponent(itemId)}`)
setNewsItemEditorDraft('')
setNewsItemEditorId('')
},
})}><Trash2 size={14} /></Button>
</>
) : null}
</div>
</div>
<p>{text(item.summary || item.display_summary, '暂无摘要')}</p>
<small>{[text(item.source, ''), text(item.region, ''), text(item.category, ''), text(item.published_at, '')].filter(Boolean).join(' · ')}</small>
</div>
)
}) : <EmptyState title="暂无新闻" description={isManual ? '可以单条添加或上传 JSON 数组导入。' : '该 RSS 来源暂无入库新闻。'} />}
</div>
</section>
{newsItemEditorDraft ? (
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3>{newsItemEditorId === '__new__' ? '新增新闻' : '编辑新闻'}</h3>
<p></p>
</div>
<FieldGrid
record={currentEditorRecord}
draft={newsItemEditorDraft}
onDraftChange={setNewsItemEditorDraft}
fields={manualNewsItemFields}
searchGroupKey={`${activeGroup.key}:news-editor`}
/>
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button variant="subtle" onClick={() => {
setNewsItemEditorDraft('')
setNewsItemEditorId('')
}}><X size={15} /></Button>
<Button variant="primary" onClick={() => void saveNewsItemEditor()} loading={actionLoading}><Save size={15} /></Button>
</TactileControlGroup>
</section>
) : null}
</>
)
}
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 === 'news_sources') {
const fullPayload = newsSourcesPayload(activeState.raw)
const existingSources = arrayAt(fullPayload, 'sources')
const oldId = text(activeGroup.record.id, '')
const nextSource = newsSourceFromEditor(payload)
const validationError = newsSourceValidationError(nextSource, existingSources, activeGroup.record.__isDraft ? '' : oldId)
if (validationError) {
toast({ title: '新闻源配置不完整', description: validationError, tone: 'error' })
return
}
const nextSources = activeGroup.record.__isDraft
? [...existingSources, nextSource]
: existingSources.some((source) => text(source.id, '') === oldId)
? existingSources.map((source) => text(source.id, '') === oldId ? nextSource : source)
: [...existingSources, nextSource]
await saveHierarchySettings('/earth/news-sources', { ...fullPayload, sources: nextSources }, '保存新闻源')
if (activeGroup.record.__isDraft) {
setNewsDraftGroup(null)
setActiveGroupKey(`news-source:${text(nextSource.id, '')}`)
}
return
}
if (activeSection.key === 'news_items') {
if (activeGroup.record.editable === false || text(activeGroup.record.group_type, '') !== 'manual') {
toast({ title: 'RSS 来源只读', description: 'RSS 来源组不能在这里重命名或编辑。', tone: 'error' })
return
}
const groupId = text(activeGroup.record.id, '')
await requestAction('保存新闻组', 'put', `/earth/news-groups/${encodeURIComponent(groupId)}`, {
name: text(payload.name, '').trim(),
})
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>
) : config === configs.earthContent && activeSection.key === 'news_sources' ? (
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button size="icon" variant="subtle" icon="plus" title="新增新闻源" aria-label="新增新闻源" onClick={() => {
const group = makeNewNewsSourceGroup(arrayAt(newsPayload, 'sources').length + 1)
setNewsDraftGroup(group)
selectHierarchyGroup(group)
}} />
</TactileControlGroup>
) : config === configs.earthContent && activeSection.key === 'news_items' ? (
<TactileControlGroup className="an-hierarchy-list__footer-actions">
<Button size="icon" variant="subtle" icon="plus" title="新增新闻组" aria-label="新增新闻组" onClick={() => void createManualNewsGroup()} loading={actionLoading} />
</TactileControlGroup>
) : null
const hierarchyHeader = config === configs.earthContent && activeSection.key === 'news_sources' ? (
<div className="an-news-source-filters">
<select className="an-input" value={newsFilters.status} onChange={(event) => setNewsFilters((current) => ({ ...current, status: event.target.value }))}>
<option value="all"></option>
<option value="enabled"></option>
<option value="disabled"></option>
<option value="reference"></option>
</select>
<select className="an-input" value={newsFilters.sourceType} onChange={(event) => setNewsFilters((current) => ({ ...current, sourceType: event.target.value }))}>
<option value="all"></option>
{NEWS_SOURCE_TYPE_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select className="an-input" value={newsFilters.region} onChange={(event) => setNewsFilters((current) => ({ ...current, region: event.target.value }))}>
<option value="all"></option>
{NEWS_REGION_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select className="an-input" value={newsFilters.tag} onChange={(event) => setNewsFilters((current) => ({ ...current, tag: event.target.value }))}>
<option value="all"></option>
{newsTagOptions(newsPayload).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</div>
) : null
return (
<div className="an-hierarchy-workspace">
{hierarchyHeader}
<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={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileHierarchyDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</strong>
</div>
<div className="an-panel-heading">
<div>
<h2 data-admin-search-target={activeGroup?.key} data-admin-search-text={localizeSearchParts([activeGroup?.label, activeGroup?.description], locale)}>{localizeDisplayValue(activeGroup?.label || activeSection.label, locale)}</h2>
<p>{localizeDisplayValue(activeGroup?.description || '选择左侧父级后编辑它的子配置。', locale)}</p>
</div>
<div className="an-toolbar">
{config === configs.earthContent && activeSection.key === 'brand' ? (
<>
<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 === 'news_sources' && activeGroup ? (
<>
<Button
size="icon"
variant="subtle"
title={newsSourceType(record) === 'reference' ? '参考链接不参与抓取' : '测试当前新闻源'}
aria-label={newsSourceType(record) === 'reference' ? '参考链接不参与抓取' : '测试当前新闻源'}
onClick={() => void testSingleEarthNewsSource(record)}
loading={actionLoading}
icon="test"
/>
<Button size="icon" variant="subtle" title="恢复当前表单" aria-label="恢复当前表单" onClick={() => {
setHierarchyDraft(formatRaw(activeGroup.record))
toast({ title: '已恢复当前项', description: '表单已恢复到加载时状态。', tone: 'success' })
}}><Redo2 size={15} /></Button>
{!activeGroup.record.__isDraft ? (
<Button size="icon" variant="danger" title="删除新闻源" aria-label="删除新闻源" onClick={() => setConfirmAction({
title: '删除新闻源',
description: `确认删除 ${recordTitle(record)}`,
danger: true,
confirmLabel: '删除',
run: async () => {
const fullPayload = newsSourcesPayload(activeState.raw)
const sourceId = text(record.id, '')
await saveHierarchySettings('/earth/news-sources', {
...fullPayload,
sources: arrayAt(fullPayload, 'sources').filter((source) => text(source.id, '') !== sourceId),
}, '删除新闻源')
setActiveGroupKey('')
},
})} loading={actionLoading}><Trash2 size={15} /></Button>
) : null}
</>
) : null}
{config === configs.earthContent && activeSection.key === 'news_items' && activeGroup ? (
<>
<Button size="icon" variant="subtle" title="恢复当前表单" aria-label="恢复当前表单" onClick={() => {
setHierarchyDraft(formatRaw(activeGroup.record))
setNewsItemEditorDraft('')
setNewsItemEditorId('')
toast({ title: '已恢复当前项', description: '表单已恢复到加载时状态。', tone: 'success' })
}}><Redo2 size={15} /></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} icon="test" />
</>
) : 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.earthContent && activeSection.key === 'news_sources' && activeGroup?.record.__isDraft ? (
<Button variant="subtle" onClick={() => {
setNewsDraftGroup(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>{localizeAdminText(`${snapshotTimeline.length} 个历史快照,选择后查看该版本详情。`, locale)}</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(snapshotStatus(activeSnapshot))}>
{semanticLabel(snapshotStatus(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 === 'news_sources' ? (
<>
<section className="an-field-cluster an-field-cluster--compact">
<div className="an-field-cluster__heading">
<h3></h3>
<p>{newsSourceHealthDescription(isObjectRecord(record.__health) ? record.__health : {})}</p>
</div>
</section>
{newsSourceType(record) === 'reference' ? (
<section className="an-field-cluster an-field-cluster--compact">
<div className="an-field-cluster__heading">
<h3></h3>
<p>线 RSS/Atom RSSAtom Aggregated</p>
</div>
</section>
) : null}
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3></h3>
<p></p>
</div>
<FieldGrid
record={activeGroup.record}
draft={hierarchyDraft}
onDraftChange={setHierarchyDraft}
fields={fields.filter((field) => ['id', 'name', 'region', 'source_type', 'enabled', 'homepage_url', 'feed_directory_url', 'source_tags_text'].includes(field.key))}
searchGroupKey={activeGroup.key}
/>
</section>
{newsSourceType(record) === 'reference' ? null : renderNewsFeedEditor()}
<section className="an-field-cluster">
<div className="an-field-cluster__heading">
<h3></h3>
<p></p>
</div>
<FieldGrid
record={activeGroup.record}
draft={hierarchyDraft}
onDraftChange={setHierarchyDraft}
fields={fields.filter((field) => ['importance_weight', 'fetch_interval_minutes', 'timeout_seconds', 'failure_threshold', 'cooldown_minutes', 'circuit_breaker'].includes(field.key))}
searchGroupKey={activeGroup.key}
/>
</section>
</>
) : config === configs.earthContent && activeSection.key === 'news_items' ? (
renderNewsContentGroupEditor()
) : 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={editableFields} 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>
</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 === '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(
<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.earthContent && endpointKey === 'newsSources') {
actions.push(
<Button key="test-news-source" size="icon" variant="subtle" icon="test" onClick={() => void testEarthNewsSource(selected)} loading={actionLoading} title="测试启用源" aria-label="测试启用源" />,
<Button key="reset-news-sources" variant="subtle" onClick={() => setConfirmAction({
title: '重置新闻源',
description: '确认恢复内置默认来源、Feed 子项、源属性标签和策略?当前自定义配置会被清空。',
confirmLabel: '重置',
run: () => requestAction('重置新闻源', 'post', '/earth/news-sources/reset'),
})} loading={actionLoading}><RefreshCw size={15} /></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', 'newsSources'].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', 'newsSources'].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 openLogsForTask = (item: Pick<CollectionQueueItem, 'taskId' | 'sourceId' | 'source'> & { requestId?: string | number | null }) => {
const params = new URLSearchParams({ source: 'system-db' })
const requestId = text(item.requestId, '')
const taskId = text(item.taskId, '')
const sourceId = text(item.sourceId, '')
const source = text(item.source, '')
if (requestId) {
params.set('search', `request_id=${requestId}`)
} else if (taskId) {
params.set('search', `task_id=${taskId}`)
} else if (sourceId) {
params.set('search', `datasource_id=${sourceId}`)
} else if (source) {
params.set('search', source)
}
navigate(`/logs?${params.toString()}`)
}
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.earthContent && activeSectionKey === 'news_sources') {
actions.push(
<Button key="reset-news-sources" size="icon" variant="subtle" title="重置默认新闻源" aria-label="重置默认新闻源" onClick={() => setConfirmAction({
title: '重置默认新闻源',
description: '确认恢复内置默认来源、Feed 子项、源属性标签和策略?',
danger: false,
confirmLabel: '重置',
run: () => requestAction('重置新闻源', 'post', '/earth/news-sources/reset'),
})} loading={actionLoading}><RefreshCw size={15} /></Button>,
)
}
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 = text(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, 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>
<div className="an-task-summary__actions">
<Button size="sm" variant="subtle" onClick={() => openLogsForTask({ taskId, sourceId, source })}>
<FileText size={14} />
</Button>
</div>
</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'], '')
const taskType = text(overrides.taskType || record.task_type, 'collect')
return {
key: queueItemKey({ id: sourceId, source: record.source, task_id: taskId, task_type: taskType }),
sourceId,
source: text(record.source || record.collector_name, ''),
name: recordTitle(record),
taskId,
taskType,
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 title={item.name}>{item.name}</strong>
<p title={`${item.error || queuePrimaryMessage(item)}${item.taskId ? ` · task ${item.taskId}` : ''}${item.completedAt ? ` · ${formatDuration(item.createdAt, item.completedAt)}` : ''}`}>
{item.error || queuePrimaryMessage(item)}{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>
<Button size="icon" variant="subtle" title="查看日志" aria-label="查看日志" onClick={() => openLogsForTask(item)}>
<FileText 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={localizeAdminText(config.title, locale)}
description={localizeAdminText(config.description, locale)}
className="an-resource-page"
actions={(
<>
<Button size="icon" variant="subtle" onClick={() => void load()} loading={loading} title={localizeAdminText('刷新', locale)} aria-label={localizeAdminText('刷新', locale)}><RefreshCw size={15} /></Button>
{renderCollectionQueueAction()}
{moduleActions}
{config.actions.map((action) => (
<Button key={action.label} asChild size="icon" variant="subtle" title={localizeAdminText(action.label, locale)} aria-label={localizeAdminText(action.label, locale)}>
<Link to={action.to}>{action.icon}</Link>
</Button>
))}
</>
)}
>
<div className="an-page-grid">
<SummaryStrip>
<StatCell label={localizeAdminText('接口在线', locale)} value={`${summary.online}/${states.length || 1}`} hint={localizeAdminText('已加载分区', locale)} />
<StatCell label={localizeAdminText('记录数', locale)} value={summary.rows} hint={localizeAdminText('已加载分区汇总', locale)} />
<StatCell label={localizeAdminText('异常接口', locale)} value={summary.failing} hint={localizeAdminText('失败时页面仍可操作', locale)} />
</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>{localizeAdminText(activeSection?.label || config.listTitle, locale)}</h2>
<p>{localizeAdminText(config.listDescription, locale)}</p>
</div>
<StatusText tone={loading ? 'running' : activeState?.ok === false ? 'danger' : 'success'}>{localizeAdminText(loading ? '同步中' : activeState?.ok === false ? '接口失败' : '已就绪', locale)}</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) => localizeAdminText(`选择${recordTitle(row)}`, locale),
} : undefined}
/>
) : (
<EmptyState title={localizeAdminText(loading ? '正在加载数据' : '当前分区暂无记录', locale)} description={localizeAdminText('切换上方分区可精准查看不同配置和接口。', locale)} />
)}
</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={localizeAdminText('返回列表', locale)} aria-label={localizeAdminText('返回列表', locale)} onClick={() => setMobileResourceDetailOpen(false)}><ArrowLeft size={15} /></Button>
<strong>{selected ? localizeDisplayValue(recordTitle(selected), locale) : localizeAdminText(config.detailTitle, locale)}</strong>
</div>
<DetailPanel title={localizeAdminText(config.detailTitle, locale)} description={localizeDisplayValue(selected ? selected.__endpointLabel : '未选择记录', locale)}>
{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={localizeAdminText('返回上一级详情', locale)} aria-label={localizeAdminText('返回上一级详情', locale)} onClick={goBackResourceDetail} />
) : null}
<h3>{localizeDisplayValue(recordTitle(selected), locale)}</h3>
</div>
<StatusText tone={statusTone(recordDisplayStatus(selected))}>{localizeDisplayValue(recordDisplayStatus(selected), locale)}</StatusText>
</header>
{renderRecordActions()}
{renderDatasourceTaskSummary()}
<DetailMarkdownDocument record={selected} />
{renderStructuredEditor() || <DetailFields record={selected} />}
<div className="an-code-section">
<div className="an-code-section__header">
<strong>{localizeAdminText(selected.__endpointKey === 'logSnapshot' ? '日志正文' : '原始数据', locale)}</strong>
<Button size="icon" variant="subtle" onClick={copySelected} title={localizeAdminText('复制原始数据', locale)} aria-label={localizeAdminText('复制原始数据', locale)}><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={localizeAdminText('选择一条记录', locale)} description={localizeAdminText('详情会在右侧完整滚动显示,不会挤压主表区域。', locale)} />
)}
</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>
<Dialog
open={newsImportDialogOpen}
onOpenChange={(open) => {
setNewsImportDialogOpen(open)
if (!open) setNewsImportFile(null)
}}
title="导入 JSON 新闻"
description="上传 JSON 数组后,所有条目都会归入当前手动新闻组。"
width={520}
footer={(
<>
<Button variant="subtle" onClick={() => {
setNewsImportDialogOpen(false)
setNewsImportFile(null)
}} disabled={actionLoading}></Button>
<Button variant="primary" onClick={() => void importManualNewsJson()} loading={actionLoading} disabled={!newsImportFile}><ImageUp size={15} /></Button>
</>
)}
>
<input
ref={newsImportInputRef}
type="file"
accept="application/json,.json"
hidden
onChange={(event) => setNewsImportFile(event.target.files?.[0] ?? null)}
/>
<section className="an-field-cluster an-field-cluster--compact">
<div className="an-field-cluster__heading">
<h3>{newsImportFile ? newsImportFile.name : '选择 JSON 文件'}</h3>
<p> JSON </p>
</div>
<Button variant="subtle" onClick={() => newsImportInputRef.current?.click()}><ImageUp size={15} /></Button>
</section>
</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} />, '/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: 'news_sources',
label: '新闻源',
url: '/earth/news-sources',
map: (payload) => isObjectRecord(payload)
? [{
...payload,
__title: '新闻源配置',
__module: '新闻源',
__status: payload.is_default ? '默认配置' : '已配置',
__metric: `${arrayAt(payload, 'sources').length} 个源 / ${arrayAt(payload, 'categories').length} 个类型`,
__endpointKey: 'newsSources',
__endpointLabel: '新闻源',
}]
: [],
},
{
key: 'news_items',
label: '新闻内容',
url: '/earth/news-groups',
map: (payload) => arrayAt(payload, 'groups').map((group) => ({
...group,
__title: pick(group, ['name', 'feed_name', 'source', 'id'], '新闻组'),
__module: text(group.group_type, '') === 'manual' ? '手动新闻组' : 'RSS 来源',
__status: group.editable === false ? '只读' : '可编辑',
__metric: `${text(group.count, '0')}`,
__endpointKey: 'newsGroups',
__endpointLabel: '新闻内容',
})),
},
{ key: 'basemap', label: '底图资源', map: emptyRows },
{ key: 'layer_resources', label: '图层资源', map: emptyRows },
{ key: 'models_3d', label: '3D 模型', 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} />
}