Deliver parsers, events, analytics, and PI management in Vue; fix Telegram session mount and map navigation to events by eventId. Co-authored-by: Cursor <cursoragent@cursor.com>
384 lines
9.1 KiB
Vue
384 lines
9.1 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref, watch } from "vue";
|
|
import { useRoute } from "vue-router";
|
|
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 ObjectPanel from "../components/ObjectPanel.vue";
|
|
import TimelineBar from "../components/TimelineBar.vue";
|
|
import type { MapObject, MapObjectCreate, ObjectType } from "../types/object";
|
|
|
|
const route = useRoute();
|
|
|
|
const objects = ref<MapObject[]>([]);
|
|
const selectedObject = ref<MapObject | null>(null);
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const timelinePosition = ref(Date.now());
|
|
|
|
const contextMenu = ref<{
|
|
visible: boolean;
|
|
x: number;
|
|
y: number;
|
|
latitude: number;
|
|
longitude: number;
|
|
target: "map" | "object";
|
|
object: MapObject | null;
|
|
}>({
|
|
visible: false,
|
|
x: 0,
|
|
y: 0,
|
|
latitude: 0,
|
|
longitude: 0,
|
|
target: "map",
|
|
object: null,
|
|
});
|
|
|
|
const createModal = ref({
|
|
visible: false,
|
|
latitude: 0,
|
|
longitude: 0,
|
|
});
|
|
|
|
const editModal = ref({
|
|
visible: false,
|
|
object: null as MapObject | null,
|
|
});
|
|
|
|
const selectedId = computed(() => selectedObject.value?.id ?? null);
|
|
|
|
function objectTime(obj: MapObject): number {
|
|
return new Date(obj.created_at).getTime();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
const timelineBounds = computed(() => {
|
|
if (objects.value.length === 0) {
|
|
const now = Date.now();
|
|
return { min: now, max: now };
|
|
}
|
|
|
|
const times = objects.value.map(objectTime);
|
|
return {
|
|
min: Math.min(...times),
|
|
max: Math.max(...times),
|
|
};
|
|
});
|
|
|
|
const visibleObjects = computed(() =>
|
|
objects.value.filter((obj) => objectTime(obj) <= timelinePosition.value),
|
|
);
|
|
|
|
function syncTimelineToMax() {
|
|
timelinePosition.value = timelineBounds.value.max;
|
|
}
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
function handleSelectObject(obj: MapObject) {
|
|
selectedObject.value = obj;
|
|
}
|
|
|
|
function handleMapContextMenu(payload: {
|
|
x: number;
|
|
y: number;
|
|
latitude: number;
|
|
longitude: number;
|
|
object: MapObject | null;
|
|
}) {
|
|
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) return;
|
|
|
|
editModal.value = {
|
|
visible: true,
|
|
object: contextMenu.value.object,
|
|
};
|
|
closeContextMenu();
|
|
}
|
|
|
|
function closeEditModal() {
|
|
editModal.value.visible = false;
|
|
editModal.value.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);
|
|
}
|
|
|
|
objects.value = [...objects.value, created];
|
|
selectedObject.value = created;
|
|
timelinePosition.value = objectTime(created);
|
|
closeCreateModal();
|
|
}
|
|
|
|
async function handleEditObject(payload: {
|
|
name: string;
|
|
description: string;
|
|
type: ObjectType;
|
|
created_at: string;
|
|
}) {
|
|
if (!editModal.value.object) return;
|
|
|
|
const updated = await updateObject(editModal.value.object.id, payload);
|
|
replaceObject(updated);
|
|
timelinePosition.value = objectTime(updated);
|
|
closeEditModal();
|
|
}
|
|
|
|
async function handleDeleteObject() {
|
|
const object = contextMenu.value.object;
|
|
if (!object) return;
|
|
|
|
const confirmed = window.confirm(`Удалить объект «${object.name}»?`);
|
|
if (!confirmed) 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;
|
|
}
|
|
} catch (err) {
|
|
error.value = err instanceof Error ? err.message : "Не удалось удалить объект";
|
|
}
|
|
}
|
|
|
|
async function handleMoveObject(payload: {
|
|
object: MapObject;
|
|
latitude: number;
|
|
longitude: number;
|
|
}) {
|
|
try {
|
|
const updated = await updateObject(payload.object.id, {
|
|
latitude: payload.latitude,
|
|
longitude: payload.longitude,
|
|
});
|
|
replaceObject(updated);
|
|
} 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;
|
|
}
|
|
});
|
|
|
|
watch(
|
|
() => route.query.eventId,
|
|
(eventId) => selectByEventId(eventId as string | undefined),
|
|
);
|
|
|
|
onMounted(loadObjects);
|
|
</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>
|
|
</div>
|
|
|
|
<div class="map-content">
|
|
<MapView
|
|
:objects="visibleObjects"
|
|
:selected-id="selectedId"
|
|
@select="handleSelectObject"
|
|
@contextmenu="handleMapContextMenu"
|
|
@move="handleMoveObject"
|
|
/>
|
|
<ObjectPanel :object="selectedObject" />
|
|
</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"
|
|
@create="openCreateModal"
|
|
@edit="openEditModal"
|
|
@delete="handleDeleteObject"
|
|
/>
|
|
|
|
<CreateObjectModal
|
|
v-if="createModal.visible"
|
|
:latitude="createModal.latitude"
|
|
:longitude="createModal.longitude"
|
|
@close="closeCreateModal"
|
|
@submit="handleCreateObject"
|
|
/>
|
|
|
|
<EditObjectModal
|
|
v-if="editModal.visible && editModal.object"
|
|
:object="editModal.object"
|
|
@close="closeEditModal"
|
|
@submit="handleEditObject"
|
|
/>
|
|
</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>
|