| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692 |
- <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,
- Pagination,
- Radio,
- RadioGroup,
- Switch,
- 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 HistoryGame = {
- endTime: number;
- eventId: number;
- leagueName?: string;
- marketCount: number;
- startTime: number;
- teamAwayName?: string;
- teamHomeName?: string;
- updatedAt?: string;
- };
- type HistoryGamesResponse = {
- list: HistoryGame[];
- page: number;
- pageSize: number;
- total: number;
- };
- const MARKET_KEYS = ['ior_ot_1', 'ior_ot_2', 'ior_ot_3', 'ior_ot_4'];
- const PLATFORMS = [
- { label: 'PC', value: 'pc' },
- { label: 'OB', value: 'ob' },
- ];
- const MARKET_LABELS: Record<string, string> = {
- ior_ot_1: '总进球 1',
- ior_ot_2: '总进球 2',
- ior_ot_3: '总进球 3',
- ior_ot_4: '总进球 4',
- };
- const getMarketKey = (platform: string, key: string) => `${platform}:${key}`;
- const parseMarketKey = (key: string) => {
- if (MARKET_KEYS.includes(key)) {
- return { ior: key, platform: 'pc' };
- }
- const [platform, ior] = key.split(/[:.]/);
- if (PLATFORMS.some((item) => item.value === platform) && MARKET_KEYS.includes(ior)) {
- return { ior, platform };
- }
- return null;
- };
- const normalizeHistoryMarkets = (markets?: Record<string, MarketPoint[]>) => {
- const normalized: Record<string, MarketPoint[]> = {};
- Object.entries(markets ?? {}).forEach(([key, points]) => {
- const parsed = parseMarketKey(key);
- if (!parsed || !points?.length) {
- return;
- }
- normalized[getMarketKey(parsed.platform, parsed.ior)] = points;
- });
- return normalized;
- };
- const chartRef = ref<EchartsUIType>();
- const { renderEcharts } = useEcharts(chartRef);
- const games = ref<HistoryGame[]>([]);
- const history = ref<OddsHistory | null>(null);
- const selectedEventId = ref<number>();
- const selectedMarkets = ref<string[]>([]);
- const loadingGames = ref(false);
- const loadingHistory = ref(false);
- const isMultiSelect = ref(false);
- const marketType = ref(-1);
- const pageSize = 50;
- const currentPage = ref(1);
- const searchValue = ref('');
- const searchTimer = ref<ReturnType<typeof setTimeout>>();
- const totalGames = ref(0);
- const availableMarketKeys = computed(() => {
- const markets = normalizeHistoryMarkets(history.value?.markets);
- return PLATFORMS.flatMap(({ value: platform }) => (
- MARKET_KEYS.map((key) => getMarketKey(platform, key))
- )).filter((key) => markets[key]?.length);
- });
- const hasChartData = computed(() => !!history.value && availableMarketKeys.value.length > 0);
- const selectedGame = computed(() => {
- return games.value.find((item) => item.eventId === selectedEventId.value);
- });
- const historyTitle = computed(() => {
- const source = history.value ?? selectedGame.value;
- if (!source) {
- return '未选择比赛';
- }
- return `${source.teamHomeName ?? ''} vs ${source.teamAwayName ?? ''}`;
- });
- const isHalfEvent = (eventId?: number) => {
- return typeof eventId === 'number' && eventId < 0;
- };
- const marketOptionGroups = computed(() => {
- const available = new Set(availableMarketKeys.value);
- return PLATFORMS.map(({ label, value: platform }) => ({
- label,
- options: MARKET_KEYS.map((key) => {
- const value = getMarketKey(platform, key);
- return {
- label: MARKET_LABELS[key] ?? key,
- value,
- };
- }).filter((option) => available.has(option.value)),
- })).filter((group) => group.options.length);
- });
- const getGroupSelectedMarkets = (options: Array<{ value: string }>) => {
- const values = new Set(options.map((option) => option.value));
- return selectedMarkets.value.filter((key) => values.has(key));
- };
- const updateGroupSelectedMarkets = (options: Array<{ value: string }>, values: string[]) => {
- const groupValues = new Set(options.map((option) => option.value));
- selectedMarkets.value = [
- ...selectedMarkets.value.filter((key) => !groupValues.has(key)),
- ...values,
- ];
- };
- const selectedMarket = computed({
- get: () => selectedMarkets.value[0],
- set: (key?: string) => {
- selectedMarkets.value = key ? [key] : [];
- },
- });
- 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 getMarketLabel = (key: string) => {
- const [platform, ior] = key.includes(':') || key.includes('.') ? key.split(/[:.]/) : ['pc', key];
- const platformLabel = PLATFORMS.find((item) => item.value === platform)?.label ?? platform.toUpperCase();
- return `${platformLabel} ${MARKET_LABELS[ior] ?? ior}`;
- };
- const formatSeriesData = (points: MarketPoint[] = []) => {
- const data: Array<[number, null | number]> = [];
- let lastValue: number | null = null;
- points.forEach((point) => {
- if (point.value === 0) {
- if (lastValue !== null) {
- data.push([point.time, lastValue]);
- }
- data.push([point.time, null]);
- lastValue = null;
- return;
- }
- data.push([point.time, point.value]);
- lastValue = point.value;
- });
- return data;
- };
- const renderChart = () => {
- const markets = normalizeHistoryMarkets(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: formatSeriesData(markets[key]),
- itemStyle: {
- color: getMarketColor(index),
- },
- lineStyle: {
- width: 1,
- },
- name: getMarketLabel(key),
- showSymbol: true,
- smooth: false,
- symbolSize: 2,
- 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<HistoryGamesResponse>('/pstery/get_odds_history_games', {
- params: {
- keyword: searchValue.value.trim() || undefined,
- page: currentPage.value,
- page_size: pageSize,
- status: marketType.value,
- },
- });
- games.value = data?.list ?? [];
- totalGames.value = data?.total ?? 0;
- if (!selectedEventId.value && games.value.length) {
- selectedEventId.value = games.value[0]?.eventId;
- }
- 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 markets = normalizeHistoryMarkets(data?.markets);
- const firstKey = PLATFORMS.flatMap(({ value: platform }) => (
- MARKET_KEYS.map((key) => getMarketKey(platform, key))
- )).find((key) => markets[key]?.length);
- selectedMarkets.value = firstKey ? [firstKey] : [];
- 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 = [];
- currentPage.value = 1;
- fetchGames();
- });
- watch(searchValue, () => {
- currentPage.value = 1;
- clearTimeout(searchTimer.value);
- searchTimer.value = setTimeout(fetchGames, 300);
- });
- watch(currentPage, () => {
- selectedEventId.value = undefined;
- history.value = null;
- selectedMarkets.value = [];
- fetchGames();
- });
- watch(selectedMarkets, () => {
- renderChart();
- }, { deep: true });
- watch(isMultiSelect, (multiSelect) => {
- if (!multiSelect && selectedMarkets.value.length > 1) {
- selectedMarkets.value = [selectedMarkets.value[0]!];
- }
- 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="1">进行中</Radio>
- <Radio :value="2">已结束</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="games.length">
- <button
- v-for="game in games"
- :key="game.eventId"
- class="match-item"
- :class="{ active: game.eventId === selectedEventId }"
- type="button"
- @click="selectGame(game.eventId)"
- >
- <span class="league">{{ game.leagueName }}</span>
- <span class="teams">
- {{ game.teamHomeName }} vs {{ game.teamAwayName }}
- <span v-if="isHalfEvent(game.eventId)" class="period-tag">上半场</span>
- </span>
- <span class="meta">ID {{ game.eventId }} · {{ formatGameTime(game.startTime) }} · {{ game.marketCount }}盘</span>
- </button>
- </div>
- <Empty v-else class="list-empty" description="暂无比赛" />
- </Spin>
- <Pagination
- v-if="totalGames > pageSize"
- v-model:current="currentPage"
- class="match-pagination"
- :page-size="pageSize"
- :show-size-changer="false"
- size="small"
- :total="totalGames"
- />
- </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 v-if="isHalfEvent(selectedEventId)">比赛周期:上半场</span>
- <span>开赛:{{ formatTime(history?.startTime ?? selectedGame?.startTime) }}</span>
- <span>记录盘口:{{ availableMarketKeys.length }}</span>
- </div>
- </div>
- </div>
- <div class="market-selector" v-if="marketOptionGroups.length">
- <div class="market-mode">
- <span>盘口选择</span>
- <Switch
- v-model:checked="isMultiSelect"
- checked-children="多选"
- un-checked-children="单选"
- />
- </div>
- <div class="market-options">
- <div
- v-for="group in marketOptionGroups"
- :key="group.label"
- class="market-row"
- >
- <span class="market-platform">{{ group.label }}</span>
- <CheckboxGroup
- v-if="isMultiSelect"
- :options="group.options"
- :value="getGroupSelectedMarkets(group.options)"
- @change="(values) => updateGroupSelectedMarkets(group.options, values as string[])"
- />
- <RadioGroup v-else v-model:value="selectedMarket" :options="group.options" />
- </div>
- </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 - 274px);
- overflow: auto;
- padding: 0 10px 10px;
- }
- .match-pagination {
- display: flex;
- justify-content: center;
- padding: 10px;
- border-top: 1px solid hsl(var(--border));
- }
- .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;
- }
- .period-tag {
- display: inline-flex;
- align-items: center;
- margin-left: 6px;
- padding: 0 5px;
- border: 1px solid hsl(var(--primary) / 0.3);
- color: hsl(var(--primary));
- font-size: 12px;
- font-weight: 400;
- line-height: 18px;
- }
- .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: flex;
- align-items: flex-start;
- gap: 16px;
- padding: 12px 16px;
- border-bottom: 1px solid hsl(var(--border));
- }
- .market-mode {
- display: flex;
- align-items: center;
- gap: 8px;
- color: hsl(var(--foreground) / 0.66);
- font-size: 13px;
- }
- .market-options {
- display: grid;
- flex: 1;
- gap: 8px;
- min-width: 0;
- }
- .market-row {
- display: flex;
- align-items: center;
- gap: 10px;
- min-height: 24px;
- }
- .market-platform {
- width: 28px;
- flex: none;
- color: hsl(var(--foreground) / 0.58);
- font-size: 12px;
- font-weight: 600;
- }
- .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;
- }
- .market-selector {
- align-items: flex-start;
- flex-direction: column;
- }
- }
- </style>
|