Преглед на файлове

✨ feat(报表统计): 工单完成情况

Zimo преди 1 ден
родител
ревизия
a729171cae
променени са 2 файла, в които са добавени 509 реда и са изтрити 0 реда
  1. 144 0
      src/components/WorkOrderCompletionBar/index.vue
  2. 365 0
      src/views/report-statistics/work-order-completion.vue

+ 144 - 0
src/components/WorkOrderCompletionBar/index.vue

@@ -0,0 +1,144 @@
+<!-- src/components/MiniBarChart.vue -->
+<script lang="ts" setup>
+import { ref, onMounted, watch, onUnmounted, markRaw } from 'vue'
+import * as echarts from 'echarts/core'
+import { BarChart, type BarSeriesOption } from 'echarts/charts'
+import {
+  GridComponent,
+  type GridComponentOption,
+  TooltipComponent,
+  type TooltipComponentOption
+} from 'echarts/components'
+import { CanvasRenderer } from 'echarts/renderers'
+
+// 注册必须的组件,减小打包体积
+echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer])
+
+type EChartsOption = echarts.ComposeOption<
+  BarSeriesOption | GridComponentOption | TooltipComponentOption
+>
+
+const props = defineProps<{
+  completed: number
+  incomplete: number
+  max: number
+}>()
+
+const chartRef = ref<HTMLElement>()
+let chartInstance: echarts.ECharts | null = null
+
+const initChart = () => {
+  if (!chartRef.value) return
+
+  // 使用 markRaw 避免 Vue 深度监听 echarts 实例导致性能问题
+  chartInstance = markRaw(echarts.init(chartRef.value))
+  updateChart()
+
+  window.addEventListener('resize', handleResize)
+}
+
+const handleResize = () => {
+  chartInstance?.resize()
+}
+
+const updateChart = () => {
+  if (!chartInstance) return
+
+  const option: EChartsOption = {
+    tooltip: {
+      trigger: 'item',
+      formatter: '{b}: {c}', // 鼠标悬停显示: 已完成: 100
+      confine: true, // 限制提示框在图表区域内
+      backgroundColor: 'rgba(50,50,50,0.9)',
+      borderColor: '#333',
+      textStyle: { color: '#fff', fontSize: 12 }
+    },
+    grid: {
+      top: '12%',
+      bottom: '20%', // 留出底部文字空间
+      left: '2%',
+      right: '2%',
+      containLabel: false
+    },
+    xAxis: {
+      type: 'category',
+      data: ['已完成', '未完成'],
+      axisLine: { show: false },
+      axisTick: { show: false },
+      axisLabel: {
+        color: '#9ca3af', // 对应 Tailwind gray-400
+        fontSize: 10,
+        margin: 5,
+        interval: 0
+      }
+    },
+    yAxis: {
+      type: 'value',
+      show: false, // 隐藏 Y 轴
+      max: props.max
+    },
+    series: [
+      {
+        type: 'bar',
+        barWidth: '50%', // 柱子宽度
+        label: {
+          show: true,
+          position: 'top',
+          fontSize: 16,
+          fontWeight: 'bold'
+        },
+        data: [
+          {
+            value: props.completed,
+            name: '已完成',
+            itemStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: 'rgb(179,224.5,156.5)' }, // Emerald 400
+                { offset: 1, color: 'rgb(148.6,212.3,117.1)' } // Emerald 600
+              ]),
+              borderRadius: [4, 4, 4, 4] // 顶部圆角
+            }
+          },
+          {
+            value: props.incomplete,
+            name: '未完成',
+            itemStyle: {
+              color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+                { offset: 0, color: 'rgb(250,181.5,181.5)' }, // Rose 400
+                { offset: 1, color: 'rgb(248,152.1,152.1)' } // Rose 600
+              ]),
+              borderRadius: [4, 4, 4, 4]
+            }
+          }
+        ],
+        // 动画配置
+        animationDuration: 1000,
+        animationEasing: 'cubicOut'
+      }
+    ]
+  }
+
+  chartInstance.setOption(option)
+}
+
+watch(
+  () => [props.completed, props.incomplete],
+  () => {
+    updateChart()
+  }
+)
+
+onMounted(() => {
+  // 稍微延迟一下确保 DOM 渲染完毕,避免容器宽/高为0
+  setTimeout(initChart, 50)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('resize', handleResize)
+  chartInstance?.dispose()
+})
+</script>
+
+<template>
+  <div ref="chartRef" class="w-full h-full"></div>
+</template>

