From e0ed0b6435f577260ec1934d414caa7dc93e4a77 Mon Sep 17 00:00:00 2001 From: "Vaka.pro" Date: Wed, 8 Apr 2026 01:21:11 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BF=D1=80=D0=BE=D0=B4=20=E2=80=94=20C?= =?UTF-8?q?ORS,=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D1=8F=20API,=20=D0=BE?= =?UTF-8?q?=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=D0=B7=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=BF=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B5=D0=B4=D1=88=D0=B8=D0=BC=20=D1=81=D1=82=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CORS_ORIGIN: несколько origin через запятую; комментарии в .env.example - Версия бэкенда: APP_VERSION, безопасное чтение package.json, футер при пустой версии - Сообщения API: unknown_error и ответы 401/403/404 без JSON; отладочный лог при !ok - Статус «внесите результат» для прошедшей даты + блок на карточке старта и стили --- .env.example | 6 ++++ backend/src/config.ts | 18 ++++++++++- backend/src/version.ts | 18 ++++++++--- frontend/src/api/errors.ts | 16 ++++++++- frontend/src/api/http.ts | 19 +++++++++++ frontend/src/app/layouts/AppShellFooter.tsx | 3 +- frontend/src/lib/index.ts | 1 + frontend/src/lib/raceMetrics.ts | 36 +++++++++++++++------ frontend/src/pages/RaceDetailsPage.tsx | 15 +++++++-- frontend/src/pages/RacesPage.tsx | 2 +- frontend/src/styles/global.css | 25 ++++++++++++++ 11 files changed, 140 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index cc42396..ec216bd 100644 --- a/.env.example +++ b/.env.example @@ -24,10 +24,16 @@ API_PORT=3001 # CALENDAR_RUN_MOCK_DB=1 # ─── CORS ──────────────────────────────────────────────────── +# Должен совпадать с origin в браузере (схема + хост + порт, без пути), иначе API «молчит». # Локальный Vite: http://localhost:5173 # Стек с фронтом на 3033: http://localhost:3033 +# Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru CORS_ORIGIN=http://localhost:5173 +# ─── Версия API (опционально) ───────────────────────────────── +# Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health). +# APP_VERSION=1.0.0 + # ─── Frontend (Vite, локально из каталога frontend/) ───────── # В Docker-образе фронта базовый URL API задаётся при сборке (/api), не из .env. VITE_API_BASE_URL=http://localhost:3001 diff --git a/backend/src/config.ts b/backend/src/config.ts index f5fc252..5319951 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -33,5 +33,21 @@ export const config = { password: requireEnv("DB_PASSWORD"), }, apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10), - corsOrigin: process.env.CORS_ORIGIN || "http://localhost:5173", + /** Одно значение или несколько через запятую (прод: https://домен) */ + corsOrigin: parseCorsOrigins(), }; + +function parseCorsOrigins(): string | string[] { + const raw = process.env.CORS_ORIGIN?.trim(); + if (!raw) { + return "http://localhost:5173"; + } + const parts = raw.split(",").map((s) => s.trim()).filter(Boolean); + if (parts.length === 0) { + return "http://localhost:5173"; + } + if (parts.length === 1) { + return parts[0]!; + } + return parts; +} diff --git a/backend/src/version.ts b/backend/src/version.ts index 734b6a7..1f09a44 100644 --- a/backend/src/version.ts +++ b/backend/src/version.ts @@ -7,8 +7,18 @@ export function getBackendVersion(): string { if (cached) { return cached; } - const pkgPath = path.join(__dirname, "..", "package.json"); - const raw = fs.readFileSync(pkgPath, "utf-8"); - cached = (JSON.parse(raw) as { version: string }).version; - return cached; + const fromEnv = process.env.APP_VERSION?.trim(); + if (fromEnv) { + cached = fromEnv; + return cached; + } + try { + const pkgPath = path.join(__dirname, "..", "package.json"); + const raw = fs.readFileSync(pkgPath, "utf-8"); + cached = (JSON.parse(raw) as { version: string }).version; + return cached; + } catch { + cached = "0.0.0"; + return cached; + } } diff --git a/frontend/src/api/errors.ts b/frontend/src/api/errors.ts index 302814a..69e3b85 100644 --- a/frontend/src/api/errors.ts +++ b/frontend/src/api/errors.ts @@ -35,7 +35,8 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode { value === "validation_error" || value === "not_found" || value === "database_unavailable" || - value === "conflict" + value === "conflict" || + value === "unknown_error" ) { return value; } @@ -62,6 +63,17 @@ export function toApiError(status: number, payload: unknown): ApiError { }); } + if (!hasStructuredApiError(payload) && (status === 401 || status === 403 || status === 404)) { + return new ApiError({ + code: "network_error", + status, + message: + status === 404 + ? "API не найден по этому адресу. Проверьте прокси и префикс /api." + : "Запрос отклонён сервером. Проверьте переменную CORS_ORIGIN на бэкенде.", + }); + } + const maybePayload = payload as ApiErrorPayload; const code = normalizeApiCode(maybePayload?.error); const details = Array.isArray(maybePayload?.details) @@ -88,6 +100,8 @@ export function getApiErrorMessage(code: ApiErrorCode): string { return "Запись с таким идентификатором уже существует."; case "network_error": return "Не удалось связаться с сервером."; + case "unknown_error": + return "Сервер не смог обработать запрос. Попробуйте позже или обновите страницу."; default: return "Произошла неизвестная ошибка."; } diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts index b179f07..9aaac54 100644 --- a/frontend/src/api/http.ts +++ b/frontend/src/api/http.ts @@ -55,6 +55,25 @@ export async function requestJson(path: string, init?: RequestInit): Promise< await delay(80 * attempt); continue; } + // #region agent log + fetch("http://127.0.0.1:7488/ingest/a18f912f-72c6-4a58-866b-17810a6b89d2", { + method: "POST", + headers: { "Content-Type": "application/json", "X-Debug-Session-Id": "587ee5" }, + body: JSON.stringify({ + sessionId: "587ee5", + hypothesisId: "H-http-not-ok", + location: "http.ts:requestJson", + message: "HTTP error response", + data: { + path, + status: response.status, + contentType: response.headers.get("content-type"), + payloadIsObject: payload !== null && typeof payload === "object", + }, + timestamp: Date.now(), + }), + }).catch(() => {}); + // #endregion throw toApiError(response.status, payload); } diff --git a/frontend/src/app/layouts/AppShellFooter.tsx b/frontend/src/app/layouts/AppShellFooter.tsx index 1966638..d047480 100644 --- a/frontend/src/app/layouts/AppShellFooter.tsx +++ b/frontend/src/app/layouts/AppShellFooter.tsx @@ -12,7 +12,8 @@ export function AppShellFooter(): JSX.Element { if (ac.signal.aborted) { return; } - setBackendVersion(h.version); + const v = h.version; + setBackendVersion(typeof v === "string" && v.length > 0 ? v : "не указана"); }) .catch(() => { if (ac.signal.aborted) { diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts index adba963..9ed3e2e 100644 --- a/frontend/src/lib/index.ts +++ b/frontend/src/lib/index.ts @@ -8,6 +8,7 @@ export { isCloseDistance, parseFinishTimeToSeconds, parseRaceDate, + raceNeedsResultEntry, sortByDateAsc, sortByDateDesc, splitRacesByDate, diff --git a/frontend/src/lib/raceMetrics.ts b/frontend/src/lib/raceMetrics.ts index 333308c..1bdba62 100644 --- a/frontend/src/lib/raceMetrics.ts +++ b/frontend/src/lib/raceMetrics.ts @@ -122,18 +122,36 @@ export function getPaceLabel(finishTime: string | null, distanceKm: number): str return `${String(paceMinutes).padStart(2, "0")}:${String(paceRemainder).padStart(2, "0")} /км`; } -export function getRaceStatusClassName(status: Race["status"]): string { - const base = "race-card__status"; - if (status === "completed") { - return `${base} ${base}--completed`; +function isPastDateNeedingResult(status: Race["status"], raceDate: string): boolean { + if (status !== "planned" && status !== "registered") { + return false; } - if (status === "registered") { - return `${base} ${base}--registered`; - } - return `${base} ${base}--planned`; + const today = new Date(); + today.setHours(0, 0, 0, 0); + return parseRaceDate(raceDate).getTime() < today.getTime(); } -export function getRaceStatusLabel(status: Race["status"]): string { +export function raceNeedsResultEntry(race: Race): boolean { + return isPastDateNeedingResult(race.status, race.date); +} + +export function getRaceStatusClassName(status: Race["status"], raceDate?: string): string { + const base = "race-card__status"; + let tier = `${base}--planned`; + if (status === "completed") { + tier = `${base}--completed`; + } else if (status === "registered") { + tier = `${base}--registered`; + } + const needs = + raceDate && isPastDateNeedingResult(status, raceDate) ? ` ${base}--needs-result` : ""; + return `${base} ${tier}${needs}`; +} + +export function getRaceStatusLabel(status: Race["status"], raceDate?: string): string { + if (raceDate && isPastDateNeedingResult(status, raceDate)) { + return "внесите результат"; + } if (status === "completed") { return "пробежал"; } diff --git a/frontend/src/pages/RaceDetailsPage.tsx b/frontend/src/pages/RaceDetailsPage.tsx index 7f70834..83d3d29 100644 --- a/frontend/src/pages/RaceDetailsPage.tsx +++ b/frontend/src/pages/RaceDetailsPage.tsx @@ -7,6 +7,7 @@ import { getPaceLabel, getRaceStatusClassName, getRaceStatusLabel, + raceNeedsResultEntry, } from "../lib"; import type { Race } from "../api"; @@ -148,9 +149,19 @@ export function RaceDetailsPage(): JSX.Element { {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}

