release: bump version to 0.52.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.51.1",
|
||||
"version": "0.52.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -206,7 +206,7 @@ export const PATHS = {
|
||||
cablesApi: '/api/v1/visualization/geo/cables',
|
||||
landingPointsApi: '/api/v1/visualization/geo/landing-points',
|
||||
computeCentersApi: '/api/v1/visualization/geo/compute-centers',
|
||||
vesselsApi: '/api/v1/visualization/geo/vessels',
|
||||
vesselsApi: '/api/v1/vessels/snapshot',
|
||||
vesselTrackApi: (mmsi) => `/api/v1/visualization/vessels/${encodeURIComponent(mmsi)}/track`,
|
||||
bgpApi: '/api/v1/visualization/geo/bgp-anomalies',
|
||||
bgpIncidentsApi: '/api/v1/visualization/geo/bgp-incidents',
|
||||
|
||||
@@ -258,6 +258,11 @@ const vesselIconLayer = createInteractableLayer({
|
||||
}),
|
||||
});
|
||||
|
||||
const DEFAULT_VESSEL_VIEWPORT = {
|
||||
bbox: [-180, -90, 180, 90],
|
||||
zoom: 2,
|
||||
};
|
||||
|
||||
export function getVesselMarkers() {
|
||||
return vesselIconLayer.getMarkers();
|
||||
}
|
||||
@@ -313,6 +318,14 @@ export function clearVesselData(earth) {
|
||||
export async function loadVessels(_scene, earth, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers);
|
||||
const bbox = Array.isArray(options.bbox) && options.bbox.length === 4
|
||||
? options.bbox
|
||||
: DEFAULT_VESSEL_VIEWPORT.bbox;
|
||||
const zoom = Number.isFinite(Number(options.zoom))
|
||||
? Number(options.zoom)
|
||||
: DEFAULT_VESSEL_VIEWPORT.zoom;
|
||||
params.set("bbox", bbox.join(","));
|
||||
params.set("zoom", String(zoom));
|
||||
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
||||
params.set("limit", String(requestedLimit));
|
||||
}
|
||||
@@ -352,7 +365,15 @@ export function startVesselRealtime(earth, { onUpdate } = {}) {
|
||||
connected: true,
|
||||
};
|
||||
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
socket.send(JSON.stringify({ type: "subscribe", data: { channels: ["vessels"] } }));
|
||||
socket.send(JSON.stringify({
|
||||
type: "subscribe",
|
||||
data: {
|
||||
channel: "vessels",
|
||||
bbox: DEFAULT_VESSEL_VIEWPORT.bbox,
|
||||
zoom: DEFAULT_VESSEL_VIEWPORT.zoom,
|
||||
limit: VESSEL_CONFIG.maxRenderedMarkers,
|
||||
},
|
||||
}));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
let message;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
|
||||
import { Spin } from 'antd'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import Login from './pages/Login/Login'
|
||||
|
||||
const Register = lazy(() => import('./pages/Register/Register'))
|
||||
const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail'))
|
||||
const ForgotPassword = lazy(() => import('./pages/ForgotPassword/ForgotPassword'))
|
||||
const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts'))
|
||||
const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts'))
|
||||
const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts'))
|
||||
@@ -25,7 +28,8 @@ const EARTH_ROUTE = '/earth'
|
||||
const DOCS_ROUTE = '/docs'
|
||||
const DOCS_ROUTE_PATTERN = '/docs/:slug'
|
||||
const DOCS_ROUTE_PREFIX = `${DOCS_ROUTE}/`
|
||||
const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE])
|
||||
const AUTH_ROUTES = new Set(['/login', '/register', '/verify-email', '/forgot-password'])
|
||||
const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE, ...AUTH_ROUTES])
|
||||
|
||||
function isPublicPath(pathname: string) {
|
||||
return PUBLIC_EXACT_ROUTES.has(pathname) || pathname.startsWith(DOCS_ROUTE_PREFIX)
|
||||
@@ -33,7 +37,8 @@ function isPublicPath(pathname: string) {
|
||||
|
||||
function App() {
|
||||
const { token } = useAuthStore()
|
||||
const isPublicRoute = isPublicPath(window.location.pathname)
|
||||
const { pathname } = useLocation()
|
||||
const isPublicRoute = isPublicPath(pathname)
|
||||
|
||||
if (!token && !isPublicRoute) {
|
||||
return <Login />
|
||||
@@ -48,6 +53,10 @@ function App() {
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/admin" element={<Dashboard />} />
|
||||
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
|
||||
<Route path={EARTH_ROUTE} element={<Earth />} />
|
||||
@@ -71,4 +80,4 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button, Input, Tooltip, type InputProps } from 'antd'
|
||||
|
||||
export 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>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConnectionTestInputProps extends Omit<InputProps, 'suffix'> {
|
||||
testTitle?: ReactNode
|
||||
testing?: boolean
|
||||
testDisabled?: boolean
|
||||
onTest?: () => void
|
||||
extraSuffix?: ReactNode
|
||||
}
|
||||
|
||||
export default function ConnectionTestInput({
|
||||
testTitle = '测试连接',
|
||||
testing = false,
|
||||
testDisabled = false,
|
||||
disabled,
|
||||
onTest,
|
||||
extraSuffix,
|
||||
...inputProps
|
||||
}: ConnectionTestInputProps) {
|
||||
const actionDisabled = disabled || testDisabled
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...inputProps}
|
||||
disabled={disabled}
|
||||
suffix={(
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
|
||||
<Tooltip title={testTitle}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PlugConnectIcon />}
|
||||
loading={testing}
|
||||
disabled={actionDisabled}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!actionDisabled) {
|
||||
onTest?.()
|
||||
}
|
||||
}}
|
||||
aria-label="测试连接"
|
||||
/>
|
||||
</Tooltip>
|
||||
{extraSuffix}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -363,18 +363,10 @@ body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.playground-shell__sidebar {
|
||||
flex: 0 0 340px;
|
||||
min-width: 300px;
|
||||
max-width: 360px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playground-card {
|
||||
@@ -400,7 +392,7 @@ body {
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: none;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playground-card__icon-button:hover {
|
||||
@@ -1349,14 +1341,6 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playground-chat__service-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playground-shell__sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playground-result__meta,
|
||||
.playground-result__blocks-head {
|
||||
align-items: stretch;
|
||||
@@ -1585,6 +1569,26 @@ body {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.data-source-filter-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.data-source-filter-toolbar__control {
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
.data-source-filter-toolbar__search {
|
||||
flex: 1 1 220px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.data-source-table-region {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -1628,6 +1632,20 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-selection-column {
|
||||
width: 40px;
|
||||
min-width: 40px;
|
||||
max-width: 40px;
|
||||
padding-inline: 0 !important;
|
||||
text-align: center;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.data-source-table-region .ant-table-selection-column .ant-checkbox-wrapper {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.data-source-empty-state {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
||||
@@ -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 key;SearXNG 可留空"
|
||||
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 key;SearXNG 可留空"
|
||||
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 配置
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
144
frontend/src/pages/ForgotPassword/ForgotPassword.tsx
Normal file
144
frontend/src/pages/ForgotPassword/ForgotPassword.tsx
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
195
frontend/src/pages/Register/Register.tsx
Normal file
195
frontend/src/pages/Register/Register.tsx
Normal 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
|
||||
@@ -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 key;SearXNG 可留空"
|
||||
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>
|
||||
|
||||
208
frontend/src/pages/Settings/SmtpPanel.tsx
Normal file
208
frontend/src/pages/Settings/SmtpPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
frontend/src/pages/VerifyEmail/VerifyEmail.tsx
Normal file
121
frontend/src/pages/VerifyEmail/VerifyEmail.tsx
Normal 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
|
||||
Reference in New Issue
Block a user