Parcourir la source

Merge branch 'dev-bxy' into 'dev'

feat: 应急管理总览页面

See merge request product-group-fe/sfy-safety-group/sfy-safety!128
毕欣怡 il y a 9 mois
Parent
commit
3647def66b

+ 81 - 9
src/views/emergency/overview/PageOverview.vue

@@ -1,17 +1,89 @@
 <template>
-  <div class="page-organization"> 总览 </div>
+  <div class="page-overview">
+    <div class="left-container">
+      <div class="left-top">
+        <EmergencyOrganization class="emergency-organization" />
+        <EmergencySupplies class="emergency-supplies" />
+      </div>
+      <div class="left-bottom">
+        <EmergencyPlan class="emergency-plan" />
+        <EmergencyExercise class="emergency-exercise" />
+      </div>
+    </div>
+    <div class="right-container">
+      <EmergencyProcedure />
+    </div>
+  </div>
 </template>
 
 <script setup lang="ts">
-  // 这里可以引入需要的模块和组件
-  import { ref } from 'vue';
-
-  // 示例:定义响应式变量
-  const message = ref('Hello PageOrganization');
+  import EmergencyPlan from './components/EmergencyPlan.vue';
+  import EmergencyExercise from './components/EmergencyExercise.vue';
+  import EmergencyOrganization from './components/EmergencyOrganization.vue';
+  import EmergencySupplies from './components/EmergencySupplies.vue';
+  import EmergencyProcedure from './components/EmergencyProcedure.vue';
 </script>
 
