Ver código fonte

fix(cockpit): keep device list visible beside pipe status

ChenJZ 2 dias atrás
pai
commit
cee2bda1da

+ 33 - 0
.scratch/cockpit-shell-panel-layout/issues/57-untitled.md

@@ -0,0 +1,33 @@
+<!-- DSH-IDEMPOTENCY-KEY: underground-detail-device-panel-direct-pick-v1 -->
+<!-- parentKey: 00 -->
+<!-- created: 2026-10-19T07:53:40.784Z -->
+# 地下管线详情视图设备独立分区与场景点选
+
+## What to build
+
+维护者发现右侧“附近管道状态”长列表把“监测设备”列表压到最下方,且不符合期望:希望场景内直接显示设备图标,点击即弹出设备详情面板。
+
+## Acceptance criteria
+
+- [x] 右侧附近管道状态独立限高/滚动,设备清单分区固定可达,不受管道数量挤出。
+- [x] Three场景内真实设备模型可直接射线点选,直接打开内部设备详情。
+- [x] 设备清单仍可点击,并聚焦场景设备/打开相同详情;仅清单无坐标设备不伪造模型或镜头位置。
+- [x] 设备详情面板可关闭、失败重试,不重建场景/相机。
+- [ ] 正式入口画面确认信息密度、图标大小、设备面板遮挡与长列表滚动体验。
+
+## 进度
+
+已完成:状态列表单独限高滚动,右侧设备区独立分配固定高度且内部滚动;场景设备保留可辨高亮模型,最近可见表面射线直接派发设备身份并打开内部详情;列表点击聚焦同一场景模型并打开同一详情,关闭详情不销毁视口/不清掉列表。
+
+验证:t23-click-flow真实Vue模板点击清单→focusDevice→Pinia真实详情DOM→关闭→列表仍可用;Three射线设备拾取和遮挡见t52-device-pick;完整脚本批次T55 8/8、T50 9/9、T23 Three 9/9、颜色/鼠标交互19/19、SFC5/5通过。未启动build/dev/browser/GPU,现场长管道清单滚动、图标辨识及详情覆盖观感待维护者确认。
+
+Status: ready-for-human
+Type: task
+Blocked by:
+Labels: ready-for-human, wayfinder:task
+
+## Comments
+
+
+## Answer
+代码与离线交互已完成;待维护者正式入口画面确认。

+ 24 - 6
.scratch/cockpit-shell-panel-layout/verify/t23-click-flow.mjs

