# ScheduleTree 赛程树

赛程模块核心组件。

# 引入

import PressScheduleTree from '@tencent/press-plus/press-schedule-tree/press-schedule-tree';

export default {
  components: {
    PressScheduleTree,
  }
}

# 代码演示

# 基础用法

<ScheduleTree
  :sche-list="scheList"
  :is-admin="isAdmin"
  :is-preview="isPreview"
/>

import ScheduleTree from '@tencent/press-plus/press-schedule-tree/press-schedule-tree.vue';

export default {
  components: {
    ScheduleTree,
  },
  data() {
    return {
      scheList: [],
      isAdmin: false,
      isPreview: 0,
    }
  },
  methods: {
  },
};

# API

# Props

参数 说明 类型 默认值
sche-list 赛程列表 array -
is-admin 是否为管理员 boolean false
is-preview 是否为预览阶段,0 不是, 1 普通预览,2 预览,且自定义了赛程 number 0
my-team-id 我的队伍id string -
selected-sch-id 选中的赛程id string -
show-champion 是否显示冠军列,循环赛时可设置为false boolean true
champion-round-name 冠军列轮次名称,可设置为冠军、分组冠军等,为空时,会取championTag的值 string -
champion-name 冠军队伍名称,可设置为具体队伍名称、待定等 string -
champion-tag 冠军标签,可设置为冠军、分组冠军等 string -
can-click-final-sche 是否可以点击冠军队伍 boolean false
shadow 是否设置间隔阴影,循环赛时可设置为true boolean false
auto-back-to-top 左右滚动时是否自动回到顶部,循环赛时可设置为false boolean true
resume-position 赛程数据变少,右侧显示空白时候自动滚动位置,可为left/right/数字 [string, number] right
show-round-tab 是否显示轮次tab boolean true
max-columns 同时显示的列数目,超出的部分会被隐藏,以提升性能 number 3
tab-scroll-width tab的宽度 string 164
schedule-scroll-width 赛程树每列的宽度 string 164
loading 是否为加载中 boolean false
loadingSize 加载中尺寸 string 20px
loadingText 加载中文案 string 加载中...
finished 是否加载完成 boolean false
finishedText 加载完成文案 string -
page 当前赛程树所在页,分组淘汰赛时可用 number 0
total-page 赛程树总页数,分组淘汰赛时可用 number 0
automatic-grouping 是否自动分组 boolean false
winner-name 冠军列的名称,可为冠军、季军等 string -
ladder-map 是否是阶梯赛程图,淘汰赛为 true,循环赛、瑞士轮为 false boolean false
schedule-name 赛制名称,可选值为 knockout, cycle, doubleFailLoser, swiss string knockout
show-round-b-o 是否显示轮次的 BO 信息 boolean true

# Events

事件名称 说明 回调参数
clickRoundTab 点击轮次tab round.round_type, round
clickSche 点击赛程组 schId, scheGroup, schePair
clickVideoIcon 点击视频小图标 schId, scheGroup, schePair
clickFinalSche 点击冠军队伍 -
scroll 左右滚动时触发 scrollTime(当前滚动次数,最左侧为0), roundTotal(轮次总数)
scrollToLower 滚动到底时触发 scrollTime(当前滚动次数,最左侧为0), roundTotal(轮次总数)

# Methods

方法名 说明 参数
setScrollTime 设置横向滚动次数,设置为0,可回到最左侧 scrollTime
setBackToTop 滚动到顶部 -
scrollToOneSche 竖向滚动到某个赛程组 schId

# Slots

名称 说明
loading 自定义底部加载中提示
finished 自定义加载完成后的提示文案

# scheList

  • scheList为二维数组,第一层数组的每一项对应每一列,第二层数组的每一项对应赛程组对schePair

  • schePair由1-2个赛程组scheGroup组成,1个scheGroup有两个队伍。

  • 每个schePair为一个对象,包含battleListroundInfoisChamp等属性。

  • battleList取名不恰当,这里指的是scheGroupListbattleList有2个代表有2个赛程组。

  • scheGroup包含teamListnodeItemschidrealStatusbracketIdDesccurBoboType等信息

