Initial commit: standalone quiz-testing system
Full-stack F# (Domain/Server/Client via Fable+Elmish+Feliz), PostgreSQL persistence via Dapper, Docker Compose deployment. Student quiz-taking flow with time-limit enforcement and focus-loss tracking, Teacher question bank and quiz builder with results analytics, Admin user management.
This commit is contained in:
292
src/Client/Features/Teacher/Questions/View.fs
Normal file
292
src/Client/Features/Teacher/Questions/View.fs
Normal file
@@ -0,0 +1,292 @@
|
||||
module Client.Features.Teacher.Questions.View
|
||||
|
||||
open Feliz
|
||||
open Domain
|
||||
open Domain.Contracts
|
||||
open Client.Shared
|
||||
open Client.Features.Teacher.Questions.Types
|
||||
|
||||
let private topicsView (model: Model) dispatch =
|
||||
Html.div [
|
||||
prop.className "topics-panel"
|
||||
prop.children [
|
||||
Html.h2 "Темы"
|
||||
match model.TopicsError with
|
||||
| Some err -> Html.p [ prop.className "error"; prop.text err ]
|
||||
| None -> Html.none
|
||||
if model.TopicsLoading then
|
||||
Html.p "Загрузка…"
|
||||
else
|
||||
Html.ul [
|
||||
prop.children [
|
||||
for topic in model.Topics ->
|
||||
Html.li [
|
||||
prop.key (string topic.Id)
|
||||
prop.className (
|
||||
if model.SelectedTopicId = Some topic.Id then
|
||||
"topic-item selected"
|
||||
else
|
||||
"topic-item"
|
||||
)
|
||||
prop.onClick (fun _ -> dispatch (SelectTopic topic.Id))
|
||||
prop.text topic.Name
|
||||
]
|
||||
]
|
||||
]
|
||||
Html.form [
|
||||
prop.onSubmit (fun e ->
|
||||
e.preventDefault ()
|
||||
dispatch SubmitNewTopic)
|
||||
prop.children [
|
||||
Html.input [
|
||||
prop.type'.text
|
||||
prop.placeholder "Название новой темы"
|
||||
prop.value model.NewTopicForm.Name
|
||||
prop.onChange (SetNewTopicName >> dispatch)
|
||||
]
|
||||
Html.button [
|
||||
prop.type'.submit
|
||||
prop.disabled model.NewTopicForm.IsSubmitting
|
||||
prop.text "Добавить тему"
|
||||
]
|
||||
match model.NewTopicForm.Error with
|
||||
| Some err -> Html.p [ prop.className "error"; prop.text err ]
|
||||
| None -> Html.none
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
let private kindLabel (t: QuestionTypeView) =
|
||||
match t with
|
||||
| SingleChoiceT _ -> "Один вариант"
|
||||
| MultipleChoiceT _ -> "Несколько вариантов"
|
||||
| TrueFalseT _ -> "Верно/неверно"
|
||||
| ShortAnswerT _ -> "Короткий ответ"
|
||||
| NumericT _ -> "Числовой"
|
||||
|
||||
let private questionsListView (model: Model) dispatch =
|
||||
Html.div [
|
||||
prop.className "questions-list"
|
||||
prop.children [
|
||||
Html.h3 "Вопросы в теме"
|
||||
match model.QuestionsError with
|
||||
| Some err -> Html.p [ prop.className "error"; prop.text err ]
|
||||
| None -> Html.none
|
||||
if model.QuestionsLoading then
|
||||
Html.p "Загрузка…"
|
||||
elif model.Questions.IsEmpty then
|
||||
Html.p "В этой теме пока нет вопросов"
|
||||
else
|
||||
Html.ul [
|
||||
prop.children [
|
||||
for q in model.Questions ->
|
||||
Html.li [
|
||||
prop.key (string q.Id)
|
||||
prop.children [
|
||||
Html.span [ prop.className "question-kind"; prop.text (kindLabel q.Type) ]
|
||||
Html.text (sprintf " %s " q.Text)
|
||||
Html.span [
|
||||
prop.className "tag-mono"
|
||||
prop.text (sprintf "%s б." (Format.points q.Points))
|
||||
]
|
||||
Html.button [
|
||||
prop.type'.button
|
||||
prop.onClick (fun _ -> dispatch (StartEditQuestion q.Id))
|
||||
prop.text "Изменить"
|
||||
]
|
||||
Html.button [
|
||||
prop.type'.button
|
||||
prop.onClick (fun _ -> dispatch (RequestDeleteQuestion q.Id))
|
||||
prop.text "Удалить"
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
let private optionsEditor (form: NewQuestionForm) dispatch (multi: bool) =
|
||||
Html.div [
|
||||
prop.className "options-editor"
|
||||
prop.children [
|
||||
for opt in form.Options do
|
||||
Html.div [
|
||||
prop.key (string opt.Id)
|
||||
prop.className "option-row"
|
||||
prop.children [
|
||||
Html.input [
|
||||
prop.type'.radio
|
||||
prop.name "correct-option"
|
||||
prop.isChecked (not multi && form.CorrectSingle = Some opt.Id)
|
||||
prop.style [ if multi then style.display.none ]
|
||||
prop.onChange (fun (_: bool) -> dispatch (SetCorrectSingle opt.Id))
|
||||
]
|
||||
Html.input [
|
||||
prop.type'.checkbox
|
||||
prop.isChecked (form.CorrectMultiple.Contains opt.Id)
|
||||
prop.style [ if not multi then style.display.none ]
|
||||
prop.onChange (fun (_: bool) -> dispatch (ToggleCorrectMultiple opt.Id))
|
||||
]
|
||||
Html.input [
|
||||
prop.type'.text
|
||||
prop.placeholder "Текст варианта"
|
||||
prop.value opt.Text
|
||||
prop.onChange (fun v -> dispatch (SetOptionText(opt.Id, v)))
|
||||
]
|
||||
Html.button [
|
||||
prop.type'.button
|
||||
prop.disabled (form.Options.Length <= 2)
|
||||
prop.onClick (fun _ -> dispatch (RemoveOption opt.Id))
|
||||
prop.text "Удалить"
|
||||
]
|
||||
]
|
||||
]
|
||||
Html.button [ prop.type'.button; prop.onClick (fun _ -> dispatch AddOption); prop.text "Добавить вариант" ]
|
||||
]
|
||||
]
|
||||
|
||||
let private newQuestionFormView (isEditing: bool) (form: NewQuestionForm) dispatch =
|
||||
Html.form [
|
||||
prop.className "new-question-form"
|
||||
prop.onSubmit (fun e ->
|
||||
e.preventDefault ()
|
||||
dispatch SubmitNewQuestion)
|
||||
prop.children [
|
||||
Html.h3 (if isEditing then "Редактирование вопроса" else "Новый вопрос")
|
||||
Html.label [ prop.text "Текст вопроса" ]
|
||||
Html.input [
|
||||
prop.type'.text
|
||||
prop.value form.Text
|
||||
prop.onChange (SetQuestionText >> dispatch)
|
||||
]
|
||||
Html.label [ prop.text "Баллы" ]
|
||||
Html.input [
|
||||
prop.type'.number
|
||||
prop.value form.Points
|
||||
prop.onChange (SetQuestionPoints >> dispatch)
|
||||
]
|
||||
Html.label [ prop.text "Тип вопроса" ]
|
||||
Html.select [
|
||||
prop.value (
|
||||
match form.Kind with
|
||||
| KSingleChoice -> "single"
|
||||
| KMultipleChoice -> "multiple"
|
||||
| KTrueFalse -> "truefalse"
|
||||
| KShortAnswer -> "short"
|
||||
| KNumeric -> "numeric"
|
||||
)
|
||||
prop.onChange (fun (v: string) ->
|
||||
let kind =
|
||||
match v with
|
||||
| "single" -> KSingleChoice
|
||||
| "multiple" -> KMultipleChoice
|
||||
| "truefalse" -> KTrueFalse
|
||||
| "short" -> KShortAnswer
|
||||
| _ -> KNumeric
|
||||
|
||||
dispatch (SetQuestionKind kind))
|
||||
prop.children [
|
||||
Html.option [ prop.value "single"; prop.text "Один вариант" ]
|
||||
Html.option [ prop.value "multiple"; prop.text "Несколько вариантов" ]
|
||||
Html.option [ prop.value "truefalse"; prop.text "Верно/неверно" ]
|
||||
Html.option [ prop.value "short"; prop.text "Короткий ответ" ]
|
||||
Html.option [ prop.value "numeric"; prop.text "Числовой" ]
|
||||
]
|
||||
]
|
||||
|
||||
match form.Kind with
|
||||
| KSingleChoice -> optionsEditor form dispatch false
|
||||
| KMultipleChoice -> optionsEditor form dispatch true
|
||||
| KTrueFalse ->
|
||||
Html.div [
|
||||
for value, text in [ true, "Верно"; false, "Неверно" ] ->
|
||||
Html.label [
|
||||
prop.children [
|
||||
Html.input [
|
||||
prop.type'.radio
|
||||
prop.name "correct-bool"
|
||||
prop.isChecked (form.CorrectBool = value)
|
||||
prop.onChange (fun (_: bool) -> dispatch (SetCorrectBool value))
|
||||
]
|
||||
Html.text text
|
||||
]
|
||||
]
|
||||
]
|
||||
| KShortAnswer ->
|
||||
Html.div [
|
||||
Html.label [ prop.text "Допустимые ответы (через запятую)" ]
|
||||
Html.input [
|
||||
prop.type'.text
|
||||
prop.value form.AcceptedAnswersText
|
||||
prop.onChange (SetAcceptedAnswersText >> dispatch)
|
||||
]
|
||||
Html.label [
|
||||
prop.children [
|
||||
Html.input [
|
||||
prop.type'.checkbox
|
||||
prop.isChecked form.CaseSensitive
|
||||
prop.onChange (SetCaseSensitive >> dispatch)
|
||||
]
|
||||
Html.text "Учитывать регистр"
|
||||
]
|
||||
]
|
||||
]
|
||||
| KNumeric ->
|
||||
Html.div [
|
||||
Html.label [ prop.text "Правильное значение" ]
|
||||
Html.input [
|
||||
prop.type'.text
|
||||
prop.value form.CorrectValueText
|
||||
prop.onChange (SetCorrectValueText >> dispatch)
|
||||
]
|
||||
Html.label [ prop.text "Допустимая погрешность" ]
|
||||
Html.input [
|
||||
prop.type'.text
|
||||
prop.value form.ToleranceText
|
||||
prop.onChange (SetToleranceText >> dispatch)
|
||||
]
|
||||
]
|
||||
|
||||
match form.Error with
|
||||
| Some err -> Html.p [ prop.className "error"; prop.text err ]
|
||||
| None -> Html.none
|
||||
Html.button [
|
||||
prop.type'.submit
|
||||
prop.disabled form.IsSubmitting
|
||||
prop.text (
|
||||
if form.IsSubmitting then "Сохранение…"
|
||||
elif isEditing then "Сохранить изменения"
|
||||
else "Добавить вопрос"
|
||||
)
|
||||
]
|
||||
if isEditing then
|
||||
Html.button [
|
||||
prop.type'.button
|
||||
prop.onClick (fun _ -> dispatch CancelEditQuestion)
|
||||
prop.text "Отмена"
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
let view (model: Model) (dispatch: Msg -> unit) =
|
||||
Html.div [
|
||||
prop.className "teacher-questions-page"
|
||||
prop.children [
|
||||
Html.h1 "Банк вопросов"
|
||||
Html.div [
|
||||
prop.className "teacher-layout"
|
||||
prop.children [
|
||||
topicsView model dispatch
|
||||
match model.SelectedTopicId with
|
||||
| None -> Html.p "Выберите или создайте тему слева"
|
||||
| Some _ ->
|
||||
Html.div [
|
||||
questionsListView model dispatch
|
||||
newQuestionFormView model.EditingQuestionId.IsSome model.NewQuestionForm dispatch
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
Reference in New Issue
Block a user