v0.9 - Справка и надёжная офлайн-страница
- Справка: новая страница /help с описанием подсчёта очков игроков (формула League Points, ничьи, выбывшие = последнее место, порог 3 партии для топа); кнопка «Справка» добавлена в боковое меню. Задел под будущие правила и подсказки. - index.html больше не кэшируется (no-store на Caddy и в FastAPI): при недоступности хоста надёжно показывается страница-заглушка вместо зависшей «Загрузки».
This commit is contained in:
+5
-1
@@ -208,9 +208,13 @@ def _mount_spa(app: FastAPI) -> None:
|
|||||||
full_path
|
full_path
|
||||||
and _STATIC_DIR in candidate.parents
|
and _STATIC_DIR in candidate.parents
|
||||||
and candidate.is_file()
|
and candidate.is_file()
|
||||||
|
and candidate.name != "index.html"
|
||||||
):
|
):
|
||||||
|
# Хэшированные ассеты Vite — обычное кэширование (имя меняется при сборке).
|
||||||
return FileResponse(candidate)
|
return FileResponse(candidate)
|
||||||
return FileResponse(index_file)
|
# index.html (корень и SPA-маршруты) — НЕ кэшируем: иначе браузер отдаёт старый
|
||||||
|
# документ из кэша и при недоступности хоста не доходит до Caddy с заглушкой.
|
||||||
|
return FileResponse(index_file, headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|||||||
+16
-7
@@ -12,12 +12,21 @@
|
|||||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||||
# буферизация задержат поток событий (реал-тайм перестанет работать).
|
# буферизация задержат поток событий (реал-тайм перестанет работать).
|
||||||
#
|
#
|
||||||
# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет
|
# Туннель лёг (апстрим недоступен) → reverse_proxy отдаёт 502/503/504, и сниппет (edge)
|
||||||
# (offline) подменяет ответ на свою страницу-заглушку (/etc/caddy/maintenance/maintenance.html),
|
# подменяет ответ на страницу-заглушку (/etc/caddy/maintenance/maintenance.html) вместо
|
||||||
# вместо «зависшей загрузки» SPA. Файл заглушки — deploy/vps/maintenance.html.
|
# «зависшей загрузки» SPA. Тот же сниппет запрещает кэш HTML-документа (Cache-Control: no-store),
|
||||||
|
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
||||||
|
# Файл заглушки — deploy/vps/maintenance.html.
|
||||||
|
|
||||||
# Заглушка «сайт недоступен» при недоступности апстрима. Импортируется в каждый сайт.
|
# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима.
|
||||||
(offline) {
|
(edge) {
|
||||||
|
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||||
|
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
||||||
|
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
||||||
|
@html header Accept *text/html*
|
||||||
|
header @html Cache-Control "no-store"
|
||||||
|
|
||||||
|
# Туннель лёг (апстрим 502/503/504) → отдаём свою страницу-заглушку со статусом 503.
|
||||||
handle_errors {
|
handle_errors {
|
||||||
@down expression `{http.error.status_code} in [502, 503, 504]`
|
@down expression `{http.error.status_code} in [502, 503, 504]`
|
||||||
handle @down {
|
handle @down {
|
||||||
@@ -43,7 +52,7 @@ forbiddenstars.ru {
|
|||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 127.0.0.1:9000
|
reverse_proxy 127.0.0.1:9000
|
||||||
}
|
}
|
||||||
import offline
|
import edge
|
||||||
}
|
}
|
||||||
|
|
||||||
forbidden-stars.ru {
|
forbidden-stars.ru {
|
||||||
@@ -58,5 +67,5 @@ forbidden-stars.ru {
|
|||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 127.0.0.1:9001
|
reverse_proxy 127.0.0.1:9001
|
||||||
}
|
}
|
||||||
import offline
|
import edge
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AccountPage } from "../pages/AccountPage";
|
|||||||
import { CreateMatchPage } from "../pages/CreateMatchPage";
|
import { CreateMatchPage } from "../pages/CreateMatchPage";
|
||||||
import { GroupPage } from "../pages/GroupPage";
|
import { GroupPage } from "../pages/GroupPage";
|
||||||
import { GroupSettingsPage } from "../pages/GroupSettingsPage";
|
import { GroupSettingsPage } from "../pages/GroupSettingsPage";
|
||||||
|
import { HelpPage } from "../pages/HelpPage";
|
||||||
import { HomePage } from "../pages/HomePage";
|
import { HomePage } from "../pages/HomePage";
|
||||||
import { LoginPage } from "../pages/LoginPage";
|
import { LoginPage } from "../pages/LoginPage";
|
||||||
import { MatchDetailPage } from "../pages/MatchDetailPage";
|
import { MatchDetailPage } from "../pages/MatchDetailPage";
|
||||||
@@ -52,6 +53,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ path: "account", element: <AccountPage /> },
|
{ path: "account", element: <AccountPage /> },
|
||||||
|
{ path: "help", element: <HelpPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const TITLES: Record<string, string> = {
|
|||||||
"/account": "Аккаунт",
|
"/account": "Аккаунт",
|
||||||
"/match/new": "Новая партия",
|
"/match/new": "Новая партия",
|
||||||
"/stats": "Общая статистика",
|
"/stats": "Общая статистика",
|
||||||
|
"/help": "Справка",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LogOut } from "lucide-react";
|
import { HelpCircle, LogOut } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useLogout, useMe } from "../hooks/auth";
|
import { useLogout, useMe } from "../hooks/auth";
|
||||||
@@ -18,6 +18,11 @@ export function SideMenu({ open, onClose }: { open: boolean; onClose: () => void
|
|||||||
navigate("/account");
|
navigate("/account");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goHelp = () => {
|
||||||
|
onClose();
|
||||||
|
navigate("/help");
|
||||||
|
};
|
||||||
|
|
||||||
const doLogout = async () => {
|
const doLogout = async () => {
|
||||||
onClose();
|
onClose();
|
||||||
await logout.mutateAsync().catch(() => {});
|
await logout.mutateAsync().catch(() => {});
|
||||||
@@ -41,6 +46,9 @@ export function SideMenu({ open, onClose }: { open: boolean; onClose: () => void
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="side-menu-items">
|
<div className="side-menu-items">
|
||||||
|
<button className="side-menu-item" onClick={goHelp}>
|
||||||
|
<HelpCircle size={18} /> Справка
|
||||||
|
</button>
|
||||||
<button className="side-menu-item danger" onClick={doLogout}>
|
<button className="side-menu-item danger" onClick={doLogout}>
|
||||||
<LogOut size={18} /> Выйти
|
<LogOut size={18} /> Выйти
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Справка. Пока — только как считаются очки игроков (рейтинг). В дальнейшем сюда
|
||||||
|
* добавятся правила, подсказки и прочее. Текст соответствует backend/app/services/scoring.py.
|
||||||
|
*/
|
||||||
|
export function HelpPage() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="card">
|
||||||
|
<h2 style={{ margin: 0 }}>Справка</h2>
|
||||||
|
<p className="muted small" style={{ marginTop: 6 }}>
|
||||||
|
Здесь со временем появятся правила, подсказки и пояснения. Пока — о том, как
|
||||||
|
считается рейтинг игроков.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Очки игроков (рейтинг)</h3>
|
||||||
|
<p className="small">
|
||||||
|
Рейтинг считается только по <b>завершённым</b> партиям. Незавершённые и отменённые
|
||||||
|
в зачёт не идут.
|
||||||
|
</p>
|
||||||
|
<p className="small">
|
||||||
|
За каждую партию игрок получает «очки за место» — они нормированы по числу игроков
|
||||||
|
за столом, поэтому победа за большим столом ценится выше, чем за маленьким:
|
||||||
|
</p>
|
||||||
|
<div className="formula">
|
||||||
|
очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1)
|
||||||
|
</div>
|
||||||
|
<p className="small" style={{ marginTop: 10 }}>
|
||||||
|
где <b>N</b> — число игроков в партии. Первое место даёт <b>1.0</b>, последнее — <b>0.0</b>.
|
||||||
|
</p>
|
||||||
|
<ul className="small" style={{ marginTop: 0, paddingLeft: 18 }}>
|
||||||
|
<li>Если несколько игроков делят место (ничья) — они делят сумму очков поровну.</li>
|
||||||
|
<li>Выбывшие из партии считаются как последнее место.</li>
|
||||||
|
</ul>
|
||||||
|
<p className="small">
|
||||||
|
Итоговый <b>рейтинг</b> игрока = среднее «очков за место» по всем его партиям × 100
|
||||||
|
(шкала от 0 до 100).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Пример (стол на 4 игроков)</h3>
|
||||||
|
<div className="stack" style={{ gap: 6 }}>
|
||||||
|
<div className="row-between small"><span>1-е место</span><b>(4−1−0)/3 = 1.00 → 100</b></div>
|
||||||
|
<div className="row-between small"><span>2-е место</span><b>(4−2−0)/3 = 0.67 → 67</b></div>
|
||||||
|
<div className="row-between small"><span>3-е место</span><b>(4−3−0)/3 = 0.33 → 33</b></div>
|
||||||
|
<div className="row-between small"><span>4-е место</span><b>(4−4−0)/3 = 0.00 → 0</b></div>
|
||||||
|
<div className="row-between small">
|
||||||
|
<span>Ничья за 1-е (двое)</span><b>(4−1−0.5)/3 = 0.83 каждому</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Попадание в топ</h3>
|
||||||
|
<p className="small">
|
||||||
|
Чтобы попасть в ранжированный топ, нужно сыграть минимум <b>3 партии</b>. До этого
|
||||||
|
игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<h3>Другие показатели</h3>
|
||||||
|
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||||
|
<li><b>Винрейт</b> — доля партий, в которых игрок занял 1-е место.</li>
|
||||||
|
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
|
||||||
|
<li>
|
||||||
|
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
||||||
|
и затем по нику.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -94,6 +94,18 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
|
|
||||||
.error-text { color: var(--danger); font-size: 13px; }
|
.error-text { color: var(--danger); font-size: 13px; }
|
||||||
|
|
||||||
|
/* Формула в справке */
|
||||||
|
.formula {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
/* Модальное окно подтверждения */
|
/* Модальное окно подтверждения */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user