Browse Source

地图新增搜索框:按路口名称/编号本地过滤,定位后即时刷新视口

    - 新增 src/components/ui/MapSearchBox.vue:input + 下拉,includes 过滤
      单地区数千内零延迟,0 网络/配额;↓↑/Enter/ESC 键盘交互、中文输入
      法 compositionstart/end 防抖动、点击外部关闭、清空按钮
    - TongzhouTrafficMap 集成:引入组件,绑定 intersectionData,新增
      onSearchSelect → focusById + 立即 computeVisibleMarkers,避开 100ms
      debounce 造成的目标 marker 空白
    - 位置布局:searchBoxStyle 用 position:fixed + z-index:999 跳出
      map-wrapper 的 stacking context,避免被 ui-layer 上的右侧栏遮挡;
      新增 searchOffsetRight/Top props 适配不同侧栏宽度
    - 三个 special-situation-monitoring 布局的 view(StatusMonitoring /
      SpecialSituationMonitoring / TrunkCoordination)右侧栏 480px,
      显式传 :search-offset-right="570";Home 用默认 490
画安 1 month ago
parent
commit
53c9610f98

+ 30 - 1
src/components/TongzhouTrafficMap.vue

@@ -39,19 +39,29 @@
     <div class="legend-show-btn" v-if="(!mode || mode === '路口') && !legendVisible" @click="toggleLegend" :style="legendShowBtnStyle">
       <div class="legend-show-icon">☰</div>
     </div>
+
+    <div class="map-search-wrap" :style="searchBoxStyle">
+      <MapSearchBox :data-source="intersectionData" @select="onSearchSelect" />
+    </div>
   </div>
 </template>
 
 <script>
 import AMapLoader from '@amap/amap-jsapi-loader';
 import { getIntersectionCategory } from '@/mock/api';
