Незакоммиченная работа: UI заявок/планирования и правки сервисов
- pcp-tgu-ops-ui: вкладка «Заявки», редактор планирования, утилиты uuid и e2e-verify скрипты, обновление зависимостей - pcp-ballistics-service, pcp-mission-planing-service: текущие правки
This commit is contained in:
@@ -28,6 +28,11 @@ export function TguPlanningPage() {
|
||||
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
||||
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
|
||||
// и дальше держим смонтированной (скрытой через display:none), чтобы её
|
||||
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
|
||||
// при переключении между вкладками.
|
||||
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<TguActiveTab>>(() => new Set(["timeline"]));
|
||||
const [decisionInFlight, setDecisionInFlight] = useState(false);
|
||||
const [decisionNotice, setDecisionNotice] = useState<string>();
|
||||
const [decisionError, setDecisionError] = useState<string>();
|
||||
@@ -80,6 +85,15 @@ export function TguPlanningPage() {
|
||||
void loadData(appliedRange);
|
||||
}, [appliedRange, loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
|
||||
}, [activeTab]);
|
||||
|
||||
// display:contents для активной вкладки сохраняет grid-раскладку shell
|
||||
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
|
||||
const tabStyle = (tab: TguActiveTab) =>
|
||||
({ display: activeTab === tab ? "contents" : "none" }) as const;
|
||||
|
||||
const platforms = useMemo(
|
||||
() => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
|
||||
[rawPlatforms, plans, appliedRange]
|
||||
@@ -152,7 +166,7 @@ export function TguPlanningPage() {
|
||||
/>
|
||||
}
|
||||
>
|
||||
{activeTab === "timeline" ? (
|
||||
<div style={tabStyle("timeline")}>
|
||||
<main className="tgu-workspace">
|
||||
<TguSidebar
|
||||
platforms={platforms}
|
||||
@@ -190,26 +204,38 @@ export function TguPlanningPage() {
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
</main>
|
||||
) : activeTab === "map" ? (
|
||||
<Tgu2DMapTab
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
selectedPlan={selectedPlan}
|
||||
platforms={platforms}
|
||||
/>
|
||||
) : activeTab === "requests" ? (
|
||||
<TguRequestsTab
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
platforms={platforms}
|
||||
/>
|
||||
) : (
|
||||
<TguEditorTab
|
||||
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
appliedRange={appliedRange}
|
||||
platforms={platforms}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{visitedTabs.has("map") && (
|
||||
<div style={tabStyle("map")}>
|
||||
<Tgu2DMapTab
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
selectedPlan={selectedPlan}
|
||||
platforms={platforms}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visitedTabs.has("requests") && (
|
||||
<div style={tabStyle("requests")}>
|
||||
<TguRequestsTab
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
platforms={platforms}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visitedTabs.has("editor") && (
|
||||
<div style={tabStyle("editor")}>
|
||||
<TguEditorTab
|
||||
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
|
||||
selectedPlan={selectedPlan}
|
||||
appliedRange={appliedRange}
|
||||
platforms={platforms}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TguPlanningLayout>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
|
||||
|
||||
type Props = {
|
||||
@@ -27,6 +28,13 @@ export function RequestDetailsPanel({
|
||||
? requests[0]
|
||||
: undefined;
|
||||
|
||||
// Второй шаг подтверждения удаления. Сбрасываем при смене выбранной заявки,
|
||||
// чтобы открытый запрос на удаление не «переезжал» на другую заявку.
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
useEffect(() => {
|
||||
setConfirmingDelete(false);
|
||||
}, [selectedEntry?.item.id]);
|
||||
|
||||
return (
|
||||
<aside className="tgu-map-requests-panel">
|
||||
<div className="tgu-panel-heading">
|
||||
@@ -104,20 +112,41 @@ export function RequestDetailsPanel({
|
||||
|
||||
{deleteError && <div className="tgu-alert tgu-alert--danger tgu-alert--compact">{deleteError}</div>}
|
||||
|
||||
<div className="tgu-requests-form__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--reject"
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Удалить заявку «${selectedEntry.item.name || selectedEntry.item.id}»?`)) {
|
||||
onDelete(selectedEntry.item.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{deleting ? "Удаление..." : "Удалить заявку"}
|
||||
</button>
|
||||
</div>
|
||||
{confirmingDelete ? (
|
||||
<div className="tgu-requests-confirm">
|
||||
<div className="tgu-requests-confirm__title">Удалить заявку?</div>
|
||||
<div className="tgu-requests-confirm__subtitle">{selectedEntry.item.name || selectedEntry.item.id}</div>
|
||||
<div className="tgu-requests-form__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--reject"
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => onDelete(selectedEntry.item.id)}
|
||||
>
|
||||
{deleting ? "Удаление..." : "Да, удалить"}
|
||||
</button>
|
||||
<button
|
||||
className="tgu-button"
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDelete(false)}
|
||||
>
|
||||
Нет, не удалять
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="tgu-requests-form__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--reject"
|
||||
type="button"
|
||||
disabled={deleting}
|
||||
onClick={() => setConfirmingDelete(true)}
|
||||
>
|
||||
Удалить заявку
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||
import type { RequestAtPoint } from "../tgu-map-2d/Tgu2DMapRequestsPanel";
|
||||
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
|
||||
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
|
||||
import { generateUuid } from "../../utils/uuid";
|
||||
import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel";
|
||||
import { RequestDetailsPanel } from "./RequestDetailsPanel";
|
||||
import { polygonToWkt } from "./requestGeometry";
|
||||
@@ -195,7 +196,7 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
|
||||
setCreating(true);
|
||||
setCreateError(undefined);
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
const id = generateUuid();
|
||||
await createRequest({
|
||||
id,
|
||||
name: form.name,
|
||||
|
||||
@@ -1319,6 +1319,27 @@
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.tgu-requests-confirm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid rgba(229, 115, 115, 0.45);
|
||||
border-radius: 6px;
|
||||
background: rgba(229, 115, 115, 0.08);
|
||||
}
|
||||
|
||||
.tgu-requests-confirm__title {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tgu-requests-confirm__subtitle {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.8rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tgu-requests-form__hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.75rem;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { generateUuid, uuidV4Fallback } from "./uuid";
|
||||
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
describe("generateUuid", () => {
|
||||
it("возвращает валидный UUID v4", () => {
|
||||
expect(generateUuid()).toMatch(UUID_V4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uuidV4Fallback (insecure context)", () => {
|
||||
it("даёт валидный UUID v4 без crypto.randomUUID", () => {
|
||||
expect(uuidV4Fallback()).toMatch(UUID_V4);
|
||||
});
|
||||
|
||||
it("генерирует разные значения", () => {
|
||||
expect(uuidV4Fallback()).not.toBe(uuidV4Fallback());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Генерация UUID v4.
|
||||
*
|
||||
* `crypto.randomUUID()` доступен только в secure context (https или localhost),
|
||||
* поэтому при открытии фронта по сетевому адресу (http://<ip>:5174) он отсутствует
|
||||
* и падает с «crypto.randomUUID is not a function». `crypto.getRandomValues`, наоборот,
|
||||
* доступен и в insecure context — используем его как фолбэк (RFC 4122 v4).
|
||||
*/
|
||||
export function generateUuid(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return uuidV4Fallback();
|
||||
}
|
||||
|
||||
/** RFC 4122 v4 на основе crypto.getRandomValues (работает в insecure context). */
|
||||
export function uuidV4Fallback(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // версия 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10xx
|
||||
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0"));
|
||||
return (
|
||||
hex.slice(0, 4).join("") +
|
||||
"-" +
|
||||
hex.slice(4, 6).join("") +
|
||||
"-" +
|
||||
hex.slice(6, 8).join("") +
|
||||
"-" +
|
||||
hex.slice(8, 10).join("") +
|
||||
"-" +
|
||||
hex.slice(10, 16).join("")
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user