Feat: CRUD курсов

 CourseController (resource)
 CoursePolicy
 Маршруты: /admin/courses
 Blade-шаблоны:
  - index.blade.php (список с карточками)
  - create.blade.php (форма создания)
  - edit.blade.php (форма редактирования)
  - show.blade.php (просмотр)
 Ссылка в сайдбаре
 Загрузка изображений (thumbnail)
 Типы курсов: standard, scorm, h5p

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-03-26 11:31:40 +08:00
parent ca95621856
commit 731eb48537
10 changed files with 523 additions and 1 deletions

View File

@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Course;
use App\Models\CourseCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class CourseController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index(Request $request)
{
Gate::authorize('viewAny', Course::class);
$query = Course::with(['category', 'creator']);
if ($request->filled('category_id')) {
$query->where('category_id', $request->category_id);
}
if ($request->filled('type')) {
$query->where('type', $request->type);
}
if ($request->filled('search')) {
$query->where(function($q) use ($request) {
$q->where('title', 'like', '%' . $request->search . '%')
->orWhere('description', 'like', '%' . $request->search . '%');
});
}
$courses = $query->orderBy('created_at', 'desc')->paginate(20);
$categories = CourseCategory::pluck('name', 'id');
return view('admin.courses.index', compact('courses', 'categories'));
}
public function create()
{
Gate::authorize('create', Course::class);
$categories = CourseCategory::pluck('name', 'id');
return view('admin.courses.create', compact('categories'));
}
public function store(Request $request)
{
Gate::authorize('create', Course::class);
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:courses',
'description' => 'nullable|string',
'objectives' => 'nullable|string',
'category_id' => 'nullable|exists:course_categories,id',
'type' => 'required|in:standard,scorm,h5p',
'thumbnail' => 'nullable|image|max:2048',
'duration_minutes' => 'nullable|integer',
'passing_score' => 'nullable|integer|min:0|max:100',
'has_certificate' => 'boolean',
'is_active' => 'boolean',
]);
$validated['slug'] = $validated['slug'] ?? Str::slug($validated['title']);
$validated['created_by'] = auth()->id();
$validated['is_active'] = $request->boolean('is_active');
$validated['has_certificate'] = $request->boolean('has_certificate');
if ($request->hasFile('thumbnail')) {
$validated['thumbnail'] = $request->file('thumbnail')->store('courses/thumbnails', 'public');
}
$course = Course::create($validated);
return redirect()->route('admin.courses.show', $course)->with('success', 'Курс успешно создан.');
}
public function show(Course $course)
{
Gate::authorize('view', $course);
$course->load(['category', 'creator', 'modules', 'tests']);
return view('admin.courses.show', compact('course'));
}
public function edit(Course $course)
{
Gate::authorize('update', $course);
$categories = CourseCategory::pluck('name', 'id');
return view('admin.courses.edit', compact('course', 'categories'));
}
public function update(Request $request, Course $course)
{
Gate::authorize('update', $course);
$validated = $request->validate([
'title' => 'required|string|max:255',
'slug' => 'nullable|string|max:255|unique:courses,slug,' . $course->id,
'description' => 'nullable|string',
'objectives' => 'nullable|string',
'category_id' => 'nullable|exists:course_categories,id',
'type' => 'required|in:standard,scorm,h5p',
'thumbnail' => 'nullable|image|max:2048',
'duration_minutes' => 'nullable|integer',
'passing_score' => 'nullable|integer|min:0|max:100',
'has_certificate' => 'boolean',
'is_active' => 'boolean',
]);
$validated['slug'] = $validated['slug'] ?? Str::slug($validated['title']);
$validated['is_active'] = $request->boolean('is_active');
$validated['has_certificate'] = $request->boolean('has_certificate');
if ($request->hasFile('thumbnail')) {
if ($course->thumbnail) Storage::disk('public')->delete($course->thumbnail);
$validated['thumbnail'] = $request->file('thumbnail')->store('courses/thumbnails', 'public');
}
$course->update($validated);
return redirect()->route('admin.courses.show', $course)->with('success', 'Курс успешно обновлён.');
}
public function destroy(Course $course)
{
Gate::authorize('delete', $course);
if ($course->thumbnail) Storage::disk('public')->delete($course->thumbnail);
$course->delete();
return redirect()->route('admin.courses.index')->with('success', 'Курс успешно удалён.');
}
}

