feat: add Guanghan module detail modal

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
2026-07-09 16:59:39 +08:00
co-authored by Codex
parent 2780847c50
commit b1a91159d9
2 changed files with 229 additions and 0 deletions
+155
View File
@@ -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>
+74
View File
@@ -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()