index.vue 12 KB

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