init
This commit is contained in:
687
frontend/pages/admin.vue
Normal file
687
frontend/pages/admin.vue
Normal 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>
|
||||
278
frontend/pages/admin/pushes.vue
Normal file
278
frontend/pages/admin/pushes.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
middleware: "admin"
|
||||
});
|
||||
|
||||
type PushSubscriptionsDashboardResponse = {
|
||||
summary: {
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
ok: number;
|
||||
ready: number;
|
||||
error: number;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
endpoint: string;
|
||||
endpointHost: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: "ok" | "ready" | "error" | "inactive";
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
active: boolean;
|
||||
notificationSetting: {
|
||||
signalsPushEnabled: boolean;
|
||||
resultsPushEnabled: boolean;
|
||||
} | null;
|
||||
};
|
||||
latestEvent: {
|
||||
createdAt: string;
|
||||
level: string;
|
||||
message: string;
|
||||
ok: boolean | null;
|
||||
statusCode: number | null;
|
||||
reason: string | null;
|
||||
notificationType: string | null;
|
||||
} | null;
|
||||
}>;
|
||||
recentNotificationLogs: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
recipients: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const { formatDateTime } = useBrowserDateTime();
|
||||
|
||||
const dashboard = ref<PushSubscriptionsDashboardResponse | null>(null);
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const lastUpdatedAt = ref<Date | null>(null);
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const statusLabel: Record<PushSubscriptionsDashboardResponse["items"][number]["status"], string> = {
|
||||
ok: "OK",
|
||||
ready: "Ready",
|
||||
error: "Error",
|
||||
inactive: "Inactive"
|
||||
};
|
||||
|
||||
const statusBadgeClass = (status: PushSubscriptionsDashboardResponse["items"][number]["status"]) => {
|
||||
if (status === "error") {
|
||||
return "signal-row__badge--manual_review";
|
||||
}
|
||||
|
||||
if (status === "inactive") {
|
||||
return "signal-row__badge--inactive";
|
||||
}
|
||||
|
||||
return "signal-row__badge--win";
|
||||
};
|
||||
|
||||
const staleSubscriptions = computed(() => {
|
||||
if (!dashboard.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return dashboard.value.items.filter((item) => {
|
||||
const providerExpired =
|
||||
item.latestEvent?.statusCode === 404 ||
|
||||
item.latestEvent?.statusCode === 410;
|
||||
|
||||
return item.status === "inactive" || providerExpired;
|
||||
});
|
||||
});
|
||||
|
||||
const healthySubscriptions = computed(() => {
|
||||
if (!dashboard.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const staleIds = new Set(staleSubscriptions.value.map((item) => item.id));
|
||||
return dashboard.value.items.filter((item) => !staleIds.has(item.id));
|
||||
});
|
||||
|
||||
const loadDashboard = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
dashboard.value = await useApi<PushSubscriptionsDashboardResponse>("/admin/push-subscriptions");
|
||||
lastUpdatedAt.value = new Date();
|
||||
errorMessage.value = "";
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Failed to load push dashboard";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDashboard();
|
||||
|
||||
refreshTimer = setInterval(() => {
|
||||
void loadDashboard();
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page admin-grid">
|
||||
<div class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<div>
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>Push subscriptions</h1>
|
||||
<p class="muted">Auto-refresh every 5 seconds with the latest delivery state for each subscription.</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<NuxtLink class="topbar__link" to="/admin">Back to admin</NuxtLink>
|
||||
<button :disabled="loading" @click="loadDashboard">{{ loading ? "Refreshing..." : "Refresh now" }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="lastUpdatedAt" class="muted">Last updated: {{ formatDateTime(lastUpdatedAt) }}</p>
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="admin-summary-grid">
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Total</span>
|
||||
<strong>{{ dashboard.summary.total }}</strong>
|
||||
<p class="muted">subscriptions in database</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Active</span>
|
||||
<strong>{{ dashboard.summary.active }}</strong>
|
||||
<p class="muted">eligible for delivery</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Ready</span>
|
||||
<strong>{{ dashboard.summary.ready }}</strong>
|
||||
<p class="muted">active without delivery history</p>
|
||||
</div>
|
||||
<div class="panel admin-summary-card">
|
||||
<span class="eyebrow">Errors</span>
|
||||
<strong>{{ dashboard.summary.error }}</strong>
|
||||
<p class="muted">last attempt failed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>Stale or dropped subscriptions</h2>
|
||||
<p class="muted">This block highlights subscriptions that are already inactive or were rejected by the push provider with 404/410.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="staleSubscriptions.length" class="admin-list">
|
||||
<article v-for="item in staleSubscriptions" :key="item.id" class="push-row">
|
||||
<div class="push-row__main">
|
||||
<div class="push-row__heading">
|
||||
<strong>{{ item.user.email }}</strong>
|
||||
<span class="signal-row__badge" :class="statusBadgeClass(item.status)">
|
||||
{{ statusLabel[item.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">{{ item.endpointHost }}</p>
|
||||
<p class="push-row__endpoint">{{ item.endpoint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__meta">
|
||||
<p><strong>Signals push:</strong> {{ item.user.notificationSetting?.signalsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Results push:</strong> {{ item.user.notificationSetting?.resultsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Created:</strong> {{ formatDateTime(item.createdAt) }}</p>
|
||||
<p><strong>Updated:</strong> {{ formatDateTime(item.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__event">
|
||||
<template v-if="item.latestEvent">
|
||||
<p><strong>Last event:</strong> {{ formatDateTime(item.latestEvent.createdAt) }}</p>
|
||||
<p><strong>Type:</strong> {{ item.latestEvent.notificationType ?? "unknown" }}</p>
|
||||
<p><strong>Status code:</strong> {{ item.latestEvent.statusCode ?? "n/a" }}</p>
|
||||
<p><strong>Reason:</strong> {{ item.latestEvent.reason ?? item.latestEvent.message }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="muted">No delivery attempts yet.</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="muted">No stale subscriptions right now.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>All other subscriptions</h2>
|
||||
<p class="muted">OK means the last delivery succeeded. Ready means the subscription is active but has not been used yet.</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
<article v-for="item in healthySubscriptions" :key="item.id" class="push-row">
|
||||
<div class="push-row__main">
|
||||
<div class="push-row__heading">
|
||||
<strong>{{ item.user.email }}</strong>
|
||||
<span class="signal-row__badge" :class="statusBadgeClass(item.status)">
|
||||
{{ statusLabel[item.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="muted">{{ item.endpointHost }}</p>
|
||||
<p class="push-row__endpoint">{{ item.endpoint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__meta">
|
||||
<p><strong>Signals push:</strong> {{ item.user.notificationSetting?.signalsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Results push:</strong> {{ item.user.notificationSetting?.resultsPushEnabled ? "on" : "off" }}</p>
|
||||
<p><strong>Created:</strong> {{ formatDateTime(item.createdAt) }}</p>
|
||||
<p><strong>Updated:</strong> {{ formatDateTime(item.updatedAt) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="push-row__event">
|
||||
<template v-if="item.latestEvent">
|
||||
<p><strong>Last event:</strong> {{ formatDateTime(item.latestEvent.createdAt) }}</p>
|
||||
<p><strong>Type:</strong> {{ item.latestEvent.notificationType ?? "unknown" }}</p>
|
||||
<p><strong>Status code:</strong> {{ item.latestEvent.statusCode ?? "n/a" }}</p>
|
||||
<p><strong>Reason:</strong> {{ item.latestEvent.reason ?? item.latestEvent.message }}</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="muted">No delivery attempts yet.</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="dashboard" class="panel">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<h2>Recent notification batches</h2>
|
||||
</div>
|
||||
|
||||
<div class="admin-list">
|
||||
<div v-for="log in dashboard.recentNotificationLogs" :key="log.id" class="admin-row">
|
||||
<div>
|
||||
<strong>{{ log.type }}</strong>
|
||||
<p class="muted">{{ formatDateTime(log.createdAt) }}</p>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<span class="muted">Recipients: {{ log.recipients }}</span>
|
||||
<span class="muted">Success: {{ log.successCount }}</span>
|
||||
<span class="muted">Failed: {{ log.failedCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
321
frontend/pages/bots/[key].vue
Normal file
321
frontend/pages/bots/[key].vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<script setup lang="ts">
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Message from "primevue/message";
|
||||
import Paginator from "primevue/paginator";
|
||||
import Select from "primevue/select";
|
||||
import SelectButton from "primevue/selectbutton";
|
||||
import Skeleton from "primevue/skeleton";
|
||||
import Tag from "primevue/tag";
|
||||
import type { Bot, PaginatedResponse, Signal } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const { user } = useAuth();
|
||||
|
||||
const botKey = computed(() => String(route.params.key || ""));
|
||||
const availableBots = computed(() => user.value?.botAccesses?.map((access) => access.bot) ?? []);
|
||||
const bot = computed<Bot | null>(() => availableBots.value.find((entry) => entry.key === botKey.value) ?? null);
|
||||
|
||||
const signals = ref<Signal[]>([]);
|
||||
const status = ref<string | null>(null);
|
||||
const query = ref("");
|
||||
const activeTab = ref<"all" | "1" | "2">("1");
|
||||
const perPage = ref(20);
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
perPage: 20,
|
||||
total: 0,
|
||||
totalPages: 1
|
||||
});
|
||||
|
||||
const sortBy = ref<"eventStartTime" | "signalTime" | "odds">("eventStartTime");
|
||||
const sortDirection = ref<"desc" | "asc">("desc");
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
|
||||
const tabCounts = ref<{ all: number; "1": number; "2": number }>({ all: 0, "1": 0, "2": 0 });
|
||||
|
||||
const tabOptions = [
|
||||
{ label: "Все", value: "all" },
|
||||
{ label: "Активные", value: "1" },
|
||||
{ label: "Неактивные", value: "2" }
|
||||
] as const;
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "Все статусы", value: null },
|
||||
{ label: "В ожидании", value: "pending" },
|
||||
{ label: "Выигрыш", value: "win" },
|
||||
{ label: "Проигрыш", value: "lose" },
|
||||
{ label: "Возврат", value: "void" },
|
||||
{ label: "Ручная проверка", value: "manual_review" }
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{ label: "По дате матча", value: "eventStartTime" },
|
||||
{ label: "По времени сигнала", value: "signalTime" },
|
||||
{ label: "По коэффициенту", value: "odds" }
|
||||
] as const;
|
||||
|
||||
const perPageOptions = [
|
||||
{ label: "20 на странице", value: 20 },
|
||||
{ label: "40 на странице", value: 40 },
|
||||
{ label: "100 на странице", value: 100 }
|
||||
];
|
||||
|
||||
const getSortValue = (signal: Signal) => {
|
||||
if (sortBy.value === "odds") {
|
||||
return signal.odds;
|
||||
}
|
||||
|
||||
const rawValue = sortBy.value === "eventStartTime" ? signal.eventStartTime || signal.signalTime : signal.signalTime;
|
||||
const parsedValue = Date.parse(rawValue);
|
||||
return Number.isNaN(parsedValue) ? 0 : parsedValue;
|
||||
};
|
||||
|
||||
const sortedSignals = computed(() =>
|
||||
[...signals.value].sort((left, right) => {
|
||||
const leftValue = getSortValue(left);
|
||||
const rightValue = getSortValue(right);
|
||||
|
||||
if (leftValue !== rightValue) {
|
||||
return sortDirection.value === "desc" ? rightValue - leftValue : leftValue - rightValue;
|
||||
}
|
||||
|
||||
const leftSignalTime = Date.parse(left.signalTime);
|
||||
const rightSignalTime = Date.parse(right.signalTime);
|
||||
return sortDirection.value === "desc" ? rightSignalTime - leftSignalTime : leftSignalTime - rightSignalTime;
|
||||
})
|
||||
);
|
||||
|
||||
const currentTabLabel = computed(() => tabOptions.find((option) => option.value === activeTab.value)?.label ?? "Все");
|
||||
|
||||
const buildParams = (tab: "all" | "1" | "2", targetPage: number, targetPerPage: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("published", "true");
|
||||
params.set("botKey", botKey.value);
|
||||
if (tab !== "all") params.set("activeTab", tab);
|
||||
params.set("page", String(targetPage));
|
||||
params.set("perPage", String(targetPerPage));
|
||||
if (status.value) params.set("status", status.value);
|
||||
if (query.value) params.set("q", query.value);
|
||||
return params;
|
||||
};
|
||||
|
||||
const loadSignals = async () => {
|
||||
if (!bot.value) {
|
||||
loadError.value = "Нет доступа к этому боту";
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await useApi<PaginatedResponse<Signal>>(
|
||||
`/signals?${buildParams(activeTab.value, pagination.value.page, perPage.value).toString()}`
|
||||
);
|
||||
|
||||
signals.value = response.items;
|
||||
pagination.value = response.pagination;
|
||||
perPage.value = response.pagination.perPage;
|
||||
if (response.tabCounts) {
|
||||
tabCounts.value = response.tabCounts;
|
||||
}
|
||||
loadError.value = "";
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : "Не удалось загрузить сигналы";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const reloadFromFirstPage = async () => {
|
||||
pagination.value.page = 1;
|
||||
await loadSignals();
|
||||
};
|
||||
|
||||
const handlePageChange = async (event: { page: number; rows: number }) => {
|
||||
pagination.value.page = event.page + 1;
|
||||
perPage.value = event.rows;
|
||||
await loadSignals();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSignals();
|
||||
});
|
||||
|
||||
watch([status, query, sortBy], () => {
|
||||
void reloadFromFirstPage();
|
||||
});
|
||||
|
||||
watch(activeTab, () => {
|
||||
void reloadFromFirstPage();
|
||||
});
|
||||
|
||||
watch(perPage, () => {
|
||||
void reloadFromFirstPage();
|
||||
});
|
||||
|
||||
watch(botKey, () => {
|
||||
pagination.value.page = 1;
|
||||
void loadSignals();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sakai-page">
|
||||
<div class="sakai-hero-card">
|
||||
<div>
|
||||
<div class="sakai-page-back">
|
||||
<NuxtLink to="/">
|
||||
<Button text severity="secondary" icon="pi pi-arrow-left" label="Все боты" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<span class="sakai-section-label">Лента бота</span>
|
||||
<h2>{{ bot?.name ?? "Бот" }}</h2>
|
||||
<p>Сигналы по выбранному боту с отдельными фильтрами, статусами и навигацией по страницам.</p>
|
||||
</div>
|
||||
<div class="sakai-hero-card__tags">
|
||||
<Tag class="rounded" :value="currentTabLabel" severity="contrast" />
|
||||
<Tag class="rounded" :value="`${pagination.total} сигналов`" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="sakai-filter-card">
|
||||
<template #content>
|
||||
<div class="sakai-filter-grid">
|
||||
<div class="sakai-field">
|
||||
<label for="signal-search">Поиск</label>
|
||||
<InputText id="signal-search" v-model="query" placeholder="Лига, команда, рынок" />
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label>Вкладка</label>
|
||||
<div class="sakai-tab-scroll">
|
||||
<SelectButton v-model="activeTab" :options="tabOptions" option-label="label" option-value="value" allow-empty="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label for="signal-status">Статус</label>
|
||||
<Select
|
||||
id="signal-status"
|
||||
v-model="status"
|
||||
:options="statusOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Все статусы"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label for="signal-sort">Сортировка</label>
|
||||
<Select
|
||||
id="signal-sort"
|
||||
v-model="sortBy"
|
||||
:options="sortOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label for="signal-limit">Лимит</label>
|
||||
<Select
|
||||
id="signal-limit"
|
||||
v-model="perPage"
|
||||
:options="perPageOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sakai-field">
|
||||
<label>Порядок</label>
|
||||
<Button
|
||||
fluid
|
||||
severity="secondary"
|
||||
outlined
|
||||
:label="sortDirection === 'desc' ? 'Сначала новые' : 'Сначала старые'"
|
||||
:icon="sortDirection === 'desc' ? 'pi pi-sort-amount-down' : 'pi pi-sort-amount-up'"
|
||||
@click="sortDirection = sortDirection === 'desc' ? 'asc' : 'desc'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Message v-if="loadError" severity="error" :closable="false">{{ loadError }}</Message>
|
||||
|
||||
<div class="sakai-signal-layout">
|
||||
<Card class="sakai-signal-panel">
|
||||
<template #title>Сигналы</template>
|
||||
<template #subtitle>
|
||||
Всего: {{ tabCounts.all }} · Активные: {{ tabCounts["1"] }} · Неактивные: {{ tabCounts["2"] }}
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="loading" class="sakai-signal-list">
|
||||
<div v-for="index in 5" :key="index" class="sakai-signal-skeleton">
|
||||
<Skeleton width="35%" height="1rem" />
|
||||
<Skeleton width="70%" height="1.25rem" />
|
||||
<Skeleton width="55%" height="1rem" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-else-if="sortedSignals.length === 0" severity="secondary" :closable="false">
|
||||
По текущему фильтру сигналов нет.
|
||||
</Message>
|
||||
|
||||
<div v-else class="sakai-signal-list">
|
||||
<NuxtLink
|
||||
v-for="signal in sortedSignals"
|
||||
:key="signal.id"
|
||||
:to="`/signals/${signal.id}`"
|
||||
class="sakai-signal-link"
|
||||
>
|
||||
<SignalCard :signal="signal" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Card class="sakai-summary-panel">
|
||||
<template #title>Сводка</template>
|
||||
<template #content>
|
||||
<div class="sakai-summary-list">
|
||||
<div class="sakai-summary-item">
|
||||
<span>Все сигналы</span>
|
||||
<strong>{{ tabCounts.all }}</strong>
|
||||
</div>
|
||||
<div class="sakai-summary-item">
|
||||
<span>Активные</span>
|
||||
<strong>{{ tabCounts["1"] }}</strong>
|
||||
</div>
|
||||
<div class="sakai-summary-item">
|
||||
<span>Неактивные</span>
|
||||
<strong>{{ tabCounts["2"] }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<template #content>
|
||||
<Paginator
|
||||
:first="(pagination.page - 1) * perPage"
|
||||
:rows="perPage"
|
||||
:total-records="pagination.total"
|
||||
:rows-per-page-options="[20, 40, 100]"
|
||||
template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||
@page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</Card>
|
||||
</section>
|
||||
</template>
|
||||
455
frontend/pages/chat.vue
Normal file
455
frontend/pages/chat.vue
Normal file
@@ -0,0 +1,455 @@
|
||||
<script setup lang="ts">
|
||||
import type { SupportConversation } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
const { user, token } = useAuth();
|
||||
const { formatDateTime } = useBrowserDateTime();
|
||||
const { connect, disconnect, onConversationUpdated, onMessageCreated } = useSupportRealtime();
|
||||
const { replaceConversations, upsertConversation } = useSupportUnread();
|
||||
|
||||
const conversations = ref<SupportConversation[]>([]);
|
||||
const activeConversationId = ref<string | null>(null);
|
||||
const conversation = ref<SupportConversation | null>(null);
|
||||
const loading = ref(false);
|
||||
const sending = ref(false);
|
||||
const statusSaving = ref(false);
|
||||
const draft = ref("");
|
||||
const errorMessage = ref("");
|
||||
const messageListRef = ref<HTMLElement | null>(null);
|
||||
const isMobile = ref(false);
|
||||
|
||||
const isAdmin = computed(() => user.value?.role === "admin");
|
||||
const showAdminConversation = computed(() => !isAdmin.value || !isMobile.value || Boolean(activeConversationId.value));
|
||||
const showAdminList = computed(() => isAdmin.value && (!isMobile.value || !activeConversationId.value));
|
||||
|
||||
const activeConversation = computed(() => {
|
||||
if (!isAdmin.value) {
|
||||
return conversation.value;
|
||||
}
|
||||
|
||||
return conversations.value.find((item) => item.id === activeConversationId.value) ?? conversation.value;
|
||||
});
|
||||
|
||||
const canSend = computed(() => draft.value.trim().length > 0 && !sending.value);
|
||||
|
||||
const sortConversations = (items: SupportConversation[]) =>
|
||||
[...items].sort((left, right) => new Date(right.lastMessageAt).getTime() - new Date(left.lastMessageAt).getTime());
|
||||
|
||||
const updateConversationInList = (nextConversation: SupportConversation) => {
|
||||
const withoutCurrent = conversations.value.filter((item) => item.id !== nextConversation.id);
|
||||
conversations.value = sortConversations([nextConversation, ...withoutCurrent]);
|
||||
};
|
||||
|
||||
const replaceActiveConversation = (nextConversation: SupportConversation) => {
|
||||
conversation.value = nextConversation;
|
||||
updateConversationInList(nextConversation);
|
||||
|
||||
if (!activeConversationId.value) {
|
||||
activeConversationId.value = nextConversation.id;
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick();
|
||||
|
||||
if (!messageListRef.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
messageListRef.value.scrollTop = messageListRef.value.scrollHeight;
|
||||
};
|
||||
|
||||
const loadUserConversation = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await useChatApi<SupportConversation>("/support/conversation");
|
||||
conversation.value = response;
|
||||
activeConversationId.value = response.id;
|
||||
conversations.value = [response];
|
||||
replaceConversations([response]);
|
||||
errorMessage.value = "";
|
||||
await scrollToBottom();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось загрузить чат";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAdminConversations = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await useChatApi<SupportConversation[]>("/admin/support/conversations");
|
||||
conversations.value = sortConversations(response);
|
||||
replaceConversations(response);
|
||||
|
||||
if (!activeConversationId.value && response.length > 0) {
|
||||
activeConversationId.value = response[0].id;
|
||||
}
|
||||
|
||||
if (activeConversationId.value) {
|
||||
await loadAdminConversation(activeConversationId.value);
|
||||
} else {
|
||||
conversation.value = null;
|
||||
}
|
||||
|
||||
errorMessage.value = "";
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось загрузить обращения";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadAdminConversation = async (conversationId: string) => {
|
||||
activeConversationId.value = conversationId;
|
||||
|
||||
try {
|
||||
const response = await useChatApi<SupportConversation>(`/admin/support/conversations/${conversationId}`);
|
||||
replaceActiveConversation(response);
|
||||
upsertConversation(response);
|
||||
errorMessage.value = "";
|
||||
await scrollToBottom();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось открыть диалог";
|
||||
}
|
||||
};
|
||||
|
||||
const closeMobileConversation = () => {
|
||||
if (!isAdmin.value || !isMobile.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeConversationId.value = null;
|
||||
conversation.value = null;
|
||||
};
|
||||
|
||||
const loadPage = async () => {
|
||||
if (isAdmin.value) {
|
||||
await loadAdminConversations();
|
||||
return;
|
||||
}
|
||||
|
||||
await loadUserConversation();
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const body = draft.value.trim();
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
|
||||
sending.value = true;
|
||||
|
||||
try {
|
||||
const response = isAdmin.value
|
||||
? await useChatApi<SupportConversation>(`/admin/support/conversations/${activeConversationId.value}/messages`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
body
|
||||
}
|
||||
})
|
||||
: await useChatApi<SupportConversation>("/support/conversation/messages", {
|
||||
method: "POST",
|
||||
body: {
|
||||
body
|
||||
}
|
||||
});
|
||||
|
||||
draft.value = "";
|
||||
replaceActiveConversation(response);
|
||||
upsertConversation(response);
|
||||
errorMessage.value = "";
|
||||
await scrollToBottom();
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось отправить сообщение";
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setConversationStatus = async (status: "open" | "closed") => {
|
||||
if (!isAdmin.value || !activeConversationId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusSaving.value = true;
|
||||
|
||||
try {
|
||||
const response = await useChatApi<SupportConversation>(`/admin/support/conversations/${activeConversationId.value}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
status
|
||||
}
|
||||
});
|
||||
|
||||
replaceActiveConversation(response);
|
||||
upsertConversation(response);
|
||||
errorMessage.value = "";
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "Не удалось обновить статус";
|
||||
} finally {
|
||||
statusSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRealtimeConversation = async (payload: { conversation: SupportConversation }) => {
|
||||
if (!payload?.conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateConversationInList(payload.conversation);
|
||||
upsertConversation(payload.conversation);
|
||||
|
||||
if (!isAdmin.value) {
|
||||
conversation.value = payload.conversation;
|
||||
await scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeConversationId.value) {
|
||||
activeConversationId.value = payload.conversation.id;
|
||||
}
|
||||
|
||||
if (payload.conversation.id === activeConversationId.value) {
|
||||
conversation.value = payload.conversation;
|
||||
await scrollToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRealtimeMessage = async (payload: { conversation: SupportConversation }) => {
|
||||
await handleRealtimeConversation(payload);
|
||||
};
|
||||
|
||||
let offConversationUpdated: (() => void) | null = null;
|
||||
let offMessageCreated: (() => void) | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
if (process.client) {
|
||||
const mediaQuery = window.matchMedia("(max-width: 767px)");
|
||||
const syncIsMobile = () => {
|
||||
isMobile.value = mediaQuery.matches;
|
||||
};
|
||||
|
||||
syncIsMobile();
|
||||
mediaQuery.addEventListener("change", syncIsMobile);
|
||||
onBeforeUnmount(() => {
|
||||
mediaQuery.removeEventListener("change", syncIsMobile);
|
||||
});
|
||||
}
|
||||
|
||||
await loadPage();
|
||||
|
||||
if (!process.client || !token.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
connect();
|
||||
offConversationUpdated = onConversationUpdated((payload) => {
|
||||
void handleRealtimeConversation(payload);
|
||||
});
|
||||
offMessageCreated = onMessageCreated((payload) => {
|
||||
void handleRealtimeMessage(payload);
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
offConversationUpdated?.();
|
||||
offConversationUpdated = null;
|
||||
offMessageCreated?.();
|
||||
offMessageCreated = null;
|
||||
disconnect();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => activeConversation.value?.messages?.length,
|
||||
() => {
|
||||
void scrollToBottom();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page admin-grid">
|
||||
<div class="page-header page-header--admin-section">
|
||||
<div>
|
||||
<h2>{{ isAdmin ? "Обращения пользователей" : "Чат с поддержкой" }}</h2>
|
||||
<p class="muted">
|
||||
{{
|
||||
isAdmin
|
||||
? "Все администраторы видят единый поток обращений и могут отвечать пользователям прямо из приложения."
|
||||
: "Напишите админу прямо здесь. Когда поддержка ответит, сообщение появится в этом чате."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMessage" class="error">{{ errorMessage }}</p>
|
||||
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:style="isAdmin && !isMobile ? { gridTemplateColumns: 'minmax(280px, 360px) minmax(0, 1fr)' } : undefined"
|
||||
>
|
||||
<div v-if="showAdminList" class="panel" style="padding: 0;">
|
||||
<div class="admin-list" style="padding: 1rem;">
|
||||
<div
|
||||
v-for="item in conversations"
|
||||
:key="item.id"
|
||||
class="rounded-2xl border p-4 transition cursor-pointer"
|
||||
:style="{
|
||||
borderColor: item.id === activeConversationId ? 'var(--accent)' : 'var(--border)',
|
||||
backgroundColor: item.id === activeConversationId ? 'color-mix(in srgb, var(--accent) 8%, var(--surface))' : 'var(--surface)'
|
||||
}"
|
||||
@click="loadAdminConversation(item.id)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<strong class="block truncate">{{ item.user.email }}</strong>
|
||||
<span class="text-sm text-(--muted)">
|
||||
{{ item.status === "closed" ? "Закрыт" : item.unreadForAdmin ? "Новое сообщение" : "Открыт" }}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.unreadForAdmin"
|
||||
class="signal-row__badge signal-row__badge--win"
|
||||
>
|
||||
новое
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-3 mb-1 text-sm break-words">
|
||||
{{ item.latestMessage?.body || "Пока без сообщений" }}
|
||||
</p>
|
||||
<p class="m-0 text-xs text-(--muted)">
|
||||
{{ formatDateTime(item.lastMessageAt) }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="!conversations.length && !loading" class="muted">Обращений пока нет.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAdminConversation" class="panel">
|
||||
<div
|
||||
v-if="activeConversation"
|
||||
class="grid gap-4"
|
||||
:style="{ minHeight: isAdmin ? '70vh' : '60vh', gridTemplateRows: 'auto minmax(0, 1fr) auto' }"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="isAdmin && isMobile"
|
||||
type="button"
|
||||
class="secondary"
|
||||
style="padding-inline: 0.85rem;"
|
||||
@click="closeMobileConversation"
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<strong class="block">
|
||||
{{ isAdmin ? activeConversation.user.email : "Поддержка" }}
|
||||
</strong>
|
||||
<span class="text-sm text-(--muted)">
|
||||
{{
|
||||
activeConversation.status === "closed"
|
||||
? "Диалог закрыт"
|
||||
: isAdmin
|
||||
? "Отвечайте пользователю от лица поддержки"
|
||||
: "Админы ответят сюда же"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="flex gap-2">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="statusSaving || activeConversation.status === 'open'"
|
||||
@click="setConversationStatus('open')"
|
||||
>
|
||||
Открыть
|
||||
</button>
|
||||
<button
|
||||
:disabled="statusSaving || activeConversation.status === 'closed'"
|
||||
@click="setConversationStatus('closed')"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="messageListRef"
|
||||
class="grid gap-3 overflow-y-auto rounded-2xl border p-3"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface)' }"
|
||||
>
|
||||
<div
|
||||
v-for="message in activeConversation.messages ?? []"
|
||||
:key="message.id"
|
||||
class="flex"
|
||||
:style="{ justifyContent: message.author.id === user?.id ? 'flex-end' : 'flex-start' }"
|
||||
>
|
||||
<div
|
||||
class="max-w-[85%] rounded-2xl px-4 py-3"
|
||||
:style="message.author.id === user?.id
|
||||
? {
|
||||
backgroundColor: 'color-mix(in srgb, var(--accent) 18%, var(--surface))',
|
||||
border: '1px solid color-mix(in srgb, var(--accent) 35%, var(--border))'
|
||||
}
|
||||
: {
|
||||
backgroundColor: 'var(--surface-strong)',
|
||||
border: '1px solid var(--border)'
|
||||
}"
|
||||
>
|
||||
<div class="mb-2 text-xs text-(--muted)">
|
||||
{{ message.author.email }} · {{ formatDateTime(message.createdAt) }}
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap break-words">{{ message.body }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!(activeConversation.messages ?? []).length" class="muted">
|
||||
{{ isAdmin ? "В этом диалоге ещё нет сообщений." : "Напишите первое сообщение админу." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3" @submit.prevent="sendMessage">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
rows="4"
|
||||
placeholder="Введите сообщение"
|
||||
:disabled="activeConversation.status === 'closed' && !isAdmin"
|
||||
/>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-(--muted)">
|
||||
{{
|
||||
activeConversation.status === "closed"
|
||||
? isAdmin
|
||||
? "Диалог закрыт, но администратор может открыть его снова."
|
||||
: "Диалог закрыт. Дождитесь, пока администратор откроет его снова."
|
||||
: "Сообщения доставляются в реальном времени."
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!canSend || (!isAdmin && activeConversation.status === 'closed')"
|
||||
>
|
||||
{{ sending ? "Отправка..." : "Отправить" }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="muted">Загрузка чата...</div>
|
||||
<div v-else class="muted">
|
||||
{{ isAdmin ? "Выберите диалог слева или дождитесь нового обращения." : "Чат пока недоступен." }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
75
frontend/pages/forgot-password.vue
Normal file
75
frontend/pages/forgot-password.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
const email = ref("");
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const pending = ref(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
pending.value = true;
|
||||
|
||||
try {
|
||||
const result = await useApi<{ message: string }>("/auth/forgot-password", {
|
||||
method: "POST",
|
||||
body: { email: email.value }
|
||||
});
|
||||
|
||||
success.value = result.message;
|
||||
} catch (submitError) {
|
||||
error.value = submitError instanceof Error ? submitError.message : "Не удалось отправить письмо";
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto grid min-h-[calc(100vh-12rem)] w-full items-center gap-8 md:max-w-6xl md:grid-cols-[minmax(0,1.1fr)_minmax(22rem,28rem)] md:[min-height:calc(100vh-10rem)]">
|
||||
<div class="grid max-w-lg gap-3">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Alpinbet</p>
|
||||
<h1 class="m-0 text-4xl font-semibold leading-tight">Восстановление пароля</h1>
|
||||
<p class="m-0 text-base leading-7 text-(--muted)">
|
||||
Укажите email аккаунта. Мы отправим ссылку, по которой можно задать новый пароль.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="grid w-full max-w-md gap-4 rounded-[28px] border p-4"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface-strong)' }"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Запрос письма</p>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="email" />
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-2xl p-4 text-sm whitespace-pre-line"
|
||||
:style="{ backgroundColor: 'var(--danger-bg)', color: 'var(--danger-text)' }"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="success"
|
||||
class="rounded-2xl p-4 text-sm"
|
||||
:style="{ backgroundColor: 'var(--success-bg)', color: 'var(--success-text)' }"
|
||||
>
|
||||
{{ success }}
|
||||
</p>
|
||||
|
||||
<button type="submit" :disabled="pending">
|
||||
{{ pending ? "Отправляем..." : "Отправить ссылку" }}
|
||||
</button>
|
||||
|
||||
<p class="m-0 text-(--muted)">
|
||||
Вспомнили пароль?
|
||||
<NuxtLink class="text-(--accent-strong)" to="/login">Вернуться ко входу</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
134
frontend/pages/index.vue
Normal file
134
frontend/pages/index.vue
Normal file
@@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
import Card from "primevue/card";
|
||||
import Message from "primevue/message";
|
||||
import Skeleton from "primevue/skeleton";
|
||||
import Tag from "primevue/tag";
|
||||
import type { ActiveSignalCountByBot, Bot } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
type BotCard = {
|
||||
bot: Bot;
|
||||
totalSignals: number;
|
||||
};
|
||||
|
||||
const { user } = useAuth();
|
||||
|
||||
const loading = ref(true);
|
||||
const loadError = ref("");
|
||||
const botCards = ref<BotCard[]>([]);
|
||||
|
||||
const availableBots = computed(() => user.value?.botAccesses?.map((access) => access.bot) ?? []);
|
||||
|
||||
const formatSignalCount = (count: number) => {
|
||||
if (count === 0) return "Нет активных сигналов";
|
||||
if (count === 1) return "1 активный сигнал";
|
||||
if (count < 5) return `${count} активных сигнала`;
|
||||
return `${count} активных сигналов`;
|
||||
};
|
||||
|
||||
const loadBotCards = async () => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const response = await useApi<{ items: ActiveSignalCountByBot[] }>("/signals/active-counts");
|
||||
const countsByBotKey = new Map(response.items.map((item) => [item.botKey, item.activeSignals]));
|
||||
const cards = availableBots.value.map((bot) => ({
|
||||
bot,
|
||||
totalSignals: countsByBotKey.get(bot.key) ?? 0
|
||||
}));
|
||||
|
||||
botCards.value = cards;
|
||||
loadError.value = "";
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : "Не удалось загрузить список ботов";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBotCards();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => availableBots.value.map((bot) => bot.key).join("|"),
|
||||
(nextKeys, previousKeys) => {
|
||||
if (nextKeys === previousKeys) return;
|
||||
void loadBotCards();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-4">
|
||||
<div
|
||||
class="flex flex-col gap-4 rounded-[24px] border p-5 md:flex-row md:items-start md:justify-between"
|
||||
:style="{
|
||||
borderColor: 'var(--border)',
|
||||
background: 'radial-gradient(circle at top right, color-mix(in srgb, var(--accent) 10%, transparent), transparent 28%), var(--surface)',
|
||||
boxShadow: '0 10px 30px color-mix(in srgb, var(--text) 4%, transparent)'
|
||||
}"
|
||||
>
|
||||
<div>
|
||||
<span class="mb-2 inline-block text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Доступные боты</span>
|
||||
<h2 class="m-0 text-3xl font-semibold">Выберите бота</h2>
|
||||
<p class="m-0 text-sm leading-6 text-(--muted)">
|
||||
Каждая карточка открывает отдельную ленту сигналов без общего шума и лишних фильтров на старте.
|
||||
</p>
|
||||
</div>
|
||||
<Tag class="rounded" :value="`${availableBots.length} в доступе`" severity="contrast" />
|
||||
</div>
|
||||
|
||||
<Message v-if="loadError" severity="error" :closable="false">{{ loadError }}</Message>
|
||||
<Message v-else-if="!loading && botCards.length === 0" severity="info" :closable="false">
|
||||
У пользователя пока нет доступа ни к одному боту.
|
||||
</Message>
|
||||
|
||||
<div v-if="loading" class="bot-grid">
|
||||
<Card v-for="index in 6" :key="index" class="overflow-hidden rounded-[24px] border shadow-sm">
|
||||
<template #content>
|
||||
<div class="grid gap-3 p-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Skeleton width="2.75rem" height="2.75rem" borderRadius="16px" />
|
||||
<Skeleton width="2.25rem" height="1.75rem" borderRadius="999px" />
|
||||
</div>
|
||||
<Skeleton width="3rem" height="0.7rem" />
|
||||
<Skeleton width="72%" height="1.45rem" />
|
||||
<Skeleton width="58%" height="0.95rem" />
|
||||
<Skeleton width="100%" height="2.5rem" borderRadius="16px" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-else class="bot-grid">
|
||||
<NuxtLink
|
||||
v-for="card in botCards"
|
||||
:key="card.bot.id"
|
||||
:to="`/bots/${card.bot.key}`"
|
||||
class="bot-tile"
|
||||
>
|
||||
<div class="bot-tile__top">
|
||||
<div class="bot-tile__icon">
|
||||
<AppLogo size="26px" />
|
||||
</div>
|
||||
<Tag class="bot-tile__count" :value="String(card.totalSignals)" severity="contrast" />
|
||||
</div>
|
||||
|
||||
<div class="bot-tile__body">
|
||||
<p class="bot-tile__eyebrow">Бот</p>
|
||||
<h2>{{ card.bot.name }}</h2>
|
||||
<p class="bot-tile__meta">{{ formatSignalCount(card.totalSignals) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="bot-tile__footer">
|
||||
<span class="bot-tile__action">Открыть ленту</span>
|
||||
<i class="pi pi-arrow-right bot-tile__arrow" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
108
frontend/pages/login.vue
Normal file
108
frontend/pages/login.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import type { User } from "~/types";
|
||||
|
||||
const { login, user, refreshMe } = useAuth();
|
||||
const { claimAnonymousPushSubscriptions, consumePendingPushRoute } = usePush();
|
||||
const route = useRoute();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const showPassword = ref(false);
|
||||
const error = ref("");
|
||||
const redirectTarget = computed(() =>
|
||||
typeof route.query.redirect === "string" ? route.query.redirect : "/"
|
||||
);
|
||||
|
||||
watch(
|
||||
user,
|
||||
(currentUser) => {
|
||||
if (currentUser) {
|
||||
void navigateTo(redirectTarget.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = "";
|
||||
|
||||
try {
|
||||
const result = await useApi<{ token?: string; user: User }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: { email: email.value, password: password.value }
|
||||
});
|
||||
|
||||
await login(result.token ?? null, result.user);
|
||||
await refreshMe();
|
||||
await claimAnonymousPushSubscriptions();
|
||||
const redirectedFromPush = await consumePendingPushRoute();
|
||||
if (!redirectedFromPush) {
|
||||
await navigateTo(redirectTarget.value);
|
||||
}
|
||||
} catch (submitError) {
|
||||
error.value = submitError instanceof Error ? submitError.message : "Ошибка входа";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto grid min-h-[calc(100vh-12rem)] w-full items-center gap-8 md:max-w-6xl md:grid-cols-[minmax(0,1.1fr)_minmax(22rem,28rem)] md:[min-height:calc(100vh-10rem)]">
|
||||
<div class="grid max-w-lg gap-3">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Alpinbet</p>
|
||||
<h1 class="m-0 text-4xl font-semibold leading-tight">Вход в систему</h1>
|
||||
<p class="m-0 text-base leading-7 text-(--muted)">
|
||||
Авторизуйтесь, чтобы открыть доступ к ботам, сигналам и настройкам уведомлений.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="grid w-full max-w-md gap-4 rounded-[28px] border p-4"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface-strong)' }"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Авторизация</p>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="email" />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i class="pi" :class="showPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<NuxtLink class="justify-self-end text-sm text-(--accent-strong)" to="/forgot-password">
|
||||
Забыли пароль?
|
||||
</NuxtLink>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-2xl p-4 text-sm whitespace-pre-line"
|
||||
:style="{ backgroundColor: 'var(--danger-bg)', color: 'var(--danger-text)' }"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<button type="submit">Войти</button>
|
||||
|
||||
<p class="m-0 text-(--muted)">
|
||||
Нет аккаунта?
|
||||
<NuxtLink class="text-(--accent-strong)" to="/register">Зарегистрироваться</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
135
frontend/pages/register.vue
Normal file
135
frontend/pages/register.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup lang="ts">
|
||||
import type { User } from "~/types";
|
||||
|
||||
const { login, user, refreshMe } = useAuth();
|
||||
const { claimAnonymousPushSubscriptions, consumePendingPushRoute } = usePush();
|
||||
const route = useRoute();
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const showPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
const error = ref("");
|
||||
const redirectTarget = computed(() =>
|
||||
typeof route.query.redirect === "string" ? route.query.redirect : "/"
|
||||
);
|
||||
|
||||
watch(
|
||||
user,
|
||||
(currentUser) => {
|
||||
if (currentUser) {
|
||||
void navigateTo(redirectTarget.value);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = "";
|
||||
|
||||
if (!password.value || password.value.length < 6) {
|
||||
error.value = "Пароль должен быть не короче 6 символов";
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.value !== confirmPassword.value) {
|
||||
error.value = "Пароли не совпадают";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await useApi<{ token?: string; user: User }>("/auth/register", {
|
||||
method: "POST",
|
||||
body: { email: email.value, password: password.value }
|
||||
});
|
||||
|
||||
await login(result.token ?? null, result.user);
|
||||
await refreshMe();
|
||||
await claimAnonymousPushSubscriptions();
|
||||
const redirectedFromPush = await consumePendingPushRoute();
|
||||
if (!redirectedFromPush) {
|
||||
await navigateTo(redirectTarget.value);
|
||||
}
|
||||
} catch (submitError) {
|
||||
error.value = submitError instanceof Error ? submitError.message : "Ошибка регистрации";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto grid min-h-[calc(100vh-12rem)] w-full items-center gap-8 md:max-w-6xl md:grid-cols-[minmax(0,1.1fr)_minmax(22rem,28rem)] md:[min-height:calc(100vh-10rem)]">
|
||||
<div class="grid max-w-lg gap-3">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Alpinbet</p>
|
||||
<h1 class="m-0 text-4xl font-semibold leading-tight">Создание аккаунта</h1>
|
||||
<p class="m-0 text-base leading-7 text-(--muted)">
|
||||
После регистрации администратор сможет выдать доступы к нужным ботам и сигналам.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="grid w-full max-w-md gap-4 rounded-[28px] border p-4"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface-strong)' }"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Новый аккаунт</p>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Email</span>
|
||||
<input v-model="email" type="email" autocomplete="email" />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i class="pi" :class="showPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Повторите пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showConfirmPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<i class="pi" :class="showConfirmPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-2xl p-4 text-sm whitespace-pre-line"
|
||||
:style="{ backgroundColor: 'var(--danger-bg)', color: 'var(--danger-text)' }"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<button type="submit">Создать аккаунт</button>
|
||||
|
||||
<p class="m-0 text-(--muted)">
|
||||
Уже есть аккаунт?
|
||||
<NuxtLink class="text-(--accent-strong)" to="/login">Войти</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
130
frontend/pages/reset-password.vue
Normal file
130
frontend/pages/reset-password.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<script setup lang="ts">
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const token = computed(() => (typeof route.query.token === "string" ? route.query.token : ""));
|
||||
const password = ref("");
|
||||
const confirmPassword = ref("");
|
||||
const showPassword = ref(false);
|
||||
const showConfirmPassword = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const pending = ref(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
|
||||
if (!token.value) {
|
||||
error.value = "Ссылка восстановления некорректна";
|
||||
return;
|
||||
}
|
||||
|
||||
pending.value = true;
|
||||
|
||||
try {
|
||||
const result = await useApi<{ message: string }>("/auth/reset-password", {
|
||||
method: "POST",
|
||||
body: {
|
||||
token: token.value,
|
||||
password: password.value,
|
||||
confirmPassword: confirmPassword.value
|
||||
}
|
||||
});
|
||||
|
||||
success.value = result.message;
|
||||
password.value = "";
|
||||
confirmPassword.value = "";
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 1500);
|
||||
} catch (submitError) {
|
||||
error.value = submitError instanceof Error ? submitError.message : "Не удалось обновить пароль";
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="mx-auto grid min-h-[calc(100vh-12rem)] w-full items-center gap-8 md:max-w-6xl md:grid-cols-[minmax(0,1.1fr)_minmax(22rem,28rem)] md:[min-height:calc(100vh-10rem)]">
|
||||
<div class="grid max-w-lg gap-3">
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Alpinbet</p>
|
||||
<h1 class="m-0 text-4xl font-semibold leading-tight">Новый пароль</h1>
|
||||
<p class="m-0 text-base leading-7 text-(--muted)">
|
||||
Установите новый пароль для аккаунта. После сохранения можно будет сразу войти в систему.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="grid w-full max-w-md gap-4 rounded-[28px] border p-4"
|
||||
:style="{ borderColor: 'var(--border)', backgroundColor: 'var(--surface-strong)' }"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<p class="m-0 text-xs font-semibold uppercase tracking-[0.18em] text-(--muted)">Смена пароля</p>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Новый пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<i class="pi" :class="showPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-1">
|
||||
<span>Повторите пароль</span>
|
||||
<div class="password-field">
|
||||
<input
|
||||
v-model="confirmPassword"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="password-field__toggle"
|
||||
:aria-label="showConfirmPassword ? 'Скрыть пароль' : 'Показать пароль'"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<i class="pi" :class="showConfirmPassword ? 'pi-eye-slash' : 'pi-eye'" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="rounded-2xl p-4 text-sm whitespace-pre-line"
|
||||
:style="{ backgroundColor: 'var(--danger-bg)', color: 'var(--danger-text)' }"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="success"
|
||||
class="rounded-2xl p-4 text-sm"
|
||||
:style="{ backgroundColor: 'var(--success-bg)', color: 'var(--success-text)' }"
|
||||
>
|
||||
{{ success }}
|
||||
</p>
|
||||
|
||||
<button type="submit" :disabled="pending || !token">
|
||||
{{ pending ? "Сохраняем..." : "Сохранить пароль" }}
|
||||
</button>
|
||||
|
||||
<p class="m-0 text-(--muted)">
|
||||
Нужна новая ссылка?
|
||||
<NuxtLink class="text-(--accent-strong)" to="/forgot-password">Запросить повторно</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
127
frontend/pages/settings.vue
Normal file
127
frontend/pages/settings.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { App as CapacitorApp } from "@capacitor/app";
|
||||
import { Capacitor } from "@capacitor/core";
|
||||
import type { NotificationSettings, UserBotAccess } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
const { ensurePushSubscription } = usePush();
|
||||
const settings = ref<NotificationSettings>({
|
||||
signalsPushEnabled: true,
|
||||
resultsPushEnabled: false
|
||||
});
|
||||
const subscriptions = ref<UserBotAccess[]>([]);
|
||||
const message = ref("");
|
||||
const appVersion = ref("");
|
||||
const isAndroidApp = ref(false);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [notificationSettings, subscriptionItems] = await Promise.all([
|
||||
useApi<NotificationSettings>("/me/notification-settings"),
|
||||
useApi<UserBotAccess[]>("/me/subscriptions")
|
||||
]);
|
||||
|
||||
settings.value = notificationSettings;
|
||||
subscriptions.value = subscriptionItems;
|
||||
} catch {
|
||||
message.value = "";
|
||||
}
|
||||
|
||||
if (!process.client || !Capacitor.isNativePlatform() || Capacitor.getPlatform() !== "android") {
|
||||
return;
|
||||
}
|
||||
|
||||
isAndroidApp.value = true;
|
||||
|
||||
try {
|
||||
const appInfo = await CapacitorApp.getInfo();
|
||||
appVersion.value = appInfo.version?.trim() ?? "";
|
||||
} catch {
|
||||
appVersion.value = "";
|
||||
}
|
||||
});
|
||||
|
||||
const save = async () => {
|
||||
settings.value = await useApi<NotificationSettings>("/me/notification-settings", {
|
||||
method: "PATCH",
|
||||
body: settings.value
|
||||
});
|
||||
message.value = "Настройки сохранены";
|
||||
};
|
||||
|
||||
const enablePush = async () => {
|
||||
await ensurePushSubscription();
|
||||
message.value = "Push-уведомления подключены";
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<div class="panel notification-settings">
|
||||
<div class="notification-settings__header">
|
||||
<p class="eyebrow">Уведомления</p>
|
||||
<h1>Настройки уведомлений</h1>
|
||||
<p class="muted">Выберите, какие уведомления должны приходить на это устройство.</p>
|
||||
</div>
|
||||
|
||||
<div class="notification-settings__list">
|
||||
<label class="notification-option">
|
||||
<input v-model="settings.signalsPushEnabled" type="checkbox" />
|
||||
<span class="notification-option__body">
|
||||
<strong>Новые сигналы</strong>
|
||||
<small>Мгновенные push-уведомления при появлении новых матчей и сигналов.</small>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="notification-option">
|
||||
<input v-model="settings.resultsPushEnabled" type="checkbox" />
|
||||
<span class="notification-option__body">
|
||||
<strong>Результаты сигналов</strong>
|
||||
<small>Уведомления после расчёта исхода: выигрыш, проигрыш или возврат.</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="notification-settings__actions">
|
||||
<button @click="save">Сохранить настройки</button>
|
||||
<button class="secondary" @click="enablePush">Подключить Web Push</button>
|
||||
</div>
|
||||
|
||||
<p v-if="message" class="success">{{ message }}</p>
|
||||
</div>
|
||||
|
||||
<div class="panel notification-settings">
|
||||
<div class="notification-settings__header">
|
||||
<p class="eyebrow">Подписки</p>
|
||||
<h2>Доступ к ботам</h2>
|
||||
<p class="muted">Здесь показаны текущие подписки и срок действия доступа.</p>
|
||||
</div>
|
||||
|
||||
<div class="notification-settings__list">
|
||||
<div v-for="subscription in subscriptions" :key="subscription.id" class="notification-option">
|
||||
<span class="notification-option__body">
|
||||
<strong>{{ subscription.bot.name }}</strong>
|
||||
<small>
|
||||
Статус: {{ subscription.status }}
|
||||
<template v-if="subscription.expiresAt">
|
||||
· до {{ new Date(subscription.expiresAt).toLocaleString("ru-RU") }}
|
||||
</template>
|
||||
<template v-else>
|
||||
· без ограничения по сроку
|
||||
</template>
|
||||
</small>
|
||||
<small v-if="subscription.notes">{{ subscription.notes }}</small>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="subscriptions.length === 0" class="muted">Подписок пока нет.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isAndroidApp && appVersion" class="settings-version">
|
||||
Версия приложения для Android: {{ appVersion }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
271
frontend/pages/signals/[id].vue
Normal file
271
frontend/pages/signals/[id].vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<script setup lang="ts">
|
||||
import Button from "primevue/button";
|
||||
import Card from "primevue/card";
|
||||
import Message from "primevue/message";
|
||||
import Skeleton from "primevue/skeleton";
|
||||
import Tag from "primevue/tag";
|
||||
import type { Signal } from "~/types";
|
||||
|
||||
definePageMeta({
|
||||
middleware: "auth"
|
||||
});
|
||||
|
||||
type SignalDetails = Signal & {
|
||||
settlement?: {
|
||||
explanation: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const { formatDateTime, browserTimeZone } = useBrowserDateTime();
|
||||
const { copyText } = useClipboard();
|
||||
const signal = ref<SignalDetails | null>(null);
|
||||
const loadError = ref("");
|
||||
|
||||
const botName = computed(() => signal.value?.rawPayload?.botName || null);
|
||||
const botKey = computed(() => signal.value?.rawPayload?.botKey || null);
|
||||
const forecastImageUrl = computed(() => signal.value?.rawPayload?.forecastImageUrl || null);
|
||||
const displayForecast = computed(() => signal.value?.forecast || signal.value?.rawPayload?.forecast || "");
|
||||
const forecastImageFailed = ref(false);
|
||||
const isInactiveForecast = computed(() => signal.value?.rawPayload?.forecastInactive === true);
|
||||
const copiedMatch = ref(false);
|
||||
let copiedMatchResetTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const statusLabels: Record<Signal["status"], string> = {
|
||||
pending: "LIVE",
|
||||
win: "WIN",
|
||||
lose: "LOSE",
|
||||
void: "VOID",
|
||||
manual_review: "CHECK",
|
||||
unpublished: "OFF"
|
||||
};
|
||||
|
||||
const displayStatusLabel = computed(() => {
|
||||
if (!signal.value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (signal.value.status === "pending" && isInactiveForecast.value) {
|
||||
return "OFF";
|
||||
}
|
||||
|
||||
return statusLabels[signal.value.status];
|
||||
});
|
||||
|
||||
const statusSeverity = computed(() => {
|
||||
if (!signal.value) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
if (signal.value.status === "pending" && isInactiveForecast.value) {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
switch (signal.value.status) {
|
||||
case "win":
|
||||
return "success";
|
||||
case "lose":
|
||||
return "danger";
|
||||
case "manual_review":
|
||||
return "warn";
|
||||
case "pending":
|
||||
return "info";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
});
|
||||
|
||||
const formattedSignalTime = computed(() => {
|
||||
return signal.value ? formatDateTime(signal.value.signalTime) : ""
|
||||
});
|
||||
const formattedOdds = computed(() => (signal.value ? signal.value.odds.toFixed(2) : ""));
|
||||
const formattedLineValue = computed(() => {
|
||||
if (!signal.value || signal.value.lineValue === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(signal.value.lineValue);
|
||||
});
|
||||
|
||||
const shouldShowForecastImage = computed(() => Boolean(forecastImageUrl.value) && !forecastImageFailed.value);
|
||||
|
||||
const detailItems = computed(() => {
|
||||
if (!signal.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "Статус", value: displayStatusLabel.value },
|
||||
// { label: "Источник", value: signal.value.sourceType },
|
||||
{ label: "Время сигнала", value: formattedSignalTime.value },
|
||||
{ label: "Часовой пояс", value: browserTimeZone.value ?? "UTC" },
|
||||
{ label: "Коэффициент", value: formattedOdds.value },
|
||||
{ label: "Линия", value: formattedLineValue.value ?? "Не указана" }
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
signal.value = await useApi<SignalDetails>(`/signals/${route.params.id}`);
|
||||
loadError.value = "";
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : "Не удалось загрузить сигнал";
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => signal.value?.rawPayload?.forecastImageUrl,
|
||||
() => {
|
||||
forecastImageFailed.value = false;
|
||||
}
|
||||
);
|
||||
|
||||
const copyMatchName = async () => {
|
||||
const copied = await copyText(`${signal.value?.homeTeam ?? ""} - ${signal.value?.awayTeam ?? ""}`);
|
||||
if (!copied) {
|
||||
return;
|
||||
}
|
||||
copiedMatch.value = true;
|
||||
|
||||
if (copiedMatchResetTimeout) {
|
||||
clearTimeout(copiedMatchResetTimeout);
|
||||
}
|
||||
|
||||
copiedMatchResetTimeout = setTimeout(() => {
|
||||
copiedMatch.value = false;
|
||||
}, 1600);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sakai-page">
|
||||
<div class="sakai-hero-card">
|
||||
<div>
|
||||
<div class="sakai-page-back">
|
||||
<NuxtLink :to="botKey ? `/bots/${botKey}` : '/'">
|
||||
<Button
|
||||
text
|
||||
severity="secondary"
|
||||
icon="pi pi-arrow-left"
|
||||
:label="botKey ? 'Назад к сигналам' : 'На главную'"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<span class="sakai-section-label">Детали сигнала</span>
|
||||
<h2>{{ signal ? `${signal.homeTeam} - ${signal.awayTeam}` : "Загрузка сигнала" }}</h2>
|
||||
</div>
|
||||
<div v-if="signal" class="sakai-hero-card__tags">
|
||||
<Tag class="rounded" :value="signal.sportType" severity="contrast" />
|
||||
<Tag class="rounded" :value="signal.leagueName" severity="info" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="loadError" severity="error" :closable="false">{{ loadError }}</Message>
|
||||
|
||||
<div v-else-if="!signal" class="sakai-signal-detail-loading">
|
||||
<Card class="sakai-signal-panel">
|
||||
<template #content>
|
||||
<div class="sakai-signal-detail-skeleton">
|
||||
<Skeleton width="30%" height="1rem" />
|
||||
<Skeleton width="65%" height="2rem" />
|
||||
<Skeleton width="45%" height="1.2rem" />
|
||||
<Skeleton width="100%" height="16rem" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-else class="sakai-signal-layout sakai-signal-layout--detail">
|
||||
<div class="sakai-signal-detail-main">
|
||||
<Card class="sakai-signal-panel">
|
||||
<template #content>
|
||||
<article class="sakai-signal-card sakai-signal-card--detail">
|
||||
<div class="sakai-signal-card__main">
|
||||
<div class="sakai-signal-card__meta">
|
||||
<span>
|
||||
<i class="pi pi-calendar" />
|
||||
Сигнал: {{ formattedSignalTime }}
|
||||
</span>
|
||||
<span>
|
||||
<i class="pi pi-flag" />
|
||||
{{ signal.leagueName }}
|
||||
</span>
|
||||
<span v-if="botName">
|
||||
<AppLogo size="14px" />
|
||||
{{ botName }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__teams">
|
||||
<strong>{{ signal.homeTeam }}</strong>
|
||||
<strong>{{ signal.awayTeam }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="sakai-copy-button sakai-copy-button--wide"
|
||||
:aria-label="`Скопировать матч ${signal.homeTeam} - ${signal.awayTeam}`"
|
||||
@click="copyMatchName"
|
||||
>
|
||||
<i class="pi" :class="copiedMatch ? 'pi-check' : 'pi-copy'" aria-hidden="true" />
|
||||
<span>{{ copiedMatch ? "Скопировано" : "Скопировать матч" }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__market">
|
||||
<span>{{ signal.marketType }}</span>
|
||||
<span>{{ signal.selection }}</span>
|
||||
<span v-if="formattedLineValue">{{ formattedLineValue }}</span>
|
||||
<span>{{ signal.sourceType }}</span>
|
||||
</div>
|
||||
|
||||
<p class="sakai-signal-detail__timezone">
|
||||
Время показано в часовом поясе: {{ browserTimeZone ?? "UTC" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-card__side sakai-signal-card__side--detail">
|
||||
<div v-if="forecastImageUrl || displayForecast" class="sakai-signal-card__forecast">
|
||||
<img
|
||||
v-if="shouldShowForecastImage"
|
||||
:src="forecastImageUrl"
|
||||
:alt="displayForecast || `${signal.homeTeam} - ${signal.awayTeam}`"
|
||||
class="sakai-signal-card__forecast-image sakai-signal-card__forecast-image--detail"
|
||||
loading="lazy"
|
||||
@error="forecastImageFailed = true"
|
||||
>
|
||||
<p v-else-if="displayForecast" class="sakai-signal-card__forecast-text">
|
||||
{{ displayForecast }}
|
||||
</p>
|
||||
<p v-else class="sakai-signal-card__forecast-text sakai-signal-card__forecast-text--empty">
|
||||
Прогноз недоступен
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-detail__status-row">
|
||||
<Tag class="rounded" :value="displayStatusLabel" :severity="statusSeverity" />
|
||||
<div class="sakai-signal-card__odds">{{ formattedOdds }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="sakai-signal-detail-aside">
|
||||
<Card class="sakai-summary-panel sakai-summary-panel--sticky">
|
||||
<template #title>Сводка</template>
|
||||
<template #content>
|
||||
<div class="sakai-signal-detail-grid">
|
||||
<div v-for="item in detailItems" :key="item.label" class="sakai-signal-detail-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user