-<style lang="scss" scoped>
-  .page-organization {
-    padding: 24px;
+<style scoped lang="scss">
+  .page-overview {
+    width: 100%;
+    height: 100%;
+    display: flex;
+  }
+
+  .left-container {
+    width: 70%;
+    height: 100%;
+    margin-right: 10px;
+
+    .left-top {
+      width: 100%;
+      height: 286px;
+      margin-bottom: 10px;
+      display: flex;
+
+      .emergency-organization {
+        width: 55%;
+        height: 100%;
+        background-color: #fff;
+        border-radius: 4px;
+        margin-right: 10px;
+      }
+
+      .emergency-supplies {
+        width: calc(45% - 10px);
+        height: 100%;
+        background-color: #fff;
+        border-radius: 4px;
+      }
+    }
+
+    .left-bottom {
+      width: 100%;
+      height: calc(100% - 286px - 10px);
+      display: flex;
+
+      .emergency-plan {
+        width: 55%;
+        height: 100%;
+        background-color: #fff;
+        border-radius: 4px;
+        margin-right: 10px;
+      }
+
+      .emergency-exercise {
+        width: calc(45% - 10px);
+        height: 100%;
+        background-color: #fff;
+        border-radius: 4px;
+      }
+    }
+  }
+
+  .right-container {
+    width: calc(30% - 10px);
+    height: 100%;
+    background-color: #fff;
+    border-radius: 4px;
   }
 </style>

+ 68 - 0
src/views/emergency/overview/components/EmergencyEventsChart.vue

@@ -0,0 +1,68 @@
+<template>
+  <div ref="chartRef" style="height: 216px"></div>
+</template>
+
+<script setup lang="ts">
+  import { ref, onMounted, onBeforeUnmount } from 'vue';
+  import * as echarts from 'echarts';
+
+  const chartRef = ref<HTMLDivElement | null>(null);
+  let chartInstance: echarts.ECharts | null = null;
+
+  const option: echarts.EChartsOption = {
+    grid: {
+      top: 20,
+      bottom: 40,
+      left: 45,
+      right: 30,
+    },
+    tooltip: {
+      trigger: 'axis',
+    },
+    xAxis: {
+      type: 'category',
+      data: [
+        '1\n月',
+        '2\n月',
+        '3\n月',
+        '4\n月',
+        '5\n月',
+        '6\n月',
+        '7\n月',
+        '8\n月',
+        '9\n月',
+        '10\n月',
+        '11\n月',
+        '12\n月',
+      ],
+    },
+    yAxis: {
+      type: 'value',
+    },
+    series: [
+      {
+        name: '事件数',
+        type: 'line',
+        showSymbol: false,
+        data: [3, 10, 2, 22, 1, 28, 23, 190, 15, 12, 5, 30],
+      },
+    ],
+  };
+
+  onMounted(() => {
+    if (chartRef.value) {
+      chartInstance = echarts.init(chartRef.value);
+      chartInstance.setOption(option);
+      window.addEventListener('resize', resizeChart);
+    }
+  });
+
+  function resizeChart() {
+    chartInstance?.resize();
+  }
+
+  onBeforeUnmount(() => {
+    window.removeEventListener('resize', resizeChart);
+    chartInstance?.dispose();
+  });
+</script>

+ 348 - 0
src/views/emergency/overview/components/EmergencyExercise.vue

@@ -0,0 +1,348 @@
+<template>
+  <div class="emergency-exercise-container">
+    <div class="container-title">
+      <span class="line"></span>
+      <span class="title">应急演练</span>
+    </div>
+    <YearSelector class="year-selector" :years="yearsList" :defaultYear="defaultYear" @year-change="handleYearChange" />
+    <div class="container-exercise">
+      <div class="exercise-tabs">
+        <div
+          class="exercise-tab"
+          :class="{ 'active-tab': item.name === activeTab }"
+          v-for="item in exerciseTabs"
+          :key="item.name"
+          @click="handleChangeTab(item.name)"
+        >
+          {{ item.name }}
+        </div>
+      </div>
+      <div class="exercise-counts">
+        <div>
+          <span>年度演练计划</span>
+          <span class="exercise-count">{{ yearCount }}</span>
+        </div>
+        <div>
+          <span>已完成演练</span>
+          <span class="exercise-count">{{ finishedCount }}</span>
+        </div>
+      </div>
+      <div class="exercise-list">
+        <div class="exercise-item" v-for="item in exercisesList" :key="item.id" @click="handleJumpToDetail(item.id)">
+          <div class="exercise-info">
+            <span class="exercise-title">{{ item.title }}</span>
+            <div class="exercise-info-content">
+              <span class="exercise-date">{{ item.date }}</span>
+              <div class="exercise-label" v-if="item.label">
+                <div class="label-content">{{ item.label }}</div>
+              </div>
+            </div>
+          </div>
+          <span class="exercise-dept">{{ item.dept }}</span>
+        </div>
+
+        <!-- 哨兵 -->
+        <div ref="sentinelRef" style="height: 1px"></div>
+
+        <div v-if="isLoading" class="loading">
+          <span>加载中...</span>
+        </div>
+        <div v-if="!hasMore && exercisesList.length > 0" class="no-more">
+          <span>暂无更多应急演练</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, ref } from 'vue';
+  import YearSelector from './YearSelector.vue';
+  import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
+
+  interface Item {
+    id: number;
+    title: string;
+    label?: string;
+    date: string;
+    dept: string;
+  }
+
+  const defaultYear = ref<number>(new Date().getFullYear()); // 默认为当前年份
+  const yearsList = ref<number[]>([2021, 2022, 2023, 2024, 2025]);
+  const yearSelected = ref<number>(defaultYear.value);
+
+  const exerciseTabs = ref([{ name: '院级演练' }, { name: '部门级演练' }]);
+  const activeTab = ref('院级演练');
+
+  const yearCount = ref<number>(0);
+  const finishedCount = ref<number>(0);
+  const exercisesList = ref<Item[]>([]);
+  const page = ref<number>(1);
+  const pageSize = ref<number>(10);
+  const hasMore = ref<boolean>(true);
+  const isLoading = ref<boolean>(false);
+  const sentinelRef = ref<HTMLElement | null>(null); // 哨兵元素引用
+
+  const params = ref({
+    year: yearSelected.value,
+    tab: activeTab.value,
+    page: page.value,
+    pageSize: pageSize.value,
+  });
+  // 请求后端接口获取数据
+  const fetchExerciseData = async () => {
+    if (isLoading.value || !hasMore.value) return;
+
+    isLoading.value = true;
+
+    try {
+      // TODO: 替换为实际的 API 请求
+      // const res = await axios.get('/api/items', params.value);
+      const res = {
+        data: {
+          items: [
+            { id: 1, title: '火灾爆炸事故专项应急预案', label: '已演练', date: '2025-01-01', dept: '安全与应急管理部' },
+            { id: 2, title: '消防安全综合应急预案', label: '已演练', date: '2025-01-01', dept: '安全与应急管理部' },
+            { id: 3, title: '化学品泄漏应急处置方案', label: '已演练', date: '2025-01-01', dept: '环境保护部' },
+            { id: 4, title: '自然灾害应急响应预案', label: '已演练', date: '2025-01-01', dept: '自然灾害管理局' },
+            { id: 5, title: '公共卫生事件应急预案', label: '已演练', date: '2025-01-01', dept: '公共卫生部门' },
+            { id: 6, title: '交通事故应急处理方案', date: '2025-01-01', dept: '交通运输部' },
+            { id: 1, title: '火灾爆炸事故专项应急预案', date: '2025-01-01', dept: '安全与应急管理部' },
+            { id: 2, title: '消防安全综合应急预案', date: '2025-01-01', dept: '安全与应急管理部' },
+            { id: 3, title: '化学品泄漏应急处置方案', date: '2025-01-01', dept: '环境保护部' },
+            { id: 4, title: '自然灾害应急响应预案', date: '2025-01-01', dept: '自然灾害管理局' },
+            { id: 5, title: '公共卫生事件应急预案', date: '2025-01-01', dept: '公共卫生部门' },
+            { id: 6, title: '交通事故应急处理方案', date: '2025-01-01', dept: '交通运输部' },
+            { id: 7, title: '电力系统故障应急预案', date: '2025-01-01', dept: '电力公司' },
+            { id: 8, title: '信息安全事件应急预案', date: '2025-01-01', dept: '信息技术部' },
+            { id: 9, title: '食品安全事故应急预案', date: '2025-01-01', dept: '食品药品监督管理局' },
+            { id: 10, title: '建筑工程事故应急预案', date: '2025-01-01', dept: '建设局' },
+          ],
+        },
+      };
+
+      // TODO: 年度演练计划和已完成演练数量赋值
+      const newItems = res.data.items as Item[];
+
+      if (newItems.length === 0) {
+        hasMore.value = false;
+      } else {
+        exercisesList.value.push(...newItems);
+        page.value++;
+      }
+    } catch (error) {
+      console.error('请求失败:', error);
+    } finally {
+      isLoading.value = false;
+    }
+  };
+
+  // 切换标签/年份都有的状态重置
+  const resetStateAndFetchData = () => {
+    hasMore.value = true;
+    page.value = 1;
+    exercisesList.value = []; // 清空当前列表
+    yearCount.value = 0;
+    finishedCount.value = 0;
+    console.log('params', params.value);
+    fetchExerciseData();
+  };
+
+  // 切换标签时重置状态
+  const handleChangeTab = (tabName: string) => {
+    activeTab.value = tabName;
+    resetStateAndFetchData();
+  };
+
+  const handleYearChange = (year: number) => {
+    yearSelected.value = year;
+    resetStateAndFetchData();
+  };
+
+  // 跳转到详情页
+  const handleJumpToDetail = (id: number) => {
+    // TODO: 替换为实际的路由跳转
+    console.log(`跳转到详情页,ID: ${id}`);
+  };
+
+  onMounted(() => {
+    fetchExerciseData();
+    useInfiniteScroll(sentinelRef, fetchExerciseData);
+  });
+</script>
+
+<style scoped lang="scss">
+  .container-title {
+    display: flex;
+    align-items: center;
+    font-weight: 500;
+    font-size: 16px;
+    color: #000000;
+
+    .line {
+      width: 3px;
+      height: 16px;
+      background: #1777ff;
+      margin-right: 10px;
+    }
+
+    .title {
+      margin-right: 12px;
+    }
+  }
+
+  .emergency-exercise-container {
+    padding: 10px 0;
+    position: relative;
+
+    .year-selector {
+      position: absolute;
+      top: 11px;
+      right: 16px;
+    }
+
+    .container-exercise {
+      width: 100%;
+      height: calc(100% - 34px);
+      margin-top: 10px;
+
+      .exercise-tabs {
+        display: flex;
+        justify-content: space-evenly;
+        align-items: center;
+        font-size: 16px;
+        color: rgba(0, 0, 0, 0.65);
+
+        .exercise-tab {
+          cursor: pointer;
+          padding: 0 10px;
+          position: relative;
+        }
+
+        .exercise-tab:hover {
+          color: #1890ff;
+        }
+
+        .active-tab {
+          color: #1890ff;
+          font-weight: 500;
+        }
+
+        .active-tab::after {
+          content: '';
+          display: block;
+          width: 28px;
+          height: 2px;
+          background-color: #1777ff;
+          border-radius: 2px;
+          position: absolute;
+          left: 50%;
+          margin-top: 5px;
+          transform: translateX(-50%);
+        }
+      }
+
+      .exercise-counts {
+        display: flex;
+        justify-content: space-around;
+        align-items: center;
+        margin: 20px 7px 10px 7px;
+        padding: 12px 0px;
+        background: #e7f1ff;
+        border-radius: 8px;
+        font-weight: 400;
+        font-size: 16px;
+        color: rgba(0, 0, 0, 0.65);
+
+        .exercise-count {
+          color: #1777ff;
+          margin-left: 4px;
+        }
+      }
+
+      .exercise-list {
+        width: 100%;
+        height: calc(100% - 102px - 10px);
+        margin-top: 10px;
+        padding: 0 16px;
+        overflow-y: auto;
+
+        .exercise-item {
+          margin-bottom: 16px;
+          padding-bottom: 18px;
+          border-bottom: 1px solid #e8e8e8;
+          cursor: pointer;
+
+          .exercise-info {
+            display: flex;
+            justify-content: space-between;
+            position: relative;
+            // padding-right: 56px;
+
+            .exercise-title {
+              font-weight: 500;
+              font-size: 16px;
+              color: #333333;
+            }
+
+            .exercise-info-content {
+              display: flex;
+              align-items: center;
+            }
+
+            .exercise-date {
+              font-weight: 400;
+              font-size: 14px;
+              color: #666666;
+            }
+
+            .exercise-label {
+              width: 46px;
+              height: 16px;
+              border-radius: 3px;
+              background: #52c41a;
+              font-size: 10px;
+              color: #ffffff;
+              // position: absolute;
+              // top: 3px;
+              // right: 0;
+              margin-left: 5px;
+              transform: skew(-15deg, 0deg);
+
+              .label-content {
+                transform: skew(15deg, 0deg);
+                text-align: center;
+                line-height: 16px;
+              }
+            }
+          }
+
+          .exercise-dept {
+            font-weight: 400;
+            font-size: 14px;
+            color: #666666;
+          }
+        }
+
+        .exercise-item:hover {
+          .exercise-title,
+          .exercise-dept {
+            color: #1890ff;
+          }
+        }
+
+        .exercise-item:last-child {
+          margin-bottom: 0;
+        }
+      }
+    }
+
+    .loading,
+    .no-more {
+      text-align: center;
+      padding: 10px;
+      color: gray;
+    }
+  }
+</style>

