This commit is contained in:
2026-09-02 00:59:46 +07:00
parent 690efbbbf5
commit 30ffe8ea08
45 changed files with 2138 additions and 1643 deletions
+14
View File
@@ -0,0 +1,14 @@
# Сборка и зависимости
dist
dist-ssr
node_modules
# Генерируемые и lock-файлы
package-lock.json
src/api/generated
proto
# Документы, которые форматируются в отдельных задачах / вручную
AGENTS.md
README.md
docker-compose.yml
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -1,12 +1,12 @@
<!DOCTYPE html> <!doctype html>
<html lang=""> <html lang="">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Вечерний детектив</title> <title>Вечерний детектив</title>
<script type="module" crossorigin src="/assets/index-DGX5lmmj.js"></script> <script type="module" crossorigin src="/assets/index-DIvjQ2Ad.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dd0gvwlX.css"> <link rel="stylesheet" crossorigin href="/assets/index-DlrAb_dw.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+4 -4
View File
@@ -1,8 +1,8 @@
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript' import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import { globalIgnores } from 'eslint/config' import { globalIgnores } from 'eslint/config'
import simpleImportSort from 'eslint-plugin-simple-import-sort'; import simpleImportSort from 'eslint-plugin-simple-import-sort'
import unusedImports from 'eslint-plugin-unused-imports'; import unusedImports from 'eslint-plugin-unused-imports'
import pluginVue from 'eslint-plugin-vue' import pluginVue from 'eslint-plugin-vue'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines: // To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
@@ -41,7 +41,7 @@ export default defineConfigWithVueTs(
'@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'off',
// Включаем правила плагина // Включаем правила плагина
'unused-imports/no-unused-imports': 'error', // 🔧 автофикс 'unused-imports/no-unused-imports': 'error', // 🔧 автофикс
'unused-imports/no-unused-vars': [ 'unused-imports/no-unused-vars': [
'warn', 'warn',
{ {
@@ -52,5 +52,5 @@ export default defineConfigWithVueTs(
}, },
], ],
}, },
} },
) )
+4 -4
View File
@@ -1,9 +1,9 @@
<!DOCTYPE html> <!doctype html>
<html lang=""> <html lang="">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Вечерний детектив</title> <title>Вечерний детектив</title>
</head> </head>
<body> <body>
+28
View File
@@ -40,6 +40,7 @@
"prettier": "3.9.4", "prettier": "3.9.4",
"stylelint": "^17.14.1", "stylelint": "^17.14.1",
"stylelint-config-standard-scss": "^17.0.0", "stylelint-config-standard-scss": "^17.0.0",
"stylelint-order": "^8.1.1",
"ts-proto": "^2.12.0", "ts-proto": "^2.12.0",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"vfonts": "^0.0.3", "vfonts": "^0.0.3",
@@ -5321,6 +5322,16 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/postcss-sorting": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-10.0.0.tgz",
"integrity": "sha512-TXbU+h6vVRW+86c/+ewhWq9k7pr7ijASTnepVhCQiC87zAOTkvB1v2dHyWP+ggstSTX/PNvjzS+IOqzejndz9w==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"postcss": "^8.4.20"
}
},
"node_modules/postcss-value-parser": { "node_modules/postcss-value-parser": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
@@ -5915,6 +5926,23 @@
} }
} }
}, },
"node_modules/stylelint-order": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-8.1.1.tgz",
"integrity": "sha512-LqsEB6VggJuu5v10RtkrQsBObcdwBE7GuAOlwfc/LR3VL/w8UqKX2BOLIjhyGt0Gne/njo7gRNGiJAKhfmPMNw==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss": "^8.5.8",
"postcss-sorting": "^10.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"stylelint": "^16.18.0 || ^17.0.0"
}
},
"node_modules/stylelint-scss": { "node_modules/stylelint-scss": {
"version": "7.2.0", "version": "7.2.0",
"resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-7.2.0.tgz", "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-7.2.0.tgz",
+2 -1
View File
@@ -11,7 +11,7 @@
"type-check": "vue-tsc --build", "type-check": "vue-tsc --build",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"lint:css": "stylelint \"src/**/*.{css,scss,vue}\" --fix", "lint:css": "stylelint \"src/**/*.{css,scss,vue}\" --fix",
"format": "prettier --write src/", "format": "prettier --write .",
"proto:gen": "protoc --typescript-http_out=src/api/generated --proto_path=proto proto/main.proto" "proto:gen": "protoc --typescript-http_out=src/api/generated --proto_path=proto proto/main.proto"
}, },
"dependencies": { "dependencies": {
@@ -47,6 +47,7 @@
"prettier": "3.9.4", "prettier": "3.9.4",
"stylelint": "^17.14.1", "stylelint": "^17.14.1",
"stylelint-config-standard-scss": "^17.0.0", "stylelint-config-standard-scss": "^17.0.0",
"stylelint-order": "^8.1.1",
"ts-proto": "^2.12.0", "ts-proto": "^2.12.0",
"typescript": "~6.0.3", "typescript": "~6.0.3",
"vfonts": "^0.0.3", "vfonts": "^0.0.3",
-2
View File
@@ -11,5 +11,3 @@ import { RouterView } from 'vue-router'
</n-message-provider> </n-message-provider>
</n-config-provider> </n-config-provider>
</template> </template>
<style scoped></style>
+3 -5
View File
@@ -20,10 +20,7 @@ export function getArchiveClient() {
return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken) return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken)
} }
function buildArchiveClient( function buildArchiveClient(refreshTokens: () => Promise<boolean>, getToken: () => string | null) {
refreshTokens: () => Promise<boolean>,
getToken: () => string | null,
) {
async function request(path: string, init: RequestInit): Promise<Response> { async function request(path: string, init: RequestInit): Promise<Response> {
const headers: Record<string, string> = {} const headers: Record<string, string> = {}
const token = getToken() const token = getToken()
@@ -59,7 +56,8 @@ function buildArchiveClient(
} }
const blob = await response.blob() const blob = await response.blob()
const filename = const filename =
parseContentDispositionFilename(response.headers.get('Content-Disposition')) || `scenario-${id}.zip` parseContentDispositionFilename(response.headers.get('Content-Disposition')) ||
`scenario-${id}.zip`
return { blob, filename } return { blob, filename }
} }
+9 -9
View File
@@ -51,18 +51,13 @@
*::before, *::before,
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0;
font-weight: normal; font-weight: normal;
margin: 0;
} }
body { body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background); background: var(--color-background);
transition: color: var(--color-text);
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family: font-family:
Inter, Inter,
-apple-system, -apple-system,
@@ -77,7 +72,12 @@ body {
'Helvetica Neue', 'Helvetica Neue',
sans-serif; sans-serif;
font-size: 15px; font-size: 15px;
text-rendering: optimizelegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} line-height: 1.6;
min-height: 100vh;
text-rendering: optimizelegibility;
transition:
color 0.5s,
background-color 0.5s;
}
+10 -10
View File
@@ -1,9 +1,9 @@
@import './base.css'; @import './base.css';
body { body {
overflow: hidden;
color: #eee;
background-color: #111; background-color: #111;
color: #eee;
overflow: hidden;
scrollbar-width: none; scrollbar-width: none;
} }
@@ -21,9 +21,9 @@ body {
} }
.center-block-custom { .center-block-custom {
height: calc(100vh - 150px);
margin-top: 70px; margin-top: 70px;
overflow-y: auto; overflow-y: auto;
height: calc(100vh - 150px);
/* Firefox */ /* Firefox */
scrollbar-width: none; scrollbar-width: none;
@@ -35,13 +35,13 @@ body {
} }
.width700 { .width700 {
padding: 10px;
margin: 0 auto; margin: 0 auto;
padding: 10px;
} }
.width1200 { .width1200 {
padding: 10px;
margin: 0 auto; margin: 0 auto;
padding: 10px;
} }
.display-table { .display-table {
@@ -52,20 +52,20 @@ body {
@media (width >=1024px) { @media (width >=1024px) {
.center-block-custom { .center-block-custom {
margin: 70px auto 0;
height: calc(100dvh - 70px); height: calc(100dvh - 70px);
margin: 70px auto 0;
} }
.width700 { .width700 {
width: 700px;
padding: 20px;
margin: 0 auto; margin: 0 auto;
padding: 20px;
width: 700px;
} }
.width1200 { .width1200 {
width: 1200px;
padding: 20px;
margin: 0 auto; margin: 0 auto;
padding: 20px;
width: 1200px;
} }
.center-middle-block-custom { .center-middle-block-custom {
+6 -6
View File
@@ -12,23 +12,23 @@
<style scoped> <style scoped>
.belt-block { .belt-block {
height: 100%;
background-color: rgb(0 0 0); background-color: rgb(0 0 0);
background-image: url("@/assets/images/belt.png"); background-image: url('@/assets/images/belt.png');
background-size: cover; background-size: cover;
height: 100%;
position: relative; position: relative;
} }
.shadow { .shadow {
height: 10px;
width: 100%;
position: absolute;
background-color: black; background-color: black;
height: 10px;
position: absolute;
width: 100%;
} }
.shadow-top { .shadow-top {
top: 0;
box-shadow: 0 -5px 10px black; box-shadow: 0 -5px 10px black;
top: 0;
} }
.shadow-bottom { .shadow-bottom {
+64 -59
View File
@@ -14,110 +14,115 @@ const scenarios = ref<Scenario[]>([])
const router = useRouter() const router = useRouter()
async function getScenarios() { async function getScenarios() {
scenarios.value = [] scenarios.value = []
const res = await client.GetScenariosCatalog({}) const res = await client.GetScenariosCatalog({})
scenarios.value = res.scenarios! scenarios.value = res.scenarios!
} }
async function toScenarioInfo(id: number) { async function toScenarioInfo(id: number) {
router.push('/scenarios/' + id) router.push('/scenarios/' + id)
} }
getScenarios() getScenarios()
</script> </script>
<template> <template>
<div class="center-block-custom"> <div class="center-block-custom">
<div class="width1200 display-table"> <div class="width1200 display-table">
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioInfo(scenario.id!)" <div
v-bind:key="scenario.id"> class="scenario-block"
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"> v-for="scenario in scenarios"
</div> @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"> <div class="scenario-content-block">
<p class="scenario-title">{{ scenario.name }}</p> <p class="scenario-title">{{ scenario.name }}</p>
<p class="scenario-description">{{ scenario.description }}</p> <p class="scenario-description">{{ scenario.description }}</p>
<p class="scenario-label"> <p class="scenario-label">
<n-tag :bordered="false" type="success" class="status-block">Автор: {{ <n-tag :bordered="false" type="success" class="status-block"
scenario.author?.username >Автор: {{ scenario.author?.username }}</n-tag
}}</n-tag> >
</p> </p>
</div>
</div>
</div> </div>
</div>
</div> </div>
</div>
<HeaderMenu active="catalog" /> <HeaderMenu active="catalog" />
</template> </template>
<style scoped> <style scoped>
.scenario-block { .scenario-block {
position: relative; /* background-color: #553333; */
margin: 10px; background-color: #222;
width: 160px; border: 2px solid #222;
height: 300px; border-radius: 12px;
cursor: pointer; cursor: pointer;
border-radius: 12px; height: 300px;
margin: 10px;
/* background-color: #553333; */ position: relative;
background-color: #222; width: 160px;
border: 2px solid #222;
} }
.scenario-content-block { .scenario-content-block {
padding: 2px 10px; padding: 2px 10px;
} }
.scenario-block:hover { .scenario-block:hover {
color: #63e2b7; border: 2px solid #63e2b7;
border: 2px solid #63e2b7; color: #63e2b7;
} }
.scenario-image-block { .scenario-image-block {
background-color: #111; background-color: #111;
height: 160px; border-radius: 10px;
border-radius: 10px; height: 160px;
} }
.scenario-image { .scenario-image {
background-size: cover; background-position: center;
background-position: center; background-size: cover;
} }
.scenario-title { .scenario-title {
padding: 5px 0; padding: 5px 0;
} }
.scenario-description { .scenario-description {
color: #aaa; color: #aaa;
display: none; display: none;
} }
.status-block { .status-block {
position: absolute; bottom: 15px;
bottom: 15px; position: absolute;
right: 15px; right: 15px;
} }
@media (width >=1024px) { @media (width >=1024px) {
.scenario-block { .scenario-block {
width: 250px; height: 400px;
height: 400px; width: 250px;
} }
.scenario-image-block { .scenario-image-block {
height: 250px; height: 250px;
} }
.scenario-content-block { .scenario-content-block {
padding: 4px 10px; padding: 4px 10px;
} }
.scenario-description { .scenario-description {
color: #aaa; color: #aaa;
display: block; display: block;
} }
} }
</style> </style>
+21 -19
View File
@@ -72,7 +72,9 @@ onMounted(async () => {
· ·
<RouterLink to="/privacy-policy" class="docs-link">Политика конфиденциальности</RouterLink> <RouterLink to="/privacy-policy" class="docs-link">Политика конфиденциальности</RouterLink>
· ·
<RouterLink to="/consent" class="docs-link">Согласие на обработку персональных данных</RouterLink> <RouterLink to="/consent" class="docs-link"
>Согласие на обработку персональных данных</RouterLink
>
</div> </div>
</div> </div>
</div> </div>
@@ -104,17 +106,17 @@ onMounted(async () => {
} }
.paper { .paper {
position: relative;
z-index: 2;
padding: 32px 28px;
border-radius: 3px;
background-image: url('@/assets/images/paper_white.jpg'); background-image: url('@/assets/images/paper_white.jpg');
background-size: cover; background-size: cover;
color: #1d1d1d; border-radius: 3px;
box-shadow: 0 0 12px rgb(0 0 0 / 70%); box-shadow: 0 0 12px rgb(0 0 0 / 70%);
color: #1d1d1d;
max-height: 60vh; max-height: 60vh;
overflow-y: auto; overflow-y: auto;
padding: 32px 28px;
position: relative;
scrollbar-width: none; scrollbar-width: none;
z-index: 2;
} }
.paper::-webkit-scrollbar { .paper::-webkit-scrollbar {
@@ -122,44 +124,44 @@ onMounted(async () => {
} }
.doc-text { .doc-text {
font-family: 'font_old_typer'; font-family: 'font_old_typer', serif;
font-size: 17px; font-size: 17px;
line-height: 1.65; line-height: 1.65;
white-space: pre-wrap; overflow-wrap: anywhere;
word-break: break-word;
text-align: justify; text-align: justify;
white-space: pre-wrap;
} }
.paper-shadow { .paper-shadow {
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
border-radius: 3px;
height: 100%;
position: absolute; position: absolute;
top: 0; top: 0;
width: 100%; width: 100%;
height: 100%;
border-radius: 3px;
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
} }
.paper-shadow-left { .paper-shadow-left {
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
filter: brightness(60%);
left: 0; left: 0;
transform: rotate(-2.2deg); transform: rotate(-2.2deg);
filter: brightness(60%);
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
} }
.paper-shadow-right { .paper-shadow-right {
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
filter: brightness(75%);
right: 0; right: 0;
transform: rotate(2.2deg); transform: rotate(2.2deg);
filter: brightness(75%);
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
} }
.docs-links { .docs-links {
color: #999;
font-size: 15px;
margin-top: 28px; margin-top: 28px;
padding-bottom: 20px; padding-bottom: 20px;
text-align: center; text-align: center;
font-size: 15px;
color: #999;
} }
.docs-link { .docs-link {
+77 -55
View File
@@ -2,31 +2,31 @@
import { Footsteps } from '@vicons/ionicons5' import { Footsteps } from '@vicons/ionicons5'
import { ContactMailRound } from '@vicons/material' import { ContactMailRound } from '@vicons/material'
import { Icon } from '@vicons/utils' import { Icon } from '@vicons/utils'
import { type PropType, ref } from 'vue'; import { type PropType, ref } from 'vue'
import BeltBlock from './BeltBlock.vue'; import BeltBlock from './BeltBlock.vue'
import HeaderText from './HeaderText.vue'; import HeaderText from './HeaderText.vue'
import MetalPlate from './MetalPlate.vue'; import MetalPlate from './MetalPlate.vue'
const code = ref('') const code = ref('')
const props = defineProps({ const props = defineProps({
addAction: { addAction: {
type: Function as PropType<(param: string) => void>, type: Function as PropType<(param: string) => void>,
required: true required: true,
}, },
allPlacesCount: { allPlacesCount: {
type: Number, type: Number,
required: true required: true,
}, },
newPlacesCount: { newPlacesCount: {
type: Number, type: Number,
required: true required: true,
}, },
gameStatus: { gameStatus: {
type: String, type: String,
required: true required: true,
} },
}) })
async function addClickButton() { async function addClickButton() {
@@ -51,7 +51,6 @@ function getTextForCounter(count: number): string[] {
return [text1, text2] return [text1, text2]
} }
</script> </script>
<template> <template>
@@ -62,7 +61,6 @@ function getTextForCounter(count: number): string[] {
<div class="center-block-700"> <div class="center-block-700">
<div class="controller"> <div class="controller">
<div class="counters-block"> <div class="counters-block">
<div class="counter-block"> <div class="counter-block">
<div class="places-text"> <div class="places-text">
@@ -70,7 +68,13 @@ function getTextForCounter(count: number): string[] {
<Footsteps /> <Footsteps />
</Icon> </Icon>
</div> </div>
<div v-for="(n, index) in getTextForCounter(allPlacesCount)" :key="index" class="places-text places-text-number">{{ n }}</div> <div
v-for="(n, index) in getTextForCounter(allPlacesCount)"
:key="index"
class="places-text places-text-number"
>
{{ n }}
</div>
</div> </div>
<div class="counter-block"> <div class="counter-block">
<div class="places-text"> <div class="places-text">
@@ -78,19 +82,37 @@ function getTextForCounter(count: number): string[] {
<ContactMailRound /> <ContactMailRound />
</Icon> </Icon>
</div> </div>
<div v-for="(n, index) in getTextForCounter(newPlacesCount)" :key="index" class="places-text places-text-number">{{ n }}</div> <div
v-for="(n, index) in getTextForCounter(newPlacesCount)"
:key="index"
class="places-text places-text-number"
>
{{ n }}
</div>
</div> </div>
</div> </div>
<div class="game-input"> <div class="game-input">
<input id="run" class="game-input-run" v-model="code" type="text" <input
id="run"
class="game-input-run"
v-model="code"
type="text"
:placeholder="props.gameStatus == 'run' ? 'Место назначения' : 'Ходы запрещены'" :placeholder="props.gameStatus == 'run' ? 'Место назначения' : 'Ходы запрещены'"
:disabled="props.gameStatus != 'run'"> :disabled="props.gameStatus != 'run'"
/>
</div> </div>
<div class="game-button-run-shadow"></div> <div class="game-button-run-shadow"></div>
<button class="game-button-run" type="submit" :disabled="code.length == 0" v-on:click="addClickButton"> <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 == 'run'">Поехали</HeaderText>
<HeaderText v-if="props.gameStatus == 'pause' || props.gameStatus == 'draft'">Ждём</HeaderText> <HeaderText v-if="props.gameStatus == 'pause' || props.gameStatus == 'draft'"
>Ждём</HeaderText
>
<HeaderText v-if="props.gameStatus == 'finish'">Всё</HeaderText> <HeaderText v-if="props.gameStatus == 'finish'">Всё</HeaderText>
</button> </button>
</div> </div>
@@ -101,27 +123,27 @@ function getTextForCounter(count: number): string[] {
<style scoped> <style scoped>
.form-custom { .form-custom {
position: fixed;
bottom: 0; bottom: 0;
left: 0;
width: 100%;
color: white; color: white;
left: 0;
position: fixed;
width: 100%;
z-index: 1000; z-index: 1000;
} }
.input-form { .input-form {
height: 76px; height: 76px;
position: relative;
z-index: 1000;
margin: 0 auto; margin: 0 auto;
max-width: 1920px; max-width: 1920px;
position: relative;
z-index: 1000;
} }
.controller-metal { .controller-metal {
width: 30px;
height: calc(100% + 2px); height: calc(100% + 2px);
position: absolute; position: absolute;
top: -1px; top: -1px;
width: 30px;
} }
.controller-metal-left { .controller-metal-left {
@@ -138,23 +160,23 @@ function getTextForCounter(count: number): string[] {
} }
.game-input { .game-input {
height: 50px;
left: 15px;
position: relative; position: relative;
top: 14px; top: 14px;
left: 15px;
height: 50px;
width: calc(100% - 150px - 25px); width: calc(100% - 150px - 25px);
} }
.game-input-run { .game-input-run {
height: 100%; background-color: transparent;
width: 100%; background-image: url('@/assets/images/input_center.png');
padding-left: 27px;
background-image: url("@/assets/images/input_center.png");
background-size: cover; background-size: cover;
border: 0; border: 0;
font-family: 'font_old_typer', serif;
font-size: 18px; font-size: 18px;
font-family: 'font_old_typer'; height: 100%;
background-color: transparent; padding-left: 27px;
width: 100%;
} }
.game-input-run::placeholder { .game-input-run::placeholder {
@@ -167,27 +189,27 @@ function getTextForCounter(count: number): string[] {
} }
.game-button-run-shadow { .game-button-run-shadow {
box-shadow: -5px 5px 10px black;
height: 80px;
position: absolute; position: absolute;
right: 10px; right: 10px;
top: -5px; top: -5px;
height: 80px;
width: 150px; width: 150px;
box-shadow: -5px 5px 10px black;
} }
.game-button-run { .game-button-run {
background-image: url("@/assets/images/button.png"); background-color: transparent;
background-image: url('@/assets/images/button.png');
background-size: cover; background-size: cover;
border: 0;
font-size: 1.5em; font-size: 1.5em;
height: 80px;
margin: 0;
padding: 0;
position: absolute; position: absolute;
right: 10px; right: 10px;
top: -5px; top: -5px;
height: 80px;
width: 155px; width: 155px;
border: 0;
background-color: transparent;
margin: 0;
padding: 0;
} }
.game-button-run { .game-button-run {
@@ -196,35 +218,35 @@ function getTextForCounter(count: number): string[] {
.new-places-label, .new-places-label,
.all-places-label { .all-places-label {
position: absolute;
height: 48px; height: 48px;
position: absolute;
} }
.new-places-label { .new-places-label {
top: 14px;
left: 85px; left: 85px;
top: 14px;
width: 40px; width: 40px;
} }
.all-places-label { .all-places-label {
top: 14px;
left: 15px; left: 15px;
top: 14px;
width: 60px; width: 60px;
} }
.places-text { .places-text {
background-color: #d1cebd; background-color: #d1cebd;
border: 1px solid #9e9982; background-image: url('@/assets/images/paper.jpg');
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; background-size: cover;
border: 1px solid #9e9982;
border-radius: 7px;
box-shadow: 0 -2px 5px black; box-shadow: 0 -2px 5px black;
color: #111;
display: inline-block;
font-family: 'font_old_typer', serif;
font-size: 1.4em;
margin: 0 1px;
text-align: center;
} }
.places-text-number { .places-text-number {
@@ -232,10 +254,10 @@ function getTextForCounter(count: number): string[] {
} }
.counters-block { .counters-block {
position: absolute;
top: -40px;
left: 23px; left: 23px;
margin: 20px 10px 20px 5px; margin: 20px 10px 20px 5px;
position: absolute;
top: -40px;
} }
.counter-block { .counter-block {
@@ -244,15 +266,15 @@ function getTextForCounter(count: number): string[] {
} }
.icon-for-counter { .icon-for-counter {
margin: 0 4px;
position: relative; position: relative;
top: 3px; top: 3px;
margin: 0 4px;
} }
@media (width >=1025px) { @media (width >=1025px) {
.center-block-700 { .center-block-700 {
width: 700px;
margin: 0 auto; margin: 0 auto;
width: 700px;
} }
} }
</style> </style>
File diff suppressed because it is too large Load Diff
+8 -8
View File
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui'
import { ref } from 'vue'; import { ref } from 'vue'
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'; import { getAuthClient } from '@/api/auth_client'
import type { Game } from '@/api/generated/crabs/evening_detective_server'; import type { Game } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue' import HeaderMenu from '@/components/HeaderMenu.vue'
const client = getAuthClient() const client = getAuthClient()
@@ -48,15 +48,15 @@ async function toGame(id: number) {
<style scoped> <style scoped>
.game-block { .game-block {
background-color: #222;
border-radius: 10px;
margin: 10px 0; margin: 10px 0;
padding: 20px; padding: 20px;
border-radius: 10px;
background-color: #222;
} }
.game-block:hover { .game-block:hover {
cursor: pointer;
color: #63e2b7; color: #63e2b7;
cursor: pointer;
} }
.game-title { .game-title {
+56 -27
View File
@@ -43,7 +43,6 @@ const goToLogout = () => {
} }
const openMenu = ref(false) const openMenu = ref(false)
</script> </script>
<template> <template>
@@ -53,26 +52,54 @@ const openMenu = ref(false)
<MenuOutlined /> <MenuOutlined />
</Icon> </Icon>
</div> </div>
<div class="left-group menu-buttons-block" :class="!openMenu ? 'menu-buttons-block-active' : ''"> <div
<div class="menu-item-block" :class="{ active: props.active == 'main' }" @click="router.push('/')"> 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>
<div class="menu-item-block" :class="{ active: props.active == 'office' }" v-if="authStore.hasRole('user')" <div
@click="router.push('/office')"> class="menu-item-block"
:class="{ active: props.active == 'office' }"
v-if="authStore.hasRole('user')"
@click="router.push('/office')"
>
Кабинет Кабинет
</div> </div>
<div class="menu-item-block" :class="{ active: props.active == 'games' }" v-if="hasPermission('games_page')" <div
@click="router.push('/games')"> class="menu-item-block"
:class="{ active: props.active == 'games' }"
v-if="hasPermission('games_page')"
@click="router.push('/games')"
>
Игры Игры
</div> </div>
<div class="menu-item-block" :class="{ active: props.active == 'catalog' }" @click="router.push('/catalog')"> <div
class="menu-item-block"
:class="{ active: props.active == 'catalog' }"
@click="router.push('/catalog')"
>
Каталог Каталог
</div> </div>
<div class="menu-item-block" :class="{ active: props.active == 'scenarios' }" v-if="hasPermission('scenarios_page')" @click="router.push('/scenarios')"> <div
class="menu-item-block"
:class="{ active: props.active == 'scenarios' }"
v-if="hasPermission('scenarios_page')"
@click="router.push('/scenarios')"
>
Мои сценарии Мои сценарии
</div> </div>
<div class="menu-item-block" :class="{ active: props.active == 'users' }" v-if="hasPermission('users_page')" <div
@click="router.push('/users')"> class="menu-item-block"
:class="{ active: props.active == 'users' }"
v-if="hasPermission('users_page')"
@click="router.push('/users')"
>
Пользователи Пользователи
</div> </div>
</div> </div>
@@ -81,31 +108,33 @@ const openMenu = ref(false)
<div v-if="!authStore.isAuthenticated" class="menu-item-block right" @click="goToLogin"> <div v-if="!authStore.isAuthenticated" class="menu-item-block right" @click="goToLogin">
Войти Войти
</div> </div>
<div v-if="authStore.isAuthenticated" class="menu-item-block right" @click="goToLogout">Выйти</div> <div v-if="authStore.isAuthenticated" class="menu-item-block right" @click="goToLogout">
Выйти
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.menu-block { .menu-block {
align-items: center;
background-color: #222;
display: flex;
height: 70px;
justify-content: space-between;
left: 0;
padding: 20px;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0;
width: 100%; width: 100%;
height: 70px;
background-color: #222;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
} }
.menu-item-block { .menu-item-block {
display: inline-block;
margin-left: 10px;
cursor: pointer; cursor: pointer;
padding: 5px; display: inline-block;
font-size: 1.1em; font-size: 1.1em;
margin-left: 10px;
padding: 5px;
} }
.menu-item-block:hover { .menu-item-block:hover {
@@ -129,14 +158,14 @@ const openMenu = ref(false)
@media (width < 1024px) { @media (width < 1024px) {
.menu-buttons-block { .menu-buttons-block {
position: fixed; background-color: #222;
top: 70px;
left: 0;
width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #222; left: 0;
padding-bottom: 5px; padding-bottom: 5px;
position: fixed;
top: 70px;
width: 100%;
} }
.menu-buttons-block div { .menu-buttons-block div {
+2 -2
View File
@@ -10,11 +10,11 @@
<style scoped> <style scoped>
.text-with-font { .text-with-font {
font-family: 'font_old_typer';
color: #bfa07d; color: #bfa07d;
font-family: 'font_old_typer', serif;
font-size: 22px;
font-weight: 600; font-weight: 600;
letter-spacing: 2px; letter-spacing: 2px;
line-height: 20px; line-height: 20px;
font-size: 22px;
} }
</style> </style>
+26 -15
View File
@@ -60,10 +60,12 @@ const removeAudio = () => {
</div> </div>
<!-- Просмотр вступления в редакторе: клик — в режим редактирования --> <!-- Просмотр вступления в редакторе: клик — в режим редактирования -->
<div v-if="mode == 'show-editor'" class="place-block-hover show-editor-block" @click="updateMode('edit')"> <div
<div class="message-header"> v-if="mode == 'show-editor'"
Вступление class="place-block-hover show-editor-block"
</div> @click="updateMode('edit')"
>
<div class="message-header">Вступление</div>
<hr class="hr" /> <hr class="hr" />
<div class="message-content"> <div class="message-content">
<div v-if="hasAudio" class="intro-audio-block"> <div v-if="hasAudio" class="intro-audio-block">
@@ -78,14 +80,23 @@ const removeAudio = () => {
<div v-if="mode == 'edit'" class="place-block"> <div v-if="mode == 'edit'" class="place-block">
<n-space vertical> <n-space vertical>
<p class="settings-header">Текст вступления</p> <p class="settings-header">Текст вступления</p>
<n-input v-model:value="props.introduction!.text" type="textarea" <n-input
v-model:value="props.introduction!.text"
type="textarea"
:autosize="{ minRows: 4, maxRows: 12 }" :autosize="{ minRows: 4, maxRows: 12 }"
placeholder="Дорогие сыщики! В этом деле вам предстоит..." /> placeholder="Дорогие сыщики! В этом деле вам предстоит..."
/>
<p class="settings-header">Аудио вступления</p> <p class="settings-header">Аудио вступления</p>
<div class="intro-edit-audio"> <div class="intro-edit-audio">
<n-space vertical> <n-space vertical>
<audio v-if="hasAudio" :src="audioUrl" controls preload="none" class="intro-audio"></audio> <audio
v-if="hasAudio"
:src="audioUrl"
controls
preload="none"
class="intro-audio"
></audio>
<n-text v-else depth="3">Аудио не задано</n-text> <n-text v-else depth="3">Аудио не задано</n-text>
<n-button @click="removeAudio()" :disabled="!hasAudio">Убрать аудио</n-button> <n-button @click="removeAudio()" :disabled="!hasAudio">Убрать аудио</n-button>
</n-space> </n-space>
@@ -126,17 +137,17 @@ const removeAudio = () => {
/* Панели в стиле блоков точек (см. PlaceBlock.vue) */ /* Панели в стиле блоков точек (см. PlaceBlock.vue) */
.place-block-hover, .place-block-hover,
.place-block { .place-block {
background-color: #222;
border: 1px solid #222;
border-radius: 10px;
margin: 10px 0; margin: 10px 0;
padding: 20px; padding: 20px;
border-radius: 10px;
border: 1px solid #222;
background-color: #222;
} }
.place-block-hover:hover { .place-block-hover:hover {
border: 1px solid #63e2b7;
color: #63e2b7; color: #63e2b7;
cursor: pointer; cursor: pointer;
border: 1px solid #63e2b7;
} }
.show-editor-block { .show-editor-block {
@@ -144,9 +155,9 @@ const removeAudio = () => {
} }
.hr { .hr {
margin: 10px 0;
border: dashed 1px; border: dashed 1px;
border-color: #eee; border-color: #eee;
margin: 10px 0;
} }
.message-header { .message-header {
@@ -172,16 +183,16 @@ const removeAudio = () => {
} }
.intro-audio { .intro-audio {
width: 100%;
max-width: 460px;
border-radius: 5px; border-radius: 5px;
max-width: 460px;
width: 100%;
} }
.intro-edit-audio { .intro-edit-audio {
align-items: flex-start;
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
gap: 20px; gap: 20px;
align-items: flex-start;
} }
.slot-block { .slot-block {
+33 -11
View File
@@ -84,9 +84,15 @@ async function sendNewPassword() {
<n-tab-pane name="signin" tab="Вход"> <n-tab-pane name="signin" tab="Вход">
<n-space vertical> <n-space vertical>
<div class="form-label">Почта</div> <div class="form-label">Почта</div>
<n-auto-complete v-model:value="email" :input-props="{ <n-auto-complete
autocomplete: 'disabled', v-model:value="email"
}" :options="options" placeholder="detective@mail.ru" clearable /> :input-props="{
autocomplete: 'disabled',
}"
:options="options"
placeholder="detective@mail.ru"
clearable
/>
<div class="form-label">Пароль</div> <div class="form-label">Пароль</div>
<n-input v-model:value="password" type="password" placeholder="********" /> <n-input v-model:value="password" type="password" placeholder="********" />
<div class="form-button-wrapper"> <div class="form-button-wrapper">
@@ -105,13 +111,21 @@ async function sendNewPassword() {
<div class="form-label">Позывной</div> <div class="form-label">Позывной</div>
<n-input v-model:value="username" type="text" placeholder="Шерлок" /> <n-input v-model:value="username" type="text" placeholder="Шерлок" />
<div class="form-label">Почта</div> <div class="form-label">Почта</div>
<n-auto-complete v-model:value="email" :input-props="{ <n-auto-complete
autocomplete: 'disabled', v-model:value="email"
}" :options="options" placeholder="detective@mail.ru" clearable /> :input-props="{
autocomplete: 'disabled',
}"
:options="options"
placeholder="detective@mail.ru"
clearable
/>
<div class="form-label"> <div class="form-label">
<n-checkbox v-model:checked="acceptTerms"> <n-checkbox v-model:checked="acceptTerms">
Я принимаю условия Я принимаю условия
<a href="/user-agreement" target="_blank" class="docs-link">пользовательского соглашения</a> <a href="/user-agreement" target="_blank" class="docs-link"
>пользовательского соглашения</a
>
</n-checkbox> </n-checkbox>
</div> </div>
<div class="form-label"> <div class="form-label">
@@ -124,7 +138,9 @@ async function sendNewPassword() {
<div class="form-label"> <div class="form-label">
<n-button <n-button
@click="signup" @click="signup"
:disabled="username.length == 0 || email.length == 0 || !acceptTerms || !acceptPrivacy" :disabled="
username.length == 0 || email.length == 0 || !acceptTerms || !acceptPrivacy
"
> >
<span v-if="!authStore.isLoading"> Регистрация </span> <span v-if="!authStore.isLoading"> Регистрация </span>
<span v-else> Подождите... </span> <span v-else> Подождите... </span>
@@ -137,9 +153,15 @@ async function sendNewPassword() {
<n-tab-pane name="new-password" tab="Новый пароль"> <n-tab-pane name="new-password" tab="Новый пароль">
<n-space vertical> <n-space vertical>
<div class="form-label">Почта</div> <div class="form-label">Почта</div>
<n-auto-complete v-model:value="email" :input-props="{ <n-auto-complete
autocomplete: 'disabled', v-model:value="email"
}" :options="options" placeholder="detective@mail.ru" clearable /> :input-props="{
autocomplete: 'disabled',
}"
:options="options"
placeholder="detective@mail.ru"
clearable
/>
<div class="form-button-wrapper"> <div class="form-button-wrapper">
<div class="form-label"> <div class="form-label">
<n-button @click="sendNewPassword" :disabled="email.length == 0"> <n-button @click="sendNewPassword" :disabled="email.length == 0">
-65
View File
@@ -1,65 +0,0 @@
<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>
+9 -7
View File
@@ -17,7 +17,9 @@
position: relative; position: relative;
} }
.message-cloud, .message-cloud-2, .message-cloud-3 { .message-cloud,
.message-cloud-2,
.message-cloud-3 {
background-color: bisque; background-color: bisque;
} }
@@ -35,31 +37,31 @@
.message-cloud, .message-cloud,
.message-cloud-2, .message-cloud-2,
.message-cloud-3 { .message-cloud-3 {
border-radius: 5px; background-image: url('@/assets/images/paper.jpg');
background-image: url("@/assets/images/paper.jpg");
background-size: cover; background-size: cover;
display: flow-root; border-radius: 5px;
box-shadow: 0 0 5px black; box-shadow: 0 0 5px black;
display: flow-root;
} }
.message-cloud-2, .message-cloud-2,
.message-cloud-3 { .message-cloud-3 {
left: 0;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0;
} }
.message-cloud-2 { .message-cloud-2 {
transform: rotate(-3deg);
filter: brightness(50%); filter: brightness(50%);
height: 100%; height: 100%;
transform: rotate(-3deg);
width: 100%; width: 100%;
} }
.message-cloud-3 { .message-cloud-3 {
transform: rotate(2deg);
filter: brightness(80%); filter: brightness(80%);
height: 100%; height: 100%;
transform: rotate(2deg);
width: 100%; width: 100%;
} }
</style> </style>
+10 -12
View File
@@ -1,6 +1,4 @@
<script setup lang="ts"> <script setup lang="ts"></script>
</script>
<template> <template>
<div class="plate-block"> <div class="plate-block">
@@ -16,34 +14,34 @@
<style scoped> <style scoped>
.plate-block { .plate-block {
position: relative;
box-shadow: 0 0 10px black; box-shadow: 0 0 10px black;
position: relative;
} }
.metal-plate-block { .metal-plate-block {
height: 100%; background-image: url('@/assets/images/metal.png');
background-image: url("@/assets/images/metal.png");
background-size: cover; background-size: cover;
position: relative;
border-radius: 4px; border-radius: 4px;
height: 100%;
position: relative;
} }
.pin { .pin {
width: 7px; background-image: url('@/assets/images/pin.png');
background-size: cover;
height: 7px; height: 7px;
position: absolute; position: absolute;
background-image: url("@/assets/images/pin.png"); width: 7px;
background-size: cover;
} }
.pin-top-left { .pin-top-left {
top: 3px;
left: 3px; left: 3px;
top: 3px;
} }
.pin-top-right { .pin-top-right {
top: 3px;
right: 3px; right: 3px;
top: 3px;
} }
.pin-bottom-right { .pin-bottom-right {
+150 -142
View File
@@ -33,178 +33,186 @@ type Tip = {
*/ */
function getDetectiveTip(): Tip { function getDetectiveTip(): Tip {
// ----- 1. Получаем текущую дату и определяем "настроение" ----- // ----- 1. Получаем текущую дату и определяем "настроение" -----
const now = new Date(); const now = new Date()
const dayOfWeek = now.getDay(); // 0 - Воскресенье, 1 - Понедельник, ... const dayOfWeek = now.getDay() // 0 - Воскресенье, 1 - Понедельник, ...
const dayOfMonth = now.getDate(); const dayOfMonth = now.getDate()
const month = now.getMonth() + 1; // Январь = 1, Декабрь = 12 const month = now.getMonth() + 1 // Январь = 1, Декабрь = 12
const isMonday = dayOfWeek === 1; const isMonday = dayOfWeek === 1
const isFriday13 = dayOfWeek === 5 && dayOfMonth === 13; const isFriday13 = dayOfWeek === 5 && dayOfMonth === 13
const isNewYear = (month === 1 && dayOfMonth === 1) || (month === 12 && dayOfMonth === 31); const isNewYear = (month === 1 && dayOfMonth === 1) || (month === 12 && dayOfMonth === 31)
const isSummer = month >= 6 && month <= 8; const isSummer = month >= 6 && month <= 8
const isWinter = month === 12 || month <= 2; const isWinter = month === 12 || month <= 2
const isFullMoonJoke = dayOfMonth % 5 === 0; // Просто для разнообразия const isFullMoonJoke = dayOfMonth % 5 === 0 // Просто для разнообразия
// ----- 2. База из 100 советов ----- // ----- 2. База из 100 советов -----
const tips: string[] = [ const tips: string[] = [
// 1-10 // 1-10
"Всегда носи с собой запасную ручку — признания любят записывать в самый неподходящий момент.", 'Всегда носи с собой запасную ручку — признания любят записывать в самый неподходящий момент.',
"Если подозреваемый слишком вежлив, он либо дипломат, либо прячет труп в гараже.", 'Если подозреваемый слишком вежлив, он либо дипломат, либо прячет труп в гараже.',
"Кофе в участке — это не напиток, это яд для улик. Пей только свой.", 'Кофе в участке — это не напиток, это яд для улик. Пей только свой.',
"Следы на месте преступления всегда ведут к тому, кто забыл закрыть дверь.", 'Следы на месте преступления всегда ведут к тому, кто забыл закрыть дверь.',
"Лучший способ разговорить молчуна — предложить ему чай с тремя ложками сахара.", 'Лучший способ разговорить молчуна — предложить ему чай с тремя ложками сахара.',
"Если всё указывает на дворецкого, ищи того, кто нанимал дворецкого.", 'Если всё указывает на дворецкого, ищи того, кто нанимал дворецкого.',
"Записывай каждую мелочь. Особенно цвет носков убитого.", 'Записывай каждую мелочь. Особенно цвет носков убитого.',
"Не верь алиби, которое начинается со слов 'Я бы никогда...'", "Не верь алиби, которое начинается со слов 'Я бы никогда...'",
"Преступник всегда возвращается на место преступления. Или за забытым телефоном.", 'Преступник всегда возвращается на место преступления. Или за забытым телефоном.',
"Ищи того, кому есть что терять, кроме своего времени.", 'Ищи того, кому есть что терять, кроме своего времени.',
// 11-20 // 11-20
"Если женщина плачет в кабинете — дай ей платок. Если мужчина — дай ему стакан воды.", 'Если женщина плачет в кабинете — дай ей платок. Если мужчина — дай ему стакан воды.',
"Самое опасное оружие — не пистолет, а бухгалтерская книга.", 'Самое опасное оружие — не пистолет, а бухгалтерская книга.',
"Собака всегда знает убийцу. Но она не говорит, потому что у неё контракт.", 'Собака всегда знает убийцу. Но она не говорит, потому что у неё контракт.',
"Проверяй чеки, билеты и смс. Ложь не терпит документации.", 'Проверяй чеки, билеты и смс. Ложь не терпит документации.',
"Если у тела нет врагов — ты плохо искал врагов.", 'Если у тела нет врагов — ты плохо искал врагов.',
"Лупа нужна не для увеличения улик, а для того, чтобы выглядеть умнее.", 'Лупа нужна не для увеличения улик, а для того, чтобы выглядеть умнее.',
"Интуиция — это когда ты уже знаешь ответ, но хочешь убедиться, что не ошибся.", 'Интуиция — это когда ты уже знаешь ответ, но хочешь убедиться, что не ошибся.',
"Подозревай всех, даже самого себя. Иногда преступник — это ты в прошлом.", 'Подозревай всех, даже самого себя. Иногда преступник — это ты в прошлом.',
"Хороший детектив видит улики. Великий — видит их отсутствие.", 'Хороший детектив видит улики. Великий — видит их отсутствие.',
"Никогда не допрашивай свидетеля на голодный желудок. И себя тоже.", 'Никогда не допрашивай свидетеля на голодный желудок. И себя тоже.',
// 21-30 // 21-30
"Если дело зашло в тупик, переверни бумаги вверх ногами.", 'Если дело зашло в тупик, переверни бумаги вверх ногами.',
"Самый ценный свидетель — это камера наблюдения, у которой сломана защита.", 'Самый ценный свидетель — это камера наблюдения, у которой сломана защита.',
"Не ищи мотив. Ищи возможность.", 'Не ищи мотив. Ищи возможность.',
"Преступник не оставляет следов. Он оставляет выбор.", 'Преступник не оставляет следов. Он оставляет выбор.',
"Если дверь была закрыта изнутри — ищи люк, который открывается снаружи.", 'Если дверь была закрыта изнутри — ищи люк, который открывается снаружи.',
"Лучший детектив — тот, кого не замечают в толпе.", 'Лучший детектив — тот, кого не замечают в толпе.',
"Учись читать по губам. Пригодится на допросах с адвокатом.", 'Учись читать по губам. Пригодится на допросах с адвокатом.',
"Запах духов на месте преступления важнее запаха пороха.", 'Запах духов на месте преступления важнее запаха пороха.',
"Если ты нашел отпечаток пальца — проверь, не твой ли он.", 'Если ты нашел отпечаток пальца — проверь, не твой ли он.',
"Раскрыть дело — это искусство. Не раскрыть — тоже искусство, но другое.", 'Раскрыть дело — это искусство. Не раскрыть — тоже искусство, но другое.',
// 31-40 // 31-40
"Никогда не спорь с баллистикой. Она всегда права, даже если ты не согласен.", 'Никогда не спорь с баллистикой. Она всегда права, даже если ты не согласен.',
"В каждом деле есть одна странная деталь. Найди её — и пазл сложится.", 'В каждом деле есть одна странная деталь. Найди её — и пазл сложится.',
"Если преступник умный — он оставит ложный след. Если глупый — он оставит следы в соцсетях.", 'Если преступник умный — он оставит ложный след. Если глупый — он оставит следы в соцсетях.',
"Лучший друг детектива — тишина. Второй друг — кофе.", 'Лучший друг детектива — тишина. Второй друг — кофе.',
"Если свидетель говорит слишком много, он врет. Если молчит — он тоже врет.", 'Если свидетель говорит слишком много, он врет. Если молчит — он тоже врет.',
"Преступление редко бывает идеальным. Идеальным бывает только алиби жены.", 'Преступление редко бывает идеальным. Идеальным бывает только алиби жены.',
"Всегда смотри на часы на трупе. Возможно, они показывают время, когда он перестал верить в людей.", 'Всегда смотри на часы на трупе. Возможно, они показывают время, когда он перестал верить в людей.',
"Воры любят темноту. Детективы — свет. А маньяки — полную луну.", 'Воры любят темноту. Детективы — свет. А маньяки — полную луну.',
"Если ты поймал преступника, не радуйся раньше времени — может, он просто лжец.", 'Если ты поймал преступника, не радуйся раньше времени — может, он просто лжец.',
"В каждом городе есть одно место, где собираются все сплетники. Начни оттуда.", 'В каждом городе есть одно место, где собираются все сплетники. Начни оттуда.',
// 41-50 // 41-50
"Не верь в совпадения. Но верь в удачу.", 'Не верь в совпадения. Но верь в удачу.',
"Если у тебя нет улик — придумай их. Но только мысленно, чтобы знать, где искать.", 'Если у тебя нет улик — придумай их. Но только мысленно, чтобы знать, где искать.',
"Жертва всегда знала убийцу. Даже если она не знала, что знает.", 'Жертва всегда знала убийцу. Даже если она не знала, что знает.',
"Проверяй алиби на разрыв. Если оно выдерживает — ты ошибся.", 'Проверяй алиби на разрыв. Если оно выдерживает — ты ошибся.',
"Самый страшный враг детектива — не маньяк, а его собственная лень.", 'Самый страшный враг детектива — не маньяк, а его собственная лень.',
"Если преступник оставил записку — прочитай её. И сожги, чтобы никто не узнал твой почерк.", 'Если преступник оставил записку — прочитай её. И сожги, чтобы никто не узнал твой почерк.',
"Всегда носи с собой фонарик. Даже днём. Для эффекта.", 'Всегда носи с собой фонарик. Даже днём. Для эффекта.',
"В деле важно всё: от укуса комара до размера обуви.", 'В деле важно всё: от укуса комара до размера обуви.',
"Если ты думаешь, что дело простое — значит, ты не додумал.", 'Если ты думаешь, что дело простое — значит, ты не додумал.',
"Убийца всегда ищет внимания. Или наоборот, избегает его.", 'Убийца всегда ищет внимания. Или наоборот, избегает его.',
// 51-60 // 51-60
"Стук в дверь в полночь — это либо полиция, либо заказчик.", 'Стук в дверь в полночь — это либо полиция, либо заказчик.',
"Никогда не давай обещаний родственникам жертвы. Только факты.", 'Никогда не давай обещаний родственникам жертвы. Только факты.',
"Если у тебя есть три версии — выбирай четвёртую.", 'Если у тебя есть три версии — выбирай четвёртую.',
"Лучшее оружие — это бумага. На ней можно написать что угодно.", 'Лучшее оружие — это бумага. На ней можно написать что угодно.',
"Когда ты смотришь на улику, смотри на неё глазами преступника.", 'Когда ты смотришь на улику, смотри на неё глазами преступника.',
"Все любят детективы, пока не становятся подозреваемыми.", 'Все любят детективы, пока не становятся подозреваемыми.',
"Следы крови важны. Но важнее — следы лжи.", 'Следы крови важны. Но важнее — следы лжи.',
"Если ты не спишь третьи сутки, значит, ты либо гений, либо дурак.", 'Если ты не спишь третьи сутки, значит, ты либо гений, либо дурак.',
"Проверяй каждого, кто говорит 'Я ничего не видел'.", "Проверяй каждого, кто говорит 'Я ничего не видел'.",
"В каждом деле есть один человек, который знает всё. И он не говорит.", 'В каждом деле есть один человек, который знает всё. И он не говорит.',
// 61-70 // 61-70
"Не суди преступника по одежде. Суди по тому, как он её снимал.", 'Не суди преступника по одежде. Суди по тому, как он её снимал.',
"Дыра в алиби — это как дыра в лодке. Рано или поздно она утонет.", 'Дыра в алиби — это как дыра в лодке. Рано или поздно она утонет.',
"Если преступник использует перчатки — он профессионал. Если нет — он любитель.", 'Если преступник использует перчатки — он профессионал. Если нет — он любитель.',
"Запомни: первая версия — всегда неверная.", 'Запомни: первая версия — всегда неверная.',
"Если у тебя кончились идеи, иди в бар. Там всегда есть кто-то, кто расскажет тебе дело.", 'Если у тебя кончились идеи, иди в бар. Там всегда есть кто-то, кто расскажет тебе дело.',
"Свидетель с фотороботом — это как слепой с компасом.", 'Свидетель с фотороботом — это как слепой с компасом.',
"Никогда не смейся над уликами. Они могут обидеться и исчезнуть.", 'Никогда не смейся над уликами. Они могут обидеться и исчезнуть.',
"Если ты нашёл оружие — не трогай его. Иначе твои отпечатки будут на нём.", 'Если ты нашёл оружие — не трогай его. Иначе твои отпечатки будут на нём.',
"Преступник умён, но ты умнее. Потому что ты пьёшь правильный кофе.", 'Преступник умён, но ты умнее. Потому что ты пьёшь правильный кофе.',
"Всегда пересматривай видеозаписи. Особенно в два раза медленнее.", 'Всегда пересматривай видеозаписи. Особенно в два раза медленнее.',
// 71-80 // 71-80
"Если на месте преступления есть кот — он видел всё. Но он не скажет.", 'Если на месте преступления есть кот — он видел всё. Но он не скажет.',
"Лучший способ поймать преступника — пригласить его на обед.", 'Лучший способ поймать преступника — пригласить его на обед.',
"Если подозреваемый улыбается, он либо невиновен, либо психопат.", 'Если подозреваемый улыбается, он либо невиновен, либо психопат.',
"Проверяй мусор. В нём всегда есть что-то полезное.", 'Проверяй мусор. В нём всегда есть что-то полезное.',
"Никогда не доверяй красивым историям. Только уродливым фактам.", 'Никогда не доверяй красивым историям. Только уродливым фактам.',
"Если ты нашёл улику слишком легко — это ловушка.", 'Если ты нашёл улику слишком легко — это ловушка.',
"Убийца всегда оставляет послание. Даже если это просто тишина.", 'Убийца всегда оставляет послание. Даже если это просто тишина.',
"Если ты запнулся на допросе — делай вид, что так и задумано.", 'Если ты запнулся на допросе — делай вид, что так и задумано.',
"Помни: все врут. Даже я тебе советую.", 'Помни: все врут. Даже я тебе советую.',
"Если ты боишься темноты — стань детективом. Страх пройдёт.", 'Если ты боишься темноты — стань детективом. Страх пройдёт.',
// 81-90 // 81-90
"Самые лучшие признания случаются под утро, когда все устали.", 'Самые лучшие признания случаются под утро, когда все устали.',
"Если преступник использовал компьютер, значит, он либо хакер, либо дурак.", 'Если преступник использовал компьютер, значит, он либо хакер, либо дурак.',
"Следы шин — это как подпись на дороге.", 'Следы шин — это как подпись на дороге.',
"Никогда не забывай про запах. Он помнит дольше, чем люди.", 'Никогда не забывай про запах. Он помнит дольше, чем люди.',
"Если ты не знаешь, что делать — съешь шоколадку.", 'Если ты не знаешь, что делать — съешь шоколадку.',
"В каждом деле есть одна минута слабости. Найди её.", 'В каждом деле есть одна минута слабости. Найди её.',
"Преступник всегда нервничает. Даже если он не нервничает.", 'Преступник всегда нервничает. Даже если он не нервничает.',
"Если у тебя есть подозреваемый и нет улик — подожди. Улики появятся.", 'Если у тебя есть подозреваемый и нет улик — подожди. Улики появятся.',
"Не верь в чудеса. Верь в логику.", 'Не верь в чудеса. Верь в логику.',
"Если ты устал — возьми отпуск. Преступление никуда не денется.", 'Если ты устал — возьми отпуск. Преступление никуда не денется.',
// 91-100 // 91-100
"Запомни: самый опасный преступник — тот, кто читал детективы.", 'Запомни: самый опасный преступник — тот, кто читал детективы.',
"Если ты думаешь, что всё кончено — значит, ты пропустил поворот.", 'Если ты думаешь, что всё кончено — значит, ты пропустил поворот.',
"Следы на полу важнее следов в душе.", 'Следы на полу важнее следов в душе.',
"Если преступник оставил окурок — он курил. Или хотел, чтобы ты так думал.", 'Если преступник оставил окурок — он курил. Или хотел, чтобы ты так думал.',
"Хороший детектив всегда голоден. Это помогает мыслить.", 'Хороший детектив всегда голоден. Это помогает мыслить.',
"Не суди по первому впечатлению. Суди по второму, когда увидишь его в наручниках.", 'Не суди по первому впечатлению. Суди по второму, когда увидишь его в наручниках.',
"Если у тебя есть тайный агент — не доверяй ему.", 'Если у тебя есть тайный агент — не доверяй ему.',
"В деле всегда есть тот, кто знает больше, чем говорит.", 'В деле всегда есть тот, кто знает больше, чем говорит.',
"Если ты не можешь найти убийцу — стань им (в мыслях).", 'Если ты не можешь найти убийцу — стань им (в мыслях).',
"И последнее: всегда проверяй, закрыл ли ты дверь участка." 'И последнее: всегда проверяй, закрыл ли ты дверь участка.',
]; ]
// ----- 3. Шутки, зависящие от даты ----- // ----- 3. Шутки, зависящие от даты -----
const dateJokes: string[] = []; const dateJokes: string[] = []
if (isMonday) { if (isMonday) {
dateJokes.push("☕ Понедельник: Сегодня даже преступники не хотят работать. Проверь тех, кто был слишком бодр."); dateJokes.push(
'☕ Понедельник: Сегодня даже преступники не хотят работать. Проверь тех, кто был слишком бодр.',
)
} }
if (isFriday13) { if (isFriday13) {
dateJokes.push("🔮 Пятница 13-е: Если улики ведут к чёрной кошке, ищи чёрного кота с алиби."); dateJokes.push('🔮 Пятница 13-е: Если улики ведут к чёрной кошке, ищи чёрного кота с алиби.')
} }
if (isNewYear) { if (isNewYear) {
dateJokes.push("🎄 Новый год: Свидетель в костюме Деда Мороза врёт. Настоящий Дед Мороз всегда в отпуске."); dateJokes.push(
'🎄 Новый год: Свидетель в костюме Деда Мороза врёт. Настоящий Дед Мороз всегда в отпуске.',
)
} }
if (isSummer) { if (isSummer) {
dateJokes.push("☀️ Лето: В жару преступники забывают улики в карманах, потому что снимают пиджаки."); dateJokes.push(
'☀️ Лето: В жару преступники забывают улики в карманах, потому что снимают пиджаки.',
)
} }
if (isWinter) { if (isWinter) {
dateJokes.push("❄️ Зима: Следы на снегу ведут к тому, у кого самые холодные руки."); dateJokes.push('❄️ Зима: Следы на снегу ведут к тому, у кого самые холодные руки.')
} }
if (isFullMoonJoke) { if (isFullMoonJoke) {
dateJokes.push("🌕 Полнолуние: Если подозреваемый воет на луну, он либо оборотень, либо фанат рока."); dateJokes.push(
'🌕 Полнолуние: Если подозреваемый воет на луну, он либо оборотень, либо фанат рока.',
)
} }
// ----- 4. Объединяем все советы и выбираем случайный ----- // ----- 4. Объединяем все советы и выбираем случайный -----
const allTips = tips.concat(dateJokes); const allTips = tips.concat(dateJokes)
const randomTip = allTips[Math.floor(Math.random() * allTips.length)]; const randomTip = allTips[Math.floor(Math.random() * allTips.length)]
// ----- 5. Красивый заголовок ----- // ----- 5. Красивый заголовок -----
const headers = [ const headers = [
"🔍 Заметки сыщика:", '🔍 Заметки сыщика:',
"📓 Из дневника инспектора:", '📓 Из дневника инспектора:',
"🕵️ Мудрость Шерлока:", '🕵️ Мудрость Шерлока:',
"🧠 Гениально и просто:", '🧠 Гениально и просто:',
"📌 Правило большого пальца:", '📌 Правило большого пальца:',
"☕ Кофейная истина:" '☕ Кофейная истина:',
]; ]
const header = headers[Math.floor(Math.random() * headers.length)]; const header = headers[Math.floor(Math.random() * headers.length)]
return { header: header, text: randomTip } return { header: header, text: randomTip }
} }
@@ -232,7 +240,7 @@ async function confirmDeleteAccount() {
<template> <template>
<div class="center-block-custom"> <div class="center-block-custom">
<div class="width700"> <div class="width700">
<n-alert :title="tip.header" :show-icon="false" type="info" style="margin-bottom: 20px;"> <n-alert :title="tip.header" :show-icon="false" type="info" style="margin-bottom: 20px">
{{ tip.text }} {{ tip.text }}
</n-alert> </n-alert>
@@ -257,8 +265,8 @@ async function confirmDeleteAccount() {
class="delete-modal" class="delete-modal"
> >
<p class="delete-modal-text"> <p class="delete-modal-text">
Это действие необратимо. Для подтверждения удаления введите пароль от учётной записи — Это действие необратимо. Для подтверждения удаления введите пароль от учётной записи — он
он понадобится, чтобы убедиться, что аккаунт удаляет его владелец. понадобится, чтобы убедиться, что аккаунт удаляет его владелец.
</p> </p>
<n-input <n-input
v-model:value="deletePassword" v-model:value="deletePassword"
@@ -285,45 +293,45 @@ async function confirmDeleteAccount() {
<style scoped> <style scoped>
.danger-zone { .danger-zone {
display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 40px;
padding: 20px;
background-color: #222; background-color: #222;
border: 1px solid rgb(224 108 117 / 40%); border: 1px solid rgb(224 108 117 / 40%);
border-radius: 10px; border-radius: 10px;
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: space-between;
margin-top: 40px;
padding: 20px;
} }
.danger-zone-title { .danger-zone-title {
color: #e06c75;
font-size: 17px; font-size: 17px;
font-weight: 600; font-weight: 600;
color: #e06c75;
} }
.danger-zone-hint { .danger-zone-hint {
margin-top: 6px;
font-size: 14px;
color: #999; color: #999;
font-size: 14px;
margin-top: 6px;
} }
.delete-modal-text { .delete-modal-text {
margin: 0 0 16px;
line-height: 1.5;
color: #bbb; color: #bbb;
line-height: 1.5;
margin: 0 0 16px;
} }
.delete-modal-error { .delete-modal-error {
margin: 12px 0 0;
color: #e06c75; color: #e06c75;
margin: 12px 0 0;
} }
.delete-modal-buttons { .delete-modal-buttons {
display: flex; display: flex;
justify-content: flex-end;
gap: 12px; gap: 12px;
justify-content: flex-end;
margin-top: 20px; margin-top: 20px;
} }
</style> </style>
+311 -177
View File
@@ -2,18 +2,28 @@
import { Key24Regular } from '@vicons/fluent' import { Key24Regular } from '@vicons/fluent'
import { LockOutlined } from '@vicons/material' import { LockOutlined } from '@vicons/material'
import { Icon } from '@vicons/utils' import { Icon } from '@vicons/utils'
import { NButton, NCard, NDynamicInput, NInput, NInputGroup, NModal, NSpace, NSwitch, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui' import {
import { computed, type PropType,ref } from 'vue' NButton,
NCard,
NDynamicInput,
NInput,
NInputGroup,
NModal,
NSpace,
NSwitch,
NTag,
NText,
NUpload,
NUploadDragger,
type UploadFileInfo,
} from 'naive-ui'
import { computed, type PropType, ref } from 'vue'
import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server' import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server'
import type { EditableApplication } from '@/types/editor' import type { EditableApplication } from '@/types/editor'
import { import { applicationFileName, applicationFileType, applicationFileURL } from '@/utils/application'
applicationFileName,
applicationFileType,
applicationFileURL,
} from '@/utils/application'
import MessagePaper from './MessagePaper.vue'; import MessagePaper from './MessagePaper.vue'
// Точка сценария + служебное поле fileList (выбранные файлы для загрузки картинки) // Точка сценария + служебное поле fileList (выбранные файлы для загрузки картинки)
type PlaceWithFileList = Place & { type PlaceWithFileList = Place & {
@@ -39,13 +49,13 @@ const props = defineProps({
}, },
addAction: { addAction: {
type: Function as PropType<(param: string) => void>, type: Function as PropType<(param: string) => void>,
} },
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'update:newPlaceCode': [value: string], 'update:newPlaceCode': [value: string]
'update:newPlaceName': [value: string], 'update:newPlaceName': [value: string]
'update:newPlaceText': [value: string], 'update:newPlaceText': [value: string]
'update:mode': [value: string] 'update:mode': [value: string]
}>() }>()
const updateCode = (code: string) => { const updateCode = (code: string) => {
@@ -193,73 +203,113 @@ const bottomApplications = computed<Application[]>(() => {
</script> </script>
<template> <template>
<div v-if="mode == 'mini-add'" class="place-block-hover show-editor-block" @click="updateMode('add')"> <div
v-if="mode == 'mini-add'"
class="place-block-hover show-editor-block"
@click="updateMode('add')"
>
<p class="place-image-plus">+</p> <p class="place-image-plus">+</p>
</div> </div>
<div v-if="mode == 'add'" class="place-block"> <div v-if="mode == 'add'" class="place-block">
<n-space vertical> <n-space vertical>
<n-input-group> <n-input-group>
<n-input :style="{ width: '20%' }" :value="newPlaceCode" @update:value="updateCode" type="text" <n-input
placeholder="Ы-1" /> :style="{ width: '20%' }"
<n-input :style="{ width: '80%' }" :value="newPlaceName" @update:value="updateName" type="text" :value="newPlaceCode"
placeholder="Дом №Ы" /> @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-group>
<n-input :value="newPlaceText" @update:value="updateText" type="textarea" :autosize="{ minRows: 3 }" <n-input
placeholder="В начале было слово..." /> :value="newPlaceText"
@update:value="updateText"
type="textarea"
:autosize="{ minRows: 3 }"
placeholder="В начале было слово..."
/>
<slot></slot> <slot></slot>
</n-space> </n-space>
</div> </div>
<div v-if="mode == 'show-editor'" class="place-block-hover show-editor-block" @click="updateMode('edit')"> <div
<div class="message-header"> v-if="mode == 'show-editor'"
{{ props.place?.code }}: {{ props.place?.name }} class="place-block-hover show-editor-block"
</div> @click="updateMode('edit')"
>
<div class="message-header">{{ props.place?.code }}: {{ props.place?.name }}</div>
<hr class="hr" /> <hr class="hr" />
<div class="message-content"> <div class="message-content">
<div v-if="props.place?.image"> <div v-if="props.place?.image">
<div class="message-image-border" <div
@click.stop="openImageView(props.place?.name || '', props.place?.image || '')"> class="message-image-border"
<img :src="props.place?.image" class="message-image"> @click.stop="openImageView(props.place?.name || '', props.place?.image || '')"
>
<img :src="props.place?.image" class="message-image" />
</div> </div>
</div> </div>
{{ props.place?.text }} {{ props.place?.text }}
</div> </div>
<div v-for="door in props.place?.doors" v-bind:key="door.code" class="door-block"> <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"> {{ door.name }}
<span v-for="key in door.keys" :key="key.name" class="key-block">
<Icon class="lock-icon"> <Icon class="lock-icon">
<LockOutlined /> <LockOutlined />
</Icon> {{ key.name }} </Icon>
{{ key.name }}
</span> </span>
</div> </div>
<hr class="hr" v-if="props.place?.applications?.length" /> <hr class="hr" v-if="props.place?.applications?.length" />
<div class="message-footer application-block" v-for="application in props.place?.applications" <div
v-bind:key="application.name"> class="message-footer application-block"
v-for="application in props.place?.applications"
v-bind:key="application.name"
>
<div class="application-name">Приложение: {{ application.name }}</div> <div class="application-name">Приложение: {{ application.name }}</div>
<div v-if="applicationFileType(application) == 'image' && application.image" class="application-image-border" <div
@click.stop="openApplicationView(application)"> v-if="applicationFileType(application) == 'image' && application.image"
<img :src="applicationFileURL(application.image)" class="application-image"> class="application-image-border"
@click.stop="openApplicationView(application)"
>
<img :src="applicationFileURL(application.image)" class="application-image" />
</div> </div>
<audio v-else-if="applicationFileType(application) == 'audio' && application.image" <audio
:src="applicationFileURL(application.image)" controls preload="none" class="application-audio" v-else-if="applicationFileType(application) == 'audio' && application.image"
@click.stop></audio> :src="applicationFileURL(application.image)"
<a v-else-if="applicationFileType(application) == 'pdf' && application.image" controls
:href="applicationFileURL(application.image)" target="_blank" rel="noopener" class="application-pdf-link" preload="none"
@click.stop> class="application-audio"
@click.stop
></audio>
<a
v-else-if="applicationFileType(application) == 'pdf' && application.image"
:href="applicationFileURL(application.image)"
target="_blank"
rel="noopener"
class="application-pdf-link"
@click.stop
>
PDF PDF
</a> </a>
</div> </div>
<p class="gray-block"> <p class="gray-block">
<span v-if="props.place?.hidden"> <span v-if="props.place?.hidden"> Скрытая точка </span>
Скрытая точка
</span>
<span v-for="key in props.place?.keys" :key="key.name" class="key-block"> <span v-for="key in props.place?.keys" :key="key.name" class="key-block">
<Icon class="lock-icon"> <Icon class="lock-icon">
<Key24Regular /> <Key24Regular />
</Icon> {{ key.name }} </Icon>
{{ key.name }}
</span> </span>
</p> </p>
</div> </div>
@@ -273,22 +323,31 @@ const bottomApplications = computed<Application[]>(() => {
<hr class="show-hr" /> <hr class="show-hr" />
<div class="message-show-content font"> <div class="message-show-content font">
<div v-if="place?.image?.length"> <div v-if="place?.image?.length">
<div class="message-image-border" <div
@click="openImageView(place?.name || '', place?.image || '')"> class="message-image-border"
@click="openImageView(place?.name || '', place?.image || '')"
>
<img v-bind:src="place?.image" class="message-image" /> <img v-bind:src="place?.image" class="message-image" />
</div> </div>
</div> </div>
<!-- Если у точки нет своей картинки, первая картинка-улика занимает <!-- Если у точки нет своей картинки, первая картинка-улика занимает
её место в самом начале текста --> её место в самом начале текста -->
<div v-else-if="firstImageApplication" class="message-image-border" <div
@click="openApplicationView(firstImageApplication)"> v-else-if="firstImageApplication"
class="message-image-border"
@click="openApplicationView(firstImageApplication)"
>
<img :src="applicationFileURL(firstImageApplication.image)" class="message-image" /> <img :src="applicationFileURL(firstImageApplication.image)" class="message-image" />
</div> </div>
{{ textBeforeApplications }} {{ textBeforeApplications }}
<!-- Остальные улики-картинки встроены в поток текста: блок справа, текст обтекает его --> <!-- Остальные улики-картинки встроены в поток текста: блок справа, текст обтекает его -->
<div v-if="flowApplications.length" class="show-applications-block"> <div v-if="flowApplications.length" class="show-applications-block">
<div class="show-application" v-for="application in flowApplications" :key="application.name"> <div
class="show-application"
v-for="application in flowApplications"
:key="application.name"
>
<!-- Картинка-улика без заголовка, просто фото внизу справа --> <!-- Картинка-улика без заголовка, просто фото внизу справа -->
<div class="application-image-block" @click="openApplicationView(application)"> <div class="application-image-block" @click="openApplicationView(application)">
<img :src="applicationFileURL(application.image)" class="application-image" /> <img :src="applicationFileURL(application.image)" class="application-image" />
@@ -299,25 +358,41 @@ const bottomApplications = computed<Application[]>(() => {
{{ textAfterApplications }} {{ textAfterApplications }}
</div> </div>
<hr class="show-hr" v-if="props.place?.doors?.length" /> <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" <div
v-on:click="addAction!(door.code!)"> v-for="door in props.place?.doors"
<div class="button-dialog-text"> :key="door.code"
{{ door.code }} {{ door.name }} class="button-dialog font"
</div> v-on:click="addAction!(door.code!)"
>
<div class="button-dialog-text">{{ door.code }} {{ door.name }}</div>
</div> </div>
<!-- Аудио и PDF-улики (и улики без файла): в самом низу после <!-- Аудио и PDF-улики (и улики без файла): в самом низу после
пунктирной линии; PDF открывается по нажатию на название --> пунктирной линии; PDF открывается по нажатию на название -->
<hr class="show-hr" v-if="bottomApplications.length" /> <hr class="show-hr" v-if="bottomApplications.length" />
<div class="message-show-footer font" v-for="application in bottomApplications" :key="application.name"> <div
<a v-if="applicationFileType(application) == 'pdf' && application.image" class="message-show-footer font"
:href="applicationFileURL(application.image)" target="_blank" rel="noopener" class="pdf-application-name"> v-for="application in bottomApplications"
:key="application.name"
>
<a
v-if="applicationFileType(application) == 'pdf' && application.image"
:href="applicationFileURL(application.image)"
target="_blank"
rel="noopener"
class="pdf-application-name"
>
Приложение: {{ application.name }} Приложение: {{ application.name }}
</a> </a>
<template v-else> <template v-else>
<div class="application-label">Приложение: {{ application.name }}</div> <div class="application-label">Приложение: {{ application.name }}</div>
<audio v-if="applicationFileType(application) == 'audio' && application.image" <audio
:src="applicationFileURL(application.image)" controls preload="none" class="application-audio"></audio> v-if="applicationFileType(application) == 'audio' && application.image"
:src="applicationFileURL(application.image)"
controls
preload="none"
class="application-audio"
></audio>
</template> </template>
</div> </div>
</MessagePaper> </MessagePaper>
@@ -335,21 +410,42 @@ const bottomApplications = computed<Application[]>(() => {
<div v-if="mode == 'edit'" class="place-block"> <div v-if="mode == 'edit'" class="place-block">
<n-space vertical> <n-space vertical>
<n-input-group> <n-input-group>
<n-input :style="{ width: '20%' }" v-model:value="props.place!.code" type="text" placeholder="Ы-1" /> <n-input
<n-input :style="{ width: '80%' }" v-model:value="props.place!.name" type="text" placeholder="Дом №Ы" /> :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-group>
<n-input v-model:value="props.place!.text" type="textarea" :autosize="{ minRows: 3 }" <n-input
placeholder="В начале было слово..." /> v-model:value="props.place!.text"
type="textarea"
:autosize="{ minRows: 3 }"
placeholder="В начале было слово..."
/>
<p class="settings-header">Картинка</p> <p class="settings-header">Картинка</p>
<div> <div>
<n-space> <n-space>
<n-space vertical> <n-space vertical>
<img :src="props.place!.image" class="message-edit-image"> <img :src="props.place!.image" class="message-edit-image" />
<n-button @click="props.place!.image = '', props.place!.fileList = undefined">Убрать картинку</n-button> <n-button @click="((props.place!.image = ''), (props.place!.fileList = undefined))"
>Убрать картинку</n-button
>
</n-space> </n-space>
<n-space vertical> <n-space vertical>
<n-input v-model:value="props.place!.image" type="text" placeholder="Не задано" disabled /> <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 directory-dnd :max="1" v-model:file-list="props.place!.fileList">
<n-upload-dragger> <n-upload-dragger>
<n-text style="font-size: 16px"> <n-text style="font-size: 16px">
@@ -363,12 +459,8 @@ const bottomApplications = computed<Application[]>(() => {
<p class="settings-header">Видимость точки</p> <p class="settings-header">Видимость точки</p>
<n-switch v-model:value="props.place!.hidden"> <n-switch v-model:value="props.place!.hidden">
<template #checked> <template #checked> Скрытая точка </template>
Скрытая точка <template #unchecked> Обычная точка </template>
</template>
<template #unchecked>
Обычная точка
</template>
</n-switch> </n-switch>
Улики Улики
@@ -385,21 +477,41 @@ const bottomApplications = computed<Application[]>(() => {
{{ applicationFileType(value) || 'файл' }} {{ applicationFileType(value) || 'файл' }}
</n-tag> </n-tag>
</n-text> </n-text>
<n-button size="small" quaternary @click="removeApplicationFile(editableApplication(value))"> <n-button
size="small"
quaternary
@click="removeApplicationFile(editableApplication(value))"
>
Убрать файл Убрать файл
</n-button> </n-button>
</n-space> </n-space>
<img v-if="applicationFileType(value) == 'image'" :src="applicationFileURL(value.image)" <img
class="application-edit-image" /> v-if="applicationFileType(value) == 'image'"
<audio v-else-if="applicationFileType(value) == 'audio'" :src="applicationFileURL(value.image)" :src="applicationFileURL(value.image)"
controls preload="none" class="application-edit-audio"></audio> class="application-edit-image"
<a v-else-if="applicationFileType(value) == 'pdf'" :href="applicationFileURL(value.image)" />
target="_blank" rel="noopener" class="application-pdf-link"> <audio
v-else-if="applicationFileType(value) == 'audio'"
:src="applicationFileURL(value.image)"
controls
preload="none"
class="application-edit-audio"
></audio>
<a
v-else-if="applicationFileType(value) == 'pdf'"
:href="applicationFileURL(value.image)"
target="_blank"
rel="noopener"
class="application-pdf-link"
>
PDF PDF
</a> </a>
</div> </div>
<n-upload :max="1" v-model:file-list="editableApplication(value).fileList" <n-upload
accept="image/*,application/pdf,audio/*"> :max="1"
v-model:file-list="editableApplication(value).fileList"
accept="image/*,application/pdf,audio/*"
>
<n-button size="small">Загрузить файл улики</n-button> <n-button size="small">Загрузить файл улики</n-button>
</n-upload> </n-upload>
</div> </div>
@@ -410,18 +522,27 @@ const bottomApplications = computed<Application[]>(() => {
<n-dynamic-input v-model:value="props.place!.doors" :on-create="onCreateDoor"> <n-dynamic-input v-model:value="props.place!.doors" :on-create="onCreateDoor">
<template #create-button-default> Добавить действие </template> <template #create-button-default> Добавить действие </template>
<template #action="{ index, create, remove }"> <template #action="{ index, create, remove }">
<div style="display: flex; flex-direction: column;"> <div style="display: flex; flex-direction: column">
<n-button @click="() => create(index)" class="plus-minus-buttons">+</n-button> <n-button @click="() => create(index)" class="plus-minus-buttons">+</n-button>
<n-button @click="() => remove(index)" class="plus-minus-buttons">-</n-button> <n-button @click="() => remove(index)" class="plus-minus-buttons">-</n-button>
</div> </div>
</template> </template>
<template #default="{ value: door }"> <template #default="{ value: door }">
<div style="display: flex; flex-direction: column; width: 100%"> <div style="display: flex; flex-direction: column; width: 100%">
<div class="door-add-block"> <div class="door-add-block">
<n-input-group> <n-input-group>
<n-input :style="{ width: '30%' }" v-model:value="door.code" type="text" placeholder="Код" /> <n-input
<n-input :style="{ width: '70%' }" v-model:value="door.name" type="text" placeholder="Название" /> :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> </n-input-group>
<p class="need-keys-text">Требуемые ключи</p> <p class="need-keys-text">Требуемые ключи</p>
@@ -429,14 +550,13 @@ const bottomApplications = computed<Application[]>(() => {
<n-dynamic-input v-model:value="door.keys" :on-create="onCreateKey"> <n-dynamic-input v-model:value="door.keys" :on-create="onCreateKey">
<template #create-button-default> Добавить ключ </template> <template #create-button-default> Добавить ключ </template>
<template #default="{ value: key }"> <template #default="{ value: key }">
<div style="display: flex; align-items: center; width: 100%"> <div style="align-items: center; display: flex; width: 100%">
<n-input v-model:value="key.name" type="text" placeholder="Название" /> <n-input v-model:value="key.name" type="text" placeholder="Название" />
</div> </div>
</template> </template>
</n-dynamic-input> </n-dynamic-input>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
</n-dynamic-input> </n-dynamic-input>
@@ -445,7 +565,7 @@ const bottomApplications = computed<Application[]>(() => {
<n-dynamic-input v-model:value="props.place!.keys" :on-create="onCreateKey"> <n-dynamic-input v-model:value="props.place!.keys" :on-create="onCreateKey">
<template #create-button-default> Добавить ключ </template> <template #create-button-default> Добавить ключ </template>
<template #default="{ value: key }"> <template #default="{ value: key }">
<div style="display: flex; align-items: center; width: 100%"> <div style="align-items: center; display: flex; width: 100%">
<n-input v-model:value="key.name" type="text" placeholder="Название" /> <n-input v-model:value="key.name" type="text" placeholder="Название" />
</div> </div>
</template> </template>
@@ -459,22 +579,37 @@ const bottomApplications = computed<Application[]>(() => {
<!-- Просмотр улики / картинки точки --> <!-- Просмотр улики / картинки точки -->
<n-modal v-model:show="showViewApplicationModal" :mask-closable="true"> <n-modal v-model:show="showViewApplicationModal" :mask-closable="true">
<n-card style="width: min(94vw, 1200px)" :bordered="false" role="dialog" aria-modal="true" <n-card
:content-style="{ padding: '14px' }"> style="width: min(94vw, 1200px)"
:bordered="false"
role="dialog"
aria-modal="true"
:content-style="{ padding: '14px' }"
>
<template #header> <template #header>
{{ viewApplication?.name || viewImage?.title }} {{ viewApplication?.name || viewImage?.title }}
</template> </template>
<template #header-extra> <template #header-extra>
<n-button quaternary circle @click="closeApplicationView()"> x </n-button> <n-button quaternary circle @click="closeApplicationView()"> x </n-button>
</template> </template>
<div v-if="viewApplication && applicationFileType(viewApplication) == 'image'" <div
class="view-application-image-block"> v-if="viewApplication && applicationFileType(viewApplication) == 'image'"
class="view-application-image-block"
>
<img :src="applicationFileURL(viewApplication.image)" class="view-application-image" /> <img :src="applicationFileURL(viewApplication.image)" class="view-application-image" />
</div> </div>
<iframe v-else-if="viewApplication && applicationFileType(viewApplication) == 'pdf'" <iframe
:src="applicationFileURL(viewApplication.image)" class="view-application-pdf"></iframe> v-else-if="viewApplication && applicationFileType(viewApplication) == 'pdf'"
<audio v-else-if="viewApplication && applicationFileType(viewApplication) == 'audio'" :src="applicationFileURL(viewApplication.image)"
:src="applicationFileURL(viewApplication.image)" controls autoplay class="view-application-audio"></audio> class="view-application-pdf"
></iframe>
<audio
v-else-if="viewApplication && applicationFileType(viewApplication) == 'audio'"
:src="applicationFileURL(viewApplication.image)"
controls
autoplay
class="view-application-audio"
></audio>
<div v-else-if="viewImage" class="view-application-image-block"> <div v-else-if="viewImage" class="view-application-image-block">
<img :src="viewImage.url" class="view-application-image" /> <img :src="viewImage.url" class="view-application-image" />
</div> </div>
@@ -484,33 +619,33 @@ const bottomApplications = computed<Application[]>(() => {
<style scoped> <style scoped>
.hr { .hr {
margin: 10px 0;
border: dashed 1px; border: dashed 1px;
/* border-color: black; */ /* border-color: black; */
border-color: #eee; border-color: #eee;
margin: 10px 0;
} }
.place-block-hover, .place-block-hover,
.place-block { .place-block {
background-color: #222;
border: 1px solid #222;
border-radius: 10px;
margin: 10px 0; margin: 10px 0;
padding: 20px; padding: 20px;
border-radius: 10px;
border: 1px solid #222;
background-color: #222;
} }
.place-block-hover:hover { .place-block-hover:hover {
border: 1px solid #63e2b7;
color: #63e2b7; color: #63e2b7;
cursor: pointer; cursor: pointer;
border: 1px solid #63e2b7;
} }
.place-image-plus { .place-image-plus {
font-size: 30px;
display: flex;
justify-content: center;
align-items: center; align-items: center;
display: flex;
font-size: 30px;
justify-content: center;
} }
.show-editor-block { .show-editor-block {
@@ -523,13 +658,13 @@ const bottomApplications = computed<Application[]>(() => {
} }
.gray-block { .gray-block {
margin-top: 5px;
color: #777; color: #777;
margin-top: 5px;
} }
.application-block { .application-block {
margin-right: 5px;
color: burlywood; color: burlywood;
margin-right: 5px;
} }
.application-name { .application-name {
@@ -537,25 +672,25 @@ const bottomApplications = computed<Application[]>(() => {
} }
.application-image-border { .application-image-border {
width: 40%; background-image: url('@/assets/images/paper_white.jpg');
max-width: 120px;
float: left;
padding: 7px;
margin-right: 15px;
background-image: url("@/assets/images/paper_white.jpg");
background-size: cover; background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98); box-shadow: 0 3px 15px rgb(98 98 98);
transform: rotate(-3deg);
cursor: zoom-in; cursor: zoom-in;
float: left;
margin-right: 15px;
max-width: 120px;
padding: 7px;
transform: rotate(-3deg);
width: 40%;
} }
/* В редакторе улики показываются друг под другом: /* В редакторе улики показываются друг под другом:
картинка улики не плавает, а идёт под своим названием */ картинка улики не плавает, а идёт под своим названием */
.message-footer .application-image-border { .message-footer .application-image-border {
float: none; float: none;
width: 120px;
max-width: 120px;
margin: 4px 0 10px; margin: 4px 0 10px;
max-width: 120px;
width: 120px;
} }
.application-image { .application-image {
@@ -563,26 +698,26 @@ const bottomApplications = computed<Application[]>(() => {
} }
.application-image-block { .application-image-block {
margin: 14px 0 5px; background-image: url('@/assets/images/paper_white.jpg');
padding: 7px;
max-width: 120px;
background-image: url("@/assets/images/paper_white.jpg");
background-size: cover; background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98); box-shadow: 0 3px 15px rgb(98 98 98);
cursor: zoom-in; cursor: zoom-in;
margin: 14px 0 5px;
max-width: 120px;
padding: 7px;
} }
.application-audio { .application-audio {
width: 100%;
margin: 5px 0; margin: 5px 0;
width: 100%;
} }
.application-pdf-link { .application-pdf-link {
color: var(--second-color);
cursor: pointer;
display: inline-block; display: inline-block;
margin: 5px 0; margin: 5px 0;
color: var(--second-color);
text-decoration: underline; text-decoration: underline;
cursor: pointer;
} }
.view-application-image-block { .view-application-image-block {
@@ -590,14 +725,14 @@ const bottomApplications = computed<Application[]>(() => {
} }
.view-application-image { .view-application-image {
max-width: 100%;
max-height: 72vh; max-height: 72vh;
max-width: 100%;
} }
.view-application-pdf { .view-application-pdf {
width: 100%;
height: 72vh;
border: none; border: none;
height: 72vh;
width: 100%;
} }
.view-application-audio { .view-application-audio {
@@ -605,22 +740,22 @@ const bottomApplications = computed<Application[]>(() => {
} }
.application-edit-file { .application-edit-file {
background-color: #222;
border-radius: 5px;
margin: 8px 0 4px; margin: 8px 0 4px;
padding: 8px; padding: 8px;
border-radius: 5px;
background-color: #222;
} }
.application-edit-image { .application-edit-image {
width: 100%;
max-width: 120px;
margin-top: 6px;
border-radius: 4px; border-radius: 4px;
margin-top: 6px;
max-width: 120px;
width: 100%;
} }
.application-edit-audio { .application-edit-audio {
width: 100%;
margin-top: 6px; margin-top: 6px;
width: 100%;
} }
.application-label { .application-label {
@@ -628,19 +763,19 @@ const bottomApplications = computed<Application[]>(() => {
} }
.door-block { .door-block {
background-color: #222;
border-radius: 5px;
color: #eee;
display: inline-block; display: inline-block;
margin: 10px 5px 10px 0; margin: 10px 5px 10px 0;
padding: 5px 10px; padding: 5px 10px;
border-radius: 5px;
background-color: #222;
color: #eee;
} }
.door-add-block { .door-add-block {
padding: 10px;
border-radius: 5px;
background-color: #222; background-color: #222;
border-radius: 5px;
color: #eee; color: #eee;
padding: 10px;
} }
.key-block { .key-block {
@@ -669,30 +804,30 @@ const bottomApplications = computed<Application[]>(() => {
.plus-minus-buttons { .plus-minus-buttons {
border: 1px solid #444; border: 1px solid #444;
padding: 0 13px; border-radius: 40px;
margin: 0 0 10px 5px;
text-align: center;
color: #eee; color: #eee;
font-size: 25px; font-size: 25px;
border-radius: 40px; margin: 0 0 10px 5px;
padding: 0 13px;
text-align: center;
} }
.plus-minus-buttons:hover { .plus-minus-buttons:hover {
cursor: pointer;
color: #63e2b7; color: #63e2b7;
cursor: pointer;
} }
.message-image-border { .message-image-border {
width: 40%; background-image: url('@/assets/images/paper_white.jpg');
max-width: 200px;
float: left;
padding: 7px;
margin-right: 15px;
background-image: url("@/assets/images/paper_white.jpg");
background-size: cover; background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98); box-shadow: 0 3px 15px rgb(98 98 98);
transform: rotate(-3deg);
cursor: zoom-in; cursor: zoom-in;
float: left;
margin-right: 15px;
max-width: 200px;
padding: 7px;
transform: rotate(-3deg);
width: 40%;
} }
.message-image { .message-image {
@@ -700,8 +835,8 @@ const bottomApplications = computed<Application[]>(() => {
} }
.message-edit-image { .message-edit-image {
width: 135px;
height: 135px; height: 135px;
width: 135px;
} }
.message-header { .message-header {
@@ -710,9 +845,9 @@ const bottomApplications = computed<Application[]>(() => {
} }
.message-footer { .message-footer {
padding-right: 50px;
font-weight: 400;
color: burlywood; color: burlywood;
font-weight: 400;
padding-right: 50px;
position: relative; position: relative;
} }
@@ -722,15 +857,15 @@ const bottomApplications = computed<Application[]>(() => {
} }
.collapse-icon { .collapse-icon {
position: absolute; background-image: url('@/assets/images/collapse.png');
top: 12px;
right: 12px;
width: 45px;
height: 28px;
cursor: pointer;
background-image: url("@/assets/images/collapse.png");
background-size: cover;
background-position: center; background-position: center;
background-size: cover;
cursor: pointer;
height: 28px;
position: absolute;
right: 12px;
top: 12px;
width: 45px;
} }
.collapse-icon-up { .collapse-icon-up {
@@ -738,9 +873,9 @@ const bottomApplications = computed<Application[]>(() => {
} }
.show-hr { .show-hr {
margin: 10px 0;
border: dashed 1px; border: dashed 1px;
border-color: black; border-color: black;
margin: 10px 0;
} }
.font { .font {
@@ -749,43 +884,42 @@ const bottomApplications = computed<Application[]>(() => {
} }
.message-show-content { .message-show-content {
font-weight: 500;
white-space: pre-wrap;
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает /* BFC: плавающий блок улик остаётся внутри текста и не наезжает
на следующие секции (двери/действия) при коротком тексте */ на следующие секции (двери/действия) при коротком тексте */
display: flow-root; display: flow-root;
font-weight: 500;
white-space: pre-wrap;
} }
/* PDF-улики внизу после пунктирной линии */ /* PDF-улики внизу после пунктирной линии */
.message-show-footer { .message-show-footer {
padding-right: 50px;
font-weight: 400;
color: var(--second-color); color: var(--second-color);
font-weight: 400;
padding-right: 50px;
position: relative; position: relative;
} }
.pdf-application-name { .pdf-application-name {
color: var(--second-color); color: var(--second-color);
text-decoration: underline;
cursor: pointer; cursor: pointer;
text-decoration: underline;
} }
/* Улики встроены в поток текста: блок прижат к правому краю, /* Улики встроены в поток текста: блок прижат к правому краю,
текст обтекает его с левой стороны */ текст обтекает его с левой стороны */
.show-applications-block { .show-applications-block {
float: right; align-items: flex-end;
margin: 4px 0 10px 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; float: right;
gap: 12px; gap: 12px;
margin: 4px 0 10px 14px;
} }
.show-application { .show-application {
align-items: flex-end;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end;
max-width: 100%; max-width: 100%;
} }
@@ -794,8 +928,8 @@ const bottomApplications = computed<Application[]>(() => {
} }
.show-application .application-audio { .show-application .application-audio {
width: 260px;
margin: 4px 0 0; margin: 4px 0 0;
width: 260px;
} }
.show-application .application-pdf-link { .show-application .application-pdf-link {
@@ -803,16 +937,16 @@ const bottomApplications = computed<Application[]>(() => {
} }
.button-dialog { .button-dialog {
display: inline-block; background-image: url('@/assets/images/belt.png');
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; background-size: cover;
border-radius: 5px;
color: #bfa07d; color: #bfa07d;
cursor: pointer; cursor: pointer;
display: inline-block;
font-size: 16px;
font-weight: 600;
margin-right: 10px;
margin-top: 10px;
} }
.button-dialog-text { .button-dialog-text {
+58 -24
View File
@@ -44,28 +44,49 @@ const updateMode = (mode: string) => {
</script> </script>
<template> <template>
<div v-if="mode == 'mini-add'" class="place-block-hover show-editor-block" @click="updateMode('add')"> <div
v-if="mode == 'mini-add'"
class="place-block-hover show-editor-block"
@click="updateMode('add')"
>
<p class="place-image-plus">+</p> <p class="place-image-plus">+</p>
</div> </div>
<div v-if="mode == 'add'" class="place-block"> <div v-if="mode == 'add'" class="place-block">
<n-space vertical> <n-space vertical>
<n-input-group> <n-input-group>
<n-input :style="{ width: '25%' }" :value="newQuestionCode" @update:value="updateCode" type="text" <n-input
placeholder="В-1" /> :style="{ width: '25%' }"
<n-input :style="{ width: '75%' }" :value="newQuestionText" @update:value="updateText" type="text" :value="newQuestionCode"
placeholder="Кто украл пастуха?" /> @update:value="updateCode"
type="text"
placeholder="В-1"
/>
<n-input
:style="{ width: '75%' }"
:value="newQuestionText"
@update:value="updateText"
type="text"
placeholder="Кто украл пастуха?"
/>
</n-input-group> </n-input-group>
<n-input :value="newQuestionAnswer" @update:value="updateAnswer" type="textarea" :autosize="{ minRows: 2 }" <n-input
placeholder="Правильный ответ (виден только автору и организаторам)" /> :value="newQuestionAnswer"
@update:value="updateAnswer"
type="textarea"
:autosize="{ minRows: 2 }"
placeholder="Правильный ответ (виден только автору и организаторам)"
/>
<slot></slot> <slot></slot>
</n-space> </n-space>
</div> </div>
<div v-if="mode == 'show-editor'" class="place-block-hover show-editor-block" @click="updateMode('edit')"> <div
<div class="message-header"> v-if="mode == 'show-editor'"
{{ props.question?.code }}: {{ props.question?.text }} class="place-block-hover show-editor-block"
</div> @click="updateMode('edit')"
>
<div class="message-header">{{ props.question?.code }}: {{ props.question?.text }}</div>
<hr class="hr" /> <hr class="hr" />
<div class="message-content"> <div class="message-content">
<span v-if="props.question?.answer">Ответ: {{ props.question.answer }}</span> <span v-if="props.question?.answer">Ответ: {{ props.question.answer }}</span>
@@ -76,14 +97,27 @@ const updateMode = (mode: string) => {
<div v-if="mode == 'edit'" class="place-block"> <div v-if="mode == 'edit'" class="place-block">
<n-space vertical> <n-space vertical>
<n-input-group> <n-input-group>
<n-input :style="{ width: '25%' }" v-model:value="props.question!.code" type="text" placeholder="В-1" /> <n-input
<n-input :style="{ width: '75%' }" v-model:value="props.question!.text" type="text" :style="{ width: '25%' }"
placeholder="Кто украл пастуха?" /> v-model:value="props.question!.code"
type="text"
placeholder="В-1"
/>
<n-input
:style="{ width: '75%' }"
v-model:value="props.question!.text"
type="text"
placeholder="Кто украл пастуха?"
/>
</n-input-group> </n-input-group>
<p class="settings-header">Правильный ответ</p> <p class="settings-header">Правильный ответ</p>
<n-input v-model:value="props.question!.answer" type="textarea" :autosize="{ minRows: 2 }" <n-input
placeholder="Правильный ответ (виден только автору и организаторам)" /> v-model:value="props.question!.answer"
type="textarea"
:autosize="{ minRows: 2 }"
placeholder="Правильный ответ (виден только автору и организаторам)"
/>
<n-text class="hint" depth="3"> <n-text class="hint" depth="3">
Смена кода вопроса не переносит ответы команд, отправленные под старым кодом. Смена кода вопроса не переносит ответы команд, отправленные под старым кодом.
</n-text> </n-text>
@@ -98,24 +132,24 @@ const updateMode = (mode: string) => {
<style scoped> <style scoped>
.place-block-hover, .place-block-hover,
.place-block { .place-block {
background-color: #222;
border: 1px solid #222;
border-radius: 10px;
margin: 10px 0; margin: 10px 0;
padding: 20px; padding: 20px;
border-radius: 10px;
border: 1px solid #222;
background-color: #222;
} }
.place-block-hover:hover { .place-block-hover:hover {
border: 1px solid #63e2b7;
color: #63e2b7; color: #63e2b7;
cursor: pointer; cursor: pointer;
border: 1px solid #63e2b7;
} }
.place-image-plus { .place-image-plus {
font-size: 30px;
display: flex;
justify-content: center;
align-items: center; align-items: center;
display: flex;
font-size: 30px;
justify-content: center;
} }
.show-editor-block { .show-editor-block {
@@ -123,9 +157,9 @@ const updateMode = (mode: string) => {
} }
.hr { .hr {
margin: 10px 0;
border: dashed 1px; border: dashed 1px;
border-color: #eee; border-color: #eee;
margin: 10px 0;
} }
.message-header { .message-header {
+45 -39
View File
@@ -16,64 +16,70 @@ const route = useRoute()
const scenarioId = route.params.id const scenarioId = route.params.id
const scenario = ref<Scenario>({ const scenario = ref<Scenario>({
id: undefined, id: undefined,
name: undefined, name: undefined,
story: undefined, story: undefined,
author: undefined, author: undefined,
status: undefined, status: undefined,
updatedAt: undefined, updatedAt: undefined,
createdAt: undefined, createdAt: undefined,
publishedAt: undefined, publishedAt: undefined,
}) })
async function getScenario(id: number) { async function getScenario(id: number) {
const res = await client.GetScenario({ id: id }) const res = await client.GetScenario({ id: id })
if (res.error != '') { if (res.error != '') {
message.error(res.error!) message.error(res.error!)
return return
} }
scenario.value = res.scenario! scenario.value = res.scenario!
} }
getScenario(Number(scenarioId)) getScenario(Number(scenarioId))
</script> </script>
<template> <template>
<div class="center-block-custom"> <div class="center-block-custom">
<div class="width700"> <div class="width700">
<div class="scenario-title"> <div class="scenario-title">
{{ scenario.name }} {{ scenario.name }}
</div> </div>
<img class="scenario-image" :src="scenario.image"> <img class="scenario-image" :src="scenario.image" />
<div class="scenario-description"> <div class="scenario-description">
{{ scenario.description }} {{ scenario.description }}
</div> </div>
<div class="scenario-author"> <div class="scenario-author">
<n-tag :bordered="false" type="success" class="status-block">Автор: {{ scenario.author?.username <n-tag :bordered="false" type="success" class="status-block"
}}</n-tag> >Автор: {{ scenario.author?.username }}</n-tag
</div> >
<IntroBlock </div>
v-if="scenario.story?.introduction && (scenario.story.introduction.text || scenario.story.introduction.audio)" <IntroBlock
:introduction="scenario.story.introduction" mode="show" /> v-if="
</div> scenario.story?.introduction &&
(scenario.story.introduction.text || scenario.story.introduction.audio)
"
:introduction="scenario.story.introduction"
mode="show"
/>
</div> </div>
</div>
<HeaderMenu active="catalog" /> <HeaderMenu active="catalog" />
</template> </template>
<style scoped> <style scoped>
.scenario-title { .scenario-title {
font-size: 24px; font-size: 24px;
margin-bottom: 10px; margin-bottom: 10px;
} }
.scenario-image { .scenario-image {
width: 100%; margin-bottom: 10px;
max-width: 400px; max-width: 400px;
margin-bottom: 10px; width: 100%;
} }
.scenario-description { .scenario-description {
color: #aaa; color: #aaa;
margin-bottom: 10px; margin-bottom: 10px;
} }
</style> </style>
+151 -50
View File
@@ -2,7 +2,18 @@
import { Settings } from '@vicons/carbon' import { Settings } from '@vicons/carbon'
import { Icon } from '@vicons/utils' import { Icon } from '@vicons/utils'
import { NButton, type UploadFileInfo, useMessage } from 'naive-ui' import { NButton, type UploadFileInfo, useMessage } from 'naive-ui'
import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui' import {
NAlert,
NCard,
NFlex,
NInput,
NModal,
NSpace,
NTag,
NText,
NUpload,
NUploadDragger,
} from 'naive-ui'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
@@ -15,7 +26,12 @@ import PlaceBlock from '@/components/PlaceBlock.vue'
import QuestionBlock from '@/components/QuestionBlock.vue' import QuestionBlock from '@/components/QuestionBlock.vue'
import router from '@/router' import router from '@/router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import type { EditableApplication, EditableIntroduction, EditablePlace, EditableQuestion } from '@/types/editor' import type {
EditableApplication,
EditableIntroduction,
EditablePlace,
EditableQuestion,
} from '@/types/editor'
const authStore = useAuthStore() const authStore = useAuthStore()
const client = getAuthClient() const client = getAuthClient()
@@ -104,7 +120,7 @@ async function addGame(name: string, id: number) {
name: ('Тестовая игра: ' + name).trim(), name: ('Тестовая игра: ' + name).trim(),
description: 'Тестовая игра', description: 'Тестовая игра',
startAt: now, startAt: now,
scenarioId: id scenarioId: id,
}) })
if (resAddGame.error != '') { if (resAddGame.error != '') {
message.error(resAddGame.error!) message.error(resAddGame.error!)
@@ -118,7 +134,7 @@ async function addGame(name: string, id: number) {
message.error(resAddTeam.error!) message.error(resAddTeam.error!)
return return
} }
router.push("/team-story/" + resAddTeam.id! + '?password=' + resAddTeam.password!) router.push('/team-story/' + resAddTeam.id! + '?password=' + resAddTeam.password!)
} }
async function draftScenario(id: number) { async function draftScenario(id: number) {
@@ -220,12 +236,12 @@ function uint8ToBase64(uint8Array: Uint8Array) {
} }
function fromCharCodeSafe(arr: Uint8Array) { function fromCharCodeSafe(arr: Uint8Array) {
let result = ''; let result = ''
for (let i = 0; i < arr.length; i += 50000) { for (let i = 0; i < arr.length; i += 50000) {
const chunk = arr.slice(i, i + 50000); const chunk = arr.slice(i, i + 50000)
result += String.fromCharCode(...chunk); result += String.fromCharCode(...chunk)
} }
return result; return result
} }
const code = ref('') const code = ref('')
@@ -233,7 +249,6 @@ const name = ref('')
const text = ref('') const text = ref('')
const mode = ref('mini-add') const mode = ref('mini-add')
async function addPlace() { async function addPlace() {
const res = await client.AddScenarioPlace({ const res = await client.AddScenarioPlace({
id: scenario.value.id, id: scenario.value.id,
@@ -283,7 +298,7 @@ async function addNotFoundDoors(scenario: Scenario, place: EditablePlace) {
hidden: true, hidden: true,
applications: undefined, applications: undefined,
doors: undefined, doors: undefined,
keys: undefined keys: undefined,
}, },
}) })
if (res.error != '') { if (res.error != '') {
@@ -306,7 +321,10 @@ async function updatePlace(place: EditablePlace) {
if (file == null) { if (file == null) {
return return
} }
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name) const uploaded = await uploadFile(
file,
'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name,
)
if (uploaded.filename === '') { if (uploaded.filename === '') {
return return
} }
@@ -448,7 +466,8 @@ async function updateIntro() {
if (file == null) { if (file == null) {
return return
} }
audio = (await uploadFile(file, 'scenarios_' + scenario.value.id + '_intro_' + file.name)).filename audio = (await uploadFile(file, 'scenarios_' + scenario.value.id + '_intro_' + file.name))
.filename
if (audio === '') { if (audio === '') {
return return
} }
@@ -487,11 +506,11 @@ function getRandomComplimentForNastya() {
{ text: 'Настя, твоя энергия заряжает батарейки!', nameForm: 'Настя' }, { text: 'Настя, твоя энергия заряжает батарейки!', nameForm: 'Настя' },
{ text: 'С Настей хочется проводить каждую минуту.', nameForm: 'Настей' }, { text: 'С Настей хочется проводить каждую минуту.', nameForm: 'Настей' },
{ text: 'Настенька, ты — самое красивое, что случилось в этом мире.', nameForm: 'Настенька' }, { text: 'Настенька, ты — самое красивое, что случилось в этом мире.', nameForm: 'Настенька' },
{ text: 'У Насти золотое сердце.', nameForm: 'Насти' } { text: 'У Насти золотое сердце.', nameForm: 'Насти' },
]; ]
const randomIndex = Math.floor(Math.random() * compliments.length); const randomIndex = Math.floor(Math.random() * compliments.length)
return compliments[randomIndex].text; return compliments[randomIndex].text
} }
</script> </script>
@@ -499,16 +518,25 @@ function getRandomComplimentForNastya() {
<div class="center-block-custom"> <div class="center-block-custom">
<div class="width700"> <div class="width700">
<div class="places-container"> <div class="places-container">
<n-alert v-if="scenario.status == 'public'" title="Сценарий опубликован" type="warning" <n-alert
style="margin-bottom: 20px;"> v-if="scenario.status == 'public'"
title="Сценарий опубликован"
type="warning"
style="margin-bottom: 20px"
>
Не рекомендуется редактировать сценарий когда он опубликован. Не рекомендуется редактировать сценарий когда он опубликован.
</n-alert> </n-alert>
<n-alert v-if="authStore.user?.id == 2" :title="getRandomComplimentForNastya()" type="info" <n-alert
style="margin-bottom: 20px;"></n-alert> v-if="authStore.user?.id == 2"
:title="getRandomComplimentForNastya()"
type="info"
style="margin-bottom: 20px"
></n-alert>
<div class="settings-block"> <div class="settings-block">
Точек: {{ scenario.story?.places?.length }}, Вопросов: {{ scenario.story?.questions?.length }} Точек: {{ scenario.story?.places?.length }}, Вопросов:
{{ scenario.story?.questions?.length }}
<n-button quaternary @click="showSettingsModal = true" class="settings-button"> <n-button quaternary @click="showSettingsModal = true" class="settings-button">
<Icon class="settings-icon"> <Icon class="settings-icon">
<Settings /> <Settings />
@@ -526,33 +554,51 @@ function getRandomComplimentForNastya() {
<div class="questions-header">Вопросы дела</div> <div class="questions-header">Вопросы дела</div>
<QuestionBlock v-model:mode="questionMode" v-model:newQuestionCode="newQuestionCode" <QuestionBlock
v-model:newQuestionText="newQuestionText" v-model:newQuestionAnswer="newQuestionAnswer"> v-model:mode="questionMode"
v-model:newQuestionCode="newQuestionCode"
v-model:newQuestionText="newQuestionText"
v-model:newQuestionAnswer="newQuestionAnswer"
>
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="cancelAddQuestion()">Отмена</n-button> <n-button @click="cancelAddQuestion()">Отмена</n-button>
<n-button @click="addQuestion()" :disabled="!newQuestionCode">Добавить вопрос</n-button> <n-button @click="addQuestion()" :disabled="!newQuestionCode">Добавить вопрос</n-button>
</n-flex> </n-flex>
</QuestionBlock> </QuestionBlock>
<QuestionBlock :question="question" v-model:mode="question.mode" v-for="question in editorQuestions" <QuestionBlock
v-bind:key="question.oldCode ?? question.code ?? ''"> :question="question"
v-model:mode="question.mode"
v-for="question in editorQuestions"
v-bind:key="question.oldCode ?? question.code ?? ''"
>
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="deleteQuestion(question)" type="error" ghost>Удалить</n-button> <n-button @click="deleteQuestion(question)" type="error" ghost>Удалить</n-button>
<n-button @click="cancelUpdateQuestion(question)">Отмена</n-button> <n-button @click="cancelUpdateQuestion(question)">Отмена</n-button>
<n-button @click="updateQuestion(question)" :disabled="question.code == ''">Сохранить</n-button> <n-button @click="updateQuestion(question)" :disabled="question.code == ''"
>Сохранить</n-button
>
</n-flex> </n-flex>
</QuestionBlock> </QuestionBlock>
<PlaceBlock v-model:mode="mode" v-model:newPlaceCode="code" v-model:newPlaceName="name" <PlaceBlock
v-model:newPlaceText="text"> v-model:mode="mode"
v-model:newPlaceCode="code"
v-model:newPlaceName="name"
v-model:newPlaceText="text"
>
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="cancelAddPlace()">Отмена</n-button> <n-button @click="cancelAddPlace()">Отмена</n-button>
<n-button @click="addPlace()" :disabled="!code">Добавить точку</n-button> <n-button @click="addPlace()" :disabled="!code">Добавить точку</n-button>
</n-flex> </n-flex>
</PlaceBlock> </PlaceBlock>
<PlaceBlock :place="place" v-model:mode="place.mode" v-for="place in editorPlaces" <PlaceBlock
v-bind:key="place.oldCode ?? place.code ?? ''"> :place="place"
v-model:mode="place.mode"
v-for="place in editorPlaces"
v-bind:key="place.oldCode ?? place.code ?? ''"
>
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button> <n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button>
<n-button @click="cancelUpdatePlace(place)">Отмена</n-button> <n-button @click="cancelUpdatePlace(place)">Отмена</n-button>
@@ -565,20 +611,39 @@ function getRandomComplimentForNastya() {
<!-- Окно настроек --> <!-- Окно настроек -->
<n-modal v-model:show="showSettingsModal"> <n-modal v-model:show="showSettingsModal">
<n-card style="width: 600px" title="Настройки сценария" :bordered="false" size="huge" role="dialog" <n-card
aria-modal="true"> style="width: 600px"
title="Настройки сценария"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra> <template #header-extra>
<n-button @click="showSettingsModal = false"> x </n-button> <n-button @click="showSettingsModal = false"> x </n-button>
</template> </template>
<n-space vertical> <n-space vertical>
<p>Название</p> <p>Название</p>
<n-input v-model:value="scenario.name" type="text" placeholder='Дело №0 "Следствие ведут овечки"' /> <n-input
v-model:value="scenario.name"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<p class="settings-header">Описание</p> <p class="settings-header">Описание</p>
<n-input v-model:value="scenario.description" type="textarea" placeholder="Убийство пастуха..." /> <n-input
v-model:value="scenario.description"
type="textarea"
placeholder="Убийство пастуха..."
/>
<p class="settings-header">Картинка</p> <p class="settings-header">Картинка</p>
<n-input v-model:value="scenario.image" type="text" placeholder="default_scenario.png" disabled /> <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 directory-dnd :max="1" v-model:file-list="fileList">
<n-upload-dragger> <n-upload-dragger>
<n-text style="font-size: 16px"> <n-text style="font-size: 16px">
@@ -592,9 +657,15 @@ function getRandomComplimentForNastya() {
<hr class="settings-hr" /> <hr class="settings-hr" />
<p class="settings-header">Архив сценария</p> <p class="settings-header">Архив сценария</p>
<p class="settings-hint"> <p class="settings-hint">
Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом аккаунте. Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом
аккаунте.
</p> </p>
<n-button :loading="isDownloadingArchive" :disabled="!scenario.id" ghost @click="downloadArchive()"> <n-button
:loading="isDownloadingArchive"
:disabled="!scenario.id"
ghost
@click="downloadArchive()"
>
Скачать архив (.zip) Скачать архив (.zip)
</n-button> </n-button>
@@ -607,23 +678,45 @@ function getRandomComplimentForNastya() {
<hr class="settings-hr" /> <hr class="settings-hr" />
<p>Публикация</p> <p>Публикация</p>
<p> <p>
<n-tag v-if="scenario.status == 'public'" :bordered="false" type="success" class="status-block"> <n-tag
v-if="scenario.status == 'public'"
:bordered="false"
type="success"
class="status-block"
>
Опубликовано Опубликовано
</n-tag> </n-tag>
<n-tag v-if="scenario.status == 'draft'" :bordered="false" type="warning" class="status-block"> <n-tag
v-if="scenario.status == 'draft'"
:bordered="false"
type="warning"
class="status-block"
>
В разработке В разработке
</n-tag> </n-tag>
</p> </p>
<p> <p>
Введите название сценария <n-text class="name-text" strong>{{ scenario.name }}</n-text> Введите название сценария <n-text class="name-text" strong>{{ scenario.name }}</n-text>
</p> </p>
<n-input v-model:value="statusScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' /> <n-input
<n-button v-if="scenario.status != 'public'" @click="publicScenario(scenario.id!)" ghost v-model:value="statusScenarioName"
:disabled="statusScenarioName != scenario.name"> type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<n-button
v-if="scenario.status != 'public'"
@click="publicScenario(scenario.id!)"
ghost
:disabled="statusScenarioName != scenario.name"
>
Опубликовать сценарий Опубликовать сценарий
</n-button> </n-button>
<n-button v-if="scenario.status != 'draft'" @click="draftScenario(scenario.id!)" ghost <n-button
:disabled="statusScenarioName != scenario.name"> v-if="scenario.status != 'draft'"
@click="draftScenario(scenario.id!)"
ghost
:disabled="statusScenarioName != scenario.name"
>
Снять сценарий с публикации Снять сценарий с публикации
</n-button> </n-button>
@@ -633,9 +726,17 @@ function getRandomComplimentForNastya() {
Введите название сценария Введите название сценария
<n-text class="name-text" strong>{{ scenario.name }}</n-text> чтобы удалить его <n-text class="name-text" strong>{{ scenario.name }}</n-text> чтобы удалить его
</p> </p>
<n-input v-model:value="deletedScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' /> <n-input
<n-button @click="deleteScenario(scenario.id!)" type="error" ghost v-model:value="deletedScenarioName"
:disabled="deletedScenarioName != scenario.name"> type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<n-button
@click="deleteScenario(scenario.id!)"
type="error"
ghost
:disabled="deletedScenarioName != scenario.name"
>
Удалить сценарий Удалить сценарий
</n-button> </n-button>
</n-space> </n-space>
@@ -662,9 +763,9 @@ function getRandomComplimentForNastya() {
} }
.name-text { .name-text {
padding: 3px;
border-radius: 3px;
background-color: rgb(255 255 255 / 10%); background-color: rgb(255 255 255 / 10%);
border-radius: 3px;
padding: 3px;
} }
.settings-header { .settings-header {
@@ -681,9 +782,9 @@ function getRandomComplimentForNastya() {
} }
.questions-header { .questions-header {
margin: 30px 0 5px;
color: #aaa; color: #aaa;
font-size: 18px; font-size: 18px;
margin: 30px 0 5px;
} }
.places-container { .places-container {
+85 -37
View File
@@ -2,7 +2,18 @@
import { Archive } from '@vicons/carbon' import { Archive } from '@vicons/carbon'
import { Icon } from '@vicons/utils' import { Icon } from '@vicons/utils'
import { useMessage } from 'naive-ui' import { useMessage } from 'naive-ui'
import { NButton, NCard, NFlex, NInput, NModal, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui' import {
NButton,
NCard,
NFlex,
NInput,
NModal,
NTag,
NText,
NUpload,
NUploadDragger,
type UploadFileInfo,
} from 'naive-ui'
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -97,17 +108,34 @@ getScenarios()
</div> </div>
</div> </div>
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioEditor(scenario.id!)" <div
v-bind:key="scenario.id"> class="scenario-block"
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div> 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"> <div class="scenario-content-block">
<p class="scenario-title">{{ scenario.name }}</p> <p class="scenario-title">{{ scenario.name }}</p>
<p class="scenario-label"> <p class="scenario-label">
<n-tag v-if="scenario.status == 'public'" :bordered="false" type="success" <n-tag
class="status-block">Опубликовано</n-tag> v-if="scenario.status == 'public'"
<n-tag v-if="scenario.status == 'draft'" :bordered="false" type="warning" class="status-block">В :bordered="false"
разработке</n-tag> 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>
</div> </div>
</div> </div>
@@ -116,12 +144,22 @@ getScenarios()
<!-- Окно создания сценария --> <!-- Окно создания сценария -->
<n-modal v-model:show="showAddScenarioModal"> <n-modal v-model:show="showAddScenarioModal">
<n-card style="width: 600px" title="Создание сценария" :bordered="false" size="huge" role="dialog" <n-card
aria-modal="true"> style="width: 600px"
title="Создание сценария"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra> <template #header-extra>
<n-button @click="showAddScenarioModal = false"> x </n-button> <n-button @click="showAddScenarioModal = false"> x </n-button>
</template> </template>
<n-input v-model:value="newScenarioName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' /> <n-input
v-model:value="newScenarioName"
type="text"
placeholder='Дело №0 "Следствие ведут овечки"'
/>
<template #footer> <template #footer>
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="addScenario(newScenarioName)" :disabled="newScenarioName.length == 0"> <n-button @click="addScenario(newScenarioName)" :disabled="newScenarioName.length == 0">
@@ -134,28 +172,39 @@ getScenarios()
<!-- Окно импорта сценария из архива --> <!-- Окно импорта сценария из архива -->
<n-modal v-model:show="showImportModal"> <n-modal v-model:show="showImportModal">
<n-card style="width: 600px" title="Импорт сценария из архива" :bordered="false" size="huge" role="dialog" <n-card
aria-modal="true"> style="width: 600px"
title="Импорт сценария из архива"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template #header-extra> <template #header-extra>
<n-button @click="resetImport()"> x </n-button> <n-button @click="resetImport()"> x </n-button>
</template> </template>
<p class="import-hint"> <p class="import-hint">
Выберите ZIP-архив сценария (файл scenario.json + папка images). Архив можно получить Выберите ZIP-архив сценария (файл scenario.json + папка images). Архив можно получить через
через «Скачать архив» в настройках сценария. «Скачать архив» в настройках сценария.
</p> </p>
<n-upload :max="1" v-model:file-list="importFileList" <n-upload
accept=".zip,application/zip,application/x-zip-compressed,application/octet-stream"> :max="1"
v-model:file-list="importFileList"
accept=".zip,application/zip,application/x-zip-compressed,application/octet-stream"
>
<n-upload-dragger> <n-upload-dragger>
<n-text style="font-size: 16px"> <n-text style="font-size: 16px"> Щелкните или перетащите ZIP-архив в эту область </n-text>
Щелкните или перетащите ZIP-архив в эту область
</n-text>
</n-upload-dragger> </n-upload-dragger>
</n-upload> </n-upload>
<template #footer> <template #footer>
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="resetImport()">Отмена</n-button> <n-button @click="resetImport()">Отмена</n-button>
<n-button type="primary" :loading="isImporting" :disabled="importFileList.length == 0" <n-button
@click="importScenario()"> type="primary"
:loading="isImporting"
:disabled="importFileList.length == 0"
@click="importScenario()"
>
Импортировать Импортировать
</n-button> </n-button>
</n-flex> </n-flex>
@@ -168,16 +217,15 @@ getScenarios()
<style scoped> <style scoped>
.scenario-block { .scenario-block {
position: relative;
margin: 10px;
width: 160px;
height: 300px;
cursor: pointer;
border-radius: 12px;
/* background-color: #553333; */ /* background-color: #553333; */
background-color: #222; background-color: #222;
border: 2px solid #222; border: 2px solid #222;
border-radius: 12px;
cursor: pointer;
height: 300px;
margin: 10px;
position: relative;
width: 160px;
} }
.scenario-content-block { .scenario-content-block {
@@ -185,22 +233,22 @@ getScenarios()
} }
.scenario-block:hover { .scenario-block:hover {
color: #63e2b7;
border: 2px solid #63e2b7; border: 2px solid #63e2b7;
color: #63e2b7;
} }
.scenario-image-block { .scenario-image-block {
background-color: #111; background-color: #111;
height: 160px;
border-radius: 10px; border-radius: 10px;
height: 160px;
} }
.scenario-image-plus { .scenario-image-plus {
font-size: 120px;
display: flex;
justify-content: center;
align-items: center; align-items: center;
background-color: #111; background-color: #111;
display: flex;
font-size: 120px;
justify-content: center;
} }
.import-icon svg { .import-icon svg {
@@ -214,8 +262,8 @@ getScenarios()
} }
.scenario-image { .scenario-image {
background-size: cover;
background-position: center; background-position: center;
background-size: cover;
} }
.scenario-title { .scenario-title {
@@ -223,15 +271,15 @@ getScenarios()
} }
.status-block { .status-block {
position: absolute;
bottom: 15px; bottom: 15px;
position: absolute;
right: 15px; right: 15px;
} }
@media (width >=1024px) { @media (width >=1024px) {
.scenario-block { .scenario-block {
width: 250px;
height: 400px; height: 400px;
width: 250px;
} }
.scenario-image-block { .scenario-image-block {
+256 -222
View File
@@ -1,19 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { NButton, NConfigProvider, NFlex, NInput, NQrCode } from 'naive-ui' import { NButton, NConfigProvider, NFlex, NInput, NQrCode } from 'naive-ui'
import { useMessage } from 'naive-ui'; import { useMessage } from 'naive-ui'
import { computed, nextTick, ref } from 'vue'; import { computed, nextTick, ref } from 'vue'
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'; import { getAuthClient } from '@/api/auth_client'
import type { Game, Introduction, Place, Question } from '@/api/generated/crabs/evening_detective_server'; import type {
Game,
Introduction,
Place,
Question,
} from '@/api/generated/crabs/evening_detective_server'
import GameInputForm from '@/components/GameInputForm.vue' import GameInputForm from '@/components/GameInputForm.vue'
import HeaderMenu from '@/components/HeaderMenu.vue' import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue' import IntroBlock from '@/components/IntroBlock.vue'
import PlaceBlock from '@/components/PlaceBlock.vue' import PlaceBlock from '@/components/PlaceBlock.vue'
import { useSimplePolling } from '@/composables/useSimplePolling'; import { useSimplePolling } from '@/composables/useSimplePolling'
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth'
import MessagePaper from './MessagePaper.vue'; import MessagePaper from './MessagePaper.vue'
const authStore = useAuthStore() const authStore = useAuthStore()
const client = getAuthClient() const client = getAuthClient()
@@ -24,22 +29,22 @@ const password = route.query.password
const newPlacesCount = ref(0) const newPlacesCount = ref(0)
const scrollContainer = ref<HTMLDivElement | null>(); const scrollContainer = ref<HTMLDivElement | null>()
const story = ref<AdvancedStory>({ const story = ref<AdvancedStory>({
introduction: undefined, introduction: undefined,
places: [], places: [],
questions: [] questions: [],
}) })
const game = ref<Game | undefined>({ const game = ref<Game | undefined>({
id: undefined, id: undefined,
name: undefined, name: undefined,
description: undefined, description: undefined,
startAt: undefined, startAt: undefined,
status: undefined, status: undefined,
scenario: undefined, scenario: undefined,
teams: undefined teams: undefined,
}) })
const code = ref('') const code = ref('')
@@ -47,134 +52,135 @@ const code = ref('')
const url = window.location.href const url = window.location.href
const isAtBottom = () => { const isAtBottom = () => {
const container = scrollContainer.value; const container = scrollContainer.value
if (!container) return false; if (!container) return false
return container.scrollHeight - container.scrollTop - container.clientHeight < 1; return container.scrollHeight - container.scrollTop - container.clientHeight < 1
}; }
async function getStory() { async function getStory() {
const oldStory = story.value const oldStory = story.value
const res = await client.GetTeamStory({ const res = await client.GetTeamStory({
id: Number(teamId), id: Number(teamId),
password: password as string password: password as string,
}) })
if (res.error != '') { if (res.error != '') {
message.error(res.error!) message.error(res.error!)
return return
} }
game.value = res.game game.value = res.game
const wasAtBottom = isAtBottom(); const wasAtBottom = isAtBottom()
if (wasAtBottom) { if (wasAtBottom) {
newPlacesCount.value = 0 newPlacesCount.value = 0
} }
const oldCount = story.value.places.length const oldCount = story.value.places.length
story.value = { story.value = {
introduction: res.story!.introduction, introduction: res.story!.introduction,
places: res.story!.places?.map((item, i) => { places:
console.log(oldStory?.places[i]?.mode) res.story!.places?.map((item, i) => {
return { console.log(oldStory?.places[i]?.mode)
place: item, return {
mode: oldStory?.places[i]?.mode || 'show' place: item,
} mode: oldStory?.places[i]?.mode || 'show',
}) || [], }
questions: res.story!.questions || [] }) || [],
} questions: res.story!.questions || [],
}
const newCount = story.value.places.length const newCount = story.value.places.length
if (newCount > oldCount && oldCount != 0) { if (newCount > oldCount && oldCount != 0) {
newPlacesCount.value += newCount - oldCount newPlacesCount.value += newCount - oldCount
} }
await nextTick(); await nextTick()
if (wasAtBottom && oldCount != 0) { if (wasAtBottom && oldCount != 0) {
const container = scrollContainer.value; const container = scrollContainer.value
if (!container) return false; if (!container) return false
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight
} }
} }
getStory() getStory()
getAnswers() getAnswers()
async function addAction(newCode: string) { async function addAction(newCode: string) {
const res = await client.AddTeamAction({ const res = await client.AddTeamAction({
id: Number(teamId), id: Number(teamId),
password: password as string, password: password as string,
code: newCode code: newCode,
}) })
if (res.error != '') { if (res.error != '') {
message.error(res.error!) message.error(res.error!)
return return
} }
await getStory() await getStory()
code.value = '' code.value = ''
await nextTick(); await nextTick()
newPlacesCount.value = 0 newPlacesCount.value = 0
} }
type AdvancedStory = { type AdvancedStory = {
introduction?: Introduction; introduction?: Introduction
places: AdvancedPlace[]; places: AdvancedPlace[]
questions: Question[]; questions: Question[]
} }
type AdvancedPlace = { type AdvancedPlace = {
place: Place; place: Place
mode: string; mode: string
} }
// Ответы команды на вопросы дела: код вопроса -> текст ответа // Ответы команды на вопросы дела: код вопроса -> текст ответа
const answers = ref<Record<string, string>>({}) const answers = ref<Record<string, string>>({})
async function getAnswers() { async function getAnswers() {
const res = await client.GetTeamAnswers({ const res = await client.GetTeamAnswers({
id: Number(teamId), id: Number(teamId),
password: password as string password: password as string,
}) })
if (res.error != '') { if (res.error != '') {
message.error(res.error!) message.error(res.error!)
return return
} }
const next: Record<string, string> = {} const next: Record<string, string> = {}
for (const answer of res.answers || []) { for (const answer of res.answers || []) {
next[answer.questionCode || ''] = answer.answer || '' next[answer.questionCode || ''] = answer.answer || ''
} }
answers.value = next answers.value = next
} }
async function submitAnswers() { async function submitAnswers() {
const res = await client.SubmitTeamAnswers({ const res = await client.SubmitTeamAnswers({
id: Number(teamId), id: Number(teamId),
password: password as string, password: password as string,
answers: (story.value.questions || []).map((question) => ({ answers: (story.value.questions || []).map((question) => ({
questionCode: question.code, questionCode: question.code,
answer: answers.value[question.code || ''] || '' answer: answers.value[question.code || ''] || '',
})) })),
}) })
if (res.error != '') { if (res.error != '') {
message.error(res.error!) message.error(res.error!)
return return
} }
message.success('Ответы сохранены') message.success('Ответы сохранены')
await getAnswers() await getAnswers()
} }
// Отвечать можно только пока игра идёт (run/pause); после завершения // Отвечать можно только пока игра идёт (run/pause); после завершения
// ответы фиксируются для оценки организатором. // ответы фиксируются для оценки организатором.
const canAnswer = computed(() => { const canAnswer = computed(() => {
return game.value?.status == 'run' || game.value?.status == 'pause' return game.value?.status == 'run' || game.value?.status == 'pause'
}) })
const hasQuestions = computed(() => { const hasQuestions = computed(() => {
return (story.value.questions?.length || 0) > 0 return (story.value.questions?.length || 0) > 0
}) })
// Поля ввода и кнопка на светлом бумажном листе: тёмная тема naive-ui // Поля ввода и кнопка на светлом бумажном листе: тёмная тема naive-ui
@@ -183,188 +189,216 @@ const hasQuestions = computed(() => {
// «чернильной» рамкой (бумага просвечивает), шрифт наследуется // «чернильной» рамкой (бумага просвечивает), шрифт наследуется
// печатной машинки от родителя. // печатной машинки от родителя.
const paperControlsOverrides = { const paperControlsOverrides = {
Input: { Input: {
border: '1px dashed black', border: '1px dashed black',
borderDisabled: '1px dashed black', borderDisabled: '1px dashed black',
borderFocus: '1px solid black', borderFocus: '1px solid black',
borderHover: '1px dashed black', borderHover: '1px dashed black',
boxShadowFocus: '0 0 0 2px rgb(99 226 183 / 30%)', boxShadowFocus: '0 0 0 2px rgb(99 226 183 / 30%)',
caretColor: '#63e2b7', caretColor: '#63e2b7',
color: 'transparent', color: 'transparent',
colorDisabled: 'transparent', colorDisabled: 'transparent',
colorFocus: 'transparent', colorFocus: 'transparent',
fontSizeMedium: '18px', fontSizeMedium: '18px',
placeholderColor: 'black', placeholderColor: 'black',
placeholderColorDisabled: 'black', placeholderColorDisabled: 'black',
textColor: 'black', textColor: 'black',
textColorDisabled: 'black', textColorDisabled: 'black',
}, },
} }
async function deleteLast() { async function deleteLast() {
const res = await client.DeleteLastTeamAction({ const res = await client.DeleteLastTeamAction({
id: Number(teamId), id: Number(teamId),
}) })
if (res.error != '') { if (res.error != '') {
message.error(res.error!) message.error(res.error!)
return return
} }
await getStory() await getStory()
code.value = '' code.value = ''
} }
useSimplePolling(() => { useSimplePolling(() => {
getStory() getStory()
}, 2000); }, 2000)
</script> </script>
<template> <template>
<div class="center-block-custom bottom-margin smooth-scroll" ref="scrollContainer"> <div class="center-block-custom bottom-margin smooth-scroll" ref="scrollContainer">
<div class="width700"> <div class="width700">
<GameInputForm :addAction="addAction" :allPlacesCount="story.places.length" :newPlacesCount="newPlacesCount" <GameInputForm
:gameStatus="game?.status || ''"></GameInputForm> :addAction="addAction"
:allPlacesCount="story.places.length"
:newPlacesCount="newPlacesCount"
:gameStatus="game?.status || ''"
></GameInputForm>
<IntroBlock <IntroBlock
v-if="game?.status && game.status != 'draft' && story.introduction && (story.introduction.text || story.introduction.audio)" v-if="
:introduction="story.introduction" mode="show"> game?.status &&
</IntroBlock> game.status != 'draft' &&
story.introduction &&
(story.introduction.text || story.introduction.audio)
"
:introduction="story.introduction"
mode="show"
>
</IntroBlock>
<MessagePaper v-if="hasQuestions && game?.status && game.status != 'draft'" class="questions-block"> <MessagePaper
<div class="font">Вопросы дела</div> v-if="hasQuestions && game?.status && game.status != 'draft'"
<hr class="questions-hr" /> class="questions-block"
<p class="questions-hint font"> >
Ответьте на вопросы дела. Ответы можно менять, пока игра идёт. <div class="font">Вопросы дела</div>
</p> <hr class="questions-hr" />
<n-config-provider :theme-overrides="paperControlsOverrides"> <p class="questions-hint font">
<div v-for="question in story.questions" :key="question.code" class="question-item font"> Ответьте на вопросы дела. Ответы можно менять, пока игра идёт.
<div class="question-text">{{ question.code }}: {{ question.text }}</div> </p>
<n-input v-model:value="answers[question.code || '']" type="textarea" <n-config-provider :theme-overrides="paperControlsOverrides">
:autosize="{ minRows: 2, maxRows: 6 }" :disabled="!canAnswer" placeholder="Ваш ответ..." /> <div v-for="question in story.questions" :key="question.code" class="question-item font">
</div> <div class="question-text">{{ question.code }}: {{ question.text }}</div>
<n-flex v-if="canAnswer" justify="end" class="questions-actions"> <n-input
<button type="button" class="paper-button" @click="submitAnswers()">Сохранить ответы</button> v-model:value="answers[question.code || '']"
</n-flex> type="textarea"
</n-config-provider> :autosize="{ minRows: 2, maxRows: 6 }"
</MessagePaper> :disabled="!canAnswer"
placeholder="Ваш ответ..."
/>
</div>
<n-flex v-if="canAnswer" justify="end" class="questions-actions">
<button type="button" class="paper-button" @click="submitAnswers()">
Сохранить ответы
</button>
</n-flex>
</n-config-provider>
</MessagePaper>
<PlaceBlock :place="advancedPlace.place" v-model:mode="advancedPlace.mode" <PlaceBlock
v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction"> :place="advancedPlace.place"
</PlaceBlock> 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 && game?.status == 'draft'"> <MessagePaper
<n-space vertical> class="qr-main-block"
<h2 class="qr-main-block-text">Это код этого дела</h2> v-if="story.places.length == 0 && game?.status == 'draft'"
<p class="qr-main-block-text">Поделись с командой</p> >
<div class="qr-block"> <n-space vertical>
<n-qr-code :value="url" :size="300" :padding="0" /> <h2 class="qr-main-block-text">Это код этого дела</h2>
</div> <p class="qr-main-block-text">Поделись с командой</p>
<p class="qr-main-block-text">Игра скоро начнется</p> <div class="qr-block">
</n-space> <n-qr-code :value="url" :size="300" :padding="0" />
</MessagePaper> </div>
<p class="qr-main-block-text">Игра скоро начнется</p>
</n-space>
</MessagePaper>
<div v-if="authStore.hasRole('author') && story.places.length > 0" class="edit-panel"> <div v-if="authStore.hasRole('author') && story.places.length > 0" class="edit-panel">
<n-flex justify="end"> <n-flex justify="end">
<n-button @click="deleteLast">Удалить последний ход</n-button> <n-button @click="deleteLast">Удалить последний ход</n-button>
</n-flex> </n-flex>
</div> </div>
</div>
</div> </div>
</div>
<HeaderMenu active="games" /> <HeaderMenu active="games" />
</template> </template>
<style scoped> <style scoped>
.bottom-margin { .bottom-margin {
height: calc(100dvh - 70px - 76px); height: calc(100dvh - 70px - 76px);
} }
.smooth-scroll { .smooth-scroll {
scroll-behavior: smooth; scroll-behavior: smooth;
} }
.edit-panel { .edit-panel {
margin: 30px 0; background-color: #222;
padding: 20px; border-radius: 10px;
background-color: #222; margin: 30px 0;
border-radius: 10px; padding: 20px;
} }
.qr-main-block { .qr-main-block {
display: flex; align-items: center;
justify-content: center; display: flex;
align-items: center; justify-content: center;
} }
.questions-hr { .questions-hr {
margin: 10px 0; border: dashed 1px;
border: dashed 1px; border-color: black;
border-color: black; margin: 10px 0;
} }
.questions-hint { .questions-hint {
color: black; color: black;
} }
.question-item { .question-item {
margin: 15px 0; margin: 15px 0;
} }
.question-text { .question-text {
margin-bottom: 6px; font-weight: 500;
font-weight: 500; margin-bottom: 6px;
} }
.questions-actions { .questions-actions {
margin-top: 10px; margin-top: 10px;
} }
/* Кнопка действия на бумажном листе — как «двери» в точках: /* Кнопка действия на бумажном листе — как «двери» в точках:
текстура ремня, бронзовый текст печатной машинки */ текстура ремня, бронзовый текст печатной машинки */
.paper-button { .paper-button {
display: inline-block; background-image: url('@/assets/images/belt.png');
margin-top: 10px; background-size: cover;
padding: 5px 12px; border: 0;
border: 0; border-radius: 5px;
border-radius: 5px; color: #bfa07d;
background-image: url('@/assets/images/belt.png'); cursor: pointer;
background-size: cover; display: inline-block;
color: #bfa07d; font-family: 'font_old_typer', serif;
font-family: 'font_old_typer', serif; font-size: 16px;
font-size: 16px; font-weight: 600;
font-weight: 600; margin-top: 10px;
cursor: pointer; padding: 5px 12px;
} }
.paper-button:hover { .paper-button:hover {
color: #63e2b7; color: #63e2b7;
} }
.font { .font {
font-family: 'font_old_typer', serif; font-family: 'font_old_typer', serif;
font-size: 18px; font-size: 18px;
} }
.qr-main-block-text { .qr-main-block-text {
font-family: 'font_old_typer', serif; font-family: 'font_old_typer', serif;
font-size: 18px; font-size: 18px;
margin: 10px 0; margin: 10px 0;
text-align: left; text-align: left;
} }
p.qr-main-block-text { p.qr-main-block-text {
font-weight: 500; font-weight: 500;
} }
.qr-block { .qr-block {
display: inline-block; background-color: white;
background-color: white; display: inline-block;
padding: 12px; padding: 12px;
} }
@media (width >=1024px) { @media (width >=1024px) {
.bottom-margin { .bottom-margin {
height: calc(100dvh - 70px - 76px); height: calc(100dvh - 70px - 76px);
} }
} }
</style> </style>
+15 -14
View File
@@ -44,29 +44,30 @@ async function deleteRole(userId: number, role: string) {
<div class="center-block-custom"> <div class="center-block-custom">
<div class="width700"> <div class="width700">
<div v-for="user in users" v-bind:key="user.id" class="user-block"> <div v-for="user in users" v-bind:key="user.id" class="user-block">
<div class="user-field"> <div class="user-field">Позывной: {{ user.username }}</div>
Позывной: {{ user.username }} <div class="user-field">Почта: {{ user.email }}</div>
</div>
<div class="user-field">
Почта: {{ user.email }}
</div>
<div class="user-field"> <div class="user-field">
<n-space> <n-space>
Роли: <n-tag :bordered="false" v-for="role in user.roles" v-bind:key="role"> Роли:
<n-tag :bordered="false" v-for="role in user.roles" v-bind:key="role">
{{ role }} {{ role }}
</n-tag> </n-tag>
</n-space> </n-space>
</div> </div>
<n-flex justify="end" class="roles-buttons-block"> <n-flex justify="end" class="roles-buttons-block">
<n-button @click="addRole(user.id!, 'author')" v-if="!user.roles?.includes('author')"
<n-button @click="addRole(user.id!, 'author')" v-if="!user.roles?.includes('author')">Сделать >Сделать автором</n-button
автором</n-button> >
<n-button @click="deleteRole(user.id!, 'author')" v-else>Забрать роль автора</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="addRole(user.id!, 'organizer')"
<n-button @click="deleteRole(user.id!, 'organizer')" v-else>Забрать роль организатора</n-button> v-if="!user.roles?.includes('organizer')"
>Сделать организатором</n-button
>
<n-button @click="deleteRole(user.id!, 'organizer')" v-else
>Забрать роль организатора</n-button
>
</n-flex> </n-flex>
</div> </div>
</div> </div>
+18 -18
View File
@@ -1,25 +1,25 @@
import { onMounted, onUnmounted,ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue'
export function useSimplePolling(callback: () => void, interval = 5000) { export function useSimplePolling(callback: () => void, interval = 5000) {
const isActive = ref(true); const isActive = ref(true)
let timerId: number | null = null; let timerId: number | null = null
const start = () => { const start = () => {
isActive.value = true; isActive.value = true
callback(); callback()
timerId = window.setInterval(callback, interval); timerId = window.setInterval(callback, interval)
}; }
const stop = () => { const stop = () => {
isActive.value = false; isActive.value = false
if (timerId) { if (timerId) {
clearInterval(timerId); clearInterval(timerId)
timerId = null; timerId = null
} }
}; }
onMounted(start); onMounted(start)
onUnmounted(stop); onUnmounted(stop)
return { isActive, start, stop }; return { isActive, start, stop }
} }
+6 -1
View File
@@ -1,6 +1,11 @@
import type { UploadFileInfo } from 'naive-ui' import type { UploadFileInfo } from 'naive-ui'
import type { Application, Introduction, Place, Question } from '@/api/generated/crabs/evening_detective_server' import type {
Application,
Introduction,
Place,
Question,
} from '@/api/generated/crabs/evening_detective_server'
// Служебные поля интерфейса, которых нет в сгенерированной схеме API // Служебные поля интерфейса, которых нет в сгенерированной схеме API
// (типы из генератора — type-алиасы, их нельзя расширить через declare module). // (типы из генератора — type-алиасы, их нельзя расширить через declare module).
+1 -3
View File
@@ -3,7 +3,5 @@ import CatalogPage from '@/components/CatalogPage.vue'
</script> </script>
<template> <template>
<CatalogPage /> <CatalogPage />
</template> </template>
<style lang="css"></style>
+1 -3
View File
@@ -3,7 +3,5 @@ import GamePage from '@/components/GamePage.vue'
</script> </script>
<template> <template>
<GamePage /> <GamePage />
</template> </template>
<style lang="css"></style>
-2
View File
@@ -5,5 +5,3 @@ import GamesPage from '@/components/GamesPage.vue'
<template> <template>
<GamesPage /> <GamesPage />
</template> </template>
<style lang="css"></style>
+7 -11
View File
@@ -1,7 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import HeaderMenu from '@/components/HeaderMenu.vue' import HeaderMenu from '@/components/HeaderMenu.vue'
</script> </script>
<template> <template>
@@ -9,9 +7,7 @@ import HeaderMenu from '@/components/HeaderMenu.vue'
<div> <div>
<h1 class="font-text">Вечерний детектив</h1> <h1 class="font-text">Вечерний детектив</h1>
<h3 class="font-text">Дело №0</h3> <h3 class="font-text">Дело №0</h3>
<p class="content-text"> <p class="content-text">Поиграем?</p>
Поиграем?
</p>
</div> </div>
</div> </div>
@@ -20,26 +16,26 @@ import HeaderMenu from '@/components/HeaderMenu.vue'
<style lang="css"> <style lang="css">
.font-text { .font-text {
font-family: 'font_old_typer'; font-family: 'font_old_typer', serif;
} }
.content-text { .content-text {
position: relative;
top: 100px;
right: 0;
float: right; float: right;
position: relative;
right: 0;
top: 100px;
} }
@media (width >=1024px) { @media (width >=1024px) {
.font-text { .font-text {
position: relative;
left: -50px; left: -50px;
position: relative;
} }
.content-text { .content-text {
position: relative; position: relative;
top: 100px;
right: -50px; right: -50px;
top: 100px;
} }
} }
</style> </style>
-1
View File
@@ -6,6 +6,5 @@ import LoginPage from '@/components/LoginPage.vue'
<template> <template>
<LoginPage /> <LoginPage />
<HeaderMenu /> <HeaderMenu />
</template> </template>
+1 -1
View File
@@ -3,5 +3,5 @@ import ScenarioPage from '@/components/ScenarioPage.vue'
</script> </script>
<template> <template>
<ScenarioPage /> <ScenarioPage />
</template> </template>
+1 -1
View File
@@ -3,5 +3,5 @@ import TeamStoryPage from '@/components/TeamStoryPage.vue'
</script> </script>
<template> <template>
<TeamStoryPage /> <TeamStoryPage />
</template> </template>
+2 -1
View File
@@ -1,4 +1,5 @@
export default { export default {
plugins: ['stylelint-order'],
extends: [ extends: [
'stylelint-config-standard-scss', // Базовые правила для SCSS и CSS 'stylelint-config-standard-scss', // Базовые правила для SCSS и CSS
], ],
@@ -14,4 +15,4 @@ export default {
'order/properties-alphabetical-order': true, // Сортировка свойств 'order/properties-alphabetical-order': true, // Сортировка свойств
// 'selector-class-pattern': '^[a-z][a-zA-Z0-9]+$', // Названия классов в camelCase // 'selector-class-pattern': '^[a-z][a-zA-Z0-9]+$', // Названия классов в camelCase
}, },
}; }
+2 -5
View File
@@ -6,13 +6,10 @@ import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [vue(), vueDevTools()],
vue(),
vueDevTools(),
],
resolve: { resolve: {
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url)),
}, },
}, },
}) })