Compare commits
98 Commits
1ab4975c1c
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 606d515ade | |||
| 4174ad304a | |||
| 862c918981 | |||
| bb8fca352a | |||
| ffa80a19ad | |||
| c17f91b903 | |||
| b3c51ff4de | |||
| 3fc1e188ef | |||
| a1c19cba3e | |||
| 66890b8a62 | |||
| 115f2498cd | |||
| a43b23aad9 | |||
| 39d6ced46d | |||
| ec6a5dc709 | |||
| 33cde1d28b | |||
| 2241c33902 | |||
| 489e9c0bec | |||
| d5a05fe7cd | |||
| 92c72ba42f | |||
| d9c5791c68 | |||
| 33501fe4c7 | |||
| 491a69e980 | |||
| d4ad314093 | |||
| d84539f1d0 | |||
| f8b4b6c6a8 | |||
| a71c1c9f6d | |||
| e463c0103e | |||
| 5629333ce3 | |||
| 8ccc17a03a | |||
| 1d6e2fe478 | |||
| 47d43d61a8 | |||
| fc5fcd3ade | |||
| 72efa66a2f | |||
| 762858807c | |||
| f88818dbf9 | |||
| c51cfe0620 | |||
| d1aac40b7f | |||
| a392d1a928 | |||
| 90d1837565 | |||
| 12edf754bc | |||
| 9b3f43da98 | |||
| c04ae2868c | |||
| 5d3044abb9 | |||
| 035bf4c30d | |||
| 1f3d1bb284 | |||
| 6d5766092b | |||
| 4663493c66 | |||
| a7502ab771 | |||
| 48c6315697 | |||
| 93b131dbca | |||
| e7b7c1180b | |||
| e1753fd450 | |||
| 91ac1a43c8 | |||
| 16a107827a | |||
| e9cc3e3007 | |||
| 18aa914497 | |||
| c45b53e400 | |||
| 7d19ca800c | |||
| 7d04e5a1cd | |||
| 9bcfe42683 | |||
| e9e0d4e739 | |||
| 671105c3fb | |||
| 084455b982 | |||
| 3b6852834d | |||
| 32c2dae619 | |||
| beefb549d0 | |||
| 4086f7a676 | |||
| 0dffd01241 | |||
| 2570d71f72 | |||
| c64955928e | |||
| 19f3277207 | |||
| 2f42977113 | |||
| 7db0c6f248 | |||
| 04f516a9ea | |||
| 16114bb134 | |||
| db558c87d1 | |||
| c314e4f329 | |||
| 301cfcd61b | |||
| 34520a95d4 | |||
| a0dfa65be8 | |||
| b49074334d | |||
| 83b3ae5dfe | |||
| a97993c70b | |||
| 2599be3f53 | |||
| 3f8b8ba432 | |||
| 75efb4cbf0 | |||
| 0b8cbfc138 | |||
| 7ce91ecd49 | |||
| c1a113b849 | |||
| b81000249b | |||
| 28f1e43338 | |||
| 0731123fbb | |||
| ad68ccc297 | |||
| 6b902cad98 | |||
| f368619471 | |||
| f36e1c2ce7 | |||
| 63e83d87ed | |||
| 083a856111 |
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
VITE_APP_TITLE=Вечерний детектив
|
||||||
@@ -9,7 +9,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
# dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
coverage
|
coverage
|
||||||
*.local
|
*.local
|
||||||
@@ -28,3 +28,5 @@ coverage
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.env
|
||||||
|
|||||||
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Вечерний детектив</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-CNblC4ZU.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BlFsLzpe.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
evening_detective_frontend:
|
||||||
|
image: byjg/static-httpserver:latest
|
||||||
|
container_name: evening_detective_frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./dist:/static
|
||||||
|
networks:
|
||||||
|
- crabs-network
|
||||||
|
ports:
|
||||||
|
- "8100:8080"
|
||||||
|
environment:
|
||||||
|
- SPA_MODE=true
|
||||||
|
labels:
|
||||||
|
reproxy.1.server: "evening-detective.crabs-games.art"
|
||||||
|
reproxy.1.route: "/(.*)"
|
||||||
|
reproxy.1.dest: "http://evening_detective_frontend:8100/$$1"
|
||||||
|
reproxy.1.port: "8100"
|
||||||
|
reproxy.1.ping: "/"
|
||||||
|
reproxy.1.priority: 100
|
||||||
|
|
||||||
|
networks:
|
||||||
|
crabs-network:
|
||||||
|
name: crabs-network
|
||||||
|
external: true
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { globalIgnores } from 'eslint/config'
|
|
||||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
|
||||||
import pluginVue from 'eslint-plugin-vue'
|
|
||||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||||
|
import unusedImports from 'eslint-plugin-unused-imports';
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
|
||||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
@@ -19,4 +21,36 @@ export default defineConfigWithVueTs(
|
|||||||
pluginVue.configs['flat/essential'],
|
pluginVue.configs['flat/essential'],
|
||||||
vueTsConfigs.recommended,
|
vueTsConfigs.recommended,
|
||||||
skipFormatting,
|
skipFormatting,
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
'simple-import-sort': simpleImportSort,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Основные правила сортировки
|
||||||
|
'simple-import-sort/imports': 'error',
|
||||||
|
'simple-import-sort/exports': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
'unused-imports': unusedImports,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// Отключаем стандартное правило
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
|
||||||
|
// Включаем правила плагина
|
||||||
|
'unused-imports/no-unused-imports': 'error', // 🔧 автофикс
|
||||||
|
'unused-imports/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
vars: 'all',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
args: 'after-used',
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>Вечерний детектив</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -4,14 +4,15 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --host",
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"build-only": "vite build",
|
"build-only": "vite build",
|
||||||
"type-check": "vue-tsc --build",
|
"type-check": "vue-tsc --build",
|
||||||
"lint": "eslint . --fix",
|
"lint": "eslint . --fix",
|
||||||
|
"lint:css": "stylelint \"src/**/*.{css,scss,vue}\" --fix",
|
||||||
"format": "prettier --write src/",
|
"format": "prettier --write src/",
|
||||||
"proto:gen": "protoc --plugin=protoc-gen-ts_proto=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=src/api/generated --ts_proto_opt=outputEncodeMethods=true,outputDecodeMethods=true,outputClientImpl=false,esModuleInterop=true,useExactTypes=false,addGrpcMetadata=false,useOptionals=all --proto_path=proto proto/main.proto"
|
"proto:gen": "protoc --typescript-http_out=src/api/generated --proto_path=proto proto/main.proto"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
@@ -22,17 +23,33 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "^22.0.5",
|
"@tsconfig/node22": "^22.0.5",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.1.1",
|
||||||
|
"@vicons/antd": "^0.13.0",
|
||||||
|
"@vicons/carbon": "^0.13.0",
|
||||||
|
"@vicons/fa": "^0.13.0",
|
||||||
|
"@vicons/fluent": "^0.13.0",
|
||||||
|
"@vicons/ionicons4": "^0.13.0",
|
||||||
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
|
"@vicons/material": "^0.13.0",
|
||||||
|
"@vicons/tabler": "^0.13.0",
|
||||||
|
"@vicons/utils": "^0.1.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.7",
|
"@vitejs/plugin-vue": "^6.0.7",
|
||||||
"@vue/eslint-config-prettier": "^10.2.0",
|
"@vue/eslint-config-prettier": "^10.2.0",
|
||||||
"@vue/eslint-config-typescript": "^14.9.0",
|
"@vue/eslint-config-typescript": "^14.9.0",
|
||||||
"@vue/tsconfig": "^0.9.1",
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"eslint": "^10.6.0",
|
"eslint": "^10.6.0",
|
||||||
|
"eslint-plugin-simple-import-sort": "^14.0.0",
|
||||||
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"eslint-plugin-vue": "~10.9.2",
|
"eslint-plugin-vue": "~10.9.2",
|
||||||
"jiti": "^2.7.0",
|
"jiti": "^2.7.0",
|
||||||
|
"naive-ui": "^2.44.1",
|
||||||
"npm-run-all2": "^9.0.2",
|
"npm-run-all2": "^9.0.2",
|
||||||
|
"postcss-html": "^1.8.1",
|
||||||
"prettier": "3.9.4",
|
"prettier": "3.9.4",
|
||||||
|
"stylelint": "^17.14.1",
|
||||||
|
"stylelint-config-standard-scss": "^17.0.0",
|
||||||
"ts-proto": "^2.12.0",
|
"ts-proto": "^2.12.0",
|
||||||
"typescript": "~6.0.3",
|
"typescript": "~6.0.3",
|
||||||
|
"vfonts": "^0.0.3",
|
||||||
"vite": "^8.1.3",
|
"vite": "^8.1.3",
|
||||||
"vite-plugin-vue-devtools": "^8.1.5",
|
"vite-plugin-vue-devtools": "^8.1.5",
|
||||||
"vue-tsc": "^3.3.6"
|
"vue-tsc": "^3.3.6"
|
||||||
|
|||||||
@@ -115,6 +115,47 @@ service EveningDetectiveServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rpc DeleteAccount(DeleteAccountReq) returns (DeleteAccountRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
delete: "/api/auth/delete-account"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Регистрация и вход";
|
||||||
|
summary : "Удалить учётную запись и персональные данные";
|
||||||
|
description: "Удаляет учётную запись и персональные данные текущего пользователя (ст. 21 152-ФЗ). Требует заголовок X-Password с паролем учётной записи (подтверждение владельца; пароль не передаётся в URL).";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc GetTerms(GetTermsReq) returns (GetTermsRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/terms"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Документы";
|
||||||
|
summary: "Получить текст Пользовательского соглашения (оферты)";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc GetPrivacy(GetPrivacyReq) returns (GetPrivacyRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/privacy"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Документы";
|
||||||
|
summary: "Получить текст Политики конфиденциальности";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc GetConsent(GetConsentReq) returns (GetConsentRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/consent"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Документы";
|
||||||
|
summary: "Получить текст Согласия на обработку персональных данных";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
rpc GetUsers(GetUsersReq) returns (GetUsersRsp) {
|
rpc GetUsers(GetUsersReq) returns (GetUsersRsp) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
get: "/api/users"
|
get: "/api/users"
|
||||||
@@ -219,6 +260,26 @@ service EveningDetectiveServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rpc GetScenariosCatalog(GetScenariosCatalogReq) returns (GetScenariosCatalogRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/catalog"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Сценарии";
|
||||||
|
summary: "Получить каталог сценариев";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc GetFullScenario(GetScenarioReq) returns (GetScenarioRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/scenarios/{id}/full"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Сценарии";
|
||||||
|
summary: "Получить сценарий со всеми деталями по id";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
rpc GetScenario(GetScenarioReq) returns (GetScenarioRsp) {
|
rpc GetScenario(GetScenarioReq) returns (GetScenarioRsp) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
get: "/api/scenarios/{id}"
|
get: "/api/scenarios/{id}"
|
||||||
@@ -240,6 +301,28 @@ service EveningDetectiveServer {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rpc PublicScenario(PublicScenarioReq) returns (PublicScenarioRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/scenarios/{id}/public"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Сценарии";
|
||||||
|
summary: "Опубликовать сценарий";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc DraftScenario(DraftScenarioReq) returns (DraftScenarioRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/scenarios/{id}/draft"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Сценарии";
|
||||||
|
summary: "Снять с публикации сценарий";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
rpc DeleteScenario(DeleteScenarioReq) returns (DeleteScenarioRsp) {
|
rpc DeleteScenario(DeleteScenarioReq) returns (DeleteScenarioRsp) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
delete: "/api/scenarios/{id}"
|
delete: "/api/scenarios/{id}"
|
||||||
@@ -281,6 +364,214 @@ service EveningDetectiveServer {
|
|||||||
summary: "Удалить точку сценария";
|
summary: "Удалить точку сценария";
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rpc DownloadScenarioArchive(DownloadScenarioArchiveReq) returns (google.api.HttpBody) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/scenarios/{id}/archive"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Сценарии";
|
||||||
|
summary: "Скачать сценарий архивом (со всеми материалами и картинками)";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc UploadScenarioArchive(google.api.HttpBody) returns (UploadScenarioArchiveRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
post: "/api/scenarios/archive"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Сценарии";
|
||||||
|
summary: "Создать сценарий из архива";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc AddGame(AddGameReq) returns (AddGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
post: "/api/games"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Создать игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc GetGames(GetGamesReq) returns (GetGamesRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/games"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Получить игры";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc GetGame(GetGameReq) returns (GetGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/games/{id}"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Получить игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc UpdateGame(UpdateGameReq) returns (UpdateGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/games/{id}"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Обновить игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc StartGame(StartGameReq) returns (StartGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/games/{id}/start"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Начать игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc PauseGame(PauseGameReq) returns (PauseGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/games/{id}/pause"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Остановить игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc PlayGame(PlayGameReq) returns (PlayGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/games/{id}/play"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Продолжить игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc FinishGame(FinishGameReq) returns (FinishGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/games/{id}/finish"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Закончить игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc ResetGame(ResetGameReq) returns (ResetGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/games/{id}/reset"
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Сбросить игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc DeleteGame(DeleteGameReq) returns (DeleteGameRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
delete: "/api/games/{id}"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Игры";
|
||||||
|
summary: "Удалить игру";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc AddTeam(AddTeamReq) returns (AddTeamRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
post: "/api/teams",
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Команда";
|
||||||
|
summary: "Создать команду";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc UpdateTeam(UpdateTeamReq) returns (UpdateTeamRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
put : "/api/teams/{id}",
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Команда";
|
||||||
|
summary: "Обновить команду";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc DeleteTeam(DeleteTeamReq) returns (DeleteTeamRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
delete: "/api/teams/{id}"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Команда";
|
||||||
|
summary: "Удалить команду";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc GiveTeamApplications(GiveTeamApplicationsReq) returns (GiveTeamApplicationsRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
post: "/api/teams/{id}/applications",
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Команда";
|
||||||
|
summary: "Выдать улику";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc GetTeamStory(GetTeamStoryReq) returns (GetTeamStoryRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/teams/{id}/story"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
security: {
|
||||||
|
security_requirement: {}
|
||||||
|
}
|
||||||
|
tags : "Ходы";
|
||||||
|
summary: "Получить ходы";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc AddTeamAction(AddTeamActionReq) returns (AddTeamActionRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
post: "/api/teams/{id}/actions",
|
||||||
|
body: "*"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
security: {
|
||||||
|
security_requirement: {}
|
||||||
|
}
|
||||||
|
tags : "Ходы";
|
||||||
|
summary: "Сделать ход";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc DeleteLastTeamAction(DeleteLastTeamActionReq) returns (DeleteLastTeamActionRsp) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
delete: "/api/teams/{id}/last-actions"
|
||||||
|
};
|
||||||
|
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
|
||||||
|
tags : "Ходы";
|
||||||
|
summary: "Удалить последний ход";
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message PingReq {}
|
message PingReq {}
|
||||||
@@ -296,8 +587,10 @@ message EchoRsp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message SignupReq {
|
message SignupReq {
|
||||||
string username = 1;
|
string username = 1;
|
||||||
string email = 2;
|
string email = 2;
|
||||||
|
bool accept_terms = 3;
|
||||||
|
bool accept_privacy = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SignupRsp {
|
message SignupRsp {
|
||||||
@@ -333,6 +626,39 @@ message RefreshRsp {
|
|||||||
string refreshToken = 3;
|
string refreshToken = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Пароль подтверждает, что удаляет аккаунт его владелец, а не обладатель
|
||||||
|
// украденного access-токена (ст. 21 152-ФЗ). Передаётся HTTP-заголовком
|
||||||
|
// X-Password (пробрасывается матчером в cmd/evening_detective_server/main.go),
|
||||||
|
// чтобы креденшел не попадал в URL/query-параметры и логи.
|
||||||
|
message DeleteAccountReq {}
|
||||||
|
|
||||||
|
message DeleteAccountRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Документы раздаются по постоянным URL, на которые ссылается сервис при
|
||||||
|
// фиксации акцептов соглашений (user_agreements) и форма регистрации.
|
||||||
|
message GetTermsReq {}
|
||||||
|
|
||||||
|
message GetTermsRsp {
|
||||||
|
string error = 1;
|
||||||
|
string text = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetPrivacyReq {}
|
||||||
|
|
||||||
|
message GetPrivacyRsp {
|
||||||
|
string error = 1;
|
||||||
|
string text = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetConsentReq {}
|
||||||
|
|
||||||
|
message GetConsentRsp {
|
||||||
|
string error = 1;
|
||||||
|
string text = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message GetUsersReq {}
|
message GetUsersReq {}
|
||||||
|
|
||||||
message GetUsersRsp {
|
message GetUsersRsp {
|
||||||
@@ -420,6 +746,13 @@ message GetMyScenariosRsp {
|
|||||||
repeated Scenario scenarios = 2;
|
repeated Scenario scenarios = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetScenariosCatalogReq {}
|
||||||
|
|
||||||
|
message GetScenariosCatalogRsp {
|
||||||
|
string error = 1;
|
||||||
|
repeated Scenario scenarios = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message GetScenarioReq {
|
message GetScenarioReq {
|
||||||
int32 id = 1;
|
int32 id = 1;
|
||||||
}
|
}
|
||||||
@@ -435,10 +768,11 @@ message Scenario {
|
|||||||
optional string description = 3;
|
optional string description = 3;
|
||||||
optional string image = 4;
|
optional string image = 4;
|
||||||
Story story = 5;
|
Story story = 5;
|
||||||
User author = 6;
|
string status = 6;
|
||||||
google.protobuf.Timestamp updated_at = 7;
|
User author = 7;
|
||||||
google.protobuf.Timestamp created_at = 8;
|
google.protobuf.Timestamp updated_at = 8;
|
||||||
google.protobuf.Timestamp published_at = 9;
|
google.protobuf.Timestamp created_at = 9;
|
||||||
|
google.protobuf.Timestamp published_at = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Story {
|
message Story {
|
||||||
@@ -482,6 +816,22 @@ message UpdateScenarioRsp {
|
|||||||
string error = 1;
|
string error = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message PublicScenarioReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PublicScenarioRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DraftScenarioReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DraftScenarioRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message DeleteScenarioReq {
|
message DeleteScenarioReq {
|
||||||
int32 id = 1;
|
int32 id = 1;
|
||||||
}
|
}
|
||||||
@@ -517,3 +867,187 @@ message DeleteScenarioPlaceReq {
|
|||||||
message DeleteScenarioPlaceRsp {
|
message DeleteScenarioPlaceRsp {
|
||||||
string error = 1;
|
string error = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message DownloadScenarioArchiveReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UploadScenarioArchiveRsp {
|
||||||
|
string error = 1;
|
||||||
|
int32 id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddGameReq {
|
||||||
|
string name = 1;
|
||||||
|
string description = 2;
|
||||||
|
google.protobuf.Timestamp start_at = 3;
|
||||||
|
int32 scenario_id = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
int32 id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetGamesReq {}
|
||||||
|
|
||||||
|
message GetGamesRsp {
|
||||||
|
string error = 1;
|
||||||
|
repeated Game games = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Game {
|
||||||
|
int32 id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string description = 3;
|
||||||
|
google.protobuf.Timestamp start_at = 4;
|
||||||
|
string status = 5;
|
||||||
|
Scenario scenario = 6;
|
||||||
|
repeated Team teams = 7;
|
||||||
|
optional google.protobuf.Timestamp started_at = 8;
|
||||||
|
optional google.protobuf.Timestamp ended_at = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Team {
|
||||||
|
int32 id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string status = 3;
|
||||||
|
string password = 4;
|
||||||
|
int32 actions_count = 5;
|
||||||
|
repeated Application applications = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetGameReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
Game game = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateGameReq {
|
||||||
|
int32 id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string description = 3;
|
||||||
|
google.protobuf.Timestamp start_at = 4;
|
||||||
|
int32 scenario_id = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StartGameReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message StartGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PauseGameReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PauseGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PlayGameReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PlayGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FinishGameReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FinishGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ResetGameReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ResetGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteGameReq {
|
||||||
|
int32 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteGameRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddTeamReq {
|
||||||
|
int32 game_id = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddTeamRsp {
|
||||||
|
string error = 1;
|
||||||
|
int32 id = 2;
|
||||||
|
string password = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateTeamReq {
|
||||||
|
int32 id = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message UpdateTeamRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteTeamReq {
|
||||||
|
int64 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteTeamRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GiveTeamApplicationsReq {
|
||||||
|
int64 id = 1;
|
||||||
|
string name = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GiveTeamApplicationsRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetTeamStoryReq {
|
||||||
|
int64 id = 1;
|
||||||
|
string password = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetTeamStoryRsp {
|
||||||
|
string error = 1;
|
||||||
|
Story story = 2;
|
||||||
|
Game game = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddTeamActionReq {
|
||||||
|
int64 id = 1;
|
||||||
|
string password = 2;
|
||||||
|
string code = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AddTeamActionRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteLastTeamActionReq {
|
||||||
|
int64 id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message DeleteLastTeamActionRsp {
|
||||||
|
string error = 1;
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,85 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { NConfigProvider, NMessageProvider } from 'naive-ui'
|
||||||
import HelloWorld from './components/HelloWorld.vue'
|
import { darkTheme } from 'naive-ui'
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header>
|
<n-config-provider :theme="darkTheme">
|
||||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
<n-message-provider>
|
||||||
|
<RouterView />
|
||||||
<div class="wrapper">
|
</n-message-provider>
|
||||||
<HelloWorld msg="You did it!" />
|
</n-config-provider>
|
||||||
|
|
||||||
<nav>
|
|
||||||
<RouterLink to="/">Home</RouterLink>
|
|
||||||
<RouterLink to="/about">About</RouterLink>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<RouterView />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
header {
|
|
||||||
line-height: 1.5;
|
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: block;
|
|
||||||
margin: 0 auto 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
width: 100%;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a.router-link-exact-active:hover {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-left: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a:first-of-type {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
|
||||||
header {
|
|
||||||
display: flex;
|
|
||||||
place-items: center;
|
|
||||||
padding-right: calc(var(--section-gap) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
margin: 0 2rem 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
header .wrapper {
|
|
||||||
display: flex;
|
|
||||||
place-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
text-align: left;
|
|
||||||
margin-left: -1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
padding: 1rem 0;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
import { getURL } from './generated/crabs/evening_detective_server/client'
|
||||||
|
|
||||||
|
export type DownloadArchiveResult = {
|
||||||
|
blob: Blob
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UploadArchiveResult = {
|
||||||
|
id: number | null
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Клиент для бинарных операций со сценарием (ZIP-архив). Сгенерированный
|
||||||
|
// HTTP-хендлер всегда парсит ответ как JSON, поэтому для архивов используем
|
||||||
|
// сырой fetch: скачивание отдаёт байты архива, загрузка принимает их напрямую.
|
||||||
|
export function getArchiveClient() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildArchiveClient(
|
||||||
|
refreshTokens: () => Promise<boolean>,
|
||||||
|
getToken: () => string | null,
|
||||||
|
) {
|
||||||
|
async function request(path: string, init: RequestInit): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
const token = getToken()
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = await fetch(`${getURL()}/${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...headers, ...(init.headers as Record<string, string>) },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Токен протух — обновляем и повторяем запрос один раз.
|
||||||
|
if (response.status == 401) {
|
||||||
|
await refreshTokens()
|
||||||
|
const newToken = getToken()
|
||||||
|
if (newToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${newToken}`
|
||||||
|
}
|
||||||
|
response = await fetch(`${getURL()}/${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { ...headers, ...(init.headers as Record<string, string>) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadScenarioArchive(id: number): Promise<DownloadArchiveResult> {
|
||||||
|
const response = await request(`api/scenarios/${id}/archive`, { method: 'GET' })
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await gatewayErrorMessage(response))
|
||||||
|
}
|
||||||
|
const blob = await response.blob()
|
||||||
|
const filename =
|
||||||
|
parseContentDispositionFilename(response.headers.get('Content-Disposition')) || `scenario-${id}.zip`
|
||||||
|
return { blob, filename }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadScenarioArchive(file: File): Promise<UploadArchiveResult> {
|
||||||
|
const contentType = file.type || 'application/zip'
|
||||||
|
const response = await request('api/scenarios/archive', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': contentType },
|
||||||
|
body: file,
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
if (!response.ok) {
|
||||||
|
return { id: null, error: data?.message || `Ошибка сервера (${response.status})` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = typeof data?.error === 'string' && data.error !== '' ? data.error : null
|
||||||
|
return { id: typeof data?.id === 'number' ? data.id : null, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { downloadScenarioArchive, uploadScenarioArchive }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Извлекает имя файла из Content-Disposition: attachment; filename="name.zip"
|
||||||
|
function parseContentDispositionFilename(value: string | null): string {
|
||||||
|
if (!value) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const match = value.match(/filename="?([^";]+)"?/i)
|
||||||
|
const filename = match ? match[1] : ''
|
||||||
|
return filename.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ошибки grpc-gateway приходят JSON-объектом {"code": ..., "message": ...}
|
||||||
|
async function gatewayErrorMessage(response: Response): Promise<string> {
|
||||||
|
const data = await response.json().catch(() => null)
|
||||||
|
if (data && typeof data.message === 'string' && data.message !== '') {
|
||||||
|
return data.message
|
||||||
|
}
|
||||||
|
return `Ошибка сервера (${response.status})`
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createEveningDetectiveServerClient,
|
||||||
|
type EveningDetectiveServer,
|
||||||
|
} from './generated/crabs/evening_detective_server'
|
||||||
|
import { createHttpHandler } from './generated/crabs/evening_detective_server/client'
|
||||||
|
|
||||||
|
export function getAuthClient() {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
return buildAuthClient(authStore.refreshTokenAction, authStore.getToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthClient(
|
||||||
|
refreshTokens: () => Promise<boolean>,
|
||||||
|
getToken: () => string | null,
|
||||||
|
): EveningDetectiveServer {
|
||||||
|
return createEveningDetectiveServerClient(
|
||||||
|
createAuthHttpHandler(refreshTokens, createHttpHandler(getToken)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAuthHttpHandler(
|
||||||
|
refreshTokens: () => Promise<boolean>,
|
||||||
|
handler: (request: { path: string; method: string; body: string | null }) => Promise<unknown>,
|
||||||
|
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
|
||||||
|
return async (request: { path: string; method: string; body: string | null }) => {
|
||||||
|
try {
|
||||||
|
return await handler(request)
|
||||||
|
} catch {
|
||||||
|
await refreshTokens()
|
||||||
|
return await handler(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createEveningDetectiveServerClient, type EveningDetectiveServer } from '.'
|
||||||
|
|
||||||
|
export type ExtraHeadersProvider = () => Record<string, string> | null | undefined
|
||||||
|
|
||||||
|
export function buildClient(
|
||||||
|
getToken?: () => string | null,
|
||||||
|
getExtraHeaders?: ExtraHeadersProvider,
|
||||||
|
): EveningDetectiveServer {
|
||||||
|
return createEveningDetectiveServerClient(createHttpHandler(getToken, getExtraHeaders))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHttpHandler(
|
||||||
|
getToken?: () => string | null,
|
||||||
|
getExtraHeaders?: ExtraHeadersProvider,
|
||||||
|
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
|
||||||
|
return async (request: { path: string; method: string; body: string | null }) => {
|
||||||
|
const baseURL = getURL()
|
||||||
|
|
||||||
|
// Формируем полный URL
|
||||||
|
const url = `${baseURL}/${request.path}`
|
||||||
|
|
||||||
|
// Подготавливаем заголовки
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем токен, если он есть
|
||||||
|
const token = getToken?.()
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем дополнительные заголовки (например, X-Password для удаления аккаунта)
|
||||||
|
const extraHeaders = getExtraHeaders?.()
|
||||||
|
if (extraHeaders) {
|
||||||
|
Object.assign(headers, extraHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Делаем запрос
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: request.method,
|
||||||
|
headers,
|
||||||
|
body: request.body, // body уже JSON-строка из генератора
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status == 401) {
|
||||||
|
throw new Error('unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Парсим ответ
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Возвращаем в формате, который ожидает генератор
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getURL(): string {
|
||||||
|
const url = import.meta.env.VITE_API_URL || 'http://localhost:8090'
|
||||||
|
// console.log(url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
@@ -1,24 +1,26 @@
|
|||||||
/* color palette from <https://github.com/vuejs/theme> */
|
/* color palette from <https://github.com/vuejs/theme> */
|
||||||
:root {
|
:root {
|
||||||
--vt-c-white: #ffffff;
|
--vt-c-white: #fff;
|
||||||
--vt-c-white-soft: #f8f8f8;
|
--vt-c-white-soft: #f8f8f8;
|
||||||
--vt-c-white-mute: #f2f2f2;
|
--vt-c-white-mute: #f2f2f2;
|
||||||
|
|
||||||
--vt-c-black: #181818;
|
--vt-c-black: #181818;
|
||||||
--vt-c-black-soft: #222222;
|
--vt-c-black-soft: #222;
|
||||||
--vt-c-black-mute: #282828;
|
--vt-c-black-mute: #282828;
|
||||||
|
|
||||||
--vt-c-indigo: #2c3e50;
|
--vt-c-indigo: #2c3e50;
|
||||||
|
--vt-c-divider-light-1: rgb(60 60 60 / 29%);
|
||||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
--vt-c-divider-light-2: rgb(60 60 60 / 12%);
|
||||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
--vt-c-divider-dark-1: rgb(84 84 84 / 65%);
|
||||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
--vt-c-divider-dark-2: rgb(84 84 84 / 48%);
|
||||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
|
||||||
|
|
||||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
--vt-c-text-light-2: rgb(60 60 60 / 66%);
|
||||||
--vt-c-text-dark-1: var(--vt-c-white);
|
--vt-c-text-dark-1: var(--vt-c-white);
|
||||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
--vt-c-text-dark-2: rgb(235 235 235 / 64%);
|
||||||
|
|
||||||
|
/* Главный цвет */
|
||||||
|
--main-color: rgb(34 50 60 / 100%);
|
||||||
|
--second-color: rgb(97 74 22);
|
||||||
|
--main-back-color: rgb(240 240 240 / 100%);
|
||||||
|
--main-back-item-color: rgb(254 254 254 / 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* semantic color variables for this project */
|
/* semantic color variables for this project */
|
||||||
@@ -26,13 +28,10 @@
|
|||||||
--color-background: var(--vt-c-white);
|
--color-background: var(--vt-c-white);
|
||||||
--color-background-soft: var(--vt-c-white-soft);
|
--color-background-soft: var(--vt-c-white-soft);
|
||||||
--color-background-mute: var(--vt-c-white-mute);
|
--color-background-mute: var(--vt-c-white-mute);
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-light-2);
|
--color-border: var(--vt-c-divider-light-2);
|
||||||
--color-border-hover: var(--vt-c-divider-light-1);
|
--color-border-hover: var(--vt-c-divider-light-1);
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-light-1);
|
--color-heading: var(--vt-c-text-light-1);
|
||||||
--color-text: var(--vt-c-text-light-1);
|
--color-text: var(--vt-c-text-light-1);
|
||||||
|
|
||||||
--section-gap: 160px;
|
--section-gap: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,10 +40,8 @@
|
|||||||
--color-background: var(--vt-c-black);
|
--color-background: var(--vt-c-black);
|
||||||
--color-background-soft: var(--vt-c-black-soft);
|
--color-background-soft: var(--vt-c-black-soft);
|
||||||
--color-background-mute: var(--vt-c-black-mute);
|
--color-background-mute: var(--vt-c-black-mute);
|
||||||
|
|
||||||
--color-border: var(--vt-c-divider-dark-2);
|
--color-border: var(--vt-c-divider-dark-2);
|
||||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||||
|
|
||||||
--color-heading: var(--vt-c-text-dark-1);
|
--color-heading: var(--vt-c-text-dark-1);
|
||||||
--color-text: var(--vt-c-text-dark-2);
|
--color-text: var(--vt-c-text-dark-2);
|
||||||
}
|
}
|
||||||
@@ -80,7 +77,7 @@ body {
|
|||||||
'Helvetica Neue',
|
'Helvetica Neue',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizelegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1,35 +1,75 @@
|
|||||||
@import './base.css';
|
@import './base.css';
|
||||||
|
|
||||||
#app {
|
body {
|
||||||
max-width: 1280px;
|
overflow: hidden;
|
||||||
|
color: #eee;
|
||||||
|
background-color: #111;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'font_old_typer';
|
||||||
|
src: url('@/assets/fonts/a_OldTyper.ttf');
|
||||||
|
}
|
||||||
|
|
||||||
|
.height80 {
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-block {
|
||||||
|
margin-top: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-block-custom {
|
||||||
|
margin-top: 70px;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
|
||||||
|
/* Firefox */
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-block-custom::-webkit-scrollbar {
|
||||||
|
/* Chrome, Edge, Safari */
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width700 {
|
||||||
|
padding: 10px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
a,
|
.width1200 {
|
||||||
.green {
|
padding: 10px;
|
||||||
text-decoration: none;
|
margin: 0 auto;
|
||||||
color: hsla(160, 100%, 37%, 1);
|
|
||||||
transition: 0.4s;
|
|
||||||
padding: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (hover: hover) {
|
.display-table {
|
||||||
a:hover {
|
display: flex;
|
||||||
background-color: hsla(160, 100%, 37%, 0.2);
|
flex-flow: row wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >=1024px) {
|
||||||
|
.center-block-custom {
|
||||||
|
margin: 70px auto 0;
|
||||||
|
height: calc(100dvh - 70px);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
.width700 {
|
||||||
body {
|
width: 700px;
|
||||||
display: flex;
|
padding: 20px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.width1200 {
|
||||||
|
width: 1200px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-middle-block-custom {
|
||||||
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
padding: 0 2rem;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="shadow shadow-top"></div>
|
||||||
|
<div class="shadow shadow-bottom"></div>
|
||||||
|
<div class="belt-block">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.belt-block {
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgb(0 0 0);
|
||||||
|
background-image: url("@/assets/images/belt.png");
|
||||||
|
background-size: cover;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow {
|
||||||
|
height: 10px;
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-top {
|
||||||
|
top: 0;
|
||||||
|
box-shadow: 0 -5px 10px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-bottom {
|
||||||
|
bottom: 0;
|
||||||
|
box-shadow: 0 5px 10px black;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NTag } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
|
||||||
|
const client = getAuthClient()
|
||||||
|
|
||||||
|
const scenarios = ref<Scenario[]>([])
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
async function getScenarios() {
|
||||||
|
scenarios.value = []
|
||||||
|
const res = await client.GetScenariosCatalog({})
|
||||||
|
scenarios.value = res.scenarios!
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toScenarioInfo(id: number) {
|
||||||
|
router.push('/scenarios/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getScenarios()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width1200 display-table">
|
||||||
|
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioInfo(scenario.id!)"
|
||||||
|
v-bind:key="scenario.id">
|
||||||
|
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scenario-content-block">
|
||||||
|
<p class="scenario-title">{{ scenario.name }}</p>
|
||||||
|
|
||||||
|
<p class="scenario-description">{{ scenario.description }}</p>
|
||||||
|
|
||||||
|
<p class="scenario-label">
|
||||||
|
<n-tag :bordered="false" type="success" class="status-block">Автор: {{
|
||||||
|
scenario.author?.username
|
||||||
|
}}</n-tag>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HeaderMenu active="catalog" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scenario-block {
|
||||||
|
position: relative;
|
||||||
|
margin: 10px;
|
||||||
|
width: 160px;
|
||||||
|
height: 300px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
/* background-color: #553333; */
|
||||||
|
background-color: #222;
|
||||||
|
border: 2px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-content-block {
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-block:hover {
|
||||||
|
color: #63e2b7;
|
||||||
|
border: 2px solid #63e2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-image-block {
|
||||||
|
background-color: #111;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-image {
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-title {
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-description {
|
||||||
|
color: #aaa;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-block {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 15px;
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >=1024px) {
|
||||||
|
.scenario-block {
|
||||||
|
width: 250px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-image-block {
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-content-block {
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-description {
|
||||||
|
color: #aaa;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NSpin } from 'naive-ui'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import HeaderText from '@/components/HeaderText.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
kind: 'terms' | 'privacy' | 'consent'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const titles: Record<string, string> = {
|
||||||
|
terms: 'Пользовательское соглашение',
|
||||||
|
privacy: 'Политика конфиденциальности',
|
||||||
|
consent: 'Согласие на обработку персональных данных',
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const client = getAuthClient()
|
||||||
|
const fetchers = {
|
||||||
|
terms: () => client.GetTerms({}),
|
||||||
|
privacy: () => client.GetPrivacy({}),
|
||||||
|
consent: () => client.GetConsent({}),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetchers[props.kind]()
|
||||||
|
if (res.error) {
|
||||||
|
error.value = res.error
|
||||||
|
} else {
|
||||||
|
text.value = res.text || ''
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Не удалось загрузить документ'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width700 doc-page">
|
||||||
|
<h1 class="doc-title">
|
||||||
|
<HeaderText>{{ titles[props.kind] }}</HeaderText>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="doc-loading">
|
||||||
|
<n-spin size="large" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-alert v-else-if="error" type="error" :show-icon="false" class="doc-error">
|
||||||
|
{{ error }}
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<div v-else class="paper-block">
|
||||||
|
<div class="paper">
|
||||||
|
<p class="doc-text">{{ text }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="paper-shadow paper-shadow-left"></div>
|
||||||
|
<div class="paper-shadow paper-shadow-right"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="docs-links">
|
||||||
|
Смотрите также:
|
||||||
|
<RouterLink to="/user-agreement" class="docs-link">Пользовательское соглашение</RouterLink>
|
||||||
|
·
|
||||||
|
<RouterLink to="/privacy-policy" class="docs-link">Политика конфиденциальности</RouterLink>
|
||||||
|
·
|
||||||
|
<RouterLink to="/consent" class="docs-link">Согласие на обработку персональных данных</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.doc-page {
|
||||||
|
padding-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-title {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-error {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-block {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 32px 28px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-image: url('@/assets/images/paper_white.jpg');
|
||||||
|
background-size: cover;
|
||||||
|
color: #1d1d1d;
|
||||||
|
box-shadow: 0 0 12px rgb(0 0 0 / 70%);
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-text {
|
||||||
|
font-family: 'font_old_typer';
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-shadow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-image: url('@/assets/images/paper_white.jpg');
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-shadow-left {
|
||||||
|
left: 0;
|
||||||
|
transform: rotate(-2.2deg);
|
||||||
|
filter: brightness(60%);
|
||||||
|
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paper-shadow-right {
|
||||||
|
right: 0;
|
||||||
|
transform: rotate(2.2deg);
|
||||||
|
filter: brightness(75%);
|
||||||
|
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-links {
|
||||||
|
margin-top: 28px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-link {
|
||||||
|
color: #45aa89;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-link:hover {
|
||||||
|
color: #63e2b7;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Footsteps } from '@vicons/ionicons5'
|
||||||
|
import { ContactMailRound } from '@vicons/material'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
|
import { type PropType, ref } from 'vue';
|
||||||
|
|
||||||
|
import BeltBlock from './BeltBlock.vue';
|
||||||
|
import HeaderText from './HeaderText.vue';
|
||||||
|
import MetalPlate from './MetalPlate.vue';
|
||||||
|
|
||||||
|
const code = ref('')
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
addAction: {
|
||||||
|
type: Function as PropType<(param: string) => void>,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
allPlacesCount: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
newPlacesCount: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
gameStatus: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function addClickButton() {
|
||||||
|
await props.addAction(code.value)
|
||||||
|
code.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextForCounter(count: number): string[] {
|
||||||
|
count = count % 100
|
||||||
|
const number1 = Math.floor(count / 10) % 10
|
||||||
|
const number2 = count % 10
|
||||||
|
|
||||||
|
let text1 = '–'
|
||||||
|
let text2 = '–'
|
||||||
|
|
||||||
|
if (number1 > 0) {
|
||||||
|
text1 = String(number1)
|
||||||
|
}
|
||||||
|
if (number2 > 0 || number1 > 0) {
|
||||||
|
text2 = String(number2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [text1, text2]
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="form-custom">
|
||||||
|
<BeltBlock class="input-form">
|
||||||
|
<MetalPlate class="controller-metal controller-metal-left"></MetalPlate>
|
||||||
|
<MetalPlate class="controller-metal controller-metal-right"></MetalPlate>
|
||||||
|
|
||||||
|
<div class="center-block-700">
|
||||||
|
<div class="controller">
|
||||||
|
|
||||||
|
<div class="counters-block">
|
||||||
|
<div class="counter-block">
|
||||||
|
<div class="places-text">
|
||||||
|
<Icon class="icon-for-counter">
|
||||||
|
<Footsteps />
|
||||||
|
</Icon>
|
||||||
|
</div>
|
||||||
|
<div v-for="n in getTextForCounter(allPlacesCount)" class="places-text places-text-number">{{ n }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="counter-block">
|
||||||
|
<div class="places-text">
|
||||||
|
<Icon class="icon-for-counter">
|
||||||
|
<ContactMailRound />
|
||||||
|
</Icon>
|
||||||
|
</div>
|
||||||
|
<div v-for="n in getTextForCounter(newPlacesCount)" class="places-text places-text-number">{{ n }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-input">
|
||||||
|
<input id="run" class="game-input-run" v-model="code" type="text"
|
||||||
|
:placeholder="props.gameStatus == 'run' ? 'Место назначения' : 'Ходы запрещены'"
|
||||||
|
:disabled="props.gameStatus != 'run'">
|
||||||
|
</div>
|
||||||
|
<div class="game-button-run-shadow"></div>
|
||||||
|
<button class="game-button-run" type="submit" :disabled="code.length == 0" v-on:click="addClickButton">
|
||||||
|
<HeaderText v-if="props.gameStatus == 'run'">Поехали</HeaderText>
|
||||||
|
<HeaderText v-if="props.gameStatus == 'pause' || props.gameStatus == 'draft'">Ждём</HeaderText>
|
||||||
|
<HeaderText v-if="props.gameStatus == 'finish'">Всё</HeaderText>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BeltBlock>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.form-custom {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
color: white;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-form {
|
||||||
|
height: 76px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1000;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 1920px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-metal {
|
||||||
|
width: 30px;
|
||||||
|
height: calc(100% + 2px);
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-metal-left {
|
||||||
|
left: -30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller-metal-right {
|
||||||
|
right: -30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controller {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-input {
|
||||||
|
position: relative;
|
||||||
|
top: 14px;
|
||||||
|
left: 15px;
|
||||||
|
height: 50px;
|
||||||
|
width: calc(100% - 150px - 25px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-input-run {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
padding-left: 27px;
|
||||||
|
background-image: url("@/assets/images/input_center.png");
|
||||||
|
background-size: cover;
|
||||||
|
border: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-family: 'font_old_typer';
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-input-run::placeholder {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-input-run:focus {
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-button-run-shadow {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: -5px;
|
||||||
|
height: 80px;
|
||||||
|
width: 150px;
|
||||||
|
box-shadow: -5px 5px 10px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-button-run {
|
||||||
|
background-image: url("@/assets/images/button.png");
|
||||||
|
background-size: cover;
|
||||||
|
font-size: 1.5em;
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
top: -5px;
|
||||||
|
height: 80px;
|
||||||
|
width: 155px;
|
||||||
|
border: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-button-run {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-places-label,
|
||||||
|
.all-places-label {
|
||||||
|
position: absolute;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-places-label {
|
||||||
|
top: 14px;
|
||||||
|
left: 85px;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-places-label {
|
||||||
|
top: 14px;
|
||||||
|
left: 15px;
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.places-text {
|
||||||
|
background-color: #d1cebd;
|
||||||
|
border: 1px solid #9e9982;
|
||||||
|
color: #111;
|
||||||
|
font-size: 1.4em;
|
||||||
|
font-family: 'font_old_typer';
|
||||||
|
border-radius: 7px;
|
||||||
|
margin: 0 1px;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
background-image: url("@/assets/images/paper.jpg");
|
||||||
|
background-size: cover;
|
||||||
|
box-shadow: 0 -2px 5px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.places-text-number {
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counters-block {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
left: 23px;
|
||||||
|
margin: 20px 10px 20px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.counter-block {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-for-counter {
|
||||||
|
position: relative;
|
||||||
|
top: 3px;
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >=1025px) {
|
||||||
|
.center-block-700 {
|
||||||
|
width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
import { Flag, Pause, Play, Reset, Settings } from '@vicons/carbon'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
|
import { NButton, NTag } from 'naive-ui'
|
||||||
|
import { NCard, NFlex, NInput, NModal, NQrCode, NSpace, NText } from 'naive-ui'
|
||||||
|
import { useMessage } from 'naive-ui';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client';
|
||||||
|
import type { Application, Game, Team } from '@/api/generated/crabs/evening_detective_server';
|
||||||
|
import { getURL } from '@/api/generated/crabs/evening_detective_server/client';
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||||
|
|
||||||
|
const client = getAuthClient()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const gameId = route.params.id
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const showSettingsModal = ref(false)
|
||||||
|
const showGiveApplicationModal = ref(false)
|
||||||
|
const showAddTeamModal = ref(false)
|
||||||
|
const showDeleteTeamModal = ref(false)
|
||||||
|
const showDeleteGameModal = ref(false)
|
||||||
|
const newTeamName = ref('')
|
||||||
|
const deletedGameName = ref('')
|
||||||
|
const showQR = ref(false)
|
||||||
|
const urlQR = ref('')
|
||||||
|
const titleQR = ref('')
|
||||||
|
|
||||||
|
const game = ref<Game>({
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
description: undefined,
|
||||||
|
startAt: undefined,
|
||||||
|
status: undefined,
|
||||||
|
scenario: undefined,
|
||||||
|
teams: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function getGame(id: number) {
|
||||||
|
const res = await client.GetGame({ id: id })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
game.value = res.game!
|
||||||
|
game.value.teams = game.value.teams?.sort((a, b) => {
|
||||||
|
let aLen = 0
|
||||||
|
if ((a.applications?.length || 0) > 0) {
|
||||||
|
aLen = 1
|
||||||
|
}
|
||||||
|
let bLen = 0
|
||||||
|
if ((b.applications?.length || 0) > 0) {
|
||||||
|
bLen = 1
|
||||||
|
}
|
||||||
|
if (aLen > bLen) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if (aLen < bLen) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const aName = a.name || ''
|
||||||
|
const bName = b.name || ''
|
||||||
|
if (aName > bName) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if (aName < bName) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getGame(Number(gameId))
|
||||||
|
|
||||||
|
async function toTeamStory(id: number, password: string, newTab: boolean = false) {
|
||||||
|
const uri = '/team-story/' + id + '?password=' + password
|
||||||
|
if (newTab) {
|
||||||
|
window.open(uri, '_blank');
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGame() {
|
||||||
|
const res = await client.UpdateGame({
|
||||||
|
id: game.value.id,
|
||||||
|
name: game.value.name,
|
||||||
|
description: game.value.description,
|
||||||
|
startAt: game.value.startAt,
|
||||||
|
scenarioId: game.value.scenario?.id,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
showSettingsModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const giveApplicationTeam = ref<Team>({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
status: '',
|
||||||
|
password: '',
|
||||||
|
actionsCount: 0,
|
||||||
|
applications: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const deletedTeamName = ref('')
|
||||||
|
const deletedTeam = ref<Team>({
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
status: '',
|
||||||
|
password: '',
|
||||||
|
actionsCount: 0,
|
||||||
|
applications: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const giveApplicationApplication = ref<Application>({
|
||||||
|
name: '',
|
||||||
|
image: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = window.location.href
|
||||||
|
|
||||||
|
function giveApplication(team: Team, application: Application) {
|
||||||
|
giveApplicationTeam.value = team
|
||||||
|
giveApplicationApplication.value = application
|
||||||
|
showGiveApplicationModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTeam() {
|
||||||
|
const res = await client.AddTeam({
|
||||||
|
gameId: game.value.id,
|
||||||
|
name: newTeamName.value,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
showAddTeamModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmGiveApplication() {
|
||||||
|
const res = await client.GiveTeamApplications({
|
||||||
|
id: giveApplicationTeam.value.id,
|
||||||
|
name: giveApplicationApplication.value.name,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
showGiveApplicationModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTeam(team: Team) {
|
||||||
|
deletedTeam.value = team
|
||||||
|
showDeleteTeamModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteTeam() {
|
||||||
|
const res = await client.DeleteTeam({
|
||||||
|
id: deletedTeam.value.id,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
showDeleteTeamModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGame() {
|
||||||
|
const res = await client.DeleteGame({
|
||||||
|
id: game.value.id,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success('Игра удалена')
|
||||||
|
router.push('/games')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTeamStoryURL(id: number, password: string): string {
|
||||||
|
return getURL() + '/team-story/' + id + '?password=' + password
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openQR(url: string, text: string) {
|
||||||
|
urlQR.value = url
|
||||||
|
titleQR.value = text
|
||||||
|
showQR.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
useSimplePolling(() => {
|
||||||
|
getGame(Number(gameId))
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
async function resetGame() {
|
||||||
|
const res = await client.ResetGame({
|
||||||
|
id: game.value.id
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startGame() {
|
||||||
|
const res = await client.StartGame({
|
||||||
|
id: game.value.id
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pauseGame() {
|
||||||
|
const res = await client.PauseGame({
|
||||||
|
id: game.value.id
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playGame() {
|
||||||
|
const res = await client.PlayGame({
|
||||||
|
id: game.value.id
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishGame() {
|
||||||
|
const res = await client.FinishGame({
|
||||||
|
id: game.value.id
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getGame(game.value.id!)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateWithoutTimezone(dateStr: string): Date {
|
||||||
|
// Разбираем ISO строку: "2024-01-01T12:30:00"
|
||||||
|
const [datePart, timePart] = dateStr.split('T');
|
||||||
|
const [year, month, day] = datePart.split('-').map(Number);
|
||||||
|
|
||||||
|
const correctTimePart = timePart.split('.')[0]
|
||||||
|
const [hours, minutes, seconds] = (correctTimePart || '00:00:00').split(':').map(Number);
|
||||||
|
|
||||||
|
// Создаем дату в UTC, чтобы избежать сдвига
|
||||||
|
return new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentUTC(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeDiff(startStr: string, endStr: string): string {
|
||||||
|
if (startStr == '') {
|
||||||
|
return 'Игра ещё не началась'
|
||||||
|
}
|
||||||
|
const start = parseDateWithoutTimezone(startStr)
|
||||||
|
let endTime = getCurrentUTC();
|
||||||
|
if (endStr != '') {
|
||||||
|
endTime = parseDateWithoutTimezone(endStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffMs = endTime.getTime() - start.getTime();
|
||||||
|
|
||||||
|
if (diffMs < 0) return 'Игра ещё не началась';
|
||||||
|
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
|
||||||
|
if (years > 0) return `${years}г ${days % 365}д`;
|
||||||
|
if (months > 0) return `${months}мес ${days % 30}д`;
|
||||||
|
if (days > 0) return `${days}д ${hours % 24}ч`;
|
||||||
|
if (hours > 0) return `${hours}ч ${minutes % 60}м`;
|
||||||
|
if (minutes > 0) return `${minutes}м ${seconds % 60}с`;
|
||||||
|
return `${seconds}с`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeString = ref('')
|
||||||
|
useSimplePolling(() => {
|
||||||
|
timeString.value = getTimeDiff(game.value.startedAt || '', game.value.endedAt || '')
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width1200">
|
||||||
|
|
||||||
|
<div class="settings-block">
|
||||||
|
<n-flex justify="space-between">
|
||||||
|
<n-space>
|
||||||
|
<n-tag :bordered="false" type="info">
|
||||||
|
Команд: {{ game.teams?.length }}
|
||||||
|
</n-tag>
|
||||||
|
<n-tag type="info">
|
||||||
|
{{ timeString }}
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<n-button quaternary class="settings-button" @click="openQR(url, 'Админка')">
|
||||||
|
QR
|
||||||
|
</n-button>
|
||||||
|
<n-button quaternary @click="showSettingsModal = true" class="settings-button">
|
||||||
|
<Icon class="settings-icon">
|
||||||
|
<Settings />
|
||||||
|
</Icon>
|
||||||
|
Настройки
|
||||||
|
</n-button>
|
||||||
|
<n-button v-if="(game.teams?.length || 0) == 0" type="error" quaternary
|
||||||
|
@click="showDeleteGameModal = true" class="settings-button">
|
||||||
|
Удалить игру
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<n-space>
|
||||||
|
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
|
||||||
|
<Icon>
|
||||||
|
<Reset />
|
||||||
|
</Icon>
|
||||||
|
</n-button>
|
||||||
|
<n-button type="info" v-if="game.status == 'draft'" dashed @click="startGame()">
|
||||||
|
<Icon>
|
||||||
|
<Play />
|
||||||
|
</Icon>
|
||||||
|
</n-button>
|
||||||
|
<n-button type="info" v-if="game.status == 'pause'" dashed @click="playGame()">
|
||||||
|
<Icon>
|
||||||
|
<Play />
|
||||||
|
</Icon>
|
||||||
|
</n-button>
|
||||||
|
<n-button type="info" v-if="game.status == 'run'" dashed @click="pauseGame()">
|
||||||
|
<Icon>
|
||||||
|
<Pause />
|
||||||
|
</Icon>
|
||||||
|
</n-button>
|
||||||
|
<n-button type="info" v-if="game.status != 'finish'" dashed @click="finishGame()">
|
||||||
|
<Icon>
|
||||||
|
<Flag />
|
||||||
|
</Icon>
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="team-block team-block-hover" @click="showAddTeamModal = true">
|
||||||
|
<p class="place-image-plus">+</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="team in game.teams" class="team-block">
|
||||||
|
<div class="team-content-block">
|
||||||
|
|
||||||
|
<n-flex justify="space-between">
|
||||||
|
<div class="title-block">
|
||||||
|
<span class="team-name-block">
|
||||||
|
{{ team.name }}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
Поездки: {{ team.actionsCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="openQR(getTeamStoryURL(team.id!, team.password!), 'Подсмотреть игру')">
|
||||||
|
QR
|
||||||
|
</n-button>
|
||||||
|
<n-button @click="toTeamStory(team.id!, team.password!, true)">
|
||||||
|
Подсмотреть игру
|
||||||
|
</n-button>
|
||||||
|
<n-button type="error" ghost @click="deleteTeam(team)">
|
||||||
|
Удалить
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
</n-flex>
|
||||||
|
|
||||||
|
<n-button v-for="application in team.applications" :key="application.name" class="link-button"
|
||||||
|
@click="giveApplication(team, application)">
|
||||||
|
Выдать: {{ application.name }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Окно настроек -->
|
||||||
|
<n-modal v-model:show="showSettingsModal">
|
||||||
|
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showSettingsModal = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-space vertical>
|
||||||
|
<p>Название</p>
|
||||||
|
<n-input v-model:value="game.name" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||||
|
|
||||||
|
<p class="settings-header">Описание</p>
|
||||||
|
<n-input v-model:value="game.description" type="textarea" placeholder="Убийство пастуха..." />
|
||||||
|
|
||||||
|
</n-space>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="updateGame()" ghost> Сохранить изменения </n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Окно выдачи улик -->
|
||||||
|
<n-modal v-model:show="showGiveApplicationModal">
|
||||||
|
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showGiveApplicationModal = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-space vertical>
|
||||||
|
<p>Команда</p>
|
||||||
|
<h3>{{ giveApplicationTeam.name }}</h3>
|
||||||
|
|
||||||
|
|
||||||
|
<p>Улика</p>
|
||||||
|
<h3>{{ giveApplicationApplication.name }}</h3>
|
||||||
|
|
||||||
|
</n-space>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="confirmGiveApplication()" ghost> Выдать </n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Окно создания команды -->
|
||||||
|
<n-modal v-model:show="showAddTeamModal">
|
||||||
|
<n-card style="width: 600px" title="Создание команды" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showAddTeamModal = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-input v-model:value="newTeamName" type="text" placeholder='Три дебила' />
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="addTeam()" :disabled="newTeamName.length == 0">
|
||||||
|
Создать
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Окно создания команды -->
|
||||||
|
<n-modal v-model:show="showDeleteTeamModal">
|
||||||
|
<n-card style="width: 600px" title="Удаление команды" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showDeleteTeamModal = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-space vertical>
|
||||||
|
<p>
|
||||||
|
Введите название команды
|
||||||
|
<n-text class="name-text" strong>{{ deletedTeam.name }}</n-text> чтобы удалить его
|
||||||
|
</p>
|
||||||
|
<n-input v-model:value="deletedTeamName" type="text" placeholder='Три дебила' />
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="confirmDeleteTeam()" :disabled="deletedTeamName != deletedTeam.name">
|
||||||
|
Удалить
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Окно удаления игры -->
|
||||||
|
<n-modal v-model:show="showDeleteGameModal">
|
||||||
|
<n-card style="width: 600px" title="Удаление игры" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showDeleteGameModal = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-space vertical>
|
||||||
|
<p>
|
||||||
|
Введите название игры
|
||||||
|
<n-text class="name-text" strong>{{ game.name }}</n-text> чтобы удалить её
|
||||||
|
</p>
|
||||||
|
<n-input v-model:value="deletedGameName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="deleteGame()" :disabled="deletedGameName != game.name">
|
||||||
|
Удалить
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Окно qr -->
|
||||||
|
<n-modal v-model:show="showQR">
|
||||||
|
<n-card style="width: 600px" :title="titleQR" :bordered="false" size="huge" role="dialog" aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showQR = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-space vertical>
|
||||||
|
<div class="qr-block">
|
||||||
|
<n-qr-code :value="urlQR" :size="300" :padding="0" />
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<template #footer></template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<HeaderMenu active="games" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.team-block {
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #222;
|
||||||
|
border: 1px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-block-hover:hover {
|
||||||
|
color: #63e2b7;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #63e2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-content-block {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-name-block {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-block {}
|
||||||
|
|
||||||
|
.settings-button {
|
||||||
|
float: right;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-icon {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-block {}
|
||||||
|
|
||||||
|
.link-button {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-image-plus {
|
||||||
|
font-size: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-text {
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: rgb(255 255 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-block {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: white;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMessage } from 'naive-ui';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client';
|
||||||
|
import type { Game } from '@/api/generated/crabs/evening_detective_server';
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
|
||||||
|
const client = getAuthClient()
|
||||||
|
const message = useMessage()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const games = ref<Game[]>([])
|
||||||
|
|
||||||
|
async function getGames() {
|
||||||
|
games.value = []
|
||||||
|
const res = await client.GetGames({})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
games.value = res.games || []
|
||||||
|
}
|
||||||
|
getGames()
|
||||||
|
|
||||||
|
async function toGame(id: number) {
|
||||||
|
router.push('/games/' + id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width1200">
|
||||||
|
<div v-for="game in games" :key="game.id" class="game-block" @click="toGame(game.id!)">
|
||||||
|
<div class="game-title">
|
||||||
|
{{ game.name }}
|
||||||
|
</div>
|
||||||
|
<div class="game-description">
|
||||||
|
{{ game.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HeaderMenu active="games" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.game-block {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-block:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #63e2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-description {
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps({
|
||||||
|
active: {
|
||||||
|
type: String,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
import { MenuOutlined } from '@vicons/material'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.initFromStorage()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const client = getAuthClient()
|
||||||
|
|
||||||
|
const permissions = ref<string[]>([])
|
||||||
|
|
||||||
|
async function getPermissions() {
|
||||||
|
permissions.value = []
|
||||||
|
const res = await client.GetPermissions({})
|
||||||
|
permissions.value = res.permissions!
|
||||||
|
}
|
||||||
|
getPermissions()
|
||||||
|
|
||||||
|
function hasPermission(permission: string): boolean {
|
||||||
|
return permissions.value.includes(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToLogin = () => {
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goToLogout = () => {
|
||||||
|
authStore.logout()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenu = ref(false)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="menu-block">
|
||||||
|
<div @click="openMenu = !openMenu" class="burger-button">
|
||||||
|
<Icon class="burger-icon" size="28">
|
||||||
|
<MenuOutlined />
|
||||||
|
</Icon>
|
||||||
|
</div>
|
||||||
|
<div class="left-group menu-buttons-block" :class="!openMenu ? 'menu-buttons-block-active' : ''">
|
||||||
|
<div class="menu-item-block" :class="{ active: props.active == 'main' }" @click="router.push('/')">
|
||||||
|
Главная
|
||||||
|
</div>
|
||||||
|
<div class="menu-item-block" :class="{ active: props.active == 'office' }" v-if="authStore.hasRole('user')"
|
||||||
|
@click="router.push('/office')">
|
||||||
|
Кабинет
|
||||||
|
</div>
|
||||||
|
<div class="menu-item-block" :class="{ active: props.active == 'games' }" v-if="hasPermission('games_page')"
|
||||||
|
@click="router.push('/games')">
|
||||||
|
Игры
|
||||||
|
</div>
|
||||||
|
<div class="menu-item-block" :class="{ active: props.active == 'catalog' }" @click="router.push('/catalog')">
|
||||||
|
Каталог
|
||||||
|
</div>
|
||||||
|
<div class="menu-item-block" :class="{ active: props.active == 'scenarios' }" v-if="hasPermission('scenarios_page')" @click="router.push('/scenarios')">
|
||||||
|
Мои сценарии
|
||||||
|
</div>
|
||||||
|
<div class="menu-item-block" :class="{ active: props.active == 'users' }" v-if="hasPermission('users_page')"
|
||||||
|
@click="router.push('/users')">
|
||||||
|
Пользователи
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="right-group">
|
||||||
|
<div v-if="!authStore.isAuthenticated" class="menu-item-block right" @click="goToLogin">
|
||||||
|
Войти
|
||||||
|
</div>
|
||||||
|
<div v-if="authStore.isAuthenticated" class="menu-item-block right" @click="goToLogout">Выйти</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.menu-block {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 70px;
|
||||||
|
background-color: #222;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-block {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 1.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item-block:hover {
|
||||||
|
color: #63e2b7;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active {
|
||||||
|
color: #63e2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >=1024px) {
|
||||||
|
.burger-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width < 1024px) {
|
||||||
|
.menu-buttons-block {
|
||||||
|
position: fixed;
|
||||||
|
top: 70px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #222;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-buttons-block div {
|
||||||
|
padding: 15px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-buttons-block-active {
|
||||||
|
position: relative;
|
||||||
|
top: -1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.burger-icon {
|
||||||
|
position: relative;
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/* код */
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="text-with-font">
|
||||||
|
<slot></slot>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.text-with-font {
|
||||||
|
font-family: 'font_old_typer';
|
||||||
|
color: #bfa07d;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
defineProps<{
|
|
||||||
msg: string
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="greetings">
|
|
||||||
<h1 class="green">{{ msg }}</h1>
|
|
||||||
<h3>
|
|
||||||
You’ve 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>
|
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
NAutoComplete,
|
||||||
|
NButton,
|
||||||
|
NCard,
|
||||||
|
NCheckbox,
|
||||||
|
NInput,
|
||||||
|
NSpace,
|
||||||
|
NTabPane,
|
||||||
|
NTabs,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const username = ref('')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const acceptTerms = ref(false)
|
||||||
|
const acceptPrivacy = ref(false)
|
||||||
|
|
||||||
|
const options = computed(() => {
|
||||||
|
return ['@mail.ru', '@yandex.ru', '@gmail.com'].map((suffix) => {
|
||||||
|
const prefix = email.value.split('@')[0]
|
||||||
|
return {
|
||||||
|
label: prefix + suffix,
|
||||||
|
value: prefix + suffix,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function signin() {
|
||||||
|
const res = await authStore.login(email.value, password.value)
|
||||||
|
|
||||||
|
if (!res && authStore.error != null) {
|
||||||
|
message.error(authStore.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/office')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signup() {
|
||||||
|
if (!acceptTerms.value || !acceptPrivacy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await authStore.signup(
|
||||||
|
username.value,
|
||||||
|
email.value,
|
||||||
|
acceptTerms.value,
|
||||||
|
acceptPrivacy.value,
|
||||||
|
)
|
||||||
|
if (!res && authStore.error != null) {
|
||||||
|
message.error(authStore.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.info('Пароль отправлен на почту')
|
||||||
|
acceptTerms.value = false
|
||||||
|
acceptPrivacy.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendNewPassword() {
|
||||||
|
const res = await authStore.refreshPassword(email.value)
|
||||||
|
if (!res && authStore.error != null) {
|
||||||
|
message.error(authStore.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.info('Пароль отправлен на почту')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom content-block center-middle-block-custom height80">
|
||||||
|
<n-card style="margin-bottom: 16px" class="sign-card">
|
||||||
|
<n-tabs type="line" animated>
|
||||||
|
<n-tab-pane name="signin" tab="Вход">
|
||||||
|
<n-space vertical>
|
||||||
|
<div class="form-label">Почта</div>
|
||||||
|
<n-auto-complete v-model:value="email" :input-props="{
|
||||||
|
autocomplete: 'disabled',
|
||||||
|
}" :options="options" placeholder="detective@mail.ru" clearable />
|
||||||
|
<div class="form-label">Пароль</div>
|
||||||
|
<n-input v-model:value="password" type="password" placeholder="********" />
|
||||||
|
<div class="form-button-wrapper">
|
||||||
|
<div class="form-label">
|
||||||
|
<n-button @click="signin" :disabled="email.length == 0 || password.length == 0">
|
||||||
|
<span v-if="!authStore.isLoading"> Вход </span>
|
||||||
|
<span v-else> Подождите... </span>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<n-tab-pane name="signup" tab="Регистрация">
|
||||||
|
<n-space vertical>
|
||||||
|
<div class="form-label">Позывной</div>
|
||||||
|
<n-input v-model:value="username" type="text" placeholder="Шерлок" />
|
||||||
|
<div class="form-label">Почта</div>
|
||||||
|
<n-auto-complete v-model:value="email" :input-props="{
|
||||||
|
autocomplete: 'disabled',
|
||||||
|
}" :options="options" placeholder="detective@mail.ru" clearable />
|
||||||
|
<div class="form-label">
|
||||||
|
<n-checkbox v-model:checked="acceptTerms">
|
||||||
|
Я принимаю условия
|
||||||
|
<a href="/user-agreement" target="_blank" class="docs-link">пользовательского соглашения</a>
|
||||||
|
</n-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="form-label">
|
||||||
|
<n-checkbox v-model:checked="acceptPrivacy">
|
||||||
|
Я даю согласие на обработку
|
||||||
|
<a href="/privacy-policy" target="_blank" class="docs-link">персональных данных</a>
|
||||||
|
</n-checkbox>
|
||||||
|
</div>
|
||||||
|
<div class="form-button-wrapper">
|
||||||
|
<div class="form-label">
|
||||||
|
<n-button
|
||||||
|
@click="signup"
|
||||||
|
:disabled="username.length == 0 || email.length == 0 || !acceptTerms || !acceptPrivacy"
|
||||||
|
>
|
||||||
|
<span v-if="!authStore.isLoading"> Регистрация </span>
|
||||||
|
<span v-else> Подождите... </span>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<n-tab-pane name="new-password" tab="Новый пароль">
|
||||||
|
<n-space vertical>
|
||||||
|
<div class="form-label">Почта</div>
|
||||||
|
<n-auto-complete v-model:value="email" :input-props="{
|
||||||
|
autocomplete: 'disabled',
|
||||||
|
}" :options="options" placeholder="detective@mail.ru" clearable />
|
||||||
|
<div class="form-button-wrapper">
|
||||||
|
<div class="form-label">
|
||||||
|
<n-button @click="sendNewPassword" :disabled="email.length == 0">
|
||||||
|
<span v-if="!authStore.isLoading"> Прислать новый пароль </span>
|
||||||
|
<span v-else> Подождите... </span>
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sign-card {
|
||||||
|
background-color: rgb(29 29 29);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-button-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-link {
|
||||||
|
color: #45aa89;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-link:hover {
|
||||||
|
color: #63e2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >=1024px) {
|
||||||
|
.sign-card {
|
||||||
|
width: 380px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/* код */
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="main">
|
||||||
|
<div class="message-cloud-3"></div>
|
||||||
|
<div class="message-cloud-2"></div>
|
||||||
|
<div class="message-cloud">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.main {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud, .message-cloud-2, .message-cloud-3 {
|
||||||
|
background-color: bisque;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud {
|
||||||
|
margin: 15px 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: main;
|
||||||
|
color: black;
|
||||||
|
line-height: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud,
|
||||||
|
.message-cloud-2,
|
||||||
|
.message-cloud-3 {
|
||||||
|
border-radius: 5px;
|
||||||
|
background-image: url("@/assets/images/paper.jpg");
|
||||||
|
background-size: cover;
|
||||||
|
display: flow-root;
|
||||||
|
box-shadow: 0 0 5px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud-2,
|
||||||
|
.message-cloud-3 {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud-2 {
|
||||||
|
transform: rotate(-3deg);
|
||||||
|
filter: brightness(50%);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud-3 {
|
||||||
|
transform: rotate(2deg);
|
||||||
|
filter: brightness(80%);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/* код */
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="main">
|
||||||
|
<div class="message-cloud-3"></div>
|
||||||
|
<div class="message-cloud-2"></div>
|
||||||
|
<div class="message-cloud">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.main {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud, .message-cloud-2, .message-cloud-3 {
|
||||||
|
background-color: bisque;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud {
|
||||||
|
margin: 15px 0;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: main;
|
||||||
|
color: black;
|
||||||
|
line-height: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud,
|
||||||
|
.message-cloud-2,
|
||||||
|
.message-cloud-3 {
|
||||||
|
border-radius: 5px;
|
||||||
|
background-image: url("@/assets/images/paper.jpg");
|
||||||
|
background-size: cover;
|
||||||
|
display: flow-root;
|
||||||
|
box-shadow: 0 0 5px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud-2,
|
||||||
|
.message-cloud-3 {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud-2 {
|
||||||
|
transform: rotate(-3deg);
|
||||||
|
filter: brightness(50%);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-cloud-3 {
|
||||||
|
transform: rotate(2deg);
|
||||||
|
filter: brightness(80%);
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="plate-block">
|
||||||
|
<div class="metal-plate-block">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<div class="pin pin-top-left"></div>
|
||||||
|
<div class="pin pin-top-right"></div>
|
||||||
|
<div class="pin pin-bottom-right"></div>
|
||||||
|
<div class="pin pin-bottom-left"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.plate-block {
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 10px black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metal-plate-block {
|
||||||
|
height: 100%;
|
||||||
|
background-image: url("@/assets/images/metal.png");
|
||||||
|
background-size: cover;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
position: absolute;
|
||||||
|
background-image: url("@/assets/images/pin.png");
|
||||||
|
background-size: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-top-left {
|
||||||
|
top: 3px;
|
||||||
|
left: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-top-right {
|
||||||
|
top: 3px;
|
||||||
|
right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-bottom-right {
|
||||||
|
bottom: 3px;
|
||||||
|
right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-bottom-left {
|
||||||
|
bottom: 3px;
|
||||||
|
left: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NAlert, NButton, NInput, NModal, useMessage } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
|
const client = getAuthClient()
|
||||||
|
|
||||||
|
const permissions = ref<string[]>([])
|
||||||
|
|
||||||
|
async function getPermissions() {
|
||||||
|
permissions.value = []
|
||||||
|
const res = await client.GetPermissions({})
|
||||||
|
permissions.value = res.permissions!
|
||||||
|
}
|
||||||
|
|
||||||
|
getPermissions()
|
||||||
|
|
||||||
|
type Tip = {
|
||||||
|
header: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Функция возвращает случайный детективный совет
|
||||||
|
* с шутками, зависящими от даты
|
||||||
|
*/
|
||||||
|
function getDetectiveTip(): Tip {
|
||||||
|
// ----- 1. Получаем текущую дату и определяем "настроение" -----
|
||||||
|
const now = new Date();
|
||||||
|
const dayOfWeek = now.getDay(); // 0 - Воскресенье, 1 - Понедельник, ...
|
||||||
|
const dayOfMonth = now.getDate();
|
||||||
|
const month = now.getMonth() + 1; // Январь = 1, Декабрь = 12
|
||||||
|
|
||||||
|
const isMonday = dayOfWeek === 1;
|
||||||
|
const isFriday13 = dayOfWeek === 5 && dayOfMonth === 13;
|
||||||
|
const isNewYear = (month === 1 && dayOfMonth === 1) || (month === 12 && dayOfMonth === 31);
|
||||||
|
const isSummer = month >= 6 && month <= 8;
|
||||||
|
const isWinter = month === 12 || month <= 2;
|
||||||
|
const isFullMoonJoke = dayOfMonth % 5 === 0; // Просто для разнообразия
|
||||||
|
|
||||||
|
// ----- 2. База из 100 советов -----
|
||||||
|
const tips: string[] = [
|
||||||
|
// 1-10
|
||||||
|
"Всегда носи с собой запасную ручку — признания любят записывать в самый неподходящий момент.",
|
||||||
|
"Если подозреваемый слишком вежлив, он либо дипломат, либо прячет труп в гараже.",
|
||||||
|
"Кофе в участке — это не напиток, это яд для улик. Пей только свой.",
|
||||||
|
"Следы на месте преступления всегда ведут к тому, кто забыл закрыть дверь.",
|
||||||
|
"Лучший способ разговорить молчуна — предложить ему чай с тремя ложками сахара.",
|
||||||
|
"Если всё указывает на дворецкого, ищи того, кто нанимал дворецкого.",
|
||||||
|
"Записывай каждую мелочь. Особенно цвет носков убитого.",
|
||||||
|
"Не верь алиби, которое начинается со слов 'Я бы никогда...'",
|
||||||
|
"Преступник всегда возвращается на место преступления. Или за забытым телефоном.",
|
||||||
|
"Ищи того, кому есть что терять, кроме своего времени.",
|
||||||
|
|
||||||
|
// 11-20
|
||||||
|
"Если женщина плачет в кабинете — дай ей платок. Если мужчина — дай ему стакан воды.",
|
||||||
|
"Самое опасное оружие — не пистолет, а бухгалтерская книга.",
|
||||||
|
"Собака всегда знает убийцу. Но она не говорит, потому что у неё контракт.",
|
||||||
|
"Проверяй чеки, билеты и смс. Ложь не терпит документации.",
|
||||||
|
"Если у тела нет врагов — ты плохо искал врагов.",
|
||||||
|
"Лупа нужна не для увеличения улик, а для того, чтобы выглядеть умнее.",
|
||||||
|
"Интуиция — это когда ты уже знаешь ответ, но хочешь убедиться, что не ошибся.",
|
||||||
|
"Подозревай всех, даже самого себя. Иногда преступник — это ты в прошлом.",
|
||||||
|
"Хороший детектив видит улики. Великий — видит их отсутствие.",
|
||||||
|
"Никогда не допрашивай свидетеля на голодный желудок. И себя тоже.",
|
||||||
|
|
||||||
|
// 21-30
|
||||||
|
"Если дело зашло в тупик, переверни бумаги вверх ногами.",
|
||||||
|
"Самый ценный свидетель — это камера наблюдения, у которой сломана защита.",
|
||||||
|
"Не ищи мотив. Ищи возможность.",
|
||||||
|
"Преступник не оставляет следов. Он оставляет выбор.",
|
||||||
|
"Если дверь была закрыта изнутри — ищи люк, который открывается снаружи.",
|
||||||
|
"Лучший детектив — тот, кого не замечают в толпе.",
|
||||||
|
"Учись читать по губам. Пригодится на допросах с адвокатом.",
|
||||||
|
"Запах духов на месте преступления важнее запаха пороха.",
|
||||||
|
"Если ты нашел отпечаток пальца — проверь, не твой ли он.",
|
||||||
|
"Раскрыть дело — это искусство. Не раскрыть — тоже искусство, но другое.",
|
||||||
|
|
||||||
|
// 31-40
|
||||||
|
"Никогда не спорь с баллистикой. Она всегда права, даже если ты не согласен.",
|
||||||
|
"В каждом деле есть одна странная деталь. Найди её — и пазл сложится.",
|
||||||
|
"Если преступник умный — он оставит ложный след. Если глупый — он оставит следы в соцсетях.",
|
||||||
|
"Лучший друг детектива — тишина. Второй друг — кофе.",
|
||||||
|
"Если свидетель говорит слишком много, он врет. Если молчит — он тоже врет.",
|
||||||
|
"Преступление редко бывает идеальным. Идеальным бывает только алиби жены.",
|
||||||
|
"Всегда смотри на часы на трупе. Возможно, они показывают время, когда он перестал верить в людей.",
|
||||||
|
"Воры любят темноту. Детективы — свет. А маньяки — полную луну.",
|
||||||
|
"Если ты поймал преступника, не радуйся раньше времени — может, он просто лжец.",
|
||||||
|
"В каждом городе есть одно место, где собираются все сплетники. Начни оттуда.",
|
||||||
|
|
||||||
|
// 41-50
|
||||||
|
"Не верь в совпадения. Но верь в удачу.",
|
||||||
|
"Если у тебя нет улик — придумай их. Но только мысленно, чтобы знать, где искать.",
|
||||||
|
"Жертва всегда знала убийцу. Даже если она не знала, что знает.",
|
||||||
|
"Проверяй алиби на разрыв. Если оно выдерживает — ты ошибся.",
|
||||||
|
"Самый страшный враг детектива — не маньяк, а его собственная лень.",
|
||||||
|
"Если преступник оставил записку — прочитай её. И сожги, чтобы никто не узнал твой почерк.",
|
||||||
|
"Всегда носи с собой фонарик. Даже днём. Для эффекта.",
|
||||||
|
"В деле важно всё: от укуса комара до размера обуви.",
|
||||||
|
"Если ты думаешь, что дело простое — значит, ты не додумал.",
|
||||||
|
"Убийца всегда ищет внимания. Или наоборот, избегает его.",
|
||||||
|
|
||||||
|
// 51-60
|
||||||
|
"Стук в дверь в полночь — это либо полиция, либо заказчик.",
|
||||||
|
"Никогда не давай обещаний родственникам жертвы. Только факты.",
|
||||||
|
"Если у тебя есть три версии — выбирай четвёртую.",
|
||||||
|
"Лучшее оружие — это бумага. На ней можно написать что угодно.",
|
||||||
|
"Когда ты смотришь на улику, смотри на неё глазами преступника.",
|
||||||
|
"Все любят детективы, пока не становятся подозреваемыми.",
|
||||||
|
"Следы крови важны. Но важнее — следы лжи.",
|
||||||
|
"Если ты не спишь третьи сутки, значит, ты либо гений, либо дурак.",
|
||||||
|
"Проверяй каждого, кто говорит 'Я ничего не видел'.",
|
||||||
|
"В каждом деле есть один человек, который знает всё. И он не говорит.",
|
||||||
|
|
||||||
|
// 61-70
|
||||||
|
"Не суди преступника по одежде. Суди по тому, как он её снимал.",
|
||||||
|
"Дыра в алиби — это как дыра в лодке. Рано или поздно она утонет.",
|
||||||
|
"Если преступник использует перчатки — он профессионал. Если нет — он любитель.",
|
||||||
|
"Запомни: первая версия — всегда неверная.",
|
||||||
|
"Если у тебя кончились идеи, иди в бар. Там всегда есть кто-то, кто расскажет тебе дело.",
|
||||||
|
"Свидетель с фотороботом — это как слепой с компасом.",
|
||||||
|
"Никогда не смейся над уликами. Они могут обидеться и исчезнуть.",
|
||||||
|
"Если ты нашёл оружие — не трогай его. Иначе твои отпечатки будут на нём.",
|
||||||
|
"Преступник умён, но ты умнее. Потому что ты пьёшь правильный кофе.",
|
||||||
|
"Всегда пересматривай видеозаписи. Особенно в два раза медленнее.",
|
||||||
|
|
||||||
|
// 71-80
|
||||||
|
"Если на месте преступления есть кот — он видел всё. Но он не скажет.",
|
||||||
|
"Лучший способ поймать преступника — пригласить его на обед.",
|
||||||
|
"Если подозреваемый улыбается, он либо невиновен, либо психопат.",
|
||||||
|
"Проверяй мусор. В нём всегда есть что-то полезное.",
|
||||||
|
"Никогда не доверяй красивым историям. Только уродливым фактам.",
|
||||||
|
"Если ты нашёл улику слишком легко — это ловушка.",
|
||||||
|
"Убийца всегда оставляет послание. Даже если это просто тишина.",
|
||||||
|
"Если ты запнулся на допросе — делай вид, что так и задумано.",
|
||||||
|
"Помни: все врут. Даже я тебе советую.",
|
||||||
|
"Если ты боишься темноты — стань детективом. Страх пройдёт.",
|
||||||
|
|
||||||
|
// 81-90
|
||||||
|
"Самые лучшие признания случаются под утро, когда все устали.",
|
||||||
|
"Если преступник использовал компьютер, значит, он либо хакер, либо дурак.",
|
||||||
|
"Следы шин — это как подпись на дороге.",
|
||||||
|
"Никогда не забывай про запах. Он помнит дольше, чем люди.",
|
||||||
|
"Если ты не знаешь, что делать — съешь шоколадку.",
|
||||||
|
"В каждом деле есть одна минута слабости. Найди её.",
|
||||||
|
"Преступник всегда нервничает. Даже если он не нервничает.",
|
||||||
|
"Если у тебя есть подозреваемый и нет улик — подожди. Улики появятся.",
|
||||||
|
"Не верь в чудеса. Верь в логику.",
|
||||||
|
"Если ты устал — возьми отпуск. Преступление никуда не денется.",
|
||||||
|
|
||||||
|
// 91-100
|
||||||
|
"Запомни: самый опасный преступник — тот, кто читал детективы.",
|
||||||
|
"Если ты думаешь, что всё кончено — значит, ты пропустил поворот.",
|
||||||
|
"Следы на полу важнее следов в душе.",
|
||||||
|
"Если преступник оставил окурок — он курил. Или хотел, чтобы ты так думал.",
|
||||||
|
"Хороший детектив всегда голоден. Это помогает мыслить.",
|
||||||
|
"Не суди по первому впечатлению. Суди по второму, когда увидишь его в наручниках.",
|
||||||
|
"Если у тебя есть тайный агент — не доверяй ему.",
|
||||||
|
"В деле всегда есть тот, кто знает больше, чем говорит.",
|
||||||
|
"Если ты не можешь найти убийцу — стань им (в мыслях).",
|
||||||
|
"И последнее: всегда проверяй, закрыл ли ты дверь участка."
|
||||||
|
];
|
||||||
|
|
||||||
|
// ----- 3. Шутки, зависящие от даты -----
|
||||||
|
const dateJokes: string[] = [];
|
||||||
|
|
||||||
|
if (isMonday) {
|
||||||
|
dateJokes.push("☕ Понедельник: Сегодня даже преступники не хотят работать. Проверь тех, кто был слишком бодр.");
|
||||||
|
}
|
||||||
|
if (isFriday13) {
|
||||||
|
dateJokes.push("🔮 Пятница 13-е: Если улики ведут к чёрной кошке, ищи чёрного кота с алиби.");
|
||||||
|
}
|
||||||
|
if (isNewYear) {
|
||||||
|
dateJokes.push("🎄 Новый год: Свидетель в костюме Деда Мороза врёт. Настоящий Дед Мороз всегда в отпуске.");
|
||||||
|
}
|
||||||
|
if (isSummer) {
|
||||||
|
dateJokes.push("☀️ Лето: В жару преступники забывают улики в карманах, потому что снимают пиджаки.");
|
||||||
|
}
|
||||||
|
if (isWinter) {
|
||||||
|
dateJokes.push("❄️ Зима: Следы на снегу ведут к тому, у кого самые холодные руки.");
|
||||||
|
}
|
||||||
|
if (isFullMoonJoke) {
|
||||||
|
dateJokes.push("🌕 Полнолуние: Если подозреваемый воет на луну, он либо оборотень, либо фанат рока.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 4. Объединяем все советы и выбираем случайный -----
|
||||||
|
const allTips = tips.concat(dateJokes);
|
||||||
|
const randomTip = allTips[Math.floor(Math.random() * allTips.length)];
|
||||||
|
|
||||||
|
// ----- 5. Красивый заголовок -----
|
||||||
|
const headers = [
|
||||||
|
"🔍 Заметки сыщика:",
|
||||||
|
"📓 Из дневника инспектора:",
|
||||||
|
"🕵️ Мудрость Шерлока:",
|
||||||
|
"🧠 Гениально и просто:",
|
||||||
|
"📌 Правило большого пальца:",
|
||||||
|
"☕ Кофейная истина:"
|
||||||
|
];
|
||||||
|
|
||||||
|
const header = headers[Math.floor(Math.random() * headers.length)];
|
||||||
|
|
||||||
|
return { header: header, text: randomTip }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tip = getDetectiveTip()
|
||||||
|
|
||||||
|
// ===== Удаление аккаунта =====
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const deletePassword = ref('')
|
||||||
|
const deleteError = ref('')
|
||||||
|
|
||||||
|
async function confirmDeleteAccount() {
|
||||||
|
deleteError.value = ''
|
||||||
|
const res = await authStore.deleteAccount(deletePassword.value)
|
||||||
|
if (!res) {
|
||||||
|
deleteError.value = authStore.error || 'Не удалось удалить аккаунт'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success('Аккаунт и персональные данные удалены')
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width700">
|
||||||
|
<n-alert :title="tip.header" :show-icon="false" type="info" style="margin-bottom: 20px;">
|
||||||
|
{{ tip.text }}
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<p>Доброго времени суток, {{ authStore.username }}.</p>
|
||||||
|
|
||||||
|
<div class="danger-zone">
|
||||||
|
<div class="danger-zone-text">
|
||||||
|
<div class="danger-zone-title">Удаление аккаунта</div>
|
||||||
|
<div class="danger-zone-hint">
|
||||||
|
Удаление необратимо: аккаунт и все персональные данные будут стёрты (ст. 21 152-ФЗ).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<n-button type="error" ghost @click="showDeleteDialog = true">Удалить аккаунт</n-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-modal
|
||||||
|
v-model:show="showDeleteDialog"
|
||||||
|
preset="card"
|
||||||
|
title="Удаление аккаунта"
|
||||||
|
class="delete-modal"
|
||||||
|
>
|
||||||
|
<p class="delete-modal-text">
|
||||||
|
Это действие необратимо. Для подтверждения удаления введите пароль от учётной записи —
|
||||||
|
он понадобится, чтобы убедиться, что аккаунт удаляет его владелец.
|
||||||
|
</p>
|
||||||
|
<n-input
|
||||||
|
v-model:value="deletePassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Пароль для подтверждения"
|
||||||
|
@keyup.enter="confirmDeleteAccount"
|
||||||
|
/>
|
||||||
|
<p v-if="deleteError" class="delete-modal-error">{{ deleteError }}</p>
|
||||||
|
<div class="delete-modal-buttons">
|
||||||
|
<n-button @click="showDeleteDialog = false">Отмена</n-button>
|
||||||
|
<n-button
|
||||||
|
type="error"
|
||||||
|
:disabled="deletePassword.length == 0"
|
||||||
|
:loading="authStore.isLoading"
|
||||||
|
@click="confirmDeleteAccount"
|
||||||
|
>
|
||||||
|
Удалить навсегда
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<HeaderMenu active="office" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.danger-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #222;
|
||||||
|
border: 1px solid rgb(224 108 117 / 40%);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e06c75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-zone-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-text {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-error {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: #e06c75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-modal-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Key24Regular } from '@vicons/fluent'
|
||||||
|
import { LockOutlined } from '@vicons/material'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
|
import { NButton, NDynamicInput, NInput, NInputGroup, NSpace, NSwitch, NText, NUpload, NUploadDragger } from 'naive-ui'
|
||||||
|
import type { PropType } from 'vue'
|
||||||
|
|
||||||
|
import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
|
||||||
|
import MessagePaper from './MessagePaper.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
newPlaceCode: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
newPlaceName: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
newPlaceText: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
place: {
|
||||||
|
type: Object as PropType<Place>,
|
||||||
|
},
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
addAction: {
|
||||||
|
type: Function as PropType<(param: string) => void>,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:newPlaceCode': [value: string],
|
||||||
|
'update:newPlaceName': [value: string],
|
||||||
|
'update:newPlaceText': [value: string],
|
||||||
|
'update:mode': [value: string]
|
||||||
|
}>()
|
||||||
|
const updateCode = (code: string) => {
|
||||||
|
emit('update:newPlaceCode', code)
|
||||||
|
}
|
||||||
|
const updateName = (name: string) => {
|
||||||
|
emit('update:newPlaceName', name)
|
||||||
|
}
|
||||||
|
const updateText = (text: string) => {
|
||||||
|
emit('update:newPlaceText', text)
|
||||||
|
}
|
||||||
|
const updateMode = (mode: string) => {
|
||||||
|
emit('update:mode', mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreateApplication(): Application {
|
||||||
|
return {
|
||||||
|
name: undefined,
|
||||||
|
image: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreateDoor(): Door {
|
||||||
|
return {
|
||||||
|
code: undefined,
|
||||||
|
name: undefined,
|
||||||
|
keys: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreateKey(): Key {
|
||||||
|
return {
|
||||||
|
name: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="mode == 'mini-add'" class="place-block-hover show-editor-block" @click="updateMode('add')">
|
||||||
|
<p class="place-image-plus">+</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode == 'add'" class="place-block">
|
||||||
|
<n-space vertical>
|
||||||
|
<n-input-group>
|
||||||
|
<n-input :style="{ width: '20%' }" :value="newPlaceCode" @update:value="updateCode" type="text"
|
||||||
|
placeholder="Ы-1" />
|
||||||
|
<n-input :style="{ width: '80%' }" :value="newPlaceName" @update:value="updateName" type="text"
|
||||||
|
placeholder="Дом №Ы" />
|
||||||
|
</n-input-group>
|
||||||
|
<n-input :value="newPlaceText" @update:value="updateText" type="textarea" :autosize="{ minRows: 3 }"
|
||||||
|
placeholder="В начале было слово..." />
|
||||||
|
<slot></slot>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode == 'show-editor'" class="place-block-hover show-editor-block" @click="updateMode('edit')">
|
||||||
|
<div class="message-header">
|
||||||
|
{{ props.place?.code }}: {{ props.place?.name }}
|
||||||
|
</div>
|
||||||
|
<hr class="hr" />
|
||||||
|
<div class="message-content">
|
||||||
|
<div v-if="props.place?.image">
|
||||||
|
<div class="message-image-border">
|
||||||
|
<img :src="props.place?.image" class="message-image">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ props.place?.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="door in props.place?.doors" v-bind:key="door.code" class="door-block">
|
||||||
|
{{ door.name }} <span v-for="key in door.keys" :key="key.name" class="key-block">
|
||||||
|
<Icon class="lock-icon">
|
||||||
|
<LockOutlined />
|
||||||
|
</Icon> {{ key.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="hr" v-if="props.place?.applications?.length" />
|
||||||
|
<div class="message-footer application-block" v-for="application in props.place?.applications"
|
||||||
|
v-bind:key="application.name">
|
||||||
|
Приложение: {{ application.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="gray-block">
|
||||||
|
<span v-if="props.place?.hidden">
|
||||||
|
Скрытая точка
|
||||||
|
</span>
|
||||||
|
<span v-for="key in props.place?.keys" class="key-block">
|
||||||
|
<Icon class="lock-icon">
|
||||||
|
<Key24Regular />
|
||||||
|
</Icon> {{ key.name }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode == 'show'">
|
||||||
|
<MessagePaper>
|
||||||
|
<div class="message-show-header font">
|
||||||
|
{{ place?.code }}: {{ place?.name }}
|
||||||
|
<span class="collapse-icon collapse-icon-up" @click="updateMode('show-collapse')"></span>
|
||||||
|
</div>
|
||||||
|
<hr class="show-hr" />
|
||||||
|
<div class="message-show-content font">
|
||||||
|
<div v-if="place?.image?.length">
|
||||||
|
<div class="message-image-border">
|
||||||
|
<img v-bind:src="place?.image" class="message-image" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ place?.text }}
|
||||||
|
</div>
|
||||||
|
<hr class="show-hr" v-if="props.place?.doors?.length" />
|
||||||
|
<div v-for="door in props.place?.doors" :key="door.code" class="button-dialog font"
|
||||||
|
v-on:click="addAction!(door.code!)">
|
||||||
|
<div class="button-dialog-text">
|
||||||
|
{{ door.code }} {{ door.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr class="show-hr" v-if="props.place?.applications?.length" />
|
||||||
|
<div class="message-show-footer font" v-for="application in props.place?.applications" :key="application.name">
|
||||||
|
Приложение: {{ application.name }}
|
||||||
|
<!-- <div class="application-label">{{ application.number }}</div> -->
|
||||||
|
</div>
|
||||||
|
</MessagePaper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode == 'show-collapse'">
|
||||||
|
<MessagePaper>
|
||||||
|
<div class="message-header font">
|
||||||
|
{{ place?.code }}: {{ place?.name }}
|
||||||
|
<span class="collapse-icon" @click="updateMode('show')"></span>
|
||||||
|
</div>
|
||||||
|
</MessagePaper>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="mode == 'edit'" class="place-block">
|
||||||
|
<n-space vertical>
|
||||||
|
<n-input-group>
|
||||||
|
<n-input :style="{ width: '20%' }" v-model:value="props.place!.code" type="text" placeholder="Ы-1" />
|
||||||
|
<n-input :style="{ width: '80%' }" v-model:value="props.place!.name" type="text" placeholder="Дом №Ы" />
|
||||||
|
</n-input-group>
|
||||||
|
<n-input v-model:value="props.place!.text" type="textarea" :autosize="{ minRows: 3 }"
|
||||||
|
placeholder="В начале было слово..." />
|
||||||
|
|
||||||
|
<p class="settings-header">Картинка</p>
|
||||||
|
<div>
|
||||||
|
<n-space>
|
||||||
|
<n-space vertical>
|
||||||
|
<img :src="props.place!.image" class="message-edit-image">
|
||||||
|
<n-button @click="props.place!.image = '', props.place!.fileList = null">Убрать картинку</n-button>
|
||||||
|
</n-space>
|
||||||
|
<n-space vertical>
|
||||||
|
<n-input v-model:value="props.place!.image" type="text" placeholder="Не задано" disabled />
|
||||||
|
<n-upload directory-dnd :max="1" v-model:file-list="props.place!.fileList">
|
||||||
|
<n-upload-dragger>
|
||||||
|
<n-text style="font-size: 16px">
|
||||||
|
Щелкните или перетащите файл в эту область для загрузки
|
||||||
|
</n-text>
|
||||||
|
</n-upload-dragger>
|
||||||
|
</n-upload>
|
||||||
|
</n-space>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="settings-header">Видимость точки</p>
|
||||||
|
<n-switch v-model:value="props.place!.hidden">
|
||||||
|
<template #checked>
|
||||||
|
Скрытая точка
|
||||||
|
</template>
|
||||||
|
<template #unchecked>
|
||||||
|
Обычная точка
|
||||||
|
</template>
|
||||||
|
</n-switch>
|
||||||
|
|
||||||
|
Улики
|
||||||
|
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
|
||||||
|
<template #create-button-default> Добавить улику </template>
|
||||||
|
<template #default="{ value }">
|
||||||
|
<div style="display: flex; align-items: center; width: 100%">
|
||||||
|
<n-input v-model:value="value.name" type="text" placeholder="Название" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-dynamic-input>
|
||||||
|
|
||||||
|
Действия
|
||||||
|
<n-dynamic-input v-model:value="props.place!.doors" :on-create="onCreateDoor">
|
||||||
|
<template #create-button-default> Добавить действие </template>
|
||||||
|
<template #action="{ index, create, remove }">
|
||||||
|
<div style="display: flex; flex-direction: column;">
|
||||||
|
<n-button @click="() => create(index)" class="plus-minus-buttons">+</n-button>
|
||||||
|
<n-button @click="() => remove(index)" class="plus-minus-buttons">-</n-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #default="{ value: door }">
|
||||||
|
<div style="display: flex; flex-direction: column; width: 100%">
|
||||||
|
|
||||||
|
<div class="door-add-block">
|
||||||
|
<n-input-group>
|
||||||
|
<n-input :style="{ width: '30%' }" v-model:value="door.code" type="text" placeholder="Код" />
|
||||||
|
<n-input :style="{ width: '70%' }" v-model:value="door.name" type="text" placeholder="Название" />
|
||||||
|
</n-input-group>
|
||||||
|
|
||||||
|
<p class="need-keys-text">Требуемые ключи</p>
|
||||||
|
<div class="door-field">
|
||||||
|
<n-dynamic-input v-model:value="door.keys" :on-create="onCreateKey">
|
||||||
|
<template #create-button-default> Добавить ключ </template>
|
||||||
|
<template #default="{ value: key }">
|
||||||
|
<div style="display: flex; align-items: center; width: 100%">
|
||||||
|
<n-input v-model:value="key.name" type="text" placeholder="Название" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-dynamic-input>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-dynamic-input>
|
||||||
|
|
||||||
|
Получаемые ключи
|
||||||
|
<n-dynamic-input v-model:value="props.place!.keys" :on-create="onCreateKey">
|
||||||
|
<template #create-button-default> Добавить ключ </template>
|
||||||
|
<template #default="{ value: key }">
|
||||||
|
<div style="display: flex; align-items: center; width: 100%">
|
||||||
|
<n-input v-model:value="key.name" type="text" placeholder="Название" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</n-dynamic-input>
|
||||||
|
|
||||||
|
<div class="slot-block">
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hr {
|
||||||
|
margin: 10px 0;
|
||||||
|
border: dashed 1px;
|
||||||
|
|
||||||
|
/* border-color: black; */
|
||||||
|
border-color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-block-hover,
|
||||||
|
.place-block {
|
||||||
|
margin: 10px 0;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #222;
|
||||||
|
background-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-block-hover:hover {
|
||||||
|
color: #63e2b7;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid #63e2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-image-plus {
|
||||||
|
font-size: 30px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-editor-block {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-content {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gray-block {
|
||||||
|
margin-top: 5px;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.application-block {
|
||||||
|
margin-right: 5px;
|
||||||
|
color: burlywood;
|
||||||
|
}
|
||||||
|
|
||||||
|
.door-block {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 10px 5px 10px 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #222;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.door-add-block {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #222;
|
||||||
|
color: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-block {
|
||||||
|
color: gold;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lock-icon {
|
||||||
|
position: relative;
|
||||||
|
top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.door-field {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.need-keys-text {
|
||||||
|
color: gold;
|
||||||
|
margin: 15px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-block {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-minus-buttons {
|
||||||
|
border: 1px solid #444;
|
||||||
|
padding: 0 13px;
|
||||||
|
margin: 0 0 10px 5px;
|
||||||
|
text-align: center;
|
||||||
|
color: #eee;
|
||||||
|
font-size: 25px;
|
||||||
|
border-radius: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plus-minus-buttons:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #63e2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image-border {
|
||||||
|
width: 40%;
|
||||||
|
max-width: 200px;
|
||||||
|
float: left;
|
||||||
|
padding: 7px;
|
||||||
|
margin-right: 15px;
|
||||||
|
background-image: url("@/assets/images/paper_white.jpg");
|
||||||
|
background-size: cover;
|
||||||
|
box-shadow: 0 3px 15px rgb(98 98 98);
|
||||||
|
transform: rotate(-3deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-image {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-edit-image {
|
||||||
|
width: 135px;
|
||||||
|
height: 135px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
font-size: 20px;
|
||||||
|
padding-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-footer {
|
||||||
|
padding-right: 50px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: burlywood;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-show-header {
|
||||||
|
font-size: 20px;
|
||||||
|
padding-right: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
width: 45px;
|
||||||
|
height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-image: url("@/assets/images/collapse.png");
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-icon-up {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.show-hr {
|
||||||
|
margin: 10px 0;
|
||||||
|
border: dashed 1px;
|
||||||
|
border-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font {
|
||||||
|
font-family: 'font_old_typer';
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-show-content {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-show-footer {
|
||||||
|
padding-right: 50px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--second-color);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-dialog {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-right: 10px;
|
||||||
|
background-image: url("@/assets/images/belt.png");
|
||||||
|
background-size: cover;
|
||||||
|
color: #bfa07d;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-dialog-text {
|
||||||
|
padding: 5px 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { NTag } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const client = getAuthClient()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const scenarioId = route.params.id
|
||||||
|
|
||||||
|
const scenario = ref<Scenario>({
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
story: undefined,
|
||||||
|
author: undefined,
|
||||||
|
status: undefined,
|
||||||
|
updatedAt: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
publishedAt: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function getScenario(id: number) {
|
||||||
|
const res = await client.GetScenario({ id: id })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scenario.value = res.scenario!
|
||||||
|
}
|
||||||
|
getScenario(Number(scenarioId))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width700">
|
||||||
|
<div class="scenario-title">
|
||||||
|
{{ scenario.name }}
|
||||||
|
</div>
|
||||||
|
<img class="scenario-image" :src="scenario.image">
|
||||||
|
<div class="scenario-description">
|
||||||
|
{{ scenario.description }}
|
||||||
|
</div>
|
||||||
|
<div class="scenario-author">
|
||||||
|
<n-tag :bordered="false" type="success" class="status-block">Автор: {{ scenario.author?.username
|
||||||
|
}}</n-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HeaderMenu active="catalog" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scenario-title {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-image {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-description {
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Settings } from '@vicons/carbon'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
|
import { NButton, type UploadFileInfo, useMessage } from 'naive-ui'
|
||||||
|
import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { getArchiveClient } from '@/api/archive_client'
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import type { Place, Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const client = getAuthClient()
|
||||||
|
const archiveClient = getArchiveClient()
|
||||||
|
const route = useRoute()
|
||||||
|
const scenarioId = route.params.id
|
||||||
|
|
||||||
|
const showSettingsModal = ref(false)
|
||||||
|
const statusScenarioName = ref('')
|
||||||
|
const deletedScenarioName = ref('')
|
||||||
|
const isDownloadingArchive = ref(false)
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const fileList = ref<UploadFileInfo[]>([])
|
||||||
|
|
||||||
|
const scenario = ref<Scenario>({
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
story: undefined,
|
||||||
|
author: undefined,
|
||||||
|
status: undefined,
|
||||||
|
updatedAt: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
publishedAt: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function getScenario(id: number) {
|
||||||
|
const res = await client.GetFullScenario({ id: id })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scenario.value = res.scenario!
|
||||||
|
scenario.value.story?.places?.forEach((item) => {
|
||||||
|
item.oldCode = item.code
|
||||||
|
item.mode = 'show-editor'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
getScenario(Number(scenarioId))
|
||||||
|
|
||||||
|
async function deleteScenario(id: number) {
|
||||||
|
const res = await client.DeleteScenario({ id: id })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push('/scenarios')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publicScenario(id: number) {
|
||||||
|
const res = await client.PublicScenario({ id: id })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getScenario(Number(scenarioId))
|
||||||
|
statusScenarioName.value = ''
|
||||||
|
showSettingsModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addGame(name: string, id: number) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const resAddGame = await client.AddGame({
|
||||||
|
name: ('Тестовая игра: ' + name).trim(),
|
||||||
|
description: 'Тестовая игра',
|
||||||
|
startAt: now,
|
||||||
|
scenarioId: id
|
||||||
|
})
|
||||||
|
if (resAddGame.error != '') {
|
||||||
|
message.error(resAddGame.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const resAddTeam = await client.AddTeam({
|
||||||
|
name: 'Тестовая команда',
|
||||||
|
gameId: resAddGame.id!,
|
||||||
|
})
|
||||||
|
if (resAddTeam.error != '') {
|
||||||
|
message.error(resAddTeam.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push("/team-story/" + resAddTeam.id! + '?password=' + resAddTeam.password!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function draftScenario(id: number) {
|
||||||
|
const res = await client.DraftScenario({ id: id })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getScenario(Number(scenarioId))
|
||||||
|
statusScenarioName.value = ''
|
||||||
|
showSettingsModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadArchive() {
|
||||||
|
isDownloadingArchive.value = true
|
||||||
|
try {
|
||||||
|
const { blob, filename } = await archiveClient.downloadScenarioArchive(scenario.value.id!)
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
// Отзываем URL после того как браузер начал скачивание
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
|
message.success('Архив сценария скачан')
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : 'Не удалось скачать архив')
|
||||||
|
} finally {
|
||||||
|
isDownloadingArchive.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file: File | null, filename: string): Promise<string> {
|
||||||
|
if (!file) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Читаем как ArrayBuffer
|
||||||
|
const arrayBuffer = await file.arrayBuffer()
|
||||||
|
const bytes = new Uint8Array(arrayBuffer)
|
||||||
|
|
||||||
|
const resUploadFile = await client.UploadFile({
|
||||||
|
filename: filename,
|
||||||
|
data: uint8ToBase64(bytes),
|
||||||
|
})
|
||||||
|
if (resUploadFile.error != '') {
|
||||||
|
message.error(resUploadFile.error!)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return resUploadFile.filename || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateScenario() {
|
||||||
|
let resUploadFilename = ''
|
||||||
|
if (fileList.value && fileList.value.length > 0) {
|
||||||
|
const file = fileList.value[0].file || null
|
||||||
|
if (file == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
|
||||||
|
if (resUploadFilename === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await client.UpdateScenario({
|
||||||
|
id: scenario.value.id,
|
||||||
|
name: scenario.value.name,
|
||||||
|
description: scenario.value.description,
|
||||||
|
image: resUploadFilename,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getScenario(Number(scenarioId))
|
||||||
|
showSettingsModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function uint8ToBase64(uint8Array: Uint8Array) {
|
||||||
|
// Преобразуем Uint8Array в двоичную строку
|
||||||
|
const binaryString = fromCharCodeSafe(uint8Array)
|
||||||
|
// Кодируем в Base64
|
||||||
|
return btoa(binaryString)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromCharCodeSafe(arr: Uint8Array) {
|
||||||
|
let result = '';
|
||||||
|
for (let i = 0; i < arr.length; i += 50000) {
|
||||||
|
const chunk = arr.slice(i, i + 50000);
|
||||||
|
result += String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = ref('')
|
||||||
|
const name = ref('')
|
||||||
|
const text = ref('')
|
||||||
|
const mode = ref('mini-add')
|
||||||
|
|
||||||
|
|
||||||
|
async function addPlace() {
|
||||||
|
const res = await client.AddScenarioPlace({
|
||||||
|
id: scenario.value.id,
|
||||||
|
place: {
|
||||||
|
code: code.value,
|
||||||
|
name: name.value,
|
||||||
|
text: text.value,
|
||||||
|
image: undefined,
|
||||||
|
hidden: undefined,
|
||||||
|
applications: undefined,
|
||||||
|
doors: undefined,
|
||||||
|
keys: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getScenario(Number(scenarioId))
|
||||||
|
|
||||||
|
code.value = ''
|
||||||
|
name.value = ''
|
||||||
|
text.value = ''
|
||||||
|
mode.value = 'add'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelAddPlace() {
|
||||||
|
code.value = ''
|
||||||
|
name.value = ''
|
||||||
|
text.value = ''
|
||||||
|
mode.value = 'mini-add'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNotFoundDoors(scenario: Scenario, place: Place) {
|
||||||
|
for (const door of place.doors || []) {
|
||||||
|
const place = scenario.story?.places?.find((place) => {
|
||||||
|
return place.code == door.code
|
||||||
|
})
|
||||||
|
if (place == undefined) {
|
||||||
|
const res = await client.AddScenarioPlace({
|
||||||
|
id: scenario.id,
|
||||||
|
place: {
|
||||||
|
code: door.code,
|
||||||
|
name: door.name,
|
||||||
|
text: undefined,
|
||||||
|
image: undefined,
|
||||||
|
hidden: true,
|
||||||
|
applications: undefined,
|
||||||
|
doors: undefined,
|
||||||
|
keys: undefined
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelUpdatePlace(place: Place) {
|
||||||
|
await getScenario(Number(scenarioId))
|
||||||
|
|
||||||
|
place.mode = 'show-editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updatePlace(place: Place) {
|
||||||
|
if (place.fileList && place.fileList.length > 0) {
|
||||||
|
const file = place.fileList[0].file || null
|
||||||
|
if (file == null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
|
||||||
|
if (resUploadFilename === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
place.image = resUploadFilename
|
||||||
|
}
|
||||||
|
|
||||||
|
await addNotFoundDoors(scenario.value, place)
|
||||||
|
const res = await client.UpdateScenarioPlace({
|
||||||
|
id: scenario.value.id,
|
||||||
|
code: place.oldCode,
|
||||||
|
place: place,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getScenario(Number(scenarioId))
|
||||||
|
|
||||||
|
place.mode = 'show-editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlace(place: Place) {
|
||||||
|
const res = await client.DeleteScenarioPlace({
|
||||||
|
id: scenario.value.id,
|
||||||
|
code: place.oldCode,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getScenario(Number(scenarioId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRandomComplimentForNastya() {
|
||||||
|
const compliments = [
|
||||||
|
{ text: 'Настя, ты просто ослепительна сегодня!', nameForm: 'Настя' },
|
||||||
|
{ text: 'У Насти невероятный вкус!', nameForm: 'Насти' },
|
||||||
|
{ text: 'Настенька, твоя улыбка делает мир ярче.', nameForm: 'Настенька' },
|
||||||
|
{ text: 'С Настей всегда интересно общаться.', nameForm: 'Настей' },
|
||||||
|
{ text: 'Настя, ты — источник вдохновения для всех вокруг.', nameForm: 'Настя' },
|
||||||
|
{ text: 'Мне нравится, как Настя смотрит на мир.', nameForm: 'Настя' },
|
||||||
|
{ text: 'Настюха, ты супер!', nameForm: 'Настюха' },
|
||||||
|
{ text: 'Талант Насти поражает!', nameForm: 'Насти' },
|
||||||
|
{ text: 'Настя, твоя энергия заряжает батарейки!', nameForm: 'Настя' },
|
||||||
|
{ text: 'С Настей хочется проводить каждую минуту.', nameForm: 'Настей' },
|
||||||
|
{ text: 'Настенька, ты — самое красивое, что случилось в этом мире.', nameForm: 'Настенька' },
|
||||||
|
{ text: 'У Насти золотое сердце.', nameForm: 'Насти' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const randomIndex = Math.floor(Math.random() * compliments.length);
|
||||||
|
return compliments[randomIndex].text;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width700">
|
||||||
|
<div class="places-container">
|
||||||
|
<n-alert v-if="scenario.status == 'public'" title="Сценарий опубликован" type="warning"
|
||||||
|
style="margin-bottom: 20px;">
|
||||||
|
Не рекомендуется редактировать сценарий когда он опубликован.
|
||||||
|
</n-alert>
|
||||||
|
|
||||||
|
<n-alert v-if="authStore.user?.id == 2" :title="getRandomComplimentForNastya()" type="info"
|
||||||
|
style="margin-bottom: 20px;"></n-alert>
|
||||||
|
|
||||||
|
<div class="settings-block">
|
||||||
|
Точек: {{ scenario.story?.places?.length }}
|
||||||
|
<n-button quaternary @click="showSettingsModal = true" class="settings-button">
|
||||||
|
<Icon class="settings-icon">
|
||||||
|
<Settings />
|
||||||
|
</Icon>
|
||||||
|
Настройки
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlaceBlock v-model:mode="mode" v-model:newPlaceCode="code" v-model:newPlaceName="name"
|
||||||
|
v-model:newPlaceText="text">
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="cancelAddPlace()">Отмена</n-button>
|
||||||
|
<n-button @click="addPlace()" :disabled="!code">Добавить точку</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</PlaceBlock>
|
||||||
|
|
||||||
|
<PlaceBlock :place="place" v-model:mode="place.mode" v-for="place in scenario.story?.places"
|
||||||
|
v-bind:key="place.oldCode">
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button>
|
||||||
|
<n-button @click="cancelUpdatePlace(place)">Отмена</n-button>
|
||||||
|
<n-button @click="updatePlace(place)" :disabled="place.code == ''">Сохранить</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</PlaceBlock>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Окно настроек -->
|
||||||
|
<n-modal v-model:show="showSettingsModal">
|
||||||
|
<n-card style="width: 600px" title="Настройки сценария" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showSettingsModal = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-space vertical>
|
||||||
|
<p>Название</p>
|
||||||
|
<n-input v-model:value="scenario.name" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||||
|
|
||||||
|
<p class="settings-header">Описание</p>
|
||||||
|
<n-input v-model:value="scenario.description" type="textarea" placeholder="Убийство пастуха..." />
|
||||||
|
|
||||||
|
<p class="settings-header">Картинка</p>
|
||||||
|
<n-input v-model:value="scenario.image" type="text" placeholder="default_scenario.png" disabled />
|
||||||
|
<n-upload directory-dnd :max="1" v-model:file-list="fileList">
|
||||||
|
<n-upload-dragger>
|
||||||
|
<n-text style="font-size: 16px">
|
||||||
|
Щелкните или перетащите файл в эту область для загрузки
|
||||||
|
</n-text>
|
||||||
|
</n-upload-dragger>
|
||||||
|
</n-upload>
|
||||||
|
|
||||||
|
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
|
||||||
|
|
||||||
|
<hr class="settings-hr" />
|
||||||
|
<p class="settings-header">Архив сценария</p>
|
||||||
|
<p class="settings-hint">
|
||||||
|
Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом аккаунте.
|
||||||
|
</p>
|
||||||
|
<n-button :loading="isDownloadingArchive" :disabled="!scenario.id" ghost @click="downloadArchive()">
|
||||||
|
Скачать архив (.zip)
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<hr class="settings-hr" />
|
||||||
|
<p>Создать тестовую игру</p>
|
||||||
|
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
|
||||||
|
Создать игру
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<hr class="settings-hr" />
|
||||||
|
<p>Публикация</p>
|
||||||
|
<p>
|
||||||
|
<n-tag v-if="scenario.status == 'public'" :bordered="false" type="success" class="status-block">
|
||||||
|
Опубликовано
|
||||||
|
</n-tag>
|
||||||
|
<n-tag v-if="scenario.status == 'draft'" :bordered="false" type="warning" class="status-block">
|
||||||
|
В разработке
|
||||||
|
</n-tag>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Введите название сценария <n-text class="name-text" strong>{{ scenario.name }}</n-text>
|
||||||
|
</p>
|
||||||
|
<n-input v-model:value="statusScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||||
|
<n-button v-if="scenario.status != 'public'" @click="publicScenario(scenario.id!)" ghost
|
||||||
|
:disabled="statusScenarioName != scenario.name">
|
||||||
|
Опубликовать сценарий
|
||||||
|
</n-button>
|
||||||
|
<n-button v-if="scenario.status != 'draft'" @click="draftScenario(scenario.id!)" ghost
|
||||||
|
:disabled="statusScenarioName != scenario.name">
|
||||||
|
Снять сценарий с публикации
|
||||||
|
</n-button>
|
||||||
|
|
||||||
|
<hr class="settings-hr" />
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Введите название сценария
|
||||||
|
<n-text class="name-text" strong>{{ scenario.name }}</n-text> чтобы удалить его
|
||||||
|
</p>
|
||||||
|
<n-input v-model:value="deletedScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||||
|
<n-button @click="deleteScenario(scenario.id!)" type="error" ghost
|
||||||
|
:disabled="deletedScenarioName != scenario.name">
|
||||||
|
Удалить сценарий
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
<template #footer> </template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<HeaderMenu active="scenarios" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings-block {
|
||||||
|
height: 40px;
|
||||||
|
|
||||||
|
/* background-color: brown; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-button {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-icon {
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-text {
|
||||||
|
padding: 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: rgb(255 255 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-hr {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.places-container {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Archive } from '@vicons/carbon'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
|
import { useMessage } from 'naive-ui'
|
||||||
|
import { NButton, NCard, NFlex, NInput, NModal, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { getArchiveClient } from '@/api/archive_client'
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
|
||||||
|
const client = getAuthClient()
|
||||||
|
const archiveClient = getArchiveClient()
|
||||||
|
|
||||||
|
const scenarios = ref<Scenario[]>([])
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const showAddScenarioModal = ref(false)
|
||||||
|
const newScenarioName = ref('')
|
||||||
|
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const importFileList = ref<UploadFileInfo[]>([])
|
||||||
|
const isImporting = ref(false)
|
||||||
|
|
||||||
|
async function getScenarios() {
|
||||||
|
scenarios.value = []
|
||||||
|
const res = await client.GetMyScenarios({})
|
||||||
|
scenarios.value = res.scenarios!
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addScenario(name: string) {
|
||||||
|
const res = await client.AddScenario({ name: name })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await toScenarioEditor(res.id!)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toScenarioEditor(id: number) {
|
||||||
|
router.push('/scenarios/' + id + '/editor')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetImport() {
|
||||||
|
importFileList.value = []
|
||||||
|
showImportModal.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importScenario() {
|
||||||
|
const file = importFileList.value[0]?.file
|
||||||
|
if (!file) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isImporting.value = true
|
||||||
|
try {
|
||||||
|
const res = await archiveClient.uploadScenarioArchive(file)
|
||||||
|
if (res.error) {
|
||||||
|
message.error(res.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success('Сценарий импортирован из архива')
|
||||||
|
resetImport()
|
||||||
|
await toScenarioEditor(res.id!)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(err instanceof Error ? err.message : 'Не удалось импортировать архив')
|
||||||
|
} finally {
|
||||||
|
isImporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getScenarios()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width1200 display-table">
|
||||||
|
<div class="scenario-block" @click="showAddScenarioModal = true">
|
||||||
|
<div class="scenario-image-block scenario-image-plus">+</div>
|
||||||
|
<div class="scenario-content-block">
|
||||||
|
<p class="scenario-title">Создать новый сценарий</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scenario-block" @click="showImportModal = true">
|
||||||
|
<div class="scenario-image-block scenario-image-plus import-icon">
|
||||||
|
<Icon>
|
||||||
|
<Archive />
|
||||||
|
</Icon>
|
||||||
|
</div>
|
||||||
|
<div class="scenario-content-block">
|
||||||
|
<p class="scenario-title">Импортировать из архива</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioEditor(scenario.id!)"
|
||||||
|
v-bind:key="scenario.id">
|
||||||
|
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
|
||||||
|
|
||||||
|
<div class="scenario-content-block">
|
||||||
|
<p class="scenario-title">{{ scenario.name }}</p>
|
||||||
|
<p class="scenario-label">
|
||||||
|
<n-tag v-if="scenario.status == 'public'" :bordered="false" type="success"
|
||||||
|
class="status-block">Опубликовано</n-tag>
|
||||||
|
<n-tag v-if="scenario.status == 'draft'" :bordered="false" type="warning" class="status-block">В
|
||||||
|
разработке</n-tag>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Окно создания сценария -->
|
||||||
|
<n-modal v-model:show="showAddScenarioModal">
|
||||||
|
<n-card style="width: 600px" title="Создание сценария" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showAddScenarioModal = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-input v-model:value="newScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="addScenario(newScenarioName)" :disabled="newScenarioName.length == 0">
|
||||||
|
Создать
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Окно импорта сценария из архива -->
|
||||||
|
<n-modal v-model:show="showImportModal">
|
||||||
|
<n-card style="width: 600px" title="Импорт сценария из архива" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="resetImport()"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<p class="import-hint">
|
||||||
|
Выберите ZIP-архив сценария (файл scenario.json + папка images). Архив можно получить
|
||||||
|
через «Скачать архив» в настройках сценария.
|
||||||
|
</p>
|
||||||
|
<n-upload :max="1" v-model:file-list="importFileList"
|
||||||
|
accept=".zip,application/zip,application/x-zip-compressed,application/octet-stream">
|
||||||
|
<n-upload-dragger>
|
||||||
|
<n-text style="font-size: 16px">
|
||||||
|
Щелкните или перетащите ZIP-архив в эту область
|
||||||
|
</n-text>
|
||||||
|
</n-upload-dragger>
|
||||||
|
</n-upload>
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="resetImport()">Отмена</n-button>
|
||||||
|
<n-button type="primary" :loading="isImporting" :disabled="importFileList.length == 0"
|
||||||
|
@click="importScenario()">
|
||||||
|
Импортировать
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<HeaderMenu active="scenarios" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scenario-block {
|
||||||
|
position: relative;
|
||||||
|
margin: 10px;
|
||||||
|
width: 160px;
|
||||||
|
height: 300px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
/* background-color: #553333; */
|
||||||
|
background-color: #222;
|
||||||
|
border: 2px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-content-block {
|
||||||
|
padding: 2px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-block:hover {
|
||||||
|
color: #63e2b7;
|
||||||
|
border: 2px solid #63e2b7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-image-block {
|
||||||
|
background-color: #111;
|
||||||
|
height: 160px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-image-plus {
|
||||||
|
font-size: 120px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-icon svg {
|
||||||
|
height: 100px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-hint {
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-image {
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-title {
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-block {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 15px;
|
||||||
|
right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >=1024px) {
|
||||||
|
.scenario-block {
|
||||||
|
width: 250px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-image-block {
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scenario-content-block {
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NFlex, NQrCode } from 'naive-ui'
|
||||||
|
import { useMessage } from 'naive-ui';
|
||||||
|
import { nextTick, ref } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client';
|
||||||
|
import type { Game, Place } from '@/api/generated/crabs/evening_detective_server';
|
||||||
|
import GameInputForm from '@/components/GameInputForm.vue'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||||
|
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
import MessagePaper from './MessagePaper.vue';
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const client = getAuthClient()
|
||||||
|
const message = useMessage()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const teamId = route.params.id
|
||||||
|
const password = route.query.password
|
||||||
|
|
||||||
|
const newPlacesCount = ref(0)
|
||||||
|
|
||||||
|
const scrollContainer = ref<HTMLDivElement | null>();
|
||||||
|
|
||||||
|
const story = ref<AdvancedStory>({
|
||||||
|
places: []
|
||||||
|
})
|
||||||
|
|
||||||
|
const game = ref<Game | undefined>({
|
||||||
|
id: undefined,
|
||||||
|
name: undefined,
|
||||||
|
description: undefined,
|
||||||
|
startAt: undefined,
|
||||||
|
status: undefined,
|
||||||
|
scenario: undefined,
|
||||||
|
teams: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const code = ref('')
|
||||||
|
|
||||||
|
const url = window.location.href
|
||||||
|
|
||||||
|
const isAtBottom = () => {
|
||||||
|
const container = scrollContainer.value;
|
||||||
|
if (!container) return false;
|
||||||
|
|
||||||
|
return container.scrollHeight - container.scrollTop - container.clientHeight < 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function getStory() {
|
||||||
|
const oldStory = story.value
|
||||||
|
|
||||||
|
const res = await client.GetTeamStory({
|
||||||
|
id: Number(teamId),
|
||||||
|
password: password as string
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
game.value = res.game
|
||||||
|
|
||||||
|
const wasAtBottom = isAtBottom();
|
||||||
|
|
||||||
|
if (wasAtBottom) {
|
||||||
|
newPlacesCount.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldCount = story.value.places.length
|
||||||
|
|
||||||
|
story.value = {
|
||||||
|
places: res.story!.places?.map((item, i) => {
|
||||||
|
console.log(oldStory?.places[i]?.mode)
|
||||||
|
return {
|
||||||
|
place: item,
|
||||||
|
mode: oldStory?.places[i]?.mode || 'show'
|
||||||
|
}
|
||||||
|
}) || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCount = story.value.places.length
|
||||||
|
|
||||||
|
if (newCount > oldCount && oldCount != 0) {
|
||||||
|
newPlacesCount.value += newCount - oldCount
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
if (wasAtBottom && oldCount != 0) {
|
||||||
|
const container = scrollContainer.value;
|
||||||
|
if (!container) return false;
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getStory()
|
||||||
|
|
||||||
|
async function addAction(newCode: string) {
|
||||||
|
const res = await client.AddTeamAction({
|
||||||
|
id: Number(teamId),
|
||||||
|
password: password as string,
|
||||||
|
code: newCode
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getStory()
|
||||||
|
code.value = ''
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
newPlacesCount.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvancedStory = {
|
||||||
|
places: AdvancedPlace[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdvancedPlace = {
|
||||||
|
place: Place;
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLast() {
|
||||||
|
const res = await client.DeleteLastTeamAction({
|
||||||
|
id: Number(teamId),
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getStory()
|
||||||
|
code.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
useSimplePolling(() => {
|
||||||
|
getStory()
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom bottom-margin smooth-scroll" ref="scrollContainer">
|
||||||
|
<div class="width700">
|
||||||
|
<GameInputForm :addAction="addAction" :allPlacesCount="story.places.length" :newPlacesCount="newPlacesCount"
|
||||||
|
:gameStatus="game?.status || ''"></GameInputForm>
|
||||||
|
|
||||||
|
<PlaceBlock :place="advancedPlace.place" v-model:mode="advancedPlace.mode"
|
||||||
|
v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction">
|
||||||
|
</PlaceBlock>
|
||||||
|
|
||||||
|
<MessagePaper class="qr-main-block" v-if="story.places.length == 0">
|
||||||
|
<n-space vertical>
|
||||||
|
<h2 class="qr-main-block-text">Это код этого дела</h2>
|
||||||
|
<p class="qr-main-block-text">Поделись с командой</p>
|
||||||
|
<div class="qr-block">
|
||||||
|
<n-qr-code :value="url" :size="300" :padding="0" />
|
||||||
|
</div>
|
||||||
|
<p v-if="game?.status == 'draft'" class="qr-main-block-text">Игра скоро начнется</p>
|
||||||
|
<p v-if="game?.status == 'run'" class="qr-main-block-text">Игра началась!!!</p>
|
||||||
|
</n-space>
|
||||||
|
</MessagePaper>
|
||||||
|
|
||||||
|
<div v-if="authStore.hasRole('author') && story.places.length > 0" class="edit-panel">
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="deleteLast">Удалить последний ход</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HeaderMenu active="games" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bottom-margin {
|
||||||
|
height: calc(100dvh - 70px - 76px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.smooth-scroll {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-panel {
|
||||||
|
margin: 30px 0;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #222;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-main-block {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-main-block-text {
|
||||||
|
text-align: left;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-block {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: white;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >=1024px) {
|
||||||
|
.bottom-margin {
|
||||||
|
height: calc(100dvh - 70px - 76px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
Vue’s
|
|
||||||
<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>
|
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { NButton, NFlex, NSpace, NTag, useMessage } from 'naive-ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
|
import type { User } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
|
||||||
|
const client = getAuthClient()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const users = ref<User[]>([])
|
||||||
|
|
||||||
|
async function getUsers() {
|
||||||
|
const res = await client.GetUsers({})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users.value = res.users!
|
||||||
|
}
|
||||||
|
getUsers()
|
||||||
|
|
||||||
|
async function addRole(userId: number, role: string) {
|
||||||
|
const res = await client.AddUserRole({ id: userId, role: role })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRole(userId: number, role: string) {
|
||||||
|
const res = await client.DeleteUserRole({ id: userId, role: role })
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await getUsers()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="center-block-custom">
|
||||||
|
<div class="width700">
|
||||||
|
<div v-for="user in users" v-bind:key="user.id" class="user-block">
|
||||||
|
<div class="user-field">
|
||||||
|
Позывной: {{ user.username }}
|
||||||
|
</div>
|
||||||
|
<div class="user-field">
|
||||||
|
Почта: {{ user.email }}
|
||||||
|
</div>
|
||||||
|
<div class="user-field">
|
||||||
|
<n-space>
|
||||||
|
Роли: <n-tag :bordered="false" v-for="role in user.roles" v-bind:key="role">
|
||||||
|
{{ role }}
|
||||||
|
</n-tag>
|
||||||
|
</n-space>
|
||||||
|
</div>
|
||||||
|
<n-flex justify="end" class="roles-buttons-block">
|
||||||
|
|
||||||
|
<n-button @click="addRole(user.id!, 'author')" v-if="!user.roles?.includes('author')">Сделать
|
||||||
|
автором</n-button>
|
||||||
|
<n-button @click="deleteRole(user.id!, 'author')" v-else>Забрать роль автора</n-button>
|
||||||
|
|
||||||
|
<n-button @click="addRole(user.id!, 'organizer')" v-if="!user.roles?.includes('organizer')">Сделать
|
||||||
|
организатором</n-button>
|
||||||
|
<n-button @click="deleteRole(user.id!, 'organizer')" v-else>Забрать роль организатора</n-button>
|
||||||
|
|
||||||
|
</n-flex>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HeaderMenu active="users" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-block {
|
||||||
|
background-color: #222;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-field {
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.roles-buttons-block {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<!-- 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,25 @@
|
|||||||
|
import { onMounted, onUnmounted,ref } from 'vue';
|
||||||
|
|
||||||
|
export function useSimplePolling(callback: () => void, interval = 5000) {
|
||||||
|
const isActive = ref(true);
|
||||||
|
let timerId: number | null = null;
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
isActive.value = true;
|
||||||
|
callback();
|
||||||
|
timerId = window.setInterval(callback, interval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
isActive.value = false;
|
||||||
|
if (timerId) {
|
||||||
|
clearInterval(timerId);
|
||||||
|
timerId = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(start);
|
||||||
|
onUnmounted(stop);
|
||||||
|
|
||||||
|
return { isActive, start, stop };
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import CatalogView from '../views/CatalogView.vue'
|
||||||
|
import ConsentView from '../views/ConsentView.vue'
|
||||||
|
import GamesView from '../views/GamesView.vue'
|
||||||
|
import GameView from '../views/GameView.vue'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
|
import LoginView from '../views/LoginView.vue'
|
||||||
|
import OfficeView from '../views/OfficeView.vue'
|
||||||
|
import PrivacyPolicyView from '../views/PrivacyPolicyView.vue'
|
||||||
|
import ScenariosEditorView from '../views/ScenariosEditorView.vue'
|
||||||
|
import ScenariosView from '../views/ScenariosView.vue'
|
||||||
|
import ScenarioView from '../views/ScenarioView.vue'
|
||||||
|
import TeamStoryView from '../views/TeamStoryView.vue'
|
||||||
|
import UserAgreementView from '../views/UserAgreementView.vue'
|
||||||
|
import UsersView from '../views/UsersView.vue'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -10,12 +24,69 @@ const router = createRouter({
|
|||||||
component: HomeView,
|
component: HomeView,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/about',
|
path: '/login',
|
||||||
name: 'about',
|
name: 'login',
|
||||||
// route level code-splitting
|
component: LoginView,
|
||||||
// this generates a separate chunk (About.[hash].js) for this route
|
},
|
||||||
// which is lazy-loaded when the route is visited.
|
{
|
||||||
component: () => import('../views/AboutView.vue'),
|
path: '/office',
|
||||||
|
name: 'office',
|
||||||
|
component: OfficeView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/user-agreement',
|
||||||
|
name: 'user-agreement',
|
||||||
|
component: UserAgreementView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/privacy-policy',
|
||||||
|
name: 'privacy-policy',
|
||||||
|
component: PrivacyPolicyView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/consent',
|
||||||
|
name: 'consent',
|
||||||
|
component: ConsentView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/games',
|
||||||
|
name: 'games',
|
||||||
|
component: GamesView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/games/:id',
|
||||||
|
name: 'game',
|
||||||
|
component: GameView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/team-story/:id',
|
||||||
|
name: 'team-story',
|
||||||
|
component: TeamStoryView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/scenarios',
|
||||||
|
name: 'scenarios',
|
||||||
|
component: ScenariosView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/catalog',
|
||||||
|
name: 'catalog',
|
||||||
|
component: CatalogView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/scenarios/:id/editor', // Динамический параметр id
|
||||||
|
name: 'scenario-editor',
|
||||||
|
component: ScenariosEditorView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/scenarios/:id', // Динамический параметр id
|
||||||
|
name: 'scenario',
|
||||||
|
component: ScenarioView,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/users',
|
||||||
|
name: 'users',
|
||||||
|
component: UsersView,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { type User } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
|
// ===== STATE =====
|
||||||
|
const accessToken = ref<string | null>(null)
|
||||||
|
const refreshToken = ref<string | null>(null)
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// ===== GETTERS =====
|
||||||
|
const isAuthenticated = computed(() => !!accessToken.value)
|
||||||
|
const username = computed(() => user.value?.username || '')
|
||||||
|
const userRoles = computed(() => user.value?.roles || [])
|
||||||
|
|
||||||
|
// ===== ACTIONS =====
|
||||||
|
|
||||||
|
// Инициализация из localStorage при загрузке
|
||||||
|
function initFromStorage() {
|
||||||
|
const token = localStorage.getItem('accessToken')
|
||||||
|
const refresh = localStorage.getItem('refreshToken')
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
accessToken.value = token
|
||||||
|
refreshToken.value = refresh
|
||||||
|
|
||||||
|
// Получаем данные пользователя
|
||||||
|
fetchUser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getToken(): string | null {
|
||||||
|
return accessToken.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение данных пользователя
|
||||||
|
async function fetchUser() {
|
||||||
|
if (!accessToken.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = buildClient(getToken)
|
||||||
|
const response = await client.GetMe({})
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
error.value = response.error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.user) {
|
||||||
|
user.value = response.user
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch user:', err)
|
||||||
|
// Если 401 — токен протух
|
||||||
|
if (err instanceof Error && err.message.includes('401')) {
|
||||||
|
await logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Логин
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = buildClient(getToken)
|
||||||
|
const response = await client.Login({ email, password })
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
error.value = response.error
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем токены
|
||||||
|
if (response.accessToken) {
|
||||||
|
accessToken.value = response.accessToken
|
||||||
|
refreshToken.value = response.refreshToken || null
|
||||||
|
|
||||||
|
// Сохраняем в localStorage
|
||||||
|
localStorage.setItem('accessToken', response.accessToken)
|
||||||
|
if (response.refreshToken) {
|
||||||
|
localStorage.setItem('refreshToken', response.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем данные пользователя
|
||||||
|
await fetchUser()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление токена
|
||||||
|
async function refreshTokenAction() {
|
||||||
|
if (!refreshToken.value) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = buildClient(getToken)
|
||||||
|
const response = await client.Refresh({
|
||||||
|
refreshToken: refreshToken.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.error || !response.accessToken) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем токены
|
||||||
|
accessToken.value = response.accessToken
|
||||||
|
refreshToken.value = response.refreshToken || null
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', response.accessToken)
|
||||||
|
if (response.refreshToken) {
|
||||||
|
localStorage.setItem('refreshToken', response.refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрация
|
||||||
|
async function signup(
|
||||||
|
username: string,
|
||||||
|
email: string,
|
||||||
|
acceptTerms: boolean,
|
||||||
|
acceptPrivacy: boolean,
|
||||||
|
) {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = buildClient(getToken)
|
||||||
|
const response = await client.Signup({ username, email, acceptTerms, acceptPrivacy })
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
error.value = response.error
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Signup failed'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление аккаунта (подтверждение владельца паролем, ст. 21 152-ФЗ)
|
||||||
|
async function deleteAccount(password: string) {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Пароль передаётся HTTP-заголовком X-Password, а не в URL или body
|
||||||
|
const client = buildClient(getToken, () => ({ 'X-Password': password }))
|
||||||
|
const response = await client.DeleteAccount({})
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
error.value = response.error
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
await logout()
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Account deletion failed'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Восстановление пароля
|
||||||
|
async function refreshPassword(email: string) {
|
||||||
|
isLoading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = buildClient(getToken)
|
||||||
|
const response = await client.RefreshPassword({ email })
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
error.value = response.error
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err instanceof Error ? err.message : 'Password reset failed'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выход
|
||||||
|
async function logout() {
|
||||||
|
accessToken.value = null
|
||||||
|
refreshToken.value = null
|
||||||
|
user.value = null
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
localStorage.removeItem('accessToken')
|
||||||
|
localStorage.removeItem('refreshToken')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка ролей
|
||||||
|
function hasRole(role: string): boolean {
|
||||||
|
return userRoles.value.includes(role)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyRole(roles: string[]): boolean {
|
||||||
|
return roles.some((role) => userRoles.value.includes(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== RETURN =====
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
user,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
isAuthenticated,
|
||||||
|
username,
|
||||||
|
userRoles,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
initFromStorage,
|
||||||
|
getToken,
|
||||||
|
login,
|
||||||
|
signup,
|
||||||
|
deleteAccount,
|
||||||
|
refreshPassword,
|
||||||
|
logout,
|
||||||
|
fetchUser,
|
||||||
|
refreshTokenAction,
|
||||||
|
hasRole,
|
||||||
|
hasAnyRole,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { ref, computed } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
|
|
||||||
export const useCounterStore = defineStore('counter', () => {
|
|
||||||
const count = ref(0)
|
|
||||||
const doubleCount = computed(() => count.value * 2)
|
|
||||||
function increment() {
|
|
||||||
count.value++
|
|
||||||
}
|
|
||||||
|
|
||||||
return { count, doubleCount, increment }
|
|
||||||
})
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import CatalogPage from '@/components/CatalogPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CatalogPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css"></style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DocsPage from '@/components/DocsPage.vue'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DocsPage kind="consent" />
|
||||||
|
|
||||||
|
<HeaderMenu />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import GamePage from '@/components/GamePage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GamePage />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css"></style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import GamesPage from '@/components/GamesPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<GamesPage />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="css"></style>
|
||||||
@@ -1,9 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import TheWelcome from '../components/TheWelcome.vue'
|
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<div class="center-block-custom content-block center-middle-block-custom height80">
|
||||||
<TheWelcome />
|
<div>
|
||||||
</main>
|
<h1 class="font-text">Вечерний детектив</h1>
|
||||||
|
<h3 class="font-text">Дело №0</h3>
|
||||||
|
<p class="content-text">
|
||||||
|
Поиграем?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HeaderMenu active="main" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="css">
|
||||||
|
.font-text {
|
||||||
|
font-family: 'font_old_typer';
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-text {
|
||||||
|
position: relative;
|
||||||
|
top: 100px;
|
||||||
|
right: 0;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width >=1024px) {
|
||||||
|
.font-text {
|
||||||
|
position: relative;
|
||||||
|
left: -50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-text {
|
||||||
|
position: relative;
|
||||||
|
top: 100px;
|
||||||
|
right: -50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import LoginPage from '@/components/LoginPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<LoginPage />
|
||||||
|
|
||||||
|
|
||||||
|
<HeaderMenu />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import OfficePage from '@/components/OfficePage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<OfficePage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DocsPage from '@/components/DocsPage.vue'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DocsPage kind="privacy" />
|
||||||
|
|
||||||
|
<HeaderMenu />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ScenarioPage from '@/components/ScenarioPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScenarioPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ScenariosEditorPage from '@/components/ScenariosEditorPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScenariosEditorPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ScenariosPage from '@/components/ScenariosPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ScenariosPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import TeamStoryPage from '@/components/TeamStoryPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TeamStoryPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DocsPage from '@/components/DocsPage.vue'
|
||||||
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DocsPage kind="terms" />
|
||||||
|
|
||||||
|
<HeaderMenu />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import UsersPage from '@/components/UsersPage.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UsersPage />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export default {
|
||||||
|
extends: [
|
||||||
|
'stylelint-config-standard-scss', // Базовые правила для SCSS и CSS
|
||||||
|
],
|
||||||
|
// Добавляем поддержку Vue файлов
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
customSyntax: 'postcss-html',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
// Здесь можно переопределить правила
|
||||||
|
'order/properties-alphabetical-order': true, // Сортировка свойств
|
||||||
|
// 'selector-class-pattern': '^[a-z][a-zA-Z0-9]+$', // Названия классов в camelCase
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
import { defineConfig } from 'vite'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
|
|||||||