# ScheduleTree 赛程树
赛程模块核心组件。
# 引入
import PressScheduleTree from '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 '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为一个对象,包含battleList、roundInfo、isChamp等属性。battleList取名不恰当,这里指的是scheGroupList,battleList有2个代表有2个赛程组。scheGroup包含teamList、nodeItem、schid、realStatus、bracketIdDesc、curBo、boType等信息
示例数据可以参考这里 (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开头,比如schId、selectedSchId - 赛程组、赛程组对、赛程列表,以
sche开头,比如scheGroup、schePair、scheList
- 赛程组id,以
- events
- 不能以
on开头,比如不能命名为onclickSche,需改为clickSche - 不能与业务耦合,比如不能命名为
jumpToSetPage,需改为与业务无关的clickRoundTab
- 不能以
# 常见问题
# 整个页面跟着滚动,包括上面的tab
原因是page高度大于100%,下面scroll-view高度太大,可设置合适的元素为flex: 1。
# 整页滚动正常,但赛程树下面滑动后有空白
原因是单位不统一,有的用rpx,有的用rem。
# 赛程树累计错位
要么是某些DOM元素高度没有指定,要么是rpx转px时候舍弃了小数点,rpx转px公式如下:
// rpx 转换为 px ,传参类型是数字(Number)
export function rpxToPx(rpx) {
const deviceWidth = wx.getSystemInfoSync().windowWidth; //获取设备屏幕宽度
const px = (deviceWidth / 750) * Number(rpx);
return Math.floor(px);
}
px转rpx公式:
// 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 类型分别是 IScheColumn、ISchePair、IScheGroup、ITeamInfo。
淘汰赛上面几种数据结构的对应关系是,list = n列,1列 = n个赛程组对,1个赛程组对 = 2个或1个赛程组,1个赛程组 = 2个队伍。
[ [ { battleList: [{ teamList: [] }] } ] ]
循环赛和瑞士轮上面几种数据结构的对应关系是,list = n列,1列 = n个赛程组对,1个赛程组对 = 1个赛程组,1个赛程组 = 2个队伍。
循环赛和瑞士轮的区别是,循环赛每一轮的赛程组对数目是一样的,而瑞士轮是不一样的,且逐轮递增。
# 赛程树阶梯控制
淘汰赛和双败赛败者组,都有阶梯图,即下一列的 top 和 margin-bottom 有所不同。这是如何做到的呢,以及如何和循环赛、瑞士轮区分呢?
如果传入了 ladderMap 为 false(默认为 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-right 为 0 即可,上、下边框以及 after 伪元素一样存在。
对于瑞士轮,连接线包括每个赛程组对左右两侧的横线,和中间的竖线。左右两侧的横线可以在 press-schedule-tree-pair 增加伪元素 before、after 来实现,一左一右。中间的竖线需要在 press-schedule-tree-column 层级增加一个元素,其高度为列高度减去上下两个赛程组对的一半之和,top 偏移则为顶部赛程组对的一半。
瑞士轮的连接线也不可以不用计算的方式,用 pair-border 作偏移,第1个 pair 的 top 为 50%,最后一个 pair 的 bottom 为 50%,其余均为 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));
# 模拟数据实现
这里说下瑞士轮的实现,其他赛制都比较简单。
核心规则如下:
- 先确定轮数,若
n为队伍数,轮次数k = log2(n)向上取整 - 第一轮一定是所有队伍为0分
- 后面每轮,遍历每一种积分中的所有队伍:
- 当前积分队伍数若为偶数: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;
}
# 循环赛
循环赛有几点不同
- 分轮次查询数据
- 多了一层组的概念