+ 76 - 0
src/views/emergency/overview/components/EmergencyOrganization.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="emergency-organization-container">
+    <div class="container-title">
+      <span class="line"></span>
+      <span class="title">应急架构体系</span>
+    </div>
+    <div class="container-chart">
+      <OrgChart :treeData="treeData" @node-click="handleNodeClick" @canvas-click="handleCanvasClick" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import OrgChart from '../../components/OrgChart.vue';
+
+  const treeData = {
+    id: 'root',
+    data: { name: '应急领导小组' },
+    children: [
+      {
+        id: 'group1',
+        data: { name: '应急指挥小组' },
+      },
+      {
+        id: 'group2',
+        data: { name: '应急响应小组' },
+      },
+      {
+        id: 'group3',
+        data: { name: '应急支援小组' },
+      },
+    ],
+  };
+
+  const handleNodeClick = (nodeData: any) => {
+    console.log('节点被点击:', nodeData);
+    // 在这里处理节点点击事件
+  };
+
+  const handleCanvasClick = () => {
+    console.log('画布被点击');
+    // 在这里处理画布点击事件
+  };
+</script>
+
+<style scoped lang="scss">
+  .container-title {
+    display: flex;
+    align-items: center;
+    font-weight: 500;
+    font-size: 16px;
+    color: #000000;
+
+    .line {
+      width: 3px;
+      height: 16px;
+      background: #1777ff;
+      margin-right: 10px;
+    }
+
+    .title {
+      margin-right: 12px;
+    }
+  }
+
+  .emergency-organization-container {
+    padding: 10px 0;
+
+    .container-chart {
+      width: 100%;
+      height: calc(100% - 34px);
+      margin: 10px 0;
+      padding: 10px;
+    }
+  }
+</style>

