Selaa lähdekoodia

Merge branch 'dev-wyf' into 'dev'

天眼功能迁移

See merge request product-group-fe/sfy-safety-group/sfy-safety!255
ai0197 6 kuukautta sitten
vanhempi
commit
a58fc99354
32 muutettua tiedostoa jossa 1877 lisäystä ja 5 poistoa
  1. 3 1
      package.json
  2. BIN
      src/assets/images/institute-safety/alert-white.png
  3. BIN
      src/assets/images/institute-safety/alert.png
  4. BIN
      src/assets/images/institute-safety/camera.png
  5. BIN
      src/assets/images/institute-safety/close.png
  6. BIN
      src/assets/images/institute-safety/config.png
  7. BIN
      src/assets/images/institute-safety/control-tab-switch.png
  8. BIN
      src/assets/images/institute-safety/item-active.png
  9. BIN
      src/assets/images/institute-safety/item-hover.png
  10. BIN
      src/assets/images/institute-safety/rating.png
  11. BIN
      src/assets/images/institute-safety/surveillance-area.png
  12. BIN
      src/assets/images/institute-safety/title-decoration.png
  13. BIN
      src/assets/images/institute-safety/violation-title-bg.png
  14. 12 4
      src/views/institute-safety/components/CardMapAndAlert.vue
  15. 71 0
      src/views/institute-safety/modules/safety-company-home/CompanyHome.vue
  16. 48 0
      src/views/institute-safety/modules/safety-company-home/apis/index.ts
  17. 51 0
      src/views/institute-safety/modules/safety-company-home/components/CompanyRating.vue
  18. 110 0
      src/views/institute-safety/modules/safety-company-home/components/ControlTab.vue
  19. 66 0
      src/views/institute-safety/modules/safety-company-home/components/RealtimeSurveillance.vue
  20. 173 0
      src/views/institute-safety/modules/safety-company-home/components/SurveillanceList.vue
  21. 83 0
      src/views/institute-safety/modules/safety-company-home/hooks/use-violation-notice-company.ts
  22. 69 0
      src/views/institute-safety/modules/safety-company-home/stores/use-camera-store.ts
  23. 32 0
      src/views/institute-safety/modules/safety-company-home/stores/use-question-list.ts
  24. 45 0
      src/views/institute-safety/modules/safety-company-home/stores/use-violation-notice-store.ts
  25. 72 0
      src/views/institute-safety/modules/safety-company-home/types.ts
  26. 166 0
      src/views/institute-safety/modules/safety-question-list/ImageViewer.vue
  27. 463 0
      src/views/institute-safety/modules/safety-question-list/QuestionList.vue
  28. 1 0
      src/views/institute-safety/modules/safety-question-list/api/index.ts
  29. 57 0
      src/views/institute-safety/modules/safety-question-list/constant.ts
  30. 102 0
      src/views/institute-safety/modules/safety-workshop-list/WorkshopList.vue
  31. 17 0
      src/views/institute-safety/modules/safety-workshop-list/apis/index.ts
  32. 236 0
      src/views/institute-safety/modules/safety-workshop-list/constants.ts

+ 3 - 1
package.json

@@ -35,7 +35,7 @@
     "@vue-office/excel": "1.7.14",
     "@vue-office/pdf": "2.0.10",
     "@vue-office/pptx": "1.0.1",
-    "@vueuse/core": "8.9.4",
+    "@vueuse/core": "14.0.0",
     "@vueuse/router": "10.6.1",
     "@wangeditor/editor-for-vue": "5.1.12",
     "animate.css": "4.1.1",
@@ -49,8 +49,10 @@
     "form-data": "4.0.0",
     "html2canvas": "1.0.0",
     "lodash-es": "4.17.21",
+    "mitt": "3.0.1",
     "mockjs": "1.1.0",
     "mpegts.js": "1.7.3",
+    "notivue": "2.4.5",
     "nprogress": "0.2.0",
     "perfect-scrollbar": "1.5.5",
     "pinia": "2.0.16",

BIN
src/assets/images/institute-safety/alert-white.png


BIN
src/assets/images/institute-safety/alert.png


BIN
src/assets/images/institute-safety/camera.png


BIN
src/assets/images/institute-safety/close.png


BIN
src/assets/images/institute-safety/config.png


BIN
src/assets/images/institute-safety/control-tab-switch.png


BIN
src/assets/images/institute-safety/item-active.png


BIN
src/assets/images/institute-safety/item-hover.png


BIN
src/assets/images/institute-safety/rating.png


BIN
src/assets/images/institute-safety/surveillance-area.png


BIN
src/assets/images/institute-safety/title-decoration.png