示例数据可以参考这里 (opens new window)

type IScheList = Array<IScheColumn>

// 赛程列
type IScheColumn = Array<ISchePair>

// 赛程组对
type ISchePair = {
  // battleList 代表赛程组,长度为 2 或 1
  battleList: Array<IScheGroup>;
  roundInfo: IRoundInfo;
  isChamp: boolean;
  hidden: boolean;
}

// 赛程组,2个队伍
type IScheGroup = {
  teamList: Array<ITeamInfo>;
  nodeItem: {
    sch_dantao: {};
    sch_rule: {};
    sch_score: {};
  };
  schid: string;
  realStatus: number;
  bracketIdDesc: string;
  curBo: number;
  boType: number;
}


type ITeamInfo = {
  teamavatar: string;
  teamname: string;
  teamid: string;
}


type IRoundInfo = {
  round_type: string;
  round_id: number;
  round_name: string;
  round_stage: number;
  bo_type: number;
  start_battle_type: number;
  ready_time_utime?: number;
  grp_num?: number;
  bo_type: number;
}

上面是原始数据,经处理后的数据格式如下:

// 传给 press-schedule-team
type IScheGroupInfo = {
  schid: string;
  bracketIdDesc?: string;
  timeDesc?: string;

  statusDesc?: string;
  isStatusHighList?: boolean;

  showLiveIcon?: boolean;
  showChannelsIcon?: boolean;
  abnormalErr?: boolean; // 是否为顶号

  upTeamInfo: ITeamInfo;
  downTeamInfo: ITeamInfo;
}

type ITeamInfo = {
  isWinner?: number; // 1 表示为胜者
  scoreDesc?: string;
  teamname?: string;
  teamid?: string;

  teamAvatarSkeleton?: boolean;
  teamAvatar?: string;

  teamNameSkeleton?: boolean;
  teamNameDesc?: string;
}

// 传给 press-schedule-tree
type IScheList = Array<Array<IScheGroupInfo>>

# 命名规范

  • props
    • 赛程组id,以sch开头,比如schIdselectedSchId
    • 赛程组、赛程组对、赛程列表,以sche开头,比如scheGroupschePairscheList
  • events
    • 不能以on开头,比如不能命名为onclickSche,需改为clickSche
    • 不能与业务耦合,比如不能命名为jumpToSetPage,需改为与业务无关的clickRoundTab

# 常见问题

# 整个页面跟着滚动,包括上面的tab

原因是page高度大于100%,下面scroll-view高度太大,可设置合适的元素为flex: 1

# 整页滚动正常,但赛程树下面滑动后有空白

原因是单位不统一,有的用rpx,有的用rem

# 赛程树累计错位

要么是某些DOM元素高度没有指定,要么是rpxpx时候舍弃了小数点,rpxpx公式如下:

// rpx 转换为 px ,传参类型是数字(Number)
export function rpxToPx(rpx) {
  const deviceWidth = wx.getSystemInfoSync().windowWidth; //获取设备屏幕宽度
  const px = (deviceWidth / 750) * Number(rpx);
  return Math.floor(px);
}

pxrpx公式:

// px 转换为 rpx ,传参类型是数字(Number)
export function pxToRpx(px) {
  const deviceWidth = wx.getSystemInfoSync().windowWidth; //获取设备屏幕宽度
  const rpx = (750 / deviceWidth) * Number(px);
  return Math.floor(rpx);
}

# 解决pc端多余的滚动条

::v-deep {
  .press-schedule-tree {
    overflow-y: auto;
  }
  .press-schedule-tree-main {
    overflow-y: auto;
  }
}

# 瑞士轮

瑞士轮的结构这里解析下,与循环赛、淘汰赛总体一致,都是列、赛程组对、赛程组、队伍,对应上面的 typescript 类型分别是 IScheColumnISchePairIScheGroupITeamInfo

