From ea207088bc1f62ae430c91771823451ffbfca452 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Fri, 5 Jun 2026 16:13:33 +0300 Subject: [PATCH] =?UTF-8?q?pcp-tgu-ops-ui:=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B2=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=BE=D0=BA=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=BF=D0=BE=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F=20+=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D1=81=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BB=D0=B0=D0=BD=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Редактор: инспектор включения теперь только просмотр (время с десятыми долями секунды), кнопки «Редактировать»→«Удалить». «Редактировать» открывает окно построения, предзаполненное параметрами, и при сохранении заменяет маршрут. Прицеливание опорной точки — явный режим: активно, пока не выбран проход. Планы: состав включений выбранного плана перечитывается при возврате на вкладку «Планы», иначе счётчик маршрутов оставался прежним после правок в редакторе. --- .../src/features/tgu-editor/EditorPanels.tsx | 181 ++++++---------- .../src/features/tgu-editor/TguEditorTab.tsx | 199 +++++++++++++++--- .../features/tgu-editor/editorDraft.test.ts | 15 +- .../src/features/tgu-editor/editorDraft.ts | 24 --- .../features/tgu-editor/editorPresentation.ts | 12 ++ .../features/tgu-planning/TguPlanningPage.tsx | 6 +- .../pcp-tgu-ops-ui/src/styles/tgu-editor.css | 8 +- 7 files changed, 264 insertions(+), 181 deletions(-) diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx index 7ccb17c..abf4ed0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorPanels.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; +import { formatDate, formatDateTime, formatDateTimeSeconds, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; import { statusStyle } from "../tgu-planning/tguStatus"; import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime"; import { Time24Field } from "../../components/timeFields"; @@ -39,9 +39,11 @@ type WorkInspectorProps = { readOnly?: boolean; /** Разрешить удаление даже у read-only включения плана (есть черновик — режим живёт в нём). */ canDelete?: boolean; - onUpdate: (workId: string, patch: EditorWorkPatch) => void; + /** План редактируемый — показываем кнопку «Редактировать» (открывает окно построения). */ + canEdit?: boolean; + /** Открыть включение в окне построения для правки параметров. */ + onEdit: (workId: string) => void; onDelete: (workId: string) => void; - onDuplicate: (workId: string) => void; onClose: () => void; }; @@ -66,6 +68,12 @@ type ShootPanelProps = { routeLoading?: boolean; routeError?: string; route?: SurveyRoute; + /** Режим правки существующей съёмки — меняет заголовок и подпись кнопки. */ + editing?: boolean; + /** Активен режим прицеливания: карта в режиме клика по точке (перекрестие), иначе — «рука». */ + picking?: boolean; + /** Включить режим прицеливания (указать/сменить опорную точку одним кликом по карте). */ + onPickTarget: () => void; /** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */ addBlockedReason?: string; onRouteStartChange: (startMs: number) => void; @@ -80,7 +88,6 @@ type ShootPanelProps = { onModeChange: (modeId: string) => void; onAdd: () => void; onClose: () => void; - onClearTarget: () => void; }; /** Минимальная длительность маршрута, мс (нижняя граница окна). */ @@ -119,6 +126,8 @@ type DropPanelProps = { onToggleSurvey: (id: number) => void; saving?: boolean; saveError?: string; + /** Режим правки существующего сброса — меняет заголовок и подпись кнопки. */ + editing?: boolean; /** Почему нельзя добавить включение в этот план (заложен/стартовал) — гасит «Добавить». */ addBlockedReason?: string; onAdd: () => void; @@ -134,9 +143,9 @@ export function WorkInspector({ conflicts, readOnly, canDelete, - onUpdate, + canEdit, + onEdit, onDelete, - onDuplicate, onClose }: WorkInspectorProps) { const color = kindColor(work.kind); @@ -156,22 +165,14 @@ export function WorkInspector({
Время
- {readOnly ? ( -
-
Начало
-
{formatDateTime(work.startMs)}
-
Конец
-
{formatDateTime(work.endMs)}
-
Длительность
-
{formatDuration(work.endMs - work.startMs)}
-
- ) : ( - <> - onUpdate(work.id, { startMs: value })} /> - onUpdate(work.id, { endMs: value })} /> -
длительность: {formatDuration(work.endMs - work.startMs)}
- - )} +
+
Начало
+
{formatDateTimeSeconds(work.startMs)}
+
Конец
+
{formatDateTimeSeconds(work.endMs)}
+
Длительность
+
{formatDuration(work.endMs - work.startMs)}
+
@@ -229,55 +230,23 @@ export function WorkInspector({ {work.kind === "shoot" && (
Режим аппаратуры
- {readOnly ? ( -
{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}
- ) : ( -
- {modes.map((mode) => ( - - ))} -
- )} +
{modes.find((mode) => mode.id === work.modeId)?.label ?? "—"}
{work.targetName &&
цель: {work.targetName}
}
)} - {work.kind === "downlink" && !readOnly && ( -
-
Станция приёма
- -
- )} - - {!readOnly && ( + {(canEdit || !readOnly || canDelete) && (
- - -
- )} - - {readOnly && canDelete && ( -
- + {canEdit && ( + + )} + {(!readOnly || canDelete) && ( + + )}
)} @@ -303,6 +272,9 @@ export function ShootPanel({ routeLoading, routeError, route, + editing, + picking, + onPickTarget, addBlockedReason, onRouteStartChange, onRouteEndChange, @@ -312,32 +284,42 @@ export function ShootPanel({ onSelectPass, onModeChange, onAdd, - onClose, - onClearTarget + onClose }: ShootPanelProps) { return (
- +
1 · опорная точка прицеливания
- {target && ( - )}
- {!target ? ( + {target ? ( + <> +
+ + {target.name} + + {target.lat.toFixed(2)}, {target.lon.toFixed(2)} + +
+ {picking && ( +
+ Кликайте по карте, чтобы переставить точку. Выберите проход ниже, когда точка верна. +
+ )} + + ) : picking ? (
Кликните точку на карте слева, чтобы задать опорную точку прицеливания.
) : ( -
- - {target.name} - - {target.lat.toFixed(2)}, {target.lon.toFixed(2)} - -
+ )}
@@ -461,7 +443,7 @@ export function ShootPanel({ onClick={onAdd} disabled={!target || !selectedPass || !modeId || !!addBlockedReason} > - + Добавить съёмку в план + {editing ? "Сохранить съёмку" : "+ Добавить съёмку в план"}
); @@ -523,6 +505,7 @@ export function DropPanel({ onToggleSurvey, saving, saveError, + editing, addBlockedReason, onAdd, onClose @@ -531,7 +514,7 @@ export function DropPanel({ return (
- +
Выберите зону радиовидимости станции, затем маршруты съёмки, которые успевают завершиться до начала зоны.
@@ -601,7 +584,7 @@ export function DropPanel({ {saveError &&
{saveError}
}
); @@ -803,38 +786,6 @@ function PanelHead({ title, onClose }: { title: string; onClose: () => void }) { ); } -function TimeStepper({ - label, - value, - min, - max, - onChange -}: { - label: string; - value: number; - min?: number; - max?: number; - onChange: (value: number) => void; -}) { - const change = (deltaMs: number) => { - const next = Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, value + deltaMs)); - onChange(next); - }; - - return ( - - ); -} - /** * Степпер времени окна маршрута с секундной точностью: кнопки ±5 c / ±30 c и ручной * ввод времени `чч:мм:сс` (локальное, без даты — дата берётся из текущего значения). diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index 1998bc0..fd19361 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -30,11 +30,9 @@ import { addDownlink, addShoot, deleteWork, - duplicateWork, mutate, planModesToWorks, - seedDraft, - updateWork + seedDraft } from "./editorDraft"; import { checkVisibility, conflictCount, mergeConflictMaps, overlaps, type EditorConflictMap } from "./editorConflicts"; import { DEFAULT_EDITOR_MODES, formatDuration } from "./editorPresentation"; @@ -52,7 +50,6 @@ import type { EditorVisibilityTrack, EditorVisibilityWindow, EditorWork, - EditorWorkPatch, TguEditorTabProps } from "./model/editorTypes"; @@ -114,7 +111,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, ); const [rightMode, setRightMode] = useState("none"); const [selectedWorkId, setSelectedWorkId] = useState(); + // id редактируемого включения: окно построения открыто для правки существующего маршрута, а не + // создания нового. При сохранении старый маршрут заменяется (удаляется в БД + локально). + const [editingWorkId, setEditingWorkId] = useState(); + // Параметры окна/крена, которыми нужно ОДНОКРАТНО предзаполнить окно построения съёмки при правке + // (иначе эффект дефолтного окна затёр бы их при установке прохода). Сбрасывается после применения. + const editShootPrefillRef = useRef<{ startMs: number; endMs: number; roll: number } | null>(null); + // Аналогично для сброса: предвыбранная зона + сбрасываемые съёмки, применяются после входа в режим. + const editDropPrefillRef = useRef<{ zone: RvaZone; surveyIds: number[] } | null>(null); const [target, setTarget] = useState(); + // Режим прицеливания: карта ждёт клик по опорной точке (перекрестие). Включается явно — кнопкой + // «Указать/Сменить точку», после одного клика гаснет. Иначе карта в обычном режиме («рука»), + // и клики не сбрасывают параметры маршрута. + const [targetPickActive, setTargetPickActive] = useState(false); const [selectedShootPass, setSelectedShootPass] = useState(); const [modeId, setModeId] = useState(DEFAULT_EDITOR_MODES[0].id); const [rvaZones, setRvaZones] = useState([]); @@ -198,6 +207,23 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const liveConflicts = useMemo(() => overlaps(draft.works), [draft.works]); const conflicts = useMemo(() => mergeConflictMaps(liveConflicts, serviceConflicts), [liveConflicts, serviceConflicts]); const selectedWork = displayWorks.find((work) => work.id === selectedWorkId); + // Редактируемое включение и id его режима в БД: на время правки прячем его и из сцены карты, и с + // таймлайна — иначе исходный контур виден одновременно с живым превью (карта 2, таймлайн 1). + const editingWork = useMemo( + () => (editingWorkId ? displayWorks.find((work) => work.id === editingWorkId) : undefined), + [editingWorkId, displayWorks] + ); + const editingModeId = editingWork?.planModeId; + // Включения для таймлайна: при правке исключаем редактируемое (его место занимает превью на карте). + const timelineWorks = useMemo( + () => (editingWorkId ? displayWorks.filter((work) => work.id !== editingWorkId) : displayWorks), + [displayWorks, editingWorkId] + ); + // Режимы для сцены карты: при правке исключаем контур редактируемого режима (показываем превью). + const sceneModes = useMemo( + () => (editingModeId != null ? planModes.filter((mode) => mode.id !== editingModeId) : planModes), + [planModes, editingModeId] + ); const activeLabel = platformLabel(platforms, selectedSpacecraftId); const [shootPasses, setShootPasses] = useState([]); const [shootPassesLoading, setShootPassesLoading] = useState(false); @@ -216,8 +242,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const effectiveFrom = Math.max(1, Math.min(passFrom, orbitPasses.length || 1)); const effectiveTo = Math.max(effectiveFrom, Math.min(passTo, orbitPasses.length || 1)); const mapScene = useMemo( - () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute), - [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, planModes, effectiveFrom, effectiveTo, surveyRoute] + () => buildEditorMapScene(selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute), + [selectedSpacecraftId, flightLineData, orbitPasses, stationDtos, requestItems, sceneModes, effectiveFrom, effectiveTo, surveyRoute] ); // Клик по включению = «как клик по объекту на карте»: центрируем карту на нём и подсвечиваем. const focusWorkOnMap = useCallback( @@ -277,8 +303,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setHistory({ draft: seedDraft(selectedPlan, selectedSpacecraftId), past: [], future: [] }); setWindow(defaultEditorWindow(selectedPlan, appliedRange)); setSelectedWorkId(undefined); + setEditingWorkId(undefined); setRightMode("none"); setTarget(undefined); + setTargetPickActive(false); setSelectedShootPass(undefined); setSurveyRoute(undefined); setRouteError(undefined); @@ -386,6 +414,14 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setRouteRoll(undefined); return; } + // Правка существующего маршрута: подставляем его окно/крен один раз, не центрируем по проходу. + const prefill = editShootPrefillRef.current; + if (prefill) { + editShootPrefillRef.current = null; + setRouteWindow({ startMs: prefill.startMs, endMs: prefill.endMs }); + setRouteRoll(Math.round(prefill.roll * 10) / 10); + return; + } const passDurationMs = selectedShootPass.endMs - selectedShootPass.startMs; const half = Math.min(DEFAULT_ROUTE_DURATION_MS, passDurationMs) / 2; const mid = (selectedShootPass.startMs + selectedShootPass.endMs) / 2; @@ -449,8 +485,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, let cancelled = false; setRvaLoading(true); setRvaError(undefined); - setSelectedZone(undefined); - setSelectedDropSurveyIds(new Set()); + // Правка существующего сброса: подставляем его зону и сбрасываемые съёмки один раз. + const prefill = editDropPrefillRef.current; + editDropPrefillRef.current = null; + if (prefill) { + setSelectedZone(prefill.zone); + setSelectedDropSurveyIds(new Set(prefill.surveyIds)); + } else { + setSelectedZone(undefined); + setSelectedDropSurveyIds(new Set()); + } void fetchSatelliteRva(numericNorad, window.fromMs, window.toMs) .then((zones) => { if (!cancelled) setRvaZones(zones); @@ -518,8 +562,57 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setApplyError(undefined); }; - const onChangeWork = (workId: string, patch: EditorWorkPatch) => { - changeDraft((current) => updateWork(current, workId, patch)); + // «Редактировать» включение: открываем то же окно, что и при построении, предзаполнив его + // параметрами включения. Сохранение заменит исходный маршрут (см. addShootWork/addDropWork). + const beginEditWork = (workId: string) => { + const work = displayWorks.find((item) => item.id === workId); + if (!work) return; + setEditingWorkId(workId); + setSelectedWorkId(undefined); + setMapInfoSelection(undefined); + clearMapPick(); + setNotice(undefined); + setApplyError(undefined); + + if (work.kind === "shoot") { + const lat = work.targetLat ?? work.centerLat; + const lon = work.targetLon ?? work.centerLon; + setTarget( + lat != null && lon != null + ? { id: `edit-${workId}`, name: work.targetName ?? "Цель съёмки", lat, lon } + : undefined + ); + // Окно/крен подставит эффект дефолтного окна по этому ref (иначе он бы их центрировал). + editShootPrefillRef.current = { startMs: work.startMs, endMs: work.endMs, roll: work.rollDeg ?? 0 }; + // Синтетический «проход» задаёт границы окна маршрута (±30 мин) и виток для пересчёта маршрута. + setSelectedShootPass({ + startMs: work.startMs, + endMs: work.endMs, + quality: 1, + revolution: work.revolution, + rollDeg: work.rollDeg + }); + setModeId(work.modeId ?? DEFAULT_EDITOR_MODES[0].id); + // Правка начинается без прицеливания: точка уже есть, карта в режиме «рука». + setTargetPickActive(false); + setRightMode("shoot"); + return; + } + + // Сброс: синтезируем зону из включения (для отображения и сохранения) и предвыбираем съёмки. + const stationNum = Number(work.stationId); + const midMs = (work.startMs + work.endMs) / 2; + const zone: RvaZone = { + noradId: noradId ? Number(noradId) : 0, + stationId: Number.isFinite(stationNum) ? stationNum : 0, + revolution: work.revolution ?? 0, + onStart: { time: toLocalIso(work.startMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + onMaximum: { time: toLocalIso(midMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + onStop: { time: toLocalIso(work.endMs).slice(0, 19), elevation: 0, azimuth: 0, range: 0 }, + duration: Math.max(1, (work.endMs - work.startMs) / 1000) + }; + editDropPrefillRef.current = { zone, surveyIds: work.surveyIds ?? [] }; + setRightMode("downlink"); }; // Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же @@ -557,9 +650,38 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, void revalidate(missionId, planModesToWorks(modes)); }, [revalidate]); + // Удаляет из черновика маршрут редактируемого включения (замена при правке). Свежепостроенное или + // уже-в-черновике включение адресуется по planModeId; включение из живого плана после клонирования + // получает новый id в черновике — находим его по типу/витку/времени старта. + const deleteEditedModeFromDraft = async ( + draftId: string, + draftExistedBefore: boolean, + work: EditorWork, + type: "SURVEY" | "DROP" + ) => { + if (work.planModeId != null && draftExistedBefore) { + await deleteMode(work.planModeId).catch(() => {}); + return; + } + const draftModes = await fetchPlanModes(draftId); + const match = draftModes.find( + (m) => + m.type === type && + m.revolution === work.revolution && + m.timeStart != null && + Math.abs(parseLocal(m.timeStart) - work.startMs) < 2000 + ); + if (match) await deleteMode(match.id).catch(() => {}); + }; + const addShootWork = async () => { if (!target || !selectedShootPass || !modeId) return; + // Правка существующего маршрута: его исходную версию удаляем при сохранении (замена). + const editId = editingWorkId; + const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined; + const draftExistedBefore = draftPlanId != null; + // Персистенция построенного маршрута съёмки в pcp-mission-planing-service по id // выбранного плана (planId == mission_id). Сохраняем только если план выбран и маршрут // уже построен; иначе (открыт только КА без плана) добавляем включение без сохранения. @@ -573,6 +695,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, try { // Ленивое создание черновика на первую правку; адресуем съёмку в DRAFT, а не в живой план. const draftId = await ensureDraft(); + // Заменяем: сносим исходный маршрут редактируемого включения перед сохранением нового. + if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "SURVEY"); savedRoute = await saveSurveyRoute(draftId, { satelliteId: numericNorad, time: toLocalIso(routeWindow.startMs), @@ -602,7 +726,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const id = nextWorkId(idCounterRef); changeDraft((current) => - addShoot(current, { + addShoot(editId ? deleteWork(current, editId) : current, { id, startMs, endMs, @@ -618,9 +742,11 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, centerLon: savedRoute?.longitude }) ); + setEditingWorkId(undefined); setSelectedWorkId(id); setRightMode("inspect"); setTarget(undefined); + setTargetPickActive(false); setSelectedShootPass(undefined); }; @@ -652,12 +778,19 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const zoneStartMsVal = localDateTimeMs(selectedZone.onStart.time); const zoneStopMsVal = localDateTimeMs(selectedZone.onStop.time); + // Правка существующего сброса: его исходную версию удаляем при сохранении (замена). + const editId = editingWorkId; + const editWork = editId ? displayWorks.find((item) => item.id === editId) : undefined; + const draftExistedBefore = draftPlanId != null; + setDropSaving(true); setDropError(undefined); let savedDropId: number | undefined; try { // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план. const draftId = await ensureDraft(); + // Заменяем: сносим исходный сброс редактируемого включения перед сохранением нового. + if (editWork) await deleteEditedModeFromDraft(draftId, draftExistedBefore, editWork, "DROP"); const savedDrop = await saveDropRoute(draftId, { satelliteId: numericNorad, station: String(selectedZone.stationId), @@ -683,7 +816,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, const stationNm = stationNameById(selectedZone.stationId); const id = nextWorkId(idCounterRef); changeDraft((current) => - addDownlink(current, { + addDownlink(editId ? deleteWork(current, editId) : current, { id, startMs: zoneStartMsVal, endMs: zoneStopMsVal, @@ -692,6 +825,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, planModeId: savedDropId }) ); + setEditingWorkId(undefined); setSelectedWorkId(id); setRightMode("inspect"); setSelectedZone(undefined); @@ -761,6 +895,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, await refreshDraftModes(draftId); changeDraft((current) => ({ ...current, works: [] })); setSelectedWorkId(undefined); + setEditingWorkId(undefined); setRightMode("none"); setNotice("План полностью очищен."); } catch (error) { @@ -794,6 +929,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } changeDraft((current) => deleteWork(current, workId)); setSelectedWorkId(undefined); + if (editingWorkId === workId) setEditingWorkId(undefined); setRightMode("none"); }; @@ -967,13 +1103,9 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, conflicts={conflicts[work.id] ?? []} readOnly={work.origin === "plan"} canDelete={draftPlanId != null} - onUpdate={onChangeWork} + canEdit={!inPlaceEditBlockedReason} + onEdit={beginEditWork} onDelete={(workId) => { void handleDeleteWork(workId); }} - onDuplicate={(workId) => { - const id = nextWorkId(idCounterRef); - changeDraft((current) => duplicateWork(current, workId, id)); - setSelectedWorkId(id); - }} onClose={onClose} /> ); @@ -1018,6 +1150,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, onSetRightMode={(mode) => { setRightMode(mode); setSelectedWorkId(undefined); + setEditingWorkId(undefined); + // Новая съёмка начинается с прицеливания (точки ещё нет); прочие режимы — без перекрестия. + setTargetPickActive(mode === "shoot"); + setTarget(undefined); setMapInfoSelection(undefined); clearMapPick(); setNotice(undefined); @@ -1061,7 +1197,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, style={{ gridTemplateRows: `${(mapRatio * 100).toFixed(1)}% minmax(0, 1fr)` }} >
- {rightMode === "shoot" &&
Кликните точку съёмки на карте.
} + {rightMode === "shoot" && targetPickActive &&
Кликните точку съёмки на карте.
} {orbitPasses.length > 0 ? ( { // Опорная точка прицеливания — всегда одна: каждый клик заменяет // предыдущее перекрестие, цели не накапливаются. @@ -1099,6 +1235,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, setSelectedShootPass(undefined); setModeId(DEFAULT_EDITOR_MODES[0].id); setNotice(undefined); + // Прицеливание НЕ гасим: пока не выбран проход, точку можно свободно переставлять + // повторными кликами по карте. «Рука» включится при выборе строки прохождения. }} onObjectSelect={handleMapObjectSelect} focusPoint={mapFocus.point} @@ -1109,7 +1247,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setTargetPickActive(true)} addBlockedReason={inPlaceEditBlockedReason} onRouteStartChange={(startMs) => setRouteWindow((current) => (current ? { ...current, startMs } : current))} onRouteEndChange={(endMs) => setRouteWindow((current) => (current ? { ...current, endMs } : current))} @@ -1186,16 +1327,18 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, Number.isFinite(deg) ? Math.max(-MAX_ROUTE_ROLL_DEG, Math.min(MAX_ROUTE_ROLL_DEG, deg)) : current ) } - onSelectPass={setSelectedShootPass} + onSelectPass={(pass) => { + setSelectedShootPass(pass); + // Проход выбран — точка зафиксирована: выходим из прицеливания, карта снова «рука». + if (pass) setTargetPickActive(false); + }} onModeChange={setModeId} onAdd={addShootWork} onClose={() => { setRightMode("none"); + setEditingWorkId(undefined); setTarget(undefined); - setSelectedShootPass(undefined); - }} - onClearTarget={() => { - setTarget(undefined); + setTargetPickActive(false); setSelectedShootPass(undefined); }} /> @@ -1226,10 +1369,12 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } saving={dropSaving} saveError={dropError} + editing={editingWorkId != null} addBlockedReason={inPlaceEditBlockedReason} onAdd={addDropWork} onClose={() => { setRightMode("none"); + setEditingWorkId(undefined); setSelectedZone(undefined); setSelectedDropSurveyIds(new Set()); setDropError(undefined); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts index 5c133e6..041f186 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.test.ts @@ -4,7 +4,6 @@ import { addDownlink, addShoot, deleteWork, - duplicateWork, mutate, planModesToWorks, redo, @@ -155,7 +154,7 @@ describe("editorDraft", () => { expect(redone.future).toHaveLength(0); }); - it("updates, duplicates and deletes works without generating ids internally", () => { + it("updates and deletes works without generating ids internally", () => { const draft = addDownlink(seedDraft(plan), { id: "downlink-1", label: "Сброс на НКПОР", @@ -164,17 +163,9 @@ describe("editorDraft", () => { station: { id: "KPP-1", name: "KPP-1" } }); const updated = updateWork(draft, "downlink-1", { label: "Сеанс", startMs: plan.startMs + 60_000 }); - const duplicated = duplicateWork(updated, "downlink-1", "copy-1", 120_000); - const deleted = deleteWork(duplicated, "downlink-1"); + const deleted = deleteWork(updated, "downlink-1"); expect(updated.works[0].label).toBe("Сеанс"); - expect(duplicated.works[1]).toMatchObject({ - id: "copy-1", - startMs: updated.works[0].startMs + 120_000, - endMs: updated.works[0].endMs + 120_000, - origin: "added", - isNew: true - }); - expect(deleted.works.map((work) => work.id)).toEqual(["copy-1"]); + expect(deleted.works.map((work) => work.id)).toEqual([]); }); }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts index 962f2fd..280cc32 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorDraft.ts @@ -12,7 +12,6 @@ import type { EditorWorkPatch } from "./model/editorTypes"; -const DEFAULT_DUPLICATE_OFFSET_MS = 20 * 60 * 1000; const DEFAULT_HISTORY_LIMIT = 50; export type AddShootInput = { @@ -198,29 +197,6 @@ export function deleteWork(draft: Draft, workId: string): Draft { }; } -export function duplicateWork( - draft: Draft, - workId: string, - newId: string, - offsetMs = DEFAULT_DUPLICATE_OFFSET_MS -): Draft { - const source = draft.works.find((work) => work.id === workId); - if (!source) { - return draft; - } - - const duplicated: EditorWork = { - ...source, - id: newId, - startMs: source.startMs + offsetMs, - endMs: source.endMs + offsetMs, - origin: "added", - isNew: true - }; - - return { ...draft, works: [...draft.works, duplicated] }; -} - function readEmbeddedWorks(plan: TguPlanUi): EditorWork[] { const rawWorks = (plan as TguPlanUi & { works?: unknown }).works; if (!Array.isArray(rawWorks)) { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts index f56a6dc..99b3765 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/editorPresentation.ts @@ -48,6 +48,18 @@ export function formatDateTime(ms: number): string { return `${formatDate(ms)} ${formatTime(ms)}`; } +/** Время с секундами и одной десятой долей: ЧЧ:ММ:СС.с (локальное). */ +export function formatTimeSeconds(ms: number): string { + const date = new Date(ms); + const tenths = Math.floor((((ms % 1000) + 1000) % 1000) / 100); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${tenths}`; +} + +/** Дата и время с десятыми долями секунды: «дд мес ЧЧ:ММ:СС.с» (локальное). */ +export function formatDateTimeSeconds(ms: number): string { + return `${formatDate(ms)} ${formatTimeSeconds(ms)}`; +} + export function formatDuration(ms: number): string { const totalSeconds = Math.max(0, Math.round(ms / 1000)); if (totalSeconds < 60) { diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index eccbe4b..4230d3f 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -220,9 +220,11 @@ export function TguPlanningPage() { // Состав включений выбранного плана (счётчики съёмок/сбросов) для правой панели. // planId == mission_id, поэтому грузим режимы плана тем же эндпоинтом, что и редактор. + // Зависим и от activeTab: при возврате на вкладку «Планы» перечитываем состав, иначе + // счётчик маршрутов остаётся прежним после правок в редакторе (selectedPlanId не менялся). const [planModeCounts, setPlanModeCounts] = useState(); useEffect(() => { - if (!selectedPlanId) { + if (!selectedPlanId || activeTab !== "timeline") { setPlanModeCounts(undefined); return; } @@ -242,7 +244,7 @@ export function TguPlanningPage() { return () => { cancelled = true; }; - }, [selectedPlanId]); + }, [selectedPlanId, activeTab]); const applyRange = () => { setDecisionNotice(undefined); diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index f64d496..bf9e6de 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -863,10 +863,16 @@ .tgu-editor-panel__actions { display: flex; align-items: center; - justify-content: space-between; + flex-wrap: wrap; gap: 0.6rem; } +.tgu-editor-panel__actions > .tgu-button { + flex: 1 1 0; + min-width: 0; + white-space: nowrap; +} + .tgu-editor-panel__head h2 { margin: 0; font-size: 0.95rem;