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:
143
src/Client/Features/Quizzes/Browse/View.fs
Normal file
143
src/Client/Features/Quizzes/Browse/View.fs
Normal file
@@ -0,0 +1,143 @@
|
||||
module Client.Features.Quizzes.Browse.View
|
||||
|
||||
open Feliz
|
||||
open Domain.Contracts
|
||||
open Client.Shared
|
||||
open Client.Features.Quizzes.Browse.Types
|
||||
|
||||
let private attemptRow (a: MyAttemptSummary) =
|
||||
Html.li [
|
||||
prop.key (string a.AttemptId)
|
||||
prop.className "attempt-row"
|
||||
prop.children [
|
||||
Html.span [
|
||||
prop.className "tag-mono"
|
||||
prop.text (a.StartedAt.ToLocalTime().ToString("dd.MM.yyyy HH:mm"))
|
||||
]
|
||||
Html.span [
|
||||
prop.className "score-value"
|
||||
prop.text (sprintf "%s / %s" (Format.points a.Score) (Format.points a.MaxScore))
|
||||
]
|
||||
match a.Passed with
|
||||
| Some true -> Html.span [ prop.className "grade-stamp passed"; prop.text "Пройден" ]
|
||||
| Some false -> Html.span [ prop.className "grade-stamp failed"; prop.text "Не пройден" ]
|
||||
| None -> Html.none
|
||||
]
|
||||
]
|
||||
|
||||
let private resultsPanel (model: Model) =
|
||||
Html.div [
|
||||
prop.className "results-panel"
|
||||
prop.children [
|
||||
match model.ResultsError with
|
||||
| Some err -> Html.p [ prop.className "error"; prop.text err ]
|
||||
| None -> Html.none
|
||||
if model.ResultsLoading then
|
||||
Html.p "Загрузка…"
|
||||
elif model.Results.IsEmpty then
|
||||
Html.p "Нет завершённых попыток"
|
||||
else
|
||||
Html.ul [ prop.children [ for a in model.Results -> attemptRow a ] ]
|
||||
]
|
||||
]
|
||||
|
||||
let view (model: Model) (dispatch: Msg -> unit) =
|
||||
Html.div [
|
||||
prop.className "quiz-list-page"
|
||||
prop.children [
|
||||
Html.h2 "Доступные тесты"
|
||||
match model.Error with
|
||||
| Some err -> Html.p [ prop.className "error"; prop.text err ]
|
||||
| None -> Html.none
|
||||
if model.Loading then
|
||||
Html.p "Загрузка…"
|
||||
elif model.Quizzes.IsEmpty then
|
||||
Html.p "Тесты недоступны"
|
||||
else
|
||||
Html.ul [
|
||||
prop.children [
|
||||
for quiz in model.Quizzes ->
|
||||
let remainingAttempts =
|
||||
quiz.MaxAttempts |> Option.map (fun m -> System.Math.Max(0, m - quiz.AttemptsCount))
|
||||
|
||||
Html.li [
|
||||
prop.key (string quiz.Id)
|
||||
prop.className "quiz-card"
|
||||
prop.children [
|
||||
Html.h3 quiz.Title
|
||||
Html.p quiz.Description
|
||||
Html.div [
|
||||
prop.className "meta-row"
|
||||
prop.children [
|
||||
Html.div [
|
||||
prop.className "meta-item"
|
||||
prop.children [
|
||||
Html.span [ prop.className "meta-label"; prop.text "Баллов" ]
|
||||
Html.span [
|
||||
prop.className "meta-value"
|
||||
prop.text (Format.points quiz.TotalPoints)
|
||||
]
|
||||
]
|
||||
]
|
||||
match quiz.TimeLimitMinutes with
|
||||
| Some minutes ->
|
||||
Html.div [
|
||||
prop.className "meta-item"
|
||||
prop.children [
|
||||
Html.span [ prop.className "meta-label"; prop.text "Лимит времени" ]
|
||||
Html.span [
|
||||
prop.className "meta-value"
|
||||
prop.text (sprintf "%d мин" minutes)
|
||||
]
|
||||
]
|
||||
]
|
||||
| None -> Html.none
|
||||
match quiz.MaxAttempts, remainingAttempts with
|
||||
| Some maxAttempts, Some remaining ->
|
||||
Html.div [
|
||||
prop.className "meta-item"
|
||||
prop.children [
|
||||
Html.span [ prop.className "meta-label"; prop.text "Осталось попыток" ]
|
||||
Html.span [
|
||||
prop.className "meta-value"
|
||||
prop.text (sprintf "%d из %d" remaining maxAttempts)
|
||||
]
|
||||
]
|
||||
]
|
||||
| _ -> Html.none
|
||||
]
|
||||
]
|
||||
if quiz.AlreadyPassed then
|
||||
Html.span [ prop.className "status-badge active"; prop.text "Тест пройден успешно" ]
|
||||
elif remainingAttempts = Some 0 then
|
||||
Html.p [ prop.className "hint"; prop.text "Попытки исчерпаны" ]
|
||||
else
|
||||
Html.button [
|
||||
prop.disabled (model.StartingQuizId = Some quiz.Id)
|
||||
prop.onClick (fun _ -> dispatch (Start quiz.Id))
|
||||
prop.text (
|
||||
if model.StartingQuizId = Some quiz.Id then
|
||||
"Запуск…"
|
||||
else
|
||||
"Начать тест"
|
||||
)
|
||||
]
|
||||
if quiz.AttemptsCount > 0 then
|
||||
Html.button [
|
||||
prop.type'.button
|
||||
prop.onClick (fun _ -> dispatch (ToggleResults quiz.Id))
|
||||
prop.text (
|
||||
if model.ExpandedResultsFor = Some quiz.Id then
|
||||
"Скрыть результаты"
|
||||
else
|
||||
sprintf "Посмотреть результаты (%d)" quiz.AttemptsCount
|
||||
)
|
||||
]
|
||||
if model.ExpandedResultsFor = Some quiz.Id then
|
||||
resultsPanel model
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
Reference in New Issue
Block a user