Загрузка данных
<template>
<div>
<div class="columns mx-1 mt-1">
<!-- ─── Карта ─── -->
<div class="column is-6-tablet is-8-widescreen is-8-fullhd main-map">
<l-map
ref="map"
:max-bounds="$options.MAP.maxBounds"
:min-zoom="$options.MAP.minZoom"
:max-zoom="$options.MAP.maxZoom"
:zoom="$options.MAP.zoom"
:center="$options.MAP.center"
:bounds="bounds"
@ready="onMapReady"
>
<l-tile-layer :url="$options.MAP.url" />
<v-marker-cluster :options="clusterOptions">
<l-marker
v-for="object in list"
:key="`marker_${object.b_id}`"
:lat-lng="[object.a_lat, object.a_lon]"
>
<l-icon :class-name="markerClass(object)">
<b-icon icon="building" size="is-small" />
</l-icon>
<l-popup>
{{ object.a_name }}
<br />
<span
v-for="providerId in object.competitors"
:key="providerId"
class="tag is-primary is-light mr-1"
>
{{ providers[providerId].p_name }}
</span>
</l-popup>
</l-marker>
</v-marker-cluster>
</l-map>
</div>
<!-- ─── Список адресов ─── -->
<div class="column">
<div class="subtitle is-5 has-background-info-light p-2 mb-2">
Список объектов на карте ({{ visibleAddresses.length }} шт.):
</div>
<b-table
:data="visibleAddresses"
bordered
narrowed
hoverable
paginated
:per-page="perPage"
:current-page="currentPage"
pagination-size="is-small"
sticky-header
height="calc(100vh - 280px)"
class="smallTable is-size-7 fullHeight"
:row-class="rowClass"
detailed
detail-key="b_id"
:show-detail-icon="false"
:opened-detailed="openedDetails"
@click="toggleRowDetail"
>
<!-- № -->
<b-table-column v-slot="props" field="id" label="№" width="20">
<div>{{ props.index + 1 }}</div>
<b-icon
v-if="!props.row.a_house_number"
icon="exclamation-circle"
type="is-danger"
title="Неверно выбрана геометка у адреса. Сверьте название по оригинальному адресу из АСТУП файла (вторая строчка в столбце с адресом) и найдите актуальный адрес из OpenStreetMap"
style="width: 12px"
/>
</b-table-column>
<!-- Адрес -->
<b-table-column v-slot="props" field="a_name" label="Адрес" width="150">
<div class="has-text-weight-semibold" title="Адрес после геокодирования OpenStreetMap" style="cursor: default">
<span v-if="props.row.a_city">{{ props.row.a_city }},</span>
{{ props.row.a_name }}
</div>
<div
:class="props.row.a_house_number ? 'has-text-grey' : 'tag is-success'"
title="Оригинальный адрес из файла АСТУП"
style="cursor: default"
>
{{ props.row.b_eva }}, {{ props.row.b_number }}
</div>
</b-table-column>
<!-- Подъездов / Этажей / Квартир -->
<b-table-column v-slot="props" field="b_entrance_count" label="Подъездов" width="20">
{{ props.row.b_entrance_count }}
</b-table-column>
<b-table-column v-slot="props" field="b_floor" label="Этажей" width="20">
{{ props.row.b_floor }}
</b-table-column>
<b-table-column v-slot="props" field="b_flat" label="Квартир" width="20">
{{ props.row.b_flat }}
</b-table-column>
<!-- Конкуренты -->
<b-table-column v-slot="props" field="competitors" label="Конкуренты" width="50" centered>
<span
v-for="providerId in props.row.competitors"
:key="providerId"
class="tag is-primary is-light mr-1"
>
{{ providers[providerId].p_name }}
</span>
</b-table-column>
<!-- Детальная строка -->
<template slot="detail" slot-scope="props">
<div class="columns">
<!-- Информация о здании -->
<div class="column">
<div class="box" :class="{ 'has-background-grey-light': props.row.b_deleted }">
<b-field>
<div class="title is-5" style="width: 100%">
{{ props.row.b_eva }}, {{ props.row.b_number }}
</div>
<div style="width: 30px">
<button
v-if="!props.row.b_deleted && isOperator"
class="button is-success is-outlined is-small is-fullwidth"
@click.stop="buildingModal_visible = true"
>
<b-icon icon="pen" aria-hidden="true" />
</button>
</div>
</b-field>
<b-tag v-if="props.row.b_deleted" type="is-danger">
Удален {{ new Date(props.row.b_deleted).toLocaleDateString() }}
</b-tag>
<b-field class="label_30" horizontal label="Адрес OpenStreetMap">
<div>
<span v-if="props.row.a_city">{{ props.row.a_city }},</span>
{{ props.row.a_name }}
</div>
</b-field>
<b-field class="label_30" horizontal label="Примечание">
{{ props.row.b_note }}
</b-field>
</div>
</div>
<!-- Список конкурентов -->
<div class="column">
<div class="box">
<div class="media">
<div class="media-content">
<div class="subtitle is-5">Список конкурентов</div>
</div>
<div class="media-right">
<button
v-if="!props.row.b_deleted && isOperator"
class="button is-info is-small is-fullwidth"
@click="openAddCompetitorModal"
>
Добавить конкурента
</button>
</div>
</div>
<b-table
v-if="props.row.competitors"
:data="props.row.competitors"
:bordered="false"
striped
narrowed
hoverable
class="is-size-7"
>
<template>
<b-table-column v-slot="{ row }" field="p_id" label="№" width="10">
{{ providers[row].p_id }}
</b-table-column>
<b-table-column v-slot="{ row }" field="p_name" label="Наименование" width="50">
{{ providers[row].p_name }}
</b-table-column>
<b-table-column v-slot="{ row }" field="edit" label="" width="10">
<button
class="button is-primary is-small is-outlined"
style="height: 24px"
disabled
title="Редактировать конкурента"
@click="openEditCompetitorModal(row)"
>
<b-icon icon="pen" />
</button>
</b-table-column>
</template>
<template slot="empty">
<section class="section">
<div class="content has-text-grey has-text-centered">
<p>Данные не получены</p>
</div>
</section>
</template>
</b-table>
<div v-else class="subtitle is-6 has-text-weight-bold mt-2">
Адреса не добавлены
</div>
</div>
</div>
</div>
</template>
<template #empty>
<div class="content has-text-grey has-text-centered">
<b-icon class="my-icon height-100 is-size-1" icon="blind" size="is-large" />
<p class="mb-3">Ничего не найдено</p>
</div>
</template>
</b-table>
</div>
</div>
<!-- ─── Модальное окно редактирования здания ─── -->
<b-modal v-model="buildingModal_visible" has-modal-card width="1000px">
<Building-modal
:edit_building="selectedRecord"
:providers="providers"
@close-modal="buildingModal_visible = false"
@update-info="onBuildingUpdated"
/>
</b-modal>
</div>
</template>
<script>
import debounce from 'lodash/debounce';
import L from 'leaflet';
import { Icon } from 'leaflet';
import { LMap, LTileLayer, LMarker, LIcon, LPopup } from 'vue2-leaflet';
import Vue2LeafletMarkerCluster from 'vue2-leaflet-markercluster';
import 'leaflet/dist/leaflet.css';
import { wrapWithLoading } from '../../../mixins/wrappers.mixin';
import { showDangerToast } from '../../../mixins/toast.mixin';
import BuildingModal from '../modals/buildingModal.vue';
// Исправляем отображение иконок Leaflet по умолчанию
delete Icon.Default.prototype._getIconUrl;
Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
});
export default {
name: 'MapPage',
components: {
LMap,
LTileLayer,
LMarker,
LIcon,
LPopup,
'v-marker-cluster': Vue2LeafletMarkerCluster,
BuildingModal,
},
mixins: [wrapWithLoading, showDangerToast],
props: ['list', 'providers', 'isOperator'],
// ─── Статические настройки карты (не реактивны — вынесены из data) ───────────
MAP: {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
center: [53.9, 27.56],
zoom: 12,
minZoom: 5,
maxZoom: 18,
maxBounds: L.latLngBounds(
[51.190785980509354, 23.076354200895736],
[56.245591731745186, 32.878022287814566]
),
},
data() {
return {
// Карта
mapInstance: null,
// Настройки кластера
clusterOptions: {
spiderfyDistanceMultiplier: 1,
chunkedLoading: false,
maxClusterRadius: 70,
disableClusteringAtZoom: 16,
},
// Начальный bbox карты (Минск)
bounds: L.latLngBounds([53.88, 27.4], [53.93, 27.7]),
// Адреса, попадающие в текущий viewport карты
visibleAddresses: [],
// Таблица
openedDetails: [],
perPage: 50,
currentPage: 1,
// Модалки
buildingModal_visible: false,
organizationModal_visible: false,
selectedOrganization: null,
};
},
computed: {
/**
* Запись, развёрнутая в детальной строке таблицы.
* Используется как пропс для BuildingModal.
*/
selectedRecord() {
if (!this.openedDetails.length) return null;
return this.list.find((item) => item.b_id === this.openedDetails[0]) ?? null;
},
},
beforeDestroy() {
if (this.mapInstance) {
this.mapInstance.off('moveend', this.updateVisibleAddresses);
}
},
methods: {
// ─── Карта ────────────────────────────────────────────────────────────────
/** Вызывается когда Leaflet-карта готова. Сохраняем инстанс и вешаем обработчик. */
onMapReady(map) {
this.mapInstance = map;
map.on('moveend', this.updateVisibleAddresses);
this.updateVisibleAddresses();
},
/**
* Фильтрует `list` по текущим границам карты.
* Debounce 1000 мс — не вызывается слишком часто при перетаскивании.
*
* Оптимизация: используем Object.freeze на результате — Vue не
* делает массив реактивным глубоко, что ускоряет рендер таблицы.
*/
updateVisibleAddresses: debounce(function () {
if (!this.mapInstance) return;
const bounds = this.mapInstance.getBounds();
this.visibleAddresses = Object.freeze(
this.list.filter((item) => bounds.contains(L.latLng(item.a_lat, item.a_lon)))
);
}, 1000),
// ─── Таблица ──────────────────────────────────────────────────────────────
/** CSS-класс строки таблицы (красный фон если нет номера дома). */
rowClass(row) {
return !row.a_house_number ? 'has-background-danger-light' : '';
},
/** Открывает / закрывает детальную строку при клике по строке таблицы. */
toggleRowDetail(row) {
this.openedDetails = this.openedDetails[0] === row.b_id ? [] : [row.b_id];
},
// ─── Маркеры ──────────────────────────────────────────────────────────────
/** CSS-класс маркера на карте в зависимости от статуса объекта. */
markerClass(object) {
if (object.o_free) return 'object-icon border-green';
if (object.o_sc) return 'object-icon border-red';
return 'object-icon border-orange';
},
// ─── Модалки / действия ───────────────────────────────────────────────────
openAddCompetitorModal() {
this.selectedOrganization = null;
this.organizationModal_visible = true;
},
openEditCompetitorModal(row) {
this.selectedOrganization = row;
this.organizationModal_visible = true;
},
onBuildingUpdated() {
this.getList();
if (this.$refs.shortloglist) {
this.$refs.shortloglist.getShortLog();
}
},
},
};
</script>
<style scoped lang="scss">
@import '~leaflet/dist/leaflet.css';
@import '~leaflet.markercluster/dist/MarkerCluster.css';
@import '~leaflet.markercluster/dist/MarkerCluster.Default.css';
// ─── Стили карты ────────────────────────────────────────────────────────────
.app-main-page .main-map::v-deep {
.vue2leaflet-map {
border-radius: 10px;
box-shadow: 3px 3px 5px #0000005c, 0 0 3px #0000008c;
height: calc(100vh - 180px) !important;
z-index: 1 !important;
// Скрываем лишний копирайт
.leaflet-control-attribution.leaflet-control a[href="https://leafletjs.com"] {
display: none;
}
// ─── Иконки объектов ────────────────────────────────────────────────────
.leaflet-marker-icon.object-icon {
background-color: white;
border-style: solid;
border-width: 2px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
height: 28px !important;
width: 28px !important;
padding: 3px;
// Цвета границ маркеров по статусу объекта
$marker-colors: (
'red': red,
'green': #23e635,
'blue': #2160e6,
'yellow': yellow,
'violet': violet,
'orange': orange,
'olive': olive,
'turquoise': turquoise,
'orangeRed': OrangeRed,
);
@each $name, $color in $marker-colors {
&.border-#{$name} {
border-color: $color;
&:after { background: $color; }
}
}
}
.leaflet-marker-icon.addressPopup {
background-color: white;
border: 2px solid black;
height: 10px;
border-radius: 50%;
}
}
}
// ─── Стили таблицы и скроллбаров ────────────────────────────────────────────
.app-map-page::v-deep {
// Общий миксин для кастомного скроллбара
@mixin custom-scrollbar {
::-webkit-scrollbar { width: 7px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { border-radius: 3px; background: #888; }
::-webkit-scrollbar-thumb:hover { background: #555; }
}
.smallTable {
.table-wrapper { overflow-x: hidden; }
@include custom-scrollbar;
}
.smallScrollCntainer {
.smallScroll {
overflow-x: hidden;
max-height: calc(100vh - 180px - 220px) !important;
overflow-y: scroll;
}
@include custom-scrollbar;
}
.halfHeight > div.table-wrapper { height: calc(50vh - 140px) !important; }
.fullHeight > div.table-wrapper { height: calc(100vh - 230px) !important; }
// Адаптив для узких экранов
@media screen and (max-width: 1000px) {
.columns {
display: block !important;
.column {
width: 100% !important;
.vue2leaflet-map { height: 600px !important; }
.smallTable .table-wrapper { height: 100%; }
}
}
}
// v-select стили
.vs__dropdown-menu { width: auto !important; }
.v-select input { height: 20px; }
}
</style>