BIN
src/assets/images/institute-safety/violation-title-bg.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>

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

@@ -0,0 +1,71 @@
+<template>
+  <div class="company-home">
+    <RealtimeSurveillance v-if="curCamera?.code" />
+    <img v-else style="width: 100%" src="@/assets/images/sfy.jpg" alt="" />
+    <CompanyRating v-if="!curCamera?.code" />
+    <ControlTab @open-surveillance-list="showSurveillanceList = true" @open-question-list="handleOpenQuestionList" />
+    <SurveillanceList v-if="showSurveillanceList" @close="showSurveillanceList = false" />
+
+    <QuestionList v-if="showQuestionList" @close="handleQuestionListClose" />
+  </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 useQuestionListStore from '@/views/institute-safety/modules/safety-company-home/stores/use-question-list';
+
+  import useViolationNoticeCompany from './hooks/use-violation-notice-company';
+  import useCameraStore from './stores/use-camera-store';
+  import { nextTick, onUnmounted, ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { WORKSHOP_INFOS } from '../safety-workshop-list/constants';
+
+  const showSurveillanceList = ref(false);
+
+  useViolationNoticeCompany();
+
+  const cameraStore = useCameraStore();
+  const { curCamera } = storeToRefs(cameraStore);
+
+  const questionListStore = useQuestionListStore();
+  const { showQuestionList } = storeToRefs(questionListStore);
+
+  function handleOpenQuestionList() {
+    if (curCamera.value?.id) {
+      questionListStore.setState({
+        type: 'camera',
+        cameraId: curCamera.value.id,
+      });
+    } else {
+      questionListStore.setState({
+        type: 'all',
+        workshopCodes: WORKSHOP_INFOS.map((item) => item.workshopCode),
+      });
+    }
+    nextTick(() => {
+      questionListStore.openList();
+    });
+  }
+  function handleQuestionListClose() {
+    showQuestionList.value = false;
+  }
+
+  onUnmounted(() => {
+    cameraStore.clearCurCamera();
+  });
+</script>
+
+<style scoped>
+  .company-home {
+    width: 100%;
+    height: 675px;
+    position: relative;
+    overflow: hidden;
+    border-radius: 5px;
+  }
+</style>

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

@@ -0,0 +1,48 @@
+import { http } from '@/utils/http/axios';
+import type { QueryPageRequest, QueryPageResponse } from '@/types/basic-query';
+
+import type { IssueItem } from '../types';
+
+// 查询今日异常告警数量(评分)
+export function getTodayIssueNumber() {
+  return http.request<number>({
+    url: '/issue/queryTodayIssueCount',
+    method: 'get',
+  });
+}
+// 通过相机id分页查询异常告警问题列表
+export function getIssueListByCameraId(data: QueryPageRequest<number>) {
+  return http.request<QueryPageResponse<IssueItem>>({
+    url: '/issue/queryIssuePageByCameraId',
+    method: 'post',
+    data,
+  });
+}
+
+// 通过车间code分页查询异常告警问题列表
+export function getIssueListByWorkshopCode(data: QueryPageRequest<{ workshopCodeList: string[] }>) {
+  return http.request<QueryPageResponse<IssueItem>>({
+    url: '/issue/queryIssuePageByWorkshopCodes',
+    method: 'post',
+    data,
+  });
+}
+
+// 查询弹窗报警
+export function getNewIssueList() {
+  return http.request<IssueItem[]>({
+    url: '/issue/queryTodayNewIssueList',
+    method: 'get',
+  });
+}
+
+// 更新已读问题id
+export function updateReadIssueId(issueId: number) {
+  return http.request({
+    url: '/issue/updateLastReadIssueId',
+    method: 'post',
+    params: {
+      issueId,
+    },
+  });
+}

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

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

@@ -0,0 +1,110 @@
+<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;
+    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>

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

@@ -0,0 +1,66 @@
+<template>
+  <div class="realtime-surveillance">
+    <LiveVideo :url="getCurCameraUrl()" :poster="getCurCameraImg()" :id="`monitor-livevideo`" class="main-video" />
+    <img src="@/assets/images/institute-safety/close.png" alt="" class="video-close" @click="clearCurCamera()" />
+
+    <img
+      src="@/assets/images/disaster-overview/full-screen.png"
+      alt=""
+      class="full-screen"
+      @click="isFullScreen ? exitFullscreen() : fullScreen(`monitor-livevideo`, 'overview-monitor')"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+  import LiveVideo from '@/components/live/LiveVideo.vue';
+  import useCameraStore from '../stores/use-camera-store';
+  import { userSplitScreenFullScreen } from '@/store/modules/userSplitScreenFullScreen';
+  import { storeToRefs } from 'pinia';
+  import screenfull from 'screenfull';
+  import { onUnmounted } from 'vue';
+
+  const { isFullScreen, curFullScreenType } = storeToRefs(userSplitScreenFullScreen());
+  const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+
+  const cameraStore = useCameraStore();
+
+  window.onresize = () => {
+    if (!screenfull.isFullscreen) {
+      isFullScreen.value = false; //判断退出全屏,进行赋值
+      curFullScreenType.value = 'single';
+    }
+  };
+
+  const { clearCurCamera, getCurCameraUrl, getCurCameraImg } = cameraStore;
+
+  onUnmounted(() => {
+    clearCurCamera();
+  });
+</script>
+
+<style scoped>
+  .realtime-surveillance {
+    width: 100%;
+    height: 100%;
+  }
+  .main-video {
+    height: 100%;
+  }
+  .video-close {
+    position: absolute;
+    right: 20px;
+    top: 20px;
+    width: 20px;
+    height: 20px;
+    cursor: pointer;
+  }
+  .full-screen {
+    position: absolute;
+    left: 20px;
+    bottom: 20px;
+    width: 30px;
+    height: 30px;
+    cursor: pointer;
+  }
+</style>

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

@@ -0,0 +1,173 @@
+<template>
+  <div class="surveillance-list">
+    <header class="list-header">
+      <img class="config-btn" src="@/assets/images/institute-safety/config.png" alt="" @click="handleToPlatform" />
+      <span> 重点监控区域 </span>
+      <img class="close-btn" src="@/assets/images/institute-safety/close.png" alt="" @click="emits('close')" />
+    </header>
+    <main class="surveillance-list-main">
+      <div class="surveillance-group" v-for="group in surveillanceAreaList" :key="group.id">
+        <div class="group-title">
+          {{ group.groupName }}
+        </div>
+        <div
+          class="surveillance-camera"
+          :class="{ 'surveillance-camera_active': cam.code === curCamera?.code }"
+          v-for="cam in group.children"
+          :key="cam.id"
+          @click="handleChangeCamera(cam)"
+        >
+          <img class="camera-preview" :src="cam.pushStreamDTO.imageUrl" alt="" />
+          <div class="camera-info">
+            <div class="camera-name">
+              <img src="@/assets/images/institute-safety/camera.png" alt="" />
+              <el-tooltip placement="top" :content="cam.name">
+                <span class="camera-name-text">{{ cam.name }}</span>
+              </el-tooltip>
+            </div>
+          </div>
+        </div>
+      </div>
+    </main>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { onMounted, ref } from 'vue';
+  import { storeToRefs } from 'pinia';
+  import { useRouter } from 'vue-router';
+  import { ElTooltip } from 'element-plus';
+  import { getSurveillanceAreaList } from '@/api/security-confidentiality-position';
+  import type { PositionMonitorCameraListRes } from '@/api/security-confidentiality-position';
+  import type { CameraInfo } from '@/api/disaster-overview';
+  import useCameraStore from '../stores/use-camera-store';
+
+  const emits = defineEmits<{
+    (e: 'close'): void;
+    // (e: 'change-camera', code: string): void;
+  }>();
+
+  const cameraStore = useCameraStore();
+  const { curCamera } = storeToRefs(cameraStore);
+
+  const surveillanceAreaList = ref<PositionMonitorCameraListRes[]>([]);
+
+  const getListData = () => {
+    getSurveillanceAreaList({ groupName: '', cameraName: '' }).then((res) => {
+      surveillanceAreaList.value = res;
+    });
+  };
+
+  const router = useRouter();
+  function handleToPlatform() {
+    router.push({
+      name: 'SystemSurveillance',
+    });
+  }
+
+  function handleChangeCamera(camera: CameraInfo) {
+    cameraStore.setCurCamera(camera);
+  }
+
+  onMounted(() => {
+    getListData();
+  });
+</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;
+  }
+  .surveillance-list-main {
+    padding: 0 16px;
+    height: 623px;
+    overflow: auto;
+  }
+
+  .surveillance-group {
+    margin-top: 20px;
+  }
+
+  .group-title {
+    width: 86px;
+    height: 28px;
+    line-height: 26px;
+    background: rgba(0, 0, 0, 0.5);
+    border-radius: 4px;
+    border: 1px solid #000000;
+
+    font-weight: 400;
+    font-size: 14px;
+    color: #ffffff;
+    text-align: center;
+  }
+  .surveillance-camera {
+    cursor: pointer;
+    position: relative;
+    margin-top: 16px;
+    width: 249px;
+    height: 142px;
+    border-radius: 4px;
+    border: 1px solid rgba(255, 255, 255, 0.8);
+  }
+  .surveillance-camera_active {
+    border: 1px solid #409eff;
+    box-shadow: 0px 0px 5px 5px rgba(64, 158, 255, 0.44);
+  }
+  .camera-preview {
+    width: 100%;
+  }
+  .camera-info {
+    position: absolute;
+    bottom: 0;
+    width: 248px;
+    height: 80px;
+    background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.8) 100%);
+
+    display: flex;
+    align-items: flex-end;
+    justify-content: flex-start;
+    padding: 8px 12px;
+  }
+  .camera-name {
+    font-weight: 500;
+    font-size: 12px;
+    color: #ffffff;
+
+    display: flex;
+    align-items: center;
+    gap: 4px;
+  }
+  .camera-name-text {
+    max-width: 200px;
+    white-space: nowrap; /* 防止文本换行 */
+    overflow: hidden; /* 隐藏溢出的文本 */
+    text-overflow: ellipsis; /* 显示省略号 */
+  }
+</style>

