Незакоммиченная работа: UI заявок/планирования и правки сервисов
- pcp-tgu-ops-ui: вкладка «Заявки», редактор планирования, утилиты uuid и e2e-verify скрипты, обновление зависимостей - pcp-ballistics-service, pcp-mission-planing-service: текущие правки
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = "http://localhost:5174/";
|
||||
const results = [];
|
||||
const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); };
|
||||
const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); };
|
||||
|
||||
const b = await chromium.launch({ headless: true });
|
||||
const p = await (await b.newContext({ viewport: { width: 1600, height: 900 } })).newPage();
|
||||
const consoleErrors = [];
|
||||
p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
|
||||
p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message));
|
||||
|
||||
try {
|
||||
await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 });
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
// выбрать КА 56756 и открыть редактор
|
||||
await p.getByText("NORAD 56756", { exact: false }).first().click();
|
||||
await p.waitForTimeout(500);
|
||||
await p.getByRole("tab", { name: "Редактор", exact: true }).click();
|
||||
await p.waitForTimeout(700);
|
||||
|
||||
// контролы редактора
|
||||
const shoot = p.getByRole("button", { name: "+ Съёмка", exact: true });
|
||||
const drop = p.getByRole("button", { name: "+ Сброс", exact: true });
|
||||
(await shoot.count()) > 0 && (await drop.count()) > 0
|
||||
? pass("editor:controls", "+ Съёмка / + Сброс присутствуют")
|
||||
: fail("editor:controls");
|
||||
|
||||
// режим «Сброс» -> ЗРВ реальных станций
|
||||
await drop.click();
|
||||
const rva = await p.waitForResponse((r) => /\/api\/satellites\/\d+\/rva/.test(r.url()), { timeout: 15000 }).catch(() => null);
|
||||
await p.waitForTimeout(1000);
|
||||
rva && rva.status() === 200
|
||||
? pass("editor:drop-fetches-rva", `GET rva ${rva.status()}`)
|
||||
: fail("editor:drop-fetches-rva", rva ? `status ${rva.status()}` : "не вызван");
|
||||
|
||||
const panelText = (await p.locator("aside, [class*='panel']").allInnerTexts().catch(() => [])).join(" ").replace(/\n+/g, " ");
|
||||
const stationNames = ["Шпицберген", "Отрадное", "Байконур", "Ханты-Мансийск"];
|
||||
const found = stationNames.filter((s) => panelText.includes(s));
|
||||
found.length >= 2
|
||||
? pass("editor:drop-real-stations", `зоны по станциям: ${found.join(", ")}`)
|
||||
: fail("editor:drop-real-stations", `найдено станций: ${JSON.stringify(found)}`);
|
||||
// зоны содержат виток + время (UTC-окно)
|
||||
/виток\s*\d+/i.test(panelText) && /\d{2}:\d{2}[–-]\d{2}:\d{2}/.test(panelText)
|
||||
? pass("editor:drop-zone-format", "виток + временное окно показаны")
|
||||
: fail("editor:drop-zone-format", panelText.slice(0, 200));
|
||||
|
||||
consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8)));
|
||||
} catch (e) {
|
||||
fail("SCRIPT", e.message);
|
||||
console.log(e.stack);
|
||||
} finally {
|
||||
const npass = results.filter((r) => r[0] === "PASS").length;
|
||||
const nfail = results.filter((r) => r[0] === "FAIL").length;
|
||||
console.log(`\n=== EDITOR SUMMARY: ${npass} PASS / ${nfail} FAIL ===`);
|
||||
await b.close();
|
||||
process.exit(nfail > 0 ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Проверка keep-alive: состояние вкладок не сбрасывается при переключении.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = "http://localhost:5174/";
|
||||
const results = [];
|
||||
const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); };
|
||||
const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); };
|
||||
|
||||
const b = await chromium.launch({ headless: true });
|
||||
const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } });
|
||||
const p = await ctx.newPage();
|
||||
const consoleErrors = [];
|
||||
p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
|
||||
p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message));
|
||||
|
||||
const tab = (name) => p.getByRole("tab", { name, exact: true });
|
||||
// только видимая (активная) вкладка — карта присутствует и в Заявках (рисование полигона)
|
||||
const mapToggle = (label) => p.locator(".tgu-map-toolbar__toggles button:visible", { hasText: label });
|
||||
const isActive = async (loc) => ((await loc.getAttribute("class")) || "").includes("is-active");
|
||||
|
||||
try {
|
||||
await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 });
|
||||
await p.waitForTimeout(600);
|
||||
|
||||
// --- Карта: меняем UI-состояние (выключаем слой «Станции») ---
|
||||
await tab("Карта").click();
|
||||
await p.waitForTimeout(500);
|
||||
const stationsToggle = mapToggle("Станции");
|
||||
(await isActive(stationsToggle)) ? pass("map:stations-on-initially") : fail("map:stations-on-initially");
|
||||
await stationsToggle.click();
|
||||
await p.waitForTimeout(150);
|
||||
(await isActive(stationsToggle)) === false
|
||||
? pass("map:stations-toggled-off")
|
||||
: fail("map:stations-toggled-off");
|
||||
|
||||
// --- Заявки: открываем форму создания (внутреннее состояние panelMode) ---
|
||||
await tab("Заявки").click();
|
||||
await p.waitForTimeout(400);
|
||||
await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click();
|
||||
await p.waitForTimeout(250);
|
||||
(await p.locator(".tgu-requests-form").count()) > 0
|
||||
? pass("requests:create-form-open")
|
||||
: fail("requests:create-form-open");
|
||||
|
||||
// --- уходим на Планы и возвращаемся ---
|
||||
await tab("Планы").click();
|
||||
await p.waitForTimeout(300);
|
||||
|
||||
// Карта: слой «Станции» по-прежнему выключен?
|
||||
await tab("Карта").click();
|
||||
await p.waitForTimeout(400);
|
||||
(await isActive(mapToggle("Станции"))) === false
|
||||
? pass("map:state-preserved", "слой «Станции» остался выключен")
|
||||
: fail("map:state-preserved", "состояние слоёв сбросилось");
|
||||
|
||||
// Заявки: форма создания всё ещё открыта?
|
||||
await tab("Заявки").click();
|
||||
await p.waitForTimeout(400);
|
||||
(await p.locator(".tgu-requests-form").count()) > 0
|
||||
? pass("requests:state-preserved", "форма создания осталась открытой")
|
||||
: fail("requests:state-preserved", "форма закрылась (состояние сброшено)");
|
||||
|
||||
// Скрытые вкладки не должны размонтироваться: в DOM присутствуют сразу несколько <main>
|
||||
await tab("Планы").click();
|
||||
await p.waitForTimeout(200);
|
||||
const mains = await p.locator(".tgu-app-shell main").count();
|
||||
mains >= 3
|
||||
? pass("keep-alive:tabs-mounted", `<main> в DOM: ${mains}`)
|
||||
: fail("keep-alive:tabs-mounted", `ожидалось >=3, найдено ${mains}`);
|
||||
|
||||
} catch (e) {
|
||||
fail("EXCEPTION", e.message);
|
||||
} finally {
|
||||
if (consoleErrors.length) console.log("CONSOLE_ERRORS", JSON.stringify(consoleErrors, null, 2));
|
||||
const failed = results.filter((r) => r[0] === "FAIL").length;
|
||||
console.log(`\n=== ${results.length - failed}/${results.length} PASS, ${failed} FAIL ===`);
|
||||
await b.close();
|
||||
process.exit(failed ? 1 : 0);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = "http://localhost:5174/";
|
||||
const results = [];
|
||||
const pass = (n, d = "") => { results.push(["PASS", n, d]); console.log("PASS ::", n, d); };
|
||||
const fail = (n, d = "") => { results.push(["FAIL", n, d]); console.log("FAIL ::", n, d); };
|
||||
|
||||
const b = await chromium.launch({ headless: true });
|
||||
const ctx = await b.newContext({ viewport: { width: 1600, height: 900 } });
|
||||
const p = await ctx.newPage();
|
||||
|
||||
const api = [];
|
||||
p.on("request", (r) => { if (r.url().includes("/api/")) api.push({ m: r.method(), u: r.url() }); });
|
||||
const apiResp = [];
|
||||
p.on("response", (r) => { if (r.url().includes("/api/")) apiResp.push({ m: r.request().method(), u: r.url(), s: r.status() }); });
|
||||
const consoleErrors = [];
|
||||
p.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); });
|
||||
p.on("pageerror", (e) => consoleErrors.push("PAGEERROR: " + e.message));
|
||||
// авто-принятие window.confirm (для удаления)
|
||||
p.on("dialog", (d) => d.accept());
|
||||
|
||||
const tab = (name) => p.getByRole("tab", { name, exact: true });
|
||||
|
||||
try {
|
||||
await p.goto(BASE, { waitUntil: "networkidle", timeout: 30000 });
|
||||
await p.waitForTimeout(800);
|
||||
|
||||
// ---- A: 4 вкладки ----
|
||||
for (const t of ["Планы", "Карта", "Редактор", "Заявки"]) {
|
||||
(await tab(t).count()) > 0 ? pass(`tab:${t}`) : fail(`tab:${t}`, "не найдена");
|
||||
}
|
||||
|
||||
// ---- B: Карта — дефолтные слои ----
|
||||
await tab("Карта").click();
|
||||
await p.waitForTimeout(600);
|
||||
const toggles = p.locator(".tgu-map-toolbar__toggles button");
|
||||
const n = await toggles.count();
|
||||
const labels = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const el = toggles.nth(i);
|
||||
labels.push({ t: (await el.innerText()).trim(), active: (await el.getAttribute("class") || "").includes("is-active") });
|
||||
}
|
||||
console.log("LAYER_TOGGLES", JSON.stringify(labels));
|
||||
const wanted = ["Работы плана", "Станции", "Заявки на съёмку"];
|
||||
const got = labels.map((l) => l.t);
|
||||
JSON.stringify(got) === JSON.stringify(wanted)
|
||||
? pass("map:layers-picker", "ровно planWorks/stations/requests")
|
||||
: fail("map:layers-picker", `got=${JSON.stringify(got)}`);
|
||||
labels.every((l) => l.active)
|
||||
? pass("map:layers-default-on", "все 3 активны")
|
||||
: fail("map:layers-default-on", JSON.stringify(labels));
|
||||
const forbidden = got.some((t) => /трек|track|полос|swath|маркер|spacecraft/i.test(t));
|
||||
forbidden ? fail("map:no-tracks-swath-markers", "найден лишний слой") : pass("map:no-tracks-swath-markers");
|
||||
|
||||
// ---- C: Заявки — загрузка ----
|
||||
await tab("Заявки").click();
|
||||
await p.waitForTimeout(400);
|
||||
const before = apiResp.length;
|
||||
await p.getByRole("button", { name: "Загрузить", exact: true }).click();
|
||||
await p.waitForResponse((r) => r.url().includes("/v1/requests/map"), { timeout: 15000 }).catch(() => {});
|
||||
await p.waitForTimeout(800);
|
||||
const mapCall = apiResp.find((r) => r.u.includes("/v1/requests/map"));
|
||||
mapCall && mapCall.s === 200 ? pass("requests:load", `GET map ${mapCall.s}`) : fail("requests:load", JSON.stringify(mapCall));
|
||||
const subtitle = await p.locator(".tgu-map-toolbar__subtitle").first().innerText().catch(() => "");
|
||||
console.log("REQUESTS_SUBTITLE", subtitle);
|
||||
|
||||
// ---- D: создание + валидации ----
|
||||
await p.getByRole("button", { name: "+ Создать заявку", exact: true }).click();
|
||||
await p.waitForTimeout(300);
|
||||
const form = p.locator(".tgu-requests-form");
|
||||
const submitBtn = form.getByRole("button", { name: "Создать заявку", exact: true });
|
||||
const alert = form.locator(".tgu-alert--danger");
|
||||
|
||||
// d1: до рисования — кнопка disabled, подсказка про полигон
|
||||
(await submitBtn.isDisabled()) ? pass("create:disabled-before-draw") : fail("create:disabled-before-draw");
|
||||
console.log("ALERT_INITIAL", await alert.innerText().catch(() => "(нет)"));
|
||||
|
||||
// d2: рисуем полигон 3 кликами по карте
|
||||
// :visible — keep-alive держит смонтированными карты сразу нескольких вкладок
|
||||
const map = p.locator(".tgu-2d-map:visible");
|
||||
const box = await map.boundingBox();
|
||||
const pts = [
|
||||
{ x: box.width * 0.45, y: box.height * 0.40 },
|
||||
{ x: box.width * 0.60, y: box.height * 0.40 },
|
||||
{ x: box.width * 0.52, y: box.height * 0.55 }
|
||||
];
|
||||
for (const pt of pts) { await map.click({ position: pt }); await p.waitForTimeout(150); }
|
||||
const vtext = await form.locator(".tgu-requests-form__geo-status").innerText().catch(() => "");
|
||||
console.log("VERTEX_TEXT", vtext);
|
||||
/Вершин:\s*3/.test(vtext) ? pass("create:3-vertices") : fail("create:3-vertices", vtext);
|
||||
await form.getByRole("button", { name: "Завершить", exact: true }).click();
|
||||
await p.waitForTimeout(300);
|
||||
|
||||
// d3: имя пустое -> валидация
|
||||
let a = await alert.innerText().catch(() => "");
|
||||
/название/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:empty-name", a) : fail("validate:empty-name", a);
|
||||
|
||||
// заполняем имя
|
||||
await form.getByPlaceholder("Название задания").fill("E2E-VERIFY");
|
||||
await p.waitForTimeout(200);
|
||||
|
||||
// d4: оба блока выключены
|
||||
const opticsCb = form.locator("label.tgu-requests-form__block-toggle", { hasText: "Оптическая" }).getByRole("checkbox");
|
||||
await opticsCb.uncheck();
|
||||
await p.waitForTimeout(200);
|
||||
a = await alert.innerText().catch(() => "");
|
||||
/хотя бы один блок/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:no-optics-no-rsa", a) : fail("validate:no-optics-no-rsa", a);
|
||||
await opticsCb.check();
|
||||
await p.waitForTimeout(200);
|
||||
|
||||
// d5: from >= to
|
||||
const begin = form.locator('input[type="datetime-local"]').nth(0);
|
||||
const end = form.locator('input[type="datetime-local"]').nth(1);
|
||||
const beginVal = await begin.inputValue();
|
||||
await end.fill("2000-01-01T00:00");
|
||||
await p.waitForTimeout(200);
|
||||
a = await alert.innerText().catch(() => "");
|
||||
/раньше конца/i.test(a) && (await submitBtn.isDisabled()) ? pass("validate:from-after-to", a) : fail("validate:from-after-to", a);
|
||||
// восстановить корректный конец
|
||||
await end.fill("2100-01-01T00:00");
|
||||
await p.waitForTimeout(200);
|
||||
|
||||
// d6: submit -> POST 201 + новая заявка
|
||||
(await submitBtn.isEnabled()) ? pass("create:enabled-when-valid") : fail("create:enabled-when-valid");
|
||||
const postBefore = apiResp.length;
|
||||
await submitBtn.click();
|
||||
const postResp = await p.waitForResponse((r) => r.url().includes("/v1/requests") && r.request().method() === "POST", { timeout: 15000 }).catch(() => null);
|
||||
await p.waitForTimeout(1000);
|
||||
if (postResp && (postResp.status() === 201 || postResp.status() === 200)) {
|
||||
pass("create:POST", `status ${postResp.status()}`);
|
||||
// проверим тело запроса: WKT lon lat + id
|
||||
const reqBody = postResp.request().postDataJSON?.() || JSON.parse(postResp.request().postData() || "{}");
|
||||
const wktOk = /POLYGON\s*\(\(/.test(reqBody.geometry || "");
|
||||
const idOk = /^[0-9a-f-]{36}$/i.test(reqBody.id || "");
|
||||
wktOk && idOk ? pass("create:payload", `wkt+uuid ok`) : fail("create:payload", JSON.stringify({ id: reqBody.id, geo: (reqBody.geometry||"").slice(0,30) }));
|
||||
console.log("CREATE_WKT", (reqBody.geometry || "").slice(0, 80));
|
||||
} else {
|
||||
fail("create:POST", postResp ? `status ${postResp.status()}` : "нет ответа");
|
||||
}
|
||||
const noticeTxt = await p.locator(".tgu-alert--success").innerText().catch(() => "");
|
||||
/создана/i.test(noticeTxt) ? pass("create:notice", noticeTxt) : fail("create:notice", noticeTxt || "(нет)");
|
||||
|
||||
// ---- E: детали + удаление ----
|
||||
// клик по нарисованному полигону -> панель деталей. Свипаем точки внутри треугольника,
|
||||
// т.к. точное соответствие пиксель↔гео зависит от проекции.
|
||||
const delBtn = p.getByRole("button", { name: /Удалить заявку/, exact: false });
|
||||
const rows = p.locator(".tgu-map-request-row");
|
||||
const detailsHeading = p.locator("aside .tgu-panel-heading span", { hasText: "Заявки в точке" });
|
||||
const tip = p.locator(".tgu-2d-map__tooltip");
|
||||
// hover-свип: ищем пиксель, где появляется тултип нашей заявки (надёжно к проекции/виду)
|
||||
const mbox = await map.boundingBox();
|
||||
let target = null;
|
||||
outer:
|
||||
for (let gx = 0.40; gx <= 0.65; gx += 0.02) {
|
||||
for (let gy = 0.35; gy <= 0.58; gy += 0.02) {
|
||||
await p.mouse.move(mbox.x + mbox.width * gx, mbox.y + mbox.height * gy);
|
||||
await p.waitForTimeout(25);
|
||||
if ((await tip.count()) > 0 && /E2E-VERIFY/.test(await tip.innerText().catch(() => ""))) {
|
||||
target = { x: mbox.width * gx, y: mbox.height * gy };
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
let opened = false;
|
||||
if (target) {
|
||||
await map.click({ position: target });
|
||||
await p.waitForTimeout(400);
|
||||
if ((await delBtn.count()) > 0 || (await detailsHeading.count()) > 0) opened = true;
|
||||
}
|
||||
if (opened) {
|
||||
// если в точке несколько заявок — выбрать строку, чтобы появилась кнопка удаления
|
||||
if ((await delBtn.count()) === 0 && (await rows.count()) > 0) {
|
||||
await rows.first().click();
|
||||
await p.waitForTimeout(300);
|
||||
}
|
||||
pass("details:open", `панель деталей открылась (строк в точке: ${await rows.count() || 1})`);
|
||||
|
||||
const initialDel = p.getByRole("button", { name: "Удалить заявку", exact: true });
|
||||
const confirmTitle = p.locator(".tgu-requests-confirm__title", { hasText: "Удалить заявку?" });
|
||||
const yesBtn = p.getByRole("button", { name: "Да, удалить", exact: true });
|
||||
const noBtn = p.getByRole("button", { name: "Нет, не удалять", exact: true });
|
||||
|
||||
// шаг 1: клик «Удалить заявку» -> появляется панель подтверждения с Да/Нет
|
||||
await initialDel.first().click();
|
||||
await p.waitForTimeout(250);
|
||||
(await confirmTitle.count()) > 0 && (await yesBtn.count()) > 0 && (await noBtn.count()) > 0
|
||||
? pass("delete:confirm-panel", "панель «Удалить заявку?» с «Да, удалить»/«Нет, не удалять»")
|
||||
: fail("delete:confirm-panel", `title=${await confirmTitle.count()} yes=${await yesBtn.count()} no=${await noBtn.count()}`);
|
||||
|
||||
// «Нет, не удалять» — не шлёт DELETE и закрывает подтверждение
|
||||
const delCallsBefore = apiResp.filter((r) => r.m === "DELETE").length;
|
||||
await noBtn.click();
|
||||
await p.waitForTimeout(300);
|
||||
(await confirmTitle.count()) === 0 && apiResp.filter((r) => r.m === "DELETE").length === delCallsBefore
|
||||
? pass("delete:cancel", "«Нет, не удалять» отменяет без DELETE")
|
||||
: fail("delete:cancel");
|
||||
|
||||
// «Да, удалить» -> DELETE
|
||||
await initialDel.first().click();
|
||||
await p.waitForTimeout(200);
|
||||
await yesBtn.click();
|
||||
const delResp = await p.waitForResponse((r) => r.url().includes("/v1/requests/") && r.request().method() === "DELETE", { timeout: 15000 }).catch(() => null);
|
||||
await p.waitForTimeout(800);
|
||||
delResp && (delResp.status() === 200 || delResp.status() === 204)
|
||||
? pass("delete:DELETE", `status ${delResp.status()}`)
|
||||
: fail("delete:DELETE", delResp ? `status ${delResp.status()}` : "нет DELETE-запроса");
|
||||
} else {
|
||||
fail("details:open", "панель деталей не открылась (не попал по полигону)");
|
||||
}
|
||||
|
||||
// ---- console errors ----
|
||||
consoleErrors.length === 0 ? pass("no-console-errors") : fail("no-console-errors", JSON.stringify(consoleErrors.slice(0, 8)));
|
||||
|
||||
} catch (e) {
|
||||
fail("SCRIPT", e.message);
|
||||
console.log(e.stack);
|
||||
} finally {
|
||||
console.log("\n=== API CALLS (последние 25) ===");
|
||||
for (const r of apiResp.slice(-25)) console.log(r.s, r.m, r.u.replace("http://localhost:5174", ""));
|
||||
const npass = results.filter((r) => r[0] === "PASS").length;
|
||||
const nfail = results.filter((r) => r[0] === "FAIL").length;
|
||||
console.log(`\n=== SUMMARY: ${npass} PASS / ${nfail} FAIL ===`);
|
||||
await b.close();
|
||||
process.exit(nfail > 0 ? 1 : 0);
|
||||
}
|
||||
+45
@@ -18,6 +18,7 @@
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"jsdom": "^27.2.0",
|
||||
"playwright": "^1.60.0",
|
||||
"vitest": "^4.0.14"
|
||||
}
|
||||
},
|
||||
@@ -1937,6 +1938,50 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
|
||||
@@ -11,15 +11,16 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"vite": "^7.2.4",
|
||||
"typescript": "^5.9.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"jsdom": "^27.2.0",
|
||||
"playwright": "^1.60.0",
|
||||
"vitest": "^4.0.14"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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