322 lines
10 KiB
Vue
322 lines
10 KiB
Vue
<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>
|