Feat: Обновлён create.blade.php для вопросов

 TinyMCE WYSIWYG редактор (HTML + картинки)
 Поддержка ordering типа (сортировка)
 Картинки в ответах (загрузка файлов)
 Обновлён QuestionController (store/update)
 Валидация изображений (max 2MB)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
mirivlad 2026-03-27 09:58:35 +08:00
parent 025b03c94b
commit 1c94812f7d
2 changed files with 120 additions and 22 deletions

View File

@ -7,6 +7,7 @@ use App\Models\Question;
use App\Models\Test; use App\Models\Test;
use App\Models\Answer; use App\Models\Answer;
use App\Models\QuestionMatchingPair; use App\Models\QuestionMatchingPair;
use App\Models\QuestionOrderingItem;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -22,7 +23,7 @@ class QuestionController extends Controller
{ {
Gate::authorize('viewAny', Question::class); Gate::authorize('viewAny', Question::class);
$questions = $test->questions()->with('answers')->orderBy('sort_order')->get(); $questions = $test->questions()->with(['answers', 'matchingPairs', 'orderingItems'])->orderBy('sort_order')->get();
return view('admin.questions.index', compact('test', 'questions')); return view('admin.questions.index', compact('test', 'questions'));
} }
@ -39,14 +40,17 @@ class QuestionController extends Controller
Gate::authorize('create', Question::class); Gate::authorize('create', Question::class);
$validated = $request->validate([ $validated = $request->validate([
'type' => 'required|in:single_choice,multiple_choice,input,matching', 'type' => 'required|in:multiple_choice,matching,ordering',
'question_text' => 'required|string', 'question_text' => 'required|string',
'explanation' => 'nullable|string', 'explanation' => 'nullable|string',
'score' => 'nullable|integer|min:1', 'score' => 'nullable|integer|min:1',
'sort_order' => 'nullable|integer', 'sort_order' => 'nullable|integer',
'is_required' => 'boolean', 'is_required' => 'boolean',
'answers' => 'nullable|array', 'answers' => 'nullable|array',
'answers.*.text' => 'nullable|string',
'answers.*.image' => 'nullable|image|max:2048',
'matching_pairs' => 'nullable|array', 'matching_pairs' => 'nullable|array',
'ordering_items' => 'nullable|array',
]); ]);
DB::transaction(function () use ($test, $validated) { DB::transaction(function () use ($test, $validated) {
@ -59,11 +63,17 @@ class QuestionController extends Controller
'is_required' => $validated['is_required'] ?? true, 'is_required' => $validated['is_required'] ?? true,
]); ]);
if (in_array($validated['type'], ['single_choice', 'multiple_choice']) && !empty($validated['answers'])) { // Ответы для multiple_choice
if ($validated['type'] === 'multiple_choice' && !empty($validated['answers'])) {
foreach ($validated['answers'] as $answer) { foreach ($validated['answers'] as $answer) {
if (!empty($answer['text'])) { if (!empty($answer['text'])) {
$imagePath = null;
if (!empty($answer['image'])) {
$imagePath = $answer['image']->store('questions/answers', 'public');
}
$question->answers()->create([ $question->answers()->create([
'answer_text' => $answer['text'], 'answer_text' => $answer['text'],
'image' => $imagePath,
'is_correct' => $answer['is_correct'] ?? false, 'is_correct' => $answer['is_correct'] ?? false,
'sort_order' => $answer['sort_order'] ?? 0, 'sort_order' => $answer['sort_order'] ?? 0,
]); ]);
@ -71,6 +81,7 @@ class QuestionController extends Controller
} }
} }
// Пары для matching
if ($validated['type'] === 'matching' && !empty($validated['matching_pairs'])) { if ($validated['type'] === 'matching' && !empty($validated['matching_pairs'])) {
foreach ($validated['matching_pairs'] as $pair) { foreach ($validated['matching_pairs'] as $pair) {
if (!empty($pair['left_text']) && !empty($pair['right_text'])) { if (!empty($pair['left_text']) && !empty($pair['right_text'])) {
@ -83,6 +94,19 @@ class QuestionController extends Controller
} }
} }
} }
// Элементы для ordering
if ($validated['type'] === 'ordering' && !empty($validated['ordering_items'])) {
foreach ($validated['ordering_items'] as $item) {
if (!empty($item['item_text'])) {
$question->orderingItems()->create([
'item_text' => $item['item_text'],
'correct_order' => $item['correct_order'] ?? 0,
'sort_order' => $item['sort_order'] ?? 0,
]);
}
}
}
}); });
return redirect()->route('admin.tests.questions.index', $test) return redirect()->route('admin.tests.questions.index', $test)
@ -92,13 +116,14 @@ class QuestionController extends Controller
public function show(Test $test, Question $question) public function show(Test $test, Question $question)
{ {
Gate::authorize('view', $question); Gate::authorize('view', $question);
$question->load(['answers', 'matchingPairs', 'orderingItems']);
return view('admin.questions.show', compact('test', 'question')); return view('admin.questions.show', compact('test', 'question'));
} }
public function edit(Test $test, Question $question) public function edit(Test $test, Question $question)
{ {
Gate::authorize('update', $question); Gate::authorize('update', $question);
$question->load(['answers', 'matchingPairs']); $question->load(['answers', 'matchingPairs', 'orderingItems']);
return view('admin.questions.edit', compact('test', 'question')); return view('admin.questions.edit', compact('test', 'question'));
} }
@ -107,14 +132,17 @@ class QuestionController extends Controller
Gate::authorize('update', $question); Gate::authorize('update', $question);
$validated = $request->validate([ $validated = $request->validate([
'type' => 'required|in:single_choice,multiple_choice,input,matching', 'type' => 'required|in:multiple_choice,matching,ordering',
'question_text' => 'required|string', 'question_text' => 'required|string',
'explanation' => 'nullable|string', 'explanation' => 'nullable|string',
'score' => 'nullable|integer|min:1', 'score' => 'nullable|integer|min:1',
'sort_order' => 'nullable|integer', 'sort_order' => 'nullable|integer',
'is_required' => 'boolean', 'is_required' => 'boolean',
'answers' => 'nullable|array', 'answers' => 'nullable|array',
'answers.*.text' => 'nullable|string',
'answers.*.image' => 'nullable|image|max:2048',
'matching_pairs' => 'nullable|array', 'matching_pairs' => 'nullable|array',
'ordering_items' => 'nullable|array',
]); ]);
DB::transaction(function () use ($question, $validated) { DB::transaction(function () use ($question, $validated) {
@ -127,12 +155,18 @@ class QuestionController extends Controller
'is_required' => $validated['is_required'] ?? true, 'is_required' => $validated['is_required'] ?? true,
]); ]);
if (in_array($validated['type'], ['single_choice', 'multiple_choice']) && !empty($validated['answers'])) { // Ответы
if ($validated['type'] === 'multiple_choice' && !empty($validated['answers'])) {
$question->answers()->delete(); $question->answers()->delete();
foreach ($validated['answers'] as $answer) { foreach ($validated['answers'] as $answer) {
if (!empty($answer['text'])) { if (!empty($answer['text'])) {
$imagePath = null;
if (!empty($answer['image'])) {
$imagePath = $answer['image']->store('questions/answers', 'public');
}
$question->answers()->create([ $question->answers()->create([
'answer_text' => $answer['text'], 'answer_text' => $answer['text'],
'image' => $imagePath,
'is_correct' => $answer['is_correct'] ?? false, 'is_correct' => $answer['is_correct'] ?? false,
'sort_order' => $answer['sort_order'] ?? 0, 'sort_order' => $answer['sort_order'] ?? 0,
]); ]);
@ -140,6 +174,7 @@ class QuestionController extends Controller
} }
} }
// Пары
if ($validated['type'] === 'matching' && !empty($validated['matching_pairs'])) { if ($validated['type'] === 'matching' && !empty($validated['matching_pairs'])) {
$question->matchingPairs()->delete(); $question->matchingPairs()->delete();
foreach ($validated['matching_pairs'] as $pair) { foreach ($validated['matching_pairs'] as $pair) {
@ -153,6 +188,20 @@ class QuestionController extends Controller
} }
} }
} }
// Элементы ordering
if ($validated['type'] === 'ordering' && !empty($validated['ordering_items'])) {
$question->orderingItems()->delete();
foreach ($validated['ordering_items'] as $item) {
if (!empty($item['item_text'])) {
$question->orderingItems()->create([
'item_text' => $item['item_text'],
'correct_order' => $item['correct_order'] ?? 0,
'sort_order' => $item['sort_order'] ?? 0,
]);
}
}
}
}); });
return redirect()->route('admin.tests.questions.index', $test) return redirect()->route('admin.tests.questions.index', $test)

