feat: подготовил дизайн (изменения из другого репозитория)
🚀 Create and publish a Docker image / Detect changes in backend and frontend (push) Successful in 5s
🚀 Create and publish a Docker image / Build & publish backend image (push) Successful in 8s
🚀 Create and publish a Docker image / Update stack on Portainer (push) Successful in 3s

This commit is contained in:
2026-05-08 01:06:22 +03:00
parent 655ab1b5c5
commit 047611fd24
54 changed files with 4497 additions and 28 deletions
+2 -1
View File
@@ -16,7 +16,8 @@
},
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.32"
"vue": "^3.5.32",
"vue-router": "^5.0.6"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
+219 -20
View File
@@ -14,6 +14,9 @@ importers:
vue:
specifier: ^3.5.32
version: 3.5.34(typescript@6.0.3)
vue-router:
specifier: ^5.0.6
version: 5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))
devDependencies:
'@tsconfig/node24':
specifier: ^24.0.4
@@ -23,7 +26,7 @@ importers:
version: 24.12.2
'@vitejs/plugin-vue':
specifier: ^6.0.6
version: 6.0.6(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0))(vue@3.5.34(typescript@6.0.3))
version: 6.0.6(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3))
'@vue/eslint-config-typescript':
specifier: ^14.7.0
version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.59.2(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.3.0(jiti@2.7.0))(vue-eslint-parser@10.4.0(eslint@10.3.0(jiti@2.7.0))))(eslint@10.3.0(jiti@2.7.0))(typescript@6.0.3)
@@ -59,10 +62,10 @@ importers:
version: 6.0.3
vite:
specifier: ^8.0.8
version: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)
version: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)
vite-plugin-vue-devtools:
specifier: ^8.1.1
version: 8.1.1(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0))(vue@3.5.34(typescript@6.0.3))
version: 8.1.1(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3))
vue-tsc:
specifier: ^3.2.6
version: 3.2.8(typescript@6.0.3)
@@ -624,6 +627,15 @@ packages:
'@volar/typescript@2.4.28':
resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==}
'@vue-macros/common@3.1.2':
resolution: {integrity: sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==}
engines: {node: '>=20.19.0'}
peerDependencies:
vue: ^2.7.0 || ^3.2.25
peerDependenciesMeta:
vue:
optional: true
'@vue/babel-helper-vue-transform-on@1.5.0':
resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==}
@@ -655,6 +667,9 @@ packages:
'@vue/devtools-api@7.7.9':
resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==}
'@vue/devtools-api@8.1.1':
resolution: {integrity: sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==}
'@vue/devtools-core@8.1.1':
resolution: {integrity: sha512-bCCsSABp1/ot4j8xJEycM6Mtt2wbuucfByr6hMgjbYhrtlscOJypZKvy8f1FyWLYrLTchB5Qz216Lm92wfbq0A==}
peerDependencies:
@@ -738,6 +753,14 @@ packages:
resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==}
engines: {node: '>=14'}
ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'}
ast-walker-scope@0.8.3:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'}
balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
@@ -773,6 +796,16 @@ packages:
caniuse-lite@1.0.30001792:
resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -908,6 +941,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1138,6 +1174,10 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1145,6 +1185,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
magic-string-ast@1.0.3:
resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==}
engines: {node: '>=20.19.0'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -1167,6 +1211,9 @@ packages:
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -1274,6 +1321,12 @@ packages:
typescript:
optional: true
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
@@ -1295,6 +1348,9 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1302,6 +1358,10 @@ packages:
resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==}
engines: {node: ^18.17.0 || >=20.5.0}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -1321,6 +1381,9 @@ packages:
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -1395,6 +1458,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.4:
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
@@ -1402,6 +1468,10 @@ packages:
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
engines: {node: '>=20.19.0'}
unplugin@3.0.0:
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
engines: {node: ^20.19.0 || >=22.12.0}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -1497,6 +1567,21 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
vue-router@5.0.6:
resolution: {integrity: sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==}
peerDependencies:
'@pinia/colada': '>=0.21.2'
'@vue/compiler-sfc': ^3.5.17
pinia: ^3.0.4
vue: ^3.5.0
peerDependenciesMeta:
'@pinia/colada':
optional: true
'@vue/compiler-sfc':
optional: true
pinia:
optional: true
vue-tsc@3.2.8:
resolution: {integrity: sha512-27vTLJ6Q2370obOd0PFYoYoKnmXJ521uUIedrs3Zhhhg/8YG10VOCMmwt+JQslatpAMTDbnWiitLnoD5VlIvog==}
hasBin: true
@@ -1511,6 +1596,9 @@ packages:
typescript:
optional: true
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -1536,6 +1624,11 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yaml@2.8.4:
resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==}
engines: {node: '>= 14.6'}
hasBin: true
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2055,10 +2148,10 @@ snapshots:
'@typescript-eslint/types': 8.59.2
eslint-visitor-keys: 5.0.1
'@vitejs/plugin-vue@6.0.6(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0))(vue@3.5.34(typescript@6.0.3))':
'@vitejs/plugin-vue@6.0.6(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)
vue: 3.5.34(typescript@6.0.3)
'@volar/language-core@2.4.28':
@@ -2073,6 +2166,16 @@ snapshots:
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue-macros/common@3.1.2(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@vue/compiler-sfc': 3.5.34
ast-kit: 2.2.0
local-pkg: 1.1.2
magic-string-ast: 1.0.3
unplugin-utils: 0.3.1
optionalDependencies:
vue: 3.5.34(typescript@6.0.3)
'@vue/babel-helper-vue-transform-on@1.5.0': {}
'@vue/babel-plugin-jsx@1.5.0(@babel/core@7.29.0)':
@@ -2136,6 +2239,10 @@ snapshots:
dependencies:
'@vue/devtools-kit': 7.7.9
'@vue/devtools-api@8.1.1':
dependencies:
'@vue/devtools-kit': 8.1.1
'@vue/devtools-core@8.1.1(vue@3.5.34(typescript@6.0.3))':
dependencies:
'@vue/devtools-kit': 8.1.1
@@ -2236,6 +2343,16 @@ snapshots:
ansis@4.2.0: {}
ast-kit@2.2.0:
dependencies:
'@babel/parser': 7.29.3
pathe: 2.0.3
ast-walker-scope@0.8.3:
dependencies:
'@babel/parser': 7.29.3
ast-kit: 2.2.0
balanced-match@4.0.4: {}
baseline-browser-mapping@2.10.27: {}
@@ -2266,6 +2383,14 @@ snapshots:
caniuse-lite@1.0.30001792: {}
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
confbox@0.1.8: {}
confbox@0.2.4: {}
convert-source-map@2.0.0: {}
copy-anything@4.0.5:
@@ -2399,6 +2524,8 @@ snapshots:
esutils@2.0.3: {}
exsolve@1.0.8: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -2564,6 +2691,12 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
local-pkg@1.1.2:
dependencies:
mlly: 1.8.2
pkg-types: 2.3.1
quansync: 0.2.11
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -2572,6 +2705,10 @@ snapshots:
dependencies:
yallist: 3.1.1
magic-string-ast@1.0.3:
dependencies:
magic-string: 0.30.21
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -2591,6 +2728,13 @@ snapshots:
mitt@3.0.1: {}
mlly@1.8.2:
dependencies:
acorn: 8.16.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.4
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -2695,6 +2839,18 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.2
pathe: 2.0.3
pkg-types@2.3.1:
dependencies:
confbox: 0.2.4
exsolve: 1.0.8
pathe: 2.0.3
postcss-selector-parser@7.1.1:
dependencies:
cssesc: 3.0.0
@@ -2712,6 +2868,8 @@ snapshots:
punycode@2.3.1: {}
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
read-package-json-fast@4.0.0:
@@ -2719,6 +2877,8 @@ snapshots:
json-parse-even-better-errors: 4.0.0
npm-normalize-package-bin: 4.0.0
readdirp@5.0.0: {}
reusify@1.1.0: {}
rfdc@1.4.1: {}
@@ -2750,6 +2910,8 @@ snapshots:
dependencies:
queue-microtask: 1.2.3
scule@1.3.0: {}
semver@6.3.1: {}
semver@7.7.4: {}
@@ -2811,6 +2973,8 @@ snapshots:
typescript@6.0.3: {}
ufo@1.6.4: {}
undici-types@7.16.0: {}
unplugin-utils@0.3.1:
@@ -2818,6 +2982,12 @@ snapshots:
pathe: 2.0.3
picomatch: 4.0.4
unplugin@3.0.0:
dependencies:
'@jridgewell/remapping': 2.3.5
picomatch: 4.0.4
webpack-virtual-modules: 0.6.2
update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies:
browserslist: 4.28.2
@@ -2830,17 +3000,17 @@ snapshots:
util-deprecate@1.0.2: {}
vite-dev-rpc@1.1.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)):
vite-dev-rpc@1.1.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)):
dependencies:
birpc: 2.9.0
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)
vite-hot-client: 2.2.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0))
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)
vite-hot-client: 2.2.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))
vite-hot-client@2.2.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)):
vite-hot-client@2.2.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)):
dependencies:
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)
vite-plugin-inspect@11.3.3(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)):
vite-plugin-inspect@11.3.3(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)):
dependencies:
ansis: 4.2.0
debug: 4.4.3
@@ -2850,26 +3020,26 @@ snapshots:
perfect-debounce: 2.1.0
sirv: 3.0.2
unplugin-utils: 0.3.1
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)
vite-dev-rpc: 1.1.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0))
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)
vite-dev-rpc: 1.1.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))
transitivePeerDependencies:
- supports-color
vite-plugin-vue-devtools@8.1.1(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0))(vue@3.5.34(typescript@6.0.3)):
vite-plugin-vue-devtools@8.1.1(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))(vue@3.5.34(typescript@6.0.3)):
dependencies:
'@vue/devtools-core': 8.1.1(vue@3.5.34(typescript@6.0.3))
'@vue/devtools-kit': 8.1.1
'@vue/devtools-shared': 8.1.1
sirv: 3.0.2
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)
vite-plugin-inspect: 11.3.3(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0))
vite-plugin-vue-inspector: 5.4.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0))
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)
vite-plugin-inspect: 11.3.3(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))
vite-plugin-vue-inspector: 5.4.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4))
transitivePeerDependencies:
- '@nuxt/kit'
- supports-color
- vue
vite-plugin-vue-inspector@5.4.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)):
vite-plugin-vue-inspector@5.4.0(vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)):
dependencies:
'@babel/core': 7.29.0
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
@@ -2880,11 +3050,11 @@ snapshots:
'@vue/compiler-dom': 3.5.34
kolorist: 1.8.0
magic-string: 0.30.21
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)
vite: 8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4)
transitivePeerDependencies:
- supports-color
vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0):
vite@8.0.10(@types/node@24.12.2)(jiti@2.7.0)(yaml@2.8.4):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -2895,6 +3065,7 @@ snapshots:
'@types/node': 24.12.2
fsevents: 2.3.3
jiti: 2.7.0
yaml: 2.8.4
vscode-uri@3.1.0: {}
@@ -2910,6 +3081,30 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-router@5.0.6(@vue/compiler-sfc@3.5.34)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)):
dependencies:
'@babel/generator': 7.29.1
'@vue-macros/common': 3.1.2(vue@3.5.34(typescript@6.0.3))
'@vue/devtools-api': 8.1.1
ast-walker-scope: 0.8.3
chokidar: 5.0.0
json5: 2.2.3
local-pkg: 1.1.2
magic-string: 0.30.21
mlly: 1.8.2
muggle-string: 0.4.1
pathe: 2.0.3
picomatch: 4.0.4
scule: 1.3.0
tinyglobby: 0.2.16
unplugin: 3.0.0
unplugin-utils: 0.3.1
vue: 3.5.34(typescript@6.0.3)
yaml: 2.8.4
optionalDependencies:
'@vue/compiler-sfc': 3.5.34
pinia: 3.0.4(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))
vue-tsc@3.2.8(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28
@@ -2926,6 +3121,8 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
webpack-virtual-modules@0.6.2: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -2944,4 +3141,6 @@ snapshots:
yallist@3.1.1: {}
yaml@2.8.4: {}
yocto-queue@0.1.0: {}
+92 -7
View File
@@ -1,11 +1,96 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppTopbar from '@/components/layout/AppTopbar.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
import AppBottomNav from '@/components/layout/AppBottomNav.vue'
import ToastNotification from '@/components/ui/ToastNotification.vue'
const auth = useAuthStore()
const route = useRoute()
const isAuthPage = computed(() => route.path === '/login')
interface Toast { id: number; message: string; type: 'success' | 'error' | 'info' }
const toasts = ref<Toast[]>([])
let toastId = 0
function addToast(msg: string, type: Toast['type'] = 'success') {
toasts.value.push({ id: ++toastId, message: msg, type })
}
function removeToast(id: number) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
// expose globally via provide
import { provide } from 'vue'
provide('addToast', addToast)
</script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<div class="app-root">
<!-- Auth page: no layout -->
<template v-if="isAuthPage">
<RouterView />
</template>
<style scoped></style>
<!-- Main layout -->
<template v-else-if="auth.isAuthenticated">
<AppTopbar />
<AppSidebar />
<main class="main-content">
<RouterView />
</main>
<AppBottomNav />
</template>
<!-- Redirect to login if not authenticated -->
<template v-else>
<RouterView />
</template>
<!-- Toast container -->
<div class="toast-container">
<ToastNotification
v-for="t in toasts"
:key="t.id"
:message="t.message"
:type="t.type"
@close="removeToast(t.id)"
/>
</div>
</div>
</template>
<style scoped>
.app-root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main-content {
margin-top: var(--topbar-height);
margin-left: var(--sidebar-width);
min-height: calc(100vh - var(--topbar-height));
padding: 28px 28px 80px;
}
.toast-container {
position: fixed;
bottom: 80px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 300;
pointer-events: none;
}
@media (max-width: 768px) {
.main-content {
margin-left: 0;
padding: 16px 16px 80px;
}
}
</style>
+86
View File
@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+323
View File
@@ -0,0 +1,323 @@
/* UniVerse Aero Green Design System */
:root {
--color-primary: #22C55E;
--color-primary-dark: #16A34A;
--color-primary-light: #86EFAC;
--color-aqua: #06B6D4;
--color-sky: #7DD3FC;
--color-white-glass: rgba(255, 255, 255, 0.75);
--color-surface: rgba(255, 255, 255, 0.85);
--color-border-glass: rgba(255, 255, 255, 0.8);
--color-text: #1E293B;
--color-text-secondary: #64748B;
--color-bg-start: #E0F2FE;
--color-bg-mid: #DCFCE7;
--color-error: #EF4444;
--color-success: #22C55E;
--color-warning: #F59E0B;
--gradient-bg: linear-gradient(135deg, #E0F2FE 0%, #DCFCE7 50%, #E0F2FE 100%);
--gradient-brand: linear-gradient(135deg, #22C55E 0%, #06B6D4 60%, #7DD3FC 100%);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.9);
--shadow-card: 0 4px 16px rgba(0, 0, 0, 0.06);
--sidebar-width: 240px;
--topbar-height: 60px;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-size: 14px;
color: var(--color-text);
-webkit-font-smoothing: antialiased;
}
body {
background: var(--gradient-bg);
background-attachment: fixed;
min-height: 100vh;
}
a {
color: var(--color-primary-dark);
text-decoration: none;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
outline: none;
}
input, textarea, select {
font-family: inherit;
outline: none;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: rgba(0,0,0,0.04); border-radius: 3px; }
::-webkit-scrollbar-thumb { background: rgba(34,197,94,0.3); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(34,197,94,0.5); }
/* Glass panel utility */
.glass-panel {
background: var(--color-white-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-md);
box-shadow: var(--shadow-glass);
}
/* Primary glossy button */
.btn-primary {
background: linear-gradient(180deg, #4ADE80 0%, #22C55E 50%, #16A34A 100%);
border: 1px solid #15803D;
border-radius: var(--radius-sm);
color: white;
padding: 10px 20px;
font-weight: 600;
font-size: 14px;
position: relative;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: linear-gradient(180deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 100%);
border-radius: 8px 8px 0 0;
pointer-events: none;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(34,197,94,0.4);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
/* Secondary button */
.btn-secondary {
background: rgba(255,255,255,0.7);
border: 1px solid rgba(34,197,94,0.4);
border-radius: var(--radius-sm);
color: var(--color-primary-dark);
padding: 10px 20px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
backdrop-filter: blur(8px);
}
.btn-secondary:hover {
background: rgba(34,197,94,0.1);
border-color: var(--color-primary);
}
/* Danger button */
.btn-danger {
background: linear-gradient(180deg, #FCA5A5 0%, #EF4444 50%, #DC2626 100%);
border: 1px solid #B91C1C;
border-radius: var(--radius-sm);
color: white;
padding: 10px 20px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-danger:hover {
transform: translateY(-1px);
box-shadow: 0 6px 20px rgba(239,68,68,0.4);
}
/* Ghost button */
.btn-ghost {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
padding: 8px 14px;
font-weight: 500;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-ghost:hover {
background: rgba(0,0,0,0.05);
color: var(--color-text);
}
/* Glass input */
.glass-input {
background: rgba(255,255,255,0.6);
border: 1px solid rgba(255,255,255,0.8);
border-radius: var(--radius-sm);
padding: 10px 14px;
font-size: 14px;
color: var(--color-text);
width: 100%;
transition: all 0.2s;
backdrop-filter: blur(8px);
}
.glass-input:focus {
background: rgba(255,255,255,0.85);
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(34,197,94,0.15);
}
.glass-input::placeholder {
color: var(--color-text-secondary);
}
/* Badge */
.badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.badge-green { background: rgba(34,197,94,0.15); color: #15803D; border: 1px solid rgba(34,197,94,0.3); }
.badge-blue { background: rgba(6,182,212,0.15); color: #0E7490; border: 1px solid rgba(6,182,212,0.3); }
.badge-orange { background: rgba(251,146,60,0.15); color: #C2410C; border: 1px solid rgba(251,146,60,0.3); }
.badge-gray { background: rgba(100,116,139,0.1); color: #64748B; border: 1px solid rgba(100,116,139,0.2); }
.badge-red { background: rgba(239,68,68,0.12); color: #B91C1C; border: 1px solid rgba(239,68,68,0.2); }
.badge-purple { background: rgba(139,92,246,0.12); color: #6D28D9; border: 1px solid rgba(139,92,246,0.2); }
/* Tag chip */
.tag-chip {
display: inline-flex;
align-items: center;
padding: 3px 10px;
background: rgba(34,197,94,0.1);
border: 1px solid rgba(34,197,94,0.25);
border-radius: 20px;
font-size: 12px;
color: var(--color-primary-dark);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.tag-chip:hover,
.tag-chip.active {
background: rgba(34,197,94,0.2);
border-color: var(--color-primary);
}
/* Page layout helpers */
.page-content {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
.page-title {
font-size: 22px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 20px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: var(--color-text);
margin-bottom: 14px;
}
/* Grid helpers */
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
.page-content { padding: 16px; }
}
/* Flex helpers */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; }
.gap-3 { gap: 12px; }
.gap-4 { gap: 16px; }
/* Text helpers */
.text-sm { font-size: 12px; }
.text-secondary { color: var(--color-text-secondary); }
.font-bold { font-weight: 700; }
.font-semibold { font-weight: 600; }
/* Stars rating */
.stars { color: #FBBF24; font-size: 14px; letter-spacing: 1px; }
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.fade-in { animation: fadeIn 0.3s ease; }
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
width: 20px; height: 20px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: inline-block;
}
.spinner-green {
border-color: rgba(34,197,94,0.2);
border-top-color: var(--color-primary);
}
#app {
min-height: 100vh;
}
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import WelcomeItem from './WelcomeItem.vue'
import DocumentationIcon from './icons/IconDocumentation.vue'
import ToolingIcon from './icons/IconTooling.vue'
import EcosystemIcon from './icons/IconEcosystem.vue'
import CommunityIcon from './icons/IconCommunity.vue'
import SupportIcon from './icons/IconSupport.vue'
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
</script>
<template>
<WelcomeItem>
<template #icon>
<DocumentationIcon />
</template>
<template #heading>Documentation</template>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
>Vue - Official</a
>. If you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
+87
View File
@@ -0,0 +1,87 @@
<template>
<div class="item">
<i>
<slot name="icon"></slot>
</i>
<div class="details">
<h3>
<slot name="heading"></slot>
</h3>
<slot></slot>
</div>
</div>
</template>
<style scoped>
.item {
margin-top: 2rem;
display: flex;
position: relative;
}
.details {
flex: 1;
margin-left: 1rem;
}
i {
display: flex;
place-items: center;
place-content: center;
width: 32px;
height: 32px;
color: var(--color-text);
}
h3 {
font-size: 1.2rem;
font-weight: 500;
margin-bottom: 0.4rem;
color: var(--color-heading);
}
@media (min-width: 1024px) {
.item {
margin-top: 0;
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
}
i {
top: calc(50% - 25px);
left: -26px;
position: absolute;
border: 1px solid var(--color-border);
background: var(--color-background);
border-radius: 8px;
width: 50px;
height: 50px;
}
.item:before {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
bottom: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:after {
content: ' ';
border-left: 1px solid var(--color-border);
position: absolute;
left: 0;
top: calc(50% + 25px);
height: calc(50% - 25px);
}
.item:first-of-type:before {
display: none;
}
.item:last-of-type:after {
display: none;
}
}
</style>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
<path
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
/>
</svg>
</template>
@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
<path
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
/>
</svg>
</template>
@@ -0,0 +1,19 @@
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
aria-hidden="true"
role="img"
class="iconify iconify--mdi"
width="24"
height="24"
preserveAspectRatio="xMidYMid meet"
viewBox="0 0 24 24"
>
<path
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
fill="currentColor"
></path>
</svg>
</template>
@@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const route = useRoute()
const navItems = computed(() => {
const role = auth.user?.role ?? 'student'
if (role === 'teacher') return [
{ label: 'Дашборд', icon: '📊', to: '/teacher' },
{ label: 'Лекции', icon: '📖', to: '/teacher/lectures' },
{ label: 'Аналитика',icon: '📈', to: '/teacher/analytics' },
{ label: 'Профиль', icon: '👤', to: '/profile' },
]
if (role === 'admin') return [
{ label: 'Дашборд', icon: '🛡️', to: '/admin' },
{ label: 'Юзеры', icon: '👥', to: '/admin/users' },
{ label: 'Лекции', icon: '📚', to: '/admin/lectures' },
{ label: 'ИИ', icon: '🤖', to: '/admin/llm-queue' },
]
return [
{ label: 'Главная', icon: '🏠', to: '/' },
{ label: 'Лекции', icon: '📚', to: '/catalog' },
{ label: 'Мои', icon: '📋', to: '/my-lectures' },
{ label: 'Профиль', icon: '👤', to: '/profile' },
]
})
function isActive(to: string) {
if (to === '/') return route.path === '/'
return route.path.startsWith(to) && to !== '/'
}
</script>
<template>
<nav class="bottom-nav">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="bottom-nav-item"
:class="{ active: isActive(item.to) }"
>
<span class="bottom-nav-icon">{{ item.icon }}</span>
<span class="bottom-nav-label">{{ item.label }}</span>
</RouterLink>
</nav>
</template>
<style scoped>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: rgba(255,255,255,0.9);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid var(--color-border-glass);
display: none;
align-items: center;
justify-content: space-around;
z-index: 100;
}
.bottom-nav-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: var(--color-text-secondary);
text-decoration: none;
flex: 1;
padding: 4px 0;
transition: color 0.2s;
}
.bottom-nav-item.active { color: var(--color-primary-dark); }
.bottom-nav-icon { font-size: 20px; }
.bottom-nav-label { font-size: 10px; font-weight: 600; }
@media (max-width: 768px) { .bottom-nav { display: flex; } }
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
interface NavItem { label: string; icon: string; to: string; roles: string[] }
const navItems: NavItem[] = [
{ label: 'Главная', icon: '🏠', to: '/', roles: ['student'] },
{ label: 'Каталог', icon: '📚', to: '/catalog', roles: ['student'] },
{ label: 'Мои лекции', icon: '📋', to: '/my-lectures', roles: ['student'] },
{ label: 'Достижения', icon: '🏆', to: '/achievements', roles: ['student'] },
{ label: 'Уведомления', icon: '🔔', to: '/notifications', roles: ['student'] },
{ label: 'Профиль', icon: '👤', to: '/profile', roles: ['student'] },
// Teacher
{ label: 'Дашборд', icon: '📊', to: '/teacher', roles: ['teacher'] },
{ label: 'Лекции', icon: '📖', to: '/teacher/lectures',roles: ['teacher'] },
{ label: 'Аналитика', icon: '📈', to: '/teacher/analytics',roles: ['teacher'] },
{ label: 'Профиль', icon: '👤', to: '/profile', roles: ['teacher'] },
// Admin
{ label: 'Дашборд', icon: '🛡️', to: '/admin', roles: ['admin'] },
{ label: 'Пользователи',icon: '👥', to: '/admin/users', roles: ['admin'] },
{ label: 'Лекции', icon: '📚', to: '/admin/lectures', roles: ['admin'] },
{ label: 'ИИ очередь', icon: '🤖', to: '/admin/llm-queue', roles: ['admin'] },
]
const visible = computed(() =>
navItems.filter(n => auth.user && n.roles.includes(auth.user.role))
)
function isActive(to: string) {
if (to === '/') return route.path === '/'
return route.path.startsWith(to) && to !== '/'
}
</script>
<template>
<aside class="sidebar">
<nav class="sidebar-nav">
<RouterLink
v-for="item in visible"
:key="item.to + item.label"
:to="item.to"
class="nav-item"
:class="{ active: isActive(item.to) }"
>
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</RouterLink>
</nav>
<div class="sidebar-footer">
<button class="logout-btn" @click="auth.logout(); router.push('/login')">
🚪 Выйти
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
position: fixed;
top: var(--topbar-height);
left: 0;
bottom: 0;
width: var(--sidebar-width);
background: rgba(255,255,255,0.75);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-right: 1px solid var(--color-border-glass);
display: flex;
flex-direction: column;
z-index: 90;
padding: 16px 0;
}
.sidebar-nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
padding: 0 10px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
text-decoration: none;
transition: all 0.2s;
}
.nav-item:hover {
background: rgba(34,197,94,0.1);
color: var(--color-primary-dark);
}
.nav-item.active {
background: linear-gradient(135deg, rgba(34,197,94,0.18), rgba(134,239,172,0.12));
color: var(--color-primary-dark);
font-weight: 700;
box-shadow: 0 2px 8px rgba(34,197,94,0.12);
}
.nav-icon { font-size: 17px; flex-shrink: 0; }
.sidebar-footer { padding: 10px 18px 8px; }
.logout-btn {
width: 100%;
background: rgba(239,68,68,0.08);
border: 1px solid rgba(239,68,68,0.2);
border-radius: var(--radius-sm);
padding: 9px 12px;
font-size: 13px;
font-weight: 600;
color: #991B1B;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 6px;
}
.logout-btn:hover { background: rgba(239,68,68,0.15); }
@media (max-width: 768px) { .sidebar { display: none; } }
</style>
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import CoinChip from '@/components/ui/CoinChip.vue'
import SearchInput from '@/components/ui/SearchInput.vue'
const auth = useAuthStore()
const userStore = useUserStore()
const router = useRouter()
const searchQuery = ref('')
const roleLabels: Record<string, string> = {
student: 'Студент',
teacher: 'Преподаватель',
admin: 'Администратор',
}
const unreadCount = computed(() => userStore.unreadCount())
function switchRole() {
auth.switchRole()
if (auth.user?.role === 'teacher') router.push('/teacher')
else if (auth.user?.role === 'admin') router.push('/admin')
else router.push('/')
}
function openProfile() {
router.push('/profile')
}
</script>
<template>
<header class="topbar">
<div class="topbar-brand">
<span class="brand-icon">🌍</span>
<span class="brand-name">UniVerse</span>
</div>
<div class="topbar-center">
<SearchInput v-model="searchQuery" placeholder="Поиск лекций, преподавателей, тегов" />
</div>
<div class="topbar-right">
<CoinChip v-if="auth.user" :amount="auth.user.coins" />
<button class="role-btn" @click="switchRole" v-if="auth.user">
{{ roleLabels[auth.user.role] }}
</button>
<button class="notif-btn" @click="$router.push('/notifications')">
🔔
<span class="notif-dot" v-if="auth.user && unreadCount > 0">
{{ unreadCount }}
</span>
</button>
<div
class="avatar"
role="button"
tabindex="0"
@click="openProfile"
@keydown.enter.prevent="openProfile"
@keydown.space.prevent="openProfile"
>
<span class="avatar-icon">👤</span>
<span class="avatar-name" v-if="auth.user">{{ auth.user.name.split(' ')[0] }}</span>
</div>
</div>
</header>
</template>
<style scoped>
.topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: var(--topbar-height);
background: rgba(255,255,255,0.8);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--color-border-glass);
box-shadow: 0 2px 20px rgba(0,0,0,0.06);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
z-index: 100;
gap: 12px;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
cursor: pointer;
}
.brand-icon { font-size: 24px; }
.brand-name {
font-size: 20px;
font-weight: 800;
background: var(--gradient-brand);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.topbar-center { flex: 1; max-width: 400px; }
.topbar-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.role-btn {
background: rgba(34,197,94,0.12);
border: 1px solid rgba(34,197,94,0.3);
border-radius: var(--radius-sm);
padding: 5px 10px;
font-size: 12px;
font-weight: 600;
color: var(--color-primary-dark);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.role-btn:hover { background: rgba(34,197,94,0.2); }
.notif-btn {
position: relative;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
padding: 4px;
border-radius: 50%;
transition: background 0.2s;
}
.notif-btn:hover { background: rgba(0,0,0,0.05); }
.notif-dot {
position: absolute;
top: -2px;
right: -2px;
background: var(--color-error);
color: #fff;
font-size: 9px;
font-weight: 700;
width: 16px;
height: 16px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 5px 10px;
border-radius: 20px;
border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.5);
transition: all 0.2s;
}
.avatar:hover { background: rgba(255,255,255,0.8); }
.avatar-icon { font-size: 18px; }
.avatar-name { font-size: 13px; font-weight: 600; color: var(--color-text); }
@media (max-width: 640px) {
.topbar-center { display: none; }
.brand-name { display: none; }
.avatar-name { display: none; }
.role-btn { display: none; }
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
defineProps<{
icon: string
title: string
description: string
unlocked?: boolean
unlockedAt?: string
coins?: number
}>()
</script>
<template>
<div class="badge-card" :class="{ locked: !unlocked }">
<div class="badge-icon">{{ icon }}</div>
<div class="badge-body">
<div class="badge-title">{{ title }}</div>
<div class="badge-desc">{{ description }}</div>
<div class="badge-meta" v-if="unlocked && unlockedAt">
Получено {{ new Date(unlockedAt).toLocaleDateString('ru-RU') }}
<span v-if="coins" class="coins-tag">+{{ coins }} 💰</span>
</div>
<div class="badge-meta locked-msg" v-else-if="!unlocked">🔒 Заблокировано</div>
</div>
</div>
</template>
<style scoped>
.badge-card {
background: var(--color-white-glass);
backdrop-filter: blur(10px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-md);
padding: 16px;
display: flex;
align-items: flex-start;
gap: 14px;
box-shadow: var(--shadow-card);
transition: transform 0.2s;
}
.badge-card:not(.locked):hover { transform: translateY(-2px); }
.locked {
opacity: 0.5;
filter: grayscale(0.5);
}
.badge-icon { font-size: 32px; line-height: 1; flex-shrink: 0; }
.badge-title { font-size: 15px; font-weight: 700; color: var(--color-text); margin-bottom: 3px; }
.badge-desc { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
.badge-meta { font-size: 11px; color: var(--color-text-secondary); }
.locked-msg { color: #9CA3AF; }
.coins-tag {
margin-left: 6px;
background: rgba(251,191,36,0.15);
border-radius: 10px;
padding: 1px 8px;
color: #92400E;
font-weight: 600;
}
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{ amount: number }>()
</script>
<template>
<div class="coin-chip">
<span class="coin-icon">💰</span>
<span class="coin-amount">{{ amount }}</span>
<span class="coin-label">монет</span>
</div>
</template>
<style scoped>
.coin-chip {
display: inline-flex;
align-items: center;
gap: 5px;
background: linear-gradient(135deg, rgba(251,191,36,0.2), rgba(245,158,11,0.15));
border: 1px solid rgba(245,158,11,0.4);
border-radius: 20px;
padding: 5px 12px;
cursor: default;
transition: all 0.2s;
white-space: nowrap;
}
.coin-chip:hover {
background: linear-gradient(135deg, rgba(251,191,36,0.3), rgba(245,158,11,0.25));
}
.coin-icon { font-size: 16px; }
.coin-amount { font-weight: 800; font-size: 14px; color: #78350F; }
.coin-label { font-size: 12px; color: #92400E; }
</style>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
defineProps<{
columns: Array<{ key: string; label: string; align?: 'left' | 'center' | 'right' | string }>
rows: Record<string, any>[]
}>()
</script>
<template>
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
<th
v-for="col in columns"
:key="col.key"
:class="`align-${col.align ?? 'left'}`"
>{{ col.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in rows" :key="i">
<td
v-for="col in columns"
:key="col.key"
:class="`align-${col.align ?? 'left'}`"
>
<slot :name="col.key" :row="row" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<style scoped>
.table-wrap {
overflow-x: auto;
border-radius: var(--radius-md);
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table th {
background: rgba(255,255,255,0.6);
border-bottom: 2px solid var(--color-border-glass);
padding: 10px 14px;
font-weight: 600;
color: var(--color-text-secondary);
white-space: nowrap;
}
.data-table td {
padding: 10px 14px;
border-bottom: 1px solid var(--color-border-glass);
color: var(--color-text);
}
.data-table tbody tr:last-child td { border-bottom: none; }
.data-table tbody tr:hover td {
background: rgba(34,197,94,0.05);
}
.align-left { text-align: left; }
.align-center { text-align: center; }
.align-right { text-align: right; }
</style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
defineProps<{
icon?: string
title?: string
subtitle?: string
}>()
</script>
<template>
<div class="empty-state">
<div class="empty-icon">{{ icon ?? '📭' }}</div>
<div class="empty-title">{{ title ?? 'Ничего не найдено' }}</div>
<div class="empty-sub">{{ subtitle ?? 'Попробуйте изменить фильтры или вернитесь позже.' }}</div>
<slot />
</div>
</template>
<style scoped>
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 48px 24px;
text-align: center;
color: var(--color-text-secondary);
}
.empty-icon { font-size: 52px; }
.empty-title { font-size: 18px; font-weight: 700; color: var(--color-text); }
.empty-sub { font-size: 14px; max-width: 320px; }
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
const props = defineProps<{
filters: Array<{ label: string; value: string; active?: boolean }>
}>()
const emit = defineEmits<{ toggle: [value: string] }>()
</script>
<template>
<div class="filter-chips">
<button
v-for="f in props.filters"
:key="f.value"
class="tag-chip"
:class="{ active: f.active }"
@click="emit('toggle', f.value)"
>
{{ f.label }}
</button>
</div>
</template>
<style scoped>
.filter-chips { display: flex; flex-wrap: wrap; gap: 6px; }
</style>
+38
View File
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { toRefs } from 'vue'
const props = withDefaults(defineProps<{
padding?: string
hoverable?: boolean
}>(), {
padding: '18px',
})
const { padding, hoverable } = toRefs(props)
</script>
<template>
<div class="glass-card" :class="{ hoverable }">
<slot />
</div>
</template>
<style scoped>
.glass-card {
background: var(--color-white-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-md);
box-shadow: var(--shadow-glass);
padding: v-bind(padding);
}
.hoverable {
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
}
.hoverable:hover {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
</style>
+196
View File
@@ -0,0 +1,196 @@
<script setup lang="ts">
import type { Lecture } from '@/types'
import { useRouter } from 'vue-router'
const props = defineProps<{
lecture: Lecture
registered?: boolean
}>()
const emit = defineEmits<{ register: [id: string] }>()
const router = useRouter()
function formatDate(d: string) {
const date = new Date(d)
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
}
function starsHtml(rating: number) {
const full = Math.floor(rating)
return '★'.repeat(full) + '☆'.repeat(5 - full)
}
function goDetail() {
router.push(`/lecture/${props.lecture.id}`)
}
</script>
<template>
<div class="lecture-card" @click="goDetail">
<div class="card-header">
<span class="badge" :class="lecture.format === 'online' ? 'badge-blue' : 'badge-green'">
{{ lecture.format === 'online' ? '🌐 Онлайн' : '📍 Офлайн' }}
</span>
<span
class="seats"
:class="{
'seats-low': lecture.freeSeats <= 3 && lecture.freeSeats > 0,
'seats-zero': lecture.freeSeats === 0 || lecture.registrationClosed
}"
>
<template v-if="lecture.registrationClosed">Запись закрыта</template>
<template v-else>
{{ lecture.freeSeats === 0 ? 'Мест нет' : `${lecture.freeSeats}/${lecture.totalSeats} мест` }}
</template>
</span>
</div>
<h3 class="card-title">{{ lecture.title }}</h3>
<div class="card-teacher">
<span>👤</span>
<span>{{ lecture.teacher }}</span>
<span class="text-secondary">· {{ lecture.institute }}</span>
</div>
<div class="card-meta">
<span>📅 {{ formatDate(lecture.date) }}</span>
<span> {{ lecture.time }}</span>
<span v-if="lecture.room">🏛 {{ lecture.building }}, ауд. {{ lecture.room }}</span>
<span v-else>🏛 {{ lecture.building }}</span>
</div>
<div class="card-tags">
<span class="tag-chip" v-for="tag in lecture.tags.slice(0, 3)" :key="tag">{{ tag }}</span>
</div>
<div class="card-footer">
<div class="rating">
<span class="stars">{{ starsHtml(lecture.rating) }}</span>
<span class="rating-value">{{ lecture.rating }}</span>
<span class="text-secondary text-sm">({{ lecture.reviewCount }})</span>
</div>
<button
v-if="registered"
class="btn-registered"
disabled
@click.stop
>
Записан
</button>
<button
v-else-if="lecture.freeSeats > 0 && !lecture.registrationClosed"
class="btn-primary btn-sm"
@click.stop="emit('register', lecture.id)"
>
Записаться
</button>
<button v-else class="btn-disabled" disabled @click.stop>
{{ lecture.registrationClosed ? 'Запись закрыта' : 'Мест нет' }}
</button>
</div>
</div>
</template>
<style scoped>
.lecture-card {
background: var(--color-white-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
min-width: 0;
}
.lecture-card:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(34,197,94,0.2);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.seats {
font-size: 12px;
font-weight: 600;
color: var(--color-primary-dark);
}
.seats-low { color: #EA580C; }
.seats-zero { color: #DC2626; }
.card-title {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-teacher {
display: flex;
align-items: center;
gap: 5px;
font-size: 13px;
color: var(--color-text-secondary);
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: var(--color-text-secondary);
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.rating {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
}
.rating-value {
font-weight: 700;
color: var(--color-text);
}
.btn-sm {
padding: 7px 14px;
font-size: 13px;
}
.btn-registered {
background: rgba(34,197,94,0.12);
border: 1px solid rgba(34,197,94,0.3);
border-radius: var(--radius-sm);
color: var(--color-primary-dark);
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: default;
}
.btn-disabled {
background: rgba(100,116,139,0.1);
border: 1px solid rgba(100,116,139,0.2);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
padding: 7px 14px;
font-size: 13px;
font-weight: 600;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,23 @@
<script setup lang="ts">
defineProps<{ size?: 'sm' | 'md' | 'lg' }>()
</script>
<template>
<div class="spinner-wrap">
<div class="spinner" :class="size ?? 'md'" />
</div>
</template>
<style scoped>
.spinner-wrap { display: flex; align-items: center; justify-content: center; padding: 24px; }
.spinner {
border-radius: 50%;
border: 3px solid rgba(34,197,94,0.2);
border-top-color: var(--color-primary);
animation: spin 0.8s linear infinite;
}
.sm { width: 20px; height: 20px; }
.md { width: 36px; height: 36px; }
.lg { width: 56px; height: 56px; border-width: 4px; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
@@ -0,0 +1,80 @@
<script setup lang="ts">
defineProps<{
title?: string
modelValue?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [v: boolean] }>()
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="modelValue" class="modal-overlay" @click.self="emit('update:modelValue', false)">
<div class="modal-box">
<div class="modal-header" v-if="title">
<span class="modal-title">{{ title }}</span>
<button class="modal-close" @click="emit('update:modelValue', false)">×</button>
</div>
<div class="modal-body">
<slot />
</div>
<div class="modal-footer" v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 16px;
}
.modal-box {
background: var(--color-surface);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg);
box-shadow: 0 24px 64px rgba(0,0,0,0.2);
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 22px;
border-bottom: 1px solid var(--color-border-glass);
}
.modal-title { font-size: 17px; font-weight: 700; color: var(--color-text); }
.modal-close {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
color: var(--color-text-secondary);
line-height: 1;
}
.modal-close:hover { color: var(--color-text); }
.modal-body { padding: 20px 22px; }
.modal-footer {
padding: 14px 22px;
border-top: 1px solid var(--color-border-glass);
display: flex;
gap: 10px;
justify-content: flex-end;
}
.modal-enter-active, .modal-leave-active { transition: all 0.25s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-from .modal-box, .modal-leave-to .modal-box { transform: scale(0.93); }
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
defineProps<{
value: number
max?: number
label?: string
color?: string
}>()
</script>
<template>
<div class="progress-wrap">
<div v-if="label" class="progress-label">{{ label }}</div>
<div class="progress-bar">
<div
class="progress-fill"
:style="{
width: `${Math.min(100, (value / (max ?? 100)) * 100)}%`,
background: color ?? 'linear-gradient(90deg, #22C55E, #86EFAC)'
}"
/>
</div>
<div class="progress-text">{{ value }} / {{ max ?? 100 }}</div>
</div>
</template>
<style scoped>
.progress-wrap { display: flex; flex-direction: column; gap: 4px; }
.progress-label { font-size: 12px; color: var(--color-text-secondary); font-weight: 500; }
.progress-bar {
height: 8px;
background: rgba(0,0,0,0.08);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease;
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 50%;
background: rgba(255,255,255,0.4);
border-radius: 4px 4px 0 0;
}
.progress-text { font-size: 11px; color: var(--color-text-secondary); }
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<div class="search-wrap">
<span class="search-icon">🔍</span>
<input
class="search-input"
type="text"
:value="props.modelValue"
:placeholder="placeholder ?? 'Поиск...'"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</div>
</template>
<style scoped>
.search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 12px;
font-size: 15px;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 10px 16px 10px 38px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.7);
backdrop-filter: blur(8px);
font-size: 14px;
color: var(--color-text);
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(34,197,94,0.15);
}
.search-input::placeholder { color: var(--color-text-secondary); }
</style>
@@ -0,0 +1,72 @@
<script setup lang="ts">
defineProps<{
label: string
value: string | number
icon?: string
color?: 'green' | 'aqua' | 'orange' | 'purple'
sub?: string
}>()
</script>
<template>
<div class="stats-widget" :class="`color-${color ?? 'green'}`">
<div class="widget-icon" v-if="icon">{{ icon }}</div>
<div class="widget-body">
<div class="widget-value">{{ value }}</div>
<div class="widget-label">{{ label }}</div>
<div class="widget-sub" v-if="sub">{{ sub }}</div>
</div>
</div>
</template>
<style scoped>
.stats-widget {
background: var(--color-white-glass);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
padding: 20px;
display: flex;
align-items: center;
gap: 14px;
position: relative;
overflow: hidden;
}
.stats-widget::after {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 3px;
border-radius: var(--radius-md) var(--radius-md) 0 0;
}
.color-green::after { background: linear-gradient(90deg, #22C55E, #86EFAC); }
.color-aqua::after { background: linear-gradient(90deg, #06B6D4, #67E8F9); }
.color-orange::after { background: linear-gradient(90deg, #FB923C, #FCD34D); }
.color-purple::after { background: linear-gradient(90deg, #A78BFA, #C4B5FD); }
.widget-icon {
font-size: 28px;
line-height: 1;
flex-shrink: 0;
}
.widget-body { flex: 1; min-width: 0; }
.widget-value {
font-size: 24px;
font-weight: 800;
color: var(--color-text);
line-height: 1;
margin-bottom: 4px;
}
.widget-label {
font-size: 13px;
color: var(--color-text-secondary);
font-weight: 500;
}
.widget-sub {
font-size: 11px;
color: var(--color-text-secondary);
margin-top: 2px;
}
</style>
@@ -0,0 +1,43 @@
<script setup lang="ts">
defineProps<{
status: 'pending' | 'active' | 'done' | 'cancelled' | 'approved' | 'rejected' | string
}>()
const statusMap: Record<string, { label: string; cls: string }> = {
pending: { label: 'Ожидание', cls: 'warning' },
active: { label: 'Активно', cls: 'success' },
done: { label: 'Завершено', cls: 'info' },
cancelled: { label: 'Отменено', cls: 'danger' },
approved: { label: 'Одобрено', cls: 'success' },
rejected: { label: 'Отклонено', cls: 'danger' },
open: { label: 'Открыта', cls: 'success' },
closed: { label: 'Закрыта', cls: 'danger' },
full: { label: 'Нет мест', cls: 'warning' },
upcoming: { label: 'Будущая', cls: 'info' },
ongoing: { label: 'Идет', cls: 'success' },
completed: { label: 'Завершена', cls: 'info' },
registered:{ label: 'Записан', cls: 'success' },
attended: { label: 'Посещено', cls: 'info' },
needsReview: { label: 'Нужен отзыв', cls: 'warning' },
}
</script>
<template>
<span class="status-badge" :class="statusMap[status]?.cls ?? 'info'">
{{ statusMap[status]?.label ?? status }}
</span>
</template>
<style scoped>
.status-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.success { background: rgba(220,252,231,0.9); color: #166534; border: 1px solid #86EFAC; }
.warning { background: rgba(254,243,199,0.9); color: #92400E; border: 1px solid #FDE68A; }
.danger { background: rgba(254,226,226,0.9); color: #991B1B; border: 1px solid #FCA5A5; }
.info { background: rgba(224,242,254,0.9); color: #1E40AF; border: 1px solid #93C5FD; }
</style>
@@ -0,0 +1,51 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = defineProps<{
message: string
type?: 'success' | 'error' | 'info'
duration?: number
}>()
const emit = defineEmits<{ close: [] }>()
const visible = ref(false)
onMounted(() => {
requestAnimationFrame(() => { visible.value = true })
setTimeout(() => { visible.value = false; setTimeout(() => emit('close'), 350) }, props.duration ?? 3000)
})
const iconMap = { success: '✅', error: '❌', info: '️' }
</script>
<template>
<Transition name="toast">
<div v-if="visible" class="toast" :class="type ?? 'success'">
<span>{{ iconMap[type ?? 'success'] }}</span>
<span>{{ message }}</span>
<button class="toast-close" @click="emit('close')">×</button>
</div>
</Transition>
</template>
<style scoped>
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 18px;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
pointer-events: all;
max-width: 340px;
}
.success { background: rgba(220,252,231,0.95); border: 1px solid #86EFAC; color: #166534; }
.error { background: rgba(254,226,226,0.95); border: 1px solid #FCA5A5; color: #991B1B; }
.info { background: rgba(224,242,254,0.95); border: 1px solid #93C5FD; color: #1E40AF; }
.toast-close { margin-left: auto; background: none; border: none; font-size: 18px; cursor: pointer; opacity: 0.6; }
.toast-close:hover { opacity: 1; }
.toast-enter-active, .toast-leave-active { transition: all 0.35s ease; }
.toast-enter-from { opacity: 0; transform: translateY(20px); }
.toast-leave-to { opacity: 0; transform: translateY(20px); }
</style>
+5
View File
@@ -1,9 +1,14 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+41
View File
@@ -0,0 +1,41 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/login', name: 'login', component: () => import('@/views/auth/LoginView.vue'), meta: { public: true } },
// Student
{ path: '/', name: 'dashboard', component: () => import('@/views/student/DashboardView.vue'), meta: { role: 'student' } },
{ path: '/catalog', name: 'catalog', component: () => import('@/views/student/CatalogView.vue'), meta: { role: 'student' } },
{ path: '/lecture/:id', name: 'lecture-detail', component: () => import('@/views/student/LectureDetailView.vue'), meta: { role: 'student' } },
{ path: '/my-lectures', name: 'my-lectures', component: () => import('@/views/student/MyLecturesView.vue'), meta: { role: 'student' } },
{ path: '/review/:id', name: 'review-form', component: () => import('@/views/student/ReviewFormView.vue'), meta: { role: 'student' } },
{ path: '/profile', name: 'profile', component: () => import('@/views/student/ProfileView.vue') },
{ path: '/achievements', name: 'achievements', component: () => import('@/views/student/AchievementsView.vue'), meta: { role: 'student' } },
{ path: '/notifications', name: 'notifications', component: () => import('@/views/student/NotificationsView.vue') },
// Teacher
{ path: '/teacher', name: 'teacher-dashboard', component: () => import('@/views/teacher/TeacherDashboardView.vue'), meta: { role: 'teacher' } },
{ path: '/teacher/lectures', name: 'teacher-lectures', component: () => import('@/views/teacher/TeacherLecturesView.vue'), meta: { role: 'teacher' } },
{ path: '/teacher/analytics', name: 'teacher-analytics', component: () => import('@/views/teacher/TeacherAnalyticsView.vue'), meta: { role: 'teacher' } },
// Admin
{ path: '/admin', name: 'admin-dashboard', component: () => import('@/views/admin/AdminDashboardView.vue'), meta: { role: 'admin' } },
{ path: '/admin/users', name: 'admin-users', component: () => import('@/views/admin/AdminUsersView.vue'), meta: { role: 'admin' } },
{ path: '/admin/lectures', name: 'admin-lectures', component: () => import('@/views/admin/AdminLecturesView.vue'), meta: { role: 'admin' } },
{ path: '/admin/llm-queue', name: 'admin-llm', component: () => import('@/views/admin/AdminLLMQueueView.vue'), meta: { role: 'admin' } },
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach((to) => {
const auth = useAuthStore()
if (!to.meta.public && !auth.isAuthenticated) {
return '/login'
}
})
export default router
+90
View File
@@ -0,0 +1,90 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { User, UserRole } from '@/types'
const defaultUsers: Record<UserRole, User> = {
student: {
id: 'stu-1',
name: 'Алексей Морозов',
email: 'a.morozov@sfedu.ru',
role: 'student',
institute: 'ИКТИБ',
department: 'Программная инженерия',
year: 3,
direction: 'Программная инженерия',
coins: 340,
level: 3,
xp: 120,
lecturesAttended: 12,
hoursLearned: 18.5,
achievements: ['1', '2', '3'],
},
teacher: {
id: 't-1',
name: 'Михаил Сергеевич Волков',
email: 'm.volkov@sfedu.ru',
role: 'teacher',
institute: 'ИКТИБ',
department: 'каф. Информатики',
year: 0,
direction: 'Информатика и вычислительная техника',
coins: 90,
level: 4,
xp: 240,
lecturesAttended: 24,
hoursLearned: 56,
achievements: ['1', '2', '3', '4'],
},
admin: {
id: 'adm-1',
name: 'Виктор Алексеев',
email: 'admin@sfedu.ru',
role: 'admin',
institute: 'ЮФУ',
department: 'Администрация',
year: 0,
direction: 'Цифровое развитие',
coins: 0,
level: 5,
xp: 500,
lecturesAttended: 0,
hoursLearned: 0,
achievements: [],
},
}
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isAuthenticated = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
async function login(role: UserRole = 'student', shouldFail = false) {
loading.value = true
error.value = null
await new Promise(r => setTimeout(r, 800))
if (shouldFail) {
loading.value = false
error.value = 'Не удалось подтвердить доступ через ЮФУ. Попробуйте еще раз.'
return false
}
user.value = { ...defaultUsers[role] }
isAuthenticated.value = true
loading.value = false
return true
}
function logout() {
user.value = null
isAuthenticated.value = false
}
function switchRole(role?: UserRole) {
if (!user.value) return
const roles: UserRole[] = ['student', 'teacher', 'admin']
const nextRole = (role ?? roles[(roles.indexOf(user.value.role) + 1) % roles.length]) as UserRole
user.value = { ...defaultUsers[nextRole] }
}
return { user, isAuthenticated, loading, error, login, logout, switchRole }
})
+163
View File
@@ -0,0 +1,163 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { Lecture } from '@/types'
export const LECTURES: Lecture[] = [
{
id: '1',
title: 'Введение в нейронные сети и глубокое обучение',
description: 'Лекция охватывает базовые концепции нейронных сетей: перцептрон, многослойные сети, метод обратного распространения ошибки, а также современные архитектуры — CNN, RNN, Transformer. Рассматриваются практические примеры на Python с использованием PyTorch.',
teacher: 'Волков М.С.',
teacherTitle: 'Профессор',
department: 'каф. Информатики',
institute: 'ИКТИБ',
date: '2025-05-07',
time: '14:00',
duration: 90,
building: 'ИКТИБ',
room: '305',
format: 'offline',
totalSeats: 30,
freeSeats: 12,
tags: ['#ML', '#ИИ', '#Python', '#нейросети'],
rating: 4.8,
reviewCount: 24,
status: 'upcoming',
},
{
id: '2',
title: 'Квантовые вычисления: от теории к практике',
description: 'Обзор квантовых алгоритмов и их применения. Рассматриваются кубиты, суперпозиция, запутанность, алгоритмы Шора и Гровера, а также введение в программирование на Qiskit.',
teacher: 'Петров А.И.',
teacherTitle: 'Доцент',
department: 'каф. Теоретической физики',
institute: 'ИФиМКН',
date: '2025-05-08',
time: '16:00',
duration: 120,
building: 'ИФиМКН',
room: '201',
format: 'offline',
totalSeats: 25,
freeSeats: 5,
tags: ['#квантовые-вычисления', '#физика', '#алгоритмы'],
rating: 4.6,
reviewCount: 18,
status: 'upcoming',
},
{
id: '3',
title: 'Современные методы биоинформатики',
description: 'Введение в биоинформатику: анализ последовательностей ДНК/РНК, геномная сборка, аннотация генов, инструменты BLAST, Biopython. Актуальные задачи вычислительной биологии.',
teacher: 'Смирнова Е.В.',
teacherTitle: 'Доктор биологических наук',
department: 'каф. Биологии',
institute: 'АГиС',
date: '2025-05-09',
time: '10:00',
duration: 90,
building: 'АГиС',
room: '118',
format: 'offline',
totalSeats: 20,
freeSeats: 2,
tags: ['#биоинформатика', '#генетика', '#Python'],
rating: 4.7,
reviewCount: 31,
status: 'upcoming',
},
{
id: '4',
title: 'Философия цифровой эпохи',
description: 'Как цифровые технологии меняют мышление, идентичность и общество. Тема охватывает этику ИИ, постгуманизм, цифровой дуализм и проблему сознания в эпоху автоматизации.',
teacher: 'Дмитриев К.О.',
teacherTitle: 'Кандидат философских наук',
department: 'каф. Философии',
institute: 'ИФиСН',
date: '2025-05-10',
time: '18:00',
duration: 90,
building: 'Онлайн',
format: 'online',
totalSeats: 40,
freeSeats: 16,
tags: ['#философия', '#этика', '#ИИ'],
rating: 4.5,
reviewCount: 42,
status: 'upcoming',
},
{
id: '5',
title: 'Право в информационном обществе',
description: 'Правовые аспекты работы с данными: GDPR, ФЗ-152, авторское право в сети, кибербезопасность с точки зрения права, ответственность разработчиков и операторов персональных данных.',
teacher: 'Захарова Н.А.',
teacherTitle: 'Доцент',
department: 'каф. Гражданского права',
institute: 'ЮФ',
date: '2025-05-12',
time: '15:30',
duration: 90,
building: 'ЮФ',
room: '412',
format: 'offline',
totalSeats: 30,
freeSeats: 0,
registrationClosed: true,
tags: ['#право', '#данные', '#GDPR'],
rating: 4.4,
reviewCount: 15,
status: 'upcoming',
},
{
id: '6',
title: 'Нейромаркетинг и поведение потребителей',
description: 'Как нейронауки применяются в маркетинге: eye-tracking, EEG-анализ реакций, влияние UX на покупки, нейропсихология принятия решений и кейсы ведущих брендов.',
teacher: 'Орлов П.Р.',
teacherTitle: 'Кандидат экономических наук',
department: 'каф. Маркетинга',
institute: 'ИУЭиП',
date: '2025-05-14',
time: '11:00',
duration: 120,
building: 'Онлайн',
format: 'online',
totalSeats: 35,
freeSeats: 27,
tags: ['#маркетинг', '#нейронауки', '#поведение'],
rating: 4.3,
reviewCount: 9,
status: 'upcoming',
},
]
export const useLecturesStore = defineStore('lectures', () => {
const lectures = ref<Lecture[]>(LECTURES)
const registered = ref<string[]>(['1', '3'])
const all = computed(() => lectures.value)
const registeredIds = computed(() => registered.value)
const registeredLectures = computed(() =>
lectures.value.filter(l => registered.value.includes(l.id))
)
function register(lectureId: string) {
if (!registered.value.includes(lectureId)) {
const l = lectures.value.find(x => x.id === lectureId)
if (!l || l.freeSeats === 0 || l.registrationClosed) return
registered.value.push(lectureId)
l.freeSeats--
}
}
function unregister(lectureId: string) {
registered.value = registered.value.filter(id => id !== lectureId)
const l = lectures.value.find(x => x.id === lectureId)
if (l) l.freeSeats++
}
function isRegistered(lectureId: string) {
return registered.value.includes(lectureId)
}
return { lectures, registered, all, registeredIds, registeredLectures, register, unregister, isRegistered }
})
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Achievement, Notification, CoinTransaction } from '@/types'
export const useUserStore = defineStore('user', () => {
const achievements = ref<Achievement[]>([
{ id: '1', title: 'Первый отзыв', description: 'Оставьте первый отзыв о лекции', icon: '⭐', unlocked: true, unlockedAt: '2025-04-10', coins: 20 },
{ id: '2', title: 'Межфакультетский исследователь', description: 'Посетите лекции 3 разных институтов', icon: '🔭', unlocked: true, unlockedAt: '2025-04-18', coins: 50 },
{ id: '3', title: '10 часов лекций', description: 'Наберите 10 часов посещённых лекций', icon: '⏱', unlocked: true, unlockedAt: '2025-04-25', coins: 30 },
{ id: '4', title: 'Полезный критик', description: 'Получите 5 монет за качественный отзыв', icon: '💡', unlocked: false },
{ id: '5', title: 'Знаток науки', description: 'Посетите лекции по 5 разным тематикам', icon: '🎓', unlocked: false },
{ id: '6', title: 'Ранний пташка', description: 'Запишитесь на лекцию за 7 дней до начала', icon: '🌅', unlocked: false },
{ id: '7', title: 'Социальная бабочка', description: 'Приведите друга на межфакультетскую лекцию', icon: '🦋', unlocked: false },
])
const notifications = ref<Notification[]>([
{ id: '1', type: 'reminder', title: 'Напоминание о лекции', body: 'Завтра в 14:00 — «Введение в нейронные сети». Ауд. 305, ИКТИБ', read: false, createdAt: '2025-05-06T09:00:00' },
{ id: '2', type: 'coins', title: 'Начислено 20 монет', body: 'Ваш отзыв о лекции «Квантовые вычисления» признан полезным', read: false, createdAt: '2025-05-05T18:30:00' },
{ id: '3', type: 'achievement', title: 'Новое достижение!', body: 'Вы получили значок «Межфакультетский исследователь» 🔭', read: false, createdAt: '2025-05-04T12:00:00' },
{ id: '4', type: 'recommendation', title: 'Рекомендация для вас', body: 'Новая лекция «Нейромаркетинг» — может быть интересна вам', read: true, createdAt: '2025-05-03T10:00:00' },
{ id: '5', type: 'schedule-change', title: 'Изменение расписания', body: 'Лекция «Философия цифровой эпохи» перенесена с 18:00 на 19:00', read: true, createdAt: '2025-05-02T16:00:00' },
{ id: '6', type: 'coins', title: 'Начислено 30 монет', body: 'Поздравляем с достижением «10 часов лекций»!', read: true, createdAt: '2025-04-25T11:00:00' },
])
const coinHistory = ref<CoinTransaction[]>([
{ id: '1', date: '2025-05-05', description: 'Полезный отзыв о лекции', amount: 20, type: 'earned' },
{ id: '2', date: '2025-04-25', description: 'Достижение «10 часов лекций»', amount: 30, type: 'earned' },
{ id: '3', date: '2025-04-18', description: 'Достижение «Исследователь»', amount: 50, type: 'earned' },
{ id: '4', date: '2025-04-10', description: 'Первый отзыв', amount: 20, type: 'earned' },
{ id: '5', date: '2025-04-05', description: 'Покупка стикерпака ЮФУ', amount: -80, type: 'spent' },
{ id: '6', date: '2025-03-20', description: 'Посещение серии лекций', amount: 60, type: 'earned' },
{ id: '7', date: '2025-03-10', description: 'Покупка термокружки ЮФУ', amount: -120, type: 'spent' },
{ id: '8', date: '2025-02-28', description: 'Первое посещение лекции вне факультета', amount: 40, type: 'earned' },
])
function markAllRead() {
notifications.value.forEach(n => (n.read = true))
}
const unreadCount = () => notifications.value.filter(n => !n.read).length
return { achievements, notifications, coinHistory, markAllRead, unreadCount }
})
+83
View File
@@ -0,0 +1,83 @@
export type UserRole = 'student' | 'teacher' | 'admin'
export interface User {
id: string
name: string
email: string
role: UserRole
avatar?: string
institute?: string
department?: string
year?: number
direction?: string
coins: number
level: number
xp?: number
lecturesAttended?: number
hoursLearned?: number
achievements?: string[]
}
export interface Lecture {
id: string
title: string
description: string
teacher: string
teacherTitle?: string
department?: string
institute: string
date: string
time: string
duration: number
building: string
room?: string
format: 'online' | 'offline'
totalSeats: number
freeSeats: number
registrationClosed?: boolean
tags: string[]
rating: number
reviewCount: number
status?: 'upcoming' | 'ongoing' | 'completed'
registered?: boolean
}
export interface Review {
id: string
lectureId: string
userId: string
userName: string
text: string
sentiment: 'positive' | 'neutral' | 'negative'
coins?: number
createdAt: string
status: 'pending' | 'analyzing' | 'done' | 'rejected'
quality?: number
}
export interface Achievement {
id: string
title: string
description: string
icon: string
unlocked: boolean
unlockedAt?: string
coins?: number
}
export interface Notification {
id: string
type: 'reminder' | 'schedule-change' | 'achievement' | 'coins' | 'recommendation'
title: string
body: string
read: boolean
createdAt: string
}
export interface CoinTransaction {
id: string
date: string
description: string
amount: number
type: 'earned' | 'spent'
}
+15
View File
@@ -0,0 +1,15 @@
<template>
<div class="about">
<h1>This is an about page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import GlassCard from '@/components/ui/GlassCard.vue'
import StatsWidget from '@/components/ui/StatsWidget.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const disciplines = [
{ name: 'Информатика и ИИ', value: 80 },
{ name: 'Экономика и маркетинг', value: 55 },
{ name: 'Философия и этика', value: 42 },
{ name: 'Право и политика', value: 36 },
]
</script>
<template>
<div class="admin-dashboard page-content">
<h1 class="page-title">Дашборд администратора</h1>
<div class="stats-row">
<StatsWidget label="Пользователей" :value="1247" icon="👥" color="green" />
<StatsWidget label="Лекций" :value="89" icon="📚" color="aqua" />
<StatsWidget label="Записей" :value="3421" icon="🗓️" color="orange" />
<StatsWidget label="Отзывов" :value="1089" icon="💬" color="purple" />
</div>
<div class="grid">
<GlassCard>
<div class="section-title">Популярные дисциплины</div>
<div class="bars">
<div class="bar-row" v-for="d in disciplines" :key="d.name">
<span>{{ d.name }}</span>
<div class="bar">
<div class="bar-fill" :style="{ width: `${d.value}%` }"></div>
</div>
<span class="percent">{{ d.value }}%</span>
</div>
</div>
</GlassCard>
<GlassCard>
<div class="section-title">Межфакультетская вовлеченность</div>
<div class="metric">46% студентов посещают лекции вне своего института</div>
<ProgressBar :value="46" :max="100" />
<div class="section-title">Активность студентов</div>
<div class="activity">
<div class="day" v-for="n in 7" :key="n">
<div class="day-bar" :style="{ height: `${40 + n * 6}px` }"></div>
<span>Д{{ n }}</span>
</div>
</div>
</GlassCard>
</div>
<div class="grid">
<GlassCard>
<div class="section-title">Состояние синхронизации расписания</div>
<StatusBadge status="open" />
<div class="sync-meta">Последняя синхронизация: сегодня, 09:15</div>
<div class="sync-error">Ошибка: 2 аудитории не сопоставлены с корпусами</div>
</GlassCard>
<GlassCard>
<div class="section-title">Очередь LLM-анализа</div>
<div class="queue-meta">В очереди: 24 отзыва · Обработка: 6/час</div>
<ProgressBar :value="60" :max="100" />
<div class="queue-status">Следующая проверка через 12 минут</div>
</GlassCard>
</div>
</div>
</template>
<style scoped>
.admin-dashboard { display: flex; flex-direction: column; gap: 18px; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.bars { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.bar-row { display: grid; grid-template-columns: 1fr 2fr auto; align-items: center; gap: 8px; font-size: 13px; }
.bar { background: rgba(0,0,0,0.08); border-radius: 6px; height: 8px; overflow: hidden; }
.bar-fill { background: linear-gradient(90deg, #22C55E, #86EFAC); height: 100%; }
.percent { color: var(--color-text-secondary); font-size: 12px; }
.metric { margin-bottom: 10px; color: var(--color-text-secondary); }
.activity { display: flex; gap: 10px; margin-top: 12px; align-items: flex-end; }
.day { display: flex; flex-direction: column; align-items: center; gap: 4px; font-size: 11px; color: var(--color-text-secondary); }
.day-bar { width: 16px; background: linear-gradient(180deg, #7DD3FC, #BAE6FD); border-radius: 6px 6px 0 0; }
.sync-meta { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
.sync-error { font-size: 12px; color: var(--color-error); margin-top: 8px; }
.queue-meta { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 8px; }
.queue-status { font-size: 12px; color: var(--color-text-secondary); margin-top: 6px; }
</style>
@@ -0,0 +1,54 @@
<script setup lang="ts">
import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const columns = [
{ key: 'id', label: 'ID' },
{ key: 'lecture', label: 'Лекция' },
{ key: 'student', label: 'Студент' },
{ key: 'date', label: 'Дата' },
{ key: 'status', label: 'Статус', align: 'center' },
{ key: 'sentiment', label: 'Sentiment', align: 'center' },
{ key: 'quality', label: 'Качество', align: 'center' },
{ key: 'coins', label: 'Монеты', align: 'center' },
{ key: 'actions', label: 'Действия', align: 'right' },
]
const rows = [
{ id: 'RV-1024', lecture: 'Нейронные сети', student: 'А. Морозов', date: '06.05', status: 'pending', sentiment: 'Позитивный', quality: 0.82, coins: 20 },
{ id: 'RV-1025', lecture: 'Квантовые вычисления', student: 'Н. Иванова', date: '05.05', status: 'active', sentiment: 'Нейтральный', quality: 0.63, coins: 10 },
{ id: 'RV-1026', lecture: 'Право в информационном обществе', student: 'Д. Комаров', date: '04.05', status: 'done', sentiment: 'Негативный', quality: 0.41, coins: 0 },
{ id: 'RV-1027', lecture: 'Философия цифровой эпохи', student: 'С. Орлова', date: '03.05', status: 'rejected', sentiment: 'Нейтральный', quality: 0.22, coins: 0 },
]
</script>
<template>
<div class="admin-llm page-content">
<div class="header">
<h1 class="page-title">Очередь LLM-анализа отзывов</h1>
<button class="btn-primary">Запустить повторный анализ</button>
</div>
<GlassCard>
<DataTable :columns="columns" :rows="rows">
<template #status="{ value }">
<StatusBadge :status="value" />
</template>
<template #quality="{ value }">
<span :class="value >= 0.7 ? 'badge badge-green' : value >= 0.4 ? 'badge badge-orange' : 'badge badge-red'">
{{ value }}
</span>
</template>
<template #actions>
<button class="btn-ghost">Повторить</button>
</template>
</DataTable>
</GlassCard>
</div>
</template>
<style scoped>
.admin-llm { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
</style>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue'
type TabKey = 'lectures' | 'courses' | 'rooms' | 'tags'
type TabConfig = {
title: string
columns: Array<{ key: string; label: string; align?: string }>
rows: Record<string, any>[]
}
const activeTab = ref<TabKey>('lectures')
const tabConfig: Record<TabKey, TabConfig> = {
lectures: {
title: 'Лекции',
columns: [
{ key: 'title', label: 'Название' },
{ key: 'teacher', label: 'Преподаватель' },
{ key: 'format', label: 'Формат' },
{ key: 'status', label: 'Синхронизация', align: 'center' },
],
rows: [
{ id: '1', title: 'Введение в нейронные сети', teacher: 'Волков М.С.', format: 'Офлайн', status: 'Синхронизировано' },
{ id: '2', title: 'Квантовые вычисления', teacher: 'Петров А.И.', format: 'Офлайн', status: 'Синхронизировано' },
{ id: '3', title: 'Философия цифровой эпохи', teacher: 'Дмитриев К.О.', format: 'Онлайн', status: 'Ошибка' },
],
},
courses: {
title: 'Курсы',
columns: [
{ key: 'title', label: 'Курс' },
{ key: 'institute', label: 'Институт' },
{ key: 'tags', label: 'Теги' },
],
rows: [
{ id: '1', title: 'Машинное обучение', institute: 'ИКТИБ', tags: '#ML #ИИ #Python' },
{ id: '2', title: 'Цифровая этика', institute: 'ИФиСН', tags: '#философия #этика' },
],
},
rooms: {
title: 'Аудитории',
columns: [
{ key: 'building', label: 'Корпус' },
{ key: 'room', label: 'Аудитория' },
{ key: 'capacity', label: 'Вместимость', align: 'center' },
],
rows: [
{ id: '1', building: 'ИКТИБ', room: '305', capacity: 30 },
{ id: '2', building: 'ИФиМКН', room: '201', capacity: 25 },
],
},
tags: {
title: 'Теги',
columns: [
{ key: 'tag', label: 'Тег' },
{ key: 'category', label: 'Категория' },
{ key: 'linked', label: 'Привязки', align: 'center' },
],
rows: [
{ id: '1', tag: '#ML', category: 'Data Science', linked: 12 },
{ id: '2', tag: '#философия', category: 'Гуманитарные', linked: 6 },
],
},
}
const current = computed(() => tabConfig[activeTab.value])
</script>
<template>
<div class="admin-lectures page-content">
<div class="header">
<h1 class="page-title">Управление лекциями и справочниками</h1>
<button class="btn-primary">Создать запись</button>
</div>
<div class="tabs">
<button :class="{ active: activeTab === 'lectures' }" @click="activeTab = 'lectures'">Лекции</button>
<button :class="{ active: activeTab === 'courses' }" @click="activeTab = 'courses'">Курсы</button>
<button :class="{ active: activeTab === 'rooms' }" @click="activeTab = 'rooms'">Аудитории</button>
<button :class="{ active: activeTab === 'tags' }" @click="activeTab = 'tags'">Теги</button>
</div>
<div class="grid">
<GlassCard>
<div class="section-title">{{ current.title }}</div>
<DataTable :columns="current.columns" :rows="current.rows" />
</GlassCard>
<GlassCard>
<div class="section-title">Создать / редактировать</div>
<form class="form">
<label>Название</label>
<input class="glass-input" placeholder="Введите название" />
<label>Описание</label>
<textarea rows="4" placeholder="Описание записи"></textarea>
<label>Статус синхронизации</label>
<select class="glass-input">
<option>Синхронизировано</option>
<option>Ожидает</option>
<option>Ошибка</option>
</select>
<div class="form-actions">
<button class="btn-primary" type="button">Сохранить</button>
<button class="btn-secondary" type="button">Отменить</button>
</div>
</form>
</GlassCard>
</div>
</div>
</template>
<style scoped>
.admin-lectures { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.tabs { display: inline-flex; border: 1px solid var(--color-border-glass); border-radius: 12px; overflow: hidden; }
.tabs button { background: rgba(255,255,255,0.7); border: none; padding: 8px 18px; font-size: 13px; cursor: pointer; color: var(--color-text-secondary); }
.tabs button.active { background: rgba(34,197,94,0.18); color: var(--color-primary-dark); font-weight: 600; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.form { display: flex; flex-direction: column; gap: 10px; }
textarea { padding: 10px; border-radius: var(--radius-sm); border: 1px solid var(--color-border-glass); background: rgba(255,255,255,0.8); }
.form-actions { display: flex; gap: 10px; }
</style>
@@ -0,0 +1,76 @@
<script setup lang="ts">
import { ref } from 'vue'
import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue'
const search = ref('')
const roleFilter = ref('Все роли')
const instituteFilter = ref('Все институты')
const columns = [
{ key: 'name', label: 'Имя' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Роль', align: 'center' },
{ key: 'institute', label: 'Институт' },
{ key: 'activity', label: 'Активность', align: 'center' },
{ key: 'created', label: 'Дата регистрации' },
{ key: 'actions', label: 'Действия', align: 'right' },
]
const rows = [
{ id: '1', name: 'Алексей Морозов', email: 'a.morozov@sfedu.ru', role: 'Студент', institute: 'ИКТИБ', activity: 'Высокая', created: '12.03.2024' },
{ id: '2', name: 'Елена Смирнова', email: 'e.smirnova@sfedu.ru', role: 'Преподаватель', institute: 'АГиС', activity: 'Средняя', created: '05.02.2023' },
{ id: '3', name: 'Виктор Алексеев', email: 'admin@sfedu.ru', role: 'Администратор', institute: 'ЮФУ', activity: 'Высокая', created: '01.09.2022' },
]
</script>
<template>
<div class="admin-users page-content">
<div class="header">
<h1 class="page-title">Пользователи</h1>
<button class="btn-primary">Добавить пользователя</button>
</div>
<GlassCard>
<div class="filters">
<input v-model="search" class="glass-input" placeholder="Поиск по имени или email" />
<select v-model="roleFilter" class="glass-input">
<option>Все роли</option>
<option>Студент</option>
<option>Преподаватель</option>
<option>Администратор</option>
</select>
<select v-model="instituteFilter" class="glass-input">
<option>Все институты</option>
<option>ИКТИБ</option>
<option>ИФиМКН</option>
<option>АГиС</option>
<option>ЮФ</option>
</select>
</div>
<DataTable :columns="columns" :rows="rows">
<template #role="{ value }">
<span :class="value === 'Студент' ? 'badge badge-green' : value === 'Преподаватель' ? 'badge badge-blue' : 'badge badge-purple'">{{ value }}</span>
</template>
<template #activity="{ value }">
<span class="badge" :class="value === 'Высокая' ? 'badge-green' : 'badge-orange'">{{ value }}</span>
</template>
<template #actions>
<div class="actions">
<button class="btn-ghost">Назначить роль</button>
<button class="btn-ghost">Заблокировать</button>
<button class="btn-ghost">Профиль</button>
</div>
</template>
</DataTable>
</GlassCard>
</div>
</template>
<style scoped>
.admin-users { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 12px; }
.actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
</style>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import type { UserRole } from '@/types'
const auth = useAuthStore()
const router = useRouter()
const error = ref('')
const loading = ref(false)
const selectedRole = ref<UserRole>('student')
const simulateError = ref(false)
const roleOptions: Array<{ label: string; role: UserRole }> = [
{ label: '🎓 Студент', role: 'student' },
{ label: '👩‍🏫 Преподаватель', role: 'teacher' },
{ label: '🛡️ Администратор', role: 'admin' },
]
async function login() {
loading.value = true
error.value = ''
const ok = await auth.login(selectedRole.value, simulateError.value)
loading.value = false
if (ok) {
if (selectedRole.value === 'teacher') router.push('/teacher')
else if (selectedRole.value === 'admin') router.push('/admin')
else router.push('/')
} else {
error.value = auth.error ?? 'Ошибка авторизации. Проверьте доступ и попробуйте снова.'
}
}
</script>
<template>
<div class="login-bg">
<div class="login-card">
<div class="login-header">
<div class="logo-mark">🌍</div>
<h1 class="brand">UniVerse</h1>
<p class="brand-sub">«Откройте для себя вселенную знаний»</p>
</div>
<div class="login-desc">
UniVerse единая платформа ЮФУ для поиска, записи и участия в открытых межнаправленческих лекциях.
Получайте рекомендации, оставляйте отзывы и зарабатывайте монеты за полезную обратную связь.
</div>
<div class="role-select">
<p class="demo-label">Роль для демонстрации:</p>
<div class="role-options">
<button
v-for="opt in roleOptions"
:key="opt.role"
class="role-option"
:class="{ active: selectedRole === opt.role }"
@click="selectedRole = opt.role"
>
{{ opt.label }}
</button>
</div>
</div>
<div class="login-actions">
<button class="btn-primary btn-full" type="button" :disabled="loading" @click="login">
<span v-if="loading" class="spinner-inline">
<span class="spinner"></span>
</span>
{{ loading ? 'Вход...' : 'Войти через ЮФУ (Microsoft Entra ID)' }}
</button>
<label class="toggle">
<input type="checkbox" v-model="simulateError" />
Показать ошибку авторизации
</label>
<div class="error" v-if="error"> {{ error }}</div>
</div>
<div class="login-footer">
Вход осуществляется через корпоративный аккаунт ЮФУ. При первом входе требуется подтверждение доступа.
</div>
</div>
</div>
</template>
<style scoped>
.login-bg {
min-height: 100vh;
background: var(--gradient-bg);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
width: 100%;
max-width: 520px;
background: rgba(255,255,255,0.82);
backdrop-filter: blur(20px);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-lg);
box-shadow: 0 32px 80px rgba(0,0,0,0.12);
padding: 40px 36px;
display: flex;
flex-direction: column;
gap: 20px;
}
.login-header { text-align: center; }
.logo-mark { font-size: 52px; }
.brand {
font-size: 34px;
font-weight: 900;
background: var(--gradient-brand);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
}
.brand-sub { font-size: 14px; color: var(--color-text-secondary); margin: 6px 0 0; }
.login-desc {
font-size: 14px;
color: var(--color-text-secondary);
line-height: 1.5;
background: rgba(255,255,255,0.6);
border-radius: var(--radius-md);
padding: 14px 16px;
border: 1px solid var(--color-border-glass);
}
.demo-label {
font-size: 12px;
color: var(--color-text-secondary);
font-weight: 600;
text-transform: uppercase;
margin-bottom: 8px;
}
.role-options { display: flex; flex-wrap: wrap; gap: 8px; }
.role-option {
background: rgba(255,255,255,0.6);
border: 1px solid var(--color-border-glass);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
color: var(--color-text);
}
.role-option.active {
border-color: var(--color-primary);
background: rgba(34,197,94,0.12);
color: var(--color-primary-dark);
}
.login-actions { display: flex; flex-direction: column; gap: 10px; }
.btn-full { width: 100%; justify-content: center; }
.toggle {
font-size: 12px;
color: var(--color-text-secondary);
display: flex;
align-items: center;
gap: 8px;
}
.error {
font-size: 13px;
color: var(--color-error);
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.3);
border-radius: var(--radius-sm);
padding: 8px 12px;
}
.login-footer {
text-align: center;
font-size: 12px;
color: var(--color-text-secondary);
}
.spinner-inline { display: inline-flex; margin-right: 6px; }
.spinner-inline .spinner { width: 16px; height: 16px; border-width: 2px; }
</style>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
const userStore = useUserStore()
const unlocked = computed(() => userStore.achievements.filter(a => a.unlocked))
const locked = computed(() => userStore.achievements.filter(a => !a.unlocked))
const rewards = [
{ id: 'r1', title: 'Стикерпак UniVerse', price: 80, available: true },
{ id: 'r2', title: 'Термокружка ЮФУ', price: 150, available: true },
{ id: 'r3', title: 'Доп. консультация преподавателя', price: 220, available: false },
{ id: 'r4', title: 'Цифровой бейдж «Research Explorer»', price: 60, available: true },
]
</script>
<template>
<div class="achievements page-content">
<h1 class="page-title">Достижения и магазин наград</h1>
<section>
<h2 class="section-title">Полученные достижения</h2>
<div class="list">
<AchievementBadge
v-for="a in unlocked"
:key="a.id"
:icon="a.icon"
:title="a.title"
:description="a.description"
:unlocked="a.unlocked"
:unlockedAt="a.unlockedAt"
:coins="a.coins"
/>
</div>
</section>
<section>
<h2 class="section-title">Заблокированные</h2>
<div class="list">
<AchievementBadge
v-for="a in locked"
:key="a.id"
:icon="a.icon"
:title="a.title"
:description="a.description"
:unlocked="a.unlocked"
/>
</div>
</section>
<section>
<h2 class="section-title">Магазин наград</h2>
<div class="rewards">
<GlassCard v-for="r in rewards" :key="r.id" class="reward-card">
<div class="reward-title">{{ r.title }}</div>
<div class="reward-price">{{ r.price }} монет</div>
<button class="btn-primary" :disabled="!r.available">
{{ r.available ? 'Купить' : 'Недоступно' }}
</button>
</GlassCard>
</div>
</section>
</div>
</template>
<style scoped>
.achievements { display: flex; flex-direction: column; gap: 20px; }
.list { display: flex; flex-direction: column; gap: 12px; }
.rewards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
.reward-card { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.reward-title { font-weight: 700; }
.reward-price { color: var(--color-text-secondary); font-size: 13px; }
</style>
+345
View File
@@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useLecturesStore } from '@/stores/lectures'
import SearchInput from '@/components/ui/SearchInput.vue'
import LectureCard from '@/components/ui/LectureCard.vue'
import GlassCard from '@/components/ui/GlassCard.vue'
import FilterChips from '@/components/ui/FilterChips.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import DataTable from '@/components/ui/DataTable.vue'
import ModalDialog from '@/components/ui/ModalDialog.vue'
const lecturesStore = useLecturesStore()
const search = ref('')
const viewMode = ref<'cards' | 'list' | 'calendar'>('cards')
const dateFilter = ref('Любая дата')
const direction = ref('Все направления')
const teacher = ref('Все преподаватели')
const building = ref('Все корпуса')
const format = ref<'all' | 'online' | 'offline'>('all')
const onlyFree = ref(false)
const filtersOpen = ref(false)
const addToast = inject('addToast') as ((message: string, type?: 'success' | 'error' | 'info') => void) | undefined
const tagFilters = ref([
{ label: '#ML', value: '#ML', active: false },
{ label: '#ИИ', value: '#ИИ', active: false },
{ label: '#Python', value: '#Python', active: false },
{ label: '#квантовые-вычисления', value: '#квантовые-вычисления', active: false },
{ label: '#биоинформатика', value: '#биоинформатика', active: false },
{ label: '#философия', value: '#философия', active: false },
{ label: '#право', value: '#право', active: false },
{ label: '#маркетинг', value: '#маркетинг', active: false },
])
const directions = [
'Все направления',
'Информатика и вычислительная техника',
'Физика',
'Биология',
'Философия',
'Право',
'Экономика и маркетинг',
]
const teachers = computed(() => ['Все преподаватели', ...new Set(lecturesStore.all.map(l => l.teacher))])
const buildings = computed(() => ['Все корпуса', ...new Set(lecturesStore.all.map(l => l.building))])
function toggleTag(value: string) {
const target = tagFilters.value.find(t => t.value === value)
if (target) target.active = !target.active
}
const activeTags = computed(() => tagFilters.value.filter(t => t.active).map(t => t.value))
const filtered = computed(() =>
lecturesStore.all.filter(l => {
const matchesSearch = l.title.toLowerCase().includes(search.value.toLowerCase())
const directionKey = direction.value.split(' ')[0] || ''
const matchesDirection = direction.value === 'Все направления' || l.institute.includes(directionKey)
const matchesTeacher = teacher.value === 'Все преподаватели' || l.teacher === teacher.value
const matchesBuilding = building.value === 'Все корпуса' || l.building === building.value
const matchesFormat = format.value === 'all' || l.format === format.value
const matchesTags = activeTags.value.length === 0 || activeTags.value.some(tag => l.tags.includes(tag))
const matchesFree = !onlyFree.value || l.freeSeats > 0
return matchesSearch && matchesDirection && matchesTeacher && matchesBuilding && matchesFormat && matchesTags && matchesFree
})
)
const appliedFilters = computed(() => {
const filters: string[] = []
if (dateFilter.value !== 'Любая дата') filters.push(dateFilter.value)
if (direction.value !== 'Все направления') filters.push(direction.value)
if (teacher.value !== 'Все преподаватели') filters.push(teacher.value)
if (building.value !== 'Все корпуса') filters.push(building.value)
if (format.value !== 'all') filters.push(format.value === 'online' ? 'Онлайн' : 'Офлайн')
if (onlyFree.value) filters.push('Есть места')
filters.push(...activeTags.value)
return filters
})
const tableColumns = [
{ key: 'title', label: 'Лекция' },
{ key: 'teacher', label: 'Преподаватель' },
{ key: 'date', label: 'Дата' },
{ key: 'place', label: 'Локация' },
{ key: 'seats', label: 'Места', align: 'center' },
{ key: 'action', label: 'Действия', align: 'right' },
]
const calendarGroups = computed(() => {
const groups: Record<string, typeof filtered.value> = {}
filtered.value.forEach(l => {
const date = new Date(l.date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })
groups[date] = groups[date] || []
groups[date].push(l)
})
return Object.entries(groups)
})
function registerLecture(id: string) {
lecturesStore.register(id)
addToast?.('Вы записаны на лекцию. Напоминание придет за сутки.', 'success')
}
</script>
<template>
<div class="catalog page-content">
<div class="catalog-header">
<div>
<h1 class="page-title">Каталог открытых лекций</h1>
<p class="text-secondary">Выберите лекцию, фильтруйте по направлениям и регистрируйтесь в один клик.</p>
</div>
<div class="header-actions">
<SearchInput v-model="search" placeholder="Поиск по теме лекции" />
<button class="btn-secondary filters-btn" @click="filtersOpen = true">Фильтры</button>
</div>
</div>
<GlassCard>
<div class="filters-grid">
<div>
<label class="filter-label">Дата</label>
<select v-model="dateFilter" class="glass-input">
<option>Любая дата</option>
<option>Сегодня</option>
<option>Завтра</option>
<option>На этой неделе</option>
</select>
</div>
<div>
<label class="filter-label">Направление</label>
<select v-model="direction" class="glass-input">
<option v-for="d in directions" :key="d">{{ d }}</option>
</select>
</div>
<div>
<label class="filter-label">Преподаватель</label>
<select v-model="teacher" class="glass-input">
<option v-for="t in teachers" :key="t">{{ t }}</option>
</select>
</div>
<div>
<label class="filter-label">Корпус</label>
<select v-model="building" class="glass-input">
<option v-for="b in buildings" :key="b">{{ b }}</option>
</select>
</div>
<div>
<label class="filter-label">Формат</label>
<div class="segmented">
<button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">Офлайн</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">Онлайн</button>
</div>
</div>
<div>
<label class="filter-label">Теги</label>
<FilterChips :filters="tagFilters" @toggle="toggleTag" />
</div>
<div class="free-toggle">
<label class="filter-label">Наличие мест</label>
<label class="switch">
<input type="checkbox" v-model="onlyFree" />
<span>Только свободные</span>
</label>
</div>
</div>
</GlassCard>
<div class="view-row">
<div class="segmented">
<button :class="{ active: viewMode === 'cards' }" @click="viewMode = 'cards'">Карточки</button>
<button :class="{ active: viewMode === 'list' }" @click="viewMode = 'list'">Список</button>
<button :class="{ active: viewMode === 'calendar' }" @click="viewMode = 'calendar'">Календарь</button>
</div>
<div class="applied" v-if="appliedFilters.length">
<span class="text-secondary">Фильтры:</span>
<span v-for="f in appliedFilters" :key="f" class="tag-chip active">{{ f }}</span>
</div>
</div>
<div v-if="filtered.length === 0">
<EmptyState title="Нет результатов" subtitle="Попробуйте изменить фильтры или сбросить поиск." />
</div>
<div v-else-if="viewMode === 'cards'" class="cards-grid">
<LectureCard
v-for="l in filtered"
:key="l.id"
:lecture="l"
:registered="lecturesStore.registeredIds.includes(l.id)"
@register="registerLecture"
/>
</div>
<div v-else-if="viewMode === 'list'" class="list-view">
<GlassCard>
<DataTable :columns="tableColumns" :rows="filtered">
<template #title="{ row }">
<div class="list-title">{{ row.title }}</div>
<div class="text-secondary text-sm">{{ row.tags.join(' ') }}</div>
</template>
<template #date="{ row }">
{{ new Date(row.date).toLocaleDateString('ru-RU') }} · {{ row.time }}
</template>
<template #place="{ row }">
{{ row.building }} {{ row.room ? `· ауд. ${row.room}` : '' }}
</template>
<template #seats="{ row }">
<span :class="row.freeSeats === 0 ? 'badge badge-gray' : 'badge badge-green'">
{{ row.registrationClosed ? 'Запись закрыта' : `${row.freeSeats}/${row.totalSeats}` }}
</span>
</template>
<template #action="{ row }">
<button class="btn-primary btn-sm" :disabled="row.freeSeats === 0 || row.registrationClosed">Записаться</button>
</template>
</DataTable>
</GlassCard>
</div>
<div v-else class="calendar-view">
<GlassCard v-for="([date, items]) in calendarGroups" :key="date" class="calendar-day">
<div class="calendar-date">{{ date }}</div>
<div class="calendar-items">
<div v-for="l in items" :key="l.id" class="calendar-item">
<div class="calendar-title">{{ l.title }}</div>
<div class="calendar-meta">{{ l.time }} · {{ l.building }} {{ l.room ? `· ауд. ${l.room}` : '' }}</div>
<button class="btn-secondary btn-sm">Подробнее</button>
</div>
</div>
</GlassCard>
</div>
<ModalDialog v-model="filtersOpen" title="Фильтры">
<div class="modal-filters">
<label>Дата</label>
<select v-model="dateFilter" class="glass-input">
<option>Любая дата</option>
<option>Сегодня</option>
<option>Завтра</option>
<option>На этой неделе</option>
</select>
<label>Направление</label>
<select v-model="direction" class="glass-input">
<option v-for="d in directions" :key="d">{{ d }}</option>
</select>
<label>Преподаватель</label>
<select v-model="teacher" class="glass-input">
<option v-for="t in teachers" :key="t">{{ t }}</option>
</select>
<label>Корпус</label>
<select v-model="building" class="glass-input">
<option v-for="b in buildings" :key="b">{{ b }}</option>
</select>
<label>Формат</label>
<div class="segmented">
<button :class="{ active: format === 'all' }" @click="format = 'all'">Все</button>
<button :class="{ active: format === 'offline' }" @click="format = 'offline'">Офлайн</button>
<button :class="{ active: format === 'online' }" @click="format = 'online'">Онлайн</button>
</div>
<label>Теги</label>
<FilterChips :filters="tagFilters" @toggle="toggleTag" />
<label class="switch">
<input type="checkbox" v-model="onlyFree" />
<span>Только свободные места</span>
</label>
</div>
<template #footer>
<button class="btn-secondary" @click="filtersOpen = false">Закрыть</button>
<button class="btn-primary" @click="filtersOpen = false">Применить</button>
</template>
</ModalDialog>
</div>
</template>
<style scoped>
.catalog { display: flex; flex-direction: column; gap: 20px; }
.catalog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.header-actions { display: flex; gap: 12px; align-items: center; flex: 1; justify-content: flex-end; }
.filters-btn { display: none; }
.filters-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.filter-label { font-size: 12px; font-weight: 600; color: var(--color-text-secondary); margin-bottom: 6px; display: block; }
.segmented {
display: inline-flex;
border: 1px solid var(--color-border-glass);
border-radius: 10px;
overflow: hidden;
}
.segmented button {
background: rgba(255,255,255,0.7);
border: none;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
color: var(--color-text-secondary);
}
.segmented button.active {
background: rgba(34,197,94,0.15);
color: var(--color-primary-dark);
font-weight: 600;
}
.free-toggle { display: flex; flex-direction: column; gap: 6px; }
.switch { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--color-text-secondary); }
.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.applied { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.list-title { font-weight: 600; }
.list-view { margin-top: 6px; }
.calendar-view { display: flex; flex-direction: column; gap: 14px; }
.calendar-day { padding: 16px; }
.calendar-date { font-weight: 700; margin-bottom: 8px; }
.calendar-items { display: flex; flex-direction: column; gap: 10px; }
.calendar-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid var(--color-border-glass);
padding-bottom: 8px;
}
.calendar-item:last-child { border-bottom: none; padding-bottom: 0; }
.calendar-title { font-weight: 600; }
.calendar-meta { font-size: 12px; color: var(--color-text-secondary); }
.modal-filters { display: flex; flex-direction: column; gap: 12px; }
@media (max-width: 768px) {
.filters-grid { display: none; }
.filters-btn { display: inline-flex; }
.header-actions { width: 100%; justify-content: space-between; }
}
</style>
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useLecturesStore } from '@/stores/lectures'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
import StatsWidget from '@/components/ui/StatsWidget.vue'
import LectureCard from '@/components/ui/LectureCard.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
const auth = useAuthStore()
const lectures = useLecturesStore()
const userStore = useUserStore()
const router = useRouter()
const user = computed(() => auth.user!)
const nextLecture = computed(() => lectures.registeredLectures[0] ?? lectures.all[0]!)
const recommended = computed(() =>
lectures.all.filter(l => !lectures.registeredIds.includes(l.id)).slice(0, 3)
)
const achievements = computed(() => userStore.achievements.filter(a => a.unlocked).slice(0, 3))
const reminders = computed(() => userStore.notifications.slice(0, 3))
const xpToNext = 200
const xpProgress = computed(() => user.value.xp ?? 120)
</script>
<template>
<div class="dashboard page-content">
<div class="dashboard-welcome">
<div>
<h1 class="page-title">Добрый день, {{ user.name.split(' ')[0] }}! 👋</h1>
<p class="text-secondary">{{ user.institute }} · {{ user.direction }} · {{ user.year }} курс</p>
</div>
<div class="quick-actions">
<button class="btn-primary" @click="router.push('/catalog')">Найти лекцию</button>
<button class="btn-secondary" @click="router.push('/my-lectures')">Мои записи</button>
<button class="btn-secondary" @click="router.push(`/review/${nextLecture?.id ?? '1'}`)">Оставить отзыв</button>
</div>
</div>
<GlassCard>
<div class="next-lecture">
<div>
<div class="section-title">Ближайшая лекция</div>
<div class="next-title">{{ nextLecture.title }}</div>
<div class="next-meta">
<span>📅 Завтра, {{ nextLecture.time }}</span>
<span>🏛 {{ nextLecture.building }}, ауд. {{ nextLecture.room ?? 'онлайн' }}</span>
<span>👤 {{ nextLecture.teacher }}</span>
</div>
</div>
<div class="next-actions">
<button class="btn-primary" @click="router.push(`/lecture/${nextLecture.id}`)">Открыть</button>
<button class="btn-secondary">Добавить в календарь</button>
</div>
</div>
</GlassCard>
<div class="stats-row">
<StatsWidget label="Посещено лекций" :value="user.lecturesAttended ?? 12" icon="📚" color="green" />
<StatsWidget label="Часов обучения" :value="user.hoursLearned ?? 18.5" icon="⏱" color="aqua" />
<StatsWidget label="Монет" :value="user.coins" icon="💰" color="orange" />
<StatsWidget label="Уровень" :value="user.level" icon="⭐" color="purple" sub="текущий уровень" />
</div>
<GlassCard>
<div class="xp-section">
<div class="xp-header">
<span class="xp-label">Прогресс до уровня {{ user.level + 1 }}</span>
<span class="xp-val">{{ xpProgress }} / {{ xpToNext }} XP</span>
</div>
<ProgressBar :value="xpProgress" :max="xpToNext" />
</div>
</GlassCard>
<section>
<div class="section-header">
<h2 class="section-title"> Рекомендуемые лекции</h2>
<button class="link-btn" @click="router.push('/catalog')">Все лекции </button>
</div>
<div class="cards-grid">
<LectureCard
v-for="l in recommended"
:key="l.id"
:lecture="l"
:registered="lectures.registeredIds.includes(l.id)"
@register="lectures.register"
/>
</div>
</section>
<section class="two-column">
<GlassCard>
<div class="section-title">🏆 Достижения</div>
<div class="achievements">
<AchievementBadge
v-for="a in achievements"
:key="a.id"
:icon="a.icon"
:title="a.title"
:description="a.description"
:unlocked="a.unlocked"
:unlockedAt="a.unlockedAt"
:coins="a.coins"
/>
</div>
</GlassCard>
<GlassCard>
<div class="section-title">🔔 Напоминания</div>
<div class="reminders">
<div class="reminder-item" v-for="n in reminders" :key="n.id">
<div class="reminder-title">{{ n.title }}</div>
<div class="reminder-body">{{ n.body }}</div>
<div class="reminder-date">{{ new Date(n.createdAt).toLocaleDateString('ru-RU') }}</div>
</div>
</div>
</GlassCard>
</section>
</div>
</template>
<style scoped>
.dashboard { display: flex; flex-direction: column; gap: 24px; }
.dashboard-welcome {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.quick-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.next-lecture { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.next-title { font-size: 18px; font-weight: 700; margin: 6px 0; }
.next-meta { display: flex; flex-direction: column; gap: 4px; color: var(--color-text-secondary); font-size: 13px; }
.next-actions { display: flex; gap: 10px; align-items: flex-start; }
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.xp-section { display: flex; flex-direction: column; gap: 10px; }
.xp-header { display: flex; justify-content: space-between; font-size: 13px; font-weight: 600; }
.xp-val { color: var(--color-text-secondary); }
.section-header { display: flex; align-items: center; justify-content: space-between; }
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-top: 12px;
}
.link-btn {
background: none;
border: none;
color: var(--color-primary-dark);
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.two-column { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.achievements { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.reminders { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.reminder-item {
border-bottom: 1px solid var(--color-border-glass);
padding-bottom: 10px;
}
.reminder-item:last-child { border-bottom: none; padding-bottom: 0; }
.reminder-title { font-weight: 700; margin-bottom: 4px; }
.reminder-body { font-size: 13px; color: var(--color-text-secondary); }
.reminder-date { font-size: 11px; color: var(--color-text-secondary); margin-top: 4px; }
</style>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLecturesStore } from '@/stores/lectures'
import GlassCard from '@/components/ui/GlassCard.vue'
import LectureCard from '@/components/ui/LectureCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const route = useRoute()
const router = useRouter()
const lecturesStore = useLecturesStore()
const lecture = computed(() => lecturesStore.all.find(l => l.id === route.params.id) ?? lecturesStore.all[0]!)
const isRegistered = computed(() => lecturesStore.isRegistered(lecture.value.id))
const attendedLectures = ['1']
const isAttended = computed(() => attendedLectures.includes(lecture.value.id))
const similarLectures = computed(() => lecturesStore.all.filter(l => l.id !== lecture.value.id).slice(0, 3))
</script>
<template>
<div class="lecture-detail page-content">
<div class="header">
<div>
<div class="breadcrumb">Каталог / {{ lecture.title }}</div>
<h1 class="page-title">{{ lecture.title }}</h1>
<p class="text-secondary">{{ lecture.description }}</p>
</div>
<div class="actions">
<button
v-if="!isRegistered"
class="btn-primary"
:disabled="lecture.freeSeats === 0 || lecture.registrationClosed"
@click="lecturesStore.register(lecture.id)"
>
Записаться
</button>
<button v-else class="btn-secondary" @click="lecturesStore.unregister(lecture.id)">Отменить запись</button>
<button class="btn-secondary">Добавить в календарь</button>
<button v-if="isAttended" class="btn-primary" @click="router.push(`/review/${lecture.id}`)">Оставить отзыв</button>
</div>
</div>
<div class="info-grid">
<GlassCard>
<div class="info-section">
<h3>Преподаватель</h3>
<div class="info-value">{{ lecture.teacher }} · {{ lecture.teacherTitle }}</div>
<div class="info-sub">{{ lecture.department }}, {{ lecture.institute }}</div>
</div>
<div class="info-section">
<h3>Детали занятия</h3>
<div class="info-value">📅 {{ new Date(lecture.date).toLocaleDateString('ru-RU') }} · {{ lecture.time }}</div>
<div class="info-sub">Длительность: {{ lecture.duration }} мин</div>
<div class="info-sub">Локация: {{ lecture.building }} {{ lecture.room ? `· ауд. ${lecture.room}` : '' }}</div>
</div>
<div class="info-section">
<h3>Места</h3>
<div class="info-value">Свободно {{ lecture.freeSeats }} из {{ lecture.totalSeats }}</div>
<StatusBadge :status="lecture.registrationClosed ? 'closed' : lecture.freeSeats === 0 ? 'full' : 'open'" />
</div>
<div class="info-section">
<h3>Теги</h3>
<div class="tags">
<span class="tag-chip" v-for="tag in lecture.tags" :key="tag">{{ tag }}</span>
</div>
</div>
</GlassCard>
<GlassCard>
<div class="section-title">LLM-сводка отзывов</div>
<p class="summary">
Студенты отмечают «понятные примеры» и «много практики». Предлагается добавить больше времени на вопросы и
прикладные кейсы. Средняя оценка 4.8/5.
</p>
<div class="reviews">
<div class="review">
<div class="review-head">Анонимный отзыв · 5 </div>
<div class="review-body">Очень структурно, понравились живые примеры и объяснение базовых концепций.</div>
</div>
<div class="review">
<div class="review-head">Анонимный отзыв · 4 </div>
<div class="review-body">Полезно, но хотелось больше времени на практику и разбор домашних заданий.</div>
</div>
</div>
</GlassCard>
</div>
<section>
<h2 class="section-title">Похожие лекции</h2>
<div class="cards-grid">
<LectureCard v-for="l in similarLectures" :key="l.id" :lecture="l" />
</div>
</section>
</div>
</template>
<style scoped>
.lecture-detail { display: flex; flex-direction: column; gap: 24px; }
.breadcrumb { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.info-section { margin-bottom: 16px; }
.info-section:last-child { margin-bottom: 0; }
.info-section h3 { font-size: 14px; margin-bottom: 8px; }
.info-value { font-weight: 700; }
.info-sub { font-size: 13px; color: var(--color-text-secondary); margin-top: 4px; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.summary { font-size: 14px; color: var(--color-text-secondary); line-height: 1.5; }
.reviews { margin-top: 12px; display: flex; flex-direction: column; gap: 10px; }
.review { padding: 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); }
.review-head { font-weight: 600; margin-bottom: 4px; }
.review-body { font-size: 13px; color: var(--color-text-secondary); }
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
</style>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useLecturesStore } from '@/stores/lectures'
import GlassCard from '@/components/ui/GlassCard.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
import ModalDialog from '@/components/ui/ModalDialog.vue'
const lecturesStore = useLecturesStore()
const router = useRouter()
const activeTab = ref<'upcoming' | 'history'>('upcoming')
const cancelModal = ref(false)
const selectedId = ref<string | null>(null)
const upcoming = computed(() =>
lecturesStore.registeredLectures.map(l => ({ ...l, status: 'registered' }))
)
const history = ref([
{ id: '1', title: 'Введение в нейронные сети и глубокое обучение', date: '2025-04-20', time: '14:00', building: 'ИКТИБ', room: '305', status: 'attended' },
{ id: '4', title: 'Философия цифровой эпохи', date: '2025-04-12', time: '18:00', building: 'Онлайн', room: '', status: 'needsReview' },
{ id: '5', title: 'Право в информационном обществе', date: '2025-04-05', time: '15:30', building: 'ЮФ', room: '412', status: 'cancelled' },
])
function openCancel(id: string) {
selectedId.value = id
cancelModal.value = true
}
function confirmCancel() {
if (selectedId.value) lecturesStore.unregister(selectedId.value)
cancelModal.value = false
}
</script>
<template>
<div class="my-lectures page-content">
<div class="header">
<div>
<h1 class="page-title">Мои записи</h1>
<p class="text-secondary">Управляйте регистрациями, экспортируйте расписание и оставляйте отзывы.</p>
</div>
<button class="btn-secondary">Экспорт в календарь</button>
</div>
<div class="tabs">
<button :class="{ active: activeTab === 'upcoming' }" @click="activeTab = 'upcoming'">Предстоящие</button>
<button :class="{ active: activeTab === 'history' }" @click="activeTab = 'history'">История</button>
</div>
<div v-if="activeTab === 'upcoming'" class="list">
<GlassCard v-for="item in upcoming" :key="item.id" class="lecture-row">
<div>
<div class="lecture-title">{{ item.title }}</div>
<div class="lecture-meta">📅 {{ new Date(item.date).toLocaleDateString('ru-RU') }} · {{ item.time }}</div>
<div class="lecture-meta">🏛 {{ item.building }} {{ item.room ? `· ауд. ${item.room}` : '' }}</div>
</div>
<div class="lecture-actions">
<StatusBadge status="registered" />
<button class="btn-secondary btn-sm">Добавить в календарь</button>
<button class="btn-danger btn-sm" @click="openCancel(item.id)">Отменить</button>
</div>
</GlassCard>
</div>
<div v-else class="list">
<GlassCard v-for="item in history" :key="item.id" class="lecture-row">
<div>
<div class="lecture-title">{{ item.title }}</div>
<div class="lecture-meta">📅 {{ new Date(item.date).toLocaleDateString('ru-RU') }} · {{ item.time }}</div>
<div class="lecture-meta">🏛 {{ item.building }} {{ item.room ? `· ауд. ${item.room}` : '' }}</div>
</div>
<div class="lecture-actions">
<StatusBadge :status="item.status" />
<button v-if="item.status === 'needsReview'" class="btn-primary btn-sm" @click="router.push(`/review/${item.id}`)">
Оставить отзыв
</button>
</div>
</GlassCard>
</div>
<ModalDialog v-model="cancelModal" title="Отменить запись?">
<p>Вы уверены, что хотите отменить запись на лекцию? Место будет освобождено для других студентов.</p>
<template #footer>
<button class="btn-secondary" @click="cancelModal = false">Нет</button>
<button class="btn-danger" @click="confirmCancel">Да, отменить</button>
</template>
</ModalDialog>
</div>
</template>
<style scoped>
.my-lectures { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.tabs { display: inline-flex; border: 1px solid var(--color-border-glass); border-radius: 12px; overflow: hidden; }
.tabs button {
background: rgba(255,255,255,0.7);
border: none;
padding: 8px 18px;
font-size: 13px;
cursor: pointer;
color: var(--color-text-secondary);
}
.tabs button.active { background: rgba(34,197,94,0.18); color: var(--color-primary-dark); font-weight: 600; }
.list { display: flex; flex-direction: column; gap: 12px; }
.lecture-row { display: flex; justify-content: space-between; gap: 12px; align-items: center; flex-wrap: wrap; }
.lecture-title { font-weight: 700; margin-bottom: 4px; }
.lecture-meta { font-size: 13px; color: var(--color-text-secondary); }
.lecture-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
</style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
const userStore = useUserStore()
const grouped = computed(() => {
const map: Record<string, typeof userStore.notifications> = {}
userStore.notifications.forEach(n => {
const day = new Date(n.createdAt).toLocaleDateString('ru-RU')
map[day] = map[day] || []
map[day].push(n)
})
return Object.entries(map)
})
const typeIcon: Record<string, string> = {
reminder: '⏰',
'schedule-change': '🗓️',
achievement: '🏆',
coins: '💰',
recommendation: '✨',
}
</script>
<template>
<div class="notifications page-content">
<div class="header">
<h1 class="page-title">Уведомления</h1>
<button class="btn-secondary" @click="userStore.markAllRead">Отметить все как прочитанные</button>
</div>
<div class="notification-groups">
<GlassCard v-for="([day, items]) in grouped" :key="day" class="group">
<div class="group-title">{{ day }}</div>
<div class="items">
<div v-for="n in items" :key="n.id" class="item" :class="{ unread: !n.read }">
<div class="icon">{{ typeIcon[n.type] }}</div>
<div>
<div class="item-title">{{ n.title }}</div>
<div class="item-body">{{ n.body }}</div>
</div>
</div>
</div>
</GlassCard>
</div>
</div>
</template>
<style scoped>
.notifications { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.notification-groups { display: flex; flex-direction: column; gap: 14px; }
.group-title { font-weight: 700; margin-bottom: 10px; }
.items { display: flex; flex-direction: column; gap: 10px; }
.item { display: flex; gap: 12px; padding: 10px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); }
.item.unread { border-color: rgba(34,197,94,0.4); background: rgba(34,197,94,0.08); }
.icon { font-size: 20px; }
.item-title { font-weight: 600; }
.item-body { font-size: 13px; color: var(--color-text-secondary); }
</style>
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import GlassCard from '@/components/ui/GlassCard.vue'
import CoinChip from '@/components/ui/CoinChip.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import AchievementBadge from '@/components/ui/AchievementBadge.vue'
import DataTable from '@/components/ui/DataTable.vue'
const auth = useAuthStore()
const userStore = useUserStore()
const user = computed(() => auth.user!)
const interestTags = ref([
{ label: '#ML', active: true },
{ label: '#ИИ', active: true },
{ label: '#дизайн', active: false },
{ label: '#право', active: false },
{ label: '#биоинформатика', active: false },
{ label: '#маркетинг', active: true },
])
const notificationSettings = ref({ email: true, web: true, telegram: false })
const historyColumns = [
{ key: 'date', label: 'Дата' },
{ key: 'description', label: 'Описание' },
{ key: 'amount', label: 'Монеты', align: 'right' },
]
</script>
<template>
<div class="profile page-content">
<div class="header">
<h1 class="page-title">Профиль студента</h1>
<CoinChip :amount="user.coins" />
</div>
<div class="profile-grid">
<GlassCard>
<div class="user-info">
<div class="avatar">👤</div>
<div>
<div class="name">{{ user.name }}</div>
<div class="email">{{ user.email }}</div>
<div class="meta">{{ user.institute }} · {{ user.direction }}</div>
<div class="meta">{{ user.year }} курс</div>
</div>
</div>
<div class="level">
<div class="level-header">
<span>Уровень {{ user.level }}</span>
<span>{{ user.xp }} / 200 XP</span>
</div>
<ProgressBar :value="user.xp ?? 0" :max="200" />
</div>
<div class="tags">
<div class="section-title">Интересы</div>
<div class="tags-grid">
<button
v-for="tag in interestTags"
:key="tag.label"
class="tag-chip"
:class="{ active: tag.active }"
@click="tag.active = !tag.active"
>
{{ tag.label }}
</button>
</div>
</div>
</GlassCard>
<GlassCard>
<div class="section-title">Настройки уведомлений</div>
<div class="settings">
<label class="setting">
<input type="checkbox" v-model="notificationSettings.email" />
Email уведомления
</label>
<label class="setting">
<input type="checkbox" v-model="notificationSettings.web" />
Web push
</label>
<label class="setting">
<input type="checkbox" v-model="notificationSettings.telegram" />
Telegram бот @universe_sfedu
</label>
</div>
<div class="section-title">Достижения</div>
<div class="achievements">
<AchievementBadge
v-for="a in userStore.achievements.slice(0, 3)"
:key="a.id"
:icon="a.icon"
:title="a.title"
:description="a.description"
:unlocked="a.unlocked"
:unlockedAt="a.unlockedAt"
:coins="a.coins"
/>
</div>
</GlassCard>
</div>
<GlassCard>
<div class="section-title">История начисления монет</div>
<DataTable :columns="historyColumns" :rows="userStore.coinHistory">
<template #amount="{ value }">
<span :class="value > 0 ? 'positive' : 'negative'">{{ value > 0 ? `+${value}` : value }}</span>
</template>
</DataTable>
</GlassCard>
</div>
</template>
<style scoped>
.profile { display: flex; flex-direction: column; gap: 20px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.profile-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
.user-info { display: flex; gap: 14px; align-items: center; margin-bottom: 16px; }
.avatar { font-size: 38px; background: rgba(34,197,94,0.15); border-radius: 16px; padding: 12px; }
.name { font-weight: 700; font-size: 18px; }
.email, .meta { font-size: 13px; color: var(--color-text-secondary); }
.level { margin: 16px 0; }
.level-header { display: flex; justify-content: space-between; font-size: 12px; color: var(--color-text-secondary); margin-bottom: 6px; }
.tags-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.settings { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.setting { font-size: 13px; color: var(--color-text-secondary); display: flex; gap: 8px; align-items: center; }
.achievements { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.positive { color: #166534; font-weight: 600; }
.negative { color: #991B1B; font-weight: 600; }
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import GlassCard from '@/components/ui/GlassCard.vue'
const route = useRoute()
const rating = ref<'positive' | 'neutral' | 'negative'>('positive')
const text = ref('Лекция была хорошо структурирована, особенно понравились практические примеры и разбор кейсов.')
const submitted = ref(false)
const editing = ref(false)
function submit() {
submitted.value = true
editing.value = false
}
</script>
<template>
<div class="review page-content">
<div class="header">
<div>
<h1 class="page-title">Отзыв о лекции #{{ route.params.id }}</h1>
<p class="text-secondary">Ваш отзыв помогает улучшать качество лекций и приносит бонусные монеты.</p>
</div>
</div>
<GlassCard>
<div v-if="submitted && !editing" class="success-state">
<div class="success-icon"></div>
<div class="success-title">Отзыв отправлен и будет обработан</div>
<div class="success-sub">
Спасибо! Оценка полезности отзыва рассчитывается автоматически. Студентам не показывается техническая оценка LLM.
</div>
<button class="btn-secondary" @click="editing = true">Редактировать отзыв</button>
</div>
<form v-else @submit.prevent="submit" class="form">
<label class="field-label">Ваш отзыв о лекции</label>
<textarea v-model="text" rows="6" placeholder="Опишите, что было полезно, а что можно улучшить"></textarea>
<label class="field-label">Оценка впечатлений</label>
<div class="rating-options">
<button type="button" :class="{ active: rating === 'positive' }" @click="rating = 'positive'">👍 Положительный</button>
<button type="button" :class="{ active: rating === 'neutral' }" @click="rating = 'neutral'">😐 Нейтральный</button>
<button type="button" :class="{ active: rating === 'negative' }" @click="rating = 'negative'">👎 Отрицательный</button>
</div>
<div class="hint">
💡 Постарайтесь быть конкретными: что понравилось, где было сложно, какие темы хотите раскрыть глубже.
</div>
<div class="form-actions">
<button class="btn-primary" type="submit">Отправить отзыв</button>
<button class="btn-secondary" type="button" :disabled="submitted">Сохранить черновик</button>
</div>
</form>
</GlassCard>
</div>
</template>
<style scoped>
.review { display: flex; flex-direction: column; gap: 16px; }
.form { display: flex; flex-direction: column; gap: 12px; }
.field-label { font-weight: 600; font-size: 13px; color: var(--color-text-secondary); }
textarea {
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.8);
font-size: 14px;
resize: vertical;
}
.rating-options { display: flex; gap: 10px; flex-wrap: wrap; }
.rating-options button {
padding: 8px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-glass);
background: rgba(255,255,255,0.6);
cursor: pointer;
font-size: 13px;
}
.rating-options button.active {
border-color: var(--color-primary);
background: rgba(34,197,94,0.15);
color: var(--color-primary-dark);
font-weight: 600;
}
.hint { font-size: 12px; color: var(--color-text-secondary); background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: var(--radius-sm); }
.form-actions { display: flex; gap: 10px; }
.success-state { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.success-icon { font-size: 28px; }
.success-title { font-size: 16px; font-weight: 700; }
.success-sub { font-size: 13px; color: var(--color-text-secondary); }
</style>
@@ -0,0 +1,94 @@
<script setup lang="ts">
import GlassCard from '@/components/ui/GlassCard.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
const ratingTrend = [4.2, 4.5, 4.6, 4.8, 4.7]
</script>
<template>
<div class="teacher-analytics page-content">
<h1 class="page-title">Аналитика преподавателя</h1>
<div class="grid">
<GlassCard>
<div class="section-title">Динамика оценок</div>
<div class="chart">
<div v-for="(value, i) in ratingTrend" :key="i" class="bar">
<div class="bar-fill" :style="{ height: `${value * 18}px` }"></div>
<span class="bar-label">Нед {{ i + 1 }}</span>
</div>
</div>
<div class="avg">Средняя оценка: 4.6</div>
</GlassCard>
<GlassCard>
<div class="section-title">Sentiment-анализ отзывов</div>
<div class="sentiment">
<div>
<div class="sentiment-label">Позитивные 65%</div>
<ProgressBar :value="65" :max="100" />
</div>
<div>
<div class="sentiment-label">Нейтральные 25%</div>
<ProgressBar :value="25" :max="100" color="linear-gradient(90deg, #7DD3FC, #BAE6FD)" />
</div>
<div>
<div class="sentiment-label">Негативные 10%</div>
<ProgressBar :value="10" :max="100" color="linear-gradient(90deg, #FCA5A5, #FECACA)" />
</div>
</div>
</GlassCard>
</div>
<GlassCard>
<div class="section-title">LLM-сводка проблем и рекомендаций</div>
<p class="summary">
Студенты отмечают сильную практическую часть, но хотят больше разборов вопросов из аудитории.
Рекомендуется добавить блок с кейсами из реальных проектов и увеличить время на интерактив.
</p>
<div class="tags">
<span class="tag-chip">много практики</span>
<span class="tag-chip">понятные примеры</span>
<span class="tag-chip">сложный материал</span>
<span class="tag-chip">нужны задания</span>
</div>
</GlassCard>
<GlassCard>
<div class="section-title">Анонимные отзывы</div>
<div class="reviews">
<div class="review">
«Больше кейсов и примеров из реальной жизни, лекция очень понравилась»
</div>
<div class="review">
«Темп быстрый, но структура отличная. Хотелось бы больше практических заданий.»
</div>
<div class="review">
«Отличные слайды и примеры, спасибо за доступное объяснение сложных тем.»
</div>
</div>
<div class="section-title">Топ полезных отзывов</div>
<ul class="top-list">
<li>«Лабораторная часть помогла понять алгоритмы, пожалуйста, добавьте еще 15 минут»</li>
<li>«Понравились интерактивные задания, хочется больше времени на Q&A»</li>
</ul>
</GlassCard>
</div>
</template>
<style scoped>
.teacher-analytics { display: flex; flex-direction: column; gap: 18px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; }
.chart { display: flex; gap: 12px; align-items: flex-end; height: 160px; padding: 10px 0; }
.bar { display: flex; flex-direction: column; align-items: center; gap: 6px; }
.bar-fill { width: 26px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg, #22C55E, #86EFAC); }
.bar-label { font-size: 11px; color: var(--color-text-secondary); }
.avg { margin-top: 6px; font-weight: 600; }
.sentiment { display: flex; flex-direction: column; gap: 12px; }
.sentiment-label { font-size: 12px; color: var(--color-text-secondary); margin-bottom: 4px; }
.summary { font-size: 14px; color: var(--color-text-secondary); line-height: 1.5; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.reviews { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.review { background: rgba(255,255,255,0.6); border: 1px solid var(--color-border-glass); padding: 10px; border-radius: var(--radius-sm); font-size: 13px; }
.top-list { padding-left: 18px; color: var(--color-text-secondary); font-size: 13px; }
</style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLecturesStore } from '@/stores/lectures'
import GlassCard from '@/components/ui/GlassCard.vue'
import StatsWidget from '@/components/ui/StatsWidget.vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
const lecturesStore = useLecturesStore()
const upcoming = computed(() => lecturesStore.all.slice(0, 3))
</script>
<template>
<div class="teacher-dashboard page-content">
<div class="header">
<h1 class="page-title">Дашборд преподавателя</h1>
<div class="actions">
<button class="btn-primary">Анонсировать лекцию</button>
<button class="btn-secondary">Посмотреть отзывы</button>
<button class="btn-secondary">Отметить посещение</button>
</div>
</div>
<div class="stats-row">
<StatsWidget label="Предстоящие лекции" :value="3" icon="📅" color="green" />
<StatsWidget label="Записавшихся" :value="47" icon="👥" color="aqua" />
<StatsWidget label="Средняя оценка" :value="4.6" icon="⭐" color="orange" />
<StatsWidget label="Вовлеченность вне направления" :value="'38%'" icon="🌍" color="purple" />
</div>
<GlassCard>
<div class="section-title">Заметность за пределами направления</div>
<div class="visibility">
<div class="visibility-meta">
38% студентов из других институтов · Цель 50%
</div>
<ProgressBar :value="38" :max="100" />
</div>
</GlassCard>
<GlassCard>
<div class="section-title">Ближайшие открытые лекции</div>
<div class="upcoming">
<div class="upcoming-item" v-for="l in upcoming" :key="l.id">
<div>
<div class="upcoming-title">{{ l.title }}</div>
<div class="upcoming-meta">📅 {{ new Date(l.date).toLocaleDateString('ru-RU') }} · {{ l.time }}</div>
<div class="upcoming-meta">Записалось {{ l.totalSeats - l.freeSeats }} студентов</div>
</div>
<button class="btn-secondary btn-sm">Управлять</button>
</div>
</div>
</GlassCard>
</div>
</template>
<style scoped>
.teacher-dashboard { display: flex; flex-direction: column; gap: 18px; }
.header { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.actions { display: flex; gap: 10px; flex-wrap: wrap; }
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
.visibility { display: flex; flex-direction: column; gap: 8px; }
.visibility-meta { font-size: 13px; color: var(--color-text-secondary); }
.upcoming { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
.upcoming-item { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding-bottom: 10px; border-bottom: 1px solid var(--color-border-glass); }
.upcoming-item:last-child { border-bottom: none; padding-bottom: 0; }
.upcoming-title { font-weight: 700; }
.upcoming-meta { font-size: 13px; color: var(--color-text-secondary); }
.btn-sm { padding: 6px 12px; font-size: 12px; }
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import GlassCard from '@/components/ui/GlassCard.vue'
import DataTable from '@/components/ui/DataTable.vue'
import StatusBadge from '@/components/ui/StatusBadge.vue'
const columns = [
{ key: 'title', label: 'Лекция' },
{ key: 'date', label: 'Дата' },
{ key: 'status', label: 'Статус', align: 'center' },
{ key: 'stats', label: 'Записи/Посещения/Отзывы', align: 'center' },
{ key: 'actions', label: 'Действия', align: 'right' },
]
const rows = [
{ id: '1', title: 'Введение в нейронные сети', date: '07.05 · 14:00', status: 'upcoming', stats: '28 / — / —' },
{ id: '2', title: 'Алгоритмы глубокого обучения', date: '08.05 · 16:00', status: 'ongoing', stats: '31 / 22 / 15' },
{ id: '3', title: 'Практика по ML в бизнесе', date: '01.05 · 12:00', status: 'completed', stats: '45 / 39 / 27' },
]
</script>
<template>
<div class="teacher-lectures page-content">
<div class="header">
<h1 class="page-title">Мои лекции</h1>
<button class="btn-primary">Создать лекцию</button>
</div>
<GlassCard>
<DataTable :columns="columns" :rows="rows">
<template #status="{ value }">
<StatusBadge :status="value" />
</template>
<template #actions>
<div class="actions">
<button class="btn-ghost">Редактировать</button>
<button class="btn-ghost">Открыть/закрыть запись</button>
<button class="btn-ghost">Список записавшихся</button>
<button class="btn-ghost">Отметить посещение</button>
</div>
</template>
</DataTable>
</GlassCard>
</div>
</template>
<style scoped>
.teacher-lectures { display: flex; flex-direction: column; gap: 16px; }
.header { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.actions { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
</style>