+ 241 - 0
src/views/emergency/overview/components/EmergencyPlan.vue

@@ -0,0 +1,241 @@
+<template>
+  <div class="emergency-plan-container">
+    <div class="container-title">
+      <span class="line"></span>
+      <span class="title">应急预案</span>
+    </div>
+    <div class="container-plan">
+      <div class="plan-tabs">
+        <div
+          class="plan-tab"
+          :class="{ 'active-tab': item.name === activeTab }"
+          v-for="item in planTabs"
+          :key="item.name"
+          @click="handleChangeTab(item.name)"
+        >
+          {{ item.name }}
+          <span v-if="item.name === activeTab">({{ activeSum }})</span>
+        </div>
+      </div>
+      <div class="plan-list">
+        <div class="plan-item" v-for="item in plansList" :key="item.id" @click="handleJumpToDetail(item.id)">
+          <span class="plan-title">{{ item.title }}</span>
+          <span class="plan-dept">{{ item.dept }}</span>
+        </div>
+
+        <!-- 哨兵 -->
+        <div ref="sentinelRef" style="height: 1px"></div>
+
+        <div v-if="isLoading" class="loading">
+          <span>加载中...</span>
+        </div>
+        <div v-if="!hasMore && plansList.length > 0" class="no-more">
+          <span>暂无更多应急预案</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, ref } from 'vue';
+  import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
+
+  interface Item {
+    id: number;
+    title: string;
+    dept: string;
+  }
+
+  const planTabs = ref([{ name: '综合预案' }, { name: '专项预案' }, { name: '现场处置方案' }]);
+  const activeTab = ref('综合预案');
+  const activeSum = ref(0);
+
+  const plansList = ref<Item[]>([]);
+  const page = ref<number>(1);
+  // const pageSize = ref<number>(10);
+  const hasMore = ref<boolean>(true);
+  const isLoading = ref<boolean>(false);
+  const sentinelRef = ref<HTMLElement | null>(null); // 哨兵元素引用
+
+  // 请求后端接口获取数据
+  const fetchPlanData = async () => {
+    if (isLoading.value || !hasMore.value) return;
+
+    isLoading.value = true;
+
+    try {
+      // TODO: 替换为实际的 API 请求
+      // const res = await axios.get('/api/items', {
+      //   params: {
+      //     page: page.value,
+      //     pageSize: pageSize.value,
+      //     tab: activeTab.value,
+      //   },
+      // });
+      const res = {
+        data: {
+          items: [
+            { id: 1, title: '火灾爆炸事故专项应急预案', dept: '安全与应急管理部' },
+            { id: 2, title: '消防安全综合应急预案', dept: '安全与应急管理部' },
+            { id: 3, title: '化学品泄漏应急处置方案', dept: '环境保护部' },
+            { id: 4, title: '自然灾害应急响应预案', dept: '自然灾害管理局' },
+            { id: 5, title: '公共卫生事件应急预案', dept: '公共卫生部门' },
+            { id: 6, title: '交通事故应急处理方案', dept: '交通运输部' },
+            { id: 7, title: '电力系统故障应急预案', dept: '电力公司' },
+            { id: 8, title: '信息安全事件应急预案', dept: '信息技术部' },
+            { id: 9, title: '食品安全事故应急预案', dept: '食品药品监督管理局' },
+            { id: 10, title: '建筑工程事故应急预案', dept: '建设局' },
+          ],
+        },
+      };
+
+      const newItems = res.data.items as Item[];
+
+      if (newItems.length === 0) {
+        hasMore.value = false;
+      } else {
+        plansList.value.push(...newItems);
+        page.value++;
+      }
+    } catch (error) {
+      console.error('请求失败:', error);
+    } finally {
+      isLoading.value = false;
+    }
+  };
+
+  // 切换标签时重置状态
+  const handleChangeTab = (tabName: string) => {
+    activeTab.value = tabName;
+    hasMore.value = true;
+    page.value = 1;
+    plansList.value = []; // 清空当前列表
+    fetchPlanData(); // 重新请求数据
+  };
+
+  // 跳转到详情页
+  const handleJumpToDetail = (id: number) => {
+    // TODO: 替换为实际的路由跳转
+    console.log(`跳转到详情页,ID: ${id}`);
+  };
+
+  onMounted(() => {
+    fetchPlanData();
+    useInfiniteScroll(sentinelRef, fetchPlanData);
+  });
+</script>
+
+<style scoped lang="scss">
+  .container-title {
+    display: flex;
+    align-items: center;
+    font-weight: 500;
+    font-size: 16px;
+    color: #000000;
+
+    .line {
+      width: 3px;
+      height: 16px;
+      background: #1777ff;
+      margin-right: 10px;
+    }
+
+    .title {
+      margin-right: 12px;
+    }
+  }
+
+  .emergency-plan-container {
+    padding: 10px 0;
+
+    .container-plan {
+      width: 100%;
+      height: calc(100% - 34px);
+      margin-top: 10px;
+    }
+
+    .plan-tabs {
+      display: flex;
+      justify-content: space-evenly;
+      align-items: center;
+      font-size: 16px;
+      color: rgba(0, 0, 0, 0.65);
+
+      .plan-tab {
+        cursor: pointer;
+        padding: 0 10px;
+        position: relative;
+      }
+
+      .plan-tab:hover {
+        color: #1890ff;
+      }
+
+      .active-tab {
+        color: #1890ff;
+        font-weight: 500;
+      }
+
+      .active-tab::after {
+        content: '';
+        display: block;
+        width: 28px;
+        height: 2px;
+        background-color: #1777ff;
+        border-radius: 2px;
+        position: absolute;
+        left: 50%;
+        margin-top: 5px;
+        transform: translateX(-50%);
+      }
+    }
+
+    .plan-list {
+      width: 100%;
+      height: calc(100% - 54px);
+      margin-top: 20px;
+      padding: 0 16px;
+      overflow-y: auto;
+
+      .plan-item {
+        margin-bottom: 10px;
+        padding-bottom: 10px;
+        border-bottom: 1px solid #ededed;
+        display: flex;
+        justify-content: space-between;
+        cursor: pointer;
+
+        .plan-title {
+          font-weight: 500;
+          font-size: 16px;
+          color: #333333;
+        }
+
+        .plan-dept {
+          font-weight: 400;
+          font-size: 14px;
+          color: #666666;
+        }
+      }
+
+      .plan-item:hover {
+        .plan-title,
+        .plan-dept {
+          color: #1890ff;
+        }
+      }
+
+      .plan-item:last-child {
+        margin-bottom: 0;
+      }
+    }
+
+    .loading,
+    .no-more {
+      text-align: center;
+      padding: 10px;
+      color: gray;
+    }
+  }
+</style>

