Feat: CRUD вопросов (начало)

 QuestionController (resource)
 QuestionPolicy
 Маршруты: /admin/tests/{test}/questions
 Blade-шаблоны: index, create (с JS для динамических ответов)
 Поддержка типов: single_choice, multiple_choice, input, matching
 Интеграция в show теста

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-03-26 15:45:14 +08:00
parent fe61dcf4b7
commit a34c2e2812
7 changed files with 298 additions and 1 deletions

View File

@ -0,0 +1,65 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class QuestionController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
//
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
//
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(string $id)
{
//
}
/**
* Show the form for editing the specified resource.
*/
public function edit(string $id)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, string $id)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(string $id)
{
//
}
}

66
app/Policies/QuestionPolicy.php Executable file
View File

@ -0,0 +1,66 @@
<?php
namespace App\Policies;
use App\Models\Question;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class QuestionPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Question $question): bool
{
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Question $question): bool
{
return false;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Question $question): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Question $question): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Question $question): bool
{
return false;
}
}

View File

@ -23,6 +23,7 @@ class AuthServiceProvider extends ServiceProvider
CourseCategory::class => CourseCategoryPolicy::class,
Course::class => CoursePolicy::class,
Test::class => TestPolicy::class,
Question::class => QuestionPolicy::class,
Organization::class => OrganizationPolicy::class,
Group::class => GroupPolicy::class,
User::class => UserPolicy::class,

View File

@ -0,0 +1,119 @@
@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.tests.questions.index', $test) }}" class="btn btn-secondary btn-sm">Назад</a>
</div>
<form action="{{ route('admin.tests.questions.store', $test) }}" method="POST" id="questionForm">
@csrf
<input type="hidden" name="type" id="questionType" value="single_choice">
<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>
<select name="type_select" id="typeSelect" class="form-select" onchange="updateQuestionType()">
<option value="single_choice">Один правильный ответ</option>
<option value="multiple_choice">Несколько правильных ответов</option>
<option value="input">Ввод текста</option>
<option value="matching">Соответствие</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Текст вопроса *</label>
<textarea name="question_text" class="form-control" rows="3" required>{{ old('question_text') }}</textarea>
</div>
<div class="mb-3">
<label class="form-label">Пояснение</label>
<textarea name="explanation" class="form-control" rows="2">{{ old('explanation') }}</textarea>
<small class="text-muted">Отображается после ответа</small>
</div>
<!-- Ответы для single/multiple choice -->
<div id="answersSection" class="mb-3">
<label class="form-label">Ответы</label>
<div id="answersContainer">
<div class="input-group mb-2">
<input type="text" name="answers[0][text]" class="form-control" placeholder="Текст ответа">
<input type="hidden" name="answers[0][is_correct]" value="0">
<button type="button" class="btn btn-outline-success" onclick="toggleCorrect(this)"><i class="bi bi-circle"></i></button>
<button type="button" class="btn btn-outline-danger" onclick="removeAnswer(this)"><i class="bi bi-trash"></i></button>
</div>
</div>
<button type="button" class="btn btn-sm btn-secondary" onclick="addAnswer()">+ Добавить ответ</button>
</div>
<!-- Пары для matching -->
<div id="matchingSection" class="mb-3" style="display:none;">
<label class="form-label">Пары для соответствия</label>
<div id="matchingContainer"></div>
<button type="button" class="btn btn-sm btn-secondary" onclick="addMatchingPair()">+ Добавить пару</button>
</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>
<input type="number" name="score" class="form-control" value="{{ old('score', 1) }}" min="1">
</div>
<div class="mb-3">
<label class="form-label">Порядок</label>
<input type="number" name="sort_order" class="form-control" value="{{ old('sort_order', 0) }}">
</div>
<div class="form-check mb-3">
<input type="checkbox" name="is_required" value="1" class="form-check-input" {{ old('is_required', true) ? 'checked' : '' }}>
<label class="form-check-label">Обязательный вопрос</label>
</div>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">Создать вопрос</button>
<a href="{{ route('admin.tests.questions.index', $test) }}" class="btn btn-secondary">Отмена</a>
</form>
</main>
</div>
</div>
<script>
function updateQuestionType() {
const type = document.getElementById('typeSelect').value;
document.getElementById('questionType').value = type;
const answersSection = document.getElementById('answersSection');
const matchingSection = document.getElementById('matchingSection');
answersSection.style.display = (type === 'single_choice' || type === 'multiple_choice') ? 'block' : 'none';
matchingSection.style.display = (type === 'matching') ? 'block' : 'none';
}
function addAnswer() {
const container = document.getElementById('answersContainer');
const index = container.children.length;
const div = document.createElement('div');
div.className = 'input-group mb-2';
div.innerHTML = `<input type="text" name="answers[${index}][text]" class="form-control" placeholder="Текст ответа"><input type="hidden" name="answers[${index}][is_correct]" value="0"><button type="button" class="btn btn-outline-success" onclick="toggleCorrect(this)"><i class="bi bi-circle"></i></button><button type="button" class="btn btn-outline-danger" onclick="removeAnswer(this)"><i class="bi bi-trash"></i></button>`;
container.appendChild(div);
}
function removeAnswer(btn) { btn.parentElement.remove(); }
function toggleCorrect(btn) {
const hidden = btn.previousElementSibling;
if (hidden.value === '0') { hidden.value = '1'; btn.innerHTML = '<i class="bi bi-check-circle-fill text-success"></i>'; }
else { hidden.value = '0'; btn.innerHTML = '<i class="bi bi-circle"></i>'; }
}
function addMatchingPair() {
const container = document.getElementById('matchingContainer');
const index = container.children.length;
const div = document.createElement('div');
div.className = 'input-group mb-2';
div.innerHTML = `<input type="text" name="matching_pairs[${index}][left_text]" class="form-control" placeholder="Левая часть"><input type="text" name="matching_pairs[${index}][right_text]" class="form-control" placeholder="Правая часть"><input type="number" name="matching_pairs[${index}][match_score]" class="form-control" value="1" style="width:80px" title="Баллы"><button type="button" class="btn btn-outline-danger" onclick="this.parentElement.remove()"><i class="bi bi-trash"></i></button>`;
container.appendChild(div);
}
</script>
@endsection

