Procházet zdrojové kódy

feat: 院区安全地图

wyf před 6 měsíci
rodič
revize
b1a9d03e9c
19 změnil soubory, kde provedl 1124 přidání a 4 odebrání
  1. binární
      src/assets/images/institute-safety/alert-white.png
  2. binární
      src/assets/images/institute-safety/alert.png
  3. binární
      src/assets/images/institute-safety/close.png
  4. binární
      src/assets/images/institute-safety/config.png
  5. binární
      src/assets/images/institute-safety/control-tab-switch.png
  6. binární
      src/assets/images/institute-safety/rating.png
  7. binární
      src/assets/images/institute-safety/surveillance-area.png
  8. 12 4
      src/views/institute-safety/components/CardMapAndAlert.vue
  9. 34 0
      src/views/institute-safety/modules/safety-company-home/CompanyHome.vue
  10. 11 0
      src/views/institute-safety/modules/safety-company-home/apis/index.ts
  11. 51 0
      src/views/institute-safety/modules/safety-company-home/components/CompanyRating.vue
  12. 111 0
      src/views/institute-safety/modules/safety-company-home/components/ControlTab.vue
  13. 7 0
      src/views/institute-safety/modules/safety-company-home/components/RealtimeSurveillance.vue
  14. 48 0
      src/views/institute-safety/modules/safety-company-home/components/SurveillanceList.vue
  15. 166 0
      src/views/institute-safety/modules/safety-question-list/ImageViewer.vue
  16. 411 0
      src/views/institute-safety/modules/safety-question-list/QuestionList.vue
  17. 1 0
      src/views/institute-safety/modules/safety-question-list/api/index.ts
  18. 51 0
      src/views/institute-safety/modules/safety-question-list/constant.ts
  19. 221 0
      src/views/institute-safety/modules/safety-workshop-list/WorkshopList.vue

binární
src/assets/images/institute-safety/alert-white.png


binární
src/assets/images/institute-safety/alert.png


binární
src/assets/images/institute-safety/close.png


binární
src/assets/images/institute-safety/config.png


binární
src/assets/images/institute-safety/control-tab-switch.png


binární
src/assets/images/institute-safety/rating.png


binární
src/assets/images/institute-safety/surveillance-area.png


+ 12 - 4
src/views/institute-safety/components/CardMapAndAlert.vue

@@ -1,9 +1,17 @@
 <template>
-  <div>
-    <img style="width: 100%" src="@/assets/images/sfy.jpg" alt="" />
+  <div class="map-alert">
+    <CompanyHome />
+    <WorkshopList />
   </div>
 </template>
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+  import CompanyHome from '@/views/institute-safety/modules/safety-company-home/CompanyHome.vue';
+  import WorkshopList from '@/views/institute-safety/modules/safety-workshop-list/WorkshopList.vue';
+</script>
 
-<style scoped lang="scss"></style>
+<style scoped>
+  .map-alert {
+    padding: 10px;
+  }
+</style>

+ 34 - 0
src/views/institute-safety/modules/safety-company-home/CompanyHome.vue

@@ -0,0 +1,34 @@
+<template>
+  <div class="company-home">
+    <RealtimeSurveillance v-if="curCamCode" :code="curCamCode" />
+    <img v-else style="width: 100%" src="@/assets/images/sfy.jpg" alt="" />
+    <CompanyRating />
+    <ControlTab @open-surveillance-list="showSurveillanceList = true" @open-question-list="showQuestionList = true" />
+    <SurveillanceList v-if="showSurveillanceList" @close="showSurveillanceList = false" />
+
+    <!-- <QuestionList v-if="showQuestionList" /> -->
+  </div>
+</template>
+
+<script setup lang="ts">
+  import CompanyRating from './components/CompanyRating.vue';
+  import ControlTab from './components/ControlTab.vue';
+  import SurveillanceList from './components/SurveillanceList.vue';
+  import RealtimeSurveillance from './components/RealtimeSurveillance.vue';
+  import QuestionList from '../safety-question-list/QuestionList.vue';
+
+  import { ref } from 'vue';
+
+  const curCamCode = ref<null | string>(null);
+
+  const showSurveillanceList = ref(false);
+  const showQuestionList = ref(false);
+</script>
+
+<style scoped>
+  .company-home {
+    width: 100%;
+    position: relative;
+    overflow: hidden;
+  }
+</style>

