release: bump version to 0.43.0
This commit is contained in:
@@ -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>)
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user