feat(alarm): 设备报警监控报警提示升级与警灯交互优化 - 报警提示升级:后端新增报警派单完成接口 POST /api/worker-app/alarm-dispatches/:id/complete(供共享电工APP确认完成,归属校验403/不存在404/已完成幂等,status置1并写completed_at);GET /api/device-alarms 的 summary 由 severeIds 改为 activeSevereIds(grade=1 且无已完成派单的报警id,倒序100条,派单完成自动移出即报警取消)并新增轻量接口 GET /api/device-alarms/active-severe;前端新建全局悬浮立体警灯组件 AlarmSirenLight(红蓝双色3D警灯,挂载主布局所有页面可见,点击跳转报警监控页,30秒轮询activeSevereIds新id即时响+每3分钟重复响,Web Audio合成警笛700/900Hz交替,浏览器自动播放静默降级);index.vue 移除原10秒指示灯与880Hz beep、瘦身为纯列表页;警灯交互优化:警灯可鼠标拖动(位移>5px判为拖动不触发跳转)、位置经localStorage跨刷新记忆(默认右上角)、取消「重置报警提示」按钮;后端tsc与前端vue-tsc编译通过,接口实测派单完成排除逻辑生效,浏览器端到端验证通过(全局显示、点击跳转、拖动、刷新记忆、重置按钮无残留)

This commit is contained in:
wk authored and trae-solo-agent committed 2026-10-24 18:40:05 +08:00
1 parent 7ff2405634
commit 2d55947762
13 files changed
+676 -115

No files matched your search

