Browse Source

新增数据分析页面

- 新增 src/views/analysis/index.vue:处理时效与积分排行左右一行、分类/级别分布(echarts)、月度运营报告+导出

- 新增 src/api/analysis/index.js:stats/timeliness/ranking/report 相关接口封装

- 修正 report/monthly 与 /export 需必填 year(文档未写),补年份选择

- 按甲方调整:去掉隐患热力图与顶部筛选列

- 同步更新 PAGES_TODO / CURRENT_STATUS / DEVELOPMENT_LOG;提交前 eslint 与 build:prod 通过
master
DU 4 days ago
parent
commit
b76a59cddf
  1. 1
      docs/development/CURRENT_STATUS.md
  2. 11
      docs/development/DEVELOPMENT_LOG.md
  3. 6
      docs/development/PAGES_TODO.md
  4. 46
      src/api/analysis/index.js
  5. 365
      src/views/analysis/index.vue

1
docs/development/CURRENT_STATUS.md

@ -40,6 +40,7 @@
| 积分管理 | `src/views/points/management/index.vue` | `points/management/index` | 已完成积分规则 Tab、统一 Card 列表、厂区列、分页、详情、新增、启停和删除;超管可筛选/选择厂区,厂区管理员可管理本厂区规则。**流水冲正 Tab 已开发(2026-10-15,待测试)**;申诉处理 Tab 已启用(复用独立「申诉台账」页同一组件 `AppealLedgerPanel`);手动调整 Tab 因 `balance/adjust` 接口定义不完整暂缓 | | 积分管理 | `src/views/points/management/index.vue` | `points/management/index` | 已完成积分规则 Tab、统一 Card 列表、厂区列、分页、详情、新增、启停和删除;超管可筛选/选择厂区,厂区管理员可管理本厂区规则。**流水冲正 Tab 已开发(2026-10-15,待测试)**;申诉处理 Tab 已启用(复用独立「申诉台账」页同一组件 `AppealLedgerPanel`);手动调整 Tab 因 `balance/adjust` 接口定义不完整暂缓 |
| 申诉台账 | `src/views/points/appeal/index.vue` | `points/appeal/index` | 已开发(2026-10-15),待测试。独立页(抄隐患台账 + 审批):列表 `appeal/todoList`、详情 `appeal/detail/{id}`、审批 `appeal/completeFlow`(taskId 由 `flowable/busiInfo/getProcessDetail` 补全)。待联调确认:completeFlow 的 taskId 来源、改判补分 `adjustPoints` 传参 | | 申诉台账 | `src/views/points/appeal/index.vue` | `points/appeal/index` | 已开发(2026-10-15),待测试。独立页(抄隐患台账 + 审批):列表 `appeal/todoList`、详情 `appeal/detail/{id}`、审批 `appeal/completeFlow`(taskId 由 `flowable/busiInfo/getProcessDetail` 补全)。待联调确认:completeFlow 的 taskId 来源、改判补分 `adjustPoints` 传参 |
| 安全兑换管理 | `src/views/exchange/security/index.vue` | `exchange/security/index` | 已开发(2026-10-15),待测试。安全校验总开关 + 兑换限额 + 预警与大额审批 + 防刷配置;接口 `exchange/config/*`、`anti/farm/*`。“校验配置”为前端本地一致性校验 | | 安全兑换管理 | `src/views/exchange/security/index.vue` | `exchange/security/index` | 已开发(2026-10-15),待测试。安全校验总开关 + 兑换限额 + 预警与大额审批 + 防刷配置;接口 `exchange/config/*`、`anti/farm/*`。“校验配置”为前端本地一致性校验 |
| 数据分析 | `src/views/analysis/index.vue` | `analysis/index` | 已开发(2026-10-15),待测试。处理时效(按级别)与积分排行(积分/上报/兑换)左右一行;分类/级别分布用 echarts;月度运营报告 + 导出。按甲方要求无隐患热力图、无顶部筛选列;“超期未处理表”无接口未做 |
| 隐患流程配置 | `src/views/flowable/hazardConfig/index.vue` | `flowable/hazardConfig/index` | 已完成列表、筛选、分页、新增、修改、状态 Tag、删除和厂区权限展示;新增“流程类型”字段(`bizType`,字典 `flow_biz_type`)并在列表以 Tag 展示;分页按真实 `data.list/data.total` 解析 | | 隐患流程配置 | `src/views/flowable/hazardConfig/index.vue` | `flowable/hazardConfig/index` | 已完成列表、筛选、分页、新增、修改、状态 Tag、删除和厂区权限展示;新增“流程类型”字段(`bizType`,字典 `flow_biz_type`)并在列表以 Tag 展示;分页按真实 `data.list/data.total` 解析 |
| 切换日志 | `src/views/flowable/switchLog/index.vue` | `flowable/switchLog/index` | 空白页面,无明确列表接口 | | 切换日志 | `src/views/flowable/switchLog/index.vue` | `flowable/switchLog/index` | 空白页面,无明确列表接口 |

