Партия: совместное заполнение формы завершения на клиенте
Раскладка, комментарии, причина победы и комментарий о партии уходят в общий черновик через ~0.6с после последнего действия, а чужие правки подтягиваются по SSE. Чужой черновик применяется, только если с момента моего последнего действия прошло больше 1.5с — иначе правка соседа перетирала бы тайл прямо под рукой. Перед «Завершить» отложенная запись дожимается: иначе последняя правка попала бы в результаты, но не в черновик, и второй участник увидел бы не то, что записалось. Само завершение по-прежнему шлёт тело запроса, так что работает и без черновика. Под подсказкой о перетаскивании появилась строка «Результаты заполняет также <Ник>», а если партию завершил кто-то другой — тост вместо молча исчезающей формы. В админке у идущей партии вместо формы правки — пояснение и переход на страницу партии: сервер её результаты всё равно не примет. #28 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
Vendored
+98
@@ -548,6 +548,29 @@ export interface paths {
|
||||
patch: operations["update_match_api_matches__match_id__patch"];
|
||||
trace?: never;
|
||||
};
|
||||
"/api/matches/{match_id}/finish-draft": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
/**
|
||||
* Save Finish Draft
|
||||
* @description Общий черновик формы завершения: то, что видят все, кто заполняет партию.
|
||||
*
|
||||
* Права те же, что у самой формы. Версию партии запись черновика не двигает —
|
||||
* иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку.
|
||||
*/
|
||||
put: operations["save_finish_draft_api_matches__match_id__finish_draft_put"];
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/matches/{match_id}/attachments": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1603,6 +1626,45 @@ export interface components {
|
||||
/** Expected Version */
|
||||
expected_version?: string | null;
|
||||
};
|
||||
/**
|
||||
* MatchFinishDraftData
|
||||
* @description Состояние формы завершения: блоки мест (внутри блока — ничья), выбывшие,
|
||||
* комментарии об игроках и причина победы. Промежуточное состояние, поэтому
|
||||
* места не валидируются — человек раскладывает их постепенно.
|
||||
*/
|
||||
MatchFinishDraftData: {
|
||||
/**
|
||||
* Blocks
|
||||
* @default []
|
||||
*/
|
||||
blocks: number[][];
|
||||
/**
|
||||
* Eliminated
|
||||
* @default []
|
||||
*/
|
||||
eliminated: number[];
|
||||
/**
|
||||
* Comments
|
||||
* @default {}
|
||||
*/
|
||||
comments: {
|
||||
[key: string]: string;
|
||||
};
|
||||
/** Win Reason */
|
||||
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null;
|
||||
/** Overall Comment */
|
||||
overall_comment?: string | null;
|
||||
};
|
||||
/** MatchFinishDraftRead */
|
||||
MatchFinishDraftRead: {
|
||||
data: components["schemas"]["MatchFinishDraftData"];
|
||||
/** Updated By */
|
||||
updated_by?: number | null;
|
||||
/** Updated By Nickname */
|
||||
updated_by_nickname?: string | null;
|
||||
/** Updated At */
|
||||
updated_at: string;
|
||||
};
|
||||
/** MatchFinishParticipant */
|
||||
MatchFinishParticipant: {
|
||||
/** User Id */
|
||||
@@ -1775,6 +1837,7 @@ export interface components {
|
||||
* @default []
|
||||
*/
|
||||
attachments: components["schemas"]["AttachmentRead"][];
|
||||
finish_draft?: components["schemas"]["MatchFinishDraftRead"] | null;
|
||||
};
|
||||
/** MatchUpdate */
|
||||
MatchUpdate: {
|
||||
@@ -3238,6 +3301,41 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
save_finish_draft_api_matches__match_id__finish_draft_put: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
match_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["MatchFinishDraftData"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["MatchRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
add_attachment_api_matches__match_id__attachments_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -33,3 +33,4 @@ export type ParticipantInput = S["ParticipantInput"];
|
||||
export type MatchUpdate = S["MatchUpdate"];
|
||||
export type MatchCreate = S["MatchCreate"];
|
||||
export type MatchFinish = S["MatchFinish"];
|
||||
export type MatchFinishDraftData = S["MatchFinishDraftData"];
|
||||
|
||||
@@ -2,7 +2,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
||||
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
|
||||
import type {
|
||||
FactionRead,
|
||||
MatchCreate,
|
||||
MatchFinish,
|
||||
MatchFinishDraftData,
|
||||
MatchRead,
|
||||
} from "../domain/types";
|
||||
import { uploadFile } from "../lib/upload";
|
||||
|
||||
export function useMatch(matchId: number | null) {
|
||||
@@ -20,6 +26,26 @@ export function useMatch(matchId: number | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Общий черновик формы завершения: то, что видят все, кто заполняет партию.
|
||||
* Ответ — свежая партия целиком, поэтому кладём его прямо в кэш, без инвалидации:
|
||||
* перезапрос на каждое движение тайла был бы расточительным.
|
||||
*/
|
||||
export function useSaveFinishDraft(matchId: number | null) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (data: MatchFinishDraftData): Promise<MatchRead> =>
|
||||
unwrap(
|
||||
await api.PUT("/api/matches/{match_id}/finish-draft", {
|
||||
params: { path: { match_id: matchId as number } },
|
||||
body: data,
|
||||
}),
|
||||
),
|
||||
onSuccess: (m) => qc.setQueryData(qk.match(m.id), m),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export function useRandomizeFaction() {
|
||||
return useMutation({
|
||||
mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise<FactionRead> => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
||||
|
||||
interface ServerEvent {
|
||||
type: "match" | "group" | "invitations" | "notifications";
|
||||
type: "match" | "match_draft" | "group" | "invitations" | "notifications";
|
||||
match_id?: number;
|
||||
group_id?: number;
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export function useServerEvents(enabled: boolean) {
|
||||
qc.invalidateQueries({ queryKey: qk.invitations });
|
||||
} else if (ev.type === "notifications") {
|
||||
qc.invalidateQueries({ queryKey: qk.notifications });
|
||||
} else if (ev.type === "match_draft") {
|
||||
// Черновик меняется на каждое движение тайла: трогаем только саму партию,
|
||||
// без лидерборда и историй — иначе группа захлебнётся перезапросами.
|
||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||
} else if (ev.type === "match") {
|
||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||
if (ev.group_id != null) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
@@ -9,13 +9,16 @@ import { PlaceEditor } from "../components/PlaceEditor";
|
||||
import { PlayerLink } from "../components/PlayerLink";
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { formatDate, formatDuration, formatTime } from "../domain/format";
|
||||
import type { MatchFinishDraftData } from "../domain/types";
|
||||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import {
|
||||
useDeleteMatch,
|
||||
useDeleteMatchAttachment,
|
||||
useFinishMatch,
|
||||
useMatch,
|
||||
useSaveFinishDraft,
|
||||
useUploadMatchAttachment,
|
||||
} from "../hooks/matches";
|
||||
|
||||
@@ -27,7 +30,9 @@ export function MatchDetailPage() {
|
||||
const parsed = matchId ? Number(matchId) : NaN;
|
||||
const id = Number.isInteger(parsed) ? parsed : null;
|
||||
const { data: match, isLoading, refetch } = useMatch(id);
|
||||
const { data: me } = useMe();
|
||||
const finish = useFinishMatch();
|
||||
const saveDraft = useSaveFinishDraft(id);
|
||||
const del = useDeleteMatch();
|
||||
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||||
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||||
@@ -43,6 +48,61 @@ export function MatchDetailPage() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
// ─── Совместное заполнение формы ───
|
||||
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
|
||||
// каждое было бы перебором. Актуальное состояние держим в ref — таймер иначе
|
||||
// отправил бы то, что было на момент его постановки.
|
||||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const draftPayload = useRef<MatchFinishDraftData | null>(null);
|
||||
const lastLocalEdit = useRef(0);
|
||||
const appliedDraftAt = useRef<string | null>(null);
|
||||
const iFinished = useRef(false);
|
||||
const prevStatus = useRef<string | null>(null);
|
||||
|
||||
const sendDraft = () => {
|
||||
draftTimer.current = null;
|
||||
if (draftPayload.current && id) saveDraft.mutate(draftPayload.current);
|
||||
};
|
||||
|
||||
const queueDraft = (payload: MatchFinishDraftData) => {
|
||||
draftPayload.current = payload;
|
||||
lastLocalEdit.current = Date.now();
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
draftTimer.current = setTimeout(sendDraft, 600);
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
|
||||
// иначе правка соседа перетёрла бы тайл прямо под рукой.
|
||||
const incoming = match?.finish_draft;
|
||||
useEffect(() => {
|
||||
if (!incoming || match?.status !== "in_progress") return;
|
||||
if (incoming.updated_by === me?.id) return;
|
||||
if (incoming.updated_at === appliedDraftAt.current) return;
|
||||
if (Date.now() - lastLocalEdit.current < 1500) return;
|
||||
setBlocks(incoming.data.blocks);
|
||||
setElim(incoming.data.eliminated);
|
||||
setComments(incoming.data.comments);
|
||||
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason);
|
||||
setOverall(incoming.data.overall_comment ?? "");
|
||||
appliedDraftAt.current = incoming.updated_at;
|
||||
}, [incoming, match?.status, me?.id]);
|
||||
|
||||
// Партию завершил кто-то другой — форма исчезает, и без этого молча.
|
||||
useEffect(() => {
|
||||
if (!match) return;
|
||||
if (prevStatus.current === "in_progress" && match.status === "finished" && !iFinished.current) {
|
||||
toast.show("Партию завершил другой участник — показаны его результаты");
|
||||
}
|
||||
prevStatus.current = match.status;
|
||||
}, [match, toast]);
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||||
|
||||
@@ -58,9 +118,28 @@ export function MatchDetailPage() {
|
||||
// Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем.
|
||||
const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
||||
|
||||
// Снимок формы для общего черновика: то, что увидят остальные заполняющие.
|
||||
const draftOf = (patch: Partial<MatchFinishDraftData> = {}): MatchFinishDraftData => ({
|
||||
blocks: finishBlocks,
|
||||
eliminated: elim,
|
||||
comments: Object.fromEntries(
|
||||
Object.entries(finishComments).map(([uid, text]) => [String(uid), text]),
|
||||
),
|
||||
win_reason: winReason,
|
||||
overall_comment: overall.trim() || null,
|
||||
...patch,
|
||||
});
|
||||
|
||||
const submitFinish = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
|
||||
// но не в черновик, и второй участник увидел бы не то, что записалось.
|
||||
if (draftTimer.current) {
|
||||
clearTimeout(draftTimer.current);
|
||||
sendDraft();
|
||||
}
|
||||
iFinished.current = true;
|
||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||
let place = 1;
|
||||
const survivors = finishBlocks.flatMap((ids) => {
|
||||
@@ -207,6 +286,13 @@ export function MatchDetailPage() {
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
||||
<p className="small" style={{ color: "var(--accent-2)" }}>
|
||||
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
||||
{" · "}
|
||||
{formatTime(match.finish_draft.updated_at)}
|
||||
</p>
|
||||
)}
|
||||
<PlaceEditor
|
||||
players={match.participants}
|
||||
blocks={finishBlocks}
|
||||
@@ -215,10 +301,19 @@ export function MatchDetailPage() {
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
queueDraft(draftOf({ blocks: b, eliminated: e }));
|
||||
}}
|
||||
onComment={(uid, text) => {
|
||||
const next = { ...finishComments, [uid]: text };
|
||||
setComments(next);
|
||||
queueDraft(
|
||||
draftOf({
|
||||
comments: Object.fromEntries(
|
||||
Object.entries(next).map(([k, v]) => [String(k), v]),
|
||||
),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onComment={(uid, text) =>
|
||||
setComments({ ...finishComments, [uid]: text })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -229,7 +324,10 @@ export function MatchDetailPage() {
|
||||
options={REASON_OPTIONS}
|
||||
placeholder="— причина —"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => setWinReason(o.id)}
|
||||
onPick={(o) => {
|
||||
setWinReason(o.id);
|
||||
queueDraft(draftOf({ win_reason: o.id }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -240,7 +338,10 @@ export function MatchDetailPage() {
|
||||
style={{ resize: "none" }}
|
||||
placeholder="Комментарий о партии"
|
||||
value={overall}
|
||||
onChange={(e) => setOverall(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setOverall(e.target.value);
|
||||
queueDraft(draftOf({ overall_comment: e.target.value.trim() || null }));
|
||||
}}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<MatchMedia
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { ApiError } from "../../api/client";
|
||||
import { MatchMedia } from "../../components/MatchMedia";
|
||||
@@ -33,6 +34,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
const uploadAtt = useAdminUploadAttachment(matchId);
|
||||
const deleteAtt = useAdminDeleteAttachment(matchId);
|
||||
const toast = useToast();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [playedAt, setPlayedAt] = useState("");
|
||||
@@ -110,6 +112,20 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
||||
|
||||
{isLoading || !match ? (
|
||||
<Spinner />
|
||||
) : match.status === "in_progress" ? (
|
||||
// Результаты идущей партии сервер не примет (409): места без завершения
|
||||
// оставляли бы партию вне статистики. Объясняем это здесь, а не ошибкой.
|
||||
<div className="card">
|
||||
<h3>Партия ещё идёт</h3>
|
||||
<p className="muted small">
|
||||
Результаты незавершённой партии правкой не вносятся — их проставляют
|
||||
при завершении, на странице самой партии. Там же участники заполняют
|
||||
места совместно.
|
||||
</p>
|
||||
<button className="btn btn-block" onClick={() => navigate(`/match/${matchId}`)}>
|
||||
Открыть страницу партии
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
|
||||
Reference in New Issue
Block a user