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:
73
src/Client/Features/Quizzes/Browse/Api.fs
Normal file
73
src/Client/Features/Quizzes/Browse/Api.fs
Normal file
@@ -0,0 +1,73 @@
|
||||
module Client.Features.Quizzes.Browse.Api
|
||||
|
||||
open Fable.Core.JsInterop
|
||||
open Domain
|
||||
open Domain.Contracts
|
||||
open Client.Shared.JsonWire
|
||||
|
||||
let private decodeQuizSummary (raw: obj) : QuizSummary =
|
||||
{ Id = decQuizId raw?Id
|
||||
Title = raw?Title
|
||||
Description = raw?Description
|
||||
TotalPoints = raw?TotalPoints
|
||||
TimeLimitMinutes = raw?TimeLimitMinutes |> optDec unbox<int>
|
||||
MaxAttempts = raw?MaxAttempts |> optDec unbox<int>
|
||||
AttemptsCount = raw?AttemptsCount
|
||||
AlreadyPassed = raw?AlreadyPassed }
|
||||
|
||||
let private decodeMyAttemptSummary (raw: obj) : MyAttemptSummary =
|
||||
{ AttemptId = decAttemptId raw?AttemptId
|
||||
StartedAt = System.DateTimeOffset.Parse(raw?StartedAt: string)
|
||||
Score = raw?Score
|
||||
MaxScore = raw?MaxScore
|
||||
Passed = raw?Passed |> optDec unbox<bool> }
|
||||
|
||||
let private decodeQuestionViewKind (raw: obj) : QuestionViewKind =
|
||||
match box raw with
|
||||
| :? string as caseName ->
|
||||
match caseName with
|
||||
| "TrueFalseView" -> TrueFalseView
|
||||
| "ShortAnswerView" -> ShortAnswerView
|
||||
| "NumericView" -> NumericView
|
||||
| other -> failwithf "Неизвестный тип вопроса: %s" other
|
||||
| _ ->
|
||||
if not (isNullOrUndefined raw?SingleChoiceView) then
|
||||
let options: (obj * string)[] = raw?SingleChoiceView
|
||||
SingleChoiceView(options |> Array.toList |> List.map (fun (idRaw, text) -> decOptionId idRaw, text))
|
||||
elif not (isNullOrUndefined raw?MultipleChoiceView) then
|
||||
let options: (obj * string)[] = raw?MultipleChoiceView
|
||||
MultipleChoiceView(options |> Array.toList |> List.map (fun (idRaw, text) -> decOptionId idRaw, text))
|
||||
else
|
||||
failwith "Неизвестный тип вопроса"
|
||||
|
||||
let private decodeQuestionView (raw: obj) : QuestionView =
|
||||
{ Id = decQuestionId raw?Id
|
||||
Text = raw?Text
|
||||
Points = raw?Points
|
||||
Kind = decodeQuestionViewKind raw?Kind }
|
||||
|
||||
let private decodeQuizForAttempt (raw: obj) : QuizForAttempt =
|
||||
{ AttemptId = decAttemptId raw?AttemptId
|
||||
Quiz = decodeQuizSummary raw?Quiz
|
||||
StartedAt = System.DateTimeOffset.Parse(raw?StartedAt: string)
|
||||
Questions = (unbox<obj[]> raw?Questions) |> Array.toList |> List.map decodeQuestionView }
|
||||
|
||||
let getAvailableQuizzes (token: string option) : Async<QuizSummary list> =
|
||||
async {
|
||||
let! raw = callApi token "GET" "/api/quizzes" None
|
||||
return (unbox<obj[]> raw) |> Array.toList |> List.map decodeQuizSummary
|
||||
}
|
||||
|
||||
let startAttempt (token: string option) (quizId: QuizId) : Async<Result<QuizForAttempt, string>> =
|
||||
async {
|
||||
let body = createObj [ "QuizId" ==> encQuizId quizId ]
|
||||
let! raw = callApi token "POST" "/api/quizzes/start" (Some body)
|
||||
return decodeResult decodeQuizForAttempt raw
|
||||
}
|
||||
|
||||
let getMyAttempts (token: string option) (quizId: QuizId) : Async<Result<MyAttemptSummary list, string>> =
|
||||
async {
|
||||
let body = createObj [ "QuizId" ==> encQuizId quizId ]
|
||||
let! raw = callApi token "POST" "/api/quizzes/my-attempts" (Some body)
|
||||
return decodeResult (fun r -> (unbox<obj[]> r) |> Array.toList |> List.map decodeMyAttemptSummary) raw
|
||||
}
|
||||
49
src/Client/Features/Quizzes/Browse/State.fs
Normal file
49
src/Client/Features/Quizzes/Browse/State.fs
Normal file
@@ -0,0 +1,49 @@
|
||||
module Client.Features.Quizzes.Browse.State
|
||||
|
||||
open Elmish
|
||||
open Client.Features.Quizzes.Browse.Types
|
||||
|
||||
let init () : Model * Cmd<Msg> = empty, Cmd.ofMsg Load
|
||||
|
||||
let private loadCmd (token: string option) =
|
||||
Cmd.OfAsync.either Api.getAvailableQuizzes token Loaded (fun ex -> LoadFailed ex.Message)
|
||||
|
||||
let update (token: string option) (msg: Msg) (model: Model) : Model * Cmd<Msg> =
|
||||
match msg with
|
||||
| Load -> { model with Loading = true; Error = None }, loadCmd token
|
||||
| Loaded quizzes -> { model with Quizzes = quizzes; Loading = false }, Cmd.none
|
||||
| LoadFailed err -> { model with Loading = false; Error = Some err }, Cmd.none
|
||||
| Start quizId ->
|
||||
let cmd =
|
||||
Cmd.OfAsync.either
|
||||
(Api.startAttempt token)
|
||||
quizId
|
||||
(function
|
||||
| Ok data -> Started data
|
||||
| Error err -> StartFailed err)
|
||||
(fun ex -> StartFailed ex.Message)
|
||||
|
||||
{ model with StartingQuizId = Some quizId; Error = None }, cmd
|
||||
| Started _ -> { model with StartingQuizId = None }, Cmd.none // handled by the parent, which switches pages
|
||||
| StartFailed err -> { model with StartingQuizId = None; Error = Some err }, Cmd.none
|
||||
| ToggleResults quizId ->
|
||||
if model.ExpandedResultsFor = Some quizId then
|
||||
{ model with ExpandedResultsFor = None }, Cmd.none
|
||||
else
|
||||
let cmd =
|
||||
Cmd.OfAsync.either
|
||||
(Api.getMyAttempts token)
|
||||
quizId
|
||||
(function
|
||||
| Ok results -> ResultsLoaded results
|
||||
| Error err -> ResultsLoadFailed err)
|
||||
(fun ex -> ResultsLoadFailed ex.Message)
|
||||
|
||||
{ model with
|
||||
ExpandedResultsFor = Some quizId
|
||||
ResultsLoading = true
|
||||
ResultsError = None
|
||||
Results = [] },
|
||||
cmd
|
||||
| ResultsLoaded results -> { model with Results = results; ResultsLoading = false }, Cmd.none
|
||||
| ResultsLoadFailed err -> { model with ResultsLoading = false; ResultsError = Some err }, Cmd.none
|
||||
37
src/Client/Features/Quizzes/Browse/Types.fs
Normal file
37
src/Client/Features/Quizzes/Browse/Types.fs
Normal file
@@ -0,0 +1,37 @@
|
||||
module Client.Features.Quizzes.Browse.Types
|
||||
|
||||
open Domain
|
||||
open Domain.Contracts
|
||||
|
||||
type Model =
|
||||
{ Quizzes: QuizSummary list
|
||||
Loading: bool
|
||||
Error: string option
|
||||
StartingQuizId: QuizId option
|
||||
/// Which quiz's past-attempts panel is currently expanded, if any —
|
||||
/// only one at a time, closing on a second click of the same button.
|
||||
ExpandedResultsFor: QuizId option
|
||||
ResultsLoading: bool
|
||||
ResultsError: string option
|
||||
Results: MyAttemptSummary list }
|
||||
|
||||
let empty =
|
||||
{ Quizzes = []
|
||||
Loading = false
|
||||
Error = None
|
||||
StartingQuizId = None
|
||||
ExpandedResultsFor = None
|
||||
ResultsLoading = false
|
||||
ResultsError = None
|
||||
Results = [] }
|
||||
|
||||
type Msg =
|
||||
| Load
|
||||
| Loaded of QuizSummary list
|
||||
| LoadFailed of string
|
||||
| Start of QuizId
|
||||
| Started of QuizForAttempt
|
||||
| StartFailed of string
|
||||
| ToggleResults of QuizId
|
||||
| ResultsLoaded of MyAttemptSummary list
|
||||
| ResultsLoadFailed of string
|
||||
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