Compare commits

...

3 Commits

Author SHA1 Message Date
VLADIMIR 606d515ade add buttons 2026-08-20 23:39:56 +07:00
VLADIMIR 4174ad304a update api 2026-08-20 23:22:46 +07:00
VLADIMIR 862c918981 updates 2026-08-20 01:57:39 +07:00
18 changed files with 1186 additions and 288 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Вечерний детектив</title>
<script type="module" crossorigin src="/assets/index-B54R2K0c.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BdDl0UCc.css">
<script type="module" crossorigin src="/assets/index-CNblC4ZU.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BlFsLzpe.css">
</head>
<body>
<div id="app"></div>
+106
View File
@@ -115,6 +115,47 @@ service EveningDetectiveServer {
};
}
rpc DeleteAccount(DeleteAccountReq) returns (DeleteAccountRsp) {
option (google.api.http) = {
delete: "/api/auth/delete-account"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Регистрация и вход";
summary : "Удалить учётную запись и персональные данные";
description: "Удаляет учётную запись и персональные данные текущего пользователя (ст. 21 152-ФЗ). Требует заголовок X-Password с паролем учётной записи (подтверждение владельца; пароль не передаётся в URL).";
};
}
rpc GetTerms(GetTermsReq) returns (GetTermsRsp) {
option (google.api.http) = {
get: "/api/terms"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Документы";
summary: "Получить текст Пользовательского соглашения (оферты)";
};
}
rpc GetPrivacy(GetPrivacyReq) returns (GetPrivacyRsp) {
option (google.api.http) = {
get: "/api/privacy"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Документы";
summary: "Получить текст Политики конфиденциальности";
};
}
rpc GetConsent(GetConsentReq) returns (GetConsentRsp) {
option (google.api.http) = {
get: "/api/consent"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Документы";
summary: "Получить текст Согласия на обработку персональных данных";
};
}
rpc GetUsers(GetUsersReq) returns (GetUsersRsp) {
option (google.api.http) = {
get: "/api/users"
@@ -324,6 +365,27 @@ service EveningDetectiveServer {
};
}
rpc DownloadScenarioArchive(DownloadScenarioArchiveReq) returns (google.api.HttpBody) {
option (google.api.http) = {
get: "/api/scenarios/{id}/archive"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Сценарии";
summary: "Скачать сценарий архивом (со всеми материалами и картинками)";
};
}
rpc UploadScenarioArchive(google.api.HttpBody) returns (UploadScenarioArchiveRsp) {
option (google.api.http) = {
post: "/api/scenarios/archive"
body: "*"
};
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Сценарии";
summary: "Создать сценарий из архива";
};
}
rpc AddGame(AddGameReq) returns (AddGameRsp) {
option (google.api.http) = {
post: "/api/games"
@@ -527,6 +589,8 @@ message EchoRsp {
message SignupReq {
string username = 1;
string email = 2;
bool accept_terms = 3;
bool accept_privacy = 4;
}
message SignupRsp {
@@ -562,6 +626,39 @@ message RefreshRsp {
string refreshToken = 3;
}
// Пароль подтверждает, что удаляет аккаунт его владелец, а не обладатель
// украденного access-токена (ст. 21 152-ФЗ). Передаётся HTTP-заголовком
// X-Password (пробрасывается матчером в cmd/evening_detective_server/main.go),
// чтобы креденшел не попадал в URL/query-параметры и логи.
message DeleteAccountReq {}
message DeleteAccountRsp {
string error = 1;
}
// Документы раздаются по постоянным URL, на которые ссылается сервис при
// фиксации акцептов соглашений (user_agreements) и форма регистрации.
message GetTermsReq {}
message GetTermsRsp {
string error = 1;
string text = 2;
}
message GetPrivacyReq {}
message GetPrivacyRsp {
string error = 1;
string text = 2;
}
message GetConsentReq {}
message GetConsentRsp {
string error = 1;
string text = 2;
}
message GetUsersReq {}
message GetUsersRsp {
@@ -771,6 +868,15 @@ message DeleteScenarioPlaceRsp {
string error = 1;
}
message DownloadScenarioArchiveReq {
int32 id = 1;
}
message UploadScenarioArchiveRsp {
string error = 1;
int32 id = 2;
}
message AddGameReq {
string name = 1;
string description = 2;
+103
View File
@@ -0,0 +1,103 @@
import { useAuthStore } from '@/stores/auth'
import { getURL } from './generated/crabs/evening_detective_server/client'
export type DownloadArchiveResult = {
blob: Blob
filename: string
}
export type UploadArchiveResult = {
id: number | null
error: string | null
}
// Клиент для бинарных операций со сценарием (ZIP-архив). Сгенерированный
// HTTP-хендлер всегда парсит ответ как JSON, поэтому для архивов используем
// сырой fetch: скачивание отдаёт байты архива, загрузка принимает их напрямую.
export function getArchiveClient() {
const authStore = useAuthStore()
return buildArchiveClient(authStore.refreshTokenAction, authStore.getToken)
}
function buildArchiveClient(
refreshTokens: () => Promise<boolean>,
getToken: () => string | null,
) {
async function request(path: string, init: RequestInit): Promise<Response> {
const headers: Record<string, string> = {}
const token = getToken()
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
let response = await fetch(`${getURL()}/${path}`, {
...init,
headers: { ...headers, ...(init.headers as Record<string, string>) },
})
// Токен протух — обновляем и повторяем запрос один раз.
if (response.status == 401) {
await refreshTokens()
const newToken = getToken()
if (newToken) {
headers['Authorization'] = `Bearer ${newToken}`
}
response = await fetch(`${getURL()}/${path}`, {
...init,
headers: { ...headers, ...(init.headers as Record<string, string>) },
})
}
return response
}
async function downloadScenarioArchive(id: number): Promise<DownloadArchiveResult> {
const response = await request(`api/scenarios/${id}/archive`, { method: 'GET' })
if (!response.ok) {
throw new Error(await gatewayErrorMessage(response))
}
const blob = await response.blob()
const filename =
parseContentDispositionFilename(response.headers.get('Content-Disposition')) || `scenario-${id}.zip`
return { blob, filename }
}
async function uploadScenarioArchive(file: File): Promise<UploadArchiveResult> {
const contentType = file.type || 'application/zip'
const response = await request('api/scenarios/archive', {
method: 'POST',
headers: { 'Content-Type': contentType },
body: file,
})
const data = await response.json().catch(() => null)
if (!response.ok) {
return { id: null, error: data?.message || `Ошибка сервера (${response.status})` }
}
const error = typeof data?.error === 'string' && data.error !== '' ? data.error : null
return { id: typeof data?.id === 'number' ? data.id : null, error }
}
return { downloadScenarioArchive, uploadScenarioArchive }
}
// Извлекает имя файла из Content-Disposition: attachment; filename="name.zip"
function parseContentDispositionFilename(value: string | null): string {
if (!value) {
return ''
}
const match = value.match(/filename="?([^";]+)"?/i)
const filename = match ? match[1] : ''
return filename.replace(/[\\/:*?"<>|]/g, '_')
}
// Ошибки grpc-gateway приходят JSON-объектом {"code": ..., "message": ...}
async function gatewayErrorMessage(response: Response): Promise<string> {
const data = await response.json().catch(() => null)
if (data && typeof data.message === 'string' && data.message !== '') {
return data.message
}
return `Ошибка сервера (${response.status})`
}
@@ -1,11 +1,17 @@
import { createEveningDetectiveServerClient, type EveningDetectiveServer } from '.'
export function buildClient(getToken?: () => string | null): EveningDetectiveServer {
return createEveningDetectiveServerClient(createHttpHandler(getToken))
export type ExtraHeadersProvider = () => Record<string, string> | null | undefined
export function buildClient(
getToken?: () => string | null,
getExtraHeaders?: ExtraHeadersProvider,
): EveningDetectiveServer {
return createEveningDetectiveServerClient(createHttpHandler(getToken, getExtraHeaders))
}
export function createHttpHandler(
getToken?: () => string | null,
getExtraHeaders?: ExtraHeadersProvider,
): (request: { path: string; method: string; body: string | null }) => Promise<unknown> {
return async (request: { path: string; method: string; body: string | null }) => {
const baseURL = getURL()
@@ -24,6 +30,12 @@ export function createHttpHandler(
headers['Authorization'] = `Bearer ${token}`
}
// Добавляем дополнительные заголовки (например, X-Password для удаления аккаунта)
const extraHeaders = getExtraHeaders?.()
if (extraHeaders) {
Object.assign(headers, extraHeaders)
}
// Делаем запрос
const response = await fetch(url, {
method: request.method,
@@ -1,5 +1,5 @@
// Code generated by protoc-gen-typescript-http. DO NOT EDIT.
/* eslint-disable camelcase */
// @ts-nocheck
export type PingReq = {
@@ -19,6 +19,8 @@ export type EchoRsp = {
export type SignupReq = {
username: string | undefined;
email: string | undefined;
acceptTerms: boolean | undefined;
acceptPrivacy: boolean | undefined;
};
export type SignupRsp = {
@@ -54,6 +56,43 @@ export type RefreshRsp = {
refreshToken: string | undefined;
};
// Пароль подтверждает, что удаляет аккаунт его владелец, а не обладатель
// украденного access-токена (ст. 21 152-ФЗ). Передаётся HTTP-заголовком
// X-Password (пробрасывается матчером в cmd/evening_detective_server/main.go),
// чтобы креденшел не попадал в URL/query-параметры и логи.
export type DeleteAccountReq = {
};
export type DeleteAccountRsp = {
error: string | undefined;
};
// Документы раздаются по постоянным URL, на которые ссылается сервис при
// фиксации акцептов соглашений (user_agreements) и форма регистрации.
export type GetTermsReq = {
};
export type GetTermsRsp = {
error: string | undefined;
text: string | undefined;
};
export type GetPrivacyReq = {
};
export type GetPrivacyRsp = {
error: string | undefined;
text: string | undefined;
};
export type GetConsentReq = {
};
export type GetConsentRsp = {
error: string | undefined;
text: string | undefined;
};
export type GetUsersReq = {
};
@@ -273,6 +312,15 @@ export type DeleteScenarioPlaceRsp = {
error: string | undefined;
};
export type DownloadScenarioArchiveReq = {
id: number | undefined;
};
export type UploadScenarioArchiveRsp = {
error: string | undefined;
id: number | undefined;
};
export type AddGameReq = {
name: string | undefined;
description: string | undefined;
@@ -456,6 +504,10 @@ export interface EveningDetectiveServer {
RefreshPassword(request: RefreshPasswordReq): Promise<RefreshPasswordRsp>;
Login(request: LoginReq): Promise<LoginRsp>;
Refresh(request: RefreshReq): Promise<RefreshRsp>;
DeleteAccount(request: DeleteAccountReq): Promise<DeleteAccountRsp>;
GetTerms(request: GetTermsReq): Promise<GetTermsRsp>;
GetPrivacy(request: GetPrivacyReq): Promise<GetPrivacyRsp>;
GetConsent(request: GetConsentReq): Promise<GetConsentRsp>;
GetUsers(request: GetUsersReq): Promise<GetUsersRsp>;
GetUserById(request: GetUserByIdReq): Promise<GetUserByIdRsp>;
GetMe(request: GetMeReq): Promise<GetMeRsp>;
@@ -476,6 +528,8 @@ export interface EveningDetectiveServer {
AddScenarioPlace(request: AddScenarioPlaceReq): Promise<AddScenarioPlaceRsp>;
UpdateScenarioPlace(request: UpdateScenarioPlaceReq): Promise<UpdateScenarioPlaceRsp>;
DeleteScenarioPlace(request: DeleteScenarioPlaceReq): Promise<DeleteScenarioPlaceRsp>;
DownloadScenarioArchive(request: DownloadScenarioArchiveReq): Promise<googleapi_HttpBody>;
UploadScenarioArchive(request: googleapi_HttpBody): Promise<UploadScenarioArchiveRsp>;
AddGame(request: AddGameReq): Promise<AddGameRsp>;
GetGames(request: GetGamesReq): Promise<GetGamesRsp>;
GetGame(request: GetGameReq): Promise<GetGameRsp>;
@@ -507,8 +561,8 @@ export function createEveningDetectiveServerClient(
handler: RequestHandler
): EveningDetectiveServer {
return {
Ping(request) {
const path = `api/test/ping`;
Ping(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/test/ping`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -524,8 +578,8 @@ export function createEveningDetectiveServerClient(
method: "Ping",
}) as Promise<PingRsp>;
},
Echo(request) {
const path = `api/test/echo`;
Echo(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/test/echo`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
if (request.text) {
@@ -544,8 +598,8 @@ export function createEveningDetectiveServerClient(
method: "Echo",
}) as Promise<EchoRsp>;
},
Signup(request) {
const path = `api/auth/signup`;
Signup(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/signup`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -561,8 +615,8 @@ export function createEveningDetectiveServerClient(
method: "Signup",
}) as Promise<SignupRsp>;
},
RefreshPassword(request) {
const path = `api/auth/refresh-password`;
RefreshPassword(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/refresh-password`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -578,8 +632,8 @@ export function createEveningDetectiveServerClient(
method: "RefreshPassword",
}) as Promise<RefreshPasswordRsp>;
},
Login(request) {
const path = `api/auth/login`;
Login(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/login`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -595,8 +649,8 @@ export function createEveningDetectiveServerClient(
method: "Login",
}) as Promise<LoginRsp>;
},
Refresh(request) {
const path = `api/auth/refresh`;
Refresh(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/refresh`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -612,8 +666,76 @@ export function createEveningDetectiveServerClient(
method: "Refresh",
}) as Promise<RefreshRsp>;
},
GetUsers(request) {
const path = `api/users`;
DeleteAccount(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/auth/delete-account`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "DELETE",
body,
}, {
service: "EveningDetectiveServer",
method: "DeleteAccount",
}) as Promise<DeleteAccountRsp>;
},
GetTerms(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/terms`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetTerms",
}) as Promise<GetTermsRsp>;
},
GetPrivacy(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/privacy`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetPrivacy",
}) as Promise<GetPrivacyRsp>;
},
GetConsent(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/consent`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetConsent",
}) as Promise<GetConsentRsp>;
},
GetUsers(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/users`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -629,11 +751,11 @@ export function createEveningDetectiveServerClient(
method: "GetUsers",
}) as Promise<GetUsersRsp>;
},
GetUserById(request) {
GetUserById(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/users/${request.id}`;
const path = `api/users/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -649,8 +771,8 @@ export function createEveningDetectiveServerClient(
method: "GetUserById",
}) as Promise<GetUserByIdRsp>;
},
GetMe(request) {
const path = `api/users/me`;
GetMe(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/users/me`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -666,11 +788,11 @@ export function createEveningDetectiveServerClient(
method: "GetMe",
}) as Promise<GetMeRsp>;
},
AddUserRole(request) {
AddUserRole(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/users/${request.id}/role/add`;
const path = `api/users/${request.id}/role/add`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -686,11 +808,11 @@ export function createEveningDetectiveServerClient(
method: "AddUserRole",
}) as Promise<AddUserRoleRsp>;
},
DeleteUserRole(request) {
DeleteUserRole(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/users/${request.id}/role/delete`;
const path = `api/users/${request.id}/role/delete`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -706,8 +828,8 @@ export function createEveningDetectiveServerClient(
method: "DeleteUserRole",
}) as Promise<DeleteUserRoleRsp>;
},
GetPermissions(request) {
const path = `api/ui/permissions`;
GetPermissions(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/ui/permissions`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -723,8 +845,8 @@ export function createEveningDetectiveServerClient(
method: "GetPermissions",
}) as Promise<GetPermissionsRsp>;
},
UploadFile(request) {
const path = `api/files/upload`;
UploadFile(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/files/upload`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -740,11 +862,11 @@ export function createEveningDetectiveServerClient(
method: "UploadFile",
}) as Promise<UploadFileRsp>;
},
DownloadFile(request) {
DownloadFile(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.filename) {
throw new Error("missing required field request.filename");
}
const path = `api/files/${request.filename}`;
const path = `api/files/${request.filename}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -760,8 +882,8 @@ export function createEveningDetectiveServerClient(
method: "DownloadFile",
}) as Promise<googleapi_HttpBody>;
},
AddScenario(request) {
const path = `api/scenario`;
AddScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/scenario`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -777,8 +899,8 @@ export function createEveningDetectiveServerClient(
method: "AddScenario",
}) as Promise<AddScenarioRsp>;
},
GetMyScenarios(request) {
const path = `api/my-scenarios`;
GetMyScenarios(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/my-scenarios`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -794,8 +916,8 @@ export function createEveningDetectiveServerClient(
method: "GetMyScenarios",
}) as Promise<GetMyScenariosRsp>;
},
GetScenariosCatalog(request) {
const path = `api/catalog`;
GetScenariosCatalog(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/catalog`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -811,11 +933,11 @@ export function createEveningDetectiveServerClient(
method: "GetScenariosCatalog",
}) as Promise<GetScenariosCatalogRsp>;
},
GetFullScenario(request) {
GetFullScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/full`;
const path = `api/scenarios/${request.id}/full`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -831,11 +953,11 @@ export function createEveningDetectiveServerClient(
method: "GetFullScenario",
}) as Promise<GetScenarioRsp>;
},
GetScenario(request) {
GetScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}`;
const path = `api/scenarios/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -851,11 +973,11 @@ export function createEveningDetectiveServerClient(
method: "GetScenario",
}) as Promise<GetScenarioRsp>;
},
UpdateScenario(request) {
UpdateScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}`;
const path = `api/scenarios/${request.id}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -871,11 +993,11 @@ export function createEveningDetectiveServerClient(
method: "UpdateScenario",
}) as Promise<UpdateScenarioRsp>;
},
PublicScenario(request) {
PublicScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/public`;
const path = `api/scenarios/${request.id}/public`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -891,11 +1013,11 @@ export function createEveningDetectiveServerClient(
method: "PublicScenario",
}) as Promise<PublicScenarioRsp>;
},
DraftScenario(request) {
DraftScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/draft`;
const path = `api/scenarios/${request.id}/draft`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -911,11 +1033,11 @@ export function createEveningDetectiveServerClient(
method: "DraftScenario",
}) as Promise<DraftScenarioRsp>;
},
DeleteScenario(request) {
DeleteScenario(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}`;
const path = `api/scenarios/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -931,11 +1053,11 @@ export function createEveningDetectiveServerClient(
method: "DeleteScenario",
}) as Promise<DeleteScenarioRsp>;
},
AddScenarioPlace(request) {
AddScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/places`;
const path = `api/scenarios/${request.id}/places`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -951,14 +1073,14 @@ export function createEveningDetectiveServerClient(
method: "AddScenarioPlace",
}) as Promise<AddScenarioPlaceRsp>;
},
UpdateScenarioPlace(request) {
UpdateScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
if (!request.code) {
throw new Error("missing required field request.code");
}
const path = `api/scenarios/${request.id}/places/${request.code}`;
const path = `api/scenarios/${request.id}/places/${request.code}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -974,14 +1096,14 @@ export function createEveningDetectiveServerClient(
method: "UpdateScenarioPlace",
}) as Promise<UpdateScenarioPlaceRsp>;
},
DeleteScenarioPlace(request) {
DeleteScenarioPlace(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
if (!request.code) {
throw new Error("missing required field request.code");
}
const path = `api/scenarios/${request.id}/places/${request.code}`;
const path = `api/scenarios/${request.id}/places/${request.code}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -997,8 +1119,45 @@ export function createEveningDetectiveServerClient(
method: "DeleteScenarioPlace",
}) as Promise<DeleteScenarioPlaceRsp>;
},
AddGame(request) {
const path = `api/games`;
DownloadScenarioArchive(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/scenarios/${request.id}/archive`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "DownloadScenarioArchive",
}) as Promise<googleapi_HttpBody>;
},
UploadScenarioArchive(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/scenarios/archive`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "UploadScenarioArchive",
}) as Promise<UploadScenarioArchiveRsp>;
},
AddGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/games`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1014,8 +1173,8 @@ export function createEveningDetectiveServerClient(
method: "AddGame",
}) as Promise<AddGameRsp>;
},
GetGames(request) {
const path = `api/games`;
GetGames(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/games`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -1031,11 +1190,11 @@ export function createEveningDetectiveServerClient(
method: "GetGames",
}) as Promise<GetGamesRsp>;
},
GetGame(request) {
GetGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}`;
const path = `api/games/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -1051,11 +1210,11 @@ export function createEveningDetectiveServerClient(
method: "GetGame",
}) as Promise<GetGameRsp>;
},
UpdateGame(request) {
UpdateGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}`;
const path = `api/games/${request.id}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1071,11 +1230,11 @@ export function createEveningDetectiveServerClient(
method: "UpdateGame",
}) as Promise<UpdateGameRsp>;
},
StartGame(request) {
StartGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/start`;
const path = `api/games/${request.id}/start`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1091,11 +1250,11 @@ export function createEveningDetectiveServerClient(
method: "StartGame",
}) as Promise<StartGameRsp>;
},
PauseGame(request) {
PauseGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/pause`;
const path = `api/games/${request.id}/pause`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1111,11 +1270,11 @@ export function createEveningDetectiveServerClient(
method: "PauseGame",
}) as Promise<PauseGameRsp>;
},
PlayGame(request) {
PlayGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/play`;
const path = `api/games/${request.id}/play`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1131,11 +1290,11 @@ export function createEveningDetectiveServerClient(
method: "PlayGame",
}) as Promise<PlayGameRsp>;
},
FinishGame(request) {
FinishGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/finish`;
const path = `api/games/${request.id}/finish`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1151,11 +1310,11 @@ export function createEveningDetectiveServerClient(
method: "FinishGame",
}) as Promise<FinishGameRsp>;
},
ResetGame(request) {
ResetGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}/reset`;
const path = `api/games/${request.id}/reset`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1171,11 +1330,11 @@ export function createEveningDetectiveServerClient(
method: "ResetGame",
}) as Promise<ResetGameRsp>;
},
DeleteGame(request) {
DeleteGame(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/games/${request.id}`;
const path = `api/games/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -1191,8 +1350,8 @@ export function createEveningDetectiveServerClient(
method: "DeleteGame",
}) as Promise<DeleteGameRsp>;
},
AddTeam(request) {
const path = `api/teams`;
AddTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/teams`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1208,11 +1367,11 @@ export function createEveningDetectiveServerClient(
method: "AddTeam",
}) as Promise<AddTeamRsp>;
},
UpdateTeam(request) {
UpdateTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}`;
const path = `api/teams/${request.id}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1228,11 +1387,11 @@ export function createEveningDetectiveServerClient(
method: "UpdateTeam",
}) as Promise<UpdateTeamRsp>;
},
DeleteTeam(request) {
DeleteTeam(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}`;
const path = `api/teams/${request.id}`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
@@ -1248,11 +1407,11 @@ export function createEveningDetectiveServerClient(
method: "DeleteTeam",
}) as Promise<DeleteTeamRsp>;
},
GiveTeamApplications(request) {
GiveTeamApplications(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}/applications`;
const path = `api/teams/${request.id}/applications`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1268,11 +1427,11 @@ export function createEveningDetectiveServerClient(
method: "GiveTeamApplications",
}) as Promise<GiveTeamApplicationsRsp>;
},
GetTeamStory(request) {
GetTeamStory(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}/story`;
const path = `api/teams/${request.id}/story`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
if (request.password) {
@@ -1291,11 +1450,11 @@ export function createEveningDetectiveServerClient(
method: "GetTeamStory",
}) as Promise<GetTeamStoryRsp>;
},
AddTeamAction(request) {
AddTeamAction(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}/actions`;
const path = `api/teams/${request.id}/actions`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
@@ -1311,11 +1470,11 @@ export function createEveningDetectiveServerClient(
method: "AddTeamAction",
}) as Promise<AddTeamActionRsp>;
},
DeleteLastTeamAction(request) {
DeleteLastTeamAction(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.id) {
throw new Error("missing required field request.id");
}
const path = `api/teams/${request.id}/last-actions`;
const path = `api/teams/${request.id}/last-actions`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
+173
View File
@@ -0,0 +1,173 @@
<script setup lang="ts">
import { NAlert, NSpin } from 'naive-ui'
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import HeaderText from '@/components/HeaderText.vue'
const props = defineProps<{
kind: 'terms' | 'privacy' | 'consent'
}>()
const titles: Record<string, string> = {
terms: 'Пользовательское соглашение',
privacy: 'Политика конфиденциальности',
consent: 'Согласие на обработку персональных данных',
}
const text = ref('')
const loading = ref(true)
const error = ref('')
onMounted(async () => {
const client = getAuthClient()
const fetchers = {
terms: () => client.GetTerms({}),
privacy: () => client.GetPrivacy({}),
consent: () => client.GetConsent({}),
}
try {
const res = await fetchers[props.kind]()
if (res.error) {
error.value = res.error
} else {
text.value = res.text || ''
}
} catch (err) {
error.value = err instanceof Error ? err.message : 'Не удалось загрузить документ'
} finally {
loading.value = false
}
})
</script>
<template>
<div class="center-block-custom">
<div class="width700 doc-page">
<h1 class="doc-title">
<HeaderText>{{ titles[props.kind] }}</HeaderText>
</h1>
<div v-if="loading" class="doc-loading">
<n-spin size="large" />
</div>
<n-alert v-else-if="error" type="error" :show-icon="false" class="doc-error">
{{ error }}
</n-alert>
<div v-else class="paper-block">
<div class="paper">
<p class="doc-text">{{ text }}</p>
</div>
<div class="paper-shadow paper-shadow-left"></div>
<div class="paper-shadow paper-shadow-right"></div>
</div>
<div class="docs-links">
Смотрите также:
<RouterLink to="/user-agreement" class="docs-link">Пользовательское соглашение</RouterLink>
·
<RouterLink to="/privacy-policy" class="docs-link">Политика конфиденциальности</RouterLink>
·
<RouterLink to="/consent" class="docs-link">Согласие на обработку персональных данных</RouterLink>
</div>
</div>
</div>
</template>
<style scoped>
.doc-page {
padding-top: 30px;
}
.doc-title {
margin: 0 0 24px;
text-align: center;
}
.doc-loading {
display: flex;
justify-content: center;
padding: 80px 0;
}
.doc-error {
margin: 0 auto;
max-width: 600px;
}
.paper-block {
position: relative;
}
.paper {
position: relative;
z-index: 2;
padding: 32px 28px;
border-radius: 3px;
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
color: #1d1d1d;
box-shadow: 0 0 12px rgb(0 0 0 / 70%);
max-height: 60vh;
overflow-y: auto;
scrollbar-width: none;
}
.paper::-webkit-scrollbar {
display: none;
}
.doc-text {
font-family: 'font_old_typer';
font-size: 17px;
line-height: 1.65;
white-space: pre-wrap;
word-break: break-word;
text-align: justify;
}
.paper-shadow {
position: absolute;
top: 0;
width: 100%;
height: 100%;
border-radius: 3px;
background-image: url('@/assets/images/paper_white.jpg');
background-size: cover;
}
.paper-shadow-left {
left: 0;
transform: rotate(-2.2deg);
filter: brightness(60%);
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
}
.paper-shadow-right {
right: 0;
transform: rotate(2.2deg);
filter: brightness(75%);
box-shadow: 0 0 10px rgb(0 0 0 / 60%);
}
.docs-links {
margin-top: 28px;
padding-bottom: 20px;
text-align: center;
font-size: 15px;
color: #999;
}
.docs-link {
color: #45aa89;
text-decoration: none;
}
.docs-link:hover {
color: #63e2b7;
}
</style>
+24 -9
View File
@@ -24,7 +24,8 @@ const message = useMessage()
const username = ref('')
const email = ref('')
const password = ref('')
const approval = ref(false)
const acceptTerms = ref(false)
const acceptPrivacy = ref(false)
const options = computed(() => {
return ['@mail.ru', '@yandex.ru', '@gmail.com'].map((suffix) => {
@@ -48,15 +49,22 @@ async function signin() {
}
async function signup() {
if (!approval.value) {
if (!acceptTerms.value || !acceptPrivacy.value) {
return
}
const res = await authStore.signup(username.value, email.value)
const res = await authStore.signup(
username.value,
email.value,
acceptTerms.value,
acceptPrivacy.value,
)
if (!res && authStore.error != null) {
message.error(authStore.error)
return
}
message.info('Пароль отправлен на почту')
acceptTerms.value = false
acceptPrivacy.value = false
}
async function sendNewPassword() {
@@ -101,16 +109,23 @@ async function sendNewPassword() {
autocomplete: 'disabled',
}" :options="options" placeholder="detective@mail.ru" clearable />
<div class="form-label">
<n-checkbox v-model:checked="approval">
Я согласен с
<a href="/user-agreement" target="_blank" class="docs-link">пользовательским соглашением</a><br />
и
<a href="/privacy-policy" target="_blank" class="docs-link">соглашением о персональных данных</a>
<n-checkbox v-model:checked="acceptTerms">
Я принимаю условия
<a href="/user-agreement" target="_blank" class="docs-link">пользовательского соглашения</a>
</n-checkbox>
</div>
<div class="form-label">
<n-checkbox v-model:checked="acceptPrivacy">
Я даю согласие на обработку
<a href="/privacy-policy" target="_blank" class="docs-link">персональных данных</a>
</n-checkbox>
</div>
<div class="form-button-wrapper">
<div class="form-label">
<n-button @click="signup" :disabled="username.length == 0 || password.length == 0 || !approval">
<n-button
@click="signup"
:disabled="username.length == 0 || email.length == 0 || !acceptTerms || !acceptPrivacy"
>
<span v-if="!authStore.isLoading"> Регистрация </span>
<span v-else> Подождите... </span>
</n-button>
+104 -2
View File
@@ -1,12 +1,15 @@
<script setup lang="ts">
import { NAlert } from 'naive-ui'
import { NAlert, NButton, NInput, NModal, useMessage } from 'naive-ui'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getAuthClient } from '@/api/auth_client'
import HeaderMenu from '@/components/HeaderMenu.vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const router = useRouter()
const message = useMessage()
const client = getAuthClient()
const permissions = ref<string[]>([])
@@ -208,6 +211,22 @@ function getDetectiveTip(): Tip {
const tip = getDetectiveTip()
// ===== Удаление аккаунта =====
const showDeleteDialog = ref(false)
const deletePassword = ref('')
const deleteError = ref('')
async function confirmDeleteAccount() {
deleteError.value = ''
const res = await authStore.deleteAccount(deletePassword.value)
if (!res) {
deleteError.value = authStore.error || 'Не удалось удалить аккаунт'
return
}
message.success('Аккаунт и персональные данные удалены')
showDeleteDialog.value = false
router.push('/login')
}
</script>
<template>
@@ -218,10 +237,93 @@ const tip = getDetectiveTip()
</n-alert>
<p>Доброго времени суток, {{ authStore.username }}.</p>
<div class="danger-zone">
<div class="danger-zone-text">
<div class="danger-zone-title">Удаление аккаунта</div>
<div class="danger-zone-hint">
Удаление необратимо: аккаунт и все персональные данные будут стёрты (ст. 21 152-ФЗ).
</div>
</div>
<n-button type="error" ghost @click="showDeleteDialog = true">Удалить аккаунт</n-button>
</div>
</div>
</div>
<n-modal
v-model:show="showDeleteDialog"
preset="card"
title="Удаление аккаунта"
class="delete-modal"
>
<p class="delete-modal-text">
Это действие необратимо. Для подтверждения удаления введите пароль от учётной записи
он понадобится, чтобы убедиться, что аккаунт удаляет его владелец.
</p>
<n-input
v-model:value="deletePassword"
type="password"
placeholder="Пароль для подтверждения"
@keyup.enter="confirmDeleteAccount"
/>
<p v-if="deleteError" class="delete-modal-error">{{ deleteError }}</p>
<div class="delete-modal-buttons">
<n-button @click="showDeleteDialog = false">Отмена</n-button>
<n-button
type="error"
:disabled="deletePassword.length == 0"
:loading="authStore.isLoading"
@click="confirmDeleteAccount"
>
Удалить навсегда
</n-button>
</div>
</n-modal>
<HeaderMenu active="office" />
</template>
<style scoped></style>
<style scoped>
.danger-zone {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 40px;
padding: 20px;
background-color: #222;
border: 1px solid rgb(224 108 117 / 40%);
border-radius: 10px;
}
.danger-zone-title {
font-size: 17px;
font-weight: 600;
color: #e06c75;
}
.danger-zone-hint {
margin-top: 6px;
font-size: 14px;
color: #999;
}
.delete-modal-text {
margin: 0 0 16px;
line-height: 1.5;
color: #bbb;
}
.delete-modal-error {
margin: 12px 0 0;
color: #e06c75;
}
.delete-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 20px;
}
</style>
+38
View File
@@ -6,6 +6,7 @@ import { NAlert, NCard, NFlex, NInput, NModal, NSpace, NTag, NText, NUpload, NUp
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { getArchiveClient } from '@/api/archive_client'
import { getAuthClient } from '@/api/auth_client'
import type { Place, Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
@@ -15,12 +16,14 @@ import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const client = getAuthClient()
const archiveClient = getArchiveClient()
const route = useRoute()
const scenarioId = route.params.id
const showSettingsModal = ref(false)
const statusScenarioName = ref('')
const deletedScenarioName = ref('')
const isDownloadingArchive = ref(false)
const message = useMessage()
@@ -105,6 +108,27 @@ async function draftScenario(id: number) {
showSettingsModal.value = false
}
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
}
}
async function uploadFile(file: File | null, filename: string): Promise<string> {
if (!file) {
return ''
@@ -370,6 +394,15 @@ function getRandomComplimentForNastya() {
<n-button @click="updateScenario()" ghost> Сохранить изменения </n-button>
<hr class="settings-hr" />
<p class="settings-header">Архив сценария</p>
<p class="settings-hint">
Скачайте сценарий ZIP-архивом (scenario.json + изображения) и импортируйте его в другом аккаунте.
</p>
<n-button :loading="isDownloadingArchive" :disabled="!scenario.id" ghost @click="downloadArchive()">
Скачать архив (.zip)
</n-button>
<hr class="settings-hr" />
<p>Создать тестовую игру</p>
<n-button @click="addGame(scenario.name || '', scenario.id!)" ghost>
@@ -443,6 +476,11 @@ function getRandomComplimentForNastya() {
margin-top: 20px;
}
.settings-hint {
color: #999;
margin-bottom: 10px;
}
.settings-hr {
margin: 20px 0;
}
+88 -1
View File
@@ -1,14 +1,18 @@
<script setup lang="ts">
import { Archive } from '@vicons/carbon'
import { Icon } from '@vicons/utils'
import { useMessage } from 'naive-ui'
import { NButton, NCard, NFlex,NInput, NModal, NTag } from 'naive-ui'
import { NButton, NCard, NFlex, NInput, NModal, NTag, NText, NUpload, NUploadDragger, type UploadFileInfo } from 'naive-ui'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getArchiveClient } from '@/api/archive_client'
import { getAuthClient } from '@/api/auth_client'
import type { Scenario } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
const client = getAuthClient()
const archiveClient = getArchiveClient()
const scenarios = ref<Scenario[]>([])
@@ -19,6 +23,10 @@ const message = useMessage()
const showAddScenarioModal = ref(false)
const newScenarioName = ref('')
const showImportModal = ref(false)
const importFileList = ref<UploadFileInfo[]>([])
const isImporting = ref(false)
async function getScenarios() {
scenarios.value = []
const res = await client.GetMyScenarios({})
@@ -38,6 +46,33 @@ async function toScenarioEditor(id: number) {
router.push('/scenarios/' + id + '/editor')
}
function resetImport() {
importFileList.value = []
showImportModal.value = false
}
async function importScenario() {
const file = importFileList.value[0]?.file
if (!file) {
return
}
isImporting.value = true
try {
const res = await archiveClient.uploadScenarioArchive(file)
if (res.error) {
message.error(res.error)
return
}
message.success('Сценарий импортирован из архива')
resetImport()
await toScenarioEditor(res.id!)
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось импортировать архив')
} finally {
isImporting.value = false
}
}
getScenarios()
</script>
@@ -51,6 +86,17 @@ getScenarios()
</div>
</div>
<div class="scenario-block" @click="showImportModal = true">
<div class="scenario-image-block scenario-image-plus import-icon">
<Icon>
<Archive />
</Icon>
</div>
<div class="scenario-content-block">
<p class="scenario-title">Импортировать из архива</p>
</div>
</div>
<div class="scenario-block" v-for="scenario in scenarios" @click="toScenarioEditor(scenario.id!)"
v-bind:key="scenario.id">
<div class="scenario-image-block scenario-image" :style="{ backgroundImage: `url(${scenario.image})` }"></div>
@@ -86,6 +132,37 @@ getScenarios()
</n-card>
</n-modal>
<!-- Окно импорта сценария из архива -->
<n-modal v-model:show="showImportModal">
<n-card style="width: 600px" title="Импорт сценария из архива" :bordered="false" size="huge" role="dialog"
aria-modal="true">
<template #header-extra>
<n-button @click="resetImport()"> x </n-button>
</template>
<p class="import-hint">
Выберите ZIP-архив сценария (файл scenario.json + папка images). Архив можно получить
через «Скачать архив» в настройках сценария.
</p>
<n-upload :max="1" v-model:file-list="importFileList"
accept=".zip,application/zip,application/x-zip-compressed,application/octet-stream">
<n-upload-dragger>
<n-text style="font-size: 16px">
Щелкните или перетащите ZIP-архив в эту область
</n-text>
</n-upload-dragger>
</n-upload>
<template #footer>
<n-flex justify="end">
<n-button @click="resetImport()">Отмена</n-button>
<n-button type="primary" :loading="isImporting" :disabled="importFileList.length == 0"
@click="importScenario()">
Импортировать
</n-button>
</n-flex>
</template>
</n-card>
</n-modal>
<HeaderMenu active="scenarios" />
</template>
@@ -126,6 +203,16 @@ getScenarios()
background-color: #111;
}
.import-icon svg {
height: 100px;
width: 100px;
}
.import-hint {
color: #aaa;
margin-bottom: 16px;
}
.scenario-image {
background-size: cover;
background-position: center;
+6
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import CatalogView from '../views/CatalogView.vue'
import ConsentView from '../views/ConsentView.vue'
import GamesView from '../views/GamesView.vue'
import GameView from '../views/GameView.vue'
import HomeView from '../views/HomeView.vue'
@@ -42,6 +43,11 @@ const router = createRouter({
name: 'privacy-policy',
component: PrivacyPolicyView,
},
{
path: '/consent',
name: 'consent',
component: ConsentView,
},
{
path: '/games',
name: 'games',
+33 -2
View File
@@ -132,13 +132,18 @@ export const useAuthStore = defineStore('auth', () => {
}
// Регистрация
async function signup(username: string, email: string) {
async function signup(
username: string,
email: string,
acceptTerms: boolean,
acceptPrivacy: boolean,
) {
isLoading.value = true
error.value = null
try {
const client = buildClient(getToken)
const response = await client.Signup({ username, email })
const response = await client.Signup({ username, email, acceptTerms, acceptPrivacy })
if (response.error) {
error.value = response.error
@@ -154,6 +159,31 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// Удаление аккаунта (подтверждение владельца паролем, ст. 21 152-ФЗ)
async function deleteAccount(password: string) {
isLoading.value = true
error.value = null
try {
// Пароль передаётся HTTP-заголовком X-Password, а не в URL или body
const client = buildClient(getToken, () => ({ 'X-Password': password }))
const response = await client.DeleteAccount({})
if (response.error) {
error.value = response.error
return false
}
await logout()
return true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Account deletion failed'
return false
} finally {
isLoading.value = false
}
}
// Восстановление пароля
async function refreshPassword(email: string) {
isLoading.value = true
@@ -216,6 +246,7 @@ export const useAuthStore = defineStore('auth', () => {
getToken,
login,
signup,
deleteAccount,
refreshPassword,
logout,
fetchUser,
+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import DocsPage from '@/components/DocsPage.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
</script>
<template>
<DocsPage kind="consent" />
<HeaderMenu />
</template>
+9 -2
View File
@@ -1,3 +1,10 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import DocsPage from '@/components/DocsPage.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
</script>
<template>Соглашением о персональных данных</template>
<template>
<DocsPage kind="privacy" />
<HeaderMenu />
</template>
+9 -2
View File
@@ -1,3 +1,10 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import DocsPage from '@/components/DocsPage.vue'
import HeaderMenu from '@/components/HeaderMenu.vue'
</script>
<template>Пользовательское соглашение</template>
<template>
<DocsPage kind="terms" />
<HeaderMenu />
</template>