Compare commits

..

10 Commits

Author SHA1 Message Date
VLADIMIR 1bf463de02 update agents 2026-08-31 01:33:24 +07:00
VLADIMIR 8d24eb2844 update files 2026-08-31 00:05:01 +07:00
VLADIMIR a3f5eef6be up api 2026-08-30 22:11:28 +07:00
VLADIMIR 2a33998a8c up 2026-08-28 23:30:16 +07:00
VLADIMIR 8a535a3f3c up proto 2026-08-28 22:05:23 +07:00
VLADIMIR 606d515ade add buttons 2026-08-20 23:39:56 +07:00
VLADIMIR 4174ad304a update api 2026-08-20 23:22:46 +07:00
VLADIMIR 862c918981 updates 2026-08-20 01:57:39 +07:00
VLADIMIR bb8fca352a add delete game button 2026-08-18 01:23:42 +07:00
VLADIMIR ffa80a19ad fix 2026-08-08 18:20:43 +07:00
29 changed files with 4437 additions and 2548 deletions
+104
View File
@@ -0,0 +1,104 @@
# AGENTS.md — Вечерний детектив (фронтенд)
Правила работы агента над этим репозиторием. Читай целиком перед началом любой задачи.
Онлайн-детективная игра: команды проходят «дело» — сценарий из точек, дверей, ключей и улик.
## 1. Стек и команды
- **Стек:** Vue 3.5 (`<script setup lang="ts">`), Vite 8, Pinia 3, Vue Router 5, Naive UI (тёмная тема), @vicons, axios.
- **API:** клиенты генерируются из `proto/main.proto``src/api/generated/`.
| Команда | Назначение |
|---|---|
| `npm run dev` | dev-сервер (Vite, `--host`) |
| `npm run type-check` | проверка типов (vue-tsc) |
| `npm run lint` | ESLint с автофиксами |
| `npm run lint:css` | Stylelint (алфавитный порядок свойств) |
| `npm run format` | Prettier (`src/`) |
| `npm run build` | type-check + сборка |
| `npm run proto:gen` | генерация API-клиентов из proto (после изменения proto) |
## 2. Структура и соглашения
- `src/views/*View.vue` — тонкие обёртки над страницами (только импорт и `<Page />`).
- `src/components/*Page.vue` — страницы (GamesPage, OfficePage, TeamStoryPage…).
- `src/components/*.vue` — переиспользуемые блоки (HeaderMenu, MessagePaper, PlaceBlock, IntroBlock…).
- `src/api/generated/**`**сгенерированный код, руками не редактировать.** Обновляется только через `npm run proto:gen`.
- `src/stores/`, `src/composables/`, `src/utils/`, `src/types/` — по назначению.
- Алиас `@/``src/`. Импорты: `@/components/...` для общих блоков, относительные для соседних файлов.
- UI-тексты — на русском.
## 3. Правила выполнения задач
1. **Сначала изучи.** Прочитай `package.json`, `tsconfig.app.json`, релевантные компоненты и существующие паттерны. Следуй им; не вводи новый стек, механизмы и зависимости без согласования с пользователем.
2. **Код:** `defineProps` с типами (`Object as PropType<T>`), типизированные `emit`, производные значения через `computed`. Не дублируй логику — выноси в `composables/`/`utils/`.
3. **Импорты:** сортируй (simple-import-sort): сторонние → `@/` → относительные. Удаляй неиспользуемые (unused-imports). Точки с запятой не ставим, одинарные кавычки, ширина 100 (Prettier).
4. **API:** только через сгенерированные клиенты (`getAuthClient()` и т.п.). После вызова проверяй `res.error` и показывай `message.error(res.error!)`. Поля из proto необязательны — бери с `!` как в существующем коде.
5. **Ошибки и состояния:** используй `useMessage()` из naive-ui; загрузку — простым `ref` + асинхронная функция (существующий паттерн), поллинг — `useSimplePolling`.
6. **Стили:** `scoped`, свойства по алфавиту (stylelint). Не добавляй новые цвета хардкодом — только палитра из раздела 4.
7. **Адаптивность:** мобильная вёрстка по умолчанию, брейкпоинт `@media (width >= 1024px)` (существующий паттерн). Скрывай скроллбары как `.center-block-custom`.
8. **Проверки перед сдачей (всегда):** `npm run type-check``npm run lint``npm run lint:css`. При затрагивании сборки — `npm run build`. Сообщи в ответе, какие проверки прошли.
9. **Запрещено:** `git commit` и `git push` (глобальное правило). Не переписывай чужой код без необходимости — минимальные целевые изменения.
## 4. Правила дизайна (канон «бумажного детектива»)
Стилистика проекта — тёмный «кабинет сыщика»: тёмный фон, «бумажные листы дел» с текстурой и шрифтом печатной машинки, бронзовые/золотые акценты, зелёный акцент интерактива.
### 4.1 Палитра (использовать только эти значения)
| Роль | Значение |
|---|---|
| Фон страницы | `#111` |
| Панели / карточки / блоки | `#222` (border-radius: 10px) |
| Основной текст | `#eee` |
| Вторичный текст (подписи, служебное) | `#aaa`, `#777` |
| Акцент hover/active | `#63e2b7` |
| Бронза (заголовки на бумаге, PDF-ссылки) | `--second-color: rgb(97 74 22)` |
| Золото (ключи, «требуемые ключи») | `gold` |
| Кнопки-действия на бумаге | текст `#bfa07d`, фон — текстура `belt.png` |
| Подписи улик/приложений | `burlywood` |
| Текст на бумажном листе | `black` |
### 4.2 Типографика
- **UI (формы, панели, навигация):** системный стек из `base.css`, 15px.
- **Бумажные листы дел:** `font-family: 'font_old_typer'` (шрифт подключён в `main.css`, файл `src/assets/fonts/a_OldTyper.ttf`). Контент — 18px, заголовок вступления — 26px с `letter-spacing: 3px`.
- Никогда не используй `font_old_typer` для UI-элементов (кнопки, формы, меню).
### 4.3 Бумажный лист (компонент MessagePaper)
Контент дел (вступление, точки, приложения) оборачивается в `MessagePaper`:
- Фон — текстура `paper.jpg` (`background-size: cover`), тень `0 0 5px black`, слои-подложки с поворотами `rotate(-3deg)` / `rotate(2deg)`.
- Разделители — пунктирные `hr`: `border: dashed 1px` (на бумаге — `black`, в тёмных панелях — `#eee`).
- **«Фотографии»** (картинка точки, картинки-улики): подложка с текстурой `paper_white.jpg`, паддинг 7px, `transform: rotate(-3deg)`, тень `0 3px 15px rgb(98 98 98)`, `cursor: zoom-in`, клик — просмотр в `n-modal` + `n-card`.
- **Улики-картинки в потоке текста:** блок `float: right`, текст обтекает; контейнер — `display: flow-root` (BFC, чтобы не наезжало на двери).
- **Кнопки действий («двери»):** фон `belt.png` (`background-size: cover`), текст `#bfa07d`, `border-radius: 5px`, inline-block, паддинг `5px 12px`.
- **Сворачивание листа:** иконка `collapse.png` (45×28), развёрнутый лист — `rotate(-180deg)`.
### 4.4 Компоненты и иконки
- UI-контролы — только naive-ui (`n-*`): тёмная тема задана в `App.vue` (`NConfigProvider :theme="darkTheme"`). Не стилизуй нативные `<input>/<button>`.
- Иконки — `@vicons` через обёртку `<Icon>` из `@vicons/utils`, размер через `size` (например `<Icon size="28"><MenuOutlined /></Icon>`).
- Модальные окна просмотра — `n-modal` + `n-card`, ширина `min(94vw, 1200px)`, `role="dialog" aria-modal="true"`.
### 4.5 Раскладка страниц
- Фиксированный `HeaderMenu` сверху, высота 70px; контент — контейнер `.center-block-custom` (`margin-top: 70px`, высота `calc(100dvh - 70px)`, скрытый скроллбар).
- Ширина контента: `.width700` — бумажные листы/дела, `.width1200` — списки и таблицы.
- Карточки списков (игры, сценарии): фон `#222`, `border-radius: 10px`, hover — `color: #63e2b7` + `cursor: pointer`.
- Интерактивные блоки-«листы» в редакторе: `.place-block-hover` — hover-рамка `1px solid #63e2b7`.
### 4.6 Поведение и состояния
- Hover интерактивных элементов — только `#63e2b7` (не изобретай новые цвета).
- Заголовки и служебные подписи — вторичные цвета палитры (`#aaa`/`#777`), без ярких акцентов.
- Новые цвета/токены добавлять только через `src/assets/base.css` в `:root` (существующие: `--main-color`, `--second-color`, `--main-back-color`, `--main-back-item-color`) — и только если без них нельзя обойтись палитрой.
## 5. Чек-лист перед сдачей
1. ✅ Соблюдены соглашения структуры и импортов (разделы 2–3).
2. ✅ Использована только палитра и стилистика раздела 4.
3. ✅ Адаптивность и скролл-контейнеры как в существующих страницах.
4.`npm run type-check`, `npm run lint`, `npm run lint:css` — без ошибок (отчёт в ответе).
5. ✅ Сгенерированный код не тронут; git commit/push не выполнялись.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2431
View File
File diff suppressed because one or more lines are too long
-2388
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,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-Dn-IZW42.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CZManGvh.css">
<script type="module" crossorigin src="/assets/index-D7Q2J__k.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BsEbL24Y.css">
</head>
<body>
<div id="app"></div>
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfigWithVueTs(
files: ['**/*.{ts,mts,tsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**', 'src/api/generated/**']),
pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
+139
View File
@@ -115,6 +115,47 @@ service EveningDetectiveServer {
};
}
rpc DeleteAccount(DeleteAccountReq) returns (DeleteAccountRsp) {
option (google.api.http) = {
delete: "/api/auth/delete-account"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Регистрация и вход";
summary : "Удалить учётную запись и персональные данные";
description: "Удаляет учётную запись и персональные данные текущего пользователя (ст. 21 152-ФЗ). Требует заголовок X-Password с паролем учётной записи (подтверждение владельца; пароль не передаётся в URL).";
};
}
rpc GetTerms(GetTermsReq) returns (GetTermsRsp) {
option (google.api.http) = {
get: "/api/terms"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Документы";
summary: "Получить текст Пользовательского соглашения (оферты)";
};
}
rpc GetPrivacy(GetPrivacyReq) returns (GetPrivacyRsp) {
option (google.api.http) = {
get: "/api/privacy"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Документы";
summary: "Получить текст Политики конфиденциальности";
};
}
rpc GetConsent(GetConsentReq) returns (GetConsentRsp) {
option (google.api.http) = {
get: "/api/consent"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Документы";
summary: "Получить текст Согласия на обработку персональных данных";
};
}
rpc GetUsers(GetUsersReq) returns (GetUsersRsp) {
option (google.api.http) = {
get: "/api/users"
@@ -324,6 +365,38 @@ service EveningDetectiveServer {
};
}
rpc UpdateScenarioIntro(UpdateScenarioIntroReq) returns (UpdateScenarioIntroRsp) {
option (google.api.http) = {
put : "/api/scenarios/{id}/introduction"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Сценарии";
summary: "Обновить введение сценария";
};
}
rpc DownloadScenarioArchive(DownloadScenarioArchiveReq) returns (google.api.HttpBody) {
option (google.api.http) = {
get: "/api/scenarios/{id}/archive"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Сценарии";
summary: "Скачать сценарий архивом (со всеми материалами и картинками)";
};
}
rpc UploadScenarioArchive(google.api.HttpBody) returns (UploadScenarioArchiveRsp) {
option (google.api.http) = {
post: "/api/scenarios/archive"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Сценарии";
summary: "Создать сценарий из архива";
};
}
rpc AddGame(AddGameReq) returns (AddGameRsp) {
option (google.api.http) = {
post: "/api/games"
@@ -527,6 +600,8 @@ message EchoRsp {
message SignupReq {
string username = 1;
string email = 2;
bool accept_terms = 3;
bool accept_privacy = 4;
}
message SignupRsp {
@@ -562,6 +637,39 @@ message RefreshRsp {
string refreshToken = 3;
}
// Пароль подтверждает, что удаляет аккаунт его владелец, а не обладатель
// украденного access-токена (ст. 21 152-ФЗ). Передаётся HTTP-заголовком
// X-Password (пробрасывается матчером в cmd/evening_detective_server/main.go),
// чтобы креденшел не попадал в URL/query-параметры и логи.
message DeleteAccountReq {}
message DeleteAccountRsp {
string error = 1;
}
// Документы раздаются по постоянным URL, на которые ссылается сервис при
// фиксации акцептов соглашений (user_agreements) и форма регистрации.
message GetTermsReq {}
message GetTermsRsp {
string error = 1;
string text = 2;
}
message GetPrivacyReq {}
message GetPrivacyRsp {
string error = 1;
string text = 2;
}
message GetConsentReq {}
message GetConsentRsp {
string error = 1;
string text = 2;
}
message GetUsersReq {}
message GetUsersRsp {
@@ -627,6 +735,9 @@ message UploadFileReq {
message UploadFileRsp {
string error = 1;
string filename = 2;
// Тип загруженного файла: image | pdf | audio (определяется сервером по
// содержимому). Удобен редактору для предзаполнения file_type улики.
string file_type = 3 [json_name = "file_type"];
}
message DownloadFileReq {
@@ -679,9 +790,15 @@ message Scenario {
}
message Story {
Introduction introduction = 2;
repeated Place places = 1;
}
message Introduction {
string text = 1;
string audio = 2;
}
message Place {
string code = 1;
string name = 2;
@@ -696,6 +813,10 @@ message Place {
message Application {
string name = 1;
string image = 2;
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
// расширения файла (см. file_storage.FileType); пустое значение возможно
// только для legacy-строк без расширения.
string file_type = 3 [json_name = "file_type"];
}
message Door {
@@ -771,6 +892,24 @@ message DeleteScenarioPlaceRsp {
string error = 1;
}
message UpdateScenarioIntroReq {
int32 id = 1;
Introduction introduction = 2;
}
message UpdateScenarioIntroRsp {
string error = 1;
}
message DownloadScenarioArchiveReq {
int32 id = 1;
}
message UploadScenarioArchiveRsp {
string error = 1;
int32 id = 2;
}
message AddGameReq {
string name = 1;
string description = 2;
+103
View File
@@ -0,0 +1,103 @@
import { useAuthStore } from '@/stores/auth'
import { getURL } from './generated/crabs/evening_detective_server/client'
export type DownloadArchiveResult = {
blob: Blob
filename: string
}
export type UploadArchiveResult = {
id: number | null
error: string | null
}
// Клиент для бинарных операций со сценарием (ZIP-архив). Сгенерированный
// HTTP-хендлер всегда парсит ответ как JSON, поэтому для архивов используем
// сырой fetch: скачивание отдаёт байты архива, загрузка принимает их напрямую.
export function getArchiveClient() {
const authStore = useAuthStore()
return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken)
}
function buildArchiveClient(
refreshTokens: () => Promise<boolean>,
getToken: () => string | null,
) {
async function request(path: string, init: RequestInit): Promise<Response> {
const headers: Record<string, string> = {}
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
let response = await fetch(`${getURL()}/${path}`, {
...init,
headers: { ...headers, ...(init.headers as Record<string, string>) },
})
// Токен протух — обновляем и повторяем запрос один раз.
if (response.status == 401) {
await refreshTokens()
const newToken = getToken()
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`
}
response = await fetch(`${getURL()}/${path}`, {
...init,
headers: { ...headers, ...(init.headers as Record<string, string>) },
})
}
return response
}
async function downloadScenarioArchive(id: number): Promise<DownloadArchiveResult> {
const response = await request(`api/scenarios/${id}/archive`, { method: 'GET' })
if (!response.ok) {
throw new Error(await gatewayErrorMessage(response))
}
const blob = await response.blob()
const filename =
parseContentDispositionFilename(response.headers.get('Content-Disposition')) || `scenario-${id}.zip`
return { blob, filename }
}
async function uploadScenarioArchive(file: File): Promise<UploadArchiveResult> {
const contentType = file.type || 'application/zip'
const response = await request('api/scenarios/archive', {
method: 'POST',
headers: { 'Content-Type': contentType },
body: file,
})
const data = await response.json().catch(() => null)
if (!response.ok) {
return { id: null, error: data?.message || `Ошибка сервера (${response.status})` }
}
const error = typeof data?.error === 'string' && data.error !== '' ? data.error : null
return { id: typeof data?.id === 'number' ? data.id : null, error }
}
return { downloadScenarioArchive, uploadScenarioArchive }
}
// Извлекает имя файла из Content-Disposition: attachment; filename="name.zip"
function parseContentDispositionFilename(value: string | null): string {
if (!value) {
return ''
}
const match = value.match(/filename="?([^";]+)"?/i)
const filename = match ? match[1] : ''
return filename.replace(/[\\/:*?"<>|]/g, '_')
}
// Ошибки grpc-gateway приходят JSON-объектом {"code": ..., "message": ...}
async function gatewayErrorMessage(response: Response): Promise<string> {
const data = await response.json().catch(() => null)
if (data && typeof data.message === 'string' && data.message !== '') {
return data.message
}
return `Ошибка сервера (${response.status})`
}
@@ -1,11 +1,17 @@
import { createEveningDetectiveServerClient, type EveningDetectiveServer } from '.'
export function buildClient(getToken?: () => string | null): EveningDetectiveServer {
return createEveningDetectiveServerClient(createHttpHandler(getToken))
export type ExtraHeadersProvider = () => Record<string, string> | null | undefined
export function buildClient(
getToken?: () => string | null,
getExtraHeaders?: ExtraHeadersProvider,
): EveningDetectiveServer {
return createEveningDetectiveServerClient(createHttpHandler(getToken, getExtraHeaders))
}
export function createHttpHandler(
getToken?: () => string | null,
getExtraHeaders?: ExtraHeadersProvider,
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
return async (request: { path: string; method: string; body: string | null }) => {
const baseURL = getURL()
@@ -24,6 +30,12 @@ export function createHttpHandler(
headers['Authorization'] = `Bearer ${token}`
}
// Добавляем дополнительные заголовки (например, X-Password для удаления аккаунта)
const extraHeaders = getExtraHeaders?.()
if (extraHeaders) {
Object.assign(headers, extraHeaders)
}
// Делаем запрос
const response = await fetch(url, {
method: request.method,
@@ -1,5 +1,5 @@
// Code generated by protoc-gen-typescript-http. DO NOT EDIT.
/* eslint-disable camelcase */
// @ts-nocheck
export type PingReq = {
@@ -19,6 +19,8 @@ export type EchoRsp = {
export type SignupReq = {
username: string | undefined;
email: string | undefined;
acceptTerms: boolean | undefined;
acceptPrivacy: boolean | undefined;
};
export type SignupRsp = {
@@ -54,6 +56,43 @@ export type RefreshRsp = {
refreshToken: string | undefined;
};
// Пароль подтверждает, что удаляет аккаунт его владелец, а не обладатель
// украденного access-токена (ст. 21 152-ФЗ). Передаётся HTTP-заголовком
// X-Password (пробрасывается матчером в cmd/evening_detective_server/main.go),
// чтобы креденшел не попадал в URL/query-параметры и логи.
export type DeleteAccountReq = {
};
export type DeleteAccountRsp = {
error: string | undefined;
};
// Документы раздаются по постоянным URL, на которые ссылается сервис при
// фиксации акцептов соглашений (user_agreements) и форма регистрации.
export type GetTermsReq = {
};
export type GetTermsRsp = {
error: string | undefined;
text: string | undefined;
};
export type GetPrivacyReq = {
};
export type GetPrivacyRsp = {
error: string | undefined;
text: string | undefined;
};
export type GetConsentReq = {
};
export type GetConsentRsp = {
error: string | undefined;
text: string | undefined;
};
export type GetUsersReq = {
};
@@ -127,6 +166,9 @@ export type UploadFileReq = {
export type UploadFileRsp = {
error: string | undefined;
filename: string | undefined;
// Тип загруженного файла: image | pdf | audio (определяется сервером по
// содержимому). Удобен редактору для предзаполнения file_type улики.
file_type: string | undefined;
};
export type DownloadFileReq = {
@@ -164,9 +206,15 @@ export type Scenario = {
};
export type Story = {
introduction: Introduction | undefined;
places: Place[] | undefined;
};
export type Introduction = {
text: string | undefined;
audio: string | undefined;
};
export type Place = {
code: string | undefined;
name: string | undefined;
@@ -181,6 +229,10 @@ export type Place = {
export type Application = {
name: string | undefined;
image: string | undefined;
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
// расширения файла (см. file_storage.FileType); пустое значение возможно
// только для legacy-строк без расширения.
file_type: string | undefined;
};
export type Door = {
@@ -273,6 +325,24 @@ export type DeleteScenarioPlaceRsp = {
error: string | undefined;
};
export type UpdateScenarioIntroReq = {
id: number | undefined;
introduction: Introduction | undefined;
};
export type UpdateScenarioIntroRsp = {
error: string | undefined;
};
export type DownloadScenarioArchiveReq = {
id: number | undefined;
};
export type UploadScenarioArchiveRsp = {
error: string | undefined;
id: number | undefined;
};
export type AddGameReq = {
name: string | undefined;
description: string | undefined;
@@ -456,6 +526,10 @@ export interface EveningDetectiveServer {
RefreshPassword(request: RefreshPasswordReq): Promise<RefreshPasswordRsp>;
Login(request: LoginReq): Promise<LoginRsp>;
Refresh(request: RefreshReq): Promise<RefreshRsp>;
DeleteAccount(request: DeleteAccountReq): Promise<DeleteAccountRsp>;
GetTerms(request: GetTermsReq): Promise<GetTermsRsp>;
GetPrivacy(request: GetPrivacyReq): Promise<GetPrivacyRsp>;
GetConsent(request: GetConsentReq): Promise<GetConsentRsp>;
GetUsers(request: GetUsersReq): Promise<GetUsersRsp>;
GetUserById(request: GetUserByIdReq): Promise<GetUserByIdRsp>;
GetMe(request: GetMeReq): Promise<GetMeRsp>;
@@ -476,6 +550,9 @@ export interface EveningDetectiveServer {
AddScenarioPlace(request: AddScenarioPlaceReq): Promise<AddScenarioPlaceRsp>;
UpdateScenarioPlace(request: UpdateScenarioPlaceReq): Promise<UpdateScenarioPlaceRsp>;
DeleteScenarioPlace(request: DeleteScenarioPlaceReq): Promise<DeleteScenarioPlaceRsp>;
UpdateScenarioIntro(request: UpdateScenarioIntroReq): Promise<UpdateScenarioIntroRsp>;
DownloadScenarioArchive(request: DownloadScenarioArchiveReq): Promise<googleapi_HttpBody>;
UploadScenarioArchive(request: googleapi_HttpBody): Promise<UploadScenarioArchiveRsp>;
AddGame(request: AddGameReq): Promise<AddGameRsp>;
GetGames(request: GetGamesReq): Promise<GetGamesRsp>;
GetGame(request: GetGameReq): Promise<GetGameRsp>;
@@ -507,8 +584,8 @@ export function createEveningDetectiveServerClient(
handler: RequestHandler
): EveningDetectiveServer {
return {
Ping(request) {
const path = `api/test/ping`;
Ping(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/test/ping`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -524,8 +601,8 @@ export function createEveningDetectiveServerClient(
method: "Ping",
}) as Promise<PingRsp>;
},
Echo(request) {
const path = `api/test/echo`;
Echo(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/test/echo`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
if (request.text) {
@@ -544,8 +621,8 @@ export function createEveningDetectiveServerClient(
method: "Echo",
}) as Promise<EchoRsp>;
},
Signup(request) {
const path = `api/auth/signup`;
Signup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/signup`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -561,8 +638,8 @@ export function createEveningDetectiveServerClient(
method: "Signup",
}) as Promise<SignupRsp>;
},
RefreshPassword(request) {
const path = `api/auth/refresh-password`;
RefreshPassword(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/refresh-password`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -578,8 +655,8 @@ export function createEveningDetectiveServerClient(
method: "RefreshPassword",
}) as Promise<RefreshPasswordRsp>;
},
Login(request) {
const path = `api/auth/login`;
Login(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/login`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -595,8 +672,8 @@ export function createEveningDetectiveServerClient(
method: "Login",
}) as Promise<LoginRsp>;
},
Refresh(request) {
const path = `api/auth/refresh`;
Refresh(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/refresh`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -612,8 +689,76 @@ export function createEveningDetectiveServerClient(
method: "Refresh",
}) as Promise<RefreshRsp>;
},
GetUsers(request) {
const path = `api/users`;
DeleteAccount(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/delete-account`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "DELETE",
body,
}, {
service: "EveningDetectiveServer",
method: "DeleteAccount",
}) as Promise<DeleteAccountRsp>;
},
GetTerms(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/terms`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetTerms",
}) as Promise<GetTermsRsp>;
},
GetPrivacy(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/privacy`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetPrivacy",
}) as Promise<GetPrivacyRsp>;
},
GetConsent(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/consent`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetConsent",
}) as Promise<GetConsentRsp>;
},
GetUsers(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/users`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -629,11 +774,11 @@ export function createEveningDetectiveServerClient(
method: "GetUsers",
}) as Promise<GetUsersRsp>;
},
GetUserById(request) {
GetUserById(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/users/${request.id}`;
const path = `api/users/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -649,8 +794,8 @@ export function createEveningDetectiveServerClient(
method: "GetUserById",
}) as Promise<GetUserByIdRsp>;
},
GetMe(request) {
const path = `api/users/me`;
GetMe(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/users/me`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -666,11 +811,11 @@ export function createEveningDetectiveServerClient(
method: "GetMe",
}) as Promise<GetMeRsp>;
},
AddUserRole(request) {
AddUserRole(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/users/${request.id}/role/add`;
const path = `api/users/${request.id}/role/add`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -686,11 +831,11 @@ export function createEveningDetectiveServerClient(
method: "AddUserRole",
}) as Promise<AddUserRoleRsp>;
},
DeleteUserRole(request) {
DeleteUserRole(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/users/${request.id}/role/delete`;
const path = `api/users/${request.id}/role/delete`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -706,8 +851,8 @@ export function createEveningDetectiveServerClient(
method: "DeleteUserRole",
}) as Promise<DeleteUserRoleRsp>;
},
GetPermissions(request) {
const path = `api/ui/permissions`;
GetPermissions(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/ui/permissions`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -723,8 +868,8 @@ export function createEveningDetectiveServerClient(
method: "GetPermissions",
}) as Promise<GetPermissionsRsp>;
},
UploadFile(request) {
const path = `api/files/upload`;
UploadFile(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/files/upload`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -740,11 +885,11 @@ export function createEveningDetectiveServerClient(
method: "UploadFile",
}) as Promise<UploadFileRsp>;
},
DownloadFile(request) {
DownloadFile(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.filename) {
throw new Error("missing required field request.filename");
}
const path = `api/files/${request.filename}`;
const path = `api/files/${request.filename}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -760,8 +905,8 @@ export function createEveningDetectiveServerClient(
method: "DownloadFile",
}) as Promise<googleapi_HttpBody>;
},
AddScenario(request) {
const path = `api/scenario`;
AddScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/scenario`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -777,8 +922,8 @@ export function createEveningDetectiveServerClient(
method: "AddScenario",
}) as Promise<AddScenarioRsp>;
},
GetMyScenarios(request) {
const path = `api/my-scenarios`;
GetMyScenarios(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/my-scenarios`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -794,8 +939,8 @@ export function createEveningDetectiveServerClient(
method: "GetMyScenarios",
}) as Promise<GetMyScenariosRsp>;
},
GetScenariosCatalog(request) {
const path = `api/catalog`;
GetScenariosCatalog(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/catalog`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -811,11 +956,11 @@ export function createEveningDetectiveServerClient(
method: "GetScenariosCatalog",
}) as Promise<GetScenariosCatalogRsp>;
},
GetFullScenario(request) {
GetFullScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/full`;
const path = `api/scenarios/${request.id}/full`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -831,11 +976,11 @@ export function createEveningDetectiveServerClient(
method: "GetFullScenario",
}) as Promise<GetScenarioRsp>;
},
GetScenario(request) {
GetScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}`;
const path = `api/scenarios/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -851,11 +996,11 @@ export function createEveningDetectiveServerClient(
method: "GetScenario",
}) as Promise<GetScenarioRsp>;
},
UpdateScenario(request) {
UpdateScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}`;
const path = `api/scenarios/${request.id}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -871,11 +1016,11 @@ export function createEveningDetectiveServerClient(
method: "UpdateScenario",
}) as Promise<UpdateScenarioRsp>;
},
PublicScenario(request) {
PublicScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/public`;
const path = `api/scenarios/${request.id}/public`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -891,11 +1036,11 @@ export function createEveningDetectiveServerClient(
method: "PublicScenario",
}) as Promise<PublicScenarioRsp>;
},
DraftScenario(request) {
DraftScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/draft`;
const path = `api/scenarios/${request.id}/draft`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -911,11 +1056,11 @@ export function createEveningDetectiveServerClient(
method: "DraftScenario",
}) as Promise<DraftScenarioRsp>;
},
DeleteScenario(request) {
DeleteScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}`;
const path = `api/scenarios/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -931,11 +1076,11 @@ export function createEveningDetectiveServerClient(
method: "DeleteScenario",
}) as Promise<DeleteScenarioRsp>;
},
AddScenarioPlace(request) {
AddScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/places`;
const path = `api/scenarios/${request.id}/places`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -951,14 +1096,14 @@ export function createEveningDetectiveServerClient(
method: "AddScenarioPlace",
}) as Promise<AddScenarioPlaceRsp>;
},
UpdateScenarioPlace(request) {
UpdateScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
if (!request.code) {
throw new Error("missing required field request.code");
}
const path = `api/scenarios/${request.id}/places/${request.code}`;
const path = `api/scenarios/${request.id}/places/${request.code}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -974,14 +1119,14 @@ export function createEveningDetectiveServerClient(
method: "UpdateScenarioPlace",
}) as Promise<UpdateScenarioPlaceRsp>;
},
DeleteScenarioPlace(request) {
DeleteScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
if (!request.code) {
throw new Error("missing required field request.code");
}
const path = `api/scenarios/${request.id}/places/${request.code}`;
const path = `api/scenarios/${request.id}/places/${request.code}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -997,8 +1142,65 @@ export function createEveningDetectiveServerClient(
method: "DeleteScenarioPlace",
}) as Promise<DeleteScenarioPlaceRsp>;
},
AddGame(request) {
const path = `api/games`;
UpdateScenarioIntro(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/introduction`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "PUT",
body,
}, {
service: "EveningDetectiveServer",
method: "UpdateScenarioIntro",
}) as Promise<UpdateScenarioIntroRsp>;
},
DownloadScenarioArchive(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/archive`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "DownloadScenarioArchive",
}) as Promise<googleapi_HttpBody>;
},
UploadScenarioArchive(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/scenarios/archive`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "UploadScenarioArchive",
}) as Promise<UploadScenarioArchiveRsp>;
},
AddGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/games`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1014,8 +1216,8 @@ export function createEveningDetectiveServerClient(
method: "AddGame",
}) as Promise<AddGameRsp>;
},
GetGames(request) {
const path = `api/games`;
GetGames(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/games`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -1031,11 +1233,11 @@ export function createEveningDetectiveServerClient(
method: "GetGames",
}) as Promise<GetGamesRsp>;
},
GetGame(request) {
GetGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}`;
const path = `api/games/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -1051,11 +1253,11 @@ export function createEveningDetectiveServerClient(
method: "GetGame",
}) as Promise<GetGameRsp>;
},
UpdateGame(request) {
UpdateGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}`;
const path = `api/games/${request.id}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1071,11 +1273,11 @@ export function createEveningDetectiveServerClient(
method: "UpdateGame",
}) as Promise<UpdateGameRsp>;
},
StartGame(request) {
StartGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/start`;
const path = `api/games/${request.id}/start`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1091,11 +1293,11 @@ export function createEveningDetectiveServerClient(
method: "StartGame",
}) as Promise<StartGameRsp>;
},
PauseGame(request) {
PauseGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/pause`;
const path = `api/games/${request.id}/pause`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1111,11 +1313,11 @@ export function createEveningDetectiveServerClient(
method: "PauseGame",
}) as Promise<PauseGameRsp>;
},
PlayGame(request) {
PlayGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/play`;
const path = `api/games/${request.id}/play`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1131,11 +1333,11 @@ export function createEveningDetectiveServerClient(
method: "PlayGame",
}) as Promise<PlayGameRsp>;
},
FinishGame(request) {
FinishGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/finish`;
const path = `api/games/${request.id}/finish`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1151,11 +1353,11 @@ export function createEveningDetectiveServerClient(
method: "FinishGame",
}) as Promise<FinishGameRsp>;
},
ResetGame(request) {
ResetGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/reset`;
const path = `api/games/${request.id}/reset`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1171,11 +1373,11 @@ export function createEveningDetectiveServerClient(
method: "ResetGame",
}) as Promise<ResetGameRsp>;
},
DeleteGame(request) {
DeleteGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}`;
const path = `api/games/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -1191,8 +1393,8 @@ export function createEveningDetectiveServerClient(
method: "DeleteGame",
}) as Promise<DeleteGameRsp>;
},
AddTeam(request) {
const path = `api/teams`;
AddTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/teams`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1208,11 +1410,11 @@ export function createEveningDetectiveServerClient(
method: "AddTeam",
}) as Promise<AddTeamRsp>;
},
UpdateTeam(request) {
UpdateTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}`;
const path = `api/teams/${request.id}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1228,11 +1430,11 @@ export function createEveningDetectiveServerClient(
method: "UpdateTeam",
}) as Promise<UpdateTeamRsp>;
},
DeleteTeam(request) {
DeleteTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}`;
const path = `api/teams/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -1248,11 +1450,11 @@ export function createEveningDetectiveServerClient(
method: "DeleteTeam",
}) as Promise<DeleteTeamRsp>;
},
GiveTeamApplications(request) {
GiveTeamApplications(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}/applications`;
const path = `api/teams/${request.id}/applications`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1268,11 +1470,11 @@ export function createEveningDetectiveServerClient(
method: "GiveTeamApplications",
}) as Promise<GiveTeamApplicationsRsp>;
},
GetTeamStory(request) {
GetTeamStory(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}/story`;
const path = `api/teams/${request.id}/story`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
if (request.password) {
@@ -1291,11 +1493,11 @@ export function createEveningDetectiveServerClient(
method: "GetTeamStory",
}) as Promise<GetTeamStoryRsp>;
},
AddTeamAction(request) {
AddTeamAction(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}/actions`;
const path = `api/teams/${request.id}/actions`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1311,11 +1513,11 @@ export function createEveningDetectiveServerClient(
method: "AddTeamAction",
}) as Promise<AddTeamActionRsp>;
},
DeleteLastTeamAction(request) {
DeleteLastTeamAction(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}/last-actions`;
const path = `api/teams/${request.id}/last-actions`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
+173
View File
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { NAlert, NSpin } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import HeaderText from '@/components/HeaderText.vue'
const props = defineProps<{
kind: 'terms' | 'privacy' | 'consent'
}>()
const titles: Record<string, string> = {
terms: 'Пользовательское соглашение',
privacy: 'Политика конфиденциальности',
consent: 'Согласие на обработку персональных данных',
}
const text = ref('')
const loading = ref(true)
const error = ref('')
onMounted(async () => {
const client = getAuthClient()
const fetchers = {
terms: () => client.GetTerms({}),
privacy: () => client.GetPrivacy({}),
consent: () => client.GetConsent({}),
}
try {
const res = await fetchers[props.kind]()
if (res.error) {
error.value = res.error
} else {
text.value = res.text || ''
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить документ'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="center-block-custom">
<div class="width700 doc-page">
<h1 class="doc-title">
<HeaderText>{{ titles[props.kind] }}</HeaderText>
</h1>
<div v-if="loading" class="doc-loading">
<n-spin size="large" />
</div>
<n-alert v-else-if="error" type="error" :show-icon="false" class="doc-error">
{{ error }}
</n-alert>
<div v-else class="paper-block">
<div class="paper">
<p class="doc-text">{{ text }}</p>
</div>
<div class="paper-shadow paper-shadow-left"></div>
<div class="paper-shadow paper-shadow-right"></div>
</div>
<div class="docs-links">
Смотрите также:
<RouterLink to="/user-agreement" class="docs-link">Пользовательское соглашение</RouterLink>
·
<RouterLink to="/privacy-policy" class="docs-link">Политика конфиденциальности</RouterLink>
·
<RouterLink to="/consent" class="docs-link">Согласие на обработку персональных данных</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.doc-page {
padding-top: 30px;
}
.doc-title {
margin: 0 0 24px;
text-align: center;
}
.doc-loading {
display: flex;
justify-content: center;
padding: 80px 0;
}
.doc-error {
margin: 0 auto;
max-width: 600px;
}
.paper-block {
position: relative;
}
.paper {
position: relative;
z-index: 2;
padding: 32px 28px;
border-radius: 3px;
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
color: #1d1d1d;
box-shadow: 0 0 12px rgb(0 0 0 / 70%);
max-height: 60vh;
overflow-y: auto;
scrollbar-width: none;
}
.paper::-webkit-scrollbar {
display: none;
}
.doc-text {
font-family: 'font_old_typer';
font-size: 17px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
text-align: justify;
}
.paper-shadow {
position: absolute;
top: 0;
width: 100%;
height: 100%;
border-radius: 3px;
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
}
.paper-shadow-left {
left: 0;
transform: rotate(-2.2deg);
filter: brightness(60%);
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
}
.paper-shadow-right {
right: 0;
transform: rotate(2.2deg);
filter: brightness(75%);
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
}
.docs-links {
margin-top: 28px;
padding-bottom: 20px;
text-align: center;
font-size: 15px;
color: #999;
}
.docs-link {
color: #45aa89;
text-decoration: none;
}
.docs-link:hover {
color: #63e2b7;
}
</style>
+2 -2
View File
@@ -70,7 +70,7 @@ function getTextForCounter(count: number): string[] {
<Footsteps />
</Icon>
</div>
<div v-for="n in getTextForCounter(allPlacesCount)" 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,7 +78,7 @@ function getTextForCounter(count: number): string[] {
<ContactMailRound />
</Icon>
</div>
<div v-for="n in getTextForCounter(newPlacesCount)" 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>
+86 -3
View File
@@ -13,6 +13,10 @@ import type { Application, Game, Team } from '@/api/generated/crabs/evening_dete
import { getURL } from '@/api/generated/crabs/evening_detective_server/client';
import HeaderMenu from '@/components/HeaderMenu.vue'
import { useSimplePolling } from '@/composables/useSimplePolling';
import {
applicationFileType,
applicationFileURL,
} from '@/utils/application';
const client = getAuthClient()
const router = useRouter()
@@ -25,7 +29,9 @@ const showSettingsModal = ref(false)
const showGiveApplicationModal = ref(false)
const showAddTeamModal = ref(false)
const showDeleteTeamModal = ref(false)
const showDeleteGameModal = ref(false)
const newTeamName = ref('')
const deletedGameName = ref('')
const showQR = ref(false)
const urlQR = ref('')
const titleQR = ref('')
@@ -122,7 +128,8 @@ const deletedTeam = ref<Team>({
const giveApplicationApplication = ref<Application>({
name: '',
image: ''
image: '',
file_type: ''
})
const url = window.location.href
@@ -176,6 +183,18 @@ async function confirmDeleteTeam() {
showDeleteTeamModal.value = false
}
async function deleteGame() {
const res = await client.DeleteGame({
id: game.value.id,
})
if (res.error != '') {
message.error(res.error!)
return
}
message.success('Игра удалена')
router.push('/games')
}
function getTeamStoryURL(id: number, password: string): string {
return getURL() + '/team-story/' + id + '?password=' + password
}
@@ -323,6 +342,10 @@ useSimplePolling(() => {
</Icon>
Настройки
</n-button>
<n-button v-if="(game.teams?.length || 0) == 0" type="error" quaternary
@click="showDeleteGameModal = true" class="settings-button">
Удалить игру
</n-button>
<n-space>
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
@@ -359,7 +382,7 @@ useSimplePolling(() => {
<p class="place-image-plus">+</p>
</div>
<div v-for="team in game.teams" class="team-block">
<div v-for="team in game.teams" :key="team.id" class="team-block">
<div class="team-content-block">
<n-flex justify="space-between">
@@ -429,10 +452,21 @@ useSimplePolling(() => {
<p>Команда</p>
<h3>{{ giveApplicationTeam.name }}</h3>
<p>Улика</p>
<h3>{{ giveApplicationApplication.name }}</h3>
<div v-if="applicationFileType(giveApplicationApplication) == 'image' && giveApplicationApplication.image"
class="give-application-image-block">
<img :src="applicationFileURL(giveApplicationApplication.image)" class="give-application-image" />
</div>
<audio v-else-if="applicationFileType(giveApplicationApplication) == 'audio' && giveApplicationApplication.image"
:src="applicationFileURL(giveApplicationApplication.image)" controls preload="none"
class="give-application-audio"></audio>
<a v-else-if="applicationFileType(giveApplicationApplication) == 'pdf' && giveApplicationApplication.image"
:href="applicationFileURL(giveApplicationApplication.image)" target="_blank" rel="noopener"
class="give-application-pdf-link">
PDF
</a>
</n-space>
<template #footer>
<n-flex justify="end">
@@ -485,6 +519,31 @@ useSimplePolling(() => {
</n-card>
</n-modal>
<!-- Окно удаления игры -->
<n-modal v-model:show="showDeleteGameModal">
<n-card style="width: 600px" title="Удаление игры" :bordered="false" size="huge" role="dialog"
aria-modal="true">
<template #header-extra>
<n-button @click="showDeleteGameModal = false"> x </n-button>
</template>
<n-space vertical>
<p>
Введите название игры
<n-text class="name-text" strong>{{ game.name }}</n-text> чтобы удалить её
</p>
<n-input v-model:value="deletedGameName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="deleteGame()" :disabled="deletedGameName != game.name">
Удалить
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<!-- Окно qr -->
<n-modal v-model:show="showQR">
<n-card style="width: 600px" :title="titleQR" :bordered="false" size="huge" role="dialog" aria-modal="true">
@@ -563,4 +622,28 @@ useSimplePolling(() => {
background-color: white;
padding: 12px;
}
.give-application-image-block {
margin: 10px 0 5px;
padding: 7px;
max-width: 320px;
background-image: url("@/assets/images/paper_white.jpg");
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
}
.give-application-image {
width: 100%;
}
.give-application-audio {
width: 100%;
margin: 10px 0 5px;
}
.give-application-pdf-link {
display: inline-block;
margin: 10px 0 5px;
text-decoration: underline;
}
</style>
+198
View File
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { NButton, NInput, NSpace, NText, NUpload, NUploadDragger } from 'naive-ui'
import { computed, type PropType } from 'vue'
import { getURL } from '@/api/generated/crabs/evening_detective_server/client'
import type { EditableIntroduction } from '@/types/editor'
import MessagePaper from './MessagePaper.vue'
const props = defineProps({
introduction: {
type: Object as PropType<EditableIntroduction>,
required: true,
},
mode: {
type: String,
required: true,
},
})
const emit = defineEmits<{
'update:mode': [value: string]
}>()
const updateMode = (mode: string) => {
emit('update:mode', mode)
}
const hasText = computed(() => Boolean(props.introduction?.text))
const hasAudio = computed(() => Boolean(props.introduction?.audio))
// Бэкенд отдаёт аудио полным URL (домен + имя файла); на случай голого
// имени файла строим URL до /api/files самостоятельно.
const audioUrl = computed(() => {
const value = props.introduction?.audio
if (!value) return undefined
if (/^https?:\/\//i.test(value)) return value
return `${getURL()}/api/files/${encodeURIComponent(value)}`
})
const removeAudio = () => {
if (props.introduction) {
props.introduction!.audio = ''
props.introduction!.fileList = undefined
}
}
</script>
<template>
<!-- Просмотр вступления командой (первый лист дела) -->
<div v-if="mode == 'show'">
<MessagePaper>
<div class="intro-title font">Вступление</div>
<hr class="intro-hr" />
<div class="intro-text font">{{ introduction.text }}</div>
<div v-if="hasAudio" class="intro-audio-block">
<audio :src="audioUrl" controls preload="none" class="intro-audio"></audio>
</div>
</MessagePaper>
</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">
<audio :src="audioUrl" controls preload="none" class="intro-audio"></audio>
</div>
<span v-if="hasText">{{ introduction.text }}</span>
<span v-else class="intro-empty">Введение не задано нажмите, чтобы добавить</span>
</div>
</div>
<!-- Редактирование вступления автором -->
<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"
:autosize="{ minRows: 4, maxRows: 12 }"
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>
<n-text v-else depth="3">Аудио не задано</n-text>
<n-button @click="removeAudio()" :disabled="!hasAudio">Убрать аудио</n-button>
</n-space>
<n-upload directory-dnd :max="1" v-model:file-list="props.introduction!.fileList">
<n-upload-dragger>
<n-text style="font-size: 16px">
Щелкните или перетащите аудиофайл в эту область для загрузки
</n-text>
</n-upload-dragger>
</n-upload>
</div>
<div class="slot-block">
<slot></slot>
</div>
</n-space>
</div>
</template>
<style scoped>
/* Бумажный лист вступления (просмотр командой и в каталоге) */
.font {
font-family: 'font_old_typer';
font-size: 18px;
}
.intro-title {
text-align: center;
font-size: 26px;
letter-spacing: 3px;
color: var(--second-color);
}
.intro-hr {
margin: 10px 0;
border: dashed 1px;
border-color: #222;
}
.intro-text {
font-weight: 500;
white-space: pre-wrap;
line-height: 1.5;
}
/* Панели в стиле блоков точек (см. PlaceBlock.vue) */
.place-block-hover,
.place-block {
margin: 10px 0;
padding: 20px;
border-radius: 10px;
border: 1px solid #222;
background-color: #222;
}
.place-block-hover:hover {
color: #63e2b7;
cursor: pointer;
border: 1px solid #63e2b7;
}
.show-editor-block {
cursor: pointer;
}
.hr {
margin: 10px 0;
border: dashed 1px;
border-color: #eee;
}
.message-header {
font-size: 20px;
padding-right: 50px;
}
.message-content {
font-weight: 500;
white-space: pre-wrap;
}
.intro-empty {
color: #777;
}
.settings-header {
margin-top: 20px;
}
.intro-audio-block {
margin-top: 15px;
}
.intro-audio {
width: 100%;
max-width: 460px;
border-radius: 5px;
}
.intro-edit-audio {
display: flex;
flex-flow: row wrap;
gap: 20px;
align-items: flex-start;
}
.slot-block {
margin-top: 10px;
}
</style>
+24 -9
View File
@@ -24,7 +24,8 @@ const message = useMessage()
const username = ref('')
const email = ref('')
const password = ref('')
const approval = ref(false)
const acceptTerms = ref(false)
const acceptPrivacy = ref(false)
const options = computed(() => {
return ['@mail.ru', '@yandex.ru', '@gmail.com'].map((suffix) => {
@@ -48,15 +49,22 @@ async function signin() {
}
async function signup() {
if (!approval.value) {
if (!acceptTerms.value || !acceptPrivacy.value) {
return
}
const res = await authStore.signup(username.value, email.value)
const res = await authStore.signup(
username.value,
email.value,
acceptTerms.value,
acceptPrivacy.value,
)
if (!res && authStore.error != null) {
message.error(authStore.error)
return
}
message.info('Пароль отправлен на почту')
acceptTerms.value = false
acceptPrivacy.value = false
}
async function sendNewPassword() {
@@ -101,16 +109,23 @@ async function sendNewPassword() {
autocomplete: 'disabled',
}" :options="options" placeholder="detective@mail.ru" clearable />
<div class="form-label">
<n-checkbox v-model:checked="approval">
Я согласен с
<a href="/user-agreement" target="_blank" class="docs-link">пользовательским соглашением</a><br />
и
<a href="/privacy-policy" target="_blank" class="docs-link">соглашением о персональных данных</a>
<n-checkbox v-model:checked="acceptTerms">
Я принимаю условия
<a href="/user-agreement" target="_blank" class="docs-link">пользовательского соглашения</a>
</n-checkbox>
</div>
<div class="form-label">
<n-checkbox v-model:checked="acceptPrivacy">
Я даю согласие на обработку
<a href="/privacy-policy" target="_blank" class="docs-link">персональных данных</a>
</n-checkbox>
</div>
<div class="form-button-wrapper">
<div class="form-label">
<n-button @click="signup" :disabled="username.length == 0 || password.length == 0 || !approval">
<n-button
@click="signup"
:disabled="username.length == 0 || email.length == 0 || !acceptTerms || !acceptPrivacy"
>
<span v-if="!authStore.isLoading"> Регистрация </span>
<span v-else> Подождите... </span>
</n-button>
+104 -2
View File
@@ -1,12 +1,15 @@
<script setup lang="ts">
import { NAlert } from 'naive-ui'
import { NAlert, NButton, NInput, NModal, useMessage } from 'naive-ui'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import HeaderMenu from '@/components/HeaderMenu.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const message = useMessage()
const client = getAuthClient()
const permissions = ref<string[]>([])
@@ -208,6 +211,22 @@ function getDetectiveTip(): Tip {
const tip = getDetectiveTip()
// ===== Удаление аккаунта =====
const showDeleteDialog = ref(false)
const deletePassword = ref('')
const deleteError = ref('')
async function confirmDeleteAccount() {
deleteError.value = ''
const res = await authStore.deleteAccount(deletePassword.value)
if (!res) {
deleteError.value = authStore.error || 'Не удалось удалить аккаунт'
return
}
message.success('Аккаунт и персональные данные удалены')
showDeleteDialog.value = false
router.push('/login')
}
</script>
<template>
@@ -218,10 +237,93 @@ const tip = getDetectiveTip()
</n-alert>
<p>Доброго времени суток, {{ authStore.username }}.</p>
<div class="danger-zone">
<div class="danger-zone-text">
<div class="danger-zone-title">Удаление аккаунта</div>
<div class="danger-zone-hint">
Удаление необратимо: аккаунт и все персональные данные будут стёрты (ст. 21 152-ФЗ).
</div>
</div>
<n-button type="error" ghost @click="showDeleteDialog = true">Удалить аккаунт</n-button>
</div>
</div>
</div>
<n-modal
v-model:show="showDeleteDialog"
preset="card"
title="Удаление аккаунта"
class="delete-modal"
>
<p class="delete-modal-text">
Это действие необратимо. Для подтверждения удаления введите пароль от учётной записи
он понадобится, чтобы убедиться, что аккаунт удаляет его владелец.
</p>
<n-input
v-model:value="deletePassword"
type="password"
placeholder="Пароль для подтверждения"
@keyup.enter="confirmDeleteAccount"
/>
<p v-if="deleteError" class="delete-modal-error">{{ deleteError }}</p>
<div class="delete-modal-buttons">
<n-button @click="showDeleteDialog = false">Отмена</n-button>
<n-button
type="error"
:disabled="deletePassword.length == 0"
:loading="authStore.isLoading"
@click="confirmDeleteAccount"
>
Удалить навсегда
</n-button>
</div>
</n-modal>
<HeaderMenu active="office" />
</template>
<style scoped></style>
<style scoped>
.danger-zone {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 40px;
padding: 20px;
background-color: #222;
border: 1px solid rgb(224 108 117 / 40%);
border-radius: 10px;
}
.danger-zone-title {
font-size: 17px;
font-weight: 600;
color: #e06c75;
}
.danger-zone-hint {
margin-top: 6px;
font-size: 14px;
color: #999;
}
.delete-modal-text {
margin: 0 0 16px;
line-height: 1.5;
color: #bbb;
}
.delete-modal-error {
margin: 12px 0 0;
color: #e06c75;
}
.delete-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
</style>
+363 -15
View File
@@ -2,13 +2,24 @@
import { Key24Regular } from '@vicons/fluent'
import { LockOutlined } from '@vicons/material'
import { Icon } from '@vicons/utils'
import { NButton, NDynamicInput, NInput, NInputGroup, NSpace, NSwitch, NText, NUpload, NUploadDragger } from 'naive-ui'
import type { PropType } from 'vue'
import { 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 MessagePaper from './MessagePaper.vue';
// Точка сценария + служебное поле fileList (выбранные файлы для загрузки картинки)
type PlaceWithFileList = Place & {
fileList?: UploadFileInfo[]
}
const props = defineProps({
newPlaceCode: {
type: String,
@@ -20,7 +31,7 @@ const props = defineProps({
type: String,
},
place: {
type: Object as PropType<Place>,
type: Object as PropType<PlaceWithFileList>,
},
mode: {
type: String,
@@ -50,10 +61,11 @@ const updateMode = (mode: string) => {
emit('update:mode', mode)
}
function onCreateApplication(): Application {
function onCreateApplication(): EditableApplication {
return {
name: undefined,
image: undefined,
file_type: undefined,
}
}
@@ -70,6 +82,114 @@ function onCreateKey(): Key {
name: undefined,
}
}
// Приводит улику из шаблона n-dynamic-input к редакторскому типу
// (в объекте лежит fileList, которого нет в схеме API).
function editableApplication(value: Application): EditableApplication {
return value as EditableApplication
}
// Убирает файл улики: очищает ссылку, тип и выбранный для загрузки файл.
function removeApplicationFile(application: EditableApplication) {
application.image = undefined
application.file_type = undefined
application.fileList = undefined
}
// Просмотр улики в модальном окне (картинка целиком / PDF во встроенном
// просмотрщике / аудио с плеером). viewImage — просмотр обычной картинки
// (например, картинки самой точки).
const viewApplication = ref<Application | null>(null)
const viewImage = ref<{ title: string; url: string } | null>(null)
const showViewApplicationModal = ref(false)
function openApplicationView(application: Application) {
viewApplication.value = application
viewImage.value = null
showViewApplicationModal.value = true
}
function openImageView(title: string, url: string) {
viewImage.value = { title: title, url: url }
viewApplication.value = null
showViewApplicationModal.value = true
}
function closeApplicationView() {
showViewApplicationModal.value = false
viewApplication.value = null
viewImage.value = null
}
// Текст точки разбивается на две части: улика встаёт в поток текста, и
// текст обтекает её целиком. Разрез выбирается по границе (перенос строки
// или пробел), после которой остаётся ~45% текста — хвост достаточно
// длинный, чтобы обойти картинку по всей высоте и не оставить пустого
// места слева от неё.
function splitTextForApplications(text: string | undefined): { before: string; after: string } {
const value = text || ''
if (value.length == 0) {
return { before: '', after: '' }
}
const trailingTarget = Math.min(value.length, Math.max(120, Math.floor(value.length * 0.45)))
let best = -1
let bestDistance = Infinity
for (let i = 0; i < value.length; i++) {
const ch = value[i]
if (ch != '\n' && ch != ' ') {
continue
}
const distance = Math.abs(value.length - i - trailingTarget)
if (distance < bestDistance) {
bestDistance = distance
best = i
}
}
if (best < 0) {
// Текст без переносов и пробелов: блок ставится после всего текста.
return { before: value, after: '' }
}
return { before: value.slice(0, best), after: value.slice(best) }
}
const textBeforeApplications = computed(() => splitTextForApplications(props.place?.text).before)
const textAfterApplications = computed(() => splitTextForApplications(props.place?.text).after)
// Если у точки нет своей картинки, первая картинка-улика занимает её место
// в самом начале текста; остальные улики встают в поток текста как раньше.
const firstImageApplication = computed<Application | null>(() => {
if (props.place?.image) {
return null
}
return (
(props.place?.applications || []).find(
(application) => applicationFileType(application) == 'image' && application.image,
) || null
)
})
// Улики в потоке текста: только картинки (без первой картинки,
// занявшей место картинки точки).
const flowApplications = computed<Application[]>(() => {
const applications = props.place?.applications || []
return applications.filter((application) => {
if (application === firstImageApplication.value) {
return false
}
return applicationFileType(application) == 'image' && application.image
})
})
// Аудио, PDF и улики без файла — в самом низу после пунктирной линии.
const bottomApplications = computed<Application[]>(() => {
const applications = props.place?.applications || []
return applications.filter((application) => {
if (application === firstImageApplication.value) {
return false
}
return !(applicationFileType(application) == 'image' && application.image)
})
})
</script>
<template>
@@ -98,7 +218,8 @@ function onCreateKey(): Key {
<hr class="hr" />
<div class="message-content">
<div v-if="props.place?.image">
<div class="message-image-border">
<div class="message-image-border"
@click.stop="openImageView(props.place?.name || '', props.place?.image || '')">
<img :src="props.place?.image" class="message-image">
</div>
</div>
@@ -116,14 +237,26 @@ function onCreateKey(): Key {
<hr class="hr" v-if="props.place?.applications?.length" />
<div class="message-footer application-block" v-for="application in props.place?.applications"
v-bind:key="application.name">
Приложение: {{ application.name }}
<div 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>
<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-for="key in props.place?.keys" class="key-block">
<span v-for="key in props.place?.keys" :key="key.name" class="key-block">
<Icon class="lock-icon">
<Key24Regular />
</Icon> {{ key.name }}
@@ -140,11 +273,30 @@ function onCreateKey(): Key {
<hr class="show-hr" />
<div class="message-show-content font">
<div v-if="place?.image?.length">
<div class="message-image-border">
<div class="message-image-border"
@click="openImageView(place?.name || '', place?.image || '')">
<img v-bind:src="place?.image" class="message-image" />
</div>
</div>
{{ place?.text }}
<!-- Если у точки нет своей картинки, первая картинка-улика занимает
её место в самом начале текста -->
<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="application-image-block" @click="openApplicationView(application)">
<img :src="applicationFileURL(application.image)" class="application-image" />
</div>
</div>
</div>
{{ 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"
@@ -153,10 +305,20 @@ function onCreateKey(): Key {
{{ door.code }} {{ door.name }}
</div>
</div>
<hr class="show-hr" v-if="props.place?.applications?.length" />
<div class="message-show-footer font" v-for="application in props.place?.applications" :key="application.name">
<!-- Аудио и 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">
Приложение: {{ application.name }}
<!-- <div class="application-label">{{ application.number }}</div> -->
</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>
</template>
</div>
</MessagePaper>
</div>
@@ -184,7 +346,7 @@ function onCreateKey(): Key {
<n-space>
<n-space vertical>
<img :src="props.place!.image" class="message-edit-image">
<n-button @click="props.place!.image = '', props.place!.fileList = null">Убрать картинку</n-button>
<n-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 />
@@ -213,8 +375,33 @@ function onCreateKey(): Key {
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
<template #create-button-default> Добавить улику </template>
<template #default="{ value }">
<div style="display: flex; align-items: center; width: 100%">
<div style="display: flex; flex-direction: column; width: 100%">
<n-input v-model:value="value.name" type="text" placeholder="Название" />
<div v-if="value.image" class="application-edit-file">
<n-space align="center" justify="space-between">
<n-text depth="3">
{{ applicationFileName(value.image) }}
<n-tag size="small" :bordered="false">
{{ applicationFileType(value) || 'файл' }}
</n-tag>
</n-text>
<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">
PDF
</a>
</div>
<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>
</template>
</n-dynamic-input>
@@ -225,7 +412,7 @@ function onCreateKey(): Key {
<template #action="{ index, create, remove }">
<div style="display: flex; flex-direction: column;">
<n-button @click="() => create(index)" class="plus-minus-buttons">+</n-button>
<n-button disabled @click="() => remove(index)" class="plus-minus-buttons">-</n-button>
<n-button @click="() => remove(index)" class="plus-minus-buttons">-</n-button>
</div>
</template>
<template #default="{ value: door }">
@@ -269,6 +456,30 @@ function onCreateKey(): Key {
</div>
</n-space>
</div>
<!-- Просмотр улики / картинки точки -->
<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' }">
<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">
<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>
<div v-else-if="viewImage" class="view-application-image-block">
<img :src="viewImage.url" class="view-application-image" />
</div>
</n-card>
</n-modal>
</template>
<style scoped>
@@ -321,6 +532,101 @@ function onCreateKey(): Key {
color: burlywood;
}
.application-name {
margin-top: 5px;
}
.application-image-border {
width: 40%;
max-width: 120px;
float: left;
padding: 7px;
margin-right: 15px;
background-image: url("@/assets/images/paper_white.jpg");
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
transform: rotate(-3deg);
cursor: zoom-in;
}
/* В редакторе улики показываются друг под другом:
картинка улики не плавает, а идёт под своим названием */
.message-footer .application-image-border {
float: none;
width: 120px;
max-width: 120px;
margin: 4px 0 10px;
}
.application-image {
width: 100%;
}
.application-image-block {
margin: 14px 0 5px;
padding: 7px;
max-width: 120px;
background-image: url("@/assets/images/paper_white.jpg");
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
cursor: zoom-in;
}
.application-audio {
width: 100%;
margin: 5px 0;
}
.application-pdf-link {
display: inline-block;
margin: 5px 0;
color: var(--second-color);
text-decoration: underline;
cursor: pointer;
}
.view-application-image-block {
text-align: center;
}
.view-application-image {
max-width: 100%;
max-height: 72vh;
}
.view-application-pdf {
width: 100%;
height: 72vh;
border: none;
}
.view-application-audio {
width: 100%;
}
.application-edit-file {
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;
}
.application-edit-audio {
width: 100%;
margin-top: 6px;
}
.application-label {
font-weight: 600;
}
.door-block {
display: inline-block;
margin: 10px 5px 10px 0;
@@ -386,6 +692,7 @@ function onCreateKey(): Key {
background-size: cover;
box-shadow: 0 3px 15px rgb(98 98 98);
transform: rotate(-3deg);
cursor: zoom-in;
}
.message-image {
@@ -444,8 +751,12 @@ function onCreateKey(): Key {
.message-show-content {
font-weight: 500;
white-space: pre-wrap;
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает
на следующие секции (двери/действия) при коротком тексте */
display: flow-root;
}
/* PDF-улики внизу после пунктирной линии */
.message-show-footer {
padding-right: 50px;
font-weight: 400;
@@ -453,6 +764,43 @@ function onCreateKey(): Key {
position: relative;
}
.pdf-application-name {
color: var(--second-color);
text-decoration: underline;
cursor: pointer;
}
/* Улики встроены в поток текста: блок прижат к правому краю,
текст обтекает его с левой стороны */
.show-applications-block {
float: right;
margin: 4px 0 10px 14px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 12px;
}
.show-application {
display: flex;
flex-direction: column;
align-items: flex-end;
max-width: 100%;
}
.show-application .application-image-block {
margin: 4px 0 0;
}
.show-application .application-audio {
width: 260px;
margin: 4px 0 0;
}
.show-application .application-pdf-link {
margin: 4px 0 0;
}
.button-dialog {
display: inline-block;
font-weight: 600;
+4
View File
@@ -7,6 +7,7 @@ import { useRoute } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue'
const message = useMessage()
const client = getAuthClient()
@@ -50,6 +51,9 @@ getScenario(Number(scenarioId))
<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>
+152 -19
View File
@@ -3,24 +3,29 @@ import { Settings } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { NButton, type UploadFileInfo, useMessage } from 'naive-ui'
import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getArchiveClient } from '@/api/archive_client'
import { getAuthClient } from '@/api/auth_client'
import type { Place, Scenario } from '@/api/generated/crabs/evening_detective_server'
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
import IntroBlock from '@/components/IntroBlock.vue'
import PlaceBlock from '@/components/PlaceBlock.vue'
import router from '@/router'
import { useAuthStore } from '@/stores/auth'
import type { EditableApplication, EditableIntroduction, EditablePlace } from '@/types/editor'
const authStore = useAuthStore()
const client = getAuthClient()
const archiveClient = getArchiveClient()
const route = useRoute()
const scenarioId = route.params.id
const showSettingsModal = ref(false)
const statusScenarioName = ref('')
const deletedScenarioName = ref('')
const isDownloadingArchive = ref(false)
const message = useMessage()
@@ -37,6 +42,14 @@ const scenario = ref<Scenario>({
publishedAt: undefined,
})
const intro = ref<EditableIntroduction>({ text: '', audio: '' })
const introMode = ref('show-editor')
// Точки сценария со служебными полями редактора (oldCode, mode, fileList)
const editorPlaces = computed<EditablePlace[]>(() => {
return (scenario.value.story?.places || []) as EditablePlace[]
})
async function getScenario(id: number) {
const res = await client.GetFullScenario({ id: id })
if (res.error != '') {
@@ -45,9 +58,12 @@ async function getScenario(id: number) {
}
scenario.value = res.scenario!
scenario.value.story?.places?.forEach((item) => {
item.oldCode = item.code
item.mode = 'show-editor'
const editable = item as EditablePlace
editable.oldCode = editable.code
editable.mode = 'show-editor'
})
intro.value = scenario.value.story?.introduction || { text: '', audio: '' }
introMode.value = 'show-editor'
}
getScenario(Number(scenarioId))
@@ -105,9 +121,35 @@ async function draftScenario(id: number) {
showSettingsModal.value = false
}
async function uploadFile(file: File | null, filename: string): Promise<string> {
async function downloadArchive() {
isDownloadingArchive.value = true
try {
const { blob, filename } = await archiveClient.downloadScenarioArchive(scenario.value.id!)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
link.remove()
// Отзываем URL после того как браузер начал скачивание
setTimeout(() => URL.revokeObjectURL(url), 1000)
message.success('Архив сценария скачан')
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось скачать архив')
} finally {
isDownloadingArchive.value = false
}
}
type UploadFileResult = {
filename: string
fileType: string
}
async function uploadFile(file: File | null, filename: string): Promise<UploadFileResult> {
if (!file) {
return ''
return { filename: '', fileType: '' }
}
// Читаем как ArrayBuffer
@@ -120,10 +162,15 @@ async function uploadFile(file: File | null, filename: string): Promise<string>
})
if (resUploadFile.error != '') {
message.error(resUploadFile.error!)
return ''
return { filename: '', fileType: '' }
}
return resUploadFile.filename || ''
// file_type возвращает сервер по содержимому файла (image | pdf | audio) —
// редактор сохраняет его на улике.
return {
filename: resUploadFile.filename || '',
fileType: resUploadFile.file_type || '',
}
}
async function updateScenario() {
@@ -133,10 +180,11 @@ async function updateScenario() {
if (file == null) {
return
}
resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
if (resUploadFilename === '') {
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
if (uploaded.filename === '') {
return
}
resUploadFilename = uploaded.filename
}
const res = await client.UpdateScenario({
@@ -208,7 +256,7 @@ async function cancelAddPlace() {
mode.value = 'mini-add'
}
async function addNotFoundDoors(scenario: Scenario, place: Place) {
async function addNotFoundDoors(scenario: Scenario, place: EditablePlace) {
for (const door of place.doors || []) {
const place = scenario.story?.places?.find((place) => {
return place.code == door.code
@@ -235,23 +283,44 @@ async function addNotFoundDoors(scenario: Scenario, place: Place) {
}
}
async function cancelUpdatePlace(place: Place) {
async function cancelUpdatePlace(place: EditablePlace) {
await getScenario(Number(scenarioId))
place.mode = 'show-editor'
}
async function updatePlace(place: Place) {
async function updatePlace(place: EditablePlace) {
if (place.fileList && place.fileList.length > 0) {
const file = place.fileList[0].file || null
if (file == null) {
return
}
const resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
if (resUploadFilename === '') {
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
if (uploaded.filename === '') {
return
}
place.image = resUploadFilename
place.image = uploaded.filename
}
// Загружаем файлы улик и проставляем file_type из ответа сервера.
const applications = (place.applications || []) as EditableApplication[]
for (const application of applications) {
if (!application.fileList || application.fileList.length == 0) {
continue
}
const file = application.fileList[0].file || null
if (file == null) {
continue
}
const uploaded = await uploadFile(
file,
'scenarios_' + scenario.value.id + '_place_' + place.code + '_app_' + file.name,
)
if (uploaded.filename === '') {
return
}
application.image = uploaded.filename
application.file_type = uploaded.fileType
}
await addNotFoundDoors(scenario.value, place)
@@ -269,7 +338,7 @@ async function updatePlace(place: Place) {
place.mode = 'show-editor'
}
async function deletePlace(place: Place) {
async function deletePlace(place: EditablePlace) {
const res = await client.DeleteScenarioPlace({
id: scenario.value.id,
code: place.oldCode,
@@ -281,6 +350,49 @@ async function deletePlace(place: Place) {
await getScenario(Number(scenarioId))
}
// Бэкенд хранит аудио вступления как имя файла, а отдаёт полным URL
// (домен + имя). При сохранении отправляем только имя файла.
function toRawFilename(value: string | undefined): string {
if (!value) {
return ''
}
return value.split('/').pop() || ''
}
async function updateIntro() {
let audio = ''
// NUpload кладёт выбранный файл в intro.fileList (поле объекта вступления)
if (intro.value.fileList && intro.value.fileList.length > 0) {
const file = intro.value.fileList[0].file || null
if (file == null) {
return
}
audio = (await uploadFile(file, 'scenarios_' + scenario.value.id + '_intro_' + file.name)).filename
if (audio === '') {
return
}
} else {
audio = toRawFilename(intro.value.audio)
}
const res = await client.UpdateScenarioIntro({
id: scenario.value.id,
introduction: {
text: intro.value.text,
audio: audio,
},
})
if (res.error != '') {
message.error(res.error!)
return
}
await getScenario(Number(scenarioId))
}
async function cancelUpdateIntro() {
await getScenario(Number(scenarioId))
}
function getRandomComplimentForNastya() {
const compliments = [
{ text: 'Настя, ты просто ослепительна сегодня!', nameForm: 'Настя' },
@@ -324,6 +436,13 @@ function getRandomComplimentForNastya() {
</n-button>
</div>
<IntroBlock :introduction="intro" v-model:mode="introMode">
<n-flex justify="end">
<n-button @click="cancelUpdateIntro()">Отмена</n-button>
<n-button @click="updateIntro()">Сохранить вступление</n-button>
</n-flex>
</IntroBlock>
<PlaceBlock v-model:mode="mode" v-model:newPlaceCode="code" v-model:newPlaceName="name"
v-model:newPlaceText="text">
<n-flex justify="end">
@@ -332,8 +451,8 @@ function getRandomComplimentForNastya() {
</n-flex>
</PlaceBlock>
<PlaceBlock :place="place" v-model:mode="place.mode" v-for="place in scenario.story?.places"
v-bind:key="place.oldCode">
<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>
@@ -370,6 +489,15 @@ function getRandomComplimentForNastya() {
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
<hr class="settings-hr" />
<p class="settings-header">Архив сценария</p>
<p class="settings-hint">
Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом аккаунте.
</p>
<n-button :loading="isDownloadingArchive" :disabled="!scenario.id" ghost @click="downloadArchive()">
Скачать архив (.zip)
</n-button>
<hr class="settings-hr" />
<p>Создать тестовую игру</p>
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
@@ -443,6 +571,11 @@ function getRandomComplimentForNastya() {
margin-top: 20px;
}
.settings-hint {
color: #999;
margin-bottom: 10px;
}
.settings-hr {
margin: 20px 0;
}
+88 -1
View File
@@ -1,14 +1,18 @@
<script setup lang="ts">
import { Archive } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { useMessage } from 'naive-ui'
import { NButton, NCard, NFlex,NInput, NModal, NTag } from 'naive-ui'
import { NButton, NCard, NFlex, NInput, NModal, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getArchiveClient } from '@/api/archive_client'
import { getAuthClient } from '@/api/auth_client'
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
const client = getAuthClient()
const archiveClient = getArchiveClient()
const scenarios = ref<Scenario[]>([])
@@ -19,6 +23,10 @@ const message = useMessage()
const showAddScenarioModal = ref(false)
const newScenarioName = ref('')
const showImportModal = ref(false)
const importFileList = ref<UploadFileInfo[]>([])
const isImporting = ref(false)
async function getScenarios() {
scenarios.value = []
const res = await client.GetMyScenarios({})
@@ -38,6 +46,33 @@ async function toScenarioEditor(id: number) {
router.push('/scenarios/' + id + '/editor')
}
function resetImport() {
importFileList.value = []
showImportModal.value = false
}
async function importScenario() {
const file = importFileList.value[0]?.file
if (!file) {
return
}
isImporting.value = true
try {
const res = await archiveClient.uploadScenarioArchive(file)
if (res.error) {
message.error(res.error)
return
}
message.success('Сценарий импортирован из архива')
resetImport()
await toScenarioEditor(res.id!)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось импортировать архив')
} finally {
isImporting.value = false
}
}
getScenarios()
</script>
@@ -51,6 +86,17 @@ getScenarios()
</div>
</div>
<div class="scenario-block" @click="showImportModal = true">
<div class="scenario-image-block scenario-image-plus import-icon">
<Icon>
<Archive />
</Icon>
</div>
<div class="scenario-content-block">
<p class="scenario-title">Импортировать из архива</p>
</div>
</div>
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioEditor(scenario.id!)"
v-bind:key="scenario.id">
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
@@ -86,6 +132,37 @@ getScenarios()
</n-card>
</n-modal>
<!-- Окно импорта сценария из архива -->
<n-modal v-model:show="showImportModal">
<n-card style="width: 600px" title="Импорт сценария из архива" :bordered="false" size="huge" role="dialog"
aria-modal="true">
<template #header-extra>
<n-button @click="resetImport()"> x </n-button>
</template>
<p class="import-hint">
Выберите ZIP-архив сценария (файл scenario.json + папка images). Архив можно получить
через «Скачать архив» в настройках сценария.
</p>
<n-upload :max="1" v-model:file-list="importFileList"
accept=".zip,application/zip,application/x-zip-compressed,application/octet-stream">
<n-upload-dragger>
<n-text style="font-size: 16px">
Щелкните или перетащите ZIP-архив в эту область
</n-text>
</n-upload-dragger>
</n-upload>
<template #footer>
<n-flex justify="end">
<n-button @click="resetImport()">Отмена</n-button>
<n-button type="primary" :loading="isImporting" :disabled="importFileList.length == 0"
@click="importScenario()">
Импортировать
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<HeaderMenu active="scenarios" />
</template>
@@ -126,6 +203,16 @@ getScenarios()
background-color: #111;
}
.import-icon svg {
height: 100px;
width: 100px;
}
.import-hint {
color: #aaa;
margin-bottom: 16px;
}
.scenario-image {
background-size: cover;
background-position: center;
+13 -6
View File
@@ -2,12 +2,13 @@
import { NButton, NFlex, NQrCode } from 'naive-ui'
import { useMessage } from 'naive-ui';
import { nextTick, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import { getAuthClient } from '@/api/auth_client';
import type { Game, Place } from '@/api/generated/crabs/evening_detective_server';
import type { Game, Introduction, Place } 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';
@@ -17,7 +18,6 @@ import MessagePaper from './MessagePaper.vue';
const authStore = useAuthStore()
const client = getAuthClient()
const message = useMessage()
const router = useRouter()
const route = useRoute()
const teamId = route.params.id
const password = route.query.password
@@ -27,6 +27,7 @@ const newPlacesCount = ref(0)
const scrollContainer = ref<HTMLDivElement | null>();
const story = ref<AdvancedStory>({
introduction: undefined,
places: []
})
@@ -74,6 +75,7 @@ async function getStory() {
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 {
@@ -116,6 +118,7 @@ async function addAction(newCode: string) {
}
type AdvancedStory = {
introduction?: Introduction;
places: AdvancedPlace[];
}
@@ -148,19 +151,23 @@ useSimplePolling(() => {
<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>
<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">
<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 v-if="game?.status == 'draft'" class="qr-main-block-text">Игра скоро начнется</p>
<p v-if="game?.status == 'run'" class="qr-main-block-text">Игра началась!!!</p>
<p class="qr-main-block-text">Игра скоро начнется</p>
</n-space>
</MessagePaper>
+6
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import CatalogView from '../views/CatalogView.vue'
import ConsentView from '../views/ConsentView.vue'
import GamesView from '../views/GamesView.vue'
import GameView from '../views/GameView.vue'
import HomeView from '../views/HomeView.vue'
@@ -42,6 +43,11 @@ const router = createRouter({
name: 'privacy-policy',
component: PrivacyPolicyView,
},
{
path: '/consent',
name: 'consent',
component: ConsentView,
},
{
path: '/games',
name: 'games',
+33 -2
View File
@@ -132,13 +132,18 @@ export const useAuthStore = defineStore('auth', () => {
}
// Регистрация
async function signup(username: string, email: string) {
async function signup(
username: string,
email: string,
acceptTerms: boolean,
acceptPrivacy: boolean,
) {
isLoading.value = true
error.value = null
try {
const client = buildClient(getToken)
const response = await client.Signup({ username, email })
const response = await client.Signup({ username, email, acceptTerms, acceptPrivacy })
if (response.error) {
error.value = response.error
@@ -154,6 +159,31 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// Удаление аккаунта (подтверждение владельца паролем, ст. 21 152-ФЗ)
async function deleteAccount(password: string) {
isLoading.value = true
error.value = null
try {
// Пароль передаётся HTTP-заголовком X-Password, а не в URL или body
const client = buildClient(getToken, () => ({ 'X-Password': password }))
const response = await client.DeleteAccount({})
if (response.error) {
error.value = response.error
return false
}
await logout()
return true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Account deletion failed'
return false
} finally {
isLoading.value = false
}
}
// Восстановление пароля
async function refreshPassword(email: string) {
isLoading.value = true
@@ -216,6 +246,7 @@ export const useAuthStore = defineStore('auth', () => {
getToken,
login,
signup,
deleteAccount,
refreshPassword,
logout,
fetchUser,
+29
View File
@@ -0,0 +1,29 @@
import type { UploadFileInfo } from 'naive-ui'
import type { Application, Introduction, Place } from '@/api/generated/crabs/evening_detective_server'
// Служебные поля интерфейса, которых нет в сгенерированной схеме API
// (типы из генератора — type-алиасы, их нельзя расширить через declare module).
// Редактор сценария хранит эти поля прямо на объектах API-моделей.
/** Точка сценария со служебными полями редактора */
export type EditablePlace = Place & {
/** Код точки до редактирования (нужен для UpdateScenarioPlace) */
oldCode?: string
/** Режим отображения блока точки в редакторе (заполняется в getScenario) */
mode: string
/** Выбранные файлы для загрузки картинки точки */
fileList?: UploadFileInfo[]
}
/** Вступление сценария со служебными полями редактора */
export type EditableIntroduction = Introduction & {
/** Выбранные файлы для загрузки аудио вступления */
fileList?: UploadFileInfo[]
}
/** Улика со служебными полями редактора */
export type EditableApplication = Application & {
/** Выбранный файл улики для загрузки */
fileList?: UploadFileInfo[]
}
+46
View File
@@ -0,0 +1,46 @@
import type { Application } from '@/api/generated/crabs/evening_detective_server'
import { getURL } from '@/api/generated/crabs/evening_detective_server/client'
// Тип файла улики: явное поле file_type (image | pdf | audio) или
// деривация из расширения имени файла (легаси-данные без file_type).
export function applicationFileType(application: Application | undefined): string {
const fileType = application?.file_type
if (fileType === 'pdf' || fileType === 'image' || fileType === 'audio') {
return fileType
}
const image = (application?.image || '').toLowerCase()
if (/\.pdf($|\?)/.test(image)) {
return 'pdf'
}
if (/\.(mp3|wav|ogg|m4a|aac|flac|opus)($|\?)/.test(image)) {
return 'audio'
}
if (/\.(png|jpe?g|gif|webp|bmp|svg)($|\?)/.test(image)) {
return 'image'
}
return ''
}
// Полный URL файла улики: относительные имена (ответ UploadFile) и пути
// префиксуются хранилищем, внешние http/https ссылки не трогаются.
export function applicationFileURL(image: string | undefined): string {
if (!image) {
return ''
}
const low = image.toLowerCase()
if (low.startsWith('http://') || low.startsWith('https://')) {
return image
}
if (image.startsWith('/')) {
return getURL() + image
}
return `${getURL()}/api/files/${image}`
}
// Последний сегмент имени файла для отображения в редакторе.
export function applicationFileName(image: string | undefined): string {
if (!image) {
return ''
}
return image.split('/').pop() || image
}
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import DocsPage from '@/components/DocsPage.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
</script>
<template>
<DocsPage kind="consent" />
<HeaderMenu />
</template>
+9 -2
View File
@@ -1,3 +1,10 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import DocsPage from '@/components/DocsPage.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
</script>
<template>Соглашением о персональных данных</template>
<template>
<DocsPage kind="privacy" />
<HeaderMenu />
</template>
+9 -2
View File
@@ -1,3 +1,10 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import DocsPage from '@/components/DocsPage.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
</script>
<template>Пользовательское соглашение</template>
<template>
<DocsPage kind="terms" />
<HeaderMenu />
</template>