+ 11 - 0
src/views/institute-safety/modules/safety-company-home/apis/index.ts

@@ -0,0 +1,11 @@
+import { http } from '@/utils/http/axios';
+
+// 查询今日异常告警数量
+export function getTodayIssueNumber() {
+  return http.request<number>({
+    url: '/issue/queryTodayIssueCount',
+    method: 'get',
+  });
+}
+
+// 查询重点监控区域

+ 51 - 0
src/views/institute-safety/modules/safety-company-home/components/CompanyRating.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="company-rating">
+    <img class="rating-img" src="@/assets/images/institute-safety/rating.png" alt="" />
+    <div>
+      <div class="rating-label">{{ rating ? '今日AI检测异常问题' : '今日院区安全指数:' }}</div>
+      <div class="rating-value">{{ rating ? rating : '100%' }}</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, ref } from 'vue';
+  import { getTodayIssueNumber } from '../apis';
+
+  const rating = ref();
+
+  onMounted(async () => {
+    const res = await getTodayIssueNumber();
+    rating.value = res;
+  });
+</script>
+
+<style scoped>
+  .company-rating {
+    position: absolute;
+    top: 12px;
+    left: 12px;
+
+    width: 204px;
+    height: 84px;
+    background: rgba(0, 0, 0, 0.3);
+    border-radius: 4px;
+    backdrop-filter: blur(10px);
+
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 8px;
+  }
+  .rating-label {
+    font-weight: 500;
+    font-size: 14px;
+    color: #ffffff;
+  }
+  .rating-value {
+    font-family: DINAlternate, DINAlternate;
+    font-weight: bold;
+    font-size: 36px;
+    color: #17cba5;
+  }
+</style>

+ 111 - 0
src/views/institute-safety/modules/safety-company-home/components/ControlTab.vue

@@ -0,0 +1,111 @@
+<template>
+  <div class="control-tab" :class="{ 'control-tab_is-active': isOpen }">
+    <div @click="handleOpenTab" class="control-tab-switch">
+      <img style="margin: 10px 0" src="@/assets/images/institute-safety/control-tab-switch.png" alt="" />
+    </div>
+    <div class="control-tab-content">
+      <div class="tab-button" @click="emit('open-surveillance-list')">
+        <img src="@/assets/images/institute-safety/surveillance-area.png" alt="" />
+        <div class="tab-text">重点监控区域</div>
+      </div>
+      <div class="tab-button" @click="emit('open-question-list')">
+        <img src="@/assets/images/institute-safety/alert-white.png" alt="" />
+        <div class="tab-text">今日异常告警</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ref } from 'vue';
+
+  const emit = defineEmits<{
+    (e: 'open-surveillance-list'): void;
+    (e: 'open-question-list'): void;
+  }>();
+
+  const isOpen = ref(false);
+
+  const handleOpenTab = () => {
+    isOpen.value = !isOpen.value;
+  };
+</script>
+
+<style scoped>
+  .control-tab {
+    position: absolute;
+    bottom: 10px;
+    right: -86px;
+
+    width: 102px;
+
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    transition-duration: 0.3s;
+    transition-property: all;
+  }
+
+  .control-tab_is-active {
+    transform: translateX(-86px);
+  }
+
+  .control-tab-switch {
+    cursor: pointer;
+
+    width: 16px;
+    /* height: 75px; */
+    background: rgba(50, 50, 50, 1);
+    backdrop-filter: blur(10px);
+  }
+
+  .control-tab-switch::before {
+    content: '';
+    display: block;
+
+    position: relative;
+    bottom: 5px;
+
+    width: 16px;
+    height: 10px;
+    transform: skewY(-30deg);
+    background: rgba(50, 50, 50, 1);
+    backdrop-filter: blur(10px);
+  }
+
+  .control-tab-switch::after {
+    content: '';
+    display: block;
+
+    position: relative;
+    top: 5px;
+
+    width: 16px;
+    height: 10px;
+    transform: skewY(30deg);
+    background: rgba(50, 50, 50, 1);
+    backdrop-filter: blur(10px);
+  }
+
+  .control-tab-content {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    gap: 28px;
+    background: rgba(0, 0, 0, 0.5);
+    backdrop-filter: blur(10px);
+    border-radius: 5px;
+    padding: 16px 7px;
+  }
+  .tab-button {
+    cursor: pointer;
+    text-align: center;
+  }
+  .tab-text {
+    font-weight: 400;
+    font-size: 12px;
+    color: #ffffff;
+  }
+</style>