@@ -40,7 +40,9 @@ const viewer = {
 setActivePinia(createPinia())
 const store = usePipeEnhancementStore()
 const pipes = ['P-1', 'P-2'].map((code) => ({ code, positions: [[110.1, 28.4], [110.101, 28.401]] }))
-const plan = Vue.computed(() => planPipeEnhancement({ selected: store.selected, visiblePipes: pipes, radiusM: store.radiusM }))
+const demoDevice = { code: 'D-real', pointCode: 'MP-real', name: '真实设备', placement: 'nearby', placementText: '邻近设备 · 示意安装' }
+const plan = Vue.computed(() => ({ ...planPipeEnhancement({ selected: store.selected, visiblePipes: pipes, radiusM: store.radiusM }),
+    deviceList: [demoDevice], devices: [demoDevice], deviceCoverage: { loading: false, partial: false } }))
 const selectContext = vm.createContext({ PIPE_ENHANCEMENT_KIND, pipeEnhancementStore: store })
 vm.runInContext(`${functions(shell.scriptSetup.content, ['selectPipeEnhancement'])}\nglobalThis.select = selectPipeEnhancement`, selectContext)
 const context = vm.createContext({
@@ -76,7 +78,7 @@ function click(x = 120, y = 230, upX = x) {
 const enhancement = parse(src('components/PipeEnhancement.vue')).descriptor
 let mounts = 0
 let releases = 0
-let devicePick, objectPick
+let devicePick, objectPick, focusedDevice
 let syncs = 0
 const script = compileScript(enhancement, { id: 't23-click', inlineTemplate: true }).content
 const imports = { vue: Vue, './EquipmentDetailSections.vue': { default: { props: ['deviceCode', 'aggregate'], render() { return Vue.h('div', { 'data-device-detail': this.deviceCode }) } } }, '../services/threePipeEnhancement.js': { mountPipeEnhancement(_host, next, options) {
@@ -84,7 +86,7 @@ const imports = { vue: Vue, './EquipmentDetailSections.vue': { default: { props:
     objectPick = options.onObjectPick
     assert.equal(next.object.code, 'P-2')
     mounts += 1
-    return { sync() { syncs += 1 }, destroy() { releases += 1 } }
+    return { sync() { syncs += 1 }, focusDevice(code) { focusedDevice = code; return true }, destroy() { releases += 1 } }
 } } }
 let compiled = script
 for (const node of parseJS(script, { sourceType: 'module' }).program.body.filter((n) => n.type === 'ImportDeclaration').reverse()) {
@@ -108,6 +110,7 @@ const app = renderer.createApp({ setup: () => () => Vue.h(Component, {
     onClose: () => { store.clear(); deviceStore.clear() },
     onObjectPick: (hit) => selectContext.select(hit),
     onDevicePick: (device) => deviceStore.open(device, { fetchDetail: async (code) => ({ state: 'success', data: { deviceCode: code, sections: {} } }) }),
+    onDeviceClose: () => deviceStore.closeDetail(),
 }) })
 app.mount(root)
 function find(node, key) {
@@ -121,12 +124,27 @@ assert.ok(store.selected, `点击未派发:device/point=${JSON.stringify(conte
 assert.equal(store.selected.code, 'P-2')
 assert.equal(mounts, 1)
 assert.ok(find(root, 'data-pipe-enhancement'), 'pointer hit must render the enhancement dialog')
-devicePick({ code: 'D-real', pointCode: 'MP-real', name: '真实设备', placementText: '邻近设备 · 示意安装' })
+assert.ok(find(root, 'data-device-list'))
+assert.ok(find(root, 'data-pipe-status-list'))
+const deviceButton = find(root, 'data-device-code')
+assert.equal(deviceButton.props['data-device-code'], 'D-real')
+deviceButton.props.onClick()
+assert.equal(focusedDevice, 'D-real')
 await Vue.nextTick(); await Vue.nextTick()
 assert.equal(deviceStore.aggregate.data.deviceCode, 'D-real')
 assert.equal(find(root, 'data-device-detail').props['data-device-detail'], 'D-real')
-assert.equal(mounts, 1, '打开设备详情不能重建三维场景')
-assert.equal(store.selected.code, 'P-2')
+devicePick(demoDevice)
+await Vue.nextTick(); await Vue.nextTick()
+assert.equal(find(root, 'data-device-detail').props['data-device-detail'], 'D-real', '场景图标射线点击直接打开设备详情')
+assert.equal(find(root, 'data-device-list') !== undefined, true, '详情覆盖层打开时清单仍保留于其下')
+find(root, 'data-enhancement-device-detail').children
+    .filter((node) => node.tag === 'header')[0].children
+    .find((node) => node.tag === 'button').props.onClick()
+await Vue.nextTick()
+assert.equal(deviceStore.selected, null)
+assert.ok(find(root, 'data-device-list'), '关闭设备详情不关闭设备列表')
+assert.equal(mounts, 1, '设备详情关闭不重建三维场景')
+
 function findTag(node, tag) { return node.tag === tag ? node : (node.children || []).map((child) => findTag(child, tag)).find(Boolean) }
 findTag(root, 'select').props.onChange({ target: { value: '100' } })
 await Vue.nextTick(); await Vue.nextTick()

+ 30 - 19
src/modules/cockpit/components/PipeEnhancement.vue

@@ -19,25 +19,31 @@
         <div class="pipe-enhancement__workspace">
             <div ref="viewport" class="pipe-enhancement__viewport" data-pipe-enhancement-viewport role="region" aria-label="地下管线详情视图三维场景"></div>
             <aside class="pipe-enhancement__devices" aria-label="附近设备与详情">
-                <section v-if="localPipeStates.length" aria-label="附近管道状态">
-                    <h3>附近管道状态</h3>
-                    <p v-for="pipe in localPipeStates" :key="pipe.code">
-                        <button type="button" class="pipe-enhancement__pipe" :aria-pressed="plan.object.sourceKind === 'pipe' && plan.object.code === pipe.code" @click="$emit('object-pick', { objectKind: 'pipe', code: pipe.code })">{{ pipe.title }}({{ pipe.code }})</button>:{{ pipe.stateText || '状态未提供' }}
-                        <small class="pipe-enhancement__dimensions">源管径 {{ pipe.diameterM ? pipe.diameter + ' mm' : '未提供(仅导引)' }} · 源材质 {{ pipe.material || '未提供' }}({{ pipe.surface?.label || '材质未识别' }})</small>
-                        <small class="pipe-enhancement__dimensions">{{ pipe.clipped ? '当前裁片' : '地理路径' }} {{ pipe.geographicLengthM?.toFixed(2) }} m · 后端管长 {{ pipe.lengthM ?? '未提供' }}{{ pipe.lengthM !== null ? '(源值)' : '' }}</small>
-                    </p>
+                <section v-if="localPipeStates.length" class="pipe-enhancement__pipe-list" data-pipe-status-list aria-label="附近管道状态">
+                    <h3>附近管道状态({{ localPipeStates.length }})</h3>
+                    <div class="pipe-enhancement__pipe-scroll">
+                        <p v-for="pipe in localPipeStates" :key="pipe.code">
+                            <button type="button" class="pipe-enhancement__pipe" :aria-pressed="plan.object.sourceKind === 'pipe' && plan.object.code === pipe.code" @click="$emit('object-pick', { objectKind: 'pipe', code: pipe.code })">{{ pipe.title }}({{ pipe.code }})</button>:{{ pipe.stateText || '状态未提供' }}
+                            <small class="pipe-enhancement__dimensions">源管径 {{ pipe.diameterM ? pipe.diameter + ' mm' : '未提供(仅导引)' }} · 源材质 {{ pipe.material || '未提供' }}({{ pipe.surface?.label || '材质未识别' }})</small>
+                            <small class="pipe-enhancement__dimensions">{{ pipe.clipped ? '当前裁片' : '地理路径' }} {{ pipe.geographicLengthM?.toFixed(2) }} m · 后端管长 {{ pipe.lengthM ?? '未提供' }}{{ pipe.lengthM !== null ? '(源值)' : '' }}</small>
+                        </p>
+                    </div>
                 </section>
-                <h3>监测设备</h3>
-                <p v-if="plan.deviceCoverage?.loading">设备读取中({{ plan.deviceCoverage.loadedPoints || 0 }}/{{ plan.deviceCoverage.totalPoints || 0 }} 点),已返回设备可先查看。</p>
-                <p>可见设备模型 {{ plan.devices?.length || 0 }} 个 · 清单 {{ plan.deviceList?.length || 0 }} 台(含仅清单项)</p>
-                <p v-if="!plan.deviceCoverage?.loading && plan.deviceCoverage?.partial">设备覆盖不完整:快照未齐、设备待确认或部分点位读取失败。</p>
-                <button v-if="plan.deviceCoverage?.partial && !plan.deviceCoverage?.loading" type="button" @click="$emit('devices-reload')">重新读取设备清单</button>
-                <p v-if="plan.deviceCoverage?.stalePoints">部分设备为上次保留数据,非实时。</p>
-                <p v-if="!plan.deviceList?.length && !plan.deviceCoverage?.loading">当前可用数据中没有附近设备。</p>
-                <button v-for="device in plan.deviceList || []" :key="device.code" class="pipe-enhancement__device" type="button" @click="selectDevice(device)">
-                    {{ device.name || device.code }}<small>{{ device.placementText }}{{ device.retained ? ' · 过期数据' : '' }}</small>
-                </button>
-                <section v-if="deviceState.selected" data-enhancement-device-detail>
+                <section class="pipe-enhancement__device-list" data-device-list aria-label="监测设备">
+                    <h3>监测设备({{ plan.deviceList?.length || 0 }})</h3>
+                    <div class="pipe-enhancement__device-scroll">
+                        <p v-if="plan.deviceCoverage?.loading">设备读取中({{ plan.deviceCoverage.loadedPoints || 0 }}/{{ plan.deviceCoverage.totalPoints || 0 }} 点),已返回设备可先查看。</p>
+                        <p>场景设备 {{ plan.devices?.length || 0 }} 个 · 清单 {{ plan.deviceList?.length || 0 }} 台</p>
+                        <p v-if="!plan.deviceCoverage?.loading && plan.deviceCoverage?.partial">设备覆盖不完整:快照未齐、设备待确认或部分点位读取失败。</p>
+                        <button v-if="plan.deviceCoverage?.partial && !plan.deviceCoverage?.loading" type="button" @click="$emit('devices-reload')">重新读取设备清单</button>
+                        <p v-if="plan.deviceCoverage?.stalePoints">部分设备为上次保留数据,非实时。</p>
+                        <p v-if="!plan.deviceList?.length && !plan.deviceCoverage?.loading">当前可用数据中没有附近设备。</p>
+                        <button v-for="device in plan.deviceList || []" :key="device.code" :data-device-code="device.code" class="pipe-enhancement__device" type="button" @click="selectDevice(device)">
+                            {{ device.name || device.code }}<small>{{ device.placementText }}{{ device.retained ? ' · 过期数据' : '' }} · 点击场景图标或此处查看详情</small>
+                        </button>
+                    </div>
+                </section>
+                <section v-if="deviceState.selected" class="pipe-enhancement__device-detail" data-enhancement-device-detail>
                     <header><strong>{{ deviceState.selected.name || deviceState.selected.code }}</strong><button type="button" @click="$emit('device-close')">关闭详情</button></header>
                     <p>{{ deviceState.selected.placementText }}</p>
                     <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
@@ -140,7 +146,12 @@ onBeforeUnmount(() => {
 .pipe-enhancement__close { width: 28px; height: 28px; border: 1px solid rgba(1, 171, 234, .46); border-radius: 4px; background: transparent; color: inherit; font-size: 20px; line-height: 1; cursor: pointer; }
 .pipe-enhancement__close:hover, .pipe-enhancement__close:focus-visible { border-color: var(--cyan, #01abea); color: #fff; outline: none; }
 .pipe-enhancement__workspace { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, 32%); gap: 12px; min-height: 0; }
-.pipe-enhancement__devices { min-height: 0; overflow-y: auto; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
+.pipe-enhancement__devices { position: relative; display: flex; flex-direction: column; gap: 8px; min-height: 0; overflow: hidden; border-left: 1px solid #174969; padding-left: 12px; font-size: 12px; }
+.pipe-enhancement__pipe-list { flex: 0 1 28%; min-height: 60px; max-height: 34%; display: flex; flex-direction: column; overflow: hidden; }
+.pipe-enhancement__device-list { flex: 1 1 72%; min-height: 120px; display: flex; flex-direction: column; overflow: hidden; border-top: 1px solid #174969; padding-top: 7px; }
+.pipe-enhancement__pipe-scroll, .pipe-enhancement__device-scroll { min-height: 0; overflow: auto; overscroll-behavior: contain; }
+.pipe-enhancement__device-detail { position: absolute; z-index: 4; inset: 0; min-height: 0; overflow: auto; padding: 12px; border: 1px solid #36b8d4; border-radius: 5px; background: rgba(2, 12, 32, .98); box-shadow: 0 0 24px rgba(0, 0, 0, .65); }
+.pipe-enhancement__device-detail :deep(.equipment-sections) { max-height: calc(100% - 64px); overflow: auto; }
 .pipe-enhancement__pipe { padding: 3px 5px; border: 1px solid #226286; background: #072039; color: #d9f7ff; cursor: pointer; text-align: left; }
 .pipe-enhancement__pipe[aria-pressed="true"] { border-color: #d8fbff; box-shadow: inset 0 0 4px #38bac9; }
 .pipe-enhancement__dimensions { display: block; margin-top: 4px; color: #a7c4d9; }