Bläddra i källkod

feat: add odds history curve

flyzto 4 dagar sedan
förälder
incheckning
30454949f9

+ 15 - 1
server/models/GamesPs.js

@@ -2,6 +2,7 @@ const axios = require('axios');
 const Logs = require('../libs/logs');
 const Cache = require('../libs/cache');
 const Setting = require('./Setting');
+const OddsHistory = require('./OddsHistory');
 const { eventSolutions } = require('../triangle/eventSolutions');
 const { getPassableEvents, eventsCombination, extractOdds } = require('../triangle/trangleCalc');
 const { calcTotalProfit, calcTotalProfitWithFixedFirst, getFirstInfo } = require('../triangle/totalProfitCalc');
@@ -462,6 +463,10 @@ const syncBaseEvents = ({ mk, games, outrights }) => {
     const { eventId, originId, stage, retime, score, wm, evtime, events } = game;
     const baseGame = baseMap.get(eventId);
     if (baseGame) {
+      OddsHistory.recordGameOdds({ game: { ...baseGame, originId }, events })
+      .catch(err => {
+        Logs.out('record odds history failed, eventId %s, %s', eventId, err.message);
+      });
       const adjustedEvents = rouMaxAdjust(events);
       Object.keys(adjustedEvents).forEach(ior => {
         const regWm = /^ior_(wm|mn)/;
@@ -1356,6 +1361,13 @@ const getSolutionsByIds = async (ids) => {
   return result;
 }
 
+/**
+ * 获取单场比赛赔率历史
+ */
+const getOddsHistory = async (eventId) => {
+  return OddsHistory.getGameOddsHistory(eventId);
+}
+
 /**
  * 清理中单方案
  */
@@ -1727,6 +1739,7 @@ const loadGamesFromCache = () => {
 
 // 在模块加载时尝试从缓存恢复数据
 loadGamesFromCache();
+OddsHistory.startCleanup(Logs);
 
 // 监听进程退出事件,保存GAMES数据
 process.on('exit', saveGamesToCache);
@@ -1748,6 +1761,7 @@ module.exports = {
   updateGamesResult,
   updateOriginalData, getOriginalData,
   getSolutions, getGamesSolutions, getSolution, getSolutionsByIds,
+  getOddsHistory,
   getTotalProfitWithSid, getTotalProfitWithBetInfo, getTotalReplacement,
   notifyException,
-}
+}

+ 183 - 0
server/models/OddsHistory.js

@@ -0,0 +1,183 @@
+const mongoose = require('mongoose');
+const { Schema } = mongoose;
+
+const TRACKED_IOR_KEYS = [
+  'ior_mn',
+  'ior_wmh_1',
+  'ior_wmh_2',
+  'ior_wmh_3',
+  'ior_wmc_1',
+  'ior_wmc_2',
+  'ior_wmc_3',
+  'ior_ot_1',
+  'ior_ot_2',
+  'ior_ot_3',
+  'ior_ot_4',
+  'ior_ot_5',
+];
+
+const TRACKED_IOR_SET = new Set(TRACKED_IOR_KEYS);
+
+const ODDS_HISTORY_START_OFFSET = 2 * 60 * 60 * 1000;
+const ODDS_HISTORY_END_OFFSET = 3 * 60 * 60 * 1000;
+const ODDS_HISTORY_RETENTION = 2 * 24 * 60 * 60 * 1000;
+const ODDS_HISTORY_CLEANUP_INTERVAL = 60 * 60 * 1000;
+
+const oddsPointSchema = new Schema({
+  time: { type: Number, required: true },
+  value: { type: Number, required: true },
+  source: { type: Number },
+  origin: { type: String },
+}, { _id: false });
+
+const oddsHistorySchema = new Schema({
+  eventId: { type: Number, required: true, unique: true, index: true },
+  originId: { type: Number },
+  leagueName: { type: String },
+  teamHomeName: { type: String },
+  teamAwayName: { type: String },
+  startTime: { type: Number, required: true, index: true },
+  endTime: { type: Number, required: true, index: true },
+  markets: {
+    type: Map,
+    of: [oddsPointSchema],
+    default: {},
+  },
+}, { timestamps: true });
+
+const OddsHistory = mongoose.model('OddsHistory', oddsHistorySchema);
+
+const isTrackedKey = (key) => TRACKED_IOR_SET.has(key);
+
+const isInsideTrackWindow = (eventTime, recordTime = Date.now()) => {
+  if (!eventTime) {
+    return false;
+  }
+  return recordTime >= eventTime - ODDS_HISTORY_START_OFFSET &&
+    recordTime <= eventTime + ODDS_HISTORY_END_OFFSET;
+}
+
+const formatPoint = (event) => {
+  const value = event?.v;
+  if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
+    return null;
+  }
+  return {
+    value,
+    source: event.s,
+    origin: event.r,
+  };
+}
+
+const appendChangedPoint = (markets, key, point, time) => {
+  const history = markets.get(key) ?? [];
+  const last = history[history.length - 1];
+  if (last && last.value === point.value && last.source === point.source && last.origin === point.origin) {
+    return false;
+  }
+  history.push({ time, ...point });
+  markets.set(key, history);
+  return true;
+}
+
+const recordGameOdds = async ({ game, events }) => {
+  const eventId = +game?.eventId;
+  const startTime = +game?.timestamp;
+  const time = Date.now();
+
+  if (!eventId || !isInsideTrackWindow(startTime, time) || !events) {
+    return { updated: false };
+  }
+
+  const trackedEntries = Object.entries(events)
+    .filter(([key]) => isTrackedKey(key))
+    .map(([key, event]) => [key, formatPoint(event)])
+    .filter(([, point]) => point);
+
+  const doc = await OddsHistory.findOne({ eventId });
+  const markets = doc?.markets ?? new Map();
+  let changed = false;
+  const currentKeys = new Set(trackedEntries.map(([key]) => key));
+
+  trackedEntries.forEach(([key, point]) => {
+    if (appendChangedPoint(markets, key, point, time)) {
+      changed = true;
+    }
+  });
+
+  TRACKED_IOR_KEYS.forEach(key => {
+    if (currentKeys.has(key) || !markets.has(key)) {
+      return;
+    }
+    if (appendChangedPoint(markets, key, { value: 0 }, time)) {
+      changed = true;
+    }
+  });
+
+  if (!changed) {
+    return { updated: false };
+  }
+
+  await OddsHistory.findOneAndUpdate(
+    { eventId },
+    {
+      $set: {
+        originId: game.originId,
+        leagueName: game.leagueName,
+        teamHomeName: game.teamHomeName,
+        teamAwayName: game.teamAwayName,
+        startTime,
+        endTime: startTime + ODDS_HISTORY_END_OFFSET,
+        markets,
+      },
+    },
+    { upsert: true, new: true },
+  );
+
+  return { updated: true };
+}
+
+const getGameOddsHistory = async (eventId) => {
+  if (!eventId) {
+    throw new Error('eventId is required');
+  }
+  const history = await OddsHistory.findOne({ eventId: +eventId }).lean({ flattenMaps: true });
+  if (!history) {
+    return null;
+  }
+  return {
+    ...history,
+    markets: history.markets ? Object.fromEntries(Object.entries(history.markets)) : {},
+  };
+}
+
+const cleanupExpiredHistory = async () => {
+  const expireTime = Date.now() - ODDS_HISTORY_RETENTION;
+  return OddsHistory.deleteMany({ startTime: { $lt: expireTime } });
+}
+
+const startCleanup = (logger = console) => {
+  const cleanup = () => {
+    cleanupExpiredHistory()
+    .then(result => {
+      if (result.deletedCount) {
+        logger.out?.('odds history cleanup deleted %d records', result.deletedCount);
+      }
+    })
+    .catch(err => {
+      logger.out?.('odds history cleanup failed: %s', err.message);
+    });
+  };
+
+  cleanup();
+  return setInterval(cleanup, ODDS_HISTORY_CLEANUP_INTERVAL);
+}
+
+module.exports = {
+  TRACKED_IOR_KEYS,
+  cleanupExpiredHistory,
+  getGameOddsHistory,
+  isInsideTrackWindow,
+  recordGameOdds,
+  startCleanup,
+};

+ 15 - 1
server/routes/pstery.js

@@ -174,6 +174,20 @@ router.get('/get_solution', (req, res) => {
   });
 });
 
+/**
+ * 获取单场比赛赔率历史
+ */
+router.get('/get_odds_history', (req, res) => {
+  const { event_id } = req.query;
+  Games.getOddsHistory(event_id)
+  .then(history => {
+    res.sendSuccess(history ?? null);
+  })
+  .catch(err => {
+    res.badRequest(err.message);
+  });
+});
+
 /**
  * 通过比赛ID获取中单方案
  */
@@ -261,4 +275,4 @@ router.post('/notify_exception', (req, res) => {
   res.sendSuccess();
 });
 
-module.exports = router;
+module.exports = router;

+ 2 - 1
web/apps/web-antd/src/locales/langs/en-US/page.json

@@ -14,7 +14,8 @@
     "title": "Match Management",
     "related": "Related Matches",
     "centerOrder": "Center Order",
-    "dataTest": "Data Test"
+    "dataTest": "Data Test",
+    "oddsCurve": "Odds Curve"
   },
   "system": {
     "title": "System",

+ 2 - 1
web/apps/web-antd/src/locales/langs/zh-CN/page.json

@@ -14,7 +14,8 @@
     "title": "比赛管理",
     "related": "关联比赛",
     "centerOrder": "中单记录",
-    "dataTest": "数据测试"
+    "dataTest": "数据测试",
+    "oddsCurve": "赔率曲线"
   },
   "system": {
     "title": "系统设置",

+ 11 - 1
web/apps/web-antd/src/router/routes/modules/match.ts

@@ -23,6 +23,16 @@ const routes: RouteRecordRaw[] = [
           roles: ['admin'], // Only users with admin role can access this page
         },
       },
