Compare commits

...

8 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
29 changed files with 4393 additions and 2547 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 не выполнялись.
-2388
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2431
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-B54R2K0c.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BdDl0UCc.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>
+43 -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()
@@ -124,7 +128,8 @@ const deletedTeam = ref<Team>({
const giveApplicationApplication = ref<Application>({
name: '',
image: ''
image: '',
file_type: ''
})
const url = window.location.href
@@ -377,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">
@@ -447,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">
@@ -606,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>
+362 -14
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>
@@ -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>