+ 7 - 0
src/views/institute-safety/modules/safety-company-home/components/RealtimeSurveillance.vue

@@ -0,0 +1,7 @@
+<template>
+  <div> </div>
+</template>
+
+<script setup lang="ts"></script>
+
+<style scoped></style>

+ 48 - 0
src/views/institute-safety/modules/safety-company-home/components/SurveillanceList.vue

@@ -0,0 +1,48 @@
+<template>
+  <div class="surveillance-list">
+    <header class="list-header">
+      <img class="config-btn" src="@/assets/images/institute-safety/config.png" alt="" />
+      <span> 重点监控区域 </span>
+      <img class="close-btn" src="@/assets/images/institute-safety/close.png" alt="" @click="emits('close')" />
+    </header>
+    <main> </main>
+  </div>
+</template>
+
+<script setup lang="ts">
+  const emits = defineEmits<{
+    (e: 'close'): void;
+  }>();
+</script>
+
+<style scoped>
+  .surveillance-list {
+    width: 280px;
+    height: 675px;
+
+    position: absolute;
+    top: 0px;
+    right: 0px;
+
+    background: rgba(0, 0, 0, 0.5);
+    border-radius: 12px 4px 4px 12px;
+    backdrop-filter: blur(10px);
+  }
+
+  .list-header {
+    margin: 12px 0;
+    padding: 0 16px;
+
+    font-weight: 500;
+    font-size: 14px;
+    color: #ffffff;
+
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+  }
+  .config-btn,
+  .close-btn {
+    cursor: pointer;
+  }
+</style>

+ 166 - 0
src/views/institute-safety/modules/safety-question-list/ImageViewer.vue

