Compare commits
98 Commits
1ab4975c1c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 606d515ade | |||
| 4174ad304a | |||
| 862c918981 | |||
| bb8fca352a | |||
| ffa80a19ad | |||
| c17f91b903 | |||
| b3c51ff4de | |||
| 3fc1e188ef | |||
| a1c19cba3e | |||
| 66890b8a62 | |||
| 115f2498cd | |||
| a43b23aad9 | |||
| 39d6ced46d | |||
| ec6a5dc709 | |||
| 33cde1d28b | |||
| 2241c33902 | |||
| 489e9c0bec | |||
| d5a05fe7cd | |||
| 92c72ba42f | |||
| d9c5791c68 | |||
| 33501fe4c7 | |||
| 491a69e980 | |||
| d4ad314093 | |||
| d84539f1d0 | |||
| f8b4b6c6a8 | |||
| a71c1c9f6d | |||
| e463c0103e | |||
| 5629333ce3 | |||
| 8ccc17a03a | |||
| 1d6e2fe478 | |||
| 47d43d61a8 | |||
| fc5fcd3ade | |||
| 72efa66a2f | |||
| 762858807c | |||
| f88818dbf9 | |||
| c51cfe0620 | |||
| d1aac40b7f | |||
| a392d1a928 | |||
| 90d1837565 | |||
| 12edf754bc | |||
| 9b3f43da98 | |||
| c04ae2868c | |||
| 5d3044abb9 | |||
| 035bf4c30d | |||
| 1f3d1bb284 | |||
| 6d5766092b | |||
| 4663493c66 | |||
| a7502ab771 | |||
| 48c6315697 | |||
| 93b131dbca | |||
| e7b7c1180b | |||
| e1753fd450 | |||
| 91ac1a43c8 | |||
| 16a107827a | |||
| e9cc3e3007 | |||
| 18aa914497 | |||
| c45b53e400 | |||
| 7d19ca800c | |||
| 7d04e5a1cd | |||
| 9bcfe42683 | |||
| e9e0d4e739 | |||
| 671105c3fb | |||
| 084455b982 | |||
| 3b6852834d | |||
| 32c2dae619 | |||
| beefb549d0 | |||
| 4086f7a676 | |||
| 0dffd01241 | |||
| 2570d71f72 | |||
| c64955928e | |||
| 19f3277207 | |||
| 2f42977113 | |||
| 7db0c6f248 | |||
| 04f516a9ea | |||
| 16114bb134 | |||
| db558c87d1 | |||
| c314e4f329 | |||
| 301cfcd61b | |||
| 34520a95d4 | |||
| a0dfa65be8 | |||
| b49074334d | |||
| 83b3ae5dfe | |||
| a97993c70b | |||
| 2599be3f53 | |||
| 3f8b8ba432 | |||
| 75efb4cbf0 | |||
| 0b8cbfc138 | |||
| 7ce91ecd49 | |||
| c1a113b849 | |||
| b81000249b | |||
| 28f1e43338 | |||
| 0731123fbb | |||
| ad68ccc297 | |||
| 6b902cad98 | |||
| f368619471 | |||
| f36e1c2ce7 | |||
| 63e83d87ed | |||
| 083a856111 |
@@ -0,0 +1,2 @@
|
||||
VITE_API_URL=http://localhost:8080
|
||||
VITE_APP_TITLE=Вечерний детектив
|
||||
@@ -9,7 +9,7 @@ lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
# dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
@@ -28,3 +28,5 @@ coverage
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.env
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# ${REPO_NAME_SNAKE}
|
||||
# evening_detective_client
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Вечерний детектив</title>
|
||||
<script type="module" crossorigin src="/assets/index-CNblC4ZU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BlFsLzpe.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
evening_detective_frontend:
|
||||
image: byjg/static-httpserver:latest
|
||||
container_name: evening_detective_frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./dist:/static
|
||||
networks:
|
||||
- crabs-network
|
||||
ports:
|
||||
- "8100:8080"
|
||||
environment:
|
||||
- SPA_MODE=true
|
||||
labels:
|
||||
reproxy.1.server: "evening-detective.crabs-games.art"
|
||||
reproxy.1.route: "/(.*)"
|
||||
reproxy.1.dest: "http://evening_detective_frontend:8100/$$1"
|
||||
reproxy.1.port: "8100"
|
||||
reproxy.1.ping: "/"
|
||||
reproxy.1.priority: 100
|
||||
|
||||
networks:
|
||||
crabs-network:
|
||||
name: crabs-network
|
||||
external: true
|
||||
@@ -1,7 +1,9 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort';
|
||||
import unusedImports from 'eslint-plugin-unused-imports';
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
@@ -19,4 +21,36 @@ export default defineConfigWithVueTs(
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
skipFormatting,
|
||||
{
|
||||
plugins: {
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
rules: {
|
||||
// Основные правила сортировки
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
'unused-imports': unusedImports,
|
||||
},
|
||||
rules: {
|
||||
// Отключаем стандартное правило
|
||||
'no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
// Включаем правила плагина
|
||||
'unused-imports/no-unused-imports': 'error', // 🔧 автофикс
|
||||
'unused-imports/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
vars: 'all',
|
||||
varsIgnorePattern: '^_',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
<title>Вечерний детектив</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,35 +1,55 @@
|
||||
{
|
||||
"name": "${REPO_NAME_SNAKE}",
|
||||
"name": "evening_detective_client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --host",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "eslint . --fix",
|
||||
"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": {
|
||||
"axios": "^1.18.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"@vue/eslint-config-typescript": "^14.9.0",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^14.0.0",
|
||||
"eslint-plugin-unused-imports": "^4.4.1",
|
||||
"eslint-plugin-vue": "~10.9.2",
|
||||
"jiti": "^2.7.0",
|
||||
"naive-ui": "^2.44.1",
|
||||
"npm-run-all2": "^9.0.2",
|
||||
"postcss-html": "^1.8.1",
|
||||
"prettier": "3.9.4",
|
||||
"stylelint": "^17.14.1",
|
||||
"stylelint-config-standard-scss": "^17.0.0",
|
||||
"ts-proto": "^2.12.0",
|
||||
"typescript": "~6.0.3",
|
||||
"vfonts": "^0.0.3",
|
||||
"vite": "^8.1.3",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vue-tsc": "^3.3.6"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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¶m=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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 66 KiB |
@@ -1,85 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
import { NConfigProvider, NMessageProvider } from 'naive-ui'
|
||||
import { darkTheme } from 'naive-ui'
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<img alt="Vue logo" class="logo" src="@/assets/logo.svg" width="125" height="125" />
|
||||
|
||||
<div class="wrapper">
|
||||
<HelloWorld msg="You did it!" />
|
||||
|
||||
<nav>
|
||||
<RouterLink to="/">Home</RouterLink>
|
||||
<RouterLink to="/about">About</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<n-message-provider>
|
||||
<RouterView />
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
header {
|
||||
line-height: 1.5;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
nav a.router-link-exact-active:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
nav a {
|
||||
display: inline-block;
|
||||
padding: 0 1rem;
|
||||
border-left: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
nav a:first-of-type {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
header {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
padding-right: calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0 2rem 0 0;
|
||||
}
|
||||
|
||||
header .wrapper {
|
||||
display: flex;
|
||||
place-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
nav {
|
||||
text-align: left;
|
||||
margin-left: -1rem;
|
||||
font-size: 1rem;
|
||||
|
||||
padding: 1rem 0;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped></style>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
import { getURL } from './generated/crabs/evening_detective_server/client'
|
||||
|
||||
export type DownloadArchiveResult = {
|
||||
blob: Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
export type UploadArchiveResult = {
|
||||
id: number | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
// Клиент для бинарных операций со сценарием (ZIP-архив). Сгенерированный
|
||||
// HTTP-хендлер всегда парсит ответ как JSON, поэтому для архивов используем
|
||||
// сырой fetch: скачивание отдаёт байты архива, загрузка принимает их напрямую.
|
||||
export function getArchiveClient() {
|
||||
const authStore = useAuthStore()
|
||||
return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken)
|
||||
}
|
||||
|
||||
function buildArchiveClient(
|
||||
refreshTokens: () => Promise<boolean>,
|
||||
getToken: () => string | null,
|
||||
) {
|
||||
async function request(path: string, init: RequestInit): Promise<Response> {
|
||||
const headers: Record<string, string> = {}
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
let response = await fetch(`${getURL()}/${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init.headers as Record<string, string>) },
|
||||
})
|
||||
|
||||
// Токен протух — обновляем и повторяем запрос один раз.
|
||||
if (response.status == 401) {
|
||||
await refreshTokens()
|
||||
const newToken = getToken()
|
||||
if (newToken) {
|
||||
headers['Authorization'] = `Bearer ${newToken}`
|
||||
}
|
||||
response = await fetch(`${getURL()}/${path}`, {
|
||||
...init,
|
||||
headers: { ...headers, ...(init.headers as Record<string, string>) },
|
||||
})
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function downloadScenarioArchive(id: number): Promise<DownloadArchiveResult> {
|
||||
const response = await request(`api/scenarios/${id}/archive`, { method: 'GET' })
|
||||
if (!response.ok) {
|
||||
throw new Error(await gatewayErrorMessage(response))
|
||||
}
|
||||
const blob = await response.blob()
|
||||
const filename =
|
||||
parseContentDispositionFilename(response.headers.get('Content-Disposition')) || `scenario-${id}.zip`
|
||||
return { blob, filename }
|
||||
}
|
||||
|
||||
async function uploadScenarioArchive(file: File): Promise<UploadArchiveResult> {
|
||||
const contentType = file.type || 'application/zip'
|
||||
const response = await request('api/scenarios/archive', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': contentType },
|
||||
body: file,
|
||||
})
|
||||
|
||||
const data = await response.json().catch(() => null)
|
||||
if (!response.ok) {
|
||||
return { id: null, error: data?.message || `Ошибка сервера (${response.status})` }
|
||||
}
|
||||
|
||||
const error = typeof data?.error === 'string' && data.error !== '' ? data.error : null
|
||||
return { id: typeof data?.id === 'number' ? data.id : null, error }
|
||||
}
|
||||
|
||||
return { downloadScenarioArchive, uploadScenarioArchive }
|
||||
}
|
||||
|
||||
// Извлекает имя файла из Content-Disposition: attachment; filename="name.zip"
|
||||
function parseContentDispositionFilename(value: string | null): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const match = value.match(/filename="?([^";]+)"?/i)
|
||||
const filename = match ? match[1] : ''
|
||||
return filename.replace(/[\\/:*?"<>|]/g, '_')
|
||||
}
|
||||
|
||||
// Ошибки grpc-gateway приходят JSON-объектом {"code": ..., "message": ...}
|
||||
async function gatewayErrorMessage(response: Response): Promise<string> {
|
||||
const data = await response.json().catch(() => null)
|
||||
if (data && typeof data.message === 'string' && data.message !== '') {
|
||||
return data.message
|
||||
}
|
||||
return `Ошибка сервера (${response.status})`
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
import {
|
||||
createEveningDetectiveServerClient,
|
||||
type EveningDetectiveServer,
|
||||
} from './generated/crabs/evening_detective_server'
|
||||
import { createHttpHandler } from './generated/crabs/evening_detective_server/client'
|
||||
|
||||
export function getAuthClient() {
|
||||
const authStore = useAuthStore()
|
||||
return buildAuthClient(authStore.refreshTokenAction, authStore.getToken)
|
||||
}
|
||||
|
||||
function buildAuthClient(
|
||||
refreshTokens: () => Promise<boolean>,
|
||||
getToken: () => string | null,
|
||||
): EveningDetectiveServer {
|
||||
return createEveningDetectiveServerClient(
|
||||
createAuthHttpHandler(refreshTokens, createHttpHandler(getToken)),
|
||||
)
|
||||
}
|
||||
|
||||
function createAuthHttpHandler(
|
||||
refreshTokens: () => Promise<boolean>,
|
||||
handler: (request: { path: string; method: string; body: string | null }) => Promise<unknown>,
|
||||
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
|
||||
return async (request: { path: string; method: string; body: string | null }) => {
|
||||
try {
|
||||
return await handler(request)
|
||||
} catch {
|
||||
await refreshTokens()
|
||||
return await handler(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createEveningDetectiveServerClient, type EveningDetectiveServer } from '.'
|
||||
|
||||
export type ExtraHeadersProvider = () => Record<string, string> | null | undefined
|
||||
|
||||
export function buildClient(
|
||||
getToken?: () => string | null,
|
||||
getExtraHeaders?: ExtraHeadersProvider,
|
||||
): EveningDetectiveServer {
|
||||
return createEveningDetectiveServerClient(createHttpHandler(getToken, getExtraHeaders))
|
||||
}
|
||||
|
||||
export function createHttpHandler(
|
||||
getToken?: () => string | null,
|
||||
getExtraHeaders?: ExtraHeadersProvider,
|
||||
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
|
||||
return async (request: { path: string; method: string; body: string | null }) => {
|
||||
const baseURL = getURL()
|
||||
|
||||
// Формируем полный URL
|
||||
const url = `${baseURL}/${request.path}`
|
||||
|
||||
// Подготавливаем заголовки
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Добавляем токен, если он есть
|
||||
const token = getToken?.()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Добавляем дополнительные заголовки (например, X-Password для удаления аккаунта)
|
||||
const extraHeaders = getExtraHeaders?.()
|
||||
if (extraHeaders) {
|
||||
Object.assign(headers, extraHeaders)
|
||||
}
|
||||
|
||||
// Делаем запрос
|
||||
const response = await fetch(url, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: request.body, // body уже JSON-строка из генератора
|
||||
})
|
||||
|
||||
if (response.status == 401) {
|
||||
throw new Error('unauthorized')
|
||||
}
|
||||
|
||||
// Парсим ответ
|
||||
const data = await response.json()
|
||||
|
||||
// Возвращаем в формате, который ожидает генератор
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export function getURL(): string {
|
||||
const url = import.meta.env.VITE_API_URL || 'http://localhost:8090'
|
||||
// console.log(url)
|
||||
return url
|
||||
}
|
||||
@@ -1,24 +1,26 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white: #fff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-soft: #222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-divider-light-1: rgb(60 60 60 / 29%);
|
||||
--vt-c-divider-light-2: rgb(60 60 60 / 12%);
|
||||
--vt-c-divider-dark-1: rgb(84 84 84 / 65%);
|
||||
--vt-c-divider-dark-2: rgb(84 84 84 / 48%);
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-light-2: rgb(60 60 60 / 66%);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
--vt-c-text-dark-2: rgb(235 235 235 / 64%);
|
||||
|
||||
/* Главный цвет */
|
||||
--main-color: rgb(34 50 60 / 100%);
|
||||
--second-color: rgb(97 74 22);
|
||||
--main-back-color: rgb(240 240 240 / 100%);
|
||||
--main-back-item-color: rgb(254 254 254 / 100%);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
@@ -26,13 +28,10 @@
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@@ -41,10 +40,8 @@
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
@@ -80,7 +77,7 @@ body {
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
text-rendering: optimizelegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
@@ -1,35 +1,75 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
overflow: hidden;
|
||||
color: #eee;
|
||||
background-color: #111;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'font_old_typer';
|
||||
src: url('@/assets/fonts/a_OldTyper.ttf');
|
||||
}
|
||||
|
||||
.height80 {
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
margin-top: 70px;
|
||||
}
|
||||
|
||||
.center-block-custom {
|
||||
margin-top: 70px;
|
||||
overflow-y: auto;
|
||||
height: calc(100vh - 150px);
|
||||
|
||||
/* Firefox */
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.center-block-custom::-webkit-scrollbar {
|
||||
/* Chrome, Edge, Safari */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.width700 {
|
||||
padding: 10px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.width1200 {
|
||||
padding: 10px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.display-table {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (width >=1024px) {
|
||||
.center-block-custom {
|
||||
margin: 70px auto 0;
|
||||
height: calc(100dvh - 70px);
|
||||
}
|
||||
|
||||
.width700 {
|
||||
width: 700px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.width1200 {
|
||||
width: 1200px;
|
||||
padding: 20px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.center-middle-block-custom {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="shadow shadow-top"></div>
|
||||
<div class="shadow shadow-bottom"></div>
|
||||
<div class="belt-block">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.belt-block {
|
||||
height: 100%;
|
||||
background-color: rgb(0 0 0);
|
||||
background-image: url("@/assets/images/belt.png");
|
||||
background-size: cover;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
height: 10px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.shadow-top {
|
||||
top: 0;
|
||||
box-shadow: 0 -5px 10px black;
|
||||
}
|
||||
|
||||
.shadow-bottom {
|
||||
bottom: 0;
|
||||
box-shadow: 0 5px 10px black;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { NTag } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
|
||||
const client = getAuthClient()
|
||||
|
||||
const scenarios = ref<Scenario[]>([])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
async function getScenarios() {
|
||||
scenarios.value = []
|
||||
const res = await client.GetScenariosCatalog({})
|
||||
scenarios.value = res.scenarios!
|
||||
}
|
||||
|
||||
async function toScenarioInfo(id: number) {
|
||||
router.push('/scenarios/' + id)
|
||||
}
|
||||
|
||||
getScenarios()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width1200 display-table">
|
||||
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioInfo(scenario.id!)"
|
||||
v-bind:key="scenario.id">
|
||||
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }">
|
||||
</div>
|
||||
|
||||
<div class="scenario-content-block">
|
||||
<p class="scenario-title">{{ scenario.name }}</p>
|
||||
|
||||
<p class="scenario-description">{{ scenario.description }}</p>
|
||||
|
||||
<p class="scenario-label">
|
||||
<n-tag :bordered="false" type="success" class="status-block">Автор: {{
|
||||
scenario.author?.username
|
||||
}}</n-tag>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeaderMenu active="catalog" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scenario-block {
|
||||
position: relative;
|
||||
margin: 10px;
|
||||
width: 160px;
|
||||
height: 300px;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
|
||||
/* background-color: #553333; */
|
||||
background-color: #222;
|
||||
border: 2px solid #222;
|
||||
}
|
||||
|
||||
.scenario-content-block {
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.scenario-block:hover {
|
||||
color: #63e2b7;
|
||||
border: 2px solid #63e2b7;
|
||||
}
|
||||
|
||||
.scenario-image-block {
|
||||
background-color: #111;
|
||||
height: 160px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scenario-image {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.scenario-title {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.scenario-description {
|
||||
color: #aaa;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status-block {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
@media (width >=1024px) {
|
||||
.scenario-block {
|
||||
width: 250px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.scenario-image-block {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.scenario-content-block {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
|
||||
.scenario-description {
|
||||
color: #aaa;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NSpin } from 'naive-ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import HeaderText from '@/components/HeaderText.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'terms' | 'privacy' | 'consent'
|
||||
}>()
|
||||
|
||||
const titles: Record<string, string> = {
|
||||
terms: 'Пользовательское соглашение',
|
||||
privacy: 'Политика конфиденциальности',
|
||||
consent: 'Согласие на обработку персональных данных',
|
||||
}
|
||||
|
||||
const text = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
const client = getAuthClient()
|
||||
const fetchers = {
|
||||
terms: () => client.GetTerms({}),
|
||||
privacy: () => client.GetPrivacy({}),
|
||||
consent: () => client.GetConsent({}),
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchers[props.kind]()
|
||||
if (res.error) {
|
||||
error.value = res.error
|
||||
} else {
|
||||
text.value = res.text || ''
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Не удалось загрузить документ'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width700 doc-page">
|
||||
<h1 class="doc-title">
|
||||
<HeaderText>{{ titles[props.kind] }}</HeaderText>
|
||||
</h1>
|
||||
|
||||
<div v-if="loading" class="doc-loading">
|
||||
<n-spin size="large" />
|
||||
</div>
|
||||
|
||||
<n-alert v-else-if="error" type="error" :show-icon="false" class="doc-error">
|
||||
{{ error }}
|
||||
</n-alert>
|
||||
|
||||
<div v-else class="paper-block">
|
||||
<div class="paper">
|
||||
<p class="doc-text">{{ text }}</p>
|
||||
</div>
|
||||
<div class="paper-shadow paper-shadow-left"></div>
|
||||
<div class="paper-shadow paper-shadow-right"></div>
|
||||
</div>
|
||||
|
||||
<div class="docs-links">
|
||||
Смотрите также:
|
||||
<RouterLink to="/user-agreement" class="docs-link">Пользовательское соглашение</RouterLink>
|
||||
·
|
||||
<RouterLink to="/privacy-policy" class="docs-link">Политика конфиденциальности</RouterLink>
|
||||
·
|
||||
<RouterLink to="/consent" class="docs-link">Согласие на обработку персональных данных</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.doc-page {
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
margin: 0 0 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.doc-loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.doc-error {
|
||||
margin: 0 auto;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.paper-block {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.paper {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 32px 28px;
|
||||
border-radius: 3px;
|
||||
background-image: url('@/assets/images/paper_white.jpg');
|
||||
background-size: cover;
|
||||
color: #1d1d1d;
|
||||
box-shadow: 0 0 12px rgb(0 0 0 / 70%);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.paper::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.doc-text {
|
||||
font-family: 'font_old_typer';
|
||||
font-size: 17px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.paper-shadow {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background-image: url('@/assets/images/paper_white.jpg');
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.paper-shadow-left {
|
||||
left: 0;
|
||||
transform: rotate(-2.2deg);
|
||||
filter: brightness(60%);
|
||||
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.paper-shadow-right {
|
||||
right: 0;
|
||||
transform: rotate(2.2deg);
|
||||
filter: brightness(75%);
|
||||
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
.docs-links {
|
||||
margin-top: 28px;
|
||||
padding-bottom: 20px;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
color: #45aa89;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.docs-link:hover {
|
||||
color: #63e2b7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<script setup lang="ts">
|
||||
import { Footsteps } from '@vicons/ionicons5'
|
||||
import { ContactMailRound } from '@vicons/material'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { type PropType, ref } from 'vue';
|
||||
|
||||
import BeltBlock from './BeltBlock.vue';
|
||||
import HeaderText from './HeaderText.vue';
|
||||
import MetalPlate from './MetalPlate.vue';
|
||||
|
||||
const code = ref('')
|
||||
|
||||
const props = defineProps({
|
||||
addAction: {
|
||||
type: Function as PropType<(param: string) => void>,
|
||||
required: true
|
||||
},
|
||||
allPlacesCount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
newPlacesCount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
gameStatus: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
async function addClickButton() {
|
||||
await props.addAction(code.value)
|
||||
code.value = ''
|
||||
}
|
||||
|
||||
function getTextForCounter(count: number): string[] {
|
||||
count = count % 100
|
||||
const number1 = Math.floor(count / 10) % 10
|
||||
const number2 = count % 10
|
||||
|
||||
let text1 = '–'
|
||||
let text2 = '–'
|
||||
|
||||
if (number1 > 0) {
|
||||
text1 = String(number1)
|
||||
}
|
||||
if (number2 > 0 || number1 > 0) {
|
||||
text2 = String(number2)
|
||||
}
|
||||
|
||||
return [text1, text2]
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="form-custom">
|
||||
<BeltBlock class="input-form">
|
||||
<MetalPlate class="controller-metal controller-metal-left"></MetalPlate>
|
||||
<MetalPlate class="controller-metal controller-metal-right"></MetalPlate>
|
||||
|
||||
<div class="center-block-700">
|
||||
<div class="controller">
|
||||
|
||||
<div class="counters-block">
|
||||
<div class="counter-block">
|
||||
<div class="places-text">
|
||||
<Icon class="icon-for-counter">
|
||||
<Footsteps />
|
||||
</Icon>
|
||||
</div>
|
||||
<div v-for="n in getTextForCounter(allPlacesCount)" class="places-text places-text-number">{{ n }}</div>
|
||||
</div>
|
||||
<div class="counter-block">
|
||||
<div class="places-text">
|
||||
<Icon class="icon-for-counter">
|
||||
<ContactMailRound />
|
||||
</Icon>
|
||||
</div>
|
||||
<div v-for="n in getTextForCounter(newPlacesCount)" class="places-text places-text-number">{{ n }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-input">
|
||||
<input id="run" class="game-input-run" v-model="code" type="text"
|
||||
:placeholder="props.gameStatus == 'run' ? 'Место назначения' : 'Ходы запрещены'"
|
||||
:disabled="props.gameStatus != 'run'">
|
||||
</div>
|
||||
<div class="game-button-run-shadow"></div>
|
||||
<button class="game-button-run" type="submit" :disabled="code.length == 0" v-on:click="addClickButton">
|
||||
<HeaderText v-if="props.gameStatus == 'run'">Поехали</HeaderText>
|
||||
<HeaderText v-if="props.gameStatus == 'pause' || props.gameStatus == 'draft'">Ждём</HeaderText>
|
||||
<HeaderText v-if="props.gameStatus == 'finish'">Всё</HeaderText>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BeltBlock>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-custom {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
color: white;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.input-form {
|
||||
height: 76px;
|
||||
position: relative;
|
||||
z-index: 1000;
|
||||
margin: 0 auto;
|
||||
max-width: 1920px;
|
||||
}
|
||||
|
||||
.controller-metal {
|
||||
width: 30px;
|
||||
height: calc(100% + 2px);
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
.controller-metal-left {
|
||||
left: -30px;
|
||||
}
|
||||
|
||||
.controller-metal-right {
|
||||
right: -30px;
|
||||
}
|
||||
|
||||
.controller {
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.game-input {
|
||||
position: relative;
|
||||
top: 14px;
|
||||
left: 15px;
|
||||
height: 50px;
|
||||
width: calc(100% - 150px - 25px);
|
||||
}
|
||||
|
||||
.game-input-run {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding-left: 27px;
|
||||
background-image: url("@/assets/images/input_center.png");
|
||||
background-size: cover;
|
||||
border: 0;
|
||||
font-size: 18px;
|
||||
font-family: 'font_old_typer';
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.game-input-run::placeholder {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.game-input-run:focus {
|
||||
border: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.game-button-run-shadow {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: -5px;
|
||||
height: 80px;
|
||||
width: 150px;
|
||||
box-shadow: -5px 5px 10px black;
|
||||
}
|
||||
|
||||
.game-button-run {
|
||||
background-image: url("@/assets/images/button.png");
|
||||
background-size: cover;
|
||||
font-size: 1.5em;
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: -5px;
|
||||
height: 80px;
|
||||
width: 155px;
|
||||
border: 0;
|
||||
background-color: transparent;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.game-button-run {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-places-label,
|
||||
.all-places-label {
|
||||
position: absolute;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.new-places-label {
|
||||
top: 14px;
|
||||
left: 85px;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.all-places-label {
|
||||
top: 14px;
|
||||
left: 15px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.places-text {
|
||||
background-color: #d1cebd;
|
||||
border: 1px solid #9e9982;
|
||||
color: #111;
|
||||
font-size: 1.4em;
|
||||
font-family: 'font_old_typer';
|
||||
border-radius: 7px;
|
||||
margin: 0 1px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
background-image: url("@/assets/images/paper.jpg");
|
||||
background-size: cover;
|
||||
box-shadow: 0 -2px 5px black;
|
||||
}
|
||||
|
||||
.places-text-number {
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.counters-block {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
left: 23px;
|
||||
margin: 20px 10px 20px 5px;
|
||||
}
|
||||
|
||||
.counter-block {
|
||||
display: inline-block;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.icon-for-counter {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
@media (width >=1025px) {
|
||||
.center-block-700 {
|
||||
width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,609 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { Flag, Pause, Play, Reset, Settings } from '@vicons/carbon'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { NButton, NTag } from 'naive-ui'
|
||||
import { NCard, NFlex, NInput, NModal, NQrCode, NSpace, NText } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client';
|
||||
import type { Application, Game, Team } from '@/api/generated/crabs/evening_detective_server';
|
||||
import { getURL } from '@/api/generated/crabs/evening_detective_server/client';
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||
|
||||
const client = getAuthClient()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const gameId = route.params.id
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const showSettingsModal = ref(false)
|
||||
const showGiveApplicationModal = ref(false)
|
||||
const showAddTeamModal = ref(false)
|
||||
const showDeleteTeamModal = ref(false)
|
||||
const showDeleteGameModal = ref(false)
|
||||
const newTeamName = ref('')
|
||||
const deletedGameName = ref('')
|
||||
const showQR = ref(false)
|
||||
const urlQR = ref('')
|
||||
const titleQR = ref('')
|
||||
|
||||
const game = ref<Game>({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
startAt: undefined,
|
||||
status: undefined,
|
||||
scenario: undefined,
|
||||
teams: undefined,
|
||||
})
|
||||
|
||||
async function getGame(id: number) {
|
||||
const res = await client.GetGame({ id: id })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
game.value = res.game!
|
||||
game.value.teams = game.value.teams?.sort((a, b) => {
|
||||
let aLen = 0
|
||||
if ((a.applications?.length || 0) > 0) {
|
||||
aLen = 1
|
||||
}
|
||||
let bLen = 0
|
||||
if ((b.applications?.length || 0) > 0) {
|
||||
bLen = 1
|
||||
}
|
||||
if (aLen > bLen) {
|
||||
return -1
|
||||
}
|
||||
if (aLen < bLen) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const aName = a.name || ''
|
||||
const bName = b.name || ''
|
||||
if (aName > bName) {
|
||||
return 1
|
||||
}
|
||||
if (aName < bName) {
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
getGame(Number(gameId))
|
||||
|
||||
async function toTeamStory(id: number, password: string, newTab: boolean = false) {
|
||||
const uri = '/team-story/' + id + '?password=' + password
|
||||
if (newTab) {
|
||||
window.open(uri, '_blank');
|
||||
return
|
||||
}
|
||||
router.push(uri)
|
||||
}
|
||||
|
||||
async function updateGame() {
|
||||
const res = await client.UpdateGame({
|
||||
id: game.value.id,
|
||||
name: game.value.name,
|
||||
description: game.value.description,
|
||||
startAt: game.value.startAt,
|
||||
scenarioId: game.value.scenario?.id,
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
const giveApplicationTeam = ref<Team>({
|
||||
id: 0,
|
||||
name: '',
|
||||
status: '',
|
||||
password: '',
|
||||
actionsCount: 0,
|
||||
applications: []
|
||||
})
|
||||
|
||||
const deletedTeamName = ref('')
|
||||
const deletedTeam = ref<Team>({
|
||||
id: 0,
|
||||
name: '',
|
||||
status: '',
|
||||
password: '',
|
||||
actionsCount: 0,
|
||||
applications: []
|
||||
})
|
||||
|
||||
const giveApplicationApplication = ref<Application>({
|
||||
name: '',
|
||||
image: ''
|
||||
})
|
||||
|
||||
const url = window.location.href
|
||||
|
||||
function giveApplication(team: Team, application: Application) {
|
||||
giveApplicationTeam.value = team
|
||||
giveApplicationApplication.value = application
|
||||
showGiveApplicationModal.value = true
|
||||
}
|
||||
|
||||
async function addTeam() {
|
||||
const res = await client.AddTeam({
|
||||
gameId: game.value.id,
|
||||
name: newTeamName.value,
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
showAddTeamModal.value = false
|
||||
}
|
||||
|
||||
async function confirmGiveApplication() {
|
||||
const res = await client.GiveTeamApplications({
|
||||
id: giveApplicationTeam.value.id,
|
||||
name: giveApplicationApplication.value.name,
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
showGiveApplicationModal.value = false
|
||||
}
|
||||
|
||||
async function deleteTeam(team: Team) {
|
||||
deletedTeam.value = team
|
||||
showDeleteTeamModal.value = true
|
||||
}
|
||||
|
||||
async function confirmDeleteTeam() {
|
||||
const res = await client.DeleteTeam({
|
||||
id: deletedTeam.value.id,
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
showDeleteTeamModal.value = false
|
||||
}
|
||||
|
||||
async function deleteGame() {
|
||||
const res = await client.DeleteGame({
|
||||
id: game.value.id,
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
message.success('Игра удалена')
|
||||
router.push('/games')
|
||||
}
|
||||
|
||||
function getTeamStoryURL(id: number, password: string): string {
|
||||
return getURL() + '/team-story/' + id + '?password=' + password
|
||||
}
|
||||
|
||||
async function openQR(url: string, text: string) {
|
||||
urlQR.value = url
|
||||
titleQR.value = text
|
||||
showQR.value = true
|
||||
}
|
||||
|
||||
useSimplePolling(() => {
|
||||
getGame(Number(gameId))
|
||||
}, 2000);
|
||||
|
||||
async function resetGame() {
|
||||
const res = await client.ResetGame({
|
||||
id: game.value.id
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
const res = await client.StartGame({
|
||||
id: game.value.id
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
}
|
||||
|
||||
async function pauseGame() {
|
||||
const res = await client.PauseGame({
|
||||
id: game.value.id
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
}
|
||||
|
||||
async function playGame() {
|
||||
const res = await client.PlayGame({
|
||||
id: game.value.id
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
}
|
||||
|
||||
async function finishGame() {
|
||||
const res = await client.FinishGame({
|
||||
id: game.value.id
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getGame(game.value.id!)
|
||||
}
|
||||
|
||||
function parseDateWithoutTimezone(dateStr: string): Date {
|
||||
// Разбираем ISO строку: "2024-01-01T12:30:00"
|
||||
const [datePart, timePart] = dateStr.split('T');
|
||||
const [year, month, day] = datePart.split('-').map(Number);
|
||||
|
||||
const correctTimePart = timePart.split('.')[0]
|
||||
const [hours, minutes, seconds] = (correctTimePart || '00:00:00').split(':').map(Number);
|
||||
|
||||
// Создаем дату в UTC, чтобы избежать сдвига
|
||||
return new Date(Date.UTC(year, month - 1, day, hours, minutes, seconds || 0));
|
||||
}
|
||||
|
||||
function getCurrentUTC(): Date {
|
||||
const now = new Date();
|
||||
return new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
}
|
||||
|
||||
function getTimeDiff(startStr: string, endStr: string): string {
|
||||
if (startStr == '') {
|
||||
return 'Игра ещё не началась'
|
||||
}
|
||||
const start = parseDateWithoutTimezone(startStr)
|
||||
let endTime = getCurrentUTC();
|
||||
if (endStr != '') {
|
||||
endTime = parseDateWithoutTimezone(endStr);
|
||||
}
|
||||
|
||||
const diffMs = endTime.getTime() - start.getTime();
|
||||
|
||||
if (diffMs < 0) return 'Игра ещё не началась';
|
||||
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
const months = Math.floor(days / 30);
|
||||
const years = Math.floor(days / 365);
|
||||
|
||||
if (years > 0) return `${years}г ${days % 365}д`;
|
||||
if (months > 0) return `${months}мес ${days % 30}д`;
|
||||
if (days > 0) return `${days}д ${hours % 24}ч`;
|
||||
if (hours > 0) return `${hours}ч ${minutes % 60}м`;
|
||||
if (minutes > 0) return `${minutes}м ${seconds % 60}с`;
|
||||
return `${seconds}с`;
|
||||
}
|
||||
|
||||
const timeString = ref('')
|
||||
useSimplePolling(() => {
|
||||
timeString.value = getTimeDiff(game.value.startedAt || '', game.value.endedAt || '')
|
||||
}, 1000);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width1200">
|
||||
|
||||
<div class="settings-block">
|
||||
<n-flex justify="space-between">
|
||||
<n-space>
|
||||
<n-tag :bordered="false" type="info">
|
||||
Команд: {{ game.teams?.length }}
|
||||
</n-tag>
|
||||
<n-tag type="info">
|
||||
{{ timeString }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
|
||||
<div>
|
||||
<n-button quaternary class="settings-button" @click="openQR(url, 'Админка')">
|
||||
QR
|
||||
</n-button>
|
||||
<n-button quaternary @click="showSettingsModal = true" class="settings-button">
|
||||
<Icon class="settings-icon">
|
||||
<Settings />
|
||||
</Icon>
|
||||
Настройки
|
||||
</n-button>
|
||||
<n-button v-if="(game.teams?.length || 0) == 0" type="error" quaternary
|
||||
@click="showDeleteGameModal = true" class="settings-button">
|
||||
Удалить игру
|
||||
</n-button>
|
||||
|
||||
<n-space>
|
||||
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
|
||||
<Icon>
|
||||
<Reset />
|
||||
</Icon>
|
||||
</n-button>
|
||||
<n-button type="info" v-if="game.status == 'draft'" dashed @click="startGame()">
|
||||
<Icon>
|
||||
<Play />
|
||||
</Icon>
|
||||
</n-button>
|
||||
<n-button type="info" v-if="game.status == 'pause'" dashed @click="playGame()">
|
||||
<Icon>
|
||||
<Play />
|
||||
</Icon>
|
||||
</n-button>
|
||||
<n-button type="info" v-if="game.status == 'run'" dashed @click="pauseGame()">
|
||||
<Icon>
|
||||
<Pause />
|
||||
</Icon>
|
||||
</n-button>
|
||||
<n-button type="info" v-if="game.status != 'finish'" dashed @click="finishGame()">
|
||||
<Icon>
|
||||
<Flag />
|
||||
</Icon>
|
||||
</n-button>
|
||||
</n-space>
|
||||
</div>
|
||||
</n-flex>
|
||||
</div>
|
||||
|
||||
<div class="team-block team-block-hover" @click="showAddTeamModal = true">
|
||||
<p class="place-image-plus">+</p>
|
||||
</div>
|
||||
|
||||
<div v-for="team in game.teams" class="team-block">
|
||||
<div class="team-content-block">
|
||||
|
||||
<n-flex justify="space-between">
|
||||
<div class="title-block">
|
||||
<span class="team-name-block">
|
||||
{{ team.name }}
|
||||
</span>
|
||||
<div>
|
||||
Поездки: {{ team.actionsCount }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-flex justify="end">
|
||||
<n-button @click="openQR(getTeamStoryURL(team.id!, team.password!), 'Подсмотреть игру')">
|
||||
QR
|
||||
</n-button>
|
||||
<n-button @click="toTeamStory(team.id!, team.password!, true)">
|
||||
Подсмотреть игру
|
||||
</n-button>
|
||||
<n-button type="error" ghost @click="deleteTeam(team)">
|
||||
Удалить
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
</n-flex>
|
||||
|
||||
<n-button v-for="application in team.applications" :key="application.name" class="link-button"
|
||||
@click="giveApplication(team, application)">
|
||||
Выдать: {{ application.name }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Окно настроек -->
|
||||
<n-modal v-model:show="showSettingsModal">
|
||||
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="showSettingsModal = false"> x </n-button>
|
||||
</template>
|
||||
<n-space vertical>
|
||||
<p>Название</p>
|
||||
<n-input v-model:value="game.name" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||
|
||||
<p class="settings-header">Описание</p>
|
||||
<n-input v-model:value="game.description" type="textarea" placeholder="Убийство пастуха..." />
|
||||
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="updateGame()" ghost> Сохранить изменения </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<!-- Окно выдачи улик -->
|
||||
<n-modal v-model:show="showGiveApplicationModal">
|
||||
<n-card style="width: 600px" title="Настройки игры" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="showGiveApplicationModal = false"> x </n-button>
|
||||
</template>
|
||||
<n-space vertical>
|
||||
<p>Команда</p>
|
||||
<h3>{{ giveApplicationTeam.name }}</h3>
|
||||
|
||||
|
||||
<p>Улика</p>
|
||||
<h3>{{ giveApplicationApplication.name }}</h3>
|
||||
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="confirmGiveApplication()" ghost> Выдать </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<!-- Окно создания команды -->
|
||||
<n-modal v-model:show="showAddTeamModal">
|
||||
<n-card style="width: 600px" title="Создание команды" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="showAddTeamModal = false"> x </n-button>
|
||||
</template>
|
||||
<n-input v-model:value="newTeamName" type="text" placeholder='Три дебила' />
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="addTeam()" :disabled="newTeamName.length == 0">
|
||||
Создать
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<!-- Окно создания команды -->
|
||||
<n-modal v-model:show="showDeleteTeamModal">
|
||||
<n-card style="width: 600px" title="Удаление команды" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="showDeleteTeamModal = false"> x </n-button>
|
||||
</template>
|
||||
<n-space vertical>
|
||||
<p>
|
||||
Введите название команды
|
||||
<n-text class="name-text" strong>{{ deletedTeam.name }}</n-text> чтобы удалить его
|
||||
</p>
|
||||
<n-input v-model:value="deletedTeamName" type="text" placeholder='Три дебила' />
|
||||
</n-space>
|
||||
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="confirmDeleteTeam()" :disabled="deletedTeamName != deletedTeam.name">
|
||||
Удалить
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<!-- Окно удаления игры -->
|
||||
<n-modal v-model:show="showDeleteGameModal">
|
||||
<n-card style="width: 600px" title="Удаление игры" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="showDeleteGameModal = false"> x </n-button>
|
||||
</template>
|
||||
<n-space vertical>
|
||||
<p>
|
||||
Введите название игры
|
||||
<n-text class="name-text" strong>{{ game.name }}</n-text> чтобы удалить её
|
||||
</p>
|
||||
<n-input v-model:value="deletedGameName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||
</n-space>
|
||||
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="deleteGame()" :disabled="deletedGameName != game.name">
|
||||
Удалить
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<!-- Окно qr -->
|
||||
<n-modal v-model:show="showQR">
|
||||
<n-card style="width: 600px" :title="titleQR" :bordered="false" size="huge" role="dialog" aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="showQR = false"> x </n-button>
|
||||
</template>
|
||||
<n-space vertical>
|
||||
<div class="qr-block">
|
||||
<n-qr-code :value="urlQR" :size="300" :padding="0" />
|
||||
</div>
|
||||
</n-space>
|
||||
|
||||
<template #footer></template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<HeaderMenu active="games" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.team-block {
|
||||
margin: 10px 0;
|
||||
border-radius: 10px;
|
||||
background-color: #222;
|
||||
border: 1px solid #222;
|
||||
}
|
||||
|
||||
.team-block-hover:hover {
|
||||
color: #63e2b7;
|
||||
cursor: pointer;
|
||||
border: 1px solid #63e2b7;
|
||||
}
|
||||
|
||||
.team-content-block {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.team-name-block {
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.settings-block {}
|
||||
|
||||
.settings-button {
|
||||
float: right;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.title-block {}
|
||||
|
||||
.link-button {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.place-image-plus {
|
||||
font-size: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
background-color: rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.qr-block {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client';
|
||||
import type { Game } from '@/api/generated/crabs/evening_detective_server';
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
|
||||
const client = getAuthClient()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
|
||||
const games = ref<Game[]>([])
|
||||
|
||||
async function getGames() {
|
||||
games.value = []
|
||||
const res = await client.GetGames({})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
games.value = res.games || []
|
||||
}
|
||||
getGames()
|
||||
|
||||
async function toGame(id: number) {
|
||||
router.push('/games/' + id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width1200">
|
||||
<div v-for="game in games" :key="game.id" class="game-block" @click="toGame(game.id!)">
|
||||
<div class="game-title">
|
||||
{{ game.name }}
|
||||
</div>
|
||||
<div class="game-description">
|
||||
{{ game.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeaderMenu active="games" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-block {
|
||||
margin: 10px 0;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.game-block:hover {
|
||||
cursor: pointer;
|
||||
color: #63e2b7;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.game-description {
|
||||
color: #aaa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
active: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
})
|
||||
|
||||
import { MenuOutlined } from '@vicons/material'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
authStore.initFromStorage()
|
||||
|
||||
const router = useRouter()
|
||||
const client = getAuthClient()
|
||||
|
||||
const permissions = ref<string[]>([])
|
||||
|
||||
async function getPermissions() {
|
||||
permissions.value = []
|
||||
const res = await client.GetPermissions({})
|
||||
permissions.value = res.permissions!
|
||||
}
|
||||
getPermissions()
|
||||
|
||||
function hasPermission(permission: string): boolean {
|
||||
return permissions.value.includes(permission)
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const goToLogout = () => {
|
||||
authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const openMenu = ref(false)
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu-block">
|
||||
<div @click="openMenu = !openMenu" class="burger-button">
|
||||
<Icon class="burger-icon" size="28">
|
||||
<MenuOutlined />
|
||||
</Icon>
|
||||
</div>
|
||||
<div class="left-group menu-buttons-block" :class="!openMenu ? 'menu-buttons-block-active' : ''">
|
||||
<div class="menu-item-block" :class="{ active: props.active == 'main' }" @click="router.push('/')">
|
||||
Главная
|
||||
</div>
|
||||
<div class="menu-item-block" :class="{ active: props.active == 'office' }" v-if="authStore.hasRole('user')"
|
||||
@click="router.push('/office')">
|
||||
Кабинет
|
||||
</div>
|
||||
<div class="menu-item-block" :class="{ active: props.active == 'games' }" v-if="hasPermission('games_page')"
|
||||
@click="router.push('/games')">
|
||||
Игры
|
||||
</div>
|
||||
<div class="menu-item-block" :class="{ active: props.active == 'catalog' }" @click="router.push('/catalog')">
|
||||
Каталог
|
||||
</div>
|
||||
<div class="menu-item-block" :class="{ active: props.active == 'scenarios' }" v-if="hasPermission('scenarios_page')" @click="router.push('/scenarios')">
|
||||
Мои сценарии
|
||||
</div>
|
||||
<div class="menu-item-block" :class="{ active: props.active == 'users' }" v-if="hasPermission('users_page')"
|
||||
@click="router.push('/users')">
|
||||
Пользователи
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="right-group">
|
||||
<div v-if="!authStore.isAuthenticated" class="menu-item-block right" @click="goToLogin">
|
||||
Войти
|
||||
</div>
|
||||
<div v-if="authStore.isAuthenticated" class="menu-item-block right" @click="goToLogout">Выйти</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-block {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
background-color: #222;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-item-block {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.menu-item-block:hover {
|
||||
color: #63e2b7;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.right {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: #63e2b7;
|
||||
}
|
||||
|
||||
@media (width >=1024px) {
|
||||
.burger-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width < 1024px) {
|
||||
.menu-buttons-block {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #222;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.menu-buttons-block div {
|
||||
padding: 15px 5px;
|
||||
}
|
||||
|
||||
.menu-buttons-block-active {
|
||||
position: relative;
|
||||
top: -1000px;
|
||||
}
|
||||
|
||||
.burger-icon {
|
||||
position: relative;
|
||||
top: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
/* код */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="text-with-font">
|
||||
<slot></slot>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.text-with-font {
|
||||
font-family: 'font_old_typer';
|
||||
color: #bfa07d;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
line-height: 20px;
|
||||
font-size: 22px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,41 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
msg: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>. What's next?
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NAutoComplete,
|
||||
NButton,
|
||||
NCard,
|
||||
NCheckbox,
|
||||
NInput,
|
||||
NSpace,
|
||||
NTabPane,
|
||||
NTabs,
|
||||
useMessage,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const acceptTerms = ref(false)
|
||||
const acceptPrivacy = ref(false)
|
||||
|
||||
const options = computed(() => {
|
||||
return ['@mail.ru', '@yandex.ru', '@gmail.com'].map((suffix) => {
|
||||
const prefix = email.value.split('@')[0]
|
||||
return {
|
||||
label: prefix + suffix,
|
||||
value: prefix + suffix,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
async function signin() {
|
||||
const res = await authStore.login(email.value, password.value)
|
||||
|
||||
if (!res && authStore.error != null) {
|
||||
message.error(authStore.error)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/office')
|
||||
}
|
||||
|
||||
async function signup() {
|
||||
if (!acceptTerms.value || !acceptPrivacy.value) {
|
||||
return
|
||||
}
|
||||
const res = await authStore.signup(
|
||||
username.value,
|
||||
email.value,
|
||||
acceptTerms.value,
|
||||
acceptPrivacy.value,
|
||||
)
|
||||
if (!res && authStore.error != null) {
|
||||
message.error(authStore.error)
|
||||
return
|
||||
}
|
||||
message.info('Пароль отправлен на почту')
|
||||
acceptTerms.value = false
|
||||
acceptPrivacy.value = false
|
||||
}
|
||||
|
||||
async function sendNewPassword() {
|
||||
const res = await authStore.refreshPassword(email.value)
|
||||
if (!res && authStore.error != null) {
|
||||
message.error(authStore.error)
|
||||
return
|
||||
}
|
||||
message.info('Пароль отправлен на почту')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom content-block center-middle-block-custom height80">
|
||||
<n-card style="margin-bottom: 16px" class="sign-card">
|
||||
<n-tabs type="line" animated>
|
||||
<n-tab-pane name="signin" tab="Вход">
|
||||
<n-space vertical>
|
||||
<div class="form-label">Почта</div>
|
||||
<n-auto-complete v-model:value="email" :input-props="{
|
||||
autocomplete: 'disabled',
|
||||
}" :options="options" placeholder="detective@mail.ru" clearable />
|
||||
<div class="form-label">Пароль</div>
|
||||
<n-input v-model:value="password" type="password" placeholder="********" />
|
||||
<div class="form-button-wrapper">
|
||||
<div class="form-label">
|
||||
<n-button @click="signin" :disabled="email.length == 0 || password.length == 0">
|
||||
<span v-if="!authStore.isLoading"> Вход </span>
|
||||
<span v-else> Подождите... </span>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="signup" tab="Регистрация">
|
||||
<n-space vertical>
|
||||
<div class="form-label">Позывной</div>
|
||||
<n-input v-model:value="username" type="text" placeholder="Шерлок" />
|
||||
<div class="form-label">Почта</div>
|
||||
<n-auto-complete v-model:value="email" :input-props="{
|
||||
autocomplete: 'disabled',
|
||||
}" :options="options" placeholder="detective@mail.ru" clearable />
|
||||
<div class="form-label">
|
||||
<n-checkbox v-model:checked="acceptTerms">
|
||||
Я принимаю условия
|
||||
<a href="/user-agreement" target="_blank" class="docs-link">пользовательского соглашения</a>
|
||||
</n-checkbox>
|
||||
</div>
|
||||
<div class="form-label">
|
||||
<n-checkbox v-model:checked="acceptPrivacy">
|
||||
Я даю согласие на обработку
|
||||
<a href="/privacy-policy" target="_blank" class="docs-link">персональных данных</a>
|
||||
</n-checkbox>
|
||||
</div>
|
||||
<div class="form-button-wrapper">
|
||||
<div class="form-label">
|
||||
<n-button
|
||||
@click="signup"
|
||||
:disabled="username.length == 0 || email.length == 0 || !acceptTerms || !acceptPrivacy"
|
||||
>
|
||||
<span v-if="!authStore.isLoading"> Регистрация </span>
|
||||
<span v-else> Подождите... </span>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="new-password" tab="Новый пароль">
|
||||
<n-space vertical>
|
||||
<div class="form-label">Почта</div>
|
||||
<n-auto-complete v-model:value="email" :input-props="{
|
||||
autocomplete: 'disabled',
|
||||
}" :options="options" placeholder="detective@mail.ru" clearable />
|
||||
<div class="form-button-wrapper">
|
||||
<div class="form-label">
|
||||
<n-button @click="sendNewPassword" :disabled="email.length == 0">
|
||||
<span v-if="!authStore.isLoading"> Прислать новый пароль </span>
|
||||
<span v-else> Подождите... </span>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-space>
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sign-card {
|
||||
background-color: rgb(29 29 29);
|
||||
}
|
||||
|
||||
.form-label {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.form-button-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
color: #45aa89;
|
||||
}
|
||||
|
||||
.docs-link:hover {
|
||||
color: #63e2b7;
|
||||
}
|
||||
|
||||
@media (width >=1024px) {
|
||||
.sign-card {
|
||||
width: 380px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
/* код */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main">
|
||||
<div class="message-cloud-3"></div>
|
||||
<div class="message-cloud-2"></div>
|
||||
<div class="message-cloud">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-cloud, .message-cloud-2, .message-cloud-3 {
|
||||
background-color: bisque;
|
||||
}
|
||||
|
||||
.message-cloud {
|
||||
margin: 15px 0;
|
||||
padding: 16px;
|
||||
font-family: main;
|
||||
color: black;
|
||||
line-height: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-cloud,
|
||||
.message-cloud-2,
|
||||
.message-cloud-3 {
|
||||
border-radius: 5px;
|
||||
background-image: url("@/assets/images/paper.jpg");
|
||||
background-size: cover;
|
||||
display: flow-root;
|
||||
box-shadow: 0 0 5px black;
|
||||
}
|
||||
|
||||
.message-cloud-2,
|
||||
.message-cloud-3 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.message-cloud-2 {
|
||||
transform: rotate(-3deg);
|
||||
filter: brightness(50%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-cloud-3 {
|
||||
transform: rotate(2deg);
|
||||
filter: brightness(80%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
/* код */
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main">
|
||||
<div class="message-cloud-3"></div>
|
||||
<div class="message-cloud-2"></div>
|
||||
<div class="message-cloud">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-cloud, .message-cloud-2, .message-cloud-3 {
|
||||
background-color: bisque;
|
||||
}
|
||||
|
||||
.message-cloud {
|
||||
margin: 15px 0;
|
||||
padding: 16px;
|
||||
font-family: main;
|
||||
color: black;
|
||||
line-height: 20px;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-cloud,
|
||||
.message-cloud-2,
|
||||
.message-cloud-3 {
|
||||
border-radius: 5px;
|
||||
background-image: url("@/assets/images/paper.jpg");
|
||||
background-size: cover;
|
||||
display: flow-root;
|
||||
box-shadow: 0 0 5px black;
|
||||
}
|
||||
|
||||
.message-cloud-2,
|
||||
.message-cloud-3 {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.message-cloud-2 {
|
||||
transform: rotate(-3deg);
|
||||
filter: brightness(50%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-cloud-3 {
|
||||
transform: rotate(2deg);
|
||||
filter: brightness(80%);
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plate-block">
|
||||
<div class="metal-plate-block">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="pin pin-top-left"></div>
|
||||
<div class="pin pin-top-right"></div>
|
||||
<div class="pin pin-bottom-right"></div>
|
||||
<div class="pin pin-bottom-left"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plate-block {
|
||||
position: relative;
|
||||
box-shadow: 0 0 10px black;
|
||||
}
|
||||
|
||||
.metal-plate-block {
|
||||
height: 100%;
|
||||
background-image: url("@/assets/images/metal.png");
|
||||
background-size: cover;
|
||||
position: relative;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pin {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
position: absolute;
|
||||
background-image: url("@/assets/images/pin.png");
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.pin-top-left {
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
.pin-top-right {
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
}
|
||||
|
||||
.pin-bottom-right {
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
}
|
||||
|
||||
.pin-bottom-left {
|
||||
bottom: 3px;
|
||||
left: 3px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<script setup lang="ts">
|
||||
import { NAlert, NButton, NInput, NModal, useMessage } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const client = getAuthClient()
|
||||
|
||||
const permissions = ref<string[]>([])
|
||||
|
||||
async function getPermissions() {
|
||||
permissions.value = []
|
||||
const res = await client.GetPermissions({})
|
||||
permissions.value = res.permissions!
|
||||
}
|
||||
|
||||
getPermissions()
|
||||
|
||||
type Tip = {
|
||||
header: string
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Функция возвращает случайный детективный совет
|
||||
* с шутками, зависящими от даты
|
||||
*/
|
||||
function getDetectiveTip(): Tip {
|
||||
// ----- 1. Получаем текущую дату и определяем "настроение" -----
|
||||
const now = new Date();
|
||||
const dayOfWeek = now.getDay(); // 0 - Воскресенье, 1 - Понедельник, ...
|
||||
const dayOfMonth = now.getDate();
|
||||
const month = now.getMonth() + 1; // Январь = 1, Декабрь = 12
|
||||
|
||||
const isMonday = dayOfWeek === 1;
|
||||
const isFriday13 = dayOfWeek === 5 && dayOfMonth === 13;
|
||||
const isNewYear = (month === 1 && dayOfMonth === 1) || (month === 12 && dayOfMonth === 31);
|
||||
const isSummer = month >= 6 && month <= 8;
|
||||
const isWinter = month === 12 || month <= 2;
|
||||
const isFullMoonJoke = dayOfMonth % 5 === 0; // Просто для разнообразия
|
||||
|
||||
// ----- 2. База из 100 советов -----
|
||||
const tips: string[] = [
|
||||
// 1-10
|
||||
"Всегда носи с собой запасную ручку — признания любят записывать в самый неподходящий момент.",
|
||||
"Если подозреваемый слишком вежлив, он либо дипломат, либо прячет труп в гараже.",
|
||||
"Кофе в участке — это не напиток, это яд для улик. Пей только свой.",
|
||||
"Следы на месте преступления всегда ведут к тому, кто забыл закрыть дверь.",
|
||||
"Лучший способ разговорить молчуна — предложить ему чай с тремя ложками сахара.",
|
||||
"Если всё указывает на дворецкого, ищи того, кто нанимал дворецкого.",
|
||||
"Записывай каждую мелочь. Особенно цвет носков убитого.",
|
||||
"Не верь алиби, которое начинается со слов 'Я бы никогда...'",
|
||||
"Преступник всегда возвращается на место преступления. Или за забытым телефоном.",
|
||||
"Ищи того, кому есть что терять, кроме своего времени.",
|
||||
|
||||
// 11-20
|
||||
"Если женщина плачет в кабинете — дай ей платок. Если мужчина — дай ему стакан воды.",
|
||||
"Самое опасное оружие — не пистолет, а бухгалтерская книга.",
|
||||
"Собака всегда знает убийцу. Но она не говорит, потому что у неё контракт.",
|
||||
"Проверяй чеки, билеты и смс. Ложь не терпит документации.",
|
||||
"Если у тела нет врагов — ты плохо искал врагов.",
|
||||
"Лупа нужна не для увеличения улик, а для того, чтобы выглядеть умнее.",
|
||||
"Интуиция — это когда ты уже знаешь ответ, но хочешь убедиться, что не ошибся.",
|
||||
"Подозревай всех, даже самого себя. Иногда преступник — это ты в прошлом.",
|
||||
"Хороший детектив видит улики. Великий — видит их отсутствие.",
|
||||
"Никогда не допрашивай свидетеля на голодный желудок. И себя тоже.",
|
||||
|
||||
// 21-30
|
||||
"Если дело зашло в тупик, переверни бумаги вверх ногами.",
|
||||
"Самый ценный свидетель — это камера наблюдения, у которой сломана защита.",
|
||||
"Не ищи мотив. Ищи возможность.",
|
||||
"Преступник не оставляет следов. Он оставляет выбор.",
|
||||
"Если дверь была закрыта изнутри — ищи люк, который открывается снаружи.",
|
||||
"Лучший детектив — тот, кого не замечают в толпе.",
|
||||
"Учись читать по губам. Пригодится на допросах с адвокатом.",
|
||||
"Запах духов на месте преступления важнее запаха пороха.",
|
||||
"Если ты нашел отпечаток пальца — проверь, не твой ли он.",
|
||||
"Раскрыть дело — это искусство. Не раскрыть — тоже искусство, но другое.",
|
||||
|
||||
// 31-40
|
||||
"Никогда не спорь с баллистикой. Она всегда права, даже если ты не согласен.",
|
||||
"В каждом деле есть одна странная деталь. Найди её — и пазл сложится.",
|
||||
"Если преступник умный — он оставит ложный след. Если глупый — он оставит следы в соцсетях.",
|
||||
"Лучший друг детектива — тишина. Второй друг — кофе.",
|
||||
"Если свидетель говорит слишком много, он врет. Если молчит — он тоже врет.",
|
||||
"Преступление редко бывает идеальным. Идеальным бывает только алиби жены.",
|
||||
"Всегда смотри на часы на трупе. Возможно, они показывают время, когда он перестал верить в людей.",
|
||||
"Воры любят темноту. Детективы — свет. А маньяки — полную луну.",
|
||||
"Если ты поймал преступника, не радуйся раньше времени — может, он просто лжец.",
|
||||
"В каждом городе есть одно место, где собираются все сплетники. Начни оттуда.",
|
||||
|
||||
// 41-50
|
||||
"Не верь в совпадения. Но верь в удачу.",
|
||||
"Если у тебя нет улик — придумай их. Но только мысленно, чтобы знать, где искать.",
|
||||
"Жертва всегда знала убийцу. Даже если она не знала, что знает.",
|
||||
"Проверяй алиби на разрыв. Если оно выдерживает — ты ошибся.",
|
||||
"Самый страшный враг детектива — не маньяк, а его собственная лень.",
|
||||
"Если преступник оставил записку — прочитай её. И сожги, чтобы никто не узнал твой почерк.",
|
||||
"Всегда носи с собой фонарик. Даже днём. Для эффекта.",
|
||||
"В деле важно всё: от укуса комара до размера обуви.",
|
||||
"Если ты думаешь, что дело простое — значит, ты не додумал.",
|
||||
"Убийца всегда ищет внимания. Или наоборот, избегает его.",
|
||||
|
||||
// 51-60
|
||||
"Стук в дверь в полночь — это либо полиция, либо заказчик.",
|
||||
"Никогда не давай обещаний родственникам жертвы. Только факты.",
|
||||
"Если у тебя есть три версии — выбирай четвёртую.",
|
||||
"Лучшее оружие — это бумага. На ней можно написать что угодно.",
|
||||
"Когда ты смотришь на улику, смотри на неё глазами преступника.",
|
||||
"Все любят детективы, пока не становятся подозреваемыми.",
|
||||
"Следы крови важны. Но важнее — следы лжи.",
|
||||
"Если ты не спишь третьи сутки, значит, ты либо гений, либо дурак.",
|
||||
"Проверяй каждого, кто говорит 'Я ничего не видел'.",
|
||||
"В каждом деле есть один человек, который знает всё. И он не говорит.",
|
||||
|
||||
// 61-70
|
||||
"Не суди преступника по одежде. Суди по тому, как он её снимал.",
|
||||
"Дыра в алиби — это как дыра в лодке. Рано или поздно она утонет.",
|
||||
"Если преступник использует перчатки — он профессионал. Если нет — он любитель.",
|
||||
"Запомни: первая версия — всегда неверная.",
|
||||
"Если у тебя кончились идеи, иди в бар. Там всегда есть кто-то, кто расскажет тебе дело.",
|
||||
"Свидетель с фотороботом — это как слепой с компасом.",
|
||||
"Никогда не смейся над уликами. Они могут обидеться и исчезнуть.",
|
||||
"Если ты нашёл оружие — не трогай его. Иначе твои отпечатки будут на нём.",
|
||||
"Преступник умён, но ты умнее. Потому что ты пьёшь правильный кофе.",
|
||||
"Всегда пересматривай видеозаписи. Особенно в два раза медленнее.",
|
||||
|
||||
// 71-80
|
||||
"Если на месте преступления есть кот — он видел всё. Но он не скажет.",
|
||||
"Лучший способ поймать преступника — пригласить его на обед.",
|
||||
"Если подозреваемый улыбается, он либо невиновен, либо психопат.",
|
||||
"Проверяй мусор. В нём всегда есть что-то полезное.",
|
||||
"Никогда не доверяй красивым историям. Только уродливым фактам.",
|
||||
"Если ты нашёл улику слишком легко — это ловушка.",
|
||||
"Убийца всегда оставляет послание. Даже если это просто тишина.",
|
||||
"Если ты запнулся на допросе — делай вид, что так и задумано.",
|
||||
"Помни: все врут. Даже я тебе советую.",
|
||||
"Если ты боишься темноты — стань детективом. Страх пройдёт.",
|
||||
|
||||
// 81-90
|
||||
"Самые лучшие признания случаются под утро, когда все устали.",
|
||||
"Если преступник использовал компьютер, значит, он либо хакер, либо дурак.",
|
||||
"Следы шин — это как подпись на дороге.",
|
||||
"Никогда не забывай про запах. Он помнит дольше, чем люди.",
|
||||
"Если ты не знаешь, что делать — съешь шоколадку.",
|
||||
"В каждом деле есть одна минута слабости. Найди её.",
|
||||
"Преступник всегда нервничает. Даже если он не нервничает.",
|
||||
"Если у тебя есть подозреваемый и нет улик — подожди. Улики появятся.",
|
||||
"Не верь в чудеса. Верь в логику.",
|
||||
"Если ты устал — возьми отпуск. Преступление никуда не денется.",
|
||||
|
||||
// 91-100
|
||||
"Запомни: самый опасный преступник — тот, кто читал детективы.",
|
||||
"Если ты думаешь, что всё кончено — значит, ты пропустил поворот.",
|
||||
"Следы на полу важнее следов в душе.",
|
||||
"Если преступник оставил окурок — он курил. Или хотел, чтобы ты так думал.",
|
||||
"Хороший детектив всегда голоден. Это помогает мыслить.",
|
||||
"Не суди по первому впечатлению. Суди по второму, когда увидишь его в наручниках.",
|
||||
"Если у тебя есть тайный агент — не доверяй ему.",
|
||||
"В деле всегда есть тот, кто знает больше, чем говорит.",
|
||||
"Если ты не можешь найти убийцу — стань им (в мыслях).",
|
||||
"И последнее: всегда проверяй, закрыл ли ты дверь участка."
|
||||
];
|
||||
|
||||
// ----- 3. Шутки, зависящие от даты -----
|
||||
const dateJokes: string[] = [];
|
||||
|
||||
if (isMonday) {
|
||||
dateJokes.push("☕ Понедельник: Сегодня даже преступники не хотят работать. Проверь тех, кто был слишком бодр.");
|
||||
}
|
||||
if (isFriday13) {
|
||||
dateJokes.push("🔮 Пятница 13-е: Если улики ведут к чёрной кошке, ищи чёрного кота с алиби.");
|
||||
}
|
||||
if (isNewYear) {
|
||||
dateJokes.push("🎄 Новый год: Свидетель в костюме Деда Мороза врёт. Настоящий Дед Мороз всегда в отпуске.");
|
||||
}
|
||||
if (isSummer) {
|
||||
dateJokes.push("☀️ Лето: В жару преступники забывают улики в карманах, потому что снимают пиджаки.");
|
||||
}
|
||||
if (isWinter) {
|
||||
dateJokes.push("❄️ Зима: Следы на снегу ведут к тому, у кого самые холодные руки.");
|
||||
}
|
||||
if (isFullMoonJoke) {
|
||||
dateJokes.push("🌕 Полнолуние: Если подозреваемый воет на луну, он либо оборотень, либо фанат рока.");
|
||||
}
|
||||
|
||||
// ----- 4. Объединяем все советы и выбираем случайный -----
|
||||
const allTips = tips.concat(dateJokes);
|
||||
const randomTip = allTips[Math.floor(Math.random() * allTips.length)];
|
||||
|
||||
// ----- 5. Красивый заголовок -----
|
||||
const headers = [
|
||||
"🔍 Заметки сыщика:",
|
||||
"📓 Из дневника инспектора:",
|
||||
"🕵️ Мудрость Шерлока:",
|
||||
"🧠 Гениально и просто:",
|
||||
"📌 Правило большого пальца:",
|
||||
"☕ Кофейная истина:"
|
||||
];
|
||||
|
||||
const header = headers[Math.floor(Math.random() * headers.length)];
|
||||
|
||||
return { header: header, text: randomTip }
|
||||
}
|
||||
|
||||
const tip = getDetectiveTip()
|
||||
|
||||
// ===== Удаление аккаунта =====
|
||||
const showDeleteDialog = ref(false)
|
||||
const deletePassword = ref('')
|
||||
const deleteError = ref('')
|
||||
|
||||
async function confirmDeleteAccount() {
|
||||
deleteError.value = ''
|
||||
const res = await authStore.deleteAccount(deletePassword.value)
|
||||
if (!res) {
|
||||
deleteError.value = authStore.error || 'Не удалось удалить аккаунт'
|
||||
return
|
||||
}
|
||||
message.success('Аккаунт и персональные данные удалены')
|
||||
showDeleteDialog.value = false
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width700">
|
||||
<n-alert :title="tip.header" :show-icon="false" type="info" style="margin-bottom: 20px;">
|
||||
{{ tip.text }}
|
||||
</n-alert>
|
||||
|
||||
<p>Доброго времени суток, {{ authStore.username }}.</p>
|
||||
|
||||
<div class="danger-zone">
|
||||
<div class="danger-zone-text">
|
||||
<div class="danger-zone-title">Удаление аккаунта</div>
|
||||
<div class="danger-zone-hint">
|
||||
Удаление необратимо: аккаунт и все персональные данные будут стёрты (ст. 21 152-ФЗ).
|
||||
</div>
|
||||
</div>
|
||||
<n-button type="error" ghost @click="showDeleteDialog = true">Удалить аккаунт</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showDeleteDialog"
|
||||
preset="card"
|
||||
title="Удаление аккаунта"
|
||||
class="delete-modal"
|
||||
>
|
||||
<p class="delete-modal-text">
|
||||
Это действие необратимо. Для подтверждения удаления введите пароль от учётной записи —
|
||||
он понадобится, чтобы убедиться, что аккаунт удаляет его владелец.
|
||||
</p>
|
||||
<n-input
|
||||
v-model:value="deletePassword"
|
||||
type="password"
|
||||
placeholder="Пароль для подтверждения"
|
||||
@keyup.enter="confirmDeleteAccount"
|
||||
/>
|
||||
<p v-if="deleteError" class="delete-modal-error">{{ deleteError }}</p>
|
||||
<div class="delete-modal-buttons">
|
||||
<n-button @click="showDeleteDialog = false">Отмена</n-button>
|
||||
<n-button
|
||||
type="error"
|
||||
:disabled="deletePassword.length == 0"
|
||||
:loading="authStore.isLoading"
|
||||
@click="confirmDeleteAccount"
|
||||
>
|
||||
Удалить навсегда
|
||||
</n-button>
|
||||
</div>
|
||||
</n-modal>
|
||||
|
||||
<HeaderMenu active="office" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.danger-zone {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background-color: #222;
|
||||
border: 1px solid rgb(224 108 117 / 40%);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.danger-zone-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.danger-zone-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.delete-modal-text {
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.delete-modal-error {
|
||||
margin: 12px 0 0;
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.delete-modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,472 @@
|
||||
<script setup lang="ts">
|
||||
import { Key24Regular } from '@vicons/fluent'
|
||||
import { LockOutlined } from '@vicons/material'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { NButton, NDynamicInput, NInput, NInputGroup, NSpace, NSwitch, NText, NUpload, NUploadDragger } from 'naive-ui'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server'
|
||||
|
||||
import MessagePaper from './MessagePaper.vue';
|
||||
|
||||
const props = defineProps({
|
||||
newPlaceCode: {
|
||||
type: String,
|
||||
},
|
||||
newPlaceName: {
|
||||
type: String,
|
||||
},
|
||||
newPlaceText: {
|
||||
type: String,
|
||||
},
|
||||
place: {
|
||||
type: Object as PropType<Place>,
|
||||
},
|
||||
mode: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
addAction: {
|
||||
type: Function as PropType<(param: string) => void>,
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:newPlaceCode': [value: string],
|
||||
'update:newPlaceName': [value: string],
|
||||
'update:newPlaceText': [value: string],
|
||||
'update:mode': [value: string]
|
||||
}>()
|
||||
const updateCode = (code: string) => {
|
||||
emit('update:newPlaceCode', code)
|
||||
}
|
||||
const updateName = (name: string) => {
|
||||
emit('update:newPlaceName', name)
|
||||
}
|
||||
const updateText = (text: string) => {
|
||||
emit('update:newPlaceText', text)
|
||||
}
|
||||
const updateMode = (mode: string) => {
|
||||
emit('update:mode', mode)
|
||||
}
|
||||
|
||||
function onCreateApplication(): Application {
|
||||
return {
|
||||
name: undefined,
|
||||
image: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function onCreateDoor(): Door {
|
||||
return {
|
||||
code: undefined,
|
||||
name: undefined,
|
||||
keys: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function onCreateKey(): Key {
|
||||
return {
|
||||
name: undefined,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="mode == 'mini-add'" class="place-block-hover show-editor-block" @click="updateMode('add')">
|
||||
<p class="place-image-plus">+</p>
|
||||
</div>
|
||||
|
||||
<div v-if="mode == 'add'" class="place-block">
|
||||
<n-space vertical>
|
||||
<n-input-group>
|
||||
<n-input :style="{ width: '20%' }" :value="newPlaceCode" @update:value="updateCode" type="text"
|
||||
placeholder="Ы-1" />
|
||||
<n-input :style="{ width: '80%' }" :value="newPlaceName" @update:value="updateName" type="text"
|
||||
placeholder="Дом №Ы" />
|
||||
</n-input-group>
|
||||
<n-input :value="newPlaceText" @update:value="updateText" type="textarea" :autosize="{ minRows: 3 }"
|
||||
placeholder="В начале было слово..." />
|
||||
<slot></slot>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<div v-if="mode == 'show-editor'" class="place-block-hover show-editor-block" @click="updateMode('edit')">
|
||||
<div class="message-header">
|
||||
{{ props.place?.code }}: {{ props.place?.name }}
|
||||
</div>
|
||||
<hr class="hr" />
|
||||
<div class="message-content">
|
||||
<div v-if="props.place?.image">
|
||||
<div class="message-image-border">
|
||||
<img :src="props.place?.image" class="message-image">
|
||||
</div>
|
||||
</div>
|
||||
{{ props.place?.text }}
|
||||
</div>
|
||||
|
||||
<div v-for="door in props.place?.doors" v-bind:key="door.code" class="door-block">
|
||||
{{ door.name }} <span v-for="key in door.keys" :key="key.name" class="key-block">
|
||||
<Icon class="lock-icon">
|
||||
<LockOutlined />
|
||||
</Icon> {{ key.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="hr" v-if="props.place?.applications?.length" />
|
||||
<div class="message-footer application-block" v-for="application in props.place?.applications"
|
||||
v-bind:key="application.name">
|
||||
Приложение: {{ application.name }}
|
||||
</div>
|
||||
|
||||
<p class="gray-block">
|
||||
<span v-if="props.place?.hidden">
|
||||
Скрытая точка
|
||||
</span>
|
||||
<span v-for="key in props.place?.keys" class="key-block">
|
||||
<Icon class="lock-icon">
|
||||
<Key24Regular />
|
||||
</Icon> {{ key.name }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="mode == 'show'">
|
||||
<MessagePaper>
|
||||
<div class="message-show-header font">
|
||||
{{ place?.code }}: {{ place?.name }}
|
||||
<span class="collapse-icon collapse-icon-up" @click="updateMode('show-collapse')"></span>
|
||||
</div>
|
||||
<hr class="show-hr" />
|
||||
<div class="message-show-content font">
|
||||
<div v-if="place?.image?.length">
|
||||
<div class="message-image-border">
|
||||
<img v-bind:src="place?.image" class="message-image" />
|
||||
</div>
|
||||
</div>
|
||||
{{ place?.text }}
|
||||
</div>
|
||||
<hr class="show-hr" v-if="props.place?.doors?.length" />
|
||||
<div v-for="door in props.place?.doors" :key="door.code" class="button-dialog font"
|
||||
v-on:click="addAction!(door.code!)">
|
||||
<div class="button-dialog-text">
|
||||
{{ door.code }} {{ door.name }}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="show-hr" v-if="props.place?.applications?.length" />
|
||||
<div class="message-show-footer font" v-for="application in props.place?.applications" :key="application.name">
|
||||
Приложение: {{ application.name }}
|
||||
<!-- <div class="application-label">{{ application.number }}</div> -->
|
||||
</div>
|
||||
</MessagePaper>
|
||||
</div>
|
||||
|
||||
<div v-if="mode == 'show-collapse'">
|
||||
<MessagePaper>
|
||||
<div class="message-header font">
|
||||
{{ place?.code }}: {{ place?.name }}
|
||||
<span class="collapse-icon" @click="updateMode('show')"></span>
|
||||
</div>
|
||||
</MessagePaper>
|
||||
</div>
|
||||
|
||||
<div v-if="mode == 'edit'" class="place-block">
|
||||
<n-space vertical>
|
||||
<n-input-group>
|
||||
<n-input :style="{ width: '20%' }" v-model:value="props.place!.code" type="text" placeholder="Ы-1" />
|
||||
<n-input :style="{ width: '80%' }" v-model:value="props.place!.name" type="text" placeholder="Дом №Ы" />
|
||||
</n-input-group>
|
||||
<n-input v-model:value="props.place!.text" type="textarea" :autosize="{ minRows: 3 }"
|
||||
placeholder="В начале было слово..." />
|
||||
|
||||
<p class="settings-header">Картинка</p>
|
||||
<div>
|
||||
<n-space>
|
||||
<n-space vertical>
|
||||
<img :src="props.place!.image" class="message-edit-image">
|
||||
<n-button @click="props.place!.image = '', props.place!.fileList = null">Убрать картинку</n-button>
|
||||
</n-space>
|
||||
<n-space vertical>
|
||||
<n-input v-model:value="props.place!.image" type="text" placeholder="Не задано" disabled />
|
||||
<n-upload directory-dnd :max="1" v-model:file-list="props.place!.fileList">
|
||||
<n-upload-dragger>
|
||||
<n-text style="font-size: 16px">
|
||||
Щелкните или перетащите файл в эту область для загрузки
|
||||
</n-text>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
</n-space>
|
||||
</n-space>
|
||||
</div>
|
||||
|
||||
<p class="settings-header">Видимость точки</p>
|
||||
<n-switch v-model:value="props.place!.hidden">
|
||||
<template #checked>
|
||||
Скрытая точка
|
||||
</template>
|
||||
<template #unchecked>
|
||||
Обычная точка
|
||||
</template>
|
||||
</n-switch>
|
||||
|
||||
Улики
|
||||
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
|
||||
<template #create-button-default> Добавить улику </template>
|
||||
<template #default="{ value }">
|
||||
<div style="display: flex; align-items: center; width: 100%">
|
||||
<n-input v-model:value="value.name" type="text" placeholder="Название" />
|
||||
</div>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
|
||||
Действия
|
||||
<n-dynamic-input v-model:value="props.place!.doors" :on-create="onCreateDoor">
|
||||
<template #create-button-default> Добавить действие </template>
|
||||
<template #action="{ index, create, remove }">
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<n-button @click="() => create(index)" class="plus-minus-buttons">+</n-button>
|
||||
<n-button @click="() => remove(index)" class="plus-minus-buttons">-</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ value: door }">
|
||||
<div style="display: flex; flex-direction: column; width: 100%">
|
||||
|
||||
<div class="door-add-block">
|
||||
<n-input-group>
|
||||
<n-input :style="{ width: '30%' }" v-model:value="door.code" type="text" placeholder="Код" />
|
||||
<n-input :style="{ width: '70%' }" v-model:value="door.name" type="text" placeholder="Название" />
|
||||
</n-input-group>
|
||||
|
||||
<p class="need-keys-text">Требуемые ключи</p>
|
||||
<div class="door-field">
|
||||
<n-dynamic-input v-model:value="door.keys" :on-create="onCreateKey">
|
||||
<template #create-button-default> Добавить ключ </template>
|
||||
<template #default="{ value: key }">
|
||||
<div style="display: flex; align-items: center; width: 100%">
|
||||
<n-input v-model:value="key.name" type="text" placeholder="Название" />
|
||||
</div>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
|
||||
Получаемые ключи
|
||||
<n-dynamic-input v-model:value="props.place!.keys" :on-create="onCreateKey">
|
||||
<template #create-button-default> Добавить ключ </template>
|
||||
<template #default="{ value: key }">
|
||||
<div style="display: flex; align-items: center; width: 100%">
|
||||
<n-input v-model:value="key.name" type="text" placeholder="Название" />
|
||||
</div>
|
||||
</template>
|
||||
</n-dynamic-input>
|
||||
|
||||
<div class="slot-block">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</n-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hr {
|
||||
margin: 10px 0;
|
||||
border: dashed 1px;
|
||||
|
||||
/* border-color: black; */
|
||||
border-color: #eee;
|
||||
}
|
||||
|
||||
.place-block-hover,
|
||||
.place-block {
|
||||
margin: 10px 0;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #222;
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.place-block-hover:hover {
|
||||
color: #63e2b7;
|
||||
cursor: pointer;
|
||||
border: 1px solid #63e2b7;
|
||||
}
|
||||
|
||||
.place-image-plus {
|
||||
font-size: 30px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.show-editor-block {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-weight: 500;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.gray-block {
|
||||
margin-top: 5px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.application-block {
|
||||
margin-right: 5px;
|
||||
color: burlywood;
|
||||
}
|
||||
|
||||
.door-block {
|
||||
display: inline-block;
|
||||
margin: 10px 5px 10px 0;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
background-color: #222;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.door-add-block {
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
background-color: #222;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.key-block {
|
||||
color: gold;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.lock-icon {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.door-field {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.need-keys-text {
|
||||
color: gold;
|
||||
margin: 15px 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.slot-block {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.plus-minus-buttons {
|
||||
border: 1px solid #444;
|
||||
padding: 0 13px;
|
||||
margin: 0 0 10px 5px;
|
||||
text-align: center;
|
||||
color: #eee;
|
||||
font-size: 25px;
|
||||
border-radius: 40px;
|
||||
}
|
||||
|
||||
.plus-minus-buttons:hover {
|
||||
cursor: pointer;
|
||||
color: #63e2b7;
|
||||
}
|
||||
|
||||
.message-image-border {
|
||||
width: 40%;
|
||||
max-width: 200px;
|
||||
float: left;
|
||||
padding: 7px;
|
||||
margin-right: 15px;
|
||||
background-image: url("@/assets/images/paper_white.jpg");
|
||||
background-size: cover;
|
||||
box-shadow: 0 3px 15px rgb(98 98 98);
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
|
||||
.message-image {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.message-edit-image {
|
||||
width: 135px;
|
||||
height: 135px;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
font-size: 20px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.message-footer {
|
||||
padding-right: 50px;
|
||||
font-weight: 400;
|
||||
color: burlywood;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-show-header {
|
||||
font-size: 20px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.collapse-icon {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 45px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
background-image: url("@/assets/images/collapse.png");
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.collapse-icon-up {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
.show-hr {
|
||||
margin: 10px 0;
|
||||
border: dashed 1px;
|
||||
border-color: black;
|
||||
}
|
||||
|
||||
.font {
|
||||
font-family: 'font_old_typer';
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.message-show-content {
|
||||
font-weight: 500;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-show-footer {
|
||||
padding-right: 50px;
|
||||
font-weight: 400;
|
||||
color: var(--second-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button-dialog {
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
margin-top: 10px;
|
||||
margin-right: 10px;
|
||||
background-image: url("@/assets/images/belt.png");
|
||||
background-size: cover;
|
||||
color: #bfa07d;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button-dialog-text {
|
||||
padding: 5px 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { NTag } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
|
||||
const message = useMessage()
|
||||
const client = getAuthClient()
|
||||
const route = useRoute()
|
||||
|
||||
const scenarioId = route.params.id
|
||||
|
||||
const scenario = ref<Scenario>({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
story: undefined,
|
||||
author: undefined,
|
||||
status: undefined,
|
||||
updatedAt: undefined,
|
||||
createdAt: undefined,
|
||||
publishedAt: undefined,
|
||||
})
|
||||
|
||||
async function getScenario(id: number) {
|
||||
const res = await client.GetScenario({ id: id })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
scenario.value = res.scenario!
|
||||
}
|
||||
getScenario(Number(scenarioId))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width700">
|
||||
<div class="scenario-title">
|
||||
{{ scenario.name }}
|
||||
</div>
|
||||
<img class="scenario-image" :src="scenario.image">
|
||||
<div class="scenario-description">
|
||||
{{ scenario.description }}
|
||||
</div>
|
||||
<div class="scenario-author">
|
||||
<n-tag :bordered="false" type="success" class="status-block">Автор: {{ scenario.author?.username
|
||||
}}</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeaderMenu active="catalog" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scenario-title {
|
||||
font-size: 24px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scenario-image {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.scenario-description {
|
||||
color: #aaa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,492 @@
|
||||
<script setup lang="ts">
|
||||
import { Settings } from '@vicons/carbon'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { NButton, type UploadFileInfo, useMessage } from 'naive-ui'
|
||||
import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { getArchiveClient } from '@/api/archive_client'
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { Place, Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||
import router from '@/router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const client = getAuthClient()
|
||||
const archiveClient = getArchiveClient()
|
||||
const route = useRoute()
|
||||
const scenarioId = route.params.id
|
||||
|
||||
const showSettingsModal = ref(false)
|
||||
const statusScenarioName = ref('')
|
||||
const deletedScenarioName = ref('')
|
||||
const isDownloadingArchive = ref(false)
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const fileList = ref<UploadFileInfo[]>([])
|
||||
|
||||
const scenario = ref<Scenario>({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
story: undefined,
|
||||
author: undefined,
|
||||
status: undefined,
|
||||
updatedAt: undefined,
|
||||
createdAt: undefined,
|
||||
publishedAt: undefined,
|
||||
})
|
||||
|
||||
async function getScenario(id: number) {
|
||||
const res = await client.GetFullScenario({ id: id })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
scenario.value = res.scenario!
|
||||
scenario.value.story?.places?.forEach((item) => {
|
||||
item.oldCode = item.code
|
||||
item.mode = 'show-editor'
|
||||
})
|
||||
}
|
||||
getScenario(Number(scenarioId))
|
||||
|
||||
async function deleteScenario(id: number) {
|
||||
const res = await client.DeleteScenario({ id: id })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
router.push('/scenarios')
|
||||
}
|
||||
|
||||
async function publicScenario(id: number) {
|
||||
const res = await client.PublicScenario({ id: id })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getScenario(Number(scenarioId))
|
||||
statusScenarioName.value = ''
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
async function addGame(name: string, id: number) {
|
||||
const now = new Date().toISOString()
|
||||
const resAddGame = await client.AddGame({
|
||||
name: ('Тестовая игра: ' + name).trim(),
|
||||
description: 'Тестовая игра',
|
||||
startAt: now,
|
||||
scenarioId: id
|
||||
})
|
||||
if (resAddGame.error != '') {
|
||||
message.error(resAddGame.error!)
|
||||
return
|
||||
}
|
||||
const resAddTeam = await client.AddTeam({
|
||||
name: 'Тестовая команда',
|
||||
gameId: resAddGame.id!,
|
||||
})
|
||||
if (resAddTeam.error != '') {
|
||||
message.error(resAddTeam.error!)
|
||||
return
|
||||
}
|
||||
router.push("/team-story/" + resAddTeam.id! + '?password=' + resAddTeam.password!)
|
||||
}
|
||||
|
||||
async function draftScenario(id: number) {
|
||||
const res = await client.DraftScenario({ id: id })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getScenario(Number(scenarioId))
|
||||
statusScenarioName.value = ''
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
async function downloadArchive() {
|
||||
isDownloadingArchive.value = true
|
||||
try {
|
||||
const { blob, filename } = await archiveClient.downloadScenarioArchive(scenario.value.id!)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
// Отзываем URL после того как браузер начал скачивание
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
message.success('Архив сценария скачан')
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось скачать архив')
|
||||
} finally {
|
||||
isDownloadingArchive.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFile(file: File | null, filename: string): Promise<string> {
|
||||
if (!file) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Читаем как ArrayBuffer
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const bytes = new Uint8Array(arrayBuffer)
|
||||
|
||||
const resUploadFile = await client.UploadFile({
|
||||
filename: filename,
|
||||
data: uint8ToBase64(bytes),
|
||||
})
|
||||
if (resUploadFile.error != '') {
|
||||
message.error(resUploadFile.error!)
|
||||
return ''
|
||||
}
|
||||
|
||||
return resUploadFile.filename || ''
|
||||
}
|
||||
|
||||
async function updateScenario() {
|
||||
let resUploadFilename = ''
|
||||
if (fileList.value && fileList.value.length > 0) {
|
||||
const file = fileList.value[0].file || null
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
|
||||
if (resUploadFilename === '') {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const res = await client.UpdateScenario({
|
||||
id: scenario.value.id,
|
||||
name: scenario.value.name,
|
||||
description: scenario.value.description,
|
||||
image: resUploadFilename,
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getScenario(Number(scenarioId))
|
||||
showSettingsModal.value = false
|
||||
}
|
||||
|
||||
function uint8ToBase64(uint8Array: Uint8Array) {
|
||||
// Преобразуем Uint8Array в двоичную строку
|
||||
const binaryString = fromCharCodeSafe(uint8Array)
|
||||
// Кодируем в Base64
|
||||
return btoa(binaryString)
|
||||
}
|
||||
|
||||
function fromCharCodeSafe(arr: Uint8Array) {
|
||||
let result = '';
|
||||
for (let i = 0; i < arr.length; i += 50000) {
|
||||
const chunk = arr.slice(i, i + 50000);
|
||||
result += String.fromCharCode(...chunk);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const code = ref('')
|
||||
const name = ref('')
|
||||
const text = ref('')
|
||||
const mode = ref('mini-add')
|
||||
|
||||
|
||||
async function addPlace() {
|
||||
const res = await client.AddScenarioPlace({
|
||||
id: scenario.value.id,
|
||||
place: {
|
||||
code: code.value,
|
||||
name: name.value,
|
||||
text: text.value,
|
||||
image: undefined,
|
||||
hidden: undefined,
|
||||
applications: undefined,
|
||||
doors: undefined,
|
||||
keys: undefined,
|
||||
},
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getScenario(Number(scenarioId))
|
||||
|
||||
code.value = ''
|
||||
name.value = ''
|
||||
text.value = ''
|
||||
mode.value = 'add'
|
||||
}
|
||||
|
||||
async function cancelAddPlace() {
|
||||
code.value = ''
|
||||
name.value = ''
|
||||
text.value = ''
|
||||
mode.value = 'mini-add'
|
||||
}
|
||||
|
||||
async function addNotFoundDoors(scenario: Scenario, place: Place) {
|
||||
for (const door of place.doors || []) {
|
||||
const place = scenario.story?.places?.find((place) => {
|
||||
return place.code == door.code
|
||||
})
|
||||
if (place == undefined) {
|
||||
const res = await client.AddScenarioPlace({
|
||||
id: scenario.id,
|
||||
place: {
|
||||
code: door.code,
|
||||
name: door.name,
|
||||
text: undefined,
|
||||
image: undefined,
|
||||
hidden: true,
|
||||
applications: undefined,
|
||||
doors: undefined,
|
||||
keys: undefined
|
||||
},
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelUpdatePlace(place: Place) {
|
||||
await getScenario(Number(scenarioId))
|
||||
|
||||
place.mode = 'show-editor'
|
||||
}
|
||||
|
||||
async function updatePlace(place: Place) {
|
||||
if (place.fileList && place.fileList.length > 0) {
|
||||
const file = place.fileList[0].file || null
|
||||
if (file == null) {
|
||||
return
|
||||
}
|
||||
const resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
|
||||
if (resUploadFilename === '') {
|
||||
return
|
||||
}
|
||||
place.image = resUploadFilename
|
||||
}
|
||||
|
||||
await addNotFoundDoors(scenario.value, place)
|
||||
const res = await client.UpdateScenarioPlace({
|
||||
id: scenario.value.id,
|
||||
code: place.oldCode,
|
||||
place: place,
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getScenario(Number(scenarioId))
|
||||
|
||||
place.mode = 'show-editor'
|
||||
}
|
||||
|
||||
async function deletePlace(place: Place) {
|
||||
const res = await client.DeleteScenarioPlace({
|
||||
id: scenario.value.id,
|
||||
code: place.oldCode,
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getScenario(Number(scenarioId))
|
||||
}
|
||||
|
||||
function getRandomComplimentForNastya() {
|
||||
const compliments = [
|
||||
{ text: 'Настя, ты просто ослепительна сегодня!', nameForm: 'Настя' },
|
||||
{ text: 'У Насти невероятный вкус!', nameForm: 'Насти' },
|
||||
{ text: 'Настенька, твоя улыбка делает мир ярче.', nameForm: 'Настенька' },
|
||||
{ text: 'С Настей всегда интересно общаться.', nameForm: 'Настей' },
|
||||
{ text: 'Настя, ты — источник вдохновения для всех вокруг.', nameForm: 'Настя' },
|
||||
{ text: 'Мне нравится, как Настя смотрит на мир.', nameForm: 'Настя' },
|
||||
{ text: 'Настюха, ты супер!', nameForm: 'Настюха' },
|
||||
{ text: 'Талант Насти поражает!', nameForm: 'Насти' },
|
||||
{ text: 'Настя, твоя энергия заряжает батарейки!', nameForm: 'Настя' },
|
||||
{ text: 'С Настей хочется проводить каждую минуту.', nameForm: 'Настей' },
|
||||
{ text: 'Настенька, ты — самое красивое, что случилось в этом мире.', nameForm: 'Настенька' },
|
||||
{ text: 'У Насти золотое сердце.', nameForm: 'Насти' }
|
||||
];
|
||||
|
||||
const randomIndex = Math.floor(Math.random() * compliments.length);
|
||||
return compliments[randomIndex].text;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width700">
|
||||
<div class="places-container">
|
||||
<n-alert v-if="scenario.status == 'public'" title="Сценарий опубликован" type="warning"
|
||||
style="margin-bottom: 20px;">
|
||||
Не рекомендуется редактировать сценарий когда он опубликован.
|
||||
</n-alert>
|
||||
|
||||
<n-alert v-if="authStore.user?.id == 2" :title="getRandomComplimentForNastya()" type="info"
|
||||
style="margin-bottom: 20px;"></n-alert>
|
||||
|
||||
<div class="settings-block">
|
||||
Точек: {{ scenario.story?.places?.length }}
|
||||
<n-button quaternary @click="showSettingsModal = true" class="settings-button">
|
||||
<Icon class="settings-icon">
|
||||
<Settings />
|
||||
</Icon>
|
||||
Настройки
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<PlaceBlock v-model:mode="mode" v-model:newPlaceCode="code" v-model:newPlaceName="name"
|
||||
v-model:newPlaceText="text">
|
||||
<n-flex justify="end">
|
||||
<n-button @click="cancelAddPlace()">Отмена</n-button>
|
||||
<n-button @click="addPlace()" :disabled="!code">Добавить точку</n-button>
|
||||
</n-flex>
|
||||
</PlaceBlock>
|
||||
|
||||
<PlaceBlock :place="place" v-model:mode="place.mode" v-for="place in scenario.story?.places"
|
||||
v-bind:key="place.oldCode">
|
||||
<n-flex justify="end">
|
||||
<n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button>
|
||||
<n-button @click="cancelUpdatePlace(place)">Отмена</n-button>
|
||||
<n-button @click="updatePlace(place)" :disabled="place.code == ''">Сохранить</n-button>
|
||||
</n-flex>
|
||||
</PlaceBlock>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Окно настроек -->
|
||||
<n-modal v-model:show="showSettingsModal">
|
||||
<n-card style="width: 600px" title="Настройки сценария" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="showSettingsModal = false"> x </n-button>
|
||||
</template>
|
||||
<n-space vertical>
|
||||
<p>Название</p>
|
||||
<n-input v-model:value="scenario.name" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||
|
||||
<p class="settings-header">Описание</p>
|
||||
<n-input v-model:value="scenario.description" type="textarea" placeholder="Убийство пастуха..." />
|
||||
|
||||
<p class="settings-header">Картинка</p>
|
||||
<n-input v-model:value="scenario.image" type="text" placeholder="default_scenario.png" disabled />
|
||||
<n-upload directory-dnd :max="1" v-model:file-list="fileList">
|
||||
<n-upload-dragger>
|
||||
<n-text style="font-size: 16px">
|
||||
Щелкните или перетащите файл в эту область для загрузки
|
||||
</n-text>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
|
||||
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
|
||||
|
||||
<hr class="settings-hr" />
|
||||
<p class="settings-header">Архив сценария</p>
|
||||
<p class="settings-hint">
|
||||
Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом аккаунте.
|
||||
</p>
|
||||
<n-button :loading="isDownloadingArchive" :disabled="!scenario.id" ghost @click="downloadArchive()">
|
||||
Скачать архив (.zip)
|
||||
</n-button>
|
||||
|
||||
<hr class="settings-hr" />
|
||||
<p>Создать тестовую игру</p>
|
||||
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
|
||||
Создать игру
|
||||
</n-button>
|
||||
|
||||
<hr class="settings-hr" />
|
||||
<p>Публикация</p>
|
||||
<p>
|
||||
<n-tag v-if="scenario.status == 'public'" :bordered="false" type="success" class="status-block">
|
||||
Опубликовано
|
||||
</n-tag>
|
||||
<n-tag v-if="scenario.status == 'draft'" :bordered="false" type="warning" class="status-block">
|
||||
В разработке
|
||||
</n-tag>
|
||||
</p>
|
||||
<p>
|
||||
Введите название сценария <n-text class="name-text" strong>{{ scenario.name }}</n-text>
|
||||
</p>
|
||||
<n-input v-model:value="statusScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||
<n-button v-if="scenario.status != 'public'" @click="publicScenario(scenario.id!)" ghost
|
||||
:disabled="statusScenarioName != scenario.name">
|
||||
Опубликовать сценарий
|
||||
</n-button>
|
||||
<n-button v-if="scenario.status != 'draft'" @click="draftScenario(scenario.id!)" ghost
|
||||
:disabled="statusScenarioName != scenario.name">
|
||||
Снять сценарий с публикации
|
||||
</n-button>
|
||||
|
||||
<hr class="settings-hr" />
|
||||
|
||||
<p>
|
||||
Введите название сценария
|
||||
<n-text class="name-text" strong>{{ scenario.name }}</n-text> чтобы удалить его
|
||||
</p>
|
||||
<n-input v-model:value="deletedScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||
<n-button @click="deleteScenario(scenario.id!)" type="error" ghost
|
||||
:disabled="deletedScenarioName != scenario.name">
|
||||
Удалить сценарий
|
||||
</n-button>
|
||||
</n-space>
|
||||
<template #footer> </template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<HeaderMenu active="scenarios" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-block {
|
||||
height: 40px;
|
||||
|
||||
/* background-color: brown; */
|
||||
}
|
||||
|
||||
.settings-button {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.settings-icon {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
padding: 3px;
|
||||
border-radius: 3px;
|
||||
background-color: rgb(255 255 255 / 10%);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: #999;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.settings-hr {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.places-container {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import { Archive } from '@vicons/carbon'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { NButton, NCard, NFlex, NInput, NModal, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { getArchiveClient } from '@/api/archive_client'
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
|
||||
const client = getAuthClient()
|
||||
const archiveClient = getArchiveClient()
|
||||
|
||||
const scenarios = ref<Scenario[]>([])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const showAddScenarioModal = ref(false)
|
||||
const newScenarioName = ref('')
|
||||
|
||||
const showImportModal = ref(false)
|
||||
const importFileList = ref<UploadFileInfo[]>([])
|
||||
const isImporting = ref(false)
|
||||
|
||||
async function getScenarios() {
|
||||
scenarios.value = []
|
||||
const res = await client.GetMyScenarios({})
|
||||
scenarios.value = res.scenarios!
|
||||
}
|
||||
|
||||
async function addScenario(name: string) {
|
||||
const res = await client.AddScenario({ name: name })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await toScenarioEditor(res.id!)
|
||||
}
|
||||
|
||||
async function toScenarioEditor(id: number) {
|
||||
router.push('/scenarios/' + id + '/editor')
|
||||
}
|
||||
|
||||
function resetImport() {
|
||||
importFileList.value = []
|
||||
showImportModal.value = false
|
||||
}
|
||||
|
||||
async function importScenario() {
|
||||
const file = importFileList.value[0]?.file
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
isImporting.value = true
|
||||
try {
|
||||
const res = await archiveClient.uploadScenarioArchive(file)
|
||||
if (res.error) {
|
||||
message.error(res.error)
|
||||
return
|
||||
}
|
||||
message.success('Сценарий импортирован из архива')
|
||||
resetImport()
|
||||
await toScenarioEditor(res.id!)
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : 'Не удалось импортировать архив')
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
getScenarios()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width1200 display-table">
|
||||
<div class="scenario-block" @click="showAddScenarioModal = true">
|
||||
<div class="scenario-image-block scenario-image-plus">+</div>
|
||||
<div class="scenario-content-block">
|
||||
<p class="scenario-title">Создать новый сценарий</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-block" @click="showImportModal = true">
|
||||
<div class="scenario-image-block scenario-image-plus import-icon">
|
||||
<Icon>
|
||||
<Archive />
|
||||
</Icon>
|
||||
</div>
|
||||
<div class="scenario-content-block">
|
||||
<p class="scenario-title">Импортировать из архива</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioEditor(scenario.id!)"
|
||||
v-bind:key="scenario.id">
|
||||
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
|
||||
|
||||
<div class="scenario-content-block">
|
||||
<p class="scenario-title">{{ scenario.name }}</p>
|
||||
<p class="scenario-label">
|
||||
<n-tag v-if="scenario.status == 'public'" :bordered="false" type="success"
|
||||
class="status-block">Опубликовано</n-tag>
|
||||
<n-tag v-if="scenario.status == 'draft'" :bordered="false" type="warning" class="status-block">В
|
||||
разработке</n-tag>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Окно создания сценария -->
|
||||
<n-modal v-model:show="showAddScenarioModal">
|
||||
<n-card style="width: 600px" title="Создание сценария" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="showAddScenarioModal = false"> x </n-button>
|
||||
</template>
|
||||
<n-input v-model:value="newScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="addScenario(newScenarioName)" :disabled="newScenarioName.length == 0">
|
||||
Создать
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<!-- Окно импорта сценария из архива -->
|
||||
<n-modal v-model:show="showImportModal">
|
||||
<n-card style="width: 600px" title="Импорт сценария из архива" :bordered="false" size="huge" role="dialog"
|
||||
aria-modal="true">
|
||||
<template #header-extra>
|
||||
<n-button @click="resetImport()"> x </n-button>
|
||||
</template>
|
||||
<p class="import-hint">
|
||||
Выберите ZIP-архив сценария (файл scenario.json + папка images). Архив можно получить
|
||||
через «Скачать архив» в настройках сценария.
|
||||
</p>
|
||||
<n-upload :max="1" v-model:file-list="importFileList"
|
||||
accept=".zip,application/zip,application/x-zip-compressed,application/octet-stream">
|
||||
<n-upload-dragger>
|
||||
<n-text style="font-size: 16px">
|
||||
Щелкните или перетащите ZIP-архив в эту область
|
||||
</n-text>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
<n-button @click="resetImport()">Отмена</n-button>
|
||||
<n-button type="primary" :loading="isImporting" :disabled="importFileList.length == 0"
|
||||
@click="importScenario()">
|
||||
Импортировать
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
|
||||
<HeaderMenu active="scenarios" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.scenario-block {
|
||||
position: relative;
|
||||
margin: 10px;
|
||||
width: 160px;
|
||||
height: 300px;
|
||||
cursor: pointer;
|
||||
border-radius: 12px;
|
||||
|
||||
/* background-color: #553333; */
|
||||
background-color: #222;
|
||||
border: 2px solid #222;
|
||||
}
|
||||
|
||||
.scenario-content-block {
|
||||
padding: 2px 10px;
|
||||
}
|
||||
|
||||
.scenario-block:hover {
|
||||
color: #63e2b7;
|
||||
border: 2px solid #63e2b7;
|
||||
}
|
||||
|
||||
.scenario-image-block {
|
||||
background-color: #111;
|
||||
height: 160px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.scenario-image-plus {
|
||||
font-size: 120px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #111;
|
||||
}
|
||||
|
||||
.import-icon svg {
|
||||
height: 100px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
color: #aaa;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.scenario-image {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.scenario-title {
|
||||
padding: 5px 0;
|
||||
}
|
||||
|
||||
.status-block {
|
||||
position: absolute;
|
||||
bottom: 15px;
|
||||
right: 15px;
|
||||
}
|
||||
|
||||
@media (width >=1024px) {
|
||||
.scenario-block {
|
||||
width: 250px;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
.scenario-image-block {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.scenario-content-block {
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex, NQrCode } from 'naive-ui'
|
||||
import { useMessage } from 'naive-ui';
|
||||
import { nextTick, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client';
|
||||
import type { Game, Place } from '@/api/generated/crabs/evening_detective_server';
|
||||
import GameInputForm from '@/components/GameInputForm.vue'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
import MessagePaper from './MessagePaper.vue';
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const client = getAuthClient()
|
||||
const message = useMessage()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const teamId = route.params.id
|
||||
const password = route.query.password
|
||||
|
||||
const newPlacesCount = ref(0)
|
||||
|
||||
const scrollContainer = ref<HTMLDivElement | null>();
|
||||
|
||||
const story = ref<AdvancedStory>({
|
||||
places: []
|
||||
})
|
||||
|
||||
const game = ref<Game | undefined>({
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
startAt: undefined,
|
||||
status: undefined,
|
||||
scenario: undefined,
|
||||
teams: undefined
|
||||
})
|
||||
|
||||
const code = ref('')
|
||||
|
||||
const url = window.location.href
|
||||
|
||||
const isAtBottom = () => {
|
||||
const container = scrollContainer.value;
|
||||
if (!container) return false;
|
||||
|
||||
return container.scrollHeight - container.scrollTop - container.clientHeight < 1;
|
||||
};
|
||||
|
||||
async function getStory() {
|
||||
const oldStory = story.value
|
||||
|
||||
const res = await client.GetTeamStory({
|
||||
id: Number(teamId),
|
||||
password: password as string
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
|
||||
game.value = res.game
|
||||
|
||||
const wasAtBottom = isAtBottom();
|
||||
|
||||
if (wasAtBottom) {
|
||||
newPlacesCount.value = 0
|
||||
}
|
||||
|
||||
const oldCount = story.value.places.length
|
||||
|
||||
story.value = {
|
||||
places: res.story!.places?.map((item, i) => {
|
||||
console.log(oldStory?.places[i]?.mode)
|
||||
return {
|
||||
place: item,
|
||||
mode: oldStory?.places[i]?.mode || 'show'
|
||||
}
|
||||
}) || []
|
||||
}
|
||||
|
||||
const newCount = story.value.places.length
|
||||
|
||||
if (newCount > oldCount && oldCount != 0) {
|
||||
newPlacesCount.value += newCount - oldCount
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
if (wasAtBottom && oldCount != 0) {
|
||||
const container = scrollContainer.value;
|
||||
if (!container) return false;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}
|
||||
getStory()
|
||||
|
||||
async function addAction(newCode: string) {
|
||||
const res = await client.AddTeamAction({
|
||||
id: Number(teamId),
|
||||
password: password as string,
|
||||
code: newCode
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getStory()
|
||||
code.value = ''
|
||||
|
||||
await nextTick();
|
||||
newPlacesCount.value = 0
|
||||
}
|
||||
|
||||
type AdvancedStory = {
|
||||
places: AdvancedPlace[];
|
||||
}
|
||||
|
||||
type AdvancedPlace = {
|
||||
place: Place;
|
||||
mode: string;
|
||||
}
|
||||
|
||||
async function deleteLast() {
|
||||
const res = await client.DeleteLastTeamAction({
|
||||
id: Number(teamId),
|
||||
})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getStory()
|
||||
code.value = ''
|
||||
}
|
||||
|
||||
useSimplePolling(() => {
|
||||
getStory()
|
||||
}, 2000);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom bottom-margin smooth-scroll" ref="scrollContainer">
|
||||
<div class="width700">
|
||||
<GameInputForm :addAction="addAction" :allPlacesCount="story.places.length" :newPlacesCount="newPlacesCount"
|
||||
:gameStatus="game?.status || ''"></GameInputForm>
|
||||
|
||||
<PlaceBlock :place="advancedPlace.place" v-model:mode="advancedPlace.mode"
|
||||
v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction">
|
||||
</PlaceBlock>
|
||||
|
||||
<MessagePaper class="qr-main-block" v-if="story.places.length == 0">
|
||||
<n-space vertical>
|
||||
<h2 class="qr-main-block-text">Это код этого дела</h2>
|
||||
<p class="qr-main-block-text">Поделись с командой</p>
|
||||
<div class="qr-block">
|
||||
<n-qr-code :value="url" :size="300" :padding="0" />
|
||||
</div>
|
||||
<p v-if="game?.status == 'draft'" class="qr-main-block-text">Игра скоро начнется</p>
|
||||
<p v-if="game?.status == 'run'" class="qr-main-block-text">Игра началась!!!</p>
|
||||
</n-space>
|
||||
</MessagePaper>
|
||||
|
||||
<div v-if="authStore.hasRole('author') && story.places.length > 0" class="edit-panel">
|
||||
<n-flex justify="end">
|
||||
<n-button @click="deleteLast">Удалить последний ход</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeaderMenu active="games" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-margin {
|
||||
height: calc(100dvh - 70px - 76px);
|
||||
}
|
||||
|
||||
.smooth-scroll {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.edit-panel {
|
||||
margin: 30px 0;
|
||||
padding: 20px;
|
||||
background-color: #222;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.qr-main-block {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qr-main-block-text {
|
||||
text-align: left;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.qr-block {
|
||||
display: inline-block;
|
||||
background-color: white;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
@media (width >=1024px) {
|
||||
.bottom-margin {
|
||||
height: calc(100dvh - 70px - 76px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,94 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex, NSpace, NTag, useMessage } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { getAuthClient } from '@/api/auth_client'
|
||||
import type { User } from '@/api/generated/crabs/evening_detective_server'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
|
||||
const client = getAuthClient()
|
||||
const message = useMessage()
|
||||
|
||||
const users = ref<User[]>([])
|
||||
|
||||
async function getUsers() {
|
||||
const res = await client.GetUsers({})
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
users.value = res.users!
|
||||
}
|
||||
getUsers()
|
||||
|
||||
async function addRole(userId: number, role: string) {
|
||||
const res = await client.AddUserRole({ id: userId, role: role })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getUsers()
|
||||
}
|
||||
|
||||
async function deleteRole(userId: number, role: string) {
|
||||
const res = await client.DeleteUserRole({ id: userId, role: role })
|
||||
if (res.error != '') {
|
||||
message.error(res.error!)
|
||||
return
|
||||
}
|
||||
await getUsers()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center-block-custom">
|
||||
<div class="width700">
|
||||
<div v-for="user in users" v-bind:key="user.id" class="user-block">
|
||||
<div class="user-field">
|
||||
Позывной: {{ user.username }}
|
||||
</div>
|
||||
<div class="user-field">
|
||||
Почта: {{ user.email }}
|
||||
</div>
|
||||
<div class="user-field">
|
||||
<n-space>
|
||||
Роли: <n-tag :bordered="false" v-for="role in user.roles" v-bind:key="role">
|
||||
{{ role }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
</div>
|
||||
<n-flex justify="end" class="roles-buttons-block">
|
||||
|
||||
<n-button @click="addRole(user.id!, 'author')" v-if="!user.roles?.includes('author')">Сделать
|
||||
автором</n-button>
|
||||
<n-button @click="deleteRole(user.id!, 'author')" v-else>Забрать роль автора</n-button>
|
||||
|
||||
<n-button @click="addRole(user.id!, 'organizer')" v-if="!user.roles?.includes('organizer')">Сделать
|
||||
организатором</n-button>
|
||||
<n-button @click="deleteRole(user.id!, 'organizer')" v-else>Забрать роль организатора</n-button>
|
||||
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeaderMenu active="users" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-block {
|
||||
background-color: #222;
|
||||
border-radius: 10px;
|
||||
margin: 0 0 20px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.user-field {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.roles-buttons-block {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,87 +0,0 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,7 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { onMounted, onUnmounted,ref } from 'vue';
|
||||
|
||||
export function useSimplePolling(callback: () => void, interval = 5000) {
|
||||
const isActive = ref(true);
|
||||
let timerId: number | null = null;
|
||||
|
||||
const start = () => {
|
||||
isActive.value = true;
|
||||
callback();
|
||||
timerId = window.setInterval(callback, interval);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
isActive.value = false;
|
||||
if (timerId) {
|
||||
clearInterval(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(start);
|
||||
onUnmounted(stop);
|
||||
|
||||
return { isActive, start, stop };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import CatalogView from '../views/CatalogView.vue'
|
||||
import ConsentView from '../views/ConsentView.vue'
|
||||
import GamesView from '../views/GamesView.vue'
|
||||
import GameView from '../views/GameView.vue'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import OfficeView from '../views/OfficeView.vue'
|
||||
import PrivacyPolicyView from '../views/PrivacyPolicyView.vue'
|
||||
import ScenariosEditorView from '../views/ScenariosEditorView.vue'
|
||||
import ScenariosView from '../views/ScenariosView.vue'
|
||||
import ScenarioView from '../views/ScenarioView.vue'
|
||||
import TeamStoryView from '../views/TeamStoryView.vue'
|
||||
import UserAgreementView from '../views/UserAgreementView.vue'
|
||||
import UsersView from '../views/UsersView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -10,12 +24,69 @@ const router = createRouter({
|
||||
component: HomeView,
|
||||
},
|
||||
{
|
||||
path: '/about',
|
||||
name: 'about',
|
||||
// route level code-splitting
|
||||
// this generates a separate chunk (About.[hash].js) for this route
|
||||
// which is lazy-loaded when the route is visited.
|
||||
component: () => import('../views/AboutView.vue'),
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView,
|
||||
},
|
||||
{
|
||||
path: '/office',
|
||||
name: 'office',
|
||||
component: OfficeView,
|
||||
},
|
||||
{
|
||||
path: '/user-agreement',
|
||||
name: 'user-agreement',
|
||||
component: UserAgreementView,
|
||||
},
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
name: 'privacy-policy',
|
||||
component: PrivacyPolicyView,
|
||||
},
|
||||
{
|
||||
path: '/consent',
|
||||
name: 'consent',
|
||||
component: ConsentView,
|
||||
},
|
||||
{
|
||||
path: '/games',
|
||||
name: 'games',
|
||||
component: GamesView,
|
||||
},
|
||||
{
|
||||
path: '/games/:id',
|
||||
name: 'game',
|
||||
component: GameView,
|
||||
},
|
||||
{
|
||||
path: '/team-story/:id',
|
||||
name: 'team-story',
|
||||
component: TeamStoryView,
|
||||
},
|
||||
{
|
||||
path: '/scenarios',
|
||||
name: 'scenarios',
|
||||
component: ScenariosView,
|
||||
},
|
||||
{
|
||||
path: '/catalog',
|
||||
name: 'catalog',
|
||||
component: CatalogView,
|
||||
},
|
||||
{
|
||||
path: '/scenarios/:id/editor', // Динамический параметр id
|
||||
name: 'scenario-editor',
|
||||
component: ScenariosEditorView,
|
||||
},
|
||||
{
|
||||
path: '/scenarios/:id', // Динамический параметр id
|
||||
name: 'scenario',
|
||||
component: ScenarioView,
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
name: 'users',
|
||||
component: UsersView,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { type User } from '@/api/generated/crabs/evening_detective_server'
|
||||
import { buildClient } from '@/api/generated/crabs/evening_detective_server/client'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// ===== STATE =====
|
||||
const accessToken = ref<string | null>(null)
|
||||
const refreshToken = ref<string | null>(null)
|
||||
const user = ref<User | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// ===== GETTERS =====
|
||||
const isAuthenticated = computed(() => !!accessToken.value)
|
||||
const username = computed(() => user.value?.username || '')
|
||||
const userRoles = computed(() => user.value?.roles || [])
|
||||
|
||||
// ===== ACTIONS =====
|
||||
|
||||
// Инициализация из localStorage при загрузке
|
||||
function initFromStorage() {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const refresh = localStorage.getItem('refreshToken')
|
||||
|
||||
if (token) {
|
||||
accessToken.value = token
|
||||
refreshToken.value = refresh
|
||||
|
||||
// Получаем данные пользователя
|
||||
fetchUser()
|
||||
}
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
return accessToken.value
|
||||
}
|
||||
|
||||
// Получение данных пользователя
|
||||
async function fetchUser() {
|
||||
if (!accessToken.value) return
|
||||
|
||||
try {
|
||||
const client = buildClient(getToken)
|
||||
const response = await client.GetMe({})
|
||||
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
return
|
||||
}
|
||||
|
||||
if (response.user) {
|
||||
user.value = response.user
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch user:', err)
|
||||
// Если 401 — токен протух
|
||||
if (err instanceof Error && err.message.includes('401')) {
|
||||
await logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Логин
|
||||
async function login(email: string, password: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = buildClient(getToken)
|
||||
const response = await client.Login({ email, password })
|
||||
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
return false
|
||||
}
|
||||
|
||||
// Сохраняем токены
|
||||
if (response.accessToken) {
|
||||
accessToken.value = response.accessToken
|
||||
refreshToken.value = response.refreshToken || null
|
||||
|
||||
// Сохраняем в localStorage
|
||||
localStorage.setItem('accessToken', response.accessToken)
|
||||
if (response.refreshToken) {
|
||||
localStorage.setItem('refreshToken', response.refreshToken)
|
||||
}
|
||||
|
||||
// Получаем данные пользователя
|
||||
await fetchUser()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Login failed'
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Обновление токена
|
||||
async function refreshTokenAction() {
|
||||
if (!refreshToken.value) return false
|
||||
|
||||
try {
|
||||
const client = buildClient(getToken)
|
||||
const response = await client.Refresh({
|
||||
refreshToken: refreshToken.value,
|
||||
})
|
||||
|
||||
if (response.error || !response.accessToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Обновляем токены
|
||||
accessToken.value = response.accessToken
|
||||
refreshToken.value = response.refreshToken || null
|
||||
|
||||
localStorage.setItem('accessToken', response.accessToken)
|
||||
if (response.refreshToken) {
|
||||
localStorage.setItem('refreshToken', response.refreshToken)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Регистрация
|
||||
async function signup(
|
||||
username: string,
|
||||
email: string,
|
||||
acceptTerms: boolean,
|
||||
acceptPrivacy: boolean,
|
||||
) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = buildClient(getToken)
|
||||
const response = await client.Signup({ username, email, acceptTerms, acceptPrivacy })
|
||||
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Signup failed'
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Удаление аккаунта (подтверждение владельца паролем, ст. 21 152-ФЗ)
|
||||
async function deleteAccount(password: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
// Пароль передаётся HTTP-заголовком X-Password, а не в URL или body
|
||||
const client = buildClient(getToken, () => ({ 'X-Password': password }))
|
||||
const response = await client.DeleteAccount({})
|
||||
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
return false
|
||||
}
|
||||
|
||||
await logout()
|
||||
return true
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Account deletion failed'
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Восстановление пароля
|
||||
async function refreshPassword(email: string) {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const client = buildClient(getToken)
|
||||
const response = await client.RefreshPassword({ email })
|
||||
|
||||
if (response.error) {
|
||||
error.value = response.error
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Password reset failed'
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Выход
|
||||
async function logout() {
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
user.value = null
|
||||
error.value = null
|
||||
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
|
||||
// Проверка ролей
|
||||
function hasRole(role: string): boolean {
|
||||
return userRoles.value.includes(role)
|
||||
}
|
||||
|
||||
function hasAnyRole(roles: string[]): boolean {
|
||||
return roles.some((role) => userRoles.value.includes(role))
|
||||
}
|
||||
|
||||
// ===== RETURN =====
|
||||
return {
|
||||
// State
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Getters
|
||||
isAuthenticated,
|
||||
username,
|
||||
userRoles,
|
||||
|
||||
// Actions
|
||||
initFromStorage,
|
||||
getToken,
|
||||
login,
|
||||
signup,
|
||||
deleteAccount,
|
||||
refreshPassword,
|
||||
logout,
|
||||
fetchUser,
|
||||
refreshTokenAction,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
}
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import CatalogPage from '@/components/CatalogPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CatalogPage />
|
||||
</template>
|
||||
|
||||
<style lang="css"></style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import DocsPage from '@/components/DocsPage.vue'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DocsPage kind="consent" />
|
||||
|
||||
<HeaderMenu />
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import GamePage from '@/components/GamePage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GamePage />
|
||||
</template>
|
||||
|
||||
<style lang="css"></style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import GamesPage from '@/components/GamesPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<GamesPage />
|
||||
</template>
|
||||
|
||||
<style lang="css"></style>
|
||||
@@ -1,9 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import TheWelcome from '../components/TheWelcome.vue'
|
||||
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<TheWelcome />
|
||||
</main>
|
||||
<div class="center-block-custom content-block center-middle-block-custom height80">
|
||||
<div>
|
||||
<h1 class="font-text">Вечерний детектив</h1>
|
||||
<h3 class="font-text">Дело №0</h3>
|
||||
<p class="content-text">
|
||||
Поиграем?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeaderMenu active="main" />
|
||||
</template>
|
||||
|
||||
<style lang="css">
|
||||
.font-text {
|
||||
font-family: 'font_old_typer';
|
||||
}
|
||||
|
||||
.content-text {
|
||||
position: relative;
|
||||
top: 100px;
|
||||
right: 0;
|
||||
float: right;
|
||||
}
|
||||
|
||||
@media (width >=1024px) {
|
||||
.font-text {
|
||||
position: relative;
|
||||
left: -50px;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
position: relative;
|
||||
top: 100px;
|
||||
right: -50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
import LoginPage from '@/components/LoginPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginPage />
|
||||
|
||||
|
||||
<HeaderMenu />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import OfficePage from '@/components/OfficePage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<OfficePage />
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import DocsPage from '@/components/DocsPage.vue'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DocsPage kind="privacy" />
|
||||
|
||||
<HeaderMenu />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ScenarioPage from '@/components/ScenarioPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScenarioPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ScenariosEditorPage from '@/components/ScenariosEditorPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScenariosEditorPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ScenariosPage from '@/components/ScenariosPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ScenariosPage />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import TeamStoryPage from '@/components/TeamStoryPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TeamStoryPage />
|
||||
</template>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import DocsPage from '@/components/DocsPage.vue'
|
||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DocsPage kind="terms" />
|
||||
|
||||
<HeaderMenu />
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import UsersPage from '@/components/UsersPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UsersPage />
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
export default {
|
||||
extends: [
|
||||
'stylelint-config-standard-scss', // Базовые правила для SCSS и CSS
|
||||
],
|
||||
// Добавляем поддержку Vue файлов
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
customSyntax: 'postcss-html',
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
// Здесь можно переопределить правила
|
||||
'order/properties-alphabetical-order': true, // Сортировка свойств
|
||||
// 'selector-class-pattern': '^[a-z][a-zA-Z0-9]+$', // Названия классов в camelCase
|
||||
},
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
|
||||