This commit is contained in:
talorr
2026-03-27 03:36:08 +03:00
parent 8a97ce6d54
commit cda36918e8
225 changed files with 35641 additions and 0 deletions

687
frontend/pages/admin.vue Normal file
View File

@@ -0,0 +1,687 @@
<script setup lang="ts">
import type { Bot, PaginatedResponse, Signal, SubscriptionStatus, User, UserBotAccess } from "~/types";
definePageMeta({
middleware: "admin"
});
type AdminUser = User & {
pushSubscriptions: { id: string; endpoint: string; active: boolean }[];
};
type AdminPushDashboard = {
items: Array<{
id: string;
status: "ok" | "ready" | "error" | "inactive";
endpointHost: string;
user: {
id: string;
email: string;
};
latestEvent: {
statusCode: number | null;
reason: string | null;
} | null;
}>;
};
type SubscriptionDraft = {
status: SubscriptionStatus;
startsAt: string;
expiresAt: string;
};
const { formatDateTime } = useBrowserDateTime();
const initialForm = () => ({
eventId: "",
sportType: "football",
leagueName: "",
homeTeam: "",
awayTeam: "",
eventStartTime: "",
marketType: "1X2",
selection: "",
lineValue: "",
odds: "1.90",
signalTime: "",
comment: ""
});
const fieldLabels: Record<keyof ReturnType<typeof initialForm>, string> = {
eventId: "ID события",
sportType: "Вид спорта",
leagueName: "Лига",
homeTeam: "Домашняя команда",
awayTeam: "Гостевая команда",
eventStartTime: "Время начала события",
marketType: "Тип рынка",
selection: "Выбор",
lineValue: "Значение линии",
odds: "Коэффициент",
signalTime: "Время сигнала",
comment: "Комментарий"
};
const getFieldLabel = (key: string) => fieldLabels[key as keyof typeof fieldLabels];
const getFormValue = (key: string) => form.value[key as keyof typeof form.value];
const handleFormInput = (event: Event, key: string) => {
const target = event.target as HTMLInputElement | null;
form.value[key as keyof typeof form.value] = target?.value ?? "";
};
const form = ref(initialForm());
const signals = ref<Signal[]>([]);
const users = ref<AdminUser[]>([]);
const bots = ref<Bot[]>([]);
const pushDashboard = ref<AdminPushDashboard | null>(null);
const pushDashboardLoading = ref(false);
const pushDashboardError = ref("");
const broadcastTitle = ref("");
const broadcastBody = ref("");
const userSearch = ref("");
const usersLoading = ref(false);
const userActionMessage = ref("");
const createSignalOpen = ref(false);
const subscriptionDrafts = ref<Record<string, SubscriptionDraft>>({});
const savingSubscriptionKey = ref<string | null>(null);
const togglingUserId = ref<string | null>(null);
const subscriptionStatusOptions: Array<{ value: SubscriptionStatus; label: string }> = [
{ value: "active", label: "Активна" },
{ value: "expired", label: "Истекла" },
{ value: "canceled", label: "Отменена" }
];
const statusLabel: Record<"ok" | "ready" | "error" | "inactive", string> = {
ok: "OK",
ready: "Готово",
error: "Ошибка",
inactive: "Неактивно"
};
const subscriptionStatusLabel: Record<SubscriptionStatus, string> = {
active: "Активна",
expired: "Истекла",
canceled: "Отменена"
};
const userRoleLabel = (role: User["role"]) => (role === "admin" ? "Администратор" : "Пользователь");
const userActiveLabel = (active?: boolean) => (active ? "активен" : "отключен");
const statusBadgeClass = (status: "ok" | "ready" | "error" | "inactive") => {
if (status === "error") return "signal-row__badge--manual_review";
if (status === "inactive") return "signal-row__badge--inactive";
return "signal-row__badge--win";
};
const subscriptionBadgeClass = (status: SubscriptionStatus, isActiveNow?: boolean) => {
if (status === "canceled") return "signal-row__badge--inactive";
if (status === "expired" || !isActiveNow) return "signal-row__badge--manual_review";
return "signal-row__badge--win";
};
const subscriptionSelectClass = (status: SubscriptionStatus) => {
if (status === "canceled") return "admin-subscription-select admin-subscription-select--canceled";
if (status === "expired") return "admin-subscription-select admin-subscription-select--expired";
return "admin-subscription-select admin-subscription-select--active";
};
const subscriptionsByUserId = computed(() => {
const grouped = new Map<string, AdminPushDashboard["items"]>();
for (const item of pushDashboard.value?.items ?? []) {
const userItems = grouped.get(item.user.id) ?? [];
userItems.push(item);
grouped.set(item.user.id, userItems);
}
return grouped;
});
const toDateTimeLocal = (value?: string | null) => {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const pad = (part: number) => String(part).padStart(2, "0");
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
};
const subscriptionDraftKey = (userId: string, botId: string) => `${userId}:${botId}`;
const createSubscriptionDraft = (access?: UserBotAccess): SubscriptionDraft => ({
status: access?.status ?? "canceled",
startsAt: toDateTimeLocal(access?.startsAt ?? new Date().toISOString()),
expiresAt: toDateTimeLocal(access?.expiresAt ?? null)
});
const assignDrafts = (items: AdminUser[]) => {
const nextDrafts: Record<string, SubscriptionDraft> = {};
for (const user of items) {
for (const bot of bots.value) {
const access = (user.botAccesses ?? []).find((entry: UserBotAccess) => entry.bot.id === bot.id);
nextDrafts[subscriptionDraftKey(user.id, bot.id)] = createSubscriptionDraft(access);
}
}
subscriptionDrafts.value = nextDrafts;
};
const loadUsers = async () => {
usersLoading.value = true;
try {
const query = userSearch.value.trim();
const suffix = query ? `?q=${encodeURIComponent(query)}` : "";
const usersData = await useApi<AdminUser[]>(`/admin/users${suffix}`);
users.value = usersData;
assignDrafts(usersData);
userActionMessage.value = "";
} finally {
usersLoading.value = false;
}
};
const loadCore = async () => {
const [signalsData, botsData] = await Promise.all([
useApi<PaginatedResponse<Signal>>("/signals?page=1&perPage=200&published=true"),
useApi<Bot[]>("/admin/bots")
]);
signals.value = signalsData.items;
bots.value = botsData;
await loadUsers();
};
const loadPushDashboard = async () => {
pushDashboardLoading.value = true;
try {
pushDashboard.value = await useApi<AdminPushDashboard>("/admin/push-subscriptions");
pushDashboardError.value = "";
} catch (error) {
pushDashboard.value = null;
pushDashboardError.value = error instanceof Error ? error.message : "Не удалось загрузить статусы подписок";
} finally {
pushDashboardLoading.value = false;
}
};
const load = async () => {
await Promise.all([loadCore(), loadPushDashboard()]);
};
onMounted(async () => {
await load();
});
const createSignal = async () => {
await useApi("/admin/signals", {
method: "POST",
body: {
...form.value,
eventStartTime: new Date(form.value.eventStartTime).toISOString(),
signalTime: new Date(form.value.signalTime || form.value.eventStartTime).toISOString(),
lineValue: form.value.lineValue ? Number(form.value.lineValue) : null,
odds: Number(form.value.odds),
sourceType: "manual",
published: true
}
});
form.value = initialForm();
createSignalOpen.value = false;
await loadCore();
};
const setStatus = async (signalId: string, status: Signal["status"]) => {
await useApi(`/admin/signals/${signalId}/set-status`, {
method: "POST",
body: {
status,
explanation: `Статус был установлен вручную: ${status}`
}
});
await loadCore();
};
const sendPush = async (signalId: string) => {
await useApi(`/admin/signals/${signalId}/send-push`, {
method: "POST",
body: {}
});
};
const sendBroadcast = async () => {
await useApi("/admin/broadcast", {
method: "POST",
body: {
title: broadcastTitle.value,
body: broadcastBody.value
}
});
broadcastTitle.value = "";
broadcastBody.value = "";
};
const updateSubscriptionDraft = (userId: string, botId: string, patch: Partial<SubscriptionDraft>) => {
const key = subscriptionDraftKey(userId, botId);
subscriptionDrafts.value = {
...subscriptionDrafts.value,
[key]: {
...createSubscriptionDraft(),
...(subscriptionDrafts.value[key] ?? {}),
...patch
}
};
};
const handleSubscriptionStatusChange = (event: Event, userId: string, botId: string) => {
const target = event.target as HTMLSelectElement | null;
updateSubscriptionDraft(userId, botId, { status: (target?.value as SubscriptionStatus | undefined) ?? "canceled" });
};
const handleSubscriptionStartsAtChange = (event: Event, userId: string, botId: string) => {
const target = event.target as HTMLInputElement | null;
updateSubscriptionDraft(userId, botId, { startsAt: target?.value ?? "" });
};
const handleSubscriptionExpiresAtChange = (event: Event, userId: string, botId: string) => {
const target = event.target as HTMLInputElement | null;
updateSubscriptionDraft(userId, botId, { expiresAt: target?.value ?? "" });
};
const findUserSubscription = (member: AdminUser, botId: string) =>
(member.botAccesses ?? []).find((entry) => entry.bot.id === botId);
const addDays = (date: Date, days: number) => {
const next = new Date(date);
next.setDate(next.getDate() + days);
return next;
};
const saveSubscription = async (userId: string, botId: string) => {
const key = subscriptionDraftKey(userId, botId);
const draft = subscriptionDrafts.value[key];
if (!draft) return;
savingSubscriptionKey.value = key;
try {
const savedSubscription = await useApi<UserBotAccess>(`/admin/users/${userId}/subscriptions/${botId}`, {
method: "PATCH",
body: {
status: draft.status,
startsAt: draft.startsAt ? new Date(draft.startsAt).toISOString() : new Date().toISOString(),
expiresAt: draft.expiresAt ? new Date(draft.expiresAt).toISOString() : null
}
});
users.value = users.value.map((user) => {
if (user.id !== userId) return user;
const existing = (user.botAccesses ?? []).filter((entry: UserBotAccess) => entry.bot.id !== botId);
return {
...user,
botAccesses: [...existing, savedSubscription].sort((left, right) => left.bot.name.localeCompare(right.bot.name, "ru"))
};
});
updateSubscriptionDraft(userId, botId, createSubscriptionDraft(savedSubscription));
const userEmail = users.value.find((entry) => entry.id === userId)?.email ?? "пользователя";
const botName = bots.value.find((entry) => entry.id === botId)?.name ?? "бота";
userActionMessage.value = `Подписка ${userEmail} на ${botName} обновлена`;
} finally {
savingSubscriptionKey.value = null;
}
};
const activateMonthlySubscription = async (userId: string, botId: string) => {
const now = new Date();
const expiresAt = addDays(now, 30);
updateSubscriptionDraft(userId, botId, {
status: "active",
startsAt: toDateTimeLocal(now.toISOString()),
expiresAt: toDateTimeLocal(expiresAt.toISOString())
});
await saveSubscription(userId, botId);
};
const toggleUserActive = async (user: AdminUser) => {
togglingUserId.value = user.id;
try {
const updated = await useApi<AdminUser>(`/admin/users/${user.id}/active`, {
method: "PATCH",
body: {
active: !user.active
}
});
users.value = users.value.map((entry) => (entry.id === user.id ? { ...entry, active: updated.active } : entry));
userActionMessage.value = `${updated.email}: ${updated.active ? "доступ включен" : "доступ отключен"}`;
} finally {
togglingUserId.value = null;
}
};
</script>
<template>
<section class="page admin-grid">
<!-- <div class="panel">
<div class="page-header page-header--admin-section">
<div>
<h1>Создание сигнала</h1>
<p class="muted">Разверните блок, чтобы добавить сигнал вручную.</p>
</div>
<button class="secondary" type="button" @click="createSignalOpen = !createSignalOpen">
{{ createSignalOpen ? "Свернуть" : "Развернуть" }}
</button>
</div>
<form v-if="createSignalOpen" class="admin-list" @submit.prevent="createSignal">
<label v-for="(value, key) in form" :key="key">
{{ getFieldLabel(String(key)) }}
<input
:value="getFormValue(String(key))"
:type="String(key).includes('Time') ? 'datetime-local' : 'text'"
@input="handleFormInput($event, String(key))"
/>
</label>
<button type="submit">Сохранить сигнал</button>
</form>
</div> -->
<!-- <div class="panel">
<h2>Действия с сигналами</h2>
<div class="admin-list">
<div v-for="signal in signals" :key="signal.id" class="admin-row">
<div>
<strong>{{ signal.homeTeam }} - {{ signal.awayTeam }}</strong>
<p>{{ signal.selection }} @ {{ signal.odds }}</p>
<p v-if="signal.forecast" class="muted">{{ signal.forecast }}</p>
<p v-if="signal.rawPayload?.botName" class="muted">{{ signal.rawPayload.botName }}</p>
</div>
<div class="button-row">
<button class="secondary" @click="sendPush(signal.id)">Отправить push</button>
<button class="secondary" @click="setStatus(signal.id, 'win')">Выигрыш</button>
<button class="secondary" @click="setStatus(signal.id, 'lose')">Проигрыш</button>
<button class="secondary" @click="setStatus(signal.id, 'void')">Возврат</button>
</div>
</div>
</div>
</div> -->
<div class="panel">
<h2>Отправить уведомление со своим текстом</h2>
<label>
Заголовок
<input v-model="broadcastTitle" />
</label>
<label>
Текст
<textarea v-model="broadcastBody" rows="4" />
</label>
<button @click="sendBroadcast">Отправить рассылку</button>
</div>
<!--
<div class="panel">
<div class="page-header page-header--admin-section">
<h2>Push-подписки</h2>
<NuxtLink class="topbar__link" to="/admin/pushes">Открыть монитор</NuxtLink>
</div>
<p class="muted">Отдельная страница со всеми подписками, статусами доставки и автообновлением.</p>
<p v-if="pushDashboardLoading" class="muted">Загрузка статусов подписок...</p>
<p v-else-if="pushDashboardError" class="error">Статусы подписок недоступны: {{ pushDashboardError }}</p>
<p v-else-if="pushDashboard" class="muted">
{{ pushDashboard.items.length }} подписок, {{ pushDashboard.items.filter((item) => item.status === "ok").length }} OK,
{{ pushDashboard.items.filter((item) => item.status === "ready").length }} готово,
{{ pushDashboard.items.filter((item) => item.status === "error").length }} ошибок.
</p>
</div> -->
<div class="panel">
<div class="page-header page-header--admin-section">
<div>
<h2>Пользователи и подписки</h2>
<p class="muted">Ищите пользователя по email и управляйте подпиской на каждого бота отдельно.</p>
</div>
</div>
<div class="admin-users-toolbar">
<input
v-model="userSearch"
class="admin-users-toolbar__search"
placeholder="Поиск по email"
@keyup.enter="loadUsers"
/>
<button class="secondary" :disabled="usersLoading" @click="loadUsers">
{{ usersLoading ? "Поиск..." : "Найти пользователя" }}
</button>
</div>
<p v-if="userActionMessage" class="success">{{ userActionMessage }}</p>
<div class="admin-list">
<article v-for="member in users" :key="member.id" class="admin-user-card">
<div class="admin-user-card__header">
<div>
<strong>{{ member.email }}</strong>
<p class="muted">
{{ userRoleLabel(member.role) }} · {{ userActiveLabel(member.active) }} · создан
{{ formatDateTime(member.createdAt ?? "") }}
</p>
</div>
<button
class="secondary"
:disabled="togglingUserId === member.id"
@click="toggleUserActive(member)"
>
{{ togglingUserId === member.id ? "Сохранение..." : member.active ? "Отключить пользователя" : "Включить пользователя" }}
</button>
</div>
<div class="admin-user-card__body">
<div class="p-4 mt-2 border rounded-2xl border-(--border)">
<p class="muted">Текущие подписки</p>
<div class="admin-bot-chip-list">
<span
v-for="access in member.botAccesses ?? []"
:key="access.id"
class="signal-row__badge"
:class="subscriptionBadgeClass(access.status, access.isActiveNow)"
>
{{ access.bot.name }} · {{ subscriptionStatusLabel[access.status] }}
</span>
<span v-if="!(member.botAccesses ?? []).length" class="muted">Подписок пока нет</span>
</div>
<div v-if="subscriptionsByUserId.get(member.id)?.length" class="admin-user-subscriptions">
<div
v-for="subscription in subscriptionsByUserId.get(member.id)"
:key="subscription.id"
class="admin-user-subscription"
>
<span class="signal-row__badge" :class="statusBadgeClass(subscription.status)">
{{ statusLabel[subscription.status] }}
</span>
<span class="muted">{{ subscription.endpointHost }}</span>
<span v-if="subscription.latestEvent?.statusCode" class="muted">
{{ subscription.latestEvent.statusCode }}
</span>
<span v-if="subscription.latestEvent?.reason" class="muted">
{{ subscription.latestEvent.reason }}
</span>
</div>
</div>
</div>
<details class="admin-subscription-manager">
<summary class="admin-subscription-manager__toggle">
<span>Управление подписками по ботам</span>
<span class="admin-subscription-manager__toggle-icon" aria-hidden="true">+</span>
</summary>
<div class="admin-subscription-grid admin-subscription-grid--mobile">
<article v-for="bot in bots" :key="bot.id" class="admin-subscription-card">
<div class="admin-subscription-card__header">
<div>
<strong>{{ bot.name }}</strong>
<small>{{ bot.key }}</small>
</div>
</div>
<label>
Статус
<select
:class="subscriptionSelectClass(
subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.status ?? 'canceled'
)"
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.status ?? 'canceled'"
@change="handleSubscriptionStatusChange($event, member.id, bot.id)"
>
<option
v-for="option in subscriptionStatusOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</label>
<label>
Начало
<input
type="datetime-local"
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.startsAt ?? ''"
@input="handleSubscriptionStartsAtChange($event, member.id, bot.id)"
/>
</label>
<label>
Окончание
<input
type="datetime-local"
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.expiresAt ?? ''"
@input="handleSubscriptionExpiresAtChange($event, member.id, bot.id)"
/>
</label>
<div class="button-row">
<button
class="secondary"
:disabled="savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)"
@click="activateMonthlySubscription(member.id, bot.id)"
>
Активировать на месяц
</button>
<button
:disabled="savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)"
@click="saveSubscription(member.id, bot.id)"
>
{{
savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)
? "Сохранение..."
: "Сохранить подписку"
}}
</button>
</div>
</article>
</div>
<div class="admin-subscription-table-wrap">
<table class="admin-subscription-table">
<thead>
<tr>
<th>Бот</th>
<th>Статус</th>
<th>Начало</th>
<th>Окончание</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<tr v-for="bot in bots" :key="`table-${bot.id}`">
<td class="admin-subscription-table__bot">
<strong>{{ bot.name }}</strong>
<small>{{ bot.key }}</small>
</td>
<td>
<div class="admin-subscription-table__field">
<select
:class="subscriptionSelectClass(
subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.status ?? 'canceled'
)"
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.status ?? 'canceled'"
@change="handleSubscriptionStatusChange($event, member.id, bot.id)"
>
<option
v-for="option in subscriptionStatusOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</td>
<td>
<input
type="datetime-local"
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.startsAt ?? ''"
@input="handleSubscriptionStartsAtChange($event, member.id, bot.id)"
/>
</td>
<td>
<input
type="datetime-local"
:value="subscriptionDrafts[subscriptionDraftKey(member.id, bot.id)]?.expiresAt ?? ''"
@input="handleSubscriptionExpiresAtChange($event, member.id, bot.id)"
/>
</td>
<td>
<div class="admin-subscription-table__actions">
<button
class="secondary"
:disabled="savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)"
@click="activateMonthlySubscription(member.id, bot.id)"
>
Добавить еще месяц
</button>
<button
:disabled="savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)"
@click="saveSubscription(member.id, bot.id)"
>
{{
savingSubscriptionKey === subscriptionDraftKey(member.id, bot.id)
? "Сохранение..."
: "Сохранить"
}}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</details>
</div>
</article>
</div>
</div>
</section>
</template>