index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  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. availableKeys: group.keys.filter((key) => available.has(key)),
  133. options: group.keys
  134. .filter((key) => available.has(key))
  135. .map((key) => ({
  136. label: MARKET_LABELS[key] ?? key,
  137. value: key,
  138. })),
  139. })).filter((group) => group.options.length);
  140. });
  141. const selectMarketGroup = (keys: string[]) => {
  142. selectedMarkets.value = [...keys];
  143. };
  144. const formatTime = (time?: number) => {
  145. return time ? dayjs(time).format('MM-DD HH:mm:ss') : '-';
  146. };
  147. const formatGameTime = (time?: number) => {
  148. return time ? dayjs(time).format('MM-DD HH:mm') : '-';
  149. };
  150. const getMarketColor = (index: number) => {
  151. const colors = [
  152. '#1677ff',
  153. '#13a8a8',
  154. '#fa8c16',
  155. '#722ed1',
  156. '#eb2f96',
  157. '#52c41a',
  158. '#faad14',
  159. '#2f54eb',
  160. '#a0d911',
  161. '#f5222d',
  162. '#08979c',
  163. '#531dab',
  164. ];
  165. return colors[index % colors.length] ?? colors[0];
  166. };
  167. const renderChart = () => {
  168. const markets = history.value?.markets ?? {};
  169. const keys = selectedMarkets.value.filter((key) => markets[key]?.length);
  170. if (!keys.length) {
  171. renderEcharts({
  172. grid: { bottom: 40, containLabel: true, left: 32, right: 24, top: 32 },
  173. series: [],
  174. xAxis: { type: 'time' },
  175. yAxis: { type: 'value' },
  176. });
  177. return;
  178. }
  179. renderEcharts({
  180. dataZoom: [
  181. {
  182. bottom: 8,
  183. height: 22,
  184. type: 'slider',
  185. },
  186. {
  187. type: 'inside',
  188. },
  189. ],
  190. grid: {
  191. bottom: 66,
  192. containLabel: true,
  193. left: 24,
  194. right: 24,
  195. top: 36,
  196. },
  197. legend: {
  198. bottom: 34,
  199. type: 'scroll',
  200. },
  201. series: keys.map((key, index) => ({
  202. connectNulls: false,
  203. data: (markets[key] ?? []).map((point) => [
  204. point.time,
  205. point.value === 0 ? null : point.value,
  206. ]),
  207. itemStyle: {
  208. color: getMarketColor(index),
  209. },
  210. lineStyle: {
  211. width: 1,
  212. },
  213. name: MARKET_LABELS[key] ?? key,
  214. showSymbol: true,
  215. smooth: false,
  216. symbolSize: 3,
  217. type: 'line',
  218. })),
  219. tooltip: {
  220. trigger: 'axis',
  221. valueFormatter: (value) => {
  222. return typeof value === 'number' ? value.toFixed(3) : `${value ?? ''}`;
  223. },
  224. },
  225. xAxis: {
  226. axisLabel: {
  227. formatter: (value: number) => dayjs(value).format('HH:mm'),
  228. },
  229. max: history.value?.endTime,
  230. min: history.value ? history.value.startTime - 2 * 60 * 60 * 1000 : undefined,
  231. type: 'time',
  232. },
  233. yAxis: {
  234. min: 0,
  235. scale: true,
  236. splitLine: {
  237. lineStyle: {
  238. type: 'dashed',
  239. },
  240. },
  241. type: 'value',
  242. },
  243. });
  244. };
  245. const fetchGames = async () => {
  246. loadingGames.value = true;
  247. try {
  248. const data = await requestClient.get<GameRelation[]>('/pstery/get_games_relation', {
  249. params: { hb: false, hh: true, mk: marketType.value },
  250. });
  251. games.value = data ?? [];
  252. if (!selectedEventId.value && games.value.length) {
  253. selectedEventId.value = games.value[0]?.id;
  254. }
  255. if (selectedEventId.value) {
  256. await fetchHistory(selectedEventId.value);
  257. }
  258. }
  259. catch (error) {
  260. console.error('Failed to fetch games relation:', error);
  261. message.error('获取比赛列表失败');
  262. }
  263. finally {
  264. loadingGames.value = false;
  265. }
  266. };
  267. const fetchHistory = async (eventId: number) => {
  268. loadingHistory.value = true;
  269. try {
  270. const data = await requestClient.get<OddsHistory | null>('/pstery/get_odds_history', {
  271. params: { event_id: eventId },
  272. });
  273. history.value = data;
  274. const keys = Object.keys(data?.markets ?? {}).filter((key) => data?.markets?.[key]?.length);
  275. selectedMarkets.value = DEFAULT_MARKET_KEYS.filter((key) => keys.includes(key));
  276. await nextTick();
  277. setTimeout(renderChart);
  278. }
  279. catch (error) {
  280. console.error('Failed to fetch odds history:', error);
  281. message.error('获取赔率曲线失败');
  282. }
  283. finally {
  284. loadingHistory.value = false;
  285. }
  286. };
  287. const selectGame = (id: number) => {
  288. if (selectedEventId.value === id) {
  289. return;
  290. }
  291. selectedEventId.value = id;
  292. fetchHistory(id);
  293. };
  294. watch(marketType, () => {
  295. selectedEventId.value = undefined;
  296. history.value = null;
  297. selectedMarkets.value = [];
  298. fetchGames();
  299. });
  300. watch(selectedMarkets, () => {
  301. renderChart();
  302. });
  303. onMounted(() => {
  304. fetchGames();
  305. });
  306. </script>
  307. <template>
  308. <div class="odds-curve-page">
  309. <aside class="match-panel">
  310. <div class="panel-toolbar">
  311. <RadioGroup v-model:value="marketType" size="small">
  312. <Radio :value="-1">全部</Radio>
  313. <Radio :value="2">滚球</Radio>
  314. <Radio :value="1">今日</Radio>
  315. <Radio :value="0">早盘</Radio>
  316. </RadioGroup>
  317. <Button size="small" :loading="loadingGames" @click="fetchGames">刷新</Button>
  318. </div>
  319. <Input
  320. v-model:value="searchValue"
  321. allow-clear
  322. class="search-input"
  323. placeholder="搜索联赛/球队/赛事ID"
  324. />
  325. <Spin :spinning="loadingGames">
  326. <div class="match-list" v-if="filteredGames.length">
  327. <button
  328. v-for="game in filteredGames"
  329. :key="game.id"
  330. class="match-item"
  331. :class="{ active: game.id === selectedEventId }"
  332. type="button"
  333. @click="selectGame(game.id)"
  334. >
  335. <span class="league">{{ game.rel?.ps?.leagueName }}</span>
  336. <span class="teams">{{ game.rel?.ps?.teamHomeName }} vs {{ game.rel?.ps?.teamAwayName }}</span>
  337. <span class="meta">ID {{ game.id }} · {{ formatGameTime(game.timestamp) }}</span>
  338. </button>
  339. </div>
  340. <Empty v-else class="list-empty" description="暂无比赛" />
  341. </Spin>
  342. </aside>
  343. <main class="curve-panel">
  344. <div class="curve-header">
  345. <div>
  346. <div class="curve-title">{{ historyTitle }}</div>
  347. <div class="curve-subtitle">
  348. <span>赛事ID:{{ selectedEventId ?? '-' }}</span>
  349. <span>开赛:{{ formatTime(history?.startTime ?? selectedGame?.timestamp) }}</span>
  350. <span>记录盘口:{{ availableMarketKeys.length }}</span>
  351. </div>
  352. </div>
  353. </div>
  354. <div class="market-selector" v-if="marketOptions.length">
  355. <div v-for="group in marketOptions" :key="group.label" class="market-group">
  356. <button
  357. class="market-group-label"
  358. type="button"
  359. @click="selectMarketGroup(group.availableKeys)"
  360. >
  361. {{ group.label }}
  362. </button>
  363. <CheckboxGroup v-model:value="selectedMarkets" :options="group.options" />
  364. </div>
  365. </div>
  366. <Spin :spinning="loadingHistory">
  367. <div class="chart-wrap" :class="{ hidden: !hasChartData }">
  368. <EchartsUI ref="chartRef" />
  369. </div>
  370. <Empty v-if="!hasChartData" class="history-empty" description="暂无赔率历史" />
  371. </Spin>
  372. </main>
  373. </div>
  374. </template>
  375. <style scoped>
  376. .odds-curve-page {
  377. display: grid;
  378. min-height: calc(100vh - 112px);
  379. grid-template-columns: 360px minmax(0, 1fr);
  380. gap: 12px;
  381. padding: 12px;
  382. }
  383. .match-panel,
  384. .curve-panel {
  385. min-height: 0;
  386. border: 1px solid hsl(var(--border));
  387. background: hsl(var(--background));
  388. }
  389. .match-panel {
  390. display: flex;
  391. flex-direction: column;
  392. }
  393. .panel-toolbar {
  394. display: flex;
  395. align-items: center;
  396. justify-content: space-between;
  397. gap: 8px;
  398. padding: 10px;
  399. border-bottom: 1px solid hsl(var(--border));
  400. }
  401. .search-input {
  402. margin: 10px;
  403. width: calc(100% - 20px);
  404. }
  405. .match-list {
  406. height: calc(100vh - 230px);
  407. overflow: auto;
  408. padding: 0 10px 10px;
  409. }
  410. .match-item {
  411. display: grid;
  412. width: 100%;
  413. gap: 4px;
  414. padding: 10px;
  415. border: 0;
  416. border-bottom: 1px solid hsl(var(--border));
  417. background: transparent;
  418. color: hsl(var(--foreground));
  419. cursor: pointer;
  420. text-align: left;
  421. }
  422. .match-item:hover,
  423. .match-item.active {
  424. background: hsl(var(--accent));
  425. }
  426. .league,
  427. .meta {
  428. color: hsl(var(--foreground) / 0.58);
  429. font-size: 12px;
  430. }
  431. .teams {
  432. font-size: 14px;
  433. font-weight: 500;
  434. }
  435. .curve-panel {
  436. display: flex;
  437. min-width: 0;
  438. flex-direction: column;
  439. }
  440. .curve-header {
  441. display: flex;
  442. align-items: center;
  443. justify-content: space-between;
  444. gap: 12px;
  445. padding: 14px 16px;
  446. border-bottom: 1px solid hsl(var(--border));
  447. }
  448. .curve-title {
  449. font-size: 16px;
  450. font-weight: 600;
  451. }
  452. .curve-subtitle {
  453. display: flex;
  454. flex-wrap: wrap;
  455. gap: 12px;
  456. margin-top: 6px;
  457. color: hsl(var(--foreground) / 0.58);
  458. font-size: 12px;
  459. }
  460. .market-selector {
  461. display: grid;
  462. gap: 8px;
  463. padding: 12px 16px;
  464. border-bottom: 1px solid hsl(var(--border));
  465. }
  466. .market-group {
  467. display: flex;
  468. align-items: flex-start;
  469. gap: 12px;
  470. }
  471. .market-group-label {
  472. width: 64px;
  473. flex: none;
  474. padding: 0;
  475. border: 0;
  476. background: transparent;
  477. color: hsl(var(--foreground) / 0.66);
  478. cursor: pointer;
  479. font-size: 13px;
  480. line-height: 24px;
  481. text-align: left;
  482. }
  483. .market-group-label:hover {
  484. color: hsl(var(--primary));
  485. }
  486. .chart-wrap {
  487. height: calc(100vh - 292px);
  488. min-height: 420px;
  489. padding: 12px;
  490. }
  491. .chart-wrap.hidden {
  492. height: 0;
  493. min-height: 0;
  494. overflow: hidden;
  495. padding: 0;
  496. }
  497. .history-empty,
  498. .list-empty {
  499. padding-top: 80px;
  500. }
  501. @media (max-width: 900px) {
  502. .odds-curve-page {
  503. grid-template-columns: 1fr;
  504. }
  505. .match-list {
  506. height: 320px;
  507. }
  508. .chart-wrap {
  509. height: 420px;
  510. }
  511. }
  512. </style>