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>