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:
jze9
2026-06-02 13:56:44 +00:00
parent 71887b084a
commit eac61923f4
38 changed files with 4480 additions and 10 deletions

View 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>
);
}