feat(web): React-фронтенд вместо Flet + fix restore rmtree на bind-mount
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).
This commit is contained in:
102
web-react/src/pages/ResponsePage.tsx
Normal file
102
web-react/src/pages/ResponsePage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user