feat(safety): 安全生产六期 - 风险可视化看板前端
配套后端 dashboard 聚合接口。
新增 views/safety/dashboard:
- 顶部 4 个核心指标卡:风险点总数、待整改隐患(含超期数)、待审批作业票
(含作业超期数)、证书即将到期(含已过期数),异常项用红色标注
- 风险等级分布环形饼图(四色与字典一致)
- 隐患整改状态横向柱状图,卡片右上角显示闭环率
- 风险/隐患按车间分布柱状图(车间多时自动倾斜标签)
- 安全教育培训、持证情况两个概览卡
- 最近 5 条隐患、最近 5 张作业票动态表,状态用标签区分
注:本期只提交代码,未部署。
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace MesSafetyDashboardApi { |
| | | /** åç§°-æ°é */ |
| | | export interface NameCount { |
| | | name: string; |
| | | count: number; |
| | | extra?: number; |
| | | } |
| | | |
| | | /** 鿣ç»è®¡ */ |
| | | export interface HazardStats { |
| | | total: number; |
| | | pending: number; |
| | | rectifying: number; |
| | | toCheck: number; |
| | | closed: number; |
| | | overdue: number; |
| | | closedRate: number; |
| | | } |
| | | |
| | | /** ä½ä¸ç¥¨ç»è®¡ */ |
| | | export interface TicketStats { |
| | | total: number; |
| | | pending: number; |
| | | approved: number; |
| | | finished: number; |
| | | archived: number; |
| | | rejected: number; |
| | | overdue: number; |
| | | } |
| | | |
| | | /** å¹è®ç»è®¡ */ |
| | | export interface TrainingStats { |
| | | total: number; |
| | | finished: number; |
| | | ongoing: number; |
| | | planned: number; |
| | | attendeeTotal: number; |
| | | avgScore?: number; |
| | | } |
| | | |
| | | /** è¯ä¹¦ç»è®¡ */ |
| | | export interface CertificateStats { |
| | | total: number; |
| | | valid: number; |
| | | expired: number; |
| | | expiringSoon: number; |
| | | } |
| | | |
| | | /** æè¿éæ£ */ |
| | | export interface RecentHazard { |
| | | id: number; |
| | | code: string; |
| | | name: string; |
| | | hazardLevel?: number; |
| | | status?: number; |
| | | foundDate?: string; |
| | | } |
| | | |
| | | /** æè¿ä½ä¸ç¥¨ */ |
| | | export interface RecentTicket { |
| | | id: number; |
| | | code: string; |
| | | ticketType?: number; |
| | | workLocation?: string; |
| | | status?: number; |
| | | createTime?: string; |
| | | } |
| | | |
| | | /** çæ¿æ¦è§ */ |
| | | export interface DashboardOverview { |
| | | riskTotal: number; |
| | | riskLevelStats: NameCount[]; |
| | | riskByWorkshop: NameCount[]; |
| | | hazardStats: HazardStats; |
| | | hazardByWorkshop: NameCount[]; |
| | | ticketStats: TicketStats; |
| | | trainingStats: TrainingStats; |
| | | certificateStats: CertificateStats; |
| | | recentHazards: RecentHazard[]; |
| | | recentTickets: RecentTicket[]; |
| | | } |
| | | } |
| | | |
| | | /** è·å¾çæ¿æ¦è§æ°æ® */ |
| | | export function getDashboardOverview() { |
| | | return requestClient.get<MesSafetyDashboardApi.DashboardOverview>( |
| | | '/mes/safety/dashboard/overview', |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { EChartsOption } from '@vben/plugins/echarts'; |
| | | |
| | | import type { MesSafetyDashboardApi } from '#/api/safety/dashboard'; |
| | | |
| | | /** é£é©ç级åè²ï¼ä¸åå
¸ color_type ä¿æä¸è´ï¼ */ |
| | | const RISK_LEVEL_COLORS = ['#F56C6C', '#E6A23C', '#409EFF', '#67C23A']; |
| | | |
| | | /** é£é©ç级åå¸ - ç¯å½¢é¥¼å¾ */ |
| | | export function getRiskLevelChartOptions( |
| | | stats: MesSafetyDashboardApi.NameCount[], |
| | | ): EChartsOption { |
| | | return { |
| | | tooltip: { trigger: 'item' }, |
| | | legend: { bottom: 0, orient: 'horizontal' }, |
| | | series: [ |
| | | { |
| | | type: 'pie', |
| | | radius: ['45%', '70%'], |
| | | center: ['50%', '45%'], |
| | | avoidLabelOverlap: true, |
| | | itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 }, |
| | | label: { formatter: '{b}\n{c} 项' }, |
| | | data: stats.map((item, index) => ({ |
| | | name: item.name, |
| | | value: item.count, |
| | | itemStyle: { color: RISK_LEVEL_COLORS[index % RISK_LEVEL_COLORS.length] }, |
| | | })), |
| | | }, |
| | | ], |
| | | }; |
| | | } |
| | | |
| | | /** éæ£ç¶æåå¸ - æ¨ªåæ±ç¶å¾ */ |
| | | export function getHazardStatusChartOptions( |
| | | stats: MesSafetyDashboardApi.HazardStats, |
| | | ): EChartsOption { |
| | | const data = [ |
| | | { name: 'å¾
æ´æ¹', value: stats.pending, color: '#F56C6C' }, |
| | | { name: 'æ´æ¹ä¸', value: stats.rectifying, color: '#409EFF' }, |
| | | { name: 'å¾
éªæ¶', value: stats.toCheck, color: '#E6A23C' }, |
| | | { name: 'å·²éç¯', value: stats.closed, color: '#67C23A' }, |
| | | ]; |
| | | return { |
| | | grid: { left: 80, right: 40, top: 20, bottom: 30 }, |
| | | tooltip: { trigger: 'axis' }, |
| | | xAxis: { type: 'value', axisLabel: { integerOnly: true } }, |
| | | yAxis: { type: 'category', data: data.map((d) => d.name) }, |
| | | series: [ |
| | | { |
| | | type: 'bar', |
| | | barMaxWidth: 24, |
| | | label: { show: true, position: 'right' }, |
| | | data: data.map((d) => ({ |
| | | value: d.value, |
| | | itemStyle: { color: d.color, borderRadius: [0, 4, 4, 0] }, |
| | | })), |
| | | }, |
| | | ], |
| | | }; |
| | | } |
| | | |
| | | /** æè½¦é´åå¸ - æ±ç¶å¾ */ |
| | | export function getWorkshopChartOptions( |
| | | stats: MesSafetyDashboardApi.NameCount[], |
| | | seriesName: string, |
| | | color: string, |
| | | ): EChartsOption { |
| | | return { |
| | | grid: { left: 50, right: 30, top: 30, bottom: 60 }, |
| | | tooltip: { trigger: 'axis' }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: stats.map((s) => s.name), |
| | | axisLabel: { interval: 0, rotate: stats.length > 5 ? 30 : 0 }, |
| | | }, |
| | | yAxis: { type: 'value', axisLabel: { integerOnly: true } }, |
| | | series: [ |
| | | { |
| | | name: seriesName, |
| | | type: 'bar', |
| | | barMaxWidth: 36, |
| | | itemStyle: { color, borderRadius: [4, 4, 0, 0] }, |
| | | label: { show: true, position: 'top' }, |
| | | data: stats.map((s) => s.count), |
| | | }, |
| | | ], |
| | | }; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { TableColumnsType } from 'ant-design-vue'; |
| | | import type { EchartsUIType } from '@vben/plugins/echarts'; |
| | | import type { MesSafetyDashboardApi } from '#/api/safety/dashboard'; |
| | | |
| | | import { computed, onMounted, ref } from 'vue'; |
| | | |
| | | import { Page } from '#/packages/effects/common-ui/src'; |
| | | |
| | | import { IconifyIcon } from '@vben/icons'; |
| | | import { EchartsUI, useEcharts } from '@vben/plugins/echarts'; |
| | | |
| | | import { Card, Col, Row, Spin, Table, Tag } from 'ant-design-vue'; |
| | | |
| | | import { getDashboardOverview } from '#/api/safety/dashboard'; |
| | | |
| | | import { |
| | | getHazardStatusChartOptions, |
| | | getRiskLevelChartOptions, |
| | | getWorkshopChartOptions, |
| | | } from './chart-options'; |
| | | |
| | | defineOptions({ name: 'SafetyDashboard' }); |
| | | |
| | | const loading = ref(false); |
| | | const data = ref<MesSafetyDashboardApi.DashboardOverview>(); |
| | | |
| | | const riskChartRef = ref<EchartsUIType>(); |
| | | const hazardChartRef = ref<EchartsUIType>(); |
| | | const riskWorkshopChartRef = ref<EchartsUIType>(); |
| | | const hazardWorkshopChartRef = ref<EchartsUIType>(); |
| | | |
| | | const { renderEcharts: renderRiskChart } = useEcharts(riskChartRef); |
| | | const { renderEcharts: renderHazardChart } = useEcharts(hazardChartRef); |
| | | const { renderEcharts: renderRiskWorkshopChart } = useEcharts( |
| | | riskWorkshopChartRef, |
| | | ); |
| | | const { renderEcharts: renderHazardWorkshopChart } = useEcharts( |
| | | hazardWorkshopChartRef, |
| | | ); |
| | | |
| | | const hazardStats = computed(() => data.value?.hazardStats); |
| | | const ticketStats = computed(() => data.value?.ticketStats); |
| | | const certificateStats = computed(() => data.value?.certificateStats); |
| | | const trainingStats = computed(() => data.value?.trainingStats); |
| | | |
| | | /** 顶鍿 ¸å¿ææ å¡ç */ |
| | | const cards = computed(() => [ |
| | | { |
| | | title: 'é£é©ç¹æ»æ°', |
| | | value: data.value?.riskTotal ?? 0, |
| | | suffix: '项', |
| | | icon: 'lucide:alert-triangle', |
| | | color: '#409EFF', |
| | | }, |
| | | { |
| | | title: 'å¾
æ´æ¹éæ£', |
| | | value: |
| | | (hazardStats.value?.pending ?? 0) + (hazardStats.value?.rectifying ?? 0), |
| | | suffix: '项', |
| | | icon: 'lucide:clipboard-list', |
| | | color: '#E6A23C', |
| | | warn: (hazardStats.value?.overdue ?? 0) > 0, |
| | | extra: `è¶
æ ${hazardStats.value?.overdue ?? 0} 项`, |
| | | }, |
| | | { |
| | | title: 'å¾
审æ¹ä½ä¸ç¥¨', |
| | | value: ticketStats.value?.pending ?? 0, |
| | | suffix: 'å¼ ', |
| | | icon: 'lucide:ticket', |
| | | color: '#909399', |
| | | warn: (ticketStats.value?.overdue ?? 0) > 0, |
| | | extra: `ä½ä¸è¶
æ ${ticketStats.value?.overdue ?? 0} å¼ `, |
| | | }, |
| | | { |
| | | title: 'è¯ä¹¦å³å°å°æ', |
| | | value: |
| | | (certificateStats.value?.expiringSoon ?? 0) + |
| | | (certificateStats.value?.expired ?? 0), |
| | | suffix: 'æ¬', |
| | | icon: 'lucide:medal', |
| | | color: '#F56C6C', |
| | | warn: (certificateStats.value?.expired ?? 0) > 0, |
| | | extra: `å·²è¿æ ${certificateStats.value?.expired ?? 0} æ¬`, |
| | | }, |
| | | ]); |
| | | |
| | | const hazardColumns: TableColumnsType = [ |
| | | { title: '鿣ç¼ç ', dataIndex: 'code', width: 140 }, |
| | | { title: '鿣åç§°', dataIndex: 'name', ellipsis: true }, |
| | | { title: 'ç级', dataIndex: 'hazardLevel', width: 90 }, |
| | | { title: 'ç¶æ', dataIndex: 'status', width: 90 }, |
| | | { title: 'åç°æ¥æ', dataIndex: 'foundDate', width: 120 }, |
| | | ]; |
| | | |
| | | const ticketColumns: TableColumnsType = [ |
| | | { title: 'ä½ä¸ç¥¨å·', dataIndex: 'code', width: 140 }, |
| | | { title: 'ä½ä¸å°ç¹', dataIndex: 'workLocation', ellipsis: true }, |
| | | { title: 'ç¶æ', dataIndex: 'status', width: 90 }, |
| | | { title: 'å建æ¶é´', dataIndex: 'createTime', width: 170 }, |
| | | ]; |
| | | |
| | | const HAZARD_LEVEL_MAP: Record<number, { color: string; text: string }> = { |
| | | 1: { color: 'warning', text: 'ä¸è¬éæ£' }, |
| | | 2: { color: 'error', text: 'é大鿣' }, |
| | | }; |
| | | const HAZARD_STATUS_MAP: Record<number, { color: string; text: string }> = { |
| | | 0: { color: 'error', text: 'å¾
æ´æ¹' }, |
| | | 10: { color: 'processing', text: 'æ´æ¹ä¸' }, |
| | | 20: { color: 'warning', text: 'å¾
éªæ¶' }, |
| | | 30: { color: 'success', text: 'å·²éç¯' }, |
| | | }; |
| | | const TICKET_STATUS_MAP: Record<number, { color: string; text: string }> = { |
| | | 0: { color: 'processing', text: 'å¾
审æ¹' }, |
| | | 10: { color: 'success', text: 'å·²æ¹å' }, |
| | | 20: { color: 'default', text: 'å·²å®å·¥' }, |
| | | 30: { color: 'default', text: '已彿¡£' }, |
| | | 90: { color: 'error', text: '已驳å' }, |
| | | }; |
| | | |
| | | async function loadData() { |
| | | loading.value = true; |
| | | try { |
| | | const result = await getDashboardOverview(); |
| | | data.value = result; |
| | | // å¾è¡¨æ¸²æ |
| | | await renderRiskChart(getRiskLevelChartOptions(result.riskLevelStats ?? [])); |
| | | await renderHazardChart( |
| | | getHazardStatusChartOptions(result.hazardStats), |
| | | ); |
| | | await renderRiskWorkshopChart( |
| | | getWorkshopChartOptions(result.riskByWorkshop ?? [], 'é£é©æ°', '#409EFF'), |
| | | ); |
| | | await renderHazardWorkshopChart( |
| | | getWorkshopChartOptions( |
| | | result.hazardByWorkshop ?? [], |
| | | '鿣æ°', |
| | | '#E6A23C', |
| | | ), |
| | | ); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | onMounted(loadData); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page> |
| | | <Spin :spinning="loading"> |
| | | <div class="flex flex-col gap-4 p-4"> |
| | | <!-- æ ¸å¿ææ --> |
| | | <Row :gutter="[16, 16]"> |
| | | <Col |
| | | v-for="card in cards" |
| | | :key="card.title" |
| | | :xs="24" |
| | | :sm="12" |
| | | :lg="6" |
| | | > |
| | | <Card :bordered="false" class="shadow-sm"> |
| | | <div class="flex items-center justify-between"> |
| | | <div> |
| | | <div class="text-gray-500">{{ card.title }}</div> |
| | | <div class="mt-1 text-2xl font-bold" :style="{ color: card.color }"> |
| | | {{ card.value }} |
| | | <span class="text-sm font-normal text-gray-400"> |
| | | {{ card.suffix }} |
| | | </span> |
| | | </div> |
| | | <div |
| | | v-if="card.extra" |
| | | class="mt-1 text-xs" |
| | | :class="card.warn ? 'text-red-500' : 'text-gray-400'" |
| | | > |
| | | {{ card.extra }} |
| | | </div> |
| | | </div> |
| | | <IconifyIcon |
| | | :icon="card.icon" |
| | | class="text-4xl opacity-20" |
| | | :style="{ color: card.color }" |
| | | /> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | </Row> |
| | | |
| | | <!-- é£é© / 鿣åå¸ --> |
| | | <Row :gutter="[16, 16]"> |
| | | <Col :xs="24" :lg="12"> |
| | | <Card title="é£é©ç级åå¸" :bordered="false" class="shadow-sm"> |
| | | <EchartsUI ref="riskChartRef" height="280px" /> |
| | | </Card> |
| | | </Col> |
| | | <Col :xs="24" :lg="12"> |
| | | <Card |
| | | title="éæ£æ´æ¹ç¶æ" |
| | | :bordered="false" |
| | | class="shadow-sm" |
| | | > |
| | | <template #extra> |
| | | <span class="text-gray-500"> |
| | | éç¯ç {{ hazardStats?.closedRate ?? 0 }}% |
| | | </span> |
| | | </template> |
| | | <EchartsUI ref="hazardChartRef" height="280px" /> |
| | | </Card> |
| | | </Col> |
| | | </Row> |
| | | |
| | | <!-- æè½¦é´åå¸ --> |
| | | <Row :gutter="[16, 16]"> |
| | | <Col :xs="24" :lg="12"> |
| | | <Card title="é£é©æè½¦é´åå¸" :bordered="false" class="shadow-sm"> |
| | | <EchartsUI ref="riskWorkshopChartRef" height="280px" /> |
| | | </Card> |
| | | </Col> |
| | | <Col :xs="24" :lg="12"> |
| | | <Card title="鿣æè½¦é´åå¸" :bordered="false" class="shadow-sm"> |
| | | <EchartsUI ref="hazardWorkshopChartRef" height="280px" /> |
| | | </Card> |
| | | </Col> |
| | | </Row> |
| | | |
| | | <!-- å¹è®ä¸è¯ä¹¦æ¦è§ --> |
| | | <Row :gutter="[16, 16]"> |
| | | <Col :xs="24" :lg="12"> |
| | | <Card title="å®å
¨æè²å¹è®" :bordered="false" class="shadow-sm"> |
| | | <div class="grid grid-cols-2 gap-3 text-sm"> |
| | | <div>å¹è®æ»æ°ï¼<b>{{ trainingStats?.total ?? 0 }}</b></div> |
| | | <div>已宿ï¼<b>{{ trainingStats?.finished ?? 0 }}</b></div> |
| | | <div>è¿è¡ä¸ï¼<b>{{ trainingStats?.ongoing ?? 0 }}</b></div> |
| | | <div>计åä¸ï¼<b>{{ trainingStats?.planned ?? 0 }}</b></div> |
| | | <div>累计åè®ï¼<b>{{ trainingStats?.attendeeTotal ?? 0 }}</b> 人次</div> |
| | | <div>å¹³åæç»©ï¼<b>{{ trainingStats?.avgScore ?? '-' }}</b></div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | <Col :xs="24" :lg="12"> |
| | | <Card title="æè¯æ
åµ" :bordered="false" class="shadow-sm"> |
| | | <div class="grid grid-cols-2 gap-3 text-sm"> |
| | | <div>è¯ä¹¦æ»æ°ï¼<b>{{ certificateStats?.total ?? 0 }}</b></div> |
| | | <div>ææï¼<b class="text-green-600">{{ certificateStats?.valid ?? 0 }}</b></div> |
| | | <div>å³å°å°æï¼<b class="text-orange-600">{{ certificateStats?.expiringSoon ?? 0 }}</b></div> |
| | | <div>å·²è¿æï¼<b class="text-red-600">{{ certificateStats?.expired ?? 0 }}</b></div> |
| | | </div> |
| | | </Card> |
| | | </Col> |
| | | </Row> |
| | | |
| | | <!-- æè¿å¨æ --> |
| | | <Row :gutter="[16, 16]"> |
| | | <Col :xs="24" :lg="12"> |
| | | <Card title="æè¿éæ£" :bordered="false" class="shadow-sm"> |
| | | <Table |
| | | :data-source="data?.recentHazards ?? []" |
| | | :columns="hazardColumns" |
| | | :pagination="false" |
| | | size="small" |
| | | row-key="id" |
| | | > |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.dataIndex === 'hazardLevel'"> |
| | | <Tag |
| | | v-if="record.hazardLevel" |
| | | :color="HAZARD_LEVEL_MAP[record.hazardLevel]?.color" |
| | | > |
| | | {{ HAZARD_LEVEL_MAP[record.hazardLevel]?.text }} |
| | | </Tag> |
| | | <span v-else>-</span> |
| | | </template> |
| | | <template v-else-if="column.dataIndex === 'status'"> |
| | | <Tag :color="HAZARD_STATUS_MAP[record.status]?.color"> |
| | | {{ HAZARD_STATUS_MAP[record.status]?.text ?? '-' }} |
| | | </Tag> |
| | | </template> |
| | | </template> |
| | | </Table> |
| | | </Card> |
| | | </Col> |
| | | <Col :xs="24" :lg="12"> |
| | | <Card title="æè¿ä½ä¸ç¥¨" :bordered="false" class="shadow-sm"> |
| | | <Table |
| | | :data-source="data?.recentTickets ?? []" |
| | | :columns="ticketColumns" |
| | | :pagination="false" |
| | | size="small" |
| | | row-key="id" |
| | | > |
| | | <template #bodyCell="{ column, record }"> |
| | | <template v-if="column.dataIndex === 'status'"> |
| | | <Tag :color="TICKET_STATUS_MAP[record.status]?.color"> |
| | | {{ TICKET_STATUS_MAP[record.status]?.text ?? '-' }} |
| | | </Tag> |
| | | </template> |
| | | </template> |
| | | </Table> |
| | | </Card> |
| | | </Col> |
| | | </Row> |
| | | </div> |
| | | </Spin> |
| | | </Page> |
| | | </template> |