diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py index 06dab78..49b8371 100644 --- a/backend/app/api/routes/users.py +++ b/backend/app/api/routes/users.py @@ -9,8 +9,10 @@ from app.api.deps import get_current_user from app.db.session import get_db from app.models.rank import Rank from app.models.user import User +from app.schemas.progress import ProgressSnapshot from app.schemas.rank import RankBase from app.schemas.user import UserProfile +from app.services.rank import build_progress_snapshot router = APIRouter(prefix="/api", tags=["profile"]) @@ -35,3 +37,16 @@ def list_ranks( ranks = db.query(Rank).order_by(Rank.required_xp).all() return [RankBase.model_validate(rank) for rank in ranks] + + +@router.get("/progress", response_model=ProgressSnapshot, summary="Прогресс до следующего ранга") +def get_progress( + *, db: Session = Depends(get_db), current_user: User = Depends(get_current_user) +) -> ProgressSnapshot: + """Возвращаем агрегированную информацию о выполненных условиях следующего ранга.""" + + db.refresh(current_user) + _ = current_user.submissions + _ = current_user.competencies + snapshot = build_progress_snapshot(current_user, db) + return snapshot diff --git a/backend/app/core/config.py b/backend/app/core/config.py index c4b0a3f..469614e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -23,7 +23,11 @@ class Settings(BaseSettings): jwt_algorithm: str = "HS256" access_token_expire_minutes: int = 60 * 12 - backend_cors_origins: List[str] = ["http://localhost:3000", "http://frontend:3000"] + backend_cors_origins: List[str] = [ + "http://localhost:3000", + "http://frontend:3000", + "http://0.0.0.0:3000", + ] sqlite_path: Path = Path("/data/app.db") diff --git a/backend/app/schemas/progress.py b/backend/app/schemas/progress.py new file mode 100644 index 0000000..999ce91 --- /dev/null +++ b/backend/app/schemas/progress.py @@ -0,0 +1,59 @@ +"""Pydantic-схемы для отображения прогресса по рангу.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ProgressRank(BaseModel): + """Краткое описание ранга, пригодное для отображения на дашборде.""" + + id: int + title: str + description: str + required_xp: int + + class Config: + from_attributes = True + + +class ProgressXPMetrics(BaseModel): + """Статистика по опыту: от базового значения до целевого порога.""" + + baseline: int = Field(description="Количество XP, с которого начинается текущий этап прогресса") + current: int = Field(description="Текущее значение XP пользователя") + target: int = Field(description="Порог XP, необходимый для следующего ранга") + remaining: int = Field(description="Сколько XP осталось набрать") + progress_percent: float = Field(description="Прогресс на отрезке от baseline до target в долях единицы") + + +class ProgressMissionRequirement(BaseModel): + """Статус обязательной миссии для следующего ранга.""" + + mission_id: int + mission_title: str + is_completed: bool + + +class ProgressCompetencyRequirement(BaseModel): + """Статус требования по компетенции.""" + + competency_id: int + competency_name: str + required_level: int + current_level: int + is_met: bool + + +class ProgressSnapshot(BaseModel): + """Итоговая структура прогресса: XP, обязательные миссии и компетенции.""" + + current_rank: ProgressRank | None + next_rank: ProgressRank | None + xp: ProgressXPMetrics + mission_requirements: list[ProgressMissionRequirement] + competency_requirements: list[ProgressCompetencyRequirement] + completed_missions: int + total_missions: int + met_competencies: int + total_competencies: int diff --git a/backend/app/services/rank.py b/backend/app/services/rank.py index 559e554..e0449f5 100644 --- a/backend/app/services/rank.py +++ b/backend/app/services/rank.py @@ -2,13 +2,20 @@ from __future__ import annotations -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, selectinload from app.models.journal import JournalEventType from app.models.mission import SubmissionStatus -from app.models.rank import Rank +from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement from app.models.user import User from app.services.journal import log_event +from app.schemas.progress import ( + ProgressCompetencyRequirement, + ProgressMissionRequirement, + ProgressRank, + ProgressSnapshot, + ProgressXPMetrics, +) def _eligible_rank(user: User, db: Session) -> Rank | None: @@ -60,3 +67,106 @@ def apply_rank_upgrade(user: User, db: Session) -> Rank | None: payload={"previous_rank_id": previous_rank_id, "new_rank_id": new_rank.id}, ) return new_rank + + +def build_progress_snapshot(user: User, db: Session) -> ProgressSnapshot: + """Собираем агрегированное представление прогресса пользователя.""" + + ranks = ( + db.query(Rank) + .options( + selectinload(Rank.mission_requirements).selectinload(RankMissionRequirement.mission), + selectinload(Rank.competency_requirements).selectinload(RankCompetencyRequirement.competency), + ) + .order_by(Rank.required_xp) + .all() + ) + + current_rank_obj = next((rank for rank in ranks if rank.id == user.current_rank_id), None) + + approved_missions = { + submission.mission_id + for submission in user.submissions + if submission.status == SubmissionStatus.APPROVED + } + competency_levels = {item.competency_id: item.level for item in user.competencies} + + highest_met_rank: Rank | None = None + next_rank_obj: Rank | None = None + for rank in ranks: + missions_ok = all(req.mission_id in approved_missions for req in rank.mission_requirements) + competencies_ok = all( + competency_levels.get(req.competency_id, 0) >= req.required_level + for req in rank.competency_requirements + ) + xp_ok = user.xp >= rank.required_xp + + if xp_ok and missions_ok and competencies_ok: + highest_met_rank = rank + continue + + if next_rank_obj is None: + next_rank_obj = rank + break + + baseline_rank = current_rank_obj or highest_met_rank + baseline_xp = baseline_rank.required_xp if baseline_rank else 0 + + if next_rank_obj: + target_xp = next_rank_obj.required_xp + remaining_xp = max(0, target_xp - user.xp) + denominator = max(target_xp - baseline_xp, 1) + progress_percent = min(1.0, max(0.0, (user.xp - baseline_xp) / denominator)) + else: + target_xp = max(user.xp, baseline_xp) + remaining_xp = 0 + progress_percent = 1.0 + + mission_requirements: list[ProgressMissionRequirement] = [] + if next_rank_obj: + for requirement in next_rank_obj.mission_requirements: + title = requirement.mission.title if requirement.mission else f"Миссия #{requirement.mission_id}" + mission_requirements.append( + ProgressMissionRequirement( + mission_id=requirement.mission_id, + mission_title=title, + is_completed=requirement.mission_id in approved_missions, + ) + ) + + competency_requirements: list[ProgressCompetencyRequirement] = [] + if next_rank_obj: + for requirement in next_rank_obj.competency_requirements: + current_level = competency_levels.get(requirement.competency_id, 0) + competency_requirements.append( + ProgressCompetencyRequirement( + competency_id=requirement.competency_id, + competency_name=requirement.competency.name if requirement.competency else "", + required_level=requirement.required_level, + current_level=current_level, + is_met=current_level >= requirement.required_level, + ) + ) + + completed_missions = sum(1 for item in mission_requirements if item.is_completed) + met_competencies = sum(1 for item in competency_requirements if item.is_met) + + xp_metrics = ProgressXPMetrics( + baseline=baseline_xp, + current=user.xp, + target=target_xp, + remaining=remaining_xp, + progress_percent=round(progress_percent, 4), + ) + + return ProgressSnapshot( + current_rank=ProgressRank.model_validate(current_rank_obj) if current_rank_obj else None, + next_rank=ProgressRank.model_validate(next_rank_obj) if next_rank_obj else None, + xp=xp_metrics, + mission_requirements=mission_requirements, + competency_requirements=competency_requirements, + completed_missions=completed_missions, + total_missions=len(mission_requirements), + met_competencies=met_competencies, + total_competencies=len(competency_requirements), + ) diff --git a/backend/tests/test_rank_engine.py b/backend/tests/test_rank_engine.py index 3150820..e3a5df2 100644 --- a/backend/tests/test_rank_engine.py +++ b/backend/tests/test_rank_engine.py @@ -3,9 +3,9 @@ from __future__ import annotations from app.models.mission import Mission, MissionSubmission, SubmissionStatus -from app.models.rank import Rank, RankMissionRequirement -from app.models.user import User, UserRole -from app.services.rank import apply_rank_upgrade +from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement +from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole +from app.services.rank import apply_rank_upgrade, build_progress_snapshot def test_rank_upgrade_after_requirements(db_session): @@ -46,3 +46,74 @@ def test_rank_upgrade_after_requirements(db_session): assert new_rank is not None assert user.current_rank_id == pilot.id + + +def test_progress_snapshot_highlights_remaining_conditions(db_session): + """Снэпшот прогресса показывает, что ещё нужно закрыть.""" + + novice = Rank(title="Новичок", description="Старт", required_xp=0) + pilot = Rank(title="Пилот", description="Готов к полёту", required_xp=100) + mission = Mission(title="Тренировка", description="Базовое обучение", xp_reward=40, mana_reward=0) + competency = Competency( + name="Коммуникация", + description="Чёткая передача информации", + category=CompetencyCategory.COMMUNICATION, + ) + + db_session.add_all([novice, pilot, mission, competency]) + db_session.flush() + + db_session.add_all( + [ + RankMissionRequirement(rank_id=pilot.id, mission_id=mission.id), + RankCompetencyRequirement( + rank_id=pilot.id, + competency_id=competency.id, + required_level=1, + ), + ] + ) + + user = User( + email="progress@alabuga.space", + full_name="Прогресс Тест", + role=UserRole.PILOT, + hashed_password="hash", + xp=60, + current_rank_id=novice.id, + ) + db_session.add(user) + db_session.flush() + + db_session.add(UserCompetency(user_id=user.id, competency_id=competency.id, level=0)) + db_session.commit() + db_session.refresh(user) + + snapshot = build_progress_snapshot(user, db_session) + + assert snapshot.next_rank and snapshot.next_rank.title == "Пилот" + assert snapshot.xp.remaining == 40 + assert snapshot.completed_missions == 0 + assert snapshot.total_missions == 1 + assert snapshot.met_competencies == 0 + assert snapshot.total_competencies == 1 + + submission = MissionSubmission( + user_id=user.id, + mission_id=mission.id, + status=SubmissionStatus.APPROVED, + awarded_xp=mission.xp_reward, + ) + user.xp = 120 + user_competency = user.competencies[0] + user_competency.level = 2 + + db_session.add_all([submission, user, user_competency]) + db_session.commit() + db_session.refresh(user) + + snapshot_after = build_progress_snapshot(user, db_session) + + assert snapshot_after.completed_missions == snapshot_after.total_missions + assert snapshot_after.met_competencies == snapshot_after.total_competencies + assert snapshot_after.xp.remaining == 0 diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx index 30a032a..07b6f9d 100644 --- a/frontend/src/app/admin/page.tsx +++ b/frontend/src/app/admin/page.tsx @@ -23,7 +23,7 @@ interface MissionSummary { description: string; xp_reward: number; mana_reward: number; - difficulty: string; + difficulty: 'easy' | 'medium' | 'hard'; is_active: boolean; } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index d106052..33c69ce 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -6,7 +6,6 @@ interface ProfileResponse { full_name: string; xp: number; mana: number; - current_rank_id: number | null; competencies: Array<{ competency: { id: number; name: string }; level: number; @@ -18,54 +17,63 @@ interface ProfileResponse { }>; } -interface RankResponse { - id: number; - title: string; - description: string; - required_xp: number; +interface ProgressResponse { + current_rank: { id: number; title: string; description: string; required_xp: number } | null; + next_rank: { id: number; title: string; description: string; required_xp: number } | null; + xp: { + baseline: number; + current: number; + target: number; + remaining: number; + progress_percent: number; + }; + mission_requirements: Array<{ mission_id: number; mission_title: string; is_completed: boolean }>; + competency_requirements: Array<{ + competency_id: number; + competency_name: string; + required_level: number; + current_level: number; + is_met: boolean; + }>; + completed_missions: number; + total_missions: number; + met_competencies: number; + total_competencies: number; } async function fetchProfile() { const token = await getDemoToken(); - const profile = await apiFetch('/api/me', { authToken: token }); - const ranks = await apiFetch('/api/ranks', { authToken: token }); - const orderedRanks = [...ranks].sort((a, b) => a.required_xp - b.required_xp); - const currentIndex = Math.max( - orderedRanks.findIndex((rank) => rank.id === profile.current_rank_id), - 0 - ); - const currentRank = orderedRanks[currentIndex] ?? null; - const nextRank = orderedRanks[currentIndex + 1] ?? null; - const baselineXp = currentRank ? currentRank.required_xp : 0; - const progress = Math.max(profile.xp - baselineXp, 0); - const target = nextRank ? nextRank.required_xp - baselineXp : 0; + const [profile, progress] = await Promise.all([ + apiFetch('/api/me', { authToken: token }), + apiFetch('/api/progress', { authToken: token }) + ]); - return { token, profile, currentRank, nextRank, progress, target }; + return { token, profile, progress }; } export default async function DashboardPage() { - const { token, profile, currentRank, nextRank, progress, target } = await fetchProfile(); + const { token, profile, progress } = await fetchProfile(); return (
+ +
+
+ Ключевые миссии +

+ {progress.completed_missions}/{progress.total_missions} выполнено. +

+ + {progress.mission_requirements.length === 0 && ( + + Все миссии для ранга уже зачтены. + + )} + {progress.mission_requirements.map((mission) => ( + + {mission.mission_title} + + {mission.is_completed ? 'готово' : 'ожидает'} + + + ))} + +
+
+ Компетенции ранга +

+ {progress.met_competencies}/{progress.total_competencies} требований закрыто. +

+ + {progress.competency_requirements.length === 0 && ( + + Дополнительные требования к компетенциям отсутствуют. + + )} + {progress.competency_requirements.map((competency) => { + const percentage = competency.required_level + ? Math.min(100, (competency.current_level / competency.required_level) * 100) + : 100; + const delta = Math.max(0, competency.required_level - competency.current_level); + + return ( + +
+ {competency.competency_name} + + Уровень {competency.current_level} / {competency.required_level} + + +
+ + {competency.is_met ? 'готово' : `нужно +${delta}`} + +
+ ); + })} +
+
+
+ +
+
+ Мана экипажа +

{mana} ⚡

+ Тратьте в магазине на мерч и бонусы. +
+
+ Текущие компетенции +
    + {competencies.map((item) => ( +
  • + {item.competency.name} + уровень {item.level} +
  • + ))} + {competencies.length === 0 &&
  • Компетенции ещё не открыты.
  • } +
+
+
+ +
+ Артефакты
- {artifacts.length === 0 &&

Ещё нет трофеев — выполните миссии!

} + {artifacts.length === 0 &&

Выполните миссии, чтобы собрать коллекцию трофеев.

} {artifacts.map((artifact) => ( -
+
{artifact.rarity}

{artifact.name}

))}
-
+
); } diff --git a/frontend/src/components/admin/AdminMissionManager.tsx b/frontend/src/components/admin/AdminMissionManager.tsx index ca016c6..4813b48 100644 --- a/frontend/src/components/admin/AdminMissionManager.tsx +++ b/frontend/src/components/admin/AdminMissionManager.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FormEvent, useState } from 'react'; +import { FormEvent, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { apiFetch } from '../../lib/api'; @@ -108,6 +108,10 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + // Позволяет мгновенно подставлять базовые поля при переключении миссии, + // пока загрузка детальной карточки не завершилась. + const missionById = useMemo(() => new Map(missions.map((mission) => [mission.id, mission])), [missions]); + const resetForm = () => { setForm(initialFormState); }; @@ -160,6 +164,20 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen } const id = Number(value); + + const baseMission = missionById.get(id); + if (baseMission) { + setForm((prev) => ({ + ...prev, + title: baseMission.title, + description: baseMission.description, + xp_reward: baseMission.xp_reward, + mana_reward: baseMission.mana_reward, + difficulty: baseMission.difficulty, + is_active: baseMission.is_active + })); + } + setSelectedId(id); void loadMission(id); }; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index 84da2df..d94a25e 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -44,6 +44,8 @@ a { main { padding: 1.5rem; + max-width: 1200px; + margin: 0 auto; } .card { @@ -75,6 +77,18 @@ main { background: rgba(8, 11, 26, 0.6); padding: 0.75rem; color: var(--text); + width: 100%; +} + +.admin-form textarea { + min-height: 120px; + resize: vertical; +} + +.admin-form .checkbox { + flex-direction: row; + align-items: center; + gap: 0.5rem; } .grid {