index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692
  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. Pagination,
  12. Radio,
  13. RadioGroup,
  14. Switch,
  15. Spin,
  16. } from 'ant-design-vue';
  17. import dayjs from 'dayjs';
  18. import { requestClient } from '#/api/request';
  19. type MarketPoint = {
  20. origin?: string;
  21. source?: number;
  22. time: number;
  23. value: number;
  24. };
  25. type OddsHistory = {
  26. endTime: number;
  27. eventId: number;
  28. leagueName?: string;
  29. markets?: Record<string, MarketPoint[]>;
  30. startTime: number;
  31. teamAwayName?: string;
  32. teamHomeName?: string;
  33. };
  34. type HistoryGame = {
  35. endTime: number;
  36. eventId: number;
  37. leagueName?: string;
  38. marketCount: number;
  39. startTime: number;
  40. teamAwayName?: string;
  41. teamHomeName?: string;
  42. updatedAt?: string;
  43. };
  44. type HistoryGamesResponse = {
  45. list: HistoryGame[];
  46. page: number;
  47. pageSize: number;
  48. total: number;
  49. };
  50. const MARKET_KEYS = ['ior_ot_1', 'ior_ot_2', 'ior_ot_3', 'ior_ot_4'];
  51. const PLATFORMS = [
  52. { label: 'PC', value: 'pc' },
  53. { label: 'OB', value: 'ob' },
  54. ];
  55. const MARKET_LABELS: Record<string, string> = {
  56. ior_ot_1: '总进球 1',
  57. ior_ot_2: '总进球 2',
  58. ior_ot_3: '总进球 3',
  59. ior_ot_4: '总进球 4',
  60. };
  61. const getMarketKey = (platform: string, key: string) => `${platform}:${key}`;
  62. const parseMarketKey = (key: string) => {
  63. if (MARKET_KEYS.includes(key)) {
  64. return { ior: key, platform: 'pc' };
  65. }
  66. const [platform, ior] = key.split(/[:.]/);
  67. if (PLATFORMS.some((item) => item.value === platform) && MARKET_KEYS.includes(ior)) {
  68. return { ior, platform };
  69. }
  70. return null;
  71. };
  72. const normalizeHistoryMarkets = (markets?: Record<string, MarketPoint[]>) => {
  73. const normalized: Record<string, MarketPoint[]> = {};
  74. Object.entries(markets ?? {}).forEach(([key, points]) => {
  75. const parsed = parseMarketKey(key);
  76. if (!parsed || !points?.length) {
  77. return;
  78. }
  79. normalized[getMarketKey(parsed.platform, parsed.ior)] = points;
  80. });
  81. return normalized;
  82. };
  83. const chartRef = ref<EchartsUIType>();
  84. const { renderEcharts } = useEcharts(chartRef);
  85. const games = ref<HistoryGame[]>([]);
  86. const history = ref<OddsHistory | null>(null);
  87. const selectedEventId = ref<number>();
  88. const selectedMarkets = ref<string[]>([]);
  89. const loadingGames = ref(false);
  90. const loadingHistory = ref(false);
  91. const isMultiSelect = ref(false);
  92. const marketType = ref(-1);
  93. const pageSize = 50;
  94. const currentPage = ref(1);
  95. const searchValue = ref('');
  96. const searchTimer = ref<ReturnType<typeof setTimeout>>();
  97. const totalGames = ref(0);
  98. const availableMarketKeys = computed(() => {
  99. const markets = normalizeHistoryMarkets(history.value?.markets);
  100. return PLATFORMS.flatMap(({ value: platform }) => (
  101. MARKET_KEYS.map((key) => getMarketKey(platform, key))
  102. )).filter((key) => markets[key]?.length);
  103. });
  104. const hasChartData = computed(() => !!history.value && availableMarketKeys.value.length > 0);
  105. const selectedGame = computed(() => {
  106. return games.value.find((item) => item.eventId === selectedEventId.value);
  107. });
  108. const historyTitle = computed(() => {
  109. const source = history.value ?? selectedGame.value;
  110. if (!source) {
  111. return '未选择比赛';
  112. }
  113. return `${source.teamHomeName ?? ''} vs ${source.teamAwayName ?? ''}`;
  114. });
  115. const isHalfEvent = (eventId?: number) => {
  116. return typeof eventId === 'number' && eventId < 0;
  117. };
  118. const marketOptionGroups = computed(() => {
  119. const available = new Set(availableMarketKeys.value);
  120. return PLATFORMS.map(({ label, value: platform }) => ({
  121. label,
  122. options: MARKET_KEYS.map((key) => {
  123. const value = getMarketKey(platform, key);
  124. return {
  125. label: MARKET_LABELS[key] ?? key,
  126. value,
  127. };
  128. }).filter((option) => available.has(option.value)),
  129. })).filter((group) => group.options.length);
  130. });
  131. const getGroupSelectedMarkets = (options: Array<{ value: string }>) => {
  132. const values = new Set(options.map((option) => option.value));
  133. return selectedMarkets.value.filter((key) => values.has(key));
  134. };
  135. const updateGroupSelectedMarkets = (options: Array<{ value: string }>, values: string[]) => {
  136. const groupValues = new Set(options.map((option) => option.value));
  137. selectedMarkets.value = [
  138. ...selectedMarkets.value.filter((key) => !groupValues.has(key)),
  139. ...values,
  140. ];
  141. };
  142. const selectedMarket = computed({
  143. get: () => selectedMarkets.value[0],
  144. set: (key?: string) => {
  145. selectedMarkets.value = key ? [key] : [];
  146. },
  147. });
  148. const formatTime = (time?: number) => {
  149. return time ? dayjs(time).format('MM-DD HH:mm:ss') : '-';
  150. };
  151. const formatGameTime = (time?: number) => {
  152. return time ? dayjs(time).format('MM-DD HH:mm') : '-';
  153. };
  154. const getMarketColor = (index: number) => {
  155. const colors = [
  156. '#1677ff',
  157. '#13a8a8',
  158. '#fa8c16',
  159. '#722ed1',
  160. '#eb2f96',
  161. '#52c41a',
  162. '#faad14',
  163. '#2f54eb',
  164. '#a0d911',
  165. '#f5222d',
  166. '#08979c',
  167. '#531dab',
  168. ];
  169. return colors[index % colors.length] ?? colors[0];
  170. };
  171. const getMarketLabel = (key: string) => {
  172. const [platform, ior] = key.includes(':') || key.includes('.') ? key.split(/[:.]/) : ['pc', key];
  173. const platformLabel = PLATFORMS.find((item) => item.value === platform)?.label ?? platform.toUpperCase();
  174. return `${platformLabel} ${MARKET_LABELS[ior] ?? ior}`;
  175. };
  176. const formatSeriesData = (points: MarketPoint[] = []) => {
  177. const data: Array<[number, null | number]> = [];
  178. let lastValue: number | null = null;
  179. points.forEach((point) => {
  180. if (point.value === 0) {
  181. if (lastValue !== null) {
  182. data.push([point.time, lastValue]);
  183. }
  184. data.push([point.time, null]);
  185. lastValue = null;
  186. return;
  187. }
  188. data.push([point.time, point.value]);
  189. lastValue = point.value;
  190. });
  191. return data;
  192. };
  193. const renderChart = () => {
  194. const markets = normalizeHistoryMarkets(history.value?.markets);
  195. const keys = selectedMarkets.value.filter((key) => markets[key]?.length);
  196. if (!keys.length) {
  197. renderEcharts({
  198. grid: { bottom: 40, containLabel: true, left: 32, right: 24, top: 32 },
  199. series: [],
  200. xAxis: { type: 'time' },
  201. yAxis: { type: 'value' },
  202. });
  203. return;
  204. }
  205. renderEcharts({
  206. dataZoom: [
  207. {
  208. bottom: 8,
  209. height: 22,
  210. type: 'slider',
  211. },
  212. {
  213. type: 'inside',
  214. },
  215. ],
  216. grid: {
  217. bottom: 66,
  218. containLabel: true,
  219. left: 24,
  220. right: 24,
  221. top: 36,
  222. },
  223. legend: {
  224. bottom: 34,
  225. type: 'scroll',
  226. },
  227. series: keys.map((key, index) => ({
  228. connectNulls: false,
  229. data: formatSeriesData(markets[key]),
  230. itemStyle: {
  231. color: getMarketColor(index),
  232. },
  233. lineStyle: {
  234. width: 1,
  235. },
  236. name: getMarketLabel(key),
  237. showSymbol: true,
  238. smooth: false,
  239. symbolSize: 2,
  240. type: 'line',
  241. })),
  242. tooltip: {
  243. trigger: 'axis',
  244. valueFormatter: (value) => {
  245. return typeof value === 'number' ? value.toFixed(3) : `${value ?? ''}`;
  246. },
  247. },
  248. xAxis: {
  249. axisLabel: {
  250. formatter: (value: number) => dayjs(value).format('HH:mm'),
  251. },
  252. max: history.value?.endTime,
  253. min: history.value ? history.value.startTime - 2 * 60 * 60 * 1000 : undefined,
  254. type: 'time',
  255. },
  256. yAxis: {
  257. min: 0,
  258. scale: true,
  259. splitLine: {
  260. lineStyle: {
  261. type: 'dashed',
  262. },
  263. },
  264. type: 'value',
  265. },
  266. });
  267. };
  268. const fetchGames = async () => {
  269. loadingGames.value = true;
  270. try {
  271. const data = await requestClient.get<HistoryGamesResponse>('/pstery/get_odds_history_games', {
  272. params: {
  273. keyword: searchValue.value.trim() || undefined,
  274. page: currentPage.value,
  275. page_size: pageSize,
  276. status: marketType.value,
  277. },
  278. });
  279. games.value = data?.list ?? [];
  280. totalGames.value = data?.total ?? 0;
  281. if (!selectedEventId.value && games.value.length) {
  282. selectedEventId.value = games.value[0]?.eventId;
  283. }
  284. if (selectedEventId.value) {
  285. await fetchHistory(selectedEventId.value);
  286. }
  287. }
  288. catch (error) {
  289. console.error('Failed to fetch games relation:', error);
  290. message.error('获取比赛列表失败');
  291. }
  292. finally {
  293. loadingGames.value = false;
  294. }
  295. };
  296. const fetchHistory = async (eventId: number) => {
  297. loadingHistory.value = true;
  298. try {
  299. const data = await requestClient.get<OddsHistory | null>('/pstery/get_odds_history', {
  300. params: { event_id: eventId },
  301. });
  302. history.value = data;
  303. const markets = normalizeHistoryMarkets(data?.markets);
  304. const firstKey = PLATFORMS.flatMap(({ value: platform }) => (
  305. MARKET_KEYS.map((key) => getMarketKey(platform, key))
  306. )).find((key) => markets[key]?.length);
  307. selectedMarkets.value = firstKey ? [firstKey] : [];
  308. await nextTick();
  309. setTimeout(renderChart);
  310. }
  311. catch (error) {
  312. console.error('Failed to fetch odds history:', error);
  313. message.error('获取赔率曲线失败');
  314. }
  315. finally {
  316. loadingHistory.value = false;
  317. }
  318. };
  319. const selectGame = (id: number) => {
  320. if (selectedEventId.value === id) {
  321. return;
  322. }
  323. selectedEventId.value = id;
  324. fetchHistory(id);
  325. };
  326. watch(marketType, () => {
  327. selectedEventId.value = undefined;
  328. history.value = null;
  329. selectedMarkets.value = [];
  330. currentPage.value = 1;
  331. fetchGames();
  332. });
  333. watch(searchValue, () => {
  334. currentPage.value = 1;
  335. clearTimeout(searchTimer.value);
  336. searchTimer.value = setTimeout(fetchGames, 300);
  337. });
  338. watch(currentPage, () => {
  339. selectedEventId.value = undefined;
  340. history.value = null;
  341. selectedMarkets.value = [];
  342. fetchGames();
  343. });
  344. watch(selectedMarkets, () => {
  345. renderChart();
  346. }, { deep: true });
  347. watch(isMultiSelect, (multiSelect) => {
  348. if (!multiSelect && selectedMarkets.value.length > 1) {
  349. selectedMarkets.value = [selectedMarkets.value[0]!];
  350. }
  351. renderChart();
  352. });
  353. onMounted(() => {
  354. fetchGames();
  355. });
  356. </script>
  357. <template>
  358. <div class="odds-curve-page">
  359. <aside class="match-panel">
  360. <div class="panel-toolbar">
  361. <RadioGroup v-model:value="marketType" size="small">
  362. <Radio :value="-1">全部</Radio>
  363. <Radio :value="1">进行中</Radio>
  364. <Radio :value="2">已结束</Radio>
  365. </RadioGroup>
  366. <Button size="small" :loading="loadingGames" @click="fetchGames">刷新</Button>
  367. </div>
  368. <Input
  369. v-model:value="searchValue"
  370. allow-clear
  371. class="search-input"
  372. placeholder="搜索联赛/球队/赛事ID"
  373. />
  374. <Spin :spinning="loadingGames">
  375. <div class="match-list" v-if="games.length">
  376. <button
  377. v-for="game in games"
  378. :key="game.eventId"
  379. class="match-item"
  380. :class="{ active: game.eventId === selectedEventId }"
  381. type="button"
  382. @click="selectGame(game.eventId)"
  383. >
  384. <span class="league">{{ game.leagueName }}</span>
  385. <span class="teams">
  386. {{ game.teamHomeName }} vs {{ game.teamAwayName }}
  387. <span v-if="isHalfEvent(game.eventId)" class="period-tag">上半场</span>
  388. </span>
  389. <span class="meta">ID {{ game.eventId }} · {{ formatGameTime(game.startTime) }} · {{ game.marketCount }}盘</span>
  390. </button>
  391. </div>
  392. <Empty v-else class="list-empty" description="暂无比赛" />
  393. </Spin>
  394. <Pagination
  395. v-if="totalGames > pageSize"
  396. v-model:current="currentPage"
  397. class="match-pagination"
  398. :page-size="pageSize"
  399. :show-size-changer="false"
  400. size="small"
  401. :total="totalGames"
  402. />
  403. </aside>
  404. <main class="curve-panel">
  405. <div class="curve-header">
  406. <div>
  407. <div class="curve-title">{{ historyTitle }}</div>
  408. <div class="curve-subtitle">
  409. <span>赛事ID:{{ selectedEventId ?? '-' }}</span>
  410. <span v-if="isHalfEvent(selectedEventId)">比赛周期:上半场</span>
  411. <span>开赛:{{ formatTime(history?.startTime ?? selectedGame?.startTime) }}</span>
  412. <span>记录盘口:{{ availableMarketKeys.length }}</span>
  413. </div>
  414. </div>
  415. </div>
  416. <div class="market-selector" v-if="marketOptionGroups.length">
  417. <div class="market-mode">
  418. <span>盘口选择</span>
  419. <Switch
  420. v-model:checked="isMultiSelect"
  421. checked-children="多选"
  422. un-checked-children="单选"
  423. />
  424. </div>
  425. <div class="market-options">
  426. <div
  427. v-for="group in marketOptionGroups"
  428. :key="group.label"
  429. class="market-row"
  430. >
  431. <span class="market-platform">{{ group.label }}</span>
  432. <CheckboxGroup
  433. v-if="isMultiSelect"
  434. :options="group.options"
  435. :value="getGroupSelectedMarkets(group.options)"
  436. @change="(values) => updateGroupSelectedMarkets(group.options, values as string[])"
  437. />
  438. <RadioGroup v-else v-model:value="selectedMarket" :options="group.options" />
  439. </div>
  440. </div>
  441. </div>
  442. <Spin :spinning="loadingHistory">
  443. <div class="chart-wrap" :class="{ hidden: !hasChartData }">
  444. <EchartsUI ref="chartRef" />
  445. </div>
  446. <Empty v-if="!hasChartData" class="history-empty" description="暂无赔率历史" />
  447. </Spin>
  448. </main>
  449. </div>
  450. </template>
  451. <style scoped>
  452. .odds-curve-page {
  453. display: grid;
  454. min-height: calc(100vh - 112px);
  455. grid-template-columns: 360px minmax(0, 1fr);
  456. gap: 12px;
  457. padding: 12px;
  458. }
  459. .match-panel,
  460. .curve-panel {
  461. min-height: 0;
  462. border: 1px solid hsl(var(--border));
  463. background: hsl(var(--background));
  464. }
  465. .match-panel {
  466. display: flex;
  467. flex-direction: column;
  468. }
  469. .panel-toolbar {
  470. display: flex;
  471. align-items: center;
  472. justify-content: space-between;
  473. gap: 8px;
  474. padding: 10px;
  475. border-bottom: 1px solid hsl(var(--border));
  476. }
  477. .search-input {
  478. margin: 10px;
  479. width: calc(100% - 20px);
  480. }
  481. .match-list {
  482. height: calc(100vh - 274px);
  483. overflow: auto;
  484. padding: 0 10px 10px;
  485. }
  486. .match-pagination {
  487. display: flex;
  488. justify-content: center;
  489. padding: 10px;
  490. border-top: 1px solid hsl(var(--border));
  491. }
  492. .match-item {
  493. display: grid;
  494. width: 100%;
  495. gap: 4px;
  496. padding: 10px;
  497. border: 0;
  498. border-bottom: 1px solid hsl(var(--border));
  499. background: transparent;
  500. color: hsl(var(--foreground));
  501. cursor: pointer;
  502. text-align: left;
  503. }
  504. .match-item:hover,
  505. .match-item.active {
  506. background: hsl(var(--accent));
  507. }
  508. .league,
  509. .meta {
  510. color: hsl(var(--foreground) / 0.58);
  511. font-size: 12px;
  512. }
  513. .teams {
  514. font-size: 14px;
  515. font-weight: 500;
  516. }
  517. .period-tag {
  518. display: inline-flex;
  519. align-items: center;
  520. margin-left: 6px;
  521. padding: 0 5px;
  522. border: 1px solid hsl(var(--primary) / 0.3);
  523. color: hsl(var(--primary));
  524. font-size: 12px;
  525. font-weight: 400;
  526. line-height: 18px;
  527. }
  528. .curve-panel {
  529. display: flex;
  530. min-width: 0;
  531. flex-direction: column;
  532. }
  533. .curve-header {
  534. display: flex;
  535. align-items: center;
  536. justify-content: space-between;
  537. gap: 12px;
  538. padding: 14px 16px;
  539. border-bottom: 1px solid hsl(var(--border));
  540. }
  541. .curve-title {
  542. font-size: 16px;
  543. font-weight: 600;
  544. }
  545. .curve-subtitle {
  546. display: flex;
  547. flex-wrap: wrap;
  548. gap: 12px;
  549. margin-top: 6px;
  550. color: hsl(var(--foreground) / 0.58);
  551. font-size: 12px;
  552. }
  553. .market-selector {
  554. display: flex;
  555. align-items: flex-start;
  556. gap: 16px;
  557. padding: 12px 16px;
  558. border-bottom: 1px solid hsl(var(--border));
  559. }
  560. .market-mode {
  561. display: flex;
  562. align-items: center;
  563. gap: 8px;
  564. color: hsl(var(--foreground) / 0.66);
  565. font-size: 13px;
  566. }
  567. .market-options {
  568. display: grid;
  569. flex: 1;
  570. gap: 8px;
  571. min-width: 0;
  572. }
  573. .market-row {
  574. display: flex;
  575. align-items: center;
  576. gap: 10px;
  577. min-height: 24px;
  578. }
  579. .market-platform {
  580. width: 28px;
  581. flex: none;
  582. color: hsl(var(--foreground) / 0.58);
  583. font-size: 12px;
  584. font-weight: 600;
  585. }
  586. .chart-wrap {
  587. height: calc(100vh - 292px);
  588. min-height: 420px;
  589. padding: 12px;
  590. }
  591. .chart-wrap.hidden {
  592. height: 0;
  593. min-height: 0;
  594. overflow: hidden;
  595. padding: 0;
  596. }
  597. .history-empty,
  598. .list-empty {
  599. padding-top: 80px;
  600. }
  601. @media (max-width: 900px) {
  602. .odds-curve-page {
  603. grid-template-columns: 1fr;
  604. }
  605. .match-list {
  606. height: 320px;
  607. }
  608. .chart-wrap {
  609. height: 420px;
  610. }
  611. .market-selector {
  612. align-items: flex-start;
  613. flex-direction: column;
  614. }
  615. }
  616. </style>