api.js 61 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541
  1. /**
  2. * 模拟 API 接口层(动态数据版)
  3. *
  4. * 特性:
  5. * - 数据来源:mock_data.json(由 generate_mock_data.py 从真实 XLS 路口数据生成)
  6. * - 静态资源(视频/图片)在此处统一 import,接口直接返回完整数据
  7. * - 支持分页、筛选、排序等动态查询
  8. * - 每次请求信号倒计时、在线率、告警时间等实时数据会动态变化
  9. * - 路口状态会随机波动,模拟真实监控场景
  10. *
  11. * 使用:import { apiLogin, apiGetPoints, ... } from '@/pyscripts/api'
  12. * 所有接口返回 Promise<{ code, message, data }>
  13. */
  14. import mockData from './mock_data.json'
  15. import { simulateMaxband } from './_simulateMaxband'
  16. import { buildIconComboDemoData } from './_sample_phase_icons'
  17. import { applyControlModeColors, applyDeviceFaultColors } from '@/config/chartColors'
  18. // ── 静态资源(模拟 CDN / 后端返回的资源 URL)─────────────────────
  19. import video1 from '@/assets/videos/video1.mp4'
  20. import video2 from '@/assets/videos/video2.mp4'
  21. import video3 from '@/assets/videos/video3.mp4'
  22. import video4 from '@/assets/videos/video4.mp4'
  23. import arrow1 from '@/assets/images/arrow_1.png'
  24. import arrow2 from '@/assets/images/arrow_2.png'
  25. import arrow3 from '@/assets/images/arrow_3.png'
  26. import arrow4 from '@/assets/images/arrow_4.png'
  27. const VIDEOS = [video1, video2, video3, video4]
  28. const ARROWS = [arrow1, arrow2, arrow3, arrow4]
  29. function pickVideo(i) { return VIDEOS[i % VIDEOS.length] }
  30. // ── 工具 ─────────────────────────────────────────────────────────────
  31. function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
  32. function delay(base = 200) { return sleep(base + Math.floor(Math.random() * 200)) }
  33. function ok(data) { return { code: 200, message: 'success', data } }
  34. function fail(msg, code = 400) { return { code, message: msg, data: null } }
  35. /** 根据路口ID生成稳定的设备状态(所有API共用,确保一致) */
  36. function _getDeviceStatus(id) {
  37. const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
  38. const statusList = ['在线', '在线', '在线', '在线', '在线', '在线', '在线', '离线']
  39. return statusList[seed % statusList.length]
  40. }
  41. /**
  42. * 根据路口ID生成稳定的地图分类(在线→控制方式,离线→异常类型)
  43. * 地图标记和 API 共用,确保状态一致
  44. */
  45. export function getIntersectionCategory(id) {
  46. const status = _getDeviceStatus(id)
  47. const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
  48. if (status === '离线') {
  49. const abnormalTypes = ['离线', '降级', '故障']
  50. return abnormalTypes[seed % abnormalTypes.length]
  51. }
  52. // 用 floor(seed / 8) 解耦:_getDeviceStatus 已用 seed%8 决定在/离线,
  53. // 这里若再用同一模数,最后一个 index 永远拿不到数据(被离线分支吃掉了)
  54. const normalTypes = ['中心计划', '定周期控制', '感应控制', '自适应控制', '手动控制', '黄闪', '全红', '关灯']
  55. return normalTypes[Math.floor(seed / 8) % normalTypes.length]
  56. }
  57. /** 基于当前秒数产生稳定随机(同一秒内多次调用返回相同值) */
  58. function seededRand(seed) {
  59. const x = Math.sin(seed) * 10000
  60. return x - Math.floor(x)
  61. }
  62. /** 根据路口 ID 生成稳定 seed(全字符加权,所有 API 共用) */
  63. function _idSeed(id) {
  64. return id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
  65. }
  66. /** 根据路口 ID 获取稳定的 cycleLength(优先 preset → crossingList → seed 兜底) */
  67. function _getCycleLength(id) {
  68. const preset = DB.signalTimings[id]
  69. if (preset) return preset.data.cycleLength
  70. const crossing = DB.crossingList.find(r => r.id === id)
  71. if (crossing && crossing.cycle) return crossing.cycle
  72. return [100, 120, 130, 140, 150, 160][_idSeed(id) % 6]
  73. }
  74. /** 当前时间 HH:MM:SS */
  75. function nowTime() { return new Date().toLocaleTimeString() }
  76. function nowDate() { return new Date().toLocaleDateString() }
  77. // ── 数据缓存(首次 import 时加载) ──────────────────────────────────
  78. const DB = {
  79. points: mockData.points,
  80. menuTree: mockData.menuTree,
  81. tongzhouMenuTree: mockData.tongzhouMenuTree,
  82. trunkLineMenuTree: mockData.trunkLineMenuTree,
  83. homeData: mockData.homeData,
  84. deviceStatus: mockData.deviceStatus,
  85. timeSpaceData: mockData.timeSpaceData,
  86. crossingList: mockData.crossingList,
  87. securityRoutes: mockData.securityRoutes,
  88. securityTasks: mockData.securityTasks,
  89. filterOptions: mockData.filterOptions,
  90. signalTimings: mockData.sampleSignalTimings,
  91. intersectionConfigs: mockData.sampleIntersectionConfigs,
  92. }
  93. // ── 内部生成器 ───────────────────────────────────────────────────────
  94. /**
  95. * 生成摄像机模拟数据(基于 XLS 摄像机 Sheet 字段结构)
  96. * 字段:路口名、路口编号、摄像机编号、登录名称、摄像头密码、摄像头类型、端口号、IP地址、是否启用、位置
  97. */
  98. function _makeCameras(id, name, seed) {
  99. const positions = ['北进口', '南进口', '东进口', '西进口']
  100. // 4 方向各 1 个、统一枪机:视频模式下示意图 4 臂图标一致,避免 球机方框 与检测器视觉混淆
  101. return positions.map((pos, i) => ({
  102. intersection: name || id,
  103. intersectionId: id,
  104. cameraId: `CAM${(id || '000').slice(-6)}_${String(i + 1).padStart(2, '0')}`,
  105. loginName: `admin_${String(seed + i).slice(-4)}`,
  106. password: '******',
  107. cameraType: '枪机',
  108. port: 554 + i,
  109. ip: `192.168.${10 + (seed % 200)}.${100 + i}`,
  110. enabled: true,
  111. position: pos,
  112. }))
  113. }
  114. /** 从摄像机列表推导各方向摄像头类型 (1枪机 2球机 0无) */
  115. function _camerasToArmTypes(cameras) {
  116. const posMap = { '北进口': 'N', '南进口': 'S', '东进口': 'E', '西进口': 'W' }
  117. const typeMap = { '枪机': 1, '球机': 2 }
  118. const result = { N: 0, S: 0, E: 0, W: 0 }
  119. cameras.forEach(c => {
  120. if (c.enabled) {
  121. const dir = posMap[c.position]
  122. if (dir) result[dir] = typeMap[c.cameraType] || 0
  123. }
  124. })
  125. return result
  126. }
  127. /**
  128. * 检测器单一数据源:4 方向各 1 条(与画布 ① ② ③ ④ 一一对应)。
  129. * 编号 1=北、2=东、3=南、4=西。
  130. * bucketIdx(5s 桶)决定确定性噪声,同桶内多次调用返回相同值,让画布与表格读数同步。
  131. */
  132. function _detectorBucketSnapshot(id, bucketIdx, name) {
  133. const seed = _idSeed(id || '')
  134. const dirs = [
  135. { dir: 'N', label: '北', index: 1 },
  136. { dir: 'E', label: '东', index: 2 },
  137. { dir: 'S', label: '南', index: 3 },
  138. { dir: 'W', label: '西', index: 4 },
  139. ]
  140. return dirs.map(d => {
  141. const i = d.index
  142. const baseFlow = 60 + ((seed * (i * 7 + 13)) % 160) // 60~220
  143. const baseOcc = 15 + ((seed * (i * 11 + 17)) % 50) // 15~65
  144. // 桶内确定性噪声(无 Math.random,保证同 bucketIdx 多次调用结果一致)
  145. const flowNoise01 = (((seed ^ (i * 257) ^ (bucketIdx * 9176)) >>> 0) % 10000) / 10000
  146. const occNoise01 = (((seed ^ (i * 521) ^ (bucketIdx * 4093)) >>> 0) % 10000) / 10000
  147. const flow = Math.max(0, Math.round(baseFlow * (1 + (flowNoise01 - 0.5) * 0.3))) // ±15%
  148. const occupancy = Math.max(0, Math.min(100, Math.round(baseOcc + (occNoise01 - 0.5) * 10))) // ±5
  149. return {
  150. intersection: name || id,
  151. intersectionId: id,
  152. detectorId: `DT${(id || '000').slice(-6)}_${String(i).padStart(2, '0')}`,
  153. index: i,
  154. position: `${d.label}进口`,
  155. direction: d.dir,
  156. name: `${d.label}进口`,
  157. flow,
  158. occupancy,
  159. enabled: true,
  160. }
  161. })
  162. }
  163. /** 4 条 lane → 4 方向 armsDetector(一一对应,无需挑选) */
  164. function _bucketArmsDetector(lanes) {
  165. const out = { N: null, S: null, E: null, W: null }
  166. for (const lane of lanes) {
  167. if (!out[lane.direction]) {
  168. out[lane.direction] = {
  169. index: lane.index,
  170. detectorId: lane.detectorId,
  171. flow: lane.flow,
  172. occupancy: lane.occupancy,
  173. }
  174. }
  175. }
  176. return out
  177. }
  178. /**
  179. * 按车道展开的检测器快照——与画布徽章一一对应。
  180. * 编号顺序:N→E→S→W 顺时针累加;每个方向内司机视角左→右(arm-local 最外侧 → 最内侧)。
  181. * 4 方向 × lanesPerDir 条车道 = 全局连续编号 1..(4*lanesPerDir)。
  182. */
  183. function _detectorLaneBucketSnapshot(id, bucketIdx, name, lanesPerDir = 4) {
  184. const seed = _idSeed(id || '')
  185. const dirs = [
  186. { dir: 'N', label: '北' },
  187. { dir: 'E', label: '东' },
  188. { dir: 'S', label: '南' },
  189. { dir: 'W', label: '西' },
  190. ]
  191. const out = []
  192. let badgeNum = 1
  193. dirs.forEach(d => {
  194. // 司机视角左→右:arm-local 最外侧(laneIdx = lanesPerDir-1)→ 最内侧(laneIdx = 0)
  195. for (let laneIdx = lanesPerDir - 1; laneIdx >= 0; laneIdx--) {
  196. const driverPos = lanesPerDir - laneIdx // 1..N,司机左数第几条
  197. const i = badgeNum
  198. const baseFlow = 60 + ((seed * (i * 7 + 13)) % 160)
  199. const baseOcc = 15 + ((seed * (i * 11 + 17)) % 50)
  200. const flowNoise01 = (((seed ^ (i * 257) ^ (bucketIdx * 9176)) >>> 0) % 10000) / 10000
  201. const occNoise01 = (((seed ^ (i * 521) ^ (bucketIdx * 4093)) >>> 0) % 10000) / 10000
  202. const flow = Math.max(0, Math.round(baseFlow * (1 + (flowNoise01 - 0.5) * 0.3)))
  203. const occupancy = Math.max(0, Math.min(100, Math.round(baseOcc + (occNoise01 - 0.5) * 10)))
  204. out.push({
  205. intersection: name || id,
  206. intersectionId: id,
  207. detectorId: `DT${(id || '000').slice(-6)}_${String(badgeNum).padStart(2, '0')}`,
  208. index: badgeNum,
  209. direction: d.dir,
  210. laneIndex: laneIdx,
  211. driverPos,
  212. position: `${d.label}进口`,
  213. name: `${d.label}进口 第${driverPos}车道`,
  214. flow,
  215. occupancy,
  216. enabled: true,
  217. })
  218. badgeNum++
  219. }
  220. })
  221. return out
  222. }
  223. // 保留旧入口名,让调用方零修改(_makeIntersectionConfig 等仍能用)
  224. function _makeDetectors(id, name /* seed 不再使用:bucket 自己用 _idSeed */) {
  225. const bucketIdx = Math.floor(Date.now() / 5000)
  226. return _detectorBucketSnapshot(id, bucketIdx, name)
  227. }
  228. function _detectorsToArmConfig(detectors) {
  229. return _bucketArmsDetector(detectors)
  230. }
  231. function _makeIntersectionConfig(id, name, { fixedNsGreen, iconMode = 'default' } = {}) {
  232. const phases = ['南北直行', '东西直行', '北单放', '东单放']
  233. const nsGreen = fixedNsGreen !== undefined ? fixedNsGreen : false
  234. const countdown = 10 + Math.floor(Math.random() * 50)
  235. const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
  236. const cameras = _makeCameras(id, name, seed)
  237. const armCamTypes = _camerasToArmTypes(cameras)
  238. const detectors = _makeDetectors(id, name, seed)
  239. const armDetectors = _detectorsToArmConfig(detectors)
  240. const lanesPresetMap = {
  241. default: {
  242. N: ['U', 'L', 'S', 'R'],
  243. S: ['U', 'L', 'S', 'R'],
  244. E: ['U', 'L', 'S', 'R'],
  245. W: ['U', 'L', 'S', 'R'],
  246. },
  247. simple: {
  248. N: ['L', 'S', null, null], // 北:左转+直行
  249. S: ['U', 'S', null, null], // 南:掉头+直行
  250. E: ['L', 'S', null, null], // 东:掉头+直行
  251. W: ['U', 'S', null, null], // 西:左转+直行
  252. },
  253. }
  254. const lanesPreset = lanesPresetMap[iconMode] || lanesPresetMap.default
  255. return {
  256. status: _getDeviceStatus(id),
  257. signals: {
  258. ns: { phaseName: phases[0], time: countdown, isGreen: nsGreen },
  259. ew: { phaseName: phases[1], time: countdown, isGreen: !nsGreen },
  260. },
  261. armsConfig: {
  262. N: { cameraType: armCamTypes.N, lanes: lanesPreset.N, detector: armDetectors.N },
  263. S: { cameraType: armCamTypes.S, lanes: lanesPreset.S, detector: armDetectors.S },
  264. E: { cameraType: armCamTypes.E, lanes: lanesPreset.E, detector: armDetectors.E },
  265. W: { cameraType: armCamTypes.W, lanes: lanesPreset.W, detector: armDetectors.W },
  266. },
  267. cameras,
  268. detectors,
  269. }
  270. }
  271. /**
  272. * 动态生成路口相位配时数据
  273. * @param {number} cycleLength 周期总时长
  274. * @param {boolean} isTwoRows 是否生成上下双排 8 相位 (默认 true)
  275. */
  276. // 相位数据缓存:同一路口 (cycleLength+iconMode) 只生成一次,列表和详情弹窗共享
  277. const _phaseDataCache = {};
  278. function _makePhaseData(cycleLength = 140, isTwoRows = true, iconMode = 'default', id = '') {
  279. // 缓存 key:用路口 ID(有的话),保证同一路口列表和详情共享同一份数据
  280. const cacheKey = id || `${cycleLength}_${iconMode}`;
  281. if (_phaseDataCache[cacheKey]) return _phaseDataCache[cacheKey];
  282. const n = 4; // 4个阶段 (S1-S4)
  283. // 各阶段按比例分配时间,P1/P3较长,P2/P4较短
  284. const ratios = [0.3, 0.2, 0.3, 0.2];
  285. const stageTimes = ratios.map(r => Math.floor(cycleLength * r));
  286. // 把余数补到第一个阶段,确保总和 = cycleLength
  287. stageTimes[0] += cycleLength - stageTimes.reduce((a, b) => a + b, 0);
  288. const pd = [];
  289. // 固定4个阶段的图标和方向:P1南北直行、P2南北左转、P3东西直行、P4东西左转
  290. const phaseConfigMap = {
  291. default: [
  292. { icon: 'STRAIGHT_DOWN,STRAIGHT_UP', direction: 'ns' }, // P1: 南北直行
  293. { icon: 'TURN_DOWN_LEFT,TURN_UP_LEFT', direction: 'ns' }, // P2: 南北左转
  294. { icon: 'STRAIGHT_LEFT,STRAIGHT_RIGHT', direction: 'ew' }, // P3: 东西直行
  295. { icon: 'TURN_LEFT_DOWN,TURN_RIGHT_UP', direction: 'ew' }, // P4: 东西左转
  296. ],
  297. simple: [
  298. { icon: 'STRAIGHT_DOWN,STRAIGHT_UP', direction: 'ns' }, // P1: 南北直行
  299. { icon: 'TURN_DOWN_LEFT,TURN_UP_LEFT_UTURN', direction: 'ns' }, // P2: 北左转+南掉头
  300. { icon: 'STRAIGHT_LEFT,STRAIGHT_RIGHT', direction: 'ew' }, // P3: 东西直行
  301. { icon: 'TURN_LEFT_DOWN,TURN_RIGHT_UP_UTURN', direction: 'ew' }, // P4: 东左转+西掉头
  302. ],
  303. };
  304. const phaseConfig = phaseConfigMap[iconMode] || phaseConfigMap.default;
  305. let t = 0;
  306. for (let i = 0; i < n; i++) {
  307. const stageStart = t;
  308. const stageTime = stageTimes[i];
  309. const stageEnd = stageStart + stageTime;
  310. const { icon: stageIcon, direction } = phaseConfig[i];
  311. const pushTrackData = (trackIdx, phaseNamePrefix) => {
  312. const icon = stageIcon;
  313. const phaseName = `${phaseNamePrefix}${i + 1}`;
  314. const s = Math.floor(Math.random() * 3) + 3; // 绿闪/条纹 3-5s
  315. const y = 3; // 黄灯固定 3s
  316. const r = 2; // 全红间隔 2s
  317. const g = stageTime - s - y - r; // 绿灯 = 阶段总时长 - 条纹 - 黄灯 - 红灯
  318. let curT = stageStart;
  319. // 1. 绿灯 (phase[8]存阶段总时长,用于显示)
  320. pd.push([trackIdx, curT, curT + g, phaseName, g, 'green', icon, direction, stageTime]);
  321. curT += g;
  322. // 2. 绿闪/条纹
  323. pd.push([trackIdx, curT, curT + s, '', s, 'stripe', null, direction]);
  324. curT += s;
  325. // 3. 黄灯
  326. pd.push([trackIdx, curT, curT + y, '', y, 'yellow', null, direction]);
  327. curT += y;
  328. // 4. 红灯固定
  329. pd.push([trackIdx, curT, curT + r, '', r, 'red', null, direction]);
  330. };
  331. pushTrackData(0, 'P'); // 生成第一排 (P1-P4)
  332. if (isTwoRows) {
  333. pushTrackData(1, 'P'); // 生成第二排
  334. }
  335. t = stageEnd;
  336. }
  337. _phaseDataCache[cacheKey] = pd;
  338. return pd;
  339. }
  340. /**
  341. * 任意阶段数的相位数据生成(不缓存,每次新生成)
  342. * 用于 JNC900032 这类需要演示 N 阶段双相位图的示例路口。
  343. * 单轨道(trackIdx=0),4 方向 icon 轮转,子段长度按阶段时间自适应。
  344. * @param {number} cycleLength 周期总时长(秒)
  345. * @param {number} stageCount 阶段数
  346. */
  347. function _makeFlexiblePhaseData(cycleLength, stageCount) {
  348. const baseStage = Math.floor(cycleLength / stageCount);
  349. const remainder = cycleLength - baseStage * stageCount;
  350. const stageTimes = Array.from({ length: stageCount }, (_, i) =>
  351. baseStage + (i === 0 ? remainder : 0));
  352. // 与 _makePhaseData(iconMode='simple') 对齐:P2 北左转+南掉头、P4 东左转+西掉头
  353. // dual 示例路口(JNC900032/JNC900016)通过 apiGetCrossingDetailData 走 simple 模式,图标必须一致
  354. const iconCycle = [
  355. { icon: 'STRAIGHT_DOWN,STRAIGHT_UP', direction: 'ns' }, // P1 南北直行
  356. { icon: 'TURN_DOWN_LEFT,TURN_UP_LEFT_UTURN', direction: 'ns' }, // P2 北左转 + 南掉头
  357. { icon: 'STRAIGHT_LEFT,STRAIGHT_RIGHT', direction: 'ew' }, // P3 东西直行
  358. { icon: 'TURN_LEFT_DOWN,TURN_RIGHT_UP_UTURN', direction: 'ew' }, // P4 东左转 + 西掉头
  359. ];
  360. const pd = [];
  361. let t = 0;
  362. for (let i = 0; i < stageCount; i++) {
  363. const stageTime = stageTimes[i];
  364. const cfg = iconCycle[i % iconCycle.length];
  365. const phaseName = `P${i + 1}`;
  366. // 子段长度自适应:阶段时间太短就退化为纯 green,避免出现 0 时长子段
  367. const s = stageTime >= 4 ? Math.max(1, Math.min(3, Math.floor(stageTime * 0.15))) : 0;
  368. const y = stageTime >= 3 ? Math.max(1, Math.min(3, Math.floor(stageTime * 0.15))) : 0;
  369. const r = stageTime >= 8 ? Math.max(0, Math.min(2, Math.floor(stageTime * 0.10))) : 0;
  370. const g = stageTime - s - y - r;
  371. if (g <= 0) {
  372. pd.push([0, t, t + stageTime, phaseName, stageTime, 'green', cfg.icon, cfg.direction, stageTime]);
  373. t += stageTime;
  374. continue;
  375. }
  376. pd.push([0, t, t + g, phaseName, g, 'green', cfg.icon, cfg.direction, stageTime]);
  377. t += g;
  378. if (s > 0) { pd.push([0, t, t + s, '', s, 'stripe', null, cfg.direction]); t += s; }
  379. if (y > 0) { pd.push([0, t, t + y, '', y, 'yellow', null, cfg.direction]); t += y; }
  380. if (r > 0) { pd.push([0, t, t + r, '', r, 'red', null, cfg.direction]); t += r; }
  381. }
  382. return pd;
  383. }
  384. function _makeCornerVideos() {
  385. return { nw: video1, ne: video2, sw: video3, se: video4 }
  386. }
  387. function _makeStageList() {
  388. return [1, 2, 3, 4].map((_, i) => ({
  389. value: String(i + 1), time: [30, 30, 50, 30][i], img: ARROWS[i],
  390. }))
  391. }
  392. function _makeCardPhases(activeIndex = 0) {
  393. return ARROWS.map((img, i) => ({
  394. id: i + 1, icon: ['↑', '↰', '↑', '↰'][i], img, active: i === activeIndex,
  395. }))
  396. }
  397. /** 动态波动整数值(基于基准值上下浮动) */
  398. function _fluctuate(base, range) {
  399. return base + Math.floor(Math.random() * range * 2) - range
  400. }
  401. /** 动态更新路口状态(每次调用随机波动少量路口) */
  402. function _dynamicPoints() {
  403. return DB.points.map((p, i) => {
  404. const r = seededRand(i + 31)
  405. const status = r < 0.78 ? 'normal' : r < 0.92 ? 'busy' : 'alarm'
  406. return { ...p, status, updatedAt: Date.now() - Math.floor(r * 120000) }
  407. })
  408. }
  409. // ═══════════════════════════════════════════════════════════════════════
  410. // E: 认证
  411. // ═══════════════════════════════════════════════════════════════════════
  412. /** 验证码状态(模拟服务端 session 存储) */
  413. let _captchaStore = { code: '', expireAt: 0 }
  414. /**
  415. * GET /api/auth/captcha — 获取验证码
  416. * 返回 4 位随机字符 + base64 图片(Canvas 绘制)
  417. * 真实后端应返回图片流,这里模拟返回验证码文本 + 配置,由前端 Canvas 绘制
  418. */
  419. export async function apiGetCaptcha() {
  420. await delay(100)
  421. const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
  422. const code = Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
  423. // 存储验证码,有效期 60 秒
  424. _captchaStore = { code: code.toUpperCase(), expireAt: Date.now() + 60000 }
  425. return ok({
  426. captchaId: 'cap_' + Date.now(),
  427. code, // 模拟环境直接返回明文,真实环境不应返回
  428. length: 4,
  429. expireIn: 60, // 秒
  430. })
  431. }
  432. /**
  433. * POST /api/auth/login — 登录(含验证码校验)
  434. */
  435. export async function apiLogin({ username, password, captcha }) {
  436. await delay(300)
  437. // 验证码校验
  438. if (_captchaStore.code && captcha) {
  439. if (Date.now() > _captchaStore.expireAt) {
  440. return fail('验证码已过期,请刷新', 401)
  441. }
  442. if (captcha.toUpperCase() !== _captchaStore.code) {
  443. return fail('验证码错误', 401)
  444. }
  445. }
  446. // 账号密码校验
  447. if (username === 'admin' && password === '123456') {
  448. _captchaStore = { code: '', expireAt: 0 } // 登录成功后清除验证码
  449. return ok({ token: 'demo_token_' + Date.now(), user: { name: 'admin', role: '管理员' } })
  450. }
  451. return fail('账号或密码错误(演示账号:admin / 123456)', 401)
  452. }
  453. export async function apiChangePassword({ oldPassword, newPassword }) {
  454. await delay(300)
  455. if (oldPassword === '123456') return ok({ message: '密码修改成功' })
  456. return fail('原密码错误')
  457. }
  458. // ═══════════════════════════════════════════════════════════════════════
  459. // A: 路口基础数据
  460. // ═══════════════════════════════════════════════════════════════════════
  461. /**
  462. * GET /api/intersections — 路口点位列表
  463. * 每次调用路口状态会动态波动
  464. */
  465. export async function apiGetPoints(filters = {}) {
  466. await delay(200)
  467. let list = _dynamicPoints()
  468. if (filters.node) list = list.filter(p => p.node === filters.node)
  469. if (filters.status) list = list.filter(p => p.status === filters.status)
  470. if (filters.keyword) {
  471. const kw = filters.keyword.toLowerCase()
  472. list = list.filter(p => p.name.toLowerCase().includes(kw) || p.id.toLowerCase().includes(kw))
  473. }
  474. return ok(list)
  475. }
  476. /**
  477. * GET /api/intersections/:id — 路口详情
  478. * 信号倒计时每次请求动态变化
  479. */
  480. export async function apiGetIntersectionData(id, { fixedNsGreen } = {}) {
  481. await delay(250)
  482. const base = DB.intersectionConfigs[id]
  483. const point = DB.points.find(p => p.id === id)
  484. const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
  485. // 动态倒计时:基于当前秒数计算
  486. const nowSec = Math.floor(Date.now() / 1000)
  487. const cycle = 140
  488. const elapsed = nowSec % cycle
  489. const nsGreenVal = fixedNsGreen !== undefined ? fixedNsGreen : false
  490. const nsGreen = nsGreenVal
  491. const config = base ? {
  492. status: base.status || _getDeviceStatus(id),
  493. signals: {
  494. ns: { ...base.signals.ns, time: Math.max(1, cycle - elapsed), isGreen: nsGreen },
  495. ew: { ...base.signals.ew, time: elapsed || 1, isGreen: !nsGreen },
  496. },
  497. armsConfig: base.armsConfig,
  498. cameras: base.cameras,
  499. } : _makeIntersectionConfig(id, point ? point.name : id, { fixedNsGreen: nsGreenVal })
  500. return ok({
  501. ...config,
  502. id, name: point ? point.name : id,
  503. mainVideo: pickVideo(seed),
  504. cornerVideos: _makeCornerVideos(seed),
  505. })
  506. }
  507. /**
  508. * GET /api/intersections/:id/signal-timing — 信号配时
  509. * currentTime 随真实时间走动
  510. */
  511. export async function apiGetSignalTiming(id) {
  512. await delay(300)
  513. const cycleLength = _getCycleLength(id)
  514. return ok({
  515. cycleLength,
  516. currentTime: Math.floor(Date.now() / 1000) % cycleLength,
  517. phaseData: _makePhaseData(cycleLength, false, 'simple', id),
  518. })
  519. }
  520. /** GET /api/intersections/:id/stages */
  521. export async function apiGetIntersectionStages(id) {
  522. await delay(200)
  523. const timing = DB.signalTimings[id]
  524. if (timing) {
  525. const hasTrack1 = timing.data.phaseData.some(p => p[0] === 1)
  526. const phases = timing.data.phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[5] === 'green')
  527. return ok(phases.map((p, i) => ({
  528. value: String(i + 1), time: p[8], phaseName: p[3], direction: p[6], img: ARROWS[i % ARROWS.length],
  529. })))
  530. }
  531. return ok(_makeStageList())
  532. }
  533. /** GET /api/intersections/:id/schemes */
  534. export async function apiGetSchemes(id) {
  535. await delay(150)
  536. return ok(DB.filterOptions.schemeOptions)
  537. }
  538. // ═══════════════════════════════════════════════════════════════════════
  539. // A4: 区域菜单树
  540. // ═══════════════════════════════════════════════════════════════════════
  541. export async function apiGetMenuTree(tabId = 'arterial') {
  542. await delay(250)
  543. return ok(tabId === 'arterial' ? DB.menuTree : DB.tongzhouMenuTree)
  544. }
  545. export async function apiGetTongzhouMenuTree() {
  546. await delay(250)
  547. return ok(DB.tongzhouMenuTree)
  548. }
  549. export async function apiGetTrunkLineMenuTree() {
  550. await delay(200)
  551. return ok(DB.trunkLineMenuTree)
  552. }
  553. // ═══════════════════════════════════════════════════════════════════════
  554. // B: 设备状态 & 首页(动态波动)
  555. // ═══════════════════════════════════════════════════════════════════════
  556. /**
  557. * GET /api/devices/status/summary
  558. * 在线数每次请求轻微波动
  559. */
  560. // ── 信号机故障数缓存:同一秒内多次调用返回同一份数据,保证在线离线与故障同步 ──
  561. let _smFaultCache = { ts: 0, total: 0, faultTotal: 0 }
  562. function _getSmFaultSnapshot() {
  563. const now = Math.floor(Date.now() / 1000)
  564. if (_smFaultCache.ts === now) return _smFaultCache
  565. const sm = DB.deviceStatus.signalMachine
  566. const total = sm.chartData[0].value + sm.chartData[1].value
  567. const yellowFlashMode = DB.homeData.controlModes.find(m => m.name === '黄闪控制')
  568. const yellowFlash = yellowFlashMode ? yellowFlashMode.value : 0
  569. const ctrlBoard = Math.max(0, _fluctuate(5, 3))
  570. const phaseBoard = Math.max(0, _fluctuate(4, 2))
  571. const detBoard = Math.max(0, _fluctuate(3, 2))
  572. const faultTotal = ctrlBoard + phaseBoard + detBoard + yellowFlash
  573. _smFaultCache = { ts: now, total, faultTotal, ctrlBoard, phaseBoard, detBoard, yellowFlash }
  574. return _smFaultCache
  575. }
  576. export async function apiGetDeviceStatus(type) {
  577. await delay(200)
  578. function fluctuateStats(base) {
  579. const online = base.chartData[0].value
  580. const total = online + base.chartData[1].value
  581. const newOnline = _fluctuate(online, Math.ceil(total * 0.02))
  582. const clamped = Math.max(0, Math.min(total, newOnline))
  583. const rate = Math.round(clamped / total * 100)
  584. return {
  585. centerTitle: rate + '%',
  586. centerSubTitle: `${clamped}/${total}`,
  587. chartData: [
  588. { ...base.chartData[0], value: clamped },
  589. { ...base.chartData[1], value: total - clamped },
  590. ]
  591. }
  592. }
  593. // 信号机:离线数 = 故障总数(与设备状态同步)
  594. function smStats() {
  595. const snap = _getSmFaultSnapshot()
  596. const online = snap.total - snap.faultTotal
  597. const rate = Math.round(online / snap.total * 100)
  598. return {
  599. centerTitle: rate + '%',
  600. centerSubTitle: `${online}/${snap.total}`,
  601. chartData: [
  602. { name: '在线', value: online, color: '#32F6F8' },
  603. { name: '离线', value: snap.faultTotal, color: '#E4D552' },
  604. ]
  605. }
  606. }
  607. if (type === 'signalMachine') return ok(smStats())
  608. if (type && DB.deviceStatus[type]) return ok(fluctuateStats(DB.deviceStatus[type]))
  609. return ok({
  610. signalMachine: smStats(),
  611. detector: fluctuateStats(DB.deviceStatus.detector),
  612. camera: fluctuateStats(DB.deviceStatus.camera),
  613. })
  614. }
  615. /**
  616. * GET /api/home/snapshot — 首页快照
  617. * 在线数波动 + 时间实时更新 + 告警时间刷新
  618. */
  619. export async function apiGetHomeSnapshot() {
  620. await delay(200)
  621. const total = DB.homeData.online.total
  622. const online = _fluctuate(Math.round(total * 0.93), 30)
  623. const clamped = Math.max(0, Math.min(total, online))
  624. const fault = Math.floor(Math.random() * 5)
  625. return ok({
  626. header: { ...DB.homeData.header, timeText: nowTime(), dateText: nowDate() },
  627. online: { online: clamped, offline: total - clamped, total, rate: Math.round(clamped / total * 100) },
  628. alarms: DB.homeData.alarms.map(a => ({
  629. ...a,
  630. time: new Date(Date.now() - Math.floor(Math.random() * 3600000)).toLocaleTimeString(),
  631. })),
  632. duty: DB.homeData.duty,
  633. device: { normal: total - fault, fault },
  634. controlModes: DB.homeData.controlModes,
  635. keyIntersections: DB.homeData.keyIntersections,
  636. })
  637. }
  638. /** GET /api/home/control-mode-stats — 控制模式分布(总数与信号机在线数同步) */
  639. export async function apiGetControlModeStats() {
  640. await delay(150)
  641. const snap = _getSmFaultSnapshot()
  642. const onlineTotal = snap.total - snap.faultTotal
  643. const modes = DB.homeData.controlModes
  644. // 各项按基准值波动
  645. const fluctuated = modes.map(m => ({
  646. ...m, value: Math.max(0, _fluctuate(m.value, Math.ceil(m.value * 0.05))),
  647. }))
  648. // 修正总数:将差值补到第一项(定周期控制),保证总数 = 信号机在线数
  649. const currentSum = fluctuated.reduce((s, m) => s + m.value, 0)
  650. fluctuated[0].value = Math.max(0, fluctuated[0].value + (onlineTotal - currentSum))
  651. // 按 name 注入色 (后端只返 { name, value }, 前端统一治理配色)
  652. return ok(applyControlModeColors(fluctuated))
  653. }
  654. /**
  655. * GET /api/alarms/latest — 告警列表(分页 + 动态时间)
  656. * @param {{ page?: number, pageSize?: number, level?: string }} params
  657. */
  658. export async function apiGetLatestAlarms(params = {}) {
  659. await delay(200)
  660. const typeMap = { high: 'error', mid: 'warning', low: 'warning' }
  661. let alarms = DB.homeData.alarmList.map((a, i) => ({
  662. id: a.id, title: a.title, type: a.type || typeMap[a.level] || 'warning',
  663. time: new Date(Date.now() - i * 180000 - Math.floor(Math.random() * 60000)).toLocaleTimeString(),
  664. description: a.description || `${a.loc}-${a.title}`,
  665. position: a.position, level: a.level, loc: a.loc,
  666. }))
  667. if (params.level) alarms = alarms.filter(a => a.level === params.level)
  668. const page = params.page || 1
  669. const pageSize = params.pageSize || 10
  670. const start = (page - 1) * pageSize
  671. return ok({ total: alarms.length, page, pageSize, list: alarms.slice(start, start + pageSize) })
  672. }
  673. // ═══════════════════════════════════════════════════════════════════════
  674. // C: 勤务 & 任务(分页 + 筛选)
  675. // ═══════════════════════════════════════════════════════════════════════
  676. /**
  677. * GET /api/tasks — 勤务任务列表
  678. * 支持 page / pageSize / status / keyword 筛选
  679. */
  680. export async function apiGetTasks(params = {}) {
  681. await delay(200)
  682. let list = [...DB.securityTasks]
  683. if (params.status) list = list.filter(t => t.status === params.status)
  684. if (params.keyword) {
  685. const kw = params.keyword.toLowerCase()
  686. list = list.filter(t => t.name.toLowerCase().includes(kw) || t.executor.toLowerCase().includes(kw))
  687. }
  688. if (params.level) list = list.filter(t => t.level === params.level)
  689. const page = params.page || 1
  690. const pageSize = params.pageSize || 5
  691. const total = list.length
  692. const totalPages = Math.ceil(total / pageSize)
  693. const start = (page - 1) * pageSize
  694. return ok({
  695. total, page, pageSize, totalPages,
  696. list: list.slice(start, start + pageSize),
  697. })
  698. }
  699. /**
  700. * GET /api/security-routes — 勤务路线(含视频)
  701. */
  702. export async function apiGetSecurityRoutes() {
  703. await delay(200)
  704. return ok(DB.securityRoutes.map((r, i) => ({
  705. ...r, mainVideo: pickVideo(i), cornerVideos: _makeCornerVideos(i),
  706. })))
  707. }
  708. /** GET /api/security-routes/:id */
  709. export async function apiGetSecurityRouteDetail(id) {
  710. await delay(200)
  711. const idx = DB.securityRoutes.findIndex(r => r.id === id)
  712. const route = DB.securityRoutes[idx >= 0 ? idx : 0]
  713. if (!route) return fail('路线不存在', 404)
  714. return ok({ ...route, mainVideo: pickVideo(idx >= 0 ? idx : 0), cornerVideos: _makeCornerVideos(idx >= 0 ? idx : 0) })
  715. }
  716. /** GET /api/key-intersections */
  717. export async function apiGetKeyIntersections() {
  718. await delay(150)
  719. return ok(DB.homeData.keyIntersections)
  720. }
  721. // ═══════════════════════════════════════════════════════════════════════
  722. // D: 交通时空图
  723. // ═══════════════════════════════════════════════════════════════════════
  724. export async function apiGetTrafficTimeSpace(opts = {}) {
  725. await delay(300)
  726. const { label, mode = 'balanced' } = opts // mode: 'forward' | 'reverse' | 'balanced'
  727. const labels = ['A', 'B', 'C', 'D']
  728. const prefix = label || '交叉口'
  729. // ==== "对称协调"完美参数 ====
  730. // 数学条件: 2 × 总路长 / 速度 ≡ k × cycle (k 为整数), 双向都能完美协调
  731. // 这里: speed=36 km/h (10 m/s), 路口间距 1000m, 单程走 100s = 1 个 cycle
  732. // 整条路 (3000m) 反向走 300s = 3 个 cycle, 所有路口 offset 都为 0 时双向都对齐绿/蓝条
  733. const cycle = 100
  734. const greenDuration = 40
  735. const bandwidth = 31.5
  736. const speedKmh = 36
  737. const speedKmhBackward = 36
  738. const dists = [1000, 1000, 1000, 0]
  739. const offsets = simulateMaxband({
  740. distances: dists,
  741. cycle,
  742. speedFwd: speedKmh,
  743. speedRev: speedKmhBackward,
  744. mode,
  745. })
  746. return ok({
  747. roadSegments: labels.map((l, i) => ({
  748. name: label ? `${prefix}-路口${l}` : `${prefix}${l}`,
  749. distanceNext: dists[i],
  750. offset: offsets[i]
  751. })),
  752. speedKmh,
  753. speedKmhBackward,
  754. cycle,
  755. greenDuration,
  756. bandwidth,
  757. scanLineStart: Math.round(Math.random() * 100) / 100,
  758. meta: {
  759. algorithm: `heuristic_v1_${mode}`,
  760. coordinationMode: mode,
  761. }
  762. })
  763. }
  764. // ═══════════════════════════════════════════════════════════════════════
  765. // 路口列表表格(分页 + 筛选 + 排序 + 动态状态)
  766. // ═══════════════════════════════════════════════════════════════════════
  767. /**
  768. * GET /api/crossings — 路口列表(718条全量,支持翻页)
  769. * @param {{ keyword, subArea, status, node, isKey, page, pageSize, sortBy, sortOrder }} params
  770. */
  771. export async function apiGetCrossingList(params = {}) {
  772. await delay(250)
  773. // 动态状态:每次请求路口状态会变化
  774. const statuses = ['在线', '在线', '在线', '在线', '离线']
  775. // 基于页码生成页级起始偏移(同一页固定,不同页不同)
  776. const page = params.page || 1
  777. const pageOffset = Math.floor(seededRand(page * 97) * 120)
  778. let list = DB.crossingList.map((r, i) => {
  779. const cycleLength = _getCycleLength(r.id)
  780. const phaseData = _makePhaseData(cycleLength, false, 'simple', r.id)
  781. return {
  782. ...r,
  783. status: _getDeviceStatus(r.id),
  784. cycle: cycleLength,
  785. phaseData,
  786. currentTime: Math.floor(seededRand(i * 31 + page * 97) * cycleLength),
  787. }
  788. })
  789. // 筛选(兼容中英文值映射)
  790. if (params.keyword || params.name) {
  791. const kw = (params.keyword || params.name).toLowerCase()
  792. list = list.filter(r => r.name.toLowerCase().includes(kw) || r.id.toLowerCase().includes(kw))
  793. }
  794. if (params.subArea) list = list.filter(r => r.subArea === params.subArea)
  795. if (params.status) {
  796. const statusMap = { 'online': '在线', 'offline': '离线', 'fault': '故障' }
  797. const mapped = statusMap[params.status] || params.status
  798. list = list.filter(r => r.status === mapped)
  799. }
  800. if (params.node) list = list.filter(r => r.node === params.node)
  801. if (params.isKey !== undefined && params.isKey !== '') {
  802. const boolVal = params.isKey === 'yes' || params.isKey === true
  803. list = list.filter(r => r.isKey === boolVal)
  804. }
  805. if (params.timeOffset) {
  806. if (params.timeOffset === 'none' || params.timeOffset === '无偏差') {
  807. list = list.filter(r => r.timeOffset === '无偏差')
  808. } else {
  809. list = list.filter(r => r.timeOffset !== '无偏差')
  810. }
  811. }
  812. // 排序
  813. if (params.sortBy) {
  814. const key = params.sortBy
  815. const dir = params.sortOrder === 'desc' ? -1 : 1
  816. list.sort((a, b) => {
  817. if (a[key] < b[key]) return -1 * dir
  818. if (a[key] > b[key]) return 1 * dir
  819. return 0
  820. })
  821. }
  822. // 分页
  823. const pageSize = params.pageSize || 10
  824. const total = list.length
  825. const totalPages = Math.ceil(total / pageSize)
  826. const start = (page - 1) * pageSize
  827. return ok({
  828. total, page, pageSize, totalPages,
  829. list: list.slice(start, start + pageSize),
  830. })
  831. }
  832. /** GET /api/dict/:type */
  833. export async function apiGetDictOptions(type) {
  834. await delay(100)
  835. if (DB.filterOptions[type]) return ok(DB.filterOptions[type])
  836. return fail('未知字典类型: ' + type, 404)
  837. }
  838. // ═══════════════════════════════════════════════════════════════════════
  839. // F: 设备操作
  840. // ═══════════════════════════════════════════════════════════════════════
  841. export async function apiRestartDevice(id) {
  842. await delay(500)
  843. return ok({ message: `设备 ${id} 重启指令已下发` })
  844. }
  845. export async function apiUpgradeDevice(id, file) {
  846. await delay(800)
  847. return ok({ message: `设备 ${id} 升级任务已创建`, version: 'V3.3.0' })
  848. }
  849. // ═══════════════════════════════════════════════════════════════════════
  850. // H: 弹窗专用(动态倒计时 + 实时状态)
  851. // ═══════════════════════════════════════════════════════════════════════
  852. /**
  853. * GET /api/special-task/:id/monitor — 特勤监控面板
  854. * 信号灯倒计时随真实时间变化
  855. */
  856. export async function apiGetSpecialTaskMonitorData(id, { fixedNsGreen } = {}) {
  857. await delay(400)
  858. // 用 id 生成稳定 seed,兼容数字/字符串/undefined
  859. let numId = typeof id === 'number' ? id : parseInt(id)
  860. if (!numId || isNaN(numId)) {
  861. // 非数字 id(如字符串),用 charCode 求和
  862. numId = id ? Array.from(String(id)).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 1
  863. }
  864. const seed = Math.abs(numId * 7) || 7
  865. // 任务信息——不同任务不同数据
  866. const taskIdx = Math.abs(numId - 1) % (DB.securityTasks.length || 1)
  867. const task = DB.securityTasks.find(t => t.id === id || t.id === numId) || DB.securityTasks[taskIdx] || {}
  868. const timeSlots = ['07:30-09:30', '09:00-11:00', '12:00-14:00', '14:00-16:00', '17:00-19:00', '19:00-21:00']
  869. const statusColorMap = {
  870. '未开始': '#ffaa00',
  871. '进行中': '#ff4d4f',
  872. '已完成': '#8dc453',
  873. }
  874. const taskStatus = task.status || '未开始'
  875. const taskInfo = {
  876. name: task.name || '特勤路线',
  877. time: timeSlots[seed % timeSlots.length],
  878. manager: task.executor || DB.securityTasks[seed % DB.securityTasks.length]?.executor || '王建国',
  879. level: task.level || (seed % 3 === 0 ? '二级' : '一级'),
  880. status: taskStatus,
  881. statusColor: statusColorMap[taskStatus] || '#ffaa00',
  882. }
  883. // 根据任务 id 选取不同的关键路口(每个任务关联不同的4个路口)
  884. const allKeyPoints = DB.points.filter(p => p.isKey)
  885. const startIdx = (seed * 3) % Math.max(allKeyPoints.length - 4, 1)
  886. const keyPoints = allKeyPoints.slice(startIdx, startIdx + 4)
  887. // 视频
  888. const videos = keyPoints.map((_, i) => ({ id: i + 1, url: pickVideo(seed + i) }))
  889. // 每个路口的控制模式和状态颜色根据路口 id 变化
  890. const modeList = ['步进', '系统', '定周期', '感应', '手动']
  891. const colorList = ['#ffaa00', '#00e5ff', '#68e75f', '#00e5ff']
  892. const nowSec = Math.floor(Date.now() / 1000)
  893. const allLanes = ['U', 'L', 'S', 'R'];
  894. const lanePresets = [
  895. { N: allLanes, S: allLanes, E: allLanes, W: allLanes },
  896. ]
  897. const intersections = keyPoints.map((jnc, i) => {
  898. const jncSeed = Array.from(jnc.id).reduce((s, c, idx) => s + c.charCodeAt(0) * (idx + 1), 0)
  899. const cycle = [100, 120, 130, 140, 150][jncSeed % 5]
  900. const elapsed = nowSec % cycle
  901. const nsGreen = fixedNsGreen !== undefined ? fixedNsGreen : false
  902. const countdown = Math.max(1, cycle - elapsed)
  903. const laneSet = lanePresets[jncSeed % lanePresets.length]
  904. // 用摄像机字段结构生成,再推导 cameraType
  905. const cameras = _makeCameras(jnc.id, jnc.name, jncSeed)
  906. const armCamTypes = _camerasToArmTypes(cameras)
  907. return {
  908. id: jnc.id, name: jnc.name,
  909. statusColor: colorList[jncSeed % colorList.length],
  910. stage: (Math.floor(elapsed / (cycle / 4)) % 4) + 1,
  911. mode: modeList[jncSeed % modeList.length],
  912. timeLeft: countdown,
  913. btnText: i === 0 ? '立即解锁' : '立即执行',
  914. btnType: i === 0 ? 'normal' : 'primary',
  915. phases: _makeCardPhases(Math.floor(elapsed / (cycle / 4)) % 4),
  916. cameras,
  917. mapData: {
  918. armsConfig: {
  919. N: { lanes: laneSet.N, cameraType: armCamTypes.N },
  920. S: { lanes: laneSet.S, cameraType: armCamTypes.S },
  921. E: { lanes: laneSet.E, cameraType: armCamTypes.E },
  922. W: { lanes: laneSet.W, cameraType: armCamTypes.W },
  923. },
  924. signals: {
  925. ns: { isGreen: nsGreen, time: countdown, phaseName: '南北直行' },
  926. ew: { isGreen: !nsGreen, time: Math.max(1, cycle - countdown), phaseName: '东西直行' },
  927. },
  928. },
  929. videoUrls: _makeCornerVideos(seed + i),
  930. }
  931. })
  932. return ok({ taskInfo, videos, intersections })
  933. }
  934. /**
  935. * GET /api/crossing/panel/:id — CrossingPanel 弹窗
  936. * 倒计时实时变化
  937. */
  938. export async function apiGetCrossingPanelData(id) {
  939. await delay(300)
  940. const point = DB.points.find(p => p.id === id)
  941. const config = DB.intersectionConfigs[id] || _makeIntersectionConfig(id, null, { fixedNsGreen: false, iconMode: 'simple' })
  942. const seed = _idSeed(id)
  943. // 确保 config 有 status
  944. if (!config.status) {
  945. config.status = _getDeviceStatus(id)
  946. }
  947. const cycleLength = _getCycleLength(id)
  948. const phaseData = _makePhaseData(cycleLength, false, 'simple', id)
  949. const currentTime = Math.floor(Date.now() / 1000) % cycleLength
  950. const _nowSec = Math.floor(Date.now() / 1000)
  951. const thisCycle = {
  952. schemeId: 'sys_a',
  953. schemeName: '默认配时方案',
  954. cycleLength,
  955. currentTime,
  956. phaseData,
  957. phaseDiff: (seed * 7) % 25,
  958. coordTime: (seed * 13) % 60,
  959. }
  960. const lastCycle = {
  961. schemeId: 'sys_a',
  962. schemeName: '默认配时方案',
  963. cycleLength,
  964. actualDuration: cycleLength + 2,
  965. endedAt: new Date((_nowSec - currentTime) * 1000).toISOString(),
  966. phaseData,
  967. }
  968. return ok({
  969. currentRoute: {
  970. id, name: point ? point.name : id,
  971. level: point?.isKey ? '一级' : '二级',
  972. mode: '快进', time: cycleLength + 's',
  973. mainVideo: pickVideo(seed), cornerVideos: _makeCornerVideos(seed),
  974. },
  975. intersectionData: config,
  976. phaseData,
  977. cycleLength, currentTime,
  978. thisCycle, lastCycle,
  979. })
  980. }
  981. /**
  982. * GET /api/crossing/detail/:id — CrossingDetailPanel 弹窗
  983. */
  984. export async function apiGetCrossingDetailData(id, { iconMode = 'default' } = {}) {
  985. await delay(350)
  986. const point = DB.points.find(p => p.id === id)
  987. const config = DB.intersectionConfigs[id] || _makeIntersectionConfig(id, null, { fixedNsGreen: false, iconMode })
  988. const seed = _idSeed(id)
  989. // 确保 config 有 status 字段(预存配置可能缺失)
  990. if (!config.status) {
  991. config.status = _getDeviceStatus(id)
  992. }
  993. // 确保 detectors 存在(mock_data.json 的预存配置没有此字段,需补齐)
  994. if (!config.detectors) {
  995. const detectors = _makeDetectors(id, point ? point.name : id, seed)
  996. const armDetectors = _detectorsToArmConfig(detectors)
  997. config.detectors = detectors
  998. config.armsConfig = config.armsConfig || {}
  999. const existingKeys = Object.keys(config.armsConfig)
  1000. if (existingKeys.length === 0) {
  1001. // armsConfig 空:按 4 路口默认填充 NESW 结构
  1002. ;['N', 'E', 'S', 'W'].forEach(d => {
  1003. config.armsConfig[d] = { detector: armDetectors[d] }
  1004. })
  1005. } else {
  1006. // 已有结构:只对已存在的 NESW key 注入 detector(4 路口兼容),
  1007. // 不再创建新的 NESW key,避免污染多路口(arm_1/2/3 等)的 armsConfig
  1008. ;['N', 'E', 'S', 'W'].forEach(d => {
  1009. if (config.armsConfig[d]) {
  1010. config.armsConfig[d].detector = armDetectors[d]
  1011. }
  1012. })
  1013. }
  1014. }
  1015. // 从真实阶段数据推导周期和相位
  1016. let cycleLength = _getCycleLength(id)
  1017. let phaseData = _makePhaseData(cycleLength, false, 'simple', id)
  1018. // 双相位图示例路口:N 阶段(≠4)走独立相位生成器,覆盖外层 phaseData/cycleLength
  1019. // JNC900008 用来验证 CrossingDetailPanel "8 槽两行刚满, 不出滚动条" 的边界情况
  1020. const _dualSamples = {
  1021. JNC900032: { stageCount: 32, cycleLength: 160, schemeName: '32阶段示范方案' },
  1022. JNC900016: { stageCount: 16, cycleLength: 160, schemeName: '16阶段示范方案' },
  1023. JNC900008: { stageCount: 8, cycleLength: 120, schemeName: '8阶段示范方案' },
  1024. }
  1025. if (_dualSamples[id]) {
  1026. const cfgSample = _dualSamples[id]
  1027. cycleLength = cfgSample.cycleLength
  1028. phaseData = _makeFlexiblePhaseData(cycleLength, cfgSample.stageCount)
  1029. }
  1030. // Icon 组合演示路口:1-3 icon 不同组合 + 边界情况(覆盖 stageList & phaseData)
  1031. // 详见 _sample_phase_icons.js 的 SAMPLE_STAGE_LIST
  1032. let _iconComboOverride = null
  1033. if (id === 'JNC900099') {
  1034. _iconComboOverride = buildIconComboDemoData()
  1035. cycleLength = _iconComboOverride.cycleLength
  1036. phaseData = _iconComboOverride.phaseData
  1037. }
  1038. // thisCycle / lastCycle:所有路口统一返回,让 CrossingDetailPanel 的双相位图布局对所有
  1039. // 路口生效(普通 4 阶段路口直接复用 _makePhaseData 的结果;示例路口已用 flexible 覆盖)
  1040. const _planSchemeName = (_dualSamples[id] && _dualSamples[id].schemeName)
  1041. || (id === 'JNC900099' && 'Icon组合演示方案')
  1042. || '默认配时方案'
  1043. const _nowSec = Math.floor(Date.now() / 1000)
  1044. const _currentTimeIn = _nowSec % cycleLength
  1045. const thisCycle = {
  1046. schemeId: 'sys_a',
  1047. schemeName: _planSchemeName,
  1048. cycleLength,
  1049. currentTime: _currentTimeIn,
  1050. phaseData,
  1051. phaseDiff: (seed * 7) % 25,
  1052. coordTime: (seed * 13) % 60,
  1053. }
  1054. // 上周期:演示用复用同一份计划相位(真实后端应给上周期实绩),actualDuration 模拟 ±2s 拉伸
  1055. const lastCycle = {
  1056. schemeId: 'sys_a',
  1057. schemeName: _planSchemeName,
  1058. cycleLength,
  1059. actualDuration: cycleLength + 2,
  1060. endedAt: new Date((_nowSec - _currentTimeIn) * 1000).toISOString(),
  1061. phaseData,
  1062. }
  1063. // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0)
  1064. // 不再硬截 4 个 —— CrossingDetailPanel 现已支持 2 行 8 槽 + 滚动, 配合
  1065. // _dualSamples (JNC900008/016/032) 可看到 8/16/32 阶段的完整渲染
  1066. const hasTrack1 = phaseData.some(p => p[0] === 1)
  1067. const greenPhases = phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[5] === 'green')
  1068. // 每个阶段的锁定时间选项(不同阶段时长不同,锁定选项也不同)
  1069. const stageLockOptions = [
  1070. [{ label: '20', value: 20 }, { label: '30', value: 30 }, { label: '45', value: 45 }, { label: '60', value: 60 }],
  1071. [{ label: '15', value: 15 }, { label: '25', value: 25 }, { label: '35', value: 35 }, { label: '50', value: 50 }],
  1072. [{ label: '20', value: 20 }, { label: '30', value: 30 }, { label: '45', value: 45 }, { label: '60', value: 60 }],
  1073. [{ label: '10', value: 10 }, { label: '20', value: 20 }, { label: '30', value: 30 }, { label: '40', value: 40 }],
  1074. ]
  1075. const stageList = _iconComboOverride
  1076. ? _iconComboOverride.stageList
  1077. : greenPhases.map((p, i) => ({
  1078. value: String(i + 1),
  1079. time: p[8],
  1080. phaseName: p[3],
  1081. direction: p[6],
  1082. img: ARROWS[i],
  1083. locktimeOptions: stageLockOptions[i] || stageLockOptions[0],
  1084. }))
  1085. // 控制方式选项 + 根据路口选择不同的当前控制方式
  1086. const allMethods = [
  1087. { label: '关灯', value: 'lights_off' },
  1088. { label: '黄闪', value: 'yellow_flash' },
  1089. { label: '全红', value: 'all_red' },
  1090. { label: '定周期', value: 'fixed' },
  1091. { label: '步进', value: 'step' },
  1092. { label: '中心计划', value: 'system' },
  1093. { label: '感应控制', value: 'sensor' },
  1094. { label: '临时方案', value: 'temp' },
  1095. ]
  1096. const methodValues = ['fixed', 'step', 'system', 'sensor', 'temp']
  1097. // 示范多阶段路口固定走非-step 方法, 否则 step 模式会隐藏 button-group (取消/确认),
  1098. // 用户点"手动控制"看不到确认按钮, 误以为坏掉
  1099. const _isDualSampleId = id && /^JNC9000(?:08|16|32)$/.test(id)
  1100. const currentMethod = _isDualSampleId ? 'fixed' : methodValues[seed % methodValues.length]
  1101. // 控制模式(显示用)
  1102. const controlModes = ['定周期控制', '感应控制', '干线协调', '自适应控制']
  1103. const currentMode = controlModes[seed % controlModes.length]
  1104. // 控制方案:根据当前控制方式给出不同方案选项
  1105. const schemeMap = {
  1106. 'fixed': [
  1107. { label: '早高峰', value: 'early_peak' },
  1108. { label: '晚高峰', value: 'evening_peak' },
  1109. { label: '平峰', value: 'normal' },
  1110. { label: '夜间', value: 'night' },
  1111. { label: '周末', value: 'weekend' },
  1112. ],
  1113. 'sensor': [
  1114. { label: '全感应', value: 'full_actuated' },
  1115. { label: '半感应', value: 'semi_actuated' },
  1116. ],
  1117. 'system': [
  1118. { label: '系统优化方案A', value: 'sys_a' },
  1119. { label: '系统优化方案B', value: 'sys_b' },
  1120. { label: '系统优化方案C', value: 'sys_c' },
  1121. ],
  1122. 'step': [
  1123. { label: '步进方案1', value: 'step_1' },
  1124. { label: '步进方案2', value: 'step_2' },
  1125. ],
  1126. 'temp': [
  1127. { label: '临时方案A', value: 'temp_a' },
  1128. { label: '临时方案B', value: 'temp_b' },
  1129. { label: '临时方案C', value: 'temp_c' },
  1130. ],
  1131. 'yellow_flash': [{ label: '黄闪默认', value: 'yf_default' }],
  1132. 'lights_off': [{ label: '关灯默认', value: 'lo_default' }],
  1133. }
  1134. const schemeOptions = schemeMap[currentMethod] || schemeMap['fixed']
  1135. // 相位差和协调时间基于 seed 稳定变化
  1136. const phaseDiff = (seed * 7) % 25
  1137. const coordTime = (seed * 13) % 60
  1138. // 多路口(armsConfig key 不是 NESW)按 arm 顺序循环分配 4 个 mp4,让每条 arm 的视频独立对应
  1139. const armKeys = (config.armsConfig && Object.keys(config.armsConfig)) || []
  1140. const isMultiWay = armKeys.length > 0 && !(armKeys.length === 4 && ['N','E','S','W'].every(d => armKeys.includes(d)))
  1141. let armVideos = null
  1142. if (isMultiWay) {
  1143. armVideos = {}
  1144. armKeys.forEach((k, i) => { armVideos[k] = VIDEOS[i % VIDEOS.length] })
  1145. }
  1146. return ok({
  1147. currentRoute: {
  1148. id, name: point ? point.name : id,
  1149. level: point?.isKey ? '一级' : '二级',
  1150. mode: currentMode,
  1151. time: cycleLength + 's',
  1152. mainVideo: pickVideo(seed),
  1153. cornerVideos: _makeCornerVideos(seed),
  1154. armVideos,
  1155. },
  1156. intersectionData: config,
  1157. phaseData,
  1158. cycleLength,
  1159. currentTime: Math.floor(Date.now() / 1000) % cycleLength,
  1160. phaseDiff,
  1161. coordTime,
  1162. thisCycle,
  1163. lastCycle,
  1164. stageList,
  1165. schemeOptions,
  1166. currentScheme: schemeOptions[0].value,
  1167. controlMode: currentMode,
  1168. controlMethodOptions: allMethods,
  1169. currentMethod,
  1170. locktimeOptions: [
  1171. { label: '30', value: 30 },
  1172. { label: '50', value: 50 },
  1173. { label: '100', value: 100 },
  1174. { label: '300', value: 300 },
  1175. ],
  1176. // 临时方案时间栏数据
  1177. startDate: '2026-04-14',
  1178. startTime: '08:00:00',
  1179. endDate: '2026-04-14',
  1180. endTime: '10:00:00',
  1181. duration: 120,
  1182. period: 1,
  1183. })
  1184. }
  1185. /**
  1186. * PUT /api/crossing/temp-scheme/:id — 修改临时配时方案
  1187. * 入参:{ stages, timeRange, isFixedCycle }
  1188. * 当前 mock 仅做参数校验和成功返回;真实后端要把 timeRange 转成排程任务、stages 落库
  1189. */
  1190. export async function apiSaveCrossingTempScheme(id, payload = {}) {
  1191. await delay(200)
  1192. if (!id) return fail('缺少路口 ID')
  1193. const stages = Array.isArray(payload.stages) ? payload.stages : []
  1194. if (stages.length === 0) return fail('阶段列表不能为空')
  1195. const total = stages.reduce((a, b) => a + (Number(b.time) || 0), 0)
  1196. if (total <= 0) return fail('阶段总时长必须 > 0')
  1197. return ok({
  1198. id,
  1199. method: 'temp',
  1200. cycleTotal: total,
  1201. stages: stages.map((s, i) => ({ ...s, idx: i + 1 })),
  1202. timeRange: payload.timeRange || null,
  1203. isFixedCycle: !!payload.isFixedCycle,
  1204. appliedAt: new Date().toISOString(),
  1205. })
  1206. }
  1207. /**
  1208. * PUT /api/crossing/scheme/:id — 修改配时方案(永久)
  1209. * 入参:{ stages, isFixedCycle };schemeId 由前端 currentScheme 决定,约定走 query 或 body 字段
  1210. */
  1211. export async function apiSaveCrossingScheme(id, payload = {}) {
  1212. await delay(200)
  1213. if (!id) return fail('缺少路口 ID')
  1214. const stages = Array.isArray(payload.stages) ? payload.stages : []
  1215. if (stages.length === 0) return fail('阶段列表不能为空')
  1216. const total = stages.reduce((a, b) => a + (Number(b.time) || 0), 0)
  1217. if (total <= 0) return fail('阶段总时长必须 > 0')
  1218. return ok({
  1219. id,
  1220. method: 'scheme',
  1221. cycleTotal: total,
  1222. stages: stages.map((s, i) => ({ ...s, idx: i + 1 })),
  1223. isFixedCycle: !!payload.isFixedCycle,
  1224. appliedAt: new Date().toISOString(),
  1225. })
  1226. }
  1227. /** 多路口(非 NESW key)按实际 armsConfig 生成 per-arm 检测器数据 */
  1228. function _detectorPerArmSnapshot(id, armsConfig, bucketIdx) {
  1229. const seed = _idSeed(id || '')
  1230. const armsDetector = {}
  1231. const tableData = []
  1232. let badgeNum = 1
  1233. Object.keys(armsConfig).forEach((dir, dirIdx) => {
  1234. const lanes = ((armsConfig[dir] && armsConfig[dir].lanes) || []).filter(l => l)
  1235. if (lanes.length === 0) return
  1236. const i = dirIdx + 1
  1237. const baseFlow = 60 + ((seed * (i * 7 + 13)) % 160)
  1238. const baseOcc = 15 + ((seed * (i * 11 + 17)) % 50)
  1239. const flowNoise = (((seed ^ (i * 257) ^ (bucketIdx * 9176)) >>> 0) % 10000) / 10000
  1240. const occNoise = (((seed ^ (i * 521) ^ (bucketIdx * 4093)) >>> 0) % 10000) / 10000
  1241. const armFlow = Math.max(0, Math.round(baseFlow * (1 + (flowNoise - 0.5) * 0.3)))
  1242. const armOcc = Math.max(0, Math.min(100, Math.round(baseOcc + (occNoise - 0.5) * 10)))
  1243. armsDetector[dir] = { index: i, flow: armFlow, occupancy: armOcc }
  1244. lanes.forEach((laneType, laneIdx) => {
  1245. const j = badgeNum
  1246. const lFlow = Math.max(0, Math.round((60 + ((seed * (j * 7 + 13)) % 160)) * (1 + ((((seed ^ (j * 257) ^ (bucketIdx * 9176)) >>> 0) % 10000) / 10000 - 0.5) * 0.3)))
  1247. const lOcc = Math.max(0, Math.min(100, Math.round((15 + ((seed * (j * 11 + 17)) % 50)) + ((((seed ^ (j * 521) ^ (bucketIdx * 4093)) >>> 0) % 10000) / 10000 - 0.5) * 10)))
  1248. tableData.push({
  1249. id: badgeNum,
  1250. name: `${dir} 第${laneIdx + 1}车道`,
  1251. flow: lFlow,
  1252. occupancy: `${lOcc}%`,
  1253. })
  1254. badgeNum++
  1255. })
  1256. })
  1257. return { timeTicks: [180, 150, 120, 90, 60, 30], tableData, armsDetector }
  1258. }
  1259. /**
  1260. * GET /api/detector/monitor/:id — 检测器运行数据监视
  1261. * 返回两套视图:
  1262. * - armsDetector: { N/E/S/W or arm_X → {index, flow, occupancy} }
  1263. * - tableData: 按车道展开(4 路口 16 条;多路口按实际 arm × lane 数生成)
  1264. * 同 5s 桶内重复调用返回相同值(确定性噪声),保证画布与弹窗轮询同窗口取数一致。
  1265. */
  1266. export async function apiGetDetectorMonitorData(id) {
  1267. await delay(150)
  1268. const bucketIdx = Math.floor(Date.now() / 5000)
  1269. // 多路口路径:按实际 armsConfig 生成 per-arm 数据
  1270. const config = DB.intersectionConfigs[id]
  1271. const armsConfig = config && config.armsConfig
  1272. if (armsConfig) {
  1273. const armKeys = Object.keys(armsConfig)
  1274. const isMultiWay = !(armKeys.length === 4 && ['N','E','S','W'].every(d => armKeys.includes(d)))
  1275. if (isMultiWay) {
  1276. return ok(_detectorPerArmSnapshot(id, armsConfig, bucketIdx))
  1277. }
  1278. }
  1279. // 4 路口默认路径(原逻辑)
  1280. const dirSnap = _detectorBucketSnapshot(id, bucketIdx)
  1281. const armsDetector = _bucketArmsDetector(dirSnap)
  1282. const laneSnap = _detectorLaneBucketSnapshot(id, bucketIdx)
  1283. return ok({
  1284. timeTicks: [180, 150, 120, 90, 60, 30],
  1285. tableData: laneSnap.map(l => ({
  1286. id: l.index,
  1287. name: l.name,
  1288. flow: l.flow,
  1289. occupancy: `${l.occupancy}%`,
  1290. })),
  1291. armsDetector,
  1292. })
  1293. }
  1294. /**
  1295. * GET /api/crossing/top-charts — 路口Tab顶部圆环图(动态波动)
  1296. */
  1297. export async function apiGetCrossingTopCharts() {
  1298. await delay(150)
  1299. const sm = DB.deviceStatus.signalMachine
  1300. const baseOnline = sm.chartData[0].value
  1301. const total = baseOnline + sm.chartData[1].value
  1302. const online = _fluctuate(baseOnline, Math.ceil(total * 0.02))
  1303. const onlineChart = {
  1304. chartData: [
  1305. { name: '在线', value: online, color: '#4DF5F8' },
  1306. { name: '离线', value: total - online, color: '#FFD369' },
  1307. ],
  1308. centerTitle: Math.round(online / total * 100) + '%',
  1309. centerSubTitle: `${online}/${total}`,
  1310. }
  1311. const faultTotal = total - online
  1312. const comm = Math.floor(faultTotal * 0.26)
  1313. const det = Math.floor(faultTotal * 0.21)
  1314. const lamp = Math.floor(faultTotal * 0.38)
  1315. const conflict = faultTotal - comm - det - lamp
  1316. const faultChart = {
  1317. chartData: [
  1318. { name: '通信', value: comm, color: '#4DF5F8' },
  1319. { name: '检测器', value: det, color: '#FFA033' },
  1320. { name: '灯控', value: lamp, color: '#FFF587' },
  1321. { name: '冲突', value: conflict, color: '#FF4D4F' },
  1322. ],
  1323. centerTitle: Math.round(faultTotal / total * 100) + '%',
  1324. centerSubTitle: `${faultTotal}/${total}`,
  1325. }
  1326. return ok({ onlineChart, faultChart })
  1327. }
  1328. /** GET /api/overview/top-charts — 总览Tab顶部图表(动态) */
  1329. export async function apiGetOverviewTopCharts() {
  1330. await delay(150)
  1331. const res = await apiGetDeviceStatus()
  1332. return ok({ onlineStatus: res.data, deviceStatus: res.data })
  1333. }
  1334. /**
  1335. * GET /api/devices/fault-status
  1336. * 设备故障统计(匹配 DeviceStatusTabs 组件的 statusData 格式)
  1337. * keys: signalMachineStatus, detectorStatus, trafficLightStatus
  1338. */
  1339. export async function apiGetDeviceFaultStatus() {
  1340. await delay(200)
  1341. const dt = DB.deviceStatus.detector
  1342. const cam = DB.deviceStatus.camera
  1343. // ── 信号机故障:从共享缓存获取,确保与在线状态的离线数一致 ──
  1344. // 颜色由 applyDeviceFaultColors 按 name 注入 (红色 4 档亮度梯度 RED_GRADIENT)
  1345. const snap = _getSmFaultSnapshot()
  1346. const smTotal = snap.total
  1347. const smFaultTotal = snap.faultTotal
  1348. const smFaultList = applyDeviceFaultColors([
  1349. { name: '正常', value: Math.max(0, smTotal - smFaultTotal) },
  1350. { name: '控制板报警', value: snap.ctrlBoard },
  1351. { name: '相位板报警', value: snap.phaseBoard },
  1352. { name: '检测板报警', value: snap.detBoard },
  1353. { name: '黄闪报警', value: snap.yellowFlash },
  1354. ])
  1355. // ── 检测器故障 ──
  1356. const dtTotal = dt.chartData[0].value + dt.chartData[1].value
  1357. const dtFault = Math.max(0, _fluctuate(dt.chartData[1].value, 5))
  1358. const dtCommFault = Math.max(0, Math.floor(dtFault * 0.6))
  1359. // ── 红绿灯故障 ──
  1360. const camTotal = cam.chartData[0].value + cam.chartData[1].value
  1361. const camFault = Math.max(0, _fluctuate(cam.chartData[1].value, 2))
  1362. const camConflict = Math.max(0, Math.floor(camFault * 0.5))
  1363. return ok({
  1364. signalMachineStatus: {
  1365. centerTitle: smFaultTotal + '',
  1366. centerSubTitle: `${smFaultTotal}/${smTotal}`,
  1367. chartData: smFaultList,
  1368. },
  1369. detectorStatus: {
  1370. centerTitle: dtFault + '',
  1371. centerSubTitle: `${dtFault}/${dtTotal}`,
  1372. chartData: applyDeviceFaultColors([
  1373. { name: '正常', value: Math.max(0, dtTotal - dtFault) },
  1374. { name: '通信故障', value: dtCommFault },
  1375. { name: '数据异常', value: Math.max(0, dtFault - dtCommFault) },
  1376. ])
  1377. },
  1378. trafficLightStatus: {
  1379. centerTitle: camFault + '',
  1380. centerSubTitle: `${camFault}/${camTotal}`,
  1381. chartData: applyDeviceFaultColors([
  1382. { name: '正常', value: Math.max(0, camTotal - camFault) },
  1383. { name: '红绿冲突', value: camConflict },
  1384. { name: '红灯故障', value: Math.max(0, camFault - camConflict) },
  1385. ])
  1386. },
  1387. })
  1388. }