Keep map reads open; gate admin UI/nav and object mutations behind env-based admin credentials, and default parser batch limit to 10. Co-authored-by: Cursor <cursoragent@cursor.com>
377 lines
10 KiB
Vue
377 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
|
import { useRoute, useRouter } from "vue-router";
|
|
import { fetchMapFilters, fetchMapObjects } from "../api/map";
|
|
import {
|
|
createObject,
|
|
deleteObject,
|
|
updateObject,
|
|
uploadObjectMedia,
|
|
} from "../api/objects";
|
|
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 { useAuth } from "../composables/useAuth";
|
|
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 { authenticated } = useAuth();
|
|
|
|
const objects = ref<MapObjectWithEvent[]>([]);
|
|
const mapFilters = ref<MapFilters | null>(null);
|
|
const selectedObject = ref<MapObjectWithEvent | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const mapApi = ref<LeafletMapApi | null>(null);
|
|
const centerCoords = ref({ lat: 48.257381, lng: 37.134785 });
|
|
const openPopupId = ref<number | null>(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" 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 MapObjectWithEvent | null });
|
|
|
|
const selectedId = computed(() => selectedObject.value?.id ?? null);
|
|
|
|
function subtractDays(isoDate: string, days: number): string {
|
|
const d = new Date(isoDate);
|
|
d.setUTCDate(d.getUTCDate() - days);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function endOfDayIso(isoDate: string): string {
|
|
return `${isoDate}T23:59:59.999Z`;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (!eventId) return;
|
|
const id = Number(eventId);
|
|
if (Number.isNaN(id)) return;
|
|
|
|
const match = objects.value.find((obj) => obj.event_id === id);
|
|
if (match) {
|
|
selectedObject.value = match;
|
|
openPopupId.value = match.id;
|
|
if (match.event_date) {
|
|
selectedDate.value = match.event_date.slice(0, 10);
|
|
}
|
|
} else {
|
|
error.value = "Событие не найдено на карте";
|
|
}
|
|
}
|
|
|
|
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: {
|
|
x: number;
|
|
y: number;
|
|
latitude: number;
|
|
longitude: number;
|
|
object: MapObjectWithEvent | null;
|
|
}) {
|
|
if (!authenticated.value) return;
|
|
contextMenu.value = {
|
|
visible: true,
|
|
x: payload.x,
|
|
y: payload.y,
|
|
latitude: payload.latitude,
|
|
longitude: payload.longitude,
|
|
target: payload.object ? "object" : "map",
|
|
object: payload.object,
|
|
};
|
|
if (payload.object) selectedObject.value = payload.object;
|
|
}
|
|
|
|
function closeContextMenu() {
|
|
contextMenu.value.visible = false;
|
|
}
|
|
|
|
function openCreateModal() {
|
|
createModal.value = {
|
|
visible: true,
|
|
latitude: contextMenu.value.latitude,
|
|
longitude: contextMenu.value.longitude,
|
|
};
|
|
closeContextMenu();
|
|
}
|
|
|
|
function closeCreateModal() {
|
|
createModal.value.visible = false;
|
|
}
|
|
|
|
function openEditModal() {
|
|
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, object: null };
|
|
}
|
|
|
|
async function handleCreateObject(payload: {
|
|
name: string;
|
|
description: string;
|
|
type: ObjectType;
|
|
created_at?: string;
|
|
files: File[];
|
|
}) {
|
|
const data: MapObjectCreate = {
|
|
name: payload.name,
|
|
description: payload.description,
|
|
type: payload.type,
|
|
latitude: createModal.value.latitude,
|
|
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);
|
|
}
|
|
await loadObjects();
|
|
const match = objects.value.find((o) => o.id === created.id);
|
|
if (match) selectedObject.value = match;
|
|
closeCreateModal();
|
|
}
|
|
|
|
async function handleEditObject(payload: {
|
|
name: string;
|
|
description: string;
|
|
type: ObjectType;
|
|
created_at: string;
|
|
}) {
|
|
if (!editModal.value.object) return;
|
|
await updateObject(editModal.value.object.id, payload);
|
|
await loadObjects();
|
|
closeEditModal();
|
|
}
|
|
|
|
async function handleDeleteObject() {
|
|
const object = contextMenu.value.object;
|
|
if (!object || object.event_id) return;
|
|
if (!window.confirm(`Удалить объект «${object.name}»?`)) return;
|
|
closeContextMenu();
|
|
try {
|
|
await deleteObject(object.id);
|
|
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: MapObjectWithEvent;
|
|
latitude: number;
|
|
longitude: number;
|
|
}) {
|
|
if (!authenticated.value) return;
|
|
if (payload.object.event_id) return;
|
|
try {
|
|
await updateObject(payload.object.id, {
|
|
latitude: payload.latitude,
|
|
longitude: payload.longitude,
|
|
});
|
|
await loadObjects();
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : "Не удалось переместить объект";
|
|
}
|
|
}
|
|
|
|
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) => {
|
|
if (eventId) void loadObjects();
|
|
else selectByEventId(undefined);
|
|
},
|
|
);
|
|
|
|
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">
|
|
<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"
|
|
:status-text="error || `${objects.length} объектов`"
|
|
:status-error="Boolean(error)"
|
|
@apply="loadObjects"
|
|
>
|
|
<template #coords>
|
|
<CoordsTools :map-api="mapApi" :center-coords="centerCoords" />
|
|
</template>
|
|
<template #search>
|
|
<PlaceSearch :map-api="mapApi" />
|
|
</template>
|
|
</MapToolbar>
|
|
|
|
<div class="map-content">
|
|
<MapView
|
|
ref="mapViewRef"
|
|
:objects="objects"
|
|
:selected-id="selectedId"
|
|
:open-popup-id="openPopupId"
|
|
:editable="authenticated"
|
|
@ready="onMapReady"
|
|
@center-change="onCenterChange"
|
|
@select="handleSelectObject"
|
|
@contextmenu="handleMapContextMenu"
|
|
@move="handleMoveObject"
|
|
/>
|
|
<ObjectPanel
|
|
:object="selectedObject"
|
|
:can-edit="authenticated"
|
|
@open-events="openInEvents"
|
|
/>
|
|
</div>
|
|
|
|
<ContextMenu
|
|
v-if="contextMenu.visible && authenticated"
|
|
: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"
|
|
/>
|
|
|
|
<CreateObjectModal
|
|
v-if="createModal.visible && authenticated"
|
|
:latitude="createModal.latitude"
|
|
:longitude="createModal.longitude"
|
|
@close="closeCreateModal"
|
|
@submit="handleCreateObject"
|
|
/>
|
|
|
|
<EditObjectModal
|
|
v-if="editModal.visible && editModal.object && authenticated"
|
|
:object="editModal.object"
|
|
@close="closeEditModal"
|
|
@submit="handleEditObject"
|
|
/>
|
|
</div>
|
|
</template>
|