Партия: правка истории и кнопка «Редактировать» у игрока
Правка завершённой партии проверяла состав теми же правилами, что и создание: участник обязан состоять в группе сейчас, фракция — быть доступной сейчас. После отключения дополнения партию, сыгранную на Тау, было уже не исправить, а после удаления игрока из группы — любую партию с ним. Теперь то, что уже записано в партии, проходит всегда, а новые игроки и фракции по-прежнему берутся только из текущего состава: чинить историю можно, занести в неё постороннего — нет. Создание партии не ослабло. Править завершённую партию умел любой участник группы, но только через API — кнопки не было, и на практике это мог сделать лишь админ через админку. Теперь у блока «Результаты» есть «Редактировать», и правка идёт тем же перетаскиванием, что и завершение: раскладка восстанавливается из сохранённых мест (одинаковое место — ничья, выбывшие отдельно), рядом — фракции, причина победы и комментарии. Сохранение шлёт версию партии, так что устаревшая правка отклоняется как раньше. Фракции вынесены отдельным блоком, а не в PlaceEditor: он занят перетаскиванием, и селекты внутри него — лишний риск. В списке доступных фракций к набору группы добавляются те, что уже стоят в партии, — иначе фракцию из отключённого дополнения нельзя было бы даже оставить как есть. #29 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
@@ -19,8 +19,10 @@ import {
|
||||
useFinishMatch,
|
||||
useMatch,
|
||||
useSaveFinishDraft,
|
||||
useUpdateMatch,
|
||||
useUploadMatchAttachment,
|
||||
} from "../hooks/matches";
|
||||
import { useGroupFactions } from "../hooks/reference";
|
||||
|
||||
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||||
|
||||
@@ -31,8 +33,10 @@ export function MatchDetailPage() {
|
||||
const id = Number.isInteger(parsed) ? parsed : null;
|
||||
const { data: match, isLoading, refetch } = useMatch(id);
|
||||
const { data: me } = useMe();
|
||||
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||||
const finish = useFinishMatch();
|
||||
const saveDraft = useSaveFinishDraft(id);
|
||||
const updateMatch = useUpdateMatch();
|
||||
const del = useDeleteMatch();
|
||||
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||||
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||||
@@ -47,6 +51,9 @@ export function MatchDetailPage() {
|
||||
const [overall, setOverall] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
// Правка завершённой партии: тот же редактор мест, что и при завершении.
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editFactions, setEditFactions] = useState<Record<number, number>>({});
|
||||
|
||||
// ─── Совместное заполнение формы ───
|
||||
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
|
||||
@@ -181,6 +188,77 @@ export function MatchDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Раскладка для правки восстанавливается из сохранённых результатов: одинаковое
|
||||
// место = ничья, выбывшие уходят отдельным списком.
|
||||
const startEditing = () => {
|
||||
const byPlace = new Map<number, number[]>();
|
||||
for (const p of match.participants.filter((x) => !x.eliminated)) {
|
||||
const key = p.place ?? 99;
|
||||
byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]);
|
||||
}
|
||||
setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids));
|
||||
setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id));
|
||||
setComments(
|
||||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
|
||||
);
|
||||
setWinReason((match.win_reason ?? "objectives") as WinReason);
|
||||
setOverall(match.overall_comment ?? "");
|
||||
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
||||
setError(null);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditing(false);
|
||||
setBlocks(null);
|
||||
setComments(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!id || !match) return;
|
||||
setError(null);
|
||||
const wasRandom = Object.fromEntries(
|
||||
match.participants.map((p) => [p.user_id, p.was_random]),
|
||||
);
|
||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||||
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
|
||||
user_id: uid,
|
||||
faction_id: editFactions[uid],
|
||||
place,
|
||||
eliminated,
|
||||
was_random: wasRandom[uid] ?? false,
|
||||
comment: commentOf(uid),
|
||||
});
|
||||
let place = 1;
|
||||
const survivors = finishBlocks.flatMap((ids) => {
|
||||
const rows = ids.map((uid) => rowOf(uid, place, false));
|
||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
||||
return rows;
|
||||
});
|
||||
try {
|
||||
await updateMatch.mutateAsync({
|
||||
matchId: id,
|
||||
body: {
|
||||
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))],
|
||||
win_reason: winReason,
|
||||
overall_comment: overall.trim() || null,
|
||||
expected_version: match.version,
|
||||
},
|
||||
});
|
||||
toast.show("Партия обновлена");
|
||||
cancelEditing();
|
||||
} catch (e) {
|
||||
if (isStale(e)) {
|
||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||
refetch();
|
||||
cancelEditing();
|
||||
return;
|
||||
}
|
||||
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!id || !match) return;
|
||||
try {
|
||||
@@ -203,6 +281,15 @@ export function MatchDetailPage() {
|
||||
(a, b) => (a.place ?? 99) - (b.place ?? 99),
|
||||
);
|
||||
|
||||
// К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение
|
||||
// могли отключить после игры, но менять её фракцию это мешать не должно.
|
||||
const factionOptions = [
|
||||
...(groupFactions ?? []),
|
||||
...match.participants
|
||||
.filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id))
|
||||
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="card">
|
||||
@@ -226,11 +313,104 @@ export function MatchDetailPage() {
|
||||
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
||||
</div>
|
||||
|
||||
{/* Завершённая партия — правка результатов */}
|
||||
{!inProgress && editing && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Правка результатов</h3>
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
<PlaceEditor
|
||||
players={match.participants}
|
||||
blocks={finishBlocks}
|
||||
eliminated={elim}
|
||||
comments={finishComments}
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
}}
|
||||
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Фракции</h3>
|
||||
<div className="stack">
|
||||
{match.participants.map((p) => (
|
||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
<b style={{ flex: "0 0 40%" }}>{p.nickname}</b>
|
||||
<div style={{ flex: 1 }}>
|
||||
<PickerSelect
|
||||
selected={factionOptions.find((f) => f.id === editFactions[p.user_id]) ?? null}
|
||||
options={factionOptions.filter(
|
||||
(f) =>
|
||||
f.id === editFactions[p.user_id] ||
|
||||
!Object.values(editFactions).includes(f.id),
|
||||
)}
|
||||
placeholder="— фракция —"
|
||||
renderOption={(f) => f.name_ru}
|
||||
onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Причина победы</h3>
|
||||
<PickerSelect
|
||||
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||
options={REASON_OPTIONS}
|
||||
placeholder="— причина —"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => setWinReason(o.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>О партии</h3>
|
||||
<textarea
|
||||
rows={2}
|
||||
style={{ resize: "none" }}
|
||||
placeholder="Комментарий о партии"
|
||||
value={overall}
|
||||
onChange={(e) => setOverall(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
onClick={submitEdit}
|
||||
disabled={updateMatch.isPending}
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={cancelEditing}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Завершённая партия — результаты */}
|
||||
{!inProgress && (
|
||||
{!inProgress && !editing && (
|
||||
<div className="card">
|
||||
<h3>Результаты</h3>
|
||||
<div className="stack">
|
||||
<div className="row-between">
|
||||
<h3 style={{ margin: 0 }}>Результаты</h3>
|
||||
{canModify && (
|
||||
<button className="btn btn-ghost small" onClick={startEditing}>
|
||||
Редактировать
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack" style={{ marginTop: 10 }}>
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id}>
|
||||
<PlayerLink userId={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
|
||||
Reference in New Issue
Block a user