riku
2025-07-03 e895212fa4215c50ce79ce4b448e064caf394776
2025.7.3 动态溯源(待完成)
已修改8个文件
已添加1个文件
已重命名1个文件
504 ■■■■ 文件已修改
README.md 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components.d.ts 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/chart/RealTimeLineChart.vue 8 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/map/MapLocate.vue 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/chart/chart-option.js 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/HomePage.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/sourcetrace/SourceTrace.vue 171 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/sourcetrace/component/ClueRecordItem.vue 182 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/sourcetrace/component/PollutedExceptionItem.vue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/sourcetrace/component/SourceTraceFilter.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
README.md
@@ -134,7 +134,7 @@
│  â”‚  â”œâ”€ map
│  â”‚  â”‚  â”œâ”€ BaseMap.vue
│  â”‚  â”‚  â”œâ”€ ConfigManage.vue
│  â”‚  â”‚  â”œâ”€ MapLocation.vue
│  â”‚  â”‚  â”œâ”€ MapLocate.vue
│  â”‚  â”‚  â”œâ”€ MapScene.vue
│  â”‚  â”‚  â””─ MapToolbox.vue
│  â”‚  â”œâ”€ MessageBox.vue
@@ -263,4 +263,4 @@
├─ vite.config.js
└─ vitest.config.js
```
```
src/components.d.ts
@@ -65,7 +65,7 @@
    GaugeChart: typeof import('./components/chart/GaugeChart.vue')['default']
    GridSearch: typeof import('./components/grid/GridSearch.vue')['default']
    HistoricalTrajectory: typeof import('./components/animation/HistoricalTrajectory.vue')['default']
    MapLocation: typeof import('./components/map/MapLocation.vue')['default']
    MapLocate: typeof import('./components/map/MapLocate.vue')['default']
    MapScene: typeof import('./components/map/MapScene.vue')['default']
    MapToolbox: typeof import('./components/map/MapToolbox.vue')['default']
    MessageBox: typeof import('./components/MessageBox.vue')['default']
