index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. <script setup lang="ts">
  2. import type { EchartsUIType } from '@vben/plugins/echarts';
  3. import { computed, nextTick, onMounted, ref, watch } from 'vue';
  4. import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
  5. import {
  6. Button,
  7. CheckboxGroup,
  8. Empty,
  9. Input,
  10. message,
  11. Radio,
  12. RadioGroup,
  13. Spin,
  14. } from 'ant-design-vue';
  15. import dayjs from 'dayjs';
  16. import { requestClient } from '#/api/request';
  17. type MarketPoint = {
  18. origin?: string;
  19. source?: number;
  20. time: number;
  21. value: number;
  22. };
  23. type OddsHistory = {
  24. endTime: number;
  25. eventId: number;
  26. leagueName?: string;
  27. markets?: Record<string, MarketPoint[]>;
  28. startTime: number;
  29. teamAwayName?: string;
  30. teamHomeName?: string;
  31. };
  32. type GameRelation = {
  33. id: number;
  34. mk: number;
  35. rel?: {
  36. ps?: {
  37. eventId: number;
  38. leagueName: string;
  39. teamAwayName: string;
  40. teamHomeName: string;
  41. timestamp: number;
  42. };
  43. };
  44. timestamp: number;
  45. };
  46. const MARKET_GROUPS = [
  47. {
  48. keys: [
  49. 'ior_mn',
  50. 'ior_wmh_1',
  51. 'ior_wmh_2',
  52. 'ior_wmh_3',
  53. 'ior_wmc_1',
  54. 'ior_wmc_2',
  55. 'ior_wmc_3',
  56. ],
  57. label: '让平盘',
  58. },
  59. {
  60. keys: ['ior_ot_1', 'ior_ot_2', 'ior_ot_3', 'ior_ot_4', 'ior_ot_5'],
  61. label: '进球数',
  62. },
  63. ];
  64. const MARKET_LABELS: Record<string, string> = {
  65. ior_mn: '和局',
  66. ior_ot_1: '总进球 1',
  67. ior_ot_2: '总进球 2',
  68. ior_ot_3: '总进球 3',
  69. ior_ot_4: '总进球 4',
  70. ior_ot_5: '总进球 5',
  71. ior_wmc_1: '客胜 1 球',
  72. ior_wmc_2: '客胜 2 球',
  73. ior_wmc_3: '客胜 3 球',
  74. ior_wmh_1: '主胜 1 球',
  75. ior_wmh_2: '主胜 2 球',
  76. ior_wmh_3: '主胜 3 球',
  77. };
  78. const DEFAULT_MARKET_KEYS = [
  79. 'ior_mn',
  80. 'ior_wmh_1',
  81. 'ior_wmh_2',
  82. 'ior_wmh_3',
  83. 'ior_wmc_1',
  84. 'ior_wmc_2',
  85. 'ior_wmc_3',
  86. ];
  87. const chartRef = ref<EchartsUIType>();
  88. const { renderEcharts } = useEcharts(chartRef);
  89. const games = ref<GameRelation[]>([]);
  90. const history = ref<OddsHistory | null>(null);
  91. const selectedEventId = ref<number>();
  92. const selectedMarkets = ref<string[]>([]);
  93. const loadingGames = ref(false);
  94. const loadingHistory = ref(false);
  95. const marketType = ref(-1);
  96. const searchValue = ref('');
  97. const availableMarketKeys = computed(() => {
  98. const markets = history.value?.markets ?? {};
  99. return Object.keys(markets).filter((key) => markets[key]?.length);
  100. });
  101. const hasChartData = computed(() => !!history.value && availableMarketKeys.value.length > 0);
  102. const selectedGame = computed(() => {
  103. return games.value.find((item) => item.id === selectedEventId.value);
  104. });
  105. const filteredGames = computed(() => {
  106. const keyword = searchValue.value.trim();
  107. return games.value.filter((item) => {
  108. const ps = item.rel?.ps;
  109. if (!ps) {
  110. return false;
  111. }
  112. if (keyword) {
  113. const text = `${item.id} ${ps.leagueName} ${ps.teamHomeName} ${ps.teamAwayName}`;
  114. if (!text.includes(keyword)) {
  115. return false;
  116. }
  117. }
  118. return true;
  119. });
  120. });
  121. const historyTitle = computed(() => {
  122. const source = history.value ?? selectedGame.value?.rel?.ps;
  123. if (!source) {
  124. return '未选择比赛';
  125. }
  126. return `${source.teamHomeName ?? ''} vs ${source.teamAwayName ?? ''}`;
  127. });
  128. const marketOptions = computed(() => {
  129. const available = new Set(availableMarketKeys.value);
  130. return MARKET_GROUPS.map((group) => ({
  131. ...group,
  132. options: group.keys
  133. .filter((key) => available.has(key))
  134. .map((key) => ({
  135. label: MARKET_LABELS[key] ?? key,
  136. value: key,
  137. })),
  138. })).filter((group) => group.options.length);
  139. });
  140. const formatTime = (time?: number) => {
  141. return time ? dayjs(time).format('MM-DD HH:mm:ss') : '-';
  142. };
  143. const formatGameTime = (time?: number) => {
  144. return time ? dayjs(time).format('MM-DD HH:mm') : '-';
  145. };
  146. const getMarketColor = (index: number) => {
  147. const colors = [
  148. '#1677ff',
  149. '#13a8a8',
  150. '#fa8c16',
  151. '#722ed1',
  152. '#eb2f96',
  153. '#52c41a',
  154. '#faad14',
  155. '#2f54eb',
  156. '#a0d911',
  157. '#f5222d',
  158. '#08979c',
  159. '#531dab',
  160. ];
  161. return colors[index % colors.length] ?? colors[0];
  162. };
  163. const renderChart = () => {
  164. const markets = history.value?.markets ?? {};
  165. const keys = selectedMarkets.value.filter((key) => markets[key]?.length);
  166. if (!keys.length) {
  167. renderEcharts({
  168. grid: { bottom: 40, containLabel: true, left: 32, right: 24, top: 32 },
  169. series: [],
  170. xAxis: { type: 'time' },
  171. yAxis: { type: 'value' },
  172. });
  173. return;
  174. }
  175. renderEcharts({
  176. dataZoom: [
  177. {
  178. bottom: 8,
  179. height: 22,
  180. type: 'slider',
  181. },
  182. {
  183. type: 'inside',
  184. },
  185. ],
  186. grid: {
  187. bottom: 66,
  188. containLabel: true,
  189. left: 24,
  190. right: 24,
  191. top: 36,
  192. },
  193. legend: {
  194. bottom: 34,
  195. type: 'scroll',
  196. },
  197. series: keys.map((key, index) => ({
  198. connectNulls: false,
  199. data: (markets[key] ?? []).map((point) => [
  200. point.time,
  201. point.value === 0 ? null : point.value,
  202. ]),
  203. itemStyle: {
  204. color: getMarketColor(index),
  205. },
  206. name: MARKET_LABELS[key] ?? key,
  207. showSymbol: false,
  208. smooth: false,
  209. type: 'line',
  210. })),
  211. tooltip: {
  212. trigger: 'axis',
  213. valueFormatter: (value) => {
  214. return typeof value === 'number' ? value.toFixed(3) : `${value ?? ''}`;
  215. },
  216. },
  217. xAxis: {
  218. axisLabel: {
  219. formatter: (value: number) => dayjs(value).format('HH:mm'),
  220. },
  221. max: history.value?.endTime,
  222. min: history.value ? history.value.startTime - 2 * 60 * 60 * 1000 : undefined,
  223. type: 'time',
  224. },
  225. yAxis: {
  226. min: 0,
  227. scale: true,
  228. splitLine: {
  229. lineStyle: {
  230. type: 'dashed',
  231. },
  232. },
  233. type: 'value',
  234. },
  235. });
  236. };
  237. const fetchGames = async () => {
  238. loadingGames.value = true;
  239. try {
  240. const data = await requestClient.get<GameRelation[]>('/pstery/get_games_relation', {
  241. params: { hb: false, hh: true, mk: marketType.value },
  242. });
  243. games.value = data ?? [];
  244. if (!selectedEventId.value && games.value.length) {
  245. selectedEventId.value = games.value[0]?.id;
  246. }
  247. if (selectedEventId.value) {
  248. await fetchHistory(selectedEventId.value);
  249. }
  250. }
  251. catch (error) {
  252. console.error('Failed to fetch games relation:', error);
  253. message.error('获取比赛列表失败');
  254. }
  255. finally {
  256. loadingGames.value = false;
  257. }
  258. };
  259. const fetchHistory = async (eventId: number) => {
  260. loadingHistory.value = true;
  261. try {
  262. const data = await requestClient.get<OddsHistory | null>('/pstery/get_odds_history', {
  263. params: { event_id: eventId },
  264. });
  265. history.value = data;
  266. const keys = Object.keys(data?.markets ?? {}).filter((key) => data?.markets?.[key]?.length);
  267. selectedMarkets.value = DEFAULT_MARKET_KEYS.filter((key) => keys.includes(key));
  268. await nextTick();
  269. setTimeout(renderChart);
  270. }
  271. catch (error) {
  272. console.error('Failed to fetch odds history:', error);
  273. message.error('获取赔率曲线失败');
  274. }
  275. finally {
  276. loadingHistory.value = false;
  277. }
  278. };
  279. const selectGame = (id: number) => {
  280. if (selectedEventId.value === id) {
  281. return;
  282. }
  283. selectedEventId.value = id;
  284. fetchHistory(id);
  285. };
  286. watch(marketType, () => {
  287. selectedEventId.value = undefined;
  288. history.value = null;
  289. selectedMarkets.value = [];
  290. fetchGames();
  291. });
  292. watch(selectedMarkets, () => {
  293. renderChart();
  294. });
  295. onMounted(() => {
  296. fetchGames();
  297. });
  298. </script>
  299. <template>
  300. <div class="odds-curve-page">
  301. <aside class="match-panel">
  302. <div class="panel-toolbar">
  303. <RadioGroup v-model:value="marketType" size="small">
  304. <Radio :value="-1">全部</Radio>
  305. <Radio :value="2">滚球</Radio>
  306. <Radio :value="1">今日</Radio>
  307. <Radio :value="0">早盘</Radio>
  308. </RadioGroup>
  309. <Button size="small" :loading="loadingGames" @click="fetchGames">刷新</Button>
  310. </div>
  311. <Input
  312. v-model:value="searchValue"
  313. allow-clear
  314. class="search-input"
  315. placeholder="搜索联赛/球队/赛事ID"
  316. />
  317. <Spin :spinning="loadingGames">
  318. <div class="match-list" v-if="filteredGames.length">
  319. <button
  320. v-for="game in filteredGames"
  321. :key="game.id"
  322. class="match-item"
  323. :class="{ active: game.id === selectedEventId }"
  324. type="button"
  325. @click="selectGame(game.id)"
  326. >
  327. <span class="league">{{ game.rel?.ps?.leagueName }}</span>
  328. <span class="teams">{{ game.rel?.ps?.teamHomeName }} vs {{ game.rel?.ps?.teamAwayName }}</span>
  329. <span class="meta">ID {{ game.id }} · {{ formatGameTime(game.timestamp) }}</span>
  330. </button>
  331. </div>
  332. <Empty v-else class="list-empty" description="暂无比赛" />
  333. </Spin>
  334. </aside>
  335. <main class="curve-panel">
  336. <div class="curve-header">
  337. <div>
  338. <div class="curve-title">{{ historyTitle }}</div>
  339. <div class="curve-subtitle">
  340. <span>赛事ID:{{ selectedEventId ?? '-' }}</span>
  341. <span>开赛:{{ formatTime(history?.startTime ?? selectedGame?.timestamp) }}</span>
  342. <span>记录盘口:{{ availableMarketKeys.length }}</span>
  343. </div>
  344. </div>
  345. </div>
  346. <div class="market-selector" v-if="marketOptions.length">
  347. <div v-for="group in marketOptions" :key="group.label" class="market-group">
  348. <span class="market-group-label">{{ group.label }}</span>
  349. <CheckboxGroup v-model:value="selectedMarkets" :options="group.options" />
  350. </div>
  351. </div>
  352. <Spin :spinning="loadingHistory">
  353. <div class="chart-wrap" :class="{ hidden: !hasChartData }">
  354. <EchartsUI ref="chartRef" />
  355. </div>
  356. <Empty v-if="!hasChartData" class="history-empty" description="暂无赔率历史" />
  357. </Spin>
  358. </main>
  359. </div>
  360. </template>
  361. <style scoped>
  362. .odds-curve-page {
  363. display: grid;
  364. min-height: calc(100vh - 112px);
  365. grid-template-columns: 360px minmax(0, 1fr);
  366. gap: 12px;
  367. padding: 12px;
  368. }
  369. .match-panel,
  370. .curve-panel {
  371. min-height: 0;
  372. border: 1px solid hsl(var(--border));
  373. background: hsl(var(--background));
  374. }
  375. .match-panel {
  376. display: flex;
  377. flex-direction: column;
  378. }
  379. .panel-toolbar {
  380. display: flex;
  381. align-items: center;
  382. justify-content: space-between;
  383. gap: 8px;
  384. padding: 10px;
  385. border-bottom: 1px solid hsl(var(--border));
  386. }
  387. .search-input {
  388. margin: 10px;
  389. width: calc(100% - 20px);
  390. }
  391. .match-list {
  392. height: calc(100vh - 230px);
  393. overflow: auto;
  394. padding: 0 10px 10px;
  395. }
  396. .match-item {
  397. display: grid;
  398. width: 100%;
  399. gap: 4px;
  400. padding: 10px;
  401. border: 0;
  402. border-bottom: 1px solid hsl(var(--border));
  403. background: transparent;
  404. color: hsl(var(--foreground));
  405. cursor: pointer;
  406. text-align: left;
  407. }
  408. .match-item:hover,
  409. .match-item.active {
  410. background: hsl(var(--accent));
  411. }
  412. .league,
  413. .meta {
  414. color: hsl(var(--foreground) / 0.58);
  415. font-size: 12px;
  416. }
  417. .teams {
  418. font-size: 14px;
  419. font-weight: 500;
  420. }
  421. .curve-panel {
  422. display: flex;
  423. min-width: 0;
  424. flex-direction: column;
  425. }
  426. .curve-header {
  427. display: flex;
  428. align-items: center;
  429. justify-content: space-between;
  430. gap: 12px;
  431. padding: 14px 16px;
  432. border-bottom: 1px solid hsl(var(--border));
  433. }
  434. .curve-title {
  435. font-size: 16px;
  436. font-weight: 600;
  437. }
  438. .curve-subtitle {
  439. display: flex;
  440. flex-wrap: wrap;
  441. gap: 12px;
  442. margin-top: 6px;
  443. color: hsl(var(--foreground) / 0.58);
  444. font-size: 12px;
  445. }
  446. .market-selector {
  447. display: grid;
  448. gap: 8px;
  449. padding: 12px 16px;
  450. border-bottom: 1px solid hsl(var(--border));
  451. }
  452. .market-group {
  453. display: flex;
  454. align-items: flex-start;
  455. gap: 12px;
  456. }
  457. .market-group-label {
  458. width: 64px;
  459. flex: none;
  460. color: hsl(var(--foreground) / 0.66);
  461. font-size: 13px;
  462. line-height: 24px;
  463. }
  464. .chart-wrap {
  465. height: calc(100vh - 292px);
  466. min-height: 420px;
  467. padding: 12px;
  468. }
  469. .chart-wrap.hidden {
  470. height: 0;
  471. min-height: 0;
  472. overflow: hidden;
  473. padding: 0;
  474. }
  475. .history-empty,
  476. .list-empty {
  477. padding-top: 80px;
  478. }
  479. @media (max-width: 900px) {
  480. .odds-curve-page {
  481. grid-template-columns: 1fr;
  482. }
  483. .match-list {
  484. height: 320px;
  485. }
  486. .chart-wrap {
  487. height: 420px;
  488. }
  489. }
  490. </style>