release: bump version to 0.52.0

This commit is contained in:
linkong
2026-05-12 17:15:02 +08:00
parent b15d097b9c
commit b87cb310fd
70 changed files with 5589 additions and 2187 deletions

View File

@@ -3,12 +3,12 @@ import {
ApiOutlined,
EyeInvisibleOutlined,
EyeOutlined,
PlayCircleOutlined,
SyncOutlined,
ToolOutlined,
} from '@ant-design/icons'
import {
Alert,
AutoComplete,
Button,
Card,
Checkbox,
@@ -21,17 +21,23 @@ import {
Switch,
Tabs,
Tag,
Tooltip,
Typography,
} from 'antd'
import axios from 'axios'
import { useSearchParams } from 'react-router-dom'
import AppLayout from '../../components/AppLayout/AppLayout'
import ConnectionTestInput from '../../components/ConnectionTestInput/ConnectionTestInput'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import { PlaygroundWorkspace } from '../Playground/Playground'
const { Title, Text } = Typography
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
const DEFAULT_PROVIDER_MAX_TOKENS = 4096
const TOOL_OPTIONS = [
{ value: 'web_search', label: 'WebSearch 证据层' },
{ value: 'ocr', label: 'OCR 识别' },
]
interface SecretStatus {
configured: boolean
@@ -115,6 +121,18 @@ interface ExternalIntegrations {
scrape_formats: string[]
source: string
}
ocr: {
enabled: boolean
provider: string
base_url: string
api_key: SecretStatus
model: string
languages: string[]
timeout_seconds: number
max_file_size_mb: number
output_format: string
source: string
}
}
interface AIProviderPreset {
@@ -170,15 +188,20 @@ export default function AISettings() {
const [refreshingAiPreset, setRefreshingAiPreset] = useState(false)
const [testingAiProviderConnection, setTestingAiProviderConnection] = useState(false)
const [testingWebSearchConnection, setTestingWebSearchConnection] = useState(false)
const [selectedToolKey, setSelectedToolKey] = useState('web_search')
const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const [revealedAiProviderSecrets, setRevealedAiProviderSecrets] = useState<Record<string, { api_key: string; service_token: string }>>({})
const [revealedWebSearchSecrets, setRevealedWebSearchSecrets] = useState<Record<string, { api_key: string }>>({})
const [revealedOcrSecrets, setRevealedOcrSecrets] = useState<{ api_key: string } | null>(null)
const [aiProviderApiKeyRevealed, setAiProviderApiKeyRevealed] = useState(false)
const [serviceTokenRevealed, setServiceTokenRevealed] = useState(false)
const [webSearchApiKeyRevealed, setWebSearchApiKeyRevealed] = useState(false)
const [ocrApiKeyRevealed, setOcrApiKeyRevealed] = useState(false)
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], form)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], form)
const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], form)
const ocrEnabled = Form.useWatch(['ocr', 'enabled'], form)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
@@ -255,6 +278,19 @@ export default function AISettings() {
scrape_path: integrations.web_search.scrape_path,
scrape_formats: integrations.web_search.scrape_formats,
},
ocr: {
enabled: integrations.ocr.enabled,
provider: integrations.ocr.provider,
base_url: integrations.ocr.base_url,
api_key: integrations.ocr.api_key.configured
? integrations.ocr.api_key.preview
: '',
model: integrations.ocr.model,
languages: integrations.ocr.languages,
timeout_seconds: integrations.ocr.timeout_seconds,
max_file_size_mb: integrations.ocr.max_file_size_mb,
output_format: integrations.ocr.output_format,
},
})
}, [form, integrations, loading])
@@ -313,9 +349,23 @@ export default function AISettings() {
}
}
const buildOcrDraftPayload = (values: any) => {
const nextApiKey = String(values.ocr?.api_key || '').trim()
const apiKeyUnchanged = isSecretDraftUnchanged(
nextApiKey,
integrations?.ocr.api_key.preview,
revealedOcrSecrets?.api_key,
)
return {
...values.ocr,
api_key: apiKeyUnchanged ? '' : nextApiKey,
}
}
const buildIntegrationsPayload = (values: any) => ({
ai_provider: buildAiProviderDraftPayload(values),
web_search: buildWebSearchDraftPayload(values),
ocr: buildOcrDraftPayload(values),
barentswatch: {
endpoint: integrations?.barentswatch.endpoint || '',
client_id: integrations?.barentswatch.client_id || '',
@@ -366,6 +416,16 @@ export default function AISettings() {
return secrets
}
const revealOcrSecrets = async () => {
if (revealedOcrSecrets) return revealedOcrSecrets
const response = await axios.get('/api/v1/settings/integrations/ocr/secrets')
const secrets = {
api_key: String(response.data.api_key || ''),
}
setRevealedOcrSecrets(secrets)
return secrets
}
const handleAiProviderApiKeyVisibleChange = async (visible: boolean) => {
const provider = String(form.getFieldValue(['ai_provider', 'provider']) || integrations?.ai_provider.provider || 'minimax')
const providerSecret = integrations?.ai_provider.providers?.[provider]?.api_key || integrations?.ai_provider.api_key
@@ -428,6 +488,25 @@ export default function AISettings() {
setWebSearchApiKeyRevealed(false)
}
const handleOcrApiKeyVisibleChange = async (visible: boolean) => {
if (visible) {
try {
const secrets = await revealOcrSecrets()
if (secrets.api_key) form.setFieldValue(['ocr', 'api_key'], secrets.api_key)
setOcrApiKeyRevealed(true)
} catch {
message.error('读取 OCR API Key 失败')
}
return
}
const currentValue = String(form.getFieldValue(['ocr', 'api_key']) || '')
const revealedValue = revealedOcrSecrets?.api_key
if (revealedValue && currentValue === revealedValue) {
form.setFieldValue(['ocr', 'api_key'], integrations?.ocr.api_key.preview || '')
}
setOcrApiKeyRevealed(false)
}
const applyAiProviderSelection = (provider: string, presetOverride?: AIProviderPreset) => {
const preset = presetOverride || aiProviderPresets.find((item) => item.provider === provider)
const savedProvider = integrations?.ai_provider.providers?.[provider]
@@ -567,31 +646,40 @@ export default function AISettings() {
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
showSearch
optionFilterProp="label"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={aiProviderPresets.map((preset) => ({
value: preset.provider,
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => applyAiProviderSelection(value)}
onSelect={(value) => applyAiProviderSelection(value)}
placeholder="选择或输入 provider id例如 openai"
/>
</Form.Item>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<ConnectionTestInput
placeholder="https://api.example.com/v1"
testing={testingAiProviderConnection}
testDisabled={!selectedAiProvider}
onTest={() => { void testAiProviderConnection() }}
extraSuffix={(
<Tooltip title="刷新当前 Provider 的模型配置">
<Button
type="text"
size="small"
icon={<SyncOutlined spin={refreshingAiPreset} />}
disabled={!selectedAiProvider}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void refreshSelectedAiProviderPreset()
}}
aria-label="刷新当前 Provider 的模型配置"
/>
</Tooltip>
)}
/>
</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=" ">
<Space>
<Button icon={<SyncOutlined />} loading={refreshingAiPreset} onClick={refreshSelectedAiProviderPreset}>
</Button>
<Button icon={<PlayCircleOutlined />} loading={testingAiProviderConnection} onClick={() => { void testAiProviderConnection() }}>
</Button>
</Space>
</Form.Item>
</div>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
<Select.Option value="openai-completions">OpenAI Chat Completions</Select.Option>
@@ -600,12 +688,12 @@ export default function AISettings() {
</Select>
</Form.Item>
<Form.Item name={['ai_provider', 'model']} label="默认模型">
<Select
showSearch
optionFilterProp="label"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={(
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
).map((model) => ({ value: model, label: model }))}
placeholder="选择或输入模型名,例如 gpt-5.1"
/>
</Form.Item>
<Form.Item label="LLM API Key">
@@ -621,13 +709,15 @@ export default function AISettings() {
autoComplete="new-password"
placeholder="输入新的 LLM API key"
suffix={(
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
<Tooltip title={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}>
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -664,13 +754,15 @@ export default function AISettings() {
autoComplete="new-password"
placeholder="输入新的代理 token"
suffix={(
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
<Tooltip title={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}>
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -693,97 +785,196 @@ export default function AISettings() {
children: (
<AISettingsPanel loading={loading}>
<Form form={form} layout="vertical" onFinish={saveAISettings} onValuesChange={() => setFeedback(null)}>
<Card size="small" title={<Space><ToolOutlined />WebSearch </Space>}>
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Card size="small" title={<Space><ToolOutlined />AI Tools</Space>}>
<Form.Item label="工具">
<Select
showSearch
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
label: preset.label,
}))}
onChange={(value) => applyWebSearchProviderSelection(value)}
value={selectedToolKey}
options={TOOL_OPTIONS}
onChange={setSelectedToolKey}
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<Input placeholder="https://api.tavily.com" />
</Form.Item>
<Form.Item label=" ">
<Button icon={<PlayCircleOutlined />} loading={testingWebSearchConnection} onClick={() => { void testWebSearchConnection() }}>
</Button>
</Form.Item>
</div>
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> WebSearch Provider key</Text>
</Space>
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Button
type="text"
size="small"
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
)}
{selectedToolKey === 'web_search' ? (
<>
<Form.Item name={['web_search', 'enabled']} label="启用 WebSearch" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Select
showSearch
disabled={!webSearchEnabled}
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
label: preset.label,
}))}
onChange={(value) => applyWebSearchProviderSelection(value)}
/>
</Form.Item>
</Space>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input placeholder="/search" />
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<ConnectionTestInput
placeholder="https://api.tavily.com"
disabled={!webSearchEnabled}
testing={testingWebSearchConnection}
testDisabled={!webSearchEnabled || !selectedWebSearchProvider}
onTest={() => { void testWebSearchConnection() }}
/>
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input placeholder="basic" />
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={selectedWebSearchSecret?.configured ? 'green' : 'default'}>
{selectedWebSearchSecret?.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> WebSearch Provider key</Text>
</Space>
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
disabled={!webSearchEnabled}
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Tooltip title={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}>
<Button
type="text"
size="small"
disabled={!webSearchEnabled}
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
</Space>
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input placeholder="google" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} disabled={!webSearchEnabled} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} disabled={!webSearchEnabled} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input disabled={!webSearchEnabled} placeholder="/search" />
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input disabled={!webSearchEnabled} placeholder="basic" />
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input disabled={!webSearchEnabled} placeholder="google" />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input disabled={!webSearchEnabled} placeholder="general" />
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input disabled={!webSearchEnabled} placeholder="/v2/search" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input disabled={!webSearchEnabled} placeholder="/v2/scrape" />
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox disabled={!webSearchEnabled}> Answer</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox disabled={!webSearchEnabled}> Raw Content</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox disabled={!webSearchEnabled}> Result Text</Checkbox>
</Form.Item>
</Space>
</Card>
</>
) : null}
{selectedToolKey === 'ocr' ? (
<>
<Form.Item name={['ocr', 'enabled']} label="启用 OCR" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input placeholder="general" />
<Form.Item name={['ocr', 'provider']} label="OCR Provider">
<Select
disabled={!ocrEnabled}
options={[
{ value: 'paddleocr', label: 'PaddleOCR / Local' },
{ value: 'tesseract', label: 'Tesseract / Local' },
{ value: 'azure', label: 'Azure AI Vision' },
{ value: 'google_vision', label: 'Google Cloud Vision' },
{ value: 'custom', label: 'Custom HTTP OCR' },
]}
/>
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input placeholder="/v2/search" />
<Form.Item name={['ocr', 'base_url']} label="OCR Base URL">
<Input disabled={!ocrEnabled} placeholder="http://localhost:8020 或云 OCR endpoint" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input placeholder="/v2/scrape" />
<Form.Item label="OCR API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>
<Tag color={integrations?.ocr.api_key.configured ? 'green' : 'default'}>
{integrations?.ocr.api_key.configured ? '已配置' : '未配置'}
</Tag>
<Text type="secondary"> OCR OCR HTTP </Text>
</Space>
<Form.Item name={['ocr', 'api_key']} noStyle>
<Input
autoComplete="new-password"
disabled={!ocrEnabled}
placeholder="输入新的 OCR API key"
suffix={(
<Tooltip title={ocrApiKeyRevealed ? '隐藏 OCR API key' : '显示 OCR API key'}>
<Button
type="text"
size="small"
disabled={!ocrEnabled}
icon={ocrApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleOcrApiKeyVisibleChange(!ocrApiKeyRevealed) }}
aria-label={ocrApiKeyRevealed ? '隐藏 OCR API key' : '显示 OCR API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
</Space>
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Answer</Checkbox>
<Form.Item name={['ocr', 'model']} label="模型 / Engine">
<Input disabled={!ocrEnabled} placeholder="例如PP-OCRv5、tesseract-default、prebuilt-read" />
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Raw Content</Checkbox>
<Form.Item name={['ocr', 'languages']} label="识别语言">
<Select
mode="tags"
disabled={!ocrEnabled}
options={[
{ value: 'zh', label: '中文' },
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
]}
/>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Result Text</Checkbox>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['ocr', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={300} disabled={!ocrEnabled} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['ocr', 'max_file_size_mb']} label="最大文件(MB)">
<InputNumber min={1} max={200} disabled={!ocrEnabled} style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name={['ocr', 'output_format']} label="输出格式">
<Select
disabled={!ocrEnabled}
options={[
{ value: 'markdown', label: 'Markdown' },
{ value: 'text', label: 'Plain Text' },
{ value: 'json', label: 'JSON Blocks' },
]}
/>
</Form.Item>
</Space>
</Card>
</>
) : null}
</Card>
<Button type="primary" htmlType="submit" loading={saving} style={{ marginTop: 16 }}>
Tool

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Key } from 'react'
import {
Button,
Card,
@@ -11,6 +12,7 @@ import {
Modal,
Progress,
Row,
Select,
Space,
Table,
Tag,
@@ -50,6 +52,7 @@ interface BuiltInDataSource {
last_run: string | null
last_run_at?: string | null
last_status?: string | null
product?: string
is_running: boolean
task_id: number | null
progress: number | null
@@ -61,6 +64,8 @@ interface BuiltInDataSource {
phase_unit?: string | null
records_processed: number | null
total_records: number | null
collected_records?: number
has_collected_data?: boolean
is_free?: boolean
requires_credentials?: boolean
credential_provider?: string | null
@@ -108,6 +113,7 @@ interface UnifiedDataSource {
auth_type?: string
last_run_at?: string | null
last_status?: string | null
product?: string
is_running?: boolean
progress?: number | null
phase?: string | null
@@ -126,6 +132,8 @@ interface UnifiedDataSource {
requires_credentials?: boolean
credential_provider?: string | null
credential_status?: string
collected_records?: number
has_collected_data?: boolean
}
interface ViewDataSource extends UnifiedDataSource {
@@ -166,6 +174,32 @@ type DatasourceTaskStatus = {
status?: string | null
}
type ActiveFilter = 'enabled' | 'disabled' | 'all'
type StatusFilter = 'all' | 'success' | 'failed' | 'running' | 'not_run'
type CollectedFilter = 'all' | 'collected' | 'uncollected'
const PRODUCT_LABELS: Record<string, string> = {
vessels: '船只',
cables: '海底光缆',
satellites: '卫星',
bgp: 'BGP',
compute: '算力',
ai: 'AI',
media: '媒体',
other: '其他',
}
const PRODUCT_TAG_COLORS: Record<string, string> = {
vessels: 'cyan',
cables: 'geekblue',
satellites: 'purple',
bgp: 'volcano',
compute: 'blue',
ai: 'magenta',
media: 'green',
other: 'default',
}
function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
return {
key: `builtin:${source.id}`,
@@ -175,6 +209,7 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
source: source.source,
module: source.module,
priority: source.priority,
product: source.product,
frequency: source.frequency,
endpoint: source.endpoint,
is_active: source.is_active,
@@ -194,6 +229,8 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
requires_credentials: source.requires_credentials,
credential_provider: source.credential_provider,
credential_status: source.credential_status,
collected_records: source.collected_records,
has_collected_data: source.has_collected_data,
headers: {},
config: {},
}
@@ -208,6 +245,13 @@ function DataSources() {
const [loading, setLoading] = useState(false)
const [triggerAllLoading, setTriggerAllLoading] = useState(false)
const [forceTriggerAll, setForceTriggerAll] = useState(false)
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([])
const [productFilter, setProductFilter] = useState<string>('all')
const [moduleFilter, setModuleFilter] = useState<string>('all')
const [activeFilter, setActiveFilter] = useState<ActiveFilter>('enabled')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [collectedFilter, setCollectedFilter] = useState<CollectedFilter>('all')
const [searchQuery, setSearchQuery] = useState('')
const [viewDrawerVisible, setViewDrawerVisible] = useState(false)
const [runningTasksVisible, setRunningTasksVisible] = useState(false)
const [viewingSource, setViewingSource] = useState<ViewDataSource | null>(null)
@@ -216,8 +260,15 @@ function DataSources() {
const tableRegionRef = useRef<HTMLDivElement | null>(null)
const allSources = useMemo(() => builtInSources.map(normalizeBuiltin), [builtInSources])
const selectedSourceIds = useMemo(
() => selectedRowKeys
.map((key) => allSources.find((source) => source.key === key)?.id)
.filter((id): id is number => typeof id === 'number'),
[allSources, selectedRowKeys],
)
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
const collectedBuiltInCount = builtInSources.filter((source) => source.has_collected_data).length
const runningBuiltInSources = builtInSources.filter((source) => source.is_running)
const runningBuiltInCount = runningBuiltInSources.length
const aggregateProgress = runningBuiltInCount > 0
@@ -231,8 +282,16 @@ function DataSources() {
const fetchData = useCallback(async () => {
setLoading(true)
try {
const params = {
product: productFilter === 'all' ? undefined : productFilter,
module: moduleFilter === 'all' ? undefined : moduleFilter,
is_active: activeFilter === 'all' ? undefined : activeFilter === 'enabled',
run_status: statusFilter === 'all' ? undefined : statusFilter,
collected: collectedFilter === 'all' ? undefined : collectedFilter === 'collected',
q: searchQuery.trim() || undefined,
}
const [builtinRes, customRes] = await Promise.all([
axios.get('/api/v1/datasources'),
axios.get('/api/v1/datasources', { params }),
axios.get('/api/v1/datasources/configs'),
])
setBuiltInSources(builtinRes.data.data || [])
@@ -243,12 +302,17 @@ function DataSources() {
} finally {
setLoading(false)
}
}, [messageApi])
}, [activeFilter, collectedFilter, messageApi, moduleFilter, productFilter, searchQuery, statusFilter])
useEffect(() => {
void fetchData()
}, [fetchData])
useEffect(() => {
const visibleKeys = new Set(allSources.map((source) => source.key))
setSelectedRowKeys((keys) => keys.filter((key) => visibleKeys.has(String(key))))
}, [allSources])
useEffect(() => {
const updateHeight = () => {
setTableHeight(Math.max(260, (tableRegionRef.current?.offsetHeight || 0) - 56))
@@ -332,8 +396,15 @@ function DataSources() {
const handleTriggerAll = async () => {
try {
setTriggerAllLoading(true)
const res = await axios.post('/api/v1/datasources/trigger-all', null, {
params: { force: forceTriggerAll },
const res = await axios.post('/api/v1/datasources/trigger-batch', {
source_ids: selectedSourceIds,
force: forceTriggerAll,
product: selectedSourceIds.length ? undefined : productFilter === 'all' ? undefined : productFilter,
module: selectedSourceIds.length ? undefined : moduleFilter === 'all' ? undefined : moduleFilter,
is_active: selectedSourceIds.length ? undefined : activeFilter === 'all' ? undefined : activeFilter === 'enabled',
run_status: selectedSourceIds.length ? undefined : statusFilter === 'all' ? undefined : statusFilter,
collected: selectedSourceIds.length ? undefined : collectedFilter === 'all' ? undefined : collectedFilter === 'collected',
q: selectedSourceIds.length ? undefined : searchQuery.trim() || undefined,
})
const triggered = res.data.triggered || []
const skipped = res.data.skipped || []
@@ -343,10 +414,11 @@ function DataSources() {
skipped.length ? `跳过 ${skipped.length}` : null,
failed.length ? `失败 ${failed.length}` : null,
].filter(Boolean).join(''))
setSelectedRowKeys([])
void fetchData()
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
messageApi.error(err.response?.data?.detail || '触发失败')
messageApi.error(err.response?.data?.detail || '批量触发失败')
} finally {
setTriggerAllLoading(false)
}
@@ -431,6 +503,15 @@ function DataSources() {
width: 100,
render: () => <Tag color="blue"></Tag>,
},
{
title: '产品域',
key: 'product',
width: 110,
render: (_: unknown, record: UnifiedDataSource) => {
const product = record.product || 'other'
return <Tag color={PRODUCT_TAG_COLORS[product] || 'default'}>{PRODUCT_LABELS[product] || product}</Tag>
},
},
{
title: '层级/类型',
key: 'module',
@@ -451,6 +532,16 @@ function DataSources() {
width: 180,
render: (value: string | null | undefined) => formatDateTimeZhCN(value) || '-',
},
{
title: '已采集',
key: 'collected',
width: 110,
render: (_: unknown, record: UnifiedDataSource) => (
<Tag color={record.has_collected_data ? 'success' : 'default'}>
{record.has_collected_data ? `${record.collected_records || 0}` : '未采集'}
</Tag>
),
},
{
title: '状态',
key: 'status',
@@ -513,7 +604,7 @@ function DataSources() {
</div>
<div className="data-source-bulk-toolbar__stats">
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{allSources.length}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
@@ -521,9 +612,19 @@ function DataSources() {
<strong>{builtInSources.length}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{activeBuiltInCount}</strong>
</div>
<div className="data-source-bulk-toolbar__stat-pill">
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{collectedBuiltInCount}</strong>
</div>
{selectedSourceIds.length > 0 ? (
<div className="data-source-bulk-toolbar__stat-pill data-source-bulk-toolbar__stat-pill--success">
<span className="data-source-bulk-toolbar__stat-label"></span>
<strong>{selectedSourceIds.length}</strong>
</div>
) : null}
{runningBuiltInCount > 0 ? (
<Tooltip title="查看采集中任务">
<button
@@ -548,18 +649,93 @@ function DataSources() {
</Checkbox>
<Button type="primary" size="middle" icon={<SyncOutlined />} loading={triggerAllLoading} onClick={handleTriggerAll}>
{selectedSourceIds.length ? '采集选中项' : '采集当前筛选'}
</Button>
</Space>
</div>
<div className="data-source-filter-toolbar">
<Select
className="data-source-filter-toolbar__control"
value={productFilter}
onChange={setProductFilter}
options={[
{ value: 'all', label: '全部产品域' },
{ value: 'vessels', label: '船只' },
{ value: 'cables', label: '海底光缆' },
{ value: 'satellites', label: '卫星' },
{ value: 'bgp', label: 'BGP' },
{ value: 'compute', label: '算力' },
{ value: 'ai', label: 'AI' },
{ value: 'media', label: '媒体' },
{ value: 'other', label: '其他' },
]}
/>
<Select
className="data-source-filter-toolbar__control"
value={moduleFilter}
onChange={setModuleFilter}
options={[
{ value: 'all', label: '全部层级' },
{ value: 'L1', label: 'L1' },
{ value: 'L2', label: 'L2' },
{ value: 'L3', label: 'L3' },
{ value: 'L4', label: 'L4' },
]}
/>
<Select
className="data-source-filter-toolbar__control"
value={activeFilter}
onChange={setActiveFilter}
options={[
{ value: 'enabled', label: '已启用' },
{ value: 'disabled', label: '已禁用' },
{ value: 'all', label: '全部启用状态' },
]}
/>
<Select
className="data-source-filter-toolbar__control"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ value: 'all', label: '全部执行状态' },
{ value: 'success', label: '最近成功' },
{ value: 'failed', label: '最近失败' },
{ value: 'running', label: '采集中' },
{ value: 'not_run', label: '未执行' },
]}
/>
<Select
className="data-source-filter-toolbar__control"
value={collectedFilter}
onChange={setCollectedFilter}
options={[
{ value: 'all', label: '全部数据状态' },
{ value: 'collected', label: '已采集' },
{ value: 'uncollected', label: '未采集' },
]}
/>
<Input.Search
className="data-source-filter-toolbar__search"
allowClear
placeholder="搜索名称、标识或采集器"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
/>
</div>
<div ref={tableRegionRef} className="table-scroll-region data-source-table-region">
<Table
columns={columns}
dataSource={allSources}
rowKey="key"
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
preserveSelectedRowKeys: false,
columnWidth: 40,
}}
loading={loading}
pagination={false}
scroll={{ x: 1100, y: tableHeight }}
scroll={{ x: 1350, y: tableHeight }}
tableLayout="fixed"
size="small"
/>

View File

@@ -149,6 +149,10 @@ export const DOCS_METADATA: Record<string, DocsMetadataEntry> = {
zh: { title: 'AI Provider 指南', group: 'Agents', order: 40 },
en: { title: 'AI Provider Guide', group: 'Agents', order: 40 },
},
'ops-runbook.md': {
zh: { title: 'Planet 运维手册', group: 'Ops', order: 49 },
en: { title: 'Planet Ops Runbook', group: 'Ops', order: 49 },
},
'ops-docker-compose-buildx-upgrade.md': {
zh: { title: 'Docker + Compose + Buildx 升级', group: 'Ops', order: 50 },
en: { title: 'Docker + Compose + Buildx Upgrade', group: 'Ops', order: 50 },

View File

@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react'
import { Button, Form, Input, message, Typography } from 'antd'
import { LockOutlined, MailOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
interface ErrorBody {
response?: {
data?: { detail?: string | { code?: string; message?: string; retry_after_seconds?: number } }
}
}
function extractDetail(error: unknown): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
return '操作失败'
}
function ForgotPassword() {
const navigate = useNavigate()
const [step, setStep] = useState<'request' | 'reset'>('request')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [cooldown, setCooldown] = useState(0)
useEffect(() => {
if (cooldown <= 0) return
const timer = setTimeout(() => setCooldown((v) => v - 1), 1000)
return () => clearTimeout(timer)
}, [cooldown])
const onRequest = async (values: { email: string }) => {
setLoading(true)
try {
await axios.post(`${API_URL}/auth/forgot-password`, { email: values.email })
setEmail(values.email)
setStep('reset')
setCooldown(60)
message.success('若该邮箱已注册,验证码已发送。请到邮箱查收。')
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onReset = async (values: { code: string; new_password: string }) => {
setLoading(true)
try {
await axios.post(`${API_URL}/auth/reset-password`, {
email,
code: values.code,
new_password: values.new_password,
})
message.success('密码已重置,请用新密码登录')
navigate('/login')
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onResend = async () => {
if (!email || cooldown > 0) return
try {
await axios.post(`${API_URL}/auth/forgot-password`, { email })
setCooldown(60)
message.success('验证码已重发')
} catch (error) {
message.error(extractDetail(error))
}
}
return (
<div className="login-container">
<div className="login-box">
<h1 style={{ textAlign: 'center', marginBottom: 24 }}></h1>
{step === 'request' ? (
<Form name="forgot" onFinish={onRequest} layout="vertical">
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input prefix={<MailOutlined />} placeholder="注册时使用的邮箱" size="large" autoComplete="email" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<Typography.Link onClick={() => navigate('/login')}></Typography.Link>
</div>
</Form>
) : (
<Form name="reset" onFinish={onReset} layout="vertical">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
<b>{email}</b>10
</Typography.Paragraph>
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Input prefix={<SafetyCertificateOutlined />} placeholder="6 位验证码" size="large" maxLength={6} />
</Form.Item>
<Form.Item
name="new_password"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password prefix={<LockOutlined />} placeholder="新密码 (至少 8 位)" size="large" autoComplete="new-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography.Link onClick={() => setStep('request')}></Typography.Link>
<Button type="link" size="small" disabled={cooldown > 0} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
</Button>
</div>
</Form>
)}
</div>
</div>
)
}
export default ForgotPassword

View File

@@ -1,9 +1,16 @@
import { useState } from 'react'
import { Input, Button, Form, message } from 'antd'
import { UserOutlined, LockOutlined } from '@ant-design/icons'
import { Button, Form, Input, Typography, message } from 'antd'
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
interface LoginError {
response?: {
data?: { detail?: string | { code?: string; email?: string; message?: string } }
status?: number
}
}
function Login() {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
@@ -16,8 +23,16 @@ function Login() {
message.success('登录成功')
navigate('/admin', { replace: true })
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string } } }
message.error(err.response?.data?.detail || '登录失败')
const err = error as LoginError
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.code === 'EMAIL_NOT_VERIFIED') {
message.warning('邮箱未验证,请先完成邮箱验证')
const email = detail.email ? `?email=${encodeURIComponent(detail.email)}` : ''
navigate(`/verify-email${email}`)
return
}
const fallback = typeof detail === 'string' ? detail : detail?.message
message.error(fallback || '登录失败')
} finally {
setLoading(false)
}
@@ -36,6 +51,7 @@ function Login() {
prefix={<UserOutlined />}
placeholder="用户名"
size="large"
autoComplete="username"
/>
</Form.Item>
<Form.Item
@@ -46,6 +62,7 @@ function Login() {
prefix={<LockOutlined />}
placeholder="密码"
size="large"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item>
@@ -59,6 +76,10 @@ function Login() {
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography.Link onClick={() => navigate('/register')}></Typography.Link>
<Typography.Link onClick={() => navigate('/forgot-password')}></Typography.Link>
</div>
</Form>
</div>
</div>

View File

@@ -225,6 +225,7 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
const [helpExpanded, setHelpExpanded] = useState(true)
const [settingsOpen, setSettingsOpen] = useState(false)
const [servicePanelOpen, setServicePanelOpen] = useState(false)
const [helpPanelOpen, setHelpPanelOpen] = useState(false)
const [selectedPresetKey, setSelectedPresetKey] = useState<string>(PLAYGROUND_PRESETS[0].key)
const [title, setTitle] = useState(PLAYGROUND_PRESETS[0].values.title)
const [objective, setObjective] = useState(PLAYGROUND_PRESETS[0].values.objective)
@@ -582,14 +583,16 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
className="playground-card playground-card--provider"
title="Provider 状态"
extra={(
<Button
type="text"
shape="circle"
icon={<SyncOutlined spin={statusLoading} />}
onClick={() => void loadProviderStatus(true)}
aria-label="刷新 Provider 状态"
className="playground-card__icon-button"
/>
<Tooltip title="刷新 Provider 状态">
<Button
type="text"
shape="circle"
icon={<SyncOutlined spin={statusLoading} />}
onClick={() => void loadProviderStatus(true)}
aria-label="刷新 Provider 状态"
className="playground-card__icon-button"
/>
</Tooltip>
)}
>
<Scrollbar className="playground-card__scroll">
@@ -702,7 +705,6 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
<div className="playground-shell">
<div className="playground-shell__sidebar">
{providerStatusPanel}
{helpPanel}
</div>
<Card
@@ -719,6 +721,16 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
className="playground-card__icon-button"
/>
</Tooltip>
<Tooltip title="测试说明">
<Button
type="text"
shape="circle"
icon={<InfoCircleOutlined />}
onClick={() => setHelpPanelOpen(true)}
aria-label="测试说明"
className="playground-card__icon-button"
/>
</Tooltip>
<Tooltip title="设置">
<Button
type="text"
@@ -1015,6 +1027,18 @@ export function PlaygroundWorkspace({ embedded = false }: { embedded?: boolean }
>
<div className="playground-service-modal__body">
{providerStatusPanel}
</div>
</Modal>
<Modal
title="测试说明"
open={helpPanelOpen}
onCancel={() => setHelpPanelOpen(false)}
footer={null}
width={560}
className="playground-service-modal"
>
<div className="playground-service-modal__body">
{helpPanel}
</div>
</Modal>

View File

@@ -0,0 +1,195 @@
import { useEffect, useState } from 'react'
import { Button, Form, Input, message, Typography } from 'antd'
import { LockOutlined, MailOutlined, SafetyCertificateOutlined, UserOutlined } from '@ant-design/icons'
import axios from 'axios'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
interface RegisterFormValues {
username: string
email: string
password: string
}
interface VerifyFormValues {
code: string
}
interface ErrorBody {
response?: {
data?: {
detail?: string | { code?: string; message?: string; retry_after_seconds?: number }
}
status?: number
}
}
function extractDetail(error: unknown): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') {
return detail.message || detail.code || '操作失败'
}
return '操作失败'
}
function RESEND_COOLDOWN(): number {
return 60
}
function Register() {
const navigate = useNavigate()
const [step, setStep] = useState<'register' | 'verify'>('register')
const [email, setEmail] = useState('')
const [loading, setLoading] = useState(false)
const [resending, setResending] = useState(false)
const [cooldown, setCooldown] = useState(0)
useEffect(() => {
if (cooldown <= 0) return
const timer = setTimeout(() => setCooldown((value) => value - 1), 1000)
return () => clearTimeout(timer)
}, [cooldown])
const onRegister = async (values: RegisterFormValues) => {
setLoading(true)
try {
await axios.post(`${API_URL}/auth/register`, values)
setEmail(values.email)
setStep('verify')
setCooldown(RESEND_COOLDOWN())
message.success('验证码已发送到邮箱')
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onVerify = async (values: VerifyFormValues) => {
setLoading(true)
try {
const response = await axios.post(`${API_URL}/auth/verify-email`, {
email,
code: values.code,
})
const { access_token, user } = response.data
useAuthStore.setState({ token: access_token, user })
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
message.success('邮箱验证成功,正在登录…')
navigate('/admin', { replace: true })
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onResend = async () => {
if (cooldown > 0 || !email) return
setResending(true)
try {
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
setCooldown(RESEND_COOLDOWN())
message.success('验证码已重发')
} catch (error) {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.retry_after_seconds) {
setCooldown(detail.retry_after_seconds)
}
message.error(extractDetail(error))
} finally {
setResending(false)
}
}
return (
<div className="login-container">
<div className="login-box">
<h1 style={{ textAlign: 'center', marginBottom: 24 }}></h1>
{step === 'register' ? (
<Form name="register" onFinish={onRegister} layout="vertical">
<Form.Item
name="username"
rules={[
{ required: true, message: '请输入用户名' },
{ min: 3, max: 50, message: '用户名长度 3-50' },
]}
>
<Input prefix={<UserOutlined />} placeholder="用户名" size="large" autoComplete="username" />
</Form.Item>
<Form.Item
name="email"
rules={[
{ required: true, message: '请输入邮箱' },
{ type: 'email', message: '邮箱格式不正确' },
]}
>
<Input prefix={<MailOutlined />} placeholder="邮箱" size="large" autoComplete="email" />
</Form.Item>
<Form.Item
name="password"
rules={[
{ required: true, message: '请输入密码' },
{ min: 8, message: '密码至少 8 位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="密码 (至少 8 位)"
size="large"
autoComplete="new-password"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
<div style={{ textAlign: 'center' }}>
<Typography.Link onClick={() => navigate('/login')}></Typography.Link>
</div>
</Form>
) : (
<Form name="verify" onFinish={onVerify} layout="vertical">
<Typography.Paragraph type="secondary" style={{ textAlign: 'center' }}>
6 <b>{email}</b>10
</Typography.Paragraph>
<Form.Item
name="code"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="6 位验证码"
size="large"
maxLength={6}
inputMode="numeric"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography.Link onClick={() => setStep('register')}></Typography.Link>
<Button type="link" size="small" disabled={cooldown > 0 || resending} loading={resending} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
</Button>
</div>
</Form>
)}
</div>
</div>
)
}
export default Register

View File

@@ -18,6 +18,7 @@ import {
} from '@ant-design/icons'
import {
Alert,
AutoComplete,
Button,
Card,
Checkbox,
@@ -37,11 +38,13 @@ import {
} from 'antd'
import axios from 'axios'
import AppLayout from '../../components/AppLayout/AppLayout'
import ConnectionTestInput, { PlugConnectIcon } from '../../components/ConnectionTestInput/ConnectionTestInput'
import Scrollbar from '../../components/Scrollbar/Scrollbar'
import TableScrollRegion from '../../components/Scrollbar/TableScrollRegion'
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
import { formatDateTimeZhCN } from '../../utils/datetime'
import { useNavigate, useSearchParams } from 'react-router-dom'
import SmtpPanel from './SmtpPanel'
const { Title, Text } = Typography
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
@@ -318,62 +321,6 @@ const formatLagSeconds = (value: number | null | undefined) => {
return `${Math.round(value / 3600)} 小时`
}
function PlugConnectIcon() {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path
d="M7 12l5 5l-1.5 1.5a3.536 3.536 0 1 1 -5 -5l1.5 -1.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M17 12l-5 -5l1.5 -1.5a3.536 3.536 0 1 1 5 5l-1.5 1.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3 21l2.5 -2.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M18.5 5.5l2.5 -2.5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M10 11l-2 2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M13 14l-2 2"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
function SettingsPanel({
loading,
children,
@@ -441,6 +388,7 @@ function Settings() {
const [tvEditForm] = Form.useForm<TVStreamSource>()
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
const selectedWebSearchProvider = Form.useWatch(['web_search', 'provider'], integrationForm)
const webSearchEnabled = Form.useWatch(['web_search', 'enabled'], integrationForm)
const selectedAiProviderSecret = selectedAiProvider
? integrations?.ai_provider.providers?.[selectedAiProvider]?.api_key || integrations?.ai_provider.api_key
: integrations?.ai_provider.api_key
@@ -1907,6 +1855,11 @@ function Settings() {
</SettingsPanel>
),
},
{
key: 'smtp',
label: 'SMTP 邮件',
children: <SmtpPanel />,
},
{
key: 'tv',
label: '电视直播',
@@ -2036,42 +1989,43 @@ function Settings() {
>
<Card size="small" title={<Space><ApiOutlined />LLM Provider</Space>}>
<Form.Item name={['ai_provider', 'provider']} label="Provider">
<Select
showSearch
optionFilterProp="label"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={aiProviderPresets.map((preset) => ({
value: preset.provider,
label: `${preset.label} · ${preset.provider_api}`,
}))}
onChange={(value) => {
onSelect={(value) => {
applyAiProviderSelection(value)
}}
placeholder="选择或输入 provider id例如 openai"
/>
</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=" ">
<Space>
<Button
icon={<SyncOutlined />}
loading={refreshingAiPreset}
onClick={refreshSelectedAiProviderPreset}
>
</Button>
<Button
icon={<PlayCircleOutlined />}
loading={testingAiProviderConnection}
onClick={() => { void testAiProviderConnection() }}
>
</Button>
</Space>
</Form.Item>
</div>
<Form.Item name={['ai_provider', 'base_url']} label="LLM Base URL">
<ConnectionTestInput
placeholder="https://api.example.com/v1"
testing={testingAiProviderConnection}
testDisabled={!selectedAiProvider}
onTest={() => { void testAiProviderConnection() }}
extraSuffix={(
<Tooltip title="刷新当前 Provider 的模型配置">
<Button
type="text"
size="small"
icon={<SyncOutlined spin={refreshingAiPreset} />}
disabled={!selectedAiProvider}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void refreshSelectedAiProviderPreset()
}}
aria-label="刷新当前 Provider 的模型配置"
/>
</Tooltip>
)}
/>
</Form.Item>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
@@ -2082,13 +2036,12 @@ function Settings() {
</Form.Item>
<Form.Item name={['ai_provider', 'model']} label="默认模型">
<Select
showSearch
optionFilterProp="label"
<AutoComplete
filterOption={(input, option) => String(option?.label || '').toLowerCase().includes(input.toLowerCase())}
options={(
aiProviderPresets.find((preset) => preset.provider === selectedAiProvider)?.models || []
).map((model) => ({ value: model, label: model }))}
dropdownRender={(menu) => menu}
placeholder="选择或输入模型名,例如 gpt-5.1"
/>
</Form.Item>
@@ -2105,13 +2058,15 @@ function Settings() {
autoComplete="new-password"
placeholder="输入新的 LLM API key"
suffix={(
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
<Tooltip title={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}>
<Button
type="text"
size="small"
icon={aiProviderApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleAiProviderApiKeyVisibleChange(!aiProviderApiKeyRevealed) }}
aria-label={aiProviderApiKeyRevealed ? '隐藏 LLM API key' : '显示 LLM API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -2148,13 +2103,15 @@ function Settings() {
autoComplete="new-password"
placeholder="输入新的代理 token"
suffix={(
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
<Tooltip title={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}>
<Button
type="text"
size="small"
icon={serviceTokenRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleServiceTokenVisibleChange(!serviceTokenRevealed) }}
aria-label={serviceTokenRevealed ? '隐藏代理 token' : '显示代理 token'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -2171,6 +2128,7 @@ function Settings() {
<Form.Item name={['web_search', 'provider']} label="WebSearch Provider">
<Select
showSearch
disabled={!webSearchEnabled}
optionFilterProp="label"
options={webSearchPresets.map((preset) => ({
value: preset.provider,
@@ -2182,18 +2140,15 @@ function Settings() {
/>
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '0 12px', alignItems: 'end' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0 12px', alignItems: 'end' }}>
<Form.Item name={['web_search', 'base_url']} label="API Base URL">
<Input placeholder="https://api.tavily.com" />
</Form.Item>
<Form.Item label=" ">
<Button
icon={<PlayCircleOutlined />}
loading={testingWebSearchConnection}
onClick={() => { void testWebSearchConnection() }}
>
</Button>
<ConnectionTestInput
placeholder="https://api.tavily.com"
disabled={!webSearchEnabled}
testing={testingWebSearchConnection}
testDisabled={!webSearchEnabled || !selectedWebSearchProvider}
onTest={() => { void testWebSearchConnection() }}
/>
</Form.Item>
</div>
@@ -2208,15 +2163,19 @@ function Settings() {
<Form.Item name={['web_search', 'api_key']} noStyle>
<Input
autoComplete="new-password"
disabled={!webSearchEnabled}
placeholder="输入新的 WebSearch API keySearXNG 可留空"
suffix={(
<Button
type="text"
size="small"
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
<Tooltip title={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}>
<Button
type="text"
size="small"
disabled={!webSearchEnabled}
icon={webSearchApiKeyRevealed ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => { void handleWebSearchApiKeyVisibleChange(!webSearchApiKeyRevealed) }}
aria-label={webSearchApiKeyRevealed ? '隐藏 WebSearch API key' : '显示 WebSearch API key'}
/>
</Tooltip>
)}
/>
</Form.Item>
@@ -2225,43 +2184,43 @@ function Settings() {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'max_results']} label="最大结果数">
<InputNumber min={1} max={20} style={{ width: '100%' }} />
<InputNumber min={1} max={20} disabled={!webSearchEnabled} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name={['web_search', 'timeout_seconds']} label="超时(秒)">
<InputNumber min={3} max={120} style={{ width: '100%' }} />
<InputNumber min={3} max={120} disabled={!webSearchEnabled} style={{ width: '100%' }} />
</Form.Item>
</div>
<Card size="small" type="inner" title="高级选项" style={{ marginTop: 8 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['web_search', 'endpoint_path']} label="Endpoint Path">
<Input placeholder="/search" />
<Input disabled={!webSearchEnabled} placeholder="/search" />
</Form.Item>
<Form.Item name={['web_search', 'search_depth']} label="Search Depth">
<Input placeholder="basic" />
<Input disabled={!webSearchEnabled} placeholder="basic" />
</Form.Item>
<Form.Item name={['web_search', 'engine']} label="SerpAPI Engine">
<Input placeholder="google" />
<Input disabled={!webSearchEnabled} placeholder="google" />
</Form.Item>
<Form.Item name={['web_search', 'categories']} label="SearXNG Categories">
<Input placeholder="general" />
<Input disabled={!webSearchEnabled} placeholder="general" />
</Form.Item>
<Form.Item name={['web_search', 'search_path']} label="Firecrawl Search Path">
<Input placeholder="/v2/search" />
<Input disabled={!webSearchEnabled} placeholder="/v2/search" />
</Form.Item>
<Form.Item name={['web_search', 'scrape_path']} label="Firecrawl Scrape Path">
<Input placeholder="/v2/scrape" />
<Input disabled={!webSearchEnabled} placeholder="/v2/scrape" />
</Form.Item>
</div>
<Space wrap>
<Form.Item name={['web_search', 'include_answer']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Answer</Checkbox>
<Checkbox disabled={!webSearchEnabled}> Answer</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_raw_content']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Raw Content</Checkbox>
<Checkbox disabled={!webSearchEnabled}> Raw Content</Checkbox>
</Form.Item>
<Form.Item name={['web_search', 'include_text']} valuePropName="checked" style={{ marginBottom: 0 }}>
<Checkbox> Result Text</Checkbox>
<Checkbox disabled={!webSearchEnabled}> Result Text</Checkbox>
</Form.Item>
</Space>
</Card>

View File

@@ -0,0 +1,208 @@
import { useEffect, useState } from 'react'
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Space, Switch, Typography, message } from 'antd'
import axios from 'axios'
interface SecretStatus {
configured: boolean
preview: string
source?: string
}
interface SmtpSettingsResponse {
host: string
port: number
username: string
password: SecretStatus
from_address: string
from_name: string
use_tls: boolean
use_starttls: boolean
timeout_seconds: number
configured: boolean
}
interface SmtpFormValues {
host: string
port: number
username: string
password: string
from_address: string
from_name: string
use_tls: boolean
use_starttls: boolean
timeout_seconds: number
}
interface ErrorBody {
response?: { data?: { detail?: string | { code?: string; message?: string } } }
}
function extractDetail(error: unknown): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
return '操作失败'
}
export default function SmtpPanel() {
const [form] = Form.useForm<SmtpFormValues>()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [testOpen, setTestOpen] = useState(false)
const [testTo, setTestTo] = useState('')
const [data, setData] = useState<SmtpSettingsResponse | null>(null)
const load = async () => {
setLoading(true)
try {
const response = await axios.get('/api/v1/settings/smtp')
const smtp = response.data.smtp as SmtpSettingsResponse
setData(smtp)
form.setFieldsValue({
host: smtp.host,
port: smtp.port,
username: smtp.username,
password: smtp.password.configured ? smtp.password.preview : '',
from_address: smtp.from_address,
from_name: smtp.from_name,
use_tls: smtp.use_tls,
use_starttls: smtp.use_starttls,
timeout_seconds: smtp.timeout_seconds,
})
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const onSave = async (values: SmtpFormValues) => {
setSaving(true)
try {
const payload: Record<string, unknown> = { ...values }
// If the user did not touch the masked password, send empty so the backend keeps the current value.
if (data?.password.configured && values.password === data.password.preview) {
payload.password = ''
}
const response = await axios.put('/api/v1/settings/smtp', payload)
setData(response.data.smtp)
const smtp = response.data.smtp as SmtpSettingsResponse
form.setFieldsValue({
...values,
password: smtp.password.configured ? smtp.password.preview : '',
})
message.success('SMTP 设置已保存')
} catch (error) {
message.error(extractDetail(error))
} finally {
setSaving(false)
}
}
const onTest = async () => {
if (!testTo) {
message.warning('请填写收件地址')
return
}
setTesting(true)
try {
const values = form.getFieldsValue()
const payload: Record<string, unknown> = { ...values }
if (data?.password.configured && values.password === data.password.preview) {
payload.password = ''
}
const response = await axios.post('/api/v1/settings/smtp/test', {
to: testTo,
settings: payload,
})
if (response.data.success) {
message.success('测试邮件已发送')
setTestOpen(false)
} else {
message.error(response.data.message || '测试发送失败')
}
} catch (error) {
message.error(extractDetail(error))
} finally {
setTesting(false)
}
}
return (
<Card loading={loading} className="settings-panel-card">
{!data?.configured && (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="尚未配置 SMTP公开注册和邮箱验证暂不可用。"
description="配置主机、发件地址和(如需要)账号密码,保存后通过下方测试发送验证。"
/>
)}
<Form form={form} layout="vertical" onFinish={onSave}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name="host" label="SMTP 主机" rules={[{ required: true, message: '请输入 SMTP 主机' }]}>
<Input placeholder="smtp.example.com" />
</Form.Item>
<Form.Item name="port" label="端口" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="username" label="账号">
<Input placeholder="发件账户用户名" autoComplete="off" />
</Form.Item>
<Form.Item name="password" label="密码">
<Input.Password placeholder="保存的密码会以 *** 显示,留空或保持不变以保留原密码" autoComplete="new-password" />
</Form.Item>
<Form.Item name="from_address" label="发件地址" rules={[{ required: true, type: 'email' }]}>
<Input placeholder="noreply@example.com" />
</Form.Item>
<Form.Item name="from_name" label="发件人名称">
<Input placeholder="Planet" />
</Form.Item>
<Form.Item name="use_starttls" label="STARTTLS (端口 587)" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="use_tls" label="隐式 TLS (端口 465)" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="timeout_seconds" label="超时 (秒)">
<InputNumber min={3} max={300} style={{ width: '100%' }} />
</Form.Item>
</div>
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
STARTTLS TLS 587 465
</Typography.Paragraph>
<Space>
<Button type="primary" htmlType="submit" loading={saving}> SMTP </Button>
<Button onClick={() => setTestOpen(true)}></Button>
</Space>
</Form>
<Modal
title="发送测试邮件"
open={testOpen}
onCancel={() => setTestOpen(false)}
onOk={onTest}
okText="发送"
okButtonProps={{ loading: testing }}
cancelText="取消"
>
<Typography.Paragraph type="secondary">
SMTP
</Typography.Paragraph>
<Input
placeholder="收件地址"
value={testTo}
onChange={(event) => setTestTo(event.target.value)}
autoFocus
/>
</Modal>
</Card>
)
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { Button, Form, Input, message, Typography } from 'antd'
import { MailOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
import axios from 'axios'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAuthStore } from '../../stores/auth'
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
interface ErrorBody {
response?: {
data?: { detail?: string | { code?: string; message?: string; retry_after_seconds?: number } }
}
}
function extractDetail(error: unknown): string {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
return '操作失败'
}
function VerifyEmail() {
const navigate = useNavigate()
const [search] = useSearchParams()
const [email, setEmail] = useState(search.get('email') || '')
const [loading, setLoading] = useState(false)
const [resending, setResending] = useState(false)
const [cooldown, setCooldown] = useState(0)
useEffect(() => {
if (cooldown <= 0) return
const timer = setTimeout(() => setCooldown((v) => v - 1), 1000)
return () => clearTimeout(timer)
}, [cooldown])
const onVerify = async (values: { code: string }) => {
setLoading(true)
try {
const response = await axios.post(`${API_URL}/auth/verify-email`, { email, code: values.code })
const { access_token, user } = response.data
useAuthStore.setState({ token: access_token, user })
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
message.success('邮箱验证成功,正在登录…')
navigate('/admin', { replace: true })
} catch (error) {
message.error(extractDetail(error))
} finally {
setLoading(false)
}
}
const onResend = async () => {
if (!email || cooldown > 0) return
setResending(true)
try {
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
setCooldown(60)
message.success('验证码已重发')
} catch (error) {
const err = error as ErrorBody
const detail = err.response?.data?.detail
if (detail && typeof detail === 'object' && detail.retry_after_seconds) {
setCooldown(detail.retry_after_seconds)
}
message.error(extractDetail(error))
} finally {
setResending(false)
}
}
return (
<div className="login-container">
<div className="login-box">
<h1 style={{ textAlign: 'center', marginBottom: 24 }}></h1>
<Form name="verify-email" onFinish={onVerify} layout="vertical">
<Form.Item label="邮箱" required>
<Input
prefix={<MailOutlined />}
size="large"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="注册时使用的邮箱"
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="code"
label="验证码"
rules={[
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为 6 位数字' },
]}
>
<Input
prefix={<SafetyCertificateOutlined />}
placeholder="6 位验证码"
size="large"
maxLength={6}
inputMode="numeric"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading} disabled={!email}>
</Button>
</Form.Item>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography.Link onClick={() => navigate('/login')}></Typography.Link>
<Button type="link" size="small" disabled={cooldown > 0 || resending || !email} loading={resending} onClick={onResend}>
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
</Button>
</div>
</Form>
</div>
</div>
)
}
export default VerifyEmail