release: bump version to 0.43.0

This commit is contained in:
linkong
2026-04-28 16:10:17 +08:00
parent 1cd2dab0ee
commit ac69d5d354
69 changed files with 6954 additions and 1141 deletions

View File

@@ -1,4 +1,5 @@
import { memo } from 'react'
import { CheckOutlined, CopyOutlined } from '@ant-design/icons'
import { memo, useState } from 'react'
import type { ReactNode } from 'react'
import Scrollbar from '../Scrollbar/Scrollbar'
@@ -15,13 +16,40 @@ interface MarkdownLink {
external?: boolean
}
interface ListLine {
indent: number
ordered: boolean
content: string
}
interface ListItemNode {
content: string
checked?: boolean
children: ReactNode[]
}
interface ParsedList {
node: ReactNode
nextIndex: number
}
const INLINE_PATTERN = /(!\[[^\]]*]\([^)]+\)|\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|~~[^~]+~~|\*[^*]+\*|https?:\/\/[^\s<)]+)/g
const COPY_FEEDBACK_MS = 1400
function resolveMarkdownLink(href: string, transformLink?: MarkdownRendererProps['transformLink']): MarkdownLink {
const resolvedLink = transformLink?.(href)
return {
href: resolvedLink?.href || href,
external: resolvedLink?.external ?? /^https?:\/\//.test(href),
}
}
function renderInlineMarkdown(text: string, transformLink?: MarkdownRendererProps['transformLink']): ReactNode[] {
const result: ReactNode[] = []
const pattern = /(\[[^\]]+\]\([^)]+\)|`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*)/g
let lastIndex = 0
let key = 0
for (const match of text.matchAll(pattern)) {
for (const match of text.matchAll(INLINE_PATTERN)) {
const matchedText = match[0]
const start = match.index ?? 0
@@ -29,27 +57,55 @@ function renderInlineMarkdown(text: string, transformLink?: MarkdownRendererProp
result.push(text.slice(lastIndex, start))
}
if (matchedText.startsWith('[')) {
const linkMatch = matchedText.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (linkMatch) {
const resolvedLink = transformLink?.(linkMatch[2])
const href = resolvedLink?.href || linkMatch[2]
const isExternal = resolvedLink?.external ?? true
const imageMatch = matchedText.match(/^!\[([^\]]*)]\(([^)]+)\)$/)
if (imageMatch) {
result.push(
<img
key={`inline-${key}`}
src={imageMatch[2]}
alt={imageMatch[1]}
loading="lazy"
className="markdown-renderer__image"
/>,
)
key += 1
lastIndex = start + matchedText.length
continue
}
result.push(
<a
key={`inline-${key}`}
href={href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noreferrer' : undefined}
>
{linkMatch[1]}
</a>,
)
key += 1
lastIndex = start + matchedText.length
continue
}
const linkMatch = matchedText.match(/^\[([^\]]+)]\(([^)]+)\)$/)
if (linkMatch) {
const link = resolveMarkdownLink(linkMatch[2], transformLink)
result.push(
<a
key={`inline-${key}`}
href={link.href}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noreferrer' : undefined}
>
{linkMatch[1]}
</a>,
)
key += 1
lastIndex = start + matchedText.length
continue
}
if (/^https?:\/\//.test(matchedText)) {
const link = resolveMarkdownLink(matchedText, transformLink)
result.push(
<a
key={`inline-${key}`}
href={link.href}
target={link.external ? '_blank' : undefined}
rel={link.external ? 'noreferrer' : undefined}
>
{matchedText}
</a>,
)
key += 1
lastIndex = start + matchedText.length
continue
}
if (matchedText.startsWith('`')) {
@@ -66,6 +122,13 @@ function renderInlineMarkdown(text: string, transformLink?: MarkdownRendererProp
continue
}
if (matchedText.startsWith('~~')) {
result.push(<del key={`inline-${key}`}>{matchedText.slice(2, -2)}</del>)
key += 1
lastIndex = start + matchedText.length
continue
}
if (matchedText.startsWith('*')) {
result.push(<em key={`inline-${key}`}>{matchedText.slice(1, -1)}</em>)
key += 1
@@ -81,6 +144,161 @@ function renderInlineMarkdown(text: string, transformLink?: MarkdownRendererProp
return result
}
async function copyToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.top = '-9999px'
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
function MarkdownCodeBlock({ code, language }: { code: string; language?: string }) {
const [copied, setCopied] = useState(false)
const label = language?.trim() || 'text'
const handleCopy = async () => {
await copyToClipboard(code)
setCopied(true)
window.setTimeout(() => setCopied(false), COPY_FEEDBACK_MS)
}
return (
<div className="markdown-renderer__code-block">
<div className="markdown-renderer__code-toolbar">
<span className="markdown-renderer__code-language">{label}</span>
<button
type="button"
className="markdown-renderer__code-copy"
onClick={handleCopy}
aria-label={copied ? '已复制代码' : '复制代码'}
title={copied ? '已复制' : '复制代码'}
>
{copied ? <CheckOutlined /> : <CopyOutlined />}
</button>
</div>
<Scrollbar className="markdown-renderer__code-scroll">
<pre>
<code className={language ? `language-${language}` : undefined}>{code}</code>
</pre>
</Scrollbar>
</div>
)
}
function parseListLine(line: string): ListLine | null {
const match = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.+)$/)
if (!match) return null
return {
indent: match[1].replace(/\t/g, ' ').length,
ordered: /^\d/.test(match[2]),
content: match[3],
}
}
function parseTaskContent(content: string): { content: string; checked?: boolean } {
const taskMatch = content.match(/^\[( |x|X)]\s+(.+)$/)
if (!taskMatch) return { content }
return {
content: taskMatch[2],
checked: taskMatch[1].toLowerCase() === 'x',
}
}
function renderListItemContent(
item: ListItemNode,
transformLink?: MarkdownRendererProps['transformLink'],
): ReactNode {
if (typeof item.checked === 'boolean') {
return (
<>
<input
type="checkbox"
checked={item.checked}
readOnly
tabIndex={-1}
className="markdown-renderer__task-checkbox"
/>
<span>{renderInlineMarkdown(item.content, transformLink)}</span>
</>
)
}
return renderInlineMarkdown(item.content, transformLink)
}
function parseList(
lines: string[],
startIndex: number,
baseIndent: number,
ordered: boolean,
transformLink?: MarkdownRendererProps['transformLink'],
): ParsedList {
const items: ListItemNode[] = []
let index = startIndex
while (index < lines.length) {
const listLine = parseListLine(lines[index])
if (!listLine) break
if (listLine.indent < baseIndent || listLine.ordered !== ordered) break
if (listLine.indent > baseIndent) {
if (items.length === 0) break
const nested = parseList(lines, index, listLine.indent, listLine.ordered, transformLink)
items[items.length - 1].children.push(nested.node)
index = nested.nextIndex
continue
}
const taskContent = parseTaskContent(listLine.content)
items.push({
content: taskContent.content,
checked: taskContent.checked,
children: [],
})
index += 1
}
const Tag = ordered ? 'ol' : 'ul'
return {
node: (
<Tag key={`block-${startIndex}`} className={items.some((item) => typeof item.checked === 'boolean') ? 'markdown-renderer__task-list' : undefined}>
{items.map((item, itemIndex) => (
<li key={`item-${startIndex}-${itemIndex}`} className={typeof item.checked === 'boolean' ? 'markdown-renderer__task-item' : undefined}>
{renderListItemContent(item, transformLink)}
{item.children}
</li>
))}
</Tag>
),
nextIndex: index,
}
}
function renderHeading(
level: number,
id: string | undefined,
content: ReactNode[],
key: string,
): ReactNode {
if (level === 1) return <h1 key={key} id={id}>{content}</h1>
if (level === 2) return <h2 key={key} id={id}>{content}</h2>
if (level === 3) return <h3 key={key} id={id}>{content}</h3>
if (level === 4) return <h4 key={key} id={id}>{content}</h4>
if (level === 5) return <h5 key={key} id={id}>{content}</h5>
return <h6 key={key} id={id}>{content}</h6>
}
function MarkdownRenderer({
markdown,
className,
@@ -106,8 +324,10 @@ function MarkdownRenderer({
continue
}
if (trimmed.startsWith('```')) {
const fenceMatch = trimmed.match(/^```([^`]*)$/)
if (fenceMatch) {
const codeLines: string[] = []
const language = fenceMatch[1].trim().split(/\s+/)[0]
index += 1
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index])
@@ -117,11 +337,11 @@ function MarkdownRenderer({
index += 1
}
nodes.push(
<Scrollbar key={`block-${index}`} className="markdown-renderer__code-scroll">
<pre>
<code>{codeLines.join('\n')}</code>
</pre>
</Scrollbar>,
<MarkdownCodeBlock
key={`block-${index}`}
code={codeLines.join('\n')}
language={language || undefined}
/>,
)
continue
}
@@ -135,63 +355,33 @@ function MarkdownRenderer({
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
const level = headingMatch[1].length
const headingText = headingMatch[2]
const headingText = headingMatch[2].replace(/\s+#+\s*$/, '')
const content = renderInlineMarkdown(headingText, transformLink)
const headingId = resolveHeadingId?.(headingText, level)
if (level === 1) nodes.push(<h1 key={`block-${index}`} id={headingId}>{content}</h1>)
else if (level === 2) nodes.push(<h2 key={`block-${index}`} id={headingId}>{content}</h2>)
else if (level === 3) nodes.push(<h3 key={`block-${index}`} id={headingId}>{content}</h3>)
else if (level === 4) nodes.push(<h4 key={`block-${index}`} id={headingId}>{content}</h4>)
else nodes.push(<p key={`block-${index}`} className="markdown-renderer__heading-fallback">{content}</p>)
nodes.push(renderHeading(level, headingId, content, `block-${index}`))
index += 1
continue
}
if (trimmed.startsWith('> ')) {
if (trimmed.startsWith('>')) {
const quoteLines: string[] = []
while (index < lines.length && lines[index].trim().startsWith('> ')) {
quoteLines.push(lines[index].trim().slice(2))
index += 1
}
nodes.push(<blockquote key={`block-${index}`}>{quoteLines.join(' ')}</blockquote>)
continue
}
const unorderedMatch = trimmed.match(/^[-*]\s+(.+)$/)
if (unorderedMatch) {
const items: string[] = []
while (index < lines.length) {
const itemMatch = lines[index].trim().match(/^[-*]\s+(.+)$/)
if (!itemMatch) break
items.push(itemMatch[1])
while (index < lines.length && lines[index].trim().startsWith('>')) {
quoteLines.push(lines[index].trim().replace(/^>\s?/, ''))
index += 1
}
nodes.push(
<ul key={`block-${index}`}>
{items.map((item, itemIndex) => (
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item, transformLink)}</li>
))}
</ul>,
<blockquote key={`block-${index}`}>
{renderInlineMarkdown(quoteLines.join(' '), transformLink)}
</blockquote>,
)
continue
}
const orderedMatch = trimmed.match(/^\d+\.\s+(.+)$/)
if (orderedMatch) {
const items: string[] = []
while (index < lines.length) {
const itemMatch = lines[index].trim().match(/^\d+\.\s+(.+)$/)
if (!itemMatch) break
items.push(itemMatch[1])
index += 1
}
nodes.push(
<ol key={`block-${index}`}>
{items.map((item, itemIndex) => (
<li key={`item-${itemIndex}`}>{renderInlineMarkdown(item, transformLink)}</li>
))}
</ol>,
)
const listLine = parseListLine(line)
if (listLine) {
const parsedList = parseList(lines, index, listLine.indent, listLine.ordered, transformLink)
nodes.push(parsedList.node)
index = parsedList.nextIndex
continue
}
@@ -241,9 +431,24 @@ function MarkdownRenderer({
const paragraphLines: string[] = []
while (index < lines.length && lines[index].trim()) {
if (
lines[index].trim().startsWith('```') ||
lines[index].trim().startsWith('>') ||
parseListLine(lines[index]) ||
/^#{1,6}\s+/.test(lines[index].trim()) ||
/^(-{3,}|\*{3,}|_{3,})$/.test(lines[index].trim())
) {
break
}
paragraphLines.push(lines[index].trim())
index += 1
}
if (paragraphLines.length === 0) {
index += 1
continue
}
nodes.push(<p key={`block-${index}`}>{renderInlineMarkdown(paragraphLines.join(' '), transformLink)}</p>)
}

View File

@@ -2080,7 +2080,9 @@ body {
.markdown-renderer h1,
.markdown-renderer h2,
.markdown-renderer h3,
.markdown-renderer h4 {
.markdown-renderer h4,
.markdown-renderer h5,
.markdown-renderer h6 {
margin: 1.2em 0 0.5em;
color: #111827;
font-weight: 600;
@@ -2099,12 +2101,20 @@ body {
font-size: 16px;
}
.markdown-renderer h4 {
font-size: 15px;
}
.markdown-renderer h5,
.markdown-renderer h6 {
font-size: 14px;
}
.markdown-renderer p,
.markdown-renderer ul,
.markdown-renderer ol,
.markdown-renderer blockquote,
.markdown-renderer pre,
.markdown-renderer__code-scroll,
.markdown-renderer__code-block,
.markdown-renderer hr,
.markdown-renderer__table-wrap {
margin: 0 0 0.9em;
@@ -2119,6 +2129,34 @@ body {
margin-top: 0.25em;
}
.markdown-renderer li > ul,
.markdown-renderer li > ol {
margin: 0.3em 0 0;
}
.markdown-renderer__task-list {
list-style: none;
padding-left: 0;
}
.markdown-renderer__task-item {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
}
.markdown-renderer__task-item > ul,
.markdown-renderer__task-item > ol {
flex-basis: 100%;
}
.markdown-renderer__task-checkbox {
flex: 0 0 auto;
margin-top: 0.42em;
accent-color: #1677ff;
}
.markdown-renderer blockquote {
padding: 10px 14px;
border-left: 3px solid #91caff;
@@ -2127,17 +2165,67 @@ body {
color: #1f2937;
}
.markdown-renderer__code-block {
overflow: hidden;
border-radius: 10px;
background: #0f172a;
box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.18);
}
.markdown-renderer__code-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 34px;
padding: 6px 8px 6px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(15, 23, 42, 0.94);
color: #cbd5e1;
font-size: 12px;
}
.markdown-renderer__code-language {
overflow: hidden;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
text-overflow: ellipsis;
white-space: nowrap;
}
.markdown-renderer__code-copy {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 26px;
height: 26px;
padding: 0;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 6px;
background: rgba(30, 41, 59, 0.88);
color: #e2e8f0;
cursor: pointer;
font-size: 12px;
line-height: 1;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.markdown-renderer__code-copy:hover {
border-color: rgba(191, 219, 254, 0.55);
background: rgba(51, 65, 85, 0.96);
color: #ffffff;
}
.markdown-renderer pre {
overflow: visible;
padding: 12px 14px;
border-radius: 10px;
border-radius: 0;
background: #0f172a;
color: #e2e8f0;
}
.markdown-renderer__code-scroll {
max-width: 100%;
border-radius: 10px;
border-radius: 0;
}
.markdown-renderer__code-scroll > .scrollbar__viewport,
@@ -2150,6 +2238,14 @@ body {
margin: 0;
}
.markdown-renderer__image {
display: block;
max-width: 100%;
height: auto;
margin: 0.6em 0;
border-radius: 8px;
}
.markdown-renderer hr {
border: 0;
border-top: 1px solid rgba(15, 23, 42, 0.12);

View File

@@ -3,24 +3,29 @@ import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import {
Table, Tag, Space, Button, Form, Input, Select, Progress, Checkbox, message, Modal,
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card
Drawer, Tabs, Empty, Tooltip, Popconfirm, Collapse, InputNumber, Row, Col, Card, Alert, Typography
} from 'antd'
import {
PlayCircleOutlined, PauseCircleOutlined, PlusOutlined,
EditOutlined, DeleteOutlined, ApiOutlined,
CheckCircleOutlined, CloseCircleOutlined, ExperimentOutlined,
SyncOutlined, ClearOutlined, CopyOutlined
SyncOutlined, ClearOutlined, CopyOutlined, InfoCircleOutlined
} from '@ant-design/icons'
import axios, { type AxiosResponse } from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useWebSocket } from '../../hooks/useWebSocket'
import { Link } from 'react-router-dom'
const { Text } = Typography
const COLLECTION_REFRESH_DELAY_MS = 800
interface BuiltInDataSource {
id: number
source: string
name: string
display_name?: string
module: string
priority: string
frequency: string
@@ -30,14 +35,16 @@ interface BuiltInDataSource {
last_run: string | null
last_run_at?: string | null
last_status?: string | null
last_records_processed?: number | null
data_count?: number
is_running: boolean
task_id: number | null
progress: number | null
phase?: string | null
records_processed: number | null
total_records: number | null
is_free?: boolean
requires_credentials?: boolean
credential_provider?: string | null
credential_status?: string
}
interface TaskTrackerState {
@@ -207,6 +214,26 @@ interface ViewDataSource {
module: string
priority: string
frequency: string
display_name?: string
is_free?: boolean
requires_credentials?: boolean
credential_provider?: string | null
credential_status?: string
}
interface TargetSchemaField {
name: string
type: string
required: boolean
description: string
}
interface TargetSchema {
key: string
label: string
description: string
destination: string
fields: TargetSchemaField[]
}
function DataSources() {
@@ -221,6 +248,15 @@ function DataSources() {
const [editingConfig, setEditingConfig] = useState<CustomDataSource | null>(null)
const [builtinEditingSource, setBuiltinEditingSource] = useState<BuiltInDataSource | null>(null)
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
const [mappingSource, setMappingSource] = useState<CustomDataSource | null>(null)
const [mappingDrawerVisible, setMappingDrawerVisible] = useState(false)
const [targetSchemas, setTargetSchemas] = useState<TargetSchema[]>([])
const [selectedTargetSchema, setSelectedTargetSchema] = useState<string>('generic_records')
const [samplePayload, setSamplePayload] = useState<any>(null)
const [sampleText, setSampleText] = useState('')
const [mappingText, setMappingText] = useState('')
const [mappingPreview, setMappingPreview] = useState<any>(null)
const [mappingLoading, setMappingLoading] = useState<Record<string, boolean>>({})
const [recordCount, setRecordCount] = useState<number>(0)
const [testing, setTesting] = useState(false)
const [triggerAllLoading, setTriggerAllLoading] = useState(false)
@@ -281,7 +317,7 @@ function DataSources() {
const getBuiltinOverrideDescription = useCallback(
(source?: Pick<BuiltInDataSource, 'name'> | null) =>
source ? `Built-in datasource override for ${source.name}` : undefined,
source ? `内置采集器覆盖配置:${source.name}` : undefined,
[],
)
@@ -292,7 +328,9 @@ function DataSources() {
values.description ||
getBuiltinOverrideDescription(builtinEditingSource),
source_type: builtinEditingSource ? 'http' : values.source_type,
headers: headersListToMap(values.headers),
auth_type: builtinEditingSource ? 'none' : values.auth_type,
auth_config: builtinEditingSource ? {} : values.auth_config,
headers: builtinEditingSource ? {} : headersListToMap(values.headers),
}), [builtinEditingSource, getBuiltinOverrideDescription, headersListToMap])
const closeDrawerAfterLoadError = useCallback((
@@ -612,13 +650,11 @@ function DataSources() {
status: 'running',
},
}))
fetchData()
} else {
window.setTimeout(() => {
fetchData()
}, 800)
window.setTimeout(fetchData, COLLECTION_REFRESH_DELAY_MS)
}
fetchData()
return {
ok: true,
response: res,
@@ -811,6 +847,7 @@ function DataSources() {
setViewingSource({
id: data.id,
name: data.name,
display_name: data.display_name,
description: null,
source_type: data.collector_class,
endpoint: overrideDetail?.endpoint || data.endpoint || '',
@@ -821,6 +858,10 @@ function DataSources() {
module: data.module,
priority: data.priority,
frequency: data.frequency,
is_free: data.is_free,
requires_credentials: data.requires_credentials,
credential_provider: data.credential_provider,
credential_status: data.credential_status,
})
setRecordCount(statsRes.data.total_records || 0)
setViewDrawerVisible(true)
@@ -923,6 +964,150 @@ function DataSources() {
}
}
const setMappingStepLoading = (key: string, value: boolean) => {
setMappingLoading((prev) => ({ ...prev, [key]: value }))
}
const parseJsonText = (value: string, label: string) => {
try {
return JSON.parse(value)
} catch {
throw new Error(`${label} 不是合法 JSON`)
}
}
const openMappingDrawer = async (source: CustomDataSource) => {
setMappingSource(source)
setMappingDrawerVisible(true)
setSamplePayload(null)
setSampleText('')
setMappingText('')
setMappingPreview(null)
try {
const [schemasRes, mappingsRes] = await Promise.all([
axios.get('/api/v1/datasources/target-schemas'),
axios.get('/api/v1/datasources/mappings', {
params: { datasource_config_id: source.id, active_only: true },
}),
])
const schemas = schemasRes.data.data || []
setTargetSchemas(schemas)
const activeMapping = mappingsRes.data.data?.[0]
const nextSchema = activeMapping?.target_schema || schemas[0]?.key || 'generic_records'
setSelectedTargetSchema(nextSchema)
if (activeMapping?.mapping_json) {
setMappingText(JSON.stringify(activeMapping.mapping_json, null, 2))
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || '加载映射配置失败')
}
}
const handleFetchSample = async () => {
if (!mappingSource) return
setMappingStepLoading('sample', true)
try {
const res = await axios.post('/api/v1/datasources/custom/sample', {
datasource_config_id: mappingSource.id,
})
setSamplePayload(res.data.sample_payload)
setSampleText(JSON.stringify(res.data.sample_payload, null, 2))
setMappingPreview(null)
messageApi.success('样本已抓取')
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || '抓取样本失败')
} finally {
setMappingStepLoading('sample', false)
}
}
const handleProposeMapping = async () => {
const payload = samplePayload || parseJsonText(sampleText, '样本')
setMappingStepLoading('propose', true)
try {
const res = await axios.post('/api/v1/datasources/mappings/propose', {
sample_payload: payload,
target_schema: selectedTargetSchema,
use_ai: true,
})
setMappingText(JSON.stringify(res.data.mapping_json, null, 2))
setMappingPreview(null)
messageApi.success('映射草案已生成')
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || (error instanceof Error ? error.message : '生成映射失败'))
} finally {
setMappingStepLoading('propose', false)
}
}
const handlePreviewMapping = async () => {
setMappingStepLoading('preview', true)
try {
const payload = samplePayload || parseJsonText(sampleText, '样本')
const mappingJson = parseJsonText(mappingText, '映射配置')
const res = await axios.post('/api/v1/datasources/mappings/preview', {
sample_payload: payload,
target_schema: selectedTargetSchema,
mapping_json: mappingJson,
limit: 20,
})
setMappingPreview(res.data.preview)
messageApi[res.data.success ? 'success' : 'warning'](
res.data.success ? '预览校验通过' : '预览完成,但存在校验错误',
)
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || (error instanceof Error ? error.message : '预览失败'))
} finally {
setMappingStepLoading('preview', false)
}
}
const handleSaveMapping = async () => {
if (!mappingSource) return
setMappingStepLoading('save', true)
try {
const payload = samplePayload || parseJsonText(sampleText, '样本')
const mappingJson = parseJsonText(mappingText, '映射配置')
await axios.post('/api/v1/datasources/mappings', {
datasource_config_id: mappingSource.id,
target_schema: selectedTargetSchema,
mapping_json: mappingJson,
sample_payload: payload,
validation_status: mappingPreview?.failed_count === 0 ? 'valid' : 'draft',
is_active: true,
})
messageApi.success('映射已保存并启用')
setMappingDrawerVisible(false)
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || (error instanceof Error ? error.message : '保存映射失败'))
} finally {
setMappingStepLoading('save', false)
}
}
const handleRunMapped = async () => {
if (!mappingSource) return
setMappingStepLoading('run', true)
try {
const res = await axios.post(`/api/v1/datasources/${mappingSource.id}/run-mapped`)
if (res.data.status === 'success') {
messageApi.success(`已写入 ${res.data.written_count || 0}`)
} else {
messageApi.error(`采集失败:${res.data.failed_count || 0} 条未通过映射`)
}
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || '运行映射采集失败')
} finally {
setMappingStepLoading('run', false)
}
}
const openDrawer = async (config?: CustomDataSource) => {
setBuiltinEditingSource(null)
setEditingConfig(config || null)
@@ -997,14 +1182,17 @@ function DataSources() {
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60, fixed: 'left' as const },
{
title: '名称',
dataIndex: 'name',
dataIndex: 'display_name',
key: 'name',
width: 180,
width: 220,
ellipsis: true,
render: (name: string, record: BuiltInDataSource) => (
<Button type="link" onClick={() => handleViewSource(record)}>
{name}
</Button>
<Space direction="vertical" size={0}>
<Button type="link" style={{ padding: 0, height: 22 }} onClick={() => handleViewSource(record)}>
{name || record.name}
</Button>
<Text type="secondary" style={{ fontSize: 12 }}>{record.source}</Text>
</Space>
),
},
{ title: '模块', dataIndex: 'module', key: 'module', width: 80 },
@@ -1022,12 +1210,7 @@ function DataSources() {
key: 'last_run',
width: 180,
render: (_: string | null, record: BuiltInDataSource) => {
const label = formatDateTimeZhCN(record.last_run_at || record.last_run)
if (!label || label === '-') return '-'
if ((record.data_count || 0) === 0 && record.last_status === 'success') {
return `${label} (0条)`
}
return label
return formatDateTimeZhCN(record.last_run_at || record.last_run) || '-'
},
},
{
@@ -1192,6 +1375,12 @@ function DataSources() {
icon: <EditOutlined />,
onClick: () => { void openDrawer(record) },
},
{
key: 'mapping',
label: '映射',
icon: <ExperimentOutlined />,
onClick: () => { void openMappingDrawer(record) },
},
{
key: 'toggle',
label: record.is_active ? '禁用' : '启用',
@@ -1215,6 +1404,7 @@ function DataSources() {
]}
>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => { void openDrawer(record) }}></Button>
<Button type="link" size="small" icon={<ExperimentOutlined />} onClick={() => { void openMappingDrawer(record) }}></Button>
<Button
type="link"
size="small"
@@ -1236,7 +1426,7 @@ function DataSources() {
const tabItems = [
{
key: 'builtin',
label: '内置数据源',
label: '内置采集器',
children: (
<div className="page-shell__body data-source-builtin-tab" ref={builtinContainerRef}>
<div className="data-source-bulk-toolbar">
@@ -1283,6 +1473,9 @@ function DataSources() {
</div>
</div>
<Space size={12} align="center">
<Tooltip title="内置采集器由系统维护。这里查看状态、触发采集、覆盖 endpoint需要凭证的采集器请到设置中心维护凭证。">
<InfoCircleOutlined style={{ fontSize: 16, color: '#8c8c8c', cursor: 'default' }} />
</Tooltip>
<Checkbox
checked={forceTriggerAll}
onChange={(event) => setForceTriggerAll(event.target.checked)}
@@ -1307,7 +1500,7 @@ function DataSources() {
rowKey="id"
loading={loading}
pagination={false}
scroll={{ x: 800, y: builtinTableHeight }}
scroll={{ x: 1200, y: builtinTableHeight }}
tableLayout="fixed"
size="small"
/>
@@ -1320,19 +1513,22 @@ function DataSources() {
key: 'custom',
label: (
<span>
<ApiOutlined />
<ApiOutlined /> API
</span>
),
children: (
<div className="page-shell__body data-source-custom-tab" ref={customContainerRef}>
<div className="data-source-custom-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={() => { void openDrawer() }}>
API
</Button>
<Tooltip title="自定义 API 源是轻量 API 连接器:配置请求、抓样本、映射成目标数据结构,再保存为可采集的数据源。">
<InfoCircleOutlined style={{ fontSize: 16, color: '#8c8c8c', cursor: 'default' }} />
</Tooltip>
</div>
{customSources.length === 0 ? (
<div className="data-source-empty-state">
<Empty description="暂无自定义数据源" />
<Empty description="暂无自定义 API 源" />
</div>
) : (
<div ref={customTableRegionRef} className="table-scroll-region data-source-table-region">
@@ -1360,7 +1556,7 @@ function DataSources() {
{modalContextHolder}
<div className="page-shell">
<div className="page-shell__header">
<h2 style={{ margin: 0 }}></h2>
<h2 style={{ margin: 0 }}> API </h2>
</div>
<div className="page-shell__body">
<div className="data-source-tabs-shell">
@@ -1370,7 +1566,7 @@ function DataSources() {
</div>
<Drawer
title={builtinEditingSource ? `编辑内置数据源配置 · ${builtinEditingSource.name}` : editingConfig ? '编辑数据源' : '添加数据源'}
title={builtinEditingSource ? `编辑内置采集器覆盖配置 · ${builtinEditingSource.name}` : editingConfig ? '编辑 API 源' : '添加 API 源'}
width={600}
open={drawerVisible}
onClose={() => {
@@ -1386,7 +1582,7 @@ function DataSources() {
{builtinEditingSource && editingConfig ? (
<Popconfirm
title="恢复内置默认配置?"
description="这会删除当前 override,并重新使用代码内置默认配置。"
description="这会删除当前覆盖配置,并重新使用代码内置默认配置。"
okText="恢复默认"
cancelText="取消"
onConfirm={handleResetBuiltinOverride}
@@ -1396,13 +1592,15 @@ function DataSources() {
</Button>
</Popconfirm>
) : null}
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleTest}
>
</Button>
{!builtinEditingSource ? (
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleTest}
>
</Button>
) : null}
</Space>
<Space>
<Button onClick={() => setDrawerVisible(false)}></Button>
@@ -1416,13 +1614,18 @@ function DataSources() {
<Form form={form} layout="vertical">
{builtinEditingSource ? (
<Card size="small" bordered={false} style={{ marginBottom: 16, background: '#fafafa' }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<Tooltip title="这里只覆盖接口地址和运行参数;凭证请到设置中心的采集器凭证统一维护。">
<InfoCircleOutlined style={{ fontSize: 16, color: '#8c8c8c', cursor: 'default' }} />
</Tooltip>
</div>
<Row gutter={[12, 12]}>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={builtinEditingSource.name} disabled />
</Col>
<Col span={12}>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>Collector Key</div>
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={builtinEditingSource.source} disabled />
</Col>
</Row>
@@ -1438,7 +1641,7 @@ function DataSources() {
)}
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} placeholder="数据源描述" />
<Input.TextArea rows={2} placeholder="数据源描述" />
</Form.Item>
{builtinEditingSource ? null : (
@@ -1448,7 +1651,7 @@ function DataSources() {
rules={[{ required: true, message: '请选择类型' }]}
>
<Select>
<Select.Option value="http">HTTP API</Select.Option>
<Select.Option value="http">HTTP API </Select.Option>
<Select.Option value="api">REST API</Select.Option>
<Select.Option value="database"></Select.Option>
</Select>
@@ -1463,113 +1666,117 @@ function DataSources() {
<Input placeholder="https://api.example.com/data" />
</Form.Item>
<Collapse
className="data-source-drawer-collapse"
items={[
{
key: 'auth',
label: '认证配置',
children: (
<>
<Form.Item name="auth_type" label="认证方式">
<Select>
<Select.Option value="none"></Select.Option>
<Select.Option value="bearer">Bearer Token</Select.Option>
<Select.Option value="api_key">API Key</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
</Select>
</Form.Item>
<div>
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'bearer'}>
{({ getFieldValue }) => {
if (getFieldValue('auth_type') === 'bearer') {
return (
<Form.Item name={['auth_config', 'token']} label="Token">
<Input.Password placeholder="Bearer Token" />
</Form.Item>
)
}
return null
}}
{!builtinEditingSource ? (
<Collapse
className="data-source-drawer-collapse"
items={[
{
key: 'auth',
label: '认证配置',
children: (
<>
<Form.Item name="auth_type" label="认证方式">
<Select>
<Select.Option value="none"></Select.Option>
<Select.Option value="bearer">Bearer Token</Select.Option>
<Select.Option value="api_key">API Key</Select.Option>
<Select.Option value="basic">Basic Auth</Select.Option>
</Select>
</Form.Item>
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'api_key'}>
{({ getFieldValue }) => {
if (getFieldValue('auth_type') === 'api_key') {
return (
<>
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
<Input placeholder="X-API-Key" />
<div>
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'bearer'}>
{({ getFieldValue }) => {
if (getFieldValue('auth_type') === 'bearer') {
return (
<Form.Item name={['auth_config', 'token']} label="Token">
<Input.Password placeholder="Bearer Token" />
</Form.Item>
<Form.Item name={['auth_config', 'in']} label="传递位置" initialValue="header">
<Select>
<Select.Option value="header">Header</Select.Option>
<Select.Option value="query">Query Param</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['auth_config', 'api_key']} label="API Key">
<Input.Password placeholder="API Key" />
</Form.Item>
</>
)
}
return null
}}
</Form.Item>
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'basic'}>
{({ getFieldValue }) => {
if (getFieldValue('auth_type') === 'basic') {
return (
<>
<Form.Item name={['auth_config', 'username']} label="用户名">
<Input placeholder="Username" />
</Form.Item>
<Form.Item name={['auth_config', 'password']} label="密码">
<Input.Password placeholder="Password" />
</Form.Item>
</>
)
}
return null
}}
</Form.Item>
</div>
</>
),
},
]}
/>
)
}
return null
}}
</Form.Item>
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'api_key'}>
{({ getFieldValue }) => {
if (getFieldValue('auth_type') === 'api_key') {
return (
<>
<Form.Item name={['auth_config', 'key_name']} label="Header名称" initialValue="X-API-Key">
<Input placeholder="X-API-Key" />
</Form.Item>
<Form.Item name={['auth_config', 'in']} label="传递位置" initialValue="header">
<Select>
<Select.Option value="header">Header</Select.Option>
<Select.Option value="query">Query Param</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['auth_config', 'api_key']} label="API Key">
<Input.Password placeholder="API Key" />
</Form.Item>
</>
)
}
return null
}}
</Form.Item>
<Form.Item noStyle shouldUpdate={(_, { auth_type }) => auth_type === 'basic'}>
{({ getFieldValue }) => {
if (getFieldValue('auth_type') === 'basic') {
return (
<>
<Form.Item name={['auth_config', 'username']} label="用户名">
<Input placeholder="Username" />
</Form.Item>
<Form.Item name={['auth_config', 'password']} label="密码">
<Input.Password placeholder="Password" />
</Form.Item>
</>
)
}
return null
}}
</Form.Item>
</div>
</>
),
},
]}
/>
) : null}
<Collapse
className="data-source-drawer-collapse"
items={[
{
key: 'headers',
label: '请求头',
children: (
<Form.List name="headers">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item {...restField} name={[name, 'key']} rules={[{ required: true, message: 'Header键' }]}>
<Input placeholder="Content-Type" />
</Form.Item>
<Form.Item {...restField} name={[name, 'value']} rules={[{ required: true, message: 'Header值' }]}>
<Input placeholder="application/json" />
</Form.Item>
<Button type="link" danger onClick={() => remove(name)}></Button>
</Space>
))}
<Button type="dashed" onClick={() => add()} block>
</Button>
</>
)}
</Form.List>
),
},
]}
/>
{!builtinEditingSource ? (
<Collapse
className="data-source-drawer-collapse"
items={[
{
key: 'headers',
label: '请求头',
children: (
<Form.List name="headers">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item {...restField} name={[name, 'key']} rules={[{ required: true, message: 'Header键' }]}>
<Input placeholder="Content-Type" />
</Form.Item>
<Form.Item {...restField} name={[name, 'value']} rules={[{ required: true, message: 'Header值' }]}>
<Input placeholder="application/json" />
</Form.Item>
<Button type="link" danger onClick={() => remove(name)}></Button>
</Space>
))}
<Button type="dashed" onClick={() => add()} block>
</Button>
</>
)}
</Form.List>
),
},
]}
/>
) : null}
<Collapse
className="data-source-drawer-collapse"
@@ -1622,6 +1829,151 @@ function DataSources() {
</Form>
</Drawer>
<Drawer
title={mappingSource ? `自定义映射 · ${mappingSource.name}` : '自定义映射'}
width={760}
open={mappingDrawerVisible}
onClose={() => {
setMappingDrawerVisible(false)
setMappingSource(null)
setMappingPreview(null)
}}
footer={
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button
icon={<SyncOutlined />}
loading={mappingLoading.run}
disabled={!mappingSource}
onClick={handleRunMapped}
>
</Button>
<Space>
<Button onClick={() => setMappingDrawerVisible(false)}></Button>
<Button
type="primary"
loading={mappingLoading.save}
disabled={!mappingText || !sampleText}
onClick={handleSaveMapping}
>
</Button>
</Space>
</div>
}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="LLM 只生成映射草案,预览和采集使用确定性转换引擎。"
/>
<Card size="small" title="1. 样本">
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Button
icon={<ExperimentOutlined />}
loading={mappingLoading.sample}
onClick={handleFetchSample}
>
</Button>
<Input.TextArea
value={sampleText}
onChange={(event) => {
setSampleText(event.target.value)
setSamplePayload(null)
setMappingPreview(null)
}}
rows={8}
placeholder='{"data":[...]}'
/>
</Space>
</Card>
<Card size="small" title="2. 目标 schema">
<Select
value={selectedTargetSchema}
style={{ width: '100%' }}
onChange={(value) => {
setSelectedTargetSchema(value)
setMappingPreview(null)
}}
options={targetSchemas.map((schema) => ({
value: schema.key,
label: `${schema.label} · ${schema.destination}`,
}))}
/>
{targetSchemas.find((schema) => schema.key === selectedTargetSchema) ? (
<div style={{ marginTop: 12 }}>
<Space size={[6, 6]} wrap>
{targetSchemas.find((schema) => schema.key === selectedTargetSchema)?.fields.map((field) => (
<Tag key={field.name} color={field.required ? 'blue' : 'default'}>
{field.name}:{field.type}{field.required ? '*' : ''}
</Tag>
))}
</Space>
</div>
) : null}
</Card>
<Card
size="small"
title="3. Mapping"
extra={
<Space>
<Button
size="small"
loading={mappingLoading.propose}
disabled={!sampleText}
onClick={handleProposeMapping}
>
AI
</Button>
<Button
size="small"
loading={mappingLoading.preview}
disabled={!sampleText || !mappingText}
onClick={handlePreviewMapping}
>
</Button>
</Space>
}
>
<Input.TextArea
value={mappingText}
onChange={(event) => {
setMappingText(event.target.value)
setMappingPreview(null)
}}
rows={12}
placeholder='{"source":{"items_path":"$.data[*]"},"fields":{...}}'
/>
</Card>
{mappingPreview ? (
<Card size="small" title="4. 预览结果">
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Space wrap>
<Tag color="blue"> {mappingPreview.total_items || 0}</Tag>
<Tag color="green"> {mappingPreview.mapped_count || 0}</Tag>
<Tag color={mappingPreview.failed_count ? 'red' : 'default'}> {mappingPreview.failed_count || 0}</Tag>
</Space>
<Input.TextArea
value={JSON.stringify({
records: mappingPreview.records || [],
errors: mappingPreview.errors || [],
}, null, 2)}
rows={10}
readOnly
/>
</Space>
</Card>
) : null}
</Space>
</Drawer>
<Drawer
title="查看数据源"
width={600}
@@ -1643,13 +1995,6 @@ function DataSources() {
</Button>
</Popconfirm>
<Space>
<Button
icon={<ExperimentOutlined />}
loading={testing}
onClick={handleTest}
>
</Button>
<Button onClick={() => setViewDrawerVisible(false)}></Button>
<Button
type="primary"
@@ -1690,6 +2035,25 @@ function DataSources() {
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}></div>
<Input value={viewingSource.collector_class} disabled />
</Col>
{viewingSource.requires_credentials ? (
<Col span={24}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<Space direction="vertical" size={0}>
<Text strong></Text>
<Text type="secondary">
{viewingSource.credential_status === 'supported'
? '请在设置中心维护该采集器的外部服务凭证。'
: '该采集器需要凭证,配置入口待接入。'}
</Text>
</Space>
{viewingSource.credential_status === 'supported' ? (
<Link to="/settings?tab=collector_credentials">
<Button size="small"></Button>
</Link>
) : null}
</div>
</Col>
) : null}
</Row>
</Card>
@@ -1709,29 +2073,9 @@ function DataSources() {
<Collapse
items={[
{
key: 'auth',
label: '认证配置',
children: (
<Form.Item label="认证方式" style={{ marginBottom: 0 }}>
<Input value={viewingSource.auth_type || 'none'} disabled />
</Form.Item>
),
},
{
key: 'headers',
label: '请求头',
children: viewingSource.headers && Object.keys(viewingSource.headers).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', margin: 0 }}>
{JSON.stringify(viewingSource.headers, null, 2)}
</pre>
) : (
<div style={{ color: '#999' }}></div>
),
},
{
key: 'config',
label: '高级配置',
label: '运行参数',
children: viewingSource.config && Object.keys(viewingSource.config).length > 0 ? (
<pre style={{ background: '#f5f5f5', padding: 12, borderRadius: 4, overflow: 'auto', margin: 0 }}>
{JSON.stringify(viewingSource.config, null, 2)}

View File

@@ -534,9 +534,19 @@
color: var(--d-heading);
}
.docs-markdown.markdown-renderer h4,
.docs-markdown.markdown-renderer h5,
.docs-markdown.markdown-renderer h6 {
margin-top: 24px;
color: var(--d-heading);
}
.docs-markdown.markdown-renderer h1,
.docs-markdown.markdown-renderer h2,
.docs-markdown.markdown-renderer h3 {
.docs-markdown.markdown-renderer h3,
.docs-markdown.markdown-renderer h4,
.docs-markdown.markdown-renderer h5,
.docs-markdown.markdown-renderer h6 {
scroll-margin-top: 24px;
}
@@ -553,16 +563,39 @@
font-size: 0.88em;
}
.docs-markdown.markdown-renderer pre {
.docs-markdown .markdown-renderer__code-block {
border: 1px solid var(--d-code-border);
border-radius: 8px;
background: var(--d-code-bg);
overflow: visible;
box-shadow: none;
}
.docs-markdown.markdown-renderer pre {
background: var(--d-code-bg);
color: var(--d-code-text);
}
.docs-markdown .markdown-renderer__code-toolbar {
border-bottom: 1px solid var(--d-code-border);
background: var(--d-state-bg);
color: var(--d-toc-text);
}
.docs-markdown .markdown-renderer__code-copy {
border-color: var(--d-code-border);
background: var(--d-bg);
color: var(--d-text);
}
.docs-markdown .markdown-renderer__code-copy:hover {
border-color: var(--d-link);
background: var(--d-code-bg);
color: var(--d-heading);
}
.docs-markdown .markdown-renderer__code-scroll {
max-width: 100%;
margin: 0 0 0.9em;
margin: 0;
border-radius: 8px;
}

View File

@@ -45,7 +45,6 @@ const DOCS_GROUP_LABELS: Record<DocsLang, Record<DocsGroup, string>> = {
}
const DOCS_README_FILENAME = 'README.md'
const FALLBACK_DOCS_ORDER = 999
const MAX_HEADING_ID_LENGTH = 80
export const defaultDocsSlug = 'overview'
@@ -138,27 +137,20 @@ export function slugFromFilename(filename: string): string {
return filename === DOCS_README_FILENAME ? defaultDocsSlug : filename.replace(/\.md$/, '')
}
function fallbackTitleFromFilename(filename: string): string {
return filename
.replace(/\.md$/, '')
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
export function getDocsEntries(lang: DocsLang): DocsEntry[] {
const modules = lang === 'zh' ? zhModules : enModules
return Object.entries(modules)
.filter(([path]) => DOCS_METADATA[filenameFromPath(path)])
.map(([path, loader]) => {
const filename = filenameFromPath(path)
const meta = DOCS_METADATA[filename]
const langMeta = meta?.[lang]
const langMeta = meta[lang]
return {
slug: slugFromFilename(filename),
filename,
title: langMeta?.title || fallbackTitleFromFilename(filename),
group: (langMeta?.group || 'Other') as DocsGroup,
order: langMeta?.order ?? FALLBACK_DOCS_ORDER,
title: langMeta.title,
group: langMeta.group,
order: langMeta.order,
loader: loader as () => Promise<string>,
}
})

View File

@@ -25,6 +25,7 @@ import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
import { useAuthStore } from '../../stores/auth'
import { Link } from 'react-router-dom'
const { Title, Text, Paragraph } = Typography
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
@@ -581,7 +582,7 @@ function Playground() {
>
<Scrollbar className="playground-card__scroll">
<Spin spinning={statusLoading}>
{providerStatus ? (
{providerStatus ? (
<div className="playground-provider-panel">
<div className="playground-kv">
<Text type="secondary">Provider</Text>
@@ -616,9 +617,22 @@ function Playground() {
<Text type="secondary"></Text>
<Text>{providerStatusUpdatedAt || '-'}</Text>
</div>
{!providerStatus.configured ? (
<Alert
type="warning"
showIcon
message="AI Provider 尚未配置完整"
description={<Link to="/settings?tab=ai"> AI </Link>}
/>
) : null}
</div>
) : (
<Alert type="warning" showIcon message="尚未获取到 AI Provider 状态" />
<Alert
type="warning"
showIcon
message="尚未获取到 AI Provider 状态"
description={<Link to="/settings?tab=ai"> AI </Link>}
/>
)}
</Spin>
</Scrollbar>

View File

@@ -1,16 +1,18 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useCollapsedActions } from '../../hooks'
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons'
import { ApiOutlined, CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, SyncOutlined } from '@ant-design/icons'
import {
Button,
Card,
Checkbox,
Form,
Input,
InputNumber,
message,
Modal,
Select,
Space,
Switch,
Table,
Tabs,
@@ -23,8 +25,11 @@ import AppLayout from '../../components/AppLayout/AppLayout'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useSearchParams } from 'react-router-dom'
const { Title, Text } = Typography
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
const DEFAULT_PROVIDER_MAX_TOKENS = 4096
interface SystemSettings {
system_name: string
@@ -51,6 +56,7 @@ interface SecuritySettings {
interface CollectorSettings {
id: number
name: string
display_name?: string
source: string
module: string
priority: string
@@ -60,6 +66,10 @@ interface CollectorSettings {
last_run_at: string | null
last_status: string | null
next_run_at: string | null
is_free?: boolean
requires_credentials?: boolean
credential_provider?: string | null
credential_status?: string
}
interface TVStreamSource {
@@ -88,6 +98,46 @@ interface TVSettings {
sources: TVStreamSource[]
}
interface SecretStatus {
configured: boolean
preview: string
}
interface ExternalIntegrations {
ai_provider: {
service_url: string
service_token: SecretStatus
provider: string
provider_api: string
base_url: string
model: string
api_key: SecretStatus
max_tokens: number
anthropic_version: string
timeout_seconds: number
retry_attempts: number
source: string
}
barentswatch: {
endpoint: string
client_id: string
client_secret: SecretStatus
source: string
}
}
interface AIProviderPreset {
provider: string
label: string
provider_api: string
base_url: string
model: string
models: string[]
api_key_env: string
source: string
refresh_error?: string
}
function SettingsPanel({
loading,
children,
@@ -105,6 +155,8 @@ function SettingsPanel({
}
function Settings() {
const [searchParams, setSearchParams] = useSearchParams()
const requestedTab = searchParams.get('tab') || 'display'
const [loading, setLoading] = useState(true)
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
@@ -112,7 +164,11 @@ function Settings() {
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
const [tvSettings, setTvSettings] = useState<TVSettings | null>(null)
const [integrations, setIntegrations] = useState<ExternalIntegrations | null>(null)
const [aiProviderPresets, setAiProviderPresets] = useState<AIProviderPreset[]>([])
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
const [savingTvSettings, setSavingTvSettings] = useState(false)
const [savingIntegrations, setSavingIntegrations] = useState(false)
const [editingSource, setEditingSource] = useState<TVStreamSource | null>(null)
const [tvActionsCollapsed, tvTableRef] = useCollapsedActions(780)
const collectorTableRegionRef = useRef<HTMLDivElement | null>(null)
@@ -120,17 +176,49 @@ function Settings() {
const [systemForm] = Form.useForm<SystemSettings>()
const [notificationForm] = Form.useForm<NotificationSettings>()
const [securityForm] = Form.useForm<SecuritySettings>()
const [integrationForm] = Form.useForm()
const [tvEditForm] = Form.useForm<TVStreamSource>()
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
const credentialCollectors = collectors.filter((collector) => collector.requires_credentials)
const settingsTabKeys = new Set([
'display',
'notifications',
'security',
'tv',
'ai',
'collector_credentials',
'collectors',
])
const activeSettingsTab = requestedTab === 'system'
? 'display'
: settingsTabKeys.has(requestedTab)
? requestedTab
: 'display'
const updateSettingsTab = (tabKey: string) => {
const nextParams = new URLSearchParams(searchParams)
if (tabKey === 'display') {
nextParams.delete('tab')
} else {
nextParams.set('tab', tabKey)
}
setSearchParams(nextParams, { replace: true })
}
const fetchSettings = async () => {
try {
setLoading(true)
const response = await axios.get('/api/v1/settings')
const [response, presetsResponse] = await Promise.all([
axios.get('/api/v1/settings'),
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
])
setSystemSettings(response.data.system)
setNotificationSettings(response.data.notifications)
setSecuritySettings(response.data.security)
setTvSettings(response.data.tv || null)
setIntegrations(response.data.integrations || null)
setCollectors(response.data.collectors || [])
setAiProviderPresets(presetsResponse.data.data || [])
} catch (error) {
message.error('获取系统配置失败')
console.error(error)
@@ -161,6 +249,33 @@ function Settings() {
}
}, [loading, securityForm, securitySettings])
useEffect(() => {
if (loading || !integrations) return
integrationForm.setFieldsValue({
ai_provider: {
service_url: integrations.ai_provider.service_url,
service_token: '',
provider: integrations.ai_provider.provider,
provider_api: integrations.ai_provider.provider_api,
base_url: integrations.ai_provider.base_url,
model: integrations.ai_provider.model,
api_key: '',
max_tokens: integrations.ai_provider.max_tokens,
anthropic_version: integrations.ai_provider.anthropic_version,
timeout_seconds: integrations.ai_provider.timeout_seconds,
retry_attempts: integrations.ai_provider.retry_attempts,
clear_service_token: false,
clear_api_key: false,
},
barentswatch: {
endpoint: integrations.barentswatch.endpoint,
client_id: integrations.barentswatch.client_id,
client_secret: '',
clear_client_secret: false,
},
})
}, [integrationForm, integrations, loading])
useEffect(() => {
const updateTableHeight = () => {
const regionHeight = collectorTableRegionRef.current?.offsetHeight || 0
@@ -214,6 +329,59 @@ function Settings() {
}
}
const saveIntegrations = async (values: any) => {
try {
setSavingIntegrations(true)
const response = await axios.put('/api/v1/settings/integrations', values)
setIntegrations(response.data.integrations)
message.success('外部集成配置已保存')
await fetchSettings()
} catch {
message.error('外部集成配置保存失败')
} finally {
setSavingIntegrations(false)
}
}
const applyAiProviderPreset = (preset: AIProviderPreset) => {
integrationForm.setFieldsValue({
ai_provider: {
provider: preset.provider,
provider_api: preset.provider_api,
base_url: preset.base_url,
model: preset.model,
max_tokens: preset.provider_api === 'anthropic-messages'
? ANTHROPIC_MESSAGES_MAX_TOKENS
: DEFAULT_PROVIDER_MAX_TOKENS,
anthropic_version: '2023-06-01',
},
})
}
const refreshSelectedAiProviderPreset = async () => {
const provider = integrationForm.getFieldValue(['ai_provider', 'provider'])
if (!provider) return
try {
setRefreshingAiPreset(true)
const response = await axios.post(`/api/v1/settings/integrations/ai-provider/presets/${provider}/refresh`)
const preset = response.data.data as AIProviderPreset
setAiProviderPresets((prev) => {
const next = prev.filter((item) => item.provider !== preset.provider)
return [...next, preset].sort((a, b) => a.label.localeCompare(b.label))
})
applyAiProviderPreset(preset)
if (preset.refresh_error) {
message.warning('刷新失败,已使用本地 fallback 配置')
} else {
message.success('已刷新选中 Provider 的最新模型配置')
}
} catch {
message.error('刷新 Provider 配置失败')
} finally {
setRefreshingAiPreset(false)
}
}
const setDefaultSource = (sourceId: string) => {
if (!tvSettings) return
const next = { ...tvSettings, default_source_id: sourceId }
@@ -537,7 +705,7 @@ function Settings() {
const tabItems = [
{
key: 'system',
key: 'display',
label: '系统显示',
forceRender: true,
children: (
@@ -729,6 +897,196 @@ function Settings() {
</div>
),
},
{
key: 'ai',
label: 'AI',
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<Form form={integrationForm} layout="vertical" onFinish={saveIntegrations}>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
showSearch
optionFilterProp="label"
options={aiProviderPresets.map((preset) => ({
value: preset.provider,
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => {
const preset = aiProviderPresets.find((item) => item.provider === value)
if (preset) applyAiProviderPreset(preset)
}}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<Input placeholder="https://api.example.com/v1" />
</Form.Item>
<Form.Item label=" ">
<Button
icon={<SyncOutlined />}
loading={refreshingAiPreset}
onClick={refreshSelectedAiProviderPreset}
>
</Button>
</Form.Item>
</div>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
<Select.Option value="openai-completions">OpenAI Chat Completions</Select.Option>
<Select.Option value="anthropic-messages">Anthropic Messages</Select.Option>
<Select.Option value="ollama-generate">Ollama Generate</Select.Option>
</Select>
</Form.Item>
<Form.Item name={['ai_provider', 'model']} label="默认模型">
<Select
showSearch
optionFilterProp="label"
options={(
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
).map((model) => ({ value: model, label: model }))}
dropdownRender={(menu) => menu}
/>
</Form.Item>
<Form.Item label="LLM API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ai_provider.api_key.configured ? 'green' : 'default'}>
{integrations?.ai_provider.api_key.configured
? `已配置 ${integrations.ai_provider.api_key.preview}`
: '未配置'}
</Tag>
<Text type="secondary"> key</Text>
</Space>
<Form.Item name={['ai_provider', 'api_key']} noStyle>
<Input.Password autoComplete="new-password" placeholder="输入新的 LLM API key" />
</Form.Item>
</Space>
</Form.Item>
<Form.Item name={['ai_provider', 'clear_api_key']} valuePropName="checked">
<Checkbox> LLM API key</Checkbox>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['ai_provider', 'max_tokens']} label="最大输出 Tokens">
<InputNumber min={1} max={200000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['ai_provider', 'anthropic_version']} label="Anthropic Version">
<Input />
</Form.Item>
<Form.Item name={['ai_provider', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={5} max={600} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['ai_provider', 'retry_attempts']} label="重试次数">
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="本地 aiprovider 代理" style={{ marginTop: 8 }}>
<Form.Item name={['ai_provider', 'service_url']} label="代理地址">
<Input placeholder="http://localhost:8010" />
</Form.Item>
<Form.Item label="代理 Token">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ai_provider.service_token.configured ? 'green' : 'default'}>
{integrations?.ai_provider.service_token.configured
? `已配置 ${integrations.ai_provider.service_token.preview}`
: '未配置'}
</Tag>
<Text type="secondary"> backend aiprovider</Text>
</Space>
<Form.Item name={['ai_provider', 'service_token']} noStyle>
<Input.Password autoComplete="new-password" placeholder="输入新的代理 token" />
</Form.Item>
</Space>
</Form.Item>
<Form.Item name={['ai_provider', 'clear_service_token']} valuePropName="checked">
<Checkbox> token</Checkbox>
</Form.Item>
</Card>
</Card>
<Button
type="primary"
htmlType="submit"
loading={savingIntegrations}
style={{ marginTop: 16 }}
>
AI
</Button>
</Form>
</SettingsPanel>
),
},
{
key: 'collector_credentials',
label: '采集器凭证',
forceRender: true,
children: (
<SettingsPanel loading={loading}>
<Form form={integrationForm} layout="vertical" onFinish={saveIntegrations}>
<Card size="small" title="需要凭证的采集器" style={{ marginBottom: 16 }}>
<Space size={[6, 6]} wrap>
{credentialCollectors.length ? credentialCollectors.map((collector) => (
<Tag
key={collector.source}
color={collector.credential_status === 'supported' ? 'blue' : 'orange'}
>
{collector.display_name || collector.name}
{collector.credential_status === 'supported' ? ' · 已支持配置' : ' · 待接入'}
</Tag>
)) : (
<Text type="secondary"></Text>
)}
</Space>
</Card>
<Card
size="small"
title={<Space><ApiOutlined />BarentsWatch AIS</Space>}
>
<Form.Item name={['barentswatch', 'endpoint']} label="AIS Endpoint">
<Input placeholder="https://live.ais.barentswatch.no/v1/latest/combined" />
</Form.Item>
<Form.Item name={['barentswatch', 'client_id']} label="Client ID">
<Input autoComplete="off" />
</Form.Item>
<Form.Item label="Client Secret">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.barentswatch.client_secret.configured ? 'green' : 'default'}>
{integrations?.barentswatch.client_secret.configured
? `已配置 ${integrations.barentswatch.client_secret.preview}`
: '未配置'}
</Tag>
<Text type="secondary"> secret</Text>
</Space>
<Form.Item name={['barentswatch', 'client_secret']} noStyle>
<Input.Password autoComplete="new-password" placeholder="输入新 client secret" />
</Form.Item>
</Space>
</Form.Item>
<Form.Item name={['barentswatch', 'clear_client_secret']} valuePropName="checked">
<Checkbox> BarentsWatch client secret</Checkbox>
</Form.Item>
</Card>
<Button
type="primary"
htmlType="submit"
loading={savingIntegrations}
style={{ marginTop: 16 }}
>
</Button>
</Form>
</SettingsPanel>
),
},
{
key: 'collectors',
label: '采集调度',
@@ -767,7 +1125,12 @@ function Settings() {
</div>
<div className="page-shell__body settings-tabs-shell">
<Tabs className="settings-tabs" items={tabItems} />
<Tabs
className="settings-tabs"
activeKey={activeSettingsTab}
onChange={updateSettingsTab}
items={tabItems}
/>
</div>
</div>
</AppLayout>