+ 83 - 0
src/views/institute-safety/modules/safety-company-home/hooks/use-violation-notice-company.ts

@@ -0,0 +1,83 @@
+// 报警消息实时提醒
+
+import { getNewIssueList, updateReadIssueId } from '../apis';
+import useViolationNoticeStore, { getPlace, emitter } from '../stores/use-violation-notice-store';
+import { onUnmounted, onMounted } from 'vue';
+import dayjs from 'dayjs';
+import { push } from 'notivue';
+
+const useViolationRealtimeCompany = () => {
+  /** 消息队列,最新的排在最前面,最老的排在最后面 */
+  // 报警消息最后返回数据的id
+  const violationStore = useViolationNoticeStore();
+
+  let apiTimer: number;
+  let isFirstLoad = true;
+  //弹出消息
+  const showNotice = () => {
+    const showItem = violationStore.getLastNotice();
+    // 从数组最末尾弹出消息
+    if (!showItem) return;
+
+    // 只显示当天的时分秒
+    const renderTime = dayjs(showItem.createdAt).format('HH:mm:ss');
+    const renderPlace = getPlace([showItem.workshopName, showItem.workspaceName]);
+    push.success({
+      props: {
+        thumbnail: showItem.pictures?.[0],
+        title: showItem.title,
+        message: `<div>
+      <div>地点:${renderPlace}</div>
+      <div>时间:${renderTime}</div>
+      </div>`,
+        onClick: () => {
+          // 这里打开问题列表
+        },
+      },
+      onAutoClear(item) {
+        showNotice();
+      },
+      onManualClear(item) {
+        showNotice();
+      },
+    });
+  };
+
+  emitter.on('showNotice', showNotice);
+
+  const getViolationsRealtime = () => {
+    getNewIssueList().then((res) => {
+      if (!res || res?.length === 0) return;
+      if (isFirstLoad) {
+        // 如果第一次加载,只显示第一条
+        violationStore.add([res[0]]);
+        isFirstLoad = false;
+      } else {
+        violationStore.add(res);
+      }
+      // 在此次调用更新id接口
+      updateReadIssueId(res[0].id);
+    });
+  };
+
+  /** 轮询api */
+  const pollingApi = () => {
+    getViolationsRealtime();
+    clearInterval(apiTimer);
+    apiTimer = window.setInterval(() => {
+      getViolationsRealtime();
+    }, 5000);
+  };
+
+  onMounted(() => {
+    violationStore.clear();
+    pollingApi();
+  });
+
+  onUnmounted(() => {
+    clearInterval(apiTimer);
+    violationStore.clear();
+  });
+};
+
+export default useViolationRealtimeCompany;

