18 Commits

Author SHA1 Message Date
Ilia Miheev 4140502615 Мелкие фиксы стилей 2026-06-04 00:31:59 +03:00
Ilia Miheev b1fdb48609 Добавлены новые изображения для запчастей дронов и терминов, обновлены пути к изображениям в файлах данных 2026-06-03 22:17:44 +03:00
Ilia Miheev 7a37170b0a Обновил боковое меню для динамического отображения групп 2026-06-03 20:17:35 +03:00
IliaMiheev c2d8bbad8d Добавил боковое меню на станицу с запчастями 2026-06-03 10:43:38 +03:00
IliaMiheev b97f82f3a0 Перенёс боковое меню на главную страницу
Чтоб меню нельзя было вызвать на других страницах
2026-06-03 10:12:50 +03:00
Ilia Miheev eb4bf099f3 Конфиг Electron (maximize) 2026-06-02 23:50:06 +03:00
Ilia Miheev 25a0014d21 Убрал прошлый функционал Запчастей 2026-06-02 23:32:44 +03:00
Ilia Miheev 5272c1ddf7 Вынес Запчасти в боковое меню 2026-06-02 22:59:06 +03:00
Ilia Miheev 9db8220fdb Вынес термины в выпадающее меню 2026-06-02 22:29:49 +03:00
Ilia Miheev 817ccd3b23 Вынес стили для красивой скролл ленты + вынес компонент карточку в отдельный компонент 2026-06-02 22:09:54 +03:00
Ilia Miheev 41febc9b4b Вынес превью на отдельную страницу 2026-06-02 20:28:15 +03:00
alisagerasimova fd7408016f добавила ещё несколько компонентов 2026-06-01 13:04:53 +03:00
alisagerasimova 6eec8f097a добавила панельку с компонентами дрона 2026-05-31 19:40:16 +03:00
IliaMiheev b6597f235e Исправили проблему с табуляццией 2026-05-28 09:57:24 +03:00
Ilia Miheev e213475de5 Фиксы ошибок
Увеличил шрифт
Сделал иконку удаления
Убрал обводку после нажатия у кнопок на панели инструментов
2026-03-31 18:16:15 +03:00
alisagerasimova dc31ec1cb6 добавили тултипы 2026-03-30 14:42:09 +03:00
Alisa Gerasimova e24fa92a84 Merge pull request #17 from IliaMiheev/develop
Develop
2026-03-30 13:22:09 +03:00
Ilia Miheev d1cc7875a8 Merge pull request #5 from IliaMiheev/develop
Develop
2026-03-09 19:14:27 +03:00
47 changed files with 1603 additions and 608 deletions
+363 -16
View File
@@ -13,9 +13,11 @@
"@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"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.11.1", "@electron-forge/cli": "^7.11.1",
@@ -29,6 +31,7 @@
"@electron/fuses": "^1.8.0", "@electron/fuses": "^1.8.0",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"electron": "40.1.0", "electron": "40.1.0",
"naive-ui": "^2.44.1",
"vite": "^5.4.21" "vite": "^5.4.21"
} }
}, },
@@ -78,6 +81,35 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@css-render/plugin-bem": {
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@css-render/plugin-bem/-/plugin-bem-0.15.14.tgz",
"integrity": "sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"css-render": "~0.15.14"
}
},
"node_modules/@css-render/vue3-ssr": {
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@css-render/vue3-ssr/-/vue3-ssr-0.15.14.tgz",
"integrity": "sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"vue": "^3.0.11"
}
},
"node_modules/@ctrl/tinycolor": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz",
"integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/@electron-forge/cli": { "node_modules/@electron-forge/cli": {
"version": "7.11.1", "version": "7.11.1",
"resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.11.1.tgz", "resolved": "https://registry.npmjs.org/@electron-forge/cli/-/cli-7.11.1.tgz",
@@ -1009,6 +1041,22 @@
"node": ">=14.14" "node": ">=14.14"
} }
}, },
"node_modules/@element-plus/icons-vue": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
"integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
"license": "MIT",
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
"integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@@ -1400,6 +1448,31 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@@ -1908,6 +1981,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@juggle/resize-observer": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@listr2/prompt-adapter-inquirer": { "node_modules/@listr2/prompt-adapter-inquirer": {
"version": "2.0.22", "version": "2.0.22",
"resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.22.tgz",
@@ -2027,6 +2107,17 @@
"node": "^12.13.0 || ^14.15.0 || >=16.0.0" "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
} }
}, },
"node_modules/@popperjs/core": {
"name": "@sxzz/popperjs-es",
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz",
"integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.2", "version": "1.0.0-rc.2",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
@@ -2497,6 +2588,21 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/lodash": {
"version": "4.17.24",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz",
"integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/mute-stream": { "node_modules/@types/mute-stream": {
"version": "0.0.4", "version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz",
@@ -2625,6 +2731,12 @@
"@vue/shared": "3.5.28" "@vue/shared": "3.5.28"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.28", "version": "3.5.28",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz",
@@ -2676,14 +2788,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
"version": "14.2.1", "version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz",
"integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/web-bluetooth": "^0.0.21", "@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.2.1", "@vueuse/metadata": "14.3.0",
"@vueuse/shared": "14.2.1" "@vueuse/shared": "14.3.0"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@@ -2693,18 +2805,18 @@
} }
}, },
"node_modules/@vueuse/metadata": { "node_modules/@vueuse/metadata": {
"version": "14.2.1", "version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz",
"integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
} }
}, },
"node_modules/@vueuse/shared": { "node_modules/@vueuse/shared": {
"version": "14.2.1", "version": "14.3.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz",
"integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@@ -3064,6 +3176,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz",
"integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
"license": "MIT"
},
"node_modules/at-least-node": { "node_modules/at-least-node": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
@@ -3704,12 +3822,57 @@
"node": ">=12.10" "node": ">=12.10"
} }
}, },
"node_modules/css-render": {
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz",
"integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@emotion/hash": "~0.8.0",
"csstype": "~3.0.5"
}
},
"node_modules/css-render/node_modules/csstype": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.11.tgz",
"integrity": "sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==",
"dev": true,
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-tz": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz",
"integrity": "sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"date-fns": "^3.0.0 || ^4.0.0"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -4493,6 +4656,32 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/element-plus": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.14.1.tgz",
"integrity": "sha512-UFnm1+BckNi+azkKJ7L32q1uXs9ekr99Z9pWTQPeDR05jqEWUwQq51ro4kZMVrANbjknX3Z7ukCZwTi2T6Tr9A==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^4.2.0",
"@element-plus/icons-vue": "^2.3.2",
"@floating-ui/dom": "^1.7.6",
"@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.8",
"@types/lodash": "^4.17.24",
"@types/lodash-es": "^4.17.12",
"@vueuse/core": "14.3.0",
"async-validator": "^4.2.5",
"dayjs": "^1.11.20",
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"lodash-unified": "^1.0.3",
"memoize-one": "^6.0.0",
"normalize-wheel-es": "^1.2.0",
"vue-component-type-helpers": "^3.3.1"
},
"peerDependencies": {
"vue": "^3.3.7"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "9.2.2", "version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
@@ -4744,6 +4933,13 @@
"node": ">=0.8.x" "node": ">=0.8.x"
} }
}, },
"node_modules/evtd": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/evtd/-/evtd-0.2.4.tgz",
"integrity": "sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==",
"dev": true,
"license": "MIT"
},
"node_modules/execa": { "node_modules/execa": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz",
@@ -5371,6 +5567,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hosted-git-info": { "node_modules/hosted-git-info": {
"version": "2.8.9", "version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
@@ -5850,12 +6056,28 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash-es": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
"license": "MIT"
},
"node_modules/lodash-unified": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz",
"integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
"license": "MIT",
"peerDependencies": {
"@types/lodash-es": "*",
"lodash": "*",
"lodash-es": "*"
}
},
"node_modules/lodash.get": { "node_modules/lodash.get": {
"version": "4.4.2", "version": "4.4.2",
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
@@ -6000,6 +6222,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/memoize-one": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz",
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge-stream": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -6224,6 +6452,39 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
} }
}, },
"node_modules/naive-ui": {
"version": "2.44.1",
"resolved": "https://registry.npmjs.org/naive-ui/-/naive-ui-2.44.1.tgz",
"integrity": "sha512-reo8Esw0p58liZwbUutC7meW24Xbn3EwNv91zReWKm2W4JPu+zfgJRn/F7aO0BFmvN+h2brA2M5lRvYqLq4kuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@css-render/plugin-bem": "^0.15.14",
"@css-render/vue3-ssr": "^0.15.14",
"@types/lodash": "^4.17.20",
"@types/lodash-es": "^4.17.12",
"async-validator": "^4.2.5",
"css-render": "^0.15.14",
"csstype": "^3.1.3",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"evtd": "^0.2.4",
"highlight.js": "^11.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"seemly": "^0.3.10",
"treemate": "^0.3.11",
"vdirs": "^0.1.8",
"vooks": "^0.2.12",
"vueuc": "^0.4.65"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -6369,6 +6630,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
"integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
"license": "BSD-3-Clause"
},
"node_modules/npm-run-path": { "node_modules/npm-run-path": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz",
@@ -7285,6 +7552,13 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/seemly": {
"version": "0.3.10",
"resolved": "https://registry.npmjs.org/seemly/-/seemly-0.3.10.tgz",
"integrity": "sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/semver": { "node_modules/semver": {
"version": "7.7.3", "version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -7878,6 +8152,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/treemate": {
"version": "0.3.11",
"resolved": "https://registry.npmjs.org/treemate/-/treemate-0.3.11.tgz",
"integrity": "sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==",
"dev": true,
"license": "MIT"
},
"node_modules/trim-repeated": { "node_modules/trim-repeated": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz", "resolved": "https://registry.npmjs.org/trim-repeated/-/trim-repeated-1.0.0.tgz",
@@ -8034,6 +8315,19 @@
"spdx-expression-parse": "^3.0.0" "spdx-expression-parse": "^3.0.0"
} }
}, },
"node_modules/vdirs": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/vdirs/-/vdirs-0.1.8.tgz",
"integrity": "sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==",
"dev": true,
"license": "MIT",
"dependencies": {
"evtd": "^0.2.2"
},
"peerDependencies": {
"vue": "^3.0.11"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -8094,6 +8388,19 @@
} }
} }
}, },
"node_modules/vooks": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/vooks/-/vooks-0.2.12.tgz",
"integrity": "sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"evtd": "^0.2.2"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.28", "version": "3.5.28",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz",
@@ -8115,6 +8422,46 @@
} }
} }
}, },
"node_modules/vue-component-type-helpers": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.3.tgz",
"integrity": "sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g==",
"license": "MIT"
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vueuc": {
"version": "0.4.65",
"resolved": "https://registry.npmjs.org/vueuc/-/vueuc-0.4.65.tgz",
"integrity": "sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@css-render/vue3-ssr": "^0.15.10",
"@juggle/resize-observer": "^3.3.1",
"css-render": "^0.15.10",
"evtd": "^0.2.4",
"seemly": "^0.3.6",
"vdirs": "^0.1.4",
"vooks": "^0.2.4"
},
"peerDependencies": {
"vue": "^3.0.11"
}
},
"node_modules/watchpack": { "node_modules/watchpack": {
"version": "2.5.1", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz",
+4 -1
View File
@@ -27,6 +27,7 @@
"@electron/fuses": "^1.8.0", "@electron/fuses": "^1.8.0",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"electron": "40.1.0", "electron": "40.1.0",
"naive-ui": "^2.44.1",
"vite": "^5.4.21" "vite": "^5.4.21"
}, },
"dependencies": { "dependencies": {
@@ -34,8 +35,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"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+2 -462
View File
@@ -1,371 +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";
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() {
generateCode()
if (!listOfTrips.value.length) {
izitoast.error({
title: 'Ошибка',
message: 'Для предпросмотра нужно создать маршрут'
})
return
}
isCodeViewVisible.value = true;
}
function saveResult() {
isModalVisible.value = true;
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() {
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() {
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: createIcon('icons/trash.svg'),
},
]
},
{
label: "QR код",
icon: createIcon('icons/qr_code.png'),
children: [
{
label: "Добавить",
onClick: () => createQR(item),
icon: createIcon('icons/plus.webp')
},
{
label: "Удалить",
onClick: () => deleteQR(item),
icon: createIcon('icons/trash.svg')
},
],
},
{
label: "Удалить",
icon: createIcon('icons/trash.svg'),
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">
<Save @click="saveResult"/>
<Trash @click="cleanResult"/>
<Back @click="cancel"/>
<Preview @click="openPreview"/>
</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>
@@ -375,104 +9,10 @@ function onContextMenu(e, item) {
html { html {
background-image: radial-gradient(#23aae3e2, #222222); background-image: radial-gradient(#23aae3e2, #222222);
font-size: 18px;
} }
body { body {
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>
+11 -14
View File
@@ -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>
+92 -66
View File
@@ -1,67 +1,78 @@
<template> <template>
<div class="navigation-wrapper"> <div class="navigation-wrapper">
<!-- Кнопка Бургер -->
<button class="burger-btn" @click="toggleMenu" :class="{ 'is-active': isOpen }" 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"
>
<el-sub-menu
v-for="group in menuGroups"
:key="group.source"
:index="group.source"
> >
{{ item.title }} <template #title>
</custom-button> <span class="menu-content__group-title">{{ group.name }}</span>
</nav> </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 route = useRoute()
const router = useRouter()
const isOpen = ref(false); const isOpen = ref(false);
const isModalVisible = ref(false); const menuGroups = ref([])
const infoCard = ref({})
function handleOkBtn() { function isTopicActive(source, id) {
isModalVisible.value = false return route.name === 'topic'
isOpen.value = true && 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,8 +91,11 @@ const handleKeydown = (event) => {
}; };
useEventListener("keydown", handleKeydown); useEventListener("keydown", handleKeydown);
// Экспортируем методы, если захотим управлять меню извне onMounted(() => {
defineExpose({open: () => (isOpen.value = true), close: closeMenu}); menuGroups.value = getMenuGroups()
})
defineExpose({ open: () => (isOpen.value = true), close: closeMenu });
</script> </script>
<style scoped> <style scoped>
@@ -112,20 +120,52 @@ h1 {
} }
.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-list-item.is-active {
background-color: transparent !important;
} }
.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) { .is-active .line:nth-child(1) {
transform: translateY(6px) rotate(45deg); transform: translateY(6px) rotate(45deg);
} }
@@ -138,23 +178,20 @@ h1 {
transform: translateY(-6px) rotate(-45deg); 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 +204,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;
+114
View File
@@ -0,0 +1,114 @@
<script setup>
import { computed } from 'vue'
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(() => 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;
margin: 8px 12px;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.05);
box-sizing: border-box;
}
.list-card:hover {
background: rgba(35, 170, 227, 0.2);
}
.list-card--active {
background: linear-gradient(135deg, #23aae3, #1a6d8f);
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>
+6 -2
View File
@@ -17,6 +17,10 @@
</template> </template>
<style scoped> <style scoped>
.cls-1{fill:none;} .cls-1{
fill:none;
}
#Layer_2 {
color: black;
}
</style> </style>
+7
View File
@@ -0,0 +1,7 @@
import { ref } from 'vue'
const finallyText = ref('')
export function useFlightCode() {
return { finallyText }
}
+16
View File
@@ -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 }
}
+36
View File
@@ -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
}
+20
View File
@@ -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'
+162
View File
@@ -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
@@ -1,74 +1,92 @@
export default [ const rawTerms = [
{ {
title: 'ROS', title: 'ROS',
body: 'ROS (Robot Operating System) — экосистема для программирования роботов. В приложении через ROS дрон получает команды, передает телеметрию и данные с камеры.', body: 'ROS (Robot Operating System) — экосистема для программирования роботов. В приложении через ROS дрон получает команды, передает телеметрию и данные с камеры.',
image: '/images/terms/ROS.webp',
hrefToDoc: 'https://www.ros.org/', hrefToDoc: 'https://www.ros.org/',
hrefToCourse: 'https://stepik.org/lesson/2260739/step/1?unit=2294748' hrefToCourse: 'https://stepik.org/lesson/2260739/step/1?unit=2294748',
}, },
{ {
title: 'OpenCV', title: 'OpenCV',
body: 'OpenCV (Open Source Computer Vision Library) — библиотека компьютерного зрения. С её помощью дрон распознает цвета платформ и считывает QR-коды.', body: 'OpenCV (Open Source Computer Vision Library) — библиотека компьютерного зрения. С её помощью дрон распознает цвета платформ и считывает QR-коды.',
image: '/images/terms/openCV.webp',
hrefToDoc: 'https://opencv.org/', hrefToDoc: 'https://opencv.org/',
hrefToCourse: 'https://stepik.org/lesson/2272700/step/1?unit=2306893' hrefToCourse: 'https://stepik.org/lesson/2272700/step/1?unit=2306893',
}, },
{ {
title: 'navigate_wait', title: 'navigate_wait',
body: 'Navigate_wait — функция из библиотеки Clover. Отправляет дрон в точку и ждёт, пока он туда долетит, и только потом выполняет следующую команду. Это важно, потому что обычная команда navigate не ждет: дрон ещё летит, а скрипт уже бежит дальше — из-за этого следующие команды могут выполняться раньше времени.', body: 'Navigate_wait — функция из библиотеки Clover. Отправляет дрон в точку и ждёт, пока он туда долетит, и только потом выполняет следующую команду. Это важно, потому что обычная команда navigate не ждет: дрон ещё летит, а скрипт уже бежит дальше — из-за этого следующие команды могут выполняться раньше времени.',
hrefToDoc: 'https://clover.coex.tech/ru/snippets.html', hrefToDoc: 'https://clover.coex.tech/ru/snippets.html',
hrefToCourse: 'https://stepik.org/lesson/2264712/step/1?unit=2298803' hrefToCourse: 'https://stepik.org/lesson/2264712/step/1?unit=2298803',
}, },
{ {
title: 'Нода (Node)', title: 'Нода (Node)',
body: 'Нода (Node/узел) в ROS - исполняемая программа, которая выполняет одну конкретную задачу. В сгенерированном коде вся логика полета и обработки камеры находится внутри одной ноды. Это позволяет запускать, останавливать и отлаживать программу как единое целое. ', body: 'Нода (Node/узел) в ROS - исполняемая программа, которая выполняет одну конкретную задачу. В сгенерированном коде вся логика полета и обработки камеры находится внутри одной ноды. Это позволяет запускать, останавливать и отлаживать программу как единое целое. ',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2261765/step/1?unit=2295758' hrefToCourse: 'https://stepik.org/lesson/2261765/step/1?unit=2295758',
}, },
{ {
title: 'Топик (Topic)', title: 'Топик (Topic)',
body: 'Топик (Topic) в ROS — канал обмена данными. Например, камера публикует изображение в топик, а ваш скрипт подписывается на него и получает кадры.', body: 'Топик (Topic) в ROS — канал обмена данными. Например, камера публикует изображение в топик, а ваш скрипт подписывается на него и получает кадры.',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866' hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
}, },
{ {
title: 'Издатель (Publisher)', title: 'Издатель (Publisher)',
body: 'Издатель (Publisher) в ROS — часть программы, которая отправляет данные в топик. Например, если нужно передать команду другому узлу или опубликовать координаты дрона — создается издатель, который публикует сообщения в нужный канал. Подписчики других узлов могут эти данные получать. ', body: 'Издатель (Publisher) в ROS — часть программы, которая отправляет данные в топик. Например, если нужно передать команду другому узлу или опубликовать координаты дрона — создается издатель, который публикует сообщения в нужный канал. Подписчики других узлов могут эти данные получать. ',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866' hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
}, },
{ {
title: 'Подписчик (Subscriber)', title: 'Подписчик (Subscriber)',
body: 'Подписчик (Subscriber) в ROS — часть программы, которая получает данные из топика. Например, чтобы дрон "видел" изображение с камеры или считывал свои координаты, создаётся подписчик на соответствующий топик. Как только в топике появляются новые данные, подписчик автоматически их обрабатывает.', body: 'Подписчик (Subscriber) в ROS — часть программы, которая получает данные из топика. Например, чтобы дрон "видел" изображение с камеры или считывал свои координаты, создаётся подписчик на соответствующий топик. Как только в топике появляются новые данные, подписчик автоматически их обрабатывает.',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866' hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
}, },
{ {
title: 'Сервис', title: 'Сервис',
body: 'Сервис в ROS - способ вызвать функцию робота и сразу получить ответ. В отличие от топика, где данные идут постоянно, сервис работает по принципу "запрос — ответ". Например, команда navigate — это сервис: вы отправляете координаты, а робот отвечает "команда принята". Команда get_telemetry возвращает текущие координаты дрона.', body: 'Сервис в ROS - способ вызвать функцию робота и сразу получить ответ. В отличие от топика, где данные идут постоянно, сервис работает по принципу "запрос — ответ". Например, команда navigate — это сервис: вы отправляете координаты, а робот отвечает "команда принята". Команда get_telemetry возвращает текущие координаты дрона.',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2263182/step/1?unit=2297206' hrefToCourse: 'https://stepik.org/lesson/2263182/step/1?unit=2297206',
}, },
{ {
title: 'Системы координат', title: 'Системы координат',
body: 'Определяют, откуда отсчитывать координаты дрона. body — относительно самого дрона (вперед/назад/влево/вправо). aruco_map — относительно карты с ArUco-метками, где координаты привязаны к неподвижным маркерам. Выбор правильной системы координат позволяет дрону точно выполнять команды: лететь относительно себя для коротких маневров или по глобальной карте для точной навигации.', body: 'Определяют, откуда отсчитывать координаты дрона. body — относительно самого дрона (вперед/назад/влево/вправо). aruco_map — относительно карты с ArUco-метками, где координаты привязаны к неподвижным маркерам. Выбор правильной системы координат позволяет дрону точно выполнять команды: лететь относительно себя для коротких маневров или по глобальной карте для точной навигации.',
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407', hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
hrefToDoc: 'https://clover.coex.tech/ru/frames.html' hrefToDoc: 'https://clover.coex.tech/ru/frames.html',
}, },
{ {
title: 'ArUco-маркер', title: 'ArUco-маркер',
body: 'ArUco-маркер — квадратный визуальный маркер, который камера дрона может распознавать. Используется для навигации: по положению маркера дрон определяет свои координаты в пространстве.', body: 'ArUco-маркер — квадратный визуальный маркер, который камера дрона может распознавать. Используется для навигации: по положению маркера дрон определяет свои координаты в пространстве.',
image: '/images/terms/ArUco-marker.webp',
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407', hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/aruco.html' hrefToDoc: 'https://klever-doc.tech/ROS1/ru/aruco.html',
}, },
{ {
title: 'Симулятор (Gazebo)', title: 'Симулятор (Gazebo)',
body: 'Симулятор (Gazebo) — виртуальная среда, где дрон ведет себя как в реальности. Позволяет безопасно тестировать полетные задания перед запуском на реальном дроне.', body: 'Симулятор (Gazebo) — виртуальная среда, где дрон ведет себя как в реальности. Позволяет безопасно тестировать полетные задания перед запуском на реальном дроне.',
hrefToCourse: 'https://stepik.org/lesson/2278310/step/1?unit=2312564', hrefToCourse: 'https://stepik.org/lesson/2278310/step/1?unit=2312564',
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/simulation.html' hrefToDoc: 'https://klever-doc.tech/ROS1/ru/simulation.html',
}, },
{ {
title: 'unregister()', title: 'unregister()',
body: 'unregister() — метод, который отключает подписчика от топика. Используется, чтобы перестать получать данные, когда они больше не нужны (например, после сканирования QR-кода).', body: 'unregister() — метод, который отключает подписчика от топика. Используется, чтобы перестать получать данные, когда они больше не нужны (например, после сканирования QR-кода).',
hrefToCourse: 'https://stepik.org/lesson/2272845/step/1?unit=2307058', 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' hrefToDoc: 'https://docs.ros.org/en/diamondback/api/rospy/html/rospy.topics.Subscriber-class.html',
}, },
] ]
function toSubtitle(text) {
if (!text) return ''
return text.length > 72 ? `${text.slice(0, 72)}` : text
}
export default rawTerms.map((item, index) => ({
id: index + 1,
title: item.title,
subtitle: toSubtitle(item.body),
image: item.image ?? '',
description: item.body,
hrefToDoc: item.hrefToDoc ?? null,
hrefToCourse: item.hrefToCourse ?? null,
}))
+2 -1
View File
@@ -20,7 +20,8 @@ const createWindow = () => {
preload: path.join(__dirname, 'preload.js'), preload: path.join(__dirname, 'preload.js'),
}, },
}); });
mainWindow.menuBarVisible = false; mainWindow.setMenuBarVisibility(false);
mainWindow.maximize();
// 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) {
+12 -30
View File
@@ -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')
+25
View File
@@ -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
+2 -2
View File
@@ -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)
+30
View File
@@ -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);
}
+490
View File
@@ -0,0 +1,490 @@
<script setup>
import { ref, onMounted, 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'
const router = useRouter()
const { finallyText } = useFlightCode()
const numbers = ref([]);
const clickCount = ref(1);
const listOfTrips = ref([]);
const isModalVisible = ref(false);
const dropMenu = ref(null);
provide('dropMenu', dropMenu);
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 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: 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(${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>
<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="openPreviewFromModal">Предпросмотр</button>
<button @click="handleDownloadBtn">Скачать код</button>
<button @click="copyAndToast(finallyText)">Скопировать код</button>
</div>
</template>
</MyDialog>
</div>
</template>
<style>
: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;
}
</style>
+23
View File
@@ -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>
+154
View File
@@ -0,0 +1,154 @@
<script setup>
import { ref, watch, provide } 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'
const route = useRoute()
const router = useRouter()
const topic = ref(null)
const dropMenu = ref(null);
provide('dropMenu', dropMenu);
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="topic.image" :alt="topic.title" />
</div>
<div class="topic-view__header">
<p v-if="topic.subtitle" class="topic-view__subtitle">{{ topic.subtitle }}</p>
<h1 class="topic-view__title">{{ topic.title }}</h1>
</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>