Add futures
This commit is contained in:
parent
89734706b8
commit
1ab0cb1f1f
20
README.md
20
README.md
|
|
@ -10,6 +10,14 @@
|
|||
- `docs/` — документация, дополнительный лор.
|
||||
- `docker-compose.yaml` — инфраструктура проекта.
|
||||
|
||||
## Основные пользовательские сценарии
|
||||
|
||||
- Как кандидат: хочу проходить онбординг с лором и понимать своё место в программе.
|
||||
- Как кандидат: хочу видеть прогресс-бар до следующего ранга и инструкции по ключевым веткам.
|
||||
- Как кандидат: хочу получать награды (опыт, ману, артефакты) и видеть их в журнале.
|
||||
- Как HR: хочу управлять миссиями, ветками, артефактами и требованиями рангов.
|
||||
- Как HR: хочу видеть оперативную аналитику по активности и очереди модерации.
|
||||
|
||||
## Быстрый старт в Docker
|
||||
|
||||
1. Установите Docker и Docker Compose.
|
||||
|
|
@ -68,6 +76,14 @@ npm run dev
|
|||
| Пилот | `candidate@alabuga.space` | `orbita123` |
|
||||
| HR | `hr@alabuga.space` | `orbita123` |
|
||||
|
||||
## Проверка функционала
|
||||
|
||||
1. **Онбординг и лор**: перейдите в `/onboarding`, прочитайте слайды и отметьте шаги выполненными. Прогресс сохранится и откроет доступ к веткам миссий.
|
||||
2. **Кандидат**: авторизуйтесь под пилотом, изучите дашборд (`/`), миссии (`/missions`) и журнал (`/journal`). Доступность миссий зависит от ранга и выполненных заданий.
|
||||
3. **Выполнение миссии**: откройте карточку миссии, отправьте доказательство. Переключитесь на HR и одобрите выполнение — ранги, компетенции и мана обновятся автоматически.
|
||||
4. **HR панель**: под пользователем `hr@alabuga.space` проверьте сводку, модерацию, редактирование веток/миссий/рангов, создание артефактов и аналитику (`/admin`).
|
||||
5. **Магазин**: вернитесь к пилоту, оформите заказ в `/store` — запись появится в журнале и очереди HR.
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
|
|
@ -84,12 +100,16 @@ pytest
|
|||
- Журнал событий, экспортируемый через API.
|
||||
- Магазин артефактов с оформлением заказа.
|
||||
- Админ-панель для HR: модерация отчётов, управление ветками, миссиями и требованиями к рангам.
|
||||
- Онбординг с сохранением прогресса и космическим лором.
|
||||
- Таблица лидеров по опыту и мане за неделю/месяц/год.
|
||||
- Аналитическая сводка для HR: активность пилотов, очередь модерации, завершённость веток.
|
||||
|
||||
Админ-инструменты доступны на странице `/admin` (используйте демо-пользователя HR). Здесь можно:
|
||||
|
||||
- создавать и редактировать ветки миссий с категориями и порядком;
|
||||
- подготавливать новые миссии с наградами, зависимостями, ветками и компетенциями;
|
||||
- настраивать ранги: требуемый опыт, обязательные миссии и уровни компетенций.
|
||||
- управлять каталогом артефактов и назначать их миссиям.
|
||||
|
||||
## План развития
|
||||
|
||||
|
|
|
|||
56
backend/alembic/versions/20240611_0002_onboarding.py
Normal file
56
backend/alembic/versions/20240611_0002_onboarding.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
"""Onboarding models"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "20240611_0002"
|
||||
down_revision = "20240609_0001"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"onboarding_slides",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("order", sa.Integer(), nullable=False),
|
||||
sa.Column("title", sa.String(length=160), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("media_url", sa.String(length=512)),
|
||||
sa.Column("cta_text", sa.String(length=120)),
|
||||
sa.Column("cta_link", sa.String(length=512)),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
server_onupdate=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("order", name="uq_onboarding_slide_order"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"onboarding_states",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("last_completed_order", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("is_completed", sa.Boolean(), nullable=False, server_default=sa.sql.expression.false()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
server_onupdate=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.UniqueConstraint("user_id", name="uq_onboarding_state_user"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("onboarding_states")
|
||||
op.drop_table("onboarding_slides")
|
||||
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
"""Экспортируем роутеры для подключения в приложении."""
|
||||
|
||||
from . import admin, auth, journal, missions, store, users # noqa: F401
|
||||
from . import admin, auth, journal, missions, onboarding, store, users # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"admin",
|
||||
"auth",
|
||||
"journal",
|
||||
"missions",
|
||||
"onboarding",
|
||||
"store",
|
||||
"users",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.exc import NoResultFound
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
|
|
@ -18,8 +19,8 @@ from app.models.mission import (
|
|||
SubmissionStatus,
|
||||
)
|
||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||
from app.models.user import Competency
|
||||
from app.schemas.artifact import ArtifactRead
|
||||
from app.models.user import Competency, User, UserRole
|
||||
from app.schemas.artifact import ArtifactCreate, ArtifactRead, ArtifactUpdate
|
||||
from app.schemas.branch import BranchCreate, BranchMissionRead, BranchRead, BranchUpdate
|
||||
from app.schemas.mission import (
|
||||
MissionBase,
|
||||
|
|
@ -38,6 +39,7 @@ from app.schemas.rank import (
|
|||
)
|
||||
from app.schemas.user import CompetencyBase
|
||||
from app.services.mission import approve_submission, reject_submission
|
||||
from app.schemas.admin_stats import AdminDashboardStats, BranchCompletionStat, SubmissionStats
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
|
|
@ -108,9 +110,13 @@ def _branch_to_read(branch: Branch) -> BranchRead:
|
|||
mission_id=item.mission_id,
|
||||
mission_title=item.mission.title if item.mission else "",
|
||||
order=item.order,
|
||||
is_completed=False,
|
||||
is_available=True,
|
||||
)
|
||||
for item in missions
|
||||
],
|
||||
total_missions=len(missions),
|
||||
completed_missions=0,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -255,6 +261,144 @@ def list_artifacts(
|
|||
return [ArtifactRead.model_validate(artifact) for artifact in artifacts]
|
||||
|
||||
|
||||
@router.get("/stats", response_model=AdminDashboardStats, summary="Сводная аналитика")
|
||||
def dashboard_stats(
|
||||
*, db: Session = Depends(get_db), current_user=Depends(require_hr)
|
||||
) -> AdminDashboardStats:
|
||||
"""Основные метрики прогресса и активности пользователей."""
|
||||
|
||||
total_pilots = db.query(User).filter(User.role == UserRole.PILOT).count()
|
||||
approved_submissions = db.query(MissionSubmission).filter(
|
||||
MissionSubmission.status == SubmissionStatus.APPROVED
|
||||
)
|
||||
active_pilots = (
|
||||
approved_submissions.with_entities(MissionSubmission.user_id).distinct().count()
|
||||
)
|
||||
|
||||
completed_counts = approved_submissions.with_entities(
|
||||
MissionSubmission.user_id, func.count(MissionSubmission.id)
|
||||
).group_by(MissionSubmission.user_id)
|
||||
|
||||
total_completed = sum(row[1] for row in completed_counts)
|
||||
average_completed = total_completed / active_pilots if active_pilots else 0.0
|
||||
|
||||
submission_stats = SubmissionStats(
|
||||
pending=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.PENDING).count(),
|
||||
approved=approved_submissions.count(),
|
||||
rejected=db.query(MissionSubmission).filter(MissionSubmission.status == SubmissionStatus.REJECTED).count(),
|
||||
)
|
||||
|
||||
branches = (
|
||||
db.query(Branch)
|
||||
.options(selectinload(Branch.missions))
|
||||
.order_by(Branch.title)
|
||||
.all()
|
||||
)
|
||||
branch_stats: list[BranchCompletionStat] = []
|
||||
for branch in branches:
|
||||
total_missions = len(branch.missions)
|
||||
if total_missions == 0 or total_pilots == 0:
|
||||
branch_stats.append(
|
||||
BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=0.0)
|
||||
)
|
||||
continue
|
||||
|
||||
approved_count = (
|
||||
db.query(func.count(MissionSubmission.id))
|
||||
.join(Mission, Mission.id == MissionSubmission.mission_id)
|
||||
.join(BranchMission, BranchMission.mission_id == Mission.id)
|
||||
.filter(
|
||||
BranchMission.branch_id == branch.id,
|
||||
MissionSubmission.status == SubmissionStatus.APPROVED,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
denominator = total_missions * total_pilots
|
||||
rate = min(1.0, approved_count / denominator) if denominator else 0.0
|
||||
branch_stats.append(
|
||||
BranchCompletionStat(branch_id=branch.id, branch_title=branch.title, completion_rate=rate)
|
||||
)
|
||||
|
||||
return AdminDashboardStats(
|
||||
total_users=total_pilots,
|
||||
active_pilots=active_pilots,
|
||||
average_completed_missions=round(average_completed, 2),
|
||||
submission_stats=submission_stats,
|
||||
branch_completion=branch_stats,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/artifacts", response_model=ArtifactRead, summary="Создать артефакт")
|
||||
def create_artifact(
|
||||
artifact_in: ArtifactCreate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> ArtifactRead:
|
||||
"""Добавляем новый артефакт в каталог."""
|
||||
|
||||
artifact = Artifact(
|
||||
name=artifact_in.name,
|
||||
description=artifact_in.description,
|
||||
rarity=artifact_in.rarity,
|
||||
image_url=artifact_in.image_url,
|
||||
)
|
||||
db.add(artifact)
|
||||
db.commit()
|
||||
db.refresh(artifact)
|
||||
return ArtifactRead.model_validate(artifact)
|
||||
|
||||
|
||||
@router.put("/artifacts/{artifact_id}", response_model=ArtifactRead, summary="Обновить артефакт")
|
||||
def update_artifact(
|
||||
artifact_id: int,
|
||||
artifact_in: ArtifactUpdate,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> ArtifactRead:
|
||||
"""Редактируем существующий артефакт."""
|
||||
|
||||
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||
if not artifact:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден")
|
||||
|
||||
payload = artifact_in.model_dump(exclude_unset=True)
|
||||
for field, value in payload.items():
|
||||
setattr(artifact, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(artifact)
|
||||
return ArtifactRead.model_validate(artifact)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/artifacts/{artifact_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Удалить артефакт"
|
||||
)
|
||||
def delete_artifact(
|
||||
artifact_id: int,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(require_hr),
|
||||
) -> None:
|
||||
"""Удаляем артефакт, если он не привязан к миссиям."""
|
||||
|
||||
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
|
||||
if not artifact:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Артефакт не найден")
|
||||
|
||||
missions_with_artifact = db.query(Mission).filter(Mission.artifact_id == artifact_id).count()
|
||||
if missions_with_artifact:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Нельзя удалить артефакт, привязанный к миссиям",
|
||||
)
|
||||
|
||||
db.delete(artifact)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/missions", response_model=MissionDetail, summary="Создать миссию")
|
||||
def create_mission_endpoint(
|
||||
mission_in: MissionCreate,
|
||||
|
|
|
|||
|
|
@ -2,14 +2,17 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.journal import JournalEntry
|
||||
from app.models.user import User
|
||||
from app.schemas.journal import JournalEntryRead
|
||||
from app.schemas.journal import JournalEntryRead, LeaderboardEntry, LeaderboardResponse
|
||||
|
||||
router = APIRouter(prefix="/api/journal", tags=["journal"])
|
||||
|
||||
|
|
@ -27,3 +30,48 @@ def list_journal(
|
|||
.all()
|
||||
)
|
||||
return [JournalEntryRead.model_validate(entry) for entry in entries]
|
||||
|
||||
|
||||
@router.get("/leaderboard", response_model=LeaderboardResponse, summary="Таблица лидеров")
|
||||
def leaderboard(
|
||||
period: str = "week",
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> LeaderboardResponse:
|
||||
"""Возвращаем топ пилотов по опыту и мане за выбранный период."""
|
||||
|
||||
del current_user # информация используется только для авторизации
|
||||
|
||||
periods = {"week": 7, "month": 30, "year": 365}
|
||||
if period not in periods:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неизвестный период")
|
||||
|
||||
since = datetime.now(timezone.utc) - timedelta(days=periods[period])
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
User.id.label("user_id"),
|
||||
User.full_name,
|
||||
func.sum(JournalEntry.xp_delta).label("xp_sum"),
|
||||
func.sum(JournalEntry.mana_delta).label("mana_sum"),
|
||||
)
|
||||
.join(User, User.id == JournalEntry.user_id)
|
||||
.filter(JournalEntry.created_at >= since)
|
||||
.group_by(User.id, User.full_name)
|
||||
.order_by(func.sum(JournalEntry.xp_delta).desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
entries = [
|
||||
LeaderboardEntry(
|
||||
user_id=row.user_id,
|
||||
full_name=row.full_name,
|
||||
xp_delta=int(row.xp_sum or 0),
|
||||
mana_delta=int(row.mana_sum or 0),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return LeaderboardResponse(period=period, entries=entries)
|
||||
|
|
|
|||
|
|
@ -2,13 +2,15 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
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.mission import Mission, MissionSubmission, SubmissionStatus
|
||||
from app.models.user import User
|
||||
from app.schemas.branch import BranchMissionRead, BranchRead
|
||||
from app.schemas.mission import (
|
||||
|
|
@ -22,12 +24,73 @@ from app.services.mission import submit_mission
|
|||
router = APIRouter(prefix="/api/missions", tags=["missions"])
|
||||
|
||||
|
||||
def _load_user_progress(user: User) -> set[int]:
|
||||
"""Возвращаем идентификаторы успешно завершённых миссий."""
|
||||
|
||||
completed = {
|
||||
submission.mission_id
|
||||
for submission in user.submissions
|
||||
if submission.status == SubmissionStatus.APPROVED
|
||||
}
|
||||
return completed
|
||||
|
||||
|
||||
def _build_branch_dependencies(branches: list[Branch]) -> dict[int, set[int]]:
|
||||
"""Строим карту зависимостей миссий по веткам."""
|
||||
|
||||
dependencies: dict[int, set[int]] = defaultdict(set)
|
||||
for branch in branches:
|
||||
ordered = sorted(branch.missions, key=lambda item: item.order)
|
||||
previous: list[int] = []
|
||||
for link in ordered:
|
||||
if previous:
|
||||
dependencies[link.mission_id].update(previous)
|
||||
previous.append(link.mission_id)
|
||||
return dependencies
|
||||
|
||||
|
||||
def _mission_availability(
|
||||
*,
|
||||
mission: Mission,
|
||||
user: User,
|
||||
completed_missions: set[int],
|
||||
branch_dependencies: dict[int, set[int]],
|
||||
mission_titles: dict[int, str],
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Определяем, доступна ли миссия и формируем причины блокировки."""
|
||||
|
||||
reasons: list[str] = []
|
||||
|
||||
if mission.minimum_rank and user.xp < mission.minimum_rank.required_xp:
|
||||
reasons.append(f"Требуется ранг «{mission.minimum_rank.title}»")
|
||||
|
||||
missing_explicit = [
|
||||
req.required_mission_id
|
||||
for req in mission.prerequisites
|
||||
if req.required_mission_id not in completed_missions
|
||||
]
|
||||
for mission_id in missing_explicit:
|
||||
reasons.append(f"Завершите миссию «{mission_titles.get(mission_id, '#'+str(mission_id))}»")
|
||||
|
||||
for mission_id in branch_dependencies.get(mission.id, set()):
|
||||
if mission_id not in completed_missions:
|
||||
reasons.append(
|
||||
"Продолжение ветки откроется после миссии «"
|
||||
f"{mission_titles.get(mission_id, '#'+str(mission_id))}»"
|
||||
)
|
||||
|
||||
is_available = mission.is_active and not reasons
|
||||
return is_available, reasons
|
||||
|
||||
|
||||
@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]:
|
||||
"""Возвращаем ветки с упорядоченными миссиями."""
|
||||
|
||||
db.refresh(current_user)
|
||||
_ = current_user.submissions
|
||||
branches = (
|
||||
db.query(Branch)
|
||||
.options(selectinload(Branch.missions).selectinload(BranchMission.mission))
|
||||
|
|
@ -35,23 +98,60 @@ def list_branches(
|
|||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
completed_missions = _load_user_progress(current_user)
|
||||
branch_dependencies = _build_branch_dependencies(branches)
|
||||
|
||||
mission_titles = {
|
||||
item.mission_id: item.mission.title if item.mission else ""
|
||||
for branch in branches
|
||||
for item in branch.missions
|
||||
}
|
||||
mission_titles.update(dict(db.query(Mission.id, Mission.title).all()))
|
||||
|
||||
response: list[BranchRead] = []
|
||||
for branch in branches:
|
||||
ordered_links = sorted(branch.missions, key=lambda link: link.order)
|
||||
completed_count = sum(1 for link in ordered_links if link.mission_id in completed_missions)
|
||||
total = len(ordered_links)
|
||||
|
||||
missions_payload = []
|
||||
for link in ordered_links:
|
||||
mission_obj = link.mission
|
||||
mission_title = mission_obj.title if mission_obj else ""
|
||||
is_completed = link.mission_id in completed_missions
|
||||
if mission_obj:
|
||||
is_available, _ = _mission_availability(
|
||||
mission=mission_obj,
|
||||
user=current_user,
|
||||
completed_missions=completed_missions,
|
||||
branch_dependencies=branch_dependencies,
|
||||
mission_titles=mission_titles,
|
||||
)
|
||||
else:
|
||||
is_available = False
|
||||
missions_payload.append(
|
||||
BranchMissionRead(
|
||||
mission_id=link.mission_id,
|
||||
mission_title=mission_title,
|
||||
order=link.order,
|
||||
is_completed=is_completed,
|
||||
is_available=is_available,
|
||||
)
|
||||
)
|
||||
|
||||
response.append(
|
||||
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,
|
||||
missions=missions_payload,
|
||||
total_missions=total,
|
||||
completed_missions=completed_count,
|
||||
)
|
||||
for item in sorted(branch.missions, key=lambda link: link.order)
|
||||
],
|
||||
)
|
||||
for branch in branches
|
||||
]
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/", response_model=list[MissionBase], summary="Список миссий")
|
||||
|
|
@ -60,13 +160,45 @@ def list_missions(
|
|||
) -> list[MissionBase]:
|
||||
"""Возвращаем доступные миссии."""
|
||||
|
||||
query = db.query(Mission).filter(Mission.is_active.is_(True))
|
||||
if current_user.current_rank_id:
|
||||
query = query.filter(
|
||||
(Mission.minimum_rank_id.is_(None)) | (Mission.minimum_rank_id <= current_user.current_rank_id)
|
||||
db.refresh(current_user)
|
||||
_ = current_user.submissions
|
||||
|
||||
branches = (
|
||||
db.query(Branch)
|
||||
.options(selectinload(Branch.missions))
|
||||
.all()
|
||||
)
|
||||
missions = query.all()
|
||||
return [MissionBase.model_validate(mission) for mission in missions]
|
||||
branch_dependencies = _build_branch_dependencies(branches)
|
||||
|
||||
missions = (
|
||||
db.query(Mission)
|
||||
.options(
|
||||
selectinload(Mission.prerequisites),
|
||||
selectinload(Mission.minimum_rank),
|
||||
)
|
||||
.filter(Mission.is_active.is_(True))
|
||||
.order_by(Mission.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
mission_titles = {mission.id: mission.title for mission in missions}
|
||||
completed_missions = _load_user_progress(current_user)
|
||||
|
||||
response: list[MissionBase] = []
|
||||
for mission in missions:
|
||||
is_available, reasons = _mission_availability(
|
||||
mission=mission,
|
||||
user=current_user,
|
||||
completed_missions=completed_missions,
|
||||
branch_dependencies=branch_dependencies,
|
||||
mission_titles=mission_titles,
|
||||
)
|
||||
dto = MissionBase.model_validate(mission)
|
||||
dto.is_available = is_available
|
||||
dto.locked_reasons = reasons
|
||||
response.append(dto)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{mission_id}", response_model=MissionDetail, summary="Карточка миссии")
|
||||
|
|
@ -82,6 +214,25 @@ def get_mission(
|
|||
if not mission:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||
|
||||
db.refresh(current_user)
|
||||
_ = current_user.submissions
|
||||
branches = (
|
||||
db.query(Branch)
|
||||
.options(selectinload(Branch.missions))
|
||||
.all()
|
||||
)
|
||||
branch_dependencies = _build_branch_dependencies(branches)
|
||||
completed_missions = _load_user_progress(current_user)
|
||||
mission_titles = dict(db.query(Mission.id, Mission.title).all())
|
||||
|
||||
is_available, reasons = _mission_availability(
|
||||
mission=mission,
|
||||
user=current_user,
|
||||
completed_missions=completed_missions,
|
||||
branch_dependencies=branch_dependencies,
|
||||
mission_titles=mission_titles,
|
||||
)
|
||||
|
||||
prerequisites = [link.required_mission_id for link in mission.prerequisites]
|
||||
rewards = [
|
||||
{
|
||||
|
|
@ -100,6 +251,8 @@ def get_mission(
|
|||
mana_reward=mission.mana_reward,
|
||||
difficulty=mission.difficulty,
|
||||
is_active=mission.is_active,
|
||||
is_available=is_available,
|
||||
locked_reasons=reasons,
|
||||
minimum_rank_id=mission.minimum_rank_id,
|
||||
artifact_id=mission.artifact_id,
|
||||
prerequisites=prerequisites,
|
||||
|
|
@ -120,9 +273,30 @@ def submit(
|
|||
) -> MissionSubmissionRead:
|
||||
"""Пилот отправляет доказательство выполнения миссии."""
|
||||
|
||||
mission = db.query(Mission).filter(Mission.id == mission_id).first()
|
||||
mission = db.query(Mission).filter(Mission.id == mission_id, Mission.is_active.is_(True)).first()
|
||||
if not mission:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Миссия не найдена")
|
||||
|
||||
db.refresh(current_user)
|
||||
_ = current_user.submissions
|
||||
branches = (
|
||||
db.query(Branch)
|
||||
.options(selectinload(Branch.missions))
|
||||
.all()
|
||||
)
|
||||
branch_dependencies = _build_branch_dependencies(branches)
|
||||
completed_missions = _load_user_progress(current_user)
|
||||
mission_titles = dict(db.query(Mission.id, Mission.title).all())
|
||||
|
||||
is_available, reasons = _mission_availability(
|
||||
mission=mission,
|
||||
user=current_user,
|
||||
completed_missions=completed_missions,
|
||||
branch_dependencies=branch_dependencies,
|
||||
mission_titles=mission_titles,
|
||||
)
|
||||
if not is_available:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="; ".join(reasons))
|
||||
submission = submit_mission(
|
||||
db=db,
|
||||
user=current_user,
|
||||
|
|
|
|||
60
backend/app/api/routes/onboarding.py
Normal file
60
backend/app/api/routes/onboarding.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Онбординг и космический лор."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.onboarding import (
|
||||
OnboardingCompleteRequest,
|
||||
OnboardingOverview,
|
||||
OnboardingSlideRead,
|
||||
OnboardingStateRead,
|
||||
)
|
||||
from app.services.onboarding import complete_slide, get_overview
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/onboarding", tags=["onboarding"])
|
||||
|
||||
|
||||
@router.get("/", response_model=OnboardingOverview, summary="Лор и прогресс онбординга")
|
||||
def read_onboarding(
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> OnboardingOverview:
|
||||
"""Отдаём все слайды вместе с состоянием пользователя."""
|
||||
|
||||
slides, state, next_order = get_overview(db, current_user)
|
||||
return OnboardingOverview(
|
||||
slides=[OnboardingSlideRead.model_validate(slide) for slide in slides],
|
||||
state=OnboardingStateRead(
|
||||
last_completed_order=state.last_completed_order,
|
||||
is_completed=state.is_completed,
|
||||
),
|
||||
next_order=next_order,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/complete", response_model=OnboardingStateRead, summary="Завершаем шаг онбординга")
|
||||
def complete_onboarding_step(
|
||||
payload: OnboardingCompleteRequest,
|
||||
*,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> OnboardingStateRead:
|
||||
"""Фиксируем прохождение очередного шага лора."""
|
||||
|
||||
try:
|
||||
state = complete_slide(db, current_user, payload.order)
|
||||
except ValueError as exc: # pragma: no cover - ошибка бизнес-логики
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
return OnboardingStateRead(
|
||||
last_completed_order=state.last_completed_order,
|
||||
is_completed=state.is_completed,
|
||||
)
|
||||
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
"""Конфигурация приложения и загрузка окружения."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
|
@ -20,7 +23,7 @@ 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"]
|
||||
|
||||
sqlite_path: Path = Path("/data/app.db")
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.api.routes import admin, auth, journal, missions, store, users
|
||||
from app.api.routes import admin, auth, journal, missions, onboarding, store, users
|
||||
from app.core.config import settings
|
||||
from app.db.session import engine
|
||||
from app.models.base import Base
|
||||
|
|
@ -32,6 +32,7 @@ app.include_router(auth.router)
|
|||
app.include_router(users.router)
|
||||
app.include_router(missions.router)
|
||||
app.include_router(journal.router)
|
||||
app.include_router(onboarding.router)
|
||||
app.include_router(store.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from .artifact import Artifact # noqa: F401
|
|||
from .branch import Branch, BranchMission # noqa: F401
|
||||
from .journal import JournalEntry # noqa: F401
|
||||
from .mission import Mission, MissionCompetencyReward, MissionPrerequisite, MissionSubmission # noqa: F401
|
||||
from .onboarding import OnboardingSlide, OnboardingState # noqa: F401
|
||||
from .rank import Rank, RankCompetencyRequirement, RankMissionRequirement # noqa: F401
|
||||
from .store import Order, StoreItem # noqa: F401
|
||||
from .user import Competency, User, UserArtifact, UserCompetency # noqa: F401
|
||||
|
|
@ -17,6 +18,8 @@ __all__ = [
|
|||
"MissionCompetencyReward",
|
||||
"MissionPrerequisite",
|
||||
"MissionSubmission",
|
||||
"OnboardingSlide",
|
||||
"OnboardingState",
|
||||
"Rank",
|
||||
"RankCompetencyRequirement",
|
||||
"RankMissionRequirement",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
|
@ -30,7 +31,7 @@ class JournalEntry(Base, TimestampMixin):
|
|||
event_type: Mapped[JournalEventType] = mapped_column(SQLEnum(JournalEventType), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON)
|
||||
payload: Mapped[Optional[dict]] = mapped_column(JSON)
|
||||
xp_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
mana_delta: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
|
|
|
|||
40
backend/app/models/onboarding.py
Normal file
40
backend/app/models/onboarding.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"""Модели онбординга и лора."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
|
||||
class OnboardingSlide(Base, TimestampMixin):
|
||||
"""Контентный слайд онбординга, который видит пилот."""
|
||||
|
||||
__tablename__ = "onboarding_slides"
|
||||
__table_args__ = (UniqueConstraint("order", name="uq_onboarding_slide_order"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
media_url: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
cta_text: Mapped[Optional[str]] = mapped_column(String(120))
|
||||
cta_link: Mapped[Optional[str]] = mapped_column(String(512))
|
||||
|
||||
|
||||
class OnboardingState(Base, TimestampMixin):
|
||||
"""Прогресс пользователя по онбордингу."""
|
||||
|
||||
__tablename__ = "onboarding_states"
|
||||
__table_args__ = (UniqueConstraint("user_id", name="uq_onboarding_state_user"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
last_completed_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
is_completed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="onboarding_state")
|
||||
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import Enum as SQLEnum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
|
@ -45,7 +45,7 @@ class Order(Base, TimestampMixin):
|
|||
status: Mapped[OrderStatus] = mapped_column(
|
||||
SQLEnum(OrderStatus), default=OrderStatus.CREATED, nullable=False
|
||||
)
|
||||
comment: Mapped[str | None] = mapped_column(Text)
|
||||
comment: Mapped[Optional[str]] = mapped_column(Text)
|
||||
|
||||
user = relationship("User", back_populates="orders")
|
||||
item = relationship("StoreItem", back_populates="orders")
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
# Локальные импорты внизу файла, чтобы избежать циклов типов
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""Типы ролей в системе."""
|
||||
|
|
@ -48,6 +50,9 @@ class User(Base, TimestampMixin):
|
|||
artifacts: Mapped[List["UserArtifact"]] = relationship(
|
||||
"UserArtifact", back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
onboarding_state: Mapped[Optional["OnboardingState"]] = relationship(
|
||||
"OnboardingState", back_populates="user", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
|
||||
|
||||
class CompetencyCategory(str, Enum):
|
||||
|
|
@ -103,3 +108,7 @@ class UserArtifact(Base, TimestampMixin):
|
|||
|
||||
user = relationship("User", back_populates="artifacts")
|
||||
artifact = relationship("Artifact", back_populates="pilots")
|
||||
|
||||
|
||||
# Импорты в конце файла, чтобы отношения корректно инициализировались
|
||||
from app.models.onboarding import OnboardingState # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
|
|
|||
31
backend/app/schemas/admin_stats.py
Normal file
31
backend/app/schemas/admin_stats.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Сводные метрики для HR."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SubmissionStats(BaseModel):
|
||||
"""Структура статистики по отправкам миссий."""
|
||||
|
||||
pending: int
|
||||
approved: int
|
||||
rejected: int
|
||||
|
||||
|
||||
class BranchCompletionStat(BaseModel):
|
||||
"""Завершённость ветки."""
|
||||
|
||||
branch_id: int
|
||||
branch_title: str
|
||||
completion_rate: float
|
||||
|
||||
|
||||
class AdminDashboardStats(BaseModel):
|
||||
"""Ответ с основными метриками."""
|
||||
|
||||
total_users: int
|
||||
active_pilots: int
|
||||
average_completed_missions: float
|
||||
submission_stats: SubmissionStats
|
||||
branch_completion: list[BranchCompletionStat]
|
||||
|
|
@ -14,6 +14,25 @@ class ArtifactRead(BaseModel):
|
|||
name: str
|
||||
description: str
|
||||
rarity: ArtifactRarity
|
||||
image_url: str | None = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ArtifactCreate(BaseModel):
|
||||
"""Создание артефакта."""
|
||||
|
||||
name: str
|
||||
description: str
|
||||
rarity: ArtifactRarity
|
||||
image_url: str | None = None
|
||||
|
||||
|
||||
class ArtifactUpdate(BaseModel):
|
||||
"""Обновление артефакта."""
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
rarity: ArtifactRarity | None = None
|
||||
image_url: str | None = None
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ class BranchMissionRead(BaseModel):
|
|||
mission_id: int
|
||||
mission_title: str
|
||||
order: int
|
||||
is_completed: bool = False
|
||||
is_available: bool = False
|
||||
|
||||
|
||||
class BranchRead(BaseModel):
|
||||
|
|
@ -21,6 +23,8 @@ class BranchRead(BaseModel):
|
|||
description: str
|
||||
category: str
|
||||
missions: list[BranchMissionRead]
|
||||
total_missions: int = 0
|
||||
completed_missions: int = 0
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
|
|||
|
|
@ -24,3 +24,19 @@ class JournalEntryRead(BaseModel):
|
|||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class LeaderboardEntry(BaseModel):
|
||||
"""Участник таблицы лидеров."""
|
||||
|
||||
user_id: int
|
||||
full_name: str
|
||||
xp_delta: int
|
||||
mana_delta: int
|
||||
|
||||
|
||||
class LeaderboardResponse(BaseModel):
|
||||
"""Ответ для таблицы лидеров."""
|
||||
|
||||
period: str
|
||||
entries: list[LeaderboardEntry]
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models.mission import MissionDifficulty, SubmissionStatus
|
||||
|
||||
|
|
@ -20,6 +20,8 @@ class MissionBase(BaseModel):
|
|||
mana_reward: int
|
||||
difficulty: MissionDifficulty
|
||||
is_active: bool
|
||||
is_available: bool = True
|
||||
locked_reasons: list[str] = Field(default_factory=list)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -95,6 +97,7 @@ class MissionSubmissionCreate(BaseModel):
|
|||
class MissionSubmissionRead(BaseModel):
|
||||
"""Получение статуса отправки."""
|
||||
|
||||
id: int
|
||||
mission_id: int
|
||||
status: SubmissionStatus
|
||||
comment: Optional[str]
|
||||
|
|
|
|||
44
backend/app/schemas/onboarding.py
Normal file
44
backend/app/schemas/onboarding.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"""Схемы для онбординга."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OnboardingSlideRead(BaseModel):
|
||||
"""Отдельный слайд онбординга."""
|
||||
|
||||
id: int
|
||||
order: int
|
||||
title: str
|
||||
body: str
|
||||
media_url: Optional[str]
|
||||
cta_text: Optional[str]
|
||||
cta_link: Optional[str]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class OnboardingStateRead(BaseModel):
|
||||
"""Прогресс пользователя."""
|
||||
|
||||
last_completed_order: int
|
||||
is_completed: bool
|
||||
|
||||
|
||||
class OnboardingOverview(BaseModel):
|
||||
"""Полный ответ с прогрессом и контентом."""
|
||||
|
||||
slides: list[OnboardingSlideRead]
|
||||
state: OnboardingStateRead
|
||||
next_order: int | None
|
||||
|
||||
|
||||
class OnboardingCompleteRequest(BaseModel):
|
||||
"""Запрос на фиксацию завершения шага."""
|
||||
|
||||
order: int
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
|
|||
|
||||
from app.models.journal import JournalEventType
|
||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||
from app.models.user import User, UserCompetency
|
||||
from app.models.user import User, UserArtifact, UserCompetency
|
||||
from app.services.journal import log_event
|
||||
from app.services.rank import apply_rank_upgrade
|
||||
|
||||
|
|
@ -82,6 +82,21 @@ def approve_submission(db: Session, submission: MissionSubmission) -> MissionSub
|
|||
|
||||
_increase_competencies(db, user, submission.mission)
|
||||
|
||||
if submission.mission.artifact_id:
|
||||
already_has = any(
|
||||
artifact.artifact_id == submission.mission.artifact_id for artifact in user.artifacts
|
||||
)
|
||||
if not already_has:
|
||||
db.add(UserArtifact(user_id=user.id, artifact_id=submission.mission.artifact_id))
|
||||
log_event(
|
||||
db,
|
||||
user_id=user.id,
|
||||
event_type=JournalEventType.MISSION_COMPLETED,
|
||||
title=f"Получен артефакт за миссию «{submission.mission.title}»",
|
||||
description="Новый артефакт добавлен в коллекцию.",
|
||||
payload={"artifact_id": submission.mission.artifact_id},
|
||||
)
|
||||
|
||||
db.add_all([submission, user])
|
||||
db.commit()
|
||||
db.refresh(submission)
|
||||
|
|
|
|||
61
backend/app/services/onboarding.py
Normal file
61
backend/app/services/onboarding.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Сервисный слой для онбординга."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.onboarding import OnboardingSlide, OnboardingState
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
def _ensure_state(db: Session, user: User) -> OnboardingState:
|
||||
"""Гарантируем наличие записи о прогрессе."""
|
||||
|
||||
if user.onboarding_state:
|
||||
return user.onboarding_state
|
||||
|
||||
state = OnboardingState(user_id=user.id, last_completed_order=0, is_completed=False)
|
||||
db.add(state)
|
||||
db.flush()
|
||||
db.refresh(state)
|
||||
return state
|
||||
|
||||
|
||||
def get_overview(db: Session, user: User) -> tuple[list[OnboardingSlide], OnboardingState, int | None]:
|
||||
"""Возвращаем все слайды и текущий прогресс."""
|
||||
|
||||
slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all()
|
||||
state = _ensure_state(db, user)
|
||||
|
||||
next_slide = next((slide for slide in slides if slide.order > state.last_completed_order), None)
|
||||
next_order: int | None = next_slide.order if next_slide else None
|
||||
return slides, state, next_order
|
||||
|
||||
|
||||
def complete_slide(db: Session, user: User, order: int) -> OnboardingState:
|
||||
"""Фиксируем завершение шага, если это корректный порядок."""
|
||||
|
||||
slides = db.query(OnboardingSlide).order_by(OnboardingSlide.order).all()
|
||||
if not slides:
|
||||
raise ValueError("Онбординг ещё не настроен")
|
||||
|
||||
state = _ensure_state(db, user)
|
||||
|
||||
allowed_orders = [slide.order for slide in slides]
|
||||
if order not in allowed_orders:
|
||||
raise ValueError("Неизвестный шаг онбординга")
|
||||
|
||||
if order <= state.last_completed_order:
|
||||
return state
|
||||
|
||||
expected_order = next((value for value in allowed_orders if value > state.last_completed_order), None)
|
||||
if expected_order is None or order != expected_order:
|
||||
raise ValueError("Сначала завершите предыдущие шаги")
|
||||
|
||||
state.last_completed_order = order
|
||||
state.is_completed = order == allowed_orders[-1]
|
||||
db.add(state)
|
||||
db.commit()
|
||||
db.refresh(state)
|
||||
return state
|
||||
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.artifact import Artifact, ArtifactRarity
|
||||
from app.models.mission import Mission, MissionSubmission, SubmissionStatus
|
||||
from app.models.user import User, UserRole
|
||||
from app.services.mission import approve_submission
|
||||
|
|
@ -33,3 +34,44 @@ def test_approve_submission_rewards(db_session):
|
|||
assert user.xp == mission.xp_reward
|
||||
assert user.mana == mission.mana_reward
|
||||
assert submission.status == SubmissionStatus.APPROVED
|
||||
|
||||
|
||||
def test_approve_submission_grants_artifact(db_session):
|
||||
"""При наличии артефакта пользователь получает его единожды."""
|
||||
|
||||
artifact = Artifact(
|
||||
name="Значок испытателя",
|
||||
description="Выдан за успешную миссию",
|
||||
rarity=ArtifactRarity.RARE,
|
||||
)
|
||||
mission = Mission(
|
||||
title="Тестовая миссия",
|
||||
description="Описание",
|
||||
xp_reward=50,
|
||||
mana_reward=20,
|
||||
artifact=artifact,
|
||||
)
|
||||
user = User(
|
||||
email="artifact@alabuga.space",
|
||||
full_name="Пилот",
|
||||
role=UserRole.PILOT,
|
||||
hashed_password="hash",
|
||||
)
|
||||
db_session.add_all([artifact, mission, user])
|
||||
db_session.flush()
|
||||
|
||||
submission = MissionSubmission(user_id=user.id, mission_id=mission.id)
|
||||
db_session.add(submission)
|
||||
db_session.commit()
|
||||
db_session.refresh(submission)
|
||||
|
||||
approve_submission(db_session, submission)
|
||||
db_session.refresh(user)
|
||||
|
||||
assert len(user.artifacts) == 1
|
||||
assert user.artifacts[0].artifact_id == artifact.id
|
||||
|
||||
# Повторное одобрение не создаёт дубли
|
||||
approve_submission(db_session, submission)
|
||||
db_session.refresh(user)
|
||||
assert len(user.artifacts) == 1
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { AdminBranchManager } from '../../components/admin/AdminBranchManager';
|
||||
import { AdminMissionManager } from '../../components/admin/AdminMissionManager';
|
||||
import { AdminRankManager } from '../../components/admin/AdminRankManager';
|
||||
import { AdminArtifactManager } from '../../components/admin/AdminArtifactManager';
|
||||
import { AdminSubmissionCard } from '../../components/admin/AdminSubmissionCard';
|
||||
import { apiFetch } from '../../lib/api';
|
||||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
|
||||
interface Submission {
|
||||
id: number;
|
||||
mission_id: number;
|
||||
status: string;
|
||||
comment: string | null;
|
||||
|
|
@ -51,18 +54,40 @@ interface ArtifactSummary {
|
|||
name: string;
|
||||
description: string;
|
||||
rarity: string;
|
||||
image_url?: string | null;
|
||||
}
|
||||
|
||||
interface SubmissionStats {
|
||||
pending: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
}
|
||||
|
||||
interface BranchCompletionStat {
|
||||
branch_id: number;
|
||||
branch_title: string;
|
||||
completion_rate: number;
|
||||
}
|
||||
|
||||
interface AdminStats {
|
||||
total_users: number;
|
||||
active_pilots: number;
|
||||
average_completed_missions: number;
|
||||
submission_stats: SubmissionStats;
|
||||
branch_completion: BranchCompletionStat[];
|
||||
}
|
||||
|
||||
export default async function AdminPage() {
|
||||
const token = await getDemoToken('hr');
|
||||
|
||||
const [submissions, missions, branches, ranks, competencies, artifacts] = await Promise.all([
|
||||
const [submissions, missions, branches, ranks, competencies, artifacts, stats] = 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 })
|
||||
apiFetch<ArtifactSummary[]>('/api/admin/artifacts', { authToken: token }),
|
||||
apiFetch<AdminStats>('/api/admin/stats', { authToken: token })
|
||||
]);
|
||||
|
||||
return (
|
||||
|
|
@ -73,6 +98,46 @@ export default async function AdminPage() {
|
|||
</p>
|
||||
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', gap: '1.5rem' }}>
|
||||
<div className="card" style={{ gridColumn: '1 / -1', display: 'grid', gap: '1rem' }}>
|
||||
<h3>Сводка</h3>
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
|
||||
<div className="card" style={{ marginBottom: 0 }}>
|
||||
<span className="badge">Пилоты</span>
|
||||
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.total_users}</p>
|
||||
<small style={{ color: 'var(--text-muted)' }}>Всего зарегистрировано</small>
|
||||
</div>
|
||||
<div className="card" style={{ marginBottom: 0 }}>
|
||||
<span className="badge">Активность</span>
|
||||
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.active_pilots}</p>
|
||||
<small style={{ color: 'var(--text-muted)' }}>Закрыли хотя бы одну миссию</small>
|
||||
</div>
|
||||
<div className="card" style={{ marginBottom: 0 }}>
|
||||
<span className="badge">Средний прогресс</span>
|
||||
<p style={{ fontSize: '2rem', margin: '1rem 0 0' }}>{stats.average_completed_missions.toFixed(1)}</p>
|
||||
<small style={{ color: 'var(--text-muted)' }}>Миссий на пилота</small>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: '1rem' }}>
|
||||
<div className="card" style={{ marginBottom: 0 }}>
|
||||
<strong>Очередь модерации</strong>
|
||||
<p style={{ marginTop: '0.5rem' }}>На проверке: {stats.submission_stats.pending}</p>
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Одобрено: {stats.submission_stats.approved} · Отклонено: {stats.submission_stats.rejected}
|
||||
</small>
|
||||
</div>
|
||||
<div className="card" style={{ marginBottom: 0 }}>
|
||||
<strong>Завершённость веток</strong>
|
||||
<ul style={{ listStyle: 'none', margin: '0.75rem 0 0', padding: 0 }}>
|
||||
{stats.branch_completion.map((branchStat) => (
|
||||
<li key={branchStat.branch_id} style={{ marginBottom: '0.25rem' }}>
|
||||
{branchStat.branch_title}: {(branchStat.completion_rate * 100).toFixed(0)}%
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ gridColumn: '1 / -1' }}>
|
||||
<h3>Очередь модерации</h3>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
|
|
@ -80,22 +145,7 @@ export default async function AdminPage() {
|
|||
</p>
|
||||
<div className="grid" style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '1rem' }}>
|
||||
{submissions.map((submission) => (
|
||||
<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 && (
|
||||
<p>
|
||||
Доказательство:{' '}
|
||||
<a href={submission.proof_url} target="_blank" rel="noreferrer">
|
||||
открыть
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
||||
</small>
|
||||
</div>
|
||||
<AdminSubmissionCard key={submission.id} submission={submission} token={token} />
|
||||
))}
|
||||
{submissions.length === 0 && <p>Очередь пуста — все миссии проверены.</p>}
|
||||
</div>
|
||||
|
|
@ -111,6 +161,7 @@ export default async function AdminPage() {
|
|||
artifacts={artifacts}
|
||||
/>
|
||||
<AdminRankManager token={token} ranks={ranks} missions={missions} competencies={competencies} />
|
||||
<AdminArtifactManager token={token} artifacts={artifacts} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,22 +12,62 @@ interface JournalEntry {
|
|||
created_at: string;
|
||||
}
|
||||
|
||||
interface LeaderboardEntry {
|
||||
user_id: number;
|
||||
full_name: string;
|
||||
xp_delta: number;
|
||||
mana_delta: number;
|
||||
}
|
||||
|
||||
interface LeaderboardResponse {
|
||||
period: string;
|
||||
entries: LeaderboardEntry[];
|
||||
}
|
||||
|
||||
async function fetchJournal() {
|
||||
const token = await getDemoToken();
|
||||
const entries = await apiFetch<JournalEntry[]>('/api/journal/', { authToken: token });
|
||||
return entries;
|
||||
const [entries, week, month, year] = await Promise.all([
|
||||
apiFetch<JournalEntry[]>('/api/journal/', { authToken: token }),
|
||||
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=week', { authToken: token }),
|
||||
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=month', { authToken: token }),
|
||||
apiFetch<LeaderboardResponse>('/api/journal/leaderboard?period=year', { authToken: token })
|
||||
]);
|
||||
return { entries, leaderboards: [week, month, year] };
|
||||
}
|
||||
|
||||
export default async function JournalPage() {
|
||||
const entries = await fetchJournal();
|
||||
const { entries, leaderboards } = await fetchJournal();
|
||||
|
||||
return (
|
||||
<section>
|
||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', gap: '2rem' }}>
|
||||
<div>
|
||||
<h2>Бортовой журнал</h2>
|
||||
<p style={{ color: 'var(--text-muted)' }}>
|
||||
Здесь фиксируется каждый шаг: отправка миссий, повышение ранга, получение артефактов и покупки в магазине.
|
||||
</p>
|
||||
<JournalTimeline entries={entries} />
|
||||
</div>
|
||||
<aside className="card" style={{ position: 'sticky', top: '1.5rem' }}>
|
||||
<h3>ТОП экипажа</h3>
|
||||
{leaderboards.map((board) => (
|
||||
<div key={board.period} style={{ marginBottom: '1rem' }}>
|
||||
<strong style={{ textTransform: 'capitalize' }}>
|
||||
{board.period === 'week' ? 'Неделя' : board.period === 'month' ? 'Месяц' : 'Год'}
|
||||
</strong>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0.5rem 0 0' }}>
|
||||
{board.entries.length === 0 && <li style={{ color: 'var(--text-muted)' }}>Пока нет лидеров.</li>}
|
||||
{board.entries.map((entry, index) => (
|
||||
<li key={entry.user_id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem' }}>
|
||||
<span>
|
||||
#{index + 1} {entry.full_name}
|
||||
</span>
|
||||
<span>{entry.xp_delta} XP · {entry.mana_delta} ⚡</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,29 @@ export const metadata: Metadata = {
|
|||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>
|
||||
<body style={{ backgroundAttachment: 'fixed' }}>
|
||||
<StyledComponentsRegistry>
|
||||
<header style={{ padding: '1.5rem', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<header
|
||||
style={{
|
||||
padding: '1.5rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(108,92,231,0.25), rgba(0,184,148,0.15))',
|
||||
borderBottom: '1px solid rgba(162, 155, 254, 0.2)',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.25)'
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ margin: 0 }}>Mission Control</h1>
|
||||
<h1 style={{ margin: 0, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Mission Control</h1>
|
||||
<p style={{ margin: 0, color: 'var(--text-muted)' }}>
|
||||
Путь пилота от искателя до члена экипажа
|
||||
Путь пилота от искателя до командира космической эскадры
|
||||
</p>
|
||||
</div>
|
||||
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
|
||||
<nav style={{ display: 'flex', gap: '1rem', alignItems: 'center', fontWeight: 500 }}>
|
||||
<a href="/">Дашборд</a>
|
||||
<a href="/onboarding">Онбординг</a>
|
||||
<a href="/missions">Миссии</a>
|
||||
<a href="/journal">Журнал</a>
|
||||
<a href="/store">Магазин</a>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ interface MissionDetail {
|
|||
competency_name: string;
|
||||
level_delta: number;
|
||||
}>;
|
||||
is_available: boolean;
|
||||
locked_reasons: string[];
|
||||
}
|
||||
|
||||
async function fetchMission(id: number) {
|
||||
|
|
@ -41,6 +43,16 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
<p style={{ marginTop: '1rem' }}>
|
||||
Награда: {mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||
</p>
|
||||
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
||||
<div className="card" style={{ border: '1px solid rgba(255, 118, 117, 0.5)', background: 'rgba(255,118,117,0.1)' }}>
|
||||
<strong>Миссия заблокирована</strong>
|
||||
<ul style={{ marginTop: '0.5rem' }}>
|
||||
{mission.locked_reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="card">
|
||||
<h3>Компетенции</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
|
|
@ -52,7 +64,7 @@ export default async function MissionPage({ params }: MissionPageProps) {
|
|||
{mission.competency_rewards.length === 0 && <li>Нет прокачки компетенций.</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<MissionSubmissionForm missionId={mission.id} token={token} />
|
||||
<MissionSubmissionForm missionId={mission.id} token={token} locked={!mission.is_available} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,35 @@ import { apiFetch } from '../../lib/api';
|
|||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
import { MissionList, MissionSummary } from '../../components/MissionList';
|
||||
|
||||
interface BranchMission {
|
||||
mission_id: number;
|
||||
mission_title: string;
|
||||
order: number;
|
||||
is_completed: boolean;
|
||||
is_available: boolean;
|
||||
}
|
||||
|
||||
interface BranchOverview {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
missions: BranchMission[];
|
||||
total_missions: number;
|
||||
completed_missions: number;
|
||||
}
|
||||
|
||||
async function fetchMissions() {
|
||||
const token = await getDemoToken();
|
||||
const missions = await apiFetch<MissionSummary[]>('/api/missions/', { authToken: token });
|
||||
return missions;
|
||||
const [missions, branches] = await Promise.all([
|
||||
apiFetch<MissionSummary[]>('/api/missions/', { authToken: token }),
|
||||
apiFetch<BranchOverview[]>('/api/missions/branches', { authToken: token })
|
||||
]);
|
||||
return { missions, branches };
|
||||
}
|
||||
|
||||
export default async function MissionsPage() {
|
||||
const missions = await fetchMissions();
|
||||
const { missions, branches } = await fetchMissions();
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
|
@ -18,6 +39,45 @@ export default async function MissionsPage() {
|
|||
Список обновляется в реальном времени и зависит от вашего ранга и прогресса. HR может добавлять новые
|
||||
задания в админ-панели.
|
||||
</p>
|
||||
<div className="grid" style={{ marginBottom: '2rem' }}>
|
||||
{branches.map((branch) => {
|
||||
const progress = branch.total_missions
|
||||
? Math.round((branch.completed_missions / branch.total_missions) * 100)
|
||||
: 0;
|
||||
const nextMission = branch.missions.find((mission) => !mission.is_completed);
|
||||
return (
|
||||
<div key={branch.id} className="card">
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{branch.title}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{branch.description}</p>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<small>Прогресс ветки: {progress}%</small>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
height: '8px',
|
||||
borderRadius: '999px',
|
||||
background: 'rgba(162, 155, 254, 0.2)',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
height: '100%',
|
||||
background: 'linear-gradient(90deg, var(--accent), #00b894)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{nextMission && (
|
||||
<p style={{ marginTop: '1rem', color: 'var(--text-muted)' }}>
|
||||
Следующая миссия: «{nextMission.mission_title}»
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<MissionList missions={missions} />
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
50
frontend/src/app/onboarding/page.tsx
Normal file
50
frontend/src/app/onboarding/page.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { apiFetch } from '../../lib/api';
|
||||
import { getDemoToken } from '../../lib/demo-auth';
|
||||
import { OnboardingCarousel, OnboardingSlide } from '../../components/OnboardingCarousel';
|
||||
|
||||
interface OnboardingState {
|
||||
last_completed_order: number;
|
||||
is_completed: boolean;
|
||||
}
|
||||
|
||||
interface OnboardingResponse {
|
||||
slides: OnboardingSlide[];
|
||||
state: OnboardingState;
|
||||
next_order: number | null;
|
||||
}
|
||||
|
||||
async function fetchOnboarding() {
|
||||
const token = await getDemoToken();
|
||||
const data = await apiFetch<OnboardingResponse>('/api/onboarding/', { authToken: token });
|
||||
return { token, data };
|
||||
}
|
||||
|
||||
export default async function OnboardingPage() {
|
||||
const { token, data } = await fetchOnboarding();
|
||||
|
||||
return (
|
||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr', alignItems: 'start', gap: '2rem' }}>
|
||||
<div>
|
||||
<OnboardingCarousel
|
||||
token={token}
|
||||
slides={data.slides}
|
||||
initialCompletedOrder={data.state.last_completed_order}
|
||||
nextOrder={data.next_order}
|
||||
/>
|
||||
</div>
|
||||
<aside className="card" style={{ position: 'sticky', top: '1.5rem' }}>
|
||||
<h3>Бортовой навигатор</h3>
|
||||
<p style={{ color: 'var(--text-muted)', lineHeight: 1.6 }}>
|
||||
Онбординг знакомит вас с космической культурой «Алабуги» и объясняет, как миссии превращают карьерные шаги в
|
||||
единый маршрут. Каждый шаг открывает новые подсказки и призы в магазине.
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', margin: '1rem 0 0', padding: 0, color: 'var(--text-muted)' }}>
|
||||
<li>• Читайте лор и переходите к миссиям напрямую.</li>
|
||||
<li>• Следите за прогрессом — статус сохраняется автоматически.</li>
|
||||
<li>• Доступ к веткам миссий открывается по мере выполнения шагов.</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -22,18 +22,29 @@ interface RankResponse {
|
|||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
required_xp: 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 currentRank = ranks.find((rank) => rank.id === profile.current_rank_id);
|
||||
return { token, profile, currentRank };
|
||||
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;
|
||||
|
||||
return { token, profile, currentRank, nextRank, progress, target };
|
||||
}
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const { token, profile, currentRank } = await fetchProfile();
|
||||
const { token, profile, currentRank, nextRank, progress, target } = await fetchProfile();
|
||||
|
||||
return (
|
||||
<section className="grid" style={{ gridTemplateColumns: '2fr 1fr' }}>
|
||||
|
|
@ -45,6 +56,9 @@ export default async function DashboardPage() {
|
|||
rank={currentRank}
|
||||
competencies={profile.competencies}
|
||||
artifacts={profile.artifacts}
|
||||
nextRankTitle={nextRank?.title}
|
||||
xpProgress={progress}
|
||||
xpTarget={target}
|
||||
/>
|
||||
</div>
|
||||
<aside className="card">
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ export interface MissionSummary {
|
|||
mana_reward: number;
|
||||
difficulty: string;
|
||||
is_active: boolean;
|
||||
is_available: boolean;
|
||||
locked_reasons: string[];
|
||||
}
|
||||
|
||||
const Card = styled.div`
|
||||
|
|
@ -31,11 +33,21 @@ export function MissionList({ missions }: { missions: MissionSummary[] }) {
|
|||
<span className="badge">{mission.difficulty}</span>
|
||||
<h3 style={{ marginBottom: '0.5rem' }}>{mission.title}</h3>
|
||||
<p style={{ color: 'var(--text-muted)', minHeight: '3rem' }}>{mission.description}</p>
|
||||
<p style={{ marginTop: '1rem' }}>
|
||||
{mission.xp_reward} XP · {mission.mana_reward} ⚡
|
||||
</p>
|
||||
<a className="primary" style={{ display: 'inline-block', marginTop: '1rem' }} href={`/missions/${mission.id}`}>
|
||||
Открыть брифинг
|
||||
<p style={{ marginTop: '1rem' }}>{mission.xp_reward} XP · {mission.mana_reward} ⚡</p>
|
||||
{!mission.is_available && mission.locked_reasons.length > 0 && (
|
||||
<p style={{ color: 'var(--error)', fontSize: '0.85rem' }}>{mission.locked_reasons[0]}</p>
|
||||
)}
|
||||
<a
|
||||
className={mission.is_available ? 'primary' : 'secondary'}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginTop: '1rem',
|
||||
pointerEvents: mission.is_available ? 'auto' : 'none',
|
||||
opacity: mission.is_available ? 1 : 0.5
|
||||
}}
|
||||
href={mission.is_available ? `/missions/${mission.id}` : '#'}
|
||||
>
|
||||
{mission.is_available ? 'Открыть брифинг' : 'Заблокировано'}
|
||||
</a>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import { apiFetch } from '../lib/api';
|
|||
interface MissionSubmissionFormProps {
|
||||
missionId: number;
|
||||
token?: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFormProps) {
|
||||
export function MissionSubmissionForm({ missionId, token, locked = false }: MissionSubmissionFormProps) {
|
||||
const [comment, setComment] = useState('');
|
||||
const [proofUrl, setProofUrl] = useState('');
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
|
|
@ -21,6 +22,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
|||
return;
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
setStatus('Миссия пока недоступна — выполните предыдущие условия.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
|
|
@ -52,6 +58,7 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
|||
rows={4}
|
||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||
placeholder="Опишите, что сделали."
|
||||
disabled={locked}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem' }}>
|
||||
|
|
@ -62,10 +69,11 @@ export function MissionSubmissionForm({ missionId, token }: MissionSubmissionFor
|
|||
onChange={(event) => setProofUrl(event.target.value)}
|
||||
style={{ width: '100%', marginTop: '0.5rem', borderRadius: '12px', padding: '0.75rem' }}
|
||||
placeholder="https://..."
|
||||
disabled={locked}
|
||||
/>
|
||||
</label>
|
||||
<button className="primary" type="submit" disabled={loading}>
|
||||
{loading ? 'Отправляем...' : 'Отправить HR'}
|
||||
<button className="primary" type="submit" disabled={loading || locked}>
|
||||
{locked ? 'Недоступно' : loading ? 'Отправляем...' : 'Отправить HR'}
|
||||
</button>
|
||||
{status && <p style={{ marginTop: '1rem', color: 'var(--accent-light)' }}>{status}</p>}
|
||||
</form>
|
||||
|
|
|
|||
123
frontend/src/components/OnboardingCarousel.tsx
Normal file
123
frontend/src/components/OnboardingCarousel.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { apiFetch } from '../lib/api';
|
||||
|
||||
export interface OnboardingSlide {
|
||||
id: number;
|
||||
order: number;
|
||||
title: string;
|
||||
body: string;
|
||||
media_url?: string | null;
|
||||
cta_text?: string | null;
|
||||
cta_link?: string | null;
|
||||
}
|
||||
|
||||
interface OnboardingCarouselProps {
|
||||
token?: string;
|
||||
slides: OnboardingSlide[];
|
||||
initialCompletedOrder: number;
|
||||
nextOrder: number | null;
|
||||
}
|
||||
|
||||
export function OnboardingCarousel({ token, slides, initialCompletedOrder, nextOrder }: OnboardingCarouselProps) {
|
||||
const startIndex = nextOrder ? Math.max(slides.findIndex((slide) => slide.order === nextOrder), 0) : slides.length - 1;
|
||||
const [currentIndex, setCurrentIndex] = useState(Math.max(startIndex, 0));
|
||||
const [completedOrder, setCompletedOrder] = useState(initialCompletedOrder);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const currentSlide = slides[currentIndex];
|
||||
const allCompleted = completedOrder >= (slides.at(-1)?.order ?? 0);
|
||||
|
||||
async function handleComplete() {
|
||||
if (!token || !currentSlide) {
|
||||
setStatus('Не удалось определить пользователя. Попробуйте обновить страницу.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiFetch('/api/onboarding/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ order: currentSlide.order }),
|
||||
authToken: token
|
||||
});
|
||||
setCompletedOrder(currentSlide.order);
|
||||
setStatus('Шаг отмечен как выполненный!');
|
||||
if (currentIndex < slides.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
setStatus(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentSlide) {
|
||||
return <p>Онбординг недоступен. Свяжитесь с HR.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ display: 'grid', gap: '1.5rem', background: 'rgba(17, 22, 51, 0.9)' }}>
|
||||
<header>
|
||||
<span className="badge">Шаг {currentIndex + 1} из {slides.length}</span>
|
||||
<h2 style={{ margin: '0.75rem 0' }}>{currentSlide.title}</h2>
|
||||
<p style={{ color: 'var(--text-muted)', lineHeight: 1.6 }}>{currentSlide.body}</p>
|
||||
</header>
|
||||
|
||||
{currentSlide.media_url && (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '16px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid rgba(162, 155, 254, 0.25)'
|
||||
}}
|
||||
>
|
||||
<img src={currentSlide.media_url} alt={currentSlide.title} style={{ width: '100%', height: 'auto' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
{currentSlide.cta_text && currentSlide.cta_link && (
|
||||
<a className="primary" href={currentSlide.cta_link}>
|
||||
{currentSlide.cta_text}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{!allCompleted && (
|
||||
<button className="primary" onClick={handleComplete} disabled={loading}>
|
||||
{loading ? 'Сохраняем...' : 'Отметить как выполнено'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{status && <small style={{ color: 'var(--accent-light)' }}>{status}</small>}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '0.5rem' }}>
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() => setCurrentIndex(Math.max(currentIndex - 1, 0))}
|
||||
disabled={currentIndex === 0}
|
||||
style={{ cursor: currentIndex === 0 ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() => setCurrentIndex(Math.min(currentIndex + 1, slides.length - 1))}
|
||||
disabled={currentIndex === slides.length - 1}
|
||||
style={{ cursor: currentIndex === slides.length - 1 ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
Далее
|
||||
</button>
|
||||
</div>
|
||||
{allCompleted && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>Вы прошли весь онбординг!</p>}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -28,6 +28,9 @@ export interface ProfileProps {
|
|||
rank?: Rank;
|
||||
competencies: Competency[];
|
||||
artifacts: Artifact[];
|
||||
nextRankTitle?: string;
|
||||
xpProgress: number;
|
||||
xpTarget: number;
|
||||
}
|
||||
|
||||
const Card = styled.div`
|
||||
|
|
@ -54,8 +57,18 @@ const ProgressBar = styled.div<{ value: number }>`
|
|||
}
|
||||
`;
|
||||
|
||||
export function ProgressOverview({ fullName, xp, mana, rank, competencies, artifacts }: ProfileProps) {
|
||||
const xpPercent = Math.min(100, (xp / 500) * 100);
|
||||
export function ProgressOverview({
|
||||
fullName,
|
||||
xp,
|
||||
mana,
|
||||
rank,
|
||||
competencies,
|
||||
artifacts,
|
||||
nextRankTitle,
|
||||
xpProgress,
|
||||
xpTarget
|
||||
}: ProfileProps) {
|
||||
const xpPercent = xpTarget > 0 ? Math.min(100, (xpProgress / xpTarget) * 100) : 100;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
|
@ -64,7 +77,13 @@ export function ProgressOverview({ fullName, xp, mana, rank, competencies, artif
|
|||
<div style={{ marginTop: '1rem' }}>
|
||||
<strong>Опыт:</strong>
|
||||
<ProgressBar value={xpPercent} />
|
||||
<small style={{ color: 'var(--text-muted)' }}>{xp} / 500 XP до следующей цели</small>
|
||||
{nextRankTitle ? (
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Осталось {Math.max(xpTarget - xpProgress, 0)} XP до ранга «{nextRankTitle}»
|
||||
</small>
|
||||
) : (
|
||||
<small style={{ color: 'var(--text-muted)' }}>Вы достигли максимального ранга в демо-версии</small>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: '1rem' }}>
|
||||
<strong>Мана:</strong>
|
||||
|
|
|
|||
192
frontend/src/components/admin/AdminArtifactManager.tsx
Normal file
192
frontend/src/components/admin/AdminArtifactManager.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
'use client';
|
||||
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Artifact = {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
rarity: string;
|
||||
image_url?: string | null;
|
||||
};
|
||||
|
||||
const RARITY_OPTIONS = [
|
||||
{ value: 'common', label: 'Обычный' },
|
||||
{ value: 'rare', label: 'Редкий' },
|
||||
{ value: 'epic', label: 'Эпический' },
|
||||
{ value: 'legendary', label: 'Легендарный' }
|
||||
];
|
||||
|
||||
interface Props {
|
||||
token: string;
|
||||
artifacts: Artifact[];
|
||||
}
|
||||
|
||||
export function AdminArtifactManager({ token, artifacts }: Props) {
|
||||
const router = useRouter();
|
||||
const [selectedId, setSelectedId] = useState<number | 'new'>('new');
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [rarity, setRarity] = useState('rare');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setDescription('');
|
||||
setRarity('rare');
|
||||
setImageUrl('');
|
||||
};
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (value === 'new') {
|
||||
setSelectedId('new');
|
||||
resetForm();
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = Number(value);
|
||||
const artifact = artifacts.find((item) => item.id === id);
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedId(id);
|
||||
setName(artifact.name);
|
||||
setDescription(artifact.description);
|
||||
setRarity(artifact.rarity);
|
||||
setImageUrl(artifact.image_url ?? '');
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
description,
|
||||
rarity,
|
||||
image_url: imageUrl || null
|
||||
};
|
||||
|
||||
try {
|
||||
if (selectedId === 'new') {
|
||||
await apiFetch('/api/admin/artifacts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Артефакт создан');
|
||||
resetForm();
|
||||
} else {
|
||||
await apiFetch(`/api/admin/artifacts/${selectedId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Артефакт обновлён');
|
||||
}
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось сохранить артефакт');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedId === 'new') {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/admin/artifacts/${selectedId}`, {
|
||||
method: 'DELETE',
|
||||
authToken: token
|
||||
});
|
||||
setStatus('Артефакт удалён');
|
||||
setSelectedId('new');
|
||||
resetForm();
|
||||
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>
|
||||
{artifacts.map((artifact) => (
|
||||
<option key={artifact.id} value={artifact.id}>
|
||||
{artifact.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Название
|
||||
<input value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Описание
|
||||
<textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={3} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Редкость
|
||||
<select value={rarity} onChange={(event) => setRarity(event.target.value)}>
|
||||
{RARITY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Изображение (URL)
|
||||
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." />
|
||||
</label>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
|
||||
<button type="submit" className="primary" disabled={loading}>
|
||||
{selectedId === 'new' ? 'Создать артефакт' : 'Сохранить изменения'}
|
||||
</button>
|
||||
{selectedId !== 'new' && (
|
||||
<button type="button" className="secondary" onClick={handleDelete} disabled={loading} style={{ cursor: loading ? 'not-allowed' : 'pointer' }}>
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status && <p style={{ color: 'var(--success)', marginTop: '0.5rem' }}>{status}</p>}
|
||||
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
82
frontend/src/components/admin/AdminSubmissionCard.tsx
Normal file
82
frontend/src/components/admin/AdminSubmissionCard.tsx
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import { apiFetch } from '../../lib/api';
|
||||
|
||||
type Submission = {
|
||||
mission_id: number;
|
||||
status: string;
|
||||
comment: string | null;
|
||||
proof_url: string | null;
|
||||
awarded_xp: number;
|
||||
awarded_mana: number;
|
||||
updated_at: string;
|
||||
id: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
submission: Submission;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function AdminSubmissionCard({ submission, token }: Props) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState<'approve' | 'reject' | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleAction = async (action: 'approve' | 'reject') => {
|
||||
setLoading(action);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/admin/submissions/${submission.id}/${action}`, {
|
||||
method: 'POST',
|
||||
authToken: token
|
||||
});
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Не удалось выполнить действие');
|
||||
} finally {
|
||||
setLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 0 }}>
|
||||
<h4>Миссия #{submission.mission_id}</h4>
|
||||
<p>Статус: {submission.status}</p>
|
||||
{submission.comment && <p>Комментарий пилота: {submission.comment}</p>}
|
||||
{submission.proof_url && (
|
||||
<p>
|
||||
Доказательство:{' '}
|
||||
<a href={submission.proof_url} target="_blank" rel="noreferrer">
|
||||
открыть
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<small style={{ color: 'var(--text-muted)' }}>
|
||||
Обновлено: {new Date(submission.updated_at).toLocaleString('ru-RU')}
|
||||
</small>
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.75rem' }}>
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => handleAction('approve')}
|
||||
disabled={loading === 'approve'}
|
||||
>
|
||||
{loading === 'approve' ? 'Одобряем...' : 'Одобрить'}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() => handleAction('reject')}
|
||||
disabled={loading === 'reject'}
|
||||
style={{ cursor: loading === 'reject' ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
{loading === 'reject' ? 'Отклоняем...' : 'Отклонить'}
|
||||
</button>
|
||||
</div>
|
||||
{error && <p style={{ color: 'var(--error)', marginTop: '0.5rem' }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -21,6 +21,15 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}): P
|
|||
const text = await response.text();
|
||||
throw new Error(`API error ${response.status}: ${text}`);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
const raw = await response.text();
|
||||
return raw as unknown as T;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@
|
|||
--accent-light: #a29bfe;
|
||||
--text: #f5f6fa;
|
||||
--text-muted: #b2bec3;
|
||||
--success: #00b894;
|
||||
--error: #ff7675;
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
|
|
@ -17,6 +19,18 @@ body {
|
|||
var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(#ffffff33 1px, transparent 0);
|
||||
background-size: 60px 60px;
|
||||
opacity: 0.25;
|
||||
pointer-events: none;
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
a {
|
||||
|
|
@ -41,6 +55,28 @@ main {
|
|||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-form input,
|
||||
.admin-form textarea,
|
||||
.admin-form select {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(162, 155, 254, 0.3);
|
||||
background: rgba(8, 11, 26, 0.6);
|
||||
padding: 0.75rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
|
|
@ -71,3 +107,13 @@ main {
|
|||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(162, 155, 254, 0.4);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ from app.models.artifact import Artifact, ArtifactRarity
|
|||
from app.models.branch import Branch, BranchMission
|
||||
from app.models.mission import Mission, MissionCompetencyReward, MissionDifficulty
|
||||
from app.models.rank import Rank, RankCompetencyRequirement, RankMissionRequirement
|
||||
from app.models.onboarding import OnboardingSlide
|
||||
from app.models.store import StoreItem
|
||||
from app.models.user import Competency, CompetencyCategory, User, UserCompetency, UserRole
|
||||
|
||||
|
|
@ -250,6 +251,32 @@ def seed() -> None:
|
|||
]
|
||||
)
|
||||
|
||||
session.add_all(
|
||||
[
|
||||
OnboardingSlide(
|
||||
order=1,
|
||||
title="Добро пожаловать в орбитальный флот",
|
||||
body="Узнайте, как миссии помогают связать карьерные шаги в единую траекторию.",
|
||||
media_url="https://images.nasa.gov/details-PIA12235",
|
||||
cta_text="Перейти к миссиям",
|
||||
cta_link="/missions",
|
||||
),
|
||||
OnboardingSlide(
|
||||
order=2,
|
||||
title="Получайте опыт и ману",
|
||||
body="Выполняя задания, вы накапливаете опыт для повышения ранга и ману для магазина.",
|
||||
media_url="https://images.nasa.gov/details-PIA23499",
|
||||
),
|
||||
OnboardingSlide(
|
||||
order=3,
|
||||
title="Повышайте ранг до члена экипажа",
|
||||
body="Закройте ключевые миссии ветки «Получение оффера» и прокачайте компетенции.",
|
||||
cta_text="Открыть ветку",
|
||||
cta_link="/missions",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
session.commit()
|
||||
DATA_SENTINEL.write_text("seeded")
|
||||
print("Seed data created")
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user