Add CP→CA→PI platform foundation on MapMil monorepo.
Unify parsing workers, analytics API with PostgreSQL, map UI, and PI distribution into centers/ with Docker Compose. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,18 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MapMil</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
client_max_body_size 50M;
|
||||
proxy_pass http://ca-api:8000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
proxy_pass http://ca-api:8000/admin/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /internal/ {
|
||||
proxy_pass http://ca-api:8000/internal/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "mapmil-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"vue": "^3.5.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/leaflet": "^1.9.15",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import {
|
||||
createObject,
|
||||
deleteObject,
|
||||
fetchObjects,
|
||||
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 ObjectPanel from "./components/ObjectPanel.vue";
|
||||
import TimelineBar from "./components/TimelineBar.vue";
|
||||
import type { MapObject, MapObjectCreate, ObjectType } from "./types/object";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function loadObjects() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
objects.value = await fetchObjects();
|
||||
syncTimelineToMax();
|
||||
} 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;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(loadObjects);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app" @click="closeContextMenu">
|
||||
<header class="header">
|
||||
<h1>MapMil</h1>
|
||||
<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>
|
||||
</header>
|
||||
|
||||
<main class="main">
|
||||
<MapView
|
||||
:objects="visibleObjects"
|
||||
:selected-id="selectedId"
|
||||
@select="handleSelectObject"
|
||||
@contextmenu="handleMapContextMenu"
|
||||
@move="handleMoveObject"
|
||||
/>
|
||||
<ObjectPanel :object="selectedObject" />
|
||||
</main>
|
||||
|
||||
<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>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { MapObject, MapObjectCreate, MapObjectUpdate, ObjectMedia } from "../types/object";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(options?.headers);
|
||||
const isFormData = options?.body instanceof FormData;
|
||||
|
||||
if (!isFormData && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${url}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const message = await response.text();
|
||||
throw new Error(message || `Ошибка запроса: ${response.status}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export function fetchObjects(): Promise<MapObject[]> {
|
||||
return request<MapObject[]>("/objects");
|
||||
}
|
||||
|
||||
export function fetchObject(id: number): Promise<MapObject> {
|
||||
return request<MapObject>(`/objects/${id}`);
|
||||
}
|
||||
|
||||
export function createObject(payload: MapObjectCreate): Promise<MapObject> {
|
||||
return request<MapObject>("/objects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateObject(id: number, payload: MapObjectUpdate): Promise<MapObject> {
|
||||
return request<MapObject>(`/objects/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteObject(id: number): Promise<void> {
|
||||
return request<void>(`/objects/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchObjectMedia(objectId: number): Promise<ObjectMedia[]> {
|
||||
return request<ObjectMedia[]>(`/objects/${objectId}/media`);
|
||||
}
|
||||
|
||||
export function uploadObjectMedia(objectId: number, file: File): Promise<ObjectMedia> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
|
||||
return request<ObjectMedia>(`/objects/${objectId}/media`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteObjectMedia(mediaId: number): Promise<void> {
|
||||
return request<void>(`/media/${mediaId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
x: number;
|
||||
y: number;
|
||||
target: "map" | "object";
|
||||
objectName?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [];
|
||||
edit: [];
|
||||
delete: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="context-menu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<p v-if="target === 'object' && objectName" class="title">{{ objectName }}</p>
|
||||
|
||||
<template v-if="target === 'map'">
|
||||
<button type="button" @click="emit('create')">Создать объект</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<button type="button" @click="emit('edit')">Редактировать</button>
|
||||
<button type="button" class="danger" @click="emit('delete')">Удалить</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
background: #f8f8f8;
|
||||
border-bottom: 1px solid #eee;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #f0f4ff;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
button.danger:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { MEDIA_ACCEPT, OBJECT_TYPE_LABELS, OBJECT_TYPES, type ObjectType } from "../types/object";
|
||||
|
||||
const props = defineProps<{
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
submit: [payload: {
|
||||
name: string;
|
||||
description: string;
|
||||
type: ObjectType;
|
||||
created_at?: string;
|
||||
files: File[];
|
||||
}];
|
||||
}>();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const type = ref<ObjectType>("marker");
|
||||
const createdAt = ref(toLocalDateTimeValue(new Date()));
|
||||
const selectedFiles = ref<File[]>([]);
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
const fileLabel = computed(() => {
|
||||
if (selectedFiles.value.length === 0) return "Файлы не выбраны";
|
||||
return selectedFiles.value.map((file) => file.name).join(", ");
|
||||
});
|
||||
|
||||
function toLocalDateTimeValue(date: Date): string {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function handleFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
selectedFiles.value = input.files ? Array.from(input.files) : [];
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.value.trim()) {
|
||||
error.value = "Введите название объекта";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdAt.value) {
|
||||
error.value = "Укажите дату создания";
|
||||
return;
|
||||
}
|
||||
|
||||
error.value = "";
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
emit("submit", {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
type: type.value,
|
||||
created_at: new Date(createdAt.value).toISOString(),
|
||||
files: selectedFiles.value,
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось создать объект";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overlay" @click.self="emit('close')">
|
||||
<div class="modal" role="dialog" aria-labelledby="create-title">
|
||||
<header>
|
||||
<h2 id="create-title">Создать объект</h2>
|
||||
<button type="button" class="close" aria-label="Закрыть" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<p class="coords">
|
||||
Координаты: {{ props.latitude.toFixed(6) }}, {{ props.longitude.toFixed(6) }}
|
||||
</p>
|
||||
|
||||
<label>
|
||||
Название *
|
||||
<input v-model="name" type="text" placeholder="Название объекта" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Тип *
|
||||
<select v-model="type" required>
|
||||
<option v-for="item in OBJECT_TYPES" :key="item" :value="item">
|
||||
{{ OBJECT_TYPE_LABELS[item] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Дата создания *
|
||||
<input v-model="createdAt" type="datetime-local" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Описание
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="4"
|
||||
placeholder="Описание объекта"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Медиафайлы
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
:accept="MEDIA_ACCEPT"
|
||||
@change="handleFileChange"
|
||||
/>
|
||||
<span class="hint">{{ fileLabel }}</span>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<footer>
|
||||
<button type="button" class="secondary" @click="emit('close')">Отмена</button>
|
||||
<button type="submit" :disabled="submitting">
|
||||
{{ submitting ? "Создание..." : "Создать" }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(480px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.coords {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 1rem;
|
||||
color: #c62828;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #e8e8e8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue";
|
||||
import { OBJECT_TYPE_LABELS, OBJECT_TYPES, type MapObject, type ObjectType } from "../types/object";
|
||||
|
||||
const props = defineProps<{
|
||||
object: MapObject;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
submit: [payload: {
|
||||
name: string;
|
||||
description: string;
|
||||
type: ObjectType;
|
||||
created_at: string;
|
||||
}];
|
||||
}>();
|
||||
|
||||
const name = ref("");
|
||||
const description = ref("");
|
||||
const type = ref<ObjectType>("marker");
|
||||
const createdAt = ref("");
|
||||
const error = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
function toLocalDateTimeValue(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
name.value = props.object.name;
|
||||
description.value = props.object.description;
|
||||
type.value = props.object.type;
|
||||
createdAt.value = toLocalDateTimeValue(props.object.created_at);
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!name.value.trim()) {
|
||||
error.value = "Введите название объекта";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!createdAt.value) {
|
||||
error.value = "Укажите дату создания";
|
||||
return;
|
||||
}
|
||||
|
||||
error.value = "";
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
emit("submit", {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
type: type.value,
|
||||
created_at: new Date(createdAt.value).toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось сохранить изменения";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.object, resetForm, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overlay" @click.self="emit('close')">
|
||||
<div class="modal" role="dialog" aria-labelledby="edit-title">
|
||||
<header>
|
||||
<h2 id="edit-title">Редактировать объект</h2>
|
||||
<button type="button" class="close" aria-label="Закрыть" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<p class="coords">
|
||||
Координаты: {{ object.latitude.toFixed(6) }}, {{ object.longitude.toFixed(6) }}
|
||||
<span class="hint">(измените через «Переместить» на карте)</span>
|
||||
</p>
|
||||
|
||||
<label>
|
||||
Название *
|
||||
<input v-model="name" type="text" placeholder="Название объекта" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Тип *
|
||||
<select v-model="type" required>
|
||||
<option v-for="item in OBJECT_TYPES" :key="item" :value="item">
|
||||
{{ OBJECT_TYPE_LABELS[item] }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Дата создания *
|
||||
<input v-model="createdAt" type="datetime-local" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Описание
|
||||
<textarea
|
||||
v-model="description"
|
||||
rows="4"
|
||||
placeholder="Описание объекта"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<footer>
|
||||
<button type="button" class="secondary" @click="emit('close')">Отмена</button>
|
||||
<button type="submit" :disabled="submitting">
|
||||
{{ submitting ? "Сохранение..." : "Сохранить" }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: min(480px, calc(100vw - 2rem));
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.close {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
form {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.coords {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hint {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0 0 1rem;
|
||||
color: #c62828;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #e8e8e8;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import L from "leaflet";
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import type { MapObject } from "../types/object";
|
||||
|
||||
const props = defineProps<{
|
||||
objects: MapObject[];
|
||||
selectedId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [object: MapObject];
|
||||
contextmenu: [payload: {
|
||||
x: number;
|
||||
y: number;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
object: MapObject | null;
|
||||
}];
|
||||
move: [payload: { object: MapObject; latitude: number; longitude: number }];
|
||||
}>();
|
||||
|
||||
const mapContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
let map: L.Map | null = null;
|
||||
let markersLayer: L.LayerGroup | null = null;
|
||||
const markerById = new Map<number, L.Marker>();
|
||||
|
||||
const defaultIcon = L.icon({
|
||||
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
|
||||
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
|
||||
const selectedIcon = L.icon({
|
||||
iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-orange.png",
|
||||
iconRetinaUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-orange.png",
|
||||
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
|
||||
function isDraggable(obj: MapObject): boolean {
|
||||
return obj.id === props.selectedId;
|
||||
}
|
||||
|
||||
function getMarkerIcon(obj: MapObject): L.Icon {
|
||||
if (obj.id === props.selectedId) return selectedIcon;
|
||||
return defaultIcon;
|
||||
}
|
||||
|
||||
function bindMarker(marker: L.Marker, obj: MapObject) {
|
||||
marker.off("click");
|
||||
marker.off("contextmenu");
|
||||
marker.off("dragend");
|
||||
|
||||
marker.on("click", () => emit("select", obj));
|
||||
|
||||
marker.on("contextmenu", (event: L.LeafletMouseEvent) => {
|
||||
L.DomEvent.stopPropagation(event.originalEvent);
|
||||
L.DomEvent.preventDefault(event.originalEvent);
|
||||
|
||||
emit("contextmenu", {
|
||||
x: event.originalEvent.clientX,
|
||||
y: event.originalEvent.clientY,
|
||||
latitude: obj.latitude,
|
||||
longitude: obj.longitude,
|
||||
object: obj,
|
||||
});
|
||||
});
|
||||
|
||||
const draggable = isDraggable(obj);
|
||||
if (marker.dragging) {
|
||||
if (draggable) {
|
||||
marker.dragging.enable();
|
||||
} else {
|
||||
marker.dragging.disable();
|
||||
}
|
||||
}
|
||||
|
||||
if (draggable) {
|
||||
marker.on("dragend", () => {
|
||||
const latlng = marker.getLatLng();
|
||||
emit("move", {
|
||||
object: obj,
|
||||
latitude: latlng.lat,
|
||||
longitude: latlng.lng,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function syncMarkers() {
|
||||
if (!map || !markersLayer) return;
|
||||
|
||||
const currentIds = new Set(props.objects.map((obj) => obj.id));
|
||||
|
||||
for (const [id, marker] of markerById) {
|
||||
if (!currentIds.has(id)) {
|
||||
markersLayer.removeLayer(marker);
|
||||
markerById.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const obj of props.objects) {
|
||||
const draggable = isDraggable(obj);
|
||||
let marker = markerById.get(obj.id);
|
||||
|
||||
if (!marker) {
|
||||
marker = L.marker([obj.latitude, obj.longitude], {
|
||||
icon: getMarkerIcon(obj),
|
||||
draggable,
|
||||
});
|
||||
marker.addTo(markersLayer);
|
||||
markerById.set(obj.id, marker);
|
||||
} else {
|
||||
const latlng = marker.getLatLng();
|
||||
const positionChanged =
|
||||
Math.abs(latlng.lat - obj.latitude) > 1e-8 ||
|
||||
Math.abs(latlng.lng - obj.longitude) > 1e-8;
|
||||
|
||||
if (positionChanged) {
|
||||
marker.setLatLng([obj.latitude, obj.longitude]);
|
||||
}
|
||||
|
||||
marker.setIcon(getMarkerIcon(obj));
|
||||
if (marker.dragging) {
|
||||
if (draggable) {
|
||||
marker.dragging.enable();
|
||||
} else {
|
||||
marker.dragging.disable();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bindMarker(marker, obj);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapContextMenu(event: MouseEvent) {
|
||||
if (!map || !mapContainer.value) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const rect = mapContainer.value.getBoundingClientRect();
|
||||
const point = map.containerPointToLatLng(
|
||||
L.point(event.clientX - rect.left, event.clientY - rect.top),
|
||||
);
|
||||
|
||||
emit("contextmenu", {
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
latitude: point.lat,
|
||||
longitude: point.lng,
|
||||
object: null,
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!mapContainer.value) return;
|
||||
|
||||
map = L.map(mapContainer.value).setView([55.7558, 37.6173], 11);
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||
maxZoom: 19,
|
||||
}).addTo(map);
|
||||
|
||||
markersLayer = L.layerGroup().addTo(map);
|
||||
mapContainer.value.addEventListener("contextmenu", handleMapContextMenu);
|
||||
syncMarkers();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
mapContainer.value?.removeEventListener("contextmenu", handleMapContextMenu);
|
||||
map?.remove();
|
||||
map = null;
|
||||
markersLayer = null;
|
||||
markerById.clear();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.objects, props.selectedId] as const,
|
||||
() => syncMarkers(),
|
||||
{ deep: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="map-wrapper">
|
||||
<div
|
||||
ref="mapContainer"
|
||||
class="map"
|
||||
:class="{ 'has-selection': selectedId !== null }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.map-wrapper {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-wrapper :deep(.leaflet-container) {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.map.has-selection :deep(.leaflet-marker-draggable) {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.map.has-selection :deep(.leaflet-marker-draggable:active) {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
deleteObjectMedia,
|
||||
fetchObjectMedia,
|
||||
formatFileSize,
|
||||
uploadObjectMedia,
|
||||
} from "../api/objects";
|
||||
import { MEDIA_ACCEPT, OBJECT_TYPE_LABELS, type MapObject, type ObjectMedia } from "../types/object";
|
||||
|
||||
const props = defineProps<{
|
||||
object: MapObject | null;
|
||||
}>();
|
||||
|
||||
const mediaItems = ref<ObjectMedia[]>([]);
|
||||
const loadingMedia = ref(false);
|
||||
const mediaError = ref("");
|
||||
const uploading = ref(false);
|
||||
|
||||
const typeLabel = computed(() =>
|
||||
props.object ? OBJECT_TYPE_LABELS[props.object.type] : "",
|
||||
);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.object) return "";
|
||||
return new Date(props.object.created_at).toLocaleString("ru-RU");
|
||||
});
|
||||
|
||||
function isImage(media: ObjectMedia): boolean {
|
||||
return media.content_type.startsWith("image/");
|
||||
}
|
||||
|
||||
function isVideo(media: ObjectMedia): boolean {
|
||||
return media.content_type.startsWith("video/");
|
||||
}
|
||||
|
||||
async function loadMedia() {
|
||||
if (!props.object) {
|
||||
mediaItems.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loadingMedia.value = true;
|
||||
mediaError.value = "";
|
||||
|
||||
try {
|
||||
mediaItems.value = await fetchObjectMedia(props.object.id);
|
||||
} catch (err) {
|
||||
mediaError.value = err instanceof Error ? err.message : "Не удалось загрузить медиа";
|
||||
mediaItems.value = [];
|
||||
} finally {
|
||||
loadingMedia.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(event: Event) {
|
||||
if (!props.object) return;
|
||||
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = input.files ? Array.from(input.files) : [];
|
||||
input.value = "";
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
uploading.value = true;
|
||||
mediaError.value = "";
|
||||
|
||||
try {
|
||||
for (const file of files) {
|
||||
const uploaded = await uploadObjectMedia(props.object.id, file);
|
||||
mediaItems.value = [...mediaItems.value, uploaded];
|
||||
}
|
||||
} catch (err) {
|
||||
mediaError.value = err instanceof Error ? err.message : "Не удалось загрузить файл";
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(mediaId: number) {
|
||||
mediaError.value = "";
|
||||
|
||||
try {
|
||||
await deleteObjectMedia(mediaId);
|
||||
mediaItems.value = mediaItems.value.filter((item) => item.id !== mediaId);
|
||||
} catch (err) {
|
||||
mediaError.value = err instanceof Error ? err.message : "Не удалось удалить файл";
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.object?.id,
|
||||
() => loadMedia(),
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="panel">
|
||||
<h2>Объект</h2>
|
||||
|
||||
<div v-if="!object" class="empty">
|
||||
<p>Выберите объект на карте, чтобы увидеть описание.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="content">
|
||||
<h3>{{ object.name }}</h3>
|
||||
|
||||
<dl>
|
||||
<dt>Тип</dt>
|
||||
<dd>{{ typeLabel }}</dd>
|
||||
|
||||
<dt>Описание</dt>
|
||||
<dd>{{ object.description || "—" }}</dd>
|
||||
|
||||
<dt>Координаты</dt>
|
||||
<dd>
|
||||
{{ object.latitude.toFixed(6) }}, {{ object.longitude.toFixed(6) }}
|
||||
<span class="drag-hint">Перетащите маркер на карте для перемещения</span>
|
||||
</dd>
|
||||
|
||||
<dt>Создан</dt>
|
||||
<dd>{{ formattedDate }}</dd>
|
||||
</dl>
|
||||
|
||||
<section class="media-section">
|
||||
<div class="media-header">
|
||||
<h4>Медиа</h4>
|
||||
<label class="upload-btn">
|
||||
{{ uploading ? "Загрузка..." : "Добавить" }}
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
:accept="MEDIA_ACCEPT"
|
||||
:disabled="uploading"
|
||||
hidden
|
||||
@change="handleUpload"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-if="loadingMedia" class="media-status">Загрузка медиа...</p>
|
||||
<p v-else-if="mediaError" class="media-error">{{ mediaError }}</p>
|
||||
<p v-else-if="mediaItems.length === 0" class="media-status">Медиафайлы не прикреплены</p>
|
||||
|
||||
<ul v-else class="media-list">
|
||||
<li v-for="item in mediaItems" :key="item.id" class="media-item">
|
||||
<img v-if="isImage(item)" :src="item.url" :alt="item.original_name" class="preview" />
|
||||
<video
|
||||
v-else-if="isVideo(item)"
|
||||
:src="item.url"
|
||||
class="preview"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<a v-else :href="item.url" class="file-link" target="_blank" rel="noopener">
|
||||
{{ item.original_name }}
|
||||
</a>
|
||||
|
||||
<div class="media-meta">
|
||||
<span class="name" :title="item.original_name">{{ item.original_name }}</span>
|
||||
<span class="size">{{ formatFileSize(item.size) }}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="delete-btn" @click="handleDelete(item.id)">
|
||||
Удалить
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 320px;
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-left: 1px solid #e0e0e0;
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.content h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
dt:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0.25rem 0 0;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.drag-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.media-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.media-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.media-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: #2563eb;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upload-btn input:disabled + span,
|
||||
.upload-btn:has(input:disabled) {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.media-status {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.media-error {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.media-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.media-item {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 160px;
|
||||
object-fit: cover;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.file-link {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.media-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
display: block;
|
||||
width: calc(100% - 1rem);
|
||||
margin: 0.5rem auto 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { MapObject } from "../types/object";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: number;
|
||||
min: number;
|
||||
max: number;
|
||||
objects: MapObject[];
|
||||
visibleCount: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: number];
|
||||
}>();
|
||||
|
||||
const range = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const formattedTime = computed(() =>
|
||||
new Date(props.modelValue).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}),
|
||||
);
|
||||
|
||||
const formattedMin = computed(() =>
|
||||
new Date(props.min).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
const formattedMax = computed(() =>
|
||||
new Date(props.max).toLocaleDateString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}),
|
||||
);
|
||||
|
||||
const markers = computed(() => {
|
||||
if (props.max <= props.min) return [];
|
||||
|
||||
const span = props.max - props.min;
|
||||
return props.objects.map((obj) => {
|
||||
const time = new Date(obj.created_at).getTime();
|
||||
const percent = ((time - props.min) / span) * 100;
|
||||
return {
|
||||
id: obj.id,
|
||||
name: obj.name,
|
||||
percent: Math.min(100, Math.max(0, percent)),
|
||||
visible: time <= props.modelValue,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const hasRange = computed(() => props.max > props.min);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="timeline">
|
||||
<div class="timeline-header">
|
||||
<span class="label">Таймлайн появления</span>
|
||||
<span class="current-time">{{ formattedTime }}</span>
|
||||
<span class="count">{{ visibleCount }} / {{ objects.length }} объектов</span>
|
||||
</div>
|
||||
|
||||
<div class="slider-wrap">
|
||||
<span class="edge-label">{{ formattedMin }}</span>
|
||||
|
||||
<div class="slider-track">
|
||||
<div
|
||||
v-for="marker in markers"
|
||||
:key="marker.id"
|
||||
class="object-marker"
|
||||
:class="{ visible: marker.visible }"
|
||||
:style="{ left: `${marker.percent}%` }"
|
||||
:title="marker.name"
|
||||
/>
|
||||
|
||||
<input
|
||||
v-model.number="range"
|
||||
class="slider"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="hasRange ? Math.max(1, Math.floor((max - min) / 500)) : 1"
|
||||
:disabled="!hasRange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span class="edge-label">{{ formattedMax }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.timeline {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1.25rem 1rem;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.current-time {
|
||||
color: #2563eb;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.slider-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.edge-label {
|
||||
flex-shrink: 0;
|
||||
width: 5.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edge-label:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.edge-label:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.slider-track {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.object-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-left: -4px;
|
||||
margin-top: -4px;
|
||||
border-radius: 50%;
|
||||
background: #bbb;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transition: background 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.object-marker.visible {
|
||||
background: #2563eb;
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
accent-color: #2563eb;
|
||||
}
|
||||
|
||||
.slider:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -0,0 +1,22 @@
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
color: #1a1a1a;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export type ObjectType = "point" | "marker" | "zone" | "other";
|
||||
|
||||
export interface MapObject {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
type: ObjectType;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MapObjectCreate {
|
||||
name: string;
|
||||
description: string;
|
||||
type: ObjectType;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface MapObjectUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
type?: ObjectType;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ObjectMedia {
|
||||
id: number;
|
||||
object_id: number;
|
||||
original_name: string;
|
||||
content_type: string;
|
||||
size: number;
|
||||
created_at: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const MEDIA_ACCEPT = "image/jpeg,image/png,image/gif,image/webp,video/mp4,video/webm";
|
||||
|
||||
export const OBJECT_TYPE_LABELS: Record<ObjectType, string> = {
|
||||
point: "Точка",
|
||||
marker: "Метка",
|
||||
zone: "Зона",
|
||||
other: "Другое",
|
||||
};
|
||||
|
||||
export const OBJECT_TYPES: ObjectType[] = ["point", "marker", "zone", "other"];
|
||||
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8000",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user