+ 69 - 0
src/views/institute-safety/modules/safety-company-home/stores/use-camera-store.ts

@@ -0,0 +1,69 @@
+import type { CameraInfo } from '@/api/disaster-overview';
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+import urlJoin from 'url-join';
+
+const useCameraStore = defineStore('camera', () => {
+  //   const curWorkshopCode = ref('');
+  const curCamera = ref<CameraInfo | null>(null);
+
+  //   const setCurWorkshopCode = (val: string) => {
+  //     curWorkshopCode.value = val;
+  //   };
+
+  const setCurCamera = (val: CameraInfo | null) => {
+    curCamera.value = val;
+  };
+
+  const getCameraUrl = (val: CameraInfo) => {
+    if (val.pushStreamDTO && val.pushStreamDTO.videoUrls) {
+      const videoUrl = val.pushStreamDTO.videoUrls.pushstreamIp;
+      const protocol = window.location.protocol.startsWith('https') ? '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;
+    }
+    return '';
+  };
+
+  const getCameraImg = (val: CameraInfo) => {
+    if (val.pushStreamDTO && val.pushStreamDTO.imageUrl) {
+      return val.pushStreamDTO.imageUrl;
+    }
+    return '';
+  };
+
+  const getCurCameraUrl = () => {
+    return curCamera.value ? getCameraUrl(curCamera.value) : '';
+  };
+
+  const getCurCameraImg = () => {
+    return curCamera.value ? getCameraImg(curCamera.value) : '';
+  };
+
+  const clearCurCamera = () => {
+    // curWorkshopCode.value = '';
+    curCamera.value = null;
+  };
+
+  return {
+    // curWorkshopCode,
+    curCamera,
+    // setCurWorkshopCode,
+    setCurCamera,
+    getCurCameraUrl,
+    getCurCameraImg,
+    clearCurCamera,
+  };
+});
+
+export default useCameraStore;