@@ -0,0 +1,166 @@
+<template>
+  <div class="image-gallery-container">
+    <div
+      v-if="images.length > 1"
+      class="nav-arrow left-arrow"
+      :class="{ disabled: currentIndex === 0 }"
+      @click="previousImage"
+    >
+      <el-icon><ArrowLeftBold /></el-icon>
+    </div>
+
+    <div class="image-stage" @dblclick="handleOpenViewer">
+      <img v-if="currentImage" :src="currentImage" alt="" />
+      <div v-else>无图片</div>
+    </div>
+
+    <div
+      v-if="images.length > 1"
+      class="nav-arrow right-arrow"
+      :class="{ disabled: currentIndex === images.length - 1 }"
+      @click="nextImage"
+    >
+      <el-icon><ArrowRightBold /></el-icon>
+    </div>
+
+    <div v-if="images.length > 1" class="image-counter"> {{ currentIndex + 1 }} / {{ images.length }} </div>
+  </div>
+  <el-image-viewer
+    v-if="showImageViewer"
+    :url-list="images"
+    :initial-index="currentIndex"
+    @close="showImageViewer = false"
+  />
+</template>
+
+<script lang="ts" setup>
+import { ref, computed, watch } from "vue";
+import { ElImageViewer, ElIcon } from "element-plus";
+import { ArrowLeftBold, ArrowRightBold } from "@element-plus/icons-vue";
+
+interface Props {
+  images: string[];
+  initialIndex?: number;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  initialIndex: 0,
+});
+
+const currentIndex = ref(props.initialIndex);
+const showImageViewer = ref(false);
+
+const handleOpenViewer = () => {
+  showImageViewer.value = true;
+};
+
+const currentImage = computed(() => {
+  if (props.images.length === 0) return "";
+  return props.images[currentIndex.value] || props.images[0];
+});
+
+const previousImage = () => {
+  if (currentIndex.value > 0) {
+    currentIndex.value--;
+  }
+};
+
+const nextImage = () => {
+  if (currentIndex.value < props.images.length - 1) {
+    currentIndex.value++;
+  }
+};
+
+watch(
+  () => props.images,
+  () => {
+    currentIndex.value = 0;
+  },
+);
+
+watch(
+  () => props.initialIndex,
+  (newIndex) => {
+    if (newIndex >= 0 && newIndex < props.images.length) {
+      currentIndex.value = newIndex;
+    }
+  },
+);
+</script>
+
+<style lang="scss" scoped>
+.image-gallery-container {
+  width: 100%;
+  height: 100%;
+  background-color: #000;
+  position: relative;
+}
+
+.image-stage {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+
+  img {
+    max-width: 100%;
+    max-height: 100%;
+  }
+}
+
+.nav-arrow {
+  position: absolute;
+  top: 50%;
+  transform: translateY(-50%);
+  width: 40px;
+  height: 40px;
+  background-color: rgba(255, 255, 255, 0.5);
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  color: #ffffff;
+  z-index: 10;
+
+  &:hover:not(.disabled) {
+    background-color: rgba(24, 144, 255, 0.8);
+    transform: translateY(-50%) scale(1.1);
+  }
+
+  &.disabled {
+    opacity: 0.3;
+    cursor: not-allowed;
+    transform: translateY(-50%) scale(1);
+  }
+
+  &.left-arrow {
+    left: 20px;
+  }
+
+  &.right-arrow {
+    right: 20px;
+  }
+
+  .el-icon {
+    font-size: 18px;
+  }
+}
+
+.image-counter {
+  position: absolute;
+  left: 50%;
+  bottom: 10px;
+  transform: translateX(-50%);
+  text-align: center;
+  padding: 12px;
+  background-color: #333;
+  border-radius: 6px;
+  font-size: 14px;
+  color: #ccc;
+}
+</style>

+ 411 - 0
src/views/institute-safety/modules/safety-question-list/QuestionList.vue

