release: bump version to 0.48.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.47.0",
|
||||
"version": "0.48.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
|
||||
@@ -477,6 +477,10 @@
|
||||
<span class="stats-footer-dot"></span>
|
||||
<span id="bgp-status-summary" class="stats-footer-text" data-earth-stat="bgp-status-summary">暂无观测数据</span>
|
||||
</div>
|
||||
<div class="stats-footer">
|
||||
<span class="stats-footer-dot"></span>
|
||||
<span id="vessel-live-summary" class="stats-footer-text" data-earth-stat="vessel-live-summary">AISStream 未连接</span>
|
||||
</div>
|
||||
|
||||
<!-- hidden elements kept for JS compatibility -->
|
||||
<span id="terrain-status" data-earth-stat="terrain-status" hidden></span>
|
||||
@@ -708,6 +712,7 @@
|
||||
<div class="earth-mobile-situation-card">
|
||||
<div class="earth-mobile-situation-card-title">BGP 状态</div>
|
||||
<div id="mobile-bgp-status-summary" class="earth-mobile-situation-status" data-earth-stat="bgp-status-summary">暂无观测数据</div>
|
||||
<div id="mobile-vessel-live-summary" class="earth-mobile-situation-status" data-earth-stat="vessel-live-summary">AISStream 未连接</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
7
frontend/public/earth/js/controls.js
vendored
7
frontend/public/earth/js/controls.js
vendored
@@ -271,7 +271,12 @@ function closeTransientMobileOverlays({ except = null } = {}) {
|
||||
setMobileDrawerOpen("layer-toggles", false);
|
||||
}
|
||||
|
||||
if (except !== "media" && except !== "search" && isTVPanelVisible()) {
|
||||
if (
|
||||
except !== "media"
|
||||
&& except !== "search"
|
||||
&& except !== "settings"
|
||||
&& isTVPanelVisible()
|
||||
) {
|
||||
setTVPanelVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,30 @@ let typewriterToken = 0;
|
||||
let pendingMobileDetailState = null;
|
||||
let mobileDetailsListenerBound = false;
|
||||
let renderedMobileDetailKey = null;
|
||||
const IDENTIFIER_FIELD_KEYS = new Set([
|
||||
'mmsi',
|
||||
'mmsi_display',
|
||||
'imo',
|
||||
'imo_display',
|
||||
'callsign',
|
||||
]);
|
||||
const MAX_VESSEL_MEDIA_TILES = 4;
|
||||
|
||||
function formatInfoCardValue(field, rawValue) {
|
||||
if (rawValue === undefined || rawValue === null || rawValue === '') {
|
||||
return '-';
|
||||
}
|
||||
let value = rawValue;
|
||||
if (IDENTIFIER_FIELD_KEYS.has(field.key)) {
|
||||
value = String(value);
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getNewsSummaryText(data) {
|
||||
return (data?.summary || data?.title || '').trim() || '暂无摘要';
|
||||
@@ -109,13 +133,7 @@ function renderMobileDetailContent(type, config, data) {
|
||||
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
if (field.unit && value !== '-') value = value + ' ' + field.unit;
|
||||
const value = formatInfoCardValue(field, data[field.key]);
|
||||
html += `
|
||||
<div class="earth-mobile-detail-row">
|
||||
<span class="earth-mobile-detail-row-label">${field.label}</span>
|
||||
@@ -166,29 +184,95 @@ function ensureMobileDetailsListener() {
|
||||
function renderDefaultCardContent(content, config, data) {
|
||||
let html = '';
|
||||
for (const field of config.fields) {
|
||||
let value = data[field.key];
|
||||
|
||||
if (value === undefined || value === null || value === '') {
|
||||
value = '-';
|
||||
} else if (typeof value === 'number') {
|
||||
value = value.toLocaleString();
|
||||
}
|
||||
|
||||
if (field.unit && value !== '-') {
|
||||
value = value + ' ' + field.unit;
|
||||
}
|
||||
|
||||
const value = formatInfoCardValue(field, data[field.key]);
|
||||
const sourceLabel = getFieldSourceLabel(data, field.key);
|
||||
html += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${field.label}</span>
|
||||
<span class="info-card-value">${value}</span>
|
||||
<span class="info-card-value">${value}${sourceLabel}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (config.className === 'vessel') {
|
||||
html += renderVesselEnrichmentSection(data?.enrichment);
|
||||
}
|
||||
|
||||
content.innerHTML = html;
|
||||
}
|
||||
|
||||
function getFieldSourceLabel(data, fieldKey) {
|
||||
const sources = data && typeof data === 'object' ? data.field_sources : null;
|
||||
if (!sources || typeof sources !== 'object') return '';
|
||||
const source = sources[fieldKey];
|
||||
if (!source) return '';
|
||||
return ` <span class="info-card-source-tag" title="字段来源">${source}</span>`;
|
||||
}
|
||||
|
||||
function renderVesselEnrichmentSection(enrichment) {
|
||||
if (!enrichment || typeof enrichment !== 'object') return '';
|
||||
const profile = enrichment.profile;
|
||||
const media = enrichment.media;
|
||||
if (!profile && !media) {
|
||||
return `
|
||||
<div class="info-card-enrichment info-card-enrichment--empty">
|
||||
<div class="info-card-enrichment-title">船舶资料</div>
|
||||
<div class="info-card-enrichment-status">资料缓存中</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
let inner = '';
|
||||
if (profile?.payload && typeof profile.payload === 'object') {
|
||||
inner += renderEnrichmentPayloadRows(profile.payload);
|
||||
inner += renderEnrichmentMeta('资料', profile);
|
||||
}
|
||||
if (media?.payload && typeof media.payload === 'object') {
|
||||
if (Array.isArray(media.payload.images) && media.payload.images.length > 0) {
|
||||
const tiles = media.payload.images
|
||||
.slice(0, MAX_VESSEL_MEDIA_TILES)
|
||||
.map((url) => `<img class="info-card-enrichment-thumb" src="${String(url)}" alt="vessel media" />`)
|
||||
.join('');
|
||||
inner += `<div class="info-card-enrichment-media">${tiles}</div>`;
|
||||
}
|
||||
inner += renderEnrichmentMeta('媒体', media);
|
||||
}
|
||||
if (!inner) {
|
||||
inner = '<div class="info-card-enrichment-status">资料缓存中</div>';
|
||||
}
|
||||
return `
|
||||
<div class="info-card-enrichment">
|
||||
<div class="info-card-enrichment-title">船舶资料</div>
|
||||
${inner}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEnrichmentPayloadRows(payload) {
|
||||
let rows = '';
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
if (value === null || value === undefined || value === '') continue;
|
||||
if (typeof value === 'object') continue;
|
||||
rows += `
|
||||
<div class="info-card-property">
|
||||
<span class="info-card-label">${key}</span>
|
||||
<span class="info-card-value">${String(value)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function renderEnrichmentMeta(label, record) {
|
||||
const parts = [];
|
||||
if (record.source) parts.push(`来源 ${record.source}`);
|
||||
if (record.fetched_at) parts.push(`更新 ${record.fetched_at}`);
|
||||
if (record.confidence !== null && record.confidence !== undefined) {
|
||||
parts.push(`置信 ${Number(record.confidence).toFixed(2)}`);
|
||||
}
|
||||
if (!parts.length) return '';
|
||||
return `<div class="info-card-enrichment-meta">${label}:${parts.join(' · ')}</div>`;
|
||||
}
|
||||
|
||||
// ── Mobile popup ─────────────────────────────────────────────
|
||||
|
||||
function getMobilePopupTitle(type, data) {
|
||||
|
||||
@@ -98,6 +98,8 @@ function registerBuiltinLayerStartupTasks() {
|
||||
|
||||
function registerVesselStartupTask() {
|
||||
registerLayerStartupTask("vessels", (context) => async (layer) => {
|
||||
if (!context.getShowVessels()) return;
|
||||
|
||||
context.setLoadingMessage(
|
||||
resolveStartupMessage(layer, "load", "正在加载船只..."),
|
||||
);
|
||||
|
||||
@@ -180,9 +180,12 @@ import {
|
||||
getVesselLegendItems,
|
||||
getVesselMarkers,
|
||||
getVesselPointerIntersections as getVesselIconPointerIntersections,
|
||||
getVesselRealtimeStats,
|
||||
loadVessels,
|
||||
setVesselMarkerState,
|
||||
showVesselTrack,
|
||||
startVesselRealtime,
|
||||
stopVesselRealtime,
|
||||
toggleVessels,
|
||||
updateVesselVisualState,
|
||||
} from "./vessels.js";
|
||||
@@ -1489,6 +1492,13 @@ async function loadEarthStatsSummary({ shouldApply = () => true } = {}) {
|
||||
satelliteCount: toCount(stats.satellite_count),
|
||||
computeCenterCount: toCount(stats.compute_center_count),
|
||||
vesselCount: toCount(stats.vessel_count),
|
||||
vesselRawUniqueMmsi: toCount(stats.vessel_raw_unique_mmsi),
|
||||
vesselLegacyUniqueMmsi: toCount(stats.vessel_legacy_unique_mmsi),
|
||||
aisstreamConnectionState: stats.aisstream_connection_state || null,
|
||||
aisstreamLastSeenAt: stats.aisstream_last_seen_at || null,
|
||||
aisstreamLagSeconds: Number.isFinite(Number(stats.aisstream_lag_seconds))
|
||||
? Number(stats.aisstream_lag_seconds)
|
||||
: null,
|
||||
bgpEventCount: toCount(stats.bgp_event_count),
|
||||
bgpIncidentCount: toCount(stats.bgp_incident_count),
|
||||
bgpAnomalyCount: toCount(stats.bgp_anomaly_count),
|
||||
@@ -2154,6 +2164,36 @@ function updateVesselHud(result = {}) {
|
||||
setEarthStatValue("vessel-count", `${count} 艘`);
|
||||
}
|
||||
|
||||
function formatRelativeTime(value) {
|
||||
const date = value instanceof Date ? value : value ? new Date(value) : null;
|
||||
if (!date || Number.isNaN(date.getTime())) return null;
|
||||
const elapsedSeconds = Math.max(0, Math.round((Date.now() - date.getTime()) / 1000));
|
||||
if (elapsedSeconds < 5) return "刚刚";
|
||||
if (elapsedSeconds < 60) return `${elapsedSeconds} 秒前`;
|
||||
const elapsedMinutes = Math.round(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) return `${elapsedMinutes} 分钟前`;
|
||||
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
function formatVesselLiveSummary() {
|
||||
const stream = getVesselRealtimeStats();
|
||||
if (stream.connected) {
|
||||
const lastUpdate = formatRelativeTime(stream.lastUpdateAt);
|
||||
if (stream.updates > 0) {
|
||||
return `AISStream 实时已连接 · ${stream.updates} 次更新${lastUpdate ? ` · ${lastUpdate}` : ""}`;
|
||||
}
|
||||
return "AISStream 实时已连接 · 等待首批更新";
|
||||
}
|
||||
const state = earthStatsSummary?.aisstreamConnectionState;
|
||||
if (state === "connected") {
|
||||
const lastSeen = formatRelativeTime(earthStatsSummary?.aisstreamLastSeenAt);
|
||||
return `AISStream 后台已连接${lastSeen ? ` · 最近 ${lastSeen}` : ""}`;
|
||||
}
|
||||
if (state === "reconnecting") return "AISStream 正在重连";
|
||||
if (state === "connecting") return "AISStream 正在连接";
|
||||
return "AISStream 未连接";
|
||||
}
|
||||
|
||||
function updateVesselToggleUi(enabled, vesselCount = getVesselCount()) {
|
||||
const vesselBtn = document.getElementById("toggle-vessels");
|
||||
if (vesselBtn) {
|
||||
@@ -2164,6 +2204,7 @@ function updateVesselToggleUi(enabled, vesselCount = getVesselCount()) {
|
||||
});
|
||||
}
|
||||
setEarthStatValue("vessel-count", `${vesselCount || 0} 艘`);
|
||||
setEarthStatValue("vessel-live-summary", formatVesselLiveSummary());
|
||||
}
|
||||
|
||||
function updateCableToggleUi(enabled) {
|
||||
@@ -2292,6 +2333,12 @@ async function ensureVesselsEnabled() {
|
||||
vesselsEnabled = true;
|
||||
const result = await loadVessels(scene, earth);
|
||||
toggleVessels(true);
|
||||
startVesselRealtime(earth, {
|
||||
onUpdate: ({ totalCount }) => {
|
||||
updateVesselToggleUi(true, totalCount);
|
||||
updateStatsSummary();
|
||||
},
|
||||
});
|
||||
updateVesselToggleUi(true, result.totalCount);
|
||||
setLegendItems("vessels", getVesselLegendItems());
|
||||
refreshLegend();
|
||||
@@ -2300,6 +2347,7 @@ async function ensureVesselsEnabled() {
|
||||
|
||||
function disableVessels() {
|
||||
vesselsEnabled = false;
|
||||
stopVesselRealtime();
|
||||
toggleVessels(false);
|
||||
clearVesselSelection();
|
||||
updateVesselToggleUi(false, 0);
|
||||
@@ -2334,6 +2382,7 @@ function updateStatsSummary() {
|
||||
landingPointCount: `${landingPointCount}个`,
|
||||
satelliteCount: `${satelliteCount} 颗`,
|
||||
vesselCount: `${vesselCount} 艘`,
|
||||
vesselLiveSummary: formatVesselLiveSummary(),
|
||||
computeCenterCount: `${computeCenterCount} 个`,
|
||||
bgpAnomalyCount: `${bgpEventCount} 起`,
|
||||
bgpCollectorCount: `${bgpCollectorCount} 个`,
|
||||
|
||||
@@ -218,6 +218,7 @@ export function updateEarthStats(stats) {
|
||||
setEarthStatValue("compute-center-count", String(stats.computeCenterCount || 0));
|
||||
}
|
||||
if (has("vesselCount")) setEarthStatValue("vessel-count", String(stats.vesselCount || 0));
|
||||
if (has("vesselLiveSummary")) setEarthStatValue("vessel-live-summary", stats.vesselLiveSummary || "-");
|
||||
if (has("bgpAnomalyCount")) setEarthStatValue("bgp-anomaly-count", String(stats.bgpAnomalyCount || 0));
|
||||
if (has("bgpCollectorCount")) {
|
||||
setEarthStatValue("bgp-collector-count", String(stats.bgpCollectorCount || 0));
|
||||
|
||||
@@ -6,6 +6,15 @@ import { latLonToVector3 } from "./utils.js";
|
||||
|
||||
let showVessels = false;
|
||||
let activeTrackLine = null;
|
||||
let vesselStreamSocket = null;
|
||||
let vesselStreamReconnectTimer = null;
|
||||
let vesselDataByKey = new Map();
|
||||
let vesselRealtimeStats = {
|
||||
connected: false,
|
||||
updates: 0,
|
||||
lastUpdateAt: null,
|
||||
lastBatchSize: 0,
|
||||
};
|
||||
|
||||
const VESSEL_RENDER_ORDER = 4.4;
|
||||
const VESSEL_POINT_SIZE = 34;
|
||||
@@ -13,6 +22,95 @@ const VESSEL_ATLAS_CELL_SIZE = 128;
|
||||
const VESSEL_COURSE_BINS = 32;
|
||||
const VESSEL_TRACK_ENDPOINT_EPSILON = 0.001;
|
||||
|
||||
function getVesselDedupeKey(feature, markerData) {
|
||||
const props = feature?.properties || {};
|
||||
const mmsi = props.mmsi ?? feature?.id ?? markerData?.mmsi;
|
||||
if (mmsi !== undefined && mmsi !== null && String(mmsi).trim() !== "") {
|
||||
return `mmsi:${String(mmsi).trim()}`;
|
||||
}
|
||||
return [
|
||||
"position",
|
||||
Number(markerData.latitude).toFixed(5),
|
||||
Number(markerData.longitude).toFixed(5),
|
||||
String(props.name || markerData.name || "").trim().toLowerCase(),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function dedupeVesselFeatures(features) {
|
||||
const seen = new Set();
|
||||
const markerData = [];
|
||||
features.forEach((feature) => {
|
||||
const marker = buildVesselMarkerData(feature);
|
||||
if (!marker) return;
|
||||
const key = getVesselDedupeKey(feature, marker);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
markerData.push(marker);
|
||||
});
|
||||
return markerData;
|
||||
}
|
||||
|
||||
function markerDataToDedupeKey(item) {
|
||||
const mmsi = item?.mmsi;
|
||||
if (mmsi !== undefined && mmsi !== null && String(mmsi).trim() !== "") {
|
||||
return `mmsi:${String(mmsi).trim()}`;
|
||||
}
|
||||
return [
|
||||
"position",
|
||||
Number(item.latitude).toFixed(5),
|
||||
Number(item.longitude).toFixed(5),
|
||||
String(item.name || "").trim().toLowerCase(),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function buildVesselFeatureFromDelta(item) {
|
||||
const lat = Number(item?.lat ?? item?.latitude);
|
||||
const lon = Number(item?.lon ?? item?.lng ?? item?.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
return {
|
||||
type: "Feature",
|
||||
id: item.mmsi,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [lon, lat],
|
||||
},
|
||||
properties: {
|
||||
...item,
|
||||
mmsi: item.mmsi,
|
||||
mmsi_display: item.mmsi_display || (item.mmsi !== undefined && item.mmsi !== null ? String(item.mmsi) : undefined),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rebuildVesselLayerFromCache(earth) {
|
||||
if (!earth) return;
|
||||
vesselIconLayer.setData(Array.from(vesselDataByKey.values()));
|
||||
vesselIconLayer.attach(earth);
|
||||
vesselIconLayer.setVisible(showVessels);
|
||||
}
|
||||
|
||||
function applyVesselDeltas(earth, vessels = []) {
|
||||
let changed = false;
|
||||
vessels.forEach((item) => {
|
||||
const feature = buildVesselFeatureFromDelta(item);
|
||||
if (!feature) return;
|
||||
const marker = buildVesselMarkerData(feature);
|
||||
if (!marker) return;
|
||||
vesselDataByKey.set(markerDataToDedupeKey(marker), marker);
|
||||
changed = true;
|
||||
});
|
||||
if (changed) {
|
||||
rebuildVesselLayerFromCache(earth);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
function getVesselStreamUrl() {
|
||||
if (typeof window === "undefined") return "ws://localhost:8000/ws";
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function normalizeVesselType(value, code) {
|
||||
const type = String(value || "").trim().toLowerCase();
|
||||
const numericCode = Number(code);
|
||||
@@ -67,9 +165,14 @@ function buildVesselMarkerData(feature) {
|
||||
const navStatus = Number(props.nav_status);
|
||||
const speed = Number(props.sog);
|
||||
const anchored = navStatus === 1 || navStatus === 5 || (Number.isFinite(speed) && speed < 0.5);
|
||||
const mmsiString = props.mmsi !== undefined && props.mmsi !== null && String(props.mmsi).trim() !== ""
|
||||
? String(props.mmsi)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...props,
|
||||
mmsi: mmsiString,
|
||||
mmsi_display: props.mmsi_display ? String(props.mmsi_display) : mmsiString,
|
||||
latitude,
|
||||
longitude,
|
||||
type,
|
||||
@@ -163,6 +266,10 @@ export function getVesselCount() {
|
||||
return vesselIconLayer.getCount();
|
||||
}
|
||||
|
||||
export function getVesselRealtimeStats() {
|
||||
return { ...vesselRealtimeStats };
|
||||
}
|
||||
|
||||
export function getShowVessels() {
|
||||
return showVessels;
|
||||
}
|
||||
@@ -199,6 +306,7 @@ export function getVesselPointerIntersections(options) {
|
||||
|
||||
export function clearVesselData(earth) {
|
||||
clearVesselSelection();
|
||||
vesselDataByKey.clear();
|
||||
vesselIconLayer.clearData(earth);
|
||||
}
|
||||
|
||||
@@ -216,12 +324,11 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
const features = Array.isArray(payload?.features) ? payload.features : [];
|
||||
|
||||
clearVesselData(earth);
|
||||
let markerData = features
|
||||
.map((feature) => buildVesselMarkerData(feature))
|
||||
.filter(Boolean);
|
||||
let markerData = dedupeVesselFeatures(features);
|
||||
if (Number.isFinite(requestedLimit) && requestedLimit > 0) {
|
||||
markerData = markerData.slice(0, requestedLimit);
|
||||
}
|
||||
vesselDataByKey = new Map(markerData.map((item) => [markerDataToDedupeKey(item), item]));
|
||||
vesselIconLayer.setData(markerData);
|
||||
|
||||
vesselIconLayer.attach(earth);
|
||||
@@ -233,6 +340,97 @@ export async function loadVessels(_scene, earth, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function startVesselRealtime(earth, { onUpdate } = {}) {
|
||||
if (vesselStreamSocket || typeof WebSocket === "undefined") return;
|
||||
const connect = () => {
|
||||
if (!showVessels || vesselStreamSocket) return;
|
||||
const socket = new WebSocket(getVesselStreamUrl());
|
||||
vesselStreamSocket = socket;
|
||||
socket.onopen = () => {
|
||||
vesselRealtimeStats = {
|
||||
...vesselRealtimeStats,
|
||||
connected: true,
|
||||
};
|
||||
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
socket.send(JSON.stringify({ type: "subscribe", data: { channels: ["vessels"] } }));
|
||||
};
|
||||
socket.onmessage = (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(event.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.type === "heartbeat" && message.data?.action === "ping") {
|
||||
socket.send(JSON.stringify({ type: "heartbeat" }));
|
||||
return;
|
||||
}
|
||||
if (message.type !== "data_frame" || message.channel !== "vessels") return;
|
||||
const payload = message.payload || {};
|
||||
if (payload.action === "reload") {
|
||||
loadVessels(null, earth)
|
||||
.then((result) => {
|
||||
vesselRealtimeStats = {
|
||||
...vesselRealtimeStats,
|
||||
connected: true,
|
||||
updates: vesselRealtimeStats.updates + 1,
|
||||
lastUpdateAt: new Date(),
|
||||
lastBatchSize: 0,
|
||||
};
|
||||
onUpdate?.({ totalCount: result?.totalCount ?? getVesselCount(), payload, stream: getVesselRealtimeStats() });
|
||||
})
|
||||
.catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (payload.action !== "upsert" || !Array.isArray(payload.vessels)) return;
|
||||
if (applyVesselDeltas(earth, payload.vessels)) {
|
||||
vesselRealtimeStats = {
|
||||
connected: true,
|
||||
updates: vesselRealtimeStats.updates + 1,
|
||||
lastUpdateAt: new Date(),
|
||||
lastBatchSize: payload.vessels.length,
|
||||
};
|
||||
onUpdate?.({ totalCount: getVesselCount(), payload, stream: getVesselRealtimeStats() });
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (vesselStreamSocket === socket) {
|
||||
vesselStreamSocket = null;
|
||||
}
|
||||
vesselRealtimeStats = {
|
||||
...vesselRealtimeStats,
|
||||
connected: false,
|
||||
};
|
||||
onUpdate?.({ totalCount: getVesselCount(), stream: getVesselRealtimeStats() });
|
||||
if (showVessels) {
|
||||
vesselStreamReconnectTimer = window.setTimeout(connect, 3000);
|
||||
}
|
||||
};
|
||||
socket.onerror = () => {
|
||||
socket.close();
|
||||
};
|
||||
};
|
||||
connect();
|
||||
}
|
||||
|
||||
export function stopVesselRealtime() {
|
||||
if (vesselStreamReconnectTimer) {
|
||||
window.clearTimeout(vesselStreamReconnectTimer);
|
||||
vesselStreamReconnectTimer = null;
|
||||
}
|
||||
if (vesselStreamSocket) {
|
||||
const socket = vesselStreamSocket;
|
||||
vesselStreamSocket = null;
|
||||
socket.close();
|
||||
}
|
||||
vesselRealtimeStats = {
|
||||
connected: false,
|
||||
updates: 0,
|
||||
lastUpdateAt: null,
|
||||
lastBatchSize: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function showVesselTrack(marker, earth) {
|
||||
clearVesselTrack();
|
||||
if (!marker?.userData?.mmsi || !earth) return null;
|
||||
|
||||
@@ -54,6 +54,8 @@ interface UseWebSocketOptions {
|
||||
|
||||
interface UseWebSocketReturn {
|
||||
connected: boolean
|
||||
connecting: boolean
|
||||
status: 'connecting' | 'connected' | 'disconnected'
|
||||
lastMessage: WebSocketMessage | null
|
||||
sendMessage: (message: Record<string, unknown>) => void
|
||||
subscribe: (channels: string[]) => void
|
||||
@@ -65,6 +67,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
const {
|
||||
autoConnect = true,
|
||||
autoSubscribe = [],
|
||||
heartbeatInterval = 25000,
|
||||
onMessage,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
@@ -75,6 +78,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [connecting, setConnecting] = useState(false)
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const heartbeatTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
@@ -97,17 +101,21 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!token) {
|
||||
setConnected(false)
|
||||
setConnecting(false)
|
||||
return
|
||||
}
|
||||
|
||||
intentionalCloseRef.current = false
|
||||
setConnected(false)
|
||||
setConnecting(true)
|
||||
const candidates = buildWebSocketCandidates()
|
||||
let candidateIndex = 0
|
||||
let opened = false
|
||||
|
||||
const tryConnect = () => {
|
||||
const baseUrl = candidates[candidateIndex]
|
||||
const wsUrl = `${baseUrl}?token=${token}`
|
||||
const wsUrl = `${baseUrl}?token=${encodeURIComponent(token)}`
|
||||
activeWsUrlRef.current = baseUrl
|
||||
const ws = new WebSocket(wsUrl)
|
||||
|
||||
@@ -119,15 +127,27 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
}
|
||||
opened = true
|
||||
setConnected(true)
|
||||
setConnecting(false)
|
||||
if (autoSubscribeRef.current.length > 0) {
|
||||
ws.send(JSON.stringify({ type: 'subscribe', data: { channels: autoSubscribeRef.current } }))
|
||||
}
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearInterval(heartbeatTimerRef.current)
|
||||
}
|
||||
heartbeatTimerRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'heartbeat' }))
|
||||
}
|
||||
}, heartbeatInterval)
|
||||
onConnectRef.current?.()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data)
|
||||
if (message.type === 'heartbeat' && message.data?.action === 'ping' && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'heartbeat' }))
|
||||
}
|
||||
setLastMessage(message)
|
||||
onMessageRef.current?.(message)
|
||||
} catch {
|
||||
@@ -150,10 +170,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
|
||||
if (!opened && candidateIndex < candidates.length - 1) {
|
||||
candidateIndex += 1
|
||||
setConnecting(true)
|
||||
tryConnect()
|
||||
return
|
||||
}
|
||||
|
||||
setConnecting(false)
|
||||
|
||||
if (intentionalCloseRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -169,6 +192,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
|
||||
ws.onerror = (error) => {
|
||||
setConnected(false)
|
||||
if (opened || candidateIndex >= candidates.length - 1) {
|
||||
setConnecting(false)
|
||||
}
|
||||
if (intentionalCloseRef.current || ws.readyState === WebSocket.CLOSING || ws.readyState === WebSocket.CLOSED) {
|
||||
return
|
||||
}
|
||||
@@ -185,6 +211,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
tryConnect()
|
||||
} catch (error) {
|
||||
setConnected(false)
|
||||
setConnecting(false)
|
||||
console.warn('[WebSocket] Failed to initialize connection', { url: activeWsUrlRef.current, error })
|
||||
if (autoConnect && token) {
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
@@ -192,7 +219,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}, [token, autoConnect])
|
||||
}, [token, autoConnect, heartbeatInterval])
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
intentionalCloseRef.current = true
|
||||
@@ -213,6 +240,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
}
|
||||
}
|
||||
setConnected(false)
|
||||
setConnecting(false)
|
||||
}, [])
|
||||
|
||||
const sendMessage = useCallback((message: Record<string, unknown>) => {
|
||||
@@ -237,6 +265,8 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
|
||||
return {
|
||||
connected,
|
||||
connecting,
|
||||
status: connected ? 'connected' : connecting ? 'connecting' : 'disconnected',
|
||||
lastMessage,
|
||||
sendMessage,
|
||||
subscribe,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
WifiOutlined,
|
||||
DisconnectOutlined,
|
||||
ReloadOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
@@ -137,6 +138,7 @@ function Dashboard() {
|
||||
const [stats, setStats] = useState<Stats | null>(cachedDashboardStats)
|
||||
const [loading, setLoading] = useState(cachedDashboardStats === null)
|
||||
const [wsConnected, setWsConnected] = useState(false)
|
||||
const [wsConnecting, setWsConnecting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [restartModalOpen, setRestartModalOpen] = useState(false)
|
||||
const [restartSubmitting, setRestartSubmitting] = useState(false)
|
||||
@@ -180,7 +182,7 @@ function Dashboard() {
|
||||
fetchStats()
|
||||
}, [token, clearAuth])
|
||||
|
||||
const { connected: dashboardSocketConnected } = useWebSocket({
|
||||
const { connected: dashboardSocketConnected, connecting: dashboardSocketConnecting } = useWebSocket({
|
||||
autoConnect: true,
|
||||
autoSubscribe: ['dashboard'],
|
||||
onMessage: (message) => {
|
||||
@@ -194,7 +196,8 @@ function Dashboard() {
|
||||
|
||||
useEffect(() => {
|
||||
setWsConnected(dashboardSocketConnected)
|
||||
}, [dashboardSocketConnected])
|
||||
setWsConnecting(dashboardSocketConnecting)
|
||||
}, [dashboardSocketConnected, dashboardSocketConnecting])
|
||||
|
||||
const handleRetry = () => {
|
||||
window.location.reload()
|
||||
@@ -406,6 +409,8 @@ function Dashboard() {
|
||||
<Space wrap className="dashboard-page__actions">
|
||||
{wsConnected ? (
|
||||
<Tag className="dashboard-status-tag" icon={<WifiOutlined />} color="success">实时连接</Tag>
|
||||
) : wsConnecting ? (
|
||||
<Tag className="dashboard-status-tag" icon={<LoadingOutlined spin />} color="processing">正在连接</Tag>
|
||||
) : (
|
||||
<Tag className="dashboard-status-tag" icon={<DisconnectOutlined />} color="default">离线</Tag>
|
||||
)}
|
||||
|
||||
@@ -36,8 +36,6 @@ import { formatPhaseMetric, getPhaseDisplay, getPhaseSummary } from '../../utils
|
||||
const { Text } = Typography
|
||||
const COLLECTION_REFRESH_DELAY_MS = 800
|
||||
|
||||
type SourceKind = 'builtin' | 'custom'
|
||||
|
||||
interface BuiltInDataSource {
|
||||
id: number
|
||||
source: string
|
||||
@@ -69,7 +67,7 @@ interface BuiltInDataSource {
|
||||
credential_status?: string
|
||||
}
|
||||
|
||||
interface CustomDataSource {
|
||||
interface CustomDataSourceOverride {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
@@ -96,7 +94,6 @@ interface EditableDataSourceConfig {
|
||||
|
||||
interface UnifiedDataSource {
|
||||
key: string
|
||||
kind: SourceKind
|
||||
id: number
|
||||
name: string
|
||||
display_name: string
|
||||
@@ -172,7 +169,6 @@ type DatasourceTaskStatus = {
|
||||
function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
|
||||
return {
|
||||
key: `builtin:${source.id}`,
|
||||
kind: 'builtin',
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_name: source.display_name || source.name,
|
||||
@@ -203,32 +199,12 @@ function normalizeBuiltin(source: BuiltInDataSource): UnifiedDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeCustom(source: CustomDataSource): UnifiedDataSource {
|
||||
return {
|
||||
key: `custom:${source.id}`,
|
||||
kind: 'custom',
|
||||
id: source.id,
|
||||
name: source.name,
|
||||
display_name: source.name,
|
||||
source: source.name,
|
||||
source_type: source.source_type,
|
||||
endpoint: source.endpoint,
|
||||
auth_type: source.auth_type,
|
||||
is_active: source.is_active,
|
||||
created_at: source.created_at,
|
||||
updated_at: source.updated_at,
|
||||
description: source.description,
|
||||
headers: {},
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
|
||||
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[]>([])
|
||||
const [customOverrides, setCustomOverrides] = useState<CustomDataSourceOverride[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [triggerAllLoading, setTriggerAllLoading] = useState(false)
|
||||
const [forceTriggerAll, setForceTriggerAll] = useState(false)
|
||||
@@ -239,13 +215,7 @@ function DataSources() {
|
||||
const [tableHeight, setTableHeight] = useState(360)
|
||||
const tableRegionRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const allSources = useMemo(
|
||||
() => [
|
||||
...builtInSources.map(normalizeBuiltin),
|
||||
...customSources.map(normalizeCustom),
|
||||
],
|
||||
[builtInSources, customSources],
|
||||
)
|
||||
const allSources = useMemo(() => builtInSources.map(normalizeBuiltin), [builtInSources])
|
||||
|
||||
const activeBuiltInCount = builtInSources.filter((source) => source.is_active).length
|
||||
const runningBuiltInSources = builtInSources.filter((source) => source.is_running)
|
||||
@@ -266,7 +236,7 @@ function DataSources() {
|
||||
axios.get('/api/v1/datasources/configs'),
|
||||
])
|
||||
setBuiltInSources(builtinRes.data.data || [])
|
||||
setCustomSources(customRes.data.data || [])
|
||||
setCustomOverrides(customRes.data.data || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error)
|
||||
messageApi.error('获取数据源列表失败')
|
||||
@@ -396,8 +366,8 @@ function DataSources() {
|
||||
|
||||
const handleViewSource = async (source: UnifiedDataSource) => {
|
||||
try {
|
||||
if (source.kind === 'builtin') {
|
||||
const override = customSources.find((item) => item.name === source.source)
|
||||
{
|
||||
const override = customOverrides.find((item) => item.name === source.source)
|
||||
const [detailRes, statsRes, overrideDetail] = await Promise.all([
|
||||
axios.get(`/api/v1/datasources/${source.id}`),
|
||||
axios.get(`/api/v1/datasources/${source.id}/stats`),
|
||||
@@ -422,17 +392,6 @@ function DataSources() {
|
||||
credential_status: data.credential_status,
|
||||
})
|
||||
setRecordCount(statsRes.data.total_records || 0)
|
||||
} else {
|
||||
const detail = await axios.get<EditableDataSourceConfig>(`/api/v1/datasources/configs/${source.id}`).then((res) => res.data)
|
||||
setViewingSource({
|
||||
...source,
|
||||
description: detail.description,
|
||||
endpoint: detail.endpoint,
|
||||
auth_type: detail.auth_type,
|
||||
headers: detail.headers || {},
|
||||
config: detail.config || {},
|
||||
})
|
||||
setRecordCount(null)
|
||||
}
|
||||
setViewDrawerVisible(true)
|
||||
} catch (error) {
|
||||
@@ -468,16 +427,15 @@ function DataSources() {
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'kind',
|
||||
key: 'kind',
|
||||
width: 100,
|
||||
render: (kind: SourceKind) => <Tag color={kind === 'builtin' ? 'blue' : 'purple'}>{kind === 'builtin' ? '内置' : '自定义'}</Tag>,
|
||||
render: () => <Tag color="blue">内置</Tag>,
|
||||
},
|
||||
{
|
||||
title: '层级/类型',
|
||||
key: 'module',
|
||||
width: 120,
|
||||
render: (_: unknown, record: UnifiedDataSource) => record.kind === 'builtin' ? <Tag>{record.module}</Tag> : <Tag>{record.source_type || 'api'}</Tag>,
|
||||
render: (_: unknown, record: UnifiedDataSource) => <Tag>{record.module}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '频率',
|
||||
@@ -498,9 +456,6 @@ function DataSources() {
|
||||
key: 'status',
|
||||
width: 180,
|
||||
render: (_: unknown, record: UnifiedDataSource) => {
|
||||
if (record.kind === 'custom') {
|
||||
return <Tag color={record.is_active ? 'green' : 'default'}>{record.is_active ? '启用' : '禁用'}</Tag>
|
||||
}
|
||||
if (record.is_running) {
|
||||
return (
|
||||
<Tooltip title={getPhaseDisplay(record)}>
|
||||
@@ -517,7 +472,7 @@ function DataSources() {
|
||||
key: 'action',
|
||||
fixed: 'right' as const,
|
||||
width: 190,
|
||||
render: (_: unknown, record: UnifiedDataSource) => record.kind === 'builtin' ? (
|
||||
render: (_: unknown, record: UnifiedDataSource) => (
|
||||
<Space size={4}>
|
||||
<Button type="link" size="small" icon={<SyncOutlined />} disabled={!record.is_active} onClick={() => { void triggerDatasourceWithPrecheck(record.id) }}>
|
||||
触发
|
||||
@@ -533,7 +488,7 @@ function DataSources() {
|
||||
{record.is_active ? '禁用' : '启用'}
|
||||
</Button>
|
||||
</Space>
|
||||
) : <Text type="secondary">设置中维护</Text>,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -565,10 +520,6 @@ function DataSources() {
|
||||
<span className="data-source-bulk-toolbar__stat-label">内置</span>
|
||||
<strong>{builtInSources.length}</strong>
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">自定义</span>
|
||||
<strong>{customSources.length}</strong>
|
||||
</div>
|
||||
<div className="data-source-bulk-toolbar__stat-pill">
|
||||
<span className="data-source-bulk-toolbar__stat-label">已启用内置</span>
|
||||
<strong>{activeBuiltInCount}</strong>
|
||||
@@ -678,7 +629,7 @@ function DataSources() {
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col span={24}>
|
||||
<Space>
|
||||
<Tag color={viewingSource.kind === 'builtin' ? 'blue' : 'purple'}>{viewingSource.kind === 'builtin' ? '内置数据源' : '自定义数据源'}</Tag>
|
||||
<Tag color="blue">内置数据源</Tag>
|
||||
<Tag color={viewingSource.is_active ? 'green' : 'default'}>{viewingSource.is_active ? '启用' : '禁用'}</Tag>
|
||||
</Space>
|
||||
</Col>
|
||||
@@ -690,45 +641,26 @@ function DataSources() {
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>标识</div>
|
||||
<Input value={viewingSource.source} disabled />
|
||||
</Col>
|
||||
{viewingSource.kind === 'builtin' ? (
|
||||
<>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>模块</div>
|
||||
<Input value={viewingSource.module || '-'} disabled />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>优先级</div>
|
||||
<Input value={viewingSource.priority || '-'} disabled />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>频率</div>
|
||||
<Input value={viewingSource.frequency || '-'} disabled />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>数据量</div>
|
||||
<Input value={recordCount === null ? '-' : `${recordCount} 条`} disabled />
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>采集器</div>
|
||||
<Input value={viewingSource.collector_class || '-'} disabled />
|
||||
</Col>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>源类型</div>
|
||||
<Input value={viewingSource.source_type || '-'} disabled />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>认证方式</div>
|
||||
<Input value={viewingSource.auth_type || 'none'} disabled />
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>描述</div>
|
||||
<Input.TextArea rows={2} value={viewingSource.description || '-'} disabled />
|
||||
</Col>
|
||||
</>
|
||||
)}
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>模块</div>
|
||||
<Input value={viewingSource.module || '-'} disabled />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>优先级</div>
|
||||
<Input value={viewingSource.priority || '-'} disabled />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>频率</div>
|
||||
<Input value={viewingSource.frequency || '-'} disabled />
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>数据量</div>
|
||||
<Input value={recordCount === null ? '-' : `${recordCount} 条`} disabled />
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<div style={{ marginBottom: 4, color: '#8c8c8c', fontSize: 12 }}>采集器</div>
|
||||
<Input value={viewingSource.collector_class || '-'} disabled />
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useCollapsedActions } from '../../hooks'
|
||||
import { TableActions, actionCellProps } from '../../components/TableActions/TableActions'
|
||||
import {
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
CheckCircleOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
RobotOutlined,
|
||||
StopOutlined,
|
||||
SyncOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ import { useSearchParams } from 'react-router-dom'
|
||||
const { Title, Text } = Typography
|
||||
const ANTHROPIC_MESSAGES_MAX_TOKENS = 1200
|
||||
const DEFAULT_PROVIDER_MAX_TOKENS = 4096
|
||||
const CUSTOM_STREAM_STATUS_POLL_MS = 5000
|
||||
|
||||
interface SystemSettings {
|
||||
system_name: string
|
||||
@@ -83,6 +86,7 @@ interface CollectorSettings {
|
||||
credential_provider?: string | null
|
||||
credential_status?: string
|
||||
ais_health?: AISSourceHealth | null
|
||||
is_custom?: boolean
|
||||
}
|
||||
|
||||
interface AISSourceHealth {
|
||||
@@ -171,6 +175,7 @@ interface CredentialGuide {
|
||||
}
|
||||
|
||||
interface CollectorConfigOption {
|
||||
id?: number
|
||||
name: string
|
||||
default_url: string
|
||||
endpoint: string
|
||||
@@ -184,6 +189,7 @@ interface CollectorConfigOption {
|
||||
config: Record<string, any>
|
||||
config_id: number | null
|
||||
description: string
|
||||
is_custom?: boolean
|
||||
}
|
||||
|
||||
const AISSTREAM_BBOX_PRESETS = [
|
||||
@@ -310,6 +316,7 @@ function Settings() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingCollectorId, setSavingCollectorId] = useState<number | null>(null)
|
||||
const [collectors, setCollectors] = useState<CollectorSettings[]>([])
|
||||
const [customSourceConfigs, setCustomSourceConfigs] = useState<CollectorConfigOption[]>([])
|
||||
const [systemSettings, setSystemSettings] = useState<SystemSettings | null>(null)
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings | null>(null)
|
||||
const [securitySettings, setSecuritySettings] = useState<SecuritySettings | null>(null)
|
||||
@@ -338,10 +345,33 @@ function Settings() {
|
||||
const [securityForm] = Form.useForm<SecuritySettings>()
|
||||
const [integrationForm] = Form.useForm()
|
||||
const [collectorConfigForm] = Form.useForm()
|
||||
const [customSourceForm] = Form.useForm()
|
||||
const [tvEditForm] = Form.useForm<TVStreamSource>()
|
||||
const selectedAiProvider = Form.useWatch(['ai_provider', 'provider'], integrationForm)
|
||||
const selectedCollector = collectors.find((collector) => collector.source === selectedCollectorSource)
|
||||
const selectedCollectorConfig = collectorConfigs.find((config) => config.name === selectedCollectorSource)
|
||||
const customCollectors: CollectorSettings[] = useMemo(() => customSourceConfigs.map((config) => ({
|
||||
id: -(config.config_id || 0),
|
||||
name: config.name,
|
||||
display_name: config.description || config.name,
|
||||
source: config.name,
|
||||
module: 'CUSTOM',
|
||||
priority: 'P2',
|
||||
frequency_minutes: Number(config.config?.frequency_minutes || 0),
|
||||
frequency: 'custom',
|
||||
is_active: config.is_active,
|
||||
last_run_at: null,
|
||||
last_status: null,
|
||||
next_run_at: null,
|
||||
is_free: true,
|
||||
requires_credentials: config.auth_type !== 'none',
|
||||
credential_provider: null,
|
||||
credential_status: 'custom',
|
||||
is_custom: true,
|
||||
})), [customSourceConfigs])
|
||||
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 [customStreamStatus, setCustomStreamStatus] = useState<{ running: boolean; done: boolean } | null>(null)
|
||||
const [customStreamBusy, setCustomStreamBusy] = useState(false)
|
||||
const selectedCollectorHealth = selectedCollector
|
||||
? collectorHealthStatus[selectedCollector.source]
|
||||
: undefined
|
||||
@@ -374,10 +404,11 @@ function Settings() {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const [response, presetsResponse, collectorConfigsResponse] = await Promise.all([
|
||||
const [response, presetsResponse, collectorConfigsResponse, customConfigsResponse] = await Promise.all([
|
||||
axios.get('/api/v1/settings'),
|
||||
axios.get('/api/v1/settings/integrations/ai-provider/presets'),
|
||||
axios.get('/api/v1/datasources/configs/all'),
|
||||
axios.get('/api/v1/datasources/configs'),
|
||||
])
|
||||
setSystemSettings(response.data.system)
|
||||
setNotificationSettings(response.data.notifications)
|
||||
@@ -386,7 +417,12 @@ function Settings() {
|
||||
setIntegrations(response.data.integrations || null)
|
||||
setCollectors(response.data.collectors || [])
|
||||
setAiProviderPresets(presetsResponse.data.data || [])
|
||||
setCollectorConfigs(collectorConfigsResponse.data.data || [])
|
||||
const builtinConfigs = collectorConfigsResponse.data.data || []
|
||||
setCollectorConfigs(builtinConfigs)
|
||||
const builtinNames = new Set((response.data.collectors || []).map((collector: CollectorSettings) => collector.source))
|
||||
setCustomSourceConfigs((customConfigsResponse.data.data || [])
|
||||
.filter((config: CollectorConfigOption) => !builtinNames.has(config.name))
|
||||
.map((config: CollectorConfigOption) => ({ ...config, is_custom: true, config_id: config.id ?? config.config_id })))
|
||||
} catch (error) {
|
||||
message.error('获取系统配置失败')
|
||||
console.error(error)
|
||||
@@ -449,6 +485,24 @@ function Settings() {
|
||||
if (loading || !selectedCollectorConfig) return
|
||||
const config = selectedCollectorConfig.config || {}
|
||||
const boundingBoxes = config.bounding_boxes ?? [[[-90, -180], [90, 180]]]
|
||||
if (selectedCollector?.is_custom) {
|
||||
collectorConfigForm.setFieldsValue({
|
||||
endpoint: selectedCollectorConfig.endpoint,
|
||||
source_type: selectedCollectorConfig.source_type || 'websocket',
|
||||
auth_type: selectedCollectorConfig.auth_type || 'none',
|
||||
merge_target_source: config.merge_target_source || 'barentswatch_vessels',
|
||||
target_schema: config.target_schema || 'vessel_ais',
|
||||
auth_config: {
|
||||
api_key: selectedCollectorConfig.auth_configured?.api_key ? '••••••••' : '',
|
||||
},
|
||||
headers: Object.entries(selectedCollectorConfig.headers || {}).map(([key, value]) => ({ key, value })),
|
||||
config: {
|
||||
...config,
|
||||
advanced_json: JSON.stringify(config, null, 2),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
collectorConfigForm.setFieldsValue({
|
||||
endpoint: selectedCollectorConfig.endpoint,
|
||||
auth_config: {
|
||||
@@ -465,12 +519,12 @@ function Settings() {
|
||||
bounding_boxes_json: stringifyBoundingBoxes(boundingBoxes),
|
||||
},
|
||||
})
|
||||
}, [collectorConfigForm, loading, selectedCollectorConfig])
|
||||
}, [collectorConfigForm, loading, selectedCollector, selectedCollectorConfig])
|
||||
|
||||
useEffect(() => {
|
||||
if (!requestedCollector || !collectors.some((collector) => collector.source === requestedCollector)) return
|
||||
if (!requestedCollector || !collectorOptions.some((collector) => collector.source === requestedCollector)) return
|
||||
setSelectedCollectorSource(requestedCollector)
|
||||
}, [collectors, requestedCollector])
|
||||
}, [collectorOptions, requestedCollector])
|
||||
|
||||
useEffect(() => {
|
||||
const updateTableHeight = () => {
|
||||
@@ -516,6 +570,16 @@ function Settings() {
|
||||
}, {})
|
||||
)
|
||||
|
||||
const parseJsonObjectField = (value: string | undefined, fallback: Record<string, any> = {}) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return fallback
|
||||
const parsed = JSON.parse(text)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('高级配置必须是 JSON object')
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
const applyAisstreamBboxPreset = (presetValue: string) => {
|
||||
const preset = AISSTREAM_BBOX_PRESETS.find((item) => item.value === presetValue)
|
||||
if (!preset) return
|
||||
@@ -571,6 +635,34 @@ function Settings() {
|
||||
const baseValues = collectorConfigForm.getFieldsValue(true)
|
||||
const headers = headersListToMap(baseValues.headers)
|
||||
|
||||
if (selectedCollector.is_custom) {
|
||||
const configValues = {
|
||||
...parseJsonObjectField(baseValues.config?.advanced_json, baseValues.config || {}),
|
||||
merge_target_source: baseValues.merge_target_source,
|
||||
target_schema: baseValues.target_schema || 'vessel_ais',
|
||||
}
|
||||
delete configValues.advanced_json
|
||||
const payload: Record<string, any> = {
|
||||
name: selectedCollector.source,
|
||||
description: selectedCollector.name,
|
||||
source_type: baseValues.source_type || selectedCollectorConfig.source_type || 'websocket',
|
||||
endpoint: baseValues.endpoint,
|
||||
auth_type: baseValues.auth_type || selectedCollectorConfig.auth_type || 'none',
|
||||
headers,
|
||||
config: configValues,
|
||||
}
|
||||
const apiKey = String(baseValues.auth_config?.api_key || '').trim()
|
||||
if (payload.auth_type === 'api_key' && apiKey && !apiKey.startsWith('••••')) {
|
||||
payload.auth_config = { api_key: apiKey }
|
||||
} else {
|
||||
payload.auth_config = {}
|
||||
}
|
||||
await axios.put(`/api/v1/datasources/configs/${selectedCollectorConfig.config_id}`, payload)
|
||||
message.success('自定义源设置已保存')
|
||||
await fetchSettings()
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedCollector.source === 'barentswatch_vessels') {
|
||||
const integrationValues = integrationForm.getFieldsValue(true)
|
||||
await saveIntegrations({
|
||||
@@ -632,6 +724,184 @@ function Settings() {
|
||||
}
|
||||
}
|
||||
|
||||
const createCustomSourceFromSettings = async () => {
|
||||
try {
|
||||
const values = await customSourceForm.validateFields()
|
||||
const config = {
|
||||
...(parseJsonObjectField(values.advanced_json, {})),
|
||||
merge_target_source: values.merge_target_source,
|
||||
target_schema: values.target_schema || 'vessel_ais',
|
||||
}
|
||||
await axios.post('/api/v1/datasources/configs', {
|
||||
name: values.name,
|
||||
description: values.description || values.name,
|
||||
source_type: values.source_type || 'websocket',
|
||||
endpoint: values.endpoint,
|
||||
auth_type: values.auth_type || 'none',
|
||||
auth_config: {},
|
||||
headers: {},
|
||||
config,
|
||||
})
|
||||
message.success('自定义源已创建')
|
||||
customSourceForm.resetFields()
|
||||
await fetchSettings()
|
||||
setSelectedCollectorSource(values.name)
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } }; message?: string }
|
||||
message.error(err.response?.data?.detail || err.message || '创建自定义源失败')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmCreateCustomSource = () => {
|
||||
customSourceForm.setFieldsValue({
|
||||
source_type: 'websocket',
|
||||
auth_type: 'none',
|
||||
merge_target_source: 'barentswatch_vessels',
|
||||
target_schema: 'vessel_ais',
|
||||
endpoint: 'ws://localhost:8787/ais',
|
||||
advanced_json: JSON.stringify({
|
||||
ws_message_path: '$.data',
|
||||
ws_reconnect: true,
|
||||
delivery_mode: 'realtime_stream',
|
||||
}, null, 2),
|
||||
})
|
||||
Modal.confirm({
|
||||
title: '添加自定义源',
|
||||
width: 720,
|
||||
icon: null,
|
||||
content: (
|
||||
<Form form={customSourceForm} layout="vertical">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item name="name" label="源名称" rules={[{ required: true, message: '请输入源名称' }]}>
|
||||
<Input placeholder="mock_ais_ws" />
|
||||
</Form.Item>
|
||||
<Form.Item name="source_type" label="类型">
|
||||
<Select options={[{ value: 'websocket', label: 'WebSocket' }, { value: 'rest', label: 'REST' }]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="endpoint" label="Endpoint" rules={[{ required: true, message: '请输入 Endpoint' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="merge_target_source"
|
||||
label="合并到内置数据"
|
||||
rules={[{ required: true, message: '请选择该自定义源要合并到的内置数据' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={collectors.map((collector) => ({
|
||||
value: collector.source,
|
||||
label: `${collector.display_name || collector.name} · ${collector.source}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 16px' }}>
|
||||
<Form.Item
|
||||
name="target_schema"
|
||||
label="目标 Schema"
|
||||
rules={[{ required: true, message: '请选择目标 schema' }]}
|
||||
>
|
||||
<Select options={[{ value: 'vessel_ais', label: 'vessel_ais' }, { value: 'geo_points', label: 'geo_points' }, { value: 'generic_records', label: 'generic_records' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="auth_type" label="凭证类型">
|
||||
<Select options={[{ value: 'none', label: 'None' }, { value: 'bearer', label: 'Bearer' }, { value: 'api_key', label: 'API Key' }, { value: 'basic', label: 'Basic' }]} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="说明">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="advanced_json" label="高级配置 JSON">
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okText: '创建',
|
||||
cancelText: '取消',
|
||||
onOk: createCustomSourceFromSettings,
|
||||
})
|
||||
}
|
||||
|
||||
const refreshCustomStreamStatus = async () => {
|
||||
if (!selectedCollector?.is_custom || !selectedCollectorConfig?.config_id) {
|
||||
setCustomStreamStatus(null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`/api/v1/datasources/${selectedCollectorConfig.config_id}/stream-status`)
|
||||
setCustomStreamStatus({ running: !!response.data?.running, done: !!response.data?.done })
|
||||
} catch {
|
||||
setCustomStreamStatus(null)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshCustomStreamStatus()
|
||||
if (!selectedCollector?.is_custom || !selectedCollectorConfig?.config_id) return undefined
|
||||
const interval = window.setInterval(() => { void refreshCustomStreamStatus() }, CUSTOM_STREAM_STATUS_POLL_MS)
|
||||
return () => window.clearInterval(interval)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedCollector?.is_custom, selectedCollectorConfig?.config_id])
|
||||
|
||||
const startSelectedCustomStream = async () => {
|
||||
if (!selectedCollectorConfig?.config_id) return
|
||||
try {
|
||||
setCustomStreamBusy(true)
|
||||
await axios.post(`/api/v1/datasources/${selectedCollectorConfig.config_id}/run-mapped`, null, {
|
||||
params: { background: true },
|
||||
})
|
||||
message.success('已启动自定义实时流')
|
||||
await refreshCustomStreamStatus()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '启动实时流失败')
|
||||
} finally {
|
||||
setCustomStreamBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stopSelectedCustomStream = async () => {
|
||||
if (!selectedCollectorConfig?.config_id) return
|
||||
try {
|
||||
setCustomStreamBusy(true)
|
||||
await axios.post(`/api/v1/datasources/${selectedCollectorConfig.config_id}/stop-mapped`)
|
||||
message.success('已停止自定义实时流')
|
||||
await refreshCustomStreamStatus()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
message.error(err.response?.data?.detail || '停止实时流失败')
|
||||
} finally {
|
||||
setCustomStreamBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteSelectedCustomSource = () => {
|
||||
if (!selectedCollector?.is_custom || !selectedCollectorConfig?.config_id) return
|
||||
let deleteSourceData = false
|
||||
Modal.confirm({
|
||||
title: `删除自定义源 ${selectedCollector.source}`,
|
||||
content: (
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Alert showIcon type="warning" message="删除后不可恢复。可选择是否同时删除该自定义源生成的数据。" />
|
||||
<Checkbox onChange={(event) => { deleteSourceData = event.target.checked }}>
|
||||
同时删除该自定义源生成的所有数据
|
||||
</Checkbox>
|
||||
</Space>
|
||||
),
|
||||
okText: '删除',
|
||||
cancelText: '取消',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await axios.delete(`/api/v1/datasources/configs/${selectedCollectorConfig.config_id}`, {
|
||||
params: { delete_mappings: true, delete_source_data: deleteSourceData },
|
||||
})
|
||||
message.success('自定义源已删除')
|
||||
setSelectedCollectorSource('barentswatch_vessels')
|
||||
await fetchSettings()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const loadCredentialGuide = async (provider: string, open = true) => {
|
||||
try {
|
||||
setCredentialGuideLoading(true)
|
||||
@@ -687,6 +957,32 @@ function Settings() {
|
||||
|
||||
const testSelectedCollectorConnectivity = async () => {
|
||||
if (!selectedCollector) return
|
||||
if (selectedCollector.is_custom && selectedCollectorConfig?.config_id) {
|
||||
try {
|
||||
setTestingCredentialProvider(selectedCollector.source)
|
||||
const response = await axios.post(`/api/v1/datasources/configs/${selectedCollectorConfig.config_id}/test`)
|
||||
if (response.data.success) {
|
||||
setCollectorHealthStatus((prev) => ({
|
||||
...prev,
|
||||
[selectedCollector.source]: { ok: true, message: '自定义源连接成功' },
|
||||
}))
|
||||
message.success('自定义源连接成功')
|
||||
} else {
|
||||
throw new Error(response.data.message || response.data.error || '自定义源连接失败')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string; message?: string } }; message?: string }
|
||||
const errorMessage = err.response?.data?.message || err.response?.data?.detail || err.message || '自定义源连接失败'
|
||||
setCollectorHealthStatus((prev) => ({
|
||||
...prev,
|
||||
[selectedCollector.source]: { ok: false, message: errorMessage },
|
||||
}))
|
||||
message.error(errorMessage)
|
||||
} finally {
|
||||
setTestingCredentialProvider(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (selectedCollector.source === 'barentswatch_vessels') {
|
||||
await testBarentsWatchCredentials()
|
||||
return
|
||||
@@ -1466,11 +1762,14 @@ function Settings() {
|
||||
style={{ width: '100%' }}
|
||||
optionFilterProp="label"
|
||||
onChange={setSelectedCollectorSource}
|
||||
options={collectors.map((collector) => ({
|
||||
options={collectorOptions.map((collector) => ({
|
||||
value: collector.source,
|
||||
label: `${collector.display_name || collector.name} · ${collector.source}`,
|
||||
label: `${collector.is_custom ? '[自定义] ' : ''}${collector.display_name || collector.name} · ${collector.source}`,
|
||||
}))}
|
||||
/>
|
||||
<Tooltip title="添加自定义源">
|
||||
<Button icon={<PlusOutlined />} onClick={confirmCreateCustomSource} />
|
||||
</Tooltip>
|
||||
<Tooltip title="健康检查">
|
||||
<Button
|
||||
icon={<PlugConnectIcon />}
|
||||
@@ -1486,6 +1785,9 @@ function Settings() {
|
||||
{selectedCollector.requires_credentials ? '需要凭证' : '无需凭证'}
|
||||
</Tag>
|
||||
<Tag>{selectedCollector.module}</Tag>
|
||||
{selectedCollector.is_custom ? (
|
||||
<Tag color="purple">自定义补充源</Tag>
|
||||
) : null}
|
||||
<Tag color={selectedCollector.is_active ? 'success' : 'default'}>
|
||||
{selectedCollector.is_active ? '启用' : '禁用'}
|
||||
</Tag>
|
||||
@@ -1506,6 +1808,9 @@ function Settings() {
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{selectedCollectorConfig?.is_overridden ? <Tag color="blue">已覆盖 endpoint</Tag> : null}
|
||||
{selectedCollector.is_custom && selectedCollectorConfig?.config?.merge_target_source ? (
|
||||
<Tag color="blue">合并到 {selectedCollectorConfig.config.merge_target_source}</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
</Card>
|
||||
@@ -1642,12 +1947,37 @@ function Settings() {
|
||||
|
||||
<Card size="small" title="基础配置">
|
||||
<Form form={collectorConfigForm} layout="vertical">
|
||||
{selectedCollector?.is_custom ? (
|
||||
<>
|
||||
<Form.Item name="source_type" label="自定义源类型">
|
||||
<Select options={[{ value: 'websocket', label: 'WebSocket' }, { value: 'rest', label: 'REST' }, { value: 'http', label: 'HTTP' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="merge_target_source" label="合并到内置数据">
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={collectors.map((collector) => ({
|
||||
value: collector.source,
|
||||
label: `${collector.display_name || collector.name} · ${collector.source}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="target_schema" label="目标 Schema">
|
||||
<Select options={[{ value: 'vessel_ais', label: 'vessel_ais' }, { value: 'geo_points', label: 'geo_points' }, { value: 'generic_records', label: 'generic_records' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="auth_type" label="凭证类型">
|
||||
<Select options={[{ value: 'none', label: 'None' }, { value: 'bearer', label: 'Bearer' }, { value: 'api_key', label: 'API Key' }, { value: 'basic', label: 'Basic' }]} />
|
||||
</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>
|
||||
<Form.Item label="默认 Endpoint">
|
||||
<Input value={selectedCollectorConfig?.default_url || '-'} disabled />
|
||||
</Form.Item>
|
||||
{!selectedCollector?.is_custom ? (
|
||||
<Form.Item label="默认 Endpoint">
|
||||
<Input value={selectedCollectorConfig?.default_url || '-'} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.List name="headers">
|
||||
{(fields, { add, remove }) => (
|
||||
<Form.Item label="请求头">
|
||||
@@ -1706,16 +2036,52 @@ function Settings() {
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{selectedCollector?.is_custom ? (
|
||||
<Form.Item name={['config', 'advanced_json']} label="高级配置 JSON">
|
||||
<Input.TextArea rows={8} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
loading={savingCollectorConfig || savingIntegrations}
|
||||
onClick={() => { void saveSelectedCollectorSettings() }}
|
||||
>
|
||||
保存采集器设置
|
||||
</Button>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={savingCollectorConfig || savingIntegrations}
|
||||
onClick={() => { void saveSelectedCollectorSettings() }}
|
||||
>
|
||||
保存采集器设置
|
||||
</Button>
|
||||
{selectedCollector?.is_custom && selectedCollectorConfig?.source_type === 'websocket' ? (
|
||||
<>
|
||||
<Button
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={customStreamBusy}
|
||||
disabled={!!customStreamStatus?.running}
|
||||
onClick={() => { void startSelectedCustomStream() }}
|
||||
>
|
||||
启动实时流
|
||||
</Button>
|
||||
<Button
|
||||
icon={<StopOutlined />}
|
||||
danger
|
||||
loading={customStreamBusy}
|
||||
disabled={!customStreamStatus?.running}
|
||||
onClick={() => { void stopSelectedCustomStream() }}
|
||||
>
|
||||
停止实时流
|
||||
</Button>
|
||||
<Tag color={customStreamStatus?.running ? 'processing' : customStreamStatus?.done ? 'default' : 'default'}>
|
||||
{customStreamStatus?.running ? 'streaming' : customStreamStatus?.done ? 'stopped' : '未运行'}
|
||||
</Tag>
|
||||
</>
|
||||
) : null}
|
||||
{selectedCollector?.is_custom ? (
|
||||
<Button danger icon={<DeleteOutlined />} onClick={confirmDeleteSelectedCustomSource}>
|
||||
删除自定义源
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
</SettingsPanel>
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user