+ 32 - 0
src/views/institute-safety/modules/safety-company-home/stores/use-question-list.ts

@@ -0,0 +1,32 @@
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+
+const useQuestionListStore = defineStore('questionList', () => {
+  const showQuestionList = ref(false);
+  const ListType = ref<'all' | 'workshop' | 'camera'>('all');
+  const workshopCodes = ref<string[]>([]);
+  const cameraId = ref<number | null>();
+
+  const openList = () => {
+    if (showQuestionList.value === false) showQuestionList.value = true;
+  };
+  const closeList = () => {
+    if (showQuestionList.value === true) showQuestionList.value = false;
+  };
+  const getState = () => ({ type: ListType.value, workshopCodes: workshopCodes.value, cameraId: cameraId.value });
+  const setState = (data: { type: 'all' | 'workshop' | 'camera'; workshopCodes?: string[]; cameraId?: number }) => {
+    ListType.value = data.type;
+    console.log('setState', ListType.value);
+    workshopCodes.value = data.workshopCodes || [];
+    cameraId.value = data.cameraId || null;
+  };
+  const clearStore = () => {
+    ListType.value = 'all';
+    workshopCodes.value = [];
+    cameraId.value = null;
+  };
+
+  return { showQuestionList, ListType, workshopCodes, cameraId, openList, closeList, getState, setState, clearStore };
+});
+
+export default useQuestionListStore;

+ 45 - 0
src/views/institute-safety/modules/safety-company-home/stores/use-violation-notice-store.ts

@@ -0,0 +1,45 @@
+import { IssueItem } from '../types';
+import { NotificationHandle } from 'element-plus';
+import { defineStore } from 'pinia';
+import { ref } from 'vue';
+
+import mitt from 'mitt';
+
+export const getPlace = (arr: [string, string]): string => {
+  return arr.filter(Boolean).join('-');
+};
+export const emitter = mitt();
+
+/** 消息轮播的store */
+const useViolationNoticeStore = defineStore('violation-notice', () => {
+  const list = ref<IssueItem[]>([]);
+  let notiHandler: NotificationHandle | null = null;
+
+  /** 追加新消息 */
+  const add = (newList: IssueItem[]) => {
+    // 只有消息为空时,才需要去主动触发消息提示
+    if (list.value.length > 0) {
+      list.value = [...newList, ...list.value];
+    } else {
+      list.value = [...newList];
+      emitter.emit('showNotice');
+    }
+  };
+
+  const clear = () => {
+    // 清空的时候要把当前已有的也清空掉,同时关闭消息
+    list.value = [];
+    notiHandler?.close();
+  };
+
+  /** 获取最后一条消息(最旧的) */
+  const getLastNotice = () => {
+    if (list.value.length === 0) return null;
+    // 获取最后一条,同时原数据也要减去这一条
+    return list.value.pop()!;
+  };
+
+  return { list, add, clear, getLastNotice };
+});
+
+export default useViolationNoticeStore;

+ 72 - 0
src/views/institute-safety/modules/safety-company-home/types.ts

