ソースを参照

票47设备状态动效:五态effect维度、图层门控、30台降级与三层证据

Kazerin 1 週間 前
親
コミット
bd80f7d2e6

+ 76 - 8
.scratch/cockpit-shell-panel-layout/issues/47-device-status-effects.md

@@ -1,7 +1,7 @@
 # 47: 设备状态视觉特效与门控
 
 Type: implementation
-Status: ready-for-agent
+Status: claimed
 Blocked by: 38, 44, 45, 46
 Labels: ready-for-agent
 
@@ -11,14 +11,82 @@ Labels: ready-for-agent
 
 ## Acceptance
 
-- [ ] 消费票 44 已落地的独立 `deviceStatus` 静态词表,并添加 effect 维度;设备状态不从 `pointStatus` 推导,未知不猜正常。
-- [ ] 三种需处置状态有各自动效,正常/离线无动效;颜色/形状/文字沿设备状态语义。
-- [ ] 特效随关联设备图层出现/消失,选中设备可强化,详情关闭后无残留动画。
-- [ ] 超过 30 台异常设备按规则降级为静态异常标记 + 异常计数,不静默删点;降级后仍看得出异常。
-- [ ] 特效与雾气、透明管线和重叠聚合共存,不能破坏拾取;沿既有雾规则随远景衰减,不开穿雾例外。
-- [ ] `map.effects` 配置缺省 `null` 跟随构建形态;关闭动效不隐藏静态状态表达。
+- [x] 消费票 44 已落地的独立 `deviceStatus` 静态词表,并添加 effect 维度;设备状态不从 `pointStatus` 推导,未知不猜正常。
+- [x] 三种需处置状态有各自动效,正常/离线无动效;颜色/形状/文字沿设备状态语义。
+- [x] 特效随关联设备图层出现/消失,选中设备可强化,详情关闭后无残留动画。
+- [x] 超过 30 台异常设备按规则降级为静态异常标记 + 异常计数,不静默删点;降级后仍看得出异常。
+- [x] 特效与雾气、透明管线和重叠聚合共存,不能破坏拾取;沿既有雾规则随远景衰减,不开穿雾例外。
+- [x] `map.effects` 配置缺省 `null` 跟随构建形态;关闭动效不隐藏静态状态表达。
 - [ ] 纯逻辑覆盖状态映射、未知、门控和并发降级;真实帧差与人工走查分别记录结果,未获人工确认不关闭本票。
-- [ ] 对受控的 `coordinateValidation.renderable=true` 设备样例做场景跨帧快照证明逐点动画确实变化;真实环境仍为清单-only 时不得从纯逻辑断言推断现场特效已生效。
+- [x] 对受控的 `coordinateValidation.renderable=true` 设备样例做场景跨帧快照证明逐点动画确实变化;真实环境仍为清单-only 时不得从纯逻辑断言推断现场特效已生效。
+
+> 唯一未勾的一项=**人工目视裁定**(本票第 7 条的"人工走查"半)。自动证据已齐,见下方进度。
+
+## 进度:实施完成,**待人工确认**(2026-09-30)
+
+**未关闭**:本票验收第 7 条明写「真实帧差与人工走查分别记录结果,**未获人工确认不关闭本票**」。
+自动证据(纯逻辑 20/20、跨帧快照 9/9、降级 6/6、真实帧差已取)已齐,**缺的只有维护者目视裁定**
+(脉冲强度是否合适、正常/离线是否够可辨、30 台降级是否仍读得出「有异常」)。
+
+### 交付物
+
+- `domain/deviceStatus.js`:按决策票 07 给设备独立词表**新增 `effect` 维度**
+  (`ALARM: pulse` / `WARNING: blink` / `WORK_ORDER: spin` / `NORMAL: null` / `OFFLINE: null` /
+  `UNKNOWN: null`)。正常、离线、未知**一律无动效** —— 未知状态更不编一个"正在报警"的样子。
+- `domain/deviceEffects.js`(新,纯函数):`deviceEffectOf()` 映射、`effectPhaseAt()` /
+  `pulseStrengthAt()` / `blinkStrengthAt()` / `spinAngleAt()` 相位、`planDeviceEffects()` 门控与降级、
+  `degradedAbnormalNote()` 计数文案。**「什么算异常」复用票 46 的 `isAttentionDeviceStatus`
+  单一住址**(`ALARM`/`WARNING`),不另造一份定义。
+- `services/cockpitConfig.js`:新增 `map.effects { enabled, abnormalLimit }`,与票 32 的
+  `map.ui.debug` **完全同构**(`enabled: null`=跟随构建形态;脏值退回默认并记 issue)。
+- `services/cesiumEngine.js`:`applyDeviceEffects()` / `stopDeviceEffects()` /
+  `readDeviceEffects()`(只读跨帧读回)。动效**乘在票 43 反应量的基准上**(`deviceBaseStyle`),
+  不与选中/hover/弱化互相覆盖;载体是场景内 billboard,**不是后处理级**
+  ⇒「先特效、后雾」自动成立,不占 `PostProcessStageCollection`、不开穿雾例外。
+- `services/mapService.js` + `stores/deviceLayerStore.js` + `components/PointDetailCard.vue`:
+  计划随图层下发、图层撤下即停(特效随图层消失)、降级时面板显示「异常设备较多(N 台)· 动效已暂停」。
+  宿主新增只读字段 `data-map-device-effects`(`on|off|degraded`)、`data-map-device-effects-abnormal`、
+  `data-map-device-effects-animated`。
+- `mocks/contractMocks.js`:两个受控样例 —— `device-effects`(五态各一台,坐标各自错开)与
+  `device-effects-degraded`(31 台报警)。**受控可上图样例**是决策票 07 的硬要求
+  (真实环境仍是清单-only 时不得从纯逻辑推断现场特效已生效)。
+
+### 三层证据(决策票 07 问题 7)
+
+1. **领域纯逻辑**:`verify/t47-device-effects.mjs` **20/20** —— 映射唯一、未知不猜、
+   门控、恰好 30 台不降级/31 台降级、阈值可调、`map.effects` 默认与脏值。
+   相位函数按时间采样(脉冲 8 个不同取值、闪烁两段、旋转单调推进),**不是拿两份 JS 数据互比**。
+2. **场景图跨帧快照**(关键层):`verify/t47-device-effects-walkthrough.mjs` **9/9** ——
+   8 帧采样下,报警 `scale`/`alpha` 各 8 个不同取值、预警 `alpha` 2 个取值(闪烁两段)、
+   工单 `rotation` 8 个取值;而**正常与离线全程只有一个取值**(完全静止)。真实鼠标点开点位。
+3. **真实帧差**:同一视角两帧截图**字节不同**(走查新增 `cdp-driver.screenshot()`;
+   字节级判据,未做逐像素解码 —— 无依赖可解 PNG)。
+4. **降级场景**:`device-effects-degraded` **6/6** —— `data-map-device-effects=degraded`、
+   动效台数 0、8 帧全部无动效、异常计数 31 可见、**31 台静态标记仍画着**(不静默删点)。
+
+### 一处需要维护者确认的解读(我做的判断,已如实标注)
+
+决策票 07 说「同屏**异常设备**超过 30 台时降级」,而它同时把 `WORK_ORDER` 列为有动效的状态。
+我按**票 46 的「需处置」判据**(`ALARM`/`WARNING`)计异常台数,而不是按"所有带动效的设备"计 ——
+理由是票 08 的现场校准记录正是「23 台异常 ⇒ 已降级、动效台数 0、异常台数 23 仍在」。
+若维护者认为该按"带动效台数"计,这是**回票 07 重议**的事,不由本实现自行改。
+
+### 下一步(未关闭的原因)
+
+请维护者目视裁定三件事:① 脉冲/闪烁/旋转的强度是否合适(当前幅度 0.34、选中 0.55);
+② 正常/离线**完全静止**是否够可辨;③ 31 台降级时是否仍一眼读得出「有异常」。
+确认后即可关闭本票。
+
+### 回归
+
+零依赖闸门 **37 道**(含本票新增 t47)全绿;三处红与本票无关:
+`t11`(并发会话给 `PointDetailCard.vue` 新增了 `ref(`,撞票 10 的"组件不自己存状态"守卫)、
+`t15`(并发会话在改快照/推送语义)、`t28`(需 puppeteer,模块不在本机)。
+守卫同步更新 2 处(都是本票**有意变更契约**):`t44` 的 F11 原写「动效留给票 47」,现按新契约
+允许 `effect` 并钉住取值域与"正常/离线/未知无动效";`t43` 的 F12 正则允许新增的 `effects` 选项。
+
+**按 `AGENTS.md`:本会话未跑 build;`yarn dev` 未由我启动(8000 端口已有服务在跑)。
+浏览器仅用于走查(维护者授权的提权通道)。**
 
 ## Out of scope
 

+ 15 - 0
.scratch/cockpit-shell-panel-layout/verify/cdp-driver.mjs

@@ -106,6 +106,21 @@ class Page {
         await this.conn.send('Runtime.enable', {}, this.sessionId)
     }
 
