# 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
为一个对象,包含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;
}
# 循环赛
循环赛有几点不同
- 分轮次查询数据
- 多了一层组的概念