api.js 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007
  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. // ── 静态资源(模拟 CDN / 后端返回的资源 URL)─────────────────────
  16. import video1 from '@/assets/videos/video1.mp4'
  17. import video2 from '@/assets/videos/video2.mp4'
  18. import arrow1 from '@/assets/images/arrow_1.png'
  19. import arrow2 from '@/assets/images/arrow_2.png'
  20. import arrow3 from '@/assets/images/arrow_3.png'
  21. import arrow4 from '@/assets/images/arrow_4.png'
  22. const VIDEOS = [video1, video2]
  23. const ARROWS = [arrow1, arrow2, arrow3, arrow4]
  24. function pickVideo(i) { return VIDEOS[i % VIDEOS.length] }
  25. // ── 工具 ─────────────────────────────────────────────────────────────
  26. function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
  27. function delay(base = 200) { return sleep(base + Math.floor(Math.random() * 200)) }
  28. function ok(data) { return { code: 200, message: 'success', data } }
  29. function fail(msg, code = 400) { return { code, message: msg, data: null } }
  30. /** 基于当前秒数产生稳定随机(同一秒内多次调用返回相同值) */
  31. function seededRand(seed) {
  32. const x = Math.sin(seed) * 10000
  33. return x - Math.floor(x)
  34. }
  35. /** 当前时间 HH:MM:SS */
  36. function nowTime() { return new Date().toLocaleTimeString() }
  37. function nowDate() { return new Date().toLocaleDateString() }
  38. // ── 数据缓存(首次 import 时加载) ──────────────────────────────────
  39. const DB = {
  40. points: mockData.points,
  41. menuTree: mockData.menuTree,
  42. tongzhouMenuTree: mockData.tongzhouMenuTree,
  43. homeData: mockData.homeData,
  44. deviceStatus: mockData.deviceStatus,
  45. timeSpaceData: mockData.timeSpaceData,
  46. crossingList: mockData.crossingList,
  47. securityRoutes: mockData.securityRoutes,
  48. securityTasks: mockData.securityTasks,
  49. filterOptions: mockData.filterOptions,
  50. signalTimings: mockData.sampleSignalTimings,
  51. intersectionConfigs: mockData.sampleIntersectionConfigs,
  52. }
  53. // ── 内部生成器 ───────────────────────────────────────────────────────
  54. /**
  55. * 生成摄像机模拟数据(基于 XLS 摄像机 Sheet 字段结构)
  56. * 字段:路口名、路口编号、摄像机编号、登录名称、摄像头密码、摄像头类型、端口号、IP地址、是否启用、位置
  57. */
  58. function _makeCameras(id, name, seed) {
  59. const positions = ['北进口', '南进口', '东进口', '西进口']
  60. const types = ['枪机', '球机']
  61. const numCams = 2 + (seed % 3) // 2~4 个
  62. return Array.from({ length: numCams }, (_, i) => ({
  63. intersection: name || id,
  64. intersectionId: id,
  65. cameraId: `CAM${(id || '000').slice(-6)}_${String(i + 1).padStart(2, '0')}`,
  66. loginName: `admin_${String(seed + i).slice(-4)}`,
  67. password: '******',
  68. cameraType: types[(seed + i) % types.length],
  69. port: 554 + i,
  70. ip: `192.168.${10 + (seed % 200)}.${100 + i}`,
  71. enabled: (seed + i) % 20 !== 0, // ~5% 禁用
  72. position: positions[i % positions.length],
  73. }))
  74. }
  75. /** 从摄像机列表推导各方向摄像头类型 (1枪机 2球机 0无) */
  76. function _camerasToArmTypes(cameras) {
  77. const posMap = { '北进口': 'N', '南进口': 'S', '东进口': 'E', '西进口': 'W' }
  78. const typeMap = { '枪机': 1, '球机': 2 }
  79. const result = { N: 0, S: 0, E: 0, W: 0 }
  80. cameras.forEach(c => {
  81. if (c.enabled) {
  82. const dir = posMap[c.position]
  83. if (dir) result[dir] = typeMap[c.cameraType] || 0
  84. }
  85. })
  86. return result
  87. }
  88. function _makeIntersectionConfig(id, name) {
  89. const phases = ['南北直行', '东西直行', '北单放', '东单放']
  90. const nsGreen = Math.random() > 0.5
  91. const countdown = 10 + Math.floor(Math.random() * 50)
  92. const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
  93. const cameras = _makeCameras(id, name, seed)
  94. const armCamTypes = _camerasToArmTypes(cameras)
  95. const lanePresets = [
  96. ['U', 'L', 'S', 'R'], [null, 'L', 'S', 'R'],
  97. [null, 'L', 'S', null], ['U', 'L', 'S', null],
  98. ].sort(() => seededRand(seed) - 0.5)
  99. return {
  100. signals: {
  101. ns: { phaseName: phases[0], time: countdown, isGreen: nsGreen },
  102. ew: { phaseName: phases[1], time: countdown, isGreen: !nsGreen },
  103. },
  104. armsConfig: {
  105. N: { cameraType: armCamTypes.N, lanes: lanePresets[0] },
  106. S: { cameraType: armCamTypes.S, lanes: lanePresets[1] },
  107. E: { cameraType: armCamTypes.E, lanes: lanePresets[2] },
  108. W: { cameraType: armCamTypes.W, lanes: lanePresets[3] },
  109. },
  110. cameras,
  111. }
  112. }
  113. /**
  114. * 动态生成路口相位配时数据
  115. * @param {number} cycleLength 周期总时长
  116. * @param {boolean} isTwoRows 是否生成上下双排 8 相位 (默认 true)
  117. */
  118. function _makePhaseData(cycleLength = 140, isTwoRows = true) {
  119. const n = 4; // 4个阶段 (S1-S4)
  120. const stageTime = Math.floor(cycleLength / n);
  121. const pd = [];
  122. // 严格匹配 SignalTimingChart.vue 中的 IMAGE_MAP 键名
  123. const iconsUD = [
  124. 'STRAIGHT_UP', 'STRAIGHT_DOWN',
  125. 'TURN_UP_LEFT', 'TURN_DOWN_LEFT',
  126. 'TURN_UP_LEFT_UTURN', 'TURN_DOWN_LEFT_UTURN'
  127. ];
  128. const iconsLR = [
  129. 'STRAIGHT_LEFT', 'STRAIGHT_RIGHT',
  130. 'TURN_LEFT_DOWN', 'TURN_RIGHT_UP', // 修正了原代码中不存在的 TURN_LEFT_UP
  131. 'TURN_LEFT_DOWN_UTURN', 'TURN_RIGHT_UP_UTURN'
  132. ];
  133. const getRandomIcon = (pool) => pool[Math.floor(Math.random() * pool.length)];
  134. let t = 0;
  135. for (let i = 0; i < n; i++) {
  136. const stageStart = t;
  137. const stageEnd = stageStart + stageTime;
  138. const currentIconPool = (i < 2) ? iconsUD : iconsLR;
  139. // 辅助函数:生成单条轨道的一个阶段
  140. const pushTrackData = (trackIdx, phaseNamePrefix) => {
  141. const icon = getRandomIcon(currentIconPool);
  142. const phaseName = `${phaseNamePrefix}${i + 1}`;
  143. const g = Math.floor(Math.random() * 11) + 20; // 绿灯 20-30s
  144. const s = 3; // 闪烁/条纹 3s
  145. const y = 2; // 黄灯 2s
  146. let curT = stageStart;
  147. // 1. 绿灯
  148. pd.push([trackIdx, curT, curT + g, phaseName, g, 'green', icon]);
  149. curT += g;
  150. // 2. 绿闪/条纹
  151. pd.push([trackIdx, curT, curT + s, '', s, 'stripe', null]);
  152. curT += s;
  153. // 3. 黄灯
  154. pd.push([trackIdx, curT, curT + y, '', y, 'yellow', null]);
  155. curT += y;
  156. // 4. 红灯补齐 (确保阶段对齐)
  157. let remainRed = stageEnd - curT;
  158. if (remainRed > 0) {
  159. pd.push([trackIdx, curT, stageEnd, '', remainRed, 'red', null]);
  160. }
  161. };
  162. pushTrackData(0, 'P'); // 生成第一排 (P1-P4)
  163. if (isTwoRows) {
  164. pushTrackData(1, 'P'); // 生成第二排 (P5-P8,由于逻辑相同,名称可根据需要改为 i+5)
  165. }
  166. t = stageEnd;
  167. }
  168. return pd;
  169. }
  170. function _makeCornerVideos(seed = 0) {
  171. return { nw: pickVideo(seed), ne: pickVideo(seed + 1), sw: pickVideo(seed + 1), se: pickVideo(seed) }
  172. }
  173. function _makeStageList() {
  174. return [1, 2, 3, 4].map((_, i) => ({
  175. value: String(i + 1), time: [30, 30, 50, 30][i], img: ARROWS[i],
  176. }))
  177. }
  178. function _makeCardPhases(activeIndex = 0) {
  179. return ARROWS.map((img, i) => ({
  180. id: i + 1, icon: ['↑', '↰', '↑', '↰'][i], img, active: i === activeIndex,
  181. }))
  182. }
  183. /** 动态波动整数值(基于基准值上下浮动) */
  184. function _fluctuate(base, range) {
  185. return base + Math.floor(Math.random() * range * 2) - range
  186. }
  187. /** 动态更新路口状态(每次调用随机波动少量路口) */
  188. function _dynamicPoints() {
  189. const now = Math.floor(Date.now() / 5000) // 每5秒切换一批
  190. return DB.points.map((p, i) => {
  191. const r = seededRand(now + i)
  192. const status = r < 0.78 ? 'normal' : r < 0.92 ? 'busy' : 'alarm'
  193. return { ...p, status, updatedAt: Date.now() - Math.floor(r * 120000) }
  194. })
  195. }
  196. // ═══════════════════════════════════════════════════════════════════════
  197. // E: 认证
  198. // ═══════════════════════════════════════════════════════════════════════
  199. /** 验证码状态(模拟服务端 session 存储) */
  200. let _captchaStore = { code: '', expireAt: 0 }
  201. /**
  202. * GET /api/auth/captcha — 获取验证码
  203. * 返回 4 位随机字符 + base64 图片(Canvas 绘制)
  204. * 真实后端应返回图片流,这里模拟返回验证码文本 + 配置,由前端 Canvas 绘制
  205. */
  206. export async function apiGetCaptcha() {
  207. await delay(100)
  208. const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
  209. const code = Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
  210. // 存储验证码,有效期 60 秒
  211. _captchaStore = { code: code.toUpperCase(), expireAt: Date.now() + 60000 }
  212. return ok({
  213. captchaId: 'cap_' + Date.now(),
  214. code, // 模拟环境直接返回明文,真实环境不应返回
  215. length: 4,
  216. expireIn: 60, // 秒
  217. })
  218. }
  219. /**
  220. * POST /api/auth/login — 登录(含验证码校验)
  221. */
  222. export async function apiLogin({ username, password, captcha }) {
  223. await delay(300)
  224. // 验证码校验
  225. if (_captchaStore.code && captcha) {
  226. if (Date.now() > _captchaStore.expireAt) {
  227. return fail('验证码已过期,请刷新', 401)
  228. }
  229. if (captcha.toUpperCase() !== _captchaStore.code) {
  230. return fail('验证码错误', 401)
  231. }
  232. }
  233. // 账号密码校验
  234. if (username === 'admin' && password === '123456') {
  235. _captchaStore = { code: '', expireAt: 0 } // 登录成功后清除验证码
  236. return ok({ token: 'demo_token_' + Date.now(), user: { name: 'admin', role: '管理员' } })
  237. }
  238. return fail('账号或密码错误(演示账号:admin / 123456)', 401)
  239. }
  240. export async function apiChangePassword({ oldPassword, newPassword }) {
  241. await delay(300)
  242. if (oldPassword === '123456') return ok({ message: '密码修改成功' })
  243. return fail('原密码错误')
  244. }
  245. // ═══════════════════════════════════════════════════════════════════════
  246. // A: 路口基础数据
  247. // ═══════════════════════════════════════════════════════════════════════
  248. /**
  249. * GET /api/intersections — 路口点位列表
  250. * 每次调用路口状态会动态波动
  251. */
  252. export async function apiGetPoints(filters = {}) {
  253. await delay(200)
  254. let list = _dynamicPoints()
  255. if (filters.node) list = list.filter(p => p.node === filters.node)
  256. if (filters.status) list = list.filter(p => p.status === filters.status)
  257. if (filters.keyword) {
  258. const kw = filters.keyword.toLowerCase()
  259. list = list.filter(p => p.name.toLowerCase().includes(kw) || p.id.toLowerCase().includes(kw))
  260. }
  261. return ok(list)
  262. }
  263. /**
  264. * GET /api/intersections/:id — 路口详情
  265. * 信号倒计时每次请求动态变化
  266. */
  267. export async function apiGetIntersectionData(id) {
  268. await delay(250)
  269. const base = DB.intersectionConfigs[id]
  270. const point = DB.points.find(p => p.id === id)
  271. const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
  272. // 动态倒计时:基于当前秒数计算
  273. const nowSec = Math.floor(Date.now() / 1000)
  274. const cycle = 140
  275. const elapsed = nowSec % cycle
  276. const nsGreen = elapsed < cycle / 2
  277. const config = base ? {
  278. signals: {
  279. ns: { ...base.signals.ns, time: nsGreen ? (cycle / 2 - elapsed) : elapsed - cycle / 2, isGreen: nsGreen },
  280. ew: { ...base.signals.ew, time: nsGreen ? elapsed : (cycle - elapsed), isGreen: !nsGreen },
  281. },
  282. armsConfig: base.armsConfig,
  283. cameras: base.cameras,
  284. } : _makeIntersectionConfig(id, point ? point.name : id)
  285. return ok({
  286. ...config,
  287. id, name: point ? point.name : id,
  288. mainVideo: pickVideo(seed),
  289. cornerVideos: _makeCornerVideos(seed),
  290. })
  291. }
  292. /**
  293. * GET /api/intersections/:id/signal-timing — 信号配时
  294. * currentTime 随真实时间走动
  295. */
  296. export async function apiGetSignalTiming(id) {
  297. await delay(300)
  298. const preset = DB.signalTimings[id]
  299. if (preset) {
  300. const cycleLength = preset.data.cycleLength
  301. return {
  302. code: 200, message: 'success',
  303. data: { ...preset.data, currentTime: Math.floor(Date.now() / 1000) % cycleLength }
  304. }
  305. }
  306. const cycleLength = [100, 120, 130, 140, 150, 160][Math.floor(Math.random() * 6)]
  307. return ok({
  308. cycleLength,
  309. currentTime: Math.floor(Date.now() / 1000) % cycleLength,
  310. phaseData: _makePhaseData(cycleLength, false),
  311. })
  312. }
  313. /** GET /api/intersections/:id/stages */
  314. export async function apiGetIntersectionStages(id) {
  315. await delay(200)
  316. const timing = DB.signalTimings[id]
  317. if (timing) {
  318. const hasTrack1 = timing.data.phaseData.some(p => p[0] === 1)
  319. const phases = timing.data.phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[4] !== null)
  320. return ok(phases.map((p, i) => ({
  321. value: String(i + 1), time: p[4], phaseName: p[3], direction: p[6], img: ARROWS[i % ARROWS.length],
  322. })))
  323. }
  324. return ok(_makeStageList())
  325. }
  326. /** GET /api/intersections/:id/schemes */
  327. export async function apiGetSchemes(id) {
  328. await delay(150)
  329. return ok(DB.filterOptions.schemeOptions)
  330. }
  331. // ═══════════════════════════════════════════════════════════════════════
  332. // A4: 区域菜单树
  333. // ═══════════════════════════════════════════════════════════════════════
  334. export async function apiGetMenuTree(tabId = 'arterial') {
  335. await delay(250)
  336. return ok(tabId === 'arterial' ? DB.menuTree : DB.tongzhouMenuTree)
  337. }
  338. export async function apiGetTongzhouMenuTree() {
  339. await delay(250)
  340. return ok(DB.tongzhouMenuTree)
  341. }
  342. // ═══════════════════════════════════════════════════════════════════════
  343. // B: 设备状态 & 首页(动态波动)
  344. // ═══════════════════════════════════════════════════════════════════════
  345. /**
  346. * GET /api/devices/status/summary
  347. * 在线数每次请求轻微波动
  348. */
  349. export async function apiGetDeviceStatus(type) {
  350. await delay(200)
  351. function fluctuateStats(base) {
  352. const online = base.chartData[0].value
  353. const total = online + base.chartData[1].value
  354. const newOnline = _fluctuate(online, Math.ceil(total * 0.02))
  355. const clamped = Math.max(0, Math.min(total, newOnline))
  356. const rate = Math.round(clamped / total * 100)
  357. return {
  358. centerTitle: rate + '%',
  359. centerSubTitle: `${clamped}/${total}`,
  360. chartData: [
  361. { ...base.chartData[0], value: clamped },
  362. { ...base.chartData[1], value: total - clamped },
  363. ]
  364. }
  365. }
  366. if (type && DB.deviceStatus[type]) return ok(fluctuateStats(DB.deviceStatus[type]))
  367. return ok({
  368. signalMachine: fluctuateStats(DB.deviceStatus.signalMachine),
  369. detector: fluctuateStats(DB.deviceStatus.detector),
  370. camera: fluctuateStats(DB.deviceStatus.camera),
  371. })
  372. }
  373. /**
  374. * GET /api/home/snapshot — 首页快照
  375. * 在线数波动 + 时间实时更新 + 告警时间刷新
  376. */
  377. export async function apiGetHomeSnapshot() {
  378. await delay(200)
  379. const total = DB.homeData.online.total
  380. const online = _fluctuate(Math.round(total * 0.93), 30)
  381. const clamped = Math.max(0, Math.min(total, online))
  382. const fault = Math.floor(Math.random() * 5)
  383. return ok({
  384. header: { ...DB.homeData.header, timeText: nowTime(), dateText: nowDate() },
  385. online: { online: clamped, offline: total - clamped, total, rate: Math.round(clamped / total * 100) },
  386. alarms: DB.homeData.alarms.map(a => ({
  387. ...a,
  388. time: new Date(Date.now() - Math.floor(Math.random() * 3600000)).toLocaleTimeString(),
  389. })),
  390. duty: DB.homeData.duty,
  391. device: { normal: total - fault, fault },
  392. controlModes: DB.homeData.controlModes,
  393. keyIntersections: DB.homeData.keyIntersections,
  394. })
  395. }
  396. /** GET /api/home/control-mode-stats — 控制模式分布(轻微波动) */
  397. export async function apiGetControlModeStats() {
  398. await delay(150)
  399. return ok(DB.homeData.controlModes.map(m => ({
  400. ...m, value: _fluctuate(m.value, Math.ceil(m.value * 0.05)),
  401. })))
  402. }
  403. /**
  404. * GET /api/alarms/latest — 告警列表(分页 + 动态时间)
  405. * @param {{ page?: number, pageSize?: number, level?: string }} params
  406. */
  407. export async function apiGetLatestAlarms(params = {}) {
  408. await delay(200)
  409. const typeMap = { high: 'error', mid: 'warning', low: 'warning' }
  410. let alarms = DB.homeData.alarms.map((a, i) => ({
  411. id: a.id, title: a.title, type: typeMap[a.level] || 'warning',
  412. time: new Date(Date.now() - i * 180000 - Math.floor(Math.random() * 60000)).toLocaleTimeString(),
  413. description: `${a.loc}-${a.title}`,
  414. position: a.position, level: a.level, loc: a.loc,
  415. }))
  416. if (params.level) alarms = alarms.filter(a => a.level === params.level)
  417. const page = params.page || 1
  418. const pageSize = params.pageSize || 10
  419. const start = (page - 1) * pageSize
  420. return ok({ total: alarms.length, page, pageSize, list: alarms.slice(start, start + pageSize) })
  421. }
  422. // ═══════════════════════════════════════════════════════════════════════
  423. // C: 勤务 & 任务(分页 + 筛选)
  424. // ═══════════════════════════════════════════════════════════════════════
  425. /**
  426. * GET /api/tasks — 勤务任务列表
  427. * 支持 page / pageSize / status / keyword 筛选
  428. */
  429. export async function apiGetTasks(params = {}) {
  430. await delay(200)
  431. let list = [...DB.securityTasks]
  432. if (params.status) list = list.filter(t => t.status === params.status)
  433. if (params.keyword) {
  434. const kw = params.keyword.toLowerCase()
  435. list = list.filter(t => t.name.toLowerCase().includes(kw) || t.executor.toLowerCase().includes(kw))
  436. }
  437. if (params.level) list = list.filter(t => t.level === params.level)
  438. const page = params.page || 1
  439. const pageSize = params.pageSize || 5
  440. const total = list.length
  441. const totalPages = Math.ceil(total / pageSize)
  442. const start = (page - 1) * pageSize
  443. return ok({
  444. total, page, pageSize, totalPages,
  445. list: list.slice(start, start + pageSize),
  446. })
  447. }
  448. /**
  449. * GET /api/security-routes — 勤务路线(含视频)
  450. */
  451. export async function apiGetSecurityRoutes() {
  452. await delay(200)
  453. return ok(DB.securityRoutes.map((r, i) => ({
  454. ...r, mainVideo: pickVideo(i), cornerVideos: _makeCornerVideos(i),
  455. })))
  456. }
  457. /** GET /api/security-routes/:id */
  458. export async function apiGetSecurityRouteDetail(id) {
  459. await delay(200)
  460. const idx = DB.securityRoutes.findIndex(r => r.id === id)
  461. const route = DB.securityRoutes[idx >= 0 ? idx : 0]
  462. if (!route) return fail('路线不存在', 404)
  463. return ok({ ...route, mainVideo: pickVideo(idx >= 0 ? idx : 0), cornerVideos: _makeCornerVideos(idx >= 0 ? idx : 0) })
  464. }
  465. /** GET /api/key-intersections */
  466. export async function apiGetKeyIntersections() {
  467. await delay(150)
  468. return ok(DB.homeData.keyIntersections)
  469. }
  470. // ═══════════════════════════════════════════════════════════════════════
  471. // D: 交通时空图
  472. // ═══════════════════════════════════════════════════════════════════════
  473. export async function apiGetTrafficTimeSpace(opts = {}) {
  474. await delay(300)
  475. const { speed = 15, cycle = 120, band = 40, totalTime = 1800 } = opts
  476. const { intersections, distances } = DB.timeSpaceData
  477. const maxDist = distances[distances.length - 1]
  478. const waveData = [], greenData = []
  479. for (let t = 0; t <= totalTime; t += cycle) {
  480. const ds = t + cycle / 2
  481. waveData.push({ yBottom: 0, yTop: maxDist, xBL: t, xBR: t + band, xTL: t + maxDist / speed, xTR: t + maxDist / speed + band, label: Math.round(speed * 3.6) + 'km/h', direction: 'up' })
  482. waveData.push({ yBottom: maxDist, yTop: 0, xBL: ds, xBR: ds + band, xTL: ds + maxDist / speed, xTR: ds + maxDist / speed + band, label: Math.round(speed * 0.9 * 3.6) + 'km/h', direction: 'down' })
  483. distances.forEach(y => {
  484. greenData.push({ y, start: t + y / speed, end: t + y / speed + band })
  485. greenData.push({ y, start: ds + (maxDist - y) / speed, end: ds + (maxDist - y) / speed + band })
  486. })
  487. }
  488. return ok({ intersections, distances, waveData, greenData })
  489. }
  490. // ═══════════════════════════════════════════════════════════════════════
  491. // 路口列表表格(分页 + 筛选 + 排序 + 动态状态)
  492. // ═══════════════════════════════════════════════════════════════════════
  493. /**
  494. * GET /api/crossings — 路口列表(718条全量,支持翻页)
  495. * @param {{ keyword, subArea, status, node, isKey, page, pageSize, sortBy, sortOrder }} params
  496. */
  497. export async function apiGetCrossingList(params = {}) {
  498. await delay(250)
  499. // 动态状态:每次请求路口状态会变化
  500. const statuses = ['在线', '在线', '在线', '在线', '离线']
  501. let list = DB.crossingList.map((r, i) => ({
  502. ...r,
  503. status: statuses[Math.floor(seededRand(Math.floor(Date.now() / 10000) + i) * statuses.length)],
  504. }))
  505. // 筛选(兼容中英文值映射)
  506. if (params.keyword || params.name) {
  507. const kw = (params.keyword || params.name).toLowerCase()
  508. list = list.filter(r => r.name.toLowerCase().includes(kw) || r.id.toLowerCase().includes(kw))
  509. }
  510. if (params.subArea) list = list.filter(r => r.subArea === params.subArea)
  511. if (params.status) {
  512. const statusMap = { 'online': '在线', 'offline': '离线', 'fault': '故障' }
  513. const mapped = statusMap[params.status] || params.status
  514. list = list.filter(r => r.status === mapped)
  515. }
  516. if (params.node) list = list.filter(r => r.node === params.node)
  517. if (params.isKey !== undefined && params.isKey !== '') {
  518. const boolVal = params.isKey === 'yes' || params.isKey === true
  519. list = list.filter(r => r.isKey === boolVal)
  520. }
  521. if (params.timeOffset) {
  522. if (params.timeOffset === 'none' || params.timeOffset === '无偏差') {
  523. list = list.filter(r => r.timeOffset === '无偏差')
  524. } else {
  525. list = list.filter(r => r.timeOffset !== '无偏差')
  526. }
  527. }
  528. // 排序
  529. if (params.sortBy) {
  530. const key = params.sortBy
  531. const dir = params.sortOrder === 'desc' ? -1 : 1
  532. list.sort((a, b) => {
  533. if (a[key] < b[key]) return -1 * dir
  534. if (a[key] > b[key]) return 1 * dir
  535. return 0
  536. })
  537. }
  538. // 分页
  539. const page = params.page || 1
  540. const pageSize = params.pageSize || 10
  541. const total = list.length
  542. const totalPages = Math.ceil(total / pageSize)
  543. const start = (page - 1) * pageSize
  544. return ok({
  545. total, page, pageSize, totalPages,
  546. list: list.slice(start, start + pageSize),
  547. })
  548. }
  549. /** GET /api/dict/:type */
  550. export async function apiGetDictOptions(type) {
  551. await delay(100)
  552. if (DB.filterOptions[type]) return ok(DB.filterOptions[type])
  553. return fail('未知字典类型: ' + type, 404)
  554. }
  555. // ═══════════════════════════════════════════════════════════════════════
  556. // F: 设备操作
  557. // ═══════════════════════════════════════════════════════════════════════
  558. export async function apiRestartDevice(id) {
  559. await delay(500)
  560. return ok({ message: `设备 ${id} 重启指令已下发` })
  561. }
  562. export async function apiUpgradeDevice(id, file) {
  563. await delay(800)
  564. return ok({ message: `设备 ${id} 升级任务已创建`, version: 'V3.3.0' })
  565. }
  566. // ═══════════════════════════════════════════════════════════════════════
  567. // H: 弹窗专用(动态倒计时 + 实时状态)
  568. // ═══════════════════════════════════════════════════════════════════════
  569. /**
  570. * GET /api/special-task/:id/monitor — 特勤监控面板
  571. * 信号灯倒计时随真实时间变化
  572. */
  573. export async function apiGetSpecialTaskMonitorData(id) {
  574. await delay(400)
  575. // 用 id 生成稳定 seed,兼容数字/字符串/undefined
  576. let numId = typeof id === 'number' ? id : parseInt(id)
  577. if (!numId || isNaN(numId)) {
  578. // 非数字 id(如字符串),用 charCode 求和
  579. numId = id ? Array.from(String(id)).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 1
  580. }
  581. const seed = Math.abs(numId * 7) || 7
  582. // 任务信息——不同任务不同数据
  583. const taskIdx = Math.abs(numId - 1) % (DB.securityTasks.length || 1)
  584. const task = DB.securityTasks.find(t => t.id === id || t.id === numId) || DB.securityTasks[taskIdx] || {}
  585. 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']
  586. const statusList = [
  587. { status: '进行中', color: '#ff4d4f' },
  588. { status: '待执行', color: '#ffaa00' },
  589. { status: '进行中', color: '#ff4d4f' },
  590. { status: '进行中', color: '#00e5ff' },
  591. ]
  592. const statusItem = statusList[seed % statusList.length]
  593. const taskInfo = {
  594. name: task.name || '特勤路线',
  595. time: timeSlots[seed % timeSlots.length],
  596. manager: task.executor || DB.securityTasks[seed % DB.securityTasks.length]?.executor || '王建国',
  597. level: task.level || (seed % 3 === 0 ? '二级' : '一级'),
  598. status: statusItem.status,
  599. statusColor: statusItem.color,
  600. }
  601. // 根据任务 id 选取不同的关键路口(每个任务关联不同的4个路口)
  602. const allKeyPoints = DB.points.filter(p => p.isKey)
  603. const startIdx = (seed * 3) % Math.max(allKeyPoints.length - 4, 1)
  604. const keyPoints = allKeyPoints.slice(startIdx, startIdx + 4)
  605. // 视频
  606. const videos = keyPoints.map((_, i) => ({ id: i + 1, url: pickVideo(seed + i) }))
  607. // 每个路口的控制模式和状态颜色根据路口 id 变化
  608. const modeList = ['步进', '系统', '定周期', '感应', '手动']
  609. const colorList = ['#ffaa00', '#00e5ff', '#68e75f', '#00e5ff']
  610. const nowSec = Math.floor(Date.now() / 1000)
  611. const lanePresets = [
  612. { N: ['U', 'L', 'S', 'R'], S: [null, 'L', 'S', 'R'], E: [null, 'L', 'S', null], W: ['U', 'L', 'S', null] },
  613. { N: ['L', 'S', 'R'], S: ['L', 'S', 'R'], E: ['L', 'S', 'R'], W: ['L', 'S', 'R'] },
  614. { N: ['L', 'S', 'S', 'R'], S: ['U', 'L', 'S', 'R'], E: ['L', 'S', null], W: [null, 'S', 'R'] },
  615. { N: [null, 'L', 'S', 'R'], S: ['L', 'S', 'R', null], E: ['U', 'L', 'S', 'R'], W: ['L', 'S', 'R'] },
  616. ]
  617. const intersections = keyPoints.map((jnc, i) => {
  618. const jncSeed = Array.from(jnc.id).reduce((s, c, idx) => s + c.charCodeAt(0) * (idx + 1), 0)
  619. const cycle = [100, 120, 130, 140, 150][jncSeed % 5]
  620. const elapsed = nowSec % cycle
  621. const nsGreen = elapsed < cycle / 2
  622. const countdown = nsGreen ? (cycle / 2 - elapsed) : (cycle - elapsed)
  623. const laneSet = lanePresets[jncSeed % lanePresets.length]
  624. // 用摄像机字段结构生成,再推导 cameraType
  625. const cameras = _makeCameras(jnc.id, jnc.name, jncSeed)
  626. const armCamTypes = _camerasToArmTypes(cameras)
  627. return {
  628. id: jnc.id, name: jnc.name,
  629. statusColor: colorList[jncSeed % colorList.length],
  630. stage: (Math.floor(elapsed / (cycle / 4)) % 4) + 1,
  631. mode: modeList[jncSeed % modeList.length],
  632. timeLeft: countdown,
  633. btnText: i === 0 ? '立即解锁' : '立即执行',
  634. btnType: i === 0 ? 'normal' : 'primary',
  635. phases: _makeCardPhases(Math.floor(elapsed / (cycle / 4)) % 4),
  636. cameras,
  637. mapData: {
  638. armsConfig: {
  639. N: { lanes: laneSet.N, cameraType: armCamTypes.N },
  640. S: { lanes: laneSet.S, cameraType: armCamTypes.S },
  641. E: { lanes: laneSet.E, cameraType: armCamTypes.E },
  642. W: { lanes: laneSet.W, cameraType: armCamTypes.W },
  643. },
  644. signals: {
  645. ns: { isGreen: nsGreen, time: countdown, phaseName: '南北直行' },
  646. ew: { isGreen: !nsGreen, time: Math.max(1, cycle - countdown), phaseName: '东西直行' },
  647. },
  648. },
  649. videoUrls: _makeCornerVideos(seed + i),
  650. }
  651. })
  652. return ok({ taskInfo, videos, intersections })
  653. }
  654. /**
  655. * GET /api/crossing/panel/:id — CrossingPanel 弹窗
  656. * 倒计时实时变化
  657. */
  658. export async function apiGetCrossingPanelData(id) {
  659. await delay(300)
  660. const point = DB.points.find(p => p.id === id)
  661. const config = DB.intersectionConfigs[id] || _makeIntersectionConfig()
  662. const seed = id ? id.charCodeAt(id.length - 1) : 0
  663. const preset = DB.signalTimings[id]
  664. const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][Math.abs(seed) % 6]
  665. const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength, false)
  666. const currentTime = Math.floor(Date.now() / 1000) % cycleLength
  667. return ok({
  668. currentRoute: {
  669. id, name: point ? point.name : id,
  670. level: point?.isKey ? '一级' : '二级',
  671. mode: '快进', time: cycleLength + 's',
  672. mainVideo: pickVideo(seed), cornerVideos: _makeCornerVideos(seed),
  673. },
  674. intersectionData: config,
  675. phaseData,
  676. cycleLength, currentTime,
  677. })
  678. }
  679. /**
  680. * GET /api/crossing/detail/:id — CrossingDetailPanel 弹窗
  681. */
  682. export async function apiGetCrossingDetailData(id) {
  683. await delay(350)
  684. const point = DB.points.find(p => p.id === id)
  685. const config = DB.intersectionConfigs[id] || _makeIntersectionConfig()
  686. // 用 id 的全部字符生成稳定 seed(加权位置避免 charCode 总和碰撞)
  687. const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
  688. // 从真实阶段数据推导周期和相位
  689. const preset = DB.signalTimings[id]
  690. const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][seed % 6]
  691. const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength || 140, false)
  692. // 从相位数据中提取阶段列表(优先上轨道绿灯相位,单轨道时取 track 0,最多4个)
  693. const hasTrack1 = phaseData.some(p => p[0] === 1)
  694. const greenPhases = phaseData.filter(p => p[0] === (hasTrack1 ? 1 : 0) && p[4] !== null).slice(0, 4)
  695. const stageList = greenPhases.map((p, i) => ({
  696. value: String(i + 1),
  697. time: p[4],
  698. phaseName: p[3],
  699. direction: p[6],
  700. img: ARROWS[i],
  701. }))
  702. // 控制方式选项 + 根据路口选择不同的当前控制方式
  703. const allMethods = [
  704. { label: '定周期', value: 'fixed' },
  705. { label: '黄闪', value: 'yellow_flash' },
  706. { label: '关灯', value: 'lights_off' },
  707. { label: '步进', value: 'step' },
  708. { label: '系统方案', value: 'system' },
  709. { label: '感应控制', value: 'sensor' },
  710. { label: '临时方案', value: 'temp' },
  711. ]
  712. const methodValues = ['fixed', 'step', 'system', 'sensor', 'temp']
  713. const currentMethod = methodValues[seed % methodValues.length]
  714. // 控制模式(显示用)
  715. const controlModes = ['定周期控制', '感应控制', '干线协调', '自适应控制']
  716. const currentMode = controlModes[seed % controlModes.length]
  717. // 控制方案:根据当前控制方式给出不同方案选项
  718. const schemeMap = {
  719. 'fixed': [
  720. { label: '早高峰', value: 'early_peak' },
  721. { label: '晚高峰', value: 'evening_peak' },
  722. { label: '平峰', value: 'normal' },
  723. { label: '夜间', value: 'night' },
  724. { label: '周末', value: 'weekend' },
  725. ],
  726. 'sensor': [
  727. { label: '全感应', value: 'full_actuated' },
  728. { label: '半感应', value: 'semi_actuated' },
  729. ],
  730. 'system': [
  731. { label: '系统优化方案A', value: 'sys_a' },
  732. { label: '系统优化方案B', value: 'sys_b' },
  733. { label: '系统优化方案C', value: 'sys_c' },
  734. ],
  735. 'step': [
  736. { label: '步进方案1', value: 'step_1' },
  737. { label: '步进方案2', value: 'step_2' },
  738. ],
  739. 'temp': [
  740. { label: '临时方案A', value: 'temp_a' },
  741. { label: '临时方案B', value: 'temp_b' },
  742. { label: '临时方案C', value: 'temp_c' },
  743. ],
  744. 'yellow_flash': [{ label: '黄闪默认', value: 'yf_default' }],
  745. 'lights_off': [{ label: '关灯默认', value: 'lo_default' }],
  746. }
  747. const schemeOptions = schemeMap[currentMethod] || schemeMap['fixed']
  748. // 相位差和协调时间基于 seed 稳定变化
  749. const phaseDiff = (seed * 7) % 25
  750. const coordTime = (seed * 13) % 60
  751. return ok({
  752. currentRoute: {
  753. id, name: point ? point.name : id,
  754. level: point?.isKey ? '一级' : '二级',
  755. mode: currentMode,
  756. time: cycleLength + 's',
  757. mainVideo: pickVideo(seed),
  758. cornerVideos: _makeCornerVideos(seed),
  759. },
  760. intersectionData: config,
  761. phaseData,
  762. cycleLength,
  763. currentTime: Math.floor(Date.now() / 1000) % cycleLength,
  764. phaseDiff,
  765. coordTime,
  766. stageList,
  767. schemeOptions,
  768. currentScheme: schemeOptions[0].value,
  769. controlMode: currentMode,
  770. controlMethodOptions: allMethods,
  771. currentMethod,
  772. locktimeOptions: [
  773. { label: '30', value: 30 },
  774. { label: '50', value: 50 },
  775. { label: '100', value: 100 },
  776. { label: '300', value: 300 },
  777. ],
  778. })
  779. }
  780. /**
  781. * GET /api/crossing/top-charts — 路口Tab顶部圆环图(动态波动)
  782. */
  783. export async function apiGetCrossingTopCharts() {
  784. await delay(150)
  785. const sm = DB.deviceStatus.signalMachine
  786. const baseOnline = sm.chartData[0].value
  787. const total = baseOnline + sm.chartData[1].value
  788. const online = _fluctuate(baseOnline, Math.ceil(total * 0.02))
  789. const onlineChart = {
  790. chartData: [
  791. { name: '在线', value: online, color: '#4DF5F8' },
  792. { name: '离线', value: total - online, color: '#FFD369' },
  793. ],
  794. centerTitle: Math.round(online / total * 100) + '%',
  795. centerSubTitle: `${online}/${total}`,
  796. }
  797. const faultTotal = total - online
  798. const comm = Math.floor(faultTotal * 0.26)
  799. const det = Math.floor(faultTotal * 0.21)
  800. const lamp = Math.floor(faultTotal * 0.38)
  801. const conflict = faultTotal - comm - det - lamp
  802. const faultChart = {
  803. chartData: [
  804. { name: '通信', value: comm, color: '#4DF5F8' },
  805. { name: '检测器', value: det, color: '#FFA033' },
  806. { name: '灯控', value: lamp, color: '#FFF587' },
  807. { name: '冲突', value: conflict, color: '#FF4D4F' },
  808. ],
  809. centerTitle: Math.round(faultTotal / total * 100) + '%',
  810. centerSubTitle: `${faultTotal}/${total}`,
  811. }
  812. return ok({ onlineChart, faultChart })
  813. }
  814. /** GET /api/overview/top-charts — 总览Tab顶部图表(动态) */
  815. export async function apiGetOverviewTopCharts() {
  816. await delay(150)
  817. const res = await apiGetDeviceStatus()
  818. return ok({ onlineStatus: res.data, deviceStatus: res.data })
  819. }
  820. /**
  821. * GET /api/map/legend-config — 地图标注线路配置
  822. */
  823. export async function apiGetMapLegendConfig() {
  824. await delay(150)
  825. const tzPoints = DB.points.filter(p => p.node && p.node.includes('通州'))
  826. const lngs = tzPoints.map(p => p.lng), lats = tzPoints.map(p => p.lat)
  827. const [minLng, maxLng] = [Math.min(...lngs), Math.max(...lngs)]
  828. const [minLat, maxLat] = [Math.min(...lats), Math.max(...lats)]
  829. const midLat = (minLat + maxLat) / 2
  830. const hStep = (maxLat - minLat) / 5, vStep = (maxLng - minLng) / 8
  831. return ok([
  832. { name: '中心计划', start: [minLng, midLat + hStep], end: [maxLng, midLat + hStep], color: '#004CDE' },
  833. { name: '干线协调', start: [minLng, midLat], end: [maxLng, midLat], color: '#13C373' },
  834. { name: '勤务路线', start: [minLng, midLat - hStep], end: [maxLng, midLat - hStep], color: '#BC301D' },
  835. { name: '定周期控制', start: [minLng + vStep, maxLat], end: [minLng + vStep, minLat], color: '#3296FA' },
  836. { name: '感应控制', start: [minLng + vStep * 2, maxLat], end: [minLng + vStep * 2, minLat], color: '#FF864C' },
  837. { name: '自适应控制', start: [minLng + vStep * 3, maxLat], end: [minLng + vStep * 3, minLat], color: '#9F6EFE' },
  838. { name: '手动控制', start: [minLng + vStep * 4, maxLat], end: [minLng + vStep * 4, minLat], color: '#EB9F36' },
  839. { name: '特殊控制', start: [minLng + vStep * 5, maxLat], end: [minLng + vStep * 5, minLat], color: '#A26218' },
  840. { name: '离线', start: [minLng, maxLat - hStep * 0.3], end: [maxLng, maxLat - hStep * 0.3], color: '#7A7A7A' },
  841. { name: '降级', start: [minLng, minLat + hStep * 0.3], end: [maxLng, minLat + hStep * 0.3], color: '#D9C13B' },
  842. { name: '故障', start: [maxLng - vStep * 0.5, maxLat], end: [maxLng - vStep * 0.5, minLat], color: '#FF3938' },
  843. ])
  844. }
  845. /**
  846. * GET /api/devices/fault-status
  847. * 设备故障统计(匹配 DeviceStatusTabs 组件的 statusData 格式)
  848. * keys: signalMachineStatus, detectorStatus, trafficLightStatus
  849. */
  850. export async function apiGetDeviceFaultStatus() {
  851. await delay(200)
  852. const sm = DB.deviceStatus.signalMachine
  853. const dt = DB.deviceStatus.detector
  854. const cam = DB.deviceStatus.camera
  855. // 从在线数据推算故障数,每次波动
  856. const smTotal = sm.chartData[0].value + sm.chartData[1].value
  857. const smFault = _fluctuate(sm.chartData[1].value, 3)
  858. const dtTotal = dt.chartData[0].value + dt.chartData[1].value
  859. const dtFault = _fluctuate(dt.chartData[1].value, 5)
  860. const camTotal = cam.chartData[0].value + cam.chartData[1].value
  861. const camFault = _fluctuate(cam.chartData[1].value, 2)
  862. return ok({
  863. signalMachineStatus: {
  864. centerTitle: Math.max(0, smFault) + '',
  865. centerSubTitle: `${Math.max(0, smFault)}/${smTotal}`,
  866. chartData: [
  867. { name: '正常', value: Math.max(0, smTotal - smFault), color: '#A0E551' },
  868. { name: '故障', value: Math.max(0, smFault), color: '#D03030' },
  869. ]
  870. },
  871. detectorStatus: {
  872. centerTitle: Math.max(0, dtFault) + '',
  873. centerSubTitle: `${Math.max(0, dtFault)}/${dtTotal}`,
  874. chartData: [
  875. { name: '通信故障', value: Math.max(0, Math.floor(dtFault * 0.6)), color: '#C6302B' },
  876. { name: '数据异常', value: Math.max(0, dtFault - Math.floor(dtFault * 0.6)), color: '#faad14' },
  877. ]
  878. },
  879. trafficLightStatus: {
  880. centerTitle: Math.max(0, camFault) + '',
  881. centerSubTitle: `${Math.max(0, camFault)}/${camTotal}`,
  882. chartData: [
  883. { name: '红绿冲突', value: Math.max(0, Math.floor(camFault * 0.5)), color: '#C6302B' },
  884. { name: '红灯故障', value: Math.max(0, camFault - Math.floor(camFault * 0.5)), color: '#8F1E1E' },
  885. ]
  886. },
  887. })
  888. }