CORS is not working
This commit is contained in:
parent
8345614c8d
commit
6d84504569
|
|
@ -9,8 +9,10 @@ from app.api.deps import get_current_user
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.models.rank import Rank
|
from app.models.rank import Rank
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.schemas.progress import ProgressSnapshot
|
||||||
from app.schemas.rank import RankBase
|
from app.schemas.rank import RankBase
|
||||||
from app.schemas.user import UserProfile
|
from app.schemas.user import UserProfile
|
||||||
|
from app.services.rank import build_progress_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["profile"])
|
router = APIRouter(prefix="/api", tags=["profile"])
|
||||||
|
|
||||||
|
|
@ -35,3 +37,16 @@ def list_ranks(
|
||||||
|
|
||||||
ranks = db.query(Rank).order_by(Rank.required_xp).all()
|
ranks = db.query(Rank).order_by(Rank.required_xp).all()
|
||||||
return [RankBase.model_validate(rank) for rank in ranks]
|
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
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ class Settings(BaseSettings):
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 60 * 12
|
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")
|
sqlite_path: Path = Path("/data/app.db")
|
||||||
|
|
||||||
|
|
|
||||||
59
backend/app/schemas/progress.py
Normal file
59
backend/app/schemas/progress.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -2,13 +2,20 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, selectinload
|
||||||
|
|
||||||
from app.models.journal import JournalEventType
|
from app.models.journal import JournalEventType
|
||||||
from app.models.mission import SubmissionStatus
|
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.models.user import User
|
||||||
from app.services.journal import log_event
|
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:
|
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},
|
payload={"previous_rank_id": previous_rank_id, "new_rank_id": new_rank.id},
|
||||||
)
|
)
|
||||||
return new_rank
|
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),
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||||
from app.models.rank import Rank, RankMissionRequirement
|
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||||
from app.models.user import User, UserRole
|
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
|
||||||
from app.services.rank import apply_rank_upgrade
|
from app.services.rank import apply_rank_upgrade, build_progress_snapshot
|
||||||
|
|
||||||
|
|
||||||
def test_rank_upgrade_after_requirements(db_session):
|
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 new_rank is not None
|
||||||
assert user.current_rank_id == pilot.id
|
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
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ interface MissionSummary {
|
||||||
description: string;
|
description: string;
|
||||||
xp_reward: number;
|
xp_reward: number;
|
||||||
mana_reward: number;
|
mana_reward: number;
|
||||||
difficulty: string;
|
difficulty: 'easy' | 'medium' | 'hard';
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ interface ProfileResponse {
|
||||||
full_name: string;
|
full_name: string;
|
||||||
xp: number;
|
xp: number;
|
||||||
mana: number;
|
mana: number;
|
||||||
current_rank_id: number | null;
|
|
||||||
competencies: Array<{
|
competencies: Array<{
|
||||||
competency: { id: number; name: string };
|
competency: { id: number; name: string };
|
||||||
level: number;
|
level: number;
|
||||||
|
|
@ -18,54 +17,63 @@ interface ProfileResponse {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RankResponse {
|
interface ProgressResponse {
|
||||||
id: number;
|
current_rank: { id: number; title: string; description: string; required_xp: number } | null;
|
||||||
title: string;
|
next_rank: { id: number; title: string; description: string; required_xp: number } | null;
|
||||||
description: string;
|
xp: {
|
||||||
required_xp: number;
|
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() {
|
async function fetchProfile() {
|
||||||
const token = await getDemoToken();
|
const token = await getDemoToken();
|
||||||
const profile = await apiFetch<ProfileResponse>('/api/me', { authToken: token });
|
const [profile, progress] = await Promise.all([
|
||||||
const ranks = await apiFetch<RankResponse[]>('/api/ranks', { authToken: token });
|
apiFetch<ProfileResponse>('/api/me', { authToken: token }),
|
||||||
const orderedRanks = [...ranks].sort((a, b) => a.required_xp - b.required_xp);
|
apiFetch<ProgressResponse>('/api/progress', { authToken: token })
|
||||||
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;
|
|
||||||
|
|
||||||
return { token, profile, currentRank, nextRank, progress, target };
|
return { token, profile, progress };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const { token, profile, currentRank, nextRank, progress, target } = await fetchProfile();
|
const { token, profile, progress } = await fetchProfile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
||||||
<div>
|
<div>
|
||||||
<ProgressOverview
|
<ProgressOverview
|
||||||
fullName={profile.full_name}
|
fullName={profile.full_name}
|
||||||
xp={profile.xp}
|
|
||||||
mana={profile.mana}
|
mana={profile.mana}
|
||||||
rank={currentRank}
|
|
||||||
competencies={profile.competencies}
|
competencies={profile.competencies}
|
||||||
artifacts={profile.artifacts}
|
artifacts={profile.artifacts}
|
||||||
nextRankTitle={nextRank?.title}
|
progress={progress}
|
||||||
xpProgress={progress}
|
|
||||||
xpTarget={target}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<aside className="card">
|
<aside className="card">
|
||||||
<h3>Ближайшая цель</h3>
|
<h3>Ближайшая цель</h3>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>
|
<p style={{ color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
Выполните миссии ветки «Получение оффера», чтобы закрепиться в экипаже и открыть доступ к
|
Следующий рубеж: {progress.next_rank ? `ранг «${progress.next_rank.title}»` : 'вы на максимальном ранге демо-версии'}.
|
||||||
сложным задачам.
|
Закройте ключевые миссии и подтяните компетенции, чтобы взять оффер.
|
||||||
|
</p>
|
||||||
|
<p style={{ marginTop: '1rem' }}>
|
||||||
|
Осталось {progress.xp.remaining} XP · {progress.completed_missions}/{progress.total_missions} миссий ·{' '}
|
||||||
|
{progress.met_competencies}/{progress.total_competencies} компетенций.
|
||||||
</p>
|
</p>
|
||||||
<p style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p>
|
<p style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p>
|
||||||
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">
|
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,7 @@
|
||||||
|
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
type Rank = {
|
// Компетенции и артефакты из профиля пользователя.
|
||||||
id: number | null;
|
|
||||||
title?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Competency = {
|
type Competency = {
|
||||||
competency: {
|
competency: {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -21,16 +17,35 @@ type Artifact = {
|
||||||
rarity: string;
|
rarity: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
||||||
export interface ProfileProps {
|
export interface ProfileProps {
|
||||||
fullName: string;
|
fullName: string;
|
||||||
xp: number;
|
|
||||||
mana: number;
|
mana: number;
|
||||||
rank?: Rank;
|
|
||||||
competencies: Competency[];
|
competencies: Competency[];
|
||||||
artifacts: Artifact[];
|
artifacts: Artifact[];
|
||||||
nextRankTitle?: string;
|
progress: {
|
||||||
xpProgress: number;
|
current_rank: { title: string } | null;
|
||||||
xpTarget: number;
|
next_rank: { title: string } | 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;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const Card = styled.div`
|
const Card = styled.div`
|
||||||
|
|
@ -38,6 +53,8 @@ const Card = styled.div`
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
border: 1px solid rgba(108, 92, 231, 0.4);
|
border: 1px solid rgba(108, 92, 231, 0.4);
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ProgressBar = styled.div<{ value: number }>`
|
const ProgressBar = styled.div<{ value: number }>`
|
||||||
|
|
@ -57,60 +74,153 @@ const ProgressBar = styled.div<{ value: number }>`
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function ProgressOverview({
|
const RequirementRow = styled.div<{ $completed?: boolean }>`
|
||||||
fullName,
|
display: flex;
|
||||||
xp,
|
justify-content: space-between;
|
||||||
mana,
|
align-items: flex-start;
|
||||||
rank,
|
gap: 1rem;
|
||||||
competencies,
|
padding: 0.75rem 1rem;
|
||||||
artifacts,
|
border-radius: 12px;
|
||||||
nextRankTitle,
|
border: 1px solid ${({ $completed }) => ($completed ? 'rgba(0, 184, 148, 0.35)' : 'rgba(162, 155, 254, 0.25)')};
|
||||||
xpProgress,
|
background: ${({ $completed }) => ($completed ? 'rgba(0, 184, 148, 0.18)' : 'rgba(162, 155, 254, 0.12)')};
|
||||||
xpTarget
|
`;
|
||||||
}: ProfileProps) {
|
|
||||||
const xpPercent = xpTarget > 0 ? Math.min(100, (xpProgress / xpTarget) * 100) : 100;
|
const RequirementTitle = styled.span`
|
||||||
|
font-weight: 500;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChecklistGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled.h3`
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InlineBadge = styled.span<{ $kind?: 'success' | 'warning' }>`
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: ${({ $kind }) => ($kind === 'success' ? 'rgba(0, 184, 148, 0.25)' : 'rgba(255, 118, 117, 0.18)')};
|
||||||
|
color: ${({ $kind }) => ($kind === 'success' ? '#55efc4' : '#ff7675')};
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function ProgressOverview({ fullName, mana, competencies, artifacts, progress }: ProfileProps) {
|
||||||
|
const xpPercent = Math.round(progress.xp.progress_percent * 100);
|
||||||
|
const hasNextRank = Boolean(progress.next_rank);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<h2 style={{ marginTop: 0 }}>{fullName}</h2>
|
<header>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>Текущий ранг: {rank?.title ?? 'не назначен'}</p>
|
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
<strong>Опыт:</strong>
|
Текущий ранг: {progress.current_rank?.title ?? 'не назначен'} · Цель:{' '}
|
||||||
|
{hasNextRank ? `«${progress.next_rank?.title}»` : 'достигнут максимум в демо'}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionTitle>Опыт до следующего ранга</SectionTitle>
|
||||||
|
<p style={{ margin: '0.25rem 0', color: 'var(--text-muted)' }}>
|
||||||
|
{progress.xp.current} XP из {progress.xp.target} · осталось {progress.xp.remaining} XP
|
||||||
|
</p>
|
||||||
<ProgressBar value={xpPercent} />
|
<ProgressBar value={xpPercent} />
|
||||||
{nextRankTitle ? (
|
</section>
|
||||||
<small style={{ color: 'var(--text-muted)' }}>
|
|
||||||
Осталось {Math.max(xpTarget - xpProgress, 0)} XP до ранга «{nextRankTitle}»
|
<section className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
||||||
</small>
|
<div>
|
||||||
) : (
|
<SectionTitle>Ключевые миссии</SectionTitle>
|
||||||
<small style={{ color: 'var(--text-muted)' }}>Вы достигли максимального ранга в демо-версии</small>
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
)}
|
{progress.completed_missions}/{progress.total_missions} выполнено.
|
||||||
</div>
|
</p>
|
||||||
<div style={{ marginTop: '1rem' }}>
|
<ChecklistGrid>
|
||||||
<strong>Мана:</strong>
|
{progress.mission_requirements.length === 0 && (
|
||||||
<p style={{ margin: '0.5rem 0' }}>{mana} ⚡</p>
|
<RequirementRow $completed>
|
||||||
</div>
|
<RequirementTitle>Все миссии для ранга уже зачтены.</RequirementTitle>
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
</RequirementRow>
|
||||||
<strong>Компетенции</strong>
|
)}
|
||||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
|
{progress.mission_requirements.map((mission) => (
|
||||||
{competencies.map((item) => (
|
<RequirementRow key={mission.mission_id} $completed={mission.is_completed}>
|
||||||
<li key={item.competency.id} style={{ marginBottom: '0.25rem' }}>
|
<RequirementTitle>{mission.mission_title}</RequirementTitle>
|
||||||
<span className="badge">{item.competency.name}</span> — уровень {item.level}
|
<InlineBadge $kind={mission.is_completed ? 'success' : 'warning'}>
|
||||||
</li>
|
{mission.is_completed ? 'готово' : 'ожидает'}
|
||||||
))}
|
</InlineBadge>
|
||||||
</ul>
|
</RequirementRow>
|
||||||
</div>
|
))}
|
||||||
<div style={{ marginTop: '1.5rem' }}>
|
</ChecklistGrid>
|
||||||
<strong>Артефакты</strong>
|
</div>
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Компетенции ранга</SectionTitle>
|
||||||
|
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||||
|
{progress.met_competencies}/{progress.total_competencies} требований закрыто.
|
||||||
|
</p>
|
||||||
|
<ChecklistGrid>
|
||||||
|
{progress.competency_requirements.length === 0 && (
|
||||||
|
<RequirementRow $completed>
|
||||||
|
<RequirementTitle>Дополнительные требования к компетенциям отсутствуют.</RequirementTitle>
|
||||||
|
</RequirementRow>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<RequirementRow key={competency.competency_id} $completed={competency.is_met}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<RequirementTitle>{competency.competency_name}</RequirementTitle>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>
|
||||||
|
Уровень {competency.current_level} / {competency.required_level}
|
||||||
|
</small>
|
||||||
|
<ProgressBar value={percentage} />
|
||||||
|
</div>
|
||||||
|
<InlineBadge $kind={competency.is_met ? 'success' : 'warning'}>
|
||||||
|
{competency.is_met ? 'готово' : `нужно +${delta}`}
|
||||||
|
</InlineBadge>
|
||||||
|
</RequirementRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ChecklistGrid>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))' }}>
|
||||||
|
<div className="card" style={{ margin: 0 }}>
|
||||||
|
<SectionTitle>Мана экипажа</SectionTitle>
|
||||||
|
<p style={{ marginTop: '0.5rem', fontSize: '1.75rem', fontWeight: 600 }}>{mana} ⚡</p>
|
||||||
|
<small style={{ color: 'var(--text-muted)' }}>Тратьте в магазине на мерч и бонусы.</small>
|
||||||
|
</div>
|
||||||
|
<div className="card" style={{ margin: 0 }}>
|
||||||
|
<SectionTitle>Текущие компетенции</SectionTitle>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '0.75rem 0 0', display: 'grid', gap: '0.5rem' }}>
|
||||||
|
{competencies.map((item) => (
|
||||||
|
<li key={item.competency.id} style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span className="badge">{item.competency.name}</span>
|
||||||
|
<span>уровень {item.level}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{competencies.length === 0 && <li style={{ color: 'var(--text-muted)' }}>Компетенции ещё не открыты.</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<SectionTitle>Артефакты</SectionTitle>
|
||||||
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
||||||
{artifacts.length === 0 && <p>Ещё нет трофеев — выполните миссии!</p>}
|
{artifacts.length === 0 && <p>Выполните миссии, чтобы собрать коллекцию трофеев.</p>}
|
||||||
{artifacts.map((artifact) => (
|
{artifacts.map((artifact) => (
|
||||||
<div key={artifact.id} className="card">
|
<div key={artifact.id} className="card" style={{ margin: 0 }}>
|
||||||
<span className="badge">{artifact.rarity}</span>
|
<span className="badge">{artifact.rarity}</span>
|
||||||
<h4 style={{ marginBottom: '0.5rem' }}>{artifact.name}</h4>
|
<h4 style={{ marginBottom: '0.5rem' }}>{artifact.name}</h4>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { FormEvent, useState } from 'react';
|
import { FormEvent, useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import { apiFetch } from '../../lib/api';
|
import { apiFetch } from '../../lib/api';
|
||||||
|
|
@ -108,6 +108,10 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Позволяет мгновенно подставлять базовые поля при переключении миссии,
|
||||||
|
// пока загрузка детальной карточки не завершилась.
|
||||||
|
const missionById = useMemo(() => new Map(missions.map((mission) => [mission.id, mission])), [missions]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setForm(initialFormState);
|
setForm(initialFormState);
|
||||||
};
|
};
|
||||||
|
|
@ -160,6 +164,20 @@ export function AdminMissionManager({ token, missions, branches, ranks, competen
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = Number(value);
|
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);
|
setSelectedId(id);
|
||||||
void loadMission(id);
|
void loadMission(id);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ a {
|
||||||
|
|
||||||
main {
|
main {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
|
|
@ -75,6 +77,18 @@ main {
|
||||||
background: rgba(8, 11, 26, 0.6);
|
background: rgba(8, 11, 26, 0.6);
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
color: var(--text);
|
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 {
|
.grid {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user