release: bump version to 0.58.0
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled

Release 0.58.0 includes the Earth high-precision boundary PMTiles/MVT pipeline, standardized Earth boundary source collectors, China POV boundary configuration templates, and removal of the legacy low-precision GeoJSON fallback. It also adds Earth news target-location queueing/archive support, fixes datasource task status visibility, documents the Earth surface depth-spacing rules that prevent far-zoom z-fighting snow/black blocks, and updates bilingual operations/developer docs.
This commit is contained in:
linkong
2026-05-15 17:40:07 +08:00
parent dd176a6ae6
commit 93eb41a9f7
75 changed files with 5217 additions and 716 deletions

View File

@@ -303,7 +303,7 @@ export default function AISettings() {
nextSecret === savedPreview ||
nextSecret === revealedSecret ||
nextSecret.startsWith('••••') ||
nextSecret.includes('*')
Array.from(nextSecret).every((char) => char === '*' || char === '•' || /\s/.test(char))
)
const buildAiProviderDraftPayload = (values: any) => {
@@ -381,9 +381,11 @@ export default function AISettings() {
setFeedback({ type: 'success', message: 'AI 配置已保存' })
message.success('AI 配置已保存')
await fetchAISettings()
} catch {
setFeedback({ type: 'error', message: 'AI 配置保存失败' })
message.error('AI 配置保存失败')
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMessage = err.response?.data?.message || err.response?.data?.detail || 'AI 配置保存失败'
setFeedback({ type: 'error', message: errorMessage })
message.error(errorMessage)
} finally {
setSaving(false)
}
@@ -591,12 +593,8 @@ export default function AISettings() {
buildAiProviderDraftPayload(values),
)
if (response.data.success || response.data.connected) {
if (response.data.integrations) {
setIntegrations(response.data.integrations)
await fetchAISettings()
}
setFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功' })
message.success(response.data.message || 'AI Provider 连接成功')
setFeedback({ type: 'success', message: response.data.message || '连接测试通过' })
message.success(response.data.message || '连接测试通过')
} else {
setFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' })
message.error(response.data.message || 'AI Provider 连接失败')
@@ -680,6 +678,9 @@ export default function AISettings() {
)}
/>
</Form.Item>
<div style={{ marginTop: -16, marginBottom: 12 }}>
<Text type="secondary"></Text>
</div>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
<Select.Option value="openai-completions">OpenAI Chat Completions</Select.Option>
@@ -819,6 +820,9 @@ export default function AISettings() {
onTest={() => { void testWebSearchConnection() }}
/>
</Form.Item>
<div style={{ marginTop: -16, marginBottom: 12 }}>
<Text type="secondary"></Text>
</div>
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
<Space>

View File

@@ -38,6 +38,8 @@ import { formatPhaseMetric, getPhaseDisplay, getPhaseSummary } from '../../utils
const { Text } = Typography
const COLLECTION_REFRESH_DELAY_MS = 800
const COLLECTION_STATUS_POLL_MS = 900
const COLLECTION_STATUS_MAX_POLLS = 30
interface BuiltInDataSource {
id: number
@@ -217,6 +219,7 @@ type DatasourceTaskStatus = {
records_processed?: number | null
total_records?: number | null
status?: string | null
error_message?: string | null
}
type ActiveFilter = 'enabled' | 'disabled' | 'all'
@@ -228,6 +231,7 @@ const PRODUCT_LABELS: Record<string, string> = {
cables: '海底光缆',
satellites: '卫星',
bgp: 'BGP',
earth: 'Earth',
compute: '算力',
ai: 'AI',
media: '媒体',
@@ -239,6 +243,7 @@ const PRODUCT_TAG_COLORS: Record<string, string> = {
cables: 'geekblue',
satellites: 'purple',
bgp: 'volcano',
earth: 'lime',
compute: 'blue',
ai: 'magenta',
media: 'green',
@@ -412,11 +417,44 @@ function DataSources() {
}
}
const fetchDatasourceTaskStatus = async (id: number) => {
const res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`)
const fetchDatasourceTaskStatus = async (id: number, taskId?: number | null) => {
const res = await axios.get<DatasourceTaskStatus>(`/api/v1/datasources/${id}/task-status`, {
params: taskId ? { task_id: taskId } : undefined,
})
return res.data
}
const pollDatasourceAfterTrigger = (id: number, taskId?: number | null) => {
let polls = 0
let lastTaskId = taskId ?? null
const poll = async () => {
polls += 1
try {
const status = await fetchDatasourceTaskStatus(id, lastTaskId)
lastTaskId = status.task_id ?? lastTaskId
await fetchData()
if (status.status === 'failed') {
messageApi.error(status.error_message || status.phase_message || '采集失败')
return
}
if (status.status && status.status !== 'running' && status.status !== 'idle') {
return
}
if (polls < COLLECTION_STATUS_MAX_POLLS) {
window.setTimeout(poll, COLLECTION_STATUS_POLL_MS)
}
} catch (error) {
if (polls < COLLECTION_STATUS_MAX_POLLS) {
window.setTimeout(poll, COLLECTION_STATUS_POLL_MS)
}
}
}
window.setTimeout(poll, COLLECTION_REFRESH_DELAY_MS)
}
const triggerDatasource = async (id: number, options?: { force?: boolean }) => {
const res = await axios.post(`/api/v1/datasources/${id}/trigger`, null, {
params: { force: options?.force ?? false },
@@ -427,11 +465,8 @@ function DataSources() {
return { ok: false, status: res.status, detail: res.data?.detail } satisfies TriggerDatasourceResult
}
if (res.data.task_id) {
void fetchData()
} else {
window.setTimeout(fetchData, COLLECTION_REFRESH_DELAY_MS)
}
void fetchData()
pollDatasourceAfterTrigger(id, res.data.task_id)
return { ok: true, response: res } satisfies TriggerDatasourceResult
}
@@ -916,6 +951,7 @@ function DataSources() {
{ value: 'cables', label: '海底光缆' },
{ value: 'satellites', label: '卫星' },
{ value: 'bgp', label: 'BGP' },
{ value: 'earth', label: 'Earth' },
{ value: 'compute', label: '算力' },
{ value: 'ai', label: 'AI' },
{ value: 'media', label: '媒体' },

View File

@@ -417,6 +417,11 @@ function Settings() {
const collectorOptions = useMemo(() => [...collectors, ...customCollectors], [collectors, customCollectors])
const selectedCollector = collectorOptions.find((collector) => collector.source === selectedCollectorSource)
const selectedCollectorConfig = [...collectorConfigs, ...customSourceConfigs].find((config) => config.name === selectedCollectorSource)
const isEarthBoundarySourceCollector = Boolean(
selectedCollector?.source &&
['earth_admin0_boundaries', 'earth_coastline', 'earth_claim_lines'].includes(selectedCollector.source),
)
const isEarthPmtilesBuilder = selectedCollector?.source === 'earth_boundary_tiles'
const [customStreamStatus, setCustomStreamStatus] = useState<{ running: boolean; done: boolean } | null>(null)
const [customStreamBusy, setCustomStreamBusy] = useState(false)
const selectedCollectorHealth = selectedCollector
@@ -561,7 +566,11 @@ function Settings() {
}, [integrationForm, integrations, loading])
useEffect(() => {
if (loading || !selectedCollectorConfig) return
if (loading) return
if (!selectedCollectorConfig) {
collectorConfigForm.resetFields()
return
}
const config = selectedCollectorConfig.config || {}
const boundingBoxes = config.bounding_boxes ?? [[[-90, -180], [90, 180]]]
if (selectedCollector?.is_custom) {
@@ -591,6 +600,10 @@ function Settings() {
config: {
timeout: config.timeout ?? 30,
retry: config.retry ?? 3,
method: config.method || 'GET',
target_schema: config.target_schema || (isEarthBoundarySourceCollector ? 'earth_boundary_source' : undefined),
license: config.license || '',
mapping_json_text: JSON.stringify(config.mapping_json || {}, null, 2),
max_messages: config.max_messages ?? 500,
receive_timeout_seconds: config.receive_timeout_seconds ?? 30,
message_types: config.message_types ?? ['PositionReport', 'ShipStaticData'],
@@ -598,7 +611,7 @@ function Settings() {
bounding_boxes_json: stringifyBoundingBoxes(boundingBoxes),
},
})
}, [collectorConfigForm, loading, selectedCollector, selectedCollectorConfig])
}, [collectorConfigForm, isEarthBoundarySourceCollector, loading, selectedCollector, selectedCollectorConfig])
useEffect(() => {
if (!requestedCollector || !collectorOptions.some((collector) => collector.source === requestedCollector)) return
@@ -706,10 +719,12 @@ function Settings() {
setWebSearchSaveFeedback({ type: 'success', message: 'WebSearch 配置已保存' })
message.success('外部集成配置已保存')
await fetchSettings()
} catch {
setAiProviderSaveFeedback({ type: 'error', message: 'AI 配置保存失败' })
} catch (error: unknown) {
const err = error as { response?: { data?: { detail?: string; message?: string } } }
const errorMessage = err.response?.data?.message || err.response?.data?.detail || '外部集成配置保存失败'
setAiProviderSaveFeedback({ type: 'error', message: errorMessage })
setWebSearchSaveFeedback({ type: 'error', message: 'WebSearch 配置保存失败' })
message.error('外部集成配置保存失败')
message.error(errorMessage)
} finally {
setSavingIntegrations(false)
}
@@ -724,7 +739,7 @@ function Settings() {
nextSecret === savedPreview ||
nextSecret === revealedSecret ||
nextSecret.startsWith('••••') ||
nextSecret.includes('*')
Array.from(nextSecret).every((char) => char === '*' || char === '•' || /\s/.test(char))
)
const buildAiProviderDraftPayload = (values: any) => {
@@ -899,31 +914,8 @@ function Settings() {
buildAiProviderDraftPayload(values),
)
if (response.data.success || response.data.connected) {
if (response.data.integrations) {
setIntegrations(response.data.integrations)
integrationForm.setFieldsValue({
ai_provider: {
service_url: response.data.integrations.ai_provider.service_url,
service_token: response.data.integrations.ai_provider.service_token.configured
? response.data.integrations.ai_provider.service_token.preview
: '',
default_provider: response.data.integrations.ai_provider.default_provider || response.data.integrations.ai_provider.provider,
provider: response.data.integrations.ai_provider.provider,
provider_api: response.data.integrations.ai_provider.provider_api,
base_url: response.data.integrations.ai_provider.base_url,
model: response.data.integrations.ai_provider.model,
api_key: response.data.integrations.ai_provider.api_key.configured
? response.data.integrations.ai_provider.api_key.preview
: '',
max_tokens: response.data.integrations.ai_provider.max_tokens,
anthropic_version: response.data.integrations.ai_provider.anthropic_version,
timeout_seconds: response.data.integrations.ai_provider.timeout_seconds,
retry_attempts: response.data.integrations.ai_provider.retry_attempts,
},
})
}
setAiProviderSaveFeedback({ type: 'success', message: response.data.message || 'AI Provider 连接成功,已保存为全局默认配置' })
message.success(response.data.message || 'AI Provider 连接成功')
setAiProviderSaveFeedback({ type: 'success', message: response.data.message || '连接测试通过' })
message.success(response.data.message || '连接测试通过')
} else {
setAiProviderSaveFeedback({ type: 'error', message: response.data.message || 'AI Provider 连接失败' })
message.error(response.data.message || 'AI Provider 连接失败')
@@ -996,12 +988,23 @@ function Settings() {
delete configValues.bounding_boxes_json
delete configValues.bounding_box_preset
}
if (['earth_admin0_boundaries', 'earth_coastline', 'earth_claim_lines'].includes(selectedCollector.source)) {
try {
configValues.mapping_json = JSON.parse(configValues.mapping_json_text || '{}')
} catch {
message.error('Earth 边界 mapping_json 必须是合法 JSON')
return
}
configValues.target_schema = configValues.target_schema || 'earth_boundary_source'
configValues.method = configValues.method || 'GET'
delete configValues.mapping_json_text
}
const payload: Record<string, any> = {
name: selectedCollector.source,
description: `内置采集器覆盖配置:${selectedCollector.name}`,
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : 'http',
endpoint: baseValues.endpoint,
source_type: selectedCollector.source === 'aisstream_vessels' ? 'websocket' : isEarthPmtilesBuilder ? 'internal' : 'http',
endpoint: baseValues.endpoint || '',
auth_type: selectedCollector.source === 'aisstream_vessels' ? 'api_key' : 'none',
headers,
config: configValues,
@@ -2026,6 +2029,9 @@ function Settings() {
)}
/>
</Form.Item>
<div style={{ marginTop: -16, marginBottom: 12 }}>
<Text type="secondary"></Text>
</div>
<Form.Item name={['ai_provider', 'provider_api']} label="协议适配">
<Select>
@@ -2151,6 +2157,9 @@ function Settings() {
/>
</Form.Item>
</div>
<div style={{ marginTop: -16, marginBottom: 12 }}>
<Text type="secondary"></Text>
</div>
<Form.Item label="WebSearch API Key">
<Space direction="vertical" style={{ width: '100%' }}>
@@ -2481,14 +2490,39 @@ function Settings() {
</Form.Item>
</>
) : null}
<Form.Item name="endpoint" label="Endpoint" rules={[{ required: true, message: '请输入 Endpoint' }]}>
<Input placeholder={selectedCollectorConfig?.default_url || 'https://api.example.com'} />
<Form.Item
name="endpoint"
label="Endpoint"
rules={isEarthPmtilesBuilder ? [] : [{ required: true, message: '请输入 Endpoint' }]}
>
<Input
disabled={isEarthPmtilesBuilder}
placeholder={isEarthPmtilesBuilder ? '内部构建器不需要 Endpoint' : selectedCollectorConfig?.default_url || 'https://api.example.com'}
/>
</Form.Item>
{!selectedCollector?.is_custom ? (
<Form.Item label="默认 Endpoint">
<Input value={selectedCollectorConfig?.default_url || '-'} disabled />
</Form.Item>
) : null}
{isEarthBoundarySourceCollector ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
<Form.Item name={['config', 'method']} label="请求方法">
<Select options={[{ value: 'GET', label: 'GET' }, { value: 'POST', label: 'POST' }]} />
</Form.Item>
<Form.Item name={['config', 'target_schema']} label="目标 Schema">
<Select options={[{ value: 'earth_boundary_source', label: 'earth_boundary_source' }]} />
</Form.Item>
</div>
<Form.Item name={['config', 'license']} label="License">
<Input placeholder="例如 ODbL / source license / internal" />
</Form.Item>
<Form.Item name={['config', 'mapping_json_text']} label="Mapping JSON">
<Input.TextArea rows={8} />
</Form.Item>
</>
) : null}
<Form.List name="headers">
{(fields, { add, remove }) => (
<Form.Item label="请求头">