11
docs/development/DEVELOPMENT_LOG.md

@ -1,5 +1,16 @@
# 开发日志 # 开发日志
## 2026-10-15:新增 Web 数据分析页面(W6)
- 新增 `src/views/analysis/index.vue` + `src/api/analysis/index.js`。
- 布局:处理时效(按级别)与积分排行(积分/上报/兑换三 Tab)左右一行;隐患分类分布(echarts 横向柱状)、隐患级别分布(echarts 饼图);底部月度安全运营报告(月份选择 + 导出)。
- 接口:`analysis/stats/category`·`stats/level`·`timeliness/level`·`ranking/points|report|exchange`·`report/monthly`(+`/export`)。
- 接口修正:`report/monthly` 与 `/export` 实际还需**必填参数 `year`**(api.json 文档未写,联调报错 500 `Required request parameter 'year' ... is not present`);已补 `year` 并新增年份选择(默认当前年)。
- 按甲方调整:**去掉隐患热力图、去掉顶部筛选列**;“处理时效漏斗”与“积分排行”改为左右布局占一行。
- 说明:原型“超期未处理表”无对应接口,未实现;导出用 `file-saver` + `blobValidate` 处理。
- 菜单(需后端配置):一级「分析与风控」→「数据分析」,路由 `analysis/index`,组件 `analysis/index`(`/analysis/index`)。
- 验证:`eslint --no-ignore` 通过、`npm run build:prod` 通过(仅既有 warning)。
## 2026-10-15:新增 Web 安全兑换管理页面(W5) ## 2026-10-15:新增 Web 安全兑换管理页面(W5)
- 新增 `src/views/exchange/security/index.vue`:安全校验总开关(`switch/status` + `switch/toggle`)、兑换限额(每日/每月/每年/次数/单笔最小最大)、预警与大额审批(快速兑换阈值+窗口、大额阈值、大额审批开关)、防刷配置(上报/积分获取/兑换/自报自审)。 - 新增 `src/views/exchange/security/index.vue`:安全校验总开关(`switch/status` + `switch/toggle`)、兑换限额(每日/每月/每年/次数/单笔最小最大)、预警与大额审批(快速兑换阈值+窗口、大额阈值、大额审批开关)、防刷配置(上报/积分获取/兑换/自报自审)。

6
docs/development/PAGES_TODO.md