- {getRaceStatusLabel(race.status)} + {getRaceStatusLabel(race.status, race.date)} + {raceNeedsResultEntry(race) ? ( +

+ Дата старта уже прошла —{" "} + + внесите результат или обновите статус + + . +

+ ) : null} +
Редактировать @@ -202,7 +213,7 @@ export function RaceDetailsPage(): JSX.Element {
Статус
-
{getRaceStatusLabel(race.status)}
+
{getRaceStatusLabel(race.status, race.date)}
diff --git a/frontend/src/pages/RacesPage.tsx b/frontend/src/pages/RacesPage.tsx index d7370fc..8637aca 100644 --- a/frontend/src/pages/RacesPage.tsx +++ b/frontend/src/pages/RacesPage.tsx @@ -64,7 +64,7 @@ function RaceList(props: { title: string; races: Race[] }): JSX.Element { {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)}

- {getRaceStatusLabel(race.status)} + {getRaceStatusLabel(race.status, race.date)} ))} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index cb12269..066d28b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -291,6 +291,31 @@ a { color: #8a5a00; } +.race-card__status--needs-result { + outline: 1px solid var(--color-warning); +} + +.race-details-past-hint { + margin: 0 0 var(--space-4); + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + border: 1px solid var(--color-warning); + background: #fffaf0; + color: var(--color-text); + font-size: var(--font-size-caption); +} + +.race-details-past-hint__link { + color: var(--color-accent); + font-weight: 600; + text-decoration: underline; +} + +.race-details-past-hint__link:hover, +.race-details-past-hint__link:focus-visible { + outline: none; +} + .races-filter { margin-top: var(--space-5); display: flex;