From 5a8a04c4405b7c74d96762a23400cfda8f7ca1f2 Mon Sep 17 00:00:00 2001 From: Armor00 <2654988228@qq.com> Date: Thu, 9 Jul 2026 22:08:42 +0800 Subject: [PATCH] fix(topology): repair controls navigation and details --- data/wiki/guanghan_topology_demo.html | 114 +++++++++++++++++------ tests/test_guanghan_topology_demo.py | 8 ++ tests/test_guanghan_topology_e2e.py | 104 +++++++++++++++++++++ tests/test_topology_api.py | 19 ++++ topology_api/queries/snapshot_queries.py | 8 +- 5 files changed, 225 insertions(+), 28 deletions(-) diff --git a/data/wiki/guanghan_topology_demo.html b/data/wiki/guanghan_topology_demo.html index 39b5c68..57ecd5d 100644 --- a/data/wiki/guanghan_topology_demo.html +++ b/data/wiki/guanghan_topology_demo.html @@ -147,7 +147,24 @@ const API_BASE = ''; const SVG_NS = 'http://www.w3.org/2000/svg'; const TYPE_LABELS = { module: '舱段模块', node: '结构节点', vehicle: '着陆器/车辆', equipment: '设备模块' }; +const ANCHOR_LABELS = { + explicit_time: '明确时间', + mission_latest_event: '任务末条记录', + latest_recorded_state: '最新已记录状态', + no_events: '暂无记录', +}; +const TIME_FORMATTER = new Intl.DateTimeFormat('zh-CN', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, +}); let currentData = null; +let lastModalTrigger = null; function svgElement(name, attributes = {}) { const element = document.createElementNS(SVG_NS, name); @@ -160,17 +177,50 @@ function svgElement(name, attributes = {}) { async function fetchSnapshot(mission, at) { const params = new URLSearchParams(); if (at) params.set('at', at); - else if (mission) params.set('mission', mission); + if (mission) params.set('mission', mission); const response = await fetch(`${API_BASE}/api/v1/bases/guanghan/snapshot?${params}`); if (!response.ok) { - const error = await response.json(); + let error; + try { + error = await response.json(); + } catch { + error = { message: `HTTP ${response.status}` }; + } throw new Error(error.message || `HTTP ${response.status}`); } return response.json(); } async function loadMissions() { - return ['BL-01', 'GHC-01', 'JC-01', 'BL-02', 'GHC-02', 'GHC-03', 'BL-03', 'GHC-04', 'GHC-05']; + const response = await fetch(`${API_BASE}/api/v1/bases/guanghan/missions`); + if (!response.ok) throw new Error(`任务列表加载失败(HTTP ${response.status})`); + return (await response.json()).missions; +} + +function formatSnapshotTime(value) { + return value ? TIME_FORMATTER.format(new Date(value)) : '—'; +} + +function isValidTimeInput(value) { + if (!value) return true; + if (!/(Z|[+-]\d{2}:\d{2})$/.test(value)) return false; + return !Number.isNaN(Date.parse(value)); +} + +function setError(message) { + const error = document.getElementById('error-msg'); + error.textContent = message; + error.style.display = message ? 'block' : 'none'; +} + +function setLines(elementId, lines, fallback) { + const container = document.getElementById(elementId); + const values = lines.length ? lines : [fallback]; + container.replaceChildren(...values.map(value => { + const line = document.createElement('span'); + line.textContent = value; + return line; + })); } function splitEndpoint(endpoint) { @@ -337,11 +387,11 @@ function renderTopology() { appendNodeText(group, node, 'node-title', title, node.y + node.height * (hasHint ? 0.34 : 0.42)); appendNodeText(group, node, 'node-meta', node.displayMeta, node.y + node.height * (hasHint ? 0.60 : 0.72)); appendNodeText(group, node, 'node-hint', node.displayHint, node.y + node.height * 0.82); - group.addEventListener('click', () => openModal(node.nodeKey)); + group.addEventListener('click', () => openModal(node.nodeKey, group)); group.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - openModal(node.nodeKey); + openModal(node.nodeKey, group); } }); nodeLayer.appendChild(group); @@ -381,7 +431,7 @@ function buildConnectionsForComponent(componentKey) { return result; } -function openModal(nodeKey) { +function openModal(nodeKey, trigger = null) { const components = getNodeComponents(nodeKey); if (components.length === 0) return; const primary = components[0]; @@ -425,8 +475,7 @@ function openModal(nodeKey) { if (values.length) metrics.push(`${component.name}:${values.join(',')}`); else if (components.length > 1 && component.summary) metrics.push(`${component.name}:${component.summary}`); }); - document.getElementById('detail-metrics').innerHTML = - metrics.map(value => `${value}`).join('') || '资料未记录'; + setLines('detail-metrics', metrics, '资料未记录'); const dimensionLines = []; components.forEach(component => { @@ -438,8 +487,7 @@ function openModal(nodeKey) { if (dimensions.diameterM) values.push(`直径 ${dimensions.diameterM} m`); if (values.length) dimensionLines.push(`${component.name}:${values.join(',')}`); }); - document.getElementById('detail-dimensions').innerHTML = - dimensionLines.map(value => `${value}`).join('') || '资料未记录'; + setLines('detail-dimensions', dimensionLines, '资料未记录'); const connections = []; components.forEach(component => { @@ -447,12 +495,14 @@ function openModal(nodeKey) { if (!connections.includes(value)) connections.push(value); }); }); - document.getElementById('detail-connections').innerHTML = - connections.map(value => `${value}`).join('') || '无连接记录'; - document.getElementById('detail-mission').textContent = - primary.detailUrl ? `详见 Wiki:${primary.detailUrl}` : '资料未记录'; + setLines('detail-connections', connections, '无连接记录'); + const missions = components.map(component => + `${component.name}:${component.introducedMission} · ${formatSnapshotTime(component.introducedAt)}` + ); + setLines('detail-mission', missions, '资料未记录'); const modal = document.getElementById('detail-modal'); + lastModalTrigger = trigger; modal.setAttribute('aria-hidden', 'false'); document.body.classList.add('modal-open'); modal.querySelector('.detail-dialog').focus(); @@ -461,11 +511,13 @@ function openModal(nodeKey) { function closeModal() { document.getElementById('detail-modal').setAttribute('aria-hidden', 'true'); document.body.classList.remove('modal-open'); + if (lastModalTrigger?.isConnected) lastModalTrigger.focus(); + lastModalTrigger = null; } async function load(mission, at) { document.getElementById('loading-msg').style.display = 'block'; - document.getElementById('error-msg').style.display = 'none'; + setError(''); document.getElementById('map-shell').style.display = 'none'; try { currentData = await fetchSnapshot(mission, at); @@ -473,15 +525,14 @@ async function load(mission, at) { const query = currentData.query; const missionLabel = query.requestedMission || '(最新)'; document.getElementById('page-subtitle').textContent = - `${missionLabel} · 快照时间: ${new Date(query.snapshotAt).toLocaleString('zh-CN')} · ${currentData.components.length} 组件`; + `${missionLabel} · 快照时间: ${formatSnapshotTime(query.snapshotAt)} · ${currentData.components.length} 组件`; document.getElementById('snapshot-info').textContent = - `快照: ${query.snapshotAt ? new Date(query.snapshotAt).toLocaleString('zh-CN') : '—'} · ${query.anchorSource}`; + `快照: ${formatSnapshotTime(query.snapshotAt)} · ${ANCHOR_LABELS[query.anchorSource] || query.anchorSource}`; document.getElementById('loading-msg').style.display = 'none'; document.getElementById('map-shell').style.display = 'block'; } catch (error) { document.getElementById('loading-msg').style.display = 'none'; - document.getElementById('error-msg').style.display = 'block'; - document.getElementById('error-msg').textContent = `加载失败: ${error.message}`; + setError(`加载失败: ${error.message}`); } } @@ -490,27 +541,36 @@ async function load(mission, at) { const missions = await loadMissions(); missions.forEach(mission => { const option = document.createElement('option'); - option.value = mission; - option.textContent = mission; + option.value = mission.code; + option.textContent = mission.code; missionSelect.appendChild(option); }); - const urlParams = new URLSearchParams(window.location.search); - const urlMission = urlParams.get('mission'); - const urlAt = urlParams.get('at'); - if (urlMission) missionSelect.value = urlMission; - if (urlAt) document.getElementById('time-input').value = urlAt; - await load(urlMission || null, urlAt || null); + async function applyUrlState() { + const urlParams = new URLSearchParams(window.location.search); + const mission = urlParams.get('mission') || ''; + const at = urlParams.get('at') || ''; + missionSelect.value = mission; + document.getElementById('time-input').value = at; + await load(mission || null, at || null); + } + + await applyUrlState(); document.getElementById('go-btn').addEventListener('click', () => { const mission = missionSelect.value || null; const at = document.getElementById('time-input').value || null; + if (!isValidTimeInput(at)) { + setError('请输入带时区的 ISO 8601 时间,例如 2025-01-02T08:00:00+08:00。'); + return; + } const params = new URLSearchParams(); if (mission) params.set('mission', mission); if (at) params.set('at', at); window.history.pushState({}, '', `${window.location.pathname}${params.size ? `?${params}` : ''}`); load(mission, at); }); + window.addEventListener('popstate', applyUrlState); document.getElementById('detail-modal').addEventListener('click', event => { if (event.target === event.currentTarget) closeModal(); diff --git a/tests/test_guanghan_topology_demo.py b/tests/test_guanghan_topology_demo.py index ff46a9c..981a33f 100644 --- a/tests/test_guanghan_topology_demo.py +++ b/tests/test_guanghan_topology_demo.py @@ -40,6 +40,14 @@ class GuanghanTopologySourceTests(unittest.TestCase): self.assertIn("const API_BASE = ''", self.html) self.assertNotIn("http://localhost:5000", self.html) + def test_controls_use_api_navigation_and_safe_text(self) -> None: + self.assertIn("/api/v1/bases/guanghan/missions", self.html) + self.assertNotIn("return ['BL-01'", self.html) + self.assertIn("popstate", self.html) + self.assertIn("Asia/Shanghai", self.html) + self.assertNotIn(".innerHTML =", self.html) + self.assertIn("replaceChildren", self.html) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_guanghan_topology_e2e.py b/tests/test_guanghan_topology_e2e.py index 5177df9..820f022 100644 --- a/tests/test_guanghan_topology_e2e.py +++ b/tests/test_guanghan_topology_e2e.py @@ -115,6 +115,110 @@ class GuanghanTopologyE2ETests(unittest.TestCase): with topology_server() as base_url: asyncio.run(scenario(base_url)) + def test_controls_navigation_and_modal_semantics(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": 1000} + ) + await page.goto( + f"{base_url}/guanghan_topology_demo.html" + "?mission=GHC-05", + wait_until="networkidle", + ) + await page.wait_for_function( + """() => document.querySelectorAll( + '.topology-node' + ).length === 14""" + ) + + options = await page.locator( + "#mission-select option" + ).evaluate_all("(items) => items.map(item => item.value)") + self.assertEqual( + options, + [ + "", + "BL-01", + "GHC-01", + "JC-01", + "BL-02", + "GHC-02", + "GHC-03", + "BL-03", + "GHC-04", + "GHC-05", + ], + ) + + core = page.locator( + '.topology-node[data-node-key="core_cabin"]' + ) + await core.click() + self.assertIn( + "GHC-01", + await page.locator("#detail-mission").inner_text(), + ) + await page.locator(".modal-close").click() + self.assertEqual( + await page.evaluate( + "() => document.activeElement?.dataset.nodeKey" + ), + "core_cabin", + ) + + await page.select_option("#mission-select", "GHC-01") + await page.fill( + "#time-input", + "2025-01-02T08:00:00+08:00", + ) + await page.locator("#go-btn").click() + await page.wait_for_function( + """() => document.querySelector('#snapshot-info') + ?.textContent.includes('明确时间')""" + ) + self.assertTrue( + (await page.locator("#page-subtitle").inner_text()) + .startswith("GHC-01 ·") + ) + self.assertIn("mission=GHC-01", page.url) + self.assertIn("at=", page.url) + + await page.fill("#time-input", "not-a-time") + await page.locator("#go-btn").click() + self.assertIn( + "请输入带时区的 ISO 8601 时间", + await page.locator("#error-msg").inner_text(), + ) + + await page.fill("#time-input", "") + await page.select_option("#mission-select", "GHC-01") + await page.locator("#go-btn").click() + await page.wait_for_function( + """() => document.querySelector('#page-subtitle') + ?.textContent.startsWith('GHC-01 ·')""" + ) + await page.select_option("#mission-select", "GHC-05") + await page.locator("#go-btn").click() + await page.wait_for_function( + """() => document.querySelector('#page-subtitle') + ?.textContent.startsWith('GHC-05 ·')""" + ) + await page.go_back() + await page.wait_for_function( + """() => document.querySelector('#page-subtitle') + ?.textContent.startsWith('GHC-01 ·')""" + ) + self.assertEqual( + await page.locator("#mission-select").input_value(), + "GHC-01", + ) + await browser.close() + + with topology_server() as base_url: + asyncio.run(scenario(base_url)) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_topology_api.py b/tests/test_topology_api.py index ec88b6d..628eeb0 100644 --- a/tests/test_topology_api.py +++ b/tests/test_topology_api.py @@ -181,6 +181,25 @@ class TopologyApiQueryTests(unittest.TestCase): }, ) + def test_components_include_introduction_mission_and_time(self) -> None: + response = self.client.get( + "/api/v1/bases/guanghan/snapshot?mission=GHC-05" + ) + components = { + component["key"]: component + for component in response.get_json()["components"] + } + self.assertEqual( + components["core_cabin"]["introducedMission"], + "GHC-01", + ) + self.assertEqual( + datetime.fromisoformat( + components["core_cabin"]["introducedAt"] + ).astimezone(timezone.utc), + datetime.fromisoformat("2024-06-16T07:23:00+00:00"), + ) + if __name__ == "__main__": unittest.main() diff --git a/topology_api/queries/snapshot_queries.py b/topology_api/queries/snapshot_queries.py index 6adb16b..a6c6604 100644 --- a/topology_api/queries/snapshot_queries.py +++ b/topology_api/queries/snapshot_queries.py @@ -112,10 +112,14 @@ def fetch_snapshot(snapshot_at): cv.diameter_m, cv.mass_t, cv.detail_url, - cv.properties + cv.properties, + intro_mission.code AS introduced_mission, + e_intro.effective_at AS introduced_at FROM components c JOIN mission_events e_intro ON e_intro.id = c.introduced_by_event_id + JOIN missions intro_mission + ON intro_mission.id = e_intro.mission_id LEFT JOIN mission_events e_retire ON e_retire.id = c.retired_by_event_id LEFT JOIN LATERAL ( @@ -144,6 +148,8 @@ def fetch_snapshot(snapshot_at): "summary": row["summary"], "description": row["description"], "detailUrl": row["detail_url"], + "introducedMission": row["introduced_mission"], + "introducedAt": row["introduced_at"].isoformat(), "dimensions": {}, "properties": row["properties"] or {}, "ports": [],