+ 239 - 0
src/views/emergency/overview/components/EmergencyProcedure.vue

@@ -0,0 +1,239 @@
+<template>
+  <div class="emergency-procedure-container">
+    <div class="container-title">
+      <span class="line"></span>
+      <span class="title">应急处置</span>
+    </div>
+    <YearSelector class="year-selector" :years="yearsList" :defaultYear="defaultYear" @year-change="handleYearChange" />
+    <div class="event-counts">
+      <div v-if="yearSelected === defaultYear">
+        <span>本月应急事件</span>
+        <span class="event-count">{{ monthCount }}</span>
+      </div>
+      <div>
+        <span>本年应急事件</span>
+        <span class="event-count">{{ yearCount }}</span>
+      </div>
+    </div>
+    <EmergencyEventsChart class="events-echarts" />
+    <div class="container-title">
+      <span class="line"></span>
+      <span class="title">应急事件</span>
+    </div>
+    <div class="events-list">
+      <div class="event-item" v-for="(item, index) in eventsList" :key="index">
+        <div class="event-name">{{ item.name }}</div>
+        <div class="event-time">{{ item.time }}</div>
+        <div class="event-status" v-if="item.status">{{ item.status }}</div>
+      </div>
+
+      <!-- 哨兵 -->
+      <div ref="sentinelRef" style="height: 1px"></div>
+
+      <div v-if="isLoading" class="loading">
+        <span>加载中...</span>
+      </div>
+      <div v-if="!hasMore && eventsList.length > 0" class="no-more">
+        <span>暂无更多应急预案</span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, ref } from 'vue';
+  import YearSelector from './YearSelector.vue';
+  import EmergencyEventsChart from './EmergencyEventsChart.vue';
+  import { useInfiniteScroll } from '../hooks/useInfiniteScroll';
+
+  interface EventItem {
+    name: string;
+    time: string;
+    status?: string; // 是否是启动中状态
+  }
+
+  const defaultYear = ref<number>(new Date().getFullYear()); // 默认为当前年份
+  const yearsList = ref<number[]>([2021, 2022, 2023, 2024, 2025]);
+  const yearSelected = ref<number>(defaultYear.value);
+
+  const monthCount = ref<number>(0);
+  const yearCount = ref<number>(0);
+
+  const eventsList = ref<EventItem[]>([]);
+  const page = ref<number>(1);
+  const pageSize = ref<number>(10);
+  const hasMore = ref<boolean>(true);
+  const isLoading = ref<boolean>(false);
+  const sentinelRef = ref<HTMLElement | null>(null); // 哨兵元素引用
+
+  const params = ref({
+    year: yearSelected.value,
+    page: page.value,
+    pageSize: pageSize.value,
+  });
+
+  // 请求后端接口获取数据
+  const fetchExerciseData = async () => {
+    if (isLoading.value || !hasMore.value) return;
+
+    isLoading.value = true;
+
+    try {
+      // TODO: 替换为实际的 API 请求
+      // const res = await axios.get('/api/items', params.value);
+      const res = {
+        data: {
+          items: [
+            { name: '事件1', time: '2023-10-10', status: '启动中' },
+            { name: '事件2', time: '2023-10-10', status: '启动中' },
+            { name: '事件3', time: '2023-10-10', status: '启动中' },
+            { name: '事件4', time: '2023-10-10', status: '启动中' },
+            { name: '事件5', time: '2023-10-10' },
+            { name: '事件6', time: '2023-10-10' },
+            { name: '事件7', time: '2023-10-10' },
+            { name: '事件8', time: '2023-10-10' },
+            { name: '事件9', time: '2023-10-10' },
+            { name: '事件10', time: '2023-10-10' },
+            { name: '事件11', time: '2023-10-10' },
+            { name: '事件12', time: '2023-10-10' },
+          ],
+        },
+      };
+
+      // TODO: 赋值:本月应急事件,本年应急事件,事件列表,折线图
+      const newItems = res.data.items as EventItem[];
+
+      if (newItems.length === 0) {
+        hasMore.value = false;
+      } else {
+        eventsList.value.push(...newItems);
+        page.value++;
+      }
+    } catch (error) {
+      console.error('请求失败:', error);
+    } finally {
+      isLoading.value = false;
+    }
+  };
+
+  // 切换年份的状态重置
+  const resetStateAndFetchData = () => {
+    hasMore.value = true;
+    page.value = 1;
+    eventsList.value = []; // 清空当前列表
+    monthCount.value = 0;
+    yearCount.value = 0;
+    console.log('params', params.value);
+    fetchExerciseData();
+  };
+
+  const handleYearChange = (year: number) => {
+    yearSelected.value = year;
+    resetStateAndFetchData();
+  };
+
+  onMounted(() => {
+    fetchExerciseData();
+    useInfiniteScroll(sentinelRef, fetchExerciseData);
+  });
+</script>
+
+<style scoped lang="scss">
+  .container-title {
+    display: flex;
+    align-items: center;
+    font-weight: 500;
+    font-size: 16px;
+    color: #000000;
+
+    .line {
+      width: 3px;
+      height: 16px;
+      background: #1777ff;
+      margin-right: 10px;
+    }
+
+    .title {
+      margin-right: 12px;
+    }
+  }
+
+  .emergency-procedure-container {
+    width: 100%;
+    height: 100%;
+    padding: 10px 0;
+    position: relative;
+
+    .year-selector {
+      position: absolute;
+      top: 11px;
+      right: 16px;
+    }
+
+    .event-counts {
+      display: flex;
+      justify-content: space-around;
+      align-items: center;
+      margin: 20px 7px 10px 7px;
+      padding: 12px 0px;
+      background: #e7f1ff;
+      border-radius: 8px;
+      font-weight: 400;
+      font-size: 16px;
+      color: rgba(0, 0, 0, 0.65);
+
+      .event-count {
+        color: #1777ff;
+        margin-left: 4px;
+      }
+    }
+
+    .events-echarts {
+      margin: 15px 10px;
+    }
+
+    .events-list {
+      width: 100%;
+      height: calc(100% - 387px);
+      margin-top: 15px;
+      padding: 0 10px;
+      overflow: auto;
+
+      .event-item {
+        width: 100%;
+        height: 82px;
+        margin-bottom: 10px;
+        background: #dcfaff;
+        border-radius: 8px;
+        display: flex;
+        flex-direction: column;
+        justify-content: center;
+        padding: 0 16px;
+        position: relative;
+
+        .event-name {
+          font-weight: 500;
+          font-size: 16px;
+          color: #333333;
+          margin-bottom: 8px;
+        }
+
+        .event-time {
+          font-size: 14px;
+          color: #666666;
+        }
+
+        .event-status {
+          position: absolute;
+          top: 0;
+          right: 0;
+          background: #ff4d4f;
+          border-radius: 0px 8px 0px 8px;
+          font-size: 10px;
+          color: #ffffff;
+          padding: 2px 6px;
+        }
+      }
+    }
+  }
+</style>

