index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. <template>
  2. <!-- 第一行:统计卡片行 -->
  3. <el-row :gutter="16" class="mb-4">
  4. <el-col :span="6">
  5. <el-card class="stat-card" shadow="never">
  6. <div class="flex flex-col">
  7. <div class="flex justify-between items-center mb-1">
  8. <span class="text-gray-500 text-base font-medium">分类数量</span>
  9. <Icon icon="ep:menu" class="text-[32px] text-blue-400" />
  10. </div>
  11. <span class="text-3xl font-bold text-gray-700">
  12. {{ statsData.productCategoryCount }}
  13. </span>
  14. <el-divider class="my-2" />
  15. <div class="flex justify-between items-center text-gray-400 text-sm">
  16. <span>今日新增</span>
  17. <span class="text-green-500">+{{ statsData.productCategoryTodayCount }}</span>
  18. </div>
  19. </div>
  20. </el-card>
  21. </el-col>
  22. <el-col :span="6">
  23. <el-card class="stat-card" shadow="never">
  24. <div class="flex flex-col">
  25. <div class="flex justify-between items-center mb-1">
  26. <span class="text-gray-500 text-base font-medium">产品数量</span>
  27. <Icon icon="ep:box" class="text-[32px] text-orange-400" />
  28. </div>
  29. <span class="text-3xl font-bold text-gray-700">{{ statsData.productCount }}</span>
  30. <el-divider class="my-2" />
  31. <div class="flex justify-between items-center text-gray-400 text-sm">
  32. <span>今日新增</span>
  33. <span class="text-green-500">+{{ statsData.productTodayCount }}</span>
  34. </div>
  35. </div>
  36. </el-card>
  37. </el-col>
  38. <el-col :span="6">
  39. <el-card class="stat-card" shadow="never">
  40. <div class="flex flex-col">
  41. <div class="flex justify-between items-center mb-1">
  42. <span class="text-gray-500 text-base font-medium">设备数量</span>
  43. <Icon icon="ep:cpu" class="text-[32px] text-purple-400" />
  44. </div>
  45. <span class="text-3xl font-bold text-gray-700">{{ statsData.deviceCount }}</span>
  46. <el-divider class="my-2" />
  47. <div class="flex justify-between items-center text-gray-400 text-sm">
  48. <span>今日新增</span>
  49. <span class="text-green-500">+{{ statsData.deviceTodayCount }}</span>
  50. </div>
  51. </div>
  52. </el-card>
  53. </el-col>
  54. <el-col :span="6">
  55. <el-card class="stat-card" shadow="never">
  56. <div class="flex flex-col">
  57. <div class="flex justify-between items-center mb-1">
  58. <span class="text-gray-500 text-base font-medium">设备消息数</span>
  59. <Icon icon="ep:message" class="text-[32px] text-teal-400" />
  60. </div>
  61. <span class="text-3xl font-bold text-gray-700">
  62. {{ statsData.deviceMessageCount }}
  63. </span>
  64. <el-divider class="my-2" />
  65. <div class="flex justify-between items-center text-gray-400 text-sm">
  66. <span>今日新增</span>
  67. <span class="text-green-500">+{{ statsData.deviceMessageTodayCount }}</span>
  68. </div>
  69. </div>
  70. </el-card>
  71. </el-col>
  72. </el-row>
  73. <!-- 第二行:图表行 -->
  74. <el-row :gutter="16" class="mb-4">
  75. <el-col :span="12">
  76. <el-card class="chart-card" shadow="never">
  77. <template #header>
  78. <div class="flex items-center">
  79. <span class="text-base font-medium text-gray-600">设备数量统计</span>
  80. </div>
  81. </template>
  82. <div ref="deviceCountChartRef" class="h-[240px]"></div>
  83. </el-card>
  84. </el-col>
  85. <el-col :span="12">
  86. <el-card class="chart-card" shadow="never">
  87. <template #header>
  88. <div class="flex items-center">
  89. <span class="text-base font-medium text-gray-600">设备状态统计</span>
  90. </div>
  91. </template>
  92. <el-row class="h-[240px]">
  93. <el-col :span="8" class="flex flex-col items-center">
  94. <div ref="deviceOnlineCountChartRef" class="h-[160px] w-full"></div>
  95. <div class="text-center mt-2">
  96. <span class="text-sm text-gray-600">在线设备</span>
  97. </div>
  98. </el-col>
  99. <el-col :span="8" class="flex flex-col items-center">
  100. <div ref="deviceOfflineChartRef" class="h-[160px] w-full"></div>
  101. <div class="text-center mt-2">
  102. <span class="text-sm text-gray-600">离线设备</span>
  103. </div>
  104. </el-col>
  105. <el-col :span="8" class="flex flex-col items-center">
  106. <div ref="deviceActiveChartRef" class="h-[160px] w-full"></div>
  107. <div class="text-center mt-2">
  108. <span class="text-sm text-gray-600">待激活设备</span>
  109. </div>
  110. </el-col>
  111. </el-row>
  112. </el-card>
  113. </el-col>
  114. </el-row>
  115. <!-- 第三行:消息统计行 -->
  116. <el-row>
  117. <el-col :span="24">
  118. <el-card class="chart-card" shadow="never">
  119. <template #header>
  120. <div class="flex items-center justify-between">
  121. <span class="text-base font-medium text-gray-600">上下行消息量统计</span>
  122. <div class="flex items-center space-x-2">
  123. <el-radio-group v-model="timeRange" @change="handleTimeRangeChange">
  124. <el-radio-button label="1h">最近1小时</el-radio-button>
  125. <el-radio-button label="24h">最近24小时</el-radio-button>
  126. <el-radio-button label="7d">近一周</el-radio-button>
  127. </el-radio-group>
  128. <el-date-picker
  129. v-model="dateRange"
  130. type="datetimerange"
  131. range-separator="至"
  132. start-placeholder="开始时间"
  133. end-placeholder="结束时间"
  134. :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
  135. @change="handleDateRangeChange"
  136. />
  137. </div>
  138. </div>
  139. </template>
  140. <div ref="deviceMessageCountChartRef" class="h-[300px]"></div>
  141. </el-card>
  142. </el-col>
  143. </el-row>
  144. <!-- TODO 第四行:地图 -->
  145. </template>
  146. <script setup lang="ts" name="Index">
  147. import * as echarts from 'echarts/core'
  148. import {
  149. GridComponent,
  150. LegendComponent,
  151. TitleComponent,
  152. ToolboxComponent,
  153. TooltipComponent
  154. } from 'echarts/components'
  155. import { GaugeChart, LineChart, PieChart } from 'echarts/charts'
  156. import { LabelLayout, UniversalTransition } from 'echarts/features'
  157. import { CanvasRenderer } from 'echarts/renderers'
  158. import {
  159. IotStatisticsDeviceMessageSummaryRespVO,
  160. IotStatisticsSummaryRespVO,
  161. ProductCategoryApi
  162. } from '@/api/iot/statistics'
  163. import { formatDate } from '@/utils/formatTime'
  164. // TODO @super:参考下 /Users/yunai/Java/yudao-ui-admin-vue3/src/views/mall/home/index.vue,拆一拆组件
  165. /** IoT 首页 */
  166. defineOptions({ name: 'IoTHome' })
  167. // TODO @super:使用下 Echart 组件,参考 yudao-ui-admin-vue3/src/views/mall/home/components/TradeTrendCard.vue 等
  168. echarts.use([
  169. TooltipComponent,
  170. LegendComponent,
  171. PieChart,
  172. CanvasRenderer,
  173. LabelLayout,
  174. TitleComponent,
  175. ToolboxComponent,
  176. GridComponent,
  177. LineChart,
  178. UniversalTransition,
  179. GaugeChart
  180. ])
  181. const timeRange = ref('7d') // 修改默认选择为近一周
  182. const dateRange = ref<[Date, Date] | null>(null)
  183. const queryParams = reactive({
  184. startTime: Date.now() - 7 * 24 * 60 * 60 * 1000, // 设置默认开始时间为 7 天前
  185. endTime: Date.now() // 设置默认结束时间为当前时间
  186. })
  187. const deviceCountChartRef = ref() // 设备数量统计的图表
  188. const deviceOnlineCountChartRef = ref() // 在线设备统计的图表
  189. const deviceOfflineChartRef = ref() // 离线设备统计的图表
  190. const deviceActiveChartRef = ref() // 待激活设备统计的图表
  191. const deviceMessageCountChartRef = ref() // 上下行消息量统计的图表
  192. // 基础统计数据
  193. // TODO @super:初始为 -1,然后界面展示先是加载中?试试用 cursor 改哈
  194. const statsData = ref<IotStatisticsSummaryRespVO>({
  195. productCategoryCount: 0,
  196. productCount: 0,
  197. deviceCount: 0,
  198. deviceMessageCount: 0,
  199. productCategoryTodayCount: 0,
  200. productTodayCount: 0,
  201. deviceTodayCount: 0,
  202. deviceMessageTodayCount: 0,
  203. deviceOnlineCount: 0,
  204. deviceOfflineCount: 0,
  205. deviceInactiveCount: 0,
  206. productCategoryDeviceCounts: {}
  207. })
  208. // 消息统计数据
  209. const messageStats = ref<IotStatisticsDeviceMessageSummaryRespVO>({
  210. upstreamCounts: {},
  211. downstreamCounts: {}
  212. })
  213. /** 处理快捷时间范围选择 */
  214. const handleTimeRangeChange = (timeRange: string) => {
  215. const now = Date.now()
  216. let startTime: number
  217. // TODO @super:这个的计算,看看能不能结合 dayjs 简化。因为 1h、24h、7d 感觉是比较标准的。如果没有,抽到 utils/formatTime.ts 作为一个工具方法
  218. switch (timeRange) {
  219. case '1h':
  220. startTime = now - 60 * 60 * 1000
  221. break
  222. case '24h':
  223. startTime = now - 24 * 60 * 60 * 1000
  224. break
  225. case '7d':
  226. startTime = now - 7 * 24 * 60 * 60 * 1000
  227. break
  228. default:
  229. return
  230. }
  231. // 清空日期选择器
  232. dateRange.value = null
  233. // 更新查询参数
  234. queryParams.startTime = startTime
  235. queryParams.endTime = now
  236. // 重新获取数据
  237. getStats()
  238. }
  239. /** 处理自定义日期范围选择 */
  240. const handleDateRangeChange = (value: [Date, Date] | null) => {
  241. if (value) {
  242. // 清空快捷选项
  243. timeRange.value = ''
  244. // 更新查询参数
  245. queryParams.startTime = value[0].getTime()
  246. queryParams.endTime = value[1].getTime()
  247. // 重新获取数据
  248. getStats()
  249. }
  250. }
  251. /** 获取统计数据 */
  252. const getStats = async () => {
  253. // 获取基础统计数据
  254. statsData.value = await ProductCategoryApi.getIotStatisticsSummary()
  255. // 获取消息统计数据
  256. messageStats.value = await ProductCategoryApi.getIotStatisticsDeviceMessageSummary(queryParams)
  257. // 初始化图表
  258. initCharts()
  259. }
  260. /** 初始化图表 */
  261. const initCharts = () => {
  262. // 设备数量统计
  263. echarts.init(deviceCountChartRef.value).setOption({
  264. tooltip: {
  265. trigger: 'item'
  266. },
  267. legend: {
  268. top: '5%',
  269. right: '10%',
  270. align: 'left',
  271. orient: 'vertical',
  272. icon: 'circle'
  273. },
  274. series: [
  275. {
  276. name: 'Access From',
  277. type: 'pie',
  278. radius: ['50%', '80%'],
  279. avoidLabelOverlap: false,
  280. center: ['30%', '50%'],
  281. label: {
  282. show: false,
  283. position: 'outside'
  284. },
  285. emphasis: {
  286. label: {
  287. show: true,
  288. fontSize: 20,
  289. fontWeight: 'bold'
  290. }
  291. },
  292. labelLine: {
  293. show: false
  294. },
  295. data: Object.entries(statsData.value.productCategoryDeviceCounts).map(([name, value]) => ({
  296. name,
  297. value
  298. }))
  299. }
  300. ]
  301. })
  302. // 在线设备统计
  303. initGaugeChart(deviceOnlineCountChartRef.value, statsData.value.deviceOnlineCount, '#0d9')
  304. // 离线设备统计
  305. initGaugeChart(deviceOfflineChartRef.value, statsData.value.deviceOfflineCount, '#f50')
  306. // 待激活设备统计
  307. initGaugeChart(deviceActiveChartRef.value, statsData.value.deviceInactiveCount, '#05b')
  308. // 消息量统计
  309. initMessageChart()
  310. }
  311. /** 初始化仪表盘图表 */
  312. const initGaugeChart = (el: any, value: number, color: string) => {
  313. echarts.init(el).setOption({
  314. series: [
  315. {
  316. type: 'gauge',
  317. startAngle: 360,
  318. endAngle: 0,
  319. min: 0,
  320. max: statsData.value.deviceCount || 100, // 使用设备总数作为最大值
  321. progress: {
  322. show: true,
  323. width: 12,
  324. itemStyle: {
  325. color: color
  326. }
  327. },
  328. axisLine: {
  329. lineStyle: {
  330. width: 12,
  331. color: [[1, '#E5E7EB']]
  332. }
  333. },
  334. axisTick: { show: false },
  335. splitLine: { show: false },
  336. axisLabel: { show: false },
  337. pointer: { show: false },
  338. anchor: { show: false },
  339. title: { show: false },
  340. detail: {
  341. valueAnimation: true,
  342. fontSize: 24,
  343. fontWeight: 'bold',
  344. fontFamily: 'Inter, sans-serif',
  345. color: color,
  346. offsetCenter: [0, '0'],
  347. formatter: (value: number) => {
  348. return `${value} 个`
  349. }
  350. },
  351. data: [{ value: value }]
  352. }
  353. ]
  354. })
  355. }
  356. /** 初始化消息统计图表 */
  357. const initMessageChart = () => {
  358. // 获取所有时间戳并排序
  359. // TODO @super:一些 idea 里的红色报错,要去处理掉噢。
  360. const timestamps = Array.from(
  361. new Set([
  362. ...messageStats.value.upstreamCounts.map((item) => Number(Object.keys(item)[0])),
  363. ...messageStats.value.downstreamCounts.map((item) => Number(Object.keys(item)[0]))
  364. ])
  365. ).sort((a, b) => a - b) // 确保时间戳从小到大排序
  366. // 准备数据
  367. const xdata = timestamps.map((ts) => formatDate(ts, 'YYYY-MM-DD HH:mm'))
  368. const upData = timestamps.map((ts) => {
  369. const item = messageStats.value.upstreamCounts.find(
  370. (count) => Number(Object.keys(count)[0]) === ts
  371. )
  372. return item ? Object.values(item)[0] : 0
  373. })
  374. const downData = timestamps.map((ts) => {
  375. const item = messageStats.value.downstreamCounts.find(
  376. (count) => Number(Object.keys(count)[0]) === ts
  377. )
  378. return item ? Object.values(item)[0] : 0
  379. })
  380. // 配置图表
  381. echarts.init(deviceMessageCountChartRef.value).setOption({
  382. tooltip: {
  383. trigger: 'axis',
  384. backgroundColor: 'rgba(255, 255, 255, 0.9)',
  385. borderColor: '#E5E7EB',
  386. textStyle: {
  387. color: '#374151'
  388. }
  389. },
  390. legend: {
  391. data: ['上行消息量', '下行消息量'],
  392. textStyle: {
  393. color: '#374151',
  394. fontWeight: 500
  395. }
  396. },
  397. grid: {
  398. left: '3%',
  399. right: '4%',
  400. bottom: '3%',
  401. containLabel: true
  402. },
  403. xAxis: {
  404. type: 'category',
  405. boundaryGap: false,
  406. data: xdata,
  407. axisLine: {
  408. lineStyle: {
  409. color: '#E5E7EB'
  410. }
  411. },
  412. axisLabel: {
  413. color: '#6B7280'
  414. }
  415. },
  416. yAxis: {
  417. type: 'value',
  418. axisLine: {
  419. lineStyle: {
  420. color: '#E5E7EB'
  421. }
  422. },
  423. axisLabel: {
  424. color: '#6B7280'
  425. },
  426. splitLine: {
  427. lineStyle: {
  428. color: '#F3F4F6'
  429. }
  430. }
  431. },
  432. series: [
  433. {
  434. name: '上行消息量',
  435. type: 'line',
  436. smooth: true, // 添加平滑曲线
  437. data: upData,
  438. itemStyle: {
  439. color: '#3B82F6'
  440. },
  441. lineStyle: {
  442. width: 2
  443. },
  444. areaStyle: {
  445. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  446. { offset: 0, color: 'rgba(59, 130, 246, 0.2)' },
  447. { offset: 1, color: 'rgba(59, 130, 246, 0)' }
  448. ])
  449. }
  450. },
  451. {
  452. name: '下行消息量',
  453. type: 'line',
  454. smooth: true, // 添加平滑曲线
  455. data: downData,
  456. itemStyle: {
  457. color: '#10B981'
  458. },
  459. lineStyle: {
  460. width: 2
  461. },
  462. areaStyle: {
  463. color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  464. { offset: 0, color: 'rgba(16, 185, 129, 0.2)' },
  465. { offset: 1, color: 'rgba(16, 185, 129, 0)' }
  466. ])
  467. }
  468. }
  469. ]
  470. })
  471. }
  472. /** 初始化 */
  473. onMounted(() => {
  474. getStats()
  475. })
  476. </script>
  477. <style lang="scss" scoped></style>