+ 365 - 0
src/views/report-statistics/work-order-completion.vue

@@ -0,0 +1,365 @@
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import dayjs from 'dayjs'
+import { useDebounceFn } from '@vueuse/core'
+import MiniBarChart from '@/components/WorkOrderCompletionBar/index.vue'
+import CountTo from '@/components/count-to1.vue'
+
+// 定义时间类型
+type TimeType = 'year' | 'month' | 'day'
+
+interface Query {
+  deptId?: number
+  createTime?: [string, string]
+  pageNo: number
+  pageSize: number
+}
+
+interface StatItem {
+  key: string
+  title: string
+  icon: string
+  total: number
+  completed: number
+  incomplete: number
+  trend: number
+}
+
+// 选项配置数组
+const timeOptions: { label: string; value: TimeType }[] = [
+  { label: '年', value: 'year' },
+  { label: '月', value: 'month' },
+  { label: '日', value: 'day' }
+]
+
+const activeTimeType = ref<TimeType>('year')
+const query = ref<Query>({
+  pageNo: 1,
+  pageSize: 10
+})
+
+const defaultStats: StatItem[] = [
+  {
+    key: 'run',
+    title: '运行记录',
+    icon: 'i-material-symbols:list-alt-outline',
+    total: 0,
+    completed: 0,
+    incomplete: 0,
+    trend: 0
+  },
+  {
+    key: 'prod',
+    title: '生产日报',
+    icon: 'i-material-symbols:calendar-today-outline',
+    total: 0,
+    completed: 0,
+    incomplete: 0,
+    trend: 0
+  },
+  {
+    key: 'repair',
+    title: '维修工单',
+    icon: 'i-material-symbols:home-repair-service-outline',
+    total: 0,
+    completed: 0,
+    incomplete: 0,
+    trend: 0
+  },
+  {
+    key: 'maintain',
+    title: '保养工单',
+    icon: 'i-material-symbols:construction-rounded',
+    total: 0,
+    completed: 0,
+    incomplete: 0,
+    trend: 0
+  },
+  {
+    key: 'inspect',
+    title: '巡检工单',
+    icon: 'i-material-symbols:warning-outline',
+    total: 0,
+    completed: 0,
+    incomplete: 0,
+    trend: 0
+  }
+]
+
+const statList = ref<StatItem[]>(JSON.parse(JSON.stringify(defaultStats)))
+const dataLoading = ref(false)
+const list = ref<any[]>([])
+const loading = ref(false)
+const total = ref(0)
+
+const handleTimeChange = (type: TimeType, init = false) => {
+  activeTimeType.value = type
+
+  const formatStr = 'YYYY-MM-DD HH:mm:ss'
+  const endTime = dayjs().endOf('day').format(formatStr)
+  let startTime = ''
+
+  switch (type) {
+    case 'year':
+      startTime = dayjs().startOf('year').format(formatStr)
+      break
+    case 'month':
+      startTime = dayjs().startOf('month').format(formatStr)
+      break
+    case 'day':
+      startTime = dayjs().startOf('day').format(formatStr)
+      break
+  }
+
+  query.value.createTime = [startTime, endTime]
+
+  console.log(`切换为[${type}]:`, query.value.createTime)
+
+  if (!init) loadData()
+}
+
+// 模拟数据加载
+const loadData = useDebounceFn(async function () {
+  dataLoading.value = true
+  await new Promise((resolve) => setTimeout(resolve, 600)) // 模拟延迟
+
+  statList.value.forEach((item) => {
+    const randomTotal = Math.floor(Math.random() * 500) + 50
+    const randomCompleted = Math.floor(randomTotal * (0.6 + Math.random() * 0.3))
+    const randomIncomplete = randomTotal - randomCompleted
+    const randomTrend = Number((Math.random() * 20 - 10).toFixed(1))
+
+    item.total = randomTotal
+    item.completed = randomCompleted
+    item.incomplete = randomIncomplete
+    item.trend = randomTrend
+  })
+  dataLoading.value = false
+}, 500)
+
+const loadList = useDebounceFn(async function () {
+  loading.value = true
+  await new Promise((resolve) => setTimeout(resolve, 500))
+
+  const mockTableData = Array.from({ length: query.value.pageSize }).map((_, index) => {
+    const types = ['维修工单', '保养工单', '巡检工单', '运行记录', '生产日报']
+    const companies = ['第一工程公司', '第二工程公司', '总包单位']
+    const statuses = ['已完成', '未完成', '处理中']
+
+    return {
+      id: index + 1,
+      orderType: types[Math.floor(Math.random() * types.length)],
+      createTime: dayjs()
+        .subtract(Math.floor(Math.random() * 10), 'day')
+        .format('YYYY-MM-DD HH:mm:ss'),
+      companyName: companies[Math.floor(Math.random() * companies.length)],
+      projectDept: `项目部-${Math.floor(Math.random() * 10) + 1}`,
+      teamName: `作业队-${String.fromCharCode(65 + Math.floor(Math.random() * 5))}`,
+      status: statuses[Math.floor(Math.random() * statuses.length)],
+      deviceName: `设备-${Math.floor(Math.random() * 1000)}`
+    }
+  })
+
+  list.value = mockTableData
+  total.value = 85
+  loading.value = false
+}, 500)
+
+function handleSizeChange(val: number) {
+  query.value.pageSize = val
+  query.value.pageNo = 1
+  loadList()
+}
+
+function handleCurrentChange(val: number) {
+  query.value.pageNo = val
+  loadList()
+}
+
+function handleQuery(setPage = true) {
+  if (setPage) {
+    query.value.pageNo = 1
+  }
+  loadList()
+  loadData()
+}
+
+onMounted(() => {
+  handleTimeChange('year', true)
+})
+
+watch(
+  [() => query.value.createTime, () => query.value.deptId],
+  () => {
+    handleQuery()
+  },
+  { immediate: true }
+)
+</script>
+
+<template>
+  <div
+    class="grid grid-cols-[15%_1fr] grid-rows-[196px_1fr] gap-4 h-[calc(100vh-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]"
+  >
+    <div class="p-4 bg-white dark:bg-[#1d1e1f] shadow rounded-lg row-span-2">
+      <DeptTreeSelect
+        :top-id="156"
+        :deptId="156"
+        v-model="query.deptId"
+        :init-select="false"
+        :show-title="false"
+      />
+    </div>
+    <div class="flex flex-col gap-4 h-full overflow-hidden">
+      <div class="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-5 gap-4" v-loading="dataLoading">
+        <section
+          v-for="item in statList"
+          :key="item.key"
+          class="bg-white dark:bg-[#1d1e1f] rounded-xl shadow p-4 flex justify-between items-stretch min-h-[140px] border border-gray-200 dark:border-gray-700/50 relative overflow-hidden group transition-colors duration-300"
+        >
+          <!-- 左侧:文字信息 -->
+          <div class="flex flex-col justify-between z-10 w-[55%]">
+            <div>
+              <!-- 标题:白底深灰,黑底浅灰 -->
+              <div class="text-gray-500 dark:text-gray-400 text-sm font-medium mb-1">
+                {{ item.title }}
+              </div>
+              <!-- 数值:白底黑色,黑底白色 -->
+              <div class="text-3xl font-bold tracking-tight text-gray-900! dark:text-white! mt-1">
+                <count-to :start-val="0" :end-val="item.total" :duration="2000" />
+              </div>
+            </div>
+
+            <!-- 环比 -->
+            <div class="flex items-center gap-2 mt-2">
+              <!-- 标签:针对亮色/暗色分别设置背景和文字颜色 -->
+              <div
+                class="px-2 py-0.5 rounded text-xs font-bold flex items-center space-x-1"
+                :class="
+                  item.trend >= 0
+                    ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400'
+                    : 'bg-rose-100 text-rose-600 dark:bg-rose-500/20 dark:text-rose-400'
+                "
+              >
+                <div
+                  :class="
+                    item.trend >= 0
+                      ? 'i-material-symbols:trending-up'
+                      : 'i-material-symbols:trending-down'
+                  "
+                  class="text-sm"
+                ></div>
+                <span>{{ Math.abs(item.trend) }}%</span>
+              </div>
+              <span class="text-xs text-gray-500 dark:text-gray-400">环比</span>
+            </div>
+          </div>
+
+          <!-- 右侧:ECharts图表 -->
+          <div class="w-[45%] h-full z-10 relative">
+            <MiniBarChart
+              :completed="item.completed"
+              :incomplete="item.incomplete"
+              :max="item.total"
+            />
+          </div>
+
+          <!-- 背景装饰:颜色自适应 -->
+          <div
+            class="absolute -right-6 -bottom-6 opacity-[0.05] pointer-events-none transition-transform group-hover:scale-150 duration-500 text-black dark:text-white"
+          >
+            <div :class="item.icon" class="text-9xl"></div>
+          </div>
+        </section>
+      </div>
+      <div class="flex justify-between">
+        <el-button-group size="default">
+          <el-button
+            v-for="item in timeOptions"
+            :key="item.value"
+            :type="activeTimeType === item.value ? 'primary' : ''"
+            @click="handleTimeChange(item.value)"
+          >
+            {{ item.label }}
+          </el-button>
+        </el-button-group>
+        <el-button size="default" type="primary">导出</el-button>
+      </div>
+    </div>
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg p-4 flex flex-col">
+      <div class="flex-1 relative">
+        <el-auto-resizer class="absolute">
+          <template #default="{ width, height }">
+            <el-table
+              :data="list"
+              v-loading="loading"
+              stripe
+              class="absolute"
+              :max-height="height"
+              :height="height"
+              show-overflow-tooltip
+              :width="width"
+              scrollbar-always-on
+            >
+              <el-table-column label="序号" type="index" width="60" align="center" />
+              <el-table-column label="工单类别" prop="orderType" align="center" min-width="110" />
+              <el-table-column label="生成日期" prop="createTime" align="center" min-width="160" />
+              <el-table-column label="公司" prop="companyName" align="center" min-width="140" />
+              <el-table-column label="项目部" prop="projectDept" align="center" min-width="120" />
+              <el-table-column label="队伍" prop="teamName" align="center" min-width="100" />
+              <el-table-column label="状态" prop="status" align="center" width="100">
+                <template #default="{ row }">
+                  <el-tag v-if="row.status === '已完成'" type="success" effect="dark" size="small"
+                    >已完成</el-tag
+                  >
+                  <el-tag
+                    v-else-if="row.status === '未完成'"
+                    type="danger"
+                    effect="dark"
+                    size="small"
+                    >未完成</el-tag
+                  >
+                  <el-tag v-else type="warning" effect="plain" size="small">{{
+                    row.status
+                  }}</el-tag>
+                </template>
+              </el-table-column>
+              <el-table-column label="设备" prop="deviceName" align="center" min-width="120" />
+            </el-table>
+          </template>
+        </el-auto-resizer>
+      </div>
+      <div class="h-10 mt-4 flex items-center justify-end">
+        <el-pagination
+          size="default"
+          v-show="total > 0"
+          v-model:current-page="query.pageNo"
+          v-model:page-size="query.pageSize"
+          :background="true"
+          :page-sizes="[10, 20, 30, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+:deep(.el-table) {
+  border-top-right-radius: 8px;
+  border-top-left-radius: 8px;
+
+  .el-table__cell {
+    height: 52px;
+  }
+
+  .el-table__header-wrapper {
+    .el-table__cell {
+      background: var(--el-fill-color-light);
+    }
+  }
+}
+</style>