View File

@ -7,11 +7,11 @@
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4 main-content"> <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"> <div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Добавить вопрос</h1> <h1 class="h2">Добавить вопрос</h1>
<a href="{{ route('admin.courses.tests.show', [$test->course, $test]) }}" class="btn btn-secondary btn-sm">Назад</a> <a href="{{ route('admin.tests.questions.index', $test) }}" class="btn btn-secondary btn-sm">Назад</a>
</div> </div>
<form action="{{ route('admin.tests.questions.store', $test) }}" method="POST" id="questionForm"> <form action="{{ route('admin.tests.questions.store', $test) }}" method="POST" id="questionForm" enctype="multipart/form-data">
@csrf @csrf
<input type="hidden" name="type" id="questionType" value="single_choice"> <input type="hidden" name="type" id="questionType" value="multiple_choice">
<div class="row"> <div class="row">
<div class="col-md-8"> <div class="col-md-8">
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
@ -19,28 +19,28 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Тип вопроса *</label> <label class="form-label">Тип вопроса *</label>
<select name="type_select" id="typeSelect" class="form-select" onchange="updateQuestionType()"> <select name="type_select" id="typeSelect" class="form-select" onchange="updateQuestionType()">
<option value="single_choice">Один правильный ответ</option> <option value="multiple_choice">Множественный выбор</option>
<option value="multiple_choice">Несколько правильных ответов</option>
<option value="input">Ввод текста</option>
<option value="matching">Соответствие</option> <option value="matching">Соответствие</option>
<option value="ordering">Правильный порядок</option>
</select> </select>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Текст вопроса *</label> <label class="form-label">Текст вопроса *</label>
<textarea name="question_text" class="form-control" rows="3" required>{{ old('question_text') }}</textarea> <textarea name="question_text" id="questionText" class="form-control" rows="5">{{ old('question_text') }}</textarea>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Пояснение</label> <label class="form-label">Пояснение</label>
<textarea name="explanation" class="form-control" rows="2">{{ old('explanation') }}</textarea> <textarea name="explanation" id="questionExplanation" class="form-control" rows="3">{{ old('explanation') }}</textarea>
<small class="text-muted">Отображается после ответа</small> <small class="text-muted">Отображается после ответа</small>
</div> </div>
<!-- Ответы для single/multiple choice --> <!-- Ответы для multiple_choice -->
<div id="answersSection" class="mb-3"> <div id="answersSection" class="mb-3">
<label class="form-label">Ответы</label> <label class="form-label">Ответы</label>
<div id="answersContainer"> <div id="answersContainer">
<div class="input-group mb-2"> <div class="input-group mb-2">
<input type="text" name="answers[0][text]" class="form-control" placeholder="Текст ответа"> <input type="text" name="answers[0][text]" class="form-control" placeholder="Текст ответа">
<input type="file" name="answers[0][image]" class="form-control" accept="image/*">
<input type="hidden" name="answers[0][is_correct]" value="0"> <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-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> <button type="button" class="btn btn-outline-danger" onclick="removeAnswer(this)"><i class="bi bi-trash"></i></button>
@ -55,6 +55,13 @@
<div id="matchingContainer"></div> <div id="matchingContainer"></div>
<button type="button" class="btn btn-sm btn-secondary" onclick="addMatchingPair()">+ Добавить пару</button> <button type="button" class="btn btn-sm btn-secondary" onclick="addMatchingPair()">+ Добавить пару</button>
</div> </div>
<!-- Элементы для ordering -->
<div id="orderingSection" class="mb-3" style="display:none;">
<label class="form-label">Элементы для сортировки</label>
<div id="orderingContainer"></div>
<button type="button" class="btn btn-sm btn-secondary" onclick="addOrderingItem()">+ Добавить элемент</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -84,35 +91,77 @@
</main> </main>
</div> </div>
</div> </div>
<!-- TinyMCE -->
<script src="https://cdn.tiny.mce.com/1/tinymce/7/tinymce.min.js" referrerpolicy="origin"></script>
<script> <script>
// Инициализация TinyMCE
tinymce.init({
selector: '#questionText, #questionExplanation',
height: 300,
plugins: 'image link table lists code',
toolbar: 'undo redo | formatselect | bold italic | alignleft aligncenter alignright | bullist numlist | image link | code',
image_title: true,
automatic_uploads: true,
file_picker_types: 'image',
file_picker_callback: function(cb, value, meta) {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.addEventListener('change', function(e) {
const file = e.target.files[0];
const reader = new FileReader();
reader.addEventListener('load', function() {
const id = 'blobid' + (new Date()).getTime();
const blobCache = tinymce.activeEditor.editorUpload.blobCache;
const base64 = reader.result.split(',')[1];
const blobInfo = blobCache.create(id, file, base64);
blobCache.add(blobInfo);
cb(blobInfo.blobUri(), { title: file.name });
});
reader.readAsDataURL(file);
});
input.click();
}
});
function updateQuestionType() { function updateQuestionType() {
const type = document.getElementById('typeSelect').value; const type = document.getElementById('typeSelect').value;
document.getElementById('questionType').value = type; document.getElementById('questionType').value = type;
const answersSection = document.getElementById('answersSection'); document.getElementById('answersSection').style.display = (type === 'multiple_choice') ? 'block' : 'none';
const matchingSection = document.getElementById('matchingSection'); document.getElementById('matchingSection').style.display = (type === 'matching') ? 'block' : 'none';
answersSection.style.display = (type === 'single_choice' || type === 'multiple_choice') ? 'block' : 'none'; document.getElementById('orderingSection').style.display = (type === 'ordering') ? 'block' : 'none';
matchingSection.style.display = (type === 'matching') ? 'block' : 'none';
} }
function addAnswer() { function addAnswer() {
const container = document.getElementById('answersContainer'); const container = document.getElementById('answersContainer');
const index = container.children.length; const index = container.children.length;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'input-group mb-2'; 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>`; div.innerHTML = `<input type="text" name="answers[${index}][text]" class="form-control" placeholder="Текст ответа"><input type="file" name="answers[${index}][image]" class="form-control" accept="image/*"><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); container.appendChild(div);
} }
function removeAnswer(btn) { btn.parentElement.remove(); } function removeAnswer(btn) { btn.parentElement.remove(); }
function toggleCorrect(btn) { function toggleCorrect(btn) {
const hidden = btn.previousElementSibling; const hidden = btn.previousElementSibling.previousElementSibling;
if (hidden.value === '0') { hidden.value = '1'; btn.innerHTML = '<i class="bi bi-check-circle-fill text-success"></i>'; } 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>'; } else { hidden.value = '0'; btn.innerHTML = '<i class="bi bi-circle"></i>'; }
} }
function addMatchingPair() { function addMatchingPair() {
const container = document.getElementById('matchingContainer'); const container = document.getElementById('matchingContainer');
const index = container.children.length; const index = container.children.length;
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'input-group mb-2'; 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>`; 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"><button type="button" class="btn btn-outline-danger" onclick="this.parentElement.remove()"><i class="bi bi-trash"></i></button>`;
container.appendChild(div);
}
function addOrderingItem() {
const container = document.getElementById('orderingContainer');
const index = container.children.length;
const div = document.createElement('div');
div.className = 'input-group mb-2';
div.innerHTML = `<input type="text" name="ordering_items[${index}][item_text]" class="form-control" placeholder="Элемент"><input type="number" name="ordering_items[${index}][correct_order]" class="form-control" value="${index + 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); container.appendChild(div);
} }
</script> </script>