src/components/chart/RealTimeLineChart.vue
@@ -22,9 +22,15 @@
      //   };
      // }
    },
    // æŠ˜çº¿å›¾å±•示高度
    chartHeight: {
      type: String,
      default: '140px'
    },
    // æŠ˜çº¿å›¾Y轴刻度间距
    yMinInterval: {
      type: Number,
      default: 1
    }
  },
  data() {
@@ -45,7 +51,7 @@
    refreshChart() {
      const { xAxis, series } = this.modelValue;
      if (!this.option) {
        this.option = smallLineOption(xAxis, series);
        this.option = smallLineOption(xAxis, series, this.yMinInterval);
      } else {
        this.option.xAxis[0].data = xAxis;
        this.option.series = series;
src/components/map/MapLocate.vue
src/utils/chart/chart-option.js
@@ -121,7 +121,7 @@
}
// æŠ˜çº¿å›¾
function smallLineOption(_xAxis, _series) {
function smallLineOption(_xAxis, _series, yMinInterval) {
  var fontSize = fGetChartFontSize();
  return {
    animationEasing: 'elasticOut',
@@ -198,7 +198,7 @@
        splitLine: {
          show: false
        },
        minInterval: 1,
        minInterval: yMinInterval ? yMinInterval : 1,
        intervel: 1,
        min: function (value) {
          return Math.floor(value.min);
src/views/HomePage.vue
@@ -7,7 +7,7 @@
      <!-- <SatelliteTelemetry></SatelliteTelemetry> -->
      <!-- <MissionManage></MissionManage> -->
      <ConfigManage></ConfigManage>
      <!-- <MapLocation></MapLocation> -->
      <!-- <MapLocate></MapLocate> -->
      <SceneSearch></SceneSearch>
      <MapScene></MapScene>
      <GridSearch></GridSearch>
src/views/sourcetrace/SourceTrace.vue
@@ -18,17 +18,30 @@
    <el-col v-show="show" span="10">
      <BaseCard>
        <template #content>
          <el-checkbox-group v-model="selectedMsgTypes" size="default" :min="1">
            <el-space>
              <el-checkbox value="1">异常切片</el-checkbox>
              <el-checkbox value="2">污染线索</el-checkbox>
            </el-space>
          </el-checkbox-group>
          <el-row
            justify="space-between"
            align="middle"
            style="border-bottom: 1px solid white"
          >
            <!-- æ•°æ®åˆ‡ç‰‡ç­›é€‰æ¡ä»¶ -->
            <SourceTraceFilter
              v-model:data-slice="selectedMsgTypes"
              v-model:factor-type="selectedFactorTypes"
              :factor-options="factorOptions"
              v-model:scene-type="selectedSceneTypes"
              :scene-options="sceneOptions"
            ></SourceTraceFilter>
            <!-- <el-divider direction="vertical"></el-divider> -->
            <!-- æ•°æ®åˆ‡ç‰‡ç»Ÿè®¡ -->
            <div style="border-left: 1px solid white" class="p-l-8">
              <el-space direction="vertical">
                <el-text type="primary">溯源:{{ countMsg1.type1 }}条</el-text>
                <el-text type="primary">线索:{{ countMsg1.type2 }}条</el-text>
                <el-text type="primary">提醒:{{ countMsg1.type3 }}条</el-text>
              </el-space>
            </div>
          </el-row>
          <el-scrollbar ref="scrollbarRef" class="scrollbar">
            <!-- <div
              ref="scrollContentRef"
              style="display: flex; width: fit-content"
            > -->
            <TransitionGroup name="list">
              <div
                v-for="item in filterStreams"
@@ -40,7 +53,6 @@
                ></ClueRecordItem>
              </div>
            </TransitionGroup>
            <!-- </div> -->
          </el-scrollbar>
        </template>
      </BaseCard>
@@ -72,6 +84,9 @@
import PollutedExceptionItem from './component/PollutedExceptionItem.vue';
import ClueRecordItem from './component/ClueRecordItem.vue';
import PollutedClueItem from '@/views/sourcetrace/component/PollutedClueItem.vue';
import SourceTraceFilter from '@/views/sourcetrace/component/SourceTraceFilter.vue';
const NO_SCENE = 'no_scene';
const props = defineProps({
  factorType: String
@@ -89,7 +104,11 @@
const selectedException = ref();
const selectedClue = ref();
const selectedMsgTypes = ref(['1', '2']);
const selectedMsgTypes = ref(['1', '2', '3']);
const selectedFactorTypes = ref([]);
const factorOptions = ref([]);
const selectedSceneTypes = ref([]);
const sceneOptions = ref([]);
function scrollToBottom() {
  const h1 = scrollContentRef.value.clientHeight + 100;
@@ -107,8 +126,57 @@
const streams = reactive([]);
const filterStreams = computed(() => {
  return streams.filter((v) => {
    return selectedMsgTypes.value.indexOf(v._type) != -1;
    // åˆ¤æ–­æ¶ˆæ¯ç±»åž‹æ˜¯å¦é€‰ä¸­
    const b1 = selectedMsgTypes.value.indexOf(v._type) != -1;
    let b2, b3;
    switch (v._type) {
      case '1':
      case '3':
        // åˆ¤æ–­ç›‘测因子类型是否选中
        b2 = selectedFactorTypes.value.indexOf(v.pollutedData.factorId) != -1;
        // åˆ¤æ–­åœºæ™¯ç±»åž‹æ˜¯å¦é€‰ä¸­
        if (v.pollutedSource.sceneList.length == 0) {
          b3 = selectedSceneTypes.value.indexOf(NO_SCENE) != -1;
        } else {
          b3 =
            v.pollutedSource.sceneList.findIndex(
              (v) => selectedSceneTypes.value.indexOf(v.typeId) != -1
            ) != -1;
        }
        break;
      case '2':
        b2 = true;
        b3 =
          v.sortedSceneList.findIndex(
            (v) => selectedSceneTypes.value.indexOf(v.first.typeId) != -1
          ) != -1;
        break;
    }
    return b1 && b2 && b3;
  });
});
// ç»Ÿè®¡å„类型消息的数量
const countMsg1 = computed(() => {
  const count = {
    type1: 0,
    type2: 0,
    type3: 0
  };
  streams.forEach((v) => {
    switch (v._type) {
      case '1':
        count.type1++;
        break;
      case '2':
        count.type2++;
        break;
      case '3':
        count.type3++;
        break;
    }
  });
  return count;
});
const inputVal = ref('');
@@ -116,27 +184,16 @@
  websocket.send(inputVal.value);
};
let showFirstClueTask;
function dealMsg(data) {
  const { type, content } = websocketMsgParser.parseMsg(data);
  const obj = reactive(JSON.parse(content));
  obj._type = type;
  // æ±¡æŸ“线索 PollutedClue
  if (type == '1') {
    const obj = reactive(JSON.parse(content));
    obj._type = type;
    // obj.showMore = true;
    obj.showMore = false;
    console.log('污染异常切片: ', obj);
    // if (streams.length == 0) {
    //   streams.push(obj);
    // } else {
    //   // streams.forEach((s) => {
    //   //   showMarksAndPolygon(s);
    //   // });
    //   // hideAll();
    //   streams.unshift(obj);
    // }
    addNewMsg(obj);
    show.value = true;
@@ -144,20 +201,61 @@
    // scrollToTop();
    // drawPolygon(obj.pollutedArea);
    parseChartData(obj);
    // if (showFirstClueTask) {
    //   clearTimeout(showFirstClueTask);
    // }
    // showFirstClueTask = setTimeout(() => {
    //   showMarksAndPolygon(obj);
    // }, 1000);
  } else if (type == '2') {
    const obj = JSON.parse(content);
    obj._type = type;
    // const obj = JSON.parse(content);
    // obj._type = type;
    console.log('污染线索结果: ', obj);
    obj._timestr = timeFormatter(obj.time);
    // streams.unshift(obj);
    addNewMsg(obj);
  } else if (type == '3') {
    console.log('污染提醒切片: ', obj);
    addNewMsg(obj);
    parseChartData(obj);
  }
  optionsFilte(obj);
}
// å¯¹æ•°æ®è¿›è¡Œç­›é€‰ï¼ŒåŒ…括监测因子和场景类型
function optionsFilte(objData) {
  switch (objData._type) {
    case '1':
    case '3':
      // ç­›é€‰ç›‘测因子类型
      if (
        factorOptions.value.findIndex(
          (v) => v.value == objData.pollutedData.factorId
        ) == -1
      ) {
        factorOptions.value.push({
          label: objData.pollutedData.factorName,
          value: objData.pollutedData.factorId
        });
        selectedFactorTypes.value.push(objData.pollutedData.factorId);
      }
      // ç­›é€‰åœºæ™¯ç±»åž‹
      if (objData.pollutedSource.sceneList.length == 0) {
        // è‹¥æ²¡æœ‰æ‰¾åˆ°é£Žé™©æºæ—¶ï¼Œå°†è¯¥åˆ†ç±»è®¾å®šä¸ºnull
        if (sceneOptions.value.findIndex((v) => v.value == NO_SCENE) == -1) {
          sceneOptions.value.push({
            label: '无',
            value: NO_SCENE
          });
          selectedSceneTypes.value.push(NO_SCENE);
        }
      } else {
        objData.pollutedSource.sceneList.forEach((s) => {
          if (sceneOptions.value.findIndex((v) => v.value == s.typeId) == -1) {
            sceneOptions.value.push({
              label: s.type,
              value: s.typeId
            });
            selectedSceneTypes.value.push(s.typeId);
          }
        });
      }
    // case '2':
    //   break;
  }
}
@@ -202,6 +300,7 @@
function handleOpen(item) {
  switch (item._type) {
    case '1':
    case '3':
      if (selectedException.value) {
        selectedException.value._selected = false;
      }
src/views/sourcetrace/component/ClueRecordItem.vue
@@ -1,35 +1,64 @@
<template>
  <div :class="'wrapper' + (item._selected ? ' wrapper-select' : '')">
    <div v-if="item._type == '1'">
      <el-row justify="space-between">
        <el-space>
          <el-tag v-if="noWarn" type="info" effect="dark" size="small"
            >异常</el-tag
          >
          <el-tag v-else type="warning" effect="dark" size="small">异常</el-tag>
          <el-text type="primary">{{
            item.pollutedData.startTime + ' - ' + item.pollutedData.endTime
          }}</el-text>
        </el-space>
        <el-link type="primary" @click="emits('open', item)"> è¯¦æƒ… </el-link>
      </el-row>
      <el-col :span="24">
        <el-text type="primary">{{
          item.pollutedData.factorName +
          formatException(item.pollutedData.exceptionType) +
          ',' +
          formatDistanceType(item.pollutedArea.distanceType)
        }}</el-text>
        <el-text :type="noWarn ? 'primary' : 'warning'">
          {{
            item.pollutedSource.sceneList.length == 0
              ? '未找到可疑污染源'
              : '找到' + item.pollutedSource.sceneList.length + '个可疑污染源'
          }}
        </el-text>
    <el-row v-if="item._type == '1'">
      <el-col :span="3">
        <el-tag :type="noWarn ? 'info' : 'warning'" effect="dark" size="small"
          >溯源</el-tag
        >
      </el-col>
      <!-- <el-col :span="2"> </el-col> -->
    </div>
      <el-col :span="21">
        <el-row justify="space-between">
          <el-space>
            <el-text type="primary" size="default">
              <el-icon><Timer /></el-icon>
              {{
                item.pollutedData.startTime + ' - ' + item.pollutedData.endTime
              }}
            </el-text>
          </el-space>
          <el-link type="primary" @click="emits('open', item)"> è¯¦æƒ… </el-link>
        </el-row>
        <div>
          <el-tag
            effect="plain"
            type="info"
            size="small"
            hit
            round
            class="m-r-4"
          >
            <div v-html="formatFactorName(item.pollutedData.factorName)"></div>
          </el-tag>
          <el-text type="primary">
            {{ item.pollutedData.exception + ',' }}
          </el-text>
          <el-text type="primary">{{
            formatDistanceType(item.pollutedArea.distanceType)
          }}</el-text>
          <el-text :type="noWarn ? 'primary' : 'warning'">
            {{
              item.pollutedSource.sceneList.length == 0
                ? '未找到风险源'
                : '找到' + item.pollutedSource.sceneList.length + '个风险源'
            }}
          </el-text>
        </div>
        <div v-if="item.pollutedSource.sceneList.length > 0">
          <div v-for="s in item.pollutedSource.sceneList" :key="s.guid">
            <img style="width: 24px" :src="sceneIcon(s.typeId)" :alt="s.type" />
            <el-text
              type="warning"
              tag="ins"
              truncated
              class="text-link"
              @click="handleSetCenter(item, s)"
            >
              {{ s.name }}
            </el-text>
          </div>
        </div>
      </el-col>
    </el-row>
    <div v-else-if="item._type == '2'">
      <el-row justify="space-between">
        <el-tag type="danger" effect="dark" size="small">线索</el-tag>
@@ -37,10 +66,73 @@
      </el-row>
      <el-text type="danger">{{ item.advice }}</el-text>
    </div>
    <el-row v-else-if="item._type == '3'">
      <el-col :span="3">
        <el-tag type="primary" effect="dark" size="small">提醒</el-tag>
      </el-col>
      <el-col :span="21">
        <el-row justify="space-between">
          <el-space>
            <el-text type="primary" size="default">
              <el-icon><Timer /></el-icon>
              {{
                item.pollutedData.startTime + ' - ' + item.pollutedData.endTime
              }}
            </el-text>
          </el-space>
          <el-link type="primary" @click="emits('open', item)"> è¯¦æƒ… </el-link>
        </el-row>
        <div>
          <el-tag
            effect="plain"
            type="info"
            size="small"
            hit
            round
            class="m-r-4"
          >
            <div v-html="formatFactorName(item.pollutedData.factorName)"></div>
          </el-tag>
          <el-text type="primary">{{ item.pollutedData.exception }}</el-text>
        </div>
        <div v-if="item.pollutedSource.sceneList.length > 0">
          <div v-for="s in item.pollutedSource.sceneList" :key="s.guid">
            <img style="width: 24px" :src="sceneIcon(s.typeId)" :alt="s.type" />
            <el-text
              type="warning"
              tag="ins"
              truncated
              class="text-link"
              @click="handleSetCenter(item, s)"
            >
              {{ s.name }}
            </el-text>
          </div>
        </div>
      </el-col>
      <!-- <el-row justify="space-between">
        <el-space>
          <el-tag type="primary" effect="dark" size="small">提醒</el-tag>
          <el-text type="primary">{{
            item.pollutedData.startTime + ' - ' + item.pollutedData.endTime
          }}</el-text>
        </el-space>
        <el-link type="primary" @click="emits('open', item)"> è¯¦æƒ… </el-link>
      </el-row>
      <el-col :span="24">
        <el-tag effect="plain" type="info" size="small" hit round class="m-r-4">
          <div v-html="formatFactorName(item.pollutedData.factorName)"></div>
        </el-tag>
        <el-text type="primary">{{ item.pollutedData.exception }}</el-text>
      </el-col> -->
    </el-row>
  </div>
</template>
<script setup>
import { computed } from 'vue';
import { sceneTypes, sceneIcon } from '@/constant/scene-types';
import MapUtil from '@/utils/map/util';
const props = defineProps({
  item: Object
@@ -78,6 +170,34 @@
      break;
  }
}
function formatFactorName(name) {
  switch (name) {
    case 'PM25':
      return 'PM<sub>2.5</sub>';
    // return '<span>PM2.5</span>';
    case 'PM10':
      return 'PM<sub>10</sub>';
    case 'NO2':
      return 'NO<sub>2</sub>';
    case 'H2S':
      return 'H<sub>2</sub>S';
    case 'SO2':
      return 'SO<sub>2</sub>';
    case 'O3':
      return 'O<sub>3</sub>';
    case 'VOC':
      return 'VOC<sub>s</sub>';
    default:
      break;
  }
}
function handleSetCenter(item, scene) {
  MapUtil.setCenter([scene.longitude, scene.latitude], true);
  emits('open', item);
}
</script>
<style scoped>
.wrapper {
@@ -93,4 +213,8 @@
.no-warning {
  color: var(--el-text-color-disabled) !important;
}
.text-link {
  width: 90%;
  cursor: pointer;
}
</style>
src/views/sourcetrace/component/PollutedExceptionItem.vue
@@ -10,34 +10,43 @@
  <BaseCard v-if="item" v-show="item.showMore">
    <template #content>
      <el-scrollbar class="clue-card">
        <el-row justify="space-between">
          <!-- <el-tag v-if="index == 0" type="danger">最新</el-tag> -->
          <el-text type="primary">{{
            '切片时间:' +
            item.pollutedData.startTime +
            ' - ' +
            item.pollutedData.endTime
          }}</el-text>
        <el-row justify="space-between" align="bottom">
          <el-text type="warning" size="large"> å…¸åž‹åˆ‡ç‰‡ </el-text>
          <el-link
            type="primary"
            :underline="true"
            @click="showMarksAndPolygon(item)"
          >
            {{ item.showMore ? '收起异常' : '定位异常' }}
            {{ item.showMore ? '收起' : '定位' }}
            <el-icon size="large"><CircleClose /></el-icon>
          </el-link>
        </el-row>
        <div>
          <el-text type="primary">
            æ±¡æŸ“区域:{{ item.pollutedArea.address }}
            <el-icon><Timer /></el-icon>
            {{
              '切片时段:' +
              item.pollutedData.startTime +
              ' - ' +
              item.pollutedData.endTime
            }}
          </el-text>
        </div>
        <div>
          <el-text type="primary">
            <el-icon><MapLocation /></el-icon>
            {{ '风险区域:' + item.pollutedArea.address }}
          </el-text>
        </div>
        <!-- <div>
          <el-text type="primary">
            æº¯æºè·ç¦»ï¼š{{ formatDistanceType(item.pollutedArea.distanceType) }}
          </el-text>
        </div>
        </div> -->
        <div>
          <el-text type="primary">
            <el-icon><BellFilled /></el-icon>
            å¼‚常类型:{{ item.pollutedData.exception }}
          </el-text>
        </div>
@@ -92,6 +101,7 @@
          :key="index1"
          :model-value="item1"
          chart-height="80px"
          :y-min-interval="20"
        ></RealTimeLineChart>
        <!-- </div> -->
        <div class="border-dashed">
src/views/sourcetrace/component/SourceTraceFilter.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,99 @@
<template>
  <div>
    <div>
      <el-space>
        <el-text type="primary">数据切片</el-text>
        <el-checkbox-group
          :model-value="dataSlice"
          @update:model-value="(e) => emits('update:data-slice', e)"
          size="default"
          :min="1"
        >
          <el-space>
            <el-checkbox value="1">溯源</el-checkbox>
            <el-checkbox value="2">线索</el-checkbox>
            <el-checkbox value="3">提示</el-checkbox>
          </el-space>
        </el-checkbox-group>
      </el-space>
    </div>
    <div>
      <el-space>
        <el-text type="primary">监测因子</el-text>
        <el-checkbox-group
          :model-value="factorType"
          @update:model-value="(e) => emits('update:factor-type', e)"
          size="default"
          :min="1"
        >
          <el-space>
            <el-checkbox
              v-for="item in factorOptions"
              :value="item.value"
              :key="item.label"
            >
              {{ item.label }}
            </el-checkbox>
          </el-space>
        </el-checkbox-group>
      </el-space>
    </div>
    <div>
      <el-space>
        <el-text type="primary">场景类型</el-text>
        <el-checkbox-group
          :model-value="sceneType"
          @update:model-value="(e) => emits('update:scene-type', e)"
          size="default"
          :min="1"
        >
          <el-space>
            <el-checkbox
              v-for="item in sceneOptions"
              :value="item.value"
              :key="item.label"
            >
              {{ item.label }}
            </el-checkbox>
          </el-space>
        </el-checkbox-group>
      </el-space>
    </div>
  </div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
  // æ•°æ®åˆ‡ç‰‡ï¼Œçº¿ç´¢ã€æç¤ºã€æº¯æº
  dataSlice: Array,
  // ç›‘测因子
  factorType: Array,
  factorOptions: Array,
  // åœºæ™¯ç±»åž‹
  sceneType: Array,
  sceneOptions: Array
});
const emits = defineEmits([
  'update:data-slice',
  'update:factor-type',
  'update:scene-type'
]);
</script>
<style scoped>
:deep(.el-checkbox) {
  --el-checkbox-text-color: white;
  --main-color: #23dad1;
  --el-checkbox-checked-text-color: var(--main-color);
  --el-checkbox-checked-input-border-color: var(--main-color);
  --el-checkbox-checked-bg-color: var(--main-color);
  --el-checkbox-input-border-color-hover: var(--main-color);
  --el-checkbox-disabled-checked-input-fill: var(--main-color);
  --el-checkbox-disabled-checked-input-border-color: var(--main-color);
  --el-checkbox-disabled-checked-icon-color: white;
  margin-right: 6px;
  /* height: initial; */
}
</style>