|
|
@@ -0,0 +1,547 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import type { EchartsUIType } from '@vben/plugins/echarts';
|
|
|
+
|
|
|
+import { computed, nextTick, onMounted, ref, watch } from 'vue';
|
|
|
+
|
|
|
+import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
|
|
|
+
|
|
|
+import {
|
|
|
+ Button,
|
|
|
+ CheckboxGroup,
|
|
|
+ Empty,
|
|
|
+ Input,
|
|
|
+ message,
|
|
|
+ Radio,
|
|
|
+ RadioGroup,
|
|
|
+ Spin,
|
|
|
+} from 'ant-design-vue';
|
|
|
+import dayjs from 'dayjs';
|
|
|
+
|
|
|
+import { requestClient } from '#/api/request';
|
|
|
+
|
|
|
+type MarketPoint = {
|
|
|
+ origin?: string;
|
|
|
+ source?: number;
|
|
|
+ time: number;
|
|
|
+ value: number;
|
|
|
+};
|
|
|
+
|
|
|
+type OddsHistory = {
|
|
|
+ endTime: number;
|
|
|
+ eventId: number;
|
|
|
+ leagueName?: string;
|
|
|
+ markets?: Record<string, MarketPoint[]>;
|
|
|
+ startTime: number;
|
|
|
+ teamAwayName?: string;
|
|
|
+ teamHomeName?: string;
|
|
|
+};
|
|
|
+
|
|
|
+type GameRelation = {
|
|
|
+ id: number;
|
|
|
+ mk: number;
|
|
|
+ rel?: {
|
|
|
+ ps?: {
|
|
|
+ eventId: number;
|
|
|
+ leagueName: string;
|
|
|
+ teamAwayName: string;
|
|
|
+ teamHomeName: string;
|
|
|
+ timestamp: number;
|
|
|
+ };
|
|
|
+ };
|
|
|
+ timestamp: number;
|
|
|
+};
|
|
|
+
|
|
|
+const MARKET_GROUPS = [
|
|
|
+ {
|
|
|
+ keys: [
|
|
|
+ 'ior_mn',
|
|
|
+ 'ior_wmh_1',
|
|
|
+ 'ior_wmh_2',
|
|
|
+ 'ior_wmh_3',
|
|
|
+ 'ior_wmc_1',
|
|
|
+ 'ior_wmc_2',
|
|
|
+ 'ior_wmc_3',
|
|
|
+ ],
|
|
|
+ label: '让平盘',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ keys: ['ior_ot_1', 'ior_ot_2', 'ior_ot_3', 'ior_ot_4', 'ior_ot_5'],
|
|
|
+ label: '进球数',
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+const MARKET_LABELS: Record<string, string> = {
|
|
|
+ ior_mn: '和局',
|
|
|
+ ior_ot_1: '总进球 1',
|
|
|
+ ior_ot_2: '总进球 2',
|
|
|
+ ior_ot_3: '总进球 3',
|
|
|
+ ior_ot_4: '总进球 4',
|
|
|
+ ior_ot_5: '总进球 5',
|
|
|
+ ior_wmc_1: '客胜 1 球',
|
|
|
+ ior_wmc_2: '客胜 2 球',
|
|
|
+ ior_wmc_3: '客胜 3 球',
|
|
|
+ ior_wmh_1: '主胜 1 球',
|
|
|
+ ior_wmh_2: '主胜 2 球',
|
|
|
+ ior_wmh_3: '主胜 3 球',
|
|
|
+};
|
|
|
+
|
|
|
+const DEFAULT_MARKET_KEYS = [
|
|
|
+ 'ior_mn',
|
|
|
+ 'ior_wmh_1',
|
|
|
+ 'ior_wmh_2',
|
|
|
+ 'ior_wmh_3',
|
|
|
+ 'ior_wmc_1',
|
|
|
+ 'ior_wmc_2',
|
|
|
+ 'ior_wmc_3',
|
|
|
+];
|
|
|
+
|
|
|
+const chartRef = ref<EchartsUIType>();
|
|
|
+const { renderEcharts } = useEcharts(chartRef);
|
|
|
+
|
|
|
+const games = ref<GameRelation[]>([]);
|
|
|
+const history = ref<OddsHistory | null>(null);
|
|
|
+const selectedEventId = ref<number>();
|
|
|
+const selectedMarkets = ref<string[]>([]);
|
|
|
+const loadingGames = ref(false);
|
|
|
+const loadingHistory = ref(false);
|
|
|
+const marketType = ref(-1);
|
|
|
+const searchValue = ref('');
|
|
|
+
|
|
|
+const availableMarketKeys = computed(() => {
|
|
|
+ const markets = history.value?.markets ?? {};
|
|
|
+ return Object.keys(markets).filter((key) => markets[key]?.length);
|
|
|
+});
|
|
|
+
|
|
|
+const hasChartData = computed(() => !!history.value && availableMarketKeys.value.length > 0);
|
|
|
+
|
|
|
+const selectedGame = computed(() => {
|
|
|
+ return games.value.find((item) => item.id === selectedEventId.value);
|
|
|
+});
|
|
|
+
|
|
|
+const filteredGames = computed(() => {
|
|
|
+ const keyword = searchValue.value.trim();
|
|
|
+ return games.value.filter((item) => {
|
|
|
+ const ps = item.rel?.ps;
|
|
|
+ if (!ps) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (keyword) {
|
|
|
+ const text = `${item.id} ${ps.leagueName} ${ps.teamHomeName} ${ps.teamAwayName}`;
|
|
|
+ if (!text.includes(keyword)) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+const historyTitle = computed(() => {
|
|
|
+ const source = history.value ?? selectedGame.value?.rel?.ps;
|
|
|
+ if (!source) {
|
|
|
+ return '未选择比赛';
|
|
|
+ }
|
|
|
+ return `${source.teamHomeName ?? ''} vs ${source.teamAwayName ?? ''}`;
|
|
|
+});
|
|
|
+
|
|
|
+const marketOptions = computed(() => {
|
|
|
+ const available = new Set(availableMarketKeys.value);
|
|
|
+ return MARKET_GROUPS.map((group) => ({
|
|
|
+ ...group,
|
|
|
+ options: group.keys
|
|
|
+ .filter((key) => available.has(key))
|
|
|
+ .map((key) => ({
|
|
|
+ label: MARKET_LABELS[key] ?? key,
|
|
|
+ value: key,
|
|
|
+ })),
|
|
|
+ })).filter((group) => group.options.length);
|
|
|
+});
|
|
|
+
|
|
|
+const formatTime = (time?: number) => {
|
|
|
+ return time ? dayjs(time).format('MM-DD HH:mm:ss') : '-';
|
|
|
+};
|
|
|
+
|
|
|
+const formatGameTime = (time?: number) => {
|
|
|
+ return time ? dayjs(time).format('MM-DD HH:mm') : '-';
|
|
|
+};
|
|
|
+
|
|
|
+const getMarketColor = (index: number) => {
|
|
|
+ const colors = [
|
|
|
+ '#1677ff',
|
|
|
+ '#13a8a8',
|
|
|
+ '#fa8c16',
|
|
|
+ '#722ed1',
|
|
|
+ '#eb2f96',
|
|
|
+ '#52c41a',
|
|
|
+ '#faad14',
|
|
|
+ '#2f54eb',
|
|
|
+ '#a0d911',
|
|
|
+ '#f5222d',
|
|
|
+ '#08979c',
|
|
|
+ '#531dab',
|
|
|
+ ];
|
|
|
+ return colors[index % colors.length] ?? colors[0];
|
|
|
+};
|
|
|
+
|
|
|
+const renderChart = () => {
|
|
|
+ const markets = history.value?.markets ?? {};
|
|
|
+ const keys = selectedMarkets.value.filter((key) => markets[key]?.length);
|
|
|
+
|
|
|
+ if (!keys.length) {
|
|
|
+ renderEcharts({
|
|
|
+ grid: { bottom: 40, containLabel: true, left: 32, right: 24, top: 32 },
|
|
|
+ series: [],
|
|
|
+ xAxis: { type: 'time' },
|
|
|
+ yAxis: { type: 'value' },
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ renderEcharts({
|
|
|
+ dataZoom: [
|
|
|
+ {
|
|
|
+ bottom: 8,
|
|
|
+ height: 22,
|
|
|
+ type: 'slider',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'inside',
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ grid: {
|
|
|
+ bottom: 66,
|
|
|
+ containLabel: true,
|
|
|
+ left: 24,
|
|
|
+ right: 24,
|
|
|
+ top: 36,
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ bottom: 34,
|
|
|
+ type: 'scroll',
|
|
|
+ },
|
|
|
+ series: keys.map((key, index) => ({
|
|
|
+ connectNulls: false,
|
|
|
+ data: (markets[key] ?? []).map((point) => [
|
|
|
+ point.time,
|
|
|
+ point.value === 0 ? null : point.value,
|
|
|
+ ]),
|
|
|
+ itemStyle: {
|
|
|
+ color: getMarketColor(index),
|
|
|
+ },
|
|
|
+ name: MARKET_LABELS[key] ?? key,
|
|
|
+ showSymbol: false,
|
|
|
+ smooth: false,
|
|
|
+ type: 'line',
|
|
|
+ })),
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ valueFormatter: (value) => {
|
|
|
+ return typeof value === 'number' ? value.toFixed(3) : `${value ?? ''}`;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ xAxis: {
|
|
|
+ axisLabel: {
|
|
|
+ formatter: (value: number) => dayjs(value).format('HH:mm'),
|
|
|
+ },
|
|
|
+ max: history.value?.endTime,
|
|
|
+ min: history.value ? history.value.startTime - 2 * 60 * 60 * 1000 : undefined,
|
|
|
+ type: 'time',
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ min: 0,
|
|
|
+ scale: true,
|
|
|
+ splitLine: {
|
|
|
+ lineStyle: {
|
|
|
+ type: 'dashed',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ type: 'value',
|
|
|
+ },
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const fetchGames = async () => {
|
|
|
+ loadingGames.value = true;
|
|
|
+ try {
|
|
|
+ const data = await requestClient.get<GameRelation[]>('/pstery/get_games_relation', {
|
|
|
+ params: { hb: false, hh: true, mk: marketType.value },
|
|
|
+ });
|
|
|
+ games.value = data ?? [];
|
|
|
+ if (!selectedEventId.value && games.value.length) {
|
|
|
+ selectedEventId.value = games.value[0]?.id;
|
|
|
+ }
|
|
|
+ if (selectedEventId.value) {
|
|
|
+ await fetchHistory(selectedEventId.value);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ catch (error) {
|
|
|
+ console.error('Failed to fetch games relation:', error);
|
|
|
+ message.error('获取比赛列表失败');
|
|
|
+ }
|
|
|
+ finally {
|
|
|
+ loadingGames.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const fetchHistory = async (eventId: number) => {
|
|
|
+ loadingHistory.value = true;
|
|
|
+ try {
|
|
|
+ const data = await requestClient.get<OddsHistory | null>('/pstery/get_odds_history', {
|
|
|
+ params: { event_id: eventId },
|
|
|
+ });
|
|
|
+ history.value = data;
|
|
|
+ const keys = Object.keys(data?.markets ?? {}).filter((key) => data?.markets?.[key]?.length);
|
|
|
+ selectedMarkets.value = DEFAULT_MARKET_KEYS.filter((key) => keys.includes(key));
|
|
|
+ await nextTick();
|
|
|
+ setTimeout(renderChart);
|
|
|
+ }
|
|
|
+ catch (error) {
|
|
|
+ console.error('Failed to fetch odds history:', error);
|
|
|
+ message.error('获取赔率曲线失败');
|
|
|
+ }
|
|
|
+ finally {
|
|
|
+ loadingHistory.value = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const selectGame = (id: number) => {
|
|
|
+ if (selectedEventId.value === id) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ selectedEventId.value = id;
|
|
|
+ fetchHistory(id);
|
|
|
+};
|
|
|
+
|
|
|
+watch(marketType, () => {
|
|
|
+ selectedEventId.value = undefined;
|
|
|
+ history.value = null;
|
|
|
+ selectedMarkets.value = [];
|
|
|
+ fetchGames();
|
|
|
+});
|
|
|
+
|
|
|
+watch(selectedMarkets, () => {
|
|
|
+ renderChart();
|
|
|
+});
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ fetchGames();
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div class="odds-curve-page">
|
|
|
+ <aside class="match-panel">
|
|
|
+ <div class="panel-toolbar">
|
|
|
+ <RadioGroup v-model:value="marketType" size="small">
|
|
|
+ <Radio :value="-1">全部</Radio>
|
|
|
+ <Radio :value="2">滚球</Radio>
|
|
|
+ <Radio :value="1">今日</Radio>
|
|
|
+ <Radio :value="0">早盘</Radio>
|
|
|
+ </RadioGroup>
|
|
|
+ <Button size="small" :loading="loadingGames" @click="fetchGames">刷新</Button>
|
|
|
+ </div>
|
|
|
+ <Input
|
|
|
+ v-model:value="searchValue"
|
|
|
+ allow-clear
|
|
|
+ class="search-input"
|
|
|
+ placeholder="搜索联赛/球队/赛事ID"
|
|
|
+ />
|
|
|
+ <Spin :spinning="loadingGames">
|
|
|
+ <div class="match-list" v-if="filteredGames.length">
|
|
|
+ <button
|
|
|
+ v-for="game in filteredGames"
|
|
|
+ :key="game.id"
|
|
|
+ class="match-item"
|
|
|
+ :class="{ active: game.id === selectedEventId }"
|
|
|
+ type="button"
|
|
|
+ @click="selectGame(game.id)"
|
|
|
+ >
|
|
|
+ <span class="league">{{ game.rel?.ps?.leagueName }}</span>
|
|
|
+ <span class="teams">{{ game.rel?.ps?.teamHomeName }} vs {{ game.rel?.ps?.teamAwayName }}</span>
|
|
|
+ <span class="meta">ID {{ game.id }} · {{ formatGameTime(game.timestamp) }}</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <Empty v-else class="list-empty" description="暂无比赛" />
|
|
|
+ </Spin>
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ <main class="curve-panel">
|
|
|
+ <div class="curve-header">
|
|
|
+ <div>
|
|
|
+ <div class="curve-title">{{ historyTitle }}</div>
|
|
|
+ <div class="curve-subtitle">
|
|
|
+ <span>赛事ID:{{ selectedEventId ?? '-' }}</span>
|
|
|
+ <span>开赛:{{ formatTime(history?.startTime ?? selectedGame?.timestamp) }}</span>
|
|
|
+ <span>记录盘口:{{ availableMarketKeys.length }}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="market-selector" v-if="marketOptions.length">
|
|
|
+ <div v-for="group in marketOptions" :key="group.label" class="market-group">
|
|
|
+ <span class="market-group-label">{{ group.label }}</span>
|
|
|
+ <CheckboxGroup v-model:value="selectedMarkets" :options="group.options" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Spin :spinning="loadingHistory">
|
|
|
+ <div class="chart-wrap" :class="{ hidden: !hasChartData }">
|
|
|
+ <EchartsUI ref="chartRef" />
|
|
|
+ </div>
|
|
|
+ <Empty v-if="!hasChartData" class="history-empty" description="暂无赔率历史" />
|
|
|
+ </Spin>
|
|
|
+ </main>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.odds-curve-page {
|
|
|
+ display: grid;
|
|
|
+ min-height: calc(100vh - 112px);
|
|
|
+ grid-template-columns: 360px minmax(0, 1fr);
|
|
|
+ gap: 12px;
|
|
|
+ padding: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.match-panel,
|
|
|
+.curve-panel {
|
|
|
+ min-height: 0;
|
|
|
+ border: 1px solid hsl(var(--border));
|
|
|
+ background: hsl(var(--background));
|
|
|
+}
|
|
|
+
|
|
|
+.match-panel {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.panel-toolbar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 10px;
|
|
|
+ border-bottom: 1px solid hsl(var(--border));
|
|
|
+}
|
|
|
+
|
|
|
+.search-input {
|
|
|
+ margin: 10px;
|
|
|
+ width: calc(100% - 20px);
|
|
|
+}
|
|
|
+
|
|
|
+.match-list {
|
|
|
+ height: calc(100vh - 230px);
|
|
|
+ overflow: auto;
|
|
|
+ padding: 0 10px 10px;
|
|
|
+}
|
|
|
+
|
|
|
+.match-item {
|
|
|
+ display: grid;
|
|
|
+ width: 100%;
|
|
|
+ gap: 4px;
|
|
|
+ padding: 10px;
|
|
|
+ border: 0;
|
|
|
+ border-bottom: 1px solid hsl(var(--border));
|
|
|
+ background: transparent;
|
|
|
+ color: hsl(var(--foreground));
|
|
|
+ cursor: pointer;
|
|
|
+ text-align: left;
|
|
|
+}
|
|
|
+
|
|
|
+.match-item:hover,
|
|
|
+.match-item.active {
|
|
|
+ background: hsl(var(--accent));
|
|
|
+}
|
|
|
+
|
|
|
+.league,
|
|
|
+.meta {
|
|
|
+ color: hsl(var(--foreground) / 0.58);
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.teams {
|
|
|
+ font-size: 14px;
|
|
|
+ font-weight: 500;
|
|
|
+}
|
|
|
+
|
|
|
+.curve-panel {
|
|
|
+ display: flex;
|
|
|
+ min-width: 0;
|
|
|
+ flex-direction: column;
|
|
|
+}
|
|
|
+
|
|
|
+.curve-header {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: space-between;
|
|
|
+ gap: 12px;
|
|
|
+ padding: 14px 16px;
|
|
|
+ border-bottom: 1px solid hsl(var(--border));
|
|
|
+}
|
|
|
+
|
|
|
+.curve-title {
|
|
|
+ font-size: 16px;
|
|
|
+ font-weight: 600;
|
|
|
+}
|
|
|
+
|
|
|
+.curve-subtitle {
|
|
|
+ display: flex;
|
|
|
+ flex-wrap: wrap;
|
|
|
+ gap: 12px;
|
|
|
+ margin-top: 6px;
|
|
|
+ color: hsl(var(--foreground) / 0.58);
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.market-selector {
|
|
|
+ display: grid;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 12px 16px;
|
|
|
+ border-bottom: 1px solid hsl(var(--border));
|
|
|
+}
|
|
|
+
|
|
|
+.market-group {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+ gap: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.market-group-label {
|
|
|
+ width: 64px;
|
|
|
+ flex: none;
|
|
|
+ color: hsl(var(--foreground) / 0.66);
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 24px;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-wrap {
|
|
|
+ height: calc(100vh - 292px);
|
|
|
+ min-height: 420px;
|
|
|
+ padding: 12px;
|
|
|
+}
|
|
|
+
|
|
|
+.chart-wrap.hidden {
|
|
|
+ height: 0;
|
|
|
+ min-height: 0;
|
|
|
+ overflow: hidden;
|
|
|
+ padding: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.history-empty,
|
|
|
+.list-empty {
|
|
|
+ padding-top: 80px;
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 900px) {
|
|
|
+ .odds-curve-page {
|
|
|
+ grid-template-columns: 1fr;
|
|
|
+ }
|
|
|
+
|
|
|
+ .match-list {
|
|
|
+ height: 320px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .chart-wrap {
|
|
|
+ height: 420px;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|