@@ -0,0 +1,72 @@
+export interface IssueItem {
+  /*问题单id */
+  id: number;
+  /*问题单标题 */
+  title: string;
+  /*问题单状态 */
+  issueState: number;
+  /*问题单来源 */
+  source: number;
+  /*一级分类类型 */
+  issueMainType: number;
+  /*问题算法类型(algo_id) */
+  issueType: number;
+  /*节点类型 */
+  flowNodeType: number;
+  /*问题严重等级 */
+  severityLevel: number;
+  /*优先级,0-未加急,1-加急 */
+  priority: number;
+  /*创建者id */
+  createdBy: number;
+  /*创建者名称 */
+  createdByName: string;
+  /*是否匿名 */
+  isAnonymous: boolean;
+  /*问题描述 */
+  description: string;
+  /*摄像头id */
+  cameraId: number;
+  /*摄像头code */
+  cameraCode: string;
+  /*车间id */
+  workshopId: number;
+  /*车间名称 */
+  workshopName: string;
+  /*工位id */
+  workspaceId: number;
+  /*工位名称 */
+  workspaceName: string;
+  /*坐标点 */
+  points: string;
+  /*负责人id */
+  personInCharge: number;
+  /*负责人名称 */
+  personNameInCharge: string;
+  /*创建时间 */
+  createdAt: string;
+  /*更新时间 */
+  updatedAt: string;
+  /*处理方式 */
+  handleMode: number;
+  /*处理人id */
+  handlerId: number;
+  /*处理人名称 */
+  handlerName: string;
+  /*问题描述图片url集合 */
+  pictures: string[];
+  /*问题视频uri集合 */
+  videos: string[];
+  /*0-未删除,大于0(时间戳)-已删除 */
+  isDeleted: number;
+  /*是否隐藏 */
+  isHide: boolean;
+  /*租户id */
+  tenantId: number;
+  /*是否在审核阶段改派 */
+  isReviewTransfer: number;
+  /*权限类型:1-查看 2-审核 3-处理 4-复核 */
+  permissionType: number;
+  /*超时状态:0-正常,1-超期,2-长期 */
+  timeoutType: number;
+}

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

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