@@ -0,0 +1,411 @@
+<template>
+  <div class="mask-bg">
+    <div class="abnormal-wrapper">
+      <div class="title-pane">
+        <div class="title"><span>异常告警</span></div>
+        <img src="@/assets/images/video-grid/title-decoration.png" alt="" />
+        <el-icon class="close-icon" @click="handleClose"><Close /></el-icon>
+      </div>
+      <div class="content-container">
+        <div class="list-pane">
+          <el-scrollbar class="list-scroll">
+            <div
+              v-for="(it, idx) in items"
+              :key="it.id"
+              class="list-item"
+              :class="{ active: idx === selectedIndex }"
+              @click="onSelect(idx)"
+            >
+              <div class="thumb">
+                <img :src="it.pictures[0]" alt="" />
+              </div>
+              <div class="meta">
+                <div class="row">
+                  <span class="label">类型:</span>
+                  <span class="value ellipsis" :title="it.title">{{ it.title }}</span>
+                </div>
+                <div class="row">
+                  <span class="label">地点:</span>
+                  <span class="value ellipsis" :title="it.workspaceName">{{ it.workspaceName }}</span>
+                </div>
+                <div class="row">
+                  <span class="label">时间:</span>
+                  <span class="value ellipsis" :title="it.createdAt">{{ it.createdAt }}</span>
+                </div>
+              </div>
+            </div>
+            <div v-if="!items.length" class="empty-tip">暂无数据</div>
+          </el-scrollbar>
+        </div>
+
+        <div class="content-pane">
+          <div class="tabs">
+            <div class="tab" :class="{ on: activeTab === 'image' }" @click="activeTab = 'image'">异常画面</div>
+            <div
+              v-if="current?.videos.length"
+              class="tab"
+              :class="{ on: activeTab === 'video' }"
+              @click="activeTab = 'video'"
+              >视频回放</div
+            >
+            <div class="tab" :class="{ on: activeTab === 'live' }" @click="activeTab = 'live'">实时画面</div>
+          </div>
+
+          <div class="stage">
+            <div v-show="activeTab === 'image'" class="stage-inner">
+              <ImageViewer v-if="current?.pictures.length" :images="current?.pictures" :initial-index="0" />
+            </div>
+
+            <div v-show="activeTab === 'video'" class="stage-inner">
+              <div class="media video-stage">
+                <video
+                  v-if="current?.videos.length"
+                  ref="videoRef"
+                  preload="metadata"
+                  :src="current?.videos[0]"
+                  controls
+                  playsinline
+                ></video>
+                <div v-else class="media-placeholder">无视频</div>
+              </div>
+            </div>
+
+            <div v-show="activeTab === 'live'" class="stage-inner">
+              <div class="media">
+                <div class="media-placeholder" id="live-stage">
+                  <LiveVideo
+                    :url="getWsUrl(currentStreamIp)"
+                    :poster="currentImageUrl"
+                    @dblclick="isFullScreen ? exitFullscreen() : fullScreen('live-stage', 'single')"
+                  />
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { computed, onMounted, ref, watch } from 'vue';
+  import { ElIcon, ElScrollbar } from 'element-plus';
+  import { Close } from '@element-plus/icons-vue';
+  import { storeToRefs } from 'pinia';
+  import urlJoin from 'url-join';
+  import { userSplitScreenFullScreen } from '@/store/modules/userSplitScreenFullScreen';
+  import ImageViewer from './ImageViewer.vue';
+  import LiveVideo from '@/components/live/LiveVideoFlv.vue';
+  import { QueryTodayIssueListByWorkspaceRes, getTodayQuestionListApi } from '@/apis/splitScreenRetrieval';
+  import { getCameraInfoDetail } from '@/apis/camera/camera';
+
+  const { isFullScreen } = storeToRefs(userSplitScreenFullScreen());
+  const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+
+  const props = defineProps<{
+    id: number;
+  }>();
+
+  const emits = defineEmits<{
+    (e: 'close'): void;
+  }>();
+
+  const items = ref<QueryTodayIssueListByWorkspaceRes[]>([]);
+  const selectedIndex = ref<number>(0);
+  const current = computed<QueryTodayIssueListByWorkspaceRes | undefined>(() => items.value[selectedIndex.value]);
+  const currentImageUrl = ref('');
+  const currentStreamIp = ref('');
+
+  const activeTab = ref<'image' | 'video' | 'live'>('image');
+
+  const videoRef = ref<HTMLVideoElement | null>(null);
+
+  watch([activeTab, () => selectedIndex.value], () => {
+    if (videoRef.value) {
+      videoRef.value.pause();
+      videoRef.value.currentTime = Math.min(videoRef.value.currentTime, videoRef.value.duration || 0);
+    }
+  });
+
+  const isHttps = () => {
+    return window.location.protocol.startsWith('https');
+  };
+
+  const getWsUrl = (videoUrl: string) => {
+    if (!videoUrl) return '';
+    const protocol = isHttps() ? 'wss' : 'ws';
+    // 如果是绝对地址
+    if (videoUrl.startsWith('http')) {
+      // 如果是https的话,websocket要用wss
+      return videoUrl.replace('http', protocol);
+    }
+    const u = urlJoin(
+      `${protocol}://`,
+      window.location.host,
+      window.location.pathname === '/' ? '' : window.location.pathname,
+      videoUrl,
+    );
+    return u;
+  };
+
+  const onSelect = (idx: number): void => {
+    selectedIndex.value = idx;
+    activeTab.value = 'image';
+  };
+
+  const handleClose = () => {
+    emits('close');
+  };
+
+  const load = async (): Promise<void> => {
+    items.value = await getTodayQuestionListApi(props.id);
+    selectedIndex.value = 0;
+    activeTab.value = 'image';
+  };
+
+  watch(
+    () => current.value,
+    (newVal) => {
+      getCameraInfoDetail(newVal?.cameraCode || '').then((res) => {
+        currentImageUrl.value = res?.pushStreamDTO?.imageUrl || '';
+        currentStreamIp.value = res?.pushStreamDTO?.videoUrls?.pushstreamIp || '';
+      });
+    },
+  );
+
+  watch(
+    () => props.id,
+    () => {
+      load();
+    },
+    { immediate: false },
+  );
+
+  onMounted(() => {
+    load();
+  });
+</script>
+
+<style scoped lang="scss">
+  .mask-bg {
+    width: 100%;
+    height: 100%;
+    background-color: rgba(0, 0, 0, 0.8);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+  }
+
+  .abnormal-wrapper {
+    width: 78.5%;
+    height: 71.5%;
+    padding: 15px 12px 12px 12px;
+    background: rgba(0, 0, 0, 0.5);
+    backdrop-filter: blur(10px);
+  }
+
+  .title-pane {
+    width: 100%;
+    height: 47px;
+    margin-bottom: 10px;
+    display: flex;
+    align-items: center;
+
+    .title {
+      width: 370px;
+      height: 100%;
+      background: url(@/assets/icons/violation-title-bg.png) no-repeat;
+      background-size: 100% 100%;
+      position: relative;
+
+      span {
+        position: absolute;
+        top: 8px;
+        left: 68px;
+        font-size: 20px;
+        color: #d8f0ff;
+      }
+    }
+
+    img {
+      height: 21px;
+    }
+
+    .close-icon {
+      font-size: 24px;
+      margin-left: auto;
+      cursor: pointer;
+    }
+
+    .close-icon:hover {
+      color: #177dff;
+    }
+  }
+
+  .content-container {
+    width: 100%;
+    height: calc(100% - 57px);
+    border: 1px solid;
+    border-image: linear-gradient(to right, #1890ff80, #1890ff00, #1890ff80) 1;
+    padding: 12px;
+    display: flex;
+  }
+
+  .list-pane {
+    width: 25%;
+    height: 100%;
+
+    .list-scroll {
+      width: 100%;
+      height: 100%;
+      padding-right: 15px;
+    }
+
+    .list-item {
+      width: 100%;
+      height: 100px;
+      display: grid;
+      grid-template-columns: 137px 1fr;
+      align-items: center;
+      column-gap: 12px;
+      padding: 12px;
+      box-sizing: border-box;
+      margin-bottom: 12px;
+      transition: transform 0.1s ease, background 0.2s ease;
+      cursor: pointer;
+    }
+    .list-item:hover {
+      transform: translateY(-1px);
+      background-image: url('@/assets/images/video-grid/item-hover.png');
+      background-size: 100% 100%;
+      background-position: center;
+    }
+    .list-item.active {
+      background-image: url('@/assets/images/video-grid/item-active.png');
+      background-size: 100% 100%;
+      background-position: center;
+    }
+
+    .thumb {
+      width: 137px;
+      height: 76px;
+      background: #000;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      overflow: hidden;
+
+      img {
+        width: 100%;
+        height: 100%;
+        object-fit: cover;
+      }
+    }
+
+    .meta {
+      display: flex;
+      flex-direction: column;
+      row-gap: 6px;
+      min-width: 0;
+
+      .row {
+        display: flex;
+        align-items: center;
+        gap: 6px;
+        min-width: 0;
+      }
+      .label {
+        color: #9bb2c9;
+        font-size: 12px;
+        flex: none;
+      }
+      .value {
+        color: #e9f0f7;
+        font-size: 13px;
+      }
+    }
+  }
+
+  .content-pane {
+    width: calc(75% - 20px);
+    height: 100%;
+    margin-left: 20px;
+
+    .tabs {
+      width: fit-content;
+      height: 48px;
+      background: rgba(255, 255, 255, 0.1);
+      border-radius: 6px;
+      padding: 4px;
+      display: flex;
+
+      .tab {
+        width: 177px;
+        height: 40px;
+        border-radius: 4px;
+        font-size: 16px;
+        color: rgba(255, 255, 255, 0.8);
+        letter-spacing: 1px;
+        text-shadow: inset 0px 1px 0px #dcdfe6, inset 0px -1px 0px #dcdfe6, inset -1px 0px 0px #dcdfe6;
+        line-height: 40px;
+        cursor: pointer;
+        margin-right: 2px;
+      }
+
+      .tab:last-child {
+        margin-right: 0;
+      }
+
+      .tab:hover {
+        background: rgba(255, 255, 255, 0.2);
+      }
+
+      .tab.on {
+        background: rgba(19, 147, 255, 0.3);
+        font-weight: 500;
+        color: #ffffff;
+      }
+    }
+
+    .stage {
+      width: 100%;
+      height: calc(100% - 48px - 24px - 14px);
+      margin: 24px 0 14px 0;
+
+      .stage-inner {
+        width: 100%;
+        height: 100%;
+      }
+
+      .media {
+        width: 100%;
+        height: 100%;
+        background: #000;
+        color: #cbd5e1;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        position: relative;
+        overflow: hidden;
+      }
+
+      .video-stage video {
+        width: 100%;
+        height: 100%;
+        object-fit: contain;
+        background: #000;
+      }
+
+      .media-placeholder {
+        height: 100%;
+      }
+    }
+  }
+
+  .ellipsis {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+</style>

+ 1 - 0
src/views/institute-safety/modules/safety-question-list/api/index.ts

@@ -0,0 +1 @@
+import { http } from '@/utils/http/axios';

+ 51 - 0
src/views/institute-safety/modules/safety-question-list/constant.ts

@@ -0,0 +1,51 @@
+export const DEFAULT_PAGE_NUMBER = 1;
+export const DEFAULT_PAGE_SIZE = 10;
+export const DEFAULT_TOTAL_PAGE = 1;
+export const DEFAULT_TOTAL_ROW = 0;
+
+export enum QUESTION_SOURCE_TYPE {
+  // ai检测
+  ai = 1,
+  // 人工上报
+  manual = 2,
+  // 全部
+  all = 3,
+}
+
+export enum QUESTION_TYPE_MAIN {
+  fromHuman = 1, //人的不安全行为
+  fromThing = 2, //物的不安全状态
+  fromEnvir = 3, //环境的不安全因素
+  fromManage = 4, //管理措施的不规范
+}
+
+export const questionMainTypeNameMap = {
+  [QUESTION_TYPE_MAIN.fromHuman]: "人的不安全行为",
+  [QUESTION_TYPE_MAIN.fromThing]: "物的不安全状态",
+  [QUESTION_TYPE_MAIN.fromEnvir]: "环境的不安全因素",
+  [QUESTION_TYPE_MAIN.fromManage]: "管理措施的不规范",
+};
+
+export enum QUESTION_SEVERITY {
+  slight = 1, // 轻微问题
+  general = 2, // 一般问题
+  serious = 3, // 严重问题
+}
+
+export const questionSeverityNameMap = {
+  [QUESTION_SEVERITY.slight]: "轻微",
+  [QUESTION_SEVERITY.general]: "一般",
+  [QUESTION_SEVERITY.serious]: "严重",
+};
+
+// 问题处理状态:正常,超期未处理、长期未处理
+export enum IssueProcessState {
+  NOT_OVERDUE = 0, // 正常
+  OVERDUE = 1, // 超期(逾期)
+  LONGTIME = 2, // 长期
+}
+
+export const IssueProcessStateName = {
+  [IssueProcessState.OVERDUE]: "超期未处理",
+  [IssueProcessState.LONGTIME]: "长期未处理",
+} as const;

+ 221 - 0
src/views/institute-safety/modules/safety-workshop-list/WorkshopList.vue

@@ -0,0 +1,221 @@
+<template>
+  <div class="workshop-list">
+    <div class="workshop-list-item" v-for="item in staticWorkshopList" :key="item.id">
+      <div class="workshop-list-item-id">{{ item.id }}</div>
+      <el-tooltip placement="top" :content="item.name">
+        <div class="workshop-list-item-name">{{ item.name }}</div>
+      </el-tooltip>
+      <img class="workshop-list-item-icon" v-if="true" src="@/assets/images/institute-safety/alert.png" alt="" />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { ElTooltip } from 'element-plus';
+  const staticWorkshopList = [
+    {
+      id: 1,
+      name: '设计研发大楼',
+    },
+    {
+      id: 2,
+      name: '信息中心',
+    },
+    {
+      id: 3,
+      name: '档案中心',
+    },
+    {
+      id: 4,
+      name: '职工食堂1',
+    },
+    {
+      id: 5,
+      name: '综合楼',
+    },
+    {
+      id: 6,
+      name: '行政办公楼',
+    },
+    {
+      id: 7,
+      name: '总师楼',
+    },
+    {
+      id: 8,
+      name: '客户选型中心及多功能样机库',
+    },
+    {
+      id: 9,
+      name: '职工食堂2',
+    },
+    {
+      id: 10,
+      name: 'C919系统综合实验室',
+    },
+    {
+      id: 11,
+      name: '35KV变电站',
+    },
+    {
+      id: 12,
+      name: '强度试验室',
+    },
+    {
+      id: 13,
+      name: '垃圾房',
+    },
+    {
+      id: 14,
+      name: '配电站3',
+    },
+    {
+      id: 15,
+      name: '机构功能试验室',
+    },
+    {
+      id: 16,
+      name: '环控试验室',
+    },
+    {
+      id: 17,
+      name: '空压站',
+    },
+    {
+      id: 18,
+      name: '燃油试验室',
+    },
+    {
+      id: 19,
+      name: '防火试验室',
+    },
+    {
+      id: 20,
+      name: '煤油泵站',
+    },
+    {
+      id: 21,
+      name: '单身公寓',
+    },
+    {
+      id: 22,
+      name: '职工活动中心',
+    },
+    {
+      id: 23,
+      name: '科技创新楼',
+    },
+    {
+      id: 24,
+      name: '综合保障管理中心',
+    },
+    {
+      id: 25,
+      name: '电磁环境效应试验室',
+    },
+    {
+      id: 26,
+      name: '配电站1',
+    },
+    {
+      id: 27,
+      name: '配电站2',
+    },
+    {
+      id: 28,
+      name: '阅览中心',
+    },
+    {
+      id: 29,
+      name: '支线客机系统综合试验室',
+    },
+    {
+      id: 30,
+      name: '飞控系统试验室',
+    },
+    {
+      id: 31,
+      name: '航电系统试验室',
+    },
+    {
+      id: 32,
+      name: '电气系统试验室',
+    },
+    {
+      id: 33,
+      name: 'CR929系统综合试验室',
+    },
+    {
+      id: 34,
+      name: '总体集成试验室',
+    },
+    {
+      id: 35,
+      name: '数字仿真试验室',
+    },
+    {
+      id: 36,
+      name: '先进材料试验室',
+    },
+    {
+      id: 37,
+      name: '型号发展中心',
+    },
+    {
+      id: 38,
+      name: '结构综合试验室',
+    },
+    {
+      id: 39,
+      name: '基础技术试验室',
+    },
+  ];
+</script>
+
+<style scoped>
+  .workshop-list {
+    display: grid;
+    grid-template-columns: repeat(auto-fill, 200px);
+  }
+  .workshop-list-item {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: flex-start;
+    width: 200px;
+    height: 40px;
+    border: 1px solid #e8e8e8;
+  }
+  .workshop-list-item:nth-child(n + 12) {
+    border: 1px solid rgba(232, 232, 232, 0.6);
+  }
+  .workshop-list-item:nth-child(n + 30) {
+    border: 1px solid rgba(232, 232, 232, 0.3);
+  }
+
+  .workshop-list-item-id {
+    margin: 0 10px;
+    width: 20px;
+    border-radius: 50%;
+    background: #e8e8e8;
+    font-size: 13px;
+    color: #666666;
+    font-weight: bold;
+    font-family: DIN, DIN;
+    text-align: center;
+  }
+  .workshop-list-item-name {
+    width: 126px;
+    font-weight: 400;
+    font-size: 14px;
+    color: #666666;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+  .workshop-list-item-icon {
+    margin-left: 10px;
+    width: 20px;
+    height: 20px;
+  }
+</style>