| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571 |
- <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,
- availableKeys: group.keys.filter((key) => available.has(key)),
- options: group.keys
- .filter((key) => available.has(key))
- .map((key) => ({
- label: MARKET_LABELS[key] ?? key,
- value: key,
- })),
- })).filter((group) => group.options.length);
- });
- const selectMarketGroup = (keys: string[]) => {
- selectedMarkets.value = [...keys];
- };
- 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),
- },
- lineStyle: {
- width: 1,
- },
- name: MARKET_LABELS[key] ?? key,
- showSymbol: true,
- smooth: false,
- symbolSize: 3,
- 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">
- <button
- class="market-group-label"
- type="button"
- @click="selectMarketGroup(group.availableKeys)"
- >
- {{ group.label }}
- </button>
- <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;
- padding: 0;
- border: 0;
- background: transparent;
- color: hsl(var(--foreground) / 0.66);
- cursor: pointer;
- font-size: 13px;
- line-height: 24px;
- text-align: left;
- }
- .market-group-label:hover {
- color: hsl(var(--primary));
- }
- .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>
|