web-react/: SPA Vite+React+TS, nginx раздаёт статику и проксирует /api на api:8000 по локалке. docker-compose: web(Flet)->web-react на :80. transfer_crud.backup_restore: чистим содержимое data/radar вместо rmtree точки монтирования (EBUSY).
284 lines
9.3 KiB
TypeScript
284 lines
9.3 KiB
TypeScript
// Единое место для всех HTTP-запросов к бэкенду.
|
||
// Порт web/api_client.py. Базовый путь /api проксируется nginx (прод) или Vite (dev) на api:8000.
|
||
|
||
const BASE = "/api";
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Типы
|
||
// ---------------------------------------------------------------------------
|
||
export interface Me {
|
||
id: string;
|
||
first_name?: string;
|
||
last_name?: string;
|
||
group_id?: string | null;
|
||
organization_id?: string | null;
|
||
}
|
||
|
||
export interface Organization {
|
||
id: string;
|
||
name_organization: string;
|
||
group_id?: string | null;
|
||
}
|
||
|
||
export interface Group {
|
||
id: string;
|
||
name_group: string;
|
||
}
|
||
|
||
export interface Choice {
|
||
id: string;
|
||
text: string;
|
||
}
|
||
|
||
export interface Question {
|
||
id: string;
|
||
text: string;
|
||
choices: Choice[];
|
||
}
|
||
|
||
export interface Poll {
|
||
id: string;
|
||
title: string;
|
||
description?: string | null;
|
||
questions?: Question[];
|
||
}
|
||
|
||
export interface Answer {
|
||
question_id: string;
|
||
choice_id: string;
|
||
text?: string | null;
|
||
}
|
||
|
||
export interface ResponseDetail {
|
||
id: string;
|
||
poll_id: string;
|
||
submitted_at: string;
|
||
answers: Answer[];
|
||
}
|
||
|
||
export interface UserResponse {
|
||
id: string;
|
||
poll_id: string;
|
||
submitted_at: string;
|
||
}
|
||
|
||
export interface RadarResult {
|
||
image_url?: string | null;
|
||
[k: string]: unknown;
|
||
}
|
||
|
||
export type Result<T> = { data: T | null; error: string | null };
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Низкоуровневые помощники
|
||
// ---------------------------------------------------------------------------
|
||
function authHeaders(token?: string | null): Record<string, string> {
|
||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||
}
|
||
|
||
async function safeJson(resp: Response): Promise<any> {
|
||
try {
|
||
return await resp.json();
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// GET, возвращает распарсенный JSON или null при любой ошибке (как в api_client.py)
|
||
async function getJson<T>(path: string, token?: string | null): Promise<T | null> {
|
||
try {
|
||
const resp = await fetch(BASE + path, { headers: authHeaders(token) });
|
||
if (resp.ok) return (await resp.json()) as T;
|
||
} catch {
|
||
/* network error -> null */
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auth
|
||
// ---------------------------------------------------------------------------
|
||
export async function login(username: string, password: string): Promise<Result<string>> {
|
||
try {
|
||
const body = new URLSearchParams({ username, password });
|
||
const resp = await fetch(BASE + "/auth/login", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||
body,
|
||
});
|
||
if (resp.ok) {
|
||
const json = await resp.json();
|
||
return { data: json.access_token, error: null };
|
||
}
|
||
if (resp.status === 401) return { data: null, error: "Неверный логин или пароль" };
|
||
return { data: null, error: `Ошибка сервера: ${resp.status}` };
|
||
} catch {
|
||
return { data: null, error: "Не удалось подключиться к серверу" };
|
||
}
|
||
}
|
||
|
||
export async function logout(token: string): Promise<void> {
|
||
try {
|
||
await fetch(BASE + "/auth/logout", { method: "POST", headers: authHeaders(token) });
|
||
} catch {
|
||
/* локальный logout произойдёт в любом случае */
|
||
}
|
||
}
|
||
|
||
export async function getMe(token: string): Promise<Me | null> {
|
||
return getJson<Me>("/users/me", token);
|
||
}
|
||
|
||
const FIELD_NAMES: Record<string, string> = { password: "Пароль", username: "Логин" };
|
||
const MSG_MAP: Record<string, string> = {
|
||
"Password must be at least 8 characters": "Пароль должен быть не менее 8 символов",
|
||
"Username must be at least 3 characters": "Логин должен быть не менее 3 символов",
|
||
};
|
||
|
||
export interface RegisterPayload {
|
||
username: string;
|
||
password: string;
|
||
first_name: string;
|
||
last_name: string;
|
||
group_id?: string | null;
|
||
organization_id?: string | null;
|
||
}
|
||
|
||
// Возвращает строку с ошибкой или null при успехе (как register в api_client.py)
|
||
export async function register(p: RegisterPayload): Promise<string | null> {
|
||
const payload: Record<string, unknown> = {
|
||
username: p.username,
|
||
password: p.password,
|
||
first_name: p.first_name,
|
||
last_name: p.last_name,
|
||
};
|
||
if (p.group_id) payload.group_id = p.group_id;
|
||
if (p.organization_id) payload.organization_id = p.organization_id;
|
||
|
||
try {
|
||
const resp = await fetch(BASE + "/auth/register", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (resp.status === 201) return null;
|
||
const json = await safeJson(resp);
|
||
if (resp.status === 400) return json?.detail ?? "Ошибка регистрации";
|
||
if (resp.status === 422) {
|
||
const errors: any[] = json?.detail ?? [];
|
||
for (const err of errors) {
|
||
const raw = err?.ctx?.error || err?.msg || "";
|
||
if (raw in MSG_MAP) return MSG_MAP[raw];
|
||
const loc: any[] = err?.loc ?? [];
|
||
const field = FIELD_NAMES[loc[loc.length - 1]] ?? "";
|
||
const msg = (err?.msg ?? "Ошибка валидации").replace(/^Value error, /, "");
|
||
return `${field}: ${msg}`.replace(/^: |:?\s*$/g, "").trim() || "Проверьте правильность введённых данных";
|
||
}
|
||
return "Проверьте правильность введённых данных";
|
||
}
|
||
return `Ошибка сервера: ${resp.status}`;
|
||
} catch {
|
||
return "Не удалось подключиться к серверу";
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Organizations & Groups
|
||
// ---------------------------------------------------------------------------
|
||
export async function getOrganizations(): Promise<Organization[]> {
|
||
return (await getJson<Organization[]>("/organizations/")) ?? [];
|
||
}
|
||
|
||
export async function getGroups(): Promise<Group[]> {
|
||
return (await getJson<Group[]>("/groups/")) ?? [];
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Polls
|
||
// ---------------------------------------------------------------------------
|
||
export async function getPolls(): Promise<Poll[]> {
|
||
return (await getJson<Poll[]>("/polls/")) ?? [];
|
||
}
|
||
|
||
export async function getPoll(pollId: string): Promise<Poll | null> {
|
||
return getJson<Poll>(`/polls/${pollId}`);
|
||
}
|
||
|
||
export async function getResponse(responseId: string): Promise<ResponseDetail | null> {
|
||
return getJson<ResponseDetail>(`/polls/responses/${responseId}`);
|
||
}
|
||
|
||
export async function getRadarResult(responseId: string): Promise<RadarResult | null> {
|
||
return getJson<RadarResult>(`/responses/${responseId}/radar`);
|
||
}
|
||
|
||
// URL SVG-диаграммы — браузер тянет напрямую с того же origin (через nginx-прокси).
|
||
export function radarImageUrl(responseId: string): string {
|
||
return `${BASE}/responses/${responseId}/radar/image`;
|
||
}
|
||
|
||
export interface SubmitPayload {
|
||
user_id?: string | null;
|
||
group_id?: string | null;
|
||
organization_id?: string | null;
|
||
}
|
||
|
||
export async function submitResponse(
|
||
pollId: string,
|
||
answers: Answer[],
|
||
extra: SubmitPayload = {},
|
||
): Promise<Result<string>> {
|
||
const payload: Record<string, unknown> = { answers };
|
||
if (extra.user_id) payload.user_id = extra.user_id;
|
||
if (extra.group_id) payload.group_id = extra.group_id;
|
||
if (extra.organization_id) payload.organization_id = extra.organization_id;
|
||
try {
|
||
const resp = await fetch(BASE + `/polls/${pollId}/responses`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
});
|
||
if (resp.status === 201) {
|
||
const json = await resp.json();
|
||
return { data: json.id, error: null };
|
||
}
|
||
return { data: null, error: `Ошибка сервера: ${resp.status}` };
|
||
} catch {
|
||
return { data: null, error: "Не удалось подключиться к серверу" };
|
||
}
|
||
}
|
||
|
||
export async function getUserResponses(userId: string): Promise<UserResponse[]> {
|
||
return (await getJson<UserResponse[]>(`/polls/users/${userId}/responses`)) ?? [];
|
||
}
|
||
|
||
export interface UpdateUserPayload {
|
||
first_name?: string | null;
|
||
last_name?: string | null;
|
||
group_id?: string | null;
|
||
organization_id?: string | null;
|
||
}
|
||
|
||
export async function updateUser(
|
||
userId: string,
|
||
token: string,
|
||
payload: UpdateUserPayload,
|
||
): Promise<Result<Me>> {
|
||
const body: Record<string, unknown> = {};
|
||
if (payload.first_name !== undefined) body.first_name = payload.first_name;
|
||
if (payload.last_name !== undefined) body.last_name = payload.last_name;
|
||
if (payload.group_id !== undefined) body.group_id = payload.group_id;
|
||
if (payload.organization_id !== undefined) body.organization_id = payload.organization_id;
|
||
try {
|
||
const resp = await fetch(BASE + `/users/${userId}`, {
|
||
method: "PUT",
|
||
headers: { "Content-Type": "application/json", ...authHeaders(token) },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (resp.ok) return { data: await resp.json(), error: null };
|
||
return { data: null, error: `Ошибка сервера: ${resp.status}` };
|
||
} catch {
|
||
return { data: null, error: "Не удалось подключиться к серверу" };
|
||
}
|
||
}
|