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