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
93 changed files with 13270 additions and 452 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 node_modules
.DS_Store .DS_Store
dist # dist
dist-ssr dist-ssr
coverage coverage
*.local *.local
@@ -28,3 +28,5 @@ coverage
*.sw? *.sw?
*.tsbuildinfo *.tsbuildinfo
.env
+1 -1
View File
@@ -1,4 +1,4 @@
# ${REPO_NAME_SNAKE} # evening_detective_client
This template should help get you started developing with Vue 3 in Vite. This template should help get you started developing with Vue 3 in Vite.
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 skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import { globalIgnores } from 'eslint/config'
import simpleImportSort from 'eslint-plugin-simple-import-sort';
import unusedImports from 'eslint-plugin-unused-imports';
import pluginVue from 'eslint-plugin-vue'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: // To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript' // import { configureVueProject } from '@vue/eslint-config-typescript'
@@ -19,4 +21,36 @@ export default defineConfigWithVueTs(
pluginVue.configs['flat/essential'], pluginVue.configs['flat/essential'],
vueTsConfigs.recommended, vueTsConfigs.recommended,
skipFormatting, skipFormatting,
{
plugins: {
'simple-import-sort': simpleImportSort,
},
rules: {
// Основные правила сортировки
'simple-import-sort/imports': 'error',
'simple-import-sort/exports': 'error',
},
},
{
plugins: {
'unused-imports': unusedImports,
},
rules: {
// Отключаем стандартное правило
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
// Включаем правила плагина
'unused-imports/no-unused-imports': 'error', // 🔧 автофикс
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
}
) )
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Вечерний детектив</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+2269 -13
View File
File diff suppressed because it is too large Load Diff
+24 -4
View File
@@ -1,35 +1,55 @@
{ {
"name": "${REPO_NAME_SNAKE}", "name": "evening_detective_client",
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --host",
"build": "run-p type-check \"build-only {@}\" --", "build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build", "type-check": "vue-tsc --build",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"format": "prettier --write src/" "lint:css": "stylelint \"src/**/*.{css,scss,vue}\" --fix",
"format": "prettier --write src/",
"proto:gen": "protoc --typescript-http_out=src/api/generated --proto_path=proto proto/main.proto"
}, },
"dependencies": { "dependencies": {
"axios": "^1.18.1",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.39", "vue": "^3.5.39",
"vue-router": "^5.1.0" "vue-router": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.5", "@tsconfig/node22": "^22.0.5",
"@types/node": "^26.1.0", "@types/node": "^26.1.1",
"@vicons/antd": "^0.13.0",
"@vicons/carbon": "^0.13.0",
"@vicons/fa": "^0.13.0",
"@vicons/fluent": "^0.13.0",
"@vicons/ionicons4": "^0.13.0",
"@vicons/ionicons5": "^0.13.0",
"@vicons/material": "^0.13.0",
"@vicons/tabler": "^0.13.0",
"@vicons/utils": "^0.1.4",
"@vitejs/plugin-vue": "^6.0.7", "@vitejs/plugin-vue": "^6.0.7",
"@vue/eslint-config-prettier": "^10.2.0", "@vue/eslint-config-prettier": "^10.2.0",
"@vue/eslint-config-typescript": "^14.9.0", "@vue/eslint-config-typescript": "^14.9.0",
"@vue/tsconfig": "^0.9.1", "@vue/tsconfig": "^0.9.1",
"eslint": "^10.6.0", "eslint": "^10.6.0",
"eslint-plugin-simple-import-sort": "^14.0.0",
"eslint-plugin-unused-imports": "^4.4.1",
"eslint-plugin-vue": "~10.9.2", "eslint-plugin-vue": "~10.9.2",
"jiti": "^2.7.0", "jiti": "^2.7.0",
"naive-ui": "^2.44.1",
"npm-run-all2": "^9.0.2", "npm-run-all2": "^9.0.2",
"postcss-html": "^1.8.1",
"prettier": "3.9.4", "prettier": "3.9.4",
"stylelint": "^17.14.1",
"stylelint-config-standard-scss": "^17.0.0",
"ts-proto": "^2.12.0",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"vfonts": "^0.0.3",
"vite": "^8.1.3", "vite": "^8.1.3",
"vite-plugin-vue-devtools": "^8.1.5", "vite-plugin-vue-devtools": "^8.1.5",
"vue-tsc": "^3.3.6" "vue-tsc": "^3.3.6"
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
package google.api;
import "google/api/http.proto";
import "google/protobuf/descriptor.proto";
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
option java_multiple_files = true;
option java_outer_classname = "AnnotationsProto";
option java_package = "com.google.api";
option objc_class_prefix = "GAPI";
extend google.protobuf.MethodOptions {
// See `HttpRule`.
HttpRule http = 72295728;
}
+379
View File
@@ -0,0 +1,379 @@
// Copyright 2024 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
package google.api;
option cc_enable_arenas = true;
option go_package = "google.golang.org/genproto/googleapis/api/annotations;annotations";
option java_multiple_files = true;
option java_outer_classname = "HttpProto";
option java_package = "com.google.api";
option objc_class_prefix = "GAPI";
// Defines the HTTP configuration for an API service. It contains a list of
// [HttpRule][google.api.HttpRule], each specifying the mapping of an RPC method
// to one or more HTTP REST API methods.
message Http {
// A list of HTTP configuration rules that apply to individual API methods.
//
// **NOTE:** All service configuration rules follow "last one wins" order.
repeated HttpRule rules = 1;
// When set to true, URL path parameters will be fully URI-decoded except in
// cases of single segment matches in reserved expansion, where "%2F" will be
// left encoded.
//
// The default behavior is to not decode RFC 6570 reserved characters in multi
// segment matches.
bool fully_decode_reserved_expansion = 2;
}
// # gRPC Transcoding
//
// gRPC Transcoding is a feature for mapping between a gRPC method and one or
// more HTTP REST endpoints. It allows developers to build a single API service
// that supports both gRPC APIs and REST APIs. Many systems, including [Google
// APIs](https://github.com/googleapis/googleapis),
// [Cloud Endpoints](https://cloud.google.com/endpoints), [gRPC
// Gateway](https://github.com/grpc-ecosystem/grpc-gateway),
// and [Envoy](https://github.com/envoyproxy/envoy) proxy support this feature
// and use it for large scale production services.
//
// `HttpRule` defines the schema of the gRPC/REST mapping. The mapping specifies
// how different portions of the gRPC request message are mapped to the URL
// path, URL query parameters, and HTTP request body. It also controls how the
// gRPC response message is mapped to the HTTP response body. `HttpRule` is
// typically specified as an `google.api.http` annotation on the gRPC method.
//
// Each mapping specifies a URL path template and an HTTP method. The path
// template may refer to one or more fields in the gRPC request message, as long
// as each field is a non-repeated field with a primitive (non-message) type.
// The path template controls how fields of the request message are mapped to
// the URL path.
//
// Example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get: "/v1/{name=messages/*}"
// };
// }
// }
// message GetMessageRequest {
// string name = 1; // Mapped to URL path.
// }
// message Message {
// string text = 1; // The resource content.
// }
//
// This enables an HTTP REST to gRPC mapping as below:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456` | `GetMessage(name: "messages/123456")`
//
// Any fields in the request message which are not bound by the path template
// automatically become HTTP query parameters if there is no HTTP request body.
// For example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get:"/v1/messages/{message_id}"
// };
// }
// }
// message GetMessageRequest {
// message SubMessage {
// string subfield = 1;
// }
// string message_id = 1; // Mapped to URL path.
// int64 revision = 2; // Mapped to URL query parameter `revision`.
// SubMessage sub = 3; // Mapped to URL query parameter `sub.subfield`.
// }
//
// This enables a HTTP JSON to RPC mapping as below:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456?revision=2&sub.subfield=foo` |
// `GetMessage(message_id: "123456" revision: 2 sub: SubMessage(subfield:
// "foo"))`
//
// Note that fields which are mapped to URL query parameters must have a
// primitive type or a repeated primitive type or a non-repeated message type.
// In the case of a repeated type, the parameter can be repeated in the URL
// as `...?param=A&param=B`. In the case of a message type, each field of the
// message is mapped to a separate parameter, such as
// `...?foo.a=A&foo.b=B&foo.c=C`.
//
// For HTTP methods that allow a request body, the `body` field
// specifies the mapping. Consider a REST update method on the
// message resource collection:
//
// service Messaging {
// rpc UpdateMessage(UpdateMessageRequest) returns (Message) {
// option (google.api.http) = {
// patch: "/v1/messages/{message_id}"
// body: "message"
// };
// }
// }
// message UpdateMessageRequest {
// string message_id = 1; // mapped to the URL
// Message message = 2; // mapped to the body
// }
//
// The following HTTP JSON to RPC mapping is enabled, where the
// representation of the JSON in the request body is determined by
// protos JSON encoding:
//
// HTTP | gRPC
// -----|-----
// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id:
// "123456" message { text: "Hi!" })`
//
// The special name `*` can be used in the body mapping to define that
// every field not bound by the path template should be mapped to the
// request body. This enables the following alternative definition of
// the update method:
//
// service Messaging {
// rpc UpdateMessage(Message) returns (Message) {
// option (google.api.http) = {
// patch: "/v1/messages/{message_id}"
// body: "*"
// };
// }
// }
// message Message {
// string message_id = 1;
// string text = 2;
// }
//
//
// The following HTTP JSON to RPC mapping is enabled:
//
// HTTP | gRPC
// -----|-----
// `PATCH /v1/messages/123456 { "text": "Hi!" }` | `UpdateMessage(message_id:
// "123456" text: "Hi!")`
//
// Note that when using `*` in the body mapping, it is not possible to
// have HTTP parameters, as all fields not bound by the path end in
// the body. This makes this option more rarely used in practice when
// defining REST APIs. The common usage of `*` is in custom methods
// which don't use the URL at all for transferring data.
//
// It is possible to define multiple HTTP methods for one RPC by using
// the `additional_bindings` option. Example:
//
// service Messaging {
// rpc GetMessage(GetMessageRequest) returns (Message) {
// option (google.api.http) = {
// get: "/v1/messages/{message_id}"
// additional_bindings {
// get: "/v1/users/{user_id}/messages/{message_id}"
// }
// };
// }
// }
// message GetMessageRequest {
// string message_id = 1;
// string user_id = 2;
// }
//
// This enables the following two alternative HTTP JSON to RPC mappings:
//
// HTTP | gRPC
// -----|-----
// `GET /v1/messages/123456` | `GetMessage(message_id: "123456")`
// `GET /v1/users/me/messages/123456` | `GetMessage(user_id: "me" message_id:
// "123456")`
//
// ## Rules for HTTP mapping
//
// 1. Leaf request fields (recursive expansion nested messages in the request
// message) are classified into three categories:
// - Fields referred by the path template. They are passed via the URL path.
// - Fields referred by the [HttpRule.body][google.api.HttpRule.body]. They
// are passed via the HTTP
// request body.
// - All other fields are passed via the URL query parameters, and the
// parameter name is the field path in the request message. A repeated
// field can be represented as multiple query parameters under the same
// name.
// 2. If [HttpRule.body][google.api.HttpRule.body] is "*", there is no URL
// query parameter, all fields
// are passed via URL path and HTTP request body.
// 3. If [HttpRule.body][google.api.HttpRule.body] is omitted, there is no HTTP
// request body, all
// fields are passed via URL path and URL query parameters.
//
// ### Path template syntax
//
// Template = "/" Segments [ Verb ] ;
// Segments = Segment { "/" Segment } ;
// Segment = "*" | "**" | LITERAL | Variable ;
// Variable = "{" FieldPath [ "=" Segments ] "}" ;
// FieldPath = IDENT { "." IDENT } ;
// Verb = ":" LITERAL ;
//
// The syntax `*` matches a single URL path segment. The syntax `**` matches
// zero or more URL path segments, which must be the last part of the URL path
// except the `Verb`.
//
// The syntax `Variable` matches part of the URL path as specified by its
// template. A variable template must not contain other variables. If a variable
// matches a single path segment, its template may be omitted, e.g. `{var}`
// is equivalent to `{var=*}`.
//
// The syntax `LITERAL` matches literal text in the URL path. If the `LITERAL`
// contains any reserved character, such characters should be percent-encoded
// before the matching.
//
// If a variable contains exactly one path segment, such as `"{var}"` or
// `"{var=*}"`, when such a variable is expanded into a URL path on the client
// side, all characters except `[-_.~0-9a-zA-Z]` are percent-encoded. The
// server side does the reverse decoding. Such variables show up in the
// [Discovery
// Document](https://developers.google.com/discovery/v1/reference/apis) as
// `{var}`.
//
// If a variable contains multiple path segments, such as `"{var=foo/*}"`
// or `"{var=**}"`, when such a variable is expanded into a URL path on the
// client side, all characters except `[-_.~/0-9a-zA-Z]` are percent-encoded.
// The server side does the reverse decoding, except "%2F" and "%2f" are left
// unchanged. Such variables show up in the
// [Discovery
// Document](https://developers.google.com/discovery/v1/reference/apis) as
// `{+var}`.
//
// ## Using gRPC API Service Configuration
//
// gRPC API Service Configuration (service config) is a configuration language
// for configuring a gRPC service to become a user-facing product. The
// service config is simply the YAML representation of the `google.api.Service`
// proto message.
//
// As an alternative to annotating your proto file, you can configure gRPC
// transcoding in your service config YAML files. You do this by specifying a
// `HttpRule` that maps the gRPC method to a REST endpoint, achieving the same
// effect as the proto annotation. This can be particularly useful if you
// have a proto that is reused in multiple services. Note that any transcoding
// specified in the service config will override any matching transcoding
// configuration in the proto.
//
// Example:
//
// http:
// rules:
// # Selects a gRPC method and applies HttpRule to it.
// - selector: example.v1.Messaging.GetMessage
// get: /v1/messages/{message_id}/{sub.subfield}
//
// ## Special notes
//
// When gRPC Transcoding is used to map a gRPC to JSON REST endpoints, the
// proto to JSON conversion must follow the [proto3
// specification](https://developers.google.com/protocol-buffers/docs/proto3#json).
//
// While the single segment variable follows the semantics of
// [RFC 6570](https://tools.ietf.org/html/rfc6570) Section 3.2.2 Simple String
// Expansion, the multi segment variable **does not** follow RFC 6570 Section
// 3.2.3 Reserved Expansion. The reason is that the Reserved Expansion
// does not expand special characters like `?` and `#`, which would lead
// to invalid URLs. As the result, gRPC Transcoding uses a custom encoding
// for multi segment variables.
//
// The path variables **must not** refer to any repeated or mapped field,
// because client libraries are not capable of handling such variable expansion.
//
// The path variables **must not** capture the leading "/" character. The reason
// is that the most common use case "{var}" does not capture the leading "/"
// character. For consistency, all path variables must share the same behavior.
//
// Repeated message fields must not be mapped to URL query parameters, because
// no client library can support such complicated mapping.
//
// If an API needs to use a JSON array for request or response body, it can map
// the request or response body to a repeated field. However, some gRPC
// Transcoding implementations may not support this feature.
message HttpRule {
// Selects a method to which this rule applies.
//
// Refer to [selector][google.api.DocumentationRule.selector] for syntax
// details.
string selector = 1;
// Determines the URL pattern is matched by this rules. This pattern can be
// used with any of the {get|put|post|delete|patch} methods. A custom method
// can be defined using the 'custom' field.
oneof pattern {
// Maps to HTTP GET. Used for listing and getting information about
// resources.
string get = 2;
// Maps to HTTP PUT. Used for replacing a resource.
string put = 3;
// Maps to HTTP POST. Used for creating a resource or performing an action.
string post = 4;
// Maps to HTTP DELETE. Used for deleting a resource.
string delete = 5;
// Maps to HTTP PATCH. Used for updating a resource.
string patch = 6;
// The custom pattern is used for specifying an HTTP method that is not
// included in the `pattern` field, such as HEAD, or "*" to leave the
// HTTP method unspecified for this rule. The wild-card rule is useful
// for services that provide content to Web (HTML) clients.
CustomHttpPattern custom = 8;
}
// The name of the request field whose value is mapped to the HTTP request
// body, or `*` for mapping all request fields not captured by the path
// pattern to the HTTP body, or omitted for not having any HTTP request body.
//
// NOTE: the referred field must be present at the top-level of the request
// message type.
string body = 7;
// Optional. The name of the response field whose value is mapped to the HTTP
// response body. When omitted, the entire response message will be used
// as the HTTP response body.
//
// NOTE: The referred field must be present at the top-level of the response
// message type.
string response_body = 12;
// Additional HTTP bindings for the selector. Nested bindings must
// not contain an `additional_bindings` field themselves (that is,
// the nesting may only be one level deep).
repeated HttpRule additional_bindings = 11;
}
// A custom pattern is used for defining custom HTTP verb.
message CustomHttpPattern {
// The name of this custom HTTP verb.
string kind = 1;
// The path matched by this custom verb.
string path = 2;
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
syntax = "proto3";
package google.api;
import "google/protobuf/any.proto";
option go_package = "google.golang.org/genproto/googleapis/api/httpbody;httpbody";
option java_multiple_files = true;
option java_outer_classname = "HttpBodyProto";
option java_package = "com.google.api";
option objc_class_prefix = "GAPI";
// Message that represents an arbitrary HTTP body. It should only be used for
// payload formats that can't be represented as JSON, such as raw binary or
// an HTML page.
//
//
// This message can be used both in streaming and non-streaming API methods in
// the request as well as the response.
//
// It can be used as a top-level request field, which is convenient if one
// wants to extract parameters from either the URL or HTTP template into the
// request fields and also want access to the raw HTTP body.
//
// Example:
//
// message GetResourceRequest {
// // A unique request id.
// string request_id = 1;
//
// // The raw HTTP body is bound to this field.
// google.api.HttpBody http_body = 2;
//
// }
//
// service ResourceService {
// rpc GetResource(GetResourceRequest)
// returns (google.api.HttpBody);
// rpc UpdateResource(google.api.HttpBody)
// returns (google.protobuf.Empty);
//
// }
//
// Example with streaming methods:
//
// service CaldavService {
// rpc GetCalendar(stream google.api.HttpBody)
// returns (stream google.api.HttpBody);
// rpc UpdateCalendar(stream google.api.HttpBody)
// returns (stream google.api.HttpBody);
//
// }
//
// Use of this type only changes how the request and response bodies are
// handled, all other features will continue to work unchanged.
message HttpBody {
// The HTTP Content-Type header value specifying the content type of the body.
string content_type = 1;
// The HTTP request/response body as raw binary.
bytes data = 2;
// Application specific response metadata. Must be set in the first response
// for streaming APIs.
repeated google.protobuf.Any extensions = 3;
}
+1053
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
syntax = "proto3";
package grpc.gateway.protoc_gen_openapiv2.options;
import "google/protobuf/descriptor.proto";
import "protoc-gen-openapiv2/options/openapiv2.proto";
option go_package = "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options";
extend google.protobuf.FileOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Swagger openapiv2_swagger = 1042;
}
extend google.protobuf.MethodOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Operation openapiv2_operation = 1042;
}
extend google.protobuf.MessageOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Schema openapiv2_schema = 1042;
}
extend google.protobuf.EnumOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
EnumSchema openapiv2_enum = 1042;
}
extend google.protobuf.ServiceOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
Tag openapiv2_tag = 1042;
}
extend google.protobuf.FieldOptions {
// ID assigned by protobuf-global-extension-registry@google.com for gRPC-Gateway project.
//
// All IDs are the same, as assigned. It is okay that they are the same, as they extend
// different descriptor messages.
JSONSchema openapiv2_field = 1042;
}
@@ -0,0 +1,762 @@
syntax = "proto3";
package grpc.gateway.protoc_gen_openapiv2.options;
import "google/protobuf/struct.proto";
option go_package = "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options";
// Scheme describes the schemes supported by the OpenAPI Swagger
// and Operation objects.
enum Scheme {
UNKNOWN = 0;
HTTP = 1;
HTTPS = 2;
WS = 3;
WSS = 4;
}
// `Swagger` is a representation of OpenAPI v2 specification's Swagger object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#swaggerObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// title: "Echo API";
// version: "1.0";
// description: "";
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE";
// };
// };
// schemes: HTTPS;
// consumes: "application/json";
// produces: "application/json";
// };
//
message Swagger {
// Specifies the OpenAPI Specification version being used. It can be
// used by the OpenAPI UI and other clients to interpret the API listing. The
// value MUST be "2.0".
string swagger = 1;
// Provides metadata about the API. The metadata can be used by the
// clients if needed.
Info info = 2;
// The host (name or ip) serving the API. This MUST be the host only and does
// not include the scheme nor sub-paths. It MAY include a port. If the host is
// not included, the host serving the documentation is to be used (including
// the port). The host does not support path templating.
string host = 3;
// The base path on which the API is served, which is relative to the host. If
// it is not included, the API is served directly under the host. The value
// MUST start with a leading slash (/). The basePath does not support path
// templating.
// Note that using `base_path` does not change the endpoint paths that are
// generated in the resulting OpenAPI file. If you wish to use `base_path`
// with relatively generated OpenAPI paths, the `base_path` prefix must be
// manually removed from your `google.api.http` paths and your code changed to
// serve the API from the `base_path`.
string base_path = 4;
// The transfer protocol of the API. Values MUST be from the list: "http",
// "https", "ws", "wss". If the schemes is not included, the default scheme to
// be used is the one used to access the OpenAPI definition itself.
repeated Scheme schemes = 5;
// A list of MIME types the APIs can consume. This is global to all APIs but
// can be overridden on specific API calls. Value MUST be as described under
// Mime Types.
repeated string consumes = 6;
// A list of MIME types the APIs can produce. This is global to all APIs but
// can be overridden on specific API calls. Value MUST be as described under
// Mime Types.
repeated string produces = 7;
// field 8 is reserved for 'paths'.
reserved 8;
// field 9 is reserved for 'definitions', which at this time are already
// exposed as and customizable as proto messages.
reserved 9;
// An object to hold responses that can be used across operations. This
// property does not define global responses for all operations.
map<string, Response> responses = 10;
// Security scheme definitions that can be used across the specification.
SecurityDefinitions security_definitions = 11;
// A declaration of which security schemes are applied for the API as a whole.
// The list of values describes alternative security schemes that can be used
// (that is, there is a logical OR between the security requirements).
// Individual operations can override this definition.
repeated SecurityRequirement security = 12;
// A list of tags for API documentation control. Tags can be used for logical
// grouping of operations by resources or any other qualifier.
repeated Tag tags = 13;
// Additional external documentation.
ExternalDocumentation external_docs = 14;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 15;
}
// `Operation` is a representation of OpenAPI v2 specification's Operation object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#operationObject
//
// Example:
//
// service EchoService {
// rpc Echo(SimpleMessage) returns (SimpleMessage) {
// option (google.api.http) = {
// get: "/v1/example/echo/{id}"
// };
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
// summary: "Get a message.";
// operation_id: "getMessage";
// tags: "echo";
// responses: {
// key: "200"
// value: {
// description: "OK";
// }
// }
// };
// }
// }
message Operation {
// A list of tags for API documentation control. Tags can be used for logical
// grouping of operations by resources or any other qualifier.
repeated string tags = 1;
// A short summary of what the operation does. For maximum readability in the
// swagger-ui, this field SHOULD be less than 120 characters.
string summary = 2;
// A verbose explanation of the operation behavior. GFM syntax can be used for
// rich text representation.
string description = 3;
// Additional external documentation for this operation.
ExternalDocumentation external_docs = 4;
// Unique string used to identify the operation. The id MUST be unique among
// all operations described in the API. Tools and libraries MAY use the
// operationId to uniquely identify an operation, therefore, it is recommended
// to follow common programming naming conventions.
string operation_id = 5;
// A list of MIME types the operation can consume. This overrides the consumes
// definition at the OpenAPI Object. An empty value MAY be used to clear the
// global definition. Value MUST be as described under Mime Types.
repeated string consumes = 6;
// A list of MIME types the operation can produce. This overrides the produces
// definition at the OpenAPI Object. An empty value MAY be used to clear the
// global definition. Value MUST be as described under Mime Types.
repeated string produces = 7;
// field 8 is reserved for 'parameters'.
reserved 8;
// The list of possible responses as they are returned from executing this
// operation.
map<string, Response> responses = 9;
// The transfer protocol for the operation. Values MUST be from the list:
// "http", "https", "ws", "wss". The value overrides the OpenAPI Object
// schemes definition.
repeated Scheme schemes = 10;
// Declares this operation to be deprecated. Usage of the declared operation
// should be refrained. Default value is false.
bool deprecated = 11;
// A declaration of which security schemes are applied for this operation. The
// list of values describes alternative security schemes that can be used
// (that is, there is a logical OR between the security requirements). This
// definition overrides any declared top-level security. To remove a top-level
// security declaration, an empty array can be used.
repeated SecurityRequirement security = 12;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 13;
// Custom parameters such as HTTP request headers.
// See: https://swagger.io/docs/specification/2-0/describing-parameters/
// and https://swagger.io/specification/v2/#parameter-object.
Parameters parameters = 14;
}
// `Parameters` is a representation of OpenAPI v2 specification's parameters object.
// Note: This technically breaks compatibility with the OpenAPI 2 definition structure as we only
// allow header parameters to be set here since we do not want users specifying custom non-header
// parameters beyond those inferred from the Protobuf schema.
// See: https://swagger.io/specification/v2/#parameter-object
message Parameters {
// `Headers` is one or more HTTP header parameter.
// See: https://swagger.io/docs/specification/2-0/describing-parameters/#header-parameters
repeated HeaderParameter headers = 1;
}
// `HeaderParameter` a HTTP header parameter.
// See: https://swagger.io/specification/v2/#parameter-object
message HeaderParameter {
// `Type` is a supported HTTP header type.
// See https://swagger.io/specification/v2/#parameterType.
enum Type {
UNKNOWN = 0;
STRING = 1;
NUMBER = 2;
INTEGER = 3;
BOOLEAN = 4;
}
// `Name` is the header name.
string name = 1;
// `Description` is a short description of the header.
string description = 2;
// `Type` is the type of the object. The value MUST be one of "string", "number", "integer", or "boolean". The "array" type is not supported.
// See: https://swagger.io/specification/v2/#parameterType.
Type type = 3;
// `Format` The extending format for the previously mentioned type.
string format = 4;
// `Required` indicates if the header is optional
bool required = 5;
// field 6 is reserved for 'items', but in OpenAPI-specific way.
reserved 6;
// field 7 is reserved `Collection Format`. Determines the format of the array if type array is used.
reserved 7;
}
// `Header` is a representation of OpenAPI v2 specification's Header object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#headerObject
//
message Header {
// `Description` is a short description of the header.
string description = 1;
// The type of the object. The value MUST be one of "string", "number", "integer", or "boolean". The "array" type is not supported.
string type = 2;
// `Format` The extending format for the previously mentioned type.
string format = 3;
// field 4 is reserved for 'items', but in OpenAPI-specific way.
reserved 4;
// field 5 is reserved `Collection Format` Determines the format of the array if type array is used.
reserved 5;
// `Default` Declares the value of the header that the server will use if none is provided.
// See: https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-6.2.
// Unlike JSON Schema this value MUST conform to the defined type for the header.
string default = 6;
// field 7 is reserved for 'maximum'.
reserved 7;
// field 8 is reserved for 'exclusiveMaximum'.
reserved 8;
// field 9 is reserved for 'minimum'.
reserved 9;
// field 10 is reserved for 'exclusiveMinimum'.
reserved 10;
// field 11 is reserved for 'maxLength'.
reserved 11;
// field 12 is reserved for 'minLength'.
reserved 12;
// 'Pattern' See https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.2.3.
string pattern = 13;
// field 14 is reserved for 'maxItems'.
reserved 14;
// field 15 is reserved for 'minItems'.
reserved 15;
// field 16 is reserved for 'uniqueItems'.
reserved 16;
// field 17 is reserved for 'enum'.
reserved 17;
// field 18 is reserved for 'multipleOf'.
reserved 18;
}
// `Response` is a representation of OpenAPI v2 specification's Response object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#responseObject
//
message Response {
// `Description` is a short description of the response.
// GFM syntax can be used for rich text representation.
string description = 1;
// `Schema` optionally defines the structure of the response.
// If `Schema` is not provided, it means there is no content to the response.
Schema schema = 2;
// `Headers` A list of headers that are sent with the response.
// `Header` name is expected to be a string in the canonical format of the MIME header key
// See: https://golang.org/pkg/net/textproto/#CanonicalMIMEHeaderKey
map<string, Header> headers = 3;
// `Examples` gives per-mimetype response examples.
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#example-object
map<string, string> examples = 4;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 5;
}
// `Info` is a representation of OpenAPI v2 specification's Info object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#infoObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// title: "Echo API";
// version: "1.0";
// description: "";
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE";
// };
// };
// ...
// };
//
message Info {
// The title of the application.
string title = 1;
// A short description of the application. GFM syntax can be used for rich
// text representation.
string description = 2;
// The Terms of Service for the API.
string terms_of_service = 3;
// The contact information for the exposed API.
Contact contact = 4;
// The license information for the exposed API.
License license = 5;
// Provides the version of the application API (not to be confused
// with the specification version).
string version = 6;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 7;
}
// `Contact` is a representation of OpenAPI v2 specification's Contact object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#contactObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// ...
// contact: {
// name: "gRPC-Gateway project";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// email: "none@example.com";
// };
// ...
// };
// ...
// };
//
message Contact {
// The identifying name of the contact person/organization.
string name = 1;
// The URL pointing to the contact information. MUST be in the format of a
// URL.
string url = 2;
// The email address of the contact person/organization. MUST be in the format
// of an email address.
string email = 3;
}
// `License` is a representation of OpenAPI v2 specification's License object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#licenseObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// info: {
// ...
// license: {
// name: "BSD 3-Clause License";
// url: "https://github.com/grpc-ecosystem/grpc-gateway/blob/main/LICENSE";
// };
// ...
// };
// ...
// };
//
message License {
// The license name used for the API.
string name = 1;
// A URL to the license used for the API. MUST be in the format of a URL.
string url = 2;
}
// `ExternalDocumentation` is a representation of OpenAPI v2 specification's
// ExternalDocumentation object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#externalDocumentationObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = {
// ...
// external_docs: {
// description: "More about gRPC-Gateway";
// url: "https://github.com/grpc-ecosystem/grpc-gateway";
// }
// ...
// };
//
message ExternalDocumentation {
// A short description of the target documentation. GFM syntax can be used for
// rich text representation.
string description = 1;
// The URL for the target documentation. Value MUST be in the format
// of a URL.
string url = 2;
}
// `Schema` is a representation of OpenAPI v2 specification's Schema object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
//
message Schema {
JSONSchema json_schema = 1;
// Adds support for polymorphism. The discriminator is the schema property
// name that is used to differentiate between other schema that inherit this
// schema. The property name used MUST be defined at this schema and it MUST
// be in the required property list. When used, the value MUST be the name of
// this schema or any schema that inherits it.
string discriminator = 2;
// Relevant only for Schema "properties" definitions. Declares the property as
// "read only". This means that it MAY be sent as part of a response but MUST
// NOT be sent as part of the request. Properties marked as readOnly being
// true SHOULD NOT be in the required list of the defined schema. Default
// value is false.
bool read_only = 3;
// field 4 is reserved for 'xml'.
reserved 4;
// Additional external documentation for this schema.
ExternalDocumentation external_docs = 5;
// A free-form property to include an example of an instance for this schema in JSON.
// This is copied verbatim to the output.
string example = 6;
}
// `EnumSchema` is subset of fields from the OpenAPI v2 specification's Schema object.
// Only fields that are applicable to Enums are included
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
//
// Example:
//
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_enum) = {
// ...
// title: "MyEnum";
// description:"This is my nice enum";
// example: "ZERO";
// required: true;
// ...
// };
//
message EnumSchema {
// A short description of the schema.
string description = 1;
string default = 2;
// The title of the schema.
string title = 3;
bool required = 4;
bool read_only = 5;
// Additional external documentation for this schema.
ExternalDocumentation external_docs = 6;
string example = 7;
// Ref is used to define an external reference to include in the message.
// This could be a fully qualified proto message reference, and that type must
// be imported into the protofile. If no message is identified, the Ref will
// be used verbatim in the output.
// For example:
// `ref: ".google.protobuf.Timestamp"`.
string ref = 8;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 9;
}
// `JSONSchema` represents properties from JSON Schema taken, and as used, in
// the OpenAPI v2 spec.
//
// This includes changes made by OpenAPI v2.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
//
// See also: https://cswr.github.io/JsonSchema/spec/basic_types/,
// https://github.com/json-schema-org/json-schema-spec/blob/master/schema.json
//
// Example:
//
// message SimpleMessage {
// option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
// json_schema: {
// title: "SimpleMessage"
// description: "A simple message."
// required: ["id"]
// }
// };
//
// // Id represents the message identifier.
// string id = 1; [
// (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
// description: "The unique identifier of the simple message."
// }];
// }
//
message JSONSchema {
// field 1 is reserved for '$id', omitted from OpenAPI v2.
reserved 1;
// field 2 is reserved for '$schema', omitted from OpenAPI v2.
reserved 2;
// Ref is used to define an external reference to include in the message.
// This could be a fully qualified proto message reference, and that type must
// be imported into the protofile. If no message is identified, the Ref will
// be used verbatim in the output.
// For example:
// `ref: ".google.protobuf.Timestamp"`.
string ref = 3;
// field 4 is reserved for '$comment', omitted from OpenAPI v2.
reserved 4;
// The title of the schema.
string title = 5;
// A short description of the schema.
string description = 6;
string default = 7;
bool read_only = 8;
// A free-form property to include a JSON example of this field. This is copied
// verbatim to the output swagger.json. Quotes must be escaped.
// This property is the same for 2.0 and 3.0.0 https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/3.0.0.md#schemaObject https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#schemaObject
string example = 9;
double multiple_of = 10;
// Maximum represents an inclusive upper limit for a numeric instance. The
// value of MUST be a number,
double maximum = 11;
bool exclusive_maximum = 12;
// minimum represents an inclusive lower limit for a numeric instance. The
// value of MUST be a number,
double minimum = 13;
bool exclusive_minimum = 14;
uint64 max_length = 15;
uint64 min_length = 16;
string pattern = 17;
// field 18 is reserved for 'additionalItems', omitted from OpenAPI v2.
reserved 18;
// field 19 is reserved for 'items', but in OpenAPI-specific way.
// TODO(ivucica): add 'items'?
reserved 19;
uint64 max_items = 20;
uint64 min_items = 21;
bool unique_items = 22;
// field 23 is reserved for 'contains', omitted from OpenAPI v2.
reserved 23;
uint64 max_properties = 24;
uint64 min_properties = 25;
repeated string required = 26;
// field 27 is reserved for 'additionalProperties', but in OpenAPI-specific
// way. TODO(ivucica): add 'additionalProperties'?
reserved 27;
// field 28 is reserved for 'definitions', omitted from OpenAPI v2.
reserved 28;
// field 29 is reserved for 'properties', but in OpenAPI-specific way.
// TODO(ivucica): add 'additionalProperties'?
reserved 29;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2:
// patternProperties, dependencies, propertyNames, const
reserved 30 to 33;
// Items in 'array' must be unique.
repeated string array = 34;
enum JSONSchemaSimpleTypes {
UNKNOWN = 0;
ARRAY = 1;
BOOLEAN = 2;
INTEGER = 3;
NULL = 4;
NUMBER = 5;
OBJECT = 6;
STRING = 7;
}
repeated JSONSchemaSimpleTypes type = 35;
// `Format`
string format = 36;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2: contentMediaType, contentEncoding, if, then, else
reserved 37 to 41;
// field 42 is reserved for 'allOf', but in OpenAPI-specific way.
// TODO(ivucica): add 'allOf'?
reserved 42;
// following fields are reserved, as the properties have been omitted from
// OpenAPI v2:
// anyOf, oneOf, not
reserved 43 to 45;
// Items in `enum` must be unique https://tools.ietf.org/html/draft-fge-json-schema-validation-00#section-5.5.1
repeated string enum = 46;
// Additional field level properties used when generating the OpenAPI v2 file.
FieldConfiguration field_configuration = 1001;
// 'FieldConfiguration' provides additional field level properties used when generating the OpenAPI v2 file.
// These properties are not defined by OpenAPIv2, but they are used to control the generation.
message FieldConfiguration {
// Alternative parameter name when used as path parameter. If set, this will
// be used as the complete parameter name when this field is used as a path
// parameter. Use this to avoid having auto generated path parameter names
// for overlapping paths.
string path_param_name = 47;
// Declares this field to be deprecated. Allows for the generated OpenAPI
// parameter to be marked as deprecated without affecting the proto field.
bool deprecated = 49;
}
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 48;
}
// `Tag` is a representation of OpenAPI v2 specification's Tag object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#tagObject
//
message Tag {
// The name of the tag. Use it to allow override of the name of a
// global Tag object, then use that name to reference the tag throughout the
// OpenAPI file.
string name = 1;
// A short description for the tag. GFM syntax can be used for rich text
// representation.
string description = 2;
// Additional external documentation for this tag.
ExternalDocumentation external_docs = 3;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 4;
}
// `SecurityDefinitions` is a representation of OpenAPI v2 specification's
// Security Definitions object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securityDefinitionsObject
//
// A declaration of the security schemes available to be used in the
// specification. This does not enforce the security schemes on the operations
// and only serves to provide the relevant details for each scheme.
message SecurityDefinitions {
// A single security scheme definition, mapping a "name" to the scheme it
// defines.
map<string, SecurityScheme> security = 1;
}
// `SecurityScheme` is a representation of OpenAPI v2 specification's
// Security Scheme object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securitySchemeObject
//
// Allows the definition of a security scheme that can be used by the
// operations. Supported schemes are basic authentication, an API key (either as
// a header or as a query parameter) and OAuth2's common flows (implicit,
// password, application and access code).
message SecurityScheme {
// The type of the security scheme. Valid values are "basic",
// "apiKey" or "oauth2".
enum Type {
TYPE_INVALID = 0;
TYPE_BASIC = 1;
TYPE_API_KEY = 2;
TYPE_OAUTH2 = 3;
}
// The location of the API key. Valid values are "query" or "header".
enum In {
IN_INVALID = 0;
IN_QUERY = 1;
IN_HEADER = 2;
}
// The flow used by the OAuth2 security scheme. Valid values are
// "implicit", "password", "application" or "accessCode".
enum Flow {
FLOW_INVALID = 0;
FLOW_IMPLICIT = 1;
FLOW_PASSWORD = 2;
FLOW_APPLICATION = 3;
FLOW_ACCESS_CODE = 4;
}
// The type of the security scheme. Valid values are "basic",
// "apiKey" or "oauth2".
Type type = 1;
// A short description for security scheme.
string description = 2;
// The name of the header or query parameter to be used.
// Valid for apiKey.
string name = 3;
// The location of the API key. Valid values are "query" or
// "header".
// Valid for apiKey.
In in = 4;
// The flow used by the OAuth2 security scheme. Valid values are
// "implicit", "password", "application" or "accessCode".
// Valid for oauth2.
Flow flow = 5;
// The authorization URL to be used for this flow. This SHOULD be in
// the form of a URL.
// Valid for oauth2/implicit and oauth2/accessCode.
string authorization_url = 6;
// The token URL to be used for this flow. This SHOULD be in the
// form of a URL.
// Valid for oauth2/password, oauth2/application and oauth2/accessCode.
string token_url = 7;
// The available scopes for the OAuth2 security scheme.
// Valid for oauth2.
Scopes scopes = 8;
// Custom properties that start with "x-" such as "x-foo" used to describe
// extra functionality that is not covered by the standard OpenAPI Specification.
// See: https://swagger.io/docs/specification/2-0/swagger-extensions/
map<string, google.protobuf.Value> extensions = 9;
}
// `SecurityRequirement` is a representation of OpenAPI v2 specification's
// Security Requirement object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#securityRequirementObject
//
// Lists the required security schemes to execute this operation. The object can
// have multiple security schemes declared in it which are all required (that
// is, there is a logical AND between the schemes).
//
// The name used for each property MUST correspond to a security scheme
// declared in the Security Definitions.
message SecurityRequirement {
// If the security scheme is of type "oauth2", then the value is a list of
// scope names required for the execution. For other security scheme types,
// the array MUST be empty.
message SecurityRequirementValue {
repeated string scope = 1;
}
// Each name must correspond to a security scheme which is declared in
// the Security Definitions. If the security scheme is of type "oauth2",
// then the value is a list of scope names required for the execution.
// For other security scheme types, the array MUST be empty.
map<string, SecurityRequirementValue> security_requirement = 1;
}
// `Scopes` is a representation of OpenAPI v2 specification's Scopes object.
//
// See: https://github.com/OAI/OpenAPI-Specification/blob/3.0.0/versions/2.0.md#scopesObject
//
// Lists the available scopes for an OAuth2 security scheme.
message Scopes {
// Maps between a name of a scope to a short description of it (as the value
// of the property).
map<string, string> scope = 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"> <script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router' import { NConfigProvider, NMessageProvider } from 'naive-ui'
import HelloWorld from './components/HelloWorld.vue' import { darkTheme } from 'naive-ui'
import { RouterView } from 'vue-router'
</script> </script>
<template> <template>
<header> <n-config-provider :theme="darkTheme">
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" /> <n-message-provider>
<RouterView />
<div class="wrapper"> </n-message-provider>
<HelloWorld msg="You did it!" /> </n-config-provider>
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
</nav>
</div>
</header>
<RouterView />
</template> </template>
<style scoped> <style scoped></style>
header {
line-height: 1.5;
max-height: 100vh;
}
.logo {
display: block;
margin: 0 auto 2rem;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
nav {
text-align: left;
margin-left: -1rem;
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>
+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
+16 -19
View File
@@ -1,24 +1,26 @@
/* color palette from <https://github.com/vuejs/theme> */ /* color palette from <https://github.com/vuejs/theme> */
:root { :root {
--vt-c-white: #ffffff; --vt-c-white: #fff;
--vt-c-white-soft: #f8f8f8; --vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2; --vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818; --vt-c-black: #181818;
--vt-c-black-soft: #222222; --vt-c-black-soft: #222;
--vt-c-black-mute: #282828; --vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50; --vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgb(60 60 60 / 29%);
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29); --vt-c-divider-light-2: rgb(60 60 60 / 12%);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12); --vt-c-divider-dark-1: rgb(84 84 84 / 65%);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); --vt-c-divider-dark-2: rgb(84 84 84 / 48%);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo); --vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66); --vt-c-text-light-2: rgb(60 60 60 / 66%);
--vt-c-text-dark-1: var(--vt-c-white); --vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64); --vt-c-text-dark-2: rgb(235 235 235 / 64%);
/* Главный цвет */
--main-color: rgb(34 50 60 / 100%);
--second-color: rgb(97 74 22);
--main-back-color: rgb(240 240 240 / 100%);
--main-back-item-color: rgb(254 254 254 / 100%);
} }
/* semantic color variables for this project */ /* semantic color variables for this project */
@@ -26,13 +28,10 @@
--color-background: var(--vt-c-white); --color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft); --color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute); --color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2); --color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1); --color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1); --color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1); --color-text: var(--vt-c-text-light-1);
--section-gap: 160px; --section-gap: 160px;
} }
@@ -41,10 +40,8 @@
--color-background: var(--vt-c-black); --color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft); --color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute); --color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2); --color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1); --color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1); --color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2); --color-text: var(--vt-c-text-dark-2);
} }
@@ -80,7 +77,7 @@ body {
'Helvetica Neue', 'Helvetica Neue',
sans-serif; sans-serif;
font-size: 15px; font-size: 15px;
text-rendering: optimizeLegibility; text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
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'; @import './base.css';
#app { body {
max-width: 1280px; overflow: hidden;
color: #eee;
background-color: #111;
scrollbar-width: none;
}
@font-face {
font-family: 'font_old_typer';
src: url('@/assets/fonts/a_OldTyper.ttf');
}
.height80 {
height: 80vh;
}
.content-block {
margin-top: 70px;
}
.center-block-custom {
margin-top: 70px;
overflow-y: auto;
height: calc(100vh - 150px);
/* Firefox */
scrollbar-width: none;
}
.center-block-custom::-webkit-scrollbar {
/* Chrome, Edge, Safari */
display: none;
}
.width700 {
padding: 10px;
margin: 0 auto; margin: 0 auto;
padding: 2rem;
font-weight: normal;
} }
a, .width1200 {
.green { padding: 10px;
text-decoration: none; margin: 0 auto;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
} }
@media (hover: hover) { .display-table {
a:hover { display: flex;
background-color: hsla(160, 100%, 37%, 0.2); flex-flow: row wrap;
justify-content: center;
}
@media (width >=1024px) {
.center-block-custom {
margin: 70px auto 0;
height: calc(100dvh - 70px);
} }
}
@media (min-width: 1024px) { .width700 {
body { width: 700px;
display: flex; padding: 20px;
margin: 0 auto;
}
.width1200 {
width: 1200px;
padding: 20px;
margin: 0 auto;
}
.center-middle-block-custom {
display: grid;
place-items: center; place-items: center;
} }
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
} }
+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 './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
+77 -6
View File
@@ -1,5 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import CatalogView from '../views/CatalogView.vue'
import ConsentView from '../views/ConsentView.vue'
import GamesView from '../views/GamesView.vue'
import GameView from '../views/GameView.vue'
import HomeView from '../views/HomeView.vue' import HomeView from '../views/HomeView.vue'
import LoginView from '../views/LoginView.vue'
import OfficeView from '../views/OfficeView.vue'
import PrivacyPolicyView from '../views/PrivacyPolicyView.vue'
import ScenariosEditorView from '../views/ScenariosEditorView.vue'
import ScenariosView from '../views/ScenariosView.vue'
import ScenarioView from '../views/ScenarioView.vue'
import TeamStoryView from '../views/TeamStoryView.vue'
import UserAgreementView from '../views/UserAgreementView.vue'
import UsersView from '../views/UsersView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -10,12 +24,69 @@ const router = createRouter({
component: HomeView, component: HomeView,
}, },
{ {
path: '/about', path: '/login',
name: 'about', name: 'login',
// route level code-splitting component: LoginView,
// this generates a separate chunk (About.[hash].js) for this route },
// which is lazy-loaded when the route is visited. {
component: () => import('../views/AboutView.vue'), path: '/office',
name: 'office',
component: OfficeView,
},
{
path: '/user-agreement',
name: 'user-agreement',
component: UserAgreementView,
},
{
path: '/privacy-policy',
name: 'privacy-policy',
component: PrivacyPolicyView,
},
{
path: '/consent',
name: 'consent',
component: ConsentView,
},
{
path: '/games',
name: 'games',
component: GamesView,
},
{
path: '/games/:id',
name: 'game',
component: GameView,
},
{
path: '/team-story/:id',
name: 'team-story',
component: TeamStoryView,
},
{
path: '/scenarios',
name: 'scenarios',
component: ScenariosView,
},
{
path: '/catalog',
name: 'catalog',
component: CatalogView,
},
{
path: '/scenarios/:id/editor', // Динамический параметр id
name: 'scenario-editor',
component: ScenariosEditorView,
},
{
path: '/scenarios/:id', // Динамический параметр id
name: 'scenario',
component: ScenarioView,
},
{
path: '/users',
name: 'users',
component: UsersView,
}, },
], ],
}) })
+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"> <script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
</script> </script>
<template> <template>
<main> <div class="center-block-custom content-block center-middle-block-custom height80">
<TheWelcome /> <div>
</main> <h1 class="font-text">Вечерний детектив</h1>
<h3 class="font-text">Дело 0</h3>
<p class="content-text">
Поиграем?
</p>
</div>
</div>
<HeaderMenu active="main" />
</template> </template>
<style lang="css">
.font-text {
font-family: 'font_old_typer';
}
.content-text {
position: relative;
top: 100px;
right: 0;
float: right;
}
@media (width >=1024px) {
.font-text {
position: relative;
left: -50px;
}
.content-text {
position: relative;
top: 100px;
right: -50px;
}
}
</style>
+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 { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import vueDevTools from 'vite-plugin-vue-devtools' import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/ // https://vite.dev/config/