+      {
+        name: 'OddsCurve',
+        path: 'odds-curve',
+        component: () => import('#/views/match/odds-curve/index.vue'),
+        meta: {
+          icon: 'ion:trending-up-outline',
+          title: $t('page.match.oddsCurve'),
+          roles: ['admin'],
+        },
+      },
       {
         name: 'DataTest',
         path: 'data-test',
@@ -37,4 +47,4 @@ const routes: RouteRecordRaw[] = [
   },
 ];
 
-export default routes;
+export default routes;

+ 547 - 0
web/apps/web-antd/src/views/match/odds-curve/index.vue

@@ -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>

+ 4 - 0
web/packages/effects/plugins/src/echarts/echarts.ts

@@ -5,6 +5,7 @@ import type {
 } from 'echarts/charts';
 import type {
   DatasetComponentOption,
+  DataZoomComponentOption,
   GridComponentOption,
   // 组件类型的定义后缀都为 ComponentOption
   TitleComponentOption,
@@ -16,6 +17,7 @@ import { BarChart, LineChart, PieChart, RadarChart } from 'echarts/charts';
 import {
   // 数据集组件
   DatasetComponent,
+  DataZoomComponent,
   GridComponent,
   LegendComponent,
   TitleComponent,
@@ -32,6 +34,7 @@ import { CanvasRenderer } from 'echarts/renderers';
 export type ECOption = ComposeOption<
   | BarSeriesOption
   | DatasetComponentOption
+  | DataZoomComponentOption
   | GridComponentOption
   | LineSeriesOption
   | TitleComponentOption
@@ -46,6 +49,7 @@ echarts.use([
   TooltipComponent,
   GridComponent,
   DatasetComponent,
+  DataZoomComponent,
   TransformComponent,
   BarChart,
   LineChart,