eventStore.js 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. /**
  2. * 预警处置模式的「事件 + 预警/报警切换」状态(票 13)。
  3. *
  4. * 职责边界,沿用票 05/11/17/18 已定的分工:
  5. * · 本 store 只做三件事:持有切换状态(`mode` 当前是预警还是报警)、
  6. * 调 `services/eventApi.js` 取跨设备当前事件(当 mode 变了就换族重取)、
  7. * 把结果收成一个 `currentEvents` 分区;
  8. * · 事件怎么画归 `domain/eventList.js`(纯函数,本文件不做字段改名);
  9. * · 路径拼接、凭据、mock/real 切换归 `services/`(本文件不出现 fetched、裸字段名)。
  10. *
  11. * 为什么「切换」在 store 而不是组件里:票 19 要求「四个面板均支持预警/报警切换」——
  12. * 切换是**共享**状态(当前预警/历史分析/预警效率/预警分析四块面板读同一个当前族),
  13. * 放组件里每块各存一份,切了一块其它三块不会跟着变。本票先把 store 状态落好,
  14. * 组件(`EventListPane.vue`)只消费它,票 19 的四块面板直接复用同一份。
  15. *
  16. * 竞态收口沿用票 10/11 的 token 纪律:快速连续切 预警→报警→预警 时,
  17. * 只让最后一次请求落进分区(迟到的响应丢弃),不会出现"显示报警却把预警数据挂上去"。
  18. *
  19. * 票 61(统一分页批次三)新增**服务端页码状态**:当前事件表与历史事件单窗范围都按
  20. * `page`+`pageSize=10` 发请求,页码是本 store 的视图状态(`currentPage`/`historyPage`)。
  21. * 纪律有三条:① 条件(mode/range/industry/level/status)变化 → 页码回第 1 页;
  22. * ② 定时刷新/同条件重取**不复位**(条文 6 的反面),越界由回显夹回最后一页;
  23. * ③ 翻一页只重取该分区(不牵连另外三块面板),并用同一 token 作废迟到的页响应。
  24. */
  25. import { computed, ref } from 'vue'
  26. import { defineStore } from 'pinia'
  27. import { SECTION_STATE, section } from '../domain/sectionState.js'
  28. import { REQUEST_REASON } from '../domain/requestReason.js'
  29. import { EVENT_FAMILY } from '../domain/events.js'
  30. import { currentEventsView, isKnownEventMode, refreshedEventSelection } from '../domain/eventList.js'
  31. import { fetchCurrentEvents } from '../services/eventApi.js'
  32. import { EVENT_DETAIL_RESULT, eventDetailView, planEventLocate } from '../domain/eventDetail.js'
  33. import { getCameraState, getAssociatedDeviceState, highlightMapObject, setCameraFocus } from '../services/mapService.js'
  34. import { useMapStore } from './mapStore.js'
  35. import { EVENT_RANGES } from '../domain/warningResponsePanels.js'
  36. import { eventTimeWindow, historyWindows } from '../domain/eventRange.js'
  37. import { fetchEventAnalysis, fetchEventHistory, fetchHandlingEfficiency, fetchIncidentTrend } from '../services/warningResponseApi.js'
  38. import { fetchWorkOrderList } from '../services/detailApi.js'
  39. import { PAGE_SIZE, serverPageView } from '../domain/pagination.js'
  40. /**
  41. * 会触发「回第 1 页」的条件键(条文 5/6;定时刷新不经过这里)。
  42. *
  43. * 票 67 补 `scope`(当前范围)。它必须进这张表:片区现在真的会下发给预警分析端点
  44. * (`warningResponseApi.js` 的 `acceptsDistrict`),换片区就是换了一批数据,
  45. * 页码留在第 3 页会翻到一个在新范围里根本不存在的页。
  46. */
  47. const CONDITION_KEYS = ['mode', 'range', 'industry', 'level', 'status', 'scope']
  48. /**
  49. * 报警效率面板「当前工单列表」的页大小(票 65)。
  50. *
  51. * 与事件表的 `PAGE_SIZE`(10)**刻意分开**:两者是不同端点、不同翻页语义,
  52. * 共用一个常量会让「改事件页大小」意外改掉工单列表(票 61 变异 M1 记过同类教训)。
  53. */
  54. const WORK_ORDER_LIST_PAGE_SIZE = 5
  55. /**
  56. * 效率面板「报警列表 / 预警列表」每页条数(票 71)。
  57. *
  58. * 与事件表 `PAGE_SIZE`(10)分开、也与工单列表的 5 分开:三者是**不同块**、
  59. * 不同翻页语义,共用一个常量会让「调事件页大小」意外改掉另外两块(票 61 变异 M1 同款教训)。
  60. * 取 5 是因为这块与趋势图同屏,行数是可读性约束而不是数据约束。
  61. */
  62. const EFFICIENCY_EVENT_PAGE_SIZE = 5
  63. /**
  64. * 「当前预警/报警」列表的**取数上限**(维护者 2026-10-29 第 4 条)。
  65. *
  66. * 为什么改成一次取全、而不是服务端逐页:这张表**本地过滤掉了已终态事件**
  67. * (只留还在进行中的),而服务端分页的 `total`/`totalPages` 含已终态 ——
  68. * 两边口径不一致时就会画出「只有 1 行却显示 10 页、且能翻到空页」。
  69. * 一次取全(上限取后端 pageSize 上限 1000)后按**可见行**前端分页,页码与内容必然一致。
  70. * 超过上限时 `currentEventsView` 会置 `truncated`,界面如实标注「已加载 N / 共 M」。
  71. */
  72. const CURRENT_EVENTS_FETCH_SIZE = 1000
  73. /** 未取数时的分区(与其它 store 同形,`loaded:false` 表示"还没请求过")。 */
  74. function pending() {
  75. return { state: SECTION_STATE.EMPTY, reason: '', mode: '', data: null, missing: [], freshness: '', retained: false, loaded: false }
  76. }
  77. function initialSections() {
  78. return {
  79. currentEvents: pending(),
  80. historyAnalysis: pending(),
  81. responseEfficiency: pending(),
  82. eventAnalysis: pending(),
  83. // 票 65:报警效率面板的「当前工单列表」(全量工单端点,与上面四块各自成败)。
  84. // 分区名刻意叫 `workOrderList`:store 只说领域词汇,不照抄后端字段名
  85. // (照抄会被 t13 的 I1 守卫判红,票 05 纪律)。
  86. workOrderList: pending(),
  87. }
  88. }
  89. export const useEventStore = defineStore('eventStore', () => {
  90. /** 当前切换状态:`WARNING`(预警)或 `ALARM`(报警)。默认预警(`/events/current` 的 mode 必填)。 */
  91. const mode = ref(EVENT_FAMILY.WARNING)
  92. /**
  93. * 「预警类型」本地筛选(G11,票 07 的落点),`{ code, label }`;空 code = 未筛选。
  94. *
  95. * 为什么是**本地**筛选:`/events/current` 不收 type 参数(后端未冻结),而且当前列表
  96. * 本身只含"可解析设备"的子集。所以这里只在**已加载行**上做减法,不冒充服务端筛选,
  97. * 覆盖范围由 `currentEventsView` 的 `filterView` 如实回显(票 07 的缺口说明)。
  98. * 切族/重置时清空:类型码是预警族的词汇,挂到报警列表上只会在另一个族里静默漏筛。
  99. */
  100. const warningTypeFilter = ref({ code: '', label: '' })
  101. /** 当前事件分区(`/events/current` 的整体响应;成败由内部事件分区状态表达)。 */
  102. const sections = ref(initialSections())
  103. /** 预警处置三块聚合面板共享的时间范围,默认近 7 天。 */
  104. const range = ref('7d')
  105. /** 正在取数(界面只做轻提示;分区成败仍由分区自己说)。 */
  106. const loading = ref(false)
  107. /** 当前事件表的服务端页码(1 起,`pageSize=PAGE_SIZE`)。 */
  108. const currentPage = ref(1)
  109. /** 历史事件**单窗范围**的服务端页码(多窗合并范围无全局页,渲染层自持页码)。 */
  110. const historyPage = ref(1)
  111. /**
  112. * 「当前工单列表」的服务端页码(票 65)。
  113. *
  114. * 与上面两个页码**互不牵连**:翻工单页只重取工单分区,不动事件表/历史/效率/分析
  115. * (与「翻一页只重取该分区」同一条纪律)。
  116. */
  117. const workOrderPage = ref(1)
  118. /**
  119. * 效率面板「报警列表 / 预警列表」的服务端页码(票 71)。
  120. *
  121. * 与上面三个页码**互不牵连**:翻列表页只重取效率分区(那一块的行由该端点下发),
  122. * 不动事件表/历史/分析/工单列表。
  123. */
  124. const efficiencyPage = ref(1)
  125. /** 最后一个「当前事件分区的领域对象」(视图层读它,避免每次都重解构 section)。 */
  126. const currentDomain = ref(null)
  127. /** 当前打开的事件详情与地图定位结果;返回时不重取列表,保留 mode/筛选/分页上下文。 */
  128. const selectedEvent = ref(null)
  129. const locate = ref({ result: '', deviceCode: '', pointCode: '', reasonText: '' })
  130. /** 请求序号:翻页/切族/切范围共用一个令牌,迟到的页响应整批丢弃(token 纪律)。 */
  131. let token = 0
  132. /**
  133. * 工单列表专用的令牌(票 65)。
  134. *
  135. * 为什么不共用 `token`:那份是全局面板的令牌,翻一个工单页就把它 +1,会把在途的
  136. * 四块面板响应整批作废(用户只是翻了个工单页,另外三块却停在旧数据上)。
  137. * 工单列表是**独立分区**,竞态也该独立收口。
  138. */
  139. let workOrderToken = 0
  140. /**
  141. * 效率面板事件列表的令牌(票 71)。
  142. *
  143. * 理由与 `workOrderToken` 一字不差:效率块是**独立分区**,翻它一页不该把在途的
  144. * 另外四块响应整批作废(`token` 是全局面板令牌)。
  145. */
  146. let efficiencyToken = 0
  147. /**
  148. * 效率面板列表翻页时**钉住**的请求条件(票 71)。
  149. *
  150. * 与 `workOrderWindow` 同一理由:翻页期间窗口必须一个字节都不变。若每次翻页都重算
  151. * `eventTimeWindow()`,`endTime` 会毫秒级后移 —— 边界事件就会在页与页之间重复或消失。
  152. * 存整份条件(mode/range/industry/scope/窗口)而不只是窗口:列表是**同一端点**的分页,
  153. * 任何一项漂移都会让「第 2 页」不再属于「第 1 页」那批事件。
  154. */
  155. let efficiencyConditions = null
  156. /**
  157. * 工单列表当前钉住的时间窗(票 65,2026-10-22)。
  158. *
  159. * 由 `loadPanels` 在每次取数时写入,`setWorkOrderPage` 直接复用 —— 翻页期间窗口必须
  160. * 一个字节都不变。若每次翻页都重算 `eventTimeWindow()`,`endTime` 会毫秒级后移,
  161. * 边界工单就会在页与页之间重复或消失(P10 断言钉住这件事)。
  162. */
  163. let workOrderWindow = null
  164. /** 请求注入(config / fetchImpl / industry…):取一次让后续所有取数共用。 */
  165. let requestOptions = {}
  166. function configure(options = {}) {
  167. requestOptions = { ...requestOptions, ...options }
  168. }
  169. /** 直接写入分区(走查与单测注入用)。 */
  170. function setSection(name, value) {
  171. sections.value = { ...sections.value, [name]: { ...value, loaded: true } }
  172. }
  173. /** 条件的可比较快照(只取值,不按对象身份——视图计划每次重算都是新对象)。 */
  174. function conditionSnapshot() {
  175. return {
  176. mode: mode.value,
  177. range: range.value,
  178. industry: requestOptions.industry,
  179. level: requestOptions.level,
  180. status: requestOptions.status,
  181. scope: requestOptions.scope,
  182. }
  183. }
  184. function selectedRange() {
  185. return EVENT_RANGES.find((item) => item.key === range.value) || EVENT_RANGES[0]
  186. }
  187. /** 四块面板共享的请求条件(`/events/current` 与三块聚合接口同一份)。 */
  188. function activeConditions() {
  189. const active = selectedRange()
  190. return { ...requestOptions, ...eventTimeWindow(active.type), mode: mode.value, rangeType: active.type }
  191. }
  192. /** 该范围是否存在「全局页」(单窗才有;多窗合并范围只能对已加载集合分页)。 */
  193. function singleHistoryWindow() {
  194. const active = selectedRange()
  195. const window = eventTimeWindow(active.type)
  196. return historyWindows(window.startTime, window.endTime).length <= 1
  197. }
  198. /** 当前事件页的后端回显 → 控件视图(失败/无回显回 null,不夹、不猜)。 */
  199. /** 历史事件页的后端回显 → 控件视图(只有服务端分页形态才有全局页)。 */
  200. function historyPageView(result) {
  201. const paging = result && result.data && result.data.paging
  202. if (!paging || paging.mode !== 'server') return null
  203. return serverPageView(paging, Array.isArray(result.data.events) ? result.data.events.length : 0)
  204. }
  205. async function fetchCurrentAt(page, pageSize = PAGE_SIZE) {
  206. try {
  207. return await fetchCurrentEvents({ ...activeConditions(), page, pageSize })
  208. } catch (error) {
  209. return section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
  210. }
  211. }
  212. async function fetchHistoryAt(page) {
  213. try {
  214. return await fetchEventHistory({ ...activeConditions(), page, pageSize: PAGE_SIZE })
  215. } catch (error) {
  216. return section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
  217. }
  218. }
  219. /**
  220. * 取一页并做越界夹回(条文 6:数据变少时页码越界 ⇒ 夹回最后一页再取一次)。
  221. * 只在「后端回显的分页视图说当前页超过了最后一页」时补一次请求,不递归。
  222. * @returns {Promise<object|null>} `null` = 期间又发生了更新的请求(token 已变),调用方应放弃
  223. */
  224. async function loadPage(request, pageRef, pageViewOf, mine) {
  225. let result = await request(pageRef.value)
  226. if (mine !== token) return null
  227. const view = pageViewOf(result)
  228. if (view && view.page !== pageRef.value) {
  229. pageRef.value = view.page
  230. result = await request(pageRef.value)
  231. if (mine !== token) return null
  232. const clamped = pageViewOf(result)
  233. if (clamped) pageRef.value = clamped.page
  234. }
  235. return result
  236. }
  237. /** 当前「事件 + 切换」视图(领域层现算;组件与走查都读它)。 */
  238. const view = computed(() => {
  239. // G11:类型筛选只在预警族生效(报警列表没有这套类型码)。
  240. const filter = mode.value === EVENT_FAMILY.WARNING ? warningTypeFilter.value : { code: '', label: '' }
  241. // 页码是**前端**状态(取全后按可见行分页),所以这里要把它交给视图。
  242. const options = { warningType: filter.code, warningTypeLabel: filter.label, page: currentPage.value }
  243. // 已取到数据:从领域对象读事件分区(行 + 内部状态)。
  244. if (currentDomain.value) return currentEventsView(currentDomain.value, undefined, options)
  245. // 还没取过 / 整体失败:用整个分区驱动占位措辞(pending → 正在取数;failed → 加载失败)。
  246. return currentEventsView(null, sections.value.currentEvents, options)
  247. })
  248. /**
  249. * 设置/清除预警类型本地筛选(G11)。**不发请求**:只是换一份已加载行的视图。
  250. * @param {string} code warning_type 码(空串 = 清除)
  251. * @param {string} [label] 展示名(缺省回 code)
  252. */
  253. function setWarningTypeFilter(code, label = '') {
  254. const next = typeof code === 'string' ? code.trim() : ''
  255. warningTypeFilter.value = next ? { code: next, label: label || next } : { code: '', label: '' }
  256. return warningTypeFilter.value
  257. }
  258. function clearWarningTypeFilter() {
  259. warningTypeFilter.value = { code: '', label: '' }
  260. return warningTypeFilter.value
  261. }
  262. /**
  263. * 取预警处置四块面板。切换 mode/range/筛选时四块共享一次条件,迟到响应整批丢弃;
  264. * 条件变化同时把两个服务端页码回第 1 页(**定时刷新不带条件 ⇒ 页码保持**)。
  265. */
  266. async function loadPanels(options = {}) {
  267. const before = conditionSnapshot()
  268. if (options.mode !== undefined && isKnownEventMode(options.mode)) mode.value = options.mode
  269. if (options.range !== undefined && EVENT_RANGES.some((item) => item.key === options.range)) range.value = options.range
  270. if (Object.keys(options).some((key) => !['mode', 'range'].includes(key))) configure(options)
  271. const after = conditionSnapshot()
  272. if (CONDITION_KEYS.some((key) => before[key] !== after[key])) {
  273. currentPage.value = 1
  274. historyPage.value = 1
  275. // 票 65:条件变了,工单列表也回第 1 页(条文 5;定时刷新不带条件 ⇒ 页码保持)。
  276. workOrderPage.value = 1
  277. // 票 71:效率面板的两族列表同理(换族/换范围/换筛选 = 换了一批事件)。
  278. efficiencyPage.value = 1
  279. }
  280. const active = activeConditions()
  281. // 票 65:把窗口**钉**在这一次取数上。`eventTimeWindow()` 每次调用都按 `Date.now()`
  282. // 重算,首屏与翻页各算一次就会毫秒级漂移 —— 翻页时窗口在动,边界工单会重复或漏掉。
  283. workOrderWindow = { startTime: active.startTime, endTime: active.endTime }
  284. // 票 71:效率列表翻页复用**整份**条件(含刚钉下的窗口),不只存窗口 ——
  285. // 列表是该端点的分页,任何一项条件漂移都会让第 2 页不再属于第 1 页那批事件。
  286. efficiencyConditions = { ...active }
  287. token += 1
  288. const mine = token
  289. loading.value = true
  290. const requests = await Promise.all([
  291. // 维护者 2026-10-29 第 4 条:当前事件**一次取全**,页码交给视图按可见行算。
  292. // 逐页取会让服务端 total(含已终态)与屏上可见行两套口径打架。
  293. fetchCurrentEvents({ ...active, page: 1, pageSize: CURRENT_EVENTS_FETCH_SIZE }),
  294. fetchEventHistory({ ...active, page: historyPage.value, pageSize: PAGE_SIZE }),
  295. // 票 71:效率端点同时供出两族的事件列表,页码取 `efficiencyPage`
  296. // (定时刷新保持当前页;条件变化已在上方复位成第 1 页)。
  297. fetchHandlingEfficiency({ ...active, page: efficiencyPage.value, pageSize: EFFICIENCY_EVENT_PAGE_SIZE }),
  298. fetchEventAnalysis(active),
  299. // 票 65:报警效率面板的「当前工单列表」走**全量工单**端点(另一个粒度:
  300. // handling-efficiency 只给状态计数、不返回工单实体)。预警族不画工单列表,
  301. // 但这里照取不误——切族时不必等一次新往返,且失败只影响那一块分区。
  302. // 页码取 `workOrderPage`(定时刷新保持当前页),时间窗用刚钉下的那一份。
  303. fetchWorkOrderList(
  304. { page: workOrderPage.value, pageSize: WORK_ORDER_LIST_PAGE_SIZE, startTime: workOrderWindow.startTime, endTime: workOrderWindow.endTime },
  305. requestOptions,
  306. ),
  307. // 票 71 返工:逐日 新增/处置/累计 三条线要**带 status 的事件行**,而效率端点不返回
  308. // status(实测),所以另取一次 history-analysis(同一时间窗、总数实测一致)。
  309. // **必须放在数组末尾**:下面的解构按位置取值,插在中间会让后面每一项整体错位
  310. // (返工第二版正是这样把 analysis/workOrderList/incidentTrend 全错拿了一格)。
  311. // 失败只影响趋势那一块(分区独立纪律),不拖垮效率读数。
  312. fetchIncidentTrend(active).catch(() => null),
  313. ].map(async (request) => {
  314. try {
  315. return await request
  316. } catch (error) {
  317. return section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
  318. }
  319. }))
  320. if (mine !== token) return sections.value
  321. const [current, history, efficiency, analysis, workOrderList, incidentTrend] = requests
  322. // 票 71 返工:把逐日趋势挂进效率分区(domain 只读 `incidentTrend` 字段)。
  323. // 取失败(null)时保持缺失 ⇒ 界面说「不画」的原因,不画假线。
  324. // **同时**用这批带 status 的行替换列表数据源:效率端点不返回 status,
  325. // 用它画列表会把每条都写成「状态未发布」(维护者 2026-10-29 第 1 条反馈)。
  326. const trend = incidentTrend && incidentTrend.days ? incidentTrend : null
  327. const trendRows = trend && Array.isArray(incidentTrend.rows) ? incidentTrend.rows : null
  328. const efficiencyWithTrend = efficiency && efficiency.data
  329. ? {
  330. ...efficiency,
  331. data: {
  332. ...efficiency.data,
  333. ...(trend ? { incidentTrend: { days: trend.days, reason: trend.reason } } : {}),
  334. // 事件行取自 history-analysis:条数与效率端点同源(实测同窗一致),
  335. // 且带 status / updatedTime,列表与趋势因此看同一批事件。
  336. ...(trendRows && trendRows.length ? { events: trendRows } : {}),
  337. // **分页形态必须跟着换**:行是 history 的**全量集合**(每窗取到 1000 条上限),
  338. // 不再是效率端点那一页。若保留效率端点的 `pagination`,列表会画出
  339. // 「共 N 条 · 第 1/1 页」却把全量行都铺出来 —— 页码与内容对不上。
  340. // 所以换成前端集合分页(`paging.merged`),并在页脚如实说明取数方式。
  341. ...(trendRows && trendRows.length
  342. ? {
  343. pagination: null,
  344. paging: {
  345. mode: "merged",
  346. // 真实窗口数(7 天 1 / 30 天 2 / 90 天 4)—— 页脚要照实说合并了几个窗。
  347. windowCount: incidentTrend && incidentTrend.windowCount ? incidentTrend.windowCount : 1,
  348. loaded: trendRows.length,
  349. rangeKey: String(active.rangeType || ""),
  350. },
  351. }
  352. : {}),
  353. },
  354. }
  355. : efficiency
  356. // 当前事件一次取全、页码由视图算 ⇒ 不再做「越界补取」(那套只对服务端分页有意义)。
  357. const safeCurrent = current
  358. if (mine !== token) return sections.value
  359. const safeHistory = await clampSettled(history, historyPage, historyPageView, fetchHistoryAt, mine)
  360. if (mine !== token) return sections.value
  361. sections.value = {
  362. currentEvents: { ...safeCurrent, loaded: true },
  363. historyAnalysis: { ...safeHistory, loaded: true },
  364. responseEfficiency: { ...efficiencyWithTrend, loaded: true },
  365. eventAnalysis: { ...analysis, loaded: true },
  366. workOrderList: { ...workOrderList, loaded: true },
  367. }
  368. currentDomain.value = safeCurrent.data || null
  369. selectedEvent.value = refreshedEventSelection(selectedEvent.value, currentDomain.value)
  370. loading.value = false
  371. return sections.value
  372. }
  373. /**
  374. * 已落定的一页若越界则夹回并补取一次(与 `loadPage` 同一判据)。
  375. * 并发批次取数时不能在 `Promise.all` 里改页码,所以夹回发生在整批回包之后。
  376. */
  377. async function clampSettled(result, pageRef, pageViewOf, request, mine) {
  378. if (mine !== token) return result
  379. const view = pageViewOf(result)
  380. if (!view || view.page === pageRef.value) {
  381. if (view) pageRef.value = view.page
  382. return result
  383. }
  384. pageRef.value = view.page
  385. const clamped = await request(pageRef.value)
  386. if (mine !== token) return result
  387. const clampedView = pageViewOf(clamped)
  388. if (clampedView) pageRef.value = clampedView.page
  389. return clamped
  390. }
  391. function setRange(next) {
  392. if (!EVENT_RANGES.some((item) => item.key === next) || next === range.value) return sections.value
  393. return loadPanels({ range: next })
  394. }
  395. /**
  396. * 当前事件表翻页(服务端页码分页):只重取当前事件分区,其余三块不动。
  397. * 迟到的页响应由 token 作废;越界页码按回显夹回最后一页。
  398. * @param {number} next 目标页码(1 起)
  399. * @returns {Promise<object>} `sections.currentEvents`
  400. */
  401. async function setCurrentPage(next) {
  402. // 维护者 2026-10-29 第 4 条:当前事件已在 `loadPanels` 一次取全,
  403. // 翻页纯属**视图状态**(对可见行前端分页)—— 不发请求、不动分区。
  404. // 这样页码/总页数恒等于用户真的能看到的内容,不会出现「翻到空页」。
  405. const page = Math.max(1, Math.trunc(Number(next) || 1))
  406. currentPage.value = page
  407. return sections.value.currentEvents
  408. }
  409. /**
  410. * 历史事件单窗范围翻页(服务端页码分页):只重取历史分析分区。
  411. * 多窗合并范围没有全局页,本入口不适用(渲染层对已加载集合分页)。
  412. * @param {number} next 目标页码(1 起)
  413. * @returns {Promise<object>} `sections.historyAnalysis`
  414. */
  415. async function setHistoryPage(next) {
  416. const page = Math.max(1, Math.trunc(Number(next) || 1))
  417. if (!singleHistoryWindow() || page === historyPage.value) return sections.value.historyAnalysis
  418. historyPage.value = page
  419. token += 1
  420. const mine = token
  421. // 与当前事件表同一条纪律:翻页保持旧内容,不闪空白(条文 8)。
  422. const result = await loadPage(fetchHistoryAt, historyPage, historyPageView, mine)
  423. if (!result) return sections.value.historyAnalysis
  424. sections.value = { ...sections.value, historyAnalysis: { ...result, loaded: true } }
  425. return sections.value.historyAnalysis
  426. }
  427. /**
  428. * 「当前工单列表」翻页(服务端页码分页;票 65):只重取工单分区。
  429. *
  430. * 三条纪律与事件表翻页一致,只是竞态用**独立令牌**:
  431. * · 翻页保持旧内容、不闪空白(条文 8);
  432. * · 页码越界由后端回显夹回(`serverPageView` 同一判据),夹回后同步到 `workOrderPage`;
  433. * · 迟到的页响应整体丢弃(`workOrderToken` 变了就放弃)。
  434. *
  435. * @param {number} next 目标页码(1 起)
  436. * @returns {Promise<object>} `sections.workOrderList`
  437. */
  438. async function setWorkOrderPage(next) {
  439. const page = Math.max(1, Math.trunc(Number(next) || 1))
  440. if (page === workOrderPage.value) return sections.value.workOrderList
  441. workOrderPage.value = page
  442. workOrderToken += 1
  443. const mine = workOrderToken
  444. // 翻页必须用**首屏钉下的同一份时间窗**(不是现在重算一份):
  445. // 少了窗口,第 2 页会悄悄变回全库口径;每次重算窗口,`endTime` 会毫秒级后移,
  446. // 边界工单就会在页与页之间重复或消失。两种情况都是「看不出来」的错。
  447. const window = workOrderWindow || activeConditions()
  448. let result
  449. try {
  450. result = await fetchWorkOrderList(
  451. { page, pageSize: WORK_ORDER_LIST_PAGE_SIZE, startTime: window.startTime, endTime: window.endTime },
  452. requestOptions,
  453. )
  454. } catch (error) {
  455. result = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
  456. }
  457. // 迟到响应丢弃:期间又翻过页/切过条件,这一页不落分区。
  458. if (mine !== workOrderToken) return sections.value.workOrderList
  459. const view = serverPageView(
  460. result && result.data && result.data.pagination,
  461. result && result.data && Array.isArray(result.data.orders) ? result.data.orders.length : 0,
  462. )
  463. // 后端夹回(如数据变少后第 3 页不存在):以回显为准,不再补一次请求。
  464. if (view) workOrderPage.value = view.page
  465. sections.value = { ...sections.value, workOrderList: { ...result, loaded: true } }
  466. return sections.value.workOrderList
  467. }
  468. /**
  469. * 效率面板「报警列表 / 预警列表」翻页(服务端页码分页;票 71):只重取效率分区。
  470. *
  471. * 三条纪律与事件表/工单列表翻页一致,竞态用**独立令牌**:
  472. * · 翻页保持旧内容、不闪空白(条文 8);
  473. * · 页码越界由后端回显夹回(`serverPageView` 同一判据)并同步回 `efficiencyPage`;
  474. * · 迟到的页响应整体丢弃(`efficiencyToken` 变了就放弃)。
  475. *
  476. * 请求条件必须用 `loadPanels` 钉下的那一份(`efficiencyConditions`):重算窗口会让
  477. * `endTime` 毫秒级后移,边界事件在页与页之间重复或消失。
  478. *
  479. * @param {number} next 目标页码(1 起)
  480. * @returns {Promise<object>} `sections.responseEfficiency`
  481. */
  482. async function setEfficiencyPage(next) {
  483. const page = Math.max(1, Math.trunc(Number(next) || 1))
  484. if (page === efficiencyPage.value) return sections.value.responseEfficiency
  485. efficiencyPage.value = page
  486. efficiencyToken += 1
  487. const mine = efficiencyToken
  488. // 条件缺失(还没 loadPanels 过)时退回当前条件——那是首屏之前的情况,页码本就该是第 1 页。
  489. const active = efficiencyConditions || activeConditions()
  490. let result
  491. try {
  492. result = await fetchHandlingEfficiency({ ...active, page, pageSize: EFFICIENCY_EVENT_PAGE_SIZE })
  493. } catch (error) {
  494. result = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
  495. }
  496. // 迟到响应丢弃:期间又翻过页/切过条件,这一页不落分区。
  497. if (mine !== efficiencyToken) return sections.value.responseEfficiency
  498. const view = serverPageView(
  499. result && result.data && result.data.pagination,
  500. result && result.data && Array.isArray(result.data.events) ? result.data.events.length : 0,
  501. )
  502. // 后端夹回(数据变少后第 3 页不存在):以回显为准,不再补一次请求。
  503. if (result && result.data && result.data.pagination) efficiencyPage.value = view.page
  504. sections.value = { ...sections.value, responseEfficiency: { ...result, loaded: true } }
  505. return sections.value.responseEfficiency
  506. }
  507. /**
  508. * 取当前事件列表(跨设备的 `/events/current`;票 13 的预警处置面板数据源)。
  509. *
  510. * `options.mode` 显式给(菜单面板里点别的族)时更新切换状态;没给就沿用当前族。
  511. * token 纪律:换族后旧的在途响应作废(迟到的预警不落进报警表)。
  512. * 票 61:`page`/`pageSize` 进请求;`page` 缺省回第 1 页(这是一次完整重取)。
  513. * @param {object} [options] `{ mode, industry, level, status, page, pageSize, config, fetchImpl }`
  514. * @returns {Promise<object>} `sections.currentEvents`
  515. */
  516. async function loadCurrentEvents(options = {}) {
  517. if (options.mode !== undefined && isKnownEventMode(options.mode)) mode.value = options.mode
  518. if (Object.keys(options).some((key) => !['mode', 'page', 'pageSize'].includes(key))) configure(options)
  519. // 维护者 2026-10-29 第 4 条:一次取全,页码是前端视图状态(见 CURRENT_EVENTS_FETCH_SIZE)。
  520. currentPage.value = Number.isInteger(options.page) && options.page > 0 ? options.page : 1
  521. token += 1
  522. const mine = token
  523. loading.value = true
  524. const result = await fetchCurrentAt(1, CURRENT_EVENTS_FETCH_SIZE)
  525. // 竞态令牌:这里不再经过 `loadPage`(那个函数内部做丢弃),所以必须**自己判**。
  526. // 少了这两行,快速切族时迟到的旧族响应会覆盖新族数据(t13 的 G4 实测抓到过)。
  527. if (mine !== token) return sections.value.currentEvents
  528. // 迟到的响应丢弃:mode/页码换过之后,旧请求的结果不得写进新分区。
  529. if (!result) return sections.value.currentEvents
  530. const next = { ...result, loaded: true }
  531. sections.value = { ...sections.value, currentEvents: next }
  532. currentDomain.value = result.data || null
  533. selectedEvent.value = refreshedEventSelection(selectedEvent.value, currentDomain.value)
  534. loading.value = false
  535. return next
  536. }
  537. /** 从当前列表打开详情;不改变当前列表数据或筛选上下文。 */
  538. function openEvent(event) {
  539. selectedEvent.value = event || null
  540. // 定位结果先清空:位置(设备码/点位码)由 `locateEvent` 按事件所在行的编码现算,
  541. // 这里只留一个"还没定位过"的空态,不预先冒充结果。
  542. locate.value = { result: '', code: (event && event.code) || '', deviceCode: '', pointCode: '', reasonText: '' }
  543. return selectedEvent.value
  544. }
  545. function closeEvent() {
  546. selectedEvent.value = null
  547. locate.value = { result: '', code: '', deviceCode: '', pointCode: '', reasonText: '' }
  548. // 收起详情时把强调还原(传空 = 全部回到常态),地图上不留上一轮的高亮。
  549. highlightMapObject([])
  550. }
  551. async function locateEvent(event = selectedEvent.value, deps = {}) {
  552. if (event) selectedEvent.value = event
  553. const current = selectedEvent.value
  554. const mapStore = useMapStore()
  555. const objects = deps.objects || [
  556. ...getAssociatedDeviceState().plan.filter((row) => row.placement === 'drawn').map((row) => ({ ...row, kind: 'monitoring-device' })),
  557. ...mapStore.summary.objects,
  558. ]
  559. const mapReady = deps.mapReady === undefined ? !!getCameraState() : !!deps.mapReady
  560. const plan = planEventLocate(current, objects, mapReady)
  561. locate.value = { result: plan.result, deviceCode: plan.deviceCode, pointCode: plan.pointCode, reasonText: plan.reasonText }
  562. if (plan.result !== EVENT_DETAIL_RESULT.OK) return locate.value
  563. const focus = deps.focus || setCameraFocus
  564. const highlight = deps.highlight || highlightMapObject
  565. if (!plan.target || focus(plan.target) !== true) {
  566. locate.value = { result: EVENT_DETAIL_RESULT.UNAVAILABLE, deviceCode: plan.deviceCode, pointCode: plan.pointCode, reasonText: '地图尚未就绪,稍后重试' }
  567. return locate.value
  568. }
  569. // 设备与关联管线一起高亮;一个都没高亮到 = 目标还没上图(如实说,不假装成功)。
  570. const matched = highlight(plan.codes)
  571. if (!matched) {
  572. locate.value = { result: EVENT_DETAIL_RESULT.UNAVAILABLE, deviceCode: plan.deviceCode, pointCode: plan.pointCode, reasonText: '关联设备尚未出现在地图上' }
  573. }
  574. return locate.value
  575. }
  576. /**
  577. * 切换预警/报警。唯一入口(组件与票 19 的面板都走它):
  578. * 改切换状态 + 换族重取 —— 切换与字段/状态标签同步是 `domain/eventList.js` 的
  579. * `eventListSwitchView` 结构保证的,这里只管状态与取数。
  580. * @param {string} next `WARNING`/`ALARM`;认不出不改
  581. * @returns {Promise<object>} 分区
  582. */
  583. async function setMode(next) {
  584. if (!isKnownEventMode(next) || next === mode.value) return sections.value.currentEvents
  585. // 类型筛选是预警族的词汇,切族即失效(避免报警列表被一条预警类型静默筛空)。
  586. clearWarningTypeFilter()
  587. return loadPanels({ mode: next })
  588. }
  589. /** 仅测试用:回到未加载状态。 */
  590. function reset() {
  591. token += 1
  592. mode.value = EVENT_FAMILY.WARNING
  593. sections.value = initialSections()
  594. currentDomain.value = null
  595. range.value = '7d'
  596. warningTypeFilter.value = { code: '', label: '' }
  597. currentPage.value = 1
  598. historyPage.value = 1
  599. // 票 71:效率列表的页码与钉住的条件一并回零(reset 是「回到未加载状态」,
  600. // 留下旧页码/旧条件会让下一轮首屏取到第 3 页或旧窗口)。
  601. workOrderPage.value = 1
  602. efficiencyPage.value = 1
  603. workOrderWindow = null
  604. efficiencyConditions = null
  605. selectedEvent.value = null
  606. locate.value = { result: '', code: '', deviceCode: '', pointCode: '', reasonText: '' }
  607. loading.value = false
  608. requestOptions = {}
  609. }
  610. return {
  611. mode,
  612. range,
  613. sections,
  614. loading,
  615. currentPage,
  616. historyPage,
  617. workOrderPage,
  618. currentDomain,
  619. selectedEvent,
  620. locate,
  621. view,
  622. configure,
  623. setRange,
  624. loadPanels,
  625. openEvent,
  626. closeEvent,
  627. locateEvent,
  628. setSection,
  629. loadCurrentEvents,
  630. setCurrentPage,
  631. setHistoryPage,
  632. setWorkOrderPage,
  633. efficiencyPage,
  634. setEfficiencyPage,
  635. setMode,
  636. warningTypeFilter,
  637. setWarningTypeFilter,
  638. clearWarningTypeFilter,
  639. reset,
  640. }
  641. })