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:
danamir
2026-08-06 12:36:16 +03:00
commit 942dfc9c1a
134 changed files with 10712 additions and 0 deletions

View 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
]
]
]
]
]