@@ -0,0 +1,463 @@
+<template>
+  <div class="mask-bg">
+    <div class="abnormal-wrapper">
+      <div class="title-pane">
+        <div class="title"><span>异常告警</span></div>
+        <img src="@/assets/images/institute-safety/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" ref="scrollRef">
+            <div
+              v-for="(it, idx) in requestRes"
+              :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="!requestRes.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, onUnmounted, ref, useTemplateRef, 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 useQuestionListStore from '@/views/institute-safety/modules/safety-company-home/stores/use-question-list';
+  import ImageViewer from './ImageViewer.vue';
+  import LiveVideo from '@/components/live/LiveVideoFlv.vue';
+  import { getCameraInfoDetail } from '@/api/camera/camera';
+  import type { IssueItem } from '@/views/institute-safety/modules/safety-company-home/types';
+  import type { QueryPageRequest, QueryPageResponse } from '@/types/basic-query';
+  import { getIssueListByCameraId, getIssueListByWorkshopCode } from '../safety-company-home/apis';
+  import { useInfiniteScroll } from '@vueuse/core';
+
+  const { isFullScreen } = storeToRefs(userSplitScreenFullScreen());
+  const { fullScreen, exitFullscreen } = userSplitScreenFullScreen();
+
+  const questionListStore = useQuestionListStore();
+  const { getState, clearStore } = questionListStore;
+
+  const { type, workshopCodes, cameraId } = getState();
+  function getRequestParams() {
+    if (type === 'camera') {
+      return cameraId;
+    } else {
+      return { workshopCodeList: workshopCodes };
+    }
+  }
+
+  const DEFAULT_SIZE = 10;
+  const requestPage = ref(1);
+  const requestTotal = ref(0);
+
+  const scrollRef = useTemplateRef<HTMLElement>('scrollRef');
+  const { reset } = useInfiniteScroll(
+    scrollRef,
+    () => {
+      requestParams.value.pageNumber++;
+      load();
+    },
+    {
+      distance: 10,
+      canLoadMore: () => {
+        const noMoreContent = requestPage.value * DEFAULT_SIZE >= requestTotal.value;
+        if (noMoreContent) return false;
+        return true;
+      },
+    },
+  );
+
+  const emits = defineEmits<{
+    (e: 'close'): void;
+  }>();
+
+  const requestRes = ref<IssueItem[]>([]);
+  const requestParams = ref<QueryPageRequest<{ workshopCodeList: string[] } | number>>({
+    pageNumber: requestPage.value,
+    pageSize: DEFAULT_SIZE,
+    queryParam: getRequestParams(),
+  } as QueryPageRequest<{ workshopCodeList: string[] } | number>);
+
+  const selectedIndex = ref<number>(0);
+  const current = computed<IssueItem | undefined>(() => requestRes.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> => {
+    if (type === 'camera') {
+      const res = await getIssueListByCameraId(requestParams.value as QueryPageRequest<number>);
+      requestRes.value = res.records;
+      requestTotal.value = res.totalRow;
+    } else {
+      const res = await getIssueListByWorkshopCode(
+        requestParams.value as QueryPageRequest<{ workshopCodeList: string[] }>,
+      );
+      requestRes.value = res.records;
+      requestTotal.value = res.totalRow;
+    }
+
+    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 || '';
+      });
+    },
+  );
+
+  onMounted(() => {
+    load();
+  });
+
+  onUnmounted(() => {
+    clearStore();
+  });
+</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;
+    position: absolute;
+    top: 0;
+  }
+
+  .abnormal-wrapper {
+    // width: 78.5%;
+    // height: 71.5%;
+    width: 100%;
+    height: 100%;
+    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/images/institute-safety/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 {
+      color: #fff;
+      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/institute-safety/item-hover.png');
+      background-size: 100% 100%;
+      background-position: center;
+    }
+    .list-item.active {
+      background-image: url('@/assets/images/institute-safety/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;
+        text-align: center;
+      }
+
+      .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';

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

@@ -0,0 +1,57 @@
+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;
+
+// export const ISSUELIST_REQUEST_MAP = {
+//   all: getIssueListByWorkshopCode,
+//   workshop: getIssueListByWorkshopCode,
+//   camera: getIssueListByCameraId,
+// };

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

@@ -0,0 +1,102 @@
+<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="!item.status"
+        src="@/assets/images/institute-safety/alert.png"
+        alt=""
+        @click="handleOpenQuestionList(item.workshopCode)"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+  import { nextTick, onMounted, ref } from 'vue';
+  import { ElTooltip } from 'element-plus';
+  import { getWorkshopTodayExceptionStatus } from './apis';
+  import { WORKSHOP_INFOS } from './constants';
+
+  import useQuestionListStore from '@/views/institute-safety/modules/safety-company-home/stores/use-question-list';
+  import { storeToRefs } from 'pinia';
+
+  const questionListStore = useQuestionListStore();
+  const { showQuestionList } = storeToRefs(questionListStore);
+
+  const staticWorkshopList = ref(WORKSHOP_INFOS);
+
+  function handleOpenQuestionList(code: string) {
+    questionListStore.closeList();
+    questionListStore.clearStore();
+    nextTick(() => {
+      questionListStore.setState({
+        type: 'workshop',
+        workshopCodes: [code],
+      });
+      questionListStore.openList();
+    });
+  }
+
+  onMounted(() => {
+    getWorkshopTodayExceptionStatus(staticWorkshopList.value.map((item) => item.workshopCode)).then((res) => {
+      staticWorkshopList.value.forEach((item) => {
+        item.status = res.find((x) => x.workshopCode === item.workshopCode)!.exceptionStatus;
+      });
+    });
+  });
+</script>
+
+<style scoped>
+  .workshop-list {
+    margin-top: 8px;
+    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 {
+    cursor: pointer;
+    margin-left: 10px;
+    width: 20px;
+    height: 20px;
+  }
+</style>

+ 17 - 0
src/views/institute-safety/modules/safety-workshop-list/apis/index.ts

@@ -0,0 +1,17 @@
+import { http } from '@/utils/http/axios';
+
+// 查询车间今日是否有异常状态
+export function getWorkshopTodayExceptionStatus(list: string[]) {
+  return http.request<
+    {
+      workshopCode: string;
+      exceptionStatus: boolean;
+    }[]
+  >({
+    url: '/issue/queryWorkshopTodayExceptionStatus',
+    method: 'post',
+    data: {
+      workshopCodeList: list,
+    },
+  });
+}

+ 236 - 0
src/views/institute-safety/modules/safety-workshop-list/constants.ts

@@ -0,0 +1,236 @@
+export const WORKSHOP_INFOS = [
+  {
+    id: 1,
+    status: false,
+    workshopCode: 'R&DBuilding',
+    name: '设计研发大楼',
+  },
+  {
+    id: 2,
+    status: false,
+    workshopCode: 'ITCenter',
+    name: '信息中心',
+  },
+  {
+    id: 3,
+    status: false,
+    workshopCode: 'ArchivesCenter',
+    name: '档案中心',
+  },
+  {
+    id: 4,
+    status: false,
+    workshopCode: 'Canteen',
+    name: '职工食堂1',
+  },
+  {
+    id: 5,
+    status: false,
+    workshopCode: 'GeneralManagementBuilding',
+    name: '综合楼',
+  },
+  {
+    id: 6,
+    status: false,
+    workshopCode: 'ExecutiveBuilding',
+    name: '行政办公楼',
+  },
+  {
+    id: 7,
+    status: false,
+    workshopCode: 'ChiefDesignerBuilding',
+    name: '总师楼',
+  },
+  {
+    id: 8,
+    status: false,
+    workshopCode: 'CustomizationCenter&MultifunctionalPrototypeHangar',
+    name: '客户选型中心及多功能样机库',
+  },
+  {
+    id: 9,
+    status: false,
+    workshopCode: 'MiniCanteen',
+    name: '职工食堂2',
+  },
+  {
+    id: 10,
+    status: false,
+    workshopCode: 'C919SystemIntegrationLab',
+    name: 'C919系统综合实验室',
+  },
+  {
+    id: 11,
+    status: false,
+    workshopCode: '35VElectricityTransformerStation',
+    name: '35KV变电站',
+  },
+  {
+    id: 12,
+    status: false,
+    workshopCode: 'StressLab',
+    name: '强度试验室',
+  },
+  {
+    id: 13,
+    status: false,
+    workshopCode: 'GarbageStation',
+    name: '垃圾房',
+  },
+  {
+    id: 14,
+    status: false,
+    workshopCode: 'ElectricityDistributionStation3',
+    name: '配电站3',
+  },
+  {
+    id: 15,
+    status: false,
+    workshopCode: 'StructuralFunctionLab',
+    name: '机构功能试验室',
+  },
+  {
+    id: 16,
+    status: false,
+    workshopCode: 'EnvironmentalControlSystemLab',
+    name: '环控试验室',
+  },
+  {
+    id: 17,
+    status: false,
+    workshopCode: 'AirCompressionStation',
+    name: '空压站',
+  },
+  {
+    id: 18,
+    status: false,
+    workshopCode: 'FuelSystemLab',
+    name: '燃油试验室',
+  },
+  {
+    id: 19,
+    status: false,
+    workshopCode: 'FireProtectionSystemLab',
+    name: '防火试验室',
+  },
+  {
+    id: 20,
+    status: false,
+    workshopCode: 'KerodanePumpingStation',
+    name: '煤油泵站',
+  },
+  {
+    id: 21,
+    status: false,
+    workshopCode: 'StaffResidence',
+    name: '单身公寓',
+  },
+  {
+    id: 22,
+    status: false,
+    workshopCode: 'StaffActivityCenter',
+    name: '职工活动中心',
+  },
+  {
+    id: 23,
+    status: false,
+    workshopCode: 'TechnologyInnovationBuilding',
+    name: '科技创新楼',
+  },
+  {
+    id: 24,
+    status: false,
+    workshopCode: 'LogisticsManagementCenter',
+    name: '综合保障管理中心',
+  },
+  {
+    id: 25,
+    status: false,
+    workshopCode: 'E3Lab',
+    name: '电磁环境效应试验室',
+  },
+  {
+    id: 26,
+    status: false,
+    workshopCode: 'ElectricityDistributionStation1',
+    name: '配电站1',
+  },
+  {
+    id: 27,
+    status: false,
+    workshopCode: 'ElectricityDistributionStation2',
+    name: '配电站2',
+  },
+  {
+    id: 28,
+    status: false,
+    workshopCode: 'Library',
+    name: '阅览中心',
+  },
+  {
+    id: 29,
+    status: false,
+    workshopCode: 'RegionalJetSystemIntegrationLab',
+    name: '支线客机系统综合试验室',
+  },
+  {
+    id: 30,
+    status: false,
+    workshopCode: 'FlightControlSystemLab',
+    name: '飞控系统试验室',
+  },
+  {
+    id: 31,
+    status: false,
+    workshopCode: 'AvionicSystemLab',
+    name: '航电系统试验室',
+  },
+  {
+    id: 32,
+    status: false,
+    workshopCode: 'ElectricalSystemLab',
+    name: '电气系统试验室',
+  },
+  {
+    id: 33,
+    status: false,
+    workshopCode: 'CR929SystemIntegrationLab',
+    name: 'CR929系统综合试验室',
+  },
+  {
+    id: 34,
+    status: false,
+    workshopCode: 'AircraftIntegrationLab',
+    name: '总体集成试验室',
+  },
+  {
+    id: 35,
+    status: false,
+    workshopCode: 'DigitalSimulationLab',
+    name: '数字仿真试验室',
+  },
+  {
+    id: 36,
+    status: false,
+    workshopCode: 'AdvancedMaterialLab',
+    name: '先进材料试验室',
+  },
+  {
+    id: 37,
+    status: false,
+    workshopCode: 'ProgramDevelopmentCenter',
+    name: '型号发展中心',
+  },
+  {
+    id: 38,
+    status: false,
+    workshopCode: 'StructuralIntegrationLab',
+    name: '结构综合试验室',
+  },
+  {
+    id: 39,
+    status: false,
+    workshopCode: 'FundamentalTechnologyLab',
+    name: '基础技术试验室',
+  },
+];