release: bump version to 0.47.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.46.3",
|
||||
"version": "0.47.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -210,7 +210,7 @@ export const PATHS = {
|
||||
|
||||
export const VESSEL_CONFIG = {
|
||||
altitudeOffset: 0.2,
|
||||
maxRenderedMarkers: 5000,
|
||||
maxRenderedMarkers: 0,
|
||||
marker: {
|
||||
baseScale: 7.5,
|
||||
baseOpacity: 0.88,
|
||||
|
||||
@@ -787,12 +787,13 @@ function formatVesselStatus(navStatus) {
|
||||
|
||||
function showVesselInfo(marker, coords) {
|
||||
setLegendMode("vessels");
|
||||
const vesselType = marker.userData?.vessel_type_display || marker.userData?.vessel_type_name || "-";
|
||||
showInfoCard("vessel", {
|
||||
name: marker.userData?.name || `MMSI ${marker.userData?.mmsi}`,
|
||||
mmsi: marker.userData?.mmsi,
|
||||
imo: marker.userData?.imo || "-",
|
||||
flag: marker.userData?.flag || "-",
|
||||
vessel_type: marker.userData?.vessel_type_name || "-",
|
||||
vessel_type: vesselType,
|
||||
speed: marker.userData?.sog ?? "-",
|
||||
course: marker.userData?.cog ?? marker.userData?.heading ?? "-",
|
||||
status: formatVesselStatus(marker.userData?.nav_status),
|
||||
@@ -806,7 +807,8 @@ function showVesselInfo(marker, coords) {
|
||||
function getVesselBriefHtml(marker) {
|
||||
const name = marker.userData?.name || `MMSI ${marker.userData?.mmsi}`;
|
||||
const speed = marker.userData?.sog ?? "-";
|
||||
return `<strong>${name}</strong><br>${marker.userData?.vessel_type_name || "Vessel"} · ${speed} kn`;
|
||||
const vesselType = marker.userData?.vessel_type_display || marker.userData?.vessel_type_name || "Vessel";
|
||||
return `<strong>${name}</strong><br>${vesselType} · ${speed} kn`;
|
||||
}
|
||||
|
||||
function getComputeCenterBriefHtml(marker) {
|
||||
@@ -1390,6 +1392,7 @@ function resolveEarthSearchResults(query) {
|
||||
marker.userData?.mmsi,
|
||||
marker.userData?.imo,
|
||||
marker.userData?.flag,
|
||||
marker.userData?.vessel_type_display,
|
||||
marker.userData?.vessel_type_name,
|
||||
"船只 船舶 ais vessel ship maritime",
|
||||
);
|
||||
@@ -1401,7 +1404,7 @@ function resolveEarthSearchResults(query) {
|
||||
typeLabel: "船只",
|
||||
title: marker.userData?.name || `MMSI ${marker.userData?.mmsi}`,
|
||||
subtitle: [
|
||||
marker.userData?.vessel_type_name,
|
||||
marker.userData?.vessel_type_display || marker.userData?.vessel_type_name,
|
||||
marker.userData?.flag,
|
||||
marker.userData?.sog !== undefined ? `${marker.userData.sog} kn` : null,
|
||||
].filter(Boolean).join(" · ") || "AIS 船只",
|
||||
|
||||
@@ -24,6 +24,17 @@ function normalizeVesselType(value, code) {
|
||||
return "other";
|
||||
}
|
||||
|
||||
function formatVesselTypeLabel(type, fallback = "") {
|
||||
const rawFallback = String(fallback || "").trim();
|
||||
const normalized = String(type || "").trim().toLowerCase();
|
||||
if (normalized === "cargo") return "Cargo";
|
||||
if (normalized === "tanker") return "Tanker";
|
||||
if (normalized === "passenger") return "Passenger";
|
||||
if (normalized === "fishing") return "Fishing";
|
||||
if (normalized === "military") return "Military";
|
||||
return rawFallback && rawFallback.toLowerCase() !== "other" ? rawFallback : "Other";
|
||||
}
|
||||
|
||||
function drawVesselShape(context, anchored, glow, color = "#ffffff") {
|
||||
context.fillStyle = color;
|
||||
context.globalAlpha = anchored ? 0.55 : 0.96;
|
||||
@@ -52,6 +63,7 @@ function buildVesselMarkerData(feature) {
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
|
||||
const type = normalizeVesselType(props.vessel_type_name, props.vessel_type);
|
||||
const vesselTypeLabel = formatVesselTypeLabel(type, props.vessel_type_name);
|
||||
const navStatus = Number(props.nav_status);
|
||||
const speed = Number(props.sog);
|
||||
const anchored = navStatus === 1 || navStatus === 5 || (Number.isFinite(speed) && speed < 0.5);
|
||||
@@ -61,6 +73,7 @@ function buildVesselMarkerData(feature) {
|
||||
latitude,
|
||||
longitude,
|
||||
type,
|
||||
vessel_type_display: vesselTypeLabel,
|
||||
anchored,
|
||||
course: Number(props.cog ?? props.heading ?? 0),
|
||||
};
|
||||
@@ -191,7 +204,10 @@ export function clearVesselData(earth) {
|
||||
|
||||
export async function loadVessels(_scene, earth, options = {}) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(options.limit || VESSEL_CONFIG.maxRenderedMarkers));
|
||||
const requestedLimit = Number(options.limit ?? VESSEL_CONFIG.maxRenderedMarkers);
|
||||
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
||||
params.set("limit", String(requestedLimit));
|
||||
}
|
||||
const response = await fetch(`${PATHS.vesselsApi}?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vessels HTTP ${response.status}`);
|
||||
@@ -200,10 +216,12 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
|
||||
clearVesselData(earth);
|
||||
const markerData = features
|
||||
let markerData = features
|
||||
.map((feature) => buildVesselMarkerData(feature))
|
||||
.filter(Boolean)
|
||||
.slice(0, VESSEL_CONFIG.maxRenderedMarkers);
|
||||
.filter(Boolean);
|
||||
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
||||
markerData = markerData.slice(0, requestedLimit);
|
||||
}
|
||||
vesselIconLayer.setData(markerData);
|
||||
|
||||
vesselIconLayer.attach(earth);
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import axios, { type AxiosResponse } from 'axios'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||
import ScrollbarOverlay from '../../components/Scrollbar/ScrollbarOverlay'
|
||||
@@ -224,6 +225,7 @@ function normalizeCustom(source: CustomDataSource): UnifiedDataSource {
|
||||
|
||||
function DataSources() {
|
||||
const [messageApi, contextHolder] = message.useMessage()
|
||||
const navigate = useNavigate()
|
||||
const [modal, modalContextHolder] = Modal.useModal()
|
||||
const [builtInSources, setBuiltInSources] = useState<BuiltInDataSource[]>([])
|
||||
const [customSources, setCustomSources] = useState<CustomDataSource[]>([])
|
||||
@@ -736,6 +738,15 @@ function DataSources() {
|
||||
showIcon
|
||||
message="需要采集器凭证"
|
||||
description={viewingSource.credential_status === 'supported' ? '请在设置中心的采集器设置中维护该采集器凭证。' : '该采集器需要凭证,配置入口待接入。'}
|
||||
action={viewingSource.credential_status === 'supported' ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => navigate(`/settings?tab=collector_credentials&collector=${encodeURIComponent(viewingSource.source)}`)}
|
||||
>
|
||||
去配置
|
||||
</Button>
|
||||
) : undefined}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -82,6 +82,18 @@ interface CollectorSettings {
|
||||
requires_credentials?: boolean
|
||||
credential_provider?: string | null
|
||||
credential_status?: string
|
||||
ais_health?: AISSourceHealth | null
|
||||
}
|
||||
|
||||
interface AISSourceHealth {
|
||||
source: string
|
||||
connection_state: string
|
||||
last_seen_at: string | null
|
||||
last_success_at: string | null
|
||||
last_error: string | null
|
||||
message_rate: number | null
|
||||
lag_seconds: number | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
interface TVStreamSource {
|
||||
@@ -166,12 +178,59 @@ interface CollectorConfigOption {
|
||||
is_active: boolean
|
||||
source_type: string
|
||||
auth_type: string
|
||||
auth_config?: Record<string, any>
|
||||
auth_configured?: Record<string, boolean>
|
||||
headers: Record<string, string>
|
||||
config: Record<string, any>
|
||||
config_id: number | null
|
||||
description: string
|
||||
}
|
||||
|
||||
const AISSTREAM_BBOX_PRESETS = [
|
||||
{
|
||||
value: 'global',
|
||||
label: '全球',
|
||||
boxes: [[[-90, -180], [90, 180]]],
|
||||
},
|
||||
{
|
||||
value: 'norway_north_sea',
|
||||
label: '挪威 / 北海',
|
||||
boxes: [[[50, -8], [72, 32]]],
|
||||
},
|
||||
{
|
||||
value: 'europe_coast',
|
||||
label: '欧洲近海',
|
||||
boxes: [[[35, -12], [72, 32]]],
|
||||
},
|
||||
{
|
||||
value: 'east_asia',
|
||||
label: '东亚',
|
||||
boxes: [[[18, 105], [46, 146]]],
|
||||
},
|
||||
{
|
||||
value: 'north_america_coasts',
|
||||
label: '北美东西海岸',
|
||||
boxes: [
|
||||
[[24, -126], [50, -66]],
|
||||
[[18, -98], [31, -80]],
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const stringifyBoundingBoxes = (boxes: unknown) => JSON.stringify(boxes, null, 2)
|
||||
|
||||
const matchAisstreamBboxPreset = (boxes: unknown) => {
|
||||
const serialized = JSON.stringify(boxes)
|
||||
return AISSTREAM_BBOX_PRESETS.find((preset) => JSON.stringify(preset.boxes) === serialized)?.value || 'custom'
|
||||
}
|
||||
|
||||
const formatLagSeconds = (value: number | null | undefined) => {
|
||||
if (value == null) return '未知'
|
||||
if (value < 60) return `${Math.round(value)} 秒`
|
||||
if (value < 3600) return `${Math.round(value / 60)} 分钟`
|
||||
return `${Math.round(value / 3600)} 小时`
|
||||
}
|
||||
|
||||
function PlugConnectIcon() {
|
||||
return (
|
||||
<svg width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
@@ -247,6 +306,7 @@ function SettingsPanel({
|
||||
function Settings() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const requestedTab = searchParams.get('tab') || 'display'
|
||||
const requestedCollector = searchParams.get('collector') || ''
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
|
||||
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
|
||||
@@ -285,6 +345,7 @@ function Settings() {
|
||||
const selectedCollectorHealth = selectedCollector
|
||||
? collectorHealthStatus[selectedCollector.source]
|
||||
: undefined
|
||||
const selectedAisRuntimeHealth = selectedCollector?.ais_health || null
|
||||
const settingsTabKeys = new Set([
|
||||
'display',
|
||||
'notifications',
|
||||
@@ -386,16 +447,31 @@ function Settings() {
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || !selectedCollectorConfig) return
|
||||
const config = selectedCollectorConfig.config || {}
|
||||
const boundingBoxes = config.bounding_boxes ?? [[[-90, -180], [90, 180]]]
|
||||
collectorConfigForm.setFieldsValue({
|
||||
endpoint: selectedCollectorConfig.endpoint,
|
||||
auth_config: {
|
||||
api_key: selectedCollectorConfig.auth_configured?.api_key ? '••••••••' : '',
|
||||
},
|
||||
headers: Object.entries(selectedCollectorConfig.headers || {}).map(([key, value]) => ({ key, value })),
|
||||
config: {
|
||||
timeout: selectedCollectorConfig.config?.timeout ?? 30,
|
||||
retry: selectedCollectorConfig.config?.retry ?? 3,
|
||||
timeout: config.timeout ?? 30,
|
||||
retry: config.retry ?? 3,
|
||||
max_messages: config.max_messages ?? 500,
|
||||
receive_timeout_seconds: config.receive_timeout_seconds ?? 30,
|
||||
message_types: config.message_types ?? ['PositionReport', 'ShipStaticData'],
|
||||
bounding_box_preset: matchAisstreamBboxPreset(boundingBoxes),
|
||||
bounding_boxes_json: stringifyBoundingBoxes(boundingBoxes),
|
||||
},
|
||||
})
|
||||
}, [collectorConfigForm, loading, selectedCollectorConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedCollector || !collectors.some((collector) => collector.source === requestedCollector)) return
|
||||
setSelectedCollectorSource(requestedCollector)
|
||||
}, [collectors, requestedCollector])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTableHeight = () => {
|
||||
const regionHeight = collectorTableRegionRef.current?.offsetHeight || 0
|
||||
@@ -440,6 +516,12 @@ function Settings() {
|
||||
}, {})
|
||||
)
|
||||
|
||||
const applyAisstreamBboxPreset = (presetValue: string) => {
|
||||
const preset = AISSTREAM_BBOX_PRESETS.find((item) => item.value === presetValue)
|
||||
if (!preset) return
|
||||
collectorConfigForm.setFieldValue(['config', 'bounding_boxes_json'], stringifyBoundingBoxes(preset.boxes))
|
||||
}
|
||||
|
||||
const saveCollector = async (collector: CollectorSettings) => {
|
||||
try {
|
||||
setSavingCollectorId(collector.id)
|
||||
@@ -501,15 +583,39 @@ function Settings() {
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const configValues = { ...(baseValues.config || {}) }
|
||||
if (selectedCollector.source === 'aisstream_vessels') {
|
||||
try {
|
||||
configValues.bounding_boxes = JSON.parse(configValues.bounding_boxes_json || '[[[-90,-180],[90,180]]]')
|
||||
} catch {
|
||||
message.error('AISStream Bounding Boxes 必须是合法 JSON')
|
||||
return
|
||||
}
|
||||
delete configValues.bounding_boxes_json
|
||||
delete configValues.bounding_box_preset
|
||||
}
|
||||
|
||||
const payload: Record<string, any> = {
|
||||
name: selectedCollector.source,
|
||||
description: `内置采集器覆盖配置:${selectedCollector.name}`,
|
||||
source_type: 'http',
|
||||
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : 'http',
|
||||
endpoint: baseValues.endpoint,
|
||||
auth_type: 'none',
|
||||
auth_config: {},
|
||||
auth_type: selectedCollector.source === 'aisstream_vessels' ? 'api_key' : 'none',
|
||||
headers,
|
||||
config: baseValues.config || {},
|
||||
config: configValues,
|
||||
}
|
||||
if (selectedCollector.source === 'aisstream_vessels') {
|
||||
const apiKey = String(baseValues.auth_config?.api_key || '').trim()
|
||||
if (apiKey && !apiKey.startsWith('••••')) {
|
||||
payload.auth_config = {
|
||||
api_key: apiKey,
|
||||
in: 'payload',
|
||||
}
|
||||
} else if (!selectedCollectorConfig.config_id) {
|
||||
payload.auth_config = {}
|
||||
}
|
||||
} else {
|
||||
payload.auth_config = {}
|
||||
}
|
||||
if (selectedCollectorConfig.config_id) {
|
||||
await axios.put(`/api/v1/datasources/configs/${selectedCollectorConfig.config_id}`, payload)
|
||||
@@ -589,13 +695,16 @@ function Settings() {
|
||||
try {
|
||||
const values = collectorConfigForm.getFieldsValue(true)
|
||||
setTestingCredentialProvider(selectedCollector.source)
|
||||
const draftApiKey = String(values.auth_config?.api_key || '').trim()
|
||||
const response = await axios.post('/api/v1/datasources/configs/builtin/connect', {
|
||||
name: selectedCollector.source,
|
||||
description: `内置采集器连接验证:${selectedCollector.name}`,
|
||||
source_type: 'http',
|
||||
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : 'http',
|
||||
endpoint: values.endpoint || selectedCollectorConfig?.endpoint || selectedCollectorConfig?.default_url || '',
|
||||
auth_type: 'none',
|
||||
auth_config: {},
|
||||
auth_type: selectedCollector.source === 'aisstream_vessels' ? 'api_key' : 'none',
|
||||
auth_config: selectedCollector.source === 'aisstream_vessels' && draftApiKey && !draftApiKey.startsWith('••••')
|
||||
? { api_key: draftApiKey }
|
||||
: {},
|
||||
headers: headersListToMap(values.headers),
|
||||
config: values.config || {},
|
||||
})
|
||||
@@ -611,6 +720,9 @@ function Settings() {
|
||||
[selectedCollector.source]: { ok: false, message: response.data.message || '不可用' },
|
||||
}))
|
||||
message.error(response.data.message || '采集器健康检查失败')
|
||||
if (selectedCollector.credential_provider) {
|
||||
await loadCredentialGuide(selectedCollector.credential_provider, true)
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } } }
|
||||
@@ -620,6 +732,9 @@ function Settings() {
|
||||
[selectedCollector.source]: { ok: false, message: errorMessage },
|
||||
}))
|
||||
message.error(errorMessage)
|
||||
if (selectedCollector.credential_provider) {
|
||||
await loadCredentialGuide(selectedCollector.credential_provider, true)
|
||||
}
|
||||
} finally {
|
||||
setTestingCredentialProvider(null)
|
||||
}
|
||||
@@ -1383,6 +1498,13 @@ function Settings() {
|
||||
) : (
|
||||
<Tag>未检查</Tag>
|
||||
)}
|
||||
{selectedAisRuntimeHealth ? (
|
||||
<Tooltip title={selectedAisRuntimeHealth.last_error || '采集器运行状态'}>
|
||||
<Tag color={selectedAisRuntimeHealth.connection_state === 'connected' ? 'success' : 'default'}>
|
||||
{selectedAisRuntimeHealth.connection_state}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{selectedCollectorConfig?.is_overridden ? <Tag color="blue">已覆盖 endpoint</Tag> : null}
|
||||
</Space>
|
||||
) : null}
|
||||
@@ -1391,11 +1513,13 @@ function Settings() {
|
||||
{selectedCollector?.requires_credentials && selectedCollector.source !== 'barentswatch_vessels' ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
type={selectedCollector.source === 'aisstream_vessels' ? 'info' : 'warning'}
|
||||
message="该采集器需要凭证"
|
||||
description={selectedCollector.credential_status === 'supported'
|
||||
? '该凭证类型已支持,但当前页面还没有专用表单。'
|
||||
: '该凭证配置入口待接入。'}
|
||||
description={selectedCollector.source === 'aisstream_vessels'
|
||||
? '请在下方 AISStream 凭证中填写 API Key,并保存采集器设置。'
|
||||
: selectedCollector.credential_status === 'supported'
|
||||
? '该凭证类型已支持,但当前页面还没有专用表单。'
|
||||
: '该凭证配置入口待接入。'}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1435,6 +1559,87 @@ function Settings() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{selectedCollector?.source === 'aisstream_vessels' ? (
|
||||
<Card
|
||||
size="small"
|
||||
title={<Space><ApiOutlined />AISStream 凭证</Space>}
|
||||
extra={(
|
||||
<Space>
|
||||
<Tooltip title="查看凭证获取教程">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<BookOutlined />}
|
||||
onClick={() => { void loadCredentialGuide('aisstream', true) }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Form form={collectorConfigForm} layout="vertical">
|
||||
<Form.Item name={['auth_config', 'api_key']} label="API Key">
|
||||
<Input.Password
|
||||
autoComplete="new-password"
|
||||
placeholder="输入 AISStream API Key"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
showIcon
|
||||
type="info"
|
||||
message="AISStream 使用 WebSocket 实时流"
|
||||
description="API Key 会保存在采集器覆盖配置中;保存后可用连接测试按钮验证凭证是否已配置。"
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{selectedCollector?.source === 'aisstream_vessels' ? (
|
||||
<Card size="small" title="AISStream 运行状态">
|
||||
{selectedAisRuntimeHealth ? (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(160px, 1fr))', gap: 12 }}>
|
||||
<div>
|
||||
<Text type="secondary">连接状态</Text>
|
||||
<div>
|
||||
<Tag color={selectedAisRuntimeHealth.connection_state === 'connected' ? 'success' : 'default'}>
|
||||
{selectedAisRuntimeHealth.connection_state}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">本轮消息数</Text>
|
||||
<div>{selectedAisRuntimeHealth.message_rate ?? '未知'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">最近收到</Text>
|
||||
<div>{formatDateTimeZhCN(selectedAisRuntimeHealth.last_seen_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">最近成功</Text>
|
||||
<div>{formatDateTimeZhCN(selectedAisRuntimeHealth.last_success_at)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">数据延迟</Text>
|
||||
<div>{formatLagSeconds(selectedAisRuntimeHealth.lag_seconds)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary">状态更新时间</Text>
|
||||
<div>{formatDateTimeZhCN(selectedAisRuntimeHealth.updated_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Alert showIcon type="warning" message="尚无运行状态" description="保存配置并触发一次 AISStream 采集后,这里会显示最近连接和消息统计。" />
|
||||
)}
|
||||
{selectedAisRuntimeHealth?.last_error ? (
|
||||
<Alert
|
||||
showIcon
|
||||
type="error"
|
||||
style={{ marginTop: 12 }}
|
||||
message="最近错误"
|
||||
description={selectedAisRuntimeHealth.last_error}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card size="small" title="基础配置">
|
||||
<Form form={collectorConfigForm} layout="vertical">
|
||||
<Form.Item name="endpoint" label="Endpoint" rules={[{ required: true, message: '请输入 Endpoint' }]}>
|
||||
@@ -1471,6 +1676,36 @@ function Settings() {
|
||||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{selectedCollector?.source === 'aisstream_vessels' ? (
|
||||
<>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name={['config', 'max_messages']} label="单次最大消息数">
|
||||
<InputNumber min={1} max={10000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['config', 'receive_timeout_seconds']} label="接收超时(秒)">
|
||||
<InputNumber min={1} max={300} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name={['config', 'message_types']} label="消息类型">
|
||||
<Select mode="tags" options={[
|
||||
{ value: 'PositionReport', label: 'PositionReport' },
|
||||
{ value: 'ShipStaticData', label: 'ShipStaticData' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['config', 'bounding_box_preset']} label="采集范围">
|
||||
<Select
|
||||
options={[
|
||||
...AISSTREAM_BBOX_PRESETS.map((preset) => ({ value: preset.value, label: preset.label })),
|
||||
{ value: 'custom', label: '自定义 JSON' },
|
||||
]}
|
||||
onChange={applyAisstreamBboxPreset}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name={['config', 'bounding_boxes_json']} label="Bounding Boxes JSON">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user