淘汰赛上面几种数据结构的对应关系是,list = n列,1列 = n个赛程组对,1个赛程组对 = 2个或1个赛程组,1个赛程组 = 2个队伍。

[ [ { battleList: [{ teamList: [] }] } ] ]

循环赛和瑞士轮上面几种数据结构的对应关系是,list = n列,1列 = n个赛程组对,1个赛程组对 = 1个赛程组,1个赛程组 = 2个队伍。

循环赛和瑞士轮的区别是,循环赛每一轮的赛程组对数目是一样的,而瑞士轮是不一样的,且逐轮递增。

# 赛程树阶梯控制

淘汰赛和双败赛败者组,都有阶梯图,即下一列的 topmargin-bottom 有所不同。这是如何做到的呢,以及如何和循环赛、瑞士轮区分呢?

如果传入了 ladderMapfalse(默认为 true),则不会展示“阶梯图”,适用于循环赛、瑞士轮。

对于淘汰赛和双败赛败者组,组件内部根据每列的赛程组数,以及滚动次数,自动判断每一列应该偏移多少。核心逻辑如下:

computed: {
  /**
   * 赛程树滚动列表
   *
   * 形如:
   *  [0, 1, 2, 3]
   *  [-1, 0, 1, 2]
   *  [-2, -1, 0, 1]
   *  [-3, -2, -1, 0]
   *
   * 或者双败赛
   *  [0, 1, 1, 2, 2, 3, 3]
   *  [-1, 0, 0, 1, 1, 2, 2]
   *  [-2, -1, 0, 1, 1, 2, 2]
   *
   *  // ...
   *
   *  [-6, -5, -5, -4, -4, -3, 0]
   */
  scrollList() {
    const { scheGroupList, scrollTime, ladderMap } = this;
    const res = [0 - scrollTime];
    if (!ladderMap) {
      return Array.from({ length: scheGroupList.length }).map(() => 0);
    }

    // 观察前一个赛程组对树数目,和当前的是否相等
    for (let i = 1;i < scheGroupList.length;i++) {
      const last = res[res.length - 1];
      let cur;
      if (scheGroupList[i] === scheGroupList[i - 1]) {
        cur = last;
      } else {
        cur = last + 1;
      }
      if (scrollTime === i && cur < 0) {
        cur = 0;
      }
      res.push(cur);
    }

    return res;
  },
  /**
   * 赛程组个数列表,每个赛程组两个队伍
   *
   * 形如 [8, 4, 2, 1]
   *
   * 或者双败赛
   * [8, 4, 4, 2, 2, 1, 1]
   */
  scheGroupList() {
    const { scheList } = this;
    const temp = scheList.map((sche) => {
      const sum = sche.reduce((acc, item) => acc + item.battleList?.length, 0);
      return sum;
    });
    return temp;
  },
}

拿到 scrollList 后,模板中的每一列根据当前滚动的次数,设置对应的偏移量,以及其他的样式控制:

<div
  :style="{height: getColumnStyle(scrollList[colIndex]).height,
            overflow: getColumnStyle(scrollList[colIndex]).overflow
  }"
  class="press-schedule-tree-column"
  :class="[
    `press-schedule-tree-column--scroll-${scrollList[colIndex]}`,
    {
      'press-schedule-tree-column--shadow': shadow && colIndex % 2 === 1,
    }
  ]"
>
</div>
methods: {
  getColumnStyle(scrollTime) {
    const { maxColumns } = this;

    if (scrollTime > maxColumns - 1) {
      return { height: 0, overflow: 'hidden' };
    }
    if (scrollTime == maxColumns - 1) {
      return { height: 'calc(100% - 174px)', overflow: 'unset' };
    }
    if (scrollTime == 1) {
      return { height: 'calc(100% - 58px)', overflow: 'unset' };
    }
    if (scrollTime < 0) {
      return { height: 0, overflow: 'hidden' };
    }
    return { height: 'auto', overflow: 'unset' };
  },
}

# 赛程树连接线实现

首先循环赛没有连接线。

