pcp-tgu-ops-ui: правки API-клиентов, вкладок и стилей
complexPlanApi/missionPlanningApi, TguComplexPlanTab, TguEditorTab, стили tgu-editor.css/tgu-planning.css.
This commit is contained in:
@@ -8,6 +8,13 @@ import { toLocalIso } from "../utils/localTime";
|
|||||||
*/
|
*/
|
||||||
const BASE = "/api/pcp-complex";
|
const BASE = "/api/pcp-complex";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Максимальная длина интервала расчёта комплексного плана — 10 суток. Дольше считать запрещено
|
||||||
|
* (расчёт по всем КА на больший горизонт слишком тяжёлый и теряет смысл для оперативного плана).
|
||||||
|
*/
|
||||||
|
export const MAX_COMPLEX_PLAN_RANGE_DAYS = 10;
|
||||||
|
export const MAX_COMPLEX_PLAN_RANGE_MS = MAX_COMPLEX_PLAN_RANGE_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** Статус запуска расчёта комплексного плана (ComplexPlanRunStatus бэка). */
|
/** Статус запуска расчёта комплексного плана (ComplexPlanRunStatus бэка). */
|
||||||
export type ComplexPlanRunStatus = "CREATED" | "RUNNING" | "COMPLETED" | "FAILED";
|
export type ComplexPlanRunStatus = "CREATED" | "RUNNING" | "COMPLETED" | "FAILED";
|
||||||
|
|
||||||
@@ -45,6 +52,35 @@ export type ComplexPlanRunResult = {
|
|||||||
coveredAreaPercent: number;
|
coveredAreaPercent: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Краткая карточка прогона из истории расчётов (ComplexPlanRunSummaryDTO).
|
||||||
|
* Возвращается списком из GET /api/com-plan/runs, отсортированным по убыванию даты создания.
|
||||||
|
*/
|
||||||
|
export type ComplexPlanRunSummary = {
|
||||||
|
runId: number;
|
||||||
|
status: ComplexPlanRunStatus;
|
||||||
|
intervalStart: string;
|
||||||
|
intervalEnd: string;
|
||||||
|
satelliteIds: number[];
|
||||||
|
requestedBy: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
errorMessage: string | null;
|
||||||
|
calculatedModesCount: number;
|
||||||
|
bookedModesCount: number;
|
||||||
|
complanModesCount: number;
|
||||||
|
mixedModesCount: number;
|
||||||
|
affectedSatellitesCount: number;
|
||||||
|
processedCellsCount: number;
|
||||||
|
bookedSlotLinksCount: number;
|
||||||
|
snapshotIds: number[];
|
||||||
|
totalRequestsArea: number;
|
||||||
|
coveredArea: number;
|
||||||
|
coveredAreaPercent: number;
|
||||||
|
};
|
||||||
|
|
||||||
/** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена — локальные. */
|
/** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена — локальные. */
|
||||||
export type ComplexPlanMode = {
|
export type ComplexPlanMode = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -101,6 +137,27 @@ export async function runComplexPlan(params: RunComplexPlanParams): Promise<Comp
|
|||||||
return JSON.parse(text) as ComplexPlanRunResult;
|
return JSON.parse(text) as ComplexPlanRunResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* История прогонов комплексного плана (pcp-complex-mission-service, GET /api/com-plan/runs).
|
||||||
|
* Список отсортирован бэком по убыванию даты создания, поэтому первый завершённый элемент —
|
||||||
|
* это последний построенный комплексный план.
|
||||||
|
*/
|
||||||
|
export async function fetchComplexPlanRuns(): Promise<ComplexPlanRunSummary[]> {
|
||||||
|
const url = `${BASE}/api/com-plan/runs`;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
|
||||||
|
} catch {
|
||||||
|
throw new TguApiError("Не удалось подключиться к сервису комплексного плана.", "network");
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new TguApiError(extractErrorText(text) || "Не удалось загрузить историю расчётов.", "http", response.status);
|
||||||
|
}
|
||||||
|
if (!text.trim()) return [];
|
||||||
|
return JSON.parse(text) as ComplexPlanRunSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Режимы (съёмки/сбросы) рассчитанного run’а для отрисовки на карте
|
* Режимы (съёмки/сбросы) рассчитанного run’а для отрисовки на карте
|
||||||
* (pcp-complex-mission-service, GET /api/com-plan/runs/{runId}/modes).
|
* (pcp-complex-mission-service, GET /api/com-plan/runs/{runId}/modes).
|
||||||
|
|||||||
@@ -191,11 +191,13 @@ export async function calculateDrops(planId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Полная очистка плана: удаляет все включения (съёмки, включая ручные, и сбросы) плана-черновика
|
* Очистка плана-черновика (pcp-mission-planing-service, DELETE /api/missions/{planId}/modes).
|
||||||
* (pcp-mission-planing-service, DELETE /api/missions/{planId}/modes). Возвращает 204.
|
* Сбросы удаляются всегда; `keepManual=true` сохраняет ручные маршруты съёмки (source=MANUAL),
|
||||||
|
* `keepManual=false` (по умолчанию) удаляет все съёмки, включая ручные. Возвращает 204.
|
||||||
*/
|
*/
|
||||||
export async function clearPlanModes(planId: string): Promise<void> {
|
export async function clearPlanModes(planId: string, keepManual = false): Promise<void> {
|
||||||
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/modes`;
|
const query = keepManual ? "?keepManual=true" : "";
|
||||||
|
const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/modes${query}`;
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } });
|
response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } });
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
fetchComplexPlanRunModes,
|
fetchComplexPlanRunModes,
|
||||||
|
fetchComplexPlanRuns,
|
||||||
runComplexPlan,
|
runComplexPlan,
|
||||||
|
MAX_COMPLEX_PLAN_RANGE_DAYS,
|
||||||
|
MAX_COMPLEX_PLAN_RANGE_MS,
|
||||||
type ComplexPlanMode,
|
type ComplexPlanMode,
|
||||||
type ComplexPlanRunResult
|
type ComplexPlanRunSummary
|
||||||
} from "../../api/complexPlanApi";
|
} from "../../api/complexPlanApi";
|
||||||
|
import { fetchRequestsForMap } from "../../api/requestApi";
|
||||||
|
import type { RequestMapItem } from "../../model/requestTypes";
|
||||||
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
|
import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes";
|
||||||
import { DateTime24Field } from "../../components/timeFields";
|
import { DateTime24Field } from "../../components/timeFields";
|
||||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||||
|
import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers";
|
||||||
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
|
||||||
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
|
||||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||||
import { parseLocal, toLocalInputValue } from "../../utils/localTime";
|
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
|
||||||
|
|
||||||
type TguComplexPlanTabProps = {
|
type TguComplexPlanTabProps = {
|
||||||
range: TimelineRange;
|
range: TimelineRange;
|
||||||
@@ -19,27 +25,26 @@ type TguComplexPlanTabProps = {
|
|||||||
platforms: TguPlatformUi[];
|
platforms: TguPlatformUi[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Включён только выделенный слой «Комплексный план» (свой цвет, своя отрисовка с фиксом
|
|
||||||
// антимеридиана) — контуры не путаются с полосой обзора/работами плана других вкладок.
|
|
||||||
const COMPLEX_PLAN_LAYERS: Tgu2DMapLayersState = {
|
|
||||||
tracks: false,
|
|
||||||
swath: false,
|
|
||||||
planWorks: false,
|
|
||||||
complexPlan: true,
|
|
||||||
stations: false,
|
|
||||||
spacecraftMarkers: false,
|
|
||||||
requests: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) {
|
export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) {
|
||||||
const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
|
const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
|
||||||
const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
|
const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
|
||||||
const [running, setRunning] = useState(false);
|
const [running, setRunning] = useState(false);
|
||||||
const [result, setResult] = useState<ComplexPlanRunResult>();
|
|
||||||
const [modes, setModes] = useState<ComplexPlanMode[]>([]);
|
const [modes, setModes] = useState<ComplexPlanMode[]>([]);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [notice, setNotice] = useState<string>();
|
const [notice, setNotice] = useState<string>();
|
||||||
|
|
||||||
|
// История прогонов и выбранный для просмотра прогон.
|
||||||
|
const [runs, setRuns] = useState<ComplexPlanRunSummary[]>([]);
|
||||||
|
const [runsError, setRunsError] = useState<string>();
|
||||||
|
const [selectedRunId, setSelectedRunId] = useState<number>();
|
||||||
|
const [loadingRun, setLoadingRun] = useState(false);
|
||||||
|
|
||||||
|
// Наложение заявок поверх контуров комплексного плана.
|
||||||
|
const [showRequests, setShowRequests] = useState(false);
|
||||||
|
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
|
||||||
|
const [requestsLoading, setRequestsLoading] = useState(false);
|
||||||
|
const [requestsError, setRequestsError] = useState<string>();
|
||||||
|
|
||||||
// NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper).
|
// NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper).
|
||||||
const satelliteIds = useMemo(
|
const satelliteIds = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -61,11 +66,106 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
|||||||
!Number.isFinite(parsedRange.fromMs) ||
|
!Number.isFinite(parsedRange.fromMs) ||
|
||||||
!Number.isFinite(parsedRange.toMs) ||
|
!Number.isFinite(parsedRange.toMs) ||
|
||||||
parsedRange.fromMs >= parsedRange.toMs;
|
parsedRange.fromMs >= parsedRange.toMs;
|
||||||
|
const rangeTooLong =
|
||||||
|
!rangeInvalid && parsedRange.toMs - parsedRange.fromMs > MAX_COMPLEX_PLAN_RANGE_MS;
|
||||||
|
|
||||||
const scene = useMemo<Tgu2DMapScene>(() => ({ polygons: buildModePolygons(modes), lines: [], markers: [] }), [modes]);
|
const selectedRun = useMemo(
|
||||||
|
() => runs.find((run) => run.runId === selectedRunId),
|
||||||
|
[runs, selectedRunId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Интервал, по которому грузим заявки: показанный прогон (если выбран) либо поля «С/По».
|
||||||
|
const activeInterval = useMemo(() => {
|
||||||
|
if (selectedRun) {
|
||||||
|
return { fromMs: parseLocal(selectedRun.intervalStart), toMs: parseLocal(selectedRun.intervalEnd) };
|
||||||
|
}
|
||||||
|
return parsedRange;
|
||||||
|
}, [selectedRun, parsedRange]);
|
||||||
|
|
||||||
|
const modePolygons = useMemo(() => buildModePolygons(modes), [modes]);
|
||||||
|
const requestPolygons = useMemo(
|
||||||
|
() => (showRequests ? buildTgu2DMapScene({ range, platforms, requestItems }).polygons : []),
|
||||||
|
[showRequests, requestItems, range, platforms]
|
||||||
|
);
|
||||||
|
|
||||||
|
const scene = useMemo<Tgu2DMapScene>(
|
||||||
|
() => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }),
|
||||||
|
[modePolygons, requestPolygons]
|
||||||
|
);
|
||||||
|
|
||||||
|
const layers = useMemo<Tgu2DMapLayersState>(
|
||||||
|
() => ({
|
||||||
|
tracks: false,
|
||||||
|
swath: false,
|
||||||
|
planWorks: false,
|
||||||
|
complexPlan: true,
|
||||||
|
stations: false,
|
||||||
|
spacecraftMarkers: false,
|
||||||
|
requests: showRequests
|
||||||
|
}),
|
||||||
|
[showRequests]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadRuns = useCallback(async (): Promise<ComplexPlanRunSummary[]> => {
|
||||||
|
setRunsError(undefined);
|
||||||
|
try {
|
||||||
|
const items = await fetchComplexPlanRuns();
|
||||||
|
setRuns(items);
|
||||||
|
return items;
|
||||||
|
} catch (caught) {
|
||||||
|
setRunsError(caught instanceof Error ? caught.message : "Не удалось загрузить историю расчётов.");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadRunModes = useCallback(async (runId: number) => {
|
||||||
|
setSelectedRunId(runId);
|
||||||
|
setLoadingRun(true);
|
||||||
|
setError(undefined);
|
||||||
|
try {
|
||||||
|
const runModes = await fetchComplexPlanRunModes(runId);
|
||||||
|
setModes(runModes);
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : "Не удалось загрузить результат расчёта.");
|
||||||
|
} finally {
|
||||||
|
setLoadingRun(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// На входе во вкладку подтягиваем историю и сразу показываем последний построенный план.
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const items = await loadRuns();
|
||||||
|
const latest = items.find((run) => run.status === "COMPLETED" && run.calculatedModesCount > 0) ?? items[0];
|
||||||
|
if (latest) void loadRunModes(latest.runId);
|
||||||
|
})();
|
||||||
|
}, [loadRuns, loadRunModes]);
|
||||||
|
|
||||||
|
// Заявки за активный интервал (по тумблеру) — для сопоставления контуров плана с заявками.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showRequests) return;
|
||||||
|
const { fromMs, toMs } = activeInterval;
|
||||||
|
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setRequestsLoading(true);
|
||||||
|
setRequestsError(undefined);
|
||||||
|
fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) })
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setRequestItems(result.items);
|
||||||
|
})
|
||||||
|
.catch((caught) => {
|
||||||
|
if (!cancelled) setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки.");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setRequestsLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [showRequests, activeInterval]);
|
||||||
|
|
||||||
const runCalculation = async () => {
|
const runCalculation = async () => {
|
||||||
if (running || rangeInvalid || satelliteIds.length === 0) return;
|
if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return;
|
||||||
setRunning(true);
|
setRunning(true);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
@@ -75,13 +175,11 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
|||||||
toMs: parsedRange.toMs,
|
toMs: parsedRange.toMs,
|
||||||
satelliteIds
|
satelliteIds
|
||||||
});
|
});
|
||||||
setResult(runResult);
|
await loadRuns();
|
||||||
const runModes = await fetchComplexPlanRunModes(runResult.runId);
|
await loadRunModes(runResult.runId);
|
||||||
setModes(runModes);
|
|
||||||
const contoursCount = runModes.filter((mode) => mode.contourWkt).length;
|
|
||||||
setNotice(
|
setNotice(
|
||||||
runResult.calculatedModesCount > 0
|
runResult.calculatedModesCount > 0
|
||||||
? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}. На карте — ${contoursCount} контуров.`
|
? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}.`
|
||||||
: "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками."
|
: "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками."
|
||||||
);
|
);
|
||||||
} catch (caught) {
|
} catch (caught) {
|
||||||
@@ -91,15 +189,21 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const canRun = !running && !rangeInvalid && satelliteIds.length > 0;
|
const canRun = !running && !rangeInvalid && !rangeTooLong && satelliteIds.length > 0;
|
||||||
const overlayMessage = running
|
const busy = running || loadingRun;
|
||||||
|
const overlayMessage = busy
|
||||||
|
? running
|
||||||
? "Идёт расчёт…"
|
? "Идёт расчёт…"
|
||||||
|
: "Загрузка плана…"
|
||||||
: rangeInvalid
|
: rangeInvalid
|
||||||
? "Некорректный интервал: «С» должно быть раньше «По»."
|
? "Некорректный интервал: «С» должно быть раньше «По»."
|
||||||
: modes.length === 0
|
: modes.length === 0
|
||||||
? "Задайте интервал и нажмите «Рассчитать», чтобы построить комплексный план."
|
? "Задайте интервал и нажмите «Рассчитать» либо выберите построенный план из истории."
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const displayRun = selectedRun;
|
||||||
|
const contoursCount = modePolygons.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="tgu-map-workspace">
|
<main className="tgu-map-workspace">
|
||||||
<div className="tgu-map-toolbar">
|
<div className="tgu-map-toolbar">
|
||||||
@@ -120,11 +224,38 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
|||||||
<button className="tgu-button tgu-button--primary" type="button" onClick={runCalculation} disabled={!canRun}>
|
<button className="tgu-button tgu-button--primary" type="button" onClick={runCalculation} disabled={!canRun}>
|
||||||
{running ? "Расчёт…" : "Рассчитать"}
|
{running ? "Расчёт…" : "Рассчитать"}
|
||||||
</button>
|
</button>
|
||||||
|
<label className="tgu-field">
|
||||||
|
<span>Построенные планы</span>
|
||||||
|
<select
|
||||||
|
className="tgu-select"
|
||||||
|
value={selectedRunId ?? ""}
|
||||||
|
disabled={runs.length === 0 || busy}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = Number(event.target.value);
|
||||||
|
if (Number.isFinite(value)) void loadRunModes(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{runs.length === 0 && <option value="">нет расчётов</option>}
|
||||||
|
{runs.map((run) => (
|
||||||
|
<option key={run.runId} value={run.runId}>
|
||||||
|
{formatRunOption(run)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className={`tgu-button${showRequests ? " is-active" : ""}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowRequests((value) => !value)}
|
||||||
|
>
|
||||||
|
{showRequests ? "Скрыть заявки" : "Показать заявки"}
|
||||||
|
</button>
|
||||||
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
|
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
|
||||||
{result && (
|
{displayRun && (
|
||||||
<div className="tgu-map-toolbar__subtitle">
|
<div className="tgu-map-toolbar__subtitle">
|
||||||
run #{result.runId} · режимов {result.calculatedModesCount} · охват {formatPercent(result.coveredAreaPercent)}
|
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)}
|
||||||
{result.snapshotIds.length > 0 ? ` · snapshot ${result.snapshotIds.join(", ")}` : ""}
|
{displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров
|
||||||
|
{showRequests ? ` · заявок ${requestItems.length}` : ""}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -133,8 +264,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
|||||||
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
|
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
|
||||||
<Tgu2DMapView
|
<Tgu2DMapView
|
||||||
scene={scene}
|
scene={scene}
|
||||||
layers={COMPLEX_PLAN_LAYERS}
|
layers={layers}
|
||||||
redrawToken={modes.length}
|
redrawToken={modes.length + requestPolygons.length}
|
||||||
onObjectSelect={() => undefined}
|
onObjectSelect={() => undefined}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -145,6 +276,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
|
|||||||
Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен.
|
Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{rangeTooLong && (
|
||||||
|
<div className="tgu-alert tgu-alert--warning">
|
||||||
|
Интервал расчёта не может превышать {MAX_COMPLEX_PLAN_RANGE_DAYS} суток — сократите диапазон «С/По».
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{requestsLoading && <div className="tgu-alert">Загрузка заявок…</div>}
|
||||||
|
{requestsError && <div className="tgu-alert tgu-alert--danger">{requestsError}</div>}
|
||||||
|
{runsError && <div className="tgu-alert tgu-alert--danger">{runsError}</div>}
|
||||||
{notice && <div className="tgu-alert">{notice}</div>}
|
{notice && <div className="tgu-alert">{notice}</div>}
|
||||||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +315,13 @@ function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] {
|
|||||||
return polygons;
|
return polygons;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Подпись прогона в выпадающем списке истории: интервал, статус и счётчик режимов. */
|
||||||
|
function formatRunOption(run: ComplexPlanRunSummary): string {
|
||||||
|
const interval = `${formatLocalDateTime(parseLocal(run.intervalStart))} — ${formatLocalDateTime(parseLocal(run.intervalEnd))}`;
|
||||||
|
const status = run.status === "COMPLETED" ? "" : ` · ${run.status}`;
|
||||||
|
return `#${run.runId} · ${interval} · режимов ${run.calculatedModesCount}${status}`;
|
||||||
|
}
|
||||||
|
|
||||||
function formatPercent(value: number | null | undefined): string {
|
function formatPercent(value: number | null | undefined): string {
|
||||||
if (value == null || !Number.isFinite(value)) return "—";
|
if (value == null || !Number.isFinite(value)) return "—";
|
||||||
return `${value.toFixed(1)}%`;
|
return `${value.toFixed(1)}%`;
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
// Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) —
|
// Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) —
|
||||||
// блокирует повторный запуск и кнопки правок, пока не перечитаем включения.
|
// блокирует повторный запуск и кнопки правок, пока не перечитаем включения.
|
||||||
const [planCalcRunning, setPlanCalcRunning] = useState(false);
|
const [planCalcRunning, setPlanCalcRunning] = useState(false);
|
||||||
|
// Открыт диалог выбора режима очистки плана (всё / оставить ручные съёмки).
|
||||||
|
const [clearDialogOpen, setClearDialogOpen] = useState(false);
|
||||||
const [notice, setNotice] = useState<string>();
|
const [notice, setNotice] = useState<string>();
|
||||||
const [applyError, setApplyError] = useState<string>();
|
const [applyError, setApplyError] = useState<string>();
|
||||||
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
const [contextSpacecraftIds, setContextSpacecraftIds] = useState<Set<string>>(new Set());
|
||||||
@@ -877,27 +879,28 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Полная очистка плана: сносит ВСЕ включения (съёмки, включая ручные, и сбросы) в БД черновика
|
// Очистка плана. Оператор выбирает в диалоге, что снести: всё (включая ручные маршруты) или
|
||||||
// и в локальном представлении. Единственный способ убрать ручные маршруты массово (помимо
|
// только рассчитанное (ручные съёмки source=MANUAL остаются, как при пересчёте плана). Сбросы
|
||||||
// ручного удаления по одному). Подтверждение — действие необратимо.
|
// удаляются в любом случае. Меняем БД черновика и локальное представление. Необратимо.
|
||||||
const clearPlan = async () => {
|
const clearPlan = async (keepManual: boolean) => {
|
||||||
if (!selectedPlan || planCalcRunning) return;
|
if (!selectedPlan || planCalcRunning) return;
|
||||||
// window здесь затенён состоянием окна таймлайна — берём confirm из globalThis.
|
setClearDialogOpen(false);
|
||||||
if (!globalThis.confirm("Очистить план полностью? Будут удалены все включения, включая построенные вручную. Действие необратимо.")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPlanCalcRunning(true);
|
setPlanCalcRunning(true);
|
||||||
setApplyError(undefined);
|
setApplyError(undefined);
|
||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
try {
|
try {
|
||||||
const draftId = await ensureDraft();
|
const draftId = await ensureDraft();
|
||||||
await clearPlanModes(draftId);
|
await clearPlanModes(draftId, keepManual);
|
||||||
await refreshDraftModes(draftId);
|
await refreshDraftModes(draftId);
|
||||||
changeDraft((current) => ({ ...current, works: [] }));
|
changeDraft((current) => ({ ...current, works: [] }));
|
||||||
setSelectedWorkId(undefined);
|
setSelectedWorkId(undefined);
|
||||||
setEditingWorkId(undefined);
|
setEditingWorkId(undefined);
|
||||||
setRightMode("none");
|
setRightMode("none");
|
||||||
setNotice("План полностью очищен.");
|
setNotice(
|
||||||
|
keepManual
|
||||||
|
? "Рассчитанные включения удалены. Ручные маршруты съёмки сохранены."
|
||||||
|
: "План полностью очищен."
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setApplyError(error instanceof Error ? error.message : "Не удалось очистить план.");
|
setApplyError(error instanceof Error ? error.message : "Не удалось очистить план.");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1160,7 +1163,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
}}
|
}}
|
||||||
onCheck={runCheck}
|
onCheck={runCheck}
|
||||||
onCalculatePlan={() => { void runPlanCalculation(); }}
|
onCalculatePlan={() => { void runPlanCalculation(); }}
|
||||||
onClearPlan={() => { void clearPlan(); }}
|
onClearPlan={() => setClearDialogOpen(true)}
|
||||||
onZoom={zoom}
|
onZoom={zoom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1408,6 +1411,48 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{clearDialogOpen && (
|
||||||
|
<div
|
||||||
|
className="tgu-chain-dialog__backdrop"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Очистить план"
|
||||||
|
onClick={() => setClearDialogOpen(false)}
|
||||||
|
>
|
||||||
|
<div className="tgu-chain-dialog tgu-editor-clear-dialog" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="tgu-chain-dialog__header">
|
||||||
|
<h2>Очистить план</h2>
|
||||||
|
</div>
|
||||||
|
<p className="tgu-editor-clear-dialog__text">
|
||||||
|
Что удалить из плана? Сбросы удаляются в любом случае. Действие необратимо.
|
||||||
|
</p>
|
||||||
|
<div className="tgu-editor-clear-dialog__choices">
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--primary"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void clearPlan(true); }}
|
||||||
|
>
|
||||||
|
Оставить ручные съёмки
|
||||||
|
<small>Удалить только рассчитанные включения</small>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tgu-button tgu-button--reject"
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void clearPlan(false); }}
|
||||||
|
>
|
||||||
|
Удалить всё
|
||||||
|
<small>Включая построенные вручную маршруты</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="tgu-chain-dialog__actions">
|
||||||
|
<button className="tgu-button" type="button" onClick={() => setClearDialogOpen(false)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1188,3 +1188,36 @@
|
|||||||
border: 0;
|
border: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Диалог выбора режима очистки плана (всё / оставить ручные съёмки). */
|
||||||
|
.tgu-editor-clear-dialog {
|
||||||
|
width: min(26rem, calc(100vw - 2rem));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-clear-dialog__text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted, #9fb0b3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-clear-dialog__choices {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-clear-dialog__choices > .tgu-button {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.15rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-clear-dialog__choices > .tgu-button > small {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,12 @@
|
|||||||
color: #eafffd;
|
color: #eafffd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-button.is-active {
|
||||||
|
border-color: rgba(104, 195, 189, 0.58);
|
||||||
|
background: rgba(104, 195, 189, 0.16);
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-button--accept {
|
.tgu-button--accept {
|
||||||
border-color: rgba(47, 158, 68, 0.65);
|
border-color: rgba(47, 158, 68, 0.65);
|
||||||
background: rgba(47, 158, 68, 0.16);
|
background: rgba(47, 158, 68, 0.16);
|
||||||
|
|||||||
Reference in New Issue
Block a user