@@ -0,0 +1,11 @@
# Checklist
- [x] 警灯容器支持鼠标拖动,拖动过程不触发页面跳转
- [x] 拖动松开后警灯停驻在当前位置
- [x] 拖动位置经 localStorage(`alarm-siren-pos`)记忆,刷新后警灯显示在拖动处
- [x] 首次进入(无记录)警灯显示默认右上角
- [x] 未拖动的点击警灯仍跳转 /device/alarm(不在该页时)
- [x] 「重置报警提示」按钮已移除(模板/函数/样式/tooltip 无残留)
- [x] 即时响/3 分钟重复响/警灯激活(is-on)等原有逻辑不受影响
- [x] 前端 vue-tsc 编译通过
- [x] 浏览器端到端验证通过(默认位置、拖动、刷新记忆、点击跳转、无重置按钮)
@@ -0,0 +1,46 @@
# 报警灯可拖动 + 取消重置报警提示 Spec
## Why
上一版升级(upgrade-alarm-monitor-alert)中悬浮立体警灯固定右上角、带「重置报警提示」按钮;用户希望警灯可鼠标自由拖动(位置跨刷新记忆),并取消重置报警提示按钮。
## What Changes
- **前端 [AlarmSirenLight.vue](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/components/AlarmSirenLight.vue)**:
- 警灯改为**可鼠标拖动**:鼠标按下拖动、松开停驻;初始默认位置为右上角(现 fixed top:120px/right:24px),拖动后位置经 `localStorage` 记忆(刷新浏览器后停在拖动处)。
- 拖动与点击跳转区分:拖动位移超过阈值(约 5px)视为拖动不跳转;未拖动视为点击,保留「不在 /device/alarm 则跳转」行为。
- **移除「重置报警提示」按钮**及其逻辑(resetAlarmPrompt/按钮模板/相关 tooltip);`notifiedIds` 已通知集合保留(去重即时响仍需),仅删除清空入口。
- 布局挂载(layouts/index.vue)、后端接口、触发模型、警笛逻辑均不变。
## Impact
- Affected specs: 设备报警监控报警提示升级(upgrade-alarm-monitor-alert)
- Affected code:
- [client/src/components/AlarmSirenLight.vue](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/components/AlarmSirenLight.vue)(拖动 + 位置记忆 + 移除重置按钮)
## 明确假设
1. **拖动范围**:任意位置均可停驻(fixed 定位,拖动后以 left/top 定位);跨页面由全局组件天然保持,刷新由 localStorage 恢复。
2. **localStorage key**:`alarm-siren-pos`,存 `{ left, top }`(px);读取异常或首次进入用默认右上角。
3. **点击语义**:警灯本体点击仍跳转 `/device/alarm`(若不在该页);拖动过程不触发跳转。
## ADDED Requirements
### Requirement: 报警灯可拖动
系统 SHALL 允许用户用鼠标拖动悬浮警灯到任意位置,位置跨刷新记忆。
#### Scenario: 拖动警灯
- **WHEN** 用户在任意页面按住警灯并移动鼠标
- **THEN** 警灯跟随鼠标移动,松开后停驻在当前位置,且不触发页面跳转
#### Scenario: 刷新后位置保持
- **WHEN** 用户刷新浏览器或重新登录
- **THEN** 警灯显示在上次拖动停留的位置;首次使用时显示在默认右上角
#### Scenario: 点击仍可跳转
- **WHEN** 用户点击警灯(未拖动),且当前不在 /device/alarm
- **THEN** 跳转到设备报警监控页面
## MODIFIED Requirements
### Requirement: 悬浮警灯位置与重置按钮(原「固定右上角 + 重置报警提示」)
原实现为 fixed 右上角(top:120px/right:24px)固定位置,警灯旁有「重置报警提示」按钮(清空已通知集合重新触发)。现修改为:警灯可拖动且位置跨刷新记忆;移除「重置报警提示」按钮及其清空逻辑(已通知集合仅在页面生命周期内累积去重)。
## REMOVED Requirements
### Requirement: 「重置报警提示」按钮
**Reason**: 用户明确取消该功能。
**Migration**: 删除按钮模板、`resetAlarmPrompt()` 函数与相关样式/tooltip;`notifiedIds` 集合与即时响去重逻辑保留,不再提供清空入口。
@@ -0,0 +1,15 @@
# Tasks
- [x] Task 1: 报警灯拖动 + 位置记忆 + 移除重置按钮(client/src/components/AlarmSirenLight.vue)
- 移除「重置报警提示」按钮模板、`resetAlarmPrompt()` 函数、相关 tooltip 与 `.siren-reset` 样式
- 拖动实现:警灯容器(.floating-siren)`position: fixed`,改为 left/top 定位;mousedown 记录起点与容器坐标 → mousemove(window 监听)更新 left/top → mouseup 移除监听并保存位置
- 拖动与点击区分:mousedown 至 mouseup 位移 ≤5px 视为点击(保留「不在 /device/alarm 则 router.push」),超过则视为拖动
- 位置记忆:localStorage key `alarm-siren-pos` 存 `{ left, top }`;onMounted 读取恢复,无记录或解析失败用默认右上角(换算为 left/top,约 right:24px 对应 left = viewportWidth - 警灯宽度 - 24px)
- 保留:30 秒轮询 activeSevereIds、即时响、3 分钟重复响、警灯激活(is-on)、AudioContext 懒创建与清理
- [x] Task 2: 编译 + 浏览器验证
- client/ `npx vue-tsc --noEmit` 通过
- 浏览器验证:登录后警灯默认右上角;拖动到新位置;刷新页面警灯停在拖动处;点击(未拖动)跳转 /device/alarm;无「重置报警提示」按钮残留
# Task Dependencies
- Task 2 依赖 Task 1
@@ -0,0 +1,17 @@
# Checklist
- [x] 后端报警派单完成接口 `POST /api/worker-app/alarm-dispatches/:id/complete`:归属校验(403)、不存在(404)、status 0→1 + completedAt 写入、已完成幂等
- [x] 后端 summary 返回 activeSevereIds(grade=1 且无 status=1 派单的报警 id 数组),接口实测为数组且不含已完成派单报警
- [x] 原 severeIds 字段已替换为 activeSevereIds(后端 + 前端类型同步,无残留引用)
- [x] 前端顶部旧「严重报警」指示灯已移除,替换为右上角固定悬浮立体警灯(红蓝双色旋转闪烁、CSS 3D 效果)
- [x] activeSevereIds 非空时悬浮警灯出现并旋转闪烁
- [x] 不在 `/device/alarm` 页面时点击警灯,跳转到设备报警监控页面
- [x] 严重报警派单完成后(status=1)报警移出 activeSevereIds,不再触发警灯/警笛,列表记录保留
- [x] 全部严重报警派单完成后警灯停止旋转、3 分钟重复响停止
- [x] 派单完成时该线路存在遗留未派单严重报警:继续/重新触发灯与声音(新入库遗留报警即时触发)
- [x] 「重置报警提示」按钮清空已通知集合,当前未派单完成的严重报警重新触发一次
- [x] 新未派单完成的严重报警即时播放合成警笛声(700Hz↔900Hz 高低音交替)
- [x] 存在未派单完成的严重报警时每 3 分钟重复响一次警笛
- [x] 3 分钟警笛定时器与 30 秒列表轮询在页面卸载时正确清理(无泄漏)
- [x] 后端 tsc 编译通过、前端 vue-tsc 编译通过
- [x] 浏览器端到端验证通过(警灯旋转、点击跳转、派单完成取消、遗留重新触发、重置、警笛触发)
@@ -0,0 +1,95 @@
# 设备报警监控报警提示升级 Spec
## Why
现有报警提示仅「严重报警亮 10 秒指示灯 + 880Hz 三声 beep」,与派单状态无关联,提示不够醒目;需升级为悬浮立体警灯样式、警车警报声,并与派单联动:同线路报警派单完成后取消提示,未派单完成则每 3 分钟重复报警声。
## What Changes
- **后端**:新增**报警派单完成接口** `POST /api/worker-app/alarm-dispatches/:id/complete`(供共享电工 APP 确认完成报警派单):校验派单归属当前电工、状态为未完成(status=0),更新 status→1 并写入 completed_at;已完成的重复调用幂等提示。
- **后端**:`GET /api/device-alarms` 的 summary 由 `severeIds`(全量 grade=1)改为 **`activeSevereIds`**:grade=1 且**无已完成派单(status=1)**的报警 id 集合(倒序取最近 100 条)。派单完成后报警自动移出集合即取消提示;未派单完成的严重报警始终在集合内持续触发。
- **前端**:替换现有顶部「严重报警」指示灯为**悬浮立体警灯**(红蓝双色旋转闪烁、CSS 3D 效果),固定悬浮页面右上角;存在未派单完成的严重报警时警灯旋转闪烁。
- **前端**:点击悬浮警灯时,若当前路由不是设备报警监控页(`/device/alarm`),直接 `router.push('/device/alarm')`。
- **前端**:报警声由 880Hz beep 改为 **Web Audio 合成警笛声**(高低音交替,如 700Hz↔900Hz,循环 2~3 轮)。
- **前端**:报警声触发策略改为「**即时响一次 + 每 3 分钟重复响一次**」:新严重报警即时响;之后只要存在未派单完成的严重报警,每 3 分钟重复响一次警笛,直到全部派单完成。新增独立 3 分钟定时器(现有 30 秒列表轮询保持不变)。
- **前端**:新增「重置报警提示」小按钮(悬浮警灯旁):清空已通知集合,使当前所有未派单完成的严重报警重新触发一次灯/声音。
## 与派单联动的报警取消/重新触发语义(用户确认)
- **派单完成** = 管理端发起报警派单(status=0)→ 共享电工到现场处理完成派单任务 → 电工在共享电工 APP 点击确认完成(调用本系统新增的完成接口,status→1 + completed_at)。
- **派单完成后报警取消**:严重报警派单完成后移出 activeSevereIds → 不再触发灯/声音,3 分钟重复响(若无其他活跃严重报警)停止。
- **有遗留严重报警才重新触发**:派单完成时若该线路仍存在未派单完成的严重报警(遗留),该遗留报警持续(重新)触发灯/声音;其后同线路新严重报警正常触发。
- **手动重置**:保留「重置报警提示」按钮,清空已通知集合后当前所有未派单完成的严重报警重新触发一次。
## Impact
- Affected specs: 设备报警监控(ICC 报警事件订阅)、共享电工 APP
- Affected code:
- [server/src/controller/device_alarm.controller.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/controller/device_alarm.controller.ts)(summary `severeIds` → `activeSevereIds`)
- [server/src/controller/worker_app.controller.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/controller/worker_app.controller.ts) + [server/src/service/worker_app.service.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/service/worker_app.service.ts)(报警派单完成接口)
- [client/src/views/device/alarm/index.vue](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/views/device/alarm/index.vue)(警灯替换、警笛、activeSevereIds 驱动、3 分钟定时器、点击跳转、重置按钮)
- [client/src/api/alarm.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/api/alarm.ts)(`DeviceAlarmSummary` 的 `severeIds` → `activeSevereIds`)
## 明确假设(需用户知悉)
1. **「派单完成」业务链路与判定**:确认动作在共享电工 APP 发起,本系统后端新增完成接口承接(status→1 + completed_at);`alarm_dispatch.status = 1` 即「派单完成」判定。共享电工 APP 端 UI 的确认按钮不在本次范围(由 APP 端对接本接口)。
2. **悬浮警灯位置** = 设备报警监控页面右上角固定悬浮(position: fixed),z-index 高于页面内容。
3. **警笛声**为合成警笛(高频交替),不受浏览器自动播放策略影响时正常发声;首次加载无用户交互仍遵循浏览器自动播放限制(静默降级只亮灯/旋转)。
4. **手动重置**作用于本地已通知集合(无后端持久化),刷新页面后自动重建。
## ADDED Requirements
### Requirement: 报警派单完成接口
系统 SHALL 提供共享电工 APP 确认完成报警派单的接口:校验派单归属当前电工且未完成,更新状态为已完成(status=1)并写入完成时间(completed_at)。
#### Scenario: 电工确认完成报警派单
- **WHEN** 共享电工 APP 调用 `POST /api/worker-app/alarm-dispatches/:id/complete`,且该派单归属当前电工、状态为未完成(status=0)
- **THEN** 派单 status 更新为 1、completed_at 写入当前时间,返回完成成功;管理端报警监控页轮询后该报警移出 activeSevereIds,不再触发灯/声音
#### Scenario: 归属校验与幂等
- **WHEN** 派单不归属当前电工,或派单不存在
- **THEN** 返回 403/404;派单已完成(status=1)时重复调用返回幂等提示(不重复计数、不报错)
### Requirement: 悬浮立体警灯
系统 SHALL 在设备报警监控页面右上角以固定悬浮方式展示立体警灯(红蓝双色旋转闪烁、CSS 3D 效果),替换现有「严重报警」指示灯;当存在未派单完成的严重报警时警灯旋转闪烁。
#### Scenario: 严重报警触发警灯
- **WHEN** activeSevereIds 非空(存在未派单完成的严重报警)
- **THEN** 悬浮警灯出现并旋转闪烁
#### Scenario: 点击警灯跳转页面
- **WHEN** 页面不在设备报警监控页时用户点击悬浮警灯
- **THEN** 跳转到 `/device/alarm` 并打开设备报警监控页面
### Requirement: 派单完成后报警取消与遗留重新触发
系统 SHALL 将「已完成派单(status=1)关联的报警」从触发集合中移除,实现报警取消;同线路未派单完成的严重报警持续触发,派单完成时若有遗留未派单严重报警则重新触发。
#### Scenario: 派单完成后报警取消
- **WHEN** 某严重报警的派单完成后(status=1)
- **THEN** 该报警移出 activeSevereIds,不再触发警灯旋转与警笛声;若无其他活跃严重报警,3 分钟重复响停止;KPI 统计不受影响(仅提示取消,列表记录保留)
#### Scenario: 遗留严重报警重新触发
- **WHEN** 派单完成时该线路仍存在未派单完成的严重报警
- **THEN** 遗留报警继续/重新触发灯与声音(新入库的遗留报警即时触发一次;已触发过的按 3 分钟重复响)
#### Scenario: 手动重置
- **WHEN** 用户点击「重置报警提示」按钮
- **THEN** 已通知集合清空,当前所有未派单完成的严重报警重新触发一次灯/声音
### Requirement: 警笛声与轮询策略
系统 SHALL 在出现未派单完成的严重报警时即时播放一次合成警笛声,并每 3 分钟重复播放一次(只要仍存在未派单完成的严重报警);报警声为 Web Audio 合成警笛(高低音交替)。
#### Scenario: 即时响
- **WHEN** 新未派单完成的严重报警出现
- **THEN** 立即播放一次警笛声,同时警灯旋转闪烁
#### Scenario: 3 分钟重复响
- **WHEN** 存在未派单完成的严重报警,且距上次响声满 3 分钟
- **THEN** 重复播放一次警笛声
#### Scenario: 全部派单完成后停止
- **WHEN** 所有严重报警均派单完成(activeSevereIds 为空)
- **THEN** 不再触发警笛声,警灯停止旋转
## MODIFIED Requirements
### Requirement: 严重报警提示(原「严重报警指示灯 + 880Hz beep」)
原实现为顶部 `alarm-light` 指示灯点亮 10 秒 + 880Hz×3 次 beep,触发扫描基于后端 summary.severeIds 全量 grade=1 报警。现修改为:悬浮立体警灯(替换指示灯,存在未派单完成的严重报警时持续旋转闪烁)+ 合成警笛声(即时 + 每 3 分钟重复);触发扫描改为 summary.activeSevereIds(过滤掉已派单完成的报警)。
## REMOVED Requirements
### Requirement: 880Hz beep、10 秒报警灯计时、全量 severeIds
**Reason**: 报警提示样式与声音升级为悬浮警灯与警笛声,10 秒指示灯逻辑不再适用;触发集合需排除已派单完成的报警,severeIds(全量 grade=1)语义不再满足。
**Migration**: 删除 `alarmLight` 10 秒 setTimeout 逻辑与 doBeep;保留 Web Audio 自动播放策略静默降级(resume 失败仅亮灯不发声);summary 字段 `severeIds` 改为 `activeSevereIds`(前端唯一使用方为 device/alarm/index.vue,同步迁移)。
@@ -0,0 +1,54 @@
# Tasks
- [x] Task 1: 后端报警派单完成接口(供共享电工 APP 确认完成)
- [worker_app.controller.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/controller/worker_app.controller.ts) 新增 `@Post("/alarm-dispatches/:id/complete")`,注入 AlarmDispatch 仓储
- [worker_app.service.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/service/worker_app.service.ts) 新增 `completeAlarmDispatch(workerId, id)`:
- 查派单:不存在 → 404;`workerId` 不归属当前电工 → 403
- status 已是 1 → 幂等提示「已完成」(不重复更新)
- 事务内更新 status→1、completedAt=new Date()
- 验证:curl 实测归属校验 / 完成更新 / 幂等(编译 + 接口)
- [x] Task 2: 后端 summary 改为 activeSevereIds(未派单完成的严重报警集合)
- [device_alarm.controller.ts](file:///F:/NewProject/鑫潼Git/smart_grid_iot/server/src/controller/device_alarm.controller.ts) `list()`:
- 查 `dispatchRepo.findAll({ where: { status: 1 }, attributes: ["alarmId"], raw: true })` 得已完成派单的 alarmId 集合
- `activeSevereIds` = grade=1、lineId 非空、id 不在已完成集合中的报警 id(alarmTime DESC limit 100)
- summary 返回 `activeSevereIds`(替换并删除原 `severeIds` 查询)
- 验证:接口返回 summary.activeSevereIds 为数组,且不含已完成派单的报警 id
- [x] Task 3: 前端悬浮立体警灯替换现有指示灯(样式独立可先行)
- [index.vue](file:///F:/NewProject/鑫潼Git/smart_grid_iot/client/src/views/device/alarm/index.vue) 删除 header-right 中现有 `.alarm-light` 指示灯(保留「订阅状态」按钮)
- 新增悬浮警灯:position: fixed 右上角 + z-index;CSS 3D 造型(警灯灯罩 + 红蓝双色灯芯 + 旋转/闪烁动画)
- 警灯可见/旋转条件:`activeSevereIds` 非空(由 Task 4 触发模型提供)
- 点击警灯:若 `route.path !== '/device/alarm'` 则 `router.push('/device/alarm')`
- 警灯旁加「重置报警提示」小按钮(tooltip:清空已通知集合,重新触发当前严重报警)
- [x] Task 4: 前端触发模型(依赖 Task 2 的 activeSevereIds)
- `scanSevereAlarms` 语义改为:新 id(不在已通知集合 `notifiedIds`)→ 即时触发一次;触发过的 id 追加进集合
- 警灯旋转条件:activeSevereIds 非空(而非「新触发」)
- 「重置报警提示」:清空 notifiedIds 后重新扫描当前 activeSevereIds 触发
- 30 秒列表轮询保持,每次同步 activeSevereIds;派单完成后报警移出集合即取消
- [x] Task 5: 警笛合成 + 即时响 + 3 分钟重复响(依赖 Task 4)
- 移除 doBeep(880Hz×3),新增 `playSiren(ctx)`:700Hz↔900Hz 高低音交替 2~3 轮(每轮 0.4s),gain 包络防爆音
- playBeep 逻辑保留(AudioContext 懒创建 + suspended resume + 静默降级),仅替换发声函数
- 新增 3 分钟定时器 `sirenTimer`(`setInterval` 180000ms):activeSevereIds 非空则播放警笛;onUnmounted 清理
- [x] Task 6: 编译验证
- 后端 `npx tsc --noEmit`(server/)
- 前端 `npx vue-tsc --noEmit`(client/)
- 接口实测:登录后 `GET /api/device-alarms` 返回 summary.activeSevereIds;完成接口归属/幂等实测
- [x] Task 7: 浏览器端到端验证
- 注入 grade=1 报警(无已完成派单)后:警灯出现并旋转(class/动画状态捕获)
- 点击警灯在非报警监控页跳转 `/device/alarm`
- 调用完成接口将该报警派单置 status=1 后:报警移出 activeSevereIds,警灯停止、重复响停止
- 该线路注入新严重报警:重新触发警灯/警笛
- 点击「重置报警提示」:当前未派单完成严重报警重新触发
- 3 分钟重复响逻辑(通过缩短定时器或代码检查确认)
# Task Dependencies
- Task 1、Task 2 相互独立可并行
- Task 3 样式部分不依赖 Task 2,可与 Task 1/2 并行;触发条件与 Task 4 联动
- Task 4 依赖 Task 2(activeSevereIds 数据源)
- Task 5 依赖 Task 4
- Task 6 / Task 7 依赖 Task 1-5
+6
View File
@@ -32,6 +32,8 @@ export interface DeviceAlarmSummary {
todayCount: number; todayCount: number;
gradeOneCount: number; gradeOneCount: number;
undispatchedCount: number; undispatchedCount: number;
// 活跃严重报警 id(grade=1 且未派单完成),不受分页筛选影响,供前端全局警灯/警笛触发
activeSevereIds: number[];
} }
export interface DeviceAlarmListResult { export interface DeviceAlarmListResult {
@@ -72,6 +74,10 @@ export const deviceAlarmApi = {
dispatch: (id: number, body: DeviceAlarmDispatchParams) => dispatch: (id: number, body: DeviceAlarmDispatchParams) =>
request.post<DeviceAlarmDispatchResult>(`/device-alarms/${id}/dispatch`, body), request.post<DeviceAlarmDispatchResult>(`/device-alarms/${id}/dispatch`, body),
// 活跃严重报警 id 轻量接口(供全局悬浮警灯轮询)
activeSevere: () =>
request.get<{ activeSevereIds: number[] }>('/device-alarms/active-severe'),
}; };
// ==================== 报警派单台账(Task7 使用) ==================== // ==================== 报警派单台账(Task7 使用) ====================
+371
View File
@@ -0,0 +1,371 @@
<template>
<!-- 全局悬浮立体警灯(活跃严重报警指示,登录后所有页面可见;点击跳转报警监控页,可拖拽调整位置) -->
<div
ref="sirenRef"
class="floating-siren"
:class="{ dragging: isDragging }"
@mousedown="onMouseDown"
>
<a-tooltip :title="activeSevereIds.length ? `存在 ${activeSevereIds.length} 条未派单严重报警,点击查看` : '无未派单严重报警'">
<div
class="siren-light"
:class="{ 'is-on': activeSevereIds.length > 0 }"
>
<span class="siren-dome"></span>
<span class="siren-beam"></span>
<span class="siren-bulb bulb-red"></span>
<span class="siren-bulb bulb-blue"></span>
</div>
</a-tooltip>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { deviceAlarmApi } from '@/api/alarm';
const router = useRouter();
const route = useRoute();
// 警灯容器 ref(用于读取尺寸与设置/保存位置)
const sirenRef = ref<HTMLDivElement | null>(null);
// 拖动中标志(用于 grabbing 光标样式)
const isDragging = ref(false);
// 当前活跃严重报警 id(grade=1 且无已完成派单),非空时警灯持续旋转显示
const activeSevereIds = ref<number[]>([]);
// 已即时触发过警笛的严重报警 id(用于去重,避免重复提醒)
const notifiedIds = ref<Set<number>>(new Set());
let audioCtx: AudioContext | null = null;
// 拖动状态:起始鼠标坐标、容器起始位置、是否已发生拖动(位移 >5px)
let dragging = false;
let moved = false;
let startX = 0;
let startY = 0;
let originLeft = 0;
let originTop = 0;
const POS_KEY = 'alarm-siren-pos';
// 扫描活跃严重报警 id:新出现且未提醒过的即时触发警笛
function scanActiveSevere(ids: number[]) {
const fresh = ids.filter((id) => !notifiedIds.value.has(id));
if (!fresh.length) return;
fresh.forEach((id) => notifiedIds.value.add(id));
playBeep();
}
// 轮询拉取活跃严重报警 id(轻量接口,不受分页/筛选影响)
async function fetchActiveSevere() {
try {
const res = await deviceAlarmApi.activeSevere();
const ids = (res.data?.activeSevereIds || []).map(Number);
activeSevereIds.value = ids;
scanActiveSevere(ids);
} catch {
// 拦截器已提示,静默忽略
}
}
// 警灯点击(未拖动时由 mouseup 触发):非本页时跳转到设备报警监控页
function goAlarmPage() {
if (route.path !== '/device/alarm') {
router.push('/device/alarm');
}
}
// 拖动开始:记录起始鼠标坐标与容器当前 left/top
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return;
const el = sirenRef.value;
if (!el) return;
dragging = true;
moved = false;
startX = e.clientX;
startY = e.clientY;
originLeft = el.offsetLeft;
originTop = el.offsetTop;
isDragging.value = true;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
// 拖动中:容器跟随鼠标移动(起始位置 + 当前位移),位移超过 5px 视为拖动;限制不越出视口
function onMouseMove(e: MouseEvent) {
if (!dragging) return;
const el = sirenRef.value;
if (!el) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) moved = true;
if (!moved) return;
const maxLeft = window.innerWidth - el.offsetWidth;
const maxTop = window.innerHeight - el.offsetHeight;
el.style.left = `${Math.min(Math.max(originLeft + dx, 0), maxLeft)}px`;
el.style.top = `${Math.min(Math.max(originTop + dy, 0), maxTop)}px`;
}
// 拖动结束:移除监听;未拖动(位移 ≤5px)视为点击跳转,已拖动则保存位置到 localStorage
function onMouseUp() {
if (!dragging) return;
dragging = false;
isDragging.value = false;
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
const el = sirenRef.value;
if (moved) {
if (!el) return;
try {
localStorage.setItem(POS_KEY, JSON.stringify({ left: el.offsetLeft, top: el.offsetTop }));
} catch {
// 存储失败静默忽略
}
return;
}
goAlarmPage();
}
// 恢复警灯位置:优先读取本地记录(数值合法且不越界则应用),无记录/解析失败用默认右上角
function restorePosition() {
const el = sirenRef.value;
if (!el) return;
let left = window.innerWidth - el.offsetWidth - 24;
let top = 120;
try {
const raw = localStorage.getItem(POS_KEY);
if (raw) {
const saved = JSON.parse(raw);
const maxLeft = window.innerWidth - el.offsetWidth;
const maxTop = window.innerHeight - el.offsetHeight;
if (
typeof saved?.left === 'number' &&
typeof saved?.top === 'number' &&
Number.isFinite(saved.left) &&
Number.isFinite(saved.top) &&
saved.left >= 0 &&
saved.left <= maxLeft &&
saved.top >= 0 &&
saved.top <= maxTop
) {
left = saved.left;
top = saved.top;
}
}
} catch {
// 解析失败走默认右上角
}
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
function playBeep() {
try {
const Ctx = window.AudioContext || (window as any).webkitAudioContext;
if (!Ctx) return;
if (!audioCtx) audioCtx = new Ctx();
const ctx = audioCtx;
// 浏览器自动播放策略:处于 suspended 时尝试 resume,失败则静默降级(警灯不受影响)
if (ctx.state === 'suspended') {
ctx.resume().then(() => playSiren(ctx)).catch(() => { /* 静默降级 */ });
} else {
playSiren(ctx);
}
} catch {
// 静默降级:不阻塞页面
}
}
// 警笛合成:700Hz↔900Hz 高低音交替 3 轮(每轮 0.4s 切换频率,共约 2.4s),gain 包络防爆音
function playSiren(ctx: AudioContext) {
const now = ctx.currentTime;
const FREQS = [700, 900];
const ROUNDS = 3;
for (let i = 0; i < ROUNDS * 2; i++) {
const start = now + i * 0.4;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = FREQS[i % 2];
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.3, start + 0.02);
gain.gain.setValueAtTime(0.3, start + 0.15);
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + 0.4);
}
}
// 轮询(30 秒)与警笛重复提醒(3 分钟)
let pollTimer: number | undefined;
let sirenTimer: number | undefined;
onMounted(() => {
restorePosition();
fetchActiveSevere();
pollTimer = window.setInterval(fetchActiveSevere, 30000);
// 存在活跃严重报警时每 3 分钟重复播放一次警笛提醒
sirenTimer = window.setInterval(() => {
if (activeSevereIds.value.length > 0) playBeep();
}, 180000);
});
onUnmounted(() => {
if (pollTimer) window.clearInterval(pollTimer);
if (sirenTimer) window.clearInterval(sirenTimer);
audioCtx?.close().catch(() => {});
});
</script>
<style scoped>
/* 悬浮立体警灯(活跃严重报警指示),全局固定定位覆盖其他页面内容 */
.floating-siren {
position: fixed;
left: 0;
top: 120px;
z-index: 1000;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
cursor: grab;
}
/* 拖动中的光标样式(含警灯主体,覆盖其 pointer 光标) */
.floating-siren.dragging,
.floating-siren.dragging .siren-light {
cursor: grabbing;
}
/* 警灯主体:半球灯罩 + 旋转光束 + 红蓝双灯芯 */
.siren-light {
position: relative;
width: 56px;
height: 56px;
border-radius: 50%;
background:
radial-gradient(circle at 32% 24%, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0.08) 40%, transparent 48%),
linear-gradient(160deg, #334155 0%, #0f172a 78%);
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.28),
inset 0 -6px 12px rgba(0, 0, 0, 0.55),
0 10px 22px rgba(15, 23, 42, 0.35);
cursor: pointer;
overflow: hidden;
user-select: none;
transition: box-shadow 0.3s ease;
}
.siren-light:hover {
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.3),
inset 0 -6px 12px rgba(0, 0, 0, 0.55),
0 12px 26px rgba(15, 23, 42, 0.45);
}
/* 灯罩高光:模拟玻璃半球顶部反光 */
.siren-dome {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.35) 0%, transparent 40%);
pointer-events: none;
z-index: 2;
}
/* 旋转光束:红蓝扇形交替扫光,模拟警灯旋转 */
.siren-beam {
position: absolute;
inset: -10px;
border-radius: 50%;
background: conic-gradient(
from 0deg,
rgba(255, 77, 79, 0.5) 0deg 45deg,
transparent 45deg 135deg,
rgba(22, 119, 255, 0.5) 135deg 180deg,
transparent 180deg 360deg
);
opacity: 0;
transition: opacity 0.3s ease;
}
/* 红蓝双灯芯 */
.siren-bulb {
position: absolute;
top: 50%;
width: 15px;
height: 15px;
border-radius: 50%;
transform: translateY(-50%);
opacity: 0.3;
z-index: 1;
}
.bulb-red {
left: 9px;
background: radial-gradient(circle at 35% 30%, #fff, #ff4d4f 55%, #a8071a);
}
.bulb-blue {
right: 9px;
background: radial-gradient(circle at 35% 30%, #fff, #1677ff 55%, #003eb3);
}
/* 激活状态:红蓝交替闪烁 + 光束旋转 + 整体内发光 */
.siren-light.is-on {
box-shadow:
inset 0 2px 6px rgba(255, 255, 255, 0.3),
inset 0 -6px 12px rgba(0, 0, 0, 0.5),
0 0 16px rgba(255, 77, 79, 0.55),
0 10px 22px rgba(15, 23, 42, 0.4);
}
.siren-light.is-on .siren-beam {
opacity: 0.9;
animation: siren-rotate 1.2s linear infinite;
}
.siren-light.is-on .bulb-red {
animation: siren-blink-red 1s ease-in-out infinite;
}
.siren-light.is-on .bulb-blue {
animation: siren-blink-blue 1s ease-in-out infinite;
}
@keyframes siren-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes siren-blink-red {
0%,
45% {
opacity: 1;
box-shadow: 0 0 12px 3px rgba(255, 77, 79, 0.9);
}
55%,
100% {
opacity: 0.15;
box-shadow: none;
}
}
@keyframes siren-blink-blue {
0%,
45% {
opacity: 0.15;
box-shadow: none;
}
55%,
100% {
opacity: 1;
box-shadow: 0 0 12px 3px rgba(22, 119, 255, 0.9);
}
}
</style>
+4
View File
@@ -125,6 +125,9 @@
</a-spin> </a-spin>
</div> </div>
</a-drawer> </a-drawer>
<!-- 全局悬浮警灯(登录后所有页面共享,活跃严重报警提示) -->
<AlarmSirenLight />
</a-layout> </a-layout>
</template> </template>
@@ -157,6 +160,7 @@ import {
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import { usePermissionStore, type MenuNode } from '@/stores/permission' import { usePermissionStore, type MenuNode } from '@/stores/permission'
import { noticeApi, type NoticeInfo } from '@/api/notice' import { noticeApi, type NoticeInfo } from '@/api/notice'
import AlarmSirenLight from '@/components/AlarmSirenLight.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
+1 -114
View File
@@ -19,10 +19,6 @@
<p class="desc">实时监控接入线路的设备报警与事件,支持按客户、等级检索并快速发起报警派单。</p> <p class="desc">实时监控接入线路的设备报警与事件,支持按客户、等级检索并快速发起报警派单。</p>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="alarm-light" :class="{ active: alarmLight }" title="严重报警指示">
<span class="alarm-dot"></span>
<span>严重报警</span>
</div>
<a-button size="small" @click="handleShowSubStatus"> <a-button size="small" @click="handleShowSubStatus">
<template #icon><ApiOutlined /></template> <template #icon><ApiOutlined /></template>
订阅状态 订阅状态
@@ -357,66 +353,6 @@ async function fetchWorkerOptions() {
} }
} }
// ==================== 严重报警灯 + 声音 ====================
const notifiedSevereIds = ref<Set<number>>(new Set());
const alarmLight = ref(false);
let alarmLightTimer: number | undefined;
let audioCtx: AudioContext | null = null;
function scanSevereAlarms(list: DeviceAlarmRow[]) {
const fresh = list.filter((r) => r.alarmGrade === 1 && !notifiedSevereIds.value.has(r.id));
if (!fresh.length) return;
fresh.forEach((r) => notifiedSevereIds.value.add(r.id));
triggerAlarmAlert();
}
function triggerAlarmAlert() {
// 报警灯:点亮并保持 10 秒
alarmLight.value = true;
if (alarmLightTimer) window.clearTimeout(alarmLightTimer);
alarmLightTimer = window.setTimeout(() => {
alarmLight.value = false;
}, 10000);
// 声音:Web Audio 生成 880Hz 蜂鸣(3 次,每次 0.2s,间隔 0.1s)
playBeep();
}
function playBeep() {
try {
const Ctx = window.AudioContext || (window as any).webkitAudioContext;
if (!Ctx) return;
if (!audioCtx) audioCtx = new Ctx();
const ctx = audioCtx;
// 浏览器自动播放策略:处于 suspended 时尝试 resume,失败则静默降级只亮灯
if (ctx.state === 'suspended') {
ctx.resume().then(() => doBeep(ctx)).catch(() => { /* 静默降级 */ });
} else {
doBeep(ctx);
}
} catch {
// 静默降级:只亮灯,不阻塞
}
}
function doBeep(ctx: AudioContext) {
const now = ctx.currentTime;
for (let i = 0; i < 3; i++) {
const start = now + i * 0.3;
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = 880;
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.3, start + 0.02);
gain.gain.setValueAtTime(0.3, start + 0.15);
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.2);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + 0.2);
}
}
// ==================== 订阅状态 ==================== // ==================== 订阅状态 ====================
const subStatusVisible = ref(false); const subStatusVisible = ref(false);
const subStatusLoading = ref(false); const subStatusLoading = ref(false);
@@ -449,7 +385,6 @@ async function fetchList() {
summary.gradeOneCount = s.gradeOneCount ?? 0; summary.gradeOneCount = s.gradeOneCount ?? 0;
summary.undispatchedCount = s.undispatchedCount ?? 0; summary.undispatchedCount = s.undispatchedCount ?? 0;
} }
scanSevereAlarms(dataSource.value);
} catch { } catch {
// 拦截器已提示 // 拦截器已提示
} finally { } finally {
@@ -472,7 +407,7 @@ async function fetchCustomerOptions() {
} }
} }
// ==================== 轮询(30 秒) ==================== // ==================== 轮询(30 秒,仅刷新列表) ====================
let pollTimer: number | undefined; let pollTimer: number | undefined;
function startPolling() { function startPolling() {
@@ -490,8 +425,6 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
if (pollTimer) window.clearInterval(pollTimer); if (pollTimer) window.clearInterval(pollTimer);
if (alarmLightTimer) window.clearTimeout(alarmLightTimer);
audioCtx?.close().catch(() => {});
}); });
// ==================== 筛选与分页 ==================== // ==================== 筛选与分页 ====================
@@ -663,52 +596,6 @@ function formatTime(val?: string) {
gap: 10px; gap: 10px;
} }
/* 严重报警灯 */
.alarm-light {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 9999px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
font-size: 12px;
font-weight: 600;
color: #64748b;
user-select: none;
}
.alarm-light .alarm-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #cbd5e1;
flex-shrink: 0;
}
.alarm-light.active {
background: #fef2f2;
border-color: #fecaca;
color: #dc2626;
}
.alarm-light.active .alarm-dot {
background: #dc2626;
animation: alarm-blink 1s ease-in-out infinite;
}
@keyframes alarm-blink {
0%,
100% {
opacity: 1;
box-shadow: 0 0 0 0 rgba(220, 38, 38, 0.5);
}
50% {
opacity: 0.3;
box-shadow: 0 0 0 6px rgba(220, 38, 38, 0);
}
}
/* 统计卡片 */ /* 统计卡片 */
.kpi-section { .kpi-section {
flex-shrink: 0; flex-shrink: 0;
@@ -58,10 +58,13 @@ export class DeviceAlarmController {
const d = new Date(new Date().getTime() + 8 * 60 * 60 * 1000); const d = new Date(new Date().getTime() + 8 * 60 * 60 * 1000);
const today = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; const today = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
const todayStart = new Date(`${today}T00:00:00+08:00`); const todayStart = new Date(`${today}T00:00:00+08:00`);
// 统计:今日报警数(alarm_time >= 当天0点)/ 严重报警数(grade=1)
const [todayCount, gradeOneCount] = await Promise.all([ const [todayCount, gradeOneCount] = await Promise.all([
this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmTime: { [Op.gte]: todayStart } } }), this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmTime: { [Op.gte]: todayStart } } }),
this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmGrade: 1 } }) this.alarmRepo.count({ where: { lineId: { [Op.ne]: null }, alarmGrade: 1 } })
]); ]);
// 活跃严重报警 id 集合(grade=1 且无已完成派单,按报警时间倒序取最近 100 条,供前端全局报警灯/声音触发,不受分页筛选影响)
const activeSevereIds = await this.getActiveSevereIds();
const dispatchedAlarms = await this.dispatchRepo.findAll({ where: { status: 0 }, attributes: ["alarmId"], raw: true }); const dispatchedAlarms = await this.dispatchRepo.findAll({ where: { status: 0 }, attributes: ["alarmId"], raw: true });
const dispatchedIds = [...new Set(dispatchedAlarms.map((r: any) => Number(r.alarmId)))]; const dispatchedIds = [...new Set(dispatchedAlarms.map((r: any) => Number(r.alarmId)))];
const undispatchedCount = await this.alarmRepo.count({ const undispatchedCount = await this.alarmRepo.count({
@@ -79,11 +82,40 @@ export class DeviceAlarmController {
total: count, total: count,
page, page,
pageSize, pageSize,
summary: { todayCount, gradeOneCount, undispatchedCount } summary: { todayCount, gradeOneCount, undispatchedCount, activeSevereIds }
} }
}; };
} }
/** 活跃严重报警 id 轻量接口(供全局悬浮警灯轮询,rbac 前缀权限 alarm:list 已覆盖) */
@Get("/active-severe") async activeSevere() {
const activeSevereIds = await this.getActiveSevereIds();
return {
code: 200,
message: "获取活跃严重报警成功",
data: { activeSevereIds }
};
}
// 活跃严重报警 id 集合:grade=1、lineId 非空、且无 status=1 已完成派单的报警,
// 按报警时间倒序取最近 100 条,供前端全局报警灯/声音触发,不受分页筛选影响
private async getActiveSevereIds(): Promise<number[]> {
const completedAlarms = await this.dispatchRepo.findAll({ where: { status: 1 }, attributes: ["alarmId"], raw: true });
const completedAlarmIds = [...new Set(completedAlarms.map((r: any) => Number(r.alarmId)))];
const activeSevereAlarms = await this.alarmRepo.findAll({
where: {
lineId: { [Op.ne]: null },
alarmGrade: 1,
id: completedAlarmIds.length ? { [Op.notIn]: completedAlarmIds } : { [Op.ne]: null }
},
attributes: ["id"],
order: [["alarmTime", "DESC"]],
limit: 100,
raw: true
});
return activeSevereAlarms.map((r: any) => Number(r.id));
}
/** 发起派单:校验报警/未完成派单/电工,生成派单号,写入派单并给电工发站内通知 */ /** 发起派单:校验报警/未完成派单/电工,生成派单号,写入派单并给电工发站内通知 */
@Post("/:id/dispatch") async dispatch(@Param("id") id: string, @Body() body: any) { @Post("/:id/dispatch") async dispatch(@Param("id") id: string, @Body() body: any) {
const alarmId = Number(id); const alarmId = Number(id);
@@ -150,6 +150,12 @@ export class WorkerAppController {
return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id)); return this.workerAppService.completeOrder(this.currentWorkerId(), source, Number(id));
} }
// 报警派单完成确认
@Post("/alarm-dispatches/:id/complete")
async completeAlarmDispatch(@Param("id") id: string) {
return this.workerAppService.completeAlarmDispatch(this.currentWorkerId(), Number(id));
}
// 勘察明细维护(仅勘察单):全量替换,含建筑/楼层/房间与现场照片 // 勘察明细维护(仅勘察单):全量替换,含建筑/楼层/房间与现场照片
@Put("/orders/:source/:id/details") @Put("/orders/:source/:id/details")
async saveDetails(@Param("source") source: string, @Param("id") id: string, @Body() body: any) { async saveDetails(@Param("source") source: string, @Param("id") id: string, @Body() body: any) {
+17
View File
@@ -8,6 +8,7 @@ import WorkerCert from "../entity/worker_cert.entity";
import SysNotice from "../entity/sys_notice.entity"; import SysNotice from "../entity/sys_notice.entity";
import RepairRequest from "../entity/repair_request.entity"; import RepairRequest from "../entity/repair_request.entity";
import RepairDispatch from "../entity/repair_dispatch.entity"; import RepairDispatch from "../entity/repair_dispatch.entity";
import AlarmDispatch from "../entity/alarm_dispatch.entity";
import InstallOrder from "../entity/install_order.entity"; import InstallOrder from "../entity/install_order.entity";
import MaintenanceOrder from "../entity/maintenance_order.entity"; import MaintenanceOrder from "../entity/maintenance_order.entity";
import SurveyOrder from "../entity/survey_order.entity"; import SurveyOrder from "../entity/survey_order.entity";
@@ -63,6 +64,9 @@ export class WorkerAppService {
@InjectRepository(RepairDispatch) @InjectRepository(RepairDispatch)
dispatchModel!: Repository<RepairDispatch>; dispatchModel!: Repository<RepairDispatch>;
@InjectRepository(AlarmDispatch)
alarmDispatchModel!: Repository<AlarmDispatch>;
@InjectRepository(InstallOrder) @InjectRepository(InstallOrder)
installModel!: Repository<InstallOrder>; installModel!: Repository<InstallOrder>;
@@ -503,6 +507,19 @@ export class WorkerAppService {
return { code: 200, message: "已完成" }; return { code: 200, message: "已完成" };
} }
// 报警派单完成确认:校验归属(不属于当前电工返回 403),幂等(已处理不重复更新)
async completeAlarmDispatch(workerId: number, id: number): Promise<any> {
const dispatch = await this.alarmDispatchModel.findByPk(id);
if (!dispatch) return { code: 404, message: "派单不存在" };
if (Number(dispatch.workerId) !== Number(workerId)) return { code: 403, message: "无权操作该派单" };
if (Number(dispatch.status) === 1) return { code: 200, message: "该派单已完成" };
await this.sequelize.transaction(async t => {
await dispatch.update({ status: 1, completedAt: new Date() }, { transaction: t });
});
return { code: 200, message: "报警派单完成确认成功" };
}
// ================= 收益汇总 ================= // ================= 收益汇总 =================
// 收益汇总:累计/可提现/本月/近 7 日趋势/最近 20 条流水 // 收益汇总:累计/可提现/本月/近 7 日趋势/最近 20 条流水