tuqin
6 天以前 11f86a87b443819a8dfbab12623fd23a8dcbb33e
feat(safety): 安全生产六期 - 风险可视化看板前端

配套后端 dashboard 聚合接口。

新增 views/safety/dashboard:
- 顶部 4 个核心指标卡:风险点总数、待整改隐患(含超期数)、待审批作业票
(含作业超期数)、证书即将到期(含已过期数),异常项用红色标注
- 风险等级分布环形饼图(四色与字典一致)
- 隐患整改状态横向柱状图,卡片右上角显示闭环率
- 风险/隐患按车间分布柱状图(车间多时自动倾斜标签)
- 安全教育培训、持证情况两个概览卡
- 最近 5 条隐患、最近 5 张作业票动态表,状态用标签区分

注:本期只提交代码,未部署。
已添加3个文件
485 ■■■■■ 文件已修改
src/api/safety/dashboard/index.ts 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/dashboard/chart-options.ts 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/dashboard/index.vue 306 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/dashboard/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
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',
  );
}
src/views/safety/dashboard/chart-options.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,88 @@
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),
      },
    ],
  };
}
src/views/safety/dashboard/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,306 @@
<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>