对于淘汰赛,在赛程组对后面增加一个 press-schedule-tree-pair-border 元素,专门用来做连接线。该元素上、下、右设置border,并设置一个after的伪元素,用来做右半边(也是下一列)的连接线,就是这么简单,一个元素解决问题。

<div
  v-if="!shadow"
  class="press-schedule-tree-pair-border"
  :class="{
    'press-schedule-tree-pair-border--parallel':
      scrollList[colIndex + 1] === scrollList[colIndex]
  }"
/>

这样做是充分利用了,赛程组对是连接线的基本单位。并且,border 的逻辑是高内聚的,方便维护和扩展。

对于双败赛败者组,非平行的部分和淘汰赛一致,平行的部分只需要设置 border-right0 即可,上、下边框以及 after 伪元素一样存在。

对于瑞士轮,连接线包括每个赛程组对左右两侧的横线,和中间的竖线。左右两侧的横线可以在 press-schedule-tree-pair 增加伪元素 beforeafter 来实现,一左一右。中间的竖线需要在 press-schedule-tree-column 层级增加一个元素,其高度为列高度减去上下两个赛程组对的一半之和,top 偏移则为顶部赛程组对的一半。

瑞士轮的连接线也不可以不用计算的方式,用 pair-border 作偏移,第1个 pairtop50%,最后一个 pairbottom50%,其余均为 0,也就是拉到最大。

/**
 * 获取赛程组对的高度
 * 24 标题高度,8 标题的 margin-bottom,100 两个队伍加 padding 高度,16 赛程组间隔
 * @param pairLength 赛程组对的个数
 * @returns 高度
 */
export function getPairBoxHeight(pairLength: number) {
  return 24 + 8 + 100 * pairLength + 16 * (pairLength - 1);
}
const rawStyle = {
  // 垂直线的长度 = 全长 - (第一个 + 最后一个)分组长度的一半
  height: `${totalHeight - (firstBoxHeight / 2 + firstBoxHeight / 2)}px`,
  top: `${firstBoxHeight / 2}px`,
};

rawStyles.push(style(rawStyle));

# 模拟数据实现

这里说下瑞士轮的实现,其他赛制都比较简单。

核心规则如下:

  1. 先确定轮数,若 n 为队伍数,轮次数 k = log2(n) 向上取整
  2. 第一轮一定是所有队伍为0分
  3. 后面每轮,遍历每一种积分中的所有队伍:
    • 当前积分队伍数若为偶数:1/2的队伍+1分,另1/2保持当前分数。(模拟一半获胜,一半失败)
    • 当前积分队伍数若为奇数:1/2向上取整的队伍数+1分,1/2队伍数向下取整队伍数保留当前分数。(一半获胜,一半失败,1队轮空)

下面是核心代码,可以根据队伍数,获得每一轮不同积分对应的比赛数。

export function getSwissScheMap(teamNumber: number) {
  const totalRound = Math.ceil(Math.log(teamNumber) / Math.log(2));
  const map = {
    0: (2 ** totalRound) / 2,
  };

  const result: any = [map];

  for (let curRound = 1;curRound < totalRound;curRound ++) {
    const lastMap: any = result[result.length - 1];
    const scoreKeys = Object.keys(lastMap);
    const curMap: Record<string, any> = {};

    for (const scoreKey of scoreKeys) {
      const teamLen = lastMap[scoreKey as keyof typeof lastMap];
      const winner = Math.ceil(teamLen / 2);
      const loser = Math.floor(teamLen / 2);

      if (curMap[scoreKey]) {
        curMap[scoreKey] += loser;
      } else {
        curMap[scoreKey] = loser;
      }

      if (curMap[+scoreKey + 1]) {
        curMap[+scoreKey + 1] += winner;
      } else {
        curMap[+scoreKey + 1] = winner;
      }
    }
    result.push(curMap);
  }
  return result;
}

# 循环赛

循环赛有几点不同

  1. 分轮次查询数据
  2. 多了一层组的概念
横屏