flyzto hai 2 meses
pai
achega
5d6416d349

+ 52 - 8
server/models/GamesPs.js

@@ -25,8 +25,6 @@ const PS_IOR_KEYS = [
   ['-2', 'ior_rh_25', 'ior_wmh_2', 'ior_rac_15'],
   ['+1', 'ior_rah_05', 'ior_wmc_1', 'ior_rc_15'],
   ['+2', 'ior_rah_15', 'ior_wmc_2', 'ior_rc_25'],
-  // ['0-1', 'ior_ot_0', 'ior_os_0-1', 'ior_ot_1'],
-  // ['2-3', 'ior_ot_2', 'ior_os_2-3', 'ior_ot_3'],
   ['jqs', 'ior_ot_1', 'ior_ot_2', 'ior_ot_3', 'ior_ot_4', 'ior_ot_5', 'ior_ot_6', 'ior_ot_7'],
 ];
 
@@ -781,9 +779,9 @@ const updateSolutions = (solutions, eventsLogsMap) => {
 /**
  * 获取中单方案
  */
-const getSolutions = async ({ win_min, no_events, mk=-1 }) => {
+const getSolutions = async ({ win_min, with_events, mk=-1 }) => {
   // Logs.out('getSolutions', win_min);
-  const marketType = +mk;
+  const filterMarketType = +mk;
   const { minShowAmount } = getSetting();
   const solutionsList = Object.values(GAMES.Solutions);
   const gamesRelation = getGamesRelation();
@@ -815,20 +813,66 @@ const getSolutions = async ({ win_min, no_events, mk=-1 }) => {
   if (mk >= 0) {
     solutions = solutions.filter(item => {
       const { info: { mk } } = item;
-      return mk == marketType;
+      return mk == filterMarketType;
     });
   }
 
   solutions = solutions.sort((a, b) => b.sol.win_average - a.sol.win_average);
 
   const relIds = solutions.map(item => item.info.id);
-  let gamesEvents = null;
-  if (!no_events) {
+  let gamesEvents;
+  if (with_events) {
     gamesEvents = getGamesEvents({ relIds });
   }
   return { solutions, gamesEvents, mkCount };
 }
 
+/**
+ * 获取中单方案并按照比赛分组
+ */
+const getGamesSolutions = async ({ win_min, with_events, mk=-1 }) => {
+
+  const filterMarketType = +mk;
+  const { minShowAmount } = getSetting();
+  const solutionsList = Object.values(GAMES.Solutions);
+  const gamesRelation = getGamesRelation({ listEvents: with_events });
+  const relationsMap = new Map(gamesRelation.map(item => [item.id, item]));
+
+  const mkCount = {
+    all: 0,
+    rollball: 0,
+    today: 0,
+    early: 0,
+  }
+
+  const solutionsMap = {};
+
+  solutionsList.filter(item => {
+    const { sol: { win_average } } = item;
+    return win_average >= (win_min ?? minShowAmount);
+  }).forEach(item => {
+    const { info: { id }, ...solution } = item;
+    const gameRelation = relationsMap.get(id);
+    if (!solutionsMap[id]) {
+      solutionsMap[id] = { ...gameRelation, solutions: [] };
+    }
+    solutionsMap[id].solutions.push(solution);
+  });
+
+  const gamesSolutions = Object.values(solutionsMap)
+  .filter(item => {
+    const { mk, solutions } = item;
+    const marketType = getMarketType(mk);
+    mkCount.all ++;
+    mkCount[marketType] ++;
+    solutions.sort((a, b) => b.sol.win_average - a.sol.win_average);
+    return filterMarketType == -1 || filterMarketType == mk;
+  })
+  .sort((a, b) => b.solutions[0].sol.win_average - a.solutions[0].sol.win_average);
+
+  return { gamesSolutions, mkCount };
+}
+
 /**
  * 获取单个中单方案
  */
@@ -1104,7 +1148,7 @@ module.exports = {
   updateGamesList, updateGamesEvents,
   getGamesRelation,
   updateGamesResult,
-  getSolutions, getSolution, getSolutionsByIds,
+  getSolutions, getGamesSolutions, getSolution, getSolutionsByIds,
   getTotalProfitWithSid,
   getTotalProfitWithBetInfo,
   notifyException,

+ 18 - 2
server/routes/pstery.js

@@ -71,10 +71,10 @@ router.get('/get_games_relation', (req, res) => {
 
 // 获取中单方案
 router.get('/get_solutions', (req, res) => {
-  const { win_min, no_events, mk } = req.query;
+  const { win_min, with_events, mk } = req.query;
   Games.getSolutions({
     win_min: win_min ? +win_min : undefined,
-    no_events: no_events === 'true',
+    with_events: with_events === 'true',
     mk: mk ? +mk : -1
   })
   .then(({ solutions, gamesEvents, mkCount }) => {
@@ -85,6 +85,22 @@ router.get('/get_solutions', (req, res) => {
   });
 });
 
+// 获取中单方案并按照比赛分组
+router.get('/get_games_solutions', (req, res) => {
+  const { win_min, with_events, mk } = req.query;
+  Games.getGamesSolutions({
+    win_min: win_min ? +win_min : undefined,
+    with_events: with_events === 'true',
+    mk: mk ? +mk : -1
+  })
+  .then(gamesSolutions => {
+    res.sendSuccess(gamesSolutions);
+  })
+  .catch(err => {
+    res.badRequest(err.message);
+  });
+});
+
 // 获取单个中单方案
 router.get('/get_solution', (req, res) => {
   const { sid } = req.query;

+ 2 - 2
server/triangle/eventSolutions.js

@@ -191,13 +191,13 @@ const eventSolutions = (betInfo, showGolds=false) => {
     rebate_side_a,rebate_side_b, rebate_side_c,
   } = betInfo;
 
-
+  const win_average_rate = fixFloat(win_average / inner_base * 100);
   const win_profit_rate = fixFloat(win_average / (gold_side_a + gold_side_b + gold_side_c) * 100);
 
   let result = {
     odds_side_a, odds_side_b, odds_side_c,
     rebate_side_a, rebate_side_b, rebate_side_c,
-    win_average, win_profit_rate, cross_type,
+    win_average, win_average_rate, win_profit_rate, cross_type,
     inner_index, inner_base, inner_rebate,
   }
 

+ 12 - 55
web/apps/web-antd/src/views/match/components/match_card.vue

@@ -22,14 +22,6 @@ defineProps({
     type: Number,
     required: true
   },
-  platform: {
-    type: String,
-    required: true
-  },
-  leagueName: {
-    type: String,
-    required: true
-  },
   teamHomeName: {
     type: String,
     required: true
@@ -38,10 +30,6 @@ defineProps({
     type: String,
     required: true
   },
-  dateTime: {
-    type: String,
-    required: true
-  },
   events: {
     type: Array,
     required: true
@@ -54,33 +42,17 @@ defineProps({
     type: Array,
     required: false
   },
-  stage: {
-    type: String,
-    required: false
-  },
-  retime: {
-    type: String,
-    required: false
-  },
-  score: {
-    type: String,
-    required: false
-  }
 })
 </script>
 
 <template>
   <div class="match-card">
-    <div class="card-header">
-      <div class="league-name"><strong v-if="stage && platform == 'ps'">[{{ stage }}<span v-if="retime">&nbsp;{{ retime }}</span>]</strong>{{ leagueName }}</div>
-      <div class="date-time">{{ dateTime }}</div>
-    </div>
     <div class="team-name">
       <span class="home-name">{{ teamHomeName }}</span>
-      <em v-if="stage">{{ score }}</em><em v-else>VS</em>
+      <em>VS</em>
       <span class="away-name">{{ teamAwayName }}</span>
     </div>
-    <div class="events-list" :class="{'list-row2': events.length <= 2}">
+    <div class="events-list" v-if="events.length">
       <table>
         <tr v-for="item in events">
           <th>{{ parseEventKey(item[0]) }}</th>
@@ -99,6 +71,7 @@ defineProps({
         </tr>
       </table>
     </div>
+    <div class="events-empty" v-else>暂无数据</div>
   </div>
 </template>
 
@@ -108,24 +81,6 @@ defineProps({
   flex-direction: column;
   padding: 20px;
 }
-.card-header {
-  display: flex;
-  height: 30px;
-  align-items: center;
-  justify-content: space-between;
-  .league-name {
-    font-size: 16px;
-    strong {
-      margin-right: 4px;
-      font-weight: normal;
-      color: hsl(var(--destructive));
-    }
-  }
-  .date-time {
-    font-size: 12px;
-    color: hsl(var(--foreground) / 0.7);
-  }
-}
 .team-name {
   display: flex;
   align-items: center;
@@ -206,12 +161,14 @@ defineProps({
       color: hsl(var(--foreground) / 0.5);
     }
   }
-  &.list-row2 {
-    table {
-      th, td {
-        height: 45px;
-      }
-    }
-  }
+}
+.events-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 34px;
+  margin-top: 10px;
+  font-size: 14px;
+  border: 1px solid hsl(var(--border));
 }
 </style>

+ 217 - 0
web/apps/web-antd/src/views/match/components/match_card_bak.vue

@@ -0,0 +1,217 @@
+<script setup>
+const parseEventKey = (key) => {
+  if (key == 'm') {
+    return '独赢';
+  }
+  else if (key?.startsWith('wm')) {
+    const ratio = key.split('_')[1];
+    return `净胜 ${ratio}`;
+  }
+  else if (key?.startsWith('ou')) {
+    const ratio = key.split('_')[1];
+    return `大/小 ${ratio}`;
+  }
+  else if (key?.startsWith('ot')) {
+    const ratio = key.split('_')[1];
+    return `进球数 ${ratio}`;
+  }
+  return key;
+}
+defineProps({
+  eventId: {
+    type: Number,
+    required: true
+  },
+  platform: {
+    type: String,
+    required: true
+  },
+  leagueName: {
+    type: String,
+    required: true
+  },
+  teamHomeName: {
+    type: String,
+    required: true
+  },
+  teamAwayName: {
+    type: String,
+    required: true
+  },
+  dateTime: {
+    type: String,
+    required: true
+  },
+  events: {
+    type: Array,
+    required: true
+  },
+  matchNumStr: {
+    type: String,
+    required: false
+  },
+  selected: {
+    type: Array,
+    required: false
+  },
+  stage: {
+    type: String,
+    required: false
+  },
+  retime: {
+    type: String,
+    required: false
+  },
+  score: {
+    type: String,
+    required: false
+  }
+})
+</script>
+
+<template>
+  <div class="match-card">
+    <div class="card-header">
+      <div class="league-name"><strong v-if="stage && platform == 'ps'">[{{ stage }}<span v-if="retime">&nbsp;{{ retime }}</span>]</strong>{{ leagueName }}</div>
+      <div class="date-time">{{ dateTime }}</div>
+    </div>
+    <div class="team-name">
+      <span class="home-name">{{ teamHomeName }}</span>
+      <em v-if="stage">{{ score }}</em><em v-else>VS</em>
+      <span class="away-name">{{ teamAwayName }}</span>
+    </div>
+    <div class="events-list" :class="{'list-row2': events.length <= 2}">
+      <table>
+        <tr v-for="item in events">
+          <th>{{ parseEventKey(item[0]) }}</th>
+          <td>
+            <span :class="{'selected': selected.includes(item[1]?.key)}">{{ item[1]?.value ? item[1].value : '-' }}</span>
+            <em v-if="item[1]?.origin">{{ item[1].origin }}</em>
+          </td>
+          <td>
+            <span :class="{'selected': selected.includes(item[2]?.key)}">{{ item[2]?.value ? item[2].value : '-' }}</span>
+            <em v-if="item[2]?.origin">{{ item[2].origin }}</em>
+          </td>
+          <td>
+            <span :class="{'selected': selected.includes(item[3]?.key)}">{{ item[3]?.value ? item[3].value : '-' }}</span>
+            <em v-if="item[3]?.origin">{{ item[3].origin }}</em>
+          </td>
+        </tr>
+      </table>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.match-card {
+  display: flex;
+  flex-direction: column;
+  padding: 20px;
+}
+.card-header {
+  display: flex;
+  height: 30px;
+  align-items: center;
+  justify-content: space-between;
+  .league-name {
+    font-size: 16px;
+    strong {
+      margin-right: 4px;
+      font-weight: normal;
+      color: hsl(var(--destructive));
+    }
+  }
+  .date-time {
+    font-size: 12px;
+    color: hsl(var(--foreground) / 0.7);
+  }
+}
+.team-name {
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  .disabled & {
+    display: none;
+  }
+  span, em {
+    display: block;
+  }
+  span {
+    flex: 1;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    &:first-child {
+      text-align: left;
+    }
+    &:last-child {
+      text-align: right;
+    }
+  }
+  em {
+    margin: 0 10px;
+    font-style: normal;
+    color: #ff9d4a;
+  }
+  .home-name {
+    color: hsl(var(--primary));
+  }
+  .away-name {
+    color: hsl(var(--destructive));
+  }
+}
+.events-list {
+  margin-top: 10px;
+  .disabled & {
+    display: none;
+  }
+  table {
+    width: 100%;
+    border-collapse: collapse;
+    border-spacing: 0;
+    table-layout: fixed;
+    th, td {
+      padding: 5px;
+      border: 1px solid hsl(var(--border));
+      text-align: center;
+    }
+    th {
+      width: 80px;
+      font-weight: normal;
+    }
+    td {
+      width: calc((100% - 64px) / 2);
+      font-size: 0;
+    }
+    span {
+      display: inline-block;
+      height: 20px;
+      padding: 0 5px;
+      line-height: 20px;
+      vertical-align: middle;
+      font-size: 14px;
+      &.selected {
+        border-radius: 4px;
+        background-color: hsl(var(--primary));
+        color: hsl(var(--primary-foreground));
+      }
+    }
+    em {
+      display: block;
+      height: 18px;
+      margin-top: -3px;
+      line-height: 18px;
+      font-style: normal;
+      font-size: 12px;
+      color: hsl(var(--foreground) / 0.5);
+    }
+  }
+  &.list-row2 {
+    table {
+      th, td {
+        height: 45px;
+      }
+    }
+  }
+}
+</style>

+ 342 - 0
web/apps/web-antd/src/views/match/components/solution_item.vue

@@ -0,0 +1,342 @@
+<script setup>
+import { ref, computed, defineProps, defineEmits } from 'vue';
+import dayjs from 'dayjs';
+
+import MatchCard from './match_card.vue';
+
+const props = defineProps({
+  serial: {
+    type: Number,
+  },
+  id: {
+    type: Number,
+    required: true
+  },
+  mk: {
+    type: Number,
+    required: true
+  },
+  rel: {
+    type: Object,
+    required: true
+  },
+  solutions: {
+    type: Array,
+    required: true
+  },
+  selected: {
+    type: Boolean,
+    default: false
+  }
+});
+
+const emit = defineEmits(['toggle']);
+
+const selectedIndex = ref(0);
+
+const parseIorKey = (iorKey) => {
+  const [, type, accept, side, , ratioString] = iorKey.match(/^ior_(r|ou|m|wm|ot|os)(a?)(h|c|n)?(_([\d-]+))?$/);
+  let ratio = 0;
+  if (type === 'ot' || type === 'os') {
+    ratio = ratioString;
+  }
+  else if (ratioString) {
+    ratio = `${ratioString[0]}.${ratioString.slice(1)}` * (accept ? 1 : -1);
+  }
+  return { type, side, ratio };
+}
+
+const PS_IOR_KEYS = [
+  ['0', 'ior_mh', 'ior_mn', 'ior_mc'],
+  ['-1', 'ior_rh_15', 'ior_wmh_1', 'ior_rac_05'],
+  ['-2', 'ior_rh_25', 'ior_wmh_2', 'ior_rac_15'],
+  ['+1', 'ior_rah_05', 'ior_wmc_1', 'ior_rc_15'],
+  ['+2', 'ior_rah_15', 'ior_wmc_2', 'ior_rc_25'],
+  ['ot_1', '-', 'ior_ot_1', '-'],
+  ['ot_2', '-', 'ior_ot_2', '-'],
+  ['ot_3', '-', 'ior_ot_3', '-'],
+  ['ot_4', '-', 'ior_ot_4', '-'],
+  ['ot_5', '-', 'ior_ot_5', '-'],
+  ['ot_6', '-', 'ior_ot_6', '-'],
+  ['ot_7', '-', 'ior_ot_7', '-'],
+];
+
+const fixFloat = (number, x = 2) => {
+  return parseFloat(number.toFixed(x));
+}
+
+const formatPsEvents = (events) => {
+  return PS_IOR_KEYS.map(([label, ...keys]) => {
+    const match = keys.map(key => ({
+      key,
+      value: events[key]?.v ?? 0,
+      origin: events[key]?.r
+    }));
+    return {
+      label,
+      match
+    };
+  })
+  // .filter(item => item.match.every(entry => entry.value !== 0))
+  .map(({label, match}) => [label, ...match]);
+}
+
+const formatEvents = (events) => {
+  const eventsMap = {};
+  Object.keys(events).forEach(key => {
+    const { type, side, ratio } = parseIorKey(key);
+    let ratioKey, index;
+    if (type === 'r') {
+      if (side === 'h') {
+        ratioKey = ratio;
+        index = 0;
+      }
+      else if (side === 'c') {
+        ratioKey = -ratio;
+        index = 2;
+      }
+    }
+    else if (type === 'm') {
+      ratioKey = 'm';
+      if (side == 'h') {
+        index = 0;
+      }
+      else if (side == 'c') {
+        index = 2;
+      }
+      else {
+        index = 1;
+      }
+    }
+    else if (type === 'wm') {
+      ratioKey = `wm_${Math.abs(ratio)}`;
+      if (side === 'h') {
+        index = 0;
+      }
+      else if (side === 'c') {
+        index = 2;
+      }
+    }
+    else if (type === 'ou') {
+      ratioKey = `ou_${Math.abs(ratio)}`;
+      if (side === 'c') {
+        index = 0;
+      }
+      else if (side === 'h') {
+        index = 2;
+      }
+    }
+    else if (type === 'ot') {
+      ratioKey = `ot_${ratio}`;
+      index = 1;
+    }
+    if (typeof (ratioKey) == 'number') {
+      if (ratioKey > 0) {
+        ratioKey = `+${ratioKey}`;
+      }
+      else {
+        ratioKey = `${ratioKey}`;
+      }
+    }
+
+    if (!ratioKey) {
+      return;
+    }
+
+    if (!eventsMap[ratioKey]) {
+      eventsMap[ratioKey] = new Array(3).fill(undefined);
+    }
+
+    const value = events[key]?.v ?? 0;
+    const origin = events[key]?.r;
+    eventsMap[ratioKey][index] = { key, value, origin };
+  });
+
+  return Object.keys(eventsMap).sort((a, b) => a.localeCompare(b)).map(key => {
+    return [key, ...eventsMap[key]];
+  });
+}
+
+const toggleSolution = () => {
+  const id = props.id;
+  const sid = currentSolution.value.sid;
+  emit('toggle', { id, sid });
+};
+
+const switchSolution = (index) => {
+  if (index != selectedIndex.value) {
+    selectedIndex.value = index;
+  }
+  if (props.selected) {
+    toggleSolution();
+  }
+};
+
+const currentIndex = computed(() => {
+  const index = selectedIndex.value;
+  if (props.solutions[index]) {
+    return index;
+  }
+  return 0;
+});
+
+const currentSolution = computed(() => {
+  return props.solutions[currentIndex.value];
+});
+
+const currentRelation = computed(() => {
+  const cpr = currentSolution.value.cpr;
+  const rel = props.rel;
+  const { ps: { leagueName, timestamp, stage, retime, score } } = rel;
+  const dateTime = dayjs(timestamp).format('YYYY-MM-DD HH:mm:ss');
+  const relation = { leagueName, timestamp, dateTime, stage, retime, score };
+  Object.keys(rel).forEach(platform => {
+    const { eventId, teamHomeName, teamAwayName, events, special } = rel[platform];
+    if (!relation.rel) {
+      relation.rel = {};
+    }
+    const mergedEvents = { ...events, ...special };
+    const formattedEvents = platform === 'ps' ? formatPsEvents(mergedEvents) : formatEvents(mergedEvents);
+    relation.rel[platform] = { eventId, teamHomeName, teamAwayName, events: formattedEvents };
+  });
+  cpr.forEach(item => {
+    const { k, p } = item;
+    if (!relation.rel[p]['selected']) {
+      relation.rel[p]['selected'] = [];
+    }
+    relation.rel[p]['selected'].push(k);
+  });
+  return relation;
+});
+
+const ps = computed(() => {
+  return currentRelation.value.rel.ps;
+});
+
+const ob = computed(() => {
+  return currentRelation.value.rel.ob;
+});
+
+const hg = computed(() => {
+  return currentRelation.value.rel.hg;
+});
+
+</script>
+
+<template>
+<div class="solution-item" :class="{ 'selected': selected }">
+  <div class="solution-header">
+    <div class="serial-number" v-if="serial">{{ serial }}.</div>
+    <div class="stage" v-if="currentRelation.stage">[{{ currentRelation.stage }} {{ currentRelation.retime }}][{{ currentRelation.score }}]</div>
+    <div class="league-name">{{ currentRelation.leagueName }}</div>
+    <div class="date-time">{{ currentRelation.dateTime }}</div>
+    <div class="switch-btns">
+      <a href="javascript:;" v-for="(solution, index) in solutions" :key="index" :class="{ 'selected': index === currentIndex }" @click="switchSolution(index)">{{ solution.sol.win_average_rate ?? fixFloat(solution.sol.win_average / solution.sol.inner_base * 100) }}</a>
+    </div>
+  </div>
+  <div class="solution-content">
+    <MatchCard platform="ps" :eventId="ps.eventId" :teamHomeName="ps.teamHomeName"
+    :teamAwayName="ps.teamAwayName" :dateTime="ps.dateTime" :events="ps.events ?? []"
+    :selected="ps.selected ?? []" />
+
+    <MatchCard platform="ob" :eventId="ob.eventId" :teamHomeName="ob.teamHomeName"
+      :teamAwayName="ob.teamAwayName" :dateTime="ob.dateTime" :events="ob.events ?? []"
+      :selected="ob.selected ?? []" />
+
+    <MatchCard platform="hg" :eventId="hg.eventId" :teamHomeName="hg.teamHomeName"
+      :teamAwayName="hg.teamAwayName" :dateTime="hg.dateTime" :events="hg.events ?? []"
+      :selected="hg.selected ?? []" />
+
+    <div class="solution-profit" @click="toggleSolution()">
+      <p>{{ currentSolution.sol.win_average_rate ?? fixFloat(currentSolution.sol.win_average / currentSolution.sol.inner_base * 100) }}%</p>
+      <p>{{ currentSolution.sol.win_profit_rate }}%</p>
+      <p>{{ currentSolution.sol.cross_type }}</p>
+    </div>
+  </div>
+</div>
+</template>
+
+<style lang="scss" scoped>
+.solution-item {
+  display: flex;
+  flex-direction: column;
+  border-radius: 10px;
+  background-color: hsl(var(--card));
+
+  &.selected {
+    background-color: hsl(var(--primary) / 0.15);
+  }
+
+  &:not(:last-child) {
+    margin-bottom: 20px;
+  }
+}
+
+.solution-header {
+  display: flex;
+  align-items: center;
+  height: 40px;
+  padding: 0 15px;
+  border-bottom: 1px solid hsl(var(--border));
+  .serial-number {
+    margin-right: 5px;
+    font-size: 16px;
+    font-weight: 400;
+    color: hsl(var(--foreground) / 0.7);
+  }
+  .stage {
+    text-align: center;
+    font-size: 16px;
+    font-weight: 400;
+    color: hsl(var(--destructive));
+  }
+  .league-name {
+    margin-right: 10px;
+    font-size: 16px;
+    font-weight: 400;
+  }
+  .date-time {
+    text-align: right;
+    font-size: 14px;
+    color: hsl(var(--foreground) / 0.7);
+  }
+}
+
+.switch-btns {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  flex: 1;
+  a {
+    display: block;
+    height: 20px;
+    line-height: 20px;
+    padding: 0 5px;
+    border-radius: 4px;
+    &.selected {
+      color: hsl(var(--primary-foreground));
+      background-color: hsl(var(--primary));
+    }
+    &:not(:last-child) {
+      margin-right: 5px;
+    }
+  }
+}
+
+.solution-content {
+  display: flex;
+  .match-card {
+    flex: 1;
+    border-right: 1px solid hsl(var(--border));
+  }
+}
+
+.solution-profit {
+  display: flex;
+  flex-direction: column;
+  width: 80px;
+  align-items: center;
+  justify-content: center;
+}
+
+</style>

+ 1 - 2
web/apps/web-antd/src/views/match/datatest/index.vue

@@ -36,11 +36,10 @@ const refreshData = () => {
   buttonDisabled.value = true;
   updateTime();
   const win_min = -99999;
-  const no_events = true;
   const mk = selectedTestMode.value;
   Promise.all([
     requestClient.get('/pstery/get_games_relation', { params: { mk } }),
-    requestClient.get('/pstery/get_solutions', { params: { win_min, no_events, mk } })
+    requestClient.get('/pstery/get_solutions', { params: { win_min, mk } })
   ])
   .then(([relations, solutions]) => {
     gamesRelation.value = relations;

+ 34 - 406
web/apps/web-antd/src/views/match/solutions/index.vue

@@ -4,7 +4,8 @@ import { Button, message, Form, InputNumber, RadioGroup, Radio, Drawer } from 'a
 import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
 import dayjs from 'dayjs';
 
-import MatchCard from '../components/match_card.vue';
+// import MatchCard from '../components/match_card.vue';
+import SolutionItem from '../components/solution_item.vue';
 
 import { useContentsPositionStore } from '@vben/stores';
 const contentsPositionStore = useContentsPositionStore();
@@ -36,76 +37,20 @@ const headerStyle = computed(() => {
   }
 });
 
-// const totalProfitValue = computed(() => {
-//   const { profit = {}, preSolution = {}, subSolution = {}, gamesEvents = {} } = totalProfit.value;
-//   const sol1 = formatSolution(preSolution, gamesEvents);
-//   const sol2 = formatSolution(subSolution, gamesEvents);
-
-//   const psInfo = [];
-//   const outPreSol = [];
-//   const outSubSol = [];
-
-//   const solutions = [sol1, sol2].filter(item => item);
-//   solutions.forEach((item, index) => {
-//     const { sol: { ps_index }, cpr } = item;
-//     const newCpr = [...cpr];
-//     const ps_info = newCpr.splice(ps_index, 1);
-//     psInfo.push({ ...ps_info[0] });
-//     newCpr.forEach((c, i) => {
-//       let side = '';
-//       if (ps_index == 0) {
-//         if (i == 0) {
-//           side = "B"
-//         }
-//         else {
-//           side = "M";
-//         }
-//       }
-//       else if (ps_index == 1) {
-//         if (i == 0) {
-//           side = "A";
-//         }
-//         else {
-//           side = "M";
-//         }
-//       }
-//       else {
-//         if (i == 0) {
-//           side = "A";
-//         }
-//         else {
-//           side = "B";
-//         }
-//       }
-//       if (index == 0) {
-//         outPreSol.push({ ...c, g: profitInfo[`gold${side}${index+1}`] });
-//       }
-//       else {
-//         outSubSol.push({ ...c, g: profitInfo[`gold${side}${index+1}`] });
-//       }
-//     })
-//   });
-
-//   return { solutions, profit: profitInfo, psInfo, outPreSol, outSubSol };
-// });
-
 const solutionsList = computed(() => {
-  const startTimestamp = selectedSolutions[0]?.timestamp ?? 0;
   return solutions.value.map(item => {
-    const selected = selectedSolutions.findIndex(sol => sol.sid === item.sid) >= 0;
-    const disabled = false && !selected && (item.info.ps.timestamp < startTimestamp + 1000 * 60 * 60 * 2);
-    const { sol: { inner_base, win_average } } = item;
-    const win_average_rate = fixFloat(win_average / inner_base * 100);
-    const currentSol = { ...item.sol, win_average_rate };
-    return { ...item, sol: currentSol, selected, disabled };
+    const selected = selectedSolutions.findIndex(sol => sol.id == item.id) >= 0;
+    return { ...item, selected };
   });
 });
 
+
 const getSolutions = async () => {
   try {
     const win_min = minProfitRate.value * 100;
     const mk = marketType.value;
-    const data = await requestClient.get('/pstery/get_solutions', { params: { win_min, mk } });
+    const with_events = true;
+    const data = await requestClient.get('/pstery/get_games_solutions', { params: { win_min, mk, with_events } });
     return data;
   }
   catch (error) {
@@ -128,211 +73,15 @@ const calcTotalProfit = async () => {
   }
 }
 
-const parseIorKey = (iorKey) => {
-  const [, type, accept, side, , ratioString] = iorKey.match(/^ior_(r|ou|m|wm|ot|os)(a?)(h|c|n)?(_([\d-]+))?$/);
-  let ratio = 0;
-  if (type === 'ot' || type === 'os') {
-    ratio = ratioString;
-  }
-  else if (ratioString) {
-    ratio = `${ratioString[0]}.${ratioString.slice(1)}` * (accept ? 1 : -1);
-  }
-  return { type, side, ratio };
-}
-
-const PS_IOR_KEYS = [
-  ['0', 'ior_mh', 'ior_mn', 'ior_mc'],
-  ['-1', 'ior_rh_15', 'ior_wmh_1', 'ior_rac_05'],
-  ['-2', 'ior_rh_25', 'ior_wmh_2', 'ior_rac_15'],
-  ['+1', 'ior_rah_05', 'ior_wmc_1', 'ior_rc_15'],
-  ['+2', 'ior_rah_15', 'ior_wmc_2', 'ior_rc_25'],
-  // ['0-1', 'ior_ot_0', 'ior_os_0-1', 'ior_ot_1'],
-  // ['2-3', 'ior_ot_2', 'ior_os_2-3', 'ior_ot_3'],
-  ['ot_1', '-', 'ior_ot_1', '-'],
-  ['ot_2', '-', 'ior_ot_2', '-'],
-  ['ot_3', '-', 'ior_ot_3', '-'],
-  ['ot_4', '-', 'ior_ot_4', '-'],
-  ['ot_5', '-', 'ior_ot_5', '-'],
-  ['ot_6', '-', 'ior_ot_6', '-'],
-  ['ot_7', '-', 'ior_ot_7', '-'],
-];
-
-const formatPsEvents = (events) => {
-  return PS_IOR_KEYS.map(([label, ...keys]) => {
-    const match = keys.map(key => ({
-      key,
-      value: events[key]?.v ?? 0,
-      origin: events[key]?.r
-    }));
-    return {
-      label,
-      match
-    };
-  })
-  // .filter(item => item.match.every(entry => entry.value !== 0))
-  .map(({label, match}) => [label, ...match]);
-}
-
-// const rivalIor = (ior) => {
-//   const map = {
-//     "ior_rh": "ior_rac",
-//     "ior_rc": "ior_rah",
-//     "ior_rac": "ior_rh",
-//     "ior_rah": "ior_rc",
-//     "ior_wmh": "ior_wmc",
-//     "ior_wmc": "ior_wmh",
-//     "ior_wmh_2": "ior_wmc_2",
-//     "ior_wmc_2": "ior_wmh_2"
-//   };
-//   const iorInfos = ior.split('_');
-//   const iorStart = iorInfos.slice(0, 2).join('_');
-//   if (!map[iorStart]) {
-//     return ior;
-//   }
-//   return `${map[iorStart]}_${iorInfos[2]}`;
-// }
-
-const formatEvents = (events, cprKeys) => {
-  const eventsMap = {};
-  Object.keys(events).forEach(key => {
-    const { type, side, ratio } = parseIorKey(key);
-    let ratioKey, index;
-    if (type === 'r') {
-      if (side === 'h') {
-        ratioKey = ratio;
-        index = 0;
-      }
-      else if (side === 'c') {
-        ratioKey = -ratio;
-        index = 2;
-      }
-    }
-    else if (type === 'm') {
-      ratioKey = 'm';
-      if (side == 'h') {
-        index = 0;
-      }
-      else if (side == 'c') {
-        index = 2;
-      }
-      else {
-        index = 1;
-      }
-    }
-    else if (type === 'wm') {
-      ratioKey = `wm_${Math.abs(ratio)}`;
-      if (side === 'h') {
-        index = 0;
-      }
-      else if (side === 'c') {
-        index = 2;
-      }
-    }
-    else if (type === 'ou') {
-      ratioKey = `ou_${Math.abs(ratio)}`;
-      if (side === 'c') {
-        index = 0;
-      }
-      else if (side === 'h') {
-        index = 2;
-      }
-    }
-    // else if (type === 'os') {
-    //   ratioKey = ratio;
-    //   index = 1;
-    // }
-    else if (type === 'ot') {
-      ratioKey = `ot_${ratio}`;
-      index = 1;
-      // switch (ratio) {
-      //   case '0':
-      //     ratioKey = '0-1';
-      //     index = 0;
-      //     break;
-      //   case '1':
-      //     ratioKey = '0-1';
-      //     index = 2;
-      //     break;
-      //   case '2':
-      //     ratioKey = '2-3';
-      //     index = 0;
-      //     break;
-      //   case '3':
-      //     ratioKey = '2-3';
-      //     index = 2;
-      //     break;
-      // }
-    }
-    if (typeof (ratioKey) == 'number') {
-      if (ratioKey > 0) {
-        ratioKey = `+${ratioKey}`;
-      }
-      // else if (ratioKey === 0) {
-      //   ratioKey = '-0';
-      // }
-      else {
-        ratioKey = `${ratioKey}`;
-      }
-    }
-
-    if (!ratioKey) {
-      return;
-    }
-
-    if (!eventsMap[ratioKey]) {
-      eventsMap[ratioKey] = new Array(3).fill(undefined);
-    }
-
-    const value = events[key]?.v ?? 0;
-    const origin = events[key]?.r;
-    eventsMap[ratioKey][index] = { key, value, origin };
-  });
-
-  return Object.keys(eventsMap).sort((a, b) => a.localeCompare(b)).map(key => {
-    return [key, ...eventsMap[key]];
-  });
-}
-
-const formatSolution = (solution, eventsList) => {
-  const { cpr, info } = solution;
-  if (!cpr || !info) {
-    return null;
-  }
-
-  const cprKeys = cpr.map(item => item.k);
-
-  const psEvents = eventsList.ps?.[info.ps.eventId] ?? {};
-  const obEvents = eventsList.ob?.[info.ob.eventId] ?? {};
-  const hgEvents = eventsList.hg?.[info.hg.eventId] ?? {};
-
-  info.ps.events = formatPsEvents(psEvents);
-  info.ob.events = formatEvents(obEvents, cprKeys);
-  info.hg.events = formatEvents(hgEvents, cprKeys);
-
-  info.ps.dateTime = dayjs(info.ps.timestamp).format('YYYY-MM-DD HH:mm:ss');
-  info.ob.dateTime = dayjs(info.ob.timestamp).format('YYYY-MM-DD HH:mm:ss');
-  info.hg.dateTime = dayjs(info.hg.timestamp).format('YYYY-MM-DD HH:mm:ss');
-
-  cpr.forEach(item => {
-    const { k, p } = item;
-    if (!info[p]['selected']) {
-      info[p]['selected'] = [];
-    }
-    info[p]['selected'].push(k);
-  });
-
-  return solution;
-}
-
 const updateSolutions = async (showLoading=false) => {
   clearTimeout(loopTimer.value);
   if (showLoading && !updateLoaderHide.value) {
     updateLoaderHide.value = message.loading('数据加载中...', 0);
   }
   getSolutions()
-  .then(({ solutions: solutionsList, gamesEvents: eventsList, mkCount: mkCountData }) => {
-    solutions.value = solutionsList?.map(solution => formatSolution(solution, eventsList)) ?? [];
-    markCount.value = mkCountData;
+  .then(({ gamesSolutions, mkCount }) => {
+    solutions.value = gamesSolutions ?? [];
+    markCount.value = mkCount;
   })
   .catch(error => {
     console.error('Failed to update solutions:', error);
@@ -356,22 +105,24 @@ const showTotalProfit = async () => {
   console.log('profit', profit);
 };
 
-const closeTotalProfit = () => {
-  totalProfitVisible.value = false;
-  selectedSolutions.length = 0;
-  totalProfit.value = {};
-};
 
-const toggleSolution = (sid, timestamp) => {
-  const findIndex = selectedSolutions.findIndex(item => item.sid === sid);
+const toggleSolution = (data) => {
+  // console.log('toggleSolution', data);
+  const { id, sid } = data;
+  const findIndex = selectedSolutions.findIndex(item => item.id == id);
   if (findIndex >= 0) {
-    selectedSolutions.splice(findIndex, 1);
+    if (selectedSolutions[findIndex].sid == sid) {
+      selectedSolutions.splice(findIndex, 1);
+    }
+    else {
+      selectedSolutions.splice(findIndex, 1, data);
+    }
   }
   else if (selectedSolutions.length < 2) {
-    selectedSolutions.push({ sid, timestamp });
+    selectedSolutions.push(data);
   }
   else {
-    selectedSolutions.splice(1, 1, { sid, timestamp });
+    selectedSolutions.splice(1, 1, data);
   }
   if (selectedSolutions.length == 2) {
     showTotalProfit();
@@ -455,87 +206,19 @@ onUnmounted(() => {
       </div>
     </div>
 
-    <div class="solution-list">
-      <div class="solution-item"
-        v-for="{ sid, sol: { win_average_rate, win_profit_rate, cross_type }, info: { ps, ob, hg }, selected, disabled } in solutionsList" :key="sid"
-        :class="{ 'selected': selected, 'disabled': disabled }">
-        <MatchCard platform="ps" :eventId="ps.eventId" :leagueName="ps.leagueName" :teamHomeName="ps.teamHomeName"
-          :teamAwayName="ps.teamAwayName" :dateTime="ps.dateTime" :eventInfo="ps.eventInfo" :events="ps.events ?? []"
-          :matchNumStr="ps.matchNumStr" :selected="ps.selected ?? []" :stage="ps.stage" :retime="ps.retime" :score="ps.score" />
-
-        <MatchCard platform="ob" :eventId="ob.eventId" :leagueName="ob.leagueName" :teamHomeName="ob.teamHomeName"
-          :teamAwayName="ob.teamAwayName" :dateTime="ob.dateTime" :events="ob.events ?? []"
-          :selected="ob.selected ?? []" :stage="ob.stage" :retime="ob.retime" :score="ob.score" />
-
-        <MatchCard platform="hg" :eventId="hg.eventId" :leagueName="hg.leagueName" :teamHomeName="hg.teamHomeName"
-          :teamAwayName="hg.teamAwayName" :dateTime="hg.dateTime" :events="hg.events ?? []"
-          :selected="hg.selected ?? []" :stage="hg.stage" :retime="hg.retime" :score="hg.score" />
-
-        <div class="solution-profit" @click="!disabled && toggleSolution(sid, ps.timestamp)">
-          <p>{{ win_average_rate }}%</p>
-          <p>{{ win_profit_rate }}%</p>
-          <p>{{ cross_type }}</p>
-        </div>
-      </div>
+    <div class="solution-list" v-if="solutionsList.length">
+      <SolutionItem v-for="(solution, index) in solutionsList"
+        :key="solution.id"
+        :serial="index+1"
+        :id="solution.id"
+        :mk="solution.mk"
+        :rel="solution.rel"
+        :selected="solution.selected"
+        :solutions="solution.solutions"
+        @toggle="toggleSolution"
+      />
     </div>
-    <div class="list-empty" v-if="!solutionsList.length">暂无数据</div>
-
-    <!-- <Drawer
-      title="综合利润方案"
-      placement="bottom"
-      height="600"
-      :visible="totalProfitVisible"
-      @close="closeTotalProfit"
-    >
-      <div class="solution-total-profit" v-if="totalProfitValue.solutions.length">
-        <div class="solution-item"
-          v-for="{ sid, info: { ps, ob, hg } } in totalProfitValue.solutions" :key="sid">
-          <MatchCard platform="ps" :eventId="ps.eventId" :leagueName="ps.leagueName" :teamHomeName="ps.teamHomeName"
-            :teamAwayName="ps.teamAwayName" :dateTime="ps.dateTime" :eventInfo="ps.eventInfo" :events="ps.events ?? []"
-            :matchNumStr="ps.matchNumStr" :selected="ps.selected ?? []" />
-
-          <MatchCard platform="ob" :eventId="ob.eventId" :leagueName="ob.leagueName" :teamHomeName="ob.teamHomeName"
-            :teamAwayName="ob.teamAwayName" :dateTime="ob.dateTime" :events="ob.events ?? []"
-            :selected="ob.selected ?? []" />
-
-          <MatchCard platform="hg" :eventId="hg.eventId" :leagueName="hg.leagueName" :teamHomeName="hg.teamHomeName"
-            :teamAwayName="hg.teamAwayName" :dateTime="hg.dateTime" :events="hg.events ?? []"
-            :selected="hg.selected ?? []" />
-        </div>
-      </div>
-      <div class="profit-info">
-        <table>
-          <tr>
-            <th></th>
-            <td>PS</td>
-            <td colspan="2">第一场</td>
-            <td colspan="2">第二场</td>
-          </tr>
-          <tr>
-            <th>赔率</th>
-            <td>{{ totalProfitValue.psInfo[0]?.v }}: {{ totalProfitValue.psInfo[1]?.v }}</td>
-            <td>{{ totalProfitValue.outPreSol[0]?.p }}: {{ totalProfitValue.outPreSol[0]?.v }}</td>
-            <td>{{ totalProfitValue.outPreSol[1]?.p }}: {{ totalProfitValue.outPreSol[1]?.v }}</td>
-            <td>{{ totalProfitValue.outSubSol[0]?.p }}: {{ totalProfitValue.outSubSol[0]?.v }}</td>
-            <td>{{ totalProfitValue.outSubSol[1]?.p }}: {{ totalProfitValue.outSubSol[1]?.v }}</td>
-          </tr>
-          <tr>
-            <th>下注</th>
-            <td>{{ psOptions.bet }}</td>
-            <td>{{ totalProfitValue.outPreSol[0]?.g }}</td>
-            <td>{{ totalProfitValue.outPreSol[1]?.g }}</td>
-            <td>{{ totalProfitValue.outSubSol[0]?.g }}</td>
-            <td>{{ totalProfitValue.outSubSol[1]?.g }}</td>
-          </tr>
-          <tr>
-            <th>利润</th>
-            <td>{{ totalProfitValue.profit.win_ps }}</td>
-            <td colspan="2">{{ totalProfitValue.profit.win_target }}</td>
-            <td colspan="2">{{ totalProfitValue.profit.win_target }}</td>
-          </tr>
-        </table>
-      </div>
-    </Drawer> -->
+    <div class="list-empty" v-else>暂无数据</div>
 
   </div>
 </template>
@@ -594,64 +277,9 @@ onUnmounted(() => {
   padding-top: 74px;
 }
 
-.solution-item {
-  display: flex;
-  .match-card {
-    flex: 1;
-  }
-}
-
 .solution-list {
   padding: 20px;
   overflow: hidden;
-
-  .solution-item {
-    border-radius: 10px;
-    background-color: hsl(var(--card));
-
-    &.selected {
-      background-color: hsl(var(--primary) / 0.15);
-    }
-
-    &.disabled {
-      opacity: 0.5;
-      cursor: not-allowed;
-    }
-
-    &:not(:last-child) {
-      margin-bottom: 20px;
-    }
-
-    .match-card {
-      border-right: 1px solid hsl(var(--border));
-    }
-
-    .solution-profit {
-      display: flex;
-      flex-direction: column;
-      width: 80px;
-      align-items: center;
-      justify-content: center;
-    }
-  }
-}
-
-.profit-info {
-  table {
-    width: 100%;
-    border-collapse: collapse;
-    border-spacing: 0;
-    table-layout: fixed;
-    th, td {
-      height: 30px;
-      border: 1px solid hsl(var(--border));
-      text-align: center;
-    }
-    th {
-      width: 64px;
-      font-weight: normal;
-    }
-  }
 }
 
 .list-empty {

+ 664 - 0
web/apps/web-antd/src/views/match/solutions/index_bak.vue

@@ -0,0 +1,664 @@
+<script setup>
+import { requestClient } from '#/api/request';
+import { Button, message, Form, InputNumber, RadioGroup, Radio, Drawer } from 'ant-design-vue';
+import { ref, reactive, computed, watch, onMounted, onUnmounted } from 'vue';
+import dayjs from 'dayjs';
+
+import MatchCard from '../components/match_card.vue';
+
+import { useContentsPositionStore } from '@vben/stores';
+const contentsPositionStore = useContentsPositionStore();
+
+const solutions = ref([]);
+const markCount = ref({ all: 0, rollball: 0, today: 0, early: 0 });
+const selectedSolutions = reactive([]);
+const totalProfit = ref({});
+const loopActive = ref(false);
+const loopTimer = ref(null);
+const updateTimer = ref(null);
+const minProfitRate = ref(2);
+const marketType = ref(-1);
+const updateLoaderHide = ref(null);
+
+const totalProfitVisible = ref(false);
+
+const fixFloat = (number, x = 2) => {
+  return parseFloat(number.toFixed(x));
+}
+
+const headerStyle = computed(() => {
+  return {
+    position: contentsPositionStore.position,
+    top: contentsPositionStore.top,
+    left: contentsPositionStore.left,
+    width: contentsPositionStore.width,
+    paddingLeft: contentsPositionStore.paddingLeft,
+  }
+});
+
+// const totalProfitValue = computed(() => {
+//   const { profit = {}, preSolution = {}, subSolution = {}, gamesEvents = {} } = totalProfit.value;
+//   const sol1 = formatSolution(preSolution, gamesEvents);
+//   const sol2 = formatSolution(subSolution, gamesEvents);
+
+//   const psInfo = [];
+//   const outPreSol = [];
+//   const outSubSol = [];
+
+//   const solutions = [sol1, sol2].filter(item => item);
+//   solutions.forEach((item, index) => {
+//     const { sol: { ps_index }, cpr } = item;
+//     const newCpr = [...cpr];
+//     const ps_info = newCpr.splice(ps_index, 1);
+//     psInfo.push({ ...ps_info[0] });
+//     newCpr.forEach((c, i) => {
+//       let side = '';
+//       if (ps_index == 0) {
+//         if (i == 0) {
+//           side = "B"
+//         }
+//         else {
+//           side = "M";
+//         }
+//       }
+//       else if (ps_index == 1) {
+//         if (i == 0) {
+//           side = "A";
+//         }
+//         else {
+//           side = "M";
+//         }
+//       }
+//       else {
+//         if (i == 0) {
+//           side = "A";
+//         }
+//         else {
+//           side = "B";
+//         }
+//       }
+//       if (index == 0) {
+//         outPreSol.push({ ...c, g: profitInfo[`gold${side}${index+1}`] });
+//       }
+//       else {
+//         outSubSol.push({ ...c, g: profitInfo[`gold${side}${index+1}`] });
+//       }
+//     })
+//   });
+
+//   return { solutions, profit: profitInfo, psInfo, outPreSol, outSubSol };
+// });
+
+const solutionsList = computed(() => {
+  const startTimestamp = selectedSolutions[0]?.timestamp ?? 0;
+  return solutions.value.map(item => {
+    const selected = selectedSolutions.findIndex(sol => sol.sid === item.sid) >= 0;
+    const disabled = false && !selected && (item.info.ps.timestamp < startTimestamp + 1000 * 60 * 60 * 2);
+    const { sol: { inner_base, win_average } } = item;
+    const win_average_rate = fixFloat(win_average / inner_base * 100);
+    const currentSol = { ...item.sol, win_average_rate };
+    return { ...item, sol: currentSol, selected, disabled };
+  });
+});
+
+const getSolutions = async () => {
+  try {
+    const win_min = minProfitRate.value * 100;
+    const mk = marketType.value;
+    const with_events = true;
+    const data = await requestClient.get('/pstery/get_solutions', { params: { win_min, mk, with_events } });
+    return data;
+  }
+  catch (error) {
+    console.error('Failed to fetch solutions:', error);
+    message.error('获取中单方案失败');
+    return [];
+  }
+}
+
+const calcTotalProfit = async () => {
+  const sids = selectedSolutions.map(item => item.sid);
+  try {
+    const totalProfit = await requestClient.post('/pstery/calc_total_profit', [...sids]);
+    return totalProfit;
+  }
+  catch (error) {
+    console.error('Failed to calc total profit:', error);
+    message.error('计算综合利润失败');
+    return {};
+  }
+}
+
+const parseIorKey = (iorKey) => {
+  const [, type, accept, side, , ratioString] = iorKey.match(/^ior_(r|ou|m|wm|ot|os)(a?)(h|c|n)?(_([\d-]+))?$/);
+  let ratio = 0;
+  if (type === 'ot' || type === 'os') {
+    ratio = ratioString;
+  }
+  else if (ratioString) {
+    ratio = `${ratioString[0]}.${ratioString.slice(1)}` * (accept ? 1 : -1);
+  }
+  return { type, side, ratio };
+}
+
+const PS_IOR_KEYS = [
+  ['0', 'ior_mh', 'ior_mn', 'ior_mc'],
+  ['-1', 'ior_rh_15', 'ior_wmh_1', 'ior_rac_05'],
+  ['-2', 'ior_rh_25', 'ior_wmh_2', 'ior_rac_15'],
+  ['+1', 'ior_rah_05', 'ior_wmc_1', 'ior_rc_15'],
+  ['+2', 'ior_rah_15', 'ior_wmc_2', 'ior_rc_25'],
+  // ['0-1', 'ior_ot_0', 'ior_os_0-1', 'ior_ot_1'],
+  // ['2-3', 'ior_ot_2', 'ior_os_2-3', 'ior_ot_3'],
+  ['ot_1', '-', 'ior_ot_1', '-'],
+  ['ot_2', '-', 'ior_ot_2', '-'],
+  ['ot_3', '-', 'ior_ot_3', '-'],
+  ['ot_4', '-', 'ior_ot_4', '-'],
+  ['ot_5', '-', 'ior_ot_5', '-'],
+  ['ot_6', '-', 'ior_ot_6', '-'],
+  ['ot_7', '-', 'ior_ot_7', '-'],
+];
+
+const formatPsEvents = (events) => {
+  return PS_IOR_KEYS.map(([label, ...keys]) => {
+    const match = keys.map(key => ({
+      key,
+      value: events[key]?.v ?? 0,
+      origin: events[key]?.r
+    }));
+    return {
+      label,
+      match
+    };
+  })
+  // .filter(item => item.match.every(entry => entry.value !== 0))
+  .map(({label, match}) => [label, ...match]);
+}
+
+// const rivalIor = (ior) => {
+//   const map = {
+//     "ior_rh": "ior_rac",
+//     "ior_rc": "ior_rah",
+//     "ior_rac": "ior_rh",
+//     "ior_rah": "ior_rc",
+//     "ior_wmh": "ior_wmc",
+//     "ior_wmc": "ior_wmh",
+//     "ior_wmh_2": "ior_wmc_2",
+//     "ior_wmc_2": "ior_wmh_2"
+//   };
+//   const iorInfos = ior.split('_');
+//   const iorStart = iorInfos.slice(0, 2).join('_');
+//   if (!map[iorStart]) {
+//     return ior;
+//   }
+//   return `${map[iorStart]}_${iorInfos[2]}`;
+// }
+
+const formatEvents = (events, cprKeys) => {
+  const eventsMap = {};
+  Object.keys(events).forEach(key => {
+    const { type, side, ratio } = parseIorKey(key);
+    let ratioKey, index;
+    if (type === 'r') {
+      if (side === 'h') {
+        ratioKey = ratio;
+        index = 0;
+      }
+      else if (side === 'c') {
+        ratioKey = -ratio;
+        index = 2;
+      }
+    }
+    else if (type === 'm') {
+      ratioKey = 'm';
+      if (side == 'h') {
+        index = 0;
+      }
+      else if (side == 'c') {
+        index = 2;
+      }
+      else {
+        index = 1;
+      }
+    }
+    else if (type === 'wm') {
+      ratioKey = `wm_${Math.abs(ratio)}`;
+      if (side === 'h') {
+        index = 0;
+      }
+      else if (side === 'c') {
+        index = 2;
+      }
+    }
+    else if (type === 'ou') {
+      ratioKey = `ou_${Math.abs(ratio)}`;
+      if (side === 'c') {
+        index = 0;
+      }
+      else if (side === 'h') {
+        index = 2;
+      }
+    }
+    // else if (type === 'os') {
+    //   ratioKey = ratio;
+    //   index = 1;
+    // }
+    else if (type === 'ot') {
+      ratioKey = `ot_${ratio}`;
+      index = 1;
+      // switch (ratio) {
+      //   case '0':
+      //     ratioKey = '0-1';
+      //     index = 0;
+      //     break;
+      //   case '1':
+      //     ratioKey = '0-1';
+      //     index = 2;
+      //     break;
+      //   case '2':
+      //     ratioKey = '2-3';
+      //     index = 0;
+      //     break;
+      //   case '3':
+      //     ratioKey = '2-3';
+      //     index = 2;
+      //     break;
+      // }
+    }
+    if (typeof (ratioKey) == 'number') {
+      if (ratioKey > 0) {
+        ratioKey = `+${ratioKey}`;
+      }
+      // else if (ratioKey === 0) {
+      //   ratioKey = '-0';
+      // }
+      else {
+        ratioKey = `${ratioKey}`;
+      }
+    }
+
+    if (!ratioKey) {
+      return;
+    }
+
+    if (!eventsMap[ratioKey]) {
+      eventsMap[ratioKey] = new Array(3).fill(undefined);
+    }
+
+    const value = events[key]?.v ?? 0;
+    const origin = events[key]?.r;
+    eventsMap[ratioKey][index] = { key, value, origin };
+  });
+
+  return Object.keys(eventsMap).sort((a, b) => a.localeCompare(b)).map(key => {
+    return [key, ...eventsMap[key]];
+  });
+}
+
+const formatSolution = (solution, eventsList) => {
+  const { cpr, info } = solution;
+  if (!cpr || !info) {
+    return null;
+  }
+
+  const cprKeys = cpr.map(item => item.k);
+
+  const psEvents = eventsList.ps?.[info.ps.eventId] ?? {};
+  const obEvents = eventsList.ob?.[info.ob.eventId] ?? {};
+  const hgEvents = eventsList.hg?.[info.hg.eventId] ?? {};
+
+  info.ps.events = formatPsEvents(psEvents);
+  info.ob.events = formatEvents(obEvents, cprKeys);
+  info.hg.events = formatEvents(hgEvents, cprKeys);
+
+  info.ps.dateTime = dayjs(info.ps.timestamp).format('YYYY-MM-DD HH:mm:ss');
+  info.ob.dateTime = dayjs(info.ob.timestamp).format('YYYY-MM-DD HH:mm:ss');
+  info.hg.dateTime = dayjs(info.hg.timestamp).format('YYYY-MM-DD HH:mm:ss');
+
+  cpr.forEach(item => {
+    const { k, p } = item;
+    if (!info[p]['selected']) {
+      info[p]['selected'] = [];
+    }
+    info[p]['selected'].push(k);
+  });
+
+  return solution;
+}
+
+const updateSolutions = async (showLoading=false) => {
+  clearTimeout(loopTimer.value);
+  if (showLoading && !updateLoaderHide.value) {
+    updateLoaderHide.value = message.loading('数据加载中...', 0);
+  }
+  getSolutions()
+  .then(({ solutions: solutionsList, gamesEvents: eventsList, mkCount: mkCountData }) => {
+    solutions.value = solutionsList?.map(solution => formatSolution(solution, eventsList)) ?? [];
+    markCount.value = mkCountData;
+  })
+  .catch(error => {
+    console.error('Failed to update solutions:', error);
+    message.error('获取中单方案失败');
+  })
+  .finally(() => {
+    updateLoaderHide.value?.();
+    updateLoaderHide.value = null;
+    if (loopActive.value) {
+      loopTimer.value = setTimeout(() => {
+        updateSolutions();
+      }, 1000 * 10);
+    }
+  });
+}
+
+const showTotalProfit = async () => {
+  totalProfit.value = await calcTotalProfit();
+  totalProfitVisible.value = true;
+  const { profit } = totalProfit.value;
+  console.log('profit', profit);
+};
+
+const closeTotalProfit = () => {
+  totalProfitVisible.value = false;
+  selectedSolutions.length = 0;
+  totalProfit.value = {};
+};
+
+const toggleSolution = (sid, timestamp) => {
+  const findIndex = selectedSolutions.findIndex(item => item.sid === sid);
+  if (findIndex >= 0) {
+    selectedSolutions.splice(findIndex, 1);
+  }
+  else if (selectedSolutions.length < 2) {
+    selectedSolutions.push({ sid, timestamp });
+  }
+  else {
+    selectedSolutions.splice(1, 1, { sid, timestamp });
+  }
+  if (selectedSolutions.length == 2) {
+    showTotalProfit();
+  }
+}
+
+const setLocalStorage = (key, value) => {
+  localStorage.setItem(key, JSON.stringify(value));
+}
+
+const getLocalStorage = (key) => {
+  const value = localStorage.getItem(key);
+  return value ? JSON.parse(value) : null;
+}
+
+watch(minProfitRate, (newVal) => {
+  clearTimeout(updateTimer.value);
+  updateTimer.value = setTimeout(() => {
+    setLocalStorage('minProfitRate', newVal);
+    updateSolutions();
+  }, 1000);
+});
+
+watch(marketType, (newVal) => {
+  if (!updateLoaderHide.value) {
+    updateLoaderHide.value = message.loading('数据更新中...', 0);
+  }
+  clearTimeout(updateTimer.value);
+  updateTimer.value = setTimeout(() => {
+    setLocalStorage('marketType', newVal);
+    updateSolutions();
+  }, 1000);
+});
+
+onMounted(() => {
+  loopActive.value = true;
+  const min_win_rate = getLocalStorage('minProfitRate');
+  const mk = getLocalStorage('marketType');
+  if (min_win_rate !== null) {
+    minProfitRate.value = min_win_rate;
+  }
+  if (mk !== null) {
+    marketType.value = mk;
+  }
+  setTimeout(() => {
+    updateSolutions(true);
+  }, 100);
+});
+
+onUnmounted(() => {
+  loopActive.value = false;
+});
+
+
+</script>
+
+<template>
+  <div class="solution-container">
+
+    <div class="contents-header transition-all duration-200" :style="headerStyle">
+      <div class="solution-options">
+        <Form layout="inline" class="sol-opt-container">
+          <Form.Item label="盘口类型" class="sol-opt-item">
+            <RadioGroup v-model:value="marketType">
+              <Radio :value="-1">全部({{ markCount.all ?? 0 }})</Radio>
+              <Radio :value="2">滚球({{ markCount.rollball ?? 0 }})</Radio>
+              <Radio :value="1">今日({{ markCount.today ?? 0 }})</Radio>
+              <Radio :value="0">早盘({{ markCount.early ?? 0 }})</Radio>
+            </RadioGroup>
+          </Form.Item>
+          <Form.Item label="最小利润率(%)" class="sol-opt-item">
+            <InputNumber style="width: 60px" size="small" max="100" min="-100" step="0.1" placeholder="最小利润率(%)" v-model:value="minProfitRate"/>
+          </Form.Item>
+        </Form>
+      </div>
+      <div class="solution-header">
+        <span>PS</span>
+        <span>OB</span>
+        <span>HG</span>
+        <em>利润</em>
+      </div>
+    </div>
+
+    <div class="solution-list">
+      <div class="solution-item"
+        v-for="{ sid, sol: { win_average_rate, win_profit_rate, cross_type }, info: { ps, ob, hg }, selected, disabled } in solutionsList" :key="sid"
+        :class="{ 'selected': selected, 'disabled': disabled }">
+        <MatchCard platform="ps" :eventId="ps.eventId" :leagueName="ps.leagueName" :teamHomeName="ps.teamHomeName"
+          :teamAwayName="ps.teamAwayName" :dateTime="ps.dateTime" :eventInfo="ps.eventInfo" :events="ps.events ?? []"
+          :matchNumStr="ps.matchNumStr" :selected="ps.selected ?? []" :stage="ps.stage" :retime="ps.retime" :score="ps.score" />
+
+        <MatchCard platform="ob" :eventId="ob.eventId" :leagueName="ob.leagueName" :teamHomeName="ob.teamHomeName"
+          :teamAwayName="ob.teamAwayName" :dateTime="ob.dateTime" :events="ob.events ?? []"
+          :selected="ob.selected ?? []" :stage="ob.stage" :retime="ob.retime" :score="ob.score" />
+
+        <MatchCard platform="hg" :eventId="hg.eventId" :leagueName="hg.leagueName" :teamHomeName="hg.teamHomeName"
+          :teamAwayName="hg.teamAwayName" :dateTime="hg.dateTime" :events="hg.events ?? []"
+          :selected="hg.selected ?? []" :stage="hg.stage" :retime="hg.retime" :score="hg.score" />
+
+        <div class="solution-profit" @click="!disabled && toggleSolution(sid, ps.timestamp)">
+          <p>{{ win_average_rate }}%</p>
+          <p>{{ win_profit_rate }}%</p>
+          <p>{{ cross_type }}</p>
+        </div>
+      </div>
+    </div>
+    <div class="list-empty" v-if="!solutionsList.length">暂无数据</div>
+
+    <!-- <Drawer
+      title="综合利润方案"
+      placement="bottom"
+      height="600"
+      :visible="totalProfitVisible"
+      @close="closeTotalProfit"
+    >
+      <div class="solution-total-profit" v-if="totalProfitValue.solutions.length">
+        <div class="solution-item"
+          v-for="{ sid, info: { ps, ob, hg } } in totalProfitValue.solutions" :key="sid">
+          <MatchCard platform="ps" :eventId="ps.eventId" :leagueName="ps.leagueName" :teamHomeName="ps.teamHomeName"
+            :teamAwayName="ps.teamAwayName" :dateTime="ps.dateTime" :eventInfo="ps.eventInfo" :events="ps.events ?? []"
+            :matchNumStr="ps.matchNumStr" :selected="ps.selected ?? []" />
+
+          <MatchCard platform="ob" :eventId="ob.eventId" :leagueName="ob.leagueName" :teamHomeName="ob.teamHomeName"
+            :teamAwayName="ob.teamAwayName" :dateTime="ob.dateTime" :events="ob.events ?? []"
+            :selected="ob.selected ?? []" />
+
+          <MatchCard platform="hg" :eventId="hg.eventId" :leagueName="hg.leagueName" :teamHomeName="hg.teamHomeName"
+            :teamAwayName="hg.teamAwayName" :dateTime="hg.dateTime" :events="hg.events ?? []"
+            :selected="hg.selected ?? []" />
+        </div>
+      </div>
+      <div class="profit-info">
+        <table>
+          <tr>
+            <th></th>
+            <td>PS</td>
+            <td colspan="2">第一场</td>
+            <td colspan="2">第二场</td>
+          </tr>
+          <tr>
+            <th>赔率</th>
+            <td>{{ totalProfitValue.psInfo[0]?.v }}: {{ totalProfitValue.psInfo[1]?.v }}</td>
+            <td>{{ totalProfitValue.outPreSol[0]?.p }}: {{ totalProfitValue.outPreSol[0]?.v }}</td>
+            <td>{{ totalProfitValue.outPreSol[1]?.p }}: {{ totalProfitValue.outPreSol[1]?.v }}</td>
+            <td>{{ totalProfitValue.outSubSol[0]?.p }}: {{ totalProfitValue.outSubSol[0]?.v }}</td>
+            <td>{{ totalProfitValue.outSubSol[1]?.p }}: {{ totalProfitValue.outSubSol[1]?.v }}</td>
+          </tr>
+          <tr>
+            <th>下注</th>
+            <td>{{ psOptions.bet }}</td>
+            <td>{{ totalProfitValue.outPreSol[0]?.g }}</td>
+            <td>{{ totalProfitValue.outPreSol[1]?.g }}</td>
+            <td>{{ totalProfitValue.outSubSol[0]?.g }}</td>
+            <td>{{ totalProfitValue.outSubSol[1]?.g }}</td>
+          </tr>
+          <tr>
+            <th>利润</th>
+            <td>{{ totalProfitValue.profit.win_ps }}</td>
+            <td colspan="2">{{ totalProfitValue.profit.win_target }}</td>
+            <td colspan="2">{{ totalProfitValue.profit.win_target }}</td>
+          </tr>
+        </table>
+      </div>
+    </Drawer> -->
+
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.contents-header {
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 201;
+  width: 100%;
+  border-bottom: 1px solid hsl(var(--border));
+  background-color: hsl(var(--background));
+}
+
+.solution-options {
+  position: relative;
+  display: flex;
+  align-items: center;
+  padding: 5px 20px;
+  border-bottom: 1px solid hsl(var(--border));
+}
+
+.sol-opt-container {
+  flex-grow: 1;
+  justify-content: space-between;
+}
+
+.sol-opt-item:last-child {
+  margin-inline-end: 0 !important;
+}
+
+.solution-header {
+  position: relative;
+  display: flex;
+  align-items: center;
+  height: 30px;
+  padding: 0 20px;
+  span,
+  em {
+    display: block;
+    text-align: center;
+  }
+
+  span {
+    flex: 1;
+  }
+
+  em {
+    width: 80px;
+    font-style: normal;
+  }
+}
+
+.solution-container {
+  padding-top: 74px;
+}
+
+.solution-item {
+  display: flex;
+  .match-card {
+    flex: 1;
+  }
+}
+
+.solution-list {
+  padding: 20px;
+  overflow: hidden;
+
+  .solution-item {
+    border-radius: 10px;
+    background-color: hsl(var(--card));
+
+    &.selected {
+      background-color: hsl(var(--primary) / 0.15);
+    }
+
+    &.disabled {
+      opacity: 0.5;
+      cursor: not-allowed;
+    }
+
+    &:not(:last-child) {
+      margin-bottom: 20px;
+    }
+
+    .match-card {
+      border-right: 1px solid hsl(var(--border));
+    }
+
+    .solution-profit {
+      display: flex;
+      flex-direction: column;
+      width: 80px;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+}
+
+.profit-info {
+  table {
+    width: 100%;
+    border-collapse: collapse;
+    border-spacing: 0;
+    table-layout: fixed;
+    th, td {
+      height: 30px;
+      border: 1px solid hsl(var(--border));
+      text-align: center;
+    }
+    th {
+      width: 64px;
+      font-weight: normal;
+    }
+  }
+}
+
+.list-empty {
+  text-align: center;
+  padding: 10px;
+  font-size: 18px;
+  color: hsl(var(--foreground) / 0.7);
+}
+</style>