tuqin
6 天以前 50464df232e62d8dc28f5011e54f2f9914ca9a1d
feat(safety): 安全生产七期 - 安全数据统计分析前端

配套后端统计报表与导出接口。

新增 views/safety/statistics:
- 筛选区:日期范围 + 车间下拉,附「本月 / 近三月 / 本年」快捷按钮与重置
- 4 个汇总卡:隐患(含闭环率与超期数)、作业票(含归档与驳回数)、
培训(含参训人次与平均分)、证书(含过期数)
- Tab 分组展示:隐患(按车间/等级/来源)、作业票(按类型/状态)、
培训与证书(按类型),完成率 ≥80% 绿色、否则橙色
- 导出隐患明细 Excel(文件名带时间戳,避免重复覆盖)

注:本期只提交代码,未部署。
已添加2个文件
401 ■■■■■ 文件已修改
src/api/safety/statistics/index.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/statistics/index.vue 339 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/statistics/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
import { requestClient } from '#/api/request';
export namespace MesSafetyStatisticsApi {
  /** æŸ¥è¯¢å‚æ•° */
  export interface StatisticsParam {
    startDate?: string;
    endDate?: string;
    workshopId?: number;
  }
  /** æ±‡æ€»æŒ‡æ ‡ */
  export interface Summary {
    hazardTotal: number;
    hazardClosed: number;
    hazardClosedRate: number;
    hazardOverdue: number;
    ticketTotal: number;
    ticketArchived: number;
    ticketRejected: number;
    trainingTotal: number;
    trainingAttendee: number;
    trainingAvgScore?: number;
    certificateTotal: number;
    certificateExpired: number;
  }
  /** åˆ†ç»„统计项 */
  export interface GroupStat {
    name: string;
    code?: number;
    total: number;
    finished?: number;
    finishRate?: number;
    attendeeTotal?: number;
    avgScore?: number;
  }
  /** ç»Ÿè®¡æŠ¥è¡¨ */
  export interface StatisticsReport {
    summary: Summary;
    hazardByWorkshop: GroupStat[];
    hazardByLevel: GroupStat[];
    hazardBySource: GroupStat[];
    ticketByType: GroupStat[];
    ticketByStatus: GroupStat[];
    trainingByType: GroupStat[];
    certificateByType: GroupStat[];
  }
}
/** èŽ·å¾—å®‰å…¨ç”Ÿäº§ç»Ÿè®¡æŠ¥è¡¨ */
export function getStatisticsReport(params: MesSafetyStatisticsApi.StatisticsParam) {
  return requestClient.get<MesSafetyStatisticsApi.StatisticsReport>(
    '/mes/safety/statistics/report',
    { params },
  );
}
/** å¯¼å‡ºé𐿂£æ˜Žç»† */
export function exportHazardDetail(params: MesSafetyStatisticsApi.StatisticsParam) {
  return requestClient.download('/mes/safety/statistics/export-hazard', { params });
}
src/views/safety/statistics/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,339 @@
<script lang="ts" setup>
import type { TableColumnsType } from 'ant-design-vue';
import type { MesSafetyStatisticsApi } from '#/api/safety/statistics';
import { computed, onMounted, ref } from 'vue';
import { Page } from '#/packages/effects/common-ui/src';
import {
  Button,
  Card,
  Col,
  DatePicker,
  message,
  Row,
  Select,
  Spin,
  Table,
  Tabs,
  Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import {
  exportHazardDetail,
  getStatisticsReport,
} from '#/api/safety/statistics';
import { getWorkshopOptions } from '#/views/safety/risk/shared';
defineOptions({ name: 'SafetyStatistics' });
const { RangePicker } = DatePicker;
const loading = ref(false);
const exporting = ref(false);
const report = ref<MesSafetyStatisticsApi.StatisticsReport>();
const dateRange = ref<[string, string]>();
const workshopId = ref<number>();
const workshopOptions = ref<Array<{ id: number; name: string }>>([]);
const activeTab = ref('hazard');
const summary = computed(() => report.value?.summary);
/** æŸ¥è¯¢å‚æ•° */
function buildParams(): MesSafetyStatisticsApi.StatisticsParam {
  return {
    startDate: dateRange.value?.[0],
    endDate: dateRange.value?.[1],
    workshopId: workshopId.value,
  };
}
async function loadData() {
  loading.value = true;
  try {
    report.value = await getStatisticsReport(buildParams());
  } finally {
    loading.value = false;
  }
}
async function loadWorkshops() {
  const list = await getWorkshopOptions();
  workshopOptions.value = list;
}
/** å¿«æ·åŒºé—´ */
function setQuickRange(type: 'month' | 'quarter' | 'year') {
  const end = dayjs().format('YYYY-MM-DD');
  let start = '';
  if (type === 'month') {
    start = dayjs().startOf('month').format('YYYY-MM-DD');
  } else if (type === 'quarter') {
    start = dayjs().subtract(2, 'month').startOf('month').format('YYYY-MM-DD');
  } else {
    start = dayjs().startOf('year').format('YYYY-MM-DD');
  }
  dateRange.value = [start, end];
  loadData();
}
function resetFilter() {
  dateRange.value = undefined;
  workshopId.value = undefined;
  loadData();
}
async function handleExport() {
  exporting.value = true;
  try {
    const data = await exportHazardDetail(buildParams());
    const blob = new Blob([data as any], {
      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    });
    const url = window.URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = `隐患明细_${dayjs().format('YYYYMMDDHHmmss')}.xlsx`;
    link.click();
    window.URL.revokeObjectURL(url);
    message.success('导出成功');
  } catch {
    message.error('导出失败');
  } finally {
    exporting.value = false;
  }
}
/** åˆ†ç»„统计表格列 */
const groupColumns: TableColumnsType = [
  { title: '分组', dataIndex: 'name', width: 200 },
  { title: '数量', dataIndex: 'total', width: 120, align: 'right' },
  { title: '已完成', dataIndex: 'finished', width: 120, align: 'right' },
  { title: '完成率', dataIndex: 'finishRate', width: 140, align: 'right' },
];
const trainingColumns: TableColumnsType = [
  { title: '培训类型', dataIndex: 'name', width: 200 },
  { title: '场次', dataIndex: 'total', width: 120, align: 'right' },
  { title: '已完成', dataIndex: 'finished', width: 120, align: 'right' },
];
onMounted(async () => {
  await loadWorkshops();
  await loadData();
});
</script>
<template>
  <Page>
    <Spin :spinning="loading">
      <div class="flex flex-col gap-4 p-4">
        <!-- ç­›é€‰åŒº -->
        <Card :bordered="false" class="shadow-sm">
          <div class="flex flex-wrap items-center gap-3">
            <span class="text-gray-600">统计周期</span>
            <RangePicker
              v-model:value="dateRange"
              value-format="YYYY-MM-DD"
              :placeholder="['开始日期', '结束日期']"
            />
            <span class="ml-2 text-gray-600">车间</span>
            <Select
              v-model:value="workshopId"
              class="w-45"
              allow-clear
              placeholder="全部车间"
              :options="workshopOptions.map((w) => ({ label: w.name, value: w.id }))"
            />
            <Button type="primary" @click="loadData">查询</Button>
            <Button @click="resetFilter">重置</Button>
            <div class="ml-auto flex gap-2">
              <Button size="small" @click="setQuickRange('month')">本月</Button>
              <Button size="small" @click="setQuickRange('quarter')">近三月</Button>
              <Button size="small" @click="setQuickRange('year')">本年</Button>
            </div>
          </div>
        </Card>
        <!-- æ±‡æ€»æŒ‡æ ‡ -->
        <Row :gutter="[16, 16]">
          <Col :xs="24" :sm="12" :lg="6">
            <Card :bordered="false" class="shadow-sm">
              <div class="text-gray-500">隐患总数</div>
              <div class="mt-1 text-2xl font-bold text-blue-600">
                {{ summary?.hazardTotal ?? 0 }}
              </div>
              <div class="mt-1 text-xs text-gray-500">
                é—­çޝ {{ summary?.hazardClosed ?? 0 }} é¡¹ï¼Œé—­çŽ¯çŽ‡
                {{ summary?.hazardClosedRate ?? 0 }}%
              </div>
              <div
                v-if="(summary?.hazardOverdue ?? 0) > 0"
                class="mt-1 text-xs font-semibold text-red-500"
              >
                è¶…期 {{ summary?.hazardOverdue }} é¡¹
              </div>
            </Card>
          </Col>
          <Col :xs="24" :sm="12" :lg="6">
            <Card :bordered="false" class="shadow-sm">
              <div class="text-gray-500">作业票总数</div>
              <div class="mt-1 text-2xl font-bold text-green-600">
                {{ summary?.ticketTotal ?? 0 }}
              </div>
              <div class="mt-1 text-xs text-gray-500">
                å·²å½’æ¡£ {{ summary?.ticketArchived ?? 0 }} å¼ 
              </div>
              <div
                v-if="(summary?.ticketRejected ?? 0) > 0"
                class="mt-1 text-xs text-orange-500"
              >
                é©³å›ž {{ summary?.ticketRejected }} å¼ 
              </div>
            </Card>
          </Col>
          <Col :xs="24" :sm="12" :lg="6">
            <Card :bordered="false" class="shadow-sm">
              <div class="text-gray-500">培训场次</div>
              <div class="mt-1 text-2xl font-bold text-purple-600">
                {{ summary?.trainingTotal ?? 0 }}
              </div>
              <div class="mt-1 text-xs text-gray-500">
                å‚è®­ {{ summary?.trainingAttendee ?? 0 }} äººæ¬¡ï¼Œå¹³å‡åˆ†
                {{ summary?.trainingAvgScore ?? '-' }}
              </div>
            </Card>
          </Col>
          <Col :xs="24" :sm="12" :lg="6">
            <Card :bordered="false" class="shadow-sm">
              <div class="text-gray-500">证书总数</div>
              <div class="mt-1 text-2xl font-bold text-orange-600">
                {{ summary?.certificateTotal ?? 0 }}
              </div>
              <div
                v-if="(summary?.certificateExpired ?? 0) > 0"
                class="mt-1 text-xs font-semibold text-red-500"
              >
                å·²è¿‡æœŸ {{ summary?.certificateExpired }} æœ¬
              </div>
              <div v-else class="mt-1 text-xs text-gray-500">无过期证书</div>
            </Card>
          </Col>
        </Row>
        <!-- åˆ†ç»„统计 -->
        <Card :bordered="false" class="shadow-sm">
          <template #title>
            <div class="flex items-center gap-3">
              <span>分组统计</span>
              <Button
                type="primary"
                size="small"
                :loading="exporting"
                @click="handleExport"
              >
                å¯¼å‡ºé𐿂£æ˜Žç»†
              </Button>
            </div>
          </template>
          <Tabs v-model:active-key="activeTab">
            <Tabs.TabPane key="hazard" tab="隐患统计">
              <div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
                <div>
                  <div class="mb-2 font-medium">按车间</div>
                  <Table
                    :data-source="report?.hazardByWorkshop ?? []"
                    :columns="groupColumns"
                    :pagination="false"
                    size="small"
                    row-key="name"
                  >
                    <template #bodyCell="{ column, record }">
                      <template v-if="column.dataIndex === 'finishRate'">
                        <Tag :color="(record.finishRate ?? 0) >= 80 ? 'success' : 'warning'">
                          {{ record.finishRate ?? 0 }}%
                        </Tag>
                      </template>
                    </template>
                  </Table>
                </div>
                <div>
                  <div class="mb-2 font-medium">按等级</div>
                  <Table
                    :data-source="report?.hazardByLevel ?? []"
                    :columns="groupColumns"
                    :pagination="false"
                    size="small"
                    row-key="name"
                  />
                </div>
                <div>
                  <div class="mb-2 font-medium">按来源</div>
                  <Table
                    :data-source="report?.hazardBySource ?? []"
                    :columns="groupColumns"
                    :pagination="false"
                    size="small"
                    row-key="name"
                  />
                </div>
              </div>
            </Tabs.TabPane>
            <Tabs.TabPane key="ticket" tab="作业票统计">
              <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
                <div>
                  <div class="mb-2 font-medium">按作业类型</div>
                  <Table
                    :data-source="report?.ticketByType ?? []"
                    :columns="groupColumns"
                    :pagination="false"
                    size="small"
                    row-key="name"
                  />
                </div>
                <div>
                  <div class="mb-2 font-medium">按状态</div>
                  <Table
                    :data-source="report?.ticketByStatus ?? []"
                    :columns="groupColumns"
                    :pagination="false"
                    size="small"
                    row-key="name"
                  />
                </div>
              </div>
            </Tabs.TabPane>
            <Tabs.TabPane key="training" tab="培训统计">
              <div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
                <div>
                  <div class="mb-2 font-medium">按培训类型</div>
                  <Table
                    :data-source="report?.trainingByType ?? []"
                    :columns="trainingColumns"
                    :pagination="false"
                    size="small"
                    row-key="name"
                  />
                </div>
                <div>
                  <div class="mb-2 font-medium">按证书类型</div>
                  <Table
                    :data-source="report?.certificateByType ?? []"
                    :columns="groupColumns"
                    :pagination="false"
                    size="small"
                    row-key="name"
                  />
                </div>
              </div>
            </Tabs.TabPane>
          </Tabs>
        </Card>
      </div>
    </Spin>
  </Page>
</template>