generated from VLADIMIR/template_frontend
Compare commits
10 Commits
c17f91b903
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bf463de02 | |||
| 8d24eb2844 | |||
| a3f5eef6be | |||
| 2a33998a8c | |||
| 8a535a3f3c | |||
| 606d515ade | |||
| 4174ad304a | |||
| 862c918981 | |||
| bb8fca352a | |||
| ffa80a19ad |
@@ -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 не выполнялись.
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+2431
File diff suppressed because one or more lines are too long
Vendored
-2388
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Вечерний детектив</title>
|
<title>Вечерний детектив</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Dn-IZW42.js"></script>
|
<script type="module" crossorigin src="/assets/index-D7Q2J__k.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CZManGvh.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BsEbL24Y.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ export default defineConfigWithVueTs(
|
|||||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||||
},
|
},
|
||||||
|
|
||||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**', 'src/api/generated/**']),
|
||||||
|
|
||||||
pluginVue.configs['flat/essential'],
|
pluginVue.configs['flat/essential'],
|
||||||
vueTsConfigs.recommended,
|
vueTsConfigs.recommended,
|
||||||
|
|||||||
+142
-3
@@ -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) {
|
rpc GetUsers(GetUsersReq) returns (GetUsersRsp) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
get: "/api/users"
|
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) {
|
rpc AddGame(AddGameReq) returns (AddGameRsp) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
post: "/api/games"
|
post: "/api/games"
|
||||||
@@ -525,8 +598,10 @@ message EchoRsp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message SignupReq {
|
message SignupReq {
|
||||||
string username = 1;
|
string username = 1;
|
||||||
string email = 2;
|
string email = 2;
|
||||||
|
bool accept_terms = 3;
|
||||||
|
bool accept_privacy = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message SignupRsp {
|
message SignupRsp {
|
||||||
@@ -562,6 +637,39 @@ message RefreshRsp {
|
|||||||
string refreshToken = 3;
|
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 GetUsersReq {}
|
||||||
|
|
||||||
message GetUsersRsp {
|
message GetUsersRsp {
|
||||||
@@ -627,6 +735,9 @@ message UploadFileReq {
|
|||||||
message UploadFileRsp {
|
message UploadFileRsp {
|
||||||
string error = 1;
|
string error = 1;
|
||||||
string filename = 2;
|
string filename = 2;
|
||||||
|
// Тип загруженного файла: image | pdf | audio (определяется сервером по
|
||||||
|
// содержимому). Удобен редактору для предзаполнения file_type улики.
|
||||||
|
string file_type = 3 [json_name = "file_type"];
|
||||||
}
|
}
|
||||||
|
|
||||||
message DownloadFileReq {
|
message DownloadFileReq {
|
||||||
@@ -679,7 +790,13 @@ message Scenario {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message Story {
|
message Story {
|
||||||
repeated Place places = 1;
|
Introduction introduction = 2;
|
||||||
|
repeated Place places = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Introduction {
|
||||||
|
string text = 1;
|
||||||
|
string audio = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
message Place {
|
message Place {
|
||||||
@@ -696,6 +813,10 @@ message Place {
|
|||||||
message Application {
|
message Application {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string image = 2;
|
string image = 2;
|
||||||
|
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
|
||||||
|
// расширения файла (см. file_storage.FileType); пустое значение возможно
|
||||||
|
// только для legacy-строк без расширения.
|
||||||
|
string file_type = 3 [json_name = "file_type"];
|
||||||
}
|
}
|
||||||
|
|
||||||
message Door {
|
message Door {
|
||||||
@@ -771,6 +892,24 @@ message DeleteScenarioPlaceRsp {
|
|||||||
string error = 1;
|
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 {
|
message AddGameReq {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string description = 2;
|
string description = 2;
|
||||||
|
|||||||
@@ -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 '.'
|
import { createEveningDetectiveServerClient, type EveningDetectiveServer } from '.'
|
||||||
|
|
||||||
export function buildClient(getToken?: () => string | null): EveningDetectiveServer {
|
export type ExtraHeadersProvider = () => Record<string, string> | null | undefined
|
||||||
return createEveningDetectiveServerClient(createHttpHandler(getToken))
|
|
||||||
|
export function buildClient(
|
||||||
|
getToken?: () => string | null,
|
||||||
|
getExtraHeaders?: ExtraHeadersProvider,
|
||||||
|
): EveningDetectiveServer {
|
||||||
|
return createEveningDetectiveServerClient(createHttpHandler(getToken, getExtraHeaders))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHttpHandler(
|
export function createHttpHandler(
|
||||||
getToken?: () => string | null,
|
getToken?: () => string | null,
|
||||||
|
getExtraHeaders?: ExtraHeadersProvider,
|
||||||
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
|
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
|
||||||
return async (request: { path: string; method: string; body: string | null }) => {
|
return async (request: { path: string; method: string; body: string | null }) => {
|
||||||
const baseURL = getURL()
|
const baseURL = getURL()
|
||||||
@@ -24,6 +30,12 @@ export function createHttpHandler(
|
|||||||
headers['Authorization'] = `Bearer ${token}`
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем дополнительные заголовки (например, X-Password для удаления аккаунта)
|
||||||
|
const extraHeaders = getExtraHeaders?.()
|
||||||
|
if (extraHeaders) {
|
||||||
|
Object.assign(headers, extraHeaders)
|
||||||
|
}
|
||||||
|
|
||||||
// Делаем запрос
|
// Делаем запрос
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: request.method,
|
method: request.method,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Code generated by protoc-gen-typescript-http. DO NOT EDIT.
|
// Code generated by protoc-gen-typescript-http. DO NOT EDIT.
|
||||||
|
/* eslint-disable camelcase */
|
||||||
// @ts-nocheck
|
// @ts-nocheck
|
||||||
|
|
||||||
export type PingReq = {
|
export type PingReq = {
|
||||||
@@ -19,6 +19,8 @@ export type EchoRsp = {
|
|||||||
export type SignupReq = {
|
export type SignupReq = {
|
||||||
username: string | undefined;
|
username: string | undefined;
|
||||||
email: string | undefined;
|
email: string | undefined;
|
||||||
|
acceptTerms: boolean | undefined;
|
||||||
|
acceptPrivacy: boolean | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignupRsp = {
|
export type SignupRsp = {
|
||||||
@@ -54,6 +56,43 @@ export type RefreshRsp = {
|
|||||||
refreshToken: string | undefined;
|
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 = {
|
export type GetUsersReq = {
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,6 +166,9 @@ export type UploadFileReq = {
|
|||||||
export type UploadFileRsp = {
|
export type UploadFileRsp = {
|
||||||
error: string | undefined;
|
error: string | undefined;
|
||||||
filename: string | undefined;
|
filename: string | undefined;
|
||||||
|
// Тип загруженного файла: image | pdf | audio (определяется сервером по
|
||||||
|
// содержимому). Удобен редактору для предзаполнения file_type улики.
|
||||||
|
file_type: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DownloadFileReq = {
|
export type DownloadFileReq = {
|
||||||
@@ -164,9 +206,15 @@ export type Scenario = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type Story = {
|
export type Story = {
|
||||||
|
introduction: Introduction | undefined;
|
||||||
places: Place[] | undefined;
|
places: Place[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Introduction = {
|
||||||
|
text: string | undefined;
|
||||||
|
audio: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export type Place = {
|
export type Place = {
|
||||||
code: string | undefined;
|
code: string | undefined;
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
@@ -181,6 +229,10 @@ export type Place = {
|
|||||||
export type Application = {
|
export type Application = {
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
image: string | undefined;
|
image: string | undefined;
|
||||||
|
// Тип файла улики: image | pdf | audio. Значение выводится сервером из
|
||||||
|
// расширения файла (см. file_storage.FileType); пустое значение возможно
|
||||||
|
// только для legacy-строк без расширения.
|
||||||
|
file_type: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Door = {
|
export type Door = {
|
||||||
@@ -273,6 +325,24 @@ export type DeleteScenarioPlaceRsp = {
|
|||||||
error: string | undefined;
|
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 = {
|
export type AddGameReq = {
|
||||||
name: string | undefined;
|
name: string | undefined;
|
||||||
description: string | undefined;
|
description: string | undefined;
|
||||||
@@ -456,6 +526,10 @@ export interface EveningDetectiveServer {
|
|||||||
RefreshPassword(request: RefreshPasswordReq): Promise<RefreshPasswordRsp>;
|
RefreshPassword(request: RefreshPasswordReq): Promise<RefreshPasswordRsp>;
|
||||||
Login(request: LoginReq): Promise<LoginRsp>;
|
Login(request: LoginReq): Promise<LoginRsp>;
|
||||||
Refresh(request: RefreshReq): Promise<RefreshRsp>;
|
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>;
|
GetUsers(request: GetUsersReq): Promise<GetUsersRsp>;
|
||||||
GetUserById(request: GetUserByIdReq): Promise<GetUserByIdRsp>;
|
GetUserById(request: GetUserByIdReq): Promise<GetUserByIdRsp>;
|
||||||
GetMe(request: GetMeReq): Promise<GetMeRsp>;
|
GetMe(request: GetMeReq): Promise<GetMeRsp>;
|
||||||
@@ -476,6 +550,9 @@ export interface EveningDetectiveServer {
|
|||||||
AddScenarioPlace(request: AddScenarioPlaceReq): Promise<AddScenarioPlaceRsp>;
|
AddScenarioPlace(request: AddScenarioPlaceReq): Promise<AddScenarioPlaceRsp>;
|
||||||
UpdateScenarioPlace(request: UpdateScenarioPlaceReq): Promise<UpdateScenarioPlaceRsp>;
|
UpdateScenarioPlace(request: UpdateScenarioPlaceReq): Promise<UpdateScenarioPlaceRsp>;
|
||||||
DeleteScenarioPlace(request: DeleteScenarioPlaceReq): Promise<DeleteScenarioPlaceRsp>;
|
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>;
|
AddGame(request: AddGameReq): Promise<AddGameRsp>;
|
||||||
GetGames(request: GetGamesReq): Promise<GetGamesRsp>;
|
GetGames(request: GetGamesReq): Promise<GetGamesRsp>;
|
||||||
GetGame(request: GetGameReq): Promise<GetGameRsp>;
|
GetGame(request: GetGameReq): Promise<GetGameRsp>;
|
||||||
@@ -507,8 +584,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
handler: RequestHandler
|
handler: RequestHandler
|
||||||
): EveningDetectiveServer {
|
): EveningDetectiveServer {
|
||||||
return {
|
return {
|
||||||
Ping(request) {
|
Ping(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/test/ping`;
|
const path = `api/test/ping`; // eslint-disable-line quotes
|
||||||
const body = null;
|
const body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -524,8 +601,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "Ping",
|
method: "Ping",
|
||||||
}) as Promise<PingRsp>;
|
}) as Promise<PingRsp>;
|
||||||
},
|
},
|
||||||
Echo(request) {
|
Echo(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/test/echo`;
|
const path = `api/test/echo`; // eslint-disable-line quotes
|
||||||
const body = null;
|
const body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
if (request.text) {
|
if (request.text) {
|
||||||
@@ -544,8 +621,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "Echo",
|
method: "Echo",
|
||||||
}) as Promise<EchoRsp>;
|
}) as Promise<EchoRsp>;
|
||||||
},
|
},
|
||||||
Signup(request) {
|
Signup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/auth/signup`;
|
const path = `api/auth/signup`; // eslint-disable-line quotes
|
||||||
const body = JSON.stringify(request);
|
const body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -561,8 +638,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "Signup",
|
method: "Signup",
|
||||||
}) as Promise<SignupRsp>;
|
}) as Promise<SignupRsp>;
|
||||||
},
|
},
|
||||||
RefreshPassword(request) {
|
RefreshPassword(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/auth/refresh-password`;
|
const path = `api/auth/refresh-password`; // eslint-disable-line quotes
|
||||||
const body = JSON.stringify(request);
|
const body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -578,8 +655,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "RefreshPassword",
|
method: "RefreshPassword",
|
||||||
}) as Promise<RefreshPasswordRsp>;
|
}) as Promise<RefreshPasswordRsp>;
|
||||||
},
|
},
|
||||||
Login(request) {
|
Login(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/auth/login`;
|
const path = `api/auth/login`; // eslint-disable-line quotes
|
||||||
const body = JSON.stringify(request);
|
const body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -595,8 +672,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "Login",
|
method: "Login",
|
||||||
}) as Promise<LoginRsp>;
|
}) as Promise<LoginRsp>;
|
||||||
},
|
},
|
||||||
Refresh(request) {
|
Refresh(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/auth/refresh`;
|
const path = `api/auth/refresh`; // eslint-disable-line quotes
|
||||||
const body = JSON.stringify(request);
|
const body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -612,8 +689,76 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "Refresh",
|
method: "Refresh",
|
||||||
}) as Promise<RefreshRsp>;
|
}) as Promise<RefreshRsp>;
|
||||||
},
|
},
|
||||||
GetUsers(request) {
|
DeleteAccount(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/users`;
|
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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -629,11 +774,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetUsers",
|
method: "GetUsers",
|
||||||
}) as Promise<GetUsersRsp>;
|
}) as Promise<GetUsersRsp>;
|
||||||
},
|
},
|
||||||
GetUserById(request) {
|
GetUserById(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -649,8 +794,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetUserById",
|
method: "GetUserById",
|
||||||
}) as Promise<GetUserByIdRsp>;
|
}) as Promise<GetUserByIdRsp>;
|
||||||
},
|
},
|
||||||
GetMe(request) {
|
GetMe(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/users/me`;
|
const path = `api/users/me`; // eslint-disable-line quotes
|
||||||
const body = null;
|
const body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -666,11 +811,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetMe",
|
method: "GetMe",
|
||||||
}) as Promise<GetMeRsp>;
|
}) as Promise<GetMeRsp>;
|
||||||
},
|
},
|
||||||
AddUserRole(request) {
|
AddUserRole(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -686,11 +831,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "AddUserRole",
|
method: "AddUserRole",
|
||||||
}) as Promise<AddUserRoleRsp>;
|
}) as Promise<AddUserRoleRsp>;
|
||||||
},
|
},
|
||||||
DeleteUserRole(request) {
|
DeleteUserRole(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -706,8 +851,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DeleteUserRole",
|
method: "DeleteUserRole",
|
||||||
}) as Promise<DeleteUserRoleRsp>;
|
}) as Promise<DeleteUserRoleRsp>;
|
||||||
},
|
},
|
||||||
GetPermissions(request) {
|
GetPermissions(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/ui/permissions`;
|
const path = `api/ui/permissions`; // eslint-disable-line quotes
|
||||||
const body = null;
|
const body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -723,8 +868,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetPermissions",
|
method: "GetPermissions",
|
||||||
}) as Promise<GetPermissionsRsp>;
|
}) as Promise<GetPermissionsRsp>;
|
||||||
},
|
},
|
||||||
UploadFile(request) {
|
UploadFile(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/files/upload`;
|
const path = `api/files/upload`; // eslint-disable-line quotes
|
||||||
const body = JSON.stringify(request);
|
const body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -740,11 +885,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "UploadFile",
|
method: "UploadFile",
|
||||||
}) as Promise<UploadFileRsp>;
|
}) as Promise<UploadFileRsp>;
|
||||||
},
|
},
|
||||||
DownloadFile(request) {
|
DownloadFile(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.filename) {
|
if (!request.filename) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -760,8 +905,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DownloadFile",
|
method: "DownloadFile",
|
||||||
}) as Promise<googleapi_HttpBody>;
|
}) as Promise<googleapi_HttpBody>;
|
||||||
},
|
},
|
||||||
AddScenario(request) {
|
AddScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/scenario`;
|
const path = `api/scenario`; // eslint-disable-line quotes
|
||||||
const body = JSON.stringify(request);
|
const body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -777,8 +922,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "AddScenario",
|
method: "AddScenario",
|
||||||
}) as Promise<AddScenarioRsp>;
|
}) as Promise<AddScenarioRsp>;
|
||||||
},
|
},
|
||||||
GetMyScenarios(request) {
|
GetMyScenarios(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/my-scenarios`;
|
const path = `api/my-scenarios`; // eslint-disable-line quotes
|
||||||
const body = null;
|
const body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -794,8 +939,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetMyScenarios",
|
method: "GetMyScenarios",
|
||||||
}) as Promise<GetMyScenariosRsp>;
|
}) as Promise<GetMyScenariosRsp>;
|
||||||
},
|
},
|
||||||
GetScenariosCatalog(request) {
|
GetScenariosCatalog(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/catalog`;
|
const path = `api/catalog`; // eslint-disable-line quotes
|
||||||
const body = null;
|
const body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -811,11 +956,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetScenariosCatalog",
|
method: "GetScenariosCatalog",
|
||||||
}) as Promise<GetScenariosCatalogRsp>;
|
}) as Promise<GetScenariosCatalogRsp>;
|
||||||
},
|
},
|
||||||
GetFullScenario(request) {
|
GetFullScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -831,11 +976,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetFullScenario",
|
method: "GetFullScenario",
|
||||||
}) as Promise<GetScenarioRsp>;
|
}) as Promise<GetScenarioRsp>;
|
||||||
},
|
},
|
||||||
GetScenario(request) {
|
GetScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -851,11 +996,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetScenario",
|
method: "GetScenario",
|
||||||
}) as Promise<GetScenarioRsp>;
|
}) as Promise<GetScenarioRsp>;
|
||||||
},
|
},
|
||||||
UpdateScenario(request) {
|
UpdateScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -871,11 +1016,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "UpdateScenario",
|
method: "UpdateScenario",
|
||||||
}) as Promise<UpdateScenarioRsp>;
|
}) as Promise<UpdateScenarioRsp>;
|
||||||
},
|
},
|
||||||
PublicScenario(request) {
|
PublicScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -891,11 +1036,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "PublicScenario",
|
method: "PublicScenario",
|
||||||
}) as Promise<PublicScenarioRsp>;
|
}) as Promise<PublicScenarioRsp>;
|
||||||
},
|
},
|
||||||
DraftScenario(request) {
|
DraftScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -911,11 +1056,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DraftScenario",
|
method: "DraftScenario",
|
||||||
}) as Promise<DraftScenarioRsp>;
|
}) as Promise<DraftScenarioRsp>;
|
||||||
},
|
},
|
||||||
DeleteScenario(request) {
|
DeleteScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -931,11 +1076,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DeleteScenario",
|
method: "DeleteScenario",
|
||||||
}) as Promise<DeleteScenarioRsp>;
|
}) as Promise<DeleteScenarioRsp>;
|
||||||
},
|
},
|
||||||
AddScenarioPlace(request) {
|
AddScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -951,14 +1096,14 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "AddScenarioPlace",
|
method: "AddScenarioPlace",
|
||||||
}) as Promise<AddScenarioPlaceRsp>;
|
}) as Promise<AddScenarioPlaceRsp>;
|
||||||
},
|
},
|
||||||
UpdateScenarioPlace(request) {
|
UpdateScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field request.id");
|
throw new Error("missing required field request.id");
|
||||||
}
|
}
|
||||||
if (!request.code) {
|
if (!request.code) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -974,14 +1119,14 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "UpdateScenarioPlace",
|
method: "UpdateScenarioPlace",
|
||||||
}) as Promise<UpdateScenarioPlaceRsp>;
|
}) as Promise<UpdateScenarioPlaceRsp>;
|
||||||
},
|
},
|
||||||
DeleteScenarioPlace(request) {
|
DeleteScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field request.id");
|
throw new Error("missing required field request.id");
|
||||||
}
|
}
|
||||||
if (!request.code) {
|
if (!request.code) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -997,8 +1142,65 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DeleteScenarioPlace",
|
method: "DeleteScenarioPlace",
|
||||||
}) as Promise<DeleteScenarioPlaceRsp>;
|
}) as Promise<DeleteScenarioPlaceRsp>;
|
||||||
},
|
},
|
||||||
AddGame(request) {
|
UpdateScenarioIntro(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/games`;
|
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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1014,8 +1216,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "AddGame",
|
method: "AddGame",
|
||||||
}) as Promise<AddGameRsp>;
|
}) as Promise<AddGameRsp>;
|
||||||
},
|
},
|
||||||
GetGames(request) {
|
GetGames(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/games`;
|
const path = `api/games`; // eslint-disable-line quotes
|
||||||
const body = null;
|
const body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1031,11 +1233,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetGames",
|
method: "GetGames",
|
||||||
}) as Promise<GetGamesRsp>;
|
}) as Promise<GetGamesRsp>;
|
||||||
},
|
},
|
||||||
GetGame(request) {
|
GetGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1051,11 +1253,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetGame",
|
method: "GetGame",
|
||||||
}) as Promise<GetGameRsp>;
|
}) as Promise<GetGameRsp>;
|
||||||
},
|
},
|
||||||
UpdateGame(request) {
|
UpdateGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1071,11 +1273,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "UpdateGame",
|
method: "UpdateGame",
|
||||||
}) as Promise<UpdateGameRsp>;
|
}) as Promise<UpdateGameRsp>;
|
||||||
},
|
},
|
||||||
StartGame(request) {
|
StartGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1091,11 +1293,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "StartGame",
|
method: "StartGame",
|
||||||
}) as Promise<StartGameRsp>;
|
}) as Promise<StartGameRsp>;
|
||||||
},
|
},
|
||||||
PauseGame(request) {
|
PauseGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1111,11 +1313,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "PauseGame",
|
method: "PauseGame",
|
||||||
}) as Promise<PauseGameRsp>;
|
}) as Promise<PauseGameRsp>;
|
||||||
},
|
},
|
||||||
PlayGame(request) {
|
PlayGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1131,11 +1333,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "PlayGame",
|
method: "PlayGame",
|
||||||
}) as Promise<PlayGameRsp>;
|
}) as Promise<PlayGameRsp>;
|
||||||
},
|
},
|
||||||
FinishGame(request) {
|
FinishGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1151,11 +1353,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "FinishGame",
|
method: "FinishGame",
|
||||||
}) as Promise<FinishGameRsp>;
|
}) as Promise<FinishGameRsp>;
|
||||||
},
|
},
|
||||||
ResetGame(request) {
|
ResetGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1171,11 +1373,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "ResetGame",
|
method: "ResetGame",
|
||||||
}) as Promise<ResetGameRsp>;
|
}) as Promise<ResetGameRsp>;
|
||||||
},
|
},
|
||||||
DeleteGame(request) {
|
DeleteGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1191,8 +1393,8 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DeleteGame",
|
method: "DeleteGame",
|
||||||
}) as Promise<DeleteGameRsp>;
|
}) as Promise<DeleteGameRsp>;
|
||||||
},
|
},
|
||||||
AddTeam(request) {
|
AddTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
const path = `api/teams`;
|
const path = `api/teams`; // eslint-disable-line quotes
|
||||||
const body = JSON.stringify(request);
|
const body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1208,11 +1410,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "AddTeam",
|
method: "AddTeam",
|
||||||
}) as Promise<AddTeamRsp>;
|
}) as Promise<AddTeamRsp>;
|
||||||
},
|
},
|
||||||
UpdateTeam(request) {
|
UpdateTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1228,11 +1430,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "UpdateTeam",
|
method: "UpdateTeam",
|
||||||
}) as Promise<UpdateTeamRsp>;
|
}) as Promise<UpdateTeamRsp>;
|
||||||
},
|
},
|
||||||
DeleteTeam(request) {
|
DeleteTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1248,11 +1450,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "DeleteTeam",
|
method: "DeleteTeam",
|
||||||
}) as Promise<DeleteTeamRsp>;
|
}) as Promise<DeleteTeamRsp>;
|
||||||
},
|
},
|
||||||
GiveTeamApplications(request) {
|
GiveTeamApplications(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1268,11 +1470,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GiveTeamApplications",
|
method: "GiveTeamApplications",
|
||||||
}) as Promise<GiveTeamApplicationsRsp>;
|
}) as Promise<GiveTeamApplicationsRsp>;
|
||||||
},
|
},
|
||||||
GetTeamStory(request) {
|
GetTeamStory(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
if (request.password) {
|
if (request.password) {
|
||||||
@@ -1291,11 +1493,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "GetTeamStory",
|
method: "GetTeamStory",
|
||||||
}) as Promise<GetTeamStoryRsp>;
|
}) as Promise<GetTeamStoryRsp>;
|
||||||
},
|
},
|
||||||
AddTeamAction(request) {
|
AddTeamAction(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = JSON.stringify(request);
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
@@ -1311,11 +1513,11 @@ export function createEveningDetectiveServerClient(
|
|||||||
method: "AddTeamAction",
|
method: "AddTeamAction",
|
||||||
}) as Promise<AddTeamActionRsp>;
|
}) as Promise<AddTeamActionRsp>;
|
||||||
},
|
},
|
||||||
DeleteLastTeamAction(request) {
|
DeleteLastTeamAction(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||||
if (!request.id) {
|
if (!request.id) {
|
||||||
throw new Error("missing required field 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 body = null;
|
||||||
const queryParams: string[] = [];
|
const queryParams: string[] = [];
|
||||||
let uri = path;
|
let uri = path;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -70,7 +70,7 @@ function getTextForCounter(count: number): string[] {
|
|||||||
<Footsteps />
|
<Footsteps />
|
||||||
</Icon>
|
</Icon>
|
||||||
</div>
|
</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>
|
||||||
<div class="counter-block">
|
<div class="counter-block">
|
||||||
<div class="places-text">
|
<div class="places-text">
|
||||||
@@ -78,7 +78,7 @@ function getTextForCounter(count: number): string[] {
|
|||||||
<ContactMailRound />
|
<ContactMailRound />
|
||||||
</Icon>
|
</Icon>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { getURL } from '@/api/generated/crabs/evening_detective_server/client';
|
||||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
import { useSimplePolling } from '@/composables/useSimplePolling';
|
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||||
|
import {
|
||||||
|
applicationFileType,
|
||||||
|
applicationFileURL,
|
||||||
|
} from '@/utils/application';
|
||||||
|
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -25,7 +29,9 @@ const showSettingsModal = ref(false)
|
|||||||
const showGiveApplicationModal = ref(false)
|
const showGiveApplicationModal = ref(false)
|
||||||
const showAddTeamModal = ref(false)
|
const showAddTeamModal = ref(false)
|
||||||
const showDeleteTeamModal = ref(false)
|
const showDeleteTeamModal = ref(false)
|
||||||
|
const showDeleteGameModal = ref(false)
|
||||||
const newTeamName = ref('')
|
const newTeamName = ref('')
|
||||||
|
const deletedGameName = ref('')
|
||||||
const showQR = ref(false)
|
const showQR = ref(false)
|
||||||
const urlQR = ref('')
|
const urlQR = ref('')
|
||||||
const titleQR = ref('')
|
const titleQR = ref('')
|
||||||
@@ -122,7 +128,8 @@ const deletedTeam = ref<Team>({
|
|||||||
|
|
||||||
const giveApplicationApplication = ref<Application>({
|
const giveApplicationApplication = ref<Application>({
|
||||||
name: '',
|
name: '',
|
||||||
image: ''
|
image: '',
|
||||||
|
file_type: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const url = window.location.href
|
const url = window.location.href
|
||||||
@@ -176,6 +183,18 @@ async function confirmDeleteTeam() {
|
|||||||
showDeleteTeamModal.value = false
|
showDeleteTeamModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function deleteGame() {
|
||||||
|
const res = await client.DeleteGame({
|
||||||
|
id: game.value.id,
|
||||||
|
})
|
||||||
|
if (res.error != '') {
|
||||||
|
message.error(res.error!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
message.success('Игра удалена')
|
||||||
|
router.push('/games')
|
||||||
|
}
|
||||||
|
|
||||||
function getTeamStoryURL(id: number, password: string): string {
|
function getTeamStoryURL(id: number, password: string): string {
|
||||||
return getURL() + '/team-story/' + id + '?password=' + password
|
return getURL() + '/team-story/' + id + '?password=' + password
|
||||||
}
|
}
|
||||||
@@ -323,6 +342,10 @@ useSimplePolling(() => {
|
|||||||
</Icon>
|
</Icon>
|
||||||
Настройки
|
Настройки
|
||||||
</n-button>
|
</n-button>
|
||||||
|
<n-button v-if="(game.teams?.length || 0) == 0" type="error" quaternary
|
||||||
|
@click="showDeleteGameModal = true" class="settings-button">
|
||||||
|
Удалить игру
|
||||||
|
</n-button>
|
||||||
|
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
|
<n-button type="info" v-if="game.status != 'draft'" dashed @click="resetGame()">
|
||||||
@@ -359,7 +382,7 @@ useSimplePolling(() => {
|
|||||||
<p class="place-image-plus">+</p>
|
<p class="place-image-plus">+</p>
|
||||||
</div>
|
</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">
|
<div class="team-content-block">
|
||||||
|
|
||||||
<n-flex justify="space-between">
|
<n-flex justify="space-between">
|
||||||
@@ -429,10 +452,21 @@ useSimplePolling(() => {
|
|||||||
<p>Команда</p>
|
<p>Команда</p>
|
||||||
<h3>{{ giveApplicationTeam.name }}</h3>
|
<h3>{{ giveApplicationTeam.name }}</h3>
|
||||||
|
|
||||||
|
|
||||||
<p>Улика</p>
|
<p>Улика</p>
|
||||||
<h3>{{ giveApplicationApplication.name }}</h3>
|
<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>
|
</n-space>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
@@ -485,6 +519,31 @@ useSimplePolling(() => {
|
|||||||
</n-card>
|
</n-card>
|
||||||
</n-modal>
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- Окно удаления игры -->
|
||||||
|
<n-modal v-model:show="showDeleteGameModal">
|
||||||
|
<n-card style="width: 600px" title="Удаление игры" :bordered="false" size="huge" role="dialog"
|
||||||
|
aria-modal="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button @click="showDeleteGameModal = false"> x </n-button>
|
||||||
|
</template>
|
||||||
|
<n-space vertical>
|
||||||
|
<p>
|
||||||
|
Введите название игры
|
||||||
|
<n-text class="name-text" strong>{{ game.name }}</n-text> чтобы удалить её
|
||||||
|
</p>
|
||||||
|
<n-input v-model:value="deletedGameName" type="text" placeholder='Дело №0 "Следствие ведут овечки"' />
|
||||||
|
</n-space>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<n-flex justify="end">
|
||||||
|
<n-button @click="deleteGame()" :disabled="deletedGameName != game.name">
|
||||||
|
Удалить
|
||||||
|
</n-button>
|
||||||
|
</n-flex>
|
||||||
|
</template>
|
||||||
|
</n-card>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
<!-- Окно qr -->
|
<!-- Окно qr -->
|
||||||
<n-modal v-model:show="showQR">
|
<n-modal v-model:show="showQR">
|
||||||
<n-card style="width: 600px" :title="titleQR" :bordered="false" size="huge" role="dialog" aria-modal="true">
|
<n-card style="width: 600px" :title="titleQR" :bordered="false" size="huge" role="dialog" aria-modal="true">
|
||||||
@@ -563,4 +622,28 @@ useSimplePolling(() => {
|
|||||||
background-color: white;
|
background-color: white;
|
||||||
padding: 12px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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,7 +24,8 @@ const message = useMessage()
|
|||||||
const username = ref('')
|
const username = ref('')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const approval = ref(false)
|
const acceptTerms = ref(false)
|
||||||
|
const acceptPrivacy = ref(false)
|
||||||
|
|
||||||
const options = computed(() => {
|
const options = computed(() => {
|
||||||
return ['@mail.ru', '@yandex.ru', '@gmail.com'].map((suffix) => {
|
return ['@mail.ru', '@yandex.ru', '@gmail.com'].map((suffix) => {
|
||||||
@@ -48,15 +49,22 @@ async function signin() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function signup() {
|
async function signup() {
|
||||||
if (!approval.value) {
|
if (!acceptTerms.value || !acceptPrivacy.value) {
|
||||||
return
|
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) {
|
if (!res && authStore.error != null) {
|
||||||
message.error(authStore.error)
|
message.error(authStore.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message.info('Пароль отправлен на почту')
|
message.info('Пароль отправлен на почту')
|
||||||
|
acceptTerms.value = false
|
||||||
|
acceptPrivacy.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendNewPassword() {
|
async function sendNewPassword() {
|
||||||
@@ -101,16 +109,23 @@ async function sendNewPassword() {
|
|||||||
autocomplete: 'disabled',
|
autocomplete: 'disabled',
|
||||||
}" :options="options" placeholder="detective@mail.ru" clearable />
|
}" :options="options" placeholder="detective@mail.ru" clearable />
|
||||||
<div class="form-label">
|
<div class="form-label">
|
||||||
<n-checkbox v-model:checked="approval">
|
<n-checkbox v-model:checked="acceptTerms">
|
||||||
Я согласен с
|
Я принимаю условия
|
||||||
<a href="/user-agreement" target="_blank" class="docs-link">пользовательским соглашением</a><br />
|
<a href="/user-agreement" target="_blank" class="docs-link">пользовательского соглашения</a>
|
||||||
и
|
</n-checkbox>
|
||||||
<a href="/privacy-policy" target="_blank" class="docs-link">соглашением о персональных данных</a>
|
</div>
|
||||||
|
<div class="form-label">
|
||||||
|
<n-checkbox v-model:checked="acceptPrivacy">
|
||||||
|
Я даю согласие на обработку
|
||||||
|
<a href="/privacy-policy" target="_blank" class="docs-link">персональных данных</a>
|
||||||
</n-checkbox>
|
</n-checkbox>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-button-wrapper">
|
<div class="form-button-wrapper">
|
||||||
<div class="form-label">
|
<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-if="!authStore.isLoading"> Регистрация </span>
|
||||||
<span v-else> Подождите... </span>
|
<span v-else> Подождите... </span>
|
||||||
</n-button>
|
</n-button>
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NAlert } from 'naive-ui'
|
import { NAlert, NButton, NInput, NModal, useMessage } from 'naive-ui'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { getAuthClient } from '@/api/auth_client'
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const message = useMessage()
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
|
|
||||||
const permissions = ref<string[]>([])
|
const permissions = ref<string[]>([])
|
||||||
@@ -208,6 +211,22 @@ function getDetectiveTip(): Tip {
|
|||||||
|
|
||||||
const tip = getDetectiveTip()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -218,10 +237,93 @@ const tip = getDetectiveTip()
|
|||||||
</n-alert>
|
</n-alert>
|
||||||
|
|
||||||
<p>Доброго времени суток, {{ authStore.username }}.</p>
|
<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>
|
||||||
</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" />
|
<HeaderMenu active="office" />
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
+364
-16
@@ -2,13 +2,24 @@
|
|||||||
import { Key24Regular } from '@vicons/fluent'
|
import { Key24Regular } from '@vicons/fluent'
|
||||||
import { LockOutlined } from '@vicons/material'
|
import { LockOutlined } from '@vicons/material'
|
||||||
import { Icon } from '@vicons/utils'
|
import { Icon } from '@vicons/utils'
|
||||||
import { NButton, NDynamicInput, NInput, NInputGroup, NSpace, NSwitch, NText, NUpload, NUploadDragger } from 'naive-ui'
|
import { NButton, NCard, NDynamicInput, NInput, NInputGroup, NModal, NSpace, NSwitch, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
|
||||||
import type { PropType } from 'vue'
|
import { computed, type PropType,ref } from 'vue'
|
||||||
|
|
||||||
import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server'
|
import type { Application, Door, Key, Place } from '@/api/generated/crabs/evening_detective_server'
|
||||||
|
import type { EditableApplication } from '@/types/editor'
|
||||||
|
import {
|
||||||
|
applicationFileName,
|
||||||
|
applicationFileType,
|
||||||
|
applicationFileURL,
|
||||||
|
} from '@/utils/application'
|
||||||
|
|
||||||
import MessagePaper from './MessagePaper.vue';
|
import MessagePaper from './MessagePaper.vue';
|
||||||
|
|
||||||
|
// Точка сценария + служебное поле fileList (выбранные файлы для загрузки картинки)
|
||||||
|
type PlaceWithFileList = Place & {
|
||||||
|
fileList?: UploadFileInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
newPlaceCode: {
|
newPlaceCode: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -20,7 +31,7 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
},
|
},
|
||||||
place: {
|
place: {
|
||||||
type: Object as PropType<Place>,
|
type: Object as PropType<PlaceWithFileList>,
|
||||||
},
|
},
|
||||||
mode: {
|
mode: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -50,10 +61,11 @@ const updateMode = (mode: string) => {
|
|||||||
emit('update:mode', mode)
|
emit('update:mode', mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreateApplication(): Application {
|
function onCreateApplication(): EditableApplication {
|
||||||
return {
|
return {
|
||||||
name: undefined,
|
name: undefined,
|
||||||
image: undefined,
|
image: undefined,
|
||||||
|
file_type: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +82,114 @@ function onCreateKey(): Key {
|
|||||||
name: undefined,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -98,7 +218,8 @@ function onCreateKey(): Key {
|
|||||||
<hr class="hr" />
|
<hr class="hr" />
|
||||||
<div class="message-content">
|
<div class="message-content">
|
||||||
<div v-if="props.place?.image">
|
<div v-if="props.place?.image">
|
||||||
<div class="message-image-border">
|
<div class="message-image-border"
|
||||||
|
@click.stop="openImageView(props.place?.name || '', props.place?.image || '')">
|
||||||
<img :src="props.place?.image" class="message-image">
|
<img :src="props.place?.image" class="message-image">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,14 +237,26 @@ function onCreateKey(): Key {
|
|||||||
<hr class="hr" v-if="props.place?.applications?.length" />
|
<hr class="hr" v-if="props.place?.applications?.length" />
|
||||||
<div class="message-footer application-block" v-for="application in props.place?.applications"
|
<div class="message-footer application-block" v-for="application in props.place?.applications"
|
||||||
v-bind:key="application.name">
|
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>
|
</div>
|
||||||
|
|
||||||
<p class="gray-block">
|
<p class="gray-block">
|
||||||
<span v-if="props.place?.hidden">
|
<span v-if="props.place?.hidden">
|
||||||
Скрытая точка
|
Скрытая точка
|
||||||
</span>
|
</span>
|
||||||
<span v-for="key in props.place?.keys" class="key-block">
|
<span v-for="key in props.place?.keys" :key="key.name" class="key-block">
|
||||||
<Icon class="lock-icon">
|
<Icon class="lock-icon">
|
||||||
<Key24Regular />
|
<Key24Regular />
|
||||||
</Icon> {{ key.name }}
|
</Icon> {{ key.name }}
|
||||||
@@ -140,11 +273,30 @@ function onCreateKey(): Key {
|
|||||||
<hr class="show-hr" />
|
<hr class="show-hr" />
|
||||||
<div class="message-show-content font">
|
<div class="message-show-content font">
|
||||||
<div v-if="place?.image?.length">
|
<div v-if="place?.image?.length">
|
||||||
<div class="message-image-border">
|
<div class="message-image-border"
|
||||||
|
@click="openImageView(place?.name || '', place?.image || '')">
|
||||||
<img v-bind:src="place?.image" class="message-image" />
|
<img v-bind:src="place?.image" class="message-image" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ 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>
|
</div>
|
||||||
<hr class="show-hr" v-if="props.place?.doors?.length" />
|
<hr class="show-hr" v-if="props.place?.doors?.length" />
|
||||||
<div v-for="door in props.place?.doors" :key="door.code" class="button-dialog font"
|
<div v-for="door in props.place?.doors" :key="door.code" class="button-dialog font"
|
||||||
@@ -153,10 +305,20 @@ function onCreateKey(): Key {
|
|||||||
{{ door.code }} {{ door.name }}
|
{{ door.code }} {{ door.name }}
|
||||||
</div>
|
</div>
|
||||||
</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-улики (и улики без файла): в самом низу после
|
||||||
Приложение: {{ application.name }}
|
пунктирной линии; PDF открывается по нажатию на название -->
|
||||||
<!-- <div class="application-label">{{ application.number }}</div> -->
|
<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 }}
|
||||||
|
</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>
|
</div>
|
||||||
</MessagePaper>
|
</MessagePaper>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +346,7 @@ function onCreateKey(): Key {
|
|||||||
<n-space>
|
<n-space>
|
||||||
<n-space vertical>
|
<n-space vertical>
|
||||||
<img :src="props.place!.image" class="message-edit-image">
|
<img :src="props.place!.image" class="message-edit-image">
|
||||||
<n-button @click="props.place!.image = '', props.place!.fileList = null">Убрать картинку</n-button>
|
<n-button @click="props.place!.image = '', props.place!.fileList = undefined">Убрать картинку</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
<n-space vertical>
|
<n-space vertical>
|
||||||
<n-input v-model:value="props.place!.image" type="text" placeholder="Не задано" disabled />
|
<n-input v-model:value="props.place!.image" type="text" placeholder="Не задано" disabled />
|
||||||
@@ -213,8 +375,33 @@ function onCreateKey(): Key {
|
|||||||
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
|
<n-dynamic-input v-model:value="props.place!.applications" :on-create="onCreateApplication">
|
||||||
<template #create-button-default> Добавить улику </template>
|
<template #create-button-default> Добавить улику </template>
|
||||||
<template #default="{ value }">
|
<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="Название" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-dynamic-input>
|
</n-dynamic-input>
|
||||||
@@ -225,7 +412,7 @@ function onCreateKey(): Key {
|
|||||||
<template #action="{ index, create, remove }">
|
<template #action="{ index, create, remove }">
|
||||||
<div style="display: flex; flex-direction: column;">
|
<div style="display: flex; flex-direction: column;">
|
||||||
<n-button @click="() => create(index)" class="plus-minus-buttons">+</n-button>
|
<n-button @click="() => create(index)" class="plus-minus-buttons">+</n-button>
|
||||||
<n-button disabled @click="() => remove(index)" class="plus-minus-buttons">-</n-button>
|
<n-button @click="() => remove(index)" class="plus-minus-buttons">-</n-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ value: door }">
|
<template #default="{ value: door }">
|
||||||
@@ -269,6 +456,30 @@ function onCreateKey(): Key {
|
|||||||
</div>
|
</div>
|
||||||
</n-space>
|
</n-space>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -321,6 +532,101 @@ function onCreateKey(): Key {
|
|||||||
color: burlywood;
|
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 {
|
.door-block {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 10px 5px 10px 0;
|
margin: 10px 5px 10px 0;
|
||||||
@@ -386,6 +692,7 @@ function onCreateKey(): Key {
|
|||||||
background-size: cover;
|
background-size: cover;
|
||||||
box-shadow: 0 3px 15px rgb(98 98 98);
|
box-shadow: 0 3px 15px rgb(98 98 98);
|
||||||
transform: rotate(-3deg);
|
transform: rotate(-3deg);
|
||||||
|
cursor: zoom-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-image {
|
.message-image {
|
||||||
@@ -444,8 +751,12 @@ function onCreateKey(): Key {
|
|||||||
.message-show-content {
|
.message-show-content {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
/* BFC: плавающий блок улик остаётся внутри текста и не наезжает
|
||||||
|
на следующие секции (двери/действия) при коротком тексте */
|
||||||
|
display: flow-root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PDF-улики внизу после пунктирной линии */
|
||||||
.message-show-footer {
|
.message-show-footer {
|
||||||
padding-right: 50px;
|
padding-right: 50px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -453,6 +764,43 @@ function onCreateKey(): Key {
|
|||||||
position: relative;
|
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 {
|
.button-dialog {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import { getAuthClient } from '@/api/auth_client'
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
import type { 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 HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import IntroBlock from '@/components/IntroBlock.vue'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
@@ -50,6 +51,9 @@ getScenario(Number(scenarioId))
|
|||||||
<n-tag :bordered="false" type="success" class="status-block">Автор: {{ scenario.author?.username
|
<n-tag :bordered="false" type="success" class="status-block">Автор: {{ scenario.author?.username
|
||||||
}}</n-tag>
|
}}</n-tag>
|
||||||
</div>
|
</div>
|
||||||
|
<IntroBlock
|
||||||
|
v-if="scenario.story?.introduction && (scenario.story.introduction.text || scenario.story.introduction.audio)"
|
||||||
|
:introduction="scenario.story.introduction" mode="show" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,29 @@ import { Settings } from '@vicons/carbon'
|
|||||||
import { Icon } from '@vicons/utils'
|
import { Icon } from '@vicons/utils'
|
||||||
import { NButton, type UploadFileInfo, useMessage } from 'naive-ui'
|
import { NButton, type UploadFileInfo, useMessage } from 'naive-ui'
|
||||||
import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
|
import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUploadDragger } from 'naive-ui'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import { getArchiveClient } from '@/api/archive_client'
|
||||||
import { getAuthClient } from '@/api/auth_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 HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import IntroBlock from '@/components/IntroBlock.vue'
|
||||||
import PlaceBlock from '@/components/PlaceBlock.vue'
|
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import type { EditableApplication, EditableIntroduction, EditablePlace } from '@/types/editor'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
|
const archiveClient = getArchiveClient()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const scenarioId = route.params.id
|
const scenarioId = route.params.id
|
||||||
|
|
||||||
const showSettingsModal = ref(false)
|
const showSettingsModal = ref(false)
|
||||||
const statusScenarioName = ref('')
|
const statusScenarioName = ref('')
|
||||||
const deletedScenarioName = ref('')
|
const deletedScenarioName = ref('')
|
||||||
|
const isDownloadingArchive = ref(false)
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
@@ -37,6 +42,14 @@ const scenario = ref<Scenario>({
|
|||||||
publishedAt: undefined,
|
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) {
|
async function getScenario(id: number) {
|
||||||
const res = await client.GetFullScenario({ id: id })
|
const res = await client.GetFullScenario({ id: id })
|
||||||
if (res.error != '') {
|
if (res.error != '') {
|
||||||
@@ -45,9 +58,12 @@ async function getScenario(id: number) {
|
|||||||
}
|
}
|
||||||
scenario.value = res.scenario!
|
scenario.value = res.scenario!
|
||||||
scenario.value.story?.places?.forEach((item) => {
|
scenario.value.story?.places?.forEach((item) => {
|
||||||
item.oldCode = item.code
|
const editable = item as EditablePlace
|
||||||
item.mode = 'show-editor'
|
editable.oldCode = editable.code
|
||||||
|
editable.mode = 'show-editor'
|
||||||
})
|
})
|
||||||
|
intro.value = scenario.value.story?.introduction || { text: '', audio: '' }
|
||||||
|
introMode.value = 'show-editor'
|
||||||
}
|
}
|
||||||
getScenario(Number(scenarioId))
|
getScenario(Number(scenarioId))
|
||||||
|
|
||||||
@@ -105,9 +121,35 @@ async function draftScenario(id: number) {
|
|||||||
showSettingsModal.value = false
|
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) {
|
if (!file) {
|
||||||
return ''
|
return { filename: '', fileType: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Читаем как ArrayBuffer
|
// Читаем как ArrayBuffer
|
||||||
@@ -120,10 +162,15 @@ async function uploadFile(file: File | null, filename: string): Promise<string>
|
|||||||
})
|
})
|
||||||
if (resUploadFile.error != '') {
|
if (resUploadFile.error != '') {
|
||||||
message.error(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() {
|
async function updateScenario() {
|
||||||
@@ -133,10 +180,11 @@ async function updateScenario() {
|
|||||||
if (file == null) {
|
if (file == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
|
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_' + file.name)
|
||||||
if (resUploadFilename === '') {
|
if (uploaded.filename === '') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
resUploadFilename = uploaded.filename
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await client.UpdateScenario({
|
const res = await client.UpdateScenario({
|
||||||
@@ -208,7 +256,7 @@ async function cancelAddPlace() {
|
|||||||
mode.value = 'mini-add'
|
mode.value = 'mini-add'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addNotFoundDoors(scenario: Scenario, place: Place) {
|
async function addNotFoundDoors(scenario: Scenario, place: EditablePlace) {
|
||||||
for (const door of place.doors || []) {
|
for (const door of place.doors || []) {
|
||||||
const place = scenario.story?.places?.find((place) => {
|
const place = scenario.story?.places?.find((place) => {
|
||||||
return place.code == door.code
|
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))
|
await getScenario(Number(scenarioId))
|
||||||
|
|
||||||
place.mode = 'show-editor'
|
place.mode = 'show-editor'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePlace(place: Place) {
|
async function updatePlace(place: EditablePlace) {
|
||||||
if (place.fileList && place.fileList.length > 0) {
|
if (place.fileList && place.fileList.length > 0) {
|
||||||
const file = place.fileList[0].file || null
|
const file = place.fileList[0].file || null
|
||||||
if (file == null) {
|
if (file == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const resUploadFilename = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
|
const uploaded = await uploadFile(file, 'scenarios_' + scenario.value.id + '_place_' + place.code + '_' + file.name)
|
||||||
if (resUploadFilename === '') {
|
if (uploaded.filename === '') {
|
||||||
return
|
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)
|
await addNotFoundDoors(scenario.value, place)
|
||||||
@@ -269,7 +338,7 @@ async function updatePlace(place: Place) {
|
|||||||
place.mode = 'show-editor'
|
place.mode = 'show-editor'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deletePlace(place: Place) {
|
async function deletePlace(place: EditablePlace) {
|
||||||
const res = await client.DeleteScenarioPlace({
|
const res = await client.DeleteScenarioPlace({
|
||||||
id: scenario.value.id,
|
id: scenario.value.id,
|
||||||
code: place.oldCode,
|
code: place.oldCode,
|
||||||
@@ -281,6 +350,49 @@ async function deletePlace(place: Place) {
|
|||||||
await getScenario(Number(scenarioId))
|
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() {
|
function getRandomComplimentForNastya() {
|
||||||
const compliments = [
|
const compliments = [
|
||||||
{ text: 'Настя, ты просто ослепительна сегодня!', nameForm: 'Настя' },
|
{ text: 'Настя, ты просто ослепительна сегодня!', nameForm: 'Настя' },
|
||||||
@@ -324,6 +436,13 @@ function getRandomComplimentForNastya() {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</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"
|
<PlaceBlock v-model:mode="mode" v-model:newPlaceCode="code" v-model:newPlaceName="name"
|
||||||
v-model:newPlaceText="text">
|
v-model:newPlaceText="text">
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
@@ -332,8 +451,8 @@ function getRandomComplimentForNastya() {
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</PlaceBlock>
|
</PlaceBlock>
|
||||||
|
|
||||||
<PlaceBlock :place="place" v-model:mode="place.mode" v-for="place in scenario.story?.places"
|
<PlaceBlock :place="place" v-model:mode="place.mode" v-for="place in editorPlaces"
|
||||||
v-bind:key="place.oldCode">
|
v-bind:key="place.oldCode ?? place.code ?? ''">
|
||||||
<n-flex justify="end">
|
<n-flex justify="end">
|
||||||
<n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button>
|
<n-button @click="deletePlace(place)" type="error" ghost>Удалить</n-button>
|
||||||
<n-button @click="cancelUpdatePlace(place)">Отмена</n-button>
|
<n-button @click="cancelUpdatePlace(place)">Отмена</n-button>
|
||||||
@@ -370,6 +489,15 @@ function getRandomComplimentForNastya() {
|
|||||||
|
|
||||||
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
|
<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" />
|
<hr class="settings-hr" />
|
||||||
<p>Создать тестовую игру</p>
|
<p>Создать тестовую игру</p>
|
||||||
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
|
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
|
||||||
@@ -443,6 +571,11 @@ function getRandomComplimentForNastya() {
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-hint {
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-hr {
|
.settings-hr {
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Archive } from '@vicons/carbon'
|
||||||
|
import { Icon } from '@vicons/utils'
|
||||||
import { useMessage } from 'naive-ui'
|
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 { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { getArchiveClient } from '@/api/archive_client'
|
||||||
import { getAuthClient } from '@/api/auth_client'
|
import { getAuthClient } from '@/api/auth_client'
|
||||||
import type { 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 HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
|
const archiveClient = getArchiveClient()
|
||||||
|
|
||||||
const scenarios = ref<Scenario[]>([])
|
const scenarios = ref<Scenario[]>([])
|
||||||
|
|
||||||
@@ -19,6 +23,10 @@ const message = useMessage()
|
|||||||
const showAddScenarioModal = ref(false)
|
const showAddScenarioModal = ref(false)
|
||||||
const newScenarioName = ref('')
|
const newScenarioName = ref('')
|
||||||
|
|
||||||
|
const showImportModal = ref(false)
|
||||||
|
const importFileList = ref<UploadFileInfo[]>([])
|
||||||
|
const isImporting = ref(false)
|
||||||
|
|
||||||
async function getScenarios() {
|
async function getScenarios() {
|
||||||
scenarios.value = []
|
scenarios.value = []
|
||||||
const res = await client.GetMyScenarios({})
|
const res = await client.GetMyScenarios({})
|
||||||
@@ -38,6 +46,33 @@ async function toScenarioEditor(id: number) {
|
|||||||
router.push('/scenarios/' + id + '/editor')
|
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()
|
getScenarios()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -51,6 +86,17 @@ getScenarios()
|
|||||||
</div>
|
</div>
|
||||||
</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!)"
|
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioEditor(scenario.id!)"
|
||||||
v-bind:key="scenario.id">
|
v-bind:key="scenario.id">
|
||||||
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
|
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
|
||||||
@@ -86,6 +132,37 @@ getScenarios()
|
|||||||
</n-card>
|
</n-card>
|
||||||
</n-modal>
|
</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" />
|
<HeaderMenu active="scenarios" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -126,6 +203,16 @@ getScenarios()
|
|||||||
background-color: #111;
|
background-color: #111;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-icon svg {
|
||||||
|
height: 100px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-hint {
|
||||||
|
color: #aaa;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.scenario-image {
|
.scenario-image {
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
import { NButton, NFlex, NQrCode } from 'naive-ui'
|
import { NButton, NFlex, NQrCode } from 'naive-ui'
|
||||||
import { useMessage } from 'naive-ui';
|
import { useMessage } from 'naive-ui';
|
||||||
import { nextTick, ref } from 'vue';
|
import { nextTick, ref } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
import { getAuthClient } from '@/api/auth_client';
|
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 GameInputForm from '@/components/GameInputForm.vue'
|
||||||
import HeaderMenu from '@/components/HeaderMenu.vue'
|
import HeaderMenu from '@/components/HeaderMenu.vue'
|
||||||
|
import IntroBlock from '@/components/IntroBlock.vue'
|
||||||
import PlaceBlock from '@/components/PlaceBlock.vue'
|
import PlaceBlock from '@/components/PlaceBlock.vue'
|
||||||
import { useSimplePolling } from '@/composables/useSimplePolling';
|
import { useSimplePolling } from '@/composables/useSimplePolling';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
@@ -17,7 +18,6 @@ import MessagePaper from './MessagePaper.vue';
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const client = getAuthClient()
|
const client = getAuthClient()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const teamId = route.params.id
|
const teamId = route.params.id
|
||||||
const password = route.query.password
|
const password = route.query.password
|
||||||
@@ -27,6 +27,7 @@ const newPlacesCount = ref(0)
|
|||||||
const scrollContainer = ref<HTMLDivElement | null>();
|
const scrollContainer = ref<HTMLDivElement | null>();
|
||||||
|
|
||||||
const story = ref<AdvancedStory>({
|
const story = ref<AdvancedStory>({
|
||||||
|
introduction: undefined,
|
||||||
places: []
|
places: []
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -74,6 +75,7 @@ async function getStory() {
|
|||||||
const oldCount = story.value.places.length
|
const oldCount = story.value.places.length
|
||||||
|
|
||||||
story.value = {
|
story.value = {
|
||||||
|
introduction: res.story!.introduction,
|
||||||
places: res.story!.places?.map((item, i) => {
|
places: res.story!.places?.map((item, i) => {
|
||||||
console.log(oldStory?.places[i]?.mode)
|
console.log(oldStory?.places[i]?.mode)
|
||||||
return {
|
return {
|
||||||
@@ -116,6 +118,7 @@ async function addAction(newCode: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AdvancedStory = {
|
type AdvancedStory = {
|
||||||
|
introduction?: Introduction;
|
||||||
places: AdvancedPlace[];
|
places: AdvancedPlace[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,19 +151,23 @@ useSimplePolling(() => {
|
|||||||
<GameInputForm :addAction="addAction" :allPlacesCount="story.places.length" :newPlacesCount="newPlacesCount"
|
<GameInputForm :addAction="addAction" :allPlacesCount="story.places.length" :newPlacesCount="newPlacesCount"
|
||||||
:gameStatus="game?.status || ''"></GameInputForm>
|
: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"
|
<PlaceBlock :place="advancedPlace.place" v-model:mode="advancedPlace.mode"
|
||||||
v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction">
|
v-for="advancedPlace in story.places" v-bind:key="advancedPlace.place.code" :addAction="addAction">
|
||||||
</PlaceBlock>
|
</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>
|
<n-space vertical>
|
||||||
<h2 class="qr-main-block-text">Это код этого дела</h2>
|
<h2 class="qr-main-block-text">Это код этого дела</h2>
|
||||||
<p class="qr-main-block-text">Поделись с командой</p>
|
<p class="qr-main-block-text">Поделись с командой</p>
|
||||||
<div class="qr-block">
|
<div class="qr-block">
|
||||||
<n-qr-code :value="url" :size="300" :padding="0" />
|
<n-qr-code :value="url" :size="300" :padding="0" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="game?.status == 'draft'" class="qr-main-block-text">Игра скоро начнется</p>
|
<p class="qr-main-block-text">Игра скоро начнется</p>
|
||||||
<p v-if="game?.status == 'run'" class="qr-main-block-text">Игра началась!!!</p>
|
|
||||||
</n-space>
|
</n-space>
|
||||||
</MessagePaper>
|
</MessagePaper>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
|
||||||
import CatalogView from '../views/CatalogView.vue'
|
import CatalogView from '../views/CatalogView.vue'
|
||||||
|
import ConsentView from '../views/ConsentView.vue'
|
||||||
import GamesView from '../views/GamesView.vue'
|
import GamesView from '../views/GamesView.vue'
|
||||||
import GameView from '../views/GameView.vue'
|
import GameView from '../views/GameView.vue'
|
||||||
import HomeView from '../views/HomeView.vue'
|
import HomeView from '../views/HomeView.vue'
|
||||||
@@ -42,6 +43,11 @@ const router = createRouter({
|
|||||||
name: 'privacy-policy',
|
name: 'privacy-policy',
|
||||||
component: PrivacyPolicyView,
|
component: PrivacyPolicyView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/consent',
|
||||||
|
name: 'consent',
|
||||||
|
component: ConsentView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/games',
|
path: '/games',
|
||||||
name: 'games',
|
name: 'games',
|
||||||
|
|||||||
+33
-2
@@ -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
|
isLoading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const client = buildClient(getToken)
|
const client = buildClient(getToken)
|
||||||
const response = await client.Signup({ username, email })
|
const response = await client.Signup({ username, email, acceptTerms, acceptPrivacy })
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
error.value = 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) {
|
async function refreshPassword(email: string) {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
@@ -216,6 +246,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
getToken,
|
getToken,
|
||||||
login,
|
login,
|
||||||
signup,
|
signup,
|
||||||
|
deleteAccount,
|
||||||
refreshPassword,
|
refreshPassword,
|
||||||
logout,
|
logout,
|
||||||
fetchUser,
|
fetchUser,
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user