@ -39,7 +39,7 @@
| 15 | 积分与商城 | 商城运营 › 日常积分兑换管理 | ❌ | ⛔ | ⛔ | 同上 | | 15 | 积分与商城 | 商城运营 › 日常积分兑换管理 | ❌ | ⛔ | ⛔ | 同上 |
| 16 | 积分与商城 | 商城运营 › 日常积分兑换专区 | ❌ | ⛔ | ⛔ | 同上 | | 16 | 积分与商城 | 商城运营 › 日常积分兑换专区 | ❌ | ⛔ | ⛔ | 同上 |
| 17 | 安全兑换管理 | 安全兑换管理(总开关/限额/大额审批/防刷配置) | ⚠️ 待测试 | ✅ | ✅ | **已开发(2026-10-15)**:`src/views/exchange/security/index.vue` + `src/api/exchange/config.js`。接口 `exchange/config/active`·`switch/status`·`switch/toggle`·`add`·`updateById`、`anti/farm/active`·`updateById`。注:“校验配置”为前端本地一致性校验(无后端校验配置接口) | | 17 | 安全兑换管理 | 安全兑换管理(总开关/限额/大额审批/防刷配置) | ⚠️ 待测试 | ✅ | ✅ | **已开发(2026-10-15)**:`src/views/exchange/security/index.vue` + `src/api/exchange/config.js`。接口 `exchange/config/active`·`switch/status`·`switch/toggle`·`add`·`updateById`、`anti/farm/active`·`updateById`。注:“校验配置”为前端本地一致性校验(无后端校验配置接口) |
| 18 | 分析与风控 | 数据分析(时效漏斗/排行/分类分布/导出;**无热力图、无顶部筛选列**) | ❌ | ✅ | ✅ | 20 个接口齐全([数据分析](../api/modules/15-data-analysis.md));“超期未处理表”可用隐患待办列表拼接。**页面调整(2026-10-15 甲方)**:去掉隐患热力图、去掉顶部筛选列;“处理时效漏斗”与“积分排行”改为左右布局占一行 | | 18 | 分析与风控 | 数据分析(时效/排行/分类分布/级别分布/月度报告导出;无热力图、无顶部筛选) | ⚠️ 待测试 | ✅ | ✅ | **已开发(2026-10-15)**:`src/views/analysis/index.vue` + `src/api/analysis/index.js`。接口 `stats/category`·`stats/level`·`timeliness/level`·`ranking/points|report|exchange`·`report/monthly`(+`/export`)。“超期未处理表”无接口,暂未做 |
| 19 | 分析与风控 | 风控与审计(Tab1 风控预警 + Tab2 审计日志) | ❌ | ⚠️ | ⚠️ | 审计日志 ✅:`audit/log/getListByPage`、`audit/log/biz/{bizId}`([操作审计日志](../api/modules/16-audit-log.md));**风控预警列表 + 处置(放行/冻结 24h/黑名单)无接口**,Tab1 需等后端 | | 19 | 分析与风控 | 风控与审计(Tab1 风控预警 + Tab2 审计日志) | ❌ | ⚠️ | ⚠️ | 审计日志 ✅:`audit/log/getListByPage`、`audit/log/biz/{bizId}`([操作审计日志](../api/modules/16-audit-log.md));**风控预警列表 + 处置(放行/冻结 24h/黑名单)无接口**,Tab1 需等后端 |
| 20 | 流程管理 | 隐患流程配置 | ✅ | ✅ | — | [flowable/hazardConfig](../../src/views/flowable/hazardConfig/index.vue) | | 20 | 流程管理 | 隐患流程配置 | ✅ | ✅ | — | [flowable/hazardConfig](../../src/views/flowable/hazardConfig/index.vue) |
| 21 | 流程管理 | 隐患上报(流程发起) | ✅ | ✅ | — | [flowable/task/myProcess](../../src/views/flowable/task/myProcess) | | 21 | 流程管理 | 隐患上报(流程发起) | ✅ | ✅ | — | [flowable/task/myProcess](../../src/views/flowable/task/myProcess) |
@ -162,6 +162,8 @@
**A. 需后端确认(接口 / 字段 / 入参)** **A. 需后端确认(接口 / 字段 / 入参)**
- [x] `analysis/report/monthly`(+`/export`) 实际需**必填 `year`**(api.json 未写),已在 W6 补齐(`year` + `month`)。
- [ ] `completeFlow` 请求体结构:`taskId`、通过/驳回标识、`adjustPoints` 传参位置(每节点 or 结束节点)、审批意见字段名。 - [ ] `completeFlow` 请求体结构:`taskId`、通过/驳回标识、`adjustPoints` 传参位置(每节点 or 结束节点)、审批意见字段名。
> **W2 落地假设(2026-10-15,待联调确认)**:审批 body 采用 `{ taskId, instanceId, type: 'appeal', result: '1'|'2', comment }`;`taskId` 由 `flowable/busiInfo/getProcessDetail?id=flowInstanceId` 取当前待办任务补全(沿用隐患审核做法);**改判补分的 `adjustPoints` 暂放入 `variables`**——若后端实际从申诉记录字段读取,需改为“先 `appeal/updateById` 更新记录再 `completeFlow`”。 > **W2 落地假设(2026-10-15,待联调确认)**:审批 body 采用 `{ taskId, instanceId, type: 'appeal', result: '1'|'2', comment }`;`taskId` 由 `flowable/busiInfo/getProcessDetail?id=flowInstanceId` 取当前待办任务补全(沿用隐患审核做法);**改判补分的 `adjustPoints` 暂放入 `variables`**——若后端实际从申诉记录字段读取,需改为“先 `appeal/updateById` 更新记录再 `completeFlow`”。
@ -203,7 +205,7 @@
| W3 | Web | 积分管理 · 手动调整 Tab | `balance/adjust`、`balance/getByUserId/{userId}` | `src/views/points/management/index.vue` + `src/api/points/balance.js` | 高 | ⛔ 需确认 | **接口定义不完整**:`balance/adjust` 文档仅声明 `application/x-www-form-urlencoded` + 单个整数,缺 `userId`/`points`/原因字段结构,无法开工;**后端计划 2026-10-16 更新该接口并补参数**,更新后即可开发 | | W3 | Web | 积分管理 · 手动调整 Tab | `balance/adjust`、`balance/getByUserId/{userId}` | `src/views/points/management/index.vue` + `src/api/points/balance.js` | 高 | ⛔ 需确认 | **接口定义不完整**:`balance/adjust` 文档仅声明 `application/x-www-form-urlencoded` + 单个整数,缺 `userId`/`points`/原因字段结构,无法开工;**后端计划 2026-10-16 更新该接口并补参数**,更新后即可开发 |
| W4 | Web | 积分管理 · 流水冲正 Tab | `transaction/getListByPage`、`transaction/reverse/{logId}` | `src/views/points/management/components/TransactionReversePanel.vue` + `src/api/points/transaction.js` | 中 | ◐ 待测试 | **已开发(2026-10-15)**,待测试。积分管理第 4 个 Tab;仅 status=1(有效)可冲正,二次确认。流水无用户姓名字段,列表显示 userId | | W4 | Web | 积分管理 · 流水冲正 Tab | `transaction/getListByPage`、`transaction/reverse/{logId}` | `src/views/points/management/components/TransactionReversePanel.vue` + `src/api/points/transaction.js` | 中 | ◐ 待测试 | **已开发(2026-10-15)**,待测试。积分管理第 4 个 Tab;仅 status=1(有效)可冲正,二次确认。流水无用户姓名字段,列表显示 userId |
| W5 | Web | 安全兑换管理 | `exchange/config/active`·`switch/status`·`switch/toggle`·`add`·`updateById`、`anti/farm/active`·`updateById` | `src/views/exchange/security/index.vue` + `src/api/exchange/config.js` | 中 | ◐ 待测试 | **已开发(2026-10-15)**,待测试。菜单:一级「安全兑换管理」,路由 `exchange/security`,组件 `exchange/security/index`(`/exchange/security`) | | W5 | Web | 安全兑换管理 | `exchange/config/active`·`switch/status`·`switch/toggle`·`add`·`updateById`、`anti/farm/active`·`updateById` | `src/views/exchange/security/index.vue` + `src/api/exchange/config.js` | 中 | ◐ 待测试 | **已开发(2026-10-15)**,待测试。菜单:一级「安全兑换管理」,路由 `exchange/security`,组件 `exchange/security/index`(`/exchange/security`) |
| W6 | Web | 数据分析 | `analysis/stats/*`、`ranking/*`、`timeliness/level`、`report/monthly`(+`/export`)、`stats/category`·`stats/level` | `src/views/analysis/index.vue` + `src/api/analysis/index.js` | 中 | ☐ | **页面调整(2026-10-15)**:① 去掉隐患热力图(`heatmap/*` 接口暂不接);② 去掉顶部筛选列;③「处理时效漏斗」与「积分排行」改为左右布局占一行。图表用已装 `echarts` | | W6 | Web | 数据分析 | `analysis/stats/category`·`stats/level`·`timeliness/level`·`ranking/points|report|exchange`·`report/monthly`(+`/export`) | `src/views/analysis/index.vue` + `src/api/analysis/index.js` | 中 | ◐ 待测试 | **已开发(2026-10-15)**,待测试。菜单:一级「分析与风控」→「数据分析」,路由 `analysis/index`,组件 `analysis/index`(`/analysis/index`)。按调整:无热力图、无顶部筛选;时效与积分排行左右一行;分类/级别分布用 echarts;“超期未处理表”无接口未做 |
| A1 | APP | 设置(推送/声音/震动/关于/清缓存/退出) | 纯本地(AsyncStorage) | `src/screens/home/SettingsScreen.tsx` | 中 | ☐ | 参考原型 `scr-settings` | | A1 | APP | 设置(推送/声音/震动/关于/清缓存/退出) | 纯本地(AsyncStorage) | `src/screens/home/SettingsScreen.tsx` | 中 | ☐ | 参考原型 `scr-settings` |
| A2 | APP | 我的兑换 | `exchange/order/myList` | `src/screens/home/MyExchangeScreen.tsx` + `src/api/exchange.ts` | 中 | ☐ | 入口:我的 → 我的兑换 | | A2 | APP | 我的兑换 | `exchange/order/myList` | `src/screens/home/MyExchangeScreen.tsx` + `src/api/exchange.ts` | 中 | ☐ | 入口:我的 → 我的兑换 |
| A3 | APP | 通知提醒卡片 | `message/unread-count`、`message/list` | `src/components/NoticeReminderCard.tsx` | 中 | ☐ | 首页顶部条,点击跳 `Messages` | | A3 | APP | 通知提醒卡片 | `message/unread-count`、`message/list` | `src/components/NoticeReminderCard.tsx` | 中 | ☐ | 首页顶部条,点击跳 `Messages` |

