добавила панельку с компонентами дрона
@@ -0,0 +1,86 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Пропеллеры (винты) дрона",
|
||||||
|
"category": "Силовая установка",
|
||||||
|
"image": "/droneComponents/images/prop.png",
|
||||||
|
"description": "Пропеллеры (воздушные винты) — это лопасти, которые вращаются моторами и создают подъёмную силу и тягу для движения дрона.\n\nДва основных размера — это две цифры: первые две — диаметр (дюймы), последние две — шаг (расстояние, которое винт проходит за один оборот). Например, 5030: диаметр 5.0 дюймов, шаг 3.0 дюйма.\n\nОсновные правила:\n• Большой диаметр + низкий KV мотора = тяжёлые грузы и стабильность\n• Маленький диаметр + высокий KV = скорость и манёвренность\n• Чем больше шаг — тем быстрее дрон, но выше нагрузка на мотор\n\nПропеллеры бывают двух направлений вращения: CW (по часовой стрелке) и CCW (против часовой). У дрона всегда два винта крутятся в одну сторону, два — в другую для компенсации реактивного момента."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"name": "Регулятор оборотов (ESC)",
|
||||||
|
"category": "Электроника",
|
||||||
|
"image": "/droneComponents/images/esc.png",
|
||||||
|
"description": "ESC (Electronic Speed Controller) — это устройство, которое получает сигнал от полётного контроллера и преобразует постоянный ток от АКБ в трёхфазный переменный ток для управления бесколлекторным мотором.\n\nФактически ESC — это «педаль газа» для каждого мотора: он меняет скорость вращения тысячи раз в секунду.\n\nОсновные характеристики:\n• Ток (A, амперы): максимальный ток, который ESC может пропустить без перегрева (например, 30A, 50A)\n• Напряжение (S): сколько банок LiPo поддерживает (2-6S и выше)\n• Прошивка (firmware): BLHeli_S, BLHeli_32, AM32 — влияет на плавность и отклик\n\nНа квадрокоптере 4 ESC: один на каждый мотор. Современные дроны часто используют 4-в-1 ESC (одна плата на все 4 мотора) — это компактнее, но если сгорает один канал, менять нужно всю плату."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"name": "АКБ дрона (аккумулятор)",
|
||||||
|
"category": "Батареи",
|
||||||
|
"image": "/droneComponents/images/AKB.png",
|
||||||
|
"description": "АКБ дрона — это аккумуляторная батарея (химический источник тока), которая обеспечивает питанием все системы БПЛА: двигатели, полетный контроллер, телеметрию, камеру.\n\nКоротко: это «топливный бак» дрона, от которого зависит время полёта.\n\nОсновные характеристики:\n• Тип: Li-Po (литий-полимерные) или Li-Ion (для дальних полётов)\n• Напряжение (S): 3S (11.1В), 4S (14.8В) — чем больше банок, тем мощнее\n• Ёмкость (mAh): влияет на время полёта (1500-5000 mAh)\n• Токоотдача (C): способность мгновенно отдавать энергию"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"name": "Моторы дрона (электродвигатели)",
|
||||||
|
"category": "Силовая установка",
|
||||||
|
"image": "/droneComponents/images/motor.png",
|
||||||
|
"description": "Моторы дрона — это электродвигатели, которые преобразуют энергию от АКБ во вращение пропеллеров, создавая подъёмную силу и управляя движением коптера.\n\nСуществует два типа: бесколлекторные (Brushless) — стандарт для всех современных дронов (FPV, DJI), высокий КПД и мощность; коллекторные (Brushed) — только в дешёвых игрушках, малый ресурс.\n\nКлючевые характеристики:\n• Размер (4 цифры, например 2207): первые две — диаметр статора (мм), последние две — высота статора (мм)\n• KV (оборотов на вольт): низкий KV (1700-2000) — для тяжёлых грузов и больших винтов, высокий KV (2500-5000+) — для скорости и маленьких винтов\n• ESC (регулятор оборотов): преобразует постоянный ток батареи в трёхфазный для мотора\n\nПравило подбора: чем тяжелее дрон и длиннее винты, тем ниже должно быть KV. Перегрев мотора после полёта — признак перегрузки."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"name": "PDB (Плата распределения питания)",
|
||||||
|
"category": "Электроника",
|
||||||
|
"image": "/droneComponents/images/pdb.png",
|
||||||
|
"description": "PDB (Power Distribution Board) — это плата, которая принимает питание от АКБ и распределяет его между всеми потребителями: регуляторами оборотов (ESC), полётным контроллером, видеопередатчиком и другой периферией.\n\nЧасто PDB выполняет роль центральной «силовой магистрали» дрона, к ней сходятся все толстые провода.\n\nОсновные возможности:\n• Медные дорожки рассчитаны на большой ток (до 100+ Ампер)\n• Имеет посадочные места для пайки ESC и других устройств\n• Может включать встроенный BEC для питания электроники (5В или 12В)\n• На современных дронах часто интегрирована прямо в полётный контроллер (FC) — тогда отдельная PDB не нужна"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"name": "BEC (Блок стабилизации напряжения)",
|
||||||
|
"category": "Электроника",
|
||||||
|
"image": "/droneComponents/images/bec.png",
|
||||||
|
"description": "BEC (Battery Elimination Circuit) — это преобразователь напряжения, который понижает высокое напряжение от АКБ (например, 14.8В от 4S) до стабильного низкого напряжения (обычно 5В или 12В) для питания чувствительной электроники: полётного контроллера, приёмника, GPS, LED-лент.\n\nНазвание произошло от того, что раньше моделистам нужна была отдельная батарея для электроники, а BEC «устранил» эту батарею.\n\nТипы BEC:\n• Линейный (Linear): простой и дешёвый, но греется и неэффективен при большом перепаде напряжения\n• Импульсный (Switching / UBEC): дороже, но холодный и эффективный — используется в большинстве дронов\n\nГде встречается:\n• Встроен в PDB\n• Встроен в ESC (обычно BEC есть в одном из ESC для питания FC)\n• Отдельный модуль (UBEC) для мощных или особо чувствительных систем\n\nВажно: BEC должен выдавать достаточно тока (ампер) для всей подключаемой электроники."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"name": "Зарядное устройство (балансировочное)",
|
||||||
|
"category": "Зарядные устройства",
|
||||||
|
"image": "/droneComponents/images/zaryadka.png",
|
||||||
|
"description": "Зарядное устройство (LiPo балансировочный зарядник) — это внешний прибор для безопасной зарядки и обслуживания литий-полимерных (LiPo) и литий-ионных (Li-Ion) аккумуляторов дронов.\n\nПростые USB-зарядки из комплекта — это «медленные и опасные» зарядки без балансировки. Хорошее зарядное устройство обязательно выполняет балансировку — выравнивает напряжение на каждой банке (ячейке) АКБ, продлевая срок службы и предотвращая пожар.\n\nОсновные характеристики:\n• Тип: балансировочное (обязательно для LiPo)\n• Мощность: измеряется в Ваттах (например, 50W, 200W, 500W) — влияет на скорость зарядки\n• Ток заряда: 1С (рекомендуемый) — для АКБ 1500mAh это 1.5A\n• Режимы: заряд, разряд, хранение (storage), балансировка\n• Количество каналов: одноканальные или многоканальные (можно заряжать несколько АКБ сразу)\n\nПопулярные модели: ToolkitRC M6D, HOTA D6 Pro, SkyRC B6, ISDT Q6\n\nПравило безопасности: никогда не оставляйте LiPo без присмотра во время зарядки — используйте огнестойкий мешок (LiPo safe bag)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"name": "Полётный контроллер (FC)",
|
||||||
|
"category": "Электроника",
|
||||||
|
"image": "/droneComponents/images/controller.png",
|
||||||
|
"description": "Полётный контроллер (Flight Controller, FC) — это главный процессор дрона, который обрабатывает сигналы с датчиков и управляет моторами через ESC, чтобы стабилизировать полёт и выполнять команды пилота.\n\nЭто «мозг» дрона: он получает данные о положении, угле крена, высоте, читает команды с пульта (газ, крен, тангаж, рыскание) и мгновенно подкручивает обороты каждого мотора тысячи раз в секунду.\n\nЧто современные FC умеют:\n• Стабилизация (режимы Angle/Horizon для новичков и Acro для профессионалов)\n• Поддержка Betaflight, INAV, ArduPilot (прошивки)\n• Блэкбокс (запись полётных данных для анализа)\n• Встроенный барометр, гироскоп, иногда OSD (наложение данных на видео)\n\nПопулярные модели: SpeedyBee F405, MATEK F405, Kakute H7, Pixhawk (для тяжёлых дронов)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"name": "Гироскоп и акселерометр (IMU)",
|
||||||
|
"category": "Датчики",
|
||||||
|
"image": "/droneComponents/images/imu.png",
|
||||||
|
"description": "Гироскоп и акселерометр — это датчики положения дрона в пространстве. Оба распаяны прямо внутри корпуса полётного контроллера и вместе образуют IMU (Inertial Measurement Unit).\n\nКак работают:\n• Гироскоп измеряет угловую скорость (как быстро дрон вращается вокруг осей: крен, тангаж, рыскание)\n• Акселерометр измеряет линейные ускорения (включая гравитацию — чтобы определить, где «низ»)\n\nСовместная работа: FC объединяет данные обоих датчиков (сенсорная фьюжн) и получает точные углы наклона (attitude).\n\nВажно: акселерометр нужен только для режимов стабилизации (Angle/Horizon). В режиме Acro (свободный полёт) многие пилоты отключают акселерометр для более чистой работы гироскопа.\n\nПопулярные модели датчиков: MPU6000, MPU6050, BMI270, ICM42688."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"name": "Барометр (датчик высоты)",
|
||||||
|
"category": "Датчики",
|
||||||
|
"image": "/droneComponents/images/barometr.png",
|
||||||
|
"description": "Барометр — это датчик, который измеряет высоту дрона по изменению атмосферного давления. Чаще всего он уже встроен прямо в полётный контроллер.\n\nПринцип работы: чем выше поднимается дрон, тем ниже давление воздуха. Барометр фиксирует эту разницу и вычисляет высоту.\n\nОсобенности:\n• Работает только на открытом воздухе (давление не меняется в помещении из-за закрытого объёма)\n• Даёт абсолютную высоту относительно стартовой точки\n• Основной датчик для удержания высоты в GPS-режиме и при автономных полётах (INAV, ArduPilot)\n• Точность обычно до ±0.5-1 метра (но чувствителен к ветру и изменениям погоды)\n\nПопулярные модели: BMP280, BMP388, MS5611 (более дорогой и точный)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"name": "Дальномер (ультразвуковой или лазерный)",
|
||||||
|
"category": "Датчики",
|
||||||
|
"image": "/droneComponents/images/dalnomer.png",
|
||||||
|
"description": "Дальномер — это датчик для точного измерения расстояния от дрона до земли или препятствия. Используется для удержания высоты на малых высотах (до нескольких метров), где барометр даёт грубую ошибку.\n\nДва основных типа:\n• Ультразвуковой (SONAR): работает по принципу эхолокации — излучает звук и замеряет время возврата эха. Дёшево, но чувствителен к шуму и работает плохо над травой/водой. Дальность: 0.1-5 м.\n• Лазерный (LiDAR / TOF): измеряет время пролёта лазерного луча. Точный, не боится шума, работает на любых поверхностях. Дальность: до 10-30 метров. Дороже.\n\nГде применяется: дроны для съёмки, агродроны, дроны для полётов в помещениях (где нет GPS). В FPV-гоночных дронах почти не встречается."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"name": "GPS / Компас (магнитометр)",
|
||||||
|
"category": "Датчики",
|
||||||
|
"image": "/droneComponents/images/gps.png",
|
||||||
|
"description": "GPS/Компас — это модуль, который позволяет дрону знать своё местоположение на Земле и направление относительно магнитных полюсов (север/юг). Рекомендуется для любого дрона, который летает на улице.\n\nЧто даёт:\n• Удержание точки (Position Hold) — дрон зависает на месте даже при ветре\n• Возврат домой (RTH — Return To Home) — при потере сигнала или низком заряде дрон возвращается на точку взлёта\n• Полёты по точкам (Waypoints) — автономные миссии\n• Telemetry (скорость, высота, расстояние до дома, координаты)\n\nСостав:\n• GPS приёмник: определяет координаты, высоту (по спутникам), скорость\n• Магнитометр (компас): определяет направление по магнитному полю Земли (нужен для RTH, иначе дрон не поймёт, куда лететь)\n\nВажно: компас требует калибровки перед полётом и страдает от электромагнитных помех от проводов с током. Модуль должен стоять подальше от силовой проводки.\n\nФорматы: M8N, M10, M8Q (чем новее, тем лучше). Рекомендуется M10 с защитой от помех."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -15,6 +15,7 @@ import Save from "@/components/UI/svg/Save.vue";
|
|||||||
import Trash from "@/components/UI/svg/Trash.vue";
|
import Trash from "@/components/UI/svg/Trash.vue";
|
||||||
import Back from "@/components/UI/svg/Back.vue";
|
import Back from "@/components/UI/svg/Back.vue";
|
||||||
import { NTooltip } from 'naive-ui'
|
import { NTooltip } from 'naive-ui'
|
||||||
|
import DroneParts from "@/components/DroneParts.vue";
|
||||||
|
|
||||||
const numbers = ref([]); // Инициализируем массив с пустыми строками
|
const numbers = ref([]); // Инициализируем массив с пустыми строками
|
||||||
const clickCount = ref(1); // Счетчик нажатий
|
const clickCount = ref(1); // Счетчик нажатий
|
||||||
@@ -23,6 +24,7 @@ const isModalVisible = ref(false);
|
|||||||
const isCodeViewVisible = ref(false);
|
const isCodeViewVisible = ref(false);
|
||||||
const finallyText = ref("");
|
const finallyText = ref("");
|
||||||
const dropMenu = ref(null)
|
const dropMenu = ref(null)
|
||||||
|
const showDroneParts = ref(false);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
for (let i = 0; i < 100; i++) {
|
for (let i = 0; i < 100; i++) {
|
||||||
@@ -336,7 +338,16 @@ function onContextMenu(e, item) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!isCodeViewVisible" class="wraper">
|
<button
|
||||||
|
v-if="!showDroneParts"
|
||||||
|
@click="showDroneParts = true"
|
||||||
|
class="show-parts-btn"
|
||||||
|
>
|
||||||
|
<img src="/images/drone.png" alt="menu" class="btn-icon" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DroneParts v-if="showDroneParts" @back="showDroneParts = false" />
|
||||||
|
<div v-if="!showDroneParts && !isCodeViewVisible" class="wraper">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="square" v-for="(item, index) in numbers" :data-key="index" :key="index" @click="setNumber(item)"
|
<div class="square" v-for="(item, index) in numbers" :data-key="index" :key="index" @click="setNumber(item)"
|
||||||
@contextmenu.prevent="onContextMenu($event, item)" :style="{ backgroundImage: `url(${item.BgImg})` }">
|
@contextmenu.prevent="onContextMenu($event, item)" :style="{ backgroundImage: `url(${item.BgImg})` }">
|
||||||
@@ -410,6 +421,34 @@ body {
|
|||||||
--side: 75px;
|
--side: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.show-parts-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(12, 12, 12, 0.034);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-parts-btn:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
.wraper {
|
.wraper {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -497,6 +536,5 @@ path {
|
|||||||
|
|
||||||
.actions button {
|
.actions button {
|
||||||
padding: 20px 35px;
|
padding: 20px 35px;
|
||||||
/* background-color: red; */
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
<template>
|
||||||
|
<div class="drone-parts-container">
|
||||||
|
<!-- Кнопка возврата в левом верхнем углу -->
|
||||||
|
<button class="back-btn" @click="$emit('back')">
|
||||||
|
← Вернуться к маршруту
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Левая панель - список компонентов -->
|
||||||
|
<div class="parts-list">
|
||||||
|
<div
|
||||||
|
v-for="part in parts"
|
||||||
|
:key="part.id"
|
||||||
|
class="part-item"
|
||||||
|
:class="{ active: selectedPart?.id === part.id }"
|
||||||
|
@click="selectPart(part)"
|
||||||
|
>
|
||||||
|
<div class="part-image-wrapper">
|
||||||
|
<img :src="part.image" :alt="part.name" class="part-thumb" />
|
||||||
|
</div>
|
||||||
|
<div class="part-info">
|
||||||
|
<span class="part-name">{{ part.name }}</span>
|
||||||
|
<span class="part-category">{{ part.category }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Правая часть - детали -->
|
||||||
|
<div class="main-area">
|
||||||
|
<div v-if="selectedPart" class="part-detail">
|
||||||
|
<div class="detail-image">
|
||||||
|
<img :src="selectedPart.image" :alt="selectedPart.name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-info">
|
||||||
|
<h2>{{ selectedPart.name }}</h2>
|
||||||
|
<p class="description">{{ selectedPart.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<div class="comments-header">
|
||||||
|
<h3>📝 Мой комментарий</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="myComment" class="existing-comment">
|
||||||
|
<div class="comment-text">{{ myComment.text }}</div>
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-date">{{ myComment.date }}</span>
|
||||||
|
<button class="edit-comment" @click="editComment">✏️ Редактировать</button>
|
||||||
|
<button class="delete-comment" @click="deleteComment">🗑️ Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="add-comment">
|
||||||
|
<textarea
|
||||||
|
v-model="commentText"
|
||||||
|
:placeholder="`Мой комментарий к «${selectedPart.name}»...`"
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
<div class="comment-actions">
|
||||||
|
<button class="save-btn" @click="saveComment" :disabled="!commentText.trim()">
|
||||||
|
💾 Сохранить комментарий
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<div class="empty-icon">🛸</div>
|
||||||
|
<h3>Выберите компонент дрона</h3>
|
||||||
|
<p>Нажмите на любой компонент из списка слева</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits(['back'])
|
||||||
|
let db = null
|
||||||
|
|
||||||
|
const openDB = () => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('DroneCommentsDB', 1)
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const database = event.target.result
|
||||||
|
if (!database.objectStoreNames.contains('comments')) {
|
||||||
|
const store = database.createObjectStore('comments', { keyPath: 'id', autoIncrement: true })
|
||||||
|
store.createIndex('part_id', 'part_id', { unique: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveCommentToDB = async (partId, text) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['comments'], 'readwrite')
|
||||||
|
const store = transaction.objectStore('comments')
|
||||||
|
|
||||||
|
const comment = {
|
||||||
|
part_id: partId,
|
||||||
|
user_id: 'me',
|
||||||
|
text: text,
|
||||||
|
date: new Date().toLocaleString('ru-RU'),
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
const request = store.add(comment)
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadCommentFromDB = async (partId) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['comments'], 'readonly')
|
||||||
|
const store = transaction.objectStore('comments')
|
||||||
|
const index = store.index('part_id')
|
||||||
|
const request = index.getAll(partId)
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const comments = request.result
|
||||||
|
const myComment = comments.length > 0 ? comments[comments.length - 1] : null
|
||||||
|
resolve(myComment)
|
||||||
|
}
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCommentInDB = async (id, newText) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['comments'], 'readwrite')
|
||||||
|
const store = transaction.objectStore('comments')
|
||||||
|
|
||||||
|
const request = store.get(id)
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const comment = request.result
|
||||||
|
if (comment) {
|
||||||
|
comment.text = newText
|
||||||
|
comment.date = new Date().toLocaleString('ru-RU')
|
||||||
|
comment.timestamp = Date.now()
|
||||||
|
|
||||||
|
const updateRequest = store.put(comment)
|
||||||
|
updateRequest.onsuccess = () => resolve()
|
||||||
|
updateRequest.onerror = () => reject(updateRequest.error)
|
||||||
|
} else {
|
||||||
|
reject('Comment not found')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCommentFromDB = async (id) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['comments'], 'readwrite')
|
||||||
|
const store = transaction.objectStore('comments')
|
||||||
|
const request = store.delete(id)
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve()
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const parts = ref([])
|
||||||
|
const selectedPart = ref(null)
|
||||||
|
const myComment = ref(null)
|
||||||
|
const commentText = ref('')
|
||||||
|
const dbReady = ref(false)
|
||||||
|
const loadParts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/droneComponents/components.json')
|
||||||
|
if (response.ok) {
|
||||||
|
parts.value = await response.json()
|
||||||
|
} else {
|
||||||
|
console.error('Файл components.json не найден')
|
||||||
|
parts.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки компонентов:', error)
|
||||||
|
parts.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const selectPart = async (part) => {
|
||||||
|
selectedPart.value = part
|
||||||
|
if (dbReady.value) {
|
||||||
|
const comment = await loadCommentFromDB(part.id)
|
||||||
|
myComment.value = comment
|
||||||
|
commentText.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== КОММЕНТАРИИ ==========
|
||||||
|
const saveComment = async () => {
|
||||||
|
if (!commentText.value.trim() || !selectedPart.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await saveCommentToDB(selectedPart.value.id, commentText.value.trim())
|
||||||
|
const comment = await loadCommentFromDB(selectedPart.value.id)
|
||||||
|
myComment.value = comment
|
||||||
|
commentText.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editComment = () => {
|
||||||
|
if (myComment.value) {
|
||||||
|
commentText.value = myComment.value.text
|
||||||
|
myComment.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteComment = async () => {
|
||||||
|
if (!confirm('Удалить комментарий?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteCommentFromDB(myComment.value.id)
|
||||||
|
myComment.value = null
|
||||||
|
commentText.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка удаления:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
db = await openDB()
|
||||||
|
dbReady.value = true
|
||||||
|
await loadParts()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка открытия IndexedDB:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drone-parts-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка возврата */
|
||||||
|
.back-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: linear-gradient(135deg, #ff6b6b, #c92a2a);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Левая панель с отступом сверху, чтобы не перекрывалась кнопкой */
|
||||||
|
.parts-list {
|
||||||
|
width: 300px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parts-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parts-list::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parts-list::-webkit-scrollbar-thumb {
|
||||||
|
background: #23aae3;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-item:hover {
|
||||||
|
background: rgba(35, 170, 227, 0.2);
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-item.active {
|
||||||
|
background: linear-gradient(135deg, #23aae3, #1a6d8f);
|
||||||
|
box-shadow: 0 4px 15px rgba(35, 170, 227, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-image-wrapper {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-category {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Правая область */
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.part-detail {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 30px;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-image {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-image img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 350px;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-info h2 {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding-top: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-header h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0 0 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.existing-comment {
|
||||||
|
background: rgba(35, 170, 227, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 15px;
|
||||||
|
border-left: 3px solid #23aae3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-comment {
|
||||||
|
color: #23aae3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-comment:hover {
|
||||||
|
background: rgba(35, 170, 227, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-comment {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-comment:hover {
|
||||||
|
background: rgba(255, 107, 107, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-comment textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-comment textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #23aae3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-comment textarea::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
background: linear-gradient(135deg, #23aae3, #1a6d8f);
|
||||||
|
border: none;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:hover:not(:disabled) {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 2px 10px rgba(35, 170, 227, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 80px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h3 {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||