淘汰赛对阵图生成demo

javascript 复制代码
<template>
	<div class="page">
		<section class="panel">
			<h1>淘汰赛对阵图生成器</h1>
			<div class="controls">
				<label class="field">
					<span>队伍数量(2-64)</span>
					<input v-model.number="teamCount" type="number" min="0" max="64" />
				</label>
				<div class="names">
					<div class="names-header">
						<span>队伍名称</span>
						<div class="actions">
							<button type="button" @click="autoFillNames">自动填充</button>
							<button type="button" class="primary" @click="generateBracket">生成对阵</button>
						</div>
					</div>
					<div class="name-grid">
						<div v-for="(name, idx) in teamInputs" :key="idx" class="name-item">
							<label>
								<span>#{{ idx + 1 }}</span>
								<input v-model="teamInputs[idx]" type="text" :placeholder="`队伍${idx + 1}`" />
							</label>
						</div>
					</div>
				</div>
			</div>
		</section>

		<section class="panel">
			<div class="panel-head">
				<h2>对阵图</h2>
			</div>
			<div v-if="rounds.length" class="bracket" :style="{ gridTemplateRows: `repeat(${gridRows}, 22px)` }">
				<div v-for="(round, rIdx) in rounds" :key="rIdx" class="round">
					<div v-for="(team, tIdx) in round" :key="`${rIdx}-${tIdx}`" class="match" :class="{
					    final: rIdx === rounds.length - 1,
					    hasConnector: rIdx < rounds.length - 1,
					    top: tIdx % 2 === 0,
					    bottom: tIdx % 2 === 1
					  }" :style="teamGridStyle(rIdx, tIdx)">
						<div class="team">
							<span class="seed" v-if="rIdx === 0">{{ tIdx + 1 }}</span>
							<span class="name">{{ team }}</span>
						</div>
					</div>
				</div>
			</div>
		</section>
	</div>
</template>
<script setup>
	import {
		computed,
		ref,
		watch
	} from 'vue'

	const teamCount = ref(8)
	const teamInputs = ref(Array.from({
		length: teamCount.value
	}, (_, i) => `队伍${i + 1}`))
	const rounds = ref([])

	const bracketSize = computed(() => (rounds.value.length ? rounds.value[0].length : 0))
	const gridRows = computed(() => bracketSize.value * 2)

	watch(teamCount, (val) => {
		const safe = Math.min(64, Math.max(2, Number(val) || 2))
		if (safe !== val) teamCount.value = safe
		if (teamInputs.value.length < safe) {
			const start = teamInputs.value.length
			for (let i = start; i < safe; i += 1) {
				teamInputs.value.push(`队伍${i + 1}`)
			}
		} else if (teamInputs.value.length > safe) {
			teamInputs.value.splice(safe)
		}
	})

	const isPowerOfTwo = (n) => n > 0 && (n & (n - 1)) === 0

	const makeRounds = (teams) => {
		const size = teams.length
		const result = []
		let current = []

		// 第一轮:每个队伍单独一个元素
		for (let i = 0; i < size; i++) {
			current.push(teams[i])
		}
		result.push(current)

		// 后续轮次:每轮队伍数量减半
		while (current.length > 1) {
			const next = []
			for (let i = 0; i < current.length / 2; i++) {
				next.push('上一场胜者')
			}
			result.push(next)
			current = next
		}

		return result
	}

	const generateBracket = () => {
		const names = teamInputs.value.map((t, i) => (t?.trim() ? t.trim() : `队伍${i + 1}`))
		const valid = names.filter(Boolean)
		if (valid.length < 2) {
			alert('至少需要 2 支队伍')
			return
		}
		if (!isPowerOfTwo(valid.length)) {
			alert('队伍数量需为 2 的整数次幂,例如 2/4/8/16')
			return
		}
		rounds.value = makeRounds(valid)
	}

	const autoFillNames = () => {
		teamInputs.value = Array.from({
			length: teamCount.value
		}, (_, i) => `队伍${i + 1}`)
	}

	const teamGridStyle = (roundIdx, teamIdx) => {
		// 所有div统一高度为50px,占1行
		// 第一轮:队伍占据奇数行(1, 3, 5, 7...)
		// 后续轮次:胜者占据偶数行,位置在前一轮对应两队的中间

		if (roundIdx === 0) {
			// 第一轮:每个队伍占1行,使用奇数行
			const start = teamIdx * 2 + 1
			return {
				gridRow: `${start} / span 1`
			}
		} else {
			// 后续轮次:递归计算前一轮对应两队的位置
			// 前一轮的队伍索引是 teamIdx*2 和 teamIdx*2+1
			const getRow = (rIdx, tIdx) => {
				if (rIdx === 0) {
					return tIdx * 2 + 1
				} else {
					const prevTeam1Row = getRow(rIdx - 1, tIdx * 2)
					const prevTeam2Row = getRow(rIdx - 1, tIdx * 2 + 1)
					return Math.round((prevTeam1Row + prevTeam2Row) / 2)
				}
			}

			const centerRow = getRow(roundIdx, teamIdx)
			return {
				gridRow: `${centerRow} / span 1`
			}
		}
	}
