web-react/: SPA Vite+React+TS, nginx раздаёт статику и проксирует /api на api:8000 по локалке. docker-compose: web(Flet)->web-react на :80. transfer_crud.backup_restore: чистим содержимое data/radar вместо rmtree точки монтирования (EBUSY).
103 lines
3.1 KiB
TypeScript
103 lines
3.1 KiB
TypeScript
import { useEffect, useState } from "react";
|
||
import { useParams } from "react-router-dom";
|
||
import * as api from "../api/client";
|
||
import { Layout } from "../components/Layout";
|
||
import { Card, CenterSpinner } from "../components/ui";
|
||
import { RadarBlock } from "../components/RadarBlock";
|
||
import { IconCheckCircle, IconRadioUnchecked } from "../components/icons";
|
||
|
||
interface State {
|
||
response: api.ResponseDetail;
|
||
poll: api.Poll;
|
||
hasRadar: boolean;
|
||
}
|
||
|
||
export function ResponsePage() {
|
||
const { responseId = "" } = useParams();
|
||
const [state, setState] = useState<State | null>(null);
|
||
const [error, setError] = useState("");
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
(async () => {
|
||
const response = await api.getResponse(responseId);
|
||
if (cancelled) return;
|
||
if (!response) {
|
||
setError("Результат не найден");
|
||
return;
|
||
}
|
||
const poll = await api.getPoll(response.poll_id);
|
||
if (cancelled) return;
|
||
if (!poll) {
|
||
setError("Тест не найден");
|
||
return;
|
||
}
|
||
const radar = await api.getRadarResult(responseId);
|
||
if (cancelled) return;
|
||
setState({ response, poll, hasRadar: !!radar?.image_url });
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [responseId]);
|
||
|
||
return (
|
||
<Layout title="Результаты теста" back="/my-tests" showProfile={false}>
|
||
{error ? (
|
||
<p className="error-text">{error}</p>
|
||
) : !state ? (
|
||
<CenterSpinner />
|
||
) : (
|
||
<ResultBody {...state} responseId={responseId} />
|
||
)}
|
||
</Layout>
|
||
);
|
||
}
|
||
|
||
function ResultBody({ response, poll, hasRadar, responseId }: State & { responseId: string }) {
|
||
const questions = new Map(poll.questions?.map((q) => [q.id, q]) ?? []);
|
||
const submittedAt = response.submitted_at.slice(0, 19).replace("T", " ");
|
||
|
||
return (
|
||
<div className="stack">
|
||
<div className="col-gap" style={{ gap: "0.25rem" }}>
|
||
<span className="title-lg">{poll.title}</span>
|
||
<span className="text-sm muted">Пройден: {submittedAt}</span>
|
||
</div>
|
||
|
||
{hasRadar && (
|
||
<Card alt>
|
||
<div className="center-col">
|
||
<span className="title-md">Результат</span>
|
||
<RadarBlock responseId={responseId} />
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
<span className="title-md">Ответы</span>
|
||
{response.answers.map((ans, i) => {
|
||
const q = questions.get(ans.question_id);
|
||
if (!q) return null;
|
||
return (
|
||
<Card key={i}>
|
||
<div className="col-gap">
|
||
<span className="bold">{q.text}</span>
|
||
{q.choices.map((c) => {
|
||
const selected = c.id === ans.choice_id;
|
||
return (
|
||
<div key={c.id} className={"choice-tile" + (selected ? " selected" : "")}>
|
||
<span className="check">
|
||
{selected ? <IconCheckCircle /> : <IconRadioUnchecked />}
|
||
</span>
|
||
<span>{c.text}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|