Редактор ачивок в админке + файловое хранилище ачивок (задел под титулы)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:41:20 +03:00
co-authored by Claude Opus 4.8
parent 96daddae05
commit c4065962e2
19 changed files with 1165 additions and 6 deletions
+1
View File
@@ -20,4 +20,5 @@ export const qk = {
adminGroups: ["adminGroups"] as const,
adminMatches: ["adminMatches"] as const,
adminLogs: ["adminLogs"] as const,
adminAchievements: ["adminAchievements"] as const,
};
+339
View File
@@ -509,6 +509,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/achievements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Achievements */
get: operations["list_achievements_api_achievements_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/achievements/{slug}/icon": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Icon */
get: operations["get_icon_api_achievements__slug__icon_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/login": {
parameters: {
query?: never;
@@ -698,6 +732,59 @@ export interface paths {
patch: operations["rename_faction_api_admin_factions__faction_id__patch"];
trace?: never;
};
"/api/admin/achievements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Achievements */
get: operations["list_achievements_api_admin_achievements_get"];
put?: never;
/** Create Achievement */
post: operations["create_achievement_api_admin_achievements_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/achievements/{slug}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Delete Achievement */
delete: operations["delete_achievement_api_admin_achievements__slug__delete"];
options?: never;
head?: never;
/** Update Achievement */
patch: operations["update_achievement_api_admin_achievements__slug__patch"];
trace?: never;
};
"/api/admin/achievements/{slug}/icon": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Upload Achievement Icon */
put: operations["upload_achievement_icon_api_admin_achievements__slug__icon_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/audit-logs": {
parameters: {
query?: never;
@@ -788,6 +875,48 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
/** AchievementCreate */
AchievementCreate: {
/** Name */
name: string;
/** Description */
description?: string | null;
/** Condition */
condition?: string | null;
};
/** AchievementRead */
AchievementRead: {
/** Slug */
slug: string;
/** Name */
name: string;
/**
* Description
* @default
*/
description: string;
/**
* Condition
* @default
*/
condition: string;
/**
* Has Condition
* @default false
*/
has_condition: boolean;
/** Icon Url */
icon_url?: string | null;
};
/** AchievementUpdate */
AchievementUpdate: {
/** Name */
name?: string | null;
/** Description */
description?: string | null;
/** Condition */
condition?: string | null;
};
/** ActiveGroupUpdate */
ActiveGroupUpdate: {
/** Group Id */
@@ -912,6 +1041,11 @@ export interface components {
/** Telegram Bot Username */
telegram_bot_username?: string | null;
};
/** Body_upload_achievement_icon_api_admin_achievements__slug__icon_put */
Body_upload_achievement_icon_api_admin_achievements__slug__icon_put: {
/** File */
file: string;
};
/** Body_upload_my_avatar_api_users_me_avatar_put */
Body_upload_my_avatar_api_users_me_avatar_put: {
/** File */
@@ -2640,6 +2774,57 @@ export interface operations {
};
};
};
list_achievements_api_achievements_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"][];
};
};
};
};
get_icon_api_achievements__slug__icon_get: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_login_api_admin_auth_login_post: {
parameters: {
query?: never;
@@ -3017,6 +3202,160 @@ export interface operations {
};
};
};
list_achievements_api_admin_achievements_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"][];
};
};
};
};
create_achievement_api_admin_achievements_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AchievementCreate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_achievement_api_admin_achievements__slug__delete: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_achievement_api_admin_achievements__slug__patch: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AchievementUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
upload_achievement_icon_api_admin_achievements__slug__icon_put: {
parameters: {
query?: never;
header?: never;
path: {
slug: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_upload_achievement_icon_api_admin_achievements__slug__icon_put"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AchievementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
audit_logs_api_admin_audit_logs_get: {
parameters: {
query?: {
+2
View File
@@ -12,6 +12,7 @@ import { MatchDetailPage } from "../pages/MatchDetailPage";
import { OverallStatsPage } from "../pages/OverallStatsPage";
import { PublicProfilePage } from "../pages/PublicProfilePage";
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
import { AdminLayout } from "../pages/admin/AdminLayout";
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
@@ -65,6 +66,7 @@ export const router = createBrowserRouter([
{ path: "records", element: <AdminRecordsPage /> },
{ path: "accounts", element: <AdminAccountsPage /> },
{ path: "factions", element: <AdminFactionsPage /> },
{ path: "achievements", element: <AdminAchievementsPage /> },
{ path: "logs", element: <AdminLogsPage /> },
],
},
+1
View File
@@ -26,6 +26,7 @@ export type AdminUserRead = S["AdminUserRead"];
export type AdminGroupRead = S["AdminGroupRead"];
export type AdminMatchRead = S["AdminMatchRead"];
export type AuditLogList = S["AuditLogList"];
export type AchievementRead = S["AchievementRead"];
export type ParticipantInput = S["ParticipantInput"];
export type MatchUpdate = S["MatchUpdate"];
export type MatchCreate = S["MatchCreate"];
+87 -1
View File
@@ -1,9 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useAdminMe() {
return useQuery({
queryKey: qk.adminMe,
@@ -163,3 +168,84 @@ export function useAdminUpdateUser() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
});
}
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
interface AchievementInput {
name?: string;
description?: string;
condition?: string;
}
export function useAdminAchievements() {
return useQuery({
queryKey: qk.adminAchievements,
queryFn: async () => unwrap(await api.GET("/api/admin/achievements")),
});
}
export function useCreateAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: AchievementInput & { name: string }) =>
unwrap(await api.POST("/api/admin/achievements", { body })),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
export function useUpdateAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { slug: string; body: AchievementInput }) =>
unwrap(
await api.PATCH("/api/admin/achievements/{slug}", {
params: { path: { slug: args.slug } },
body: args.body,
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
export function useDeleteAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (slug: string) =>
unwrap(
await api.DELETE("/api/admin/achievements/{slug}", {
params: { path: { slug } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
// Иконка ачивки — multipart отдельным fetch (как аватар), с CSRF из cookie.
export function useUploadAchievementIcon() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ slug, file }: { slug: string; file: File }) => {
const form = new FormData();
form.append("file", file, file.name);
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, {
method: "PUT",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status);
}
return await r.json();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
@@ -0,0 +1,192 @@
import { useRef, useState } from "react";
import { ApiError } from "../../api/client";
import { Avatar } from "../../components/Avatar";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import {
useAdminAchievements,
useCreateAchievement,
useDeleteAchievement,
useUpdateAchievement,
useUploadAchievementIcon,
} from "../../hooks/admin";
import type { AchievementRead } from "../../domain/types";
export function AdminAchievementsPage() {
const { data: items, isLoading } = useAdminAchievements();
const create = useCreateAchievement();
const toast = useToast();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [condition, setCondition] = useState("");
if (isLoading) return <Spinner />;
const onCreate = async () => {
if (!name.trim()) return;
try {
await create.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
condition: condition.trim() || undefined,
});
setName("");
setDescription("");
setCondition("");
toast.show("Ачивка создана");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div>
<div className="card">
<h3>Новая ачивка</h3>
<div className="field">
<label className="label">Название</label>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Например: Великий полководец" />
</div>
<div className="field">
<label className="label">Краткое описание</label>
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="За что выдаётся"
/>
</div>
<div className="field">
<label className="label">Скрипт-условие (задел на будущее, пока не исполняется)</label>
<textarea
rows={3}
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="# условие выдачи — добавится позже"
/>
</div>
<button className="btn btn-primary" onClick={onCreate} disabled={create.isPending || !name.trim()}>
Создать
</button>
</div>
<div className="stack">
{(items ?? []).map((a) => (
<AchievementCard key={a.slug} ach={a} />
))}
{(items ?? []).length === 0 && <div className="muted small">Ачивок пока нет.</div>}
</div>
</div>
);
}
function AchievementCard({ ach }: { ach: AchievementRead }) {
const update = useUpdateAchievement();
const del = useDeleteAchievement();
const uploadIcon = useUploadAchievementIcon();
const toast = useToast();
const fileRef = useRef<HTMLInputElement>(null);
const [editing, setEditing] = useState(false);
const [name, setName] = useState(ach.name);
const [description, setDescription] = useState(ach.description);
const [condition, setCondition] = useState(ach.condition);
const save = async () => {
try {
await update.mutateAsync({
slug: ach.slug,
body: { name: name.trim(), description, condition },
});
setEditing(false);
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onIcon = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
try {
await uploadIcon.mutateAsync({ slug: ach.slug, file });
toast.show("Иконка обновлена");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const remove = async () => {
if (!window.confirm(`Удалить ачивку «${ach.name}»?`)) return;
try {
await del.mutateAsync(ach.slug);
toast.show("Удалено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div className="card" style={{ margin: 0 }}>
<div className="row" style={{ gap: 12, alignItems: "flex-start" }}>
<Avatar url={ach.icon_url} nickname={ach.name} size={48} />
<div style={{ flex: 1, minWidth: 0 }}>
{editing ? (
<div className="stack" style={{ gap: 8 }}>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Название" />
<input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Описание"
/>
<textarea
rows={3}
value={condition}
onChange={(e) => setCondition(e.target.value)}
placeholder="Скрипт-условие (задел)"
/>
<div className="row" style={{ gap: 8 }}>
<button className="btn btn-primary small" onClick={save} disabled={update.isPending || !name.trim()}>
Сохранить
</button>
<button className="btn btn-ghost small" onClick={() => setEditing(false)}>
Отмена
</button>
</div>
</div>
) : (
<>
<b>{ach.name}</b>
<div className="muted small">{ach.slug}</div>
{ach.description && <div style={{ marginTop: 4 }}>{ach.description}</div>}
<div className="muted small" style={{ marginTop: 4 }}>
{ach.has_condition ? "условие задано (не исполняется)" : "условие не задано"}
</div>
</>
)}
</div>
</div>
{!editing && (
<div className="row" style={{ gap: 8, marginTop: 10 }}>
<button className="btn small" onClick={() => fileRef.current?.click()} disabled={uploadIcon.isPending}>
Иконка
</button>
<button className="btn small" onClick={() => setEditing(true)}>
Изменить
</button>
<button className="btn btn-ghost btn-danger small" onClick={remove} disabled={del.isPending}>
Удалить
</button>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
style={{ display: "none" }}
onChange={onIcon}
/>
</div>
)}
</div>
);
}
+3
View File
@@ -32,6 +32,9 @@ export function AdminLayout() {
<NavLink to="/admin/factions" className={tab}>
Фракции
</NavLink>
<NavLink to="/admin/achievements" className={tab}>
Ачивки
</NavLink>
<NavLink to="/admin/logs" className={tab}>
Логи
</NavLink>