+import MapSearchBox from '@/components/ui/MapSearchBox.vue';
 
 export default {
   name: "TrafficMap",
+  components: { MapSearchBox },
   props: {
     amapKey: { type: String, default: () => process.env.VUE_APP_AMAP_KEY || 'db2da7e3e248c3b2077d53fc809be63f' },
     securityJsCode: { type: String, default: () => process.env.VUE_APP_AMAP_SECURITY_CODE || 'a7413c674852c5eaf01d90813c5b7ef6' },
-    mode: { type: String, default: '', validator: (value) => ['', '路口', '干线', '特勤'].includes(value) }
+    mode: { type: String, default: '', validator: (value) => ['', '路口', '干线', '特勤'].includes(value) },
+    // 搜索框相对屏幕右侧的偏移;默认 490px 适配 400px 右侧栏(Home),
+    // special-situation-monitoring 布局右侧栏 480px,需传 570。
+    searchOffsetRight: { type: Number, default: 490 },
+    searchOffsetTop: { type: Number, default: 110 },
   },
   data() {
     return {
@@ -202,6 +212,14 @@ export default {
       const right = (this.privateStyle.legend && this.privateStyle.legend.right) ? this.privateStyle.legend.right : '25%';
       return { right, borderRadius: '6px' };
     },
+    searchBoxStyle() {
+      return {
+        position: 'fixed',
+        top: `${this.searchOffsetTop}px`,
+        right: `${this.searchOffsetRight}px`,
+        zIndex: 999,
+      };
+    },
     legendStatusConfig() {
       // 1. 按 mode 过滤
       let list = this.statusConfig;
@@ -1547,6 +1565,17 @@ export default {
     },
 
     /**
+     * 搜索框选中回调:定位 + 立即触发视口重算(避免 100ms debounce 延迟),
+     * 让目标 marker 在 setZoomAndCenter 动画期间就完成挂载。
+     */
+    onSearchSelect(item) {
+      if (!item) return;
+      const id = item['路口编号'];
+      this.focusById(id);
+      this.computeVisibleMarkers();
+    },
+
+    /**
      * 切换图例的可见性
      */
     toggleLegend() {

+ 273 - 0
src/components/ui/MapSearchBox.vue

@@ -0,0 +1,273 @@
+<template>
+  <div class="map-search-box" @click.stop>
+    <div class="search-input-wrap" :class="{ 'is-open': open && hasResults }">
+      <i class="search-icon"></i>
+      <input
+        ref="input"
+        v-model="query"
+        :placeholder="placeholder"
+        class="search-input"
+        @focus="open = true"
+        @keydown.down.prevent="moveSelection(1)"
+        @keydown.up.prevent="moveSelection(-1)"
+        @keydown.enter.prevent="selectActive"
+        @keydown.esc="handleEsc"
+        @compositionstart="composing = true"
+        @compositionend="onCompositionEnd"
+      />
+      <i v-if="query" class="clear-icon" @click="clear" title="清空">✕</i>
+    </div>
+
+    <div v-if="open && query && !composing" class="search-dropdown">
+      <div v-if="filtered.length === 0" class="dropdown-empty">无匹配路口</div>
+      <div
+        v-for="(item, idx) in filtered"
+        :key="item[idField]"
+        class="dropdown-item"
+        :class="{ 'is-active': idx === activeIdx }"
+        @mouseenter="activeIdx = idx"
+        @click="selectItem(item)"
+      >
+        <div class="item-name">{{ item[nameField] }}</div>
+        <div class="item-id">{{ item[idField] }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'MapSearchBox',
+  props: {
+    dataSource: { type: Array, required: true },
+    placeholder: { type: String, default: '搜路口' },
+    maxResults: { type: Number, default: 10 },
+    searchFields: { type: Array, default: () => ['路口名称', '路口编号'] },
+    nameField: { type: String, default: '路口名称' },
+    idField: { type: String, default: '路口编号' },
+  },
+  data() {
+    return {
+      query: '',
+      open: false,
+      activeIdx: 0,
+      composing: false,
+    };
+  },
+  computed: {
+    filtered() {
+      const q = this.query.trim().toLowerCase();
+      if (!q) return [];
+      const out = [];
+      const fields = this.searchFields;
+      for (const item of this.dataSource) {
+        for (const f of fields) {
+          const v = item[f];
+          if (v != null && String(v).toLowerCase().includes(q)) {
+            out.push(item);
+            break;
+          }
+        }
+        if (out.length >= this.maxResults) break;
+      }
+      return out;
+    },
+    hasResults() {
+      return this.query && !this.composing;
+    },
+  },
+  watch: {
+    filtered() {
+      this.activeIdx = 0;
+    },
+  },
+  mounted() {
+    this._outsideHandler = (e) => {
+      if (!this.$el.contains(e.target)) this.open = false;
+    };
+    document.addEventListener('click', this._outsideHandler);
+  },
+  beforeDestroy() {
+    if (this._outsideHandler) {
+      document.removeEventListener('click', this._outsideHandler);
+      this._outsideHandler = null;
+    }
+  },
+  methods: {
+    moveSelection(delta) {
+      const len = this.filtered.length;
+      if (len === 0) return;
+      this.activeIdx = (this.activeIdx + delta + len) % len;
+    },
+    selectActive() {
+      const item = this.filtered[this.activeIdx];
+      if (item) this.selectItem(item);
+    },
+    selectItem(item) {
+      this.$emit('select', item);
+      this.open = false;
+      this.$refs.input && this.$refs.input.blur();
+    },
+    handleEsc() {
+      if (this.open) {
+        this.open = false;
+      } else {
+        this.clear();
+      }
+    },
+    clear() {
+      this.query = '';
+      this.activeIdx = 0;
+      this.$refs.input && this.$refs.input.focus();
+    },
+    onCompositionEnd() {
+      this.composing = false;
+    },
+  },
+};
+</script>
+
+<style scoped>
+.map-search-box {
+  width: 300px;
+  font-family: inherit;
+}
+
+.search-input-wrap {
+  position: relative;
+  display: flex;
+  align-items: center;
+  height: 38px;
+  background: rgba(5, 22, 45, 0.9);
+  border: 1px solid #1e4d8e;
+  border-radius: 7px;
+  backdrop-filter: blur(8px);
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+  transition: border-color 0.15s;
+}
+
+.search-input-wrap:focus-within,
+.search-input-wrap.is-open {
+  border-color: #3a7fd1;
+}
+
+.search-icon {
+  width: 14px;
+  height: 14px;
+  margin: 0 8px 0 12px;
+  border: 1.5px solid rgba(255, 255, 255, 0.5);
+  border-radius: 50%;
+  position: relative;
+  flex-shrink: 0;
+}
+
+.search-icon::after {
+  content: '';
+  position: absolute;
+  width: 1.5px;
+  height: 6px;
+  background: rgba(255, 255, 255, 0.5);
+  right: -3px;
+  bottom: -3px;
+  transform: rotate(-45deg);
+}
+
+.search-input {
+  flex: 1;
+  height: 100%;
+  background: transparent;
+  border: none;
+  outline: none;
+  color: #fff;
+  font-size: 13px;
+  padding: 0;
+  min-width: 0;
+}
+
+.search-input::placeholder {
+  color: rgba(255, 255, 255, 0.4);
+}
+
+.clear-icon {
+  width: 18px;
+  height: 18px;
+  line-height: 18px;
+  text-align: center;
+  margin: 0 8px;
+  color: rgba(255, 255, 255, 0.5);
+  cursor: pointer;
+  font-size: 12px;
+  font-style: normal;
+  border-radius: 50%;
+  flex-shrink: 0;
+  user-select: none;
+}
+
+.clear-icon:hover {
+  color: #fff;
+  background: rgba(255, 255, 255, 0.1);
+}
+
+.search-dropdown {
+  margin-top: 6px;
+  max-height: 320px;
+  overflow-y: auto;
+  background: rgba(5, 22, 45, 0.92);
+  border: 1px solid #1e4d8e;
+  border-radius: 7px;
+  backdrop-filter: blur(8px);
+  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+}
+
+.dropdown-empty {
+  padding: 14px 12px;
+  text-align: center;
+  color: rgba(255, 255, 255, 0.4);
+  font-size: 12px;
+}
+
+.dropdown-item {
+  padding: 8px 12px;
+  cursor: pointer;
+  border-bottom: 1px solid rgba(30, 77, 142, 0.25);
+  transition: background 0.12s;
+}
+
+.dropdown-item:last-child {
+  border-bottom: none;
+}
+
+.dropdown-item.is-active,
+.dropdown-item:hover {
+  background: rgba(30, 77, 142, 0.4);
+}
+
+.item-name {
+  color: #fff;
+  font-size: 13px;
+  line-height: 1.4;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.item-id {
+  margin-top: 2px;
+  color: rgba(120, 200, 255, 0.6);
+  font-size: 11px;
+  font-family: 'Consolas', monospace;
+}
+
+.search-dropdown::-webkit-scrollbar {
+  width: 6px;
+}
+
+.search-dropdown::-webkit-scrollbar-thumb {
+  background: rgba(30, 77, 142, 0.6);
+  border-radius: 3px;
+}
+
+.search-dropdown::-webkit-scrollbar-track {
+  background: transparent;
+}
+</style>

+ 1 - 0
src/views/SpecialSituationMonitoring.vue

@@ -16,6 +16,7 @@
             <!-- 地图 -->
             <TongzhouTrafficMap v-else ref="trafficMapRef"
                 :mode="activeLeftTab === 'crossing' ? '路口' : activeLeftTab === 'trunkLine' ? '干线' : activeLeftTab === 'specialDuty' ? '特勤' : ''"
+                :search-offset-right="570"
                 @map-crossing-click="handleMapCrossingClick"
                 @map-crossing-mouseover="handleMapCrossingMouseover"
                 @map-crossing-mouseout="handleMapCrossingMouseout"

+ 1 - 0
src/views/StatusMonitoring.vue

@@ -16,6 +16,7 @@
             <!-- 地图 -->
             <TongzhouTrafficMap v-else ref="trafficMapRef"
                 :mode="activeLeftTab === 'crossing' ? '路口' : activeLeftTab === 'trunkLine' ? '干线' : activeLeftTab === 'specialDuty' ? '特勤' : ''"
+                :search-offset-right="570"
                 @map-crossing-click="handleMapCrossingClick"
                 @map-crossing-mouseover="handleMapCrossingMouseover"
                 @map-crossing-mouseout="handleMapCrossingMouseout"

+ 1 - 0
src/views/TrunkCoordination.vue

@@ -16,6 +16,7 @@
             <!-- 地图 -->
             <TongzhouTrafficMap v-else ref="trafficMapRef"
                 :mode="activeLeftTab === 'crossing' ? '路口' : activeLeftTab === 'trunkLine' ? '干线' : activeLeftTab === 'specialDuty' ? '特勤' : ''"
+                :search-offset-right="570"
                 @map-crossing-click="handleMapCrossingClick"
                 @map-crossing-mouseover="handleMapCrossingMouseover"
                 @map-crossing-mouseout="handleMapCrossingMouseout"