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.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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
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 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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ interface MissionSummary {
|
|||
description: string;
|
||||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: string;
|
||||
difficulty: 'easy' | 'medium' | 'hard';
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ProfileResponse>('/api/me', { authToken: token });
|
||||
const ranks = await apiFetch<RankResponse[]>('/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<ProfileResponse>('/api/me', { authToken: token }),
|
||||
apiFetch<ProgressResponse>('/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 (
|
||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
||||
<div>
|
||||
<ProgressOverview
|
||||
fullName={profile.full_name}
|
||||
xp={profile.xp}
|
||||
mana={profile.mana}
|
||||
rank={currentRank}
|
||||
competencies={profile.competencies}
|
||||
artifacts={profile.artifacts}
|
||||
nextRankTitle={nextRank?.title}
|
||||
xpProgress={progress}
|
||||
xpTarget={target}
|
||||
progress={progress}
|
||||
/>
|
||||
</div>
|
||||
<aside className="card">
|
||||
<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 style={{ marginTop: '1rem' }}>Доступ к HR-панели: {token ? 'есть (демо-режим)' : 'нет'}</p>
|
||||
<a className="primary" style={{ marginTop: '1rem', display: 'inline-block' }} href="/missions">
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@
|
|||
|
||||
import styled from 'styled-components';
|
||||
|
||||
type Rank = {
|
||||
id: number | null;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
// Компетенции и артефакты из профиля пользователя.
|
||||
type Competency = {
|
||||
competency: {
|
||||
id: number;
|
||||
|
|
@ -21,16 +17,35 @@ type Artifact = {
|
|||
rarity: string;
|
||||
};
|
||||
|
||||
// Мы получаем агрегированный прогресс от backend и пробрасываем его в компонент целиком.
|
||||
export interface ProfileProps {
|
||||
fullName: string;
|
||||
xp: number;
|
||||
mana: number;
|
||||
rank?: Rank;
|
||||
competencies: Competency[];
|
||||
artifacts: Artifact[];
|
||||
nextRankTitle?: string;
|
||||
xpProgress: number;
|
||||
xpTarget: number;
|
||||
progress: {
|
||||
current_rank: { title: string } | null;
|
||||
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`
|
||||
|
|
@ -38,6 +53,8 @@ const Card = styled.div`
|
|||
border-radius: 16px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(108, 92, 231, 0.4);
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
`;
|
||||
|
||||
const ProgressBar = styled.div<{ value: number }>`
|
||||
|
|
@ -57,60 +74,153 @@ const ProgressBar = styled.div<{ value: number }>`
|
|||
}
|
||||
`;
|
||||
|
||||
export function ProgressOverview({
|
||||
fullName,
|
||||
xp,
|
||||
mana,
|
||||
rank,
|
||||
competencies,
|
||||
artifacts,
|
||||
nextRankTitle,
|
||||
xpProgress,
|
||||
xpTarget
|
||||
}: ProfileProps) {
|
||||
const xpPercent = xpTarget > 0 ? Math.min(100, (xpProgress / xpTarget) * 100) : 100;
|
||||
const RequirementRow = styled.div<{ $completed?: boolean }>`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid ${({ $completed }) => ($completed ? 'rgba(0, 184, 148, 0.35)' : 'rgba(162, 155, 254, 0.25)')};
|
||||
background: ${({ $completed }) => ($completed ? 'rgba(0, 184, 148, 0.18)' : 'rgba(162, 155, 254, 0.12)')};
|
||||
`;
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<h2 style={{ marginTop: 0 }}>{fullName}</h2>
|
||||
<p style={{ color: 'var(--text-muted)' }}>Текущий ранг: {rank?.title ?? 'не назначен'}</p>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<strong>Опыт:</strong>
|
||||
<header>
|
||||
<h2 style={{ margin: 0 }}>{fullName}</h2>
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
Текущий ранг: {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} />
|
||||
{nextRankTitle ? (
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Осталось {Math.max(xpTarget - xpProgress, 0)} XP до ранга «{nextRankTitle}»
|
||||
</small>
|
||||
) : (
|
||||
<small style={{ color: 'var(--text-muted)' }}>Вы достигли максимального ранга в демо-версии</small>
|
||||
</section>
|
||||
|
||||
<section className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))' }}>
|
||||
<div>
|
||||
<SectionTitle>Ключевые миссии</SectionTitle>
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.25rem' }}>
|
||||
{progress.completed_missions}/{progress.total_missions} выполнено.
|
||||
</p>
|
||||
<ChecklistGrid>
|
||||
{progress.mission_requirements.length === 0 && (
|
||||
<RequirementRow $completed>
|
||||
<RequirementTitle>Все миссии для ранга уже зачтены.</RequirementTitle>
|
||||
</RequirementRow>
|
||||
)}
|
||||
{progress.mission_requirements.map((mission) => (
|
||||
<RequirementRow key={mission.mission_id} $completed={mission.is_completed}>
|
||||
<RequirementTitle>{mission.mission_title}</RequirementTitle>
|
||||
<InlineBadge $kind={mission.is_completed ? 'success' : 'warning'}>
|
||||
{mission.is_completed ? 'готово' : 'ожидает'}
|
||||
</InlineBadge>
|
||||
</RequirementRow>
|
||||
))}
|
||||
</ChecklistGrid>
|
||||
</div>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<strong>Мана:</strong>
|
||||
<p style={{ margin: '0.5rem 0' }}>{mana} ⚡</p>
|
||||
<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>
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<strong>Компетенции</strong>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
|
||||
<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={{ marginBottom: '0.25rem' }}>
|
||||
<span className="badge">{item.competency.name}</span> — уровень {item.level}
|
||||
<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>
|
||||
<div style={{ marginTop: '1.5rem' }}>
|
||||
<strong>Артефакты</strong>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionTitle>Артефакты</SectionTitle>
|
||||
<div className="grid" style={{ marginTop: '0.75rem' }}>
|
||||
{artifacts.length === 0 && <p>Ещё нет трофеев — выполните миссии!</p>}
|
||||
{artifacts.length === 0 && <p>Выполните миссии, чтобы собрать коллекцию трофеев.</p>}
|
||||
{artifacts.map((artifact) => (
|
||||
<div key={artifact.id} className="card">
|
||||
<div key={artifact.id} className="card" style={{ margin: 0 }}>
|
||||
<span className="badge">{artifact.rarity}</span>
|
||||
<h4 style={{ marginBottom: '0.5rem' }}>{artifact.name}</h4>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user