+ 130 - 0
src/views/emergency/overview/components/EmergencySupplies.vue

@@ -0,0 +1,130 @@
+<template>
+  <div class="emergency-supplies-container">
+    <div class="container-title">
+      <span class="line"></span>
+      <span class="title">应急物资</span>
+    </div>
+    <div class="supply-counts">
+      <div>
+        <span>共计物资品类</span>
+        <span class="supply-count">{{ categoryCount }}</span>
+      </div>
+      <div>
+        <span>共计物资数量</span>
+        <span class="supply-count">{{ quantityCount }}</span>
+      </div>
+    </div>
+    <div class="supply-list">
+      <div class="supply-item" v-for="(item, index) in supplyCategories" :key="index">
+        <span class="supply-item-name">{{ item.name }}</span>
+        <div class="supply-item-info">
+          <span>物资品类</span>
+          <span class="category-count">{{ item.num }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+
+  const categoryCount = ref<number>(0);
+  const quantityCount = ref<number>(0);
+
+  const supplyCategories = ref<
+    {
+      name: string;
+      num: number;
+    }[]
+  >([
+    { name: '防护类物资', num: 10 },
+    { name: '抢救救援装备', num: 15 },
+    { name: '基础保障物资', num: 1111 },
+    { name: '交通与运输物资', num: 1 },
+  ]);
+</script>
+
+<style scoped lang="scss">
+  .container-title {
+    display: flex;
+    align-items: center;
+    font-weight: 500;
+    font-size: 16px;
+    color: #000000;
+
+    .line {
+      width: 3px;
+      height: 16px;
+      background: #1777ff;
+      margin-right: 10px;
+    }
+
+    .title {
+      margin-right: 12px;
+    }
+  }
+
+  .emergency-supplies-container {
+    padding: 10px 0;
+
+    .supply-counts {
+      display: flex;
+      justify-content: space-around;
+      align-items: center;
+      margin: 20px 7px 10px 7px;
+      padding: 12px 0px;
+      background: #e7f1ff;
+      border-radius: 8px;
+      font-weight: 400;
+      font-size: 16px;
+      color: rgba(0, 0, 0, 0.65);
+
+      .supply-count {
+        color: #1777ff;
+        margin-left: 4px;
+      }
+    }
+
+    .supply-list {
+      width: 100%;
+      height: calc(100% - 102px);
+      padding: 0 10px;
+      display: grid;
+      grid-template-columns: repeat(2, 1fr);
+      grid-gap: 10px;
+
+      .supply-item {
+        background: linear-gradient(90deg, #dbfaff 0%, #f5faff 100%);
+        border-radius: 8px;
+        padding: 10px 16px;
+
+        .supply-item-name {
+          font-weight: 500;
+          font-size: 16px;
+          color: #333333;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+
+        .supply-item-info {
+          margin-top: 8px;
+          font-weight: 400;
+          font-size: 16px;
+          color: #666666;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+
+        .category-count {
+          margin-left: 5px;
+          font-weight: 500;
+          font-size: 18px;
+          color: #333333;
+        }
+      }
+    }
+  }
+</style>

+ 114 - 0
src/views/emergency/overview/components/YearSelector.vue

@@ -0,0 +1,114 @@
+<template>
+  <div class="year-selector">
+    <el-icon class="prev-year" :class="{ disabled: currentYear === props.years[0] }" @click="prevYear"
+      ><CaretLeft
+    /></el-icon>
+    <span class="current-year">{{ currentYear }}</span>
+    <el-icon
+      class="next-year"
+      :class="{ disabled: currentYear === props.years[props.years.length - 1] }"
+      @click="nextYear"
+      ><CaretRight
+    /></el-icon>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref, watch } from 'vue';
+  import { ElIcon } from 'element-plus';
+  import { CaretLeft, CaretRight } from '@element-plus/icons-vue';
+
+  interface Props {
+    years: number[]; // 年份数组
+    defaultYear: number; // 默认年份
+  }
+
+  const props = withDefaults(defineProps<Props>(), {
+    years: () => [],
+    defaultYear: 0,
+  });
+
+  const emit = defineEmits<{
+    (event: 'year-change', year: number): void;
+  }>();
+
+  const currentIndex = ref<number>(0); // 当前索引
+  const currentYear = ref<number>(props.defaultYear); // 当前年份
+
+  // 初始化当前索引
+  watch(
+    () => props.years,
+    (newYears) => {
+      if (newYears.length > 0) {
+        const initialIndex = newYears.indexOf(props.defaultYear);
+        if (initialIndex !== -1) {
+          currentIndex.value = initialIndex;
+          currentYear.value = props.defaultYear;
+        } else {
+          currentIndex.value = 0;
+          currentYear.value = newYears[0];
+        }
+      }
+    },
+    { immediate: true },
+  );
+
+  // 切换年份逻辑
+  function prevYear() {
+    if (currentIndex.value > 0) {
+      currentIndex.value--;
+      currentYear.value = props.years[currentIndex.value];
+      notifyParent(currentYear.value);
+    }
+  }
+
+  function nextYear() {
+    if (currentIndex.value < props.years.length - 1) {
+      currentIndex.value++;
+      currentYear.value = props.years[currentIndex.value];
+      notifyParent(currentYear.value);
+    }
+  }
+
+  function notifyParent(year: number) {
+    emit('year-change', year);
+  }
+</script>
+
+<style scoped lang="scss">
+  .year-selector {
+    display: flex;
+    align-items: center;
+    gap: 5px;
+    padding: 0 5px;
+    border-radius: 4px;
+    border: 1px solid #1777ff;
+  }
+
+  .prev-year,
+  .next-year {
+    cursor: pointer;
+    color: #1777ff;
+    font-size: 14px;
+  }
+
+  .prev-year:hover,
+  .next-year:hover {
+    color: #0056b3;
+  }
+
+  .disabled {
+    color: #d9d9d9;
+    cursor: not-allowed;
+  }
+
+  .disabled:hover {
+    color: #d9d9d9;
+  }
+
+  .current-year {
+    font-weight: 400;
+    font-size: 14px;
+    color: #1777ff;
+  }
+</style>

+ 45 - 0
src/views/emergency/overview/hooks/useInfiniteScroll.ts

@@ -0,0 +1,45 @@
+// src/hooks/useInfiniteScroll.ts
+import { onMounted, onBeforeUnmount, Ref } from 'vue';
+
+interface UseInfiniteScrollOptions {
+  root?: Element | null;
+  rootMargin?: string;
+  threshold?: number;
+}
+
+export function useInfiniteScroll(
+  target: Ref<HTMLElement | null | undefined>,
+  callback: () => void,
+  options: UseInfiniteScrollOptions = {},
+) {
+  let observer: IntersectionObserver | null = null;
+
+  onMounted(() => {
+    const { root, rootMargin = '0px', threshold = 0.1 } = options;
+
+    if (!target.value) return;
+
+    observer = new IntersectionObserver(
+      (entries) => {
+        const [entry] = entries;
+        if (entry.isIntersecting) {
+          callback();
+        }
+      },
+      {
+        root,
+        rootMargin,
+        threshold,
+      },
+    );
+
+    observer.observe(target.value);
+  });
+
+  onBeforeUnmount(() => {
+    if (observer) {
+      observer.disconnect();
+      observer = null;
+    }
+  });
+}