46
src/api/analysis/index.js

@ -0,0 +1,46 @@
import request from '@/utils/request'
// 隐患统计-按分类(饼图/柱状)
export function getStatsCategory() {
return request({ url: '/business/points/analysis/stats/category', method: 'get' })
}
// 隐患统计-按级别
export function getStatsLevel() {
return request({ url: '/business/points/analysis/stats/level', method: 'get' })
}
// 处理时效-按级别(平均处理时长 / 已完成数)
export function getTimelinessByLevel() {
return request({ url: '/business/points/analysis/timeliness/level', method: 'get' })
}
// 积分排行榜-总入账
export function getRankingPoints() {
return request({ url: '/business/points/analysis/ranking/points', method: 'get' })
}
// 上报积分排行
export function getRankingReport() {
return request({ url: '/business/points/analysis/ranking/report', method: 'get' })
}
// 兑换排行统计(按单位)
export function getRankingExchange() {
return request({ url: '/business/points/analysis/ranking/exchange', method: 'get' })
}
// 月度安全运营报告(year 必填;month=0 全年,1~12 指定月份)
export function getMonthlyReport(params) {
return request({ url: '/business/points/analysis/report/monthly', method: 'get', params })
}
// 月度安全运营报告导出(Excel,直接下载;year 必填)
export function exportMonthlyReport(params) {
return request({
url: '/business/points/analysis/report/monthly/export',
method: 'get',
params,
responseType: 'blob'
})
}

