init
This commit is contained in:
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