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