View File

@ -74,4 +74,9 @@ class Course extends Model
{
return $this->hasMany(CourseRequestItem::class);
}
public function users()
{
return $this->belongsToMany(User::class, 'course_assignments');
}
}

34
app/Policies/CoursePolicy.php Executable file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Policies;
use App\Models\Course;
use App\Models\User;
class CoursePolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole(['Administrator', 'Manager', 'Curator']);
}
public function view(User $user, Course $course): bool
{
return $user->hasRole(['Administrator', 'Manager', 'Curator']);
}
public function create(User $user): bool
{
return $user->hasRole(['Administrator', 'Manager', 'Curator']);
}
public function update(User $user, Course $course): bool
{
return $user->hasRole(['Administrator', 'Manager', 'Curator']);
}
public function delete(User $user, Course $course): bool
{
return $user->hasRole(['Administrator', 'Manager']);
}
}

View File

@ -21,6 +21,7 @@ class AuthServiceProvider extends ServiceProvider
*/
protected $policies = [
CourseCategory::class => CourseCategoryPolicy::class,
Course::class => CoursePolicy::class,
Organization::class => OrganizationPolicy::class,
Group::class => GroupPolicy::class,
User::class => UserPolicy::class,

View File

@ -0,0 +1,89 @@
@extends('layouts.app')
@section('title', 'Добавить курс')
@section('content')
<div class="container-fluid">
<div class="row">
<nav class="col-md-3 col-lg-2 d-md-block sidebar"><div class="position-sticky pt-3">@include('partials._sidebar')</div></nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Добавить курс</h1>
<a href="{{ route('admin.courses.index') }}" class="btn btn-secondary btn-sm">Назад</a>
</div>
<form action="{{ route('admin.courses.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="row">
<div class="col-md-8">
<div class="card shadow-sm mb-3">
<div class="card-body">
<h5>Основная информация</h5>
<div class="mb-3">
<label class="form-label">Название *</label>
<input type="text" name="title" class="form-control @error('title') is-invalid @enderror" value="{{ old('title') }}" required>
@error('title')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label class="form-label">Slug (URL)</label>
<input type="text" name="slug" class="form-control" value="{{ old('slug') }}" placeholder="автоматически">
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea name="description" class="form-control" rows="3">{{ old('description') }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Цели обучения</label>
<textarea name="objectives" class="form-control" rows="2">{{ old('objectives') }}</textarea>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm mb-3">
<div class="card-body">
<h5>Настройки</h5>
<div class="mb-3">
<label class="form-label">Категория</label>
<select name="category_id" class="form-select">
<option value="">Без категории</option>
@foreach($categories as $id => $name)<option value="{{ $id }}" {{ old('category_id') == $id ? 'selected' : '' }}>{{ $name }}</option>@endforeach
</select>
</div>
<div class="mb-3">
<label class="form-label">Тип *</label>
<select name="type" class="form-select">
<option value="standard" {{ old('type') == 'standard' ? 'selected' : '' }}>Стандартный</option>
<option value="scorm" {{ old('type') == 'scorm' ? 'selected' : '' }}>SCORM</option>
<option value="h5p" {{ old('type') == 'h5p' ? 'selected' : '' }}>H5P</option>
</select>
@error('type')<div class="invalid-feedback d-block">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label class="form-label">Длительность (мин)</label>
<input type="number" name="duration_minutes" class="form-control" value="{{ old('duration_minutes', 60) }}">
</div>
<div class="mb-3">
<label class="form-label">Проходной балл (%)</label>
<input type="number" name="passing_score" class="form-control" value="{{ old('passing_score', 70) }}" min="0" max="100">
</div>
<div class="form-check mb-3">
<input type="checkbox" name="has_certificate" value="1" class="form-check-input" {{ old('has_certificate') ? 'checked' : '' }}>
<label class="form-check-label">Выдавать сертификат</label>
</div>
<div class="form-check mb-3">
<input type="checkbox" name="is_active" value="1" class="form-check-input" {{ old('is_active', true) ? 'checked' : '' }}>
<label class="form-check-label">Активен</label>
</div>
<div class="mb-3">
<label class="form-label">Обложка</label>
<input type="file" name="thumbnail" class="form-control" accept="image/*">
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Создать курс</button>
<a href="{{ route('admin.courses.index') }}" class="btn btn-secondary">Отмена</a>
</form>
</main>
</div>
</div>
@endsection

View File

@ -0,0 +1,87 @@
@extends('layouts.app')
@section('title', 'Редактировать курс')
@section('content')
<div class="container-fluid">
<div class="row">
<nav class="col-md-3 col-lg-2 d-md-block sidebar"><div class="position-sticky pt-3">@include('partials._sidebar')</div></nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Редактировать: {{ $course->title }}</h1>
<a href="{{ route('admin.courses.show', $course) }}" class="btn btn-secondary btn-sm">Назад</a>
</div>
<form action="{{ route('admin.courses.update', $course) }}" method="POST" enctype="multipart/form-data">
@csrf @method('PUT')
<div class="row">
<div class="col-md-8">
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Название *</label>
<input type="text" name="title" class="form-control @error('title') is-invalid @enderror" value="{{ old('title', $course->title) }}" required>
@error('title')<div class="invalid-feedback">{{ $message }}</div>@enderror
</div>
<div class="mb-3">
<label class="form-label">Slug</label>
<input type="text" name="slug" class="form-control" value="{{ old('slug', $course->slug) }}">
</div>
<div class="mb-3">
<label class="form-label">Описание</label>
<textarea name="description" class="form-control" rows="3">{{ old('description', $course->description) }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Цели обучения</label>
<textarea name="objectives" class="form-control" rows="2">{{ old('objectives', $course->objectives) }}</textarea>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Категория</label>
<select name="category_id" class="form-select">
<option value="">Без категории</option>
@foreach($categories as $id => $name)<option value="{{ $id }}" {{ old('category_id', $course->category_id) == $id ? 'selected' : '' }}>{{ $name }}</option>@endforeach
</select>
</div>
<div class="mb-3">
<label class="form-label">Тип *</label>
<select name="type" class="form-select">
<option value="standard" {{ old('type', $course->type) == 'standard' ? 'selected' : '' }}>Стандартный</option>
<option value="scorm" {{ old('type', $course->type) == 'scorm' ? 'selected' : '' }}>SCORM</option>
<option value="h5p" {{ old('type', $course->type) == 'h5p' ? 'selected' : '' }}>H5P</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Длительность (мин)</label>
<input type="number" name="duration_minutes" class="form-control" value="{{ old('duration_minutes', $course->duration_minutes) }}">
</div>
<div class="mb-3">
<label class="form-label">Проходной балл (%)</label>
<input type="number" name="passing_score" class="form-control" value="{{ old('passing_score', $course->passing_score) }}" min="0" max="100">
</div>
<div class="form-check mb-3">
<input type="checkbox" name="has_certificate" value="1" class="form-check-input" {{ old('has_certificate', $course->has_certificate) ? 'checked' : '' }}>
<label class="form-check-label">Выдавать сертификат</label>
</div>
<div class="form-check mb-3">
<input type="checkbox" name="is_active" value="1" class="form-check-input" {{ old('is_active', $course->is_active) ? 'checked' : '' }}>
<label class="form-check-label">Активен</label>
</div>
<div class="mb-3">
<label class="form-label">Обложка</label>
<input type="file" name="thumbnail" class="form-control" accept="image/*">
@if($course->thumbnail)<small class="text-muted">Текущая: {{ basename($course->thumbnail) }}</small>@endif
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{{ route('admin.courses.show', $course) }}" class="btn btn-secondary">Отмена</a>
</form>
</main>
</div>
</div>
@endsection

View File

@ -0,0 +1,80 @@
@extends('layouts.app')
@section('title', 'Курсы')
@section('content')
<div class="container-fluid">
<div class="row">
<nav class="col-md-3 col-lg-2 d-md-block sidebar"><div class="position-sticky pt-3">@include('partials._sidebar')</div></nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Курсы</h1>
@can('create', App\Models\Course::class)
<a href="{{ route('admin.courses.create') }}" class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> Добавить курс</a>
@endcan
</div>
@if(session('success'))<div class="alert alert-success">{{ session('success') }}</div>@endif
<div class="card shadow-sm mb-4">
<div class="card-body">
<form action="{{ route('admin.courses.index') }}" method="GET" class="row g-3">
<div class="col-md-4"><input type="text" name="search" class="form-control" placeholder="Поиск..." value="{{ request('search') }}"></div>
<div class="col-md-3">
<select name="category_id" class="form-select">
<option value="">Все категории</option>
@foreach($categories as $id => $name)<option value="{{ $id }}" {{ request('category_id') == $id ? 'selected' : '' }}>{{ $name }}</option>@endforeach
</select>
</div>
<div class="col-md-3">
<select name="type" class="form-select">
<option value="">Все типы</option>
<option value="standard" {{ request('type') == 'standard' ? 'selected' : '' }}>Стандартный</option>
<option value="scorm" {{ request('type') == 'scorm' ? 'selected' : '' }}>SCORM</option>
<option value="h5p" {{ request('type') == 'h5p' ? 'selected' : '' }}>H5P</option>
</select>
</div>
<div class="col-md-2"><button type="submit" class="btn btn-primary w-100"><i class="bi bi-search"></i></button></div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="row">
@forelse($courses as $course)
<div class="col-md-4 mb-4">
<div class="card h-100 shadow-sm">
@if($course->thumbnail)
<img src="{{ asset('storage/' . $course->thumbnail) }}" class="card-img-top" alt="{{ $course->title }}" style="height:200px;object-fit:cover;">
@else
<div class="card-img-top bg-secondary d-flex align-items-center justify-content-center" style="height:200px;"><i class="bi bi-book text-white" style="font-size:4rem;"></i></div>
@endif
<div class="card-body">
<h5 class="card-title">{{ $course->title }}</h5>
<p class="card-text text-muted small">{{ Str::limit($course->description, 80) }}</p>
<div class="mb-2">
<span class="badge bg-info">{{ $course->category?->name ?? 'Без категории' }}</span>
<span class="badge bg-secondary">{{ $course->type }}</span>
</div>
<small class="text-muted">{{ $course->modules->count() }} модулей</small>
</div>
<div class="card-footer bg-white">
<div class="btn-group btn-group-sm w-100">
<a href="{{ route('admin.courses.show', $course) }}" class="btn btn-outline-primary"><i class="bi bi-eye"></i></a>
@can('update', $course)<a href="{{ route('admin.courses.edit', $course) }}" class="btn btn-outline-warning"><i class="bi bi-pencil"></i></a>@endcan
@can('delete', $course)
<form action="{{ route('admin.courses.destroy', $course) }}" method="POST" class="d-inline" onsubmit="return confirm('Удалить?')">@csrf @method('DELETE')<button class="btn btn-outline-danger"><i class="bi bi-trash"></i></button></form>
@endcan
</div>
</div>
</div>
</div>
@empty
<div class="col-12 text-center text-muted py-5"><i class="bi bi-inbox" style="font-size:3rem;"></i><p class="mt-3">Курсов пока нет</p></div>
@endforelse
</div>
</div>
</div>
{{ $courses->links() }}
</main>
</div>
</div>
@endsection

View File

@ -0,0 +1,76 @@
@extends('layouts.app')
@section('title', $course->title)
@section('content')
<div class="container-fluid">
<div class="row">
<nav class="col-md-3 col-lg-2 d-md-block sidebar"><div class="position-sticky pt-3">@include('partials._sidebar')</div></nav>
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content">
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">{{ $course->title }}</h1>
<div>
@can('update', $course)<a href="{{ route('admin.courses.edit', $course) }}" class="btn btn-warning btn-sm me-2">Редактировать</a>@endcan
<a href="{{ route('admin.courses.index') }}" class="btn btn-secondary btn-sm">Назад</a>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-4">
<div class="card shadow-sm">
@if($course->thumbnail)<img src="{{ asset('storage/' . $course->thumbnail) }}" class="card-img-top" alt="{{ $course->title }}">
@else<div class="card-img-top bg-secondary d-flex align-items-center justify-content-center" style="height:200px;"><i class="bi bi-book text-white" style="font-size:4rem;"></i></div>@endif
</div>
</div>
<div class="col-md-8 mb-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<table class="table table-sm">
<tr><th>Категория:</th><td>{{ $course->category?->name ?? '—' }}</td></tr>
<tr><th>Тип:</th><td><span class="badge bg-info">{{ $course->type }}</span></td></tr>
<tr><th>Длительность:</th><td>{{ $course->duration_minutes ?? '—' }} мин</td></tr>
<tr><th>Проходной балл:</th><td>{{ $course->passing_score }}%</td></tr>
<tr><th>Сертификат:</th><td>@if($course->has_certificate)<span class="badge bg-success">Да</span>@else<span class="badge bg-secondary">Нет</span>@endif</td></tr>
<tr><th>Статус:</th><td>@if($course->is_active)<span class="badge bg-success">Активен</span>@else<span class="badge bg-secondary">Не активен</span>@endif</td></tr>
<tr><th>Создан:</th><td>{{ $course->created_at->format('d.m.Y H:i') }}</td></tr>
<tr><th>Автор:</th><td>{{ $course->creator?->name ?? '—' }}</td></tr>
</table>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">Описание</h5></div>
<div class="card-body">{{ $course->description ?? '—' }}</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-header"><h5 class="mb-0">Цели обучения</h5></div>
<div class="card-body">{{ $course->objectives ?? '—' }}</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between"><h5 class="mb-0">Модули</h5><a href="#" class="btn btn-sm btn-primary"><i class="bi bi-plus"></i></a></div>
<div class="card-body">
@if($course->modules->count() > 0)<ul class="list-group list-group-flush">@foreach($course->modules as $module)<li class="list-group-item d-flex justify-content-between">{{ $module->title }}<small class="text-muted">{{ $module->type }}</small></li>@endforeach</ul>
@else<p class="text-muted mb-0">Нет модулей</p>@endif
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between"><h5 class="mb-0">Тесты</h5><a href="#" class="btn btn-sm btn-primary"><i class="bi bi-plus"></i></a></div>
<div class="card-body">
@if($course->tests->count() > 0)<ul class="list-group list-group-flush">@foreach($course->tests as $test)<li class="list-group-item d-flex justify-content-between">{{ $test->title }}<span class="badge bg-secondary">{{ $test->type }}</span></li>@endforeach</ul>
@else<p class="text-muted mb-0">Нет тестов</p>@endif
</div>
</div>
</div>
</div>
</main>
</div>
</div>
@endsection

View File

@ -25,7 +25,7 @@
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">
<a class="nav-link {{ Str::startsWith($currentRoute, 'admin.courses') ? 'active' : '' }}" href="{{ route('admin.courses.index') }}">
<i class="bi bi-book"></i> Курсы
</a>
</li>

View File

@ -6,6 +6,7 @@ use App\Http\Controllers\Admin\OrganizationController;
use App\Http\Controllers\Admin\GroupController;
use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\Admin\CourseCategoryController;
use App\Http\Controllers\Admin\CourseController;
use App\Http\Controllers\DashboardController;
use Illuminate\Support\Facades\Route;
@ -40,5 +41,6 @@ Route::middleware('auth')->group(function () {
Route::resource('organizations.groups', GroupController::class);
Route::resource('users', UserController::class);
Route::resource('course-categories', CourseCategoryController::class);
Route::resource('courses', CourseController::class);
});
});