365
src/views/analysis/index.vue

@ -0,0 +1,365 @@
<template>
<div v-loading="loading" class="app-container analysis-page">
<div class="two-col">
<!-- 处理时效 -->
<div class="panel">
<div class="panel-title">处理时效(按级别)</div>
<el-table :data="timeliness" size="mini" class="analysis-table">
<el-table-column label="级别" prop="levelName" min-width="120">
<template slot-scope="scope">{{ scope.row.levelName || '—' }}</template>
</el-table-column>
<el-table-column label="平均处理时长" width="140" align="center">
<template slot-scope="scope">{{ hoursText(scope.row.avgHours) }}</template>
</el-table-column>
<el-table-column label="已完成数" width="110" align="center">
<template slot-scope="scope">{{ num(scope.row.completedCount) }}</template>
</el-table-column>
</el-table>
<div v-if="!timeliness.length && !loading" class="panel-empty">暂无数据</div>
</div>
<!-- 积分排行 -->
<div class="panel">
<div class="panel-title">积分排行</div>
<el-tabs v-model="rankTab">
<el-tab-pane label="积分" name="points">
<el-table :data="rankPoints" size="mini" class="analysis-table">
<el-table-column label="#" width="60" align="center">
<template slot-scope="scope">{{ rankBadge(scope.$index) }}</template>
</el-table-column>
<el-table-column label="用户" prop="userName" min-width="120">
<template slot-scope="scope">{{ scope.row.userName || '—' }}</template>
</el-table-column>
<el-table-column label="总入账积分" prop="totalIncome" width="120" align="right" />
</el-table>
</el-tab-pane>
<el-tab-pane label="上报" name="report">
<el-table :data="rankReport" size="mini" class="analysis-table">
<el-table-column label="#" width="60" align="center">
<template slot-scope="scope">{{ rankBadge(scope.$index) }}</template>
</el-table-column>
<el-table-column label="用户" prop="userName" min-width="120">
<template slot-scope="scope">{{ scope.row.userName || '—' }}</template>
</el-table-column>
<el-table-column label="上报次数" prop="reportCount" width="120" align="right" />
</el-table>
</el-tab-pane>
<el-tab-pane label="兑换" name="exchange">
<el-table :data="rankExchange" size="mini" class="analysis-table">
<el-table-column label="#" width="60" align="center">
<template slot-scope="scope">{{ rankBadge(scope.$index) }}</template>
</el-table-column>
<el-table-column label="单位" prop="unitName" min-width="140">
<template slot-scope="scope">{{ scope.row.unitName || '—' }}</template>
</el-table-column>
<el-table-column label="兑换单数" prop="orderCount" width="100" align="right" />
<el-table-column label="消耗积分" prop="totalPoints" width="110" align="right" />
<el-table-column label="兑换人数" prop="userCount" width="100" align="right" />
</el-table>
</el-tab-pane>
</el-tabs>
</div>
</div>
<div class="two-col">
<div class="panel">
<div class="panel-title">隐患分类分布</div>
<div v-if="categoryList.length" ref="categoryChart" class="chart" />
<div v-else class="panel-empty">暂无数据</div>
</div>
<div class="panel">
<div class="panel-title">隐患级别分布</div>
<div v-if="levelList.length" ref="levelChart" class="chart" />
<div v-else class="panel-empty">暂无数据</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<div class="panel-title">月度安全运营报告</div>
<div class="panel-actions">
<el-select v-model="reportYear" size="mini" style="width:110px" @change="loadMonthlyReport">
<el-option v-for="y in yearOptions" :key="y" :label="`${y} 年`" :value="y" />
</el-select>
<el-select v-model="reportMonth" size="mini" style="width:120px" @change="loadMonthlyReport">
<el-option label="全年各月" :value="0" />
<el-option v-for="m in 12" :key="m" :label="`${m} 月`" :value="m" />
</el-select>
<el-button size="mini" type="primary" :loading="exporting" @click="handleExport">导出报告</el-button>
</div>
</div>
<el-table :data="monthlyReport" size="mini" class="analysis-table">
<el-table-column label="月份" width="80" align="center">
<template slot-scope="scope">{{ scope.row.month === 0 ? '全年合计' : `${scope.row.month} 月` }}</template>
</el-table-column>
<el-table-column label="隐患总数" prop="hazardCount" align="right" />
<el-table-column label="已通过" prop="approvedCount" align="right" />
<el-table-column label="已驳回" prop="rejectedCount" align="right" />
<el-table-column label="发放积分" prop="pointsIssued" align="right" />
<el-table-column label="平均处理时长" align="right">
<template slot-scope="scope">{{ hoursText(scope.row.avgProcessHours) }}</template>
</el-table-column>
<el-table-column label="准时率" align="right">
<template slot-scope="scope">{{ percent(scope.row.onTimeRate) }}</template>
</el-table-column>
<el-table-column label="兑换单数" prop="exchangeOrderCount" align="right" />
<el-table-column label="兑换消耗积分" prop="exchangePoints" align="right" />
</el-table>
<div v-if="!monthlyReport.length && !loading" class="panel-empty">暂无数据</div>
</div>
</div>
</template>
<script>
import * as echarts from 'echarts'
import { saveAs } from 'file-saver'
import { blobValidate } from '@/utils/ruoyi'
import {
getStatsCategory,
getStatsLevel,
getTimelinessByLevel,
getRankingPoints,
getRankingReport,
getRankingExchange,
getMonthlyReport,
exportMonthlyReport
} from '@/api/analysis'
export default {
name: 'DataAnalysis',
data() {
return {
loading: false,
exporting: false,
rankTab: 'points',
timeliness: [],
rankPoints: [],
rankReport: [],
rankExchange: [],
categoryList: [],
levelList: [],
reportYear: new Date().getFullYear(),
reportMonth: 0,
monthlyReport: [],
categoryChart: null,
levelChart: null
}
},
computed: {
yearOptions() {
const current = new Date().getFullYear()
return [current, current - 1, current - 2, current - 3, current - 4]
}
},
created() {
this.loadData()
},
mounted() {
window.addEventListener('resize', this.handleResize)
},
activated() {
this.handleResize()
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize)
this.disposeCharts()
},
deactivated() {
this.disposeCharts()
},
methods: {
loadData() {
this.loading = true
Promise.all([
getTimelinessByLevel().catch(() => ({ data: [] })),
getRankingPoints().catch(() => ({ data: [] })),
getRankingReport().catch(() => ({ data: [] })),
getRankingExchange().catch(() => ({ data: [] })),
getStatsCategory().catch(() => ({ data: [] })),
getStatsLevel().catch(() => ({ data: [] }))
])
.then(([timeliness, points, report, exchange, category, level]) => {
this.timeliness = this.toList(timeliness)
this.rankPoints = this.toList(points)
this.rankReport = this.toList(report)
this.rankExchange = this.toList(exchange)
this.categoryList = this.toList(category)
this.levelList = this.toList(level)
this.$nextTick(this.renderCharts)
})
.catch(() => {
this.$modal.msgError('数据分析加载失败,请检查后端服务和登录状态')
})
.finally(() => {
this.loading = false
})
this.loadMonthlyReport()
},
loadMonthlyReport() {
getMonthlyReport({ year: this.reportYear, month: this.reportMonth })
.then(response => {
const data = response.data || response
this.monthlyReport = Array.isArray(data) ? data : (data && (data.list || data.rows)) || []
})
.catch(() => {
this.monthlyReport = []
})
},
toList(response) {
const data = response && response.data
if (Array.isArray(data)) return data
if (data && Array.isArray(data.list)) return data.list
if (data && Array.isArray(data.rows)) return data.rows
return []
},
renderCharts() {
this.renderCategoryChart()
this.renderLevelChart()
},
renderCategoryChart() {
const el = this.$refs.categoryChart
if (!el || !this.categoryList.length) return
if (!this.categoryChart) this.categoryChart = echarts.init(el)
const names = this.categoryList.map(item => item.categoryName || '未分类')
const values = this.categoryList.map(item => Number(item.count || 0))
this.categoryChart.setOption({
grid: { top: 16, left: 8, right: 24, bottom: 8, containLabel: true },
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }},
xAxis: { type: 'value', minInterval: 1, axisLine: { show: false }, axisTick: { show: false }, splitLine: { lineStyle: { color: '#f0f0f2' }}, axisLabel: { color: '#9898ae', fontSize: 11 }},
yAxis: { type: 'category', data: names, axisTick: { show: false }, axisLine: { lineStyle: { color: '#e5e7eb' }}, axisLabel: { color: '#5a5a72', fontSize: 11 }},
series: [{ type: 'bar', data: values, barWidth: '46%', itemStyle: { color: '#1890ff', borderRadius: [0, 4, 4, 0] }, label: { show: true, position: 'right', color: '#5a5a72', fontSize: 11 }}]
}, true)
this.categoryChart.resize()
},
renderLevelChart() {
const el = this.$refs.levelChart
if (!el || !this.levelList.length) return
if (!this.levelChart) this.levelChart = echarts.init(el)
const data = this.levelList.map(item => ({ name: item.levelName || '未分级', value: Number(item.count || 0) }))
this.levelChart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0, textStyle: { color: '#5a5a72', fontSize: 11 }},
color: ['#1890ff', '#e8952f', '#d64550', '#2d8a4e'],
series: [{
type: 'pie',
radius: ['42%', '68%'],
center: ['50%', '46%'],
avoidLabelOverlap: true,
label: { color: '#5a5a72', fontSize: 11 },
data
}]
}, true)
this.levelChart.resize()
},
handleResize() {
this.categoryChart && this.categoryChart.resize()
this.levelChart && this.levelChart.resize()
},
disposeCharts() {
if (this.categoryChart) { this.categoryChart.dispose(); this.categoryChart = null }
if (this.levelChart) { this.levelChart.dispose(); this.levelChart = null }
},
handleExport() {
this.exporting = true
exportMonthlyReport({ year: this.reportYear, month: this.reportMonth })
.then(async data => {
if (blobValidate(data)) {
const scope = this.reportMonth === 0 ? '全年' : `${this.reportMonth}月`
saveAs(new Blob([data]), `月度安全运营报告-${this.reportYear}年${scope}.xlsx`)
this.$modal.msgSuccess('报告已导出')
} else {
const text = typeof data.text === 'function' ? await data.text() : String(data)
let message = '导出失败'
try { message = JSON.parse(text).msg || message } catch (error) { /* 非 JSON 响应 */ }
this.$modal.msgError(message)
}
})
.catch(() => {
this.$modal.msgError('导出失败,请稍后重试')
})
.finally(() => {
this.exporting = false
})
},
rankBadge(index) {
return ['🥇', '🥈', '🥉'][index] || String(index + 1)
},
num(value) {
return value === undefined || value === null || value === '' ? '—' : value
},
hoursText(value) {
if (value === undefined || value === null || value === '') return '—'
const n = Number(value)
if (Number.isNaN(n)) return String(value)
return n >= 24 ? `${(n / 24).toFixed(1)} d` : `${n} h`
},
percent(value) {
if (value === undefined || value === null || value === '') return '—'
const n = Number(value)
return Number.isNaN(n) ? String(value) : `${n}%`
}
}
}
</script>
<style lang="scss" scoped>
.analysis-page {
min-height: calc(100vh - 52px);
}
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
margin-bottom: 14px;
}
.panel {
padding: 16px 18px;
background: var(--prototype-panel, #fff);
border: 1px solid var(--prototype-divider, #f0f0f2);
border-radius: var(--prototype-radius, 8px);
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.panel-title {
margin-bottom: 12px;
color: var(--prototype-text, #1a1a2e);
font-size: 14px;
font-weight: 600;
}
.panel-head .panel-title {
margin-bottom: 12px;
}
.panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
.panel-empty {
padding: 28px 0;
color: var(--prototype-text-muted, #9898ae);
font-size: 12px;
text-align: center;
}
.chart {
width: 100%;
height: 264px;
}
@media (max-width: 1100px) {
.two-col {
grid-template-columns: 1fr;
}
}
</style>
Loading…
Cancel
Save