Compare commits
26 Commits
miheev-dev
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 26a207d61e | |||
| f43c22d636 | |||
| 94dfe682c6 | |||
| 9f599a564e | |||
| 7eb973ee99 | |||
| 725b4a7a6e | |||
| 1f2e1dd419 | |||
| cf65f74a6a | |||
| 4140502615 | |||
| b1fdb48609 | |||
| 7a37170b0a | |||
| c2d8bbad8d | |||
| b97f82f3a0 | |||
| eb4bf099f3 | |||
| 25a0014d21 | |||
| 5272c1ddf7 | |||
| 9db8220fdb | |||
| 817ccd3b23 | |||
| 41febc9b4b | |||
| 4cafc621a1 | |||
| fd7408016f | |||
| 6eec8f097a | |||
| b6597f235e | |||
| 6253b09ecd | |||
| 963af23e57 | |||
| 05b5824608 |
@@ -4,12 +4,15 @@ const { FuseV1Options, FuseVersion } = require('@electron/fuses');
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
asar: true,
|
asar: true,
|
||||||
|
icon: './mainicon',
|
||||||
},
|
},
|
||||||
rebuildConfig: {},
|
rebuildConfig: {},
|
||||||
makers: [
|
makers: [
|
||||||
{
|
{
|
||||||
name: '@electron-forge/maker-squirrel',
|
name: '@electron-forge/maker-squirrel',
|
||||||
config: {},
|
config: {
|
||||||
|
setupIcon: './mainicon.ico',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '@electron-forge/maker-zip',
|
name: '@electron-forge/maker-zip',
|
||||||
|
|||||||
|
After Width: | Height: | Size: 264 KiB |
@@ -1,10 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "SkyVue",
|
"name": "skyvue",
|
||||||
"productName": "SkyVue",
|
"productName": "SkyVue",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "My Electron application description",
|
"description": "Визуальное планирование полёта дрона и генерация Python-кода для Clover/ROS",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"homepage": "https://github.com/IliaMiheev/probody",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/IliaMiheev/probody.git"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"start": "electron-forge start",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
@@ -12,8 +17,8 @@
|
|||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
"lint": "echo \"No linting configured\""
|
"lint": "echo \"No linting configured\""
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": ["drone", "clover", "ros", "electron", "flight-planning"],
|
||||||
"author": "Student",
|
"author": "Clovers (https://github.com/IliaMiheev/probody)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.11.1",
|
"@electron-forge/cli": "^7.11.1",
|
||||||
@@ -35,8 +40,10 @@
|
|||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
"copy-to-clipboard": "^3.3.3",
|
"copy-to-clipboard": "^3.3.3",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
|
"element-plus": "^2.14.1",
|
||||||
"izitoast": "^1.4.0",
|
"izitoast": "^1.4.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"vue": "^3.5.28"
|
"vue": "^3.5.28",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px"><path d="M 24 4 C 20.704135 4 18 6.7041348 18 10 L 11.746094 10 A 1.50015 1.50015 0 0 0 11.476562 9.9785156 A 1.50015 1.50015 0 0 0 11.259766 10 L 7.5 10 A 1.50015 1.50015 0 1 0 7.5 13 L 10 13 L 10 38.5 C 10 41.519774 12.480226 44 15.5 44 L 32.5 44 C 35.519774 44 38 41.519774 38 38.5 L 38 13 L 40.5 13 A 1.50015 1.50015 0 1 0 40.5 10 L 36.746094 10 A 1.50015 1.50015 0 0 0 36.259766 10 L 30 10 C 30 6.7041348 27.295865 4 24 4 z M 24 7 C 25.674135 7 27 8.3258652 27 10 L 21 10 C 21 8.3258652 22.325865 7 24 7 z M 13 13 L 35 13 L 35 38.5 C 35 39.898226 33.898226 41 32.5 41 L 15.5 41 C 14.101774 41 13 39.898226 13 38.5 L 13 13 z M 20.476562 17.978516 A 1.50015 1.50015 0 0 0 19 19.5 L 19 34.5 A 1.50015 1.50015 0 1 0 22 34.5 L 22 19.5 A 1.50015 1.50015 0 0 0 20.476562 17.978516 z M 27.476562 17.978516 A 1.50015 1.50015 0 0 0 26 19.5 L 26 34.5 A 1.50015 1.50015 0 1 0 29 34.5 L 29 19.5 A 1.50015 1.50015 0 0 0 27.476562 17.978516 z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 309 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 1022 B |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -1,395 +1,5 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, onMounted, h } from "vue";
|
|
||||||
import izitoast from "@/shared/izitoast";
|
|
||||||
import { useEventListener } from "@vueuse/core";
|
|
||||||
import MyDialog from "@/components/MyDialog.vue";
|
|
||||||
import MyDropMenu from "@/components/MyDropMenu.vue";
|
|
||||||
import copyToclipboard from "@/shared/copyToclipboard";
|
|
||||||
import ContextMenu from "@imengyu/vue3-context-menu";
|
|
||||||
import CodeView from "@/components/CodeView.vue";
|
|
||||||
import emergencyAndNavigate from '@/shablons/emergency_and_navigate.js'
|
|
||||||
import color from '@/shablons/color.js'
|
|
||||||
import qr from '@/shablons/qr.js'
|
|
||||||
import Preview from "@/components/UI/svg/Preview.vue";
|
|
||||||
import Save from "@/components/UI/svg/Save.vue";
|
|
||||||
import Trash from "@/components/UI/svg/Trash.vue";
|
|
||||||
import Back from "@/components/UI/svg/Back.vue";
|
|
||||||
import { NTooltip } from 'naive-ui'
|
|
||||||
|
|
||||||
const numbers = ref([]); // Инициализируем массив с пустыми строками
|
|
||||||
const clickCount = ref(1); // Счетчик нажатий
|
|
||||||
const listOfTrips = ref([]);
|
|
||||||
const isModalVisible = ref(false);
|
|
||||||
const isCodeViewVisible = ref(false);
|
|
||||||
const finallyText = ref("");
|
|
||||||
const dropMenu = ref(null)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
for (let i = 0; i < 100; i++) {
|
|
||||||
numbers.value.push({
|
|
||||||
variable: [],
|
|
||||||
x: i % 10,
|
|
||||||
y: 9 - Math.floor(i / 10),
|
|
||||||
index: i,
|
|
||||||
isPlatform: false,
|
|
||||||
isQR: false,
|
|
||||||
BgImg: 'images/ArUco-marker.jpg'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function setNumber(item) {
|
|
||||||
let valueOfMarker = item.variable[0];
|
|
||||||
//если в клетке нет числа, то оно поставится
|
|
||||||
if (valueOfMarker === "") {
|
|
||||||
item.variable.push(String(clickCount.value));
|
|
||||||
listOfTrips.value.push(item);
|
|
||||||
clickCount.value++;
|
|
||||||
} else {
|
|
||||||
//если нажимаем на ранее отмеченную клетку, то выводится ошибка
|
|
||||||
if (item.variable.at(-1) === clickCount.value - 1) {
|
|
||||||
izitoast.error({
|
|
||||||
title: "Ошибка",
|
|
||||||
message: "Нельзя выбирать один и тот же aruco маркер 2 раза подряд",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
listOfTrips.value.push(item);
|
|
||||||
item.variable.push(clickCount.value);
|
|
||||||
clickCount.value++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPreview(event) {
|
|
||||||
event.currentTarget.blur()
|
|
||||||
generateCode()
|
|
||||||
if (!listOfTrips.value.length) {
|
|
||||||
izitoast.error({
|
|
||||||
title: 'Ошибка',
|
|
||||||
message: 'Для предпросмотра нужно создать маршрут'
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isCodeViewVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveResult(event) {
|
|
||||||
isModalVisible.value = true;
|
|
||||||
event.currentTarget.blur()
|
|
||||||
generateCode()
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateCode() {
|
|
||||||
finallyText.value = "";
|
|
||||||
const mainCode = ref("");
|
|
||||||
const colorDetectClass = ref("");
|
|
||||||
const QRDetectClass = ref("");
|
|
||||||
const colorDetectCode = ref("");
|
|
||||||
const QRDetectCode = ref("");
|
|
||||||
const isUsedPlatform = listOfTrips.value.some(item => item.isPlatform);
|
|
||||||
const isUsedQR = listOfTrips.value.some(item => item.isQR);
|
|
||||||
|
|
||||||
if (!listOfTrips.value.length) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isUsedPlatform) {
|
|
||||||
let textOfCode = color
|
|
||||||
textOfCode = textOfCode.split('<<separator>>')
|
|
||||||
colorDetectClass.value = textOfCode[0]
|
|
||||||
colorDetectCode.value += `\t${textOfCode[1].trim()}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isUsedQR) {
|
|
||||||
let textOfCode = qr
|
|
||||||
textOfCode = textOfCode.split('<<separator>>')
|
|
||||||
QRDetectClass.value = textOfCode[0]
|
|
||||||
QRDetectCode.value += `\t${textOfCode[1].trim()}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of listOfTrips.value) {
|
|
||||||
mainCode.value += `\tnavigate_wait(${item.x}, ${item.y})\n`;
|
|
||||||
if (item.isPlatform) {
|
|
||||||
mainCode.value += colorDetectCode.value + '\n'
|
|
||||||
}
|
|
||||||
if (item.isQR) {
|
|
||||||
mainCode.value += QRDetectCode.value + '\n'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let textOfCode = emergencyAndNavigate
|
|
||||||
finallyText.value = textOfCode.replace("<<main>>", mainCode.value);
|
|
||||||
if (isUsedPlatform) {
|
|
||||||
finallyText.value = finallyText.value.replace("<<ColorDetect>>", colorDetectClass.value);
|
|
||||||
} else {
|
|
||||||
finallyText.value = finallyText.value.replace("<<ColorDetect>>", '');
|
|
||||||
}
|
|
||||||
if (isUsedQR) {
|
|
||||||
finallyText.value = finallyText.value.replace("<<qr_function>>", QRDetectClass.value);
|
|
||||||
} else {
|
|
||||||
finallyText.value = finallyText.value.replace("<<qr_function>>", '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancel(event) {
|
|
||||||
event.currentTarget.blur()
|
|
||||||
if (clickCount.value === 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (listOfTrips.value.at(-1).variable.length === 1) {
|
|
||||||
listOfTrips.value.at(-1).BgImg = 'images/ArUco-marker.jpg'
|
|
||||||
}
|
|
||||||
listOfTrips.value.at(-1).variable.pop();
|
|
||||||
listOfTrips.value.pop();
|
|
||||||
clickCount.value--;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanResult(event) {
|
|
||||||
event.currentTarget.blur()
|
|
||||||
clickCount.value = 1;
|
|
||||||
numbers.value.forEach(item => {
|
|
||||||
item.isPlatform = false;
|
|
||||||
item.isQR = false;
|
|
||||||
item.variable = [];
|
|
||||||
item.BgImg = 'images/ArUco-marker.jpg'
|
|
||||||
})
|
|
||||||
listOfTrips.value = []
|
|
||||||
izitoast.info({
|
|
||||||
title: 'Поле очищено',
|
|
||||||
message: 'Поле очищено. Можно строить маршрут заново',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обрабатываем нажатие Ctrl + Z
|
|
||||||
const handleKeydown = (event) => {
|
|
||||||
if (event.ctrlKey && event.code === "KeyZ" && listOfTrips.value.length) {
|
|
||||||
event.preventDefault();
|
|
||||||
cancel();
|
|
||||||
} else if (event.ctrlKey && event.code === "KeyS") {
|
|
||||||
event.preventDefault();
|
|
||||||
saveResult();
|
|
||||||
} else if (event.ctrlKey && event.code === "KeyN") {
|
|
||||||
event.preventDefault();
|
|
||||||
dropMenu.value.open();
|
|
||||||
} else if (event.code === "Delete") {
|
|
||||||
event.preventDefault();
|
|
||||||
cleanResult();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
useEventListener("keydown", handleKeydown);
|
|
||||||
|
|
||||||
function copyAndToast(text) {
|
|
||||||
copyToclipboard(text);
|
|
||||||
izitoast.success({
|
|
||||||
title: 'Успешно',
|
|
||||||
message: 'Код скопирован в буфер обмена'
|
|
||||||
})
|
|
||||||
isModalVisible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDownloadBtn() {
|
|
||||||
// Создание файла
|
|
||||||
const downloadUrl = ref('');
|
|
||||||
const blob = new Blob([finallyText.value], { type: 'text/plain' });
|
|
||||||
downloadUrl.value = URL.createObjectURL(blob);
|
|
||||||
// Задержка для избежания ошибок в некоторых браузерах
|
|
||||||
setTimeout(() => {
|
|
||||||
// Загрузка
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = downloadUrl.value;
|
|
||||||
link.download = 'itinerary.py';
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
izitoast.success({
|
|
||||||
title: 'Успешно',
|
|
||||||
message: 'Загрузка файла уже началась'
|
|
||||||
})
|
|
||||||
isModalVisible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPlatform(item, color) {
|
|
||||||
if (!item.variable.length) {
|
|
||||||
item.variable.push(clickCount.value);
|
|
||||||
listOfTrips.value.push(item)
|
|
||||||
clickCount.value++
|
|
||||||
}
|
|
||||||
item.isPlatform = true;
|
|
||||||
item.isQR = false;
|
|
||||||
item.BgImg = `images/${color}-platform.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePlatform(item) {
|
|
||||||
if (item.isPlatform) {
|
|
||||||
item.BgImg = 'images/ArUco-marker.jpg'
|
|
||||||
item.isPlatform = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createQR(item) {
|
|
||||||
if (!item.variable.length) {
|
|
||||||
item.variable.push(clickCount.value);
|
|
||||||
listOfTrips.value.push(item)
|
|
||||||
clickCount.value++
|
|
||||||
}
|
|
||||||
item.isPlatform = false;
|
|
||||||
item.isQR = true;
|
|
||||||
item.BgImg = `images/qr.jpg`
|
|
||||||
}
|
|
||||||
|
|
||||||
function deleteQR(item) {
|
|
||||||
if (item.isQR) {
|
|
||||||
item.BgImg = 'images/ArUco-marker.jpg'
|
|
||||||
item.isQR = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePointMap(item) {
|
|
||||||
const index = listOfTrips.value.indexOf(item);
|
|
||||||
if (index !== -1) {
|
|
||||||
const deletedIndex = item.variable.at(-1)
|
|
||||||
listOfTrips.value.forEach(square => {
|
|
||||||
square.variable.map(itemVariable => {
|
|
||||||
if (itemVariable === deletedIndex) {
|
|
||||||
square.variable = []
|
|
||||||
}
|
|
||||||
if (itemVariable > deletedIndex) {
|
|
||||||
square.variable[square.variable.indexOf(itemVariable)]--
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
listOfTrips.value.splice(index, 1);
|
|
||||||
clickCount.value--
|
|
||||||
item.isQR = false
|
|
||||||
item.isPlatform = false
|
|
||||||
item.BgImg = 'images/ArUco-marker.jpg'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createIcon(src) {
|
|
||||||
return h('img', {
|
|
||||||
src: src,
|
|
||||||
style: {
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onContextMenu(e, item) {
|
|
||||||
ContextMenu.showContextMenu({
|
|
||||||
theme: 'mac dark',
|
|
||||||
x: e.x,
|
|
||||||
y: e.y,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
label: "Платформа",
|
|
||||||
icon: createIcon('icons/base_square.webp'),
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: "Белая",
|
|
||||||
onClick: () => createPlatform(item, 'white'),
|
|
||||||
icon: createIcon('icons/white_square.webp'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Синяя",
|
|
||||||
onClick: () => createPlatform(item, 'blue'),
|
|
||||||
icon: createIcon('icons/blue_square.webp'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Красная",
|
|
||||||
onClick: () => createPlatform(item, 'red'),
|
|
||||||
icon: createIcon('icons/red_square.webp'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Удалить",
|
|
||||||
onClick: () => deletePlatform(item),
|
|
||||||
icon: h(Trash),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "QR код",
|
|
||||||
icon: createIcon('icons/qr_code.png'),
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
label: "Добавить",
|
|
||||||
onClick: () => createQR(item),
|
|
||||||
icon: createIcon('icons/plus.webp')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Удалить",
|
|
||||||
onClick: () => deleteQR(item),
|
|
||||||
icon: h(Trash)
|
|
||||||
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Удалить",
|
|
||||||
icon: h(Trash),
|
|
||||||
onClick: () => deletePointMap(item),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!isCodeViewVisible" class="wraper">
|
<router-view />
|
||||||
<div class="grid">
|
|
||||||
<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})` }">
|
|
||||||
{{ numbers[index].variable.join(", ") }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<n-tooltip trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<Save @click="saveResult" />
|
|
||||||
</template>
|
|
||||||
Сохранить
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<Trash @click="cleanResult" />
|
|
||||||
</template>
|
|
||||||
Удалить
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<Back @click="cancel" />
|
|
||||||
</template>
|
|
||||||
Отменить
|
|
||||||
</n-tooltip>
|
|
||||||
<n-tooltip trigger="hover">
|
|
||||||
<template #trigger>
|
|
||||||
<Preview @click="openPreview" />
|
|
||||||
</template>
|
|
||||||
Превью
|
|
||||||
</n-tooltip>
|
|
||||||
</div>
|
|
||||||
<MyDropMenu ref="dropMenu" />
|
|
||||||
<MyDialog v-model:show="isModalVisible" class="dialogWindow" :is-success=Boolean(listOfTrips.length)>
|
|
||||||
<template #header>
|
|
||||||
<strong v-if="listOfTrips.length">Код готов!</strong>
|
|
||||||
<strong v-else>Код не готов!</strong>
|
|
||||||
</template>
|
|
||||||
<main>
|
|
||||||
<p v-if="listOfTrips.length">Скопируйте или скачайте ваш код</p>
|
|
||||||
<p v-else>Выстройте маршрут перед сохранением кода.</p>
|
|
||||||
</main>
|
|
||||||
<template #footer>
|
|
||||||
<div v-if="listOfTrips.length">
|
|
||||||
<button @click="isCodeViewVisible = true">Предпросмотр</button>
|
|
||||||
<button @click="handleDownloadBtn">Скачать код</button>
|
|
||||||
<button @click="copyAndToast(finallyText)">Скопировать код</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MyDialog>
|
|
||||||
</div>
|
|
||||||
<CodeView v-model:show="isCodeViewVisible" :code="finallyText" :show="isCodeViewVisible" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -399,104 +9,13 @@ function onContextMenu(e, item) {
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
background-image: radial-gradient(#23aae3e2, #222222);
|
background-image: radial-gradient(#23aae3e2, #222222);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
|
||||||
--side: 75px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wraper {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px 0 80px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
padding: 3px;
|
|
||||||
grid-template-columns: repeat(10, var(--side));
|
|
||||||
gap: 5px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.square {
|
|
||||||
width: var(--side);
|
|
||||||
height: var(--side);
|
|
||||||
background-size: cover;
|
|
||||||
border: 3px solid rgb(251, 251, 251);
|
|
||||||
cursor: pointer;
|
|
||||||
overflow-y: scroll;
|
|
||||||
|
|
||||||
background-blend-mode: multiply;
|
|
||||||
background-color: transparent;
|
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
|
|
||||||
color: white;
|
|
||||||
text-shadow: -1px -1px 0 black,
|
|
||||||
1px -1px 0 black,
|
|
||||||
-1px 1px 0 black,
|
|
||||||
1px 1px 0 black;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.square:hover {
|
|
||||||
background-color: rgb(166, 162, 162);
|
|
||||||
}
|
|
||||||
|
|
||||||
.square:active {
|
|
||||||
background-color: rgb(164, 162, 162);
|
|
||||||
}
|
|
||||||
|
|
||||||
.square::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.square[data-key="90"] {
|
|
||||||
background-color: rgba(0, 2, 0, 0.638);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 10px;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background-color: #2f2f2f;
|
|
||||||
|
|
||||||
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
backdrop-filter: blur(5px);
|
|
||||||
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions svg {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
transition: color 0.4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions svg:hover {
|
|
||||||
color: rgba(20, 104, 140, 0.89);
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions svg:active {
|
|
||||||
transition: color 0s;
|
|
||||||
color: #23aae3e2;
|
|
||||||
}
|
|
||||||
|
|
||||||
path {
|
|
||||||
fill: currentColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions button {
|
|
||||||
padding: 20px 35px;
|
|
||||||
/* background-color: red; */
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,29 +2,26 @@
|
|||||||
import CustomButton from '@/components/UI/CustomButton.vue'
|
import CustomButton from '@/components/UI/CustomButton.vue'
|
||||||
import CodeBlock from "./CodeBlock.vue";
|
import CodeBlock from "./CodeBlock.vue";
|
||||||
import MovementButtons from "./MovementButtons.vue";
|
import MovementButtons from "./MovementButtons.vue";
|
||||||
const props = defineProps({
|
|
||||||
|
defineProps({
|
||||||
code: {
|
code: {
|
||||||
required: true,
|
required: true,
|
||||||
type: String
|
type: String
|
||||||
},
|
},
|
||||||
show: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
defineEmits(['back'])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="code-view" v-if="show">
|
<div class="code-view">
|
||||||
<CustomButton
|
<CustomButton @click="$emit('back')">
|
||||||
@click.stop="$emit('update:show', false)">
|
Вернуться к генерации маршрута
|
||||||
Вернуться к сохранению сгенерированного кода
|
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
<CodeBlock :code="code" />
|
<CodeBlock :code="code" />
|
||||||
|
|
||||||
<CustomButton
|
<CustomButton @click="$emit('back')">
|
||||||
@click.stop="$emit('update:show', false)">
|
Вернуться к генерации маршрута
|
||||||
Вернуться к сохранению сгенерированного кода
|
|
||||||
</CustomButton>
|
</CustomButton>
|
||||||
<MovementButtons/>
|
<MovementButtons/>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,7 +33,7 @@ const props = defineProps({
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
color: #eeeeee;
|
color: #eeeeee;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
min-height: 100vh;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -80,15 +80,16 @@ useEventListener("keydown", handleKeydown);
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-width: 500px;
|
width: 55%;
|
||||||
width: 90%;
|
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header,
|
.modal-header,
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
@@ -110,16 +111,17 @@ useEventListener("keydown", handleKeydown);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
justify-content: flex-end;
|
|
||||||
border-top: 1px solid white;
|
border-top: 1px solid white;
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer button {
|
.modal-footer button {
|
||||||
justify-content: flex-end;
|
width: 33%;
|
||||||
border-top: 1px solid white;
|
border-top: 1px solid white;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
color: black;
|
color: black;
|
||||||
font-size: 15px;
|
font-size: 20px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,67 +1,78 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="navigation-wrapper">
|
<div class="navigation-wrapper">
|
||||||
<!-- Кнопка Бургер -->
|
<button class="burger-btn" @click="toggleMenu" aria-label="Menu">
|
||||||
<button class="burger-btn" @click="toggleMenu" :class="{ 'is-active': isOpen }" aria-label="Menu">
|
|
||||||
<span class="line"></span>
|
<span class="line"></span>
|
||||||
<span class="line"></span>
|
<span class="line"></span>
|
||||||
<span class="line"></span>
|
<span class="line"></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Затемнение заднего фона (Overlay) -->
|
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<div v-if="isOpen" class="overlay" @click="closeMenu"></div>
|
<div v-if="isOpen" class="overlay" @click="closeMenu"></div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|
||||||
<!-- Выпадающая панель -->
|
|
||||||
<transition name="slide">
|
<transition name="slide">
|
||||||
<aside v-if="isOpen" class="side-panel">
|
<aside v-show="isOpen" class="side-panel scroll-strip">
|
||||||
<div class="menu-header">
|
<div class="menu-header">
|
||||||
<h1>SkyVue</h1>
|
<h1>SkyVue</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="menu-content">
|
<el-menu
|
||||||
<custom-button
|
class="menu-content"
|
||||||
@click="openInfoCard(item)"
|
background-color="transparent"
|
||||||
v-for="item in informationForDirectory"
|
text-color="#ffffff"
|
||||||
|
active-text-color="#23aae3"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
<el-sub-menu
|
||||||
</custom-button>
|
v-for="group in menuGroups"
|
||||||
</nav>
|
:key="group.source"
|
||||||
|
:index="group.source"
|
||||||
|
>
|
||||||
|
<template #title>
|
||||||
|
<span class="menu-content__group-title">{{ group.name }}</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="`${group.source}-${item.id}`"
|
||||||
|
:index="`${group.source}-${item.id}`"
|
||||||
|
class="menu-list-item"
|
||||||
|
@click="openTopic(group.source, item.id)"
|
||||||
|
>
|
||||||
|
<ListCard
|
||||||
|
:title="item.title"
|
||||||
|
:subtitle="item.subtitle"
|
||||||
|
:image="item.image"
|
||||||
|
:active="isTopicActive(group.source, item.id)"
|
||||||
|
/>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</el-menu>
|
||||||
</aside>
|
</aside>
|
||||||
</transition>
|
</transition>
|
||||||
<MyDialog isSuccess v-model:show="isModalVisible" @close="handleOkBtn">
|
|
||||||
<template #header>
|
|
||||||
<strong>{{ infoCard.title }}</strong>
|
|
||||||
</template>
|
|
||||||
<main>
|
|
||||||
<p>{{ infoCard.body }}</p>
|
|
||||||
</main>
|
|
||||||
<template #footer>
|
|
||||||
<div class="navigation-wrapper__footer">
|
|
||||||
<custom-button :to="infoCard.hrefToCourse" title="Посмотреть на Stepik">Курс</custom-button>
|
|
||||||
<custom-button :to="infoCard.hrefToDoc" title="Посмотреть в официальной документации">Документация
|
|
||||||
</custom-button>
|
|
||||||
<custom-button @click="handleOkBtn">ОК</custom-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</MyDialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {ref} from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import MyDialog from "@/components/MyDialog.vue";
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import CustomButton from '@/components/UI/CustomButton.vue'
|
import ListCard from '@/components/UI/ListCard.vue'
|
||||||
import informationForDirectory from "../data/informationForDirectory";
|
import { getMenuGroups } from '@/data/index.js'
|
||||||
import { useEventListener } from "@vueuse/core";
|
import { useEventListener } from "@vueuse/core";
|
||||||
|
|
||||||
const isOpen = ref(false);
|
const route = useRoute()
|
||||||
const isModalVisible = ref(false);
|
const router = useRouter()
|
||||||
const infoCard = ref({})
|
|
||||||
|
|
||||||
function handleOkBtn() {
|
const isOpen = ref(false);
|
||||||
isModalVisible.value = false
|
const menuGroups = ref([])
|
||||||
isOpen.value = true
|
|
||||||
|
function isTopicActive(source, id) {
|
||||||
|
return route.name === 'topic'
|
||||||
|
&& route.params.source === source
|
||||||
|
&& Number(route.params.id) === id
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTopic(source, id) {
|
||||||
|
isOpen.value = false
|
||||||
|
router.push({ name: 'topic', params: { source, id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleMenu = () => {
|
const toggleMenu = () => {
|
||||||
@@ -72,12 +83,6 @@ const closeMenu = () => {
|
|||||||
isOpen.value = false;
|
isOpen.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
function openInfoCard(obj) {
|
|
||||||
isModalVisible.value = true
|
|
||||||
isOpen.value = false
|
|
||||||
infoCard.value = obj
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleKeydown = (event) => {
|
const handleKeydown = (event) => {
|
||||||
if (event.code === "Escape" && isOpen.value) {
|
if (event.code === "Escape" && isOpen.value) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -86,7 +91,10 @@ const handleKeydown = (event) => {
|
|||||||
};
|
};
|
||||||
useEventListener("keydown", handleKeydown);
|
useEventListener("keydown", handleKeydown);
|
||||||
|
|
||||||
// Экспортируем методы, если захотим управлять меню извне
|
onMounted(() => {
|
||||||
|
menuGroups.value = getMenuGroups()
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({ open: () => (isOpen.value = true), close: closeMenu });
|
defineExpose({ open: () => (isOpen.value = true), close: closeMenu });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -99,8 +107,8 @@ defineExpose({open: () => (isOpen.value = true), close: closeMenu});
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
width: 40px;
|
min-width: 40px;
|
||||||
height: 30px;
|
min-height: 30px;
|
||||||
background: rgba(255, 255, 255, 0.508);
|
background: rgba(255, 255, 255, 0.508);
|
||||||
border: 1px solid #383838;
|
border: 1px solid #383838;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -109,52 +117,71 @@ defineExpose({open: () => (isOpen.value = true), close: closeMenu});
|
|||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-content {
|
.menu-content {
|
||||||
display: flex;
|
border-right: none;
|
||||||
flex-direction: column;
|
padding-bottom: 30px;
|
||||||
gap: 10px;
|
}
|
||||||
|
|
||||||
|
.menu-content__group-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content :deep(.el-sub-menu__title) {
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-content :deep(.el-sub-menu__title:hover) {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-list-item {
|
||||||
|
height: auto;
|
||||||
|
line-height: normal;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-list-item :deep(.list-card) {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-list-item:hover,
|
||||||
|
.menu-content :deep(.el-menu-item.menu-list-item:hover) {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.menu-content button{
|
||||||
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background-color: #383838;
|
background-color: #383838;
|
||||||
/* Ярко-красный цвет для теста */
|
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Анимация бургера в крестик */
|
|
||||||
.is-active .line:nth-child(1) {
|
|
||||||
transform: translateY(6px) rotate(45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-active .line:nth-child(2) {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.is-active .line:nth-child(3) {
|
|
||||||
transform: translateY(-6px) rotate(-45deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Боковая панель */
|
|
||||||
.side-panel {
|
.side-panel {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 25%;
|
width: 25%;
|
||||||
|
min-width: 280px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-image: radial-gradient(#23aae3e2, #222222);
|
background-image: radial-gradient(#23aae3e2, #222222);
|
||||||
color: white;
|
color: white;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
padding: 0 20px 20px;
|
padding: 0 12px 20px;
|
||||||
overflow-y: scroll;
|
|
||||||
overflow: auto;
|
|
||||||
border-right: white 2px solid;
|
border-right: white 2px solid;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Затемнение фона */
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -167,17 +194,6 @@ h1 {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
|
||||||
text-align: justify;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navigation-wrapper__footer {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Анимации появления */
|
|
||||||
.slide-enter-active,
|
.slide-enter-active,
|
||||||
.slide-leave-active {
|
.slide-leave-active {
|
||||||
transition: transform 0.39s ease;
|
transition: transform 0.39s ease;
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { publicPath } from '@/shared/publicPath'
|
||||||
|
|
||||||
|
const APP_LOGO = 'images/drone.png'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['click'])
|
||||||
|
|
||||||
|
const imageSrc = computed(() => publicPath(props.image || APP_LOGO))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="list-card"
|
||||||
|
:class="{ 'list-card--active': active }"
|
||||||
|
:title="title"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
<div class="list-card__media">
|
||||||
|
<img
|
||||||
|
:src="imageSrc"
|
||||||
|
:alt="title"
|
||||||
|
:title="title"
|
||||||
|
class="list-card__image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="list-card__body">
|
||||||
|
<span class="list-card__title">{{ title }}</span>
|
||||||
|
<span v-if="subtitle" class="list-card__subtitle">{{ subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.list-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 15px;
|
||||||
|
padding-left: 15px;
|
||||||
|
margin: 8px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, padding-left 0.25s ease;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card:hover {
|
||||||
|
background: rgb(31, 170, 230);
|
||||||
|
padding-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card--active {
|
||||||
|
background:rgb(29, 149, 201);
|
||||||
|
box-shadow: 0 4px 15px rgba(35, 170, 227, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card__media {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card__image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: initial;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card__title {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-card__subtitle {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
<script setup>
|
|
||||||
</script>
|
|
||||||
<template>
|
<template>
|
||||||
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
|
||||||
<title/>
|
<title/>
|
||||||
@@ -20,7 +18,4 @@
|
|||||||
.cls-1 {
|
.cls-1 {
|
||||||
fill: none;
|
fill: none;
|
||||||
}
|
}
|
||||||
#Layer_2 {
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const finallyText = ref('')
|
||||||
|
|
||||||
|
export function useFlightCode() {
|
||||||
|
return { finallyText }
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import partsItems from '@/data/parts.js'
|
||||||
|
|
||||||
|
const parts = ref([...partsItems])
|
||||||
|
|
||||||
|
export function usePartsCatalog() {
|
||||||
|
async function loadParts() {
|
||||||
|
return parts.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartById(id) {
|
||||||
|
return parts.value.find((part) => part.id === id) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { parts, loadParts, getPartById }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const numbers = ref([])
|
||||||
|
const clickCount = ref(1)
|
||||||
|
const listOfTrips = ref([])
|
||||||
|
|
||||||
|
function createEmptyGrid() {
|
||||||
|
return Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
variable: [],
|
||||||
|
x: i % 10,
|
||||||
|
y: 9 - Math.floor(i / 10),
|
||||||
|
index: i,
|
||||||
|
isPlatform: false,
|
||||||
|
isQR: false,
|
||||||
|
BgImg: 'images/ArUco-marker.jpg',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGridInitialized() {
|
||||||
|
if (numbers.value.length === 0) {
|
||||||
|
numbers.value = createEmptyGrid()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlannerState() {
|
||||||
|
ensureGridInitialized()
|
||||||
|
|
||||||
|
return {
|
||||||
|
numbers,
|
||||||
|
clickCount,
|
||||||
|
listOfTrips,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import termsItems from '@/data/terms.js'
|
||||||
|
import partsItems from '@/data/parts.js'
|
||||||
|
|
||||||
|
export async function resolveTopic(source, id) {
|
||||||
|
const numericId = Number(id)
|
||||||
|
|
||||||
|
if (source === 'terms') {
|
||||||
|
const term = termsItems.find((item) => item.id === numericId)
|
||||||
|
if (!term) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: term.title,
|
||||||
|
subtitle: term.subtitle,
|
||||||
|
image: term.image,
|
||||||
|
description: term.description,
|
||||||
|
hrefToCourse: term.hrefToCourse,
|
||||||
|
hrefToDoc: term.hrefToDoc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'parts') {
|
||||||
|
const part = partsItems.find((item) => item.id === numericId)
|
||||||
|
if (!part) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: part.title,
|
||||||
|
subtitle: part.subtitle,
|
||||||
|
image: part.image,
|
||||||
|
description: part.description,
|
||||||
|
hrefToCourse: null,
|
||||||
|
hrefToDoc: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import partsItems from './parts.js'
|
||||||
|
import termsItems from './terms.js'
|
||||||
|
|
||||||
|
export function getMenuGroups() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Компоненты дрона',
|
||||||
|
source: 'parts',
|
||||||
|
items: partsItems,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Термины',
|
||||||
|
source: 'terms',
|
||||||
|
items: termsItems,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export { default as partsItems, loadPartsItems } from './parts.js'
|
||||||
|
export { default as termsItems } from './terms.js'
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
export default [
|
|
||||||
{
|
|
||||||
title: 'ROS',
|
|
||||||
body: 'ROS (Robot Operating System) — экосистема для программирования роботов. В приложении через ROS дрон получает команды, передает телеметрию и данные с камеры.',
|
|
||||||
hrefToDoc: 'https://www.ros.org/',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2260739/step/1?unit=2294748'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'OpenCV',
|
|
||||||
body: 'OpenCV (Open Source Computer Vision Library) — библиотека компьютерного зрения. С её помощью дрон распознает цвета платформ и считывает QR-коды.',
|
|
||||||
hrefToDoc: 'https://opencv.org/',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2272700/step/1?unit=2306893'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'navigate_wait',
|
|
||||||
body: 'Navigate_wait — функция из библиотеки Clover. Отправляет дрон в точку и ждёт, пока он туда долетит, и только потом выполняет следующую команду. Это важно, потому что обычная команда navigate не ждет: дрон ещё летит, а скрипт уже бежит дальше — из-за этого следующие команды могут выполняться раньше времени.',
|
|
||||||
hrefToDoc: 'https://clover.coex.tech/ru/snippets.html',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2264712/step/1?unit=2298803'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Нода (Node)',
|
|
||||||
body: 'Нода (Node/узел) в ROS - исполняемая программа, которая выполняет одну конкретную задачу. В сгенерированном коде вся логика полета и обработки камеры находится внутри одной ноды. Это позволяет запускать, останавливать и отлаживать программу как единое целое. ',
|
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2261765/step/1?unit=2295758'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Топик (Topic)',
|
|
||||||
body: 'Топик (Topic) в ROS — канал обмена данными. Например, камера публикует изображение в топик, а ваш скрипт подписывается на него и получает кадры.',
|
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Издатель (Publisher)',
|
|
||||||
body: 'Издатель (Publisher) в ROS — часть программы, которая отправляет данные в топик. Например, если нужно передать команду другому узлу или опубликовать координаты дрона — создается издатель, который публикует сообщения в нужный канал. Подписчики других узлов могут эти данные получать. ',
|
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Подписчик (Subscriber)',
|
|
||||||
body: 'Подписчик (Subscriber) в ROS — часть программы, которая получает данные из топика. Например, чтобы дрон "видел" изображение с камеры или считывал свои координаты, создаётся подписчик на соответствующий топик. Как только в топике появляются новые данные, подписчик автоматически их обрабатывает.',
|
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Сервис',
|
|
||||||
body: 'Сервис в ROS - способ вызвать функцию робота и сразу получить ответ. В отличие от топика, где данные идут постоянно, сервис работает по принципу "запрос — ответ". Например, команда navigate — это сервис: вы отправляете координаты, а робот отвечает "команда принята". Команда get_telemetry возвращает текущие координаты дрона.',
|
|
||||||
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2263182/step/1?unit=2297206'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Системы координат',
|
|
||||||
body: 'Определяют, откуда отсчитывать координаты дрона. body — относительно самого дрона (вперед/назад/влево/вправо). aruco_map — относительно карты с ArUco-метками, где координаты привязаны к неподвижным маркерам. Выбор правильной системы координат позволяет дрону точно выполнять команды: лететь относительно себя для коротких маневров или по глобальной карте для точной навигации.',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
|
|
||||||
hrefToDoc: 'https://clover.coex.tech/ru/frames.html'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'ArUco-маркер',
|
|
||||||
body: 'ArUco-маркер — квадратный визуальный маркер, который камера дрона может распознавать. Используется для навигации: по положению маркера дрон определяет свои координаты в пространстве.',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
|
|
||||||
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/aruco.html'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Симулятор (Gazebo)',
|
|
||||||
body: 'Симулятор (Gazebo) — виртуальная среда, где дрон ведет себя как в реальности. Позволяет безопасно тестировать полетные задания перед запуском на реальном дроне.',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2278310/step/1?unit=2312564',
|
|
||||||
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/simulation.html'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'unregister()',
|
|
||||||
body: 'unregister() — метод, который отключает подписчика от топика. Используется, чтобы перестать получать данные, когда они больше не нужны (например, после сканирования QR-кода).',
|
|
||||||
hrefToCourse: 'https://stepik.org/lesson/2272845/step/1?unit=2307058',
|
|
||||||
hrefToDoc: 'https://docs.ros.org/en/diamondback/api/rospy/html/rospy.topics.Subscriber-class.html'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
export const partsItems = [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Пропеллеры (винты) дрона",
|
||||||
|
"subtitle": "Силовая установка",
|
||||||
|
"image": "/images/parts/propeller.png",
|
||||||
|
"description": "Пропеллеры (воздушные винты) — это лопасти, которые вращаются моторами и создают подъёмную силу и тягу для движения дрона.\n\nДва основных размера — это две цифры: первые две — диаметр (дюймы), последние две — шаг (расстояние, которое винт проходит за один оборот). Например, 5030: диаметр 5.0 дюймов, шаг 3.0 дюйма.\n\nОсновные правила:\n• Большой диаметр + низкий KV мотора = тяжёлые грузы и стабильность\n• Маленький диаметр + высокий KV = скорость и манёвренность\n• Чем больше шаг — тем быстрее дрон, но выше нагрузка на мотор\n\nПропеллеры бывают двух направлений вращения: CW (по часовой стрелке) и CCW (против часовой). У дрона всегда два винта крутятся в одну сторону, два — в другую для компенсации реактивного момента."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Регулятор оборотов (ESC)",
|
||||||
|
"subtitle": "Электроника",
|
||||||
|
"image": "/images/parts/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,
|
||||||
|
"title": "АКБ дрона (аккумулятор)",
|
||||||
|
"subtitle": "Батареи",
|
||||||
|
"image": "/images/parts/battery.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,
|
||||||
|
"title": "Моторы дрона (электродвигатели)",
|
||||||
|
"subtitle": "Силовая установка",
|
||||||
|
"image": "/images/parts/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,
|
||||||
|
"title": "PDB (Плата распределения питания)",
|
||||||
|
"subtitle": "Электроника",
|
||||||
|
"image": "/images/parts/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,
|
||||||
|
"title": "BEC (Блок стабилизации напряжения)",
|
||||||
|
"subtitle": "Электроника",
|
||||||
|
"image": "/images/parts/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,
|
||||||
|
"title": "Зарядное устройство (балансировочное)",
|
||||||
|
"subtitle": "Зарядные устройства",
|
||||||
|
"image": "/images/parts/charger.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,
|
||||||
|
"title": "Полётный контроллер (FC)",
|
||||||
|
"subtitle": "Электроника",
|
||||||
|
"image": "/images/parts/flight-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,
|
||||||
|
"title": "Гироскоп и акселерометр (IMU)",
|
||||||
|
"subtitle": "Датчики",
|
||||||
|
"image": "/images/parts/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,
|
||||||
|
"title": "Барометр (датчик высоты)",
|
||||||
|
"subtitle": "Датчики",
|
||||||
|
"image": "/images/parts/barometer.png",
|
||||||
|
"description": "Барометр — это датчик, который измеряет высоту дрона по изменению атмосферного давления. Чаще всего он уже встроен прямо в полётный контроллер.\n\nПринцип работы: чем выше поднимается дрон, тем ниже давление воздуха. Барометр фиксирует эту разницу и вычисляет высоту.\n\nОсобенности:\n• Работает только на открытом воздухе (давление не меняется в помещении из-за закрытого объёма)\n• Даёт абсолютную высоту относительно стартовой точки\n• Основной датчик для удержания высоты в GPS-режиме и при автономных полётах (INAV, ArduPilot)\n• Точность обычно до ±0.5-1 метра (но чувствителен к ветру и изменениям погоды)\n\nПопулярные модели: BMP280, BMP388, MS5611 (более дорогой и точный)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"title": "Дальномер (ультразвуковой или лазерный)",
|
||||||
|
"subtitle": "Датчики",
|
||||||
|
"image": "/images/parts/rangefinder.png",
|
||||||
|
"description": "Дальномер — это датчик для точного измерения расстояния от дрона до земли или препятствия. Используется для удержания высоты на малых высотах (до нескольких метров), где барометр даёт грубую ошибку.\n\nДва основных типа:\n• Ультразвуковой (SONAR): работает по принципу эхолокации — излучает звук и замеряет время возврата эха. Дёшево, но чувствителен к шуму и работает плохо над травой/водой. Дальность: 0.1-5 м.\n• Лазерный (LiDAR / TOF): измеряет время пролёта лазерного луча. Точный, не боится шума, работает на любых поверхностях. Дальность: до 10-30 метров. Дороже.\n\nГде применяется: дроны для съёмки, агродроны, дроны для полётов в помещениях (где нет GPS). В FPV-гоночных дронах почти не встречается."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"title": "GPS / Компас (магнитометр)",
|
||||||
|
"subtitle": "Датчики",
|
||||||
|
"image": "/images/parts/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 с защитой от помех."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"title": "Аппаратура управления (пульт пилота)",
|
||||||
|
"subtitle": "Управление",
|
||||||
|
"image": "/images/parts/transmitter.png",
|
||||||
|
"description": "Аппаратура управления (Transmitter, TX, пульт) — это устройство в руках пилота, которое отправляет команды на дрон: газ, крен, тангаж, рыскание, а также переключение режимов полёта и дополнительные функции.\n\nЭто «руль и педали» дрона. Современные пульты работают в цифровых протоколах и имеют телеметрию — обратную связь с дроном (уровень батареи, сигнал, высота).\n\nОсновные характеристики:\n• Количество каналов: минимально 4 (газ, крен, тангаж, рыскание), для FPV нужно 6-8 каналов (режимы, бипер, арм)\n• Протокол связи: ELRS (ExpressLRS) — современный стандарт для FPV (дальность километры), также Crossfire, Ghost, FrSky (ACCST, ACCESS)\n• Мощность передатчика: 100mW, 250mW, 1W — влияет на дальность\n• Частота: 2.4 ГГц (стандарт) или 900 МГц (лучшее проникновение через препятствия)\n\nФорм-факторы:\n• Геймпад (игровой стиль): Radiomaster Zorro, Jumper T-Pro\n• Боксёр (классический): Radiomaster TX16S, TBS Mambo, FrSky X18\n• Для новичков: LiteRadio 3, BetaFPV LiteRadio\n\nВажно: пульт должен поддерживать тот же протокол, что и приёмник на дроне (например, оба на ELRS)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"title": "Приёмник (Receiver, RX)",
|
||||||
|
"subtitle": "Управление",
|
||||||
|
"image": "/images/parts/receiver.png",
|
||||||
|
"description": "Приёмник (Receiver, RX) — это устройство, которое крепится на дрон, принимает радиосигнал с пульта пилота и передаёт команды на полётный контроллер.\n\nЭто «уши» дрона. Без него вы не сможете управлять дроном — он просто не услышит команды.\n\nКак работает:\n1. Пилот двигает стики на пульте → пульт кодирует команды в радиосигнал\n2. Приёмник на дроне ловит сигнал и расшифровывает его\n3. Полётный контроллер читает данные с приёмника (через протоколы типа SBUS, CRSF) и управляет моторами\n\nОсновные характеристики:\n• Протокол связи: должен совпадать с пультом (ELRS, Crossfire, FrSky, DJI, Spektrum)\n• Количество каналов: обычно 6-16 (чем больше, тем больше функций)\n• Дальность: зависит от мощности и протокола — у ELRS на 100mW можно летать на километры\n• Антенны: один или два штырька (диверсити — две антенны для лучшего приёма)\n\nПопулярные модели:\n• ELRS: Radiomaster RP1, Happymodel EP1/EP2, BetaFPV ELRS Lite\n• Crossfire: TBS Crossfire Nano RX\n• FrSky: XM+, R-XSR (устаревающий, но ещё встречается)\n\nВажно: приёмник должен быть совместим с полётным контроллером (обычно подключается через UART и протокол CRSF для ELRS или SBUS для FrSky)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"title": "FPV-камера",
|
||||||
|
"subtitle": "Видеосистема",
|
||||||
|
"image": "/images/parts/camera.png",
|
||||||
|
"description": "FPV-камера — это устройство, которое крепится на дрон и передаёт картинку с его «глаз» в реальном времени. Пилот видит эту картинку в очках или на экране и управляет дроном от первого лица.\n\nЭто «глаза» дрона. Без FPV-камеры полёт от первого лица невозможен.\n\nОсновные характеристики:\n• Размер: самый популярный — 19x19 мм для дронов 3-5 дюймов, также 14x14 мм и 27x27 мм\n• Сенсор: CCD (устаревший, плавная картинка при изменении освещения) или CMOS (современный, лучшее качество)\n• Соотношение сторон: 4:3 (классика) или 16:9 (широкоэкранный)\n• Разрешение: стандартное ~600TVL (аналог), 1080p (цифровые системы типа DJI O4)\n• WDR (широкий динамический диапазон): позволяет видеть детали и в тени, и на свету — критично для полётов против солнца\n• Задержка (латентность): для аналога <5-10 мс, для цифры ~20-40 мс — чем меньше, тем лучше для быстрых маневров\n\nПопулярные модели:\n• Аналог: Caddx Ratel 2, Foxeer T-Rex, Runcam Phoenix 2\n• HDZero: Runcam HDZero\n• Walksnail: Avatar HD\n• DJI: O3 Air Unit (камера + VTX в одном корпусе)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"title": "Видеопередатчик (VTX)",
|
||||||
|
"subtitle": "Видеосистема",
|
||||||
|
"image": "/images/parts/video-transmitter.png",
|
||||||
|
"description": "Видеопередатчик (Video Transmitter, VTX) — это устройство, которое получает видеосигнал с FPV-камеры и транслирует его по радиочастоте на землю (в очки или монитор пилота).\n\nЭто «радиостанция» дрона для видео. VTX определяет, насколько далеко и с каким качеством вы увидите картинку.\n\nОсновные характеристики:\n• Мощность: 25mW, 200mW, 600mW, 1W (1000mW) и выше. Чем выше мощность, тем дальше полёт, но сильнее нагрев\n• Частота: 5.8 ГГц (стандарт для FPV). Бывают ещё 1.3 ГГц и 2.4 ГГц, но редко\n• Каналы: 40+ каналов, включая Raceband (самые чистые частоты для групповых полётов)\n• Тип: аналоговый (дешевле, меньше задержка) или цифровой (качество как на телевизоре, дороже)\n• SmartAudio / Tramp: протоколы для управления VTX с пульта (переключать каналы и мощность в полёте)\n\nПопулярные модели:\n• Аналог: TBS Unify Pro32, Rush Tank Ultimate, Eachine Nano\n• Цифровые системы: DJI O3/O4 Air Unit, Walksnail Avatar, HDZero\n\nВажно: VTX сильно греется — его нельзя включать без антенны (сгорит). Нужен обдув от пропеллеров или радиатор."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 17,
|
||||||
|
"title": "Антенна видеопередатчика",
|
||||||
|
"subtitle": "Видеосистема",
|
||||||
|
"image": "/images/parts/antenna.png",
|
||||||
|
"description": "Антенна видеопередатчика (VTX Antenna) — это устройство, которое излучает видеосигнал VTX в окружающее пространство. Качество антенны напрямую влияет на дальность и надёжность видеосвязи.\n\nЭто «рупор» дрона для видео. Без антенны VTX не только не передаёт сигнал, но и сгорает от перегрева.\n\nТипы антенн:\n• Штыревая (Whip / Linear): простая, дешёвая, но плохая помехозащищённость — подходит только для очень маленьких дронов\n• Клеверный лист (Cloverleaf / RHCP): круговая поляризация, лучше проникает через препятствия\n• Пагода (Pagoda): дешёвая и эффективная круговая антенна\n• Лутц (Lumenier AXII, TrueRC): современные высококачественные антенны с лучшим коэффициентом усиления\n\nПоляризация:\n• RHCP (Right-Hand Circular Polarized) — правая круговая (стандарт FPV)\n• LHCP (Left-Hand Circular Polarized) — левая круговая (несовместима с RHCP!)\n\nВажно: антенна на дроне и антенны на очках должны иметь одинаковую поляризацию (например, обе RHCP). Иначе дальность упадёт в 10-20 раз.\n\nФорм-факторы: короткая (для минидронов) или длинная (лучше сигнал на большом дроне)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 18,
|
||||||
|
"title": "FPV-шлем / очки",
|
||||||
|
"subtitle": "Видеосистема",
|
||||||
|
"image": "/images/parts/goggles.png",
|
||||||
|
"description": "FPV-шлем или очки (Goggles / Video Receiver) — это устройство, которое пилот надевает на голову. Оно принимает видеосигнал с видеопередатчика (VTX) дрона и показывает картинку с камеры прямо перед глазами, создавая эффект полного погружения «от первого лица».\n\nЭто «окно в кабину» дрона. Без них FPV-полёт теряет смысл.\n\nДва основных типа:\n• Очки-«бабочка» (Box Goggles): один большой экран внутри корпуса. Дешевле, подходят для новичков и людей в очках. Пример: Eachine EV800D.\n• Компактные очки (Slim Goggles): два маленьких экрана, выглядят как лыжная маска. Дороже, компактнее, легче. Пример: FatShark, DJI Goggles.\n\nОсновные характеристики:\n• Приёмник (аналоговый или цифровой): должен совпадать с VTX дрона — аналог не примет цифру и наоборот\n• Разрешение экранов: от 480p до 1080p\n• Угол обзора (FOV): от 30° до 60° — чем больше, тем сильнее погружение\n• Запись видео: многие очки пишут DVR (то, что видел пилот) на SD-карту\n• HDMI-вход: позволяет подключать симулятор или цифровые системы сторонних производителей\n• Диоптрии / регулировка линз: для близоруких пилотов\n\nПопулярные модели:\n• Аналоговые: Eachine EV800D (начальный), FatShark Attitude V6, Skyzone Cobra X\n• Цифровые: DJI Goggles 2 / Integra / V2, Walksnail Avatar Goggles X, HDZero Goggles\n\nВажно: антенны на очках должны совпадать по поляризации с антенной на дроне (RHCP с RHCP, LHCP с LHCP)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 19,
|
||||||
|
"title": "Рама и лучи (несущая основа)",
|
||||||
|
"subtitle": "Конструкция",
|
||||||
|
"image": "/images/parts/frame.png",
|
||||||
|
"description": "Рама — это несущая основа дрона, на которую крепятся все компоненты: моторы, полётный контроллер, АКБ, видеопередатчик и остальная электроника. Лучи (arms) — это «руки» дрона, на концах которых устанавливаются моторы. Лучи обычно являются частью рамы (бывают цельные или съёмные).\n\nЭто «скелет» дрона. От жёсткости и веса рамы зависит стабильность полёта и устойчивость к вибрациям.\n\nОсновные материалы:\n• Карбон (углепластик) — самый популярный для FPV: лёгкий, жёсткий, не гнётся. Классическая толщина — 3-4 мм для рамы, 5-6 мм для лучей.\n• Пластик — для игрушек и дешёвых дронов (хрупкий).\n• Алюминий/сталь — для тяжёлых промышленных дронов (редко).\n\nХарактеристики рамы:\n• Размер (wheelbase): расстояние по диагонали между центрами моторов (например, 250 мм для 5-дюймового дрона)\n• Форма: X (симметричный), True X (равные плечи), Wide X (широкий), Deadcat (передние лучи разведены — камера не попадает в кадр)\n• Совместимость с моторами: крепление 12x12 мм (микро), 16x16 мм, 19x19 мм (стандарт M3)\n\nПопулярные производители: TBS, iFlight, GEPRC, Armattan (гарантия на поломку), Source One (дешёвый и хороший)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 20,
|
||||||
|
"title": "Фюзеляж (защитный капот)",
|
||||||
|
"subtitle": "Конструкция",
|
||||||
|
"image": "/images/parts/fuselage.png",
|
||||||
|
"description": "Фюзеляж — это защитный капот (корпус), который закрывает и защищает электронику дрона: полётный контроллер, приёмник, видеопередатчик и провода от пыли, влаги и ударов. Нужен не всем дронам — чаще используется на самолётах (крылатых БПЛА) или готовых consumer-дронах (DJI, Autel).\n\nЭто «кузов» или «кабина» дрона. В FPV-коптерах почти не встречается, так как карбоновая рама сама по себе защищает компоненты, а открытый доступ упрощает ремонт.\n\nГде применяется:\n• Самолёты и крылья: обтекаемый фюзеляж для аэродинамики\n• Промышленные октокоптеры: пыле- и брызгозащита (степень IP54+)\n• Готовые дроны (DJI Mavic, DJI Phantom, Autel Evo): полный пластиковый корпус для защиты и красоты\n• Детские и игрушечные дроны: весь дрон — один большой корпус\n\nМатериалы: пластик (ABS, поликарбонат), пенопласт (для самолётов), иногда карбон с накладками.\n\nВажно: в FPV квадрокоптерах вместо фюзеляжа используют 3D-печатные фиксаторы для АКБ, кейсы для GoPro и небольшой «капот» (top plate) на верхней плате рамы — это минималистичный вариант."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 21,
|
||||||
|
"title": "Светодиодная лента (LED / подсветка)",
|
||||||
|
"subtitle": "Электроника",
|
||||||
|
"image": "/images/parts/led-strip.png",
|
||||||
|
"description": "Светодиодная лента (LED-подсветка) — это адресные или обычные светодиоды на гибкой плате, которые крепятся на раму дрона. Используется для индикации статуса (вооружён/не вооружён, режим полёта, уровень заряда АКБ) и для визуальной ориентации дрона в темноте.\n\nЭто «световая сигнализация» и «габаритные огни» дрона. Позволяет понять, куда повёрнут дрон ночью и готов ли он к взлёту.\n\nТипы:\n• Обычные одноцветные: просто горят красным/синим/белым (дешёвые)\n• Адресные (WS2812B / NeoPixel): каждый светодиод можно управлять индивидуально — менять цвет, яркость, эффекты (бегущие огни, мигание)\n\nЧто можно делать:\n• Подключить к полётному контроллеру (UART или LED-порт) и настроить в Betaflight\n• Включить разные цвета для задних (обычно красные, тёплые) и передних (синие/белые/холодные) лучей для ориентации\n• Мигать при низком заряде АКБ\n• Светить ярко-синим/зелёным при вооружении (arm)\n\nПараметры: напряжение 5В (питается от BEC), количество светодиодов: 4-8 шт на дрон, длина обычно 1-2 метра на дрон.\n\nВажно: в Betaflight есть встроенный режим для WS2812B — не требует программирования."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 22,
|
||||||
|
"title": "Вольтметр (пищалка / LiPo Buzzer)",
|
||||||
|
"subtitle": "Аксессуары",
|
||||||
|
"image": "/images/parts/lipo-buzzer.png",
|
||||||
|
"description": "Вольтметр (называется также пищалкой, LiPo Buzzer, checker) — это маленькое устройство, которое подключается к балансировочному разъёму АКБ. Оно измеряет напряжение на каждой банке (ячейке) аккумулятора и громко пищит, когда напряжение падает ниже заданного порога, предупреждая пилота о скором разряде.\n\nЭто «сигнализация разряда» для аккумулятора. Предотвращает глубокий разряд LiPo — одну из главных причин выхода батареи из строя и пожара при зарядке.\n\nКак работает:\n1. Вставляешь в балансировочный разъём АКБ (тот, что с несколькими тонкими проводами)\n2. Устанавливаешь порог писка (для LiPo это 3.3-3.5В на банку)\n3. Когда напряжение на любой банке падает ниже порога — пищалка орёт (сигнал слышен с дрона на 50-100 метров)\n4. Пилот слышит звук и сажает дрон, чтобы не убить батарею\n\nХарактеристики:\n• Диапазон измерения: 1-8 банок (S) (чаще всего 2-6S)\n• Настройка порога срабатывания: кнопками на корпусе (например, 3.3В, 3.4В, 3.5В)\n• Режимы: звуковой сигнал (непрерывный или прерывистый), иногда дисплей с напряжением\n• Размер: маленький (2x3 см), вес 5-10 грамм\n\nВажно: в современных полётных контроллерах есть встроенный функционал пищалки через моторы (dshot beeper) — моторы сами издают звук, и отдельная пищалка не нужна. Но внешний вольтметр надёжнее: он работает даже при отказе полётного контроллера или разряде, когда ESC уже не могут пищать.\n\nПравило: всегда начинающим рекомендуется иметь такой buzzer — он стоит 50-100 рублей и спасёт не один аккумулятор."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export async function loadPartsItems() {
|
||||||
|
return partsItems
|
||||||
|
}
|
||||||
|
|
||||||
|
export default partsItems
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
export const termsItems = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: 'ROS',
|
||||||
|
subtitle: 'Экосистема',
|
||||||
|
image: '/images/terms/ROS.webp',
|
||||||
|
description: 'ROS (Robot Operating System) — экосистема для программирования роботов и дронов. Несмотря на слово «Operating System», это не операционная система, а набор библиотек, инструментов и правил, по которым разные программы робота обмениваются данными.\n\nВ приложении SkyVue сгенерированный Python-код работает внутри ROS: дрон получает команды полёта, передаёт телеметрию (координаты, высоту) и данные с камеры. Без ROS отдельные части системы — камера, контроллер полёта, ваш скрипт — не смогли бы «разговаривать» друг с другом.\n\nОсновные элементы ROS:\n• Ноды (Node) — отдельные программы, каждая выполняет свою задачу\n• Топики (Topic) — каналы для непрерывной передачи данных (изображение, телеметрия)\n• Сервисы (Service) — вызов функции с немедленным ответом (команда navigate, запрос get_telemetry)\n• Сообщения (Message) — формат данных, который передаётся в топиках и сервисах\n\nНа платформе Clover ROS уже настроен: камера, полётный контроллер и симулятор Gazebo подключены и готовы к работе. Вам остаётся написать логику в одной ноде — приложение делает это автоматически.',
|
||||||
|
hrefToDoc: 'https://www.ros.org/',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2260739/step/1?unit=2294748',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'OpenCV',
|
||||||
|
subtitle: 'Компьютерное зрение',
|
||||||
|
image: '/images/terms/openCV.webp',
|
||||||
|
description: 'OpenCV (Open Source Computer Vision Library) — библиотека компьютерного зрения с открытым исходным кодом. Она умеет обрабатывать изображения и видео: находить объекты, определять цвета, считывать QR-коды и ArUco-маркеры.\n\nЭто «глаза и мозг» для обработки картинки с камеры дрона. Камера сама по себе только передаёт пиксели — OpenCV превращает их в полезную информацию: «перед нами красная платформа» или «в QR-коде написано число 42».\n\nЧто OpenCV делает в заданиях с Clover:\n• Распознаёт цвет платформы (красный, зелёный, синий) по HSV-диапазону\n• Считывает QR-коды с помощью cv2.QRCodeDetector\n• Обрабатывает кадры с камеры в реальном времени через cv2.cvtColor, cv2.inRange и другие функции\n• Работает и в симуляторе Gazebo, и на реальном дроне — код один и тот же\n\nOpenCV — стандарт индустрии: её используют в робототехнике, автономных машинах и системах видеонаблюдения. В Python подключается одной строкой: import cv2.',
|
||||||
|
hrefToDoc: 'https://opencv.org/',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2272700/step/1?unit=2306893',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: 'navigate_wait',
|
||||||
|
subtitle: 'Clover',
|
||||||
|
image: '/images/terms/navigate_wait.jpg',
|
||||||
|
description: 'navigate_wait — функция из библиотеки Clover (clover.srv), которая отправляет дрон в заданную точку и ждёт, пока он туда долетит. Только после прилёта выполняется следующая строка кода.\n\nЭто «команда с терпением»: скрипт не бежит дальше, пока дрон не завершит перелёт.\n\nЧем отличается от обычного navigate:\n• navigate — отправляет команду и сразу идёт дальше. Дрон ещё летит, а скрипт уже выполняет следующие действия\n• navigate_wait — блокирует выполнение до прилёта. Следующая команда гарантированно выполнится в нужной точке\n\nКогда использовать navigate_wait:\n• Перед считыванием QR-кода или определением цвета платформы — дрон должен зависнуть над целью\n• При последовательных перемещениях, где порядок важен (сначала точка A, потом точка B)\n• Когда нужно дождаться завершения манёвра перед следующим действием\n\nПараметры: x, y, z (координаты), frame_id (система координат, например body или aruco_map), speed (скорость), auto_arm (автовооружение). В SkyVue эти параметры подставляются из блоков траектории на карте.',
|
||||||
|
hrefToDoc: 'https://clover.coex.tech/ru/snippets.html',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2264712/step/1?unit=2298803',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: 'Нода (Node)',
|
||||||
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/node.jpg',
|
||||||
|
description: 'Нода (Node, узел) в ROS — исполняемая программа, которая выполняет одну конкретную задачу и общается с другими нодами через топики и сервисы.\n\nЭто «отдельный работник» в команде робота: один следит за камерой, другой управляет полётом, третий обрабатывает изображение. Каждый работает независимо, но координируется через ROS.\n\nКак это выглядит в сгенерированном коде:\n• Вся логика полёта и обработки камеры находится внутри одной ноды\n• В начале файла — rospy.init_node(\'flight\'), который регистрирует программу в ROS\n• Внутри ноды создаются подписчики (на камеру, телеметрию) и вызываются сервисы (navigate, get_telemetry)\n\nЗачем одна нода, а не несколько:\n• Проще отлаживать — весь код в одном месте\n• Легче запускать: rosrun clover blockly\n• Удобно для учебных заданий, где логика невелика\n\nВ реальных проектах нод может быть много: отдельная нода для камеры, для навигации, для логирования. Но принцип тот же — каждая нода = одна программа с чёткой задачей.',
|
||||||
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2261765/step/1?unit=2295758',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: 'Топик (Topic)',
|
||||||
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/topic.jpg',
|
||||||
|
description: 'Топик (Topic) в ROS — именованный канал для обмена данными между нодами. Один узел публикует сообщения в топик, другие подписываются и получают их — как радиостанция, на которую настроились несколько приёмников.\n\nЭто «радиоканал» робота: данные текут непрерывно, пока издатель их отправляет.\n\nПримеры топиков на Clover:\n• /main_camera/image/compressed — сжатые кадры с камеры дрона\n• /mavros/state — состояние полётного контроллера (вооружён/не вооружён)\n• /mavros/local_position/pose — текущие координаты дрона\n\nКак работает связка:\n1. Камера (нода image_publisher) постоянно публикует кадры в топик\n2. Ваш скрипт подписывается на этот топик через rospy.Subscriber\n3. Каждый новый кадр автоматически вызывает вашу функцию-обработчик\n\nТопики vs сервисы:\n• Топик — поток данных без ожидания ответа (видео, телеметрия)\n• Сервис — один запрос, один ответ (команда «лети сюда»)\n\nИмя топика всегда начинается с / и уникально в системе ROS.',
|
||||||
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: 'Издатель (Publisher)',
|
||||||
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/publisher.jpg',
|
||||||
|
description: 'Издатель (Publisher) в ROS — объект в программе, который отправляет (публикует) сообщения в топик. Любая нода может создать издатель и передавать данные всем подписчикам этого топика.\n\nЭто «передатчик» в системе ROS: вы говорите что-то в эфир, а кто настроился — тот слышит.\n\nКак создаётся издатель в Python:\n• pub = rospy.Publisher(\'/my_topic\', String, queue_size=10)\n• pub.publish(сообщение) — отправить данные в топик\n\nГде встречается на практике:\n• Нода камеры Clover публикует изображения — вы только подписываетесь\n• Если нужно передать результат своей обработки другой ноде — создаёте свой издатель\n• Логирование: публикация отладочных сообщений в топик /rosout\n\nПараметры при создании:\n• Имя топика — куда публиковать\n• Тип сообщения (String, Image, PoseStamped и др.) — формат данных\n• queue_size — сколько сообщений хранить в очереди, если подписчик не успевает\n\nВ учебных заданиях Clover чаще используются подписчики (получать данные с камеры), а не издатели. Но понимание издателя важно для полной картины ROS.',
|
||||||
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: 'Подписчик (Subscriber)',
|
||||||
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/subscriber.jpg',
|
||||||
|
description: 'Подписчик (Subscriber) в ROS — объект в программе, который получает данные из топика. Когда в топике появляется новое сообщение, ROS автоматически вызывает вашу callback-функцию с этими данными.\n\nЭто «приёмник» робота: вы настраиваетесь на нужный канал и реагируете на каждое новое сообщение.\n\nКак это работает в коде Clover:\n• rospy.Subscriber(\'/main_camera/image/compressed\', CompressedImage, image_callback)\n• Функция image_callback получает каждый кадр с камеры\n• Внутри callback можно обработать изображение через OpenCV: определить цвет, прочитать QR-код\n\nТипичный сценарий в заданиях:\n1. Подписаться на камеру\n2. В callback получить кадр, декодировать его\n3. Обработать через cv2 — найти цвет платформы или QR-код\n4. Вызвать unregister(), когда данные больше не нужны\n\nВажно:\n• Callback вызывается асинхронно — не блокирует основной поток\n• Не делайте в callback долгих операций — иначе пропустите кадры\n• Всегда отключайте подписчика через unregister(), когда он больше не нужен',
|
||||||
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: 'Сервис',
|
||||||
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/service.jpg',
|
||||||
|
description: 'Сервис (Service) в ROS — способ вызвать функцию робота и сразу получить ответ. В отличие от топика, где данные текут непрерывно, сервис работает по принципу «запрос — ответ»: один раз спросил, один раз получил результат.\n\nЭто «кнопка с обратной связью»: нажал — робот выполнил и сообщил, получилось или нет.\n\nОсновные сервисы Clover:\n• navigate / navigate_wait — отправить дрон в точку (x, y, z)\n• get_telemetry — получить текущие координаты, высоту, скорость\n• land — посадить дрон\n• arming — вооружить или разоружить моторы\n\nКак вызывается сервис в Python:\n• rospy.wait_for_service(\'navigate\') — дождаться, пока сервис станет доступен\n• navigate = rospy.ServiceProxy(\'navigate\', Navigate)\n• navigate(x=1, y=0, z=1.5, frame_id=\'body\', speed=0.5) — вызов с параметрами\n\nСервис vs топик:\n• Сервис — когда нужен конкретный результат прямо сейчас (координаты, команда полёта)\n• Топик — когда данные приходят постоянно (видеопоток, телеметрия в реальном времени)\n\nВ SkyVue блоки траектории генерируют вызовы navigate_wait — это как раз работа через сервис.',
|
||||||
|
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2263182/step/1?unit=2297206',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: 'Системы координат',
|
||||||
|
subtitle: 'Навигация',
|
||||||
|
image: '/images/terms/coordinate-system.jpg',
|
||||||
|
description: 'Системы координат (frames) определяют, откуда отсчитываются координаты дрона: относительно самого дрона, карты ArUco или глобальной системы. Без правильного frame_id команда «лети на x=1, y=0» может отправить дрон не туда.\n\nЭто «точка отсчёта» на карте: одни и те же числа x, y, z означают разное в зависимости от выбранной системы.\n\nОсновные системы на Clover:\n• body — координаты относительно дрона: x — вперёд/назад, y — влево/вправо, z — вверх/вниз. Удобно для коротких манёвров («пролети 2 метра вперёд»)\n• aruco_map — координаты относительно карты ArUco-меток на полу. Маркеры неподвижны, поэтому дрон знает своё абсолютное положение в комнате\n• map — глобальная карта (используется реже в учебных заданиях)\n\nКогда что использовать:\n• body — относительные перемещения от текущей позиции\n• aruco_map — полёт к конкретной точке на карте, навигация между платформами\n• При смене системы координаты пересчитываются автоматически через TF (Transform)\n\nВ SkyVue система координат задаётся в настройках траектории и подставляется в параметр frame_id при вызове navigate_wait.',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
|
||||||
|
hrefToDoc: 'https://clover.coex.tech/ru/frames.html',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: 'ArUco-маркер',
|
||||||
|
subtitle: 'Навигация',
|
||||||
|
image: '/images/terms/ArUco-marker.webp',
|
||||||
|
description: 'ArUco-маркер — квадратный чёрно-белый визуальный маркер с уникальным ID внутри. Камера дрона распознаёт его через OpenCV и определяет положение маркера (и своё собственное) в пространстве.\n\nЭто «маяк на полу»: дрон смотрит на маркер и понимает, где он находится относительно карты.\n\nКак работает навигация по ArUco:\n• На полу или стенах размещаются маркеры с известными координатами\n• Камера дрона видит маркеры и вычисляет свою позицию в системе aruco_map\n• Дрон может лететь к точке с точностью до сантиметров — без GPS\n\nГде применяется:\n• Учебные полигоны и соревнования (RoboCup, олимпиады по робототехнике)\n• Полёты в помещении, где GPS не работает\n• Точная посадка на платформу или маркированную точку\n\nНа Clover:\n• Пакет aruco_detect распознаёт маркеры и публикует их позиции\n• Система координат aruco_map привязана к расположению маркеров на карте\n• В симуляторе Gazebo маркеры уже расставлены на сцене\n\nКаждый маркер имеет числовой ID (0, 1, 2...) — по нему система различает маркеры на одной карте.',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
|
||||||
|
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/aruco.html',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 11,
|
||||||
|
title: 'Симулятор (Gazebo)',
|
||||||
|
subtitle: 'Симуляция',
|
||||||
|
image: '/images/terms/gazebo.jpg',
|
||||||
|
description: 'Gazebo — физический симулятор роботов и дронов. В виртуальной среде дрон ведёт себя как в реальности: летит, реагирует на команды, камера передаёт изображение, ArUco-маркеры видны на полу.\n\nЭто «тренировочный полигон» без риска разбить дрон: ошибся в коде — перезапустил симуляцию, а не чинил пропеллер.\n\nЧто симулирует Gazebo на Clover:\n• Физику полёта: подъёмная сила, инерция, гравитация\n• Камеру дрона с тем же топиком /main_camera/image/compressed\n• ArUco-маркеры на виртуальной карте\n• Все ROS-сервисы: navigate, get_telemetry, land — работают так же, как на реальном дроне\n\nЗачем использовать симулятор:\n• Безопасное тестирование кода перед полётом на реальном Clover\n• Не нужен физический дрон — достаточно компьютера\n• Тот же Python-код запускается и в Gazebo, и на реальном роботе\n• Можно повторять задание сколько угодно раз\n\nКак запустить: roslaunch clover sim_clover.launch — поднимается Gazebo со сценой и дроном. Код из SkyVue можно сразу тестировать в симуляторе через rosrun.',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2278310/step/1?unit=2312564',
|
||||||
|
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/simulation.html',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 12,
|
||||||
|
title: 'unregister()',
|
||||||
|
subtitle: 'ROS',
|
||||||
|
image: '/images/terms/subscriber.jpg',
|
||||||
|
description: 'unregister() — метод объекта подписчика (Subscriber) в ROS, который отключает его от топика. После вызова программа перестаёт получать новые сообщения из этого канала.\n\nЭто «выключить приёмник»: данные больше не нужны — отписываемся, чтобы не тратить ресурсы и не вызывать callback зря.\n\nКогда использовать:\n• После успешного считывания QR-кода — камера больше не нужна для этой задачи\n• Когда определили цвет платформы и пора лететь дальше\n• Перед длительной операцией (navigate_wait), чтобы callback не мешал основному потоку\n• В конце программы — корректное завершение работы с ROS\n\nКак выглядит в коде:\n• sub = rospy.Subscriber(\'/main_camera/image/compressed\', CompressedImage, callback)\n• ... обработка кадров ...\n• sub.unregister() — отключение\n\nЧто будет, если не вызывать unregister():\n• Callback продолжит вызываться на каждый кадр — лишняя нагрузка на CPU\n• Возможны конфликты: callback срабатывает во время navigate_wait\n• При повторном создании подписчика на тот же топик — дублирование обработчиков\n\nПравило: создали подписчик — отключите его, когда задача выполнена.',
|
||||||
|
hrefToCourse: 'https://stepik.org/lesson/2272845/step/1?unit=2307058',
|
||||||
|
hrefToDoc: 'https://docs.ros.org/en/diamondback/api/rospy/html/rospy.topics.Subscriber-class.html',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default termsItems
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow } from 'electron';
|
import { app, BrowserWindow, shell } from 'electron';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import started from 'electron-squirrel-startup';
|
import started from 'electron-squirrel-startup';
|
||||||
|
|
||||||
@@ -7,6 +7,38 @@ if (started) {
|
|||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAppNavigation(url) {
|
||||||
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL && url.startsWith(MAIN_WINDOW_VITE_DEV_SERVER_URL)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.startsWith('file://');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openExternalLink(url) {
|
||||||
|
shell.openExternal(url).catch((error) => {
|
||||||
|
console.error('Failed to open external link:', url, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureExternalLinks(mainWindow) {
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
openExternalLink(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.webContents.on('will-navigate', (event, url) => {
|
||||||
|
if (isAppNavigation(url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
event.preventDefault();
|
||||||
|
openExternalLink(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const createWindow = () => {
|
const createWindow = () => {
|
||||||
const { screen } = require('electron');
|
const { screen } = require('electron');
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
@@ -20,7 +52,9 @@ const createWindow = () => {
|
|||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
mainWindow.menuBarVisible = false;
|
mainWindow.setMenuBarVisibility(false);
|
||||||
|
mainWindow.maximize();
|
||||||
|
configureExternalLinks(mainWindow);
|
||||||
|
|
||||||
// and load the index.html of the app.
|
// and load the index.html of the app.
|
||||||
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
|
||||||
|
|||||||
@@ -1,37 +1,19 @@
|
|||||||
/**
|
import ElementPlus from 'element-plus'
|
||||||
* This file will automatically be loaded by vite and run in the "renderer" context.
|
import 'element-plus/dist/index.css'
|
||||||
* To learn more about the differences between the "main" and the "renderer" context in
|
import 'dayjs/locale/ru'
|
||||||
* Electron, visit:
|
import ru from 'element-plus/es/locale/lang/ru'
|
||||||
*
|
|
||||||
* https://electronjs.org/docs/tutorial/process-model
|
|
||||||
*
|
|
||||||
* By default, Node.js integration in this file is disabled. When enabling Node.js integration
|
|
||||||
* in a renderer process, please be aware of potential security implications. You can read
|
|
||||||
* more about security risks here:
|
|
||||||
*
|
|
||||||
* https://electronjs.org/docs/tutorial/security
|
|
||||||
*
|
|
||||||
* To enable Node.js integration in this file, open up `main.js` and enable the `nodeIntegration`
|
|
||||||
* flag:
|
|
||||||
*
|
|
||||||
* ```
|
|
||||||
* // Create the browser window.
|
|
||||||
* mainWindow = new BrowserWindow({
|
|
||||||
* width: 800,
|
|
||||||
* height: 600,
|
|
||||||
* webPreferences: {
|
|
||||||
* nodeIntegration: true
|
|
||||||
* }
|
|
||||||
* });
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
|
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
|
||||||
import Prism from 'prismjs'
|
|
||||||
import 'prismjs/themes/prism-okaidia.css'
|
import 'prismjs/themes/prism-okaidia.css'
|
||||||
|
import '@/styles/scroll-strip.css'
|
||||||
|
|
||||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||||
createApp(App).use(ContextMenu).mount('#app');
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus, { locale: ru })
|
||||||
|
app.use(ContextMenu)
|
||||||
|
app.mount('#app')
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import PlannerView from '@/views/PlannerView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'planner',
|
||||||
|
component: PlannerView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/preview',
|
||||||
|
name: 'preview',
|
||||||
|
component: () => import('@/views/PreviewView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/topic/:source/:id',
|
||||||
|
name: 'topic',
|
||||||
|
component: () => import('@/views/TopicView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -41,7 +41,7 @@ def emergency_stop():
|
|||||||
|
|
||||||
"""Функция экстренной остановки, срабатывает при нажатии Ctrl + C"""
|
"""Функция экстренной остановки, срабатывает при нажатии Ctrl + C"""
|
||||||
|
|
||||||
print("\n" + "="*50)
|
print("="*50)
|
||||||
print("EMERGENCY STOP ACTIVATED!")
|
print("EMERGENCY STOP ACTIVATED!")
|
||||||
print("="*50)
|
print("="*50)
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ def signal_handler(sig, frame):
|
|||||||
"""Перехват Ctrl+C: вместо аварийного завершения программы
|
"""Перехват Ctrl+C: вместо аварийного завершения программы
|
||||||
выполняет безопасную посадку дрона."""
|
выполняет безопасную посадку дрона."""
|
||||||
|
|
||||||
print("\nCtrl+C detected - Emergency stop!")
|
print("Ctrl+C detected - Emergency stop!")
|
||||||
emergency_stop()
|
emergency_stop()
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function publicPath(relativePath) {
|
||||||
|
if (!relativePath) {
|
||||||
|
return relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = relativePath.replace(/^\.\//, '').replace(/^\//, '')
|
||||||
|
return `${import.meta.env.BASE_URL}${path}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Переиспользуемая скролл-лента.
|
||||||
|
* Подключение: import '@/styles/scroll-strip.css' (или через renderer.js).
|
||||||
|
* Использование: добавьте класс scroll-strip на прокручиваемый контейнер.
|
||||||
|
*
|
||||||
|
* Кастомизация через CSS-переменные на элементе или родителе:
|
||||||
|
* --scroll-strip-width
|
||||||
|
* --scroll-strip-track
|
||||||
|
* --scroll-strip-thumb
|
||||||
|
* --scroll-strip-radius
|
||||||
|
*/
|
||||||
|
.scroll-strip {
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--scroll-strip-thumb, #23aae3) var(--scroll-strip-track, rgba(255, 255, 255, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-strip::-webkit-scrollbar {
|
||||||
|
width: var(--scroll-strip-width, 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-strip::-webkit-scrollbar-track {
|
||||||
|
background: var(--scroll-strip-track, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: var(--scroll-strip-radius, 3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-strip::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--scroll-strip-thumb, #23aae3);
|
||||||
|
border-radius: var(--scroll-strip-radius, 3px);
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, h, inject, provide } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import izitoast from "@/shared/izitoast";
|
||||||
|
import { useEventListener } from "@vueuse/core";
|
||||||
|
import MyDialog from "@/components/MyDialog.vue";
|
||||||
|
import MyDropMenu from "@/components/MyDropMenu.vue";
|
||||||
|
import copyToclipboard from "@/shared/copyToclipboard";
|
||||||
|
import ContextMenu from "@imengyu/vue3-context-menu";
|
||||||
|
import emergencyAndNavigate from '@/shablons/emergency_and_navigate.js'
|
||||||
|
import color from '@/shablons/color.js'
|
||||||
|
import qr from '@/shablons/qr.js'
|
||||||
|
import Preview from "@/components/UI/svg/Preview.vue";
|
||||||
|
import Save from "@/components/UI/svg/Save.vue";
|
||||||
|
import Trash from "@/components/UI/svg/Trash.vue";
|
||||||
|
import Back from "@/components/UI/svg/Back.vue";
|
||||||
|
import { NTooltip } from 'naive-ui'
|
||||||
|
import { useFlightCode } from '@/composables/useFlightCode'
|
||||||
|
import { usePlannerState } from '@/composables/usePlannerState'
|
||||||
|
import { publicPath } from '@/shared/publicPath'
|
||||||
|
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { finallyText } = useFlightCode()
|
||||||
|
const { numbers, clickCount, listOfTrips } = usePlannerState()
|
||||||
|
|
||||||
|
const isModalVisible = ref(false);
|
||||||
|
const dropMenu = ref(null);
|
||||||
|
provide('dropMenu', dropMenu);
|
||||||
|
|
||||||
|
function setNumber(item) {
|
||||||
|
let valueOfMarker = item.variable[0];
|
||||||
|
if (valueOfMarker === "") {
|
||||||
|
item.variable.push(String(clickCount.value));
|
||||||
|
listOfTrips.value.push(item);
|
||||||
|
clickCount.value++;
|
||||||
|
} else {
|
||||||
|
if (item.variable.at(-1) === clickCount.value - 1) {
|
||||||
|
izitoast.error({
|
||||||
|
title: "Ошибка",
|
||||||
|
message: "Нельзя выбирать один и тот же aruco маркер 2 раза подряд",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listOfTrips.value.push(item);
|
||||||
|
item.variable.push(clickCount.value);
|
||||||
|
clickCount.value++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCode() {
|
||||||
|
finallyText.value = "";
|
||||||
|
const mainCode = ref("");
|
||||||
|
const colorDetectClass = ref("");
|
||||||
|
const QRDetectClass = ref("");
|
||||||
|
const colorDetectCode = ref("");
|
||||||
|
const QRDetectCode = ref("");
|
||||||
|
const isUsedPlatform = listOfTrips.value.some(item => item.isPlatform);
|
||||||
|
const isUsedQR = listOfTrips.value.some(item => item.isQR);
|
||||||
|
|
||||||
|
if (!listOfTrips.value.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isUsedPlatform) {
|
||||||
|
let textOfCode = color
|
||||||
|
textOfCode = textOfCode.split('<<separator>>')
|
||||||
|
colorDetectClass.value = textOfCode[0]
|
||||||
|
colorDetectCode.value += ` ${textOfCode[1].trim()}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUsedQR) {
|
||||||
|
let textOfCode = qr
|
||||||
|
textOfCode = textOfCode.split('<<separator>>')
|
||||||
|
QRDetectClass.value = textOfCode[0]
|
||||||
|
QRDetectCode.value += ` ${textOfCode[1].trim()}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of listOfTrips.value) {
|
||||||
|
mainCode.value += ` navigate_wait(${item.x}, ${item.y})\n`;
|
||||||
|
if (item.isPlatform) {
|
||||||
|
mainCode.value += colorDetectCode.value + '\n'
|
||||||
|
}
|
||||||
|
if (item.isQR) {
|
||||||
|
mainCode.value += QRDetectCode.value + '\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let textOfCode = emergencyAndNavigate
|
||||||
|
finallyText.value = textOfCode.replace("<<main>>", mainCode.value);
|
||||||
|
if (isUsedPlatform) {
|
||||||
|
finallyText.value = finallyText.value.replace("<<ColorDetect>>", colorDetectClass.value);
|
||||||
|
} else {
|
||||||
|
finallyText.value = finallyText.value.replace("<<ColorDetect>>", '');
|
||||||
|
}
|
||||||
|
if (isUsedQR) {
|
||||||
|
finallyText.value = finallyText.value.replace("<<qr_function>>", QRDetectClass.value);
|
||||||
|
} else {
|
||||||
|
finallyText.value = finallyText.value.replace("<<qr_function>>", '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreview(event) {
|
||||||
|
event.currentTarget.blur()
|
||||||
|
generateCode()
|
||||||
|
if (!listOfTrips.value.length) {
|
||||||
|
izitoast.error({
|
||||||
|
title: 'Ошибка',
|
||||||
|
message: 'Для предпросмотра нужно создать маршрут'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push({ name: 'preview' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPreviewFromModal() {
|
||||||
|
isModalVisible.value = false
|
||||||
|
router.push({ name: 'preview' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveResult(event) {
|
||||||
|
isModalVisible.value = true;
|
||||||
|
event.currentTarget.blur()
|
||||||
|
generateCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(event) {
|
||||||
|
event.currentTarget.blur()
|
||||||
|
if (clickCount.value === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (listOfTrips.value.at(-1).variable.length === 1) {
|
||||||
|
listOfTrips.value.at(-1).BgImg = 'images/ArUco-marker.jpg'
|
||||||
|
}
|
||||||
|
listOfTrips.value.at(-1).variable.pop();
|
||||||
|
listOfTrips.value.pop();
|
||||||
|
clickCount.value--;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanResult(event) {
|
||||||
|
event.currentTarget.blur()
|
||||||
|
clickCount.value = 1;
|
||||||
|
numbers.value.forEach(item => {
|
||||||
|
item.isPlatform = false;
|
||||||
|
item.isQR = false;
|
||||||
|
item.variable = [];
|
||||||
|
item.BgImg = 'images/ArUco-marker.jpg'
|
||||||
|
})
|
||||||
|
listOfTrips.value = []
|
||||||
|
izitoast.info({
|
||||||
|
title: 'Поле очищено',
|
||||||
|
message: 'Поле очищено. Можно строить маршрут заново',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event) => {
|
||||||
|
if (event.ctrlKey && event.code === "KeyZ" && listOfTrips.value.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
cancel();
|
||||||
|
} else if (event.ctrlKey && event.code === "KeyS") {
|
||||||
|
event.preventDefault();
|
||||||
|
saveResult();
|
||||||
|
} else if (event.ctrlKey && event.code === "KeyN") {
|
||||||
|
event.preventDefault();
|
||||||
|
dropMenu.value?.open();
|
||||||
|
} else if (event.code === "Delete") {
|
||||||
|
event.preventDefault();
|
||||||
|
cleanResult();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEventListener("keydown", handleKeydown);
|
||||||
|
|
||||||
|
function copyAndToast(text) {
|
||||||
|
copyToclipboard(text);
|
||||||
|
izitoast.success({
|
||||||
|
title: 'Успешно',
|
||||||
|
message: 'Код скопирован в буфер обмена'
|
||||||
|
})
|
||||||
|
isModalVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDownloadBtn() {
|
||||||
|
const downloadUrl = ref('');
|
||||||
|
const blob = new Blob([finallyText.value], { type: 'text/plain' });
|
||||||
|
downloadUrl.value = URL.createObjectURL(blob);
|
||||||
|
setTimeout(() => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = downloadUrl.value;
|
||||||
|
link.download = 'itinerary.py';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
izitoast.success({
|
||||||
|
title: 'Успешно',
|
||||||
|
message: 'Загрузка файла уже началась'
|
||||||
|
})
|
||||||
|
isModalVisible.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlatform(item, color) {
|
||||||
|
if (!item.variable.length) {
|
||||||
|
item.variable.push(clickCount.value);
|
||||||
|
listOfTrips.value.push(item)
|
||||||
|
clickCount.value++
|
||||||
|
}
|
||||||
|
item.isPlatform = true;
|
||||||
|
item.isQR = false;
|
||||||
|
item.BgImg = `images/${color}-platform.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePlatform(item) {
|
||||||
|
if (item.isPlatform) {
|
||||||
|
item.BgImg = 'images/ArUco-marker.jpg'
|
||||||
|
item.isPlatform = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQR(item) {
|
||||||
|
if (!item.variable.length) {
|
||||||
|
item.variable.push(clickCount.value);
|
||||||
|
listOfTrips.value.push(item)
|
||||||
|
clickCount.value++
|
||||||
|
}
|
||||||
|
item.isPlatform = false;
|
||||||
|
item.isQR = true;
|
||||||
|
item.BgImg = `images/qr.jpg`
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteQR(item) {
|
||||||
|
if (item.isQR) {
|
||||||
|
item.BgImg = 'images/ArUco-marker.jpg'
|
||||||
|
item.isQR = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePointMap(item) {
|
||||||
|
const index = listOfTrips.value.indexOf(item);
|
||||||
|
if (index !== -1) {
|
||||||
|
const deletedIndex = item.variable.at(-1)
|
||||||
|
listOfTrips.value.forEach(square => {
|
||||||
|
square.variable.map(itemVariable => {
|
||||||
|
if (itemVariable === deletedIndex) {
|
||||||
|
square.variable = []
|
||||||
|
}
|
||||||
|
if (itemVariable > deletedIndex) {
|
||||||
|
square.variable[square.variable.indexOf(itemVariable)]--
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
listOfTrips.value.splice(index, 1);
|
||||||
|
clickCount.value--
|
||||||
|
item.isQR = false
|
||||||
|
item.isPlatform = false
|
||||||
|
item.BgImg = 'images/ArUco-marker.jpg'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIcon(src) {
|
||||||
|
return h('img', {
|
||||||
|
src: publicPath(src),
|
||||||
|
style: {
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenu(e, item) {
|
||||||
|
ContextMenu.showContextMenu({
|
||||||
|
theme: 'mac dark',
|
||||||
|
x: e.x,
|
||||||
|
y: e.y,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: "Платформа",
|
||||||
|
icon: createIcon('icons/base_square.webp'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Белая",
|
||||||
|
onClick: () => createPlatform(item, 'white'),
|
||||||
|
icon: createIcon('icons/white_square.webp'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Синяя",
|
||||||
|
onClick: () => createPlatform(item, 'blue'),
|
||||||
|
icon: createIcon('icons/blue_square.webp'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Красная",
|
||||||
|
onClick: () => createPlatform(item, 'red'),
|
||||||
|
icon: createIcon('icons/red_square.webp'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Удалить",
|
||||||
|
onClick: () => deletePlatform(item),
|
||||||
|
icon: h(Trash),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "QR код",
|
||||||
|
icon: createIcon('icons/qr_code.png'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "Добавить",
|
||||||
|
onClick: () => createQR(item),
|
||||||
|
icon: createIcon('icons/plus.webp')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Удалить",
|
||||||
|
onClick: () => deleteQR(item),
|
||||||
|
icon: h(Trash)
|
||||||
|
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Удалить",
|
||||||
|
icon: h(Trash),
|
||||||
|
onClick: () => deletePointMap(item),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MyDropMenu ref="dropMenu" />
|
||||||
|
<div class="wraper">
|
||||||
|
<div class="grid">
|
||||||
|
<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(${publicPath(item.BgImg)})` }">
|
||||||
|
{{ numbers[index].variable.join(", ") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<span class="toolbar-item">
|
||||||
|
<Save @click="saveResult" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
Сохранить
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<span class="toolbar-item">
|
||||||
|
<Trash @click="cleanResult" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
Удалить
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<span class="toolbar-item">
|
||||||
|
<Back @click="cancel" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
Отменить
|
||||||
|
</n-tooltip>
|
||||||
|
<n-tooltip trigger="hover">
|
||||||
|
<template #trigger>
|
||||||
|
<span class="toolbar-item">
|
||||||
|
<Preview @click="openPreview" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
Превью
|
||||||
|
</n-tooltip>
|
||||||
|
</div>
|
||||||
|
<MyDialog v-model:show="isModalVisible" class="dialogWindow" :is-success=Boolean(listOfTrips.length)>
|
||||||
|
<template #header>
|
||||||
|
<strong v-if="listOfTrips.length">Код готов!</strong>
|
||||||
|
<strong v-else>Код не готов!</strong>
|
||||||
|
</template>
|
||||||
|
<main>
|
||||||
|
<p v-if="listOfTrips.length">Скопируйте или скачайте ваш код</p>
|
||||||
|
<p v-else>Выстройте маршрут перед сохранением кода.</p>
|
||||||
|
</main>
|
||||||
|
<template #footer>
|
||||||
|
<template v-if="listOfTrips.length">
|
||||||
|
<button @click="openPreviewFromModal">Предпросмотр</button>
|
||||||
|
<button @click="handleDownloadBtn">Скачать код</button>
|
||||||
|
<button @click="copyAndToast(finallyText)">Скопировать код</button>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</MyDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--side: 8vh;
|
||||||
|
--sizeActions: 5vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wraper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px 0 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
padding: 3px;
|
||||||
|
grid-template-columns: repeat(10, var(--side));
|
||||||
|
grid-template-rows: repeat(10, var(--side));
|
||||||
|
gap: 5px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
background-size: cover;
|
||||||
|
border: 3px solid #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow-y: scroll;
|
||||||
|
|
||||||
|
background-blend-mode: multiply;
|
||||||
|
background-color: #ffffff;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
|
||||||
|
color: #2267fce2;
|
||||||
|
text-shadow: -1px -1px 0 #fff,
|
||||||
|
1px -1px 0 #fff,
|
||||||
|
-1px 1px 0 #fff,
|
||||||
|
1px 1px 0 #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: bold
|
||||||
|
}
|
||||||
|
|
||||||
|
.square:hover {
|
||||||
|
background-color: #949494;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square:active {
|
||||||
|
background-color: #747474;;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.square[data-key="90"] {
|
||||||
|
background-color: rgba(0, 2, 0, 0.638);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
|
||||||
|
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-item svg {
|
||||||
|
width: var(--sizeActions);
|
||||||
|
height: var(--sizeActions);
|
||||||
|
transition:
|
||||||
|
transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1),
|
||||||
|
color 0.25s ease;
|
||||||
|
transform-origin: center bottom;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-item:hover svg {
|
||||||
|
transform: scale(1.22) translateY(-6px);
|
||||||
|
color: rgba(20, 104, 140, 0.89);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-item:active svg {
|
||||||
|
transition:
|
||||||
|
transform 0.12s ease,
|
||||||
|
color 0s;
|
||||||
|
transform: scale(1.08) translateY(-2px);
|
||||||
|
color: #23aae3e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
path {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button {
|
||||||
|
padding: 20px 35px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import CodeView from '@/components/CodeView.vue'
|
||||||
|
import { useFlightCode } from '@/composables/useFlightCode'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { finallyText } = useFlightCode()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!finallyText.value.trim()) {
|
||||||
|
router.replace({ name: 'planner' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push({ name: 'planner' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CodeView v-if="finallyText" :code="finallyText" @back="goBack" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch, provide, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { resolveTopic } from '@/composables/useTopicResolver'
|
||||||
|
import MyDropMenu from "@/components/MyDropMenu.vue";
|
||||||
|
import CustomButton from '@/components/UI/CustomButton.vue'
|
||||||
|
import { publicPath } from '@/shared/publicPath'
|
||||||
|
|
||||||
|
const APP_LOGO = 'images/drone.png'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const topic = ref(null)
|
||||||
|
const dropMenu = ref(null);
|
||||||
|
provide('dropMenu', dropMenu);
|
||||||
|
|
||||||
|
const topicImage = computed(() => publicPath(topic.value?.image || APP_LOGO))
|
||||||
|
|
||||||
|
async function loadTopic() {
|
||||||
|
topic.value = await resolveTopic(route.params.source, route.params.id)
|
||||||
|
|
||||||
|
if (!topic.value) {
|
||||||
|
router.replace({ name: 'planner' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.params.source, route.params.id],
|
||||||
|
loadTopic,
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.push({ name: 'planner' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MyDropMenu ref="dropMenu" />
|
||||||
|
<div v-if="topic" class="topic-view scroll-strip">
|
||||||
|
<article class="topic-view__card">
|
||||||
|
<button class="topic-view__back" @click="goBack">
|
||||||
|
← Назад
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="topic-view__image">
|
||||||
|
<img :src="topicImage" :alt="topic.title" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topic-view__header">
|
||||||
|
<h1 class="topic-view__title">{{ topic.title }}</h1>
|
||||||
|
<p v-if="topic.subtitle" class="topic-view__subtitle">{{ topic.subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="topic-view__description">{{ topic.description }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="topic.hrefToCourse || topic.hrefToDoc"
|
||||||
|
class="topic-view__links"
|
||||||
|
>
|
||||||
|
<CustomButton
|
||||||
|
v-if="topic.hrefToCourse"
|
||||||
|
:to="topic.hrefToCourse"
|
||||||
|
title="Посмотреть на Stepik"
|
||||||
|
>
|
||||||
|
Курс
|
||||||
|
</CustomButton>
|
||||||
|
<CustomButton
|
||||||
|
v-if="topic.hrefToDoc"
|
||||||
|
:to="topic.hrefToDoc"
|
||||||
|
title="Посмотреть в официальной документации"
|
||||||
|
>
|
||||||
|
Документация
|
||||||
|
</CustomButton>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.topic-view {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 40px 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__back {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__back:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__card {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 30px;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__image {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__subtitle {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.65);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__description {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topic-view__links {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 25px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue';
|
|||||||
|
|
||||||
// https://vitejs.dev/config
|
// https://vitejs.dev/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||