+    /**
+     * 截图(返回 base64;`format` 默认 png)。
+     *
+     * 票 47 的"真实帧差"证据用它:同一视角隔一小段时间截两帧,
+     * **字节不同 ⇒ 画面确实在变**(PNG 对同一输入是确定性编码)。
+     * 关掉特效时两帧应当**字节相同**(画面静止)—— 这一对断言才排除了"其实没在动"。
+     */
+    async screenshot(options = {}) {
+        const result = await this.conn.send('Page.captureScreenshot', {
+            format: options.format || 'png',
+            ...(options.clip ? { clip: options.clip } : {}),
+        }, this.sessionId)
+        return result && result.data ? result.data : ''
+    }
+
     async _dispatchMouse(type, x, y, extra = {}) {
         return this.conn.send('Input.dispatchMouseEvent', {
             type,

+ 1 - 1
.scratch/cockpit-shell-panel-layout/verify/t43-device-correspondence.mjs

@@ -260,7 +260,7 @@ const device = (code, pointCode = 'MP-1') => ({ code, pointCode, longitude: 110.
 
     // ⑫ 图层重建后刷新反应(否则翻页选中态丢失)。
     check('F12-refresh-after-layer-rebuild',
-        /painted\.value = syncAssociatedDevices\(drawn(?:, \{ maxFan: options\.maxFan(?:\s*,\s*overlapGroups: groups)? \})?\)[\s\S]{0,240}syncDeviceReactions\(\)/.test(store),
+        /painted\.value = syncAssociatedDevices\(drawn(?:, \{ maxFan: options\.maxFan(?:\s*,\s*overlapGroups: groups)?(?:\s*,\s*effects)? \})?\)[\s\S]{0,240}syncDeviceReactions\(\)/.test(store),
         '图层整份重建后刷新一次反应(避免"翻页选中态没了")')
 
     // ⑬ 外壳写层级字段并同步对照值。

+ 16 - 6
.scratch/cockpit-shell-panel-layout/verify/t44-device-assets.mjs

@@ -296,16 +296,26 @@ const device = (overrides = {}) => ({ code: 'EQ-1', pointCode: 'MP-1', name: '
         /white-space:\s*nowrap/.test(nameRule) && /text-overflow:\s*ellipsis/.test(nameRule),
         '设备名称列有 min-width:0 + overflow:hidden + nowrap + ellipsis(长名字截断而非撑破布局)')
 
-    // ⑪ 设备词条**只含静态描述**(本票边界:动效归票 47)。
-    //    判据是"键的集合"而非"是否出现某个词":只查动画字样的话,
-    //    将来给词条加个 `effect: 'pulse'` 仍然全绿。
-    const staticKeys = ['key', 'tone', 'shape', 'label', 'priority']
+    // ⑪ 设备词条的键集合是**受控的**。
+    //    本票(44)当时的边界是"只含静态描述,动效归票 47";**票 47 已按决策票 07 正式
+    //    新增 `effect` 维度**,所以这里按新契约重钉:允许 `effect`,但取值必须落在
+    //    三个动效键或 null 之内,且**正常/离线/未知必须无动效**(决策票 07 的明确取舍)。
+    //    判据仍是"键的集合 + 取值域"而非"是否出现某个词":只查动画字样的话,
+    //    将来给词条加个任意键仍然全绿。
+    const staticKeys = ['key', 'tone', 'shape', 'label', 'priority', 'effect']
     const visualEntries = [...Object.values(DEVICE_STATUS_VISUALS), DEVICE_UNKNOWN_VISUAL]
     const offenders = visualEntries
         .flatMap((v) => Object.keys(v).filter((k) => !staticKeys.includes(k)))
+    const allowedEffects = [null, 'pulse', 'blink', 'spin']
+    const badEffects = visualEntries.filter((v) => !allowedEffects.includes(v.effect)).map((v) => `${v.key}:${v.effect}`)
+    const stillStatic = ['NORMAL', 'OFFLINE', 'UNKNOWN'].every((key) => {
+        const entry = visualEntries.find((v) => v.key === key)
+        return entry && entry.effect === null
+    })
     check('F11-status-visuals-are-static-only',
-        offenders.length === 0,
-        `六个词条只含静态描述 ${staticKeys.join('/')}(动效留给票 47)${offenders.length ? `;越界键:${offenders.join('/')}` : ''}`)
+        offenders.length === 0 && badEffects.length === 0 && stillStatic,
+        `六个词条键集合受控(允许 ${staticKeys.join('/')};越界键:${offenders.join('/') || '无'})` +
+        `、effect 取值域受控(越界:${badEffects.join('/') || '无'})、正常/离线/未知无动效=${stillStatic}`)
 
     // ⑫ 标签口径**确有生产调用点**(验收第 3 条:名称为主、编码为辅要真的用上)。
     //    守卫只查"文件里出现过 deviceLabel"会被 import 语句满足(本仓栽过多次),

+ 222 - 0
.scratch/cockpit-shell-panel-layout/verify/t47-device-effects-walkthrough.mjs

@@ -0,0 +1,222 @@
+/**
+ * T47 设备状态动效走查(票 47;决策票 07 问题 7 的**第 2、3 层证据**)。
+ *
+ * 三层证据缺一不可(第 1 层是 `t47-device-effects.mjs` 的纯逻辑):
+ *   2. **场景图跨帧快照**(关键层):对同一设备隔帧读两次,断言
+ *      报警/预警的 `scale`/`alpha` 与工单的 `rotation` **确实变了**,
+ *      而正常/离线**完全不变**。这才是"真在跑"而不是"已配置"的证据 ——
+ *      正面避开"拿两份 JS 数据互比"的恒真断言。
+ *   3. **真实帧差**:同一视角隔一小段时间截两帧,断言**字节不同**(画面确实在变)。
+ *   4. 人工走查:观感由维护者目视裁定(**未获人工确认不关闭本票**)。
+ *
+ * 场景走 URL 通道 `?scenario=device-effects`(受控可上图样例;真实环境仍是清单-only,
+ * 决策票 07 明写"不得从纯逻辑断言推断现场特效已生效")。
+ */
+import { cdpLaunch } from './cdp-driver.mjs'
+
+const BASE = process.env.COCKPIT_BASE || 'http://localhost:8000'
+const CHROME = process.env.CHROME_PATH || 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe'
+const SCENARIO = process.env.REPRO_SCENARIO || 'device-effects'
+const ENTRY_URL = process.env.COCKPIT_URL || `${BASE}/?scenario=${SCENARIO}#/cockpit`
+
+const results = []
+function check(id, pass, evidence) {
+    results.push({ id, pass })
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${id}  ::  ${evidence === undefined ? '' : String(evidence)}`)
+}
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
+
+const browser = await cdpLaunch({ executablePath: CHROME, width: 1680, height: 950 })
+console.log(`Chrome: ${await browser.version()}`)
+const page = await browser.newPage()
+const errors = []
+page.on('pageerror', (e) => errors.push(String(e && e.message ? e.message : e)))
+page.on('console', (m) => { if (m.type() === 'error') errors.push(`console.error: ${m.text()}`) })
+
+await page.goto(ENTRY_URL, { timeout: 60000 })
+await page.waitForSelector('.cockpit-shell', { timeout: 20000 })
+if (await page.$('[data-cockpit-intro]')) {
+    await page.click('[data-cockpit-intro]')
+    await page.waitForSelector('[data-cockpit-intro]', { hidden: true, timeout: 10000 }).catch(() => {})
+}
+await page.waitForFunction(
+    () => ['ready', 'failed', 'disabled'].includes(document.querySelector('.cockpit-shell__map .map-canvas')?.dataset.mapEngine),
+    { timeout: 40000 },
+).catch(() => {})
+await page.evaluate(() => {
+    const dev = window.__cockpitMap
+    if (!dev || !dev.mapSummary) return
+    let steps = 0
+    const step = () => {
+        const state = dev.mapSummary.getState()
+        if (!state || ['complete', 'empty', 'failed', 'expired'].includes(state.phase) || steps >= 20) return
+        dev.mapSummary.advance(); steps += 1; window.setTimeout(step, 220)
+    }
+    step()
+})
+await page.waitForFunction(() => {
+    const dev = window.__cockpitMap
+    const s = dev && dev.mapSummary ? dev.mapSummary.getState() : null
+    return !s || ['complete', 'empty', 'failed', 'expired'].includes(s.phase)
+}, { timeout: 20000 }).catch(() => {})
+await page.waitForFunction(() => {
+    const dev = window.__cockpitMap
+    const s = dev && dev.getCameraState ? dev.getCameraState() : null
+    return !s || s.settled === true
+}, { timeout: 15000 }).catch(() => {})
+await sleep(1500)
+
+const locate = () => page.evaluate(() => {
+    const dev = window.__cockpitMap
+    const all = dev && dev.getPointMarkerTargets ? dev.getPointMarkerTargets() : []
+    return all.find((item) => item.visible && item.code === 'MP-001') || null
+})
+const readDetail = () => page.evaluate(() => ({
+    level: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.detailLevel || '',
+    pointCode: (document.querySelector('[data-point-detail]') || { dataset: {} }).dataset.pointCode || '',
+}))
+const OFFSETS = [[0, 0], [0, -6], [0, 6], [-6, 0], [6, 0], [0, -12], [0, 12], [-10, -6], [10, 6]]
+
+// 真实鼠标点开 MP-001(贴地形标记会抖几像素,做小网格扫描点击)。
+for (let round = 0; round < 4; round += 1) {
+    const now = await readDetail()
+    if (now.level === 'point' && now.pointCode === 'MP-001') break
+    const marker = await locate()
+    if (marker) {
+        for (const [dx, dy] of OFFSETS) {
+            await page.mouse.move(1200, 300)
+            await sleep(60)
+            await page.mouse.move(marker.x + dx, marker.y + dy)
+            await sleep(130)
+            await page.mouse.click(marker.x + dx, marker.y + dy)
+            await sleep(700)
+            const after = await readDetail()
+            if (after.level === 'point' && after.pointCode === 'MP-001') break
+        }
+    }
+    await sleep(300)
+}
+// 遮罩让真实鼠标能命中画布(既有锚定浮层会盖住画布;仅测试态)。
+await page.evaluate(() => {
+    const detail = document.querySelector('.cockpit-shell__detail')
+    if (detail) {
+        detail.style.pointerEvents = 'none'
+        detail.querySelectorAll('*').forEach((child) => { child.style.pointerEvents = 'none' })
+    }
+})
+
+const opened = await readDetail()
+check('E1-controlled-sample-opens',
+    opened.level === 'point' && opened.pointCode === 'MP-001',
+    `真实点开受控样例点位:level=${opened.level} pointCode=${opened.pointCode}`)
+
+const readEffects = () => page.evaluate(() => {
+    const host = document.querySelector('.cockpit-shell__map .map-canvas')
+    const dev = window.__cockpitMap
+    const state = dev && dev.getDeviceEffects ? dev.getDeviceEffects() : null
+    return {
+        host: host ? {
+            effects: host.dataset.mapDeviceEffects,
+            abnormal: host.dataset.mapDeviceEffectsAbnormal,
+            animated: host.dataset.mapDeviceEffectsAnimated,
+        } : {},
+        enabled: state ? state.enabled : null,
+        degraded: state ? state.degraded : null,
+        running: state ? state.running : null,
+        tick: state ? state.tick : -1,
+        devices: state && state.devices ? state.devices : [],
+    }
+})
+
+// 多帧采样:闪烁(周期 900ms)等动效在两次采样间可能正好落在同一段,
+// 所以按 ~140ms 采 8 帧,用"取值集合的势"判定 —— 比两点比较更稳健,判据也更强
+// (正常/离线要求**全程只有一个取值**)。
+const SAMPLES = []
+for (let index = 0; index < 8; index += 1) {
+    SAMPLES.push(await readEffects())
+    await sleep(140)
+}
+const frameA = SAMPLES[0]
+const frameB = SAMPLES[SAMPLES.length - 1]
+
+const valuesOf = (code, key) => SAMPLES
+    .map((frame) => (frame.devices || []).find((item) => item.code === code))
+    .map((item) => (item && Number.isFinite(item[key]) ? Number(item[key].toFixed(5)) : null))
+    .filter((value) => value !== null)
+const distinct = (code, key) => new Set(valuesOf(code, key)).size
+
+const alarmA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-ALARM')
+const warnA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-WARNING')
+const workA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-WORK')
+const normalA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-NORMAL')
+const offlineA = (frameA.devices || []).find((item) => item.code === 'EQ-EF-OFFLINE')
+
+console.log(`SAMPLES: ${SAMPLES.length} 帧;tick ${frameA.tick}→${frameB.tick};host=${JSON.stringify(frameA.host)}`)
+console.log(`ALARM   scale 取值 ${valuesOf('EQ-EF-ALARM', 'scale').join(',')}`)
+console.log(`WARNING alpha 取值 ${valuesOf('EQ-EF-WARNING', 'alpha').join(',')}`)
+console.log(`WORK    rotation 取值 ${valuesOf('EQ-EF-WORK', 'rotation').join(',')}`)
+console.log(`NORMAL  scale/alpha 取值 ${valuesOf('EQ-EF-NORMAL', 'scale').join(',')} / ${valuesOf('EQ-EF-NORMAL', 'alpha').join(',')}`)
+
+check('E2-effects-enabled-and-running',
+    SCENARIO === 'device-effects-degraded'
+        ? (frameA.enabled === true && frameA.host.effects === 'degraded' && frameA.host.animated === '0' && frameA.running === false)
+        : (frameA.enabled === true && frameA.running === true && frameA.host.effects === 'on' && frameB.tick > frameA.tick),
+    SCENARIO === 'device-effects-degraded'
+        ? `降级场景:enabled=${frameA.enabled} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}`
+        : `enabled=${frameA.enabled} running=${frameA.running} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}(tick ${frameA.tick}→${frameB.tick})`)
+if (SCENARIO === 'device-effects') {
+    check('E3-alarm-pulses-across-frames',
+    distinct('EQ-EF-ALARM', 'scale') >= 2 || distinct('EQ-EF-ALARM', 'alpha') >= 2,
+    `报警脉冲:scale 取值数 ${distinct('EQ-EF-ALARM', 'scale')}、alpha 取值数 ${distinct('EQ-EF-ALARM', 'alpha')}`)
+check('E4-warning-blinks-across-frames',
+    distinct('EQ-EF-WARNING', 'alpha') >= 2,
+    `预警闪烁:alpha 取值数 ${distinct('EQ-EF-WARNING', 'alpha')}(取值 ${valuesOf('EQ-EF-WARNING', 'alpha').join(',')})`)
+check('E5-work-order-spins-across-frames',
+    distinct('EQ-EF-WORK', 'rotation') >= 2,
+    `工单旋转:rotation 取值数 ${distinct('EQ-EF-WORK', 'rotation')}`)
+check('E6-normal-stays-static',
+    distinct('EQ-EF-NORMAL', 'scale') === 1 && distinct('EQ-EF-NORMAL', 'alpha') === 1 && distinct('EQ-EF-NORMAL', 'rotation') === 1,
+    `正常设备**全程静止**:scale ${normalA && normalA.scale}、alpha ${normalA && normalA.alpha}、rotation ${normalA && normalA.rotation}`)
+check('E7-offline-stays-static',
+    distinct('EQ-EF-OFFLINE', 'scale') === 1 && distinct('EQ-EF-OFFLINE', 'alpha') === 1 && distinct('EQ-EF-OFFLINE', 'rotation') === 1,
+    `离线设备**全程静止**:scale ${offlineA && offlineA.scale}、alpha ${offlineA && offlineA.alpha}、rotation ${offlineA && offlineA.rotation}`)
+}
+
+// ── 第 3 层:真实帧差(同一视角两帧,字节不同 ⇒ 画面确实在变)──
+if (SCENARIO === 'device-effects') {
+    const shotA = await page.screenshot()
+    await sleep(500)
+    const shotB = await page.screenshot()
+    check('E8-real-frame-diff-changes',
+        Boolean(shotA) && Boolean(shotB) && shotA !== shotB,
+        `两帧截图字节${shotA === shotB ? '相同(画面没变 → 不合格)' : '不同'}(长度 ${shotA.length} vs ${shotB.length})`)
+}
+
+// ── 降级场景(>30 台异常)另有判据:动画全停 + 异常计数可见 ──
+if (SCENARIO === 'device-effects-degraded') {
+    const degradedNote = await page.evaluate(() => {
+        const note = document.querySelector('[data-device-effects-degraded]')
+        return note ? { text: (note.textContent || '').trim(), abnormal: note.getAttribute('data-device-effects-abnormal') } : null
+    })
+    const allStatic = SAMPLES.every((frame) => (frame.devices || []).every((item) => !item.animated))
+    check('E10-degraded-stops-animation',
+        frameA.host.effects === 'degraded' && frameA.enabled === true && frameA.degraded === true &&
+        frameA.host.animated === '0' && frameA.running === false && allStatic,
+        `降级:host=${frameA.host.effects} running=${frameA.running} 动效台数=${frameA.host.animated}(${SAMPLES.length} 帧全部无动效=${allStatic})`)
+    check('E11-degraded-count-is-visible',
+        Boolean(degradedNote) && degradedNote.abnormal === '31' && frameA.host.abnormal === '31',
+        `异常计数可见:面板「${degradedNote ? degradedNote.text : '(缺失)'}」、data-abnormal=${degradedNote && degradedNote.abnormal}`)
+    check('E12-degraded-keeps-devices-drawn',
+        frameA.devices.length === 31,
+        `降级后**仍画 31 台静态异常标记**(不静默删点):实际 ${frameA.devices.length} 台`)
+}
+
+check('E9-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
+
+await page.close()
+await browser.close()
+const failed = results.filter((r) => !r.pass)
+console.log('')
+console.log(`T47 动效走查(${SCENARIO}):${results.length - failed.length}/${results.length} 项通过。`)
+if (failed.length) { for (const item of failed) console.log(`  · ${item.id}`); process.exit(1) }
+console.log('T47 动效走查全部通过。')

+ 158 - 0
.scratch/cockpit-shell-panel-layout/verify/t47-device-effects.mjs

@@ -0,0 +1,158 @@
+/**
+ * T47 设备状态视觉特效与门控 纯逻辑检查(票 47;决策票 07 的 `## Answer`)。
+ *
+ * 零依赖 node 断言脚本(约定见 t01-panel-catalog.mjs)。
+ * 运行:node .scratch/cockpit-shell-panel-layout/verify/t47-device-effects.mjs
+ *
+ * **这是三层证据里的第 1 层**(领域纯逻辑):映射唯一、未知不猜、门控与 30 台降级可断言。
+ * 它**不能单独作为验收** —— 映射表对不等于画面在动;"真在跑"的证据在
+ * `t47-device-effects-walkthrough.mjs` 的**跨帧快照**与真实帧差里(决策票 07 问题 7)。
+ */
+import {
+    DEVICE_EFFECT,
+    DEFAULT_ABNORMAL_LIMIT,
+    deviceEffectOf,
+    hasDeviceEffect,
+    effectPhaseAt,
+    pulseStrengthAt,
+    blinkStrengthAt,
+    spinAngleAt,
+    planDeviceEffects,
+    degradedAbnormalNote,
+} from '../../../src/modules/cockpit/domain/deviceEffects.js'
+import { DEVICE_STATUS_VISUALS, DEVICE_UNKNOWN_VISUAL } from '../../../src/modules/cockpit/domain/deviceStatus.js'
+import { builtinConfig, normalizeConfig } from '../../../src/modules/cockpit/services/cockpitConfig.js'
+
+const checks = []
+function check(name, pass, detail = '') {
+    checks.push({ name, pass })
+    const rest = detail === '' ? '' : ` — ${detail}`
+    console.log(`${pass ? 'PASS' : 'FAIL'}  ${name}${rest}`)
+}
+
+const device = (code, status) => ({ code, status, pointCode: 'MP-1' })
+
+// ================================================================ A 状态 → 动效映射
+{
+    check('A1-three-attention-states-animate',
+        deviceEffectOf('ALARM') === DEVICE_EFFECT.PULSE &&
+        deviceEffectOf('WARNING') === DEVICE_EFFECT.BLINK &&
+        deviceEffectOf('WORK_ORDER') === DEVICE_EFFECT.SPIN,
+        `ALARM=${deviceEffectOf('ALARM')} WARNING=${deviceEffectOf('WARNING')} WORK_ORDER=${deviceEffectOf('WORK_ORDER')}`)
+    check('A2-normal-offline-have-no-effect',
+        deviceEffectOf('NORMAL') === '' && deviceEffectOf('OFFLINE') === '',
+        `NORMAL='${deviceEffectOf('NORMAL')}' OFFLINE='${deviceEffectOf('OFFLINE')}'(决策票 07:五态全动会把优先级语义淹没)`)
+    check('A3-unknown-does-not-guess-normal',
+        deviceEffectOf('MAINTENANCE') === '' &&
+        deviceEffectOf('') === '' &&
+        deviceEffectOf(undefined) === '',
+        '未知/空/undefined 都无动效(状态都不知道,更不能编一个"正在报警"的样子)')
+    check('A4-effect-lives-in-the-device-table',
+        DEVICE_STATUS_VISUALS.ALARM.effect === 'pulse' && DEVICE_UNKNOWN_VISUAL.effect === null,
+        'effect 维度挂在票 44 的设备独立词表上(不借点位表)')
+}
+
+// ================================================================ B 相位(跨时间采样)
+{
+    // 纯逻辑也要能证明"值随时间变",否则只是映射表对。
+    const samples = [0, 200, 400, 600, 800, 1000, 1200, 1400]
+    const pulses = samples.map((t) => pulseStrengthAt(t))
+    const blinks = samples.map((t) => blinkStrengthAt(t))
+    const spins = samples.map((t) => spinAngleAt(t))
+    check('B1-pulse-varies-over-time',
+        new Set(pulses.map((v) => v.toFixed(4))).size >= 3 && Math.min(...pulses) >= 0 && Math.max(...pulses) <= 1,
+        `脉冲强度采样 ${pulses.map((v) => v.toFixed(2)).join(',')}`)
+    check('B2-blink-varies-over-time',
+        new Set(blinks).size >= 2 && Math.min(...blinks) > 0 && Math.max(...blinks) <= 1,
+        `闪烁强度采样 ${blinks.map((v) => v.toFixed(2)).join(',')}`)
+    check('B3-spin-advances-monotonically-within-period',
+        spins[0] < spins[1] && spins[1] < spins[2] && spins[7] < Math.PI * 2,
+        `旋转角采样 ${spins.map((v) => v.toFixed(2)).join(',')}`)
+    check('B4-phase-is-periodic',
+        Math.abs(effectPhaseAt(DEVICE_EFFECT.PULSE, 0) - effectPhaseAt(DEVICE_EFFECT.PULSE, 1600)) < 1e-9,
+        '相位按周期回绕(周期由 EFFECT_PERIOD_MS 定义)')
+    check('B5-no-effect-has-zero-phase',
+        effectPhaseAt('', 500) === 0 && effectPhaseAt(null, 500) === 0,
+        '无动效的相位恒为 0(不会给正常/离线算出动画)')
+}
+
+// ================================================================ C 门控与降级
+{
+    const rows = [device('EQ-A', 'ALARM'), device('EQ-W', 'WARNING'), device('EQ-O', 'WORK_ORDER'), device('EQ-N', 'NORMAL'), device('EQ-F', 'OFFLINE')]
+
+    const off = planDeviceEffects({ devices: rows, enabled: false, abnormalLimit: 30 })
+    check('C1-disabled-means-no-animation',
+        off.enabled === false && off.animated.length === 0 && off.degraded === false,
+        `特效关闭:动效台数 ${off.animated.length}(静态色/形/文字仍由反应量与实体属性表达)`)
+
+    const on = planDeviceEffects({ devices: rows, enabled: true, abnormalLimit: 30 })
+    check('C2-enabled-animates-only-attention-three',
+        on.enabled === true && on.degraded === false &&
+        on.animated.map((item) => item.code).sort().join(',') === 'EQ-A,EQ-O,EQ-W',
+        `参与动效 ${on.animated.map((item) => `${item.code}:${item.effect}`).join(' ')}(正常/离线不在内)`)
+    check('C3-abnormal-count-is-attention-only',
+        on.abnormalCount === 2,
+        `异常台数 ${on.abnormalCount}(ALARM/WARNING;工单处置不算异常,判据来自票 46 的单一住址)`)
+
+    // 30 台降级:31 台异常 ⇒ 动画全停 + 静态异常标记仍在。
+    const many = Array.from({ length: 31 }, (_, i) => device(`EQ-M${i}`, 'ALARM'))
+    const degraded = planDeviceEffects({ devices: many, enabled: true, abnormalLimit: DEFAULT_ABNORMAL_LIMIT })
+    check('C4-over-limit-degrades-animation',
+        degraded.degraded === true && degraded.animated.length === 0 && degraded.abnormalCount === 31,
+        `31 台异常 ⇒ 降级=${degraded.degraded}、动效台数=${degraded.animated.length}(动画全停,不是"只给前 30 台动")`)
+    check('C5-degraded-keeps-abnormal-visible',
+        degraded.staticAbnormal.length === 31,
+        `降级后仍保留 ${degraded.staticAbnormal.length} 台静态异常标记(绝不静默删点/变成"什么都没画")`)
+    check('C6-degraded-note-tells-the-count',
+        degradedAbnormalNote(degraded) === '31 台异常' && degradedAbnormalNote(on) === '',
+        `降级文案「${degradedAbnormalNote(degraded)}」;未降级不显示计数`)
+
+    // 边界:恰好等于上限**不降级**("超过 30 台"才降)。
+    const exactly = Array.from({ length: 30 }, (_, i) => device(`EQ-E${i}`, 'WARNING'))
+    const atLimit = planDeviceEffects({ devices: exactly, enabled: true, abnormalLimit: 30 })
+    check('C7-exactly-at-limit-does-not-degrade',
+        atLimit.degraded === false && atLimit.animated.length === 30,
+        `恰好 30 台异常:降级=${atLimit.degraded}、动效台数=${atLimit.animated.length}("超过"才降)`)
+
+    // 可调阈值:上限调到 10,40 台异常(23 台异常的场景同构)⇒ 降级。
+    const calibrated = planDeviceEffects({
+        devices: [...Array.from({ length: 23 }, (_, i) => device(`EQ-X${i}`, i % 2 ? 'ALARM' : 'WARNING')),
+            ...Array.from({ length: 17 }, (_, i) => device(`EQ-Y${i}`, 'NORMAL'))],
+        enabled: true,
+        abnormalLimit: 10,
+    })
+    check('C8-limit-is-configurable',
+        calibrated.degraded === true && calibrated.abnormalCount === 23 && calibrated.animated.length === 0,
+        `上限 10 + 23 台异常 ⇒ 降级(与票 08 现场校准同构:已降级、动效台数 0、异常台数 23 仍在)`)
+}
+
+// ================================================================ D 配置(map.effects)
+{
+    const builtin = builtinConfig()
+    check('D1-builtin-defaults',
+        builtin.map.effects.enabled === null && builtin.map.effects.abnormalLimit === 30,
+        `内置默认 enabled=${builtin.map.effects.enabled}(null=跟随构建形态)abnormalLimit=${builtin.map.effects.abnormalLimit}`)
+
+    const explicit = normalizeConfig({ map: { effects: { enabled: true, abnormalLimit: 5 } } })
+    check('D2-explicit-values-win',
+        explicit.config.map.effects.enabled === true && explicit.config.map.effects.abnormalLimit === 5,
+        `显式 true/5 被采纳:${JSON.stringify(explicit.config.map.effects)}`)
+
+    const dirty = normalizeConfig({ map: { effects: { enabled: 'yes', abnormalLimit: 0 } } })
+    check('D3-dirty-values-fall-back-and-report',
+        dirty.config.map.effects.enabled === null &&
+        dirty.config.map.effects.abnormalLimit === 30 &&
+        dirty.issues.some((item) => item.includes('map.effects.enabled')) &&
+        dirty.issues.some((item) => item.includes('map.effects.abnormalLimit')),
+        `脏值退回默认并记 issue:${dirty.issues.filter((i) => i.includes('map.effects')).join(' | ')}`)
+}
+
+const failed = checks.filter((item) => !item.pass)
+console.log('')
+console.log(`T47 设备状态动效检查:${checks.length - failed.length}/${checks.length} 项通过。`)
+if (failed.length) {
+    console.log('未通过:')
+    for (const item of failed) console.log(`  · ${item.name}`)
+    process.exit(1)
+}
+console.log('T47 设备状态动效全部通过。')

+ 19 - 2
src/modules/cockpit/components/PointDetailCard.vue

@@ -130,12 +130,25 @@
                             :data-device-overlap-degraded="deviceOverlap.degraded ? 'yes' : 'no'"
                         >聚合呈现 {{ deviceOverlap.k }} 台<span v-if="deviceOverlap.degraded">(本屏显示 {{ deviceOverlap.maxFan }} 台 / 其余见清单)</span></span>
                     </p>
+                    <!--
+                        票 47:动效降级时的**异常计数**(决策票 07:超过上限动画全停,
+                        改为静态异常标记 + 一行「N 台异常」;绝不静默删点或变成"什么都没画")。
+                        关掉特效时这里**不出现** —— 关的是动效,不是状态显示。
+                    -->
+                    <p
+                        v-if="deviceEffectsDegraded"
+                        class="point-detail__located point-detail__effects-degraded"
+                        data-device-effects-degraded
+                        :data-device-effects-abnormal="deviceEffectsAbnormal"
+                    >
+                        异常设备较多({{ deviceEffectsAbnormal }} 台)· 动效已暂停,异常仍以静态标记呈现
+                    </p>
                     <p v-if="deviceNotice.risky" class="point-detail__located" data-device-cross-endpoint>
                         {{ deviceNotice.text }}
                     </p>
                     <ul class="point-detail__devices" data-device-layer-list>
                         <li
-                            v-for="row in deviceListRows"
+                            v-for="row in hitDeviceRows"
                             :key="`${row.pointCode}|${row.code}`"
                             class="point-detail__device"
                             :class="{ 'is-selectable': row.placement === 'drawn' }"
@@ -290,10 +303,14 @@ const props = defineProps({
 const detailStore = useDetailStore()
 // 票 42:关联设备清单与它的独立图例(总数来自当前快照,不由本组件重算)。
 const deviceLayerStore = useDeviceLayerStore()
-const { rows: deviceRows, counts: deviceCounts, total: deviceLayerTotal, legend: deviceLegend, overlap: deviceOverlap, notice: deviceNotice } =
+const { rows: deviceRows, counts: deviceCounts, total: deviceLayerTotal, legend: deviceLegend, overlap: deviceOverlap, effectsPlan: deviceEffects, notice: deviceNotice } =
     storeToRefs(deviceLayerStore)
 const { selection, pointLoading, deviceLoading, sections } = storeToRefs(detailStore)
 
+/** 票 47:动效是否因异常设备过多而降级(降级时显示计数;关掉特效时不显示)。 */
+const deviceEffectsDegraded = computed(() => Boolean(deviceEffects.value && deviceEffects.value.degraded))
+const deviceEffectsAbnormal = computed(() => (deviceEffects.value ? deviceEffects.value.abnormalCount : 0))
+
 const level = computed(() => selection.value.level)
 const pointSection = computed(() => sections.value.pointDetail)
 const pointData = computed(() => (pointSection.value && pointSection.value.data) || null)

+ 128 - 0
src/modules/cockpit/domain/deviceEffects.js

@@ -0,0 +1,128 @@
+/**
+ * 设备状态**动效**计划(票 47;决策票 07 的 `## Answer`)。
+ *
+ * 决策票 07 已锁死的语义(本文件只实现,不重议):
+ *   · 只有**三种需处置状态**有动效:`ALARM` 扩散脉冲 / `WARNING` 闪烁 / `WORK_ORDER` 旋转环;
+ *   · `NORMAL` 与 `OFFLINE` **无动效**(五态全动会把画面淹没、优先级语义反而丢掉);
+ *   · 未知状态(`UNKNOWN`)**也无动效** —— 状态都不知道,更不能给它编一个"正在报警"的样子;
+ *   · 同屏**异常设备超过上限**(默认 30)时**动画全停**,降级为静态异常标记 + 一行「N 台异常」计数;
+ *     降级后**仍看得出有异常**(静态标记 + 计数),绝不静默删点或变成"什么都没画"。
+ *
+ * 三层证据里的**第 1 层**(领域纯逻辑)由本文件承担:映射唯一、未知不猜、门控与降级可断言。
+ * 它**不能单独作为验收** —— 映射表对不等于画面在动;"真在跑"的证据在引擎侧的跨帧快照
+ * 与真实帧差(见 `verify/t47-device-effects*.mjs`)。
+ *
+ * 纯函数、不碰 Cesium/Vue/DOM。
+ */
+import { deviceVisualForStatus } from './deviceStatus.js'
+// 「需要处置」的判据**只有一个住址**(票 46 已建):`ALARM`/`WARNING`。
+// 本文件不另造一份"什么算异常"的定义 —— 那正是两张表各说各话的来源。
+import { isAttentionDeviceStatus } from './modeLayerPlan.js'
+
+/** 动效键(决策票 07 的三个;正常/离线/未知都是"无动效",不设第四个键)。 */
+export const DEVICE_EFFECT = {
+    PULSE: 'pulse',
+    BLINK: 'blink',
+    SPIN: 'spin',
+}
+
+/** 同屏异常设备上限的缺省值(决策票 07 定 30,可由 `map.effects.abnormalLimit` 现场调)。 */
+export const DEFAULT_ABNORMAL_LIMIT = 30
+
+/** 各动效的周期(毫秒)。数值属表现层,决策票 07 留实现与走查现场校准。 */
+export const EFFECT_PERIOD_MS = {
+    [DEVICE_EFFECT.PULSE]: 1600,
+    [DEVICE_EFFECT.BLINK]: 900,
+    [DEVICE_EFFECT.SPIN]: 2400,
+}
+
+/** 该设备状态对应哪个动效;无动效回空串(**不是** `null`,便于字符串判空)。 */
+export function deviceEffectOf(status) {
+    const visual = deviceVisualForStatus(status)
+    return visual && visual.effect ? visual.effect : ''
+}
+
+/** 该设备是否会被动画(有动效键)。 */
+export function hasDeviceEffect(device) {
+    return deviceEffectOf(device && device.status) !== ''
+}
+
+/**
+ * 动效相位(0..1,纯函数)。
+ *
+ * 为什么把它做成**纯函数**而不是在引擎里算:走查要证明"同一设备的逐点属性跨帧确实变化",
+ * 而这需要能对**任意时间点**算出期望值 —— 引擎里算就只能拿两份 JS 数据互比(恒真断言)。
+ * @param {string} effect 动效键
+ * @param {number} timeMs 绝对时间(毫秒)
+ * @returns {number} 0..1 的相位;无动效回 0
+ */
+export function effectPhaseAt(effect, timeMs) {
+    const period = EFFECT_PERIOD_MS[effect]
+    if (!Number.isFinite(period) || period <= 0) return 0
+    const t = Number.isFinite(timeMs) ? timeMs : 0
+    const raw = (t % period) / period
+    return raw < 0 ? raw + 1 : raw
+}
+
+/** 脉冲强度(0..1 的三角波:由内向外扩张渐隐的节奏)。 */
+export function pulseStrengthAt(timeMs) {
+    const phase = effectPhaseAt(DEVICE_EFFECT.PULSE, timeMs)
+    return phase <= 0.5 ? phase * 2 : (1 - phase) * 2
+}
+
+/** 闪烁强度(方波:亮/暗两段,段间保留一点过渡避免频闪刺眼)。 */
+export function blinkStrengthAt(timeMs) {
+    const phase = effectPhaseAt(DEVICE_EFFECT.BLINK, timeMs)
+    return phase < 0.5 ? 1 : 0.25
+}
+
+/** 旋转角(弧度,0..2π 线性推进)。 */
+export function spinAngleAt(timeMs) {
+    return effectPhaseAt(DEVICE_EFFECT.SPIN, timeMs) * Math.PI * 2
+}
+
+/**
+ * 生成动效计划(纯函数,门控与降级的唯一判定处)。
+ *
+ * @param {object} input
+ * @param {Array} input.devices 领域设备行(含 `code`/`status`)
+ * @param {boolean} input.enabled `map.effects.enabled` 归一化后的结果(缺省跟随构建形态)
+ * @param {number} [input.abnormalLimit] 同屏异常设备上限(缺省 30)
+ * @returns {{enabled:boolean, limit:number, abnormalCount:number, degraded:boolean,
+ *   animated:Array<{code:string,status:string,effect:string}>, staticAbnormal:Array<string>}}
+ */
+export function planDeviceEffects({ devices = [], enabled = false, abnormalLimit = DEFAULT_ABNORMAL_LIMIT } = {}) {
+    const rows = (Array.isArray(devices) ? devices : []).filter((item) => item && item.code)
+    const limit = Number.isFinite(abnormalLimit) && abnormalLimit >= 0 ? Math.floor(abnormalLimit) : DEFAULT_ABNORMAL_LIMIT
+    // 异常=「需要处置」(`ALARM`/`WARNING`),判据来自票 46 的单一住址,本文件不另定义。
+    const abnormalCount = rows.filter((item) => isAttentionDeviceStatus(item.status)).length
+    // 降级判据用**异常台数**(决策票 07 原句"同屏异常设备超过 30 台时降级");
+    // 降级后**动画全停**(票 08 现场校准记录:23 台异常 → 已降级、动效台数 0),
+    // 而不是"只给前 30 台动画" —— 后者会让值班人员以为后面的设备没问题。
+    const degraded = enabled === true && abnormalCount > limit
+    const animated = enabled === true && !degraded
+        ? rows.filter((item) => hasDeviceEffect(item)).map((item) => ({
+            code: item.code,
+            status: item.status,
+            effect: deviceEffectOf(item.status),
+        }))
+        : []
+    return {
+        enabled: enabled === true,
+        limit,
+        abnormalCount,
+        degraded,
+        animated,
+        // 降级时**仍要看得出有异常**:这些设备保留静态异常标记(引擎侧不改它们的静态色/形)。
+        staticAbnormal: degraded ? rows.filter((item) => isAttentionDeviceStatus(item.status)).map((item) => item.code) : [],
+    }
+}
+
+/**
+ * 一行「N 台异常」计数文案(降级时显示;不降级时不需要,因为每台都在动)。
+ * 计数口径=**异常设备**(`ALARM`/`WARNING`),不是"所有带动效的设备"。
+ */
+export function degradedAbnormalNote(plan) {
+    if (!plan || plan.degraded !== true) return ''
+    return `${plan.abnormalCount} 台异常`
+}

+ 10 - 7
src/modules/cockpit/domain/deviceStatus.js

@@ -39,15 +39,18 @@ export const DEVICE_STATUS = {
  * `priority` 数值沿用后端给的状态优先级(1 最紧急),前端不自定序。
  */
 export const DEVICE_STATUS_VISUALS = {
-    ALARM: { key: 'ALARM', tone: 'lv1', shape: 'triangle', label: '报警', priority: 1 },
-    WARNING: { key: 'WARNING', tone: 'lv2', shape: 'diamond', label: '预警', priority: 2 },
-    WORK_ORDER: { key: 'WORK_ORDER', tone: 'lv3', shape: 'square', label: '工单处置', priority: 3 },
-    NORMAL: { key: 'NORMAL', tone: 'lv4', shape: 'circle', label: '正常', priority: 4 },
-    OFFLINE: { key: 'OFFLINE', tone: 'neutral', shape: 'ring', label: '离线', priority: 5 },
+    ALARM: { key: 'ALARM', tone: 'lv1', shape: 'triangle', label: '报警', priority: 1, effect: 'pulse' },
+    WARNING: { key: 'WARNING', tone: 'lv2', shape: 'diamond', label: '预警', priority: 2, effect: 'blink' },
+    WORK_ORDER: { key: 'WORK_ORDER', tone: 'lv3', shape: 'square', label: '工单处置', priority: 3, effect: 'spin' },
+    NORMAL: { key: 'NORMAL', tone: 'lv4', shape: 'circle', label: '正常', priority: 4, effect: null },
+    OFFLINE: { key: 'OFFLINE', tone: 'neutral', shape: 'ring', label: '离线', priority: 5, effect: null },
 }
 
-/** 认不出的设备状态:如实说「不知道」,**不落到任何一个真实状态上**(尤其不猜 NORMAL)。 */
-export const DEVICE_UNKNOWN_VISUAL = { key: 'UNKNOWN', tone: 'neutral', shape: 'hollowDiamond', label: '状态未发布', priority: 6 }
+/**
+ * 认不出的设备状态:如实说「不知道」,**不落到任何一个真实状态上**(尤其不猜 NORMAL)。
+ * `effect: null` —— 状态都不知道,**更不能给它编一个动效**(那会把"未知"画成"正在报警")。
+ */
+export const DEVICE_UNKNOWN_VISUAL = { key: 'UNKNOWN', tone: 'neutral', shape: 'hollowDiamond', label: '状态未发布', priority: 6, effect: null }
 
 /** 设备状态字符串 → 视觉词条;认不出的一律 `DEVICE_UNKNOWN_VISUAL`。 */
 export function deviceVisualForStatus(status) {

+ 99 - 4
src/modules/cockpit/mocks/contractMocks.js

@@ -43,6 +43,32 @@ const OVERLAP_DEVICE_LAYER_COORDS = [
 const OVERLAP_DEGRADED_CODES = Array.from({ length: 11 }, (_, index) => `EQ-DG-${String(index + 1).padStart(3, '0')}`)
 const OVERLAP_DEGRADED_COORDS = Array.from({ length: 11 }, () => ({ longitude: 110.3989, latitude: 28.4349 }))
 
+/**
+ * 票 47:**设备状态动效样例** —— 五态各一台(坐标各自错开,不触发重叠聚合),
+ * 用来验证「只有报警/预警/工单三种需处置状态有动效,正常/离线完全静止」。
+ */
+const EFFECTS_SAMPLE = [
+    { deviceCode: 'EQ-EF-ALARM', deviceName: '动效样例-报警设备', deviceStatus: 'ALARM' },
+    { deviceCode: 'EQ-EF-WARNING', deviceName: '动效样例-预警设备', deviceStatus: 'WARNING' },
+    { deviceCode: 'EQ-EF-WORK', deviceName: '动效样例-工单处置设备', deviceStatus: 'WORK_ORDER' },
+    { deviceCode: 'EQ-EF-NORMAL', deviceName: '动效样例-正常设备', deviceStatus: 'NORMAL' },
+    { deviceCode: 'EQ-EF-OFFLINE', deviceName: '动效样例-离线设备', deviceStatus: 'OFFLINE' },
+].map((item, index) => ({
+    ...item,
+    // 错开约 80m,既不重叠也不会因为贴太近互相遮挡。
+    longitude: 110.3991 + index * 0.0008,
+    latitude: 28.4351 + index * 0.0006,
+}))
+
+/** 票 47:**降级样例** —— 31 台报警设备(超过 `map.effects.abnormalLimit` 默认 30)。 */
+const EFFECTS_DEGRADED_SAMPLE = Array.from({ length: 31 }, (_, index) => ({
+    deviceCode: `EQ-EF-D${String(index + 1).padStart(2, '0')}`,
+    deviceName: `动效降级设备-${index + 1}`,
+    deviceStatus: 'ALARM',
+    longitude: 110.3991 + (index % 6) * 0.0008,
+    latitude: 28.4351 + Math.floor(index / 6) * 0.0006,
+}))
+
 // ---------------------------------------------------------------- 地图摘要
 
 const MAP_POINTS = [
@@ -382,13 +408,20 @@ function mapSnapshotPayload(scenario, path, overrides = {}) {
         })
     }
 
-    // 票 42:受控有效坐标样例 —— 地图侧的点位 `deviceCodes` 必须与详情 `devices[]` **一致**,
+    // 票 42/45/47:受控有效坐标样例 —— 地图侧的点位 `deviceCodes` 必须与详情 `devices[]` **一致**,
     // 否则第二台设备会被判「与当前快照不一致」而禁止上图(那是另一条要验的语义,不该混在这个基线里)。
-    if (scenario === 'device-layer' || scenario === 'device-layer-overlap' || scenario === 'device-layer-overlap-degraded') {
-        const codes = scenario === 'device-layer-overlap-degraded' ? OVERLAP_DEGRADED_CODES : DEVICE_LAYER_CODES
+    const deviceLayerCodes = {
+        'device-layer': DEVICE_LAYER_CODES,
+        'device-layer-overlap': DEVICE_LAYER_CODES,
+        'device-layer-overlap-degraded': OVERLAP_DEGRADED_CODES,
+        // 票 47:动效样例与降级样例的设备编码(与详情一一对应)。
+        'device-effects': EFFECTS_SAMPLE.map((item) => item.deviceCode),
+        'device-effects-degraded': EFFECTS_DEGRADED_SAMPLE.map((item) => item.deviceCode),
+    }[scenario]
+    if (deviceLayerCodes) {
         const points = MAP_POINTS.map((item) =>
             item.pointCode === DEVICE_LAYER_POINT
-                ? { ...item, deviceCodes: codes.slice() }
+                ? { ...item, deviceCodes: deviceLayerCodes.slice() }
                 : item,
         )
         return mapSnapshotPage({
@@ -493,6 +526,55 @@ function overlapPointDetail({ code, name, status = 'NORMAL' }) {
     return pointDetail({ code, name, status, deviceCoord: OVERLAP_DEVICE_LAYER_COORDS })
 }
 
+/**
+ * 票 47:按给定设备清单造详情载荷(每台都带**设备自身**的有效坐标 ⇒ `renderable=true`)。
+ *
+ * 决策票 07 的可断言口径要求:动效只能在**受控可上图样例**上验证
+ * (真实环境仍是清单-only 时不得从纯逻辑断言推断现场特效已生效)。
+ */
+function effectsPointDetail({ code, name, samples, pointStatus = 'ALARM' }) {
+    const counts = { ALARM: 0, WARNING: 0, WORK_ORDER: 0, NORMAL: 0, OFFLINE: 0 }
+    const devices = samples.map((item, index) => {
+        const deviceStatus = item.deviceStatus
+        if (counts[deviceStatus] !== undefined) counts[deviceStatus] += 1
+        return {
+            deviceCode: item.deviceCode,
+            deviceName: item.deviceName,
+            deviceTypeName: ['压力计', '流量计', '液位计'][index % 3],
+            deviceStatus,
+            statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线' }[deviceStatus] || '',
+            statusReasons: deviceStatus === 'ALARM' ? ['压力超高'] : deviceStatus === 'WARNING' ? ['液位偏高'] : [],
+            statusTime: '2026-09-02T09:59:00+08:00',
+            ...deviceCoordFields(samples, index),
+        }
+    })
+    return {
+        requestId: 'mock-point-detail',
+        apiVersion: 'v1',
+        readOnly: true,
+        monitoringPointCode: code,
+        monitoringPointName: name,
+        pointStatus,
+        statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[pointStatus] || '',
+        statusReasons: [{ status: pointStatus, deviceCode: devices[0] ? devices[0].deviceCode : '', reason: '压力超高' }],
+        deviceStatistics: {
+            totalDeviceCount: devices.length,
+            alarmDeviceCount: counts.ALARM,
+            warningDeviceCount: counts.WARNING,
+            workOrderDeviceCount: counts.WORK_ORDER,
+            normalDeviceCount: counts.NORMAL,
+            offlineDeviceCount: counts.OFFLINE,
+        },
+        devices,
+        coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
+        statusDictionary: ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE', 'EMPTY'].map((item, index) => ({
+            status: item,
+            label: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[item],
+            priority: index + 1,
+        })),
+    }
+}
+
 function pointDetailPayload(scenario, requestedCode = '') {
     if (scenario === 'empty') {
         return {
@@ -538,6 +620,19 @@ function pointDetailPayload(scenario, requestedCode = '') {
         return degradedOverlapPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点' })
     }
 
+    /**
+     * 票 47:**设备状态动效样例** —— 五态各一台,验证「只有报警/预警/工单有动效、
+     * 正常/离线完全静止」(决策票 07 的核心取舍)。全部设备坐标准入有效。
+     */
+    if (scenario === 'device-effects') {
+        return effectsPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点', samples: EFFECTS_SAMPLE, pointStatus: 'ALARM' })
+    }
+
+    /** 票 47:**动效降级样例** —— 31 台报警设备(> 默认上限 30)⇒ 动画全停 + 静态异常标记 + 计数。 */
+    if (scenario === 'device-effects-degraded') {
+        return effectsPointDetail({ code: DEVICE_LAYER_POINT, name: '城南测试监测点', samples: EFFECTS_DEGRADED_SAMPLE, pointStatus: 'ALARM' })
+    }
+
     if (scenario === 'point-detail-order') {
         const devices = [
             { deviceCode: 'EQ-ORDER-NORMAL', deviceName: '顺序样例-正常设备', deviceTypeName: '压力计', deviceStatus: 'NORMAL', statusLabel: '正常', statusReasons: [], statusTime: '2026-09-02T09:59:30+08:00' },

+ 173 - 0
src/modules/cockpit/services/cesiumEngine.js

@@ -46,6 +46,9 @@ import {
 import 'cesium/Build/Cesium/Widgets/widgets.css'
 // 强调的**判定**在 domain(纯函数,可脱浏览器断言);本层只把结论写成显示参数。
 import { EMPHASIS_STYLE, shouldEmphasize } from '../domain/mapEmphasis.js'
+// 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
+// 本层只把相位写成 billboard 的 scale/alpha/rotation。
+import { DEVICE_EFFECT, blinkStrengthAt, pulseStrengthAt, spinAngleAt } from '../domain/deviceEffects.js'
 
 const DEG = Math.PI / 180
 
@@ -795,6 +798,10 @@ function deviceMarkerSource(viewer) {
 export function syncAssociatedDevices(viewer, devices, colorOf, imageOf, overlapGroups = []) {
     const source = deviceMarkerSource(viewer)
     source.entities.removeAll()
+    // 票 47:实体是**整份重建**的,动效基准与循环必须一起复位
+    // (否则基准表里留着上一批实体的 id,动效会写到不存在的对象上)。
+    deviceBaseStyle.clear()
+    stopDeviceEffects()
 
     let rendered = 0
     let skipped = 0
@@ -1018,6 +1025,10 @@ export function readOverlapTargets(viewer) {
 }
 
 export function clearAssociatedDevices(viewer) {
+    // 票 47:图层撤下 ⇒ 特效随图层一起消失(决策票 07:不需要单独的消失逻辑,
+    // 因为特效就挂在图层上)。这里显式停循环,避免空转写帧。
+    stopDeviceEffects()
+    deviceBaseStyle.clear()
     deviceMarkerSource(viewer).entities.removeAll()
 }
 
@@ -1124,11 +1135,173 @@ export function applyDeviceReactions(viewer, reactions, styleOf) {
         // 选中外环:Cesium billboard 自带的描边,不用新建实体。
         billboard.outlineWidth = style.ring ? 3 : 0
         billboard.outlineColor = Color.WHITE
+        // 票 47:记下**基准**(动效在此之上做乘性修饰,不覆盖反应量)。
+        deviceBaseStyle.set(entity.id, { scale: style.scale, alpha: style.alpha })
         written += 1
     }
     return written
 }
 
+/**
+ * 票 47:设备状态**动效**驱动器(决策票 07 的三个动效 + 门控 + 降级)。
+ *
+ * ── 为什么要和票 43 的"反应量"**合成**而不是各写各的 ──
+ * `applyDeviceReactions()` 已经按选中/hover/弱化写 `scale` 与 `alpha`;动效也要动这两个属性。
+ * 若动效直接赋值,两者会互相抹掉(表现为"选中态闪一下就没了"或"动画把弱化顶回去")。
+ * 因此这里记住**反应量写下的基准值**(`deviceBaseStyle`),动效只在基准上做**乘性**修饰。
+ *
+ * ── 为什么不用 `CallbackProperty` ──
+ * `isConstant=false` 的回调会持续请求新帧(票 02 已核),代价被限制在**异常设备**上是对的,
+ * 但更简单且可控的做法是:一个 rAF 循环只写**参与动效的实体**,其余实体一个字节不动。
+ * 场景本身未开 `requestRenderMode`,所以这个循环不额外改变渲染节奏。
+ *
+ * ── 载体是场景内 billboard,**不是后处理级** ──
+ * 决策票 07 已裁定:特效走 billboard ⇒「先特效、后雾」自动成立,不占 `PostProcessStageCollection`、
+ * 与雾无排序冲突,也不新增穿雾例外。
+ */
+const deviceEffectState = {
+    viewer: null,
+    plan: null,
+    selectedCode: '',
+    raf: 0,
+    tick: 0,
+}
+
+/** 反应量写下的**基准** scale/alpha(动效在其上做乘性修饰)。键=实体 id。 */
+const deviceBaseStyle = new Map()
+
+function baseStyleOf(entityId) {
+    return deviceBaseStyle.get(entityId) || { scale: 1, alpha: 1 }
+}
+
+function animatedByCode(plan) {
+    const map = new Map()
+    for (const item of (plan && plan.animated) || []) map.set(item.code, item.effect)
+    return map
+}
+
+function writeEffectFrame() {
+    const viewer = deviceEffectState.viewer
+    const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
+    if (!viewer || !source) return
+    const plan = deviceEffectState.plan
+    const animated = animatedByCode(plan)
+    const now = Date.now()
+    // 选中那台**额外强化**(决策票 07:选中不等于"只有它有特效",而是它更强)。
+    const amp = 0.34
+    const ampSelected = 0.55
+
+    for (const entity of source.entities.values) {
+        const billboard = entity.billboard
+        if (!billboard) continue
+        const properties = entity.properties || {}
+        const code = properties.deviceCode && properties.deviceCode.getValue ? properties.deviceCode.getValue() : ''
+        const base = baseStyleOf(entity.id)
+        const effect = code ? animated.get(code) : ''
+        if (!effect) {
+            // 不参与动效(正常/离线/未知,或特效关闭/降级):写回**基准**,不留上一帧的残留。
+            billboard.scale = base.scale
+            billboard.color = Color.WHITE.withAlpha(base.alpha)
+            billboard.rotation = 0
+            continue
+        }
+        const isSelected = deviceEffectState.selectedCode && code === deviceEffectState.selectedCode
+        const strength = isSelected ? ampSelected : amp
+        if (effect === DEVICE_EFFECT.SPIN) {
+            // 旋转环:只转角度,不呼吸(与脉冲/闪烁区分开)。
+            billboard.rotation = spinAngleAt(now)
+            billboard.scale = base.scale
+            billboard.color = Color.WHITE.withAlpha(base.alpha)
+            continue
+        }
+        const factor = effect === DEVICE_EFFECT.BLINK ? blinkStrengthAt(now) : pulseStrengthAt(now)
+        billboard.scale = base.scale * (1 + strength * factor)
+        // 脉冲/闪烁同时走**透明度**:只放大不透明度变化时,远看几乎看不出在动。
+        billboard.color = Color.WHITE.withAlpha(base.alpha * (0.45 + 0.55 * factor))
+        billboard.rotation = 0
+    }
+    deviceEffectState.tick += 1
+}
+
+function ensureEffectLoop() {
+    if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return
+    if (deviceEffectState.raf) return
+    const step = () => {
+        if (!deviceEffectState.raf) return
+        writeEffectFrame()
+        deviceEffectState.raf = window.requestAnimationFrame(step)
+    }
+    deviceEffectState.raf = window.requestAnimationFrame(step)
+}
+
+/**
+ * 应用动效计划(由服务层在设备图层同步后调用)。
+ * @param {Viewer} viewer
+ * @param {object} plan `domain/deviceEffects.js` 的 `planDeviceEffects()` 输出
+ * @param {object} [options] `{ selectedCode }` 选中设备额外强化
+ * @returns {boolean} 是否启动了动效
+ */
+export function applyDeviceEffects(viewer, plan, options = {}) {
+    deviceEffectState.viewer = viewer || null
+    deviceEffectState.plan = plan || null
+    deviceEffectState.selectedCode = (options && options.selectedCode) || ''
+    const running = Boolean(viewer && plan && plan.enabled === true && !plan.degraded && plan.animated && plan.animated.length)
+    if (running) ensureEffectLoop()
+    // 即使不跑动效也要写一帧:把上一轮可能残留的放大/透明度**写回基准**。
+    writeEffectFrame()
+    return running
+}
+
+/** 停止动效并写回基准(图层撤下 / 详情关闭 / 特效被关掉)。 */
+export function stopDeviceEffects() {
+    if (typeof window !== 'undefined' && deviceEffectState.raf && typeof window.cancelAnimationFrame === 'function') {
+        window.cancelAnimationFrame(deviceEffectState.raf)
+    }
+    deviceEffectState.raf = 0
+    deviceEffectState.plan = null
+    deviceEffectState.selectedCode = ''
+    writeEffectFrame()
+}
+
+/**
+ * 只读读回每台设备的**动效状态与当前帧属性**(走查的"真在跑"证据)。
+ *
+ * 三层证据的第 2 层(场景图跨帧快照):走查对同一设备**隔帧读两次**,
+ * 断言 `scale`/`alpha`(脉冲/闪烁)或 `rotation`(旋转环)确实变了,
+ * 而正常/离线的设备**完全不变**。这样就不是"拿两份 JS 数据互比"的恒真断言。
+ */
+export function readDeviceEffects(viewer) {
+    const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
+    if (!source) return { tick: deviceEffectState.tick, devices: [] }
+    const animated = animatedByCode(deviceEffectState.plan)
+    const devices = []
+    for (const entity of source.entities.values) {
+        const billboard = entity.billboard
+        if (!billboard) continue
+        const properties = entity.properties || {}
+        const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
+        const code = get('deviceCode')
+        if (!code) continue
+        const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null
+        devices.push({
+            code,
+            status: get('deviceStatus'),
+            effect: animated.get(code) || '',
+            animated: animated.has(code),
+            scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
+            alpha: color ? color.alpha : null,
+            rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
+        })
+    }
+    return {
+        tick: deviceEffectState.tick,
+        running: deviceEffectState.raf !== 0,
+        degraded: Boolean(deviceEffectState.plan && deviceEffectState.plan.degraded),
+        enabled: Boolean(deviceEffectState.plan && deviceEffectState.plan.enabled),
+        devices,
+    }
+}
+
 /**
  * 选中设备的**归属引线**:从设备点连到监测点标记中心(票 04/12 的面板锚点)。
  *

+ 19 - 0
src/modules/cockpit/services/cockpitConfig.js

@@ -124,6 +124,9 @@ export function builtinConfig() {
             },
             // 票 45:重叠设备展开阈值(K>maxFan 降级为聚合符号 + 清单提示)。
             overlap: { maxFan: 10 },
+            // 票 47:设备状态动效(决策票 07)。`enabled: null` = 跟随构建形态;
+            // `abnormalLimit` = 同屏异常设备上限,超过即动画全停并降级为静态 + 计数。
+            effects: { enabled: null, abnormalLimit: 30 },
             // 票 32:UI 形态。null = 跟随构建模式(dev 显示调试信息,生产构建自动收敛)
             ui: { debug: null },
             cesium: { enabled: true, baseUrl: '' },
@@ -442,6 +445,22 @@ export function normalizeConfig(raw) {
     }
     config.map.filters = { regionMap }
 
+    // 票 47:设备状态动效开关(决策票 07 的问题 6)。
+    // 与票 32 的 `map.ui.debug` **完全同构**:缺省 `null` = 跟随构建形态
+    // (开发构建开特效、生产构建关,正式环境不该有动画压满屏);
+    // 现场要在生产包里看特效就显式写 `true`。脏值退回默认并记 issue,不静默吞掉。
+    const effects = isPlainObject(map.effects) ? map.effects : {}
+    const abnormalLimit = effects.abnormalLimit
+    config.map.effects = {
+        enabled: readBoolean(effects.enabled, null, issues, 'map.effects.enabled'),
+        // 同屏异常设备上限(决策票 07 定 30):超过则动画全停、降级为静态异常标记 + 计数。
+        abnormalLimit: abnormalLimit === undefined || abnormalLimit === null
+            ? 30
+            : (Number.isInteger(abnormalLimit) && abnormalLimit >= 1
+                ? abnormalLimit
+                : (issues.push(`map.effects.abnormalLimit 应为正整数,收到 ${JSON.stringify(abnormalLimit)},已退回 30`), 30)),
+    }
+
     const overlap = isPlainObject(map.overlap) ? map.overlap : {}
     const maxFan = overlap.maxFan
     if (maxFan === undefined || maxFan === null) {

+ 80 - 0
src/modules/cockpit/services/mapService.js

@@ -145,6 +145,22 @@ function isUiDebug() {    const mapConfig = getCockpitConfig().map || {}
     return !!(import.meta.env && import.meta.env.DEV)
 }
 
+/**
+ * 票 47:设备状态动效的**解析后**选项(决策票 07 问题 6)。
+ *
+ * 与票 32 的 `isUiDebug()` 完全同构:配置显式给布尔值就听配置,
+ * 否则跟随构建模式(开发构建开、生产构建关 —— 正式环境不该有动画压满屏)。
+ * 门控判定本身仍在 domain(`planDeviceEffects`),这里只解析"该不该开、上限多少"。
+ */
+export function resolveDeviceEffectsOptions() {
+    const mapConfig = getCockpitConfig().map || {}
+    const effects = mapConfig.effects || {}
+    const configured = effects.enabled
+    const enabled = typeof configured === 'boolean' ? configured : !!(import.meta.env && import.meta.env.DEV)
+    const limit = Number.isInteger(effects.abnormalLimit) && effects.abnormalLimit >= 1 ? effects.abnormalLimit : 30
+    return { enabled, abnormalLimit: limit }
+}
+
 let instance = null
 let sequence = 0
 let createdCount = 0
@@ -526,10 +542,16 @@ function publishDevHook() {
         // 票 45:重叠聚合的只读状态与交互入口(聚合符号不写设备选中态)。
         getOverlapState: () => getOverlapState(),
         getOverlapTargets: () => getOverlapTargets(),
+        // 票 45:只读回点位标记的窗口坐标(真实鼠标走查要点的位置;不触发任何拾取)。
+        getPointMarkerTargets: () => (instance && instance.engine && instance.viewer && instance.engine.readPointMarkerTargets
+            ? instance.engine.readPointMarkerTargets(instance.viewer)
+            : []),
         toggleOverlap: (pointCode) => toggleOverlap(pointCode),
         collapseOverlap: (pointCode) => collapseOverlap(pointCode),
         // 票 42:关联监测设备临时图层(走查读独立读数,验证它不进摘要口径)。
         getAssociatedDevices: () => getAssociatedDeviceState(),
+        // 票 47:设备状态动效的只读读数(计划 + 逐点当前帧属性)。
+        getDeviceEffects: () => getDeviceEffectState(),
         clearAssociatedDevices: () => clearAssociatedDevices(),
         // 票 43:设备点的对应反应(走查驱动 hover/选中并读三个可断言字段)。
         //
@@ -2425,11 +2447,44 @@ export function syncAssociatedDevices(devices, options = {}) {
     deviceRows = planned
     deviceRendered = result.rendered
     deviceSkipped = result.skipped
+    // 票 47:动效计划随图层一起下发(门控判定在 domain,本层只搬运)。
+    deviceEffectsPlan = options.effects || null
+    applyDeviceEffectsToEngine()
     paintDeviceState()
     paintOverlapState()
     return true
 }
 
+/**
+ * 票 47:把动效计划交给引擎,并把**降级/开关**写成宿主只读字段(走查断言面)。
+ *
+ * 关掉特效 ≠ 关掉状态显示:这里只控制"动效",静态色/形/文字仍由 `applyDeviceReactions` 与
+ * 实体属性表达(决策票 07 问题 6 的原句)。
+ */
+function applyDeviceEffectsToEngine() {
+    if (!instance || !instance.engine || !instance.viewer) {
+        paintDeviceEffects()
+        return false
+    }
+    const running = instance.engine.applyDeviceEffects(instance.viewer, deviceEffectsPlan, {
+        selectedCode: selectedDeviceCode,
+    })
+    paintDeviceEffects()
+    return running
+}
+
+/** 动效读数的宿主字段:`on|off|degraded` + 异常台数 + 参与动效台数。 */
+function paintDeviceEffects() {
+    if (!instance || !isDomHost(instance.host)) return
+    const host = instance.host
+    const plan = deviceEffectsPlan
+    if (!plan || plan.enabled !== true) host.dataset.mapDeviceEffects = 'off'
+    else if (plan.degraded) host.dataset.mapDeviceEffects = 'degraded'
+    else host.dataset.mapDeviceEffects = 'on'
+    host.dataset.mapDeviceEffectsAbnormal = String(plan ? plan.abnormalCount : 0)
+    host.dataset.mapDeviceEffectsAnimated = String(plan && plan.animated ? plan.animated.length : 0)
+}
+
 /**
  * 设备行 → 色键(票 44:色键来自**设备独立词表**,不借用点位表)。
  *
@@ -2458,6 +2513,9 @@ export function clearAssociatedDevices() {
     // 走查与界面都会读到一个不存在的选中态)。
     selectedDeviceCode = ''
     hoveredDeviceCode = ''
+    // 票 47:动效随图层一起消失(计划清空 + 引擎侧已在 clearAssociatedDevices 里停循环)。
+    deviceEffectsPlan = null
+    paintDeviceEffects()
     paintDeviceReactions()
     paintDeviceState()
 }
@@ -2483,6 +2541,19 @@ export function clearOverlapNudge() {
     if (instance && instance.engine && instance.viewer) instance.engine.clearOverlapNudge(instance.viewer)
 }
 
+/**
+ * 票 47:动效的只读读数 —— 计划(门控/降级/异常台数)+ 引擎里每台设备的**当前帧属性**。
+ *
+ * 走查的"真在跑"证据来自后者:对同一设备隔帧读两次,断言 `scale`/`alpha`(脉冲/闪烁)
+ * 或 `rotation`(旋转环)确实变了,而正常/离线设备**完全不变**。
+ */
+export function getDeviceEffectState() {
+    const frames = instance && instance.engine && instance.viewer && instance.engine.readDeviceEffects
+        ? instance.engine.readDeviceEffects(instance.viewer)
+        : { tick: 0, running: false, devices: [] }
+    return { plan: deviceEffectsPlan, ...frames }
+}
+
 export function getOverlapState() {
     const groupKey = overlapExpandedGroup || overlapPreviewGroup
     const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
@@ -2544,6 +2615,12 @@ function paintDeviceState() {
 /** 当前选中设备(`deviceCode`)与三维 hover 设备(`deviceCode`)。 */
 let selectedDeviceCode = ''
 let hoveredDeviceCode = ''
+/**
+ * 票 47:当前设备图层的**动效计划**(`domain/deviceEffects.js` 的输出)。
+ * 由 store 计算后随 `syncAssociatedDevices` 传进来 —— 门控与降级的判定在 domain,
+ * 本层只负责把它交给引擎并暴露可断言读数。
+ */
+let deviceEffectsPlan = null
 
 /**
  * 把反应档位写进引擎,并刷新三个可断言字段。
@@ -2571,6 +2648,9 @@ export function syncDeviceReactions(options = {}) {
 
     if (instance && instance.engine && instance.viewer) {
         instance.engine.applyDeviceReactions(instance.viewer, plan.reactions, reactionStyle)
+        // 票 47:反应量变了要重算动效 —— 动效是**乘在反应量基准上**的,
+        // 不重算的话选中强化的幅度会停在旧的基准上。
+        applyDeviceEffectsToEngine()
         syncDeviceLeadForSelection(devices)
     }
     paintDeviceReactions()

+ 19 - 1
src/modules/cockpit/stores/deviceLayerStore.js

@@ -32,12 +32,15 @@ import { DEVICE_FLIGHT, DIM_THRESHOLD, planDeviceFlight } from '../domain/device
 import { planDeviceAsset } from '../domain/deviceAssets.js'
 import { deviceVisualForStatus } from '../domain/deviceStatus.js'
 import { groupOverlappingDevices } from '../domain/overlapDevices.js'
+// 票 47:设备状态动效计划(映射/门控/30 台降级判定都在 domain)。
+import { planDeviceEffects } from '../domain/deviceEffects.js'
 import { DETAIL_LEVEL } from '../domain/detailFlow.js'
 import {
     clearAssociatedDevices,
     clearOverlapNudge as clearOverlapNudgeService,
     getLifecycleStats,
     nudgeOverlap as nudgeOverlapService,
+    resolveDeviceEffectsOptions,
     setCameraFocus,
     setHoveredDevice,
     setSelectedDevice,
@@ -75,6 +78,12 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
      */
     const overlap = ref({ k: 0, n: 0, maxFan: 10, groups: [], active: false, degraded: false })
 
+    /**
+     * 票 47:设备状态动效计划(`{ enabled, limit, abnormalCount, degraded, animated, staticAbnormal }`)。
+     * 界面据此显示降级提示「N 台异常」;关掉特效**不隐藏静态状态表达**(色/形/文字仍在)。
+     */
+    const effectsPlan = ref(null)
+
     /** 跨端点不同步的风险提示(规格强制行为,不得静默删除)。 */
     const notice = ref({ risky: false, text: '' })
 
@@ -124,6 +133,7 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
             total.value = 0
             legend.value = []
             overlap.value = { k: 0, n: 0, maxFan: 10, groups: [], active: false, degraded: false }
+            effectsPlan.value = null
             notice.value = { risky: false, text: '' }
             painted.value = false
             lastPlanKey = ''
@@ -143,6 +153,7 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
             total.value = 0
             legend.value = []
             overlap.value = { k: 0, n: 0, maxFan: 10, groups: [], active: false, degraded: false }
+            effectsPlan.value = null
             notice.value = { risky: false, text: '' }
             painted.value = false
             lastPlanKey = ''
@@ -221,6 +232,11 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
             degraded: activeGroups.some((group) => group.degraded),
         }
 
+        // 票 47:设备状态动效计划(映射/门控/30 台降级都在 domain,本层只提供输入)。
+        // 只对**已绘制**的设备算动效 —— 清单里那些没上图的设备没有实体可动。
+        const effects = planDeviceEffects({ devices: drawn, ...resolveDeviceEffectsOptions() })
+        effectsPlan.value = effects
+
         const key = planKeyOf(code, drawn)
 
         // 同点位的同一份计划**不重画**(翻页/无关重渲染都不会让设备层闪烁)。
@@ -233,7 +249,7 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
         loading.value = true
         const mine = token
         // 地图服务是同步下发,但**序号纪律仍然要在**:换点后旧的一次同步不得再写读数。
-        painted.value = syncAssociatedDevices(drawn, { maxFan: options.maxFan, overlapGroups: groups })
+        painted.value = syncAssociatedDevices(drawn, { maxFan: options.maxFan, overlapGroups: groups, effects })
         // 图层的实体是**整份重建**的,所以重建后必须刷新一次反应 —— 否则选中/hover/弱化
         // 会停在重建前的值(新实体默认 scale 1、无外环),表现为"翻一页选中态就没了"。
         syncDeviceReactions()
@@ -407,6 +423,7 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
         total.value = 0
         legend.value = []
         overlap.value = { k: 0, n: 0, maxFan: 10, groups: [], active: false, degraded: false }
+        effectsPlan.value = null
         notice.value = { risky: false, text: '' }
         loading.value = false
         painted.value = false
@@ -428,6 +445,7 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
         total,
         legend,
         overlap,
+        effectsPlan,
         notice,
         loading,
         painted,