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;
|
||||
}
|
||||
|
||||
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) {
|
||||
body { padding: 14px; }
|
||||
.topology-map { min-width: 980px; }
|
||||
.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>
|
||||
</head>
|
||||
@@ -331,6 +414,23 @@
|
||||
<p class="click-note">模块与车辆支持详情查看;走道、管线和散热面板仅用于表达拓扑关系。</p>
|
||||
</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>
|
||||
const MODULE_DETAILS = {
|
||||
"core": {
|
||||
@@ -488,6 +588,61 @@
|
||||
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>
|
||||
</body>
|
||||
</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