|
@@ -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>
|