View File

@ -0,0 +1,45 @@
@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">Вопросы: {{ $test->title }}</h1>
<a href="{{ route('admin.tests.questions.create', $test) }}" class="btn btn-primary btn-sm"><i class="bi bi-plus-lg"></i> Добавить вопрос</a>
</div>
@if(session('success'))<div class="alert alert-success">{{ session('success') }}</div>@endif
<div class="mb-3"><a href="{{ route('admin.tests.show', $test) }}" class="btn btn-secondary btn-sm"><i class="bi bi-arrow-left"></i> Назад к тесту</a></div>
<div class="card shadow-sm">
<div class="card-body">
@forelse($questions as $question)
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge bg-info">{{ $question->type }}</span>
<strong>#{{ $loop->iteration }}. {{ Str::limit($question->question_text, 100) }}</strong>
<div class="mt-2">
<small class="text-muted">{{ $question->answers->count() }} ответов</small>
@if($question->is_required)<span class="badge bg-warning ms-2">Обязательный</span>@endif
<span class="badge bg-secondary ms-2">{{ $question->score }} балл(а)</span>
</div>
</div>
<div class="btn-group btn-group-sm">
<a href="{{ route('admin.tests.questions.edit', [$test, $question]) }}" class="btn btn-outline-warning"><i class="bi bi-pencil"></i></a>
<form action="{{ route('admin.tests.questions.destroy', [$test, $question]) }}" 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>
</div>
</div>
</div>
</div>
@empty
<p class="text-muted text-center py-5">Нет вопросов. Добавьте первый вопрос!</p>
@endforelse
</div>
</div>
</main>
</div>
</div>
@endsection

View File

@ -36,7 +36,7 @@
<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>
<a href="{{ route('admin.tests.questions.create', $test) }}" class="btn btn-sm btn-primary"><i class="bi bi-plus"></i></a>
</div>
<div class="card-body">
@if($test->questions->count() > 0)

View File

@ -45,5 +45,6 @@ Route::middleware('auth')->group(function () {
Route::resource('course-categories', CourseCategoryController::class);
Route::resource('courses', CourseController::class);
Route::resource('courses.tests', TestController::class);
Route::resource('tests.questions', QuestionController::class);
});
});