|
|
@@ -1,22 +1,955 @@
|
|
|
-import { makeHomeData, makePoints } from "./data";
|
|
|
+/**
|
|
|
+ * 模拟 API 接口层(动态数据版)
|
|
|
+ *
|
|
|
+ * 特性:
|
|
|
+ * - 数据来源:mock_data.json(由 generate_mock_data.py 从真实 XLS 路口数据生成)
|
|
|
+ * - 静态资源(视频/图片)在此处统一 import,接口直接返回完整数据
|
|
|
+ * - 支持分页、筛选、排序等动态查询
|
|
|
+ * - 每次请求信号倒计时、在线率、告警时间等实时数据会动态变化
|
|
|
+ * - 路口状态会随机波动,模拟真实监控场景
|
|
|
+ *
|
|
|
+ * 使用:import { apiLogin, apiGetPoints, ... } from '@/pyscripts/api'
|
|
|
+ * 所有接口返回 Promise<{ code, message, data }>
|
|
|
+ */
|
|
|
|
|
|
-function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
|
+import mockData from './mock_data.json'
|
|
|
|
|
|
-export async function mockLogin({ username, password, captcha }) {
|
|
|
- await sleep(350);
|
|
|
- // 演示放宽:captcha 允许空;账号密码固定
|
|
|
- if (username === "admin" && password === "123456") {
|
|
|
- return { ok: true, token: "demo_token_123", user: { name: "admin" } };
|
|
|
+// ── 静态资源(模拟 CDN / 后端返回的资源 URL)─────────────────────
|
|
|
+
|
|
|
+import video1 from '@/assets/videos/video1.mp4'
|
|
|
+import video2 from '@/assets/videos/video2.mp4'
|
|
|
+import arrow1 from '@/assets/images/arrow_1.png'
|
|
|
+import arrow2 from '@/assets/images/arrow_2.png'
|
|
|
+import arrow3 from '@/assets/images/arrow_3.png'
|
|
|
+import arrow4 from '@/assets/images/arrow_4.png'
|
|
|
+
|
|
|
+const VIDEOS = [video1, video2]
|
|
|
+const ARROWS = [arrow1, arrow2, arrow3, arrow4]
|
|
|
+
|
|
|
+function pickVideo(i) { return VIDEOS[i % VIDEOS.length] }
|
|
|
+
|
|
|
+// ── 工具 ─────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)) }
|
|
|
+function delay(base = 200) { return sleep(base + Math.floor(Math.random() * 200)) }
|
|
|
+function ok(data) { return { code: 200, message: 'success', data } }
|
|
|
+function fail(msg, code = 400) { return { code, message: msg, data: null } }
|
|
|
+
|
|
|
+/** 基于当前秒数产生稳定随机(同一秒内多次调用返回相同值) */
|
|
|
+function seededRand(seed) {
|
|
|
+ const x = Math.sin(seed) * 10000
|
|
|
+ return x - Math.floor(x)
|
|
|
+}
|
|
|
+
|
|
|
+/** 当前时间 HH:MM:SS */
|
|
|
+function nowTime() { return new Date().toLocaleTimeString() }
|
|
|
+function nowDate() { return new Date().toLocaleDateString() }
|
|
|
+
|
|
|
+// ── 数据缓存(首次 import 时加载) ──────────────────────────────────
|
|
|
+
|
|
|
+const DB = {
|
|
|
+ points: mockData.points,
|
|
|
+ menuTree: mockData.menuTree,
|
|
|
+ tongzhouMenuTree: mockData.tongzhouMenuTree,
|
|
|
+ homeData: mockData.homeData,
|
|
|
+ deviceStatus: mockData.deviceStatus,
|
|
|
+ timeSpaceData: mockData.timeSpaceData,
|
|
|
+ crossingList: mockData.crossingList,
|
|
|
+ securityRoutes: mockData.securityRoutes,
|
|
|
+ securityTasks: mockData.securityTasks,
|
|
|
+ filterOptions: mockData.filterOptions,
|
|
|
+ signalTimings: mockData.sampleSignalTimings,
|
|
|
+ intersectionConfigs: mockData.sampleIntersectionConfigs,
|
|
|
+}
|
|
|
+
|
|
|
+// ── 内部生成器 ───────────────────────────────────────────────────────
|
|
|
+
|
|
|
+/**
|
|
|
+ * 生成摄像机模拟数据(基于 XLS 摄像机 Sheet 字段结构)
|
|
|
+ * 字段:路口名、路口编号、摄像机编号、登录名称、摄像头密码、摄像头类型、端口号、IP地址、是否启用、位置
|
|
|
+ */
|
|
|
+function _makeCameras(id, name, seed) {
|
|
|
+ const positions = ['北进口', '南进口', '东进口', '西进口']
|
|
|
+ const types = ['枪机', '球机']
|
|
|
+ const numCams = 2 + (seed % 3) // 2~4 个
|
|
|
+ return Array.from({ length: numCams }, (_, i) => ({
|
|
|
+ intersection: name || id,
|
|
|
+ intersectionId: id,
|
|
|
+ cameraId: `CAM${(id || '000').slice(-6)}_${String(i + 1).padStart(2, '0')}`,
|
|
|
+ loginName: `admin_${String(seed + i).slice(-4)}`,
|
|
|
+ password: '******',
|
|
|
+ cameraType: types[(seed + i) % types.length],
|
|
|
+ port: 554 + i,
|
|
|
+ ip: `192.168.${10 + (seed % 200)}.${100 + i}`,
|
|
|
+ enabled: (seed + i) % 20 !== 0, // ~5% 禁用
|
|
|
+ position: positions[i % positions.length],
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+/** 从摄像机列表推导各方向摄像头类型 (1枪机 2球机 0无) */
|
|
|
+function _camerasToArmTypes(cameras) {
|
|
|
+ const posMap = { '北进口': 'N', '南进口': 'S', '东进口': 'E', '西进口': 'W' }
|
|
|
+ const typeMap = { '枪机': 1, '球机': 2 }
|
|
|
+ const result = { N: 0, S: 0, E: 0, W: 0 }
|
|
|
+ cameras.forEach(c => {
|
|
|
+ if (c.enabled) {
|
|
|
+ const dir = posMap[c.position]
|
|
|
+ if (dir) result[dir] = typeMap[c.cameraType] || 0
|
|
|
+ }
|
|
|
+ })
|
|
|
+ return result
|
|
|
+}
|
|
|
+
|
|
|
+function _makeIntersectionConfig(id, name) {
|
|
|
+ const phases = ['南北直行', '东西直行', '北单放', '东单放']
|
|
|
+ const nsGreen = Math.random() > 0.5
|
|
|
+ const countdown = 10 + Math.floor(Math.random() * 50)
|
|
|
+ const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
|
|
|
+
|
|
|
+ const cameras = _makeCameras(id, name, seed)
|
|
|
+ const armCamTypes = _camerasToArmTypes(cameras)
|
|
|
+
|
|
|
+ const lanePresets = [
|
|
|
+ ['U', 'L', 'S', 'R'], [null, 'L', 'S', 'R'],
|
|
|
+ [null, 'L', 'S', null], ['U', 'L', 'S', null],
|
|
|
+ ].sort(() => seededRand(seed) - 0.5)
|
|
|
+
|
|
|
+ return {
|
|
|
+ signals: {
|
|
|
+ ns: { phaseName: phases[0], time: countdown, isGreen: nsGreen },
|
|
|
+ ew: { phaseName: phases[1], time: countdown, isGreen: !nsGreen },
|
|
|
+ },
|
|
|
+ armsConfig: {
|
|
|
+ N: { cameraType: armCamTypes.N, lanes: lanePresets[0] },
|
|
|
+ S: { cameraType: armCamTypes.S, lanes: lanePresets[1] },
|
|
|
+ E: { cameraType: armCamTypes.E, lanes: lanePresets[2] },
|
|
|
+ W: { cameraType: armCamTypes.W, lanes: lanePresets[3] },
|
|
|
+ },
|
|
|
+ cameras,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function _makePhaseData(cycleLength) {
|
|
|
+ const n = 4, tp = Math.floor(cycleLength / n)
|
|
|
+ const dirs = ['UP', 'TURN_LEFT', 'DOWN', 'TURN_RIGHT']
|
|
|
+ const pd = []
|
|
|
+ for (let track = 1; track >= 0; track--) {
|
|
|
+ let t = 0
|
|
|
+ for (let i = 0; i < n; i++) {
|
|
|
+ const g = tp - 8
|
|
|
+ pd.push([track, t, t + g, `P${track * n + i + 1}`, g, 'green', dirs[i]]); t += g
|
|
|
+ pd.push([track, t, t + 3, '', null, 'stripe', null]); t += 3
|
|
|
+ pd.push([track, t, t + 2, '', null, 'yellow', null]); t += 2
|
|
|
+ pd.push([track, t, t + 3, '', null, 'red', null]); t += 3
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return pd
|
|
|
+}
|
|
|
+
|
|
|
+function _makeCornerVideos(seed = 0) {
|
|
|
+ return { nw: pickVideo(seed), ne: pickVideo(seed + 1), sw: pickVideo(seed + 1), se: pickVideo(seed) }
|
|
|
+}
|
|
|
+
|
|
|
+function _makeStageList() {
|
|
|
+ return [1, 2, 3, 4].map((_, i) => ({
|
|
|
+ value: String(i + 1), time: [30, 30, 50, 30][i], img: ARROWS[i],
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+function _makeCardPhases(activeIndex = 0) {
|
|
|
+ return ARROWS.map((img, i) => ({
|
|
|
+ id: i + 1, icon: ['↑', '↰', '↑', '↰'][i], img, active: i === activeIndex,
|
|
|
+ }))
|
|
|
+}
|
|
|
+
|
|
|
+/** 动态波动整数值(基于基准值上下浮动) */
|
|
|
+function _fluctuate(base, range) {
|
|
|
+ return base + Math.floor(Math.random() * range * 2) - range
|
|
|
+}
|
|
|
+
|
|
|
+/** 动态更新路口状态(每次调用随机波动少量路口) */
|
|
|
+function _dynamicPoints() {
|
|
|
+ const now = Math.floor(Date.now() / 5000) // 每5秒切换一批
|
|
|
+ return DB.points.map((p, i) => {
|
|
|
+ const r = seededRand(now + i)
|
|
|
+ const status = r < 0.78 ? 'normal' : r < 0.92 ? 'busy' : 'alarm'
|
|
|
+ return { ...p, status, updatedAt: Date.now() - Math.floor(r * 120000) }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// E: 认证
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+/** 验证码状态(模拟服务端 session 存储) */
|
|
|
+let _captchaStore = { code: '', expireAt: 0 }
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/auth/captcha — 获取验证码
|
|
|
+ * 返回 4 位随机字符 + base64 图片(Canvas 绘制)
|
|
|
+ * 真实后端应返回图片流,这里模拟返回验证码文本 + 配置,由前端 Canvas 绘制
|
|
|
+ */
|
|
|
+export async function apiGetCaptcha() {
|
|
|
+ await delay(100)
|
|
|
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
|
|
+ const code = Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
|
|
+
|
|
|
+ // 存储验证码,有效期 60 秒
|
|
|
+ _captchaStore = { code: code.toUpperCase(), expireAt: Date.now() + 60000 }
|
|
|
+
|
|
|
+ return ok({
|
|
|
+ captchaId: 'cap_' + Date.now(),
|
|
|
+ code, // 模拟环境直接返回明文,真实环境不应返回
|
|
|
+ length: 4,
|
|
|
+ expireIn: 60, // 秒
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * POST /api/auth/login — 登录(含验证码校验)
|
|
|
+ */
|
|
|
+export async function apiLogin({ username, password, captcha }) {
|
|
|
+ await delay(300)
|
|
|
+
|
|
|
+ // 验证码校验
|
|
|
+ if (_captchaStore.code && captcha) {
|
|
|
+ if (Date.now() > _captchaStore.expireAt) {
|
|
|
+ return fail('验证码已过期,请刷新', 401)
|
|
|
+ }
|
|
|
+ if (captcha.toUpperCase() !== _captchaStore.code) {
|
|
|
+ return fail('验证码错误', 401)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 账号密码校验
|
|
|
+ if (username === 'admin' && password === '123456') {
|
|
|
+ _captchaStore = { code: '', expireAt: 0 } // 登录成功后清除验证码
|
|
|
+ return ok({ token: 'demo_token_' + Date.now(), user: { name: 'admin', role: '管理员' } })
|
|
|
+ }
|
|
|
+ return fail('账号或密码错误(演示账号:admin / 123456)', 401)
|
|
|
+}
|
|
|
+
|
|
|
+export async function apiChangePassword({ oldPassword, newPassword }) {
|
|
|
+ await delay(300)
|
|
|
+ if (oldPassword === '123456') return ok({ message: '密码修改成功' })
|
|
|
+ return fail('原密码错误')
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// A: 路口基础数据
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/intersections — 路口点位列表
|
|
|
+ * 每次调用路口状态会动态波动
|
|
|
+ */
|
|
|
+export async function apiGetPoints(filters = {}) {
|
|
|
+ await delay(200)
|
|
|
+ let list = _dynamicPoints()
|
|
|
+ if (filters.node) list = list.filter(p => p.node === filters.node)
|
|
|
+ if (filters.status) list = list.filter(p => p.status === filters.status)
|
|
|
+ if (filters.keyword) {
|
|
|
+ const kw = filters.keyword.toLowerCase()
|
|
|
+ list = list.filter(p => p.name.toLowerCase().includes(kw) || p.id.toLowerCase().includes(kw))
|
|
|
+ }
|
|
|
+ return ok(list)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/intersections/:id — 路口详情
|
|
|
+ * 信号倒计时每次请求动态变化
|
|
|
+ */
|
|
|
+export async function apiGetIntersectionData(id) {
|
|
|
+ await delay(250)
|
|
|
+ const base = DB.intersectionConfigs[id]
|
|
|
+ const point = DB.points.find(p => p.id === id)
|
|
|
+ const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
|
|
|
+
|
|
|
+ // 动态倒计时:基于当前秒数计算
|
|
|
+ const nowSec = Math.floor(Date.now() / 1000)
|
|
|
+ const cycle = 140
|
|
|
+ const elapsed = nowSec % cycle
|
|
|
+ const nsGreen = elapsed < cycle / 2
|
|
|
+
|
|
|
+ const config = base ? {
|
|
|
+ signals: {
|
|
|
+ ns: { ...base.signals.ns, time: nsGreen ? (cycle / 2 - elapsed) : elapsed - cycle / 2, isGreen: nsGreen },
|
|
|
+ ew: { ...base.signals.ew, time: nsGreen ? elapsed : (cycle - elapsed), isGreen: !nsGreen },
|
|
|
+ },
|
|
|
+ armsConfig: base.armsConfig,
|
|
|
+ cameras: base.cameras,
|
|
|
+ } : _makeIntersectionConfig(id, point ? point.name : id)
|
|
|
+
|
|
|
+ return ok({
|
|
|
+ ...config,
|
|
|
+ id, name: point ? point.name : id,
|
|
|
+ mainVideo: pickVideo(seed),
|
|
|
+ cornerVideos: _makeCornerVideos(seed),
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/intersections/:id/signal-timing — 信号配时
|
|
|
+ * currentTime 随真实时间走动
|
|
|
+ */
|
|
|
+export async function apiGetSignalTiming(id) {
|
|
|
+ await delay(300)
|
|
|
+ const preset = DB.signalTimings[id]
|
|
|
+ if (preset) {
|
|
|
+ const cycleLength = preset.data.cycleLength
|
|
|
+ return {
|
|
|
+ code: 200, message: 'success',
|
|
|
+ data: { ...preset.data, currentTime: Math.floor(Date.now() / 1000) % cycleLength }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const cycleLength = [100, 120, 130, 140, 150, 160][Math.floor(Math.random() * 6)]
|
|
|
+ return ok({
|
|
|
+ cycleLength,
|
|
|
+ currentTime: Math.floor(Date.now() / 1000) % cycleLength,
|
|
|
+ phaseData: _makePhaseData(cycleLength),
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** GET /api/intersections/:id/stages */
|
|
|
+export async function apiGetIntersectionStages(id) {
|
|
|
+ await delay(200)
|
|
|
+ const timing = DB.signalTimings[id]
|
|
|
+ if (timing) {
|
|
|
+ const phases = timing.data.phaseData.filter(p => p[0] === 1 && p[4] !== null)
|
|
|
+ return ok(phases.map((p, i) => ({
|
|
|
+ value: String(i + 1), time: p[4], phaseName: p[3], direction: p[6], img: ARROWS[i % ARROWS.length],
|
|
|
+ })))
|
|
|
+ }
|
|
|
+ return ok(_makeStageList())
|
|
|
+}
|
|
|
+
|
|
|
+/** GET /api/intersections/:id/schemes */
|
|
|
+export async function apiGetSchemes(id) {
|
|
|
+ await delay(150)
|
|
|
+ return ok(DB.filterOptions.schemeOptions)
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// A4: 区域菜单树
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+export async function apiGetMenuTree(tabId = 'arterial') {
|
|
|
+ await delay(250)
|
|
|
+ return ok(tabId === 'arterial' ? DB.menuTree : DB.tongzhouMenuTree)
|
|
|
+}
|
|
|
+
|
|
|
+export async function apiGetTongzhouMenuTree() {
|
|
|
+ await delay(250)
|
|
|
+ return ok(DB.tongzhouMenuTree)
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// B: 设备状态 & 首页(动态波动)
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/devices/status/summary
|
|
|
+ * 在线数每次请求轻微波动
|
|
|
+ */
|
|
|
+export async function apiGetDeviceStatus(type) {
|
|
|
+ await delay(200)
|
|
|
+ function fluctuateStats(base) {
|
|
|
+ const online = base.chartData[0].value
|
|
|
+ const total = online + base.chartData[1].value
|
|
|
+ const newOnline = _fluctuate(online, Math.ceil(total * 0.02))
|
|
|
+ const clamped = Math.max(0, Math.min(total, newOnline))
|
|
|
+ const rate = Math.round(clamped / total * 100)
|
|
|
+ return {
|
|
|
+ centerTitle: rate + '%',
|
|
|
+ centerSubTitle: `${clamped}/${total}`,
|
|
|
+ chartData: [
|
|
|
+ { ...base.chartData[0], value: clamped },
|
|
|
+ { ...base.chartData[1], value: total - clamped },
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (type && DB.deviceStatus[type]) return ok(fluctuateStats(DB.deviceStatus[type]))
|
|
|
+ return ok({
|
|
|
+ signalMachine: fluctuateStats(DB.deviceStatus.signalMachine),
|
|
|
+ detector: fluctuateStats(DB.deviceStatus.detector),
|
|
|
+ camera: fluctuateStats(DB.deviceStatus.camera),
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/home/snapshot — 首页快照
|
|
|
+ * 在线数波动 + 时间实时更新 + 告警时间刷新
|
|
|
+ */
|
|
|
+export async function apiGetHomeSnapshot() {
|
|
|
+ await delay(200)
|
|
|
+ const total = DB.homeData.online.total
|
|
|
+ const online = _fluctuate(Math.round(total * 0.93), 30)
|
|
|
+ const clamped = Math.max(0, Math.min(total, online))
|
|
|
+ const fault = Math.floor(Math.random() * 5)
|
|
|
+
|
|
|
+ return ok({
|
|
|
+ header: { ...DB.homeData.header, timeText: nowTime(), dateText: nowDate() },
|
|
|
+ online: { online: clamped, offline: total - clamped, total, rate: Math.round(clamped / total * 100) },
|
|
|
+ alarms: DB.homeData.alarms.map(a => ({
|
|
|
+ ...a,
|
|
|
+ time: new Date(Date.now() - Math.floor(Math.random() * 3600000)).toLocaleTimeString(),
|
|
|
+ })),
|
|
|
+ duty: DB.homeData.duty,
|
|
|
+ device: { normal: total - fault, fault },
|
|
|
+ controlModes: DB.homeData.controlModes,
|
|
|
+ keyIntersections: DB.homeData.keyIntersections,
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** GET /api/home/control-mode-stats — 控制模式分布(轻微波动) */
|
|
|
+export async function apiGetControlModeStats() {
|
|
|
+ await delay(150)
|
|
|
+ return ok(DB.homeData.controlModes.map(m => ({
|
|
|
+ ...m, value: _fluctuate(m.value, Math.ceil(m.value * 0.05)),
|
|
|
+ })))
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/alarms/latest — 告警列表(分页 + 动态时间)
|
|
|
+ * @param {{ page?: number, pageSize?: number, level?: string }} params
|
|
|
+ */
|
|
|
+export async function apiGetLatestAlarms(params = {}) {
|
|
|
+ await delay(200)
|
|
|
+ const typeMap = { high: 'error', mid: 'warning', low: 'warning' }
|
|
|
+ let alarms = DB.homeData.alarms.map((a, i) => ({
|
|
|
+ id: a.id, title: a.title, type: typeMap[a.level] || 'warning',
|
|
|
+ time: new Date(Date.now() - i * 180000 - Math.floor(Math.random() * 60000)).toLocaleTimeString(),
|
|
|
+ description: `${a.loc}-${a.title}`,
|
|
|
+ position: a.position, level: a.level, loc: a.loc,
|
|
|
+ }))
|
|
|
+
|
|
|
+ if (params.level) alarms = alarms.filter(a => a.level === params.level)
|
|
|
+
|
|
|
+ const page = params.page || 1
|
|
|
+ const pageSize = params.pageSize || 10
|
|
|
+ const start = (page - 1) * pageSize
|
|
|
+ return ok({ total: alarms.length, page, pageSize, list: alarms.slice(start, start + pageSize) })
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// C: 勤务 & 任务(分页 + 筛选)
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/tasks — 勤务任务列表
|
|
|
+ * 支持 page / pageSize / status / keyword 筛选
|
|
|
+ */
|
|
|
+export async function apiGetTasks(params = {}) {
|
|
|
+ await delay(200)
|
|
|
+ let list = [...DB.securityTasks]
|
|
|
+ if (params.status) list = list.filter(t => t.status === params.status)
|
|
|
+ if (params.keyword) {
|
|
|
+ const kw = params.keyword.toLowerCase()
|
|
|
+ list = list.filter(t => t.name.toLowerCase().includes(kw) || t.executor.toLowerCase().includes(kw))
|
|
|
+ }
|
|
|
+ if (params.level) list = list.filter(t => t.level === params.level)
|
|
|
+
|
|
|
+ const page = params.page || 1
|
|
|
+ const pageSize = params.pageSize || 5
|
|
|
+ const total = list.length
|
|
|
+ const totalPages = Math.ceil(total / pageSize)
|
|
|
+ const start = (page - 1) * pageSize
|
|
|
+
|
|
|
+ return ok({
|
|
|
+ total, page, pageSize, totalPages,
|
|
|
+ list: list.slice(start, start + pageSize),
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/security-routes — 勤务路线(含视频)
|
|
|
+ */
|
|
|
+export async function apiGetSecurityRoutes() {
|
|
|
+ await delay(200)
|
|
|
+ return ok(DB.securityRoutes.map((r, i) => ({
|
|
|
+ ...r, mainVideo: pickVideo(i), cornerVideos: _makeCornerVideos(i),
|
|
|
+ })))
|
|
|
+}
|
|
|
+
|
|
|
+/** GET /api/security-routes/:id */
|
|
|
+export async function apiGetSecurityRouteDetail(id) {
|
|
|
+ await delay(200)
|
|
|
+ const idx = DB.securityRoutes.findIndex(r => r.id === id)
|
|
|
+ const route = DB.securityRoutes[idx >= 0 ? idx : 0]
|
|
|
+ if (!route) return fail('路线不存在', 404)
|
|
|
+ return ok({ ...route, mainVideo: pickVideo(idx >= 0 ? idx : 0), cornerVideos: _makeCornerVideos(idx >= 0 ? idx : 0) })
|
|
|
+}
|
|
|
+
|
|
|
+/** GET /api/key-intersections */
|
|
|
+export async function apiGetKeyIntersections() {
|
|
|
+ await delay(150)
|
|
|
+ return ok(DB.homeData.keyIntersections)
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// D: 交通时空图
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+export async function apiGetTrafficTimeSpace(opts = {}) {
|
|
|
+ await delay(300)
|
|
|
+ const { speed = 15, cycle = 120, band = 40, totalTime = 1800 } = opts
|
|
|
+ const { intersections, distances } = DB.timeSpaceData
|
|
|
+ const maxDist = distances[distances.length - 1]
|
|
|
+ const waveData = [], greenData = []
|
|
|
+
|
|
|
+ for (let t = 0; t <= totalTime; t += cycle) {
|
|
|
+ const ds = t + cycle / 2
|
|
|
+ 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' })
|
|
|
+ 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' })
|
|
|
+ distances.forEach(y => {
|
|
|
+ greenData.push({ y, start: t + y / speed, end: t + y / speed + band })
|
|
|
+ greenData.push({ y, start: ds + (maxDist - y) / speed, end: ds + (maxDist - y) / speed + band })
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return ok({ intersections, distances, waveData, greenData })
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// 路口列表表格(分页 + 筛选 + 排序 + 动态状态)
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/crossings — 路口列表(718条全量,支持翻页)
|
|
|
+ * @param {{ keyword, subArea, status, node, isKey, page, pageSize, sortBy, sortOrder }} params
|
|
|
+ */
|
|
|
+export async function apiGetCrossingList(params = {}) {
|
|
|
+ await delay(250)
|
|
|
+
|
|
|
+ // 动态状态:每次请求路口状态会变化
|
|
|
+ const statuses = ['在线', '在线', '在线', '在线', '离线']
|
|
|
+ let list = DB.crossingList.map((r, i) => ({
|
|
|
+ ...r,
|
|
|
+ status: statuses[Math.floor(seededRand(Math.floor(Date.now() / 10000) + i) * statuses.length)],
|
|
|
+ }))
|
|
|
+
|
|
|
+ // 筛选(兼容中英文值映射)
|
|
|
+ if (params.keyword || params.name) {
|
|
|
+ const kw = (params.keyword || params.name).toLowerCase()
|
|
|
+ list = list.filter(r => r.name.toLowerCase().includes(kw) || r.id.toLowerCase().includes(kw))
|
|
|
+ }
|
|
|
+ if (params.subArea) list = list.filter(r => r.subArea === params.subArea)
|
|
|
+ if (params.status) {
|
|
|
+ const statusMap = { 'online': '在线', 'offline': '离线', 'fault': '故障' }
|
|
|
+ const mapped = statusMap[params.status] || params.status
|
|
|
+ list = list.filter(r => r.status === mapped)
|
|
|
+ }
|
|
|
+ if (params.node) list = list.filter(r => r.node === params.node)
|
|
|
+ if (params.isKey !== undefined && params.isKey !== '') {
|
|
|
+ const boolVal = params.isKey === 'yes' || params.isKey === true
|
|
|
+ list = list.filter(r => r.isKey === boolVal)
|
|
|
+ }
|
|
|
+ if (params.timeOffset) {
|
|
|
+ if (params.timeOffset === 'none' || params.timeOffset === '无偏差') {
|
|
|
+ list = list.filter(r => r.timeOffset === '无偏差')
|
|
|
+ } else {
|
|
|
+ list = list.filter(r => r.timeOffset !== '无偏差')
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 排序
|
|
|
+ if (params.sortBy) {
|
|
|
+ const key = params.sortBy
|
|
|
+ const dir = params.sortOrder === 'desc' ? -1 : 1
|
|
|
+ list.sort((a, b) => {
|
|
|
+ if (a[key] < b[key]) return -1 * dir
|
|
|
+ if (a[key] > b[key]) return 1 * dir
|
|
|
+ return 0
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ // 分页
|
|
|
+ const page = params.page || 1
|
|
|
+ const pageSize = params.pageSize || 10
|
|
|
+ const total = list.length
|
|
|
+ const totalPages = Math.ceil(total / pageSize)
|
|
|
+ const start = (page - 1) * pageSize
|
|
|
+
|
|
|
+ return ok({
|
|
|
+ total, page, pageSize, totalPages,
|
|
|
+ list: list.slice(start, start + pageSize),
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/** GET /api/dict/:type */
|
|
|
+export async function apiGetDictOptions(type) {
|
|
|
+ await delay(100)
|
|
|
+ if (DB.filterOptions[type]) return ok(DB.filterOptions[type])
|
|
|
+ return fail('未知字典类型: ' + type, 404)
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// F: 设备操作
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+export async function apiRestartDevice(id) {
|
|
|
+ await delay(500)
|
|
|
+ return ok({ message: `设备 ${id} 重启指令已下发` })
|
|
|
+}
|
|
|
+
|
|
|
+export async function apiUpgradeDevice(id, file) {
|
|
|
+ await delay(800)
|
|
|
+ return ok({ message: `设备 ${id} 升级任务已创建`, version: 'V3.3.0' })
|
|
|
+}
|
|
|
+
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+// H: 弹窗专用(动态倒计时 + 实时状态)
|
|
|
+// ═══════════════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/special-task/:id/monitor — 特勤监控面板
|
|
|
+ * 信号灯倒计时随真实时间变化
|
|
|
+ */
|
|
|
+export async function apiGetSpecialTaskMonitorData(id) {
|
|
|
+ await delay(400)
|
|
|
+
|
|
|
+ // 用 id 生成稳定 seed,兼容数字/字符串/undefined
|
|
|
+ let numId = typeof id === 'number' ? id : parseInt(id)
|
|
|
+ if (!numId || isNaN(numId)) {
|
|
|
+ // 非数字 id(如字符串),用 charCode 求和
|
|
|
+ numId = id ? Array.from(String(id)).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 1
|
|
|
+ }
|
|
|
+ const seed = Math.abs(numId * 7) || 7
|
|
|
+
|
|
|
+ // 任务信息——不同任务不同数据
|
|
|
+ const taskIdx = Math.abs(numId - 1) % (DB.securityTasks.length || 1)
|
|
|
+ const task = DB.securityTasks.find(t => t.id === id || t.id === numId) || DB.securityTasks[taskIdx] || {}
|
|
|
+ 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']
|
|
|
+ const statusList = [
|
|
|
+ { status: '进行中', color: '#ff4d4f' },
|
|
|
+ { status: '待执行', color: '#ffaa00' },
|
|
|
+ { status: '进行中', color: '#ff4d4f' },
|
|
|
+ { status: '进行中', color: '#00e5ff' },
|
|
|
+ ]
|
|
|
+ const statusItem = statusList[seed % statusList.length]
|
|
|
+
|
|
|
+ const taskInfo = {
|
|
|
+ name: task.name || '特勤路线',
|
|
|
+ time: timeSlots[seed % timeSlots.length],
|
|
|
+ manager: task.executor || DB.securityTasks[seed % DB.securityTasks.length]?.executor || '王建国',
|
|
|
+ level: task.level || (seed % 3 === 0 ? '二级' : '一级'),
|
|
|
+ status: statusItem.status,
|
|
|
+ statusColor: statusItem.color,
|
|
|
}
|
|
|
- return { ok: false, message: "账号或密码错误(演示账号:admin / 123456)" };
|
|
|
+
|
|
|
+ // 根据任务 id 选取不同的关键路口(每个任务关联不同的4个路口)
|
|
|
+ const allKeyPoints = DB.points.filter(p => p.isKey)
|
|
|
+ const startIdx = (seed * 3) % Math.max(allKeyPoints.length - 4, 1)
|
|
|
+ const keyPoints = allKeyPoints.slice(startIdx, startIdx + 4)
|
|
|
+
|
|
|
+ // 视频
|
|
|
+ const videos = keyPoints.map((_, i) => ({ id: i + 1, url: pickVideo(seed + i) }))
|
|
|
+
|
|
|
+ // 每个路口的控制模式和状态颜色根据路口 id 变化
|
|
|
+ const modeList = ['步进', '系统', '定周期', '感应', '手动']
|
|
|
+ const colorList = ['#ffaa00', '#00e5ff', '#68e75f', '#00e5ff']
|
|
|
+ const nowSec = Math.floor(Date.now() / 1000)
|
|
|
+
|
|
|
+ const lanePresets = [
|
|
|
+ { N: ['U', 'L', 'S', 'R'], S: [null, 'L', 'S', 'R'], E: [null, 'L', 'S', null], W: ['U', 'L', 'S', null] },
|
|
|
+ { N: ['L', 'S', 'R'], S: ['L', 'S', 'R'], E: ['L', 'S', 'R'], W: ['L', 'S', 'R'] },
|
|
|
+ { N: ['L', 'S', 'S', 'R'], S: ['U', 'L', 'S', 'R'], E: ['L', 'S', null], W: [null, 'S', 'R'] },
|
|
|
+ { N: [null, 'L', 'S', 'R'], S: ['L', 'S', 'R', null], E: ['U', 'L', 'S', 'R'], W: ['L', 'S', 'R'] },
|
|
|
+ ]
|
|
|
+
|
|
|
+ const intersections = keyPoints.map((jnc, i) => {
|
|
|
+ const jncSeed = Array.from(jnc.id).reduce((s, c, idx) => s + c.charCodeAt(0) * (idx + 1), 0)
|
|
|
+ const cycle = [100, 120, 130, 140, 150][jncSeed % 5]
|
|
|
+ const elapsed = nowSec % cycle
|
|
|
+ const nsGreen = elapsed < cycle / 2
|
|
|
+ const countdown = nsGreen ? (cycle / 2 - elapsed) : (cycle - elapsed)
|
|
|
+ const laneSet = lanePresets[jncSeed % lanePresets.length]
|
|
|
+
|
|
|
+ // 用摄像机字段结构生成,再推导 cameraType
|
|
|
+ const cameras = _makeCameras(jnc.id, jnc.name, jncSeed)
|
|
|
+ const armCamTypes = _camerasToArmTypes(cameras)
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: jnc.id, name: jnc.name,
|
|
|
+ statusColor: colorList[jncSeed % colorList.length],
|
|
|
+ stage: (Math.floor(elapsed / (cycle / 4)) % 4) + 1,
|
|
|
+ mode: modeList[jncSeed % modeList.length],
|
|
|
+ timeLeft: countdown,
|
|
|
+ btnText: i === 0 ? '立即解锁' : '立即执行',
|
|
|
+ btnType: i === 0 ? 'normal' : 'primary',
|
|
|
+ phases: _makeCardPhases(Math.floor(elapsed / (cycle / 4)) % 4),
|
|
|
+ cameras,
|
|
|
+ mapData: {
|
|
|
+ armsConfig: {
|
|
|
+ N: { lanes: laneSet.N, cameraType: armCamTypes.N },
|
|
|
+ S: { lanes: laneSet.S, cameraType: armCamTypes.S },
|
|
|
+ E: { lanes: laneSet.E, cameraType: armCamTypes.E },
|
|
|
+ W: { lanes: laneSet.W, cameraType: armCamTypes.W },
|
|
|
+ },
|
|
|
+ signals: {
|
|
|
+ ns: { isGreen: nsGreen, time: countdown, phaseName: '南北直行' },
|
|
|
+ ew: { isGreen: !nsGreen, time: Math.max(1, cycle - countdown), phaseName: '东西直行' },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ videoUrls: _makeCornerVideos(seed + i),
|
|
|
+ }
|
|
|
+ })
|
|
|
+
|
|
|
+ return ok({ taskInfo, videos, intersections })
|
|
|
}
|
|
|
|
|
|
-export async function mockHomeSnapshot() {
|
|
|
- await sleep(250);
|
|
|
- return makeHomeData();
|
|
|
+/**
|
|
|
+ * GET /api/crossing/panel/:id — CrossingPanel 弹窗
|
|
|
+ * 倒计时实时变化
|
|
|
+ */
|
|
|
+export async function apiGetCrossingPanelData(id) {
|
|
|
+ await delay(300)
|
|
|
+ const point = DB.points.find(p => p.id === id)
|
|
|
+ const config = DB.intersectionConfigs[id] || _makeIntersectionConfig()
|
|
|
+ const seed = id ? id.charCodeAt(id.length - 1) : 0
|
|
|
+
|
|
|
+ const preset = DB.signalTimings[id]
|
|
|
+ const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][Math.abs(seed) % 6]
|
|
|
+ const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength)
|
|
|
+ const currentTime = Math.floor(Date.now() / 1000) % cycleLength
|
|
|
+
|
|
|
+ return ok({
|
|
|
+ currentRoute: {
|
|
|
+ id, name: point ? point.name : id,
|
|
|
+ level: point?.isKey ? '一级' : '二级',
|
|
|
+ mode: '快进', time: cycleLength + 's',
|
|
|
+ mainVideo: pickVideo(seed), cornerVideos: _makeCornerVideos(seed),
|
|
|
+ },
|
|
|
+ intersectionData: config,
|
|
|
+ phaseData,
|
|
|
+ cycleLength, currentTime,
|
|
|
+ })
|
|
|
}
|
|
|
|
|
|
-export async function mockPoints(count = 200) {
|
|
|
- await sleep(220);
|
|
|
- return makePoints(count);
|
|
|
-}
|
|
|
+/**
|
|
|
+ * GET /api/crossing/detail/:id — CrossingDetailPanel 弹窗
|
|
|
+ */
|
|
|
+export async function apiGetCrossingDetailData(id) {
|
|
|
+ await delay(350)
|
|
|
+ const point = DB.points.find(p => p.id === id)
|
|
|
+ const config = DB.intersectionConfigs[id] || _makeIntersectionConfig()
|
|
|
+
|
|
|
+ // 用 id 的全部字符生成稳定 seed(加权位置避免 charCode 总和碰撞)
|
|
|
+ const seed = id ? Array.from(id).reduce((s, c, i) => s + c.charCodeAt(0) * (i + 1), 0) : 0
|
|
|
+
|
|
|
+ // 从真实阶段数据推导周期和相位
|
|
|
+ const preset = DB.signalTimings[id]
|
|
|
+ const cycleLength = preset ? preset.data.cycleLength : [100, 120, 130, 140, 150, 160][seed % 6]
|
|
|
+ const phaseData = preset ? preset.data.phaseData : _makePhaseData(cycleLength)
|
|
|
+
|
|
|
+ // 从相位数据中提取阶段列表(上轨道绿灯相位,最多4个)
|
|
|
+ const greenPhases = phaseData.filter(p => p[0] === 1 && p[4] !== null).slice(0, 4)
|
|
|
+ const stageList = greenPhases.map((p, i) => ({
|
|
|
+ value: String(i + 1),
|
|
|
+ time: p[4],
|
|
|
+ phaseName: p[3],
|
|
|
+ direction: p[6],
|
|
|
+ img: ARROWS[i],
|
|
|
+ }))
|
|
|
+
|
|
|
+ // 控制方式选项 + 根据路口选择不同的当前控制方式
|
|
|
+ const allMethods = [
|
|
|
+ { label: '定周期', value: 'fixed' },
|
|
|
+ { label: '黄闪', value: 'yellow_flash' },
|
|
|
+ { label: '关灯', value: 'lights_off' },
|
|
|
+ { label: '步进', value: 'step' },
|
|
|
+ { label: '系统方案', value: 'system' },
|
|
|
+ { label: '感应控制', value: 'sensor' },
|
|
|
+ { label: '临时方案', value: 'temp' },
|
|
|
+ ]
|
|
|
+ const methodValues = ['fixed', 'step', 'system', 'sensor', 'temp']
|
|
|
+ const currentMethod = methodValues[seed % methodValues.length]
|
|
|
+
|
|
|
+ // 控制模式(显示用)
|
|
|
+ const controlModes = ['定周期控制', '感应控制', '干线协调', '自适应控制']
|
|
|
+ const currentMode = controlModes[seed % controlModes.length]
|
|
|
+
|
|
|
+ // 控制方案:根据当前控制方式给出不同方案选项
|
|
|
+ const schemeMap = {
|
|
|
+ 'fixed': [
|
|
|
+ { label: '早高峰', value: 'early_peak' },
|
|
|
+ { label: '晚高峰', value: 'evening_peak' },
|
|
|
+ { label: '平峰', value: 'normal' },
|
|
|
+ { label: '夜间', value: 'night' },
|
|
|
+ { label: '周末', value: 'weekend' },
|
|
|
+ ],
|
|
|
+ 'sensor': [
|
|
|
+ { label: '全感应', value: 'full_actuated' },
|
|
|
+ { label: '半感应', value: 'semi_actuated' },
|
|
|
+ ],
|
|
|
+ 'system': [
|
|
|
+ { label: '系统优化方案A', value: 'sys_a' },
|
|
|
+ { label: '系统优化方案B', value: 'sys_b' },
|
|
|
+ { label: '系统优化方案C', value: 'sys_c' },
|
|
|
+ ],
|
|
|
+ 'step': [
|
|
|
+ { label: '步进方案1', value: 'step_1' },
|
|
|
+ { label: '步进方案2', value: 'step_2' },
|
|
|
+ ],
|
|
|
+ 'temp': [
|
|
|
+ { label: '临时方案A', value: 'temp_a' },
|
|
|
+ { label: '临时方案B', value: 'temp_b' },
|
|
|
+ { label: '临时方案C', value: 'temp_c' },
|
|
|
+ ],
|
|
|
+ 'yellow_flash': [{ label: '黄闪默认', value: 'yf_default' }],
|
|
|
+ 'lights_off': [{ label: '关灯默认', value: 'lo_default' }],
|
|
|
+ }
|
|
|
+ const schemeOptions = schemeMap[currentMethod] || schemeMap['fixed']
|
|
|
+
|
|
|
+ // 相位差和协调时间基于 seed 稳定变化
|
|
|
+ const phaseDiff = (seed * 7) % 25
|
|
|
+ const coordTime = (seed * 13) % 60
|
|
|
+
|
|
|
+ return ok({
|
|
|
+ currentRoute: {
|
|
|
+ id, name: point ? point.name : id,
|
|
|
+ level: point?.isKey ? '一级' : '二级',
|
|
|
+ mode: currentMode,
|
|
|
+ time: cycleLength + 's',
|
|
|
+ mainVideo: pickVideo(seed),
|
|
|
+ cornerVideos: _makeCornerVideos(seed),
|
|
|
+ },
|
|
|
+ intersectionData: config,
|
|
|
+ phaseData,
|
|
|
+ cycleLength,
|
|
|
+ currentTime: Math.floor(Date.now() / 1000) % cycleLength,
|
|
|
+ phaseDiff,
|
|
|
+ coordTime,
|
|
|
+ stageList,
|
|
|
+ schemeOptions,
|
|
|
+ currentScheme: schemeOptions[0].value,
|
|
|
+ controlMode: currentMode,
|
|
|
+ controlMethodOptions: allMethods,
|
|
|
+ currentMethod,
|
|
|
+ locktimeOptions: [
|
|
|
+ { label: '30', value: 30 },
|
|
|
+ { label: '50', value: 50 },
|
|
|
+ { label: '100', value: 100 },
|
|
|
+ { label: '300', value: 300 },
|
|
|
+ ],
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/crossing/top-charts — 路口Tab顶部圆环图(动态波动)
|
|
|
+ */
|
|
|
+export async function apiGetCrossingTopCharts() {
|
|
|
+ await delay(150)
|
|
|
+ const sm = DB.deviceStatus.signalMachine
|
|
|
+ const baseOnline = sm.chartData[0].value
|
|
|
+ const total = baseOnline + sm.chartData[1].value
|
|
|
+ const online = _fluctuate(baseOnline, Math.ceil(total * 0.02))
|
|
|
+
|
|
|
+ const onlineChart = {
|
|
|
+ chartData: [
|
|
|
+ { name: '在线', value: online, color: '#4DF5F8' },
|
|
|
+ { name: '离线', value: total - online, color: '#FFD369' },
|
|
|
+ ],
|
|
|
+ centerTitle: Math.round(online / total * 100) + '%',
|
|
|
+ centerSubTitle: `${online}/${total}`,
|
|
|
+ }
|
|
|
+
|
|
|
+ const faultTotal = total - online
|
|
|
+ const comm = Math.floor(faultTotal * 0.26)
|
|
|
+ const det = Math.floor(faultTotal * 0.21)
|
|
|
+ const lamp = Math.floor(faultTotal * 0.38)
|
|
|
+ const conflict = faultTotal - comm - det - lamp
|
|
|
+
|
|
|
+ const faultChart = {
|
|
|
+ chartData: [
|
|
|
+ { name: '通信', value: comm, color: '#4DF5F8' },
|
|
|
+ { name: '检测器', value: det, color: '#FFA033' },
|
|
|
+ { name: '灯控', value: lamp, color: '#FFF587' },
|
|
|
+ { name: '冲突', value: conflict, color: '#FF4D4F' },
|
|
|
+ ],
|
|
|
+ centerTitle: Math.round(faultTotal / total * 100) + '%',
|
|
|
+ centerSubTitle: `${faultTotal}/${total}`,
|
|
|
+ }
|
|
|
+
|
|
|
+ return ok({ onlineChart, faultChart })
|
|
|
+}
|
|
|
+
|
|
|
+/** GET /api/overview/top-charts — 总览Tab顶部图表(动态) */
|
|
|
+export async function apiGetOverviewTopCharts() {
|
|
|
+ await delay(150)
|
|
|
+ const res = await apiGetDeviceStatus()
|
|
|
+ return ok({ onlineStatus: res.data, deviceStatus: res.data })
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/map/legend-config — 地图标注线路配置
|
|
|
+ */
|
|
|
+export async function apiGetMapLegendConfig() {
|
|
|
+ await delay(150)
|
|
|
+ const tzPoints = DB.points.filter(p => p.node && p.node.includes('通州'))
|
|
|
+ const lngs = tzPoints.map(p => p.lng), lats = tzPoints.map(p => p.lat)
|
|
|
+ const [minLng, maxLng] = [Math.min(...lngs), Math.max(...lngs)]
|
|
|
+ const [minLat, maxLat] = [Math.min(...lats), Math.max(...lats)]
|
|
|
+ const midLat = (minLat + maxLat) / 2
|
|
|
+ const hStep = (maxLat - minLat) / 5, vStep = (maxLng - minLng) / 8
|
|
|
+
|
|
|
+ return ok([
|
|
|
+ { name: '中心计划', start: [minLng, midLat + hStep], end: [maxLng, midLat + hStep], color: '#004CDE' },
|
|
|
+ { name: '干线协调', start: [minLng, midLat], end: [maxLng, midLat], color: '#13C373' },
|
|
|
+ { name: '勤务路线', start: [minLng, midLat - hStep], end: [maxLng, midLat - hStep], color: '#BC301D' },
|
|
|
+ { name: '定周期控制', start: [minLng + vStep, maxLat], end: [minLng + vStep, minLat], color: '#3296FA' },
|
|
|
+ { name: '感应控制', start: [minLng + vStep * 2, maxLat], end: [minLng + vStep * 2, minLat], color: '#FF864C' },
|
|
|
+ { name: '自适应控制', start: [minLng + vStep * 3, maxLat], end: [minLng + vStep * 3, minLat], color: '#9F6EFE' },
|
|
|
+ { name: '手动控制', start: [minLng + vStep * 4, maxLat], end: [minLng + vStep * 4, minLat], color: '#EB9F36' },
|
|
|
+ { name: '特殊控制', start: [minLng + vStep * 5, maxLat], end: [minLng + vStep * 5, minLat], color: '#A26218' },
|
|
|
+ { name: '离线', start: [minLng, maxLat - hStep * 0.3], end: [maxLng, maxLat - hStep * 0.3], color: '#7A7A7A' },
|
|
|
+ { name: '降级', start: [minLng, minLat + hStep * 0.3], end: [maxLng, minLat + hStep * 0.3], color: '#D9C13B' },
|
|
|
+ { name: '故障', start: [maxLng - vStep * 0.5, maxLat], end: [maxLng - vStep * 0.5, minLat], color: '#FF3938' },
|
|
|
+ ])
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * GET /api/devices/fault-status
|
|
|
+ * 设备故障统计(匹配 DeviceStatusTabs 组件的 statusData 格式)
|
|
|
+ * keys: signalMachineStatus, detectorStatus, trafficLightStatus
|
|
|
+ */
|
|
|
+export async function apiGetDeviceFaultStatus() {
|
|
|
+ await delay(200)
|
|
|
+ const sm = DB.deviceStatus.signalMachine
|
|
|
+ const dt = DB.deviceStatus.detector
|
|
|
+ const cam = DB.deviceStatus.camera
|
|
|
+
|
|
|
+ // 从在线数据推算故障数,每次波动
|
|
|
+ const smTotal = sm.chartData[0].value + sm.chartData[1].value
|
|
|
+ const smFault = _fluctuate(sm.chartData[1].value, 3)
|
|
|
+ const dtTotal = dt.chartData[0].value + dt.chartData[1].value
|
|
|
+ const dtFault = _fluctuate(dt.chartData[1].value, 5)
|
|
|
+ const camTotal = cam.chartData[0].value + cam.chartData[1].value
|
|
|
+ const camFault = _fluctuate(cam.chartData[1].value, 2)
|
|
|
+
|
|
|
+ return ok({
|
|
|
+ signalMachineStatus: {
|
|
|
+ centerTitle: Math.max(0, smFault) + '',
|
|
|
+ centerSubTitle: `${Math.max(0, smFault)}/${smTotal}`,
|
|
|
+ chartData: [
|
|
|
+ { name: '正常', value: Math.max(0, smTotal - smFault), color: '#A0E551' },
|
|
|
+ { name: '故障', value: Math.max(0, smFault), color: '#D03030' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ detectorStatus: {
|
|
|
+ centerTitle: Math.max(0, dtFault) + '',
|
|
|
+ centerSubTitle: `${Math.max(0, dtFault)}/${dtTotal}`,
|
|
|
+ chartData: [
|
|
|
+ { name: '通信故障', value: Math.max(0, Math.floor(dtFault * 0.6)), color: '#C6302B' },
|
|
|
+ { name: '数据异常', value: Math.max(0, dtFault - Math.floor(dtFault * 0.6)), color: '#faad14' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ trafficLightStatus: {
|
|
|
+ centerTitle: Math.max(0, camFault) + '',
|
|
|
+ centerSubTitle: `${Math.max(0, camFault)}/${camTotal}`,
|
|
|
+ chartData: [
|
|
|
+ { name: '红绿冲突', value: Math.max(0, Math.floor(camFault * 0.5)), color: '#C6302B' },
|
|
|
+ { name: '红灯故障', value: Math.max(0, camFault - Math.floor(camFault * 0.5)), color: '#8F1E1E' },
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ })
|
|
|
+}
|