From 60b22dd8df78e7e1b6009a4986ea54f9ca0a1181 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 16:17:43 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C:?= =?UTF-8?q?=20=D0=BB=D1=8E=D0=B1=D0=B8=D0=BC=D0=B0=D1=8F=20=D1=84=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=86=D0=B8=D1=8F=20=E2=80=94=20=D0=B2=D1=8B=D0=B1?= =?UTF-8?q?=D0=BE=D1=80=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0,=20=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B2=D1=8B=D0=B2=D0=BE=D0=B4=20=D0=B8=D0=B7?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше «любимая» вычислялась как самая частая среди партий с ручным выбором фракции. Теперь это поле профиля: users.favorite_faction_id (FK на factions, ON DELETE SET NULL, миграция 0010) и необязательное поле в PATCH /api/users/me/profile. Роутер профиля переведён на model_dump(exclude_unset=True) и обновляет только пришедшие поля — иначе сохранение одного «о себе» обнуляло бы выбранную фракцию. «Основная фракция» логику не меняет (она и так считалась по всем партиям, включая рандомные раздачи) и переезжает в подпись «Чаще всего играет на»: FactionStat получает name_ru_prepositional из словаря предложного падежа в faction_service. Словарь в коде, а не в БД: набор фракций задан игрой; фракция вне словаря выводится в именительном. Служебное chosen_games существовало только ради старой «любимой» — убрано из SQL вместе с ней. #17 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- .../versions/0010_user_favorite_faction.py | 46 ++++++++++ backend/app/models.py | 10 +++ backend/app/routers/users.py | 12 ++- backend/app/schemas/api.py | 12 ++- backend/app/services/faction_service.py | 24 ++++++ backend/app/services/stats_service.py | 40 +++++---- backend/app/services/user_service.py | 13 ++- backend/tests/test_profile.py | 83 +++++++++++++++---- 8 files changed, 207 insertions(+), 33 deletions(-) create mode 100644 backend/alembic/versions/0010_user_favorite_faction.py diff --git a/backend/alembic/versions/0010_user_favorite_faction.py b/backend/alembic/versions/0010_user_favorite_faction.py new file mode 100644 index 0000000..a5740ea --- /dev/null +++ b/backend/alembic/versions/0010_user_favorite_faction.py @@ -0,0 +1,46 @@ +"""Пользователь: любимая фракция (личный выбор в профиле). + +Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite). + +Revision ID: 0010_user_favorite_faction +Revises: 0009_participant_eliminated +Create Date: 2026-09-07 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0010_user_favorite_faction" +down_revision: Union[str, None] = "0009_participant_eliminated" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + if "favorite_faction_id" not in cols: + with op.batch_alter_table("users") as b: + b.add_column(sa.Column("favorite_faction_id", sa.Integer(), nullable=True)) + b.create_foreign_key( + "fk_users_favorite_faction_id", + "factions", + ["favorite_faction_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + cols = {c["name"] for c in insp.get_columns("users")} + if "favorite_faction_id" in cols: + with op.batch_alter_table("users") as b: + b.drop_constraint("fk_users_favorite_faction_id", type_="foreignkey") + b.drop_column("favorite_faction_id") diff --git a/backend/app/models.py b/backend/app/models.py index 478d619..445f093 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -97,6 +97,16 @@ class User(SQLModel, table=True): index=True, ) ) + # Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление + # по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан. + favorite_faction_id: int | None = Field( + default=None, + sa_column=Column( + Integer, + ForeignKey("factions.id", ondelete="SET NULL"), + nullable=True, + ), + ) is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1")) # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL # (выдача ачивок игрокам — следующий этап). diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 424a769..9f91354 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -31,6 +31,7 @@ def build_me(session: Session, user: User) -> s.MeRead: active_group_id=user.active_group_id, bio=user.bio, avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] + favorite_faction_id=user.favorite_faction_id, groups=groups, ) @@ -71,6 +72,7 @@ def update_me( active_group_id=user.active_group_id, bio=user.bio, avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] + favorite_faction_id=user.favorite_faction_id, ) @@ -81,14 +83,20 @@ def update_my_profile( session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.MeRead: - user_service.update_bio(session, user, body.bio) + # Обновляем только реально переданные поля: «О себе» и любимая фракция правятся + # по отдельности, и частичный запрос не должен обнулять соседнее поле. + changed = body.model_dump(exclude_unset=True) + if "bio" in changed: + user_service.update_bio(session, user, changed["bio"]) + if "favorite_faction_id" in changed: + user_service.update_favorite_faction(session, user, changed["favorite_faction_id"]) audit_service.record( session, actor_id=user.id, action="update", entity_type="user", entity_id=user.id, - payload={"bio": True}, + payload={key: True for key in changed}, ip=request.client.host if request.client else None, ) session.commit() diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 2403c2a..aeae3dd 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -90,6 +90,8 @@ class UserRead(BaseModel): active_group_id: int | None = None bio: str | None = None avatar_url: str | None = None + # Любимая фракция — выбор игрока (id справочника); None — не выбрана. + favorite_faction_id: int | None = None class MeRead(UserRead): @@ -101,7 +103,10 @@ class NicknameUpdate(BaseModel): class ProfileUpdate(BaseModel): + # Оба поля необязательны и обновляются, только если реально переданы + # (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию. bio: str | None = None + favorite_faction_id: int | None = None class ActiveGroupUpdate(BaseModel): @@ -311,6 +316,8 @@ class FactionStat(BaseModel): faction_id: int code: str name_ru: str + # Название в предложном падеже — для строки «Чаще всего играет на …». + name_ru_prepositional: str expansion_code: str games: int wins: int @@ -331,8 +338,9 @@ class ProfileStats(BaseModel): factions: list[FactionStat] = [] best_faction: FactionStat | None = None worst_faction: FactionStat | None = None - # Любимая — по партиям с ручным выбором фракции; основная — по всем (включая рандом). - most_played_faction: FactionStat | None = None + # Любимая — личный выбор игрока в профиле (не статистика). + favorite_faction: FactionRead | None = None + # «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи. main_faction: FactionStat | None = None recent_form: list[RecentFormItem] = [] # Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал diff --git a/backend/app/services/faction_service.py b/backend/app/services/faction_service.py index 8d2529d..7ccae4c 100644 --- a/backend/app/services/faction_service.py +++ b/backend/app/services/faction_service.py @@ -18,3 +18,27 @@ def list_factions(session: Session) -> list[Faction]: select(Faction).order_by(Faction.expansion_id, Faction.sort_order) ).all() ) + + +# Предложный падеж названий фракций для фразы «Чаще всего играет на …». +# Словарь в коде, а не колонка в БД: набор фракций задан игрой и не меняется. +# Фракция, добавленная админом мимо словаря, выводится в именительном падеже. +_PREPOSITIONAL: dict[str, str] = { + "orks": "Орках", + "ultramarines": "Ультрамаринах", + "eldar": "Эльдарах", + "chaos": "Хаоситах", + "astra_militarum": "Имперской гвардии", + "tau": "Тау", + "necrons": "Некронах", + "tyranids": "Тиранидах", + "inquisition": "Инквизиции", + "sisters_of_battle": "Сёстрах битвы", + "drukhari": "Друкхари", + "adeptus_mechanicus": "Адептус Механикус", +} + + +def prepositional(code: str, name_ru: str) -> str: + """Название фракции в предложном падеже; вне словаря — именительный как есть.""" + return _PREPOSITIONAL.get(code, name_ru) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 7424dc1..13eeb4f 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -7,8 +7,8 @@ from sqlalchemy import text from sqlmodel import Session, select from app.core.timeutil import iso_utc -from app.models import Group, GroupMember, Match -from app.services import group_service, membership_service, user_service +from app.models import Faction, Group, GroupMember, Match, User +from app.services import faction_service, group_service, membership_service, user_service from app.services.scoring import ( FACTION_MIN_GAMES, MATCH_POINTS_SQL, @@ -141,9 +141,8 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(s.place) AS avg_place, - AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика - -- «лучшая/худшая», сглаживание задавило бы её к 50) - SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games + AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика + -- «лучшая/худшая», сглаживание задавило бы её к 50) FROM scored s JOIN factions f ON f.id = s.faction_id JOIN expansions e ON e.id = f.expansion_id @@ -168,9 +167,9 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> "win_rate": _round(r["win_rate"] or 0.0, 4), "avg_place": _round(r["avg_place"], 2), "score": _round(r["score"], 1), - # Служебное (в API-схему не входит): партии, где фракция выбрана - # вручную, — для «любимой фракции» рандомные раздачи не в счёт. - "chosen_games": int(r["chosen_games"] or 0), + "name_ru_prepositional": faction_service.prepositional( + r["code"], r["name_ru"] + ), } ) return out @@ -196,17 +195,30 @@ def _recent_form(session: Session, user_id: int, group_id: int | None, limit: in ] +def _favorite_faction(session: Session, user_id: int) -> dict | None: + """Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики.""" + user = session.get(User, user_id) + if user is None or user.favorite_faction_id is None: + return None + faction = session.get(Faction, user.favorite_faction_id) + if faction is None: + return None + return { + "id": faction.id, + "code": faction.code, + "name_ru": faction.name_ru, + "expansion_id": faction.expansion_id, + } + + def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict: overall = _overall_for_user(session, user_id, group_id) factions = _faction_breakdown(session, user_id, group_id) qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None - # Любимая — по числу партий, где игрок выбрал фракцию САМ (was_random=false): - # рандомная раздача о предпочтениях не говорит. Нет ручных выборов — нет любимой. - chosen = [f for f in factions if f["chosen_games"] > 0] - most_played = max(chosen, key=lambda f: f["chosen_games"]) if chosen else None - # Основная — просто самая игранная, включая рандомные раздачи. + # «Чаще всего играет на» — самая игранная фракция по всей истории, включая + # рандомные раздачи. main = max(factions, key=lambda f: f["games"]) if factions else None return { "user_id": user_id, @@ -214,7 +226,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) - "factions": factions, "best_faction": best, "worst_faction": worst, - "most_played_faction": most_played, + "favorite_faction": _favorite_faction(session, user_id), "main_faction": main, "recent_form": _recent_form(session, user_id, group_id), "min_games": MIN_GAMES, diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 13d7916..2dd6831 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -11,7 +11,7 @@ from sqlmodel import Session, select from app.auth.provider import ExternalIdentity from app.core.config import settings from app.core.errors import NicknameTakenError, NotFoundError, ValidationError -from app.models import AuthIdentity, GroupMember, User +from app.models import AuthIdentity, Faction, GroupMember, User _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _BIO_MAX = 500 @@ -185,6 +185,17 @@ def update_bio(session: Session, user: User, bio: str | None) -> User: return user +def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User: + """Любимая фракция — личный выбор игрока; None очищает выбор.""" + if faction_id is not None and session.get(Faction, faction_id) is None: + raise ValidationError("Такой фракции не существует.") + user.favorite_faction_id = faction_id + session.add(user) + session.commit() + session.refresh(user) + return user + + def sniff_image_ext(content: bytes) -> str | None: """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" if content.startswith(b"\x89PNG\r\n\x1a\n"): diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index fb62917..2f346af 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -1,4 +1,5 @@ -"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль.""" +"""Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление), +публичный профиль.""" from __future__ import annotations from fastapi.testclient import TestClient @@ -126,8 +127,8 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка -def test_most_played_faction_ignores_random(client: TestClient, engine): - """Любимая фракция — только по сознательному выбору: рандомные раздачи не в счёт.""" +def test_main_faction_counts_random_games(client: TestClient, engine): + """«Чаще всего играет на» — по всей истории, включая рандомные раздачи.""" me = login(client, "Фракционер") exps = [e["id"] for e in client.get("/api/expansions").json()] gid = client.post( @@ -156,17 +157,15 @@ def test_most_played_faction_ignores_random(client: TestClient, engine): ) stats = client.get("/api/users/me/stats").json() - assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше - assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям - - # У соперника все фракции выбраны вручную — любимая считается как раньше. - login(client, "Партнёр") - stats2 = client.get("/api/users/me/stats").json() - assert stats2["most_played_faction"]["faction_id"] == fids[1] + # Условия выбора не важны: A сыграна чаще, пусть и по рандому. + assert stats["main_faction"]["faction_id"] == fids[0] + assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена + # Любимая больше не выводится из истории — она задаётся игроком вручную. + assert stats["favorite_faction"] is None -def test_most_played_faction_absent_when_all_random(client: TestClient, engine): - """Если игрок ни разу не выбирал фракцию сам — любимой нет вовсе.""" +def test_main_faction_present_for_random_only_player(client: TestClient, engine): + """Игроку, который всегда играет рандомом, «чаще всего играет на» всё равно считается.""" me = login(client, "Рандомщик") exps = [e["id"] for e in client.get("/api/expansions").json()] gid = client.post( @@ -183,5 +182,61 @@ def test_most_played_faction_absent_when_all_random(client: TestClient, engine): ], ) stats = client.get("/api/users/me/stats").json() - assert stats["most_played_faction"] is None - assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика + assert stats["main_faction"]["faction_id"] == fids[0] + + +def test_favorite_faction_set_and_clear(client: TestClient): + """Любимая фракция — личный выбор игрока: сохраняется и снимается.""" + login(client, "Выбирающий") + fid = client.get("/api/factions").json()[0]["id"] + + r = client.patch( + "/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + assert r.json()["favorite_faction_id"] == fid + assert client.get("/api/users/me").json()["favorite_faction_id"] == fid + # Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики. + assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid + + r = client.patch( + "/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client) + ) + assert r.status_code == 200 + assert r.json()["favorite_faction_id"] is None + assert client.get("/api/users/me/stats").json()["favorite_faction"] is None + + +def test_favorite_faction_rejects_unknown(client: TestClient): + login(client, "Фантазёр") + r = client.patch( + "/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client) + ) + assert r.status_code == 422 + + +def test_bio_update_keeps_favorite_faction(client: TestClient): + """Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию.""" + login(client, "Аккуратный") + fid = client.get("/api/factions").json()[0]["id"] + client.patch( + "/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client) + ) + + r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client)) + assert r.status_code == 200 + assert r.json()["bio"] == "Только био" + assert r.json()["favorite_faction_id"] == fid + + +def test_prepositional_dictionary_and_fallback(): + """Падежная форма берётся из словаря; незнакомый код — именительный как есть.""" + from app.services import faction_service + + assert faction_service.prepositional("orks", "Орки") == "Орках" + assert ( + faction_service.prepositional("astra_militarum", "Имперская гвардия") + == "Имперской гвардии" + ) + # Фракция, заведённая админом мимо словаря, не роняет вывод. + assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы" -- 2.54.0 From befabba752c107a0fa38c6bb2e1bad4236fe7a74 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 16:17:52 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C:?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BB=D1=8E=D0=B1=D0=B8?= =?UTF-8?q?=D0=BC=D0=BE=D0=B9=20=D1=84=D1=80=D0=B0=D0=BA=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B8=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=C2=AB=D0=A7?= =?UTF-8?q?=D0=B0=D1=89=D0=B5=20=D0=B2=D1=81=D0=B5=D0=B3=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В кастомизации профиля появился выбор любимой фракции из полного справочника (PickerSelect, все 12 фракций): фракция — свойство игрока, а не группы, поэтому список не зависит от подключённых дополнений. Дополнение показано в строке опции приглушённым текстом, снятие выбора — кнопкой «Убрать». В карточке статистики «Любимая фракция» теперь берётся из профиля, а «Основная фракция» переименована в «Чаще всего играет на» и печатает название в предложном падеже. Публичный профиль правок не потребовал — он использует ту же карточку и ту же схему. schema.d.ts пересобран с живого бэкенда (npm run gen:api). #17 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/api/schema.d.ts | 10 +++- frontend/src/components/ProfileStatsCard.tsx | 9 ++-- frontend/src/hooks/auth.ts | 20 ++++++++ frontend/src/hooks/reference.ts | 8 ++++ frontend/src/pages/AccountPage.tsx | 50 ++++++++++++++++++++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index ff8fb94..c5830ac 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1357,6 +1357,8 @@ export interface components { code: string; /** Name Ru */ name_ru: string; + /** Name Ru Prepositional */ + name_ru_prepositional: string; /** Expansion Code */ expansion_code: string; /** Games */ @@ -1761,6 +1763,8 @@ export interface components { bio?: string | null; /** Avatar Url */ avatar_url?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; /** * Groups * @default [] @@ -1881,7 +1885,7 @@ export interface components { factions: components["schemas"]["FactionStat"][]; best_faction?: components["schemas"]["FactionStat"] | null; worst_faction?: components["schemas"]["FactionStat"] | null; - most_played_faction?: components["schemas"]["FactionStat"] | null; + favorite_faction?: components["schemas"]["FactionRead"] | null; main_faction?: components["schemas"]["FactionStat"] | null; /** * Recent Form @@ -1898,6 +1902,8 @@ export interface components { ProfileUpdate: { /** Bio */ bio?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; }; /** PublicProfile */ PublicProfile: { @@ -2004,6 +2010,8 @@ export interface components { bio?: string | null; /** Avatar Url */ avatar_url?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; }; /** * UserSuggestion diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index adc1de7..496d9e2 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -38,16 +38,17 @@ export function ProfileStatsCard({
Очки (рейтинг)
{o.score ?? "—"} - {stats.most_played_faction && ( + {/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */} + {stats.favorite_faction && (
Любимая фракция
- {stats.most_played_faction.name_ru} + {stats.favorite_faction.name_ru}
)} {stats.main_faction && (
-
Основная фракция
- {stats.main_faction.name_ru} +
Чаще всего играет на
+ {stats.main_faction.name_ru_prepositional}
)} diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index aef11de..4c36ee8 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -117,6 +117,26 @@ export function useUpdateBio() { }); } +// Любимая фракция — личный выбор игрока (в статистике не вычисляется). null снимает выбор. +// Шлём только это поле: роутер обновляет лишь пришедшие, «о себе» не затирается. +export function useUpdateFavoriteFaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (factionId: number | null): Promise => + unwrap( + await api.PATCH("/api/users/me/profile", { + body: { favorite_faction_id: factionId }, + }), + ), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + // Карточка статистики показывает любимую фракцию — её тоже перезапрашиваем. + qc.invalidateQueries({ queryKey: qk.myStats }); + qc.invalidateQueries({ queryKey: qk.home }); + }, + }); +} + // Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным // fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. function readCsrfToken(): string | null { diff --git a/frontend/src/hooks/reference.ts b/frontend/src/hooks/reference.ts index 7f5133d..0b8abca 100644 --- a/frontend/src/hooks/reference.ts +++ b/frontend/src/hooks/reference.ts @@ -10,6 +10,14 @@ export function useExpansions() { }); } +// Весь справочник фракций (для выбора любимой в профиле — он не зависит от группы). +export function useFactions() { + return useQuery({ + queryKey: qk.factions, + queryFn: async () => unwrap(await api.GET("/api/factions")), + }); +} + export function useGroupFactions(groupId: number | null) { return useQuery({ queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"], diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 14aac89..0914290 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; import { Avatar } from "../components/Avatar"; +import { PickerSelect } from "../components/PickerSelect"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; import { useToast } from "../context/ToastContext"; @@ -11,9 +12,11 @@ import { useLogout, useMe, useUpdateBio, + useUpdateFavoriteFaction, useUpdateNickname, useUploadAvatar, } from "../hooks/auth"; +import { useExpansions, useFactions } from "../hooks/reference"; import { useMyStats } from "../hooks/stats"; export function AccountPage() { @@ -21,6 +24,9 @@ export function AccountPage() { const { data: stats } = useMyStats(); const updateNick = useUpdateNickname(); const updateBio = useUpdateBio(); + const updateFavorite = useUpdateFavoriteFaction(); + const { data: factions } = useFactions(); + const { data: expansions } = useExpansions(); const uploadAvatar = useUploadAvatar(); const deleteAvatar = useDeleteAvatar(); const logout = useLogout(); @@ -35,6 +41,20 @@ export function AccountPage() { // bio редактируется локально; до первого ввода берём значение из профиля. const bioValue = bio ?? me.bio ?? ""; + // Любимая фракция выбирается из ПОЛНОГО справочника: это свойство игрока, а не + // группы, и не должно зависеть от подключённых у неё дополнений. + const favorite = factions?.find((f) => f.id === me.favorite_faction_id) ?? null; + const expansionName = (id: number) => expansions?.find((e) => e.id === id)?.name_ru ?? ""; + + const saveFavorite = async (factionId: number | null) => { + try { + await updateFavorite.mutateAsync(factionId); + toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + const saveNick = async () => { const nick = nickname.trim(); if (!nick) return; @@ -140,6 +160,36 @@ export function AccountPage() { +
+ +
+ ( + <> + {f.name_ru} + + {expansionName(f.expansion_id)} + + + )} + renderValue={(f) => {f.name_ru}} + onPick={(f) => saveFavorite(f.id)} + /> + {favorite && ( + + )} +
+
+
-- 2.54.0