Compare commits

..

98 Commits

Author SHA1 Message Date
VLADIMIR 606d515ade add buttons 2026-08-20 23:39:56 +07:00
VLADIMIR 4174ad304a update api 2026-08-20 23:22:46 +07:00
VLADIMIR 862c918981 updates 2026-08-20 01:57:39 +07:00
VLADIMIR bb8fca352a add delete game button 2026-08-18 01:23:42 +07:00
VLADIMIR ffa80a19ad fix 2026-08-08 18:20:43 +07:00
VLADIMIR c17f91b903 fix load image 2026-08-08 00:32:29 +07:00
VLADIMIR b3c51ff4de build 2026-08-06 00:53:37 +07:00
VLADIMIR 3fc1e188ef add admin qr 2026-08-06 00:53:17 +07:00
VLADIMIR a1c19cba3e fix 2026-08-06 00:28:15 +07:00
VLADIMIR 66890b8a62 build 2026-08-06 00:23:19 +07:00
VLADIMIR 115f2498cd add block game 2026-08-06 00:23:04 +07:00
VLADIMIR a43b23aad9 build 2026-08-05 23:11:28 +07:00
VLADIMIR 39d6ced46d fix 2026-08-05 23:10:48 +07:00
VLADIMIR ec6a5dc709 lint css 2026-08-05 23:00:14 +07:00
VLADIMIR 33cde1d28b add statuses game 2026-08-04 02:28:16 +07:00
VLADIMIR 2241c33902 add rt game teams 2026-08-03 02:23:36 +07:00
VLADIMIR 489e9c0bec add qr 2026-08-03 01:48:22 +07:00
VLADIMIR d5a05fe7cd fix 2026-08-03 01:20:15 +07:00
VLADIMIR 92c72ba42f update api 2026-08-03 01:18:17 +07:00
VLADIMIR d9c5791c68 add delete last place 2026-08-02 15:09:43 +07:00
VLADIMIR 33501fe4c7 fix collapse 2026-08-02 14:21:28 +07:00
VLADIMIR 491a69e980 build 2026-08-01 21:51:05 +07:00
VLADIMIR d4ad314093 fix 2026-08-01 21:00:32 +07:00
VLADIMIR d84539f1d0 add icons and scroll 2026-08-01 20:56:40 +07:00
VLADIMIR f8b4b6c6a8 add pooling for game 2026-08-01 16:46:41 +07:00
VLADIMIR a71c1c9f6d add tips 2026-08-01 16:20:50 +07:00
VLADIMIR e463c0103e add prikol 2026-08-01 03:52:25 +07:00
VLADIMIR 5629333ce3 fix 2026-08-01 03:40:13 +07:00
VLADIMIR 8ccc17a03a build 2026-08-01 03:30:01 +07:00
VLADIMIR 1d6e2fe478 add teams opts 2026-08-01 03:29:41 +07:00
VLADIMIR 47d43d61a8 add give applications 2026-08-01 02:20:44 +07:00
VLADIMIR fc5fcd3ade build 2026-08-01 01:34:26 +07:00
VLADIMIR 72efa66a2f add update game window 2026-08-01 01:33:54 +07:00
VLADIMIR 762858807c fix 2026-07-30 02:22:05 +07:00
VLADIMIR f88818dbf9 update css 2026-07-30 02:10:12 +07:00
VLADIMIR c51cfe0620 add input 2026-07-29 20:42:57 +07:00
VLADIMIR d1aac40b7f add input form 2026-07-29 20:41:25 +07:00
VLADIMIR a392d1a928 add new design 2026-07-29 19:41:26 +07:00
VLADIMIR 90d1837565 add team story page 2026-07-29 01:57:20 +07:00
VLADIMIR 12edf754bc fix 2026-07-29 01:05:28 +07:00
VLADIMIR 9b3f43da98 add story 2026-07-29 01:00:48 +07:00
VLADIMIR c04ae2868c add new methods 2026-07-28 23:01:17 +07:00
VLADIMIR 5d3044abb9 update editor 2026-07-28 00:02:03 +07:00
VLADIMIR 035bf4c30d fix mobile 2026-07-26 03:28:05 +07:00
VLADIMIR 1f3d1bb284 fix menu 2026-07-26 03:23:43 +07:00
VLADIMIR 6d5766092b add catalog 2026-07-26 03:02:05 +07:00
VLADIMIR 4663493c66 update scenarios page 2026-07-26 01:32:35 +07:00
VLADIMIR a7502ab771 update scenarios page 2026-07-26 01:31:36 +07:00
VLADIMIR 48c6315697 fix 2026-07-26 00:25:13 +07:00
VLADIMIR 93b131dbca add change status buttons 2026-07-26 00:22:30 +07:00
VLADIMIR e7b7c1180b fix page 2026-07-25 23:28:39 +07:00
VLADIMIR e1753fd450 fix image 2026-07-25 23:27:04 +07:00
VLADIMIR 91ac1a43c8 update editor 2026-07-24 19:18:38 +07:00
VLADIMIR 16a107827a fix 2026-07-24 17:29:56 +07:00
VLADIMIR e9cc3e3007 add refresh password 2026-07-24 17:23:03 +07:00
VLADIMIR 18aa914497 fix css and add refresh password 2026-07-24 17:02:01 +07:00
VLADIMIR c45b53e400 update editor 2026-07-24 14:02:45 +07:00
VLADIMIR 7d19ca800c fix 2026-07-24 00:56:07 +07:00
VLADIMIR 7d04e5a1cd add images 2026-07-24 00:50:46 +07:00
VLADIMIR 9bcfe42683 fix 2026-07-23 01:55:35 +07:00
VLADIMIR e9e0d4e739 fix 2026-07-23 01:49:04 +07:00
VLADIMIR 671105c3fb add places count 2026-07-23 01:38:13 +07:00
VLADIMIR 084455b982 fix 2026-07-23 01:26:22 +07:00
VLADIMIR 3b6852834d build 2026-07-23 01:24:59 +07:00
VLADIMIR 32c2dae619 update editor 2026-07-23 01:23:53 +07:00
VLADIMIR beefb549d0 build 2026-07-23 00:40:24 +07:00
VLADIMIR 4086f7a676 fix 2026-07-23 00:39:48 +07:00
VLADIMIR 0dffd01241 fix 2026-07-21 15:35:33 +07:00
Владимир Федоров 2570d71f72 p 2026-07-21 15:29:33 +07:00
VLADIMIR c64955928e add docker compose 2026-07-21 15:07:08 +07:00
VLADIMIR 19f3277207 add dist 2026-07-21 13:43:03 +07:00
VLADIMIR 2f42977113 add users 2026-07-21 02:23:30 +07:00
VLADIMIR 7db0c6f248 fix 2026-07-21 01:34:39 +07:00
VLADIMIR 04f516a9ea fix menu 2026-07-21 01:17:35 +07:00
VLADIMIR 16114bb134 update editor 2026-07-21 00:32:31 +07:00
VLADIMIR db558c87d1 format 2026-07-20 22:49:45 +07:00
VLADIMIR c314e4f329 add css linter 2026-07-20 22:47:37 +07:00
VLADIMIR 301cfcd61b linters 2026-07-20 22:36:30 +07:00
VLADIMIR 34520a95d4 update editor 2026-07-20 22:18:41 +07:00
VLADIMIR a0dfa65be8 start places editor 2026-07-20 01:31:33 +07:00
VLADIMIR b49074334d update scenario editor 2026-07-19 21:36:55 +07:00
VLADIMIR 83b3ae5dfe add scenarios editor 2026-07-19 19:23:21 +07:00
VLADIMIR a97993c70b update scenarios 2026-07-19 17:07:22 +07:00
VLADIMIR 2599be3f53 add center 2026-07-19 15:24:36 +07:00
VLADIMIR 3f8b8ba432 clear 2026-07-19 14:45:03 +07:00
VLADIMIR 75efb4cbf0 add active item to menu 2026-07-19 13:41:26 +07:00
VLADIMIR 0b8cbfc138 add pages 2026-07-19 01:44:58 +07:00
VLADIMIR 7ce91ecd49 add menu 2026-07-19 01:32:15 +07:00
VLADIMIR c1a113b849 add pages 2026-07-19 00:09:34 +07:00
VLADIMIR b81000249b clear 2026-07-18 23:48:35 +07:00
VLADIMIR 28f1e43338 add refresh 2026-07-18 18:42:05 +07:00
VLADIMIR 0731123fbb add storage 2026-07-18 16:16:10 +07:00
VLADIMIR ad68ccc297 fix mobile 2026-07-18 13:35:07 +07:00
VLADIMIR 6b902cad98 add login 2026-07-18 13:24:31 +07:00
VLADIMIR f368619471 fix colors and margin 2026-07-17 00:05:01 +07:00
VLADIMIR f36e1c2ce7 update main page 2026-07-16 23:51:05 +07:00
VLADIMIR 63e83d87ed add first page 2026-07-15 23:09:04 +07:00
VLADIMIR 083a856111 add gen proto 2026-07-15 00:44:35 +07:00
87 changed files with 11048 additions and 443 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8080
VITE_APP_TITLE=Вечерний детектив
+3 -1
View File
@@ -9,7 +9,7 @@ lerna-debug.log*
node_modules
.DS_Store
dist
# dist
dist-ssr
coverage
*.local
@@ -28,3 +28,5 @@ coverage
*.sw?
*.tsbuildinfo
.env
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long
+2430
View File
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+14
View File
@@ -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>
+27
View File
@@ -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
+37 -3
View File
@@ -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 { 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:
// import { configureVueProject } from '@vue/eslint-config-typescript'
@@ -19,4 +21,36 @@ export default defineConfigWithVueTs(
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
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: '^_',
},
],
},
}
)
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<title>Вечерний детектив</title>
</head>
<body>
<div id="app"></div>
+1868
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -4,14 +4,15 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite --host",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"lint:css": "stylelint \"src/**/*.{css,scss,vue}\" --fix",
"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": {
"axios": "^1.18.1",
@@ -22,17 +23,33 @@
"devDependencies": {
"@tsconfig/node22": "^22.0.5",
"@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",
"@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/tsconfig": "^0.9.1",
"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",
"jiti": "^2.7.0",
"naive-ui": "^2.44.1",
"npm-run-all2": "^9.0.2",
"postcss-html": "^1.8.1",
"prettier": "3.9.4",
"stylelint": "^17.14.1",
"stylelint-config-standard-scss": "^17.0.0",
"ts-proto": "^2.12.0",
"typescript": "~6.0.3",
"vfonts": "^0.0.3",
"vite": "^8.1.3",
"vite-plugin-vue-devtools": "^8.1.5",
"vue-tsc": "^3.3.6"
+540 -6
View File
@@ -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) {
option (google.api.http) = {
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) {
option (google.api.http) = {
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) {
option (google.api.http) = {
delete: "/api/scenarios/{id}"
@@ -281,6 +364,214 @@ service EveningDetectiveServer {
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 {}
@@ -296,8 +587,10 @@ message EchoRsp {
}
message SignupReq {
string username = 1;
string email = 2;
string username = 1;
string email = 2;
bool accept_terms = 3;
bool accept_privacy = 4;
}
message SignupRsp {
@@ -333,6 +626,39 @@ message RefreshRsp {
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 GetUsersRsp {
@@ -420,6 +746,13 @@ message GetMyScenariosRsp {
repeated Scenario scenarios = 2;
}
message GetScenariosCatalogReq {}
message GetScenariosCatalogRsp {
string error = 1;
repeated Scenario scenarios = 2;
}
message GetScenarioReq {
int32 id = 1;
}
@@ -435,10 +768,11 @@ message Scenario {
optional string description = 3;
optional string image = 4;
Story story = 5;
User author = 6;
google.protobuf.Timestamp updated_at = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp published_at = 9;
string status = 6;
User author = 7;
google.protobuf.Timestamp updated_at = 8;
google.protobuf.Timestamp created_at = 9;
google.protobuf.Timestamp published_at = 10;
}
message Story {
@@ -482,6 +816,22 @@ message UpdateScenarioRsp {
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 {
int32 id = 1;
}
@@ -517,3 +867,187 @@ message DeleteScenarioPlaceReq {
message DeleteScenarioPlaceRsp {
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;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 66 KiB

+9 -79
View File
@@ -1,85 +1,15 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/HelloWorld.vue'
import { NConfigProvider, NMessageProvider } from 'naive-ui'
import { darkTheme } from 'naive-ui'
import { RouterView } from 'vue-router'
</script>
<template>
<header>
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView />
<n-config-provider :theme="darkTheme">
<n-message-provider>
<RouterView />
</n-message-provider>
</n-config-provider>
</template>
<style scoped>
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>
<style scoped></style>
+103
View File
@@ -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})`
}
+35
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+15 -18
View File
@@ -1,24 +1,26 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white: #fff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-soft: #222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-divider-light-1: rgb(60 60 60 / 29%);
--vt-c-divider-light-2: rgb(60 60 60 / 12%);
--vt-c-divider-dark-1: rgb(84 84 84 / 65%);
--vt-c-divider-dark-2: rgb(84 84 84 / 48%);
--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-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 */
@@ -26,13 +28,10 @@
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@@ -41,10 +40,8 @@
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
@@ -80,7 +77,7 @@ body {
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+63 -23
View File
@@ -1,35 +1,75 @@
@import './base.css';
#app {
max-width: 1280px;
body {
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;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
.width1200 {
padding: 10px;
margin: 0 auto;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
.display-table {
display: flex;
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) {
body {
display: flex;
.width700 {
width: 700px;
padding: 20px;
margin: 0 auto;
}
.width1200 {
width: 1200px;
padding: 20px;
margin: 0 auto;
}
.center-middle-block-custom {
display: grid;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}
+38
View File
@@ -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>
+123
View File
@@ -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>
+173
View File
@@ -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>
+258
View File
@@ -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>
+609
View File
@@ -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>
+71
View File
@@ -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>
+156
View File
@@ -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>
+20
View File
@@ -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>
-41
View File
@@ -1,41 +0,0 @@
<script setup lang="ts">
defineProps<{
msg: string
}>()
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve successfully created a project with
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
</h3>
</div>
</template>
<style scoped>
h1 {
font-weight: 500;
font-size: 2.6rem;
position: relative;
top: -10px;
}
h3 {
font-size: 1.2rem;
}
.greetings h1,
.greetings h3 {
text-align: center;
}
@media (min-width: 1024px) {
.greetings h1,
.greetings h3 {
text-align: left;
}
}
</style>
+185
View File
@@ -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>
+65
View File
@@ -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>
+65
View File
@@ -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>
+58
View File
@@ -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>
+329
View File
@@ -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>
+472
View File
@@ -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>
+75
View File
@@ -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>
+492
View File
@@ -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>
+245
View File
@@ -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>
+216
View File
@@ -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>
-94
View File
@@ -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>
Vues
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
provides you with all information you need to get started.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<ToolingIcon />
</template>
<template #heading>Tooling</template>
This project is served and bundled with
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
recommended IDE setup is
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
+
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
you need to test your components and web pages, check out
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
and
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
/
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
<br />
More instructions are available in
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
>.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<EcosystemIcon />
</template>
<template #heading>Ecosystem</template>
Get official tools and libraries for your project:
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
you need more resources, we suggest paying
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
a visit.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<CommunityIcon />
</template>
<template #heading>Community</template>
Got stuck? Ask your question on
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
(our official Discord server), or
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
>StackOverflow</a
>. You should also follow the official
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
Bluesky account or the
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
X account for latest news in the Vue world.
</WelcomeItem>
<WelcomeItem>
<template #icon>
<SupportIcon />
</template>
<template #heading>Support Vue</template>
As an independent project, Vue relies on community backing for its sustainability. You can help
us by
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
</WelcomeItem>
</template>
+93
View File
@@ -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>
-87
View File
@@ -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>
-7
View File
@@ -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>
-7
View File
@@ -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>
-7
View File
@@ -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>
-19
View File
@@ -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>
+25
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
+77 -6
View File
@@ -1,5 +1,19 @@
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 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({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -10,12 +24,69 @@ const router = createRouter({
component: HomeView,
},
{
path: '/about',
name: 'about',
// route level code-splitting
// 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: '/login',
name: 'login',
component: LoginView,
},
{
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,
},
],
})
+257
View File
@@ -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,
}
})
-12
View File
@@ -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 }
})
-15
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import CatalogPage from '@/components/CatalogPage.vue'
</script>
<template>
<CatalogPage />
</template>
<style lang="css"></style>
+10
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import GamePage from '@/components/GamePage.vue'
</script>
<template>
<GamePage />
</template>
<style lang="css"></style>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
import GamesPage from '@/components/GamesPage.vue'
</script>
<template>
<GamesPage />
</template>
<style lang="css"></style>
+40 -4
View File
@@ -1,9 +1,45 @@
<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
</script>
<template>
<main>
<TheWelcome />
</main>
<div class="center-block-custom content-block center-middle-block-custom height80">
<div>
<h1 class="font-text">Вечерний детектив</h1>
<h3 class="font-text">Дело 0</h3>
<p class="content-text">
Поиграем?
</p>
</div>
</div>
<HeaderMenu active="main" />
</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>
+11
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import OfficePage from '@/components/OfficePage.vue'
</script>
<template>
<OfficePage />
</template>
+10
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import ScenarioPage from '@/components/ScenarioPage.vue'
</script>
<template>
<ScenarioPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import ScenariosEditorPage from '@/components/ScenariosEditorPage.vue'
</script>
<template>
<ScenariosEditorPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import ScenariosPage from '@/components/ScenariosPage.vue'
</script>
<template>
<ScenariosPage />
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import TeamStoryPage from '@/components/TeamStoryPage.vue'
</script>
<template>
<TeamStoryPage />
</template>
+10
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import UsersPage from '@/components/UsersPage.vue'
</script>
<template>
<UsersPage />
</template>
+17
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/