</script>
<style scoped>
	:global(body) {
		margin: 0;
		background: #f7f8fb;
		font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif;
		color: #1f2933;
	}

	.page {
		max-width: 1200px;
		margin: 32px auto 64px;
		padding: 0 20px;
		display: flex;
		flex-direction: column;
		gap: 20px;
	}

	.panel {
		background: #fff;
		border-radius: 12px;
		box-shadow: 0 10px 30px rgba(31, 41, 51, 0.08);
		padding: 20px;
	}

	.panel h1,
	.panel h2 {
		margin: 0 0 12px;
		font-weight: 700;
	}

	.panel-head {
		display: flex;
		align-items: center;
		gap: 10px;
	}

	.hint {
		color: #5f6b7a;
		font-size: 14px;
	}

	.controls {
		display: flex;
		flex-direction: column;
		gap: 16px;
	}

	.field {
		display: flex;
		align-items: center;
		gap: 12px;
		font-weight: 600;
	}

	input[type='number'],
	input[type='text'] {
		padding: 8px 10px;
		border: 1px solid #d5dae1;
		border-radius: 8px;
		outline: none;
		transition: 0.15s ease;
		font-size: 14px;
		width: 110px;
	}

	input[type='text'] {
		width: 100%;
	}

	input:focus {
		border-color: #3b82f6;
		box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
	}

	.names {
		border: 1px solid #e3e7ef;
		border-radius: 10px;
		padding: 12px;
	}

	.names-header {
		display: flex;
		justify-content: space-between;
		align-items: center;
		margin-bottom: 12px;
		font-weight: 600;
	}

	.actions {
		display: flex;
		gap: 10px;
	}

	button {
		border: 1px solid #cfd6e4;
		background: #fff;
		padding: 8px 12px;
		border-radius: 8px;
		cursor: pointer;
		font-weight: 600;
		transition: 0.15s ease;
	}

	button:hover {
		background: #f0f4ff;
		border-color: #94b3ff;
	}

	button.primary {
		background: #3b82f6;
		color: #fff;
		border-color: #3b82f6;
	}

	button.primary:hover {
		background: #2763c6;
	}

	.name-grid {
		display: grid;
		grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
		gap: 10px;
	}

	.name-item label {
		display: flex;
		align-items: center;
		gap: 8px;
		font-weight: 600;
		color: #475364;
	}

	.name-item span {
		min-width: 36px;
		text-align: right;
		color: #6b7687;
	}

	.bracket {
		display: grid;
		grid-auto-flow: column;
		grid-auto-columns: 90px;
		column-gap: 10px;
		position: relative;
		padding: 4px 0 2px;
		overflow-x: auto;
	}

	.round {
		display: grid;
		grid-template-rows: subgrid;
		grid-row: 1 / -1;
		align-content: start;
	}

	.round-title {
		font-weight: 700;
		color: #334155;
		margin-bottom: 10px;
	}

	.match {
		position: relative;
		background: linear-gradient(180deg, #f9fbff 0%, #f0f4ff 100%);
		border: 1px solid #d8e2f3;
		border-radius: 5px;
		padding: 4px 6px;
		display: flex;
		align-items: center;
		height: 22px;
		box-shadow: 0 3px 6px rgba(59, 130, 246, 0.08);
		font-size: 11px;
	}

	.match.hasConnector::after {
		content: '';
		position: absolute;
		right: -10px;
		top: 50%;
		width: 10px;
		height: 1.5px;
		background: #c4d4f5;
	}

	.match.hasConnector.top::before,
	.match.hasConnector.bottom::before {
		content: '';
		position: absolute;
		right: -10px;
		width: 1.5px;
		background: #c4d4f5;
	}

	.match.hasConnector.top::before {
		top: 50%;
		height: calc(100% + 11px);
		/* 半场距 */
	}

	.match.hasConnector.bottom::before {
		bottom: 50%;
		height: calc(100% + 11px);
	}

	.match.final::after {
		content: none;
	}

	.match.final::before,
	.match.final::after {
		display: none;
	}

	.team {
		display: flex;
		align-items: center;
		gap: 3px;
		font-weight: 600;
		color: #1f2937;
		width: 100%;
		font-size: 11px;
	}

	.seed {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-width: 14px;
		height: 14px;
		background: #e6ecfb;
		color: #3b5ab8;
		border-radius: 3px;
		font-size: 9px;
	}

	.name {
		flex: 1;
		text-align: left;
	}

	.champion {
		position: absolute;
		top: 50%;
		right: -60px;
		transform: translateY(-50%);
		font-weight: 700;
		color: #f97316;
	}

	@media (max-width: 768px) {
		.page {
			margin: 8px auto 16px;
			padding: 0 8px;
		}

		.panel {
			padding: 10px;
		}

		.bracket {
			grid-auto-columns: 80px;
			column-gap: 8px;
		}

		.match {
			padding: 4px 5px;
			height: 20px;
			font-size: 10px;
		}

		.team {
			font-size: 10px;
			gap: 2px;
		}

		.seed {
			min-width: 12px;
			height: 12px;
			font-size: 8px;
		}

		.match.hasConnector::after {
			right: -10px;
			width: 10px;
			height: 1px;
		}
	}
</style>
相关推荐
Java.熵减码农5 小时前
基于VueCli自定义创建项目
前端·javascript·vue.js
史上最菜开发5 小时前
Ant Design Vue V1.7.8版本,a-input 去空格
javascript·vue.js·anti-design-vue
前端不太难5 小时前
Vue Router 权限系统设计实战
前端·javascript·vue.js
醉挽清风7836 小时前
Vue+Djiango基础用法
前端·javascript·vue.js
菠菜盼娣7 小时前
Eslint 用法
vue.js
苏打水com7 小时前
第十七篇:Day49-51 前端工程化进阶——从“手动”到“自动化”(对标职场“提效降本”需求)
前端·javascript·css·vue.js·html
『 时光荏苒 』7 小时前
使用Vue播放M3U8视频流的方法
前端·javascript·vue.js
Tjohn97 小时前
前后端分离项目(Vue-SpringBoot)迁移记录
前端·vue.js·spring boot
鸭蛋超人不会飞7 小时前
axios简易封装,适配H5开发
前端·javascript·vue.js