Refactor map tools and drop SocialParser runtime dependency.
Add filtered map API, creamy-caprice-style toolbar (dates, layers, search), Yandex ru tiles, and store Telegram session in MapMil/data instead of mounting SocialParser. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,91 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { fetchMapFilters, fetchMapObjects } from "../api/map";
|
||||
import {
|
||||
createObject,
|
||||
deleteObject,
|
||||
fetchObjects,
|
||||
updateObject,
|
||||
uploadObjectMedia,
|
||||
} from "../api/objects";
|
||||
import { fetchEvents } from "../api/admin";
|
||||
import ContextMenu from "../components/ContextMenu.vue";
|
||||
import CreateObjectModal from "../components/CreateObjectModal.vue";
|
||||
import EditObjectModal from "../components/EditObjectModal.vue";
|
||||
import MapView from "../components/MapView.vue";
|
||||
import CoordsTools from "../components/map/CoordsTools.vue";
|
||||
import MapToolbar from "../components/map/MapToolbar.vue";
|
||||
import PlaceSearch from "../components/map/PlaceSearch.vue";
|
||||
import ObjectPanel from "../components/ObjectPanel.vue";
|
||||
import TimelineBar from "../components/TimelineBar.vue";
|
||||
import type { MapObject, MapObjectCreate, ObjectType } from "../types/object";
|
||||
import type { LeafletMapApi } from "../composables/useLeafletMap";
|
||||
import type { DateRangePreset, MapFilters, MapObjectWithEvent, MapQueryParams } from "../types/map";
|
||||
import type { MapObjectCreate, ObjectType } from "../types/object";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const objects = ref<MapObject[]>([]);
|
||||
const selectedObject = ref<MapObject | null>(null);
|
||||
const objects = ref<MapObjectWithEvent[]>([]);
|
||||
const mapFilters = ref<MapFilters | null>(null);
|
||||
const selectedObject = ref<MapObjectWithEvent | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const timelinePosition = ref(Date.now());
|
||||
const mapApi = ref<LeafletMapApi | null>(null);
|
||||
const centerCoords = ref({ lat: 48.257381, lng: 37.134785 });
|
||||
const openPopupId = ref<number | null>(null);
|
||||
|
||||
const contextMenu = ref<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
target: "map" | "object";
|
||||
object: MapObject | null;
|
||||
}>({
|
||||
const selectedDate = ref<string | null>(null);
|
||||
const rangePreset = ref<DateRangePreset>("all");
|
||||
const region = ref("");
|
||||
const topic = ref("");
|
||||
const sourceType = ref("");
|
||||
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let mapViewRef = ref<InstanceType<typeof MapView> | null>(null);
|
||||
|
||||
const contextMenu = ref({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
target: "map",
|
||||
object: null,
|
||||
target: "map" as "map" | "object",
|
||||
object: null as MapObjectWithEvent | null,
|
||||
});
|
||||
|
||||
const createModal = ref({
|
||||
visible: false,
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
});
|
||||
|
||||
const editModal = ref({
|
||||
visible: false,
|
||||
object: null as MapObject | null,
|
||||
});
|
||||
const createModal = ref({ visible: false, latitude: 0, longitude: 0 });
|
||||
const editModal = ref({ visible: false, object: null as MapObjectWithEvent | null });
|
||||
|
||||
const selectedId = computed(() => selectedObject.value?.id ?? null);
|
||||
|
||||
function objectTime(obj: MapObject): number {
|
||||
return new Date(obj.created_at).getTime();
|
||||
function subtractDays(isoDate: string, days: number): string {
|
||||
const d = new Date(isoDate);
|
||||
d.setUTCDate(d.getUTCDate() - days);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function replaceObject(updated: MapObject) {
|
||||
objects.value = objects.value.map((obj) => (obj.id === updated.id ? updated : obj));
|
||||
if (selectedObject.value?.id === updated.id) {
|
||||
selectedObject.value = updated;
|
||||
}
|
||||
function endOfDayIso(isoDate: string): string {
|
||||
return `${isoDate}T23:59:59.999Z`;
|
||||
}
|
||||
|
||||
const timelineBounds = computed(() => {
|
||||
if (objects.value.length === 0) {
|
||||
const now = Date.now();
|
||||
return { min: now, max: now };
|
||||
function startOfDayIso(isoDate: string): string {
|
||||
return `${isoDate}T00:00:00.000Z`;
|
||||
}
|
||||
|
||||
function buildQueryParams(): MapQueryParams {
|
||||
const params: MapQueryParams = {};
|
||||
if (region.value) params.region = region.value;
|
||||
if (topic.value) params.topic = topic.value;
|
||||
if (sourceType.value) params.source_type = sourceType.value;
|
||||
|
||||
const eventId = route.query.eventId;
|
||||
if (eventId) {
|
||||
params.event_id = Number(eventId);
|
||||
return params;
|
||||
}
|
||||
|
||||
const times = objects.value.map(objectTime);
|
||||
return {
|
||||
min: Math.min(...times),
|
||||
max: Math.max(...times),
|
||||
if (!selectedDate.value) return params;
|
||||
|
||||
if (rangePreset.value === "all") {
|
||||
params.on_date = selectedDate.value;
|
||||
return params;
|
||||
}
|
||||
|
||||
const end = selectedDate.value;
|
||||
const ranges: Record<DateRangePreset, number> = {
|
||||
week: 7,
|
||||
month: 30,
|
||||
"3months": 90,
|
||||
"6months": 180,
|
||||
year: 365,
|
||||
all: 0,
|
||||
};
|
||||
});
|
||||
const days = ranges[rangePreset.value];
|
||||
params.date_from = startOfDayIso(subtractDays(end, days));
|
||||
params.date_to = endOfDayIso(end);
|
||||
return params;
|
||||
}
|
||||
|
||||
const visibleObjects = computed(() =>
|
||||
objects.value.filter((obj) => objectTime(obj) <= timelinePosition.value),
|
||||
);
|
||||
async function loadFilters() {
|
||||
mapFilters.value = await fetchMapFilters();
|
||||
if (!selectedDate.value && mapFilters.value.available_dates.length > 0) {
|
||||
selectedDate.value = mapFilters.value.available_dates.at(-1) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncTimelineToMax() {
|
||||
timelinePosition.value = timelineBounds.value.max;
|
||||
async function loadObjects() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
objects.value = await fetchMapObjects(buildQueryParams());
|
||||
selectByEventId(route.query.eventId as string | undefined);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось загрузить объекты";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectByEventId(eventId: string | undefined) {
|
||||
@@ -96,51 +132,27 @@ function selectByEventId(eventId: string | undefined) {
|
||||
const match = objects.value.find((obj) => obj.event_id === id);
|
||||
if (match) {
|
||||
selectedObject.value = match;
|
||||
timelinePosition.value = Math.max(timelinePosition.value, objectTime(match));
|
||||
return;
|
||||
}
|
||||
|
||||
void fetchEvents({ limit: 500 }).then((response) => {
|
||||
const event = response.items.find((item) => item.id === id);
|
||||
if (!event || event.latitude == null || event.longitude == null) {
|
||||
error.value = "Событие не найдено на карте";
|
||||
return;
|
||||
openPopupId.value = match.id;
|
||||
if (match.event_date) {
|
||||
selectedDate.value = match.event_date.slice(0, 10);
|
||||
}
|
||||
|
||||
const eventTime = event.event_date
|
||||
? new Date(event.event_date).getTime()
|
||||
: Date.now();
|
||||
timelinePosition.value = Math.max(timelinePosition.value, eventTime);
|
||||
|
||||
const byCoords = objects.value.find(
|
||||
(obj) =>
|
||||
Math.abs(obj.latitude - event.latitude!) < 1e-4 &&
|
||||
Math.abs(obj.longitude - event.longitude!) < 1e-4,
|
||||
);
|
||||
if (byCoords) {
|
||||
selectedObject.value = byCoords;
|
||||
} else {
|
||||
error.value = "Объект карты для события ещё не создан";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadObjects() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
objects.value = await fetchObjects();
|
||||
syncTimelineToMax();
|
||||
selectByEventId(route.query.eventId as string | undefined);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось загрузить объекты";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
} else {
|
||||
error.value = "Событие не найдено на карте";
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectObject(obj: MapObject) {
|
||||
function onMapReady(api: LeafletMapApi) {
|
||||
mapApi.value = api;
|
||||
centerCoords.value = api.getCenter();
|
||||
}
|
||||
|
||||
function onCenterChange(coords: { lat: number; lng: number }) {
|
||||
centerCoords.value = coords;
|
||||
}
|
||||
|
||||
function handleSelectObject(obj: MapObjectWithEvent) {
|
||||
selectedObject.value = obj;
|
||||
openPopupId.value = obj.id;
|
||||
}
|
||||
|
||||
function handleMapContextMenu(payload: {
|
||||
@@ -148,7 +160,7 @@ function handleMapContextMenu(payload: {
|
||||
y: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
object: MapObject | null;
|
||||
object: MapObjectWithEvent | null;
|
||||
}) {
|
||||
contextMenu.value = {
|
||||
visible: true,
|
||||
@@ -159,10 +171,7 @@ function handleMapContextMenu(payload: {
|
||||
target: payload.object ? "object" : "map",
|
||||
object: payload.object,
|
||||
};
|
||||
|
||||
if (payload.object) {
|
||||
selectedObject.value = payload.object;
|
||||
}
|
||||
if (payload.object) selectedObject.value = payload.object;
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
@@ -183,18 +192,13 @@ function closeCreateModal() {
|
||||
}
|
||||
|
||||
function openEditModal() {
|
||||
if (!contextMenu.value.object) return;
|
||||
|
||||
editModal.value = {
|
||||
visible: true,
|
||||
object: contextMenu.value.object,
|
||||
};
|
||||
if (!contextMenu.value.object || contextMenu.value.object.event_id) return;
|
||||
editModal.value = { visible: true, object: contextMenu.value.object };
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
editModal.value.visible = false;
|
||||
editModal.value.object = null;
|
||||
editModal.value = { visible: false, object: null };
|
||||
}
|
||||
|
||||
async function handleCreateObject(payload: {
|
||||
@@ -212,16 +216,13 @@ async function handleCreateObject(payload: {
|
||||
longitude: createModal.value.longitude,
|
||||
created_at: payload.created_at,
|
||||
};
|
||||
|
||||
const created = await createObject(data);
|
||||
|
||||
for (const file of payload.files) {
|
||||
await uploadObjectMedia(created.id, file);
|
||||
}
|
||||
|
||||
objects.value = [...objects.value, created];
|
||||
selectedObject.value = created;
|
||||
timelinePosition.value = objectTime(created);
|
||||
await loadObjects();
|
||||
const match = objects.value.find((o) => o.id === created.id);
|
||||
if (match) selectedObject.value = match;
|
||||
closeCreateModal();
|
||||
}
|
||||
|
||||
@@ -232,103 +233,116 @@ async function handleEditObject(payload: {
|
||||
created_at: string;
|
||||
}) {
|
||||
if (!editModal.value.object) return;
|
||||
|
||||
const updated = await updateObject(editModal.value.object.id, payload);
|
||||
replaceObject(updated);
|
||||
timelinePosition.value = objectTime(updated);
|
||||
await updateObject(editModal.value.object.id, payload);
|
||||
await loadObjects();
|
||||
closeEditModal();
|
||||
}
|
||||
|
||||
async function handleDeleteObject() {
|
||||
const object = contextMenu.value.object;
|
||||
if (!object) return;
|
||||
|
||||
const confirmed = window.confirm(`Удалить объект «${object.name}»?`);
|
||||
if (!confirmed) return;
|
||||
|
||||
if (!object || object.event_id) return;
|
||||
if (!window.confirm(`Удалить объект «${object.name}»?`)) return;
|
||||
closeContextMenu();
|
||||
|
||||
try {
|
||||
await deleteObject(object.id);
|
||||
objects.value = objects.value.filter((item) => item.id !== object.id);
|
||||
|
||||
if (selectedObject.value?.id === object.id) {
|
||||
selectedObject.value = null;
|
||||
}
|
||||
if (selectedObject.value?.id === object.id) selectedObject.value = null;
|
||||
await loadObjects();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось удалить объект";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveObject(payload: {
|
||||
object: MapObject;
|
||||
object: MapObjectWithEvent;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}) {
|
||||
if (payload.object.event_id) return;
|
||||
try {
|
||||
const updated = await updateObject(payload.object.id, {
|
||||
await updateObject(payload.object.id, {
|
||||
latitude: payload.latitude,
|
||||
longitude: payload.longitude,
|
||||
});
|
||||
replaceObject(updated);
|
||||
await loadObjects();
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось переместить объект";
|
||||
}
|
||||
}
|
||||
|
||||
watch(visibleObjects, (visible) => {
|
||||
if (
|
||||
selectedObject.value &&
|
||||
!visible.some((obj) => obj.id === selectedObject.value?.id)
|
||||
) {
|
||||
selectedObject.value = null;
|
||||
}
|
||||
});
|
||||
function openInEvents() {
|
||||
if (!selectedObject.value?.event_id) return;
|
||||
void router.push({ path: "/events", query: { highlight: String(selectedObject.value.event_id) } });
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.eventId,
|
||||
(eventId) => selectByEventId(eventId as string | undefined),
|
||||
(eventId) => {
|
||||
if (eventId) void loadObjects();
|
||||
else selectByEventId(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(loadObjects);
|
||||
watch(mapViewRef, (view) => {
|
||||
if (view?.centerCoords) {
|
||||
centerCoords.value = view.centerCoords;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFilters();
|
||||
await loadObjects();
|
||||
pollTimer = setInterval(() => void loadObjects(), 30000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-page" @click="closeContextMenu">
|
||||
<div class="map-status">
|
||||
<span v-if="loading" class="status">Загрузка...</span>
|
||||
<span v-else-if="error" class="status error">{{ error }}</span>
|
||||
<span v-else class="status">
|
||||
{{ visibleObjects.length }} / {{ objects.length }} объектов на карте
|
||||
</span>
|
||||
<MapToolbar
|
||||
v-model:selected-date="selectedDate"
|
||||
v-model:range-preset="rangePreset"
|
||||
v-model:region="region"
|
||||
v-model:topic="topic"
|
||||
v-model:source-type="sourceType"
|
||||
:filters="mapFilters"
|
||||
:loading="loading"
|
||||
@apply="loadObjects"
|
||||
/>
|
||||
|
||||
<div class="map-tools-row">
|
||||
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
|
||||
<PlaceSearch :map-api="mapApi" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="map-status error">{{ error }}</div>
|
||||
<div v-else class="map-status">{{ objects.length }} объектов на карте</div>
|
||||
|
||||
<div class="map-content">
|
||||
<MapView
|
||||
:objects="visibleObjects"
|
||||
ref="mapViewRef"
|
||||
:objects="objects"
|
||||
:selected-id="selectedId"
|
||||
:open-popup-id="openPopupId"
|
||||
@ready="onMapReady"
|
||||
@center-change="onCenterChange"
|
||||
@select="handleSelectObject"
|
||||
@contextmenu="handleMapContextMenu"
|
||||
@move="handleMoveObject"
|
||||
/>
|
||||
<ObjectPanel :object="selectedObject" />
|
||||
<ObjectPanel :object="selectedObject" @open-events="openInEvents" />
|
||||
</div>
|
||||
|
||||
<TimelineBar
|
||||
v-if="!loading && objects.length > 0"
|
||||
v-model="timelinePosition"
|
||||
:min="timelineBounds.min"
|
||||
:max="timelineBounds.max"
|
||||
:objects="objects"
|
||||
:visible-count="visibleObjects.length"
|
||||
/>
|
||||
|
||||
<ContextMenu
|
||||
v-if="contextMenu.visible"
|
||||
:x="contextMenu.x"
|
||||
:y="contextMenu.y"
|
||||
:target="contextMenu.target"
|
||||
:object-name="contextMenu.object?.name"
|
||||
:can-edit="!contextMenu.object?.event_id"
|
||||
:can-delete="!contextMenu.object?.event_id"
|
||||
@create="openCreateModal"
|
||||
@edit="openEditModal"
|
||||
@delete="handleDeleteObject"
|
||||
@@ -350,34 +364,3 @@ onMounted(loadObjects);
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-status {
|
||||
padding: 0.4rem 1.25rem;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #eee;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.map-content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user