Files
talorr cda36918e8 init
2026-03-27 03:36:08 +03:00

688 lines
26 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>