feat: add Guanghan module detail modal
Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
@@ -153,10 +153,93 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.modal-open { overflow: hidden; }
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(2, 7, 14, 0.82);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop[aria-hidden="false"] { display: flex; }
|
||||||
|
|
||||||
|
.detail-dialog {
|
||||||
|
position: relative;
|
||||||
|
width: min(760px, 100%);
|
||||||
|
max-height: min(820px, calc(100vh - 48px));
|
||||||
|
overflow: auto;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #405572;
|
||||||
|
border-radius: 18px;
|
||||||
|
outline: none;
|
||||||
|
background: linear-gradient(145deg, #13233a, #0b1424 72%);
|
||||||
|
box-shadow: 0 30px 100px rgba(0, 0, 0, 0.64);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-dialog:focus-visible { box-shadow: 0 0 0 3px var(--pressurized), 0 30px 100px rgba(0, 0, 0, 0.64); }
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1px solid #4e627e;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #182a43;
|
||||||
|
color: var(--text);
|
||||||
|
font: 500 1.65rem/1 system-ui, sans-serif;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover,
|
||||||
|
.modal-close:focus-visible { border-color: var(--pressurized); outline: none; background: #203754; }
|
||||||
|
|
||||||
|
.detail-kicker {
|
||||||
|
margin: 0 46px 6px 0;
|
||||||
|
color: var(--pressurized);
|
||||||
|
font-size: 0.77rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
#detail-title { margin: 0 50px 10px 0; font-size: 1.85rem; }
|
||||||
|
#detail-role { margin: 0 0 22px; color: #c4d2e4; line-height: 1.7; }
|
||||||
|
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid #293c57;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(8, 18, 33, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-grid dt { margin-bottom: 7px; color: #7f94b1; font-size: 0.75rem; }
|
||||||
|
.detail-grid dd { margin: 0; color: #e1ebf8; line-height: 1.55; overflow-wrap: anywhere; }
|
||||||
|
.detail-grid dd span { display: block; }
|
||||||
|
.detail-grid dd span + span { margin-top: 4px; }
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
body { padding: 14px; }
|
body { padding: 14px; }
|
||||||
.topology-map { min-width: 980px; }
|
.topology-map { min-width: 980px; }
|
||||||
.legend { justify-content: flex-start; }
|
.legend { justify-content: flex-start; }
|
||||||
|
.modal-backdrop { padding: 12px; }
|
||||||
|
.detail-dialog { padding: 25px 18px 20px; max-height: calc(100vh - 24px); }
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -331,6 +414,23 @@
|
|||||||
<p class="click-note">模块与车辆支持详情查看;走道、管线和散热面板仅用于表达拓扑关系。</p>
|
<p class="click-note">模块与车辆支持详情查看;走道、管线和散热面板仅用于表达拓扑关系。</p>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="detail-modal" aria-hidden="true">
|
||||||
|
<section class="detail-dialog" role="dialog" aria-modal="true" aria-labelledby="detail-title" tabindex="-1">
|
||||||
|
<button class="modal-close" type="button" aria-label="关闭详情">×</button>
|
||||||
|
<p class="detail-kicker" id="detail-type"></p>
|
||||||
|
<h2 id="detail-title"></h2>
|
||||||
|
<p id="detail-role"></p>
|
||||||
|
<dl class="detail-grid">
|
||||||
|
<div><dt>安装位置</dt><dd id="detail-location"></dd></div>
|
||||||
|
<div><dt>运行状态</dt><dd id="detail-status"></dd></div>
|
||||||
|
<div><dt>关键参数</dt><dd id="detail-metrics"></dd></div>
|
||||||
|
<div><dt>尺寸</dt><dd id="detail-dimensions"></dd></div>
|
||||||
|
<div><dt>连接关系</dt><dd id="detail-connections"></dd></div>
|
||||||
|
<div><dt>任务节点</dt><dd id="detail-mission"></dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const MODULE_DETAILS = {
|
const MODULE_DETAILS = {
|
||||||
"core": {
|
"core": {
|
||||||
@@ -488,6 +588,61 @@
|
|||||||
mission: "BL-03 · 2024-09-23首次着陆;GHC-05转运广寒03模块"
|
mission: "BL-03 · 2024-09-23首次着陆;GHC-05转运广寒03模块"
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const modal = document.querySelector("#detail-modal");
|
||||||
|
const dialog = modal.querySelector(".detail-dialog");
|
||||||
|
const closeButton = modal.querySelector(".modal-close");
|
||||||
|
let opener = null;
|
||||||
|
|
||||||
|
function renderLines(element, items) {
|
||||||
|
element.replaceChildren(...items.map((item) => {
|
||||||
|
const line = document.createElement("span");
|
||||||
|
line.textContent = item;
|
||||||
|
return line;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetails(moduleId, trigger) {
|
||||||
|
const detail = MODULE_DETAILS[moduleId];
|
||||||
|
if (!detail) return;
|
||||||
|
opener = trigger;
|
||||||
|
document.querySelector("#detail-type").textContent = detail.type;
|
||||||
|
document.querySelector("#detail-title").textContent = detail.name;
|
||||||
|
document.querySelector("#detail-role").textContent = detail.role;
|
||||||
|
document.querySelector("#detail-location").textContent = detail.location;
|
||||||
|
document.querySelector("#detail-status").textContent = detail.status;
|
||||||
|
renderLines(document.querySelector("#detail-metrics"), detail.metrics);
|
||||||
|
renderLines(document.querySelector("#detail-dimensions"), detail.dimensions);
|
||||||
|
renderLines(document.querySelector("#detail-connections"), detail.connections);
|
||||||
|
document.querySelector("#detail-mission").textContent = detail.mission;
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
document.body.classList.add("modal-open");
|
||||||
|
dialog.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetails() {
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
document.body.classList.remove("modal-open");
|
||||||
|
opener?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-module]").forEach((node) => {
|
||||||
|
node.addEventListener("click", () => openDetails(node.dataset.module, node));
|
||||||
|
node.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
openDetails(node.dataset.module, node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
closeButton.addEventListener("click", closeDetails);
|
||||||
|
modal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === modal) closeDetails();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape" && modal.getAttribute("aria-hidden") === "false") closeDetails();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import functools
|
||||||
|
import threading
|
||||||
|
import unittest
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def local_server():
|
||||||
|
handler = functools.partial(SimpleHTTPRequestHandler, directory=str(ROOT))
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
try:
|
||||||
|
yield f"http://127.0.0.1:{server.server_port}"
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
class GuanghanTopologyE2ETests(unittest.TestCase):
|
||||||
|
def test_rendered_topology_and_modal_interactions(self) -> None:
|
||||||
|
async def scenario(base_url: str) -> None:
|
||||||
|
async with async_playwright() as pw:
|
||||||
|
browser = await pw.chromium.launch(headless=True)
|
||||||
|
page = await browser.new_page(viewport={"width": 1440, "height": 1100})
|
||||||
|
errors: list[str] = []
|
||||||
|
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||||
|
await page.goto(
|
||||||
|
f"{base_url}/data/wiki/guanghan_topology_demo.html",
|
||||||
|
wait_until="networkidle",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(await page.locator("[data-module]").count(), 14)
|
||||||
|
self.assertEqual(await page.locator('[data-connection="pressurized"]').count(), 2)
|
||||||
|
strokes = await page.locator('[data-connection="pressurized"]').evaluate_all(
|
||||||
|
"(els) => els.map((el) => getComputedStyle(el).stroke)"
|
||||||
|
)
|
||||||
|
self.assertEqual(strokes, ["rgb(81, 232, 156)", "rgb(81, 232, 156)"])
|
||||||
|
|
||||||
|
for forbidden in ("未来北部十字结构", "未来东部十字结构", "未来西部十字结构"):
|
||||||
|
self.assertEqual(await page.get_by_text(forbidden, exact=False).count(), 0)
|
||||||
|
|
||||||
|
await page.locator('[data-module="core"]').click()
|
||||||
|
self.assertEqual(await page.locator("#detail-modal").count(), 1)
|
||||||
|
self.assertEqual(await page.locator("#detail-modal").get_attribute("aria-hidden"), "false")
|
||||||
|
self.assertEqual(await page.locator("#detail-title").inner_text(), "广寒核心舱")
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
self.assertEqual(await page.locator("#detail-modal").get_attribute("aria-hidden"), "true")
|
||||||
|
|
||||||
|
await page.locator('[data-module="habitat-1"]').click()
|
||||||
|
self.assertIn("运输:高2.7 m", await page.locator("#detail-dimensions").inner_text())
|
||||||
|
await page.locator("#detail-modal").click(position={"x": 5, "y": 5})
|
||||||
|
self.assertEqual(await page.locator("#detail-modal").get_attribute("aria-hidden"), "true")
|
||||||
|
|
||||||
|
self.assertEqual(errors, [])
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
with local_server() as base_url:
|
||||||
|
asyncio.run(scenario(base_url))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user