monitoringStore.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. /**
  2. * 运行监测模式的面板状态(票 18)。
  3. *
  4. * 职责边界,沿用票 05/11/17 已定的分工:
  5. * · 本 store 只做四件事:调 `services/monitoringApi.js` 取三块面板、把**每次取数各自收成
  6. * 一个分区**、按端点的 `pagination` 渐进加载台账、按需补拉台账卡片的设备徽标;
  7. * · 面板怎么画归 `domain/monitoringPanels.js`(纯函数,本文件不做任何字段改名);
  8. * · 路径拼接、凭据、mock/real 切换归 `services/`(本文件不出现 fetch、不出现后端裸字段名)。
  9. *
  10. * **三块各自成败**(一块端点坏了不牵连其它两块):状态是 `{ [panelId]: section() }`,
  11. * 与 `infrastructureStore` 同款。
  12. *
  13. * ── 四处本票特有的收口,都写在对应函数上 ──
  14. * ① 台账与监测分布**共用一次取数**(Q4 裁定):`loadPanels` 里 `/monitoring/distribution`
  15. * 只请求一次,结果同时喂给两个分区 —— 两块各请求一次会让它们各持一份快照、分页进度还会打架。
  16. * ② 台账**渐进加载**:`loadMoreLedger()` 带第 1 页锁定的 `snapshotAt` 请求下一页
  17. * (后端 `page > 1` 必须带快照,否则 400),并把新一页的点位**追加**进台账分区。
  18. * ③ 卡片**徽标 N+1**(Q10 裁定):`loadLedgerBadge(pointCode)` 逐点位取详情里的设备计数;
  19. * 卡片进视口才调(Q14 裁定),**一台失败不影响其它卡片**。
  20. * ④ **异常设备定位**:复用票 17 立的那条共享接缝(`domain/mapLocate.js`),
  21. * 不新写一套判定。
  22. */
  23. import { computed, reactive, ref } from 'vue'
  24. import { defineStore } from 'pinia'
  25. import { SECTION_STATE, section } from '../domain/sectionState.js'
  26. import { REQUEST_REASON } from '../domain/requestReason.js'
  27. import { MONITORING_PANEL_IDS, shouldLoadBadge } from '../domain/monitoringPanels.js'
  28. // 票 62:台账自动渐进装载链(与 mapStore 的自动链同款纪律,实现独立)。
  29. import { createLedgerLoader, ledgerLoading, ledgerView } from '../domain/ledgerLoader.js'
  30. // 票 37:类型码的归一收口(数据值无静态白名单,认不出回空串=不筛)。
  31. import { normalizeDeviceType } from '../domain/filterOptions.js'
  32. import { LOCATE_RESULT, locateFailureText, planLocate } from '../domain/mapLocate.js'
  33. import { TERRAIN_LOCATE_REASON } from '../domain/pointLocate.js'
  34. import {
  35. fetchMonitoringDistribution,
  36. fetchMonitoringDistributionBatch,
  37. monitoringDistributionInputs,
  38. fetchMonitoringOverview,
  39. fetchMonitoringSituation,
  40. } from '../services/monitoringApi.js'
  41. import { fetchMonitoringPointDetail } from '../services/detailApi.js'
  42. import { getCameraState, locateMonitoringPoint } from '../services/mapService.js'
  43. import { useDetailStore } from './detailStore.js'
  44. import { useMapStore } from './mapStore.js'
  45. import { sameInputs } from '../domain/panelViewCache.js'
  46. // 票 41:监测点选择的统一入口(与地图点选共用同一个函数)。
  47. import { selectMonitoringPoint } from '../pointSelection.js'
  48. /** 未取数时的分区(与 `infrastructureStore.pending()` 同形,`loaded:false` 表示"还没请求过")。 */
  49. function pending() {
  50. return { state: SECTION_STATE.EMPTY, reason: '', mode: '', data: null, missing: [], freshness: '', retained: false, loaded: false }
  51. }
  52. /** 该把哪个 panelId 喂给哪个取数函数 —— 台账**不在**这里(它与分布共用一次取数)。 */
  53. const FETCHERS = {
  54. monitoringOverview: fetchMonitoringOverview,
  55. monitoringDistribution: fetchMonitoringDistributionBatch,
  56. operationOverview: fetchMonitoringSituation,
  57. }
  58. /** 需要独立请求的面板(台账走分布的响应,不单独请求)。 */
  59. const INDEPENDENT_PANELS = ['monitoringOverview', 'monitoringDistribution', 'operationOverview']
  60. /** 运行概况的时间窗口(Q8 裁定:面板内给 24 小时 / 7 天;显式区间必须成对传)。 */
  61. export const TREND_RANGES = [
  62. { key: '24h', label: '24 小时', hours: 24 },
  63. { key: '7d', label: '7 天', hours: 24 * 7 },
  64. ]
  65. /** 一个窗口键 → `{ startTime, endTime }`(ISO-8601 带偏移;不成对会被后端 400 拒掉)。 */
  66. export function rangeWindow(key, now = new Date()) {
  67. const found = TREND_RANGES.find((item) => item.key === key)
  68. if (!found) return {}
  69. const end = now
  70. const start = new Date(end.getTime() - found.hours * 3600 * 1000)
  71. // 后端要带偏移量的 ISO 串;`toISOString()` 给的是 UTC(Z),语义等价、可被 `OffsetDateTime.parse` 吃下。
  72. return { startTime: start.toISOString(), endTime: end.toISOString() }
  73. }
  74. export const useMonitoringStore = defineStore('monitoringStore', () => {
  75. /** 四块面板各自的分区状态(台账的分区由分布的响应喂)。 */
  76. const sections = ref(Object.fromEntries(MONITORING_PANEL_IDS.map((panelId) => [panelId, pending()])))
  77. /** 正在取数(界面只做轻提示;分区成败仍由各分区自己说)。 */
  78. const loading = ref(false)
  79. /** 台账的卡片徽标(Q10/Q14:逐点位按需取,各自成败)。 */
  80. const badges = ref({})
  81. /** 台账正在补拉徽标的点位集合(避免同一张卡滚动时反复发请求)。 */
  82. const badgeInFlight = ref({})
  83. /** 台账正在加载下一页(保留导出:界面与闸门读「是否还有一批在途」)。 */
  84. const ledgerBusy = ref(false)
  85. /**
  86. * 台账装载链的合并态(票 62):`{ phase, snapshot, page, nextPage, total, loaded, points, equipment, reason }`。
  87. * 台账分区(`sections.monitoringPointLedger`)由它派生 —— 分区仍是界面读的那一份,
  88. * 装载链是它的数据来源,两者不再各持一套进度。
  89. */
  90. const ledger = reactive({ state: null })
  91. /** 当前生效的行业筛选(**前端键**;到后端码的映射归 services)。 */
  92. const industry = ref('')
  93. /** 当前生效的设备类型码(票 37;空串=全部类型)。与行业同层的全局条件回显。 */
  94. const deviceType = ref('')
  95. /** 当前生效的点位状态筛选(**八桶集合**,票 14 多值;空数组=全部)。 */
  96. const pointStatus = ref([])
  97. /** 当前生效的范围筛选(**前端键**;到后端 district 的映射归领域词典)。 */
  98. const scope = ref('')
  99. /** 运行概况的窗口(主键,见 `TREND_RANGES`)。 */
  100. const trendRange = ref('24h')
  101. /** 最近一次定位的结果(界面据此给一行反馈,也是走查的断言面)。 */
  102. const locate = ref({ result: '', pointCode: '', reasonText: '' })
  103. /** 请求序号:换筛选/重取时旧的在途响应作废。 */
  104. let token = 0
  105. /** 台账装载纪元(票 62):每次新链(首载/换筛选/手动刷新)作废旧链,迟到写入整批丢弃。 */
  106. let ledgerEpoch = 0
  107. /** 台账装载器(幂等;`setup` 时按当前条件建一次)。 */
  108. let ledgerLoader = null
  109. /** loader内部集合所属的有效请求输入;新轮/漂移时清空,reuse不能复活旧页。 */
  110. let ledgerInputs = null
  111. /** 自动链两批之间的间隔(票 62:给渲染与交互留一口气,不一次打满请求)。 */
  112. const LEDGER_AUTO_BATCH_GAP_MS = 16
  113. /** 请求注入(config / fetchImpl):取一次让三块共用,单测与走查都靠它。 */
  114. let requestOptions = {}
  115. function configure(options = {}) {
  116. requestOptions = { ...requestOptions, ...options }
  117. }
  118. /** 直接写入一个分区(走查与单测注入用)。 */
  119. function setSection(panelId, value) {
  120. sections.value = { ...sections.value, [panelId]: { ...value, loaded: true } }
  121. }
  122. /** 当前请求条件(筛选 + 真实配置)。 */
  123. function activeOptions() {
  124. return {
  125. ...requestOptions,
  126. industry: industry.value,
  127. deviceType: deviceType.value,
  128. pointStatus: pointStatus.value,
  129. scope: scope.value,
  130. }
  131. }
  132. /**
  133. * 取三块面板。**并发取、各自成败**:一块失败不回滚其它已成功分区。
  134. *
  135. * 台账与监测分布**共用** `/monitoring/distribution` 的这一次响应(Q4 裁定):
  136. * 分布分区拿聚合与分页,台账分区拿**同一份点位序列** —— 两块的分页进度因此
  137. * 天然一致,不会出现"分布说还有下一页、台账却说到底了"。
  138. *
  139. * 行业/范围/状态/类型(票 37)都是全局条件,空值=不传参(全部)。
  140. * `deviceType` 归一收在 `domain/filterOptions.js`(类型码是数据值,无静态白名单)。
  141. * @param {object} [options] `{ industry, deviceType, pointStatus, scope, trendRange, config, fetchImpl }`
  142. * @returns {Promise<object>} 取完后的全部分区
  143. */
  144. async function loadPanels(options = {}) {
  145. if (options.industry !== undefined) industry.value = options.industry || ''
  146. // 票 37:设备类型与行业/范围同层 —— undefined = 不动;null/空 = 清空;
  147. // 非法形状归一成空串(脏值不进入全局条件,也不换后端 400)。
  148. if (options.deviceType !== undefined) deviceType.value = normalizeDeviceType(options.deviceType)
  149. // 票 14:pointStatus 多值集合;undefined = 不动,null/空 = 清空。
  150. if (options.pointStatus !== undefined) {
  151. pointStatus.value = Array.isArray(options.pointStatus) ? options.pointStatus.slice() : []
  152. }
  153. if (options.scope !== undefined) scope.value = options.scope || ''
  154. if (options.trendRange !== undefined && TREND_RANGES.some((item) => item.key === options.trendRange)) {
  155. trendRange.value = options.trendRange
  156. }
  157. if (Object.keys(options).some((key) => !['industry', 'deviceType', 'pointStatus', 'scope', 'trendRange'].includes(key))) {
  158. configure(options)
  159. }
  160. const active = activeOptions()
  161. const firstPageInputs = monitoringDistributionInputs(active)
  162. // 新一轮开始就作废旧台账写入/下一页;不能等三块面板返回后才换纪元。
  163. const initialLedgerEpoch = ++ledgerEpoch
  164. ledgerInputs = null
  165. let firstPage
  166. const window = rangeWindow(trendRange.value)
  167. token += 1
  168. const mine = token
  169. loading.value = true
  170. const results = await Promise.all(
  171. INDEPENDENT_PANELS.map(async (panelId) => {
  172. try {
  173. const result = await FETCHERS[panelId]({ ...active, ...(panelId === 'operationOverview' ? window : {}) })
  174. if (panelId === 'monitoringDistribution') {
  175. firstPage = result.firstPage
  176. return [panelId, result.section]
  177. }
  178. return [panelId, result]
  179. } catch (error) {
  180. // 与票 11/17 同款兜底:适配层不抛,但转换层万一抛了要收成一条失败分区,
  181. // 不能把面板卡在「加载中」(用户还得能看见其它块)。
  182. return [panelId, section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })]
  183. }
  184. }),
  185. )
  186. // 迟到的响应整批丢弃:换筛选后旧数据不得落进新范围。
  187. if (mine !== token) return sections.value
  188. const next = { ...sections.value }
  189. for (const [panelId, value] of results) next[panelId] = { ...value, loaded: true }
  190. // 换筛选就丢掉上一批徽标:留着会把 A 行业的设备数挂在 B 行业的点位上(同一条 token 纪律)。
  191. badges.value = {}
  192. badgeInFlight.value = {}
  193. sections.value = next
  194. loading.value = false
  195. // 台账仍有独立自动链;同轮成功首批可复用,后续页仍按其快照token装载到完整。
  196. const sameRequest = initialLedgerEpoch === ledgerEpoch &&
  197. sameInputs(firstPageInputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 }))
  198. const chain = loadLedgerChain('refresh', sameRequest ? firstPage : undefined)
  199. await chain
  200. return sections.value
  201. }
  202. // ============================================================ 票 62:台账自动渐进装载链
  203. //
  204. // 四条纪律(票面 Acceptance 逐条落在这里):
  205. // ① **纪元**:每次新链 `++ledgerEpoch`,旧链的迟到写入整批丢弃(与 mapStore 同款);
  206. // ② 条件变化 → 新快照从第 1 页重来;定时刷新走 `reuseLedgerSnapshot`,**不重放全链**(票 33 教训);
  207. // ③ 第 2 页起带第 1 页锁定的 `snapshotAt`(后端硬要求,token 只认响应回显);
  208. // ④ 失败不回滚已装载内容(`domain/ledgerLoader.js` 保证),也不无限自动重试(决策 04)。
  209. /** 建/复用装载器:`fetchPage` 每次现取条件,筛选变了也不必重建。 */
  210. function ensureLedgerLoader() {
  211. if (ledgerLoader) return ledgerLoader
  212. ledgerLoader = createLedgerLoader({
  213. // 页大小交给后端默认值(不在前端硬编码第二份口径)。
  214. fetchPage: (query) => fetchMonitoringDistribution({ ...activeOptions(), ...query, raw: true }),
  215. })
  216. return ledgerLoader
  217. }
  218. /** 合并态 → 台账分区(界面读的还是同一个 `sections.monitoringPointLedger`)。 */
  219. function syncLedgerSection(state) {
  220. ledger.state = state
  221. const view = ledgerView(state)
  222. const data = {
  223. // 兼容既有面板视图的字段名(`points`/`equipment`/`pagination` 是它读的那几个)。
  224. points: state.points,
  225. equipment: state.equipment,
  226. snapshotAt: state.snapshot,
  227. // 页码分页要的三个数:`total` 连同分页视图一起由 `domain/ledgerLoader.js` 给
  228. // (`totalPages` 也由它算——store 不另写一份 ceil 算术,条文 11 的单源纪律)。
  229. pagination: {
  230. page: state.page,
  231. pageSize: state.pageSize,
  232. total: view.total,
  233. totalPages: view.pageCount,
  234. hasMore: false,
  235. },
  236. loaded: view.loaded,
  237. ledgerPhase: state.phase,
  238. ledgerNote: view.pageNote,
  239. }
  240. const state_ = state.phase === 'failed' || state.phase === 'expired'
  241. ? SECTION_STATE.FAILED
  242. : view.loaded === 0 && state.phase === 'empty'
  243. ? SECTION_STATE.EMPTY
  244. : SECTION_STATE.SUCCESS
  245. sections.value = {
  246. ...sections.value,
  247. monitoringPointLedger: { ...section(state_, { data, reason: state.reason }), loaded: true, retained: view.loaded > 0 },
  248. }
  249. ledgerBusy.value = ledgerLoading(state)
  250. }
  251. /**
  252. * 跑一条台账链。
  253. * @param {'refresh'|'reuse'} kind `refresh`=换新快照并自动连翻到 settled;
  254. * `reuse`=还在同一快照有效期内,**只补没取完的部分**(定时刷新走这条,不重放全链)
  255. */
  256. async function loadLedgerChain(kind = 'refresh', firstPage) {
  257. const loader = ensureLedgerLoader()
  258. const inputs = monitoringDistributionInputs({ ...activeOptions(), page: 1 })
  259. const reuse = kind === 'reuse' && sameInputs(ledgerInputs, inputs)
  260. const mine = ++ledgerEpoch
  261. if (!reuse) ledgerInputs = null
  262. const stillCurrent = () => {
  263. if (mine !== ledgerEpoch) return false
  264. if (sameInputs(inputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 }))) return true
  265. ledgerInputs = null
  266. return false
  267. }
  268. const started = reuse ? loader.getState() : await loader.refresh(firstPage)
  269. if (!stillCurrent()) return sections.value.monitoringPointLedger
  270. ledgerInputs = inputs
  271. syncLedgerSection(started)
  272. let state = started
  273. while (stillCurrent() && !ledgerSettledForAuto(state)) {
  274. await new Promise((resolve) => setTimeout(resolve, LEDGER_AUTO_BATCH_GAP_MS))
  275. if (!stillCurrent()) break
  276. state = await loader.loadNext()
  277. if (!stillCurrent()) break
  278. syncLedgerSection(state)
  279. // 失败/过期不自动重试(决策 04):停链,等新鲜度周期或手动入口。
  280. if (state.phase === 'failed' || state.phase === 'expired') break
  281. }
  282. return sections.value.monitoringPointLedger
  283. }
  284. /** 自动链的循环条件(settled 或失败都停)。 */
  285. function ledgerSettledForAuto(state) {
  286. return ['complete', 'empty', 'failed', 'expired'].includes(state && state.phase)
  287. }
  288. /**
  289. * 票 62:定时刷新走这条 —— **不换快照、不重放全链**。
  290. *
  291. * 票 33 的教训:30s tick 若每次重放全量自动链,会让整块面板周期性抖动(mapStore 那边
  292. * 的直接后果是画面抽搐)。这里只做一件事:链路还没 settled 且没失败时,把剩下的批次补完;
  293. * 已 settled 的台账**一个请求都不发**。
  294. */
  295. async function reuseLedgerSnapshot() {
  296. if (!sameInputs(ledgerInputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 }))) return loadLedgerChain('refresh')
  297. const state = ledger.state
  298. if (!state) return loadLedgerChain('refresh')
  299. if (ledgerSettledForAuto(state)) return sections.value.monitoringPointLedger
  300. return loadLedgerChain('reuse')
  301. }
  302. /**
  303. * 票 62:「加载下一页监测点」按钮已退役 —— 保留同名入口只为兼容既有调用点与走查脚本,
  304. * 行为改为「再推一批」(只在链路未 settled 时有效,settled 后是空操作)。
  305. * @returns {Promise<object>} 台账分区
  306. */
  307. async function loadMoreLedger() {
  308. const state = ledger.state
  309. if (!state) return sections.value.monitoringPointLedger
  310. if (!sameInputs(ledgerInputs, monitoringDistributionInputs({ ...activeOptions(), page: 1 }))) return loadLedgerChain('refresh')
  311. if (ledgerSettledForAuto(state)) return sections.value.monitoringPointLedger
  312. return loadLedgerChain('reuse')
  313. }
  314. /**
  315. * 补拉一个点位的设备徽标(Q10:走 N+1,Q14:卡片进视口才拉)。
  316. *
  317. * 为什么不能从已有数据算:`/monitoring/distribution` 的 marker **不给设备编码列表**
  318. * (那是 `/map-snapshot` 的 `deviceCodes`),所以徽标只能问详情端点要设备计数
  319. * (`domain/pointDetail.js` 已把它翻成 `statistics`,本文件只见这个领域字段)。
  320. * **一台失败不影响其它卡片**:失败记成 `failed` 状态,
  321. * 界面显示占位符而不是 0(0 是"确实没有设备",与"没取到"是两件事)。
  322. * @param {string} pointCode 点位编码
  323. * @returns {Promise<object>} 该点位的徽标状态
  324. */
  325. async function loadLedgerBadge(pointCode) {
  326. if (!shouldLoadBadge(badges.value, pointCode)) return badges.value[pointCode]
  327. if (badgeInFlight.value[pointCode]) return badges.value[pointCode]
  328. badgeInFlight.value = { ...badgeInFlight.value, [pointCode]: true }
  329. const active = activeOptions()
  330. let result = null
  331. try {
  332. result = await fetchMonitoringPointDetail(pointCode, active)
  333. } catch (error) {
  334. result = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
  335. }
  336. const statistics = (result.data && result.data.statistics) || null
  337. // 只有真拿到统计才算成功:`statistics.missing` 非空表示后端没给这一段,
  338. // 那时**不给 0**(见上)。
  339. const given = result.state === SECTION_STATE.SUCCESS && statistics && (!statistics.missing || !statistics.missing.length)
  340. const entry = given
  341. ? { state: 'success', total: statistics.total, alarm: statistics.alarm, warning: statistics.warning, offline: statistics.offline }
  342. : { state: result.state === SECTION_STATE.SUCCESS ? 'missing' : 'failed', total: null, alarm: null, warning: null, offline: null }
  343. const nextInFlight = { ...badgeInFlight.value }
  344. delete nextInFlight[pointCode]
  345. badgeInFlight.value = nextInFlight
  346. badges.value = { ...badges.value, [pointCode]: entry }
  347. return entry
  348. }
  349. /**
  350. * 点台账卡片 / 异常设备 → 打开与地图**相同**的两级详情,并触发点位级定位。
  351. *
  352. * **票 41 改写了本函数的语义**(原为「不移动相机」,见票 18 的 Q4):验收第 1、2 条要求
  353. * 「地图点击和监测点台账选择使用同一监测点选择入口」且「有效点位坐标触发点位级完整视角定位」。
  354. * 台账因此不再是不动相机的旁路 —— 它调 `pointSelection.selectMonitoringPoint`,
  355. * 与地图点选走**同一个**函数(不是"两处各自小心保持一致")。
  356. * @param {string} pointCode 目标点位编码
  357. */
  358. async function openLedgerPoint(pointCode) {
  359. return selectMonitoringPoint(pointCode)
  360. }
  361. /**
  362. * 点运行概况里的异常设备 → 联动地图(复用票 17 立的那条共享接缝 ④)。
  363. *
  364. * 三步,顺序有意为之:先判定能否定位(三种"点不动"分得开)→ 判定通过再动相机
  365. * → 相机下发成功再打开两级详情。票 53:相机走**地形感知接缝** `locateMonitoringPoint`
  366. * (中心高程取采样地表,取不到就失败,不以椭球 0 冒充成功),按失败原因如实给说法
  367. * (地图未就绪 / 坐标不可用 / 地形待确认),**不假装定位成功**。
  368. * @param {string} code 目标编码(异常设备是设备维度,但未关联设备也以自身编码上图)
  369. * @param {object} [deps] 注入点(单测用):`{ objects, mapReady, flyPoint, openPoint }`
  370. */
  371. async function locateCode(code, deps = {}) {
  372. const mapStore = useMapStore()
  373. const detailStore = useDetailStore()
  374. const objects = deps.objects || mapStore.summary.objects
  375. const mapReady = deps.mapReady === undefined ? !!getCameraState() : !!deps.mapReady
  376. const flyPoint = deps.flyPoint || locateMonitoringPoint
  377. const openPoint = deps.openPoint || ((value) => detailStore.openPoint(value))
  378. const plan = planLocate({ objects, pointCode: code, mapReady })
  379. locate.value = { result: plan.result, pointCode: plan.pointCode, reasonText: plan.reasonText }
  380. if (plan.result !== LOCATE_RESULT.OK) return locate.value
  381. // 默认按「地图未就绪」口径:接缝没给原因时不替用户猜成地形问题(票 53 验收第 3 条)。
  382. let failureReason = TERRAIN_LOCATE_REASON.MAP
  383. const applied = await flyPoint(plan.object, {
  384. onFailure: (reason) => { failureReason = reason },
  385. })
  386. if (applied !== true) {
  387. locate.value = {
  388. result: LOCATE_RESULT.UNAVAILABLE,
  389. pointCode: plan.pointCode,
  390. reasonText: locateFailureText(failureReason),
  391. }
  392. return locate.value
  393. }
  394. // 未关联设备没有点位可摘要,直接进设备级(与票 10 从地图点设备同一条路)。
  395. if (plan.object.kind === 'monitoring-point') await openPoint(plan.pointCode)
  396. else await detailStore.openDevice(plan.pointCode)
  397. return locate.value
  398. }
  399. /** 仅测试用:回到未加载状态。 */
  400. function reset() {
  401. token += 1
  402. ledgerEpoch += 1
  403. ledgerLoader = null
  404. ledgerInputs = null
  405. ledger.state = null
  406. sections.value = Object.fromEntries(MONITORING_PANEL_IDS.map((panelId) => [panelId, pending()]))
  407. badges.value = {}
  408. badgeInFlight.value = {}
  409. loading.value = false
  410. ledgerBusy.value = false
  411. industry.value = ''
  412. deviceType.value = ''
  413. pointStatus.value = []
  414. scope.value = ''
  415. trendRange.value = '24h'
  416. locate.value = { result: '', pointCode: '', reasonText: '' }
  417. }
  418. return {
  419. sections,
  420. loading,
  421. badges,
  422. badgeInFlight,
  423. ledgerBusy,
  424. industry,
  425. deviceType,
  426. pointStatus,
  427. scope,
  428. trendRange,
  429. locate,
  430. configure,
  431. setSection,
  432. loadPanels,
  433. loadMoreLedger,
  434. reuseLedgerSnapshot,
  435. loadLedgerChain,
  436. ledger,
  437. loadLedgerBadge,
  438. openLedgerPoint,
  439. locateCode,
  440. reset,
  441. }
  442. })