Add creat missiom
This commit is contained in:
parent
ac7ead09c9
commit
89734706b8
|
|
@ -83,7 +83,13 @@ pytest
|
|||
- Начисление опыта, маны и повышение ранга по трём условиям из ТЗ.
|
||||
- Журнал событий, экспортируемый через API.
|
||||
- Магазин артефактов с оформлением заказа.
|
||||
- Админ-панель для HR: создание миссий, очередь модерации, список рангов.
|
||||
- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
|
||||
|
||||
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
||||
|
||||
- создавать и редактировать ветки миссий с категориями и порядком;
|
||||
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
|
||||
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
|
||||
|
||||
## План развития
|
||||
|
||||
|
|
|
|||
|
|
@ -3,74 +3,47 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import require_hr
|
||||
from app.db.session import get_db
|
||||
from app.models.branch import BranchMission
|
||||
from app.models.mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission, SubmissionStatus
|
||||
from app.models.rank import Rank
|
||||
from app.schemas.mission import MissionBase, MissionCreate, MissionDetail, MissionSubmissionRead
|
||||
from app.schemas.rank import RankBase
|
||||
from app.models.artifact import Artifact
|
||||
from app.models.branch import Branch, BranchMission
|
||||
from app.models.mission import (
|
||||
Mission,
|
||||
MissionCompetencyReward,
|
||||
MissionPrerequisite,
|
||||
MissionSubmission,
|
||||
SubmissionStatus,
|
||||
)
|
||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||
from app.models.user import Competency
|
||||
from app.schemas.artifact import ArtifactRead
|
||||
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
||||
from app.schemas.mission import (
|
||||
MissionBase,
|
||||
MissionCreate,
|
||||
MissionDetail,
|
||||
MissionSubmissionRead,
|
||||
MissionUpdate,
|
||||
)
|
||||
from app.schemas.rank import (
|
||||
RankBase,
|
||||
RankCreate,
|
||||
RankDetailed,
|
||||
RankRequirementCompetency,
|
||||
RankRequirementMission,
|
||||
RankUpdate,
|
||||
)
|
||||
from app.schemas.user import CompetencyBase
|
||||
from app.services.mission import approve_submission, reject_submission
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/missions", response_model=list[MissionBase], summary="Миссии (HR)")
|
||||
def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[MissionBase]:
|
||||
"""Список всех миссий для HR."""
|
||||
|
||||
missions = db.query(Mission).order_by(Mission.title).all()
|
||||
return [MissionBase.model_validate(mission) for mission in missions]
|
||||
|
||||
|
||||
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
|
||||
def create_mission_endpoint(
|
||||
mission_in: MissionCreate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> MissionDetail:
|
||||
"""Создаём новую миссию."""
|
||||
|
||||
mission = Mission(
|
||||
title=mission_in.title,
|
||||
description=mission_in.description,
|
||||
xp_reward=mission_in.xp_reward,
|
||||
mana_reward=mission_in.mana_reward,
|
||||
difficulty=mission_in.difficulty,
|
||||
minimum_rank_id=mission_in.minimum_rank_id,
|
||||
artifact_id=mission_in.artifact_id,
|
||||
)
|
||||
db.add(mission)
|
||||
db.flush()
|
||||
|
||||
for reward in mission_in.competency_rewards:
|
||||
db.add(
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission.id,
|
||||
competency_id=reward.competency_id,
|
||||
level_delta=reward.level_delta,
|
||||
)
|
||||
)
|
||||
|
||||
for prerequisite_id in mission_in.prerequisite_ids:
|
||||
db.add(
|
||||
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
|
||||
)
|
||||
|
||||
if mission_in.branch_id:
|
||||
db.add(
|
||||
BranchMission(
|
||||
branch_id=mission_in.branch_id,
|
||||
mission_id=mission.id,
|
||||
order=mission_in.branch_order,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(mission)
|
||||
def _mission_to_detail(mission: Mission) -> MissionDetail:
|
||||
"""Формируем детальную схему миссии."""
|
||||
|
||||
return MissionDetail(
|
||||
id=mission.id,
|
||||
|
|
@ -96,6 +69,313 @@ def create_mission_endpoint(
|
|||
)
|
||||
|
||||
|
||||
def _rank_to_detailed(rank: Rank) -> RankDetailed:
|
||||
"""Формируем ранг со списком требований."""
|
||||
|
||||
return RankDetailed(
|
||||
id=rank.id,
|
||||
title=rank.title,
|
||||
description=rank.description,
|
||||
required_xp=rank.required_xp,
|
||||
mission_requirements=[
|
||||
RankRequirementMission(mission_id=req.mission_id, mission_title=req.mission.title)
|
||||
for req in rank.mission_requirements
|
||||
],
|
||||
competency_requirements=[
|
||||
RankRequirementCompetency(
|
||||
competency_id=req.competency_id,
|
||||
competency_name=req.competency.name,
|
||||
required_level=req.required_level,
|
||||
)
|
||||
for req in rank.competency_requirements
|
||||
],
|
||||
created_at=rank.created_at,
|
||||
updated_at=rank.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _branch_to_read(branch: Branch) -> BranchRead:
|
||||
"""Формируем схему ветки с отсортированными миссиями."""
|
||||
|
||||
missions = sorted(branch.missions, key=lambda item: item.order)
|
||||
return BranchRead(
|
||||
id=branch.id,
|
||||
title=branch.title,
|
||||
description=branch.description,
|
||||
category=branch.category,
|
||||
missions=[
|
||||
BranchMissionRead(
|
||||
mission_id=item.mission_id,
|
||||
mission_title=item.mission.title if item.mission else "",
|
||||
order=item.order,
|
||||
)
|
||||
for item in missions
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _load_rank(db: Session, rank_id: int) -> Rank:
|
||||
"""Загружаем ранг с зависимостями."""
|
||||
|
||||
return (
|
||||
db.query(Rank)
|
||||
.options(
|
||||
selectinload(Rank.mission_requirements).selectinload(RankMissionRequirement.mission),
|
||||
selectinload(Rank.competency_requirements).selectinload(RankCompetencyRequirement.competency),
|
||||
)
|
||||
.filter(Rank.id == rank_id)
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
def _load_mission(db: Session, mission_id: int) -> Mission:
|
||||
"""Загружаем миссию с зависимостями."""
|
||||
|
||||
return (
|
||||
db.query(Mission)
|
||||
.options(
|
||||
selectinload(Mission.prerequisites),
|
||||
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||
selectinload(Mission.branches),
|
||||
)
|
||||
.filter(Mission.id == mission_id)
|
||||
.one()
|
||||
)
|
||||
|
||||
|
||||
@router.get("/missions", response_model=list[MissionBase], summary="Миссии (HR)")
|
||||
def admin_missions(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[MissionBase]:
|
||||
"""Список всех миссий для HR."""
|
||||
|
||||
missions = db.query(Mission).order_by(Mission.title).all()
|
||||
return [MissionBase.model_validate(mission) for mission in missions]
|
||||
|
||||
|
||||
@router.get("/missions/{mission_id}", response_model=MissionDetail, summary="Детали миссии")
|
||||
def admin_mission_detail(
|
||||
mission_id: int,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> MissionDetail:
|
||||
"""Детальная карточка миссии."""
|
||||
|
||||
mission = (
|
||||
db.query(Mission)
|
||||
.options(
|
||||
selectinload(Mission.prerequisites),
|
||||
selectinload(Mission.competency_rewards).selectinload(MissionCompetencyReward.competency),
|
||||
selectinload(Mission.branches),
|
||||
)
|
||||
.filter(Mission.id == mission_id)
|
||||
.first()
|
||||
)
|
||||
if not mission:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||
return _mission_to_detail(mission)
|
||||
|
||||
|
||||
@router.get("/branches", response_model=list[BranchRead], summary="Ветки миссий")
|
||||
def admin_branches(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[BranchRead]:
|
||||
"""Возвращаем ветки с миссиями."""
|
||||
|
||||
branches = (
|
||||
db.query(Branch)
|
||||
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
|
||||
.order_by(Branch.title)
|
||||
.all()
|
||||
)
|
||||
return [_branch_to_read(branch) for branch in branches]
|
||||
|
||||
|
||||
@router.post("/branches", response_model=BranchRead, summary="Создать ветку")
|
||||
def create_branch(
|
||||
branch_in: BranchCreate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> BranchRead:
|
||||
"""Создаём новую ветку."""
|
||||
|
||||
branch = Branch(
|
||||
title=branch_in.title,
|
||||
description=branch_in.description,
|
||||
category=branch_in.category,
|
||||
)
|
||||
db.add(branch)
|
||||
db.commit()
|
||||
db.refresh(branch)
|
||||
return _branch_to_read(branch)
|
||||
|
||||
|
||||
@router.put("/branches/{branch_id}", response_model=BranchRead, summary="Обновить ветку")
|
||||
def update_branch(
|
||||
branch_id: int,
|
||||
branch_in: BranchUpdate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> BranchRead:
|
||||
"""Редактируем ветку."""
|
||||
|
||||
branch = db.query(Branch).filter(Branch.id == branch_id).first()
|
||||
if not branch:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ветка не найдена")
|
||||
|
||||
branch.title = branch_in.title
|
||||
branch.description = branch_in.description
|
||||
branch.category = branch_in.category
|
||||
|
||||
db.commit()
|
||||
db.refresh(branch)
|
||||
return _branch_to_read(branch)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/competencies",
|
||||
response_model=list[CompetencyBase],
|
||||
summary="Каталог компетенций",
|
||||
)
|
||||
def list_competencies(
|
||||
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
|
||||
) -> list[CompetencyBase]:
|
||||
"""Справочник компетенций для форм HR."""
|
||||
|
||||
competencies = db.query(Competency).order_by(Competency.name).all()
|
||||
return [CompetencyBase.model_validate(competency) for competency in competencies]
|
||||
|
||||
|
||||
@router.get("/artifacts", response_model=list[ArtifactRead], summary="Каталог артефактов")
|
||||
def list_artifacts(
|
||||
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
|
||||
) -> list[ArtifactRead]:
|
||||
"""Справочник артефактов."""
|
||||
|
||||
artifacts = db.query(Artifact).order_by(Artifact.name).all()
|
||||
return [ArtifactRead.model_validate(artifact) for artifact in artifacts]
|
||||
|
||||
|
||||
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
|
||||
def create_mission_endpoint(
|
||||
mission_in: MissionCreate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> MissionDetail:
|
||||
"""Создаём новую миссию."""
|
||||
|
||||
mission = Mission(
|
||||
title=mission_in.title,
|
||||
description=mission_in.description,
|
||||
xp_reward=mission_in.xp_reward,
|
||||
mana_reward=mission_in.mana_reward,
|
||||
difficulty=mission_in.difficulty,
|
||||
minimum_rank_id=mission_in.minimum_rank_id,
|
||||
artifact_id=mission_in.artifact_id,
|
||||
)
|
||||
db.add(mission)
|
||||
db.flush()
|
||||
|
||||
for reward in mission_in.competency_rewards:
|
||||
mission.competency_rewards.append(
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission.id,
|
||||
competency_id=reward.competency_id,
|
||||
level_delta=reward.level_delta,
|
||||
)
|
||||
)
|
||||
|
||||
for prerequisite_id in mission_in.prerequisite_ids:
|
||||
mission.prerequisites.append(
|
||||
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
|
||||
)
|
||||
|
||||
if mission_in.branch_id:
|
||||
mission.branches.append(
|
||||
BranchMission(
|
||||
branch_id=mission_in.branch_id,
|
||||
mission_id=mission.id,
|
||||
order=mission_in.branch_order,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
mission = _load_mission(db, mission.id)
|
||||
|
||||
return _mission_to_detail(mission)
|
||||
|
||||
|
||||
@router.put("/missions/{mission_id}", response_model=MissionDetail, summary="Обновить миссию")
|
||||
def update_mission_endpoint(
|
||||
mission_id: int,
|
||||
mission_in: MissionUpdate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> MissionDetail:
|
||||
"""Редактируем миссию."""
|
||||
|
||||
mission = (
|
||||
db.query(Mission)
|
||||
.options(
|
||||
selectinload(Mission.prerequisites),
|
||||
selectinload(Mission.competency_rewards),
|
||||
selectinload(Mission.branches),
|
||||
)
|
||||
.filter(Mission.id == mission_id)
|
||||
.first()
|
||||
)
|
||||
if not mission:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||
|
||||
payload = mission_in.model_dump(exclude_unset=True)
|
||||
|
||||
for attr in ["title", "description", "xp_reward", "mana_reward", "difficulty", "is_active"]:
|
||||
if attr in payload:
|
||||
setattr(mission, attr, payload[attr])
|
||||
|
||||
if "minimum_rank_id" in payload:
|
||||
mission.minimum_rank_id = payload["minimum_rank_id"]
|
||||
|
||||
if "artifact_id" in payload:
|
||||
mission.artifact_id = payload["artifact_id"]
|
||||
|
||||
if "competency_rewards" in payload:
|
||||
mission.competency_rewards.clear()
|
||||
for reward in payload["competency_rewards"]:
|
||||
mission.competency_rewards.append(
|
||||
MissionCompetencyReward(
|
||||
mission_id=mission.id,
|
||||
competency_id=reward.competency_id,
|
||||
level_delta=reward.level_delta,
|
||||
)
|
||||
)
|
||||
|
||||
if "prerequisite_ids" in payload:
|
||||
mission.prerequisites.clear()
|
||||
for prerequisite_id in payload["prerequisite_ids"]:
|
||||
mission.prerequisites.append(
|
||||
MissionPrerequisite(mission_id=mission.id, required_mission_id=prerequisite_id)
|
||||
)
|
||||
|
||||
if "branch_id" in payload:
|
||||
mission.branches.clear()
|
||||
branch_id = payload["branch_id"]
|
||||
if branch_id is not None:
|
||||
order = payload.get("branch_order", 1)
|
||||
mission.branches.append(
|
||||
BranchMission(branch_id=branch_id, mission_id=mission.id, order=order)
|
||||
)
|
||||
elif "branch_order" in payload and mission.branches:
|
||||
mission.branches[0].order = payload["branch_order"]
|
||||
|
||||
db.commit()
|
||||
|
||||
mission = _load_mission(db, mission.id)
|
||||
return _mission_to_detail(mission)
|
||||
|
||||
|
||||
@router.get("/ranks", response_model=list[RankBase], summary="Список рангов")
|
||||
def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_hr)) -> list[RankBase]:
|
||||
"""Перечень рангов."""
|
||||
|
|
@ -104,6 +384,103 @@ def admin_ranks(*, db: Session = Depends(get_db), current_user=Depends(require_h
|
|||
return [RankBase.model_validate(rank) for rank in ranks]
|
||||
|
||||
|
||||
@router.get("/ranks/{rank_id}", response_model=RankDetailed, summary="Детали ранга")
|
||||
def get_rank(
|
||||
rank_id: int,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> RankDetailed:
|
||||
"""Возвращаем подробную информацию о ранге."""
|
||||
|
||||
try:
|
||||
rank = _load_rank(db, rank_id)
|
||||
except NoResultFound as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден") from exc
|
||||
return _rank_to_detailed(rank)
|
||||
|
||||
|
||||
@router.post("/ranks", response_model=RankDetailed, summary="Создать ранг")
|
||||
def create_rank(
|
||||
rank_in: RankCreate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> RankDetailed:
|
||||
"""Создаём новый ранг с требованиями."""
|
||||
|
||||
rank = Rank(title=rank_in.title, description=rank_in.description, required_xp=rank_in.required_xp)
|
||||
db.add(rank)
|
||||
db.flush()
|
||||
|
||||
for mission_id in rank_in.mission_ids:
|
||||
rank.mission_requirements.append(
|
||||
RankMissionRequirement(rank_id=rank.id, mission_id=mission_id)
|
||||
)
|
||||
|
||||
for item in rank_in.competency_requirements:
|
||||
rank.competency_requirements.append(
|
||||
RankCompetencyRequirement(
|
||||
rank_id=rank.id,
|
||||
competency_id=item.competency_id,
|
||||
required_level=item.required_level,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
rank = _load_rank(db, rank.id)
|
||||
return _rank_to_detailed(rank)
|
||||
|
||||
|
||||
@router.put("/ranks/{rank_id}", response_model=RankDetailed, summary="Обновить ранг")
|
||||
def update_rank(
|
||||
rank_id: int,
|
||||
rank_in: RankUpdate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> RankDetailed:
|
||||
"""Редактируем параметры ранга."""
|
||||
|
||||
rank = (
|
||||
db.query(Rank)
|
||||
.options(
|
||||
selectinload(Rank.mission_requirements),
|
||||
selectinload(Rank.competency_requirements),
|
||||
)
|
||||
.filter(Rank.id == rank_id)
|
||||
.first()
|
||||
)
|
||||
if not rank:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ранг не найден")
|
||||
|
||||
rank.title = rank_in.title
|
||||
rank.description = rank_in.description
|
||||
rank.required_xp = rank_in.required_xp
|
||||
|
||||
rank.mission_requirements.clear()
|
||||
for mission_id in rank_in.mission_ids:
|
||||
rank.mission_requirements.append(
|
||||
RankMissionRequirement(rank_id=rank.id, mission_id=mission_id)
|
||||
)
|
||||
|
||||
rank.competency_requirements.clear()
|
||||
for item in rank_in.competency_requirements:
|
||||
rank.competency_requirements.append(
|
||||
RankCompetencyRequirement(
|
||||
rank_id=rank.id,
|
||||
competency_id=item.competency_id,
|
||||
required_level=item.required_level,
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
rank = _load_rank(db, rank.id)
|
||||
return _rank_to_detailed(rank)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/submissions",
|
||||
response_model=list[MissionSubmissionRead],
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.branch import Branch, BranchMission
|
||||
from app.models.mission import Mission, MissionSubmission
|
||||
from app.models.user import User
|
||||
from app.schemas.branch import BranchMissionRead, BranchRead
|
||||
from app.schemas.mission import (
|
||||
MissionBase,
|
||||
MissionDetail,
|
||||
|
|
@ -20,6 +22,38 @@ from app.services.mission import submit_mission
|
|||
router = APIRouter(prefix="/api/missions", tags=["missions"])
|
||||
|
||||
|
||||
@router.get("/branches", response_model=list[BranchRead], summary="Список веток миссий")
|
||||
def list_branches(
|
||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
) -> list[BranchRead]:
|
||||
"""Возвращаем ветки с упорядоченными миссиями."""
|
||||
|
||||
branches = (
|
||||
db.query(Branch)
|
||||
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
|
||||
.order_by(Branch.title)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
BranchRead(
|
||||
id=branch.id,
|
||||
title=branch.title,
|
||||
description=branch.description,
|
||||
category=branch.category,
|
||||
missions=[
|
||||
BranchMissionRead(
|
||||
mission_id=item.mission_id,
|
||||
mission_title=item.mission.title if item.mission else "",
|
||||
order=item.order,
|
||||
)
|
||||
for item in sorted(branch.missions, key=lambda link: link.order)
|
||||
],
|
||||
)
|
||||
for branch in branches
|
||||
]
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MissionBase], summary="Список миссий")
|
||||
def list_missions(
|
||||
*, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)
|
||||
|
|
|
|||
19
backend/app/schemas/artifact.py
Normal file
19
backend/app/schemas/artifact.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
"""Схемы артефактов."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.models.artifact import ArtifactRarity
|
||||
|
||||
|
||||
class ArtifactRead(BaseModel):
|
||||
"""Краткая информация об артефакте."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
rarity: ArtifactRarity
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -24,3 +24,17 @@ class BranchRead(BaseModel):
|
|||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class BranchCreate(BaseModel):
|
||||
"""Создание ветки."""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
category: str
|
||||
|
||||
|
||||
class BranchUpdate(BranchCreate):
|
||||
"""Обновление ветки."""
|
||||
|
||||
pass
|
||||
|
|
|
|||
|
|
@ -68,6 +68,23 @@ class MissionCreate(BaseModel):
|
|||
branch_order: int = 1
|
||||
|
||||
|
||||
class MissionUpdate(BaseModel):
|
||||
"""Схема обновления миссии."""
|
||||
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
xp_reward: Optional[int] = None
|
||||
mana_reward: Optional[int] = None
|
||||
difficulty: Optional[MissionDifficulty] = None
|
||||
minimum_rank_id: Optional[int | None] = None
|
||||
artifact_id: Optional[int | None] = None
|
||||
prerequisite_ids: Optional[list[int]] = None
|
||||
competency_rewards: Optional[list[MissionCreateReward]] = None
|
||||
branch_id: Optional[int | None] = None
|
||||
branch_order: Optional[int] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class MissionSubmissionCreate(BaseModel):
|
||||
"""Отправка отчёта по миссии."""
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RankBase(BaseModel):
|
||||
|
|
@ -41,3 +41,36 @@ class RankDetailed(RankBase):
|
|||
competency_requirements: list[RankRequirementCompetency]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RankRequirementMissionInput(BaseModel):
|
||||
"""Входная схема обязательной миссии."""
|
||||
|
||||
mission_id: int
|
||||
|
||||
|
||||
class RankRequirementCompetencyInput(BaseModel):
|
||||
"""Входная схема требования к компетенции."""
|
||||
|
||||
competency_id: int
|
||||
required_level: int = Field(ge=0)
|
||||
|
||||
|
||||
class RankCreate(BaseModel):
|
||||
"""Создание нового ранга."""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
required_xp: int = Field(ge=0)
|
||||
mission_ids: list[int] = []
|
||||
competency_requirements: list[RankRequirementCompetencyInput] = []
|
||||
|
||||
|
||||
class RankUpdate(BaseModel):
|
||||
"""Обновление существующего ранга."""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
required_xp: int = Field(ge=0)
|
||||
mission_ids: list[int] = []
|
||||
competency_requirements: list[RankRequirementCompetencyInput] = []
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { AdminBranchManager } from '../../components/admin/AdminBranchManager';
|
||||
import { AdminMissionManager } from '../../components/admin/AdminMissionManager';
|
||||
import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
|
||||
|
|
@ -11,26 +14,74 @@ interface Submission {
|
|||
updated_at: string;
|
||||
}
|
||||
|
||||
async function fetchModerationQueue() {
|
||||
const token = await getDemoToken('hr');
|
||||
const submissions = await apiFetch<Submission[]>('/api/admin/submissions', { authToken: token });
|
||||
return submissions;
|
||||
interface MissionSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface BranchSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
missions: Array<{ mission_id: number; mission_title: string; order: number }>;
|
||||
}
|
||||
|
||||
interface RankSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
required_xp: number;
|
||||
}
|
||||
|
||||
interface CompetencySummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface ArtifactSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: string;
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const submissions = await fetchModerationQueue();
|
||||
const token = await getDemoToken('hr');
|
||||
|
||||
const [submissions, missions, branches, ranks, competencies, artifacts] = await Promise.all([
|
||||
apiFetch<Submission[]>('/api/admin/submissions', { authToken: token }),
|
||||
apiFetch<MissionSummary[]>('/api/admin/missions', { authToken: token }),
|
||||
apiFetch<BranchSummary[]>('/api/admin/branches', { authToken: token }),
|
||||
apiFetch<RankSummary[]>('/api/admin/ranks', { authToken: token }),
|
||||
apiFetch<CompetencySummary[]>('/api/admin/competencies', { authToken: token }),
|
||||
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token })
|
||||
]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h2>HR-панель: очередь модерации</h2>
|
||||
<h2>HR-панель</h2>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
Демонстрационная выборка отправленных миссий. В реальном приложении добавим карточки с деталями пилота и
|
||||
кнопки approve/reject непосредственно из UI.
|
||||
Управляйте миссиями, ветками и рангами, а также следите за очередью модерации отчётов.
|
||||
</p>
|
||||
<div className="grid">
|
||||
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
||||
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||
<h3>Очередь модерации</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
Список последующих отправок миссий. Для полноты UX можно добавить действия approve/reject прямо отсюда.
|
||||
</p>
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{submissions.map((submission) => (
|
||||
<div key={submission.mission_id} className="card">
|
||||
<h3>Миссия #{submission.mission_id}</h3>
|
||||
<div key={`${submission.mission_id}-${submission.updated_at}`} className="card">
|
||||
<h4>Миссия #{submission.mission_id}</h4>
|
||||
<p>Статус: {submission.status}</p>
|
||||
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
|
||||
{submission.proof_url && (
|
||||
|
|
@ -48,6 +99,19 @@ export default async function AdminPage() {
|
|||
))}
|
||||
{submissions.length === 0 && <p>Очередь пуста — все миссии проверены.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminBranchManager token={token} branches={branches} />
|
||||
<AdminMissionManager
|
||||
token={token}
|
||||
missions={missions}
|
||||
branches={branches}
|
||||
ranks={ranks}
|
||||
competencies={competencies}
|
||||
artifacts={artifacts}
|
||||
/>
|
||||
<AdminRankManager token={token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
143
frontend/src/components/admin/AdminBranchManager.tsx
Normal file
143
frontend/src/components/admin/AdminBranchManager.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
'use client';
|
||||
|
||||
import { FormEvent, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Branch = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
branches: Branch[];
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_OPTIONS = ['quest', 'recruiting', 'lecture', 'simulator'];
|
||||
|
||||
export function AdminBranchManager({ token, branches }: Props) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState('quest');
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const categoryOptions = useMemo(() => {
|
||||
const existing = new Set(DEFAULT_CATEGORY_OPTIONS);
|
||||
branches.forEach((branch) => existing.add(branch.category));
|
||||
return Array.from(existing.values());
|
||||
}, [branches]);
|
||||
|
||||
const resetForm = () => {
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setCategory(categoryOptions[0] ?? 'quest');
|
||||
};
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === 'new') {
|
||||
setSelectedId('new');
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = Number(value);
|
||||
const branch = branches.find((item) => item.id === id);
|
||||
if (!branch) {
|
||||
setSelectedId('new');
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedId(id);
|
||||
setTitle(branch.title);
|
||||
setDescription(branch.description);
|
||||
setCategory(branch.category);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
|
||||
const payload = { title, description, category };
|
||||
|
||||
try {
|
||||
if (selectedId === 'new') {
|
||||
await apiFetch('/api/admin/branches', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Ветка создана');
|
||||
} else {
|
||||
await apiFetch(`/api/admin/branches/${selectedId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Ветка обновлена');
|
||||
}
|
||||
router.refresh();
|
||||
if (selectedId === 'new') {
|
||||
resetForm();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось сохранить ветку');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Управление ветками</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
Создавайте или обновляйте ветки, чтобы миссии были организованы по сюжетам и категориям.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="admin-form">
|
||||
<label>
|
||||
Выбранная ветка
|
||||
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||
<option value="new">Новая ветка</option>
|
||||
{branches.map((branch) => (
|
||||
<option key={branch.id} value={branch.id}>
|
||||
{branch.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Название
|
||||
<input value={title} onChange={(event) => setTitle(event.target.value)} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Описание
|
||||
<textarea value={description} onChange={(event) => setDescription(event.target.value)} required rows={3} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Категория
|
||||
<select value={category} onChange={(event) => setCategory(event.target.value)}>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="primary">Сохранить</button>
|
||||
|
||||
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
411
frontend/src/components/admin/AdminMissionManager.tsx
Normal file
411
frontend/src/components/admin/AdminMissionManager.tsx
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
'use client';
|
||||
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
const DIFFICULTIES = [
|
||||
{ value: 'easy', label: 'Лёгкая' },
|
||||
{ value: 'medium', label: 'Средняя' },
|
||||
{ value: 'hard', label: 'Сложная' }
|
||||
] as const;
|
||||
|
||||
type Difficulty = (typeof DIFFICULTIES)[number]['value'];
|
||||
|
||||
type MissionBase = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: Difficulty;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
interface MissionDetail extends MissionBase {
|
||||
minimum_rank_id: number | null;
|
||||
artifact_id: number | null;
|
||||
prerequisites: number[];
|
||||
competency_rewards: Array<{ competency_id: number; competency_name: string; level_delta: number }>;
|
||||
}
|
||||
|
||||
type Branch = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
missions: Array<{ mission_id: number; mission_title: string; order: number }>;
|
||||
};
|
||||
|
||||
type Rank = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
required_xp: number;
|
||||
};
|
||||
|
||||
type Competency = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
};
|
||||
|
||||
type Artifact = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
missions: MissionBase[];
|
||||
branches: Branch[];
|
||||
ranks: Rank[];
|
||||
competencies: Competency[];
|
||||
artifacts: Artifact[];
|
||||
}
|
||||
|
||||
type RewardInput = { competency_id: number | ''; level_delta: number };
|
||||
|
||||
type FormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
xp_reward: number;
|
||||
mana_reward: number;
|
||||
difficulty: Difficulty;
|
||||
minimum_rank_id: number | '';
|
||||
artifact_id: number | '';
|
||||
branch_id: number | '';
|
||||
branch_order: number;
|
||||
prerequisite_ids: number[];
|
||||
competency_rewards: RewardInput[];
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
const initialFormState: FormState = {
|
||||
title: '',
|
||||
description: '',
|
||||
xp_reward: 0,
|
||||
mana_reward: 0,
|
||||
difficulty: 'medium',
|
||||
minimum_rank_id: '',
|
||||
artifact_id: '',
|
||||
branch_id: '',
|
||||
branch_order: 1,
|
||||
prerequisite_ids: [],
|
||||
competency_rewards: [],
|
||||
is_active: true
|
||||
};
|
||||
|
||||
export function AdminMissionManager({ token, missions, branches, ranks, competencies, artifacts }: Props) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||
const [form, setForm] = useState<FormState>(initialFormState);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setForm(initialFormState);
|
||||
};
|
||||
|
||||
const loadMission = async (missionId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const mission = await apiFetch<MissionDetail>(`/api/admin/missions/${missionId}`, { authToken: token });
|
||||
setForm({
|
||||
title: mission.title,
|
||||
description: mission.description,
|
||||
xp_reward: mission.xp_reward,
|
||||
mana_reward: mission.mana_reward,
|
||||
difficulty: mission.difficulty,
|
||||
minimum_rank_id: mission.minimum_rank_id ?? '',
|
||||
artifact_id: mission.artifact_id ?? '',
|
||||
branch_id: (() => {
|
||||
const branchLink = branches
|
||||
.flatMap((branch) => branch.missions.map((item) => ({ branch, item })))
|
||||
.find(({ item }) => item.mission_id === mission.id);
|
||||
return branchLink?.branch.id ?? '';
|
||||
})(),
|
||||
branch_order: (() => {
|
||||
const branchLink = branches
|
||||
.flatMap((branch) => branch.missions.map((item) => ({ branch, item })))
|
||||
.find(({ item }) => item.mission_id === mission.id);
|
||||
return branchLink?.item.order ?? 1;
|
||||
})(),
|
||||
prerequisite_ids: mission.prerequisites,
|
||||
competency_rewards: mission.competency_rewards.map((reward) => ({
|
||||
competency_id: reward.competency_id,
|
||||
level_delta: reward.level_delta
|
||||
})),
|
||||
is_active: mission.is_active
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось загрузить миссию');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
if (value === 'new') {
|
||||
setSelectedId('new');
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = Number(value);
|
||||
setSelectedId(id);
|
||||
void loadMission(id);
|
||||
};
|
||||
|
||||
const updateField = <K extends keyof FormState>(field: K, value: FormState[K]) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePrerequisitesChange = (event: FormEvent<HTMLSelectElement>) => {
|
||||
const options = Array.from(event.currentTarget.selectedOptions);
|
||||
updateField(
|
||||
'prerequisite_ids',
|
||||
options.map((option) => Number(option.value))
|
||||
);
|
||||
};
|
||||
|
||||
const addReward = () => {
|
||||
updateField('competency_rewards', [...form.competency_rewards, { competency_id: '', level_delta: 1 }]);
|
||||
};
|
||||
|
||||
const updateReward = (index: number, value: RewardInput) => {
|
||||
const next = [...form.competency_rewards];
|
||||
next[index] = value;
|
||||
updateField('competency_rewards', next);
|
||||
};
|
||||
|
||||
const removeReward = (index: number) => {
|
||||
const next = [...form.competency_rewards];
|
||||
next.splice(index, 1);
|
||||
updateField('competency_rewards', next);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const payloadBase = {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
xp_reward: Number(form.xp_reward),
|
||||
mana_reward: Number(form.mana_reward),
|
||||
difficulty: form.difficulty,
|
||||
minimum_rank_id: form.minimum_rank_id === '' ? null : Number(form.minimum_rank_id),
|
||||
artifact_id: form.artifact_id === '' ? null : Number(form.artifact_id),
|
||||
prerequisite_ids: form.prerequisite_ids,
|
||||
competency_rewards: form.competency_rewards
|
||||
.filter((reward) => reward.competency_id !== '')
|
||||
.map((reward) => ({
|
||||
competency_id: Number(reward.competency_id),
|
||||
level_delta: Number(reward.level_delta)
|
||||
})),
|
||||
branch_id: form.branch_id === '' ? null : Number(form.branch_id),
|
||||
branch_order: Number(form.branch_order) || 1,
|
||||
is_active: form.is_active
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedId === 'new') {
|
||||
const { is_active, ...createPayload } = payloadBase;
|
||||
await apiFetch('/api/admin/missions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(createPayload),
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Миссия создана');
|
||||
resetForm();
|
||||
setSelectedId('new');
|
||||
} else {
|
||||
await apiFetch(`/api/admin/missions/${selectedId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payloadBase),
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Миссия обновлена');
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось сохранить миссию');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Миссии</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
Создавайте и обновляйте миссии: настраивайте награды, зависимости, ветки и компетенции. Все изменения мгновенно
|
||||
отражаются в списках пилотов.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="admin-form">
|
||||
<label>
|
||||
Выбранная миссия
|
||||
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||
<option value="new">Новая миссия</option>
|
||||
{missions.map((mission) => (
|
||||
<option key={mission.id} value={mission.id}>
|
||||
{mission.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Название
|
||||
<input value={form.title} onChange={(event) => updateField('title', event.target.value)} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Описание
|
||||
<textarea value={form.description} onChange={(event) => updateField('description', event.target.value)} required rows={4} />
|
||||
</label>
|
||||
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
|
||||
<label>
|
||||
Награда (XP)
|
||||
<input type="number" min={0} value={form.xp_reward} onChange={(event) => updateField('xp_reward', Number(event.target.value))} required />
|
||||
</label>
|
||||
<label>
|
||||
Награда (мана)
|
||||
<input type="number" min={0} value={form.mana_reward} onChange={(event) => updateField('mana_reward', Number(event.target.value))} required />
|
||||
</label>
|
||||
<label>
|
||||
Сложность
|
||||
<select value={form.difficulty} onChange={(event) => updateField('difficulty', event.target.value as Difficulty)}>
|
||||
{DIFFICULTIES.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Доступен с ранга
|
||||
<select value={form.minimum_rank_id === '' ? '' : String(form.minimum_rank_id)} onChange={(event) => updateField('minimum_rank_id', event.target.value === '' ? '' : Number(event.target.value))}>
|
||||
<option value="">Любой ранг</option>
|
||||
{ranks.map((rank) => (
|
||||
<option key={rank.id} value={rank.id}>
|
||||
{rank.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Артефакт
|
||||
<select value={form.artifact_id === '' ? '' : String(form.artifact_id)} onChange={(event) => updateField('artifact_id', event.target.value === '' ? '' : Number(event.target.value))}>
|
||||
<option value="">Без артефакта</option>
|
||||
{artifacts.map((artifact) => (
|
||||
<option key={artifact.id} value={artifact.id}>
|
||||
{artifact.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox">
|
||||
<input type="checkbox" checked={form.is_active} onChange={(event) => updateField('is_active', event.target.checked)} /> Миссия активна
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Ветка
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||
<select value={form.branch_id === '' ? '' : String(form.branch_id)} onChange={(event) => updateField('branch_id', event.target.value === '' ? '' : Number(event.target.value))}>
|
||||
<option value="">Без ветки</option>
|
||||
{branches.map((branch) => (
|
||||
<option key={branch.id} value={branch.id}>
|
||||
{branch.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
Порядок
|
||||
<input type="number" min={1} value={form.branch_order} onChange={(event) => updateField('branch_order', Number(event.target.value) || 1)} style={{ width: '80px' }} />
|
||||
</label>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Предварительные миссии
|
||||
<select multiple value={form.prerequisite_ids.map(String)} onChange={handlePrerequisitesChange} size={Math.min(6, missions.length)}>
|
||||
{missions
|
||||
.filter((mission) => selectedId === 'new' || mission.id !== selectedId)
|
||||
.map((mission) => (
|
||||
<option key={mission.id} value={mission.id}>
|
||||
{mission.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Прокачка компетенций</span>
|
||||
<button type="button" onClick={addReward} className="secondary">
|
||||
Добавить компетенцию
|
||||
</button>
|
||||
</div>
|
||||
{form.competency_rewards.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>Для миссии пока не назначены компетенции.</p>
|
||||
)}
|
||||
{form.competency_rewards.map((reward, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||
<select
|
||||
value={reward.competency_id === '' ? '' : String(reward.competency_id)}
|
||||
onChange={(event) =>
|
||||
updateReward(index, {
|
||||
competency_id: event.target.value === '' ? '' : Number(event.target.value),
|
||||
level_delta: reward.level_delta
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Выберите компетенцию</option>
|
||||
{competencies.map((competency) => (
|
||||
<option key={competency.id} value={competency.id}>
|
||||
{competency.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={reward.level_delta}
|
||||
onChange={(event) =>
|
||||
updateReward(index, {
|
||||
competency_id: reward.competency_id,
|
||||
level_delta: Number(event.target.value)
|
||||
})
|
||||
}
|
||||
style={{ width: '80px' }}
|
||||
/>
|
||||
<button type="button" className="secondary" onClick={() => removeReward(index)}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="primary" disabled={loading}>
|
||||
{selectedId === 'new' ? 'Создать миссию' : 'Сохранить изменения'}
|
||||
</button>
|
||||
|
||||
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
279
frontend/src/components/admin/AdminRankManager.tsx
Normal file
279
frontend/src/components/admin/AdminRankManager.tsx
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
'use client';
|
||||
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type RankBase = {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
required_xp: number;
|
||||
};
|
||||
|
||||
type MissionOption = {
|
||||
id: number;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type CompetencyOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
};
|
||||
|
||||
interface RankDetail extends RankBase {
|
||||
mission_requirements: Array<{ mission_id: number; mission_title: string }>;
|
||||
competency_requirements: Array<{ competency_id: number; competency_name: string; required_level: number }>;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
ranks: RankBase[];
|
||||
missions: MissionOption[];
|
||||
competencies: CompetencyOption[];
|
||||
}
|
||||
|
||||
type CompetencyRequirementInput = { competency_id: number | ''; required_level: number };
|
||||
|
||||
type RankFormState = {
|
||||
title: string;
|
||||
description: string;
|
||||
required_xp: number;
|
||||
mission_ids: number[];
|
||||
competency_requirements: CompetencyRequirementInput[];
|
||||
};
|
||||
|
||||
const initialRankForm: RankFormState = {
|
||||
title: '',
|
||||
description: '',
|
||||
required_xp: 0,
|
||||
mission_ids: [],
|
||||
competency_requirements: []
|
||||
};
|
||||
|
||||
export function AdminRankManager({ token, ranks, missions, competencies }: Props) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||
const [form, setForm] = useState<RankFormState>(initialRankForm);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setForm(initialRankForm);
|
||||
};
|
||||
|
||||
const loadRank = async (rankId: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const rank = await apiFetch<RankDetail>(`/api/admin/ranks/${rankId}`, { authToken: token });
|
||||
setForm({
|
||||
title: rank.title,
|
||||
description: rank.description,
|
||||
required_xp: rank.required_xp,
|
||||
mission_ids: rank.mission_requirements.map((item) => item.mission_id),
|
||||
competency_requirements: rank.competency_requirements.map((item) => ({
|
||||
competency_id: item.competency_id,
|
||||
required_level: item.required_level
|
||||
}))
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось загрузить ранг');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
if (value === 'new') {
|
||||
setSelectedId('new');
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
|
||||
const id = Number(value);
|
||||
setSelectedId(id);
|
||||
void loadRank(id);
|
||||
};
|
||||
|
||||
const updateField = <K extends keyof RankFormState>(field: K, value: RankFormState[K]) => {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleMissionSelect = (event: FormEvent<HTMLSelectElement>) => {
|
||||
const options = Array.from(event.currentTarget.selectedOptions);
|
||||
updateField(
|
||||
'mission_ids',
|
||||
options.map((option) => Number(option.value))
|
||||
);
|
||||
};
|
||||
|
||||
const addCompetencyRequirement = () => {
|
||||
updateField('competency_requirements', [...form.competency_requirements, { competency_id: '', required_level: 1 }]);
|
||||
};
|
||||
|
||||
const updateCompetencyRequirement = (index: number, value: CompetencyRequirementInput) => {
|
||||
const next = [...form.competency_requirements];
|
||||
next[index] = value;
|
||||
updateField('competency_requirements', next);
|
||||
};
|
||||
|
||||
const removeCompetencyRequirement = (index: number) => {
|
||||
const next = [...form.competency_requirements];
|
||||
next.splice(index, 1);
|
||||
updateField('competency_requirements', next);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const payload = {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
required_xp: Number(form.required_xp),
|
||||
mission_ids: form.mission_ids,
|
||||
competency_requirements: form.competency_requirements
|
||||
.filter((item) => item.competency_id !== '')
|
||||
.map((item) => ({
|
||||
competency_id: Number(item.competency_id),
|
||||
required_level: Number(item.required_level)
|
||||
}))
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedId === 'new') {
|
||||
await apiFetch('/api/admin/ranks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Ранг создан');
|
||||
resetForm();
|
||||
} else {
|
||||
await apiFetch(`/api/admin/ranks/${selectedId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Ранг обновлён');
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось сохранить ранг');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Ранги и условия повышения</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
Управляйте требованиями к рангу: задавайте минимальный опыт, ключевые миссии и уровни компетенций.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="admin-form">
|
||||
<label>
|
||||
Выбранный ранг
|
||||
<select value={selectedId === 'new' ? 'new' : String(selectedId)} onChange={(event) => handleSelect(event.target.value)}>
|
||||
<option value="new">Новый ранг</option>
|
||||
{ranks.map((rank) => (
|
||||
<option key={rank.id} value={rank.id}>
|
||||
{rank.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Название
|
||||
<input value={form.title} onChange={(event) => updateField('title', event.target.value)} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Описание
|
||||
<textarea value={form.description} onChange={(event) => updateField('description', event.target.value)} required rows={3} />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Требуемый опыт
|
||||
<input type="number" min={0} value={form.required_xp} onChange={(event) => updateField('required_xp', Number(event.target.value))} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Ключевые миссии
|
||||
<select multiple value={form.mission_ids.map(String)} onChange={handleMissionSelect} size={Math.min(6, missions.length)}>
|
||||
{missions.map((mission) => (
|
||||
<option key={mission.id} value={mission.id}>
|
||||
{mission.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Требования к компетенциям</span>
|
||||
<button type="button" onClick={addCompetencyRequirement} className="secondary">
|
||||
Добавить требование
|
||||
</button>
|
||||
</div>
|
||||
{form.competency_requirements.length === 0 && (
|
||||
<p style={{ color: 'var(--text-muted)', marginTop: '0.5rem' }}>
|
||||
Пока нет требований. Добавьте компетенции, которые нужно прокачать до определённого уровня.
|
||||
</p>
|
||||
)}
|
||||
{form.competency_requirements.map((item, index) => (
|
||||
<div key={index} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||
<select
|
||||
value={item.competency_id === '' ? '' : String(item.competency_id)}
|
||||
onChange={(event) =>
|
||||
updateCompetencyRequirement(index, {
|
||||
competency_id: event.target.value === '' ? '' : Number(event.target.value),
|
||||
required_level: item.required_level
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="">Выберите компетенцию</option>
|
||||
{competencies.map((competency) => (
|
||||
<option key={competency.id} value={competency.id}>
|
||||
{competency.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={item.required_level}
|
||||
onChange={(event) =>
|
||||
updateCompetencyRequirement(index, {
|
||||
competency_id: item.competency_id,
|
||||
required_level: Number(event.target.value)
|
||||
})
|
||||
}
|
||||
style={{ width: '80px' }}
|
||||
/>
|
||||
<button type="button" className="secondary" onClick={() => removeCompetencyRequirement(index)}>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="submit" className="primary" disabled={loading}>
|
||||
{selectedId === 'new' ? 'Создать ранг' : 'Сохранить изменения'}
|
||||
</button>
|
||||
|
||||
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user