
背景:为什么要做这个项目?
Counter-Strike 2(反恐精英 2,也称 CS2) 是由 Valve 开发并发行的一款在线射击游戏,是 Counter-Strike: Global Offensive(反恐精英:全球攻势,也称 CSGO)的续作。CS2 采用全新的起源 2(Source2)引擎开发,不仅推出了新式烟雾弹和子刷新频率构架等更新,游戏的画质也得到了大幅升级、地图也翻新制作,是 Steam 上最热门的游戏。

Major 是 CS2 中最重要的赛事,一般每年举办两次。在 Major 期间,游戏内会推出相应的活动,其中就有竞猜系统,玩家可以通过这个竞猜系统来预测比赛结果,预测成功可以完成竞猜任务,完成一定数量的任务可以持续升级赛事纪念币,并获得丰厚奖励。


Major 竞猜凭借着它 3000 万+ 月活的庞大玩家群体基础,和它本身的趣味性和娱乐性,吸引了大量玩家关注,其中就包括我,虽然上半年的奥斯汀 Major 我绞尽脑汁只拿了个银币,都怪菊花队,这届奥斯汀 Major 能拿钻石币的都是什么神人。
在近几次的 Major 期间,有多位 UP 主搜集了大量主播竞猜情况并将其汇总成图片,在各大社交媒体、群聊间被转发传阅参考。今年的 Major 在布达佩斯(Budapest,匈牙利的首都)举办,依然有不少 UP 主在赛前搜集主播的竞猜作业(比赛竞猜情况,俗称「作业」,参考他人的竞猜作业,也称「抄作业」)并制作了汇总图片供大家参考。例如来自 B 站的 @原劫色 和 @三米七七 等 UP 主,他们发布了多篇相关视频,汇总了大量主播竞猜作业:

然而,单纯依靠图片来追踪竞猜进度并不方便,需要自己一个个人眼对照核对,非常的反人类。比如上图的布达佩斯 Major 第一阶段作业汇总,在比赛进行到一半时,通过人眼核对可以知道大家的 3-0 和 0-3 都炸的差不多了,极少主播的竞猜还能生还,真惨吧,但具体是谁还需要一个个去对照,效率极低。
这不,需求来了!作为一个搬了两三年砖的前端开发,为了更高效地参考和跟踪主播竞猜进度,我决定动手写一个在线抄作业项目,将这些主播竞猜作业数据结构化,并提供便捷的查询和排名等功能。
技术选型:为什么选 Next.js 16?
做这个项目的时候,我的核心诉求其实很明确:
- 要快 - Major 比赛周期很紧凑,我需要在比赛开始前就上线
- 数据量小 - 就十几二十个主播的竞猜数据,根本不需要后端数据库
- 要好看 - 毕竟是给玩家用的,UI 得过得去
- 要能快速迭代 - 比赛期间可能需要频繁更新数据和功能
基于这些需求,我选择了以下技术栈:
Next.js 16 (Canary) + App Router
最开始其实纠结过要不要用 Next.js,因为这个项目完全不需要 SSR,纯静态部署就行。但后来发现 Next.js 的几个特性太香了:
- App Router 的文件路由 :不用手写路由配置,
app/predictors/page.tsx直接就是/predictors路由 - 静态生成 :
next build直接输出静态 HTML,部署到 Vercel 零配置 - React Server Components:大部分页面都是 RSC,bundle size 小很多
- Turbopack :开发时编译飞快,
pnpm dev --turbopack启动不到 1 秒
版本选了 16.1.0-canary.13,主要是想试试最新的 React 19 和 Turbopack。虽然是 canary 版本,但实际用下来很稳定,没遇到什么坑。
React 19 - 最新特性
React 19 带来了一些不错的优化:
- 自动批处理优化:连续的 setState 会自动合并
- use() Hook :可以在组件中直接
use(promise),不过这个项目还没用到 - 优化的 hydration:虽然我这个项目是纯静态的,但未来可能会加一些交互功能
TypeScript - 严格模式
这个必须的,竞猜数据结构比较复杂(瑞士轮、淘汰赛、不同阶段),没有类型约束很容易写出 bug。
我在 types/index.ts 里定义了完整的类型系统:
typescript
// 瑞士轮结果包含进行中的战绩和最终结果
export interface SwissResult {
// 进行中的战绩
'1-0': string[]
'2-1': string[]
'2-2': string[]
// ... 其他战绩
// 最终结果
'3-0': string[]
'3-1': string[]
'3-2': string[]
// ... 其他结果
}
// 阶段类型
export type SwissStageType = 'stage-1' | 'stage-2' | 'stage-3'
export type FinalStageType = '8-to-4' | '4-to-2' | '2-to-1'
严格的类型定义让我在写业务逻辑时几乎不用担心类型错误,IDE 的智能提示也非常准确。
Tailwind CSS v4 - 新一代样式方案
这个选择有点激进,Tailwind v4 现在还处于早期阶段,但我看了官方文档后觉得值得一试。
v4 最大的变化是配置方式改了:
- 不再需要
tailwind.config.ts:配置全部写在 CSS 文件里 - 使用 CSS 变量定义主题:完美支持明暗主题切换
- PostCSS 插件简化 :只需要
@tailwindcss/postcss
我的主题配置全部在 app/globals.css 里:
css
@import 'tailwindcss';
:root {
/* 明亮主题变量 */
--foreground: #18181b;
--color-surface-0: #ffffff;
--color-primary-500: #ff6b35;
/* ... 其他变量 */
}
.dark {
/* 暗色主题变量 */
--foreground: #fafafa;
--color-surface-0: #0a0a0a;
/* ... 其他变量 */
}
然后定义一些语义化的工具类:
css
.text-primary {
color: var(--foreground);
}
.hover-text-primary:hover {
color: var(--foreground);
}
这样写组件的时候就不用到处写 text-zinc-900 dark:text-white,直接用 text-primary 就能自适应主题,代码简洁很多。
架构设计:纯静态数据驱动
这个项目的架构其实很简单,核心理念就是 Data as Code。
数据层设计
所有数据都存在两个 JSON 文件里:
bash
data/
├── events.json # 赛事数据(队伍、赛程、结果)
└── predictions.json # 竞猜数据(各主播的竞猜)
events.json 的结构:
json
{
"id": "budapest-2025",
"name": "布达佩斯 2025 Major",
"teams": [...],
"stage-1": {
"name": "第一阶段",
"teams": ["Liquid", "Spirit", ...],
"result": {
"3-0": ["Spirit"],
"3-1": ["Liquid", "G2", "Vitality"],
// 进行中的战绩
"2-1": ["FaZe", "MOUZ"],
"1-2": ["NAVI", "Heroic"]
}
}
}
predictions.json 的结构:
json
{
"id": "budapest-2025",
"predictions": [
{
"id": "s1mple",
"name": "s1mple",
"platform": "twitch",
"stage-1": {
"3-0": ["Spirit", "Liquid"],
"3-1-or-3-2": ["G2", "Vitality", "FaZe", "MOUZ", "NAVI", "Heroic"],
"0-3": ["BIG", "9z"]
}
}
]
}
核心业务逻辑
所有计算逻辑都在 lib/data.ts 里,这个文件有 800+ 行,是整个项目最复杂的部分。主要包含:
1. 竞猜准确度计算
瑞士轮的规则比较复杂:
- 竞猜 3-0 的队伍,必须实际 3-0 才算对(不能是 3-1 或 3-2)
- 竞猜 3-1-or-3-2 的队伍,实际 3-1 或 3-2 都算对
- 竞猜 0-3 的队伍,必须实际 0-3 才算对
这里有个细节:比赛进行中时,有些队伍的最终结果还没出来(比如现在战绩是 2-1),但我们可以提前判断某些竞猜已经"死了"。比如你竞猜某队 3-0,但它现在已经 2-1 了(输了一场),那这个竞猜就不可能对了。
typescript
export function isPredictionPossible(
teamName: string,
predictionBucket: '3-0' | '3-1-or-3-2' | '0-3',
result: SwissResult | undefined,
): boolean {
if (!result) return true
// 检查队伍当前战绩
for (const record of progressRecords) {
if (result[record]?.includes(teamName)) {
const [wins, losses] = record.split('-').map(Number)
// 3-0 竞猜:有任何失败就不可能
if (predictionBucket === '3-0') {
return losses === 0
}
// 0-3 竞猜:有任何胜利就不可能
if (predictionBucket === '0-3') {
return wins === 0
}
// 3-1-or-3-2:只要还在比赛就有可能
return true
}
}
return true
}
这个函数让我们可以在比赛进行中就实时显示"已失败"的竞猜数量,体验好很多。
2. 阶段通过判定
每个阶段都有通过标准:
- 瑞士轮:10 个竞猜中 5 个对就通过
- 八进四:4 个竞猜中 2 个对就通过
- 半决赛:2 个竞猜中 1 个对就通过
- 决赛:猜对冠军才通过
typescript
function checkSwissStagePass(
stageId: TaskStageType,
prediction: StagePrediction | undefined,
actual: SwissResult | undefined,
): StagePassStatus {
const requiredCount = 5 // 需要 5 个正确
let correctCount = 0
let impossibleCount = 0
// 检查 3-0 竞猜
for (const team of prediction['3-0']) {
if (actual['3-0'].includes(team)) {
correctCount++
} else if (!isPredictionPossible(team, '3-0', actual)) {
impossibleCount++
}
}
// ... 检查其他类别
// 判断通过状态
return {
passed:
correctCount >= requiredCount ? true :
impossibleCount > 5 ? false : // 已经不可能通过了
null // 还不确定
}
}
这里的 passed 有三种状态:true(通过)、false(失败)、null(待定),这样可以在比赛进行中就提前判断某些主播"已经寄了"。
3. 赛事进度跟踪
这个功能花了我不少时间。一开始我想的是手动配置当前进行到哪个阶段,但后来发现这样太麻烦了,每次比赛结束都要改代码。
最后我实现了一个自动进度检测的逻辑:
typescript
export function getEventProgress(event: MajorEvent): EventProgress {
const stagesProgress: StageProgress[] = []
// 检查每个阶段的结果完整度
for (const stage of ['stage-1', 'stage-2', 'stage-3', 'finals']) {
const hasResults = hasStageResults(event, stage)
const isComplete = isStageComplete(event, stage)
stagesProgress.push({
stageId: stage,
status: isComplete ? 'completed' :
hasResults ? 'in_progress' :
'not_started'
})
}
// 自动判断当前阶段
const currentStage = stagesProgress.find(s => s.status === 'in_progress')
return {
eventStatus: determineEventStatus(stagesProgress),
currentStage: currentStage?.stageId || null,
completedStages: stagesProgress
.filter(s => s.status === 'completed')
.map(s => s.stageId)
}
}
现在我只需要更新 events.json 里的比赛结果,页面会自动:
- 显示当前进行到哪个阶段
- 隐藏还没开始的阶段
- 高亮正在进行的阶段
完全不用改代码,数据驱动一切!
组件设计
组件结构遵循 Next.js App Router 的最佳实践:默认 Server Components,必要时才用 Client Components。
项目里只有 5 个 Client Component:
typescript
// components/ThemeProvider.tsx
'use client'
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('system')
// 使用 localStorage 和 useEffect
}
// components/ThemeToggle.tsx
'use client'
export function ThemeToggle() {
const { theme, setTheme } = useTheme()
// 使用 onClick 交互
}
// components/EventContext.tsx
'use client'
export const EventContext = createContext(...)
// 使用 Context 和 State
// components/EventSelector.tsx
'use client'
export function EventSelector() {
// 表单交互
}
// app/predictions/StageNav.tsx
'use client'
export function StageNav() {
// 客户端路由状态
}
其他所有页面和组件都是 Server Components,好处是:
- Bundle size 更小:Server Components 的代码不会打包到客户端
- 首屏渲染更快:直接返回 HTML,不需要 hydration
- 数据获取更简单 :可以直接
importJSON 数据
举个例子,排行榜页面的代码非常简洁:
typescript
// app/predictors/page.tsx
import { getAllPredictorStats } from '@/lib/data'
export default function PredictorsPage() {
const stats = getAllPredictorStats('budapest-2025')
return (
<div>
{stats.map(stat => (
<div key={stat.id}>
{stat.name}: {stat.totalPassed}/{stat.totalStages}
</div>
))}
</div>
)
}
不需要 useState、useEffect、fetch,直接调用函数拿数据就行,太爽了。
开发体验:Claude Code 真香
这个项目大概 70% 的代码是我和 Claude Code 配合完成的。我主要负责:
- 产品需求和交互设计
- 数据结构设计
- 核心业务逻辑的思路
- Code Review 和 Bug 修复
Claude Code 主要负责:
- 页面组件实现
- 样式调整
- 类型定义完善
- 重复性工作
具体流程大概是这样的:
lua
我:「帮我实现一个排行榜页面,显示每个主播通过了几个阶段」
Claude: 「好的,我先看看数据结构...
需要调用 getAllPredictorStats 函数...
布局用 table 还是 card?」
我:「移动端用 card,桌面端用 table」
Claude: 「收到,开始实现...」
[写完代码]
「已完成,使用了响应式布局,mobile 是 card view...」
我:「主题色不对,应该用 CS2 的橙色」
Claude: 「了解,我看看配置...
需要在 globals.css 里加 --color-primary-500...」
这种协作模式效率很高,我只需要关注"做什么"和"为什么",具体的"怎么做"交给 Claude。
当然也不是完全没问题,遇到过几次比较坑的:
坑 1:Tailwind v4 配置混乱
一开始 Claude 老是想创建 tailwind.config.ts,我说了好几次"v4 不需要 config 文件"它才理解。后来我直接在项目的 CLAUDE.md 里写明了配置方式,就没问题了。
坑 2:类型推断不够准确
有些复杂的类型推断 Claude 会搞错,比如这个:
typescript
// 我期望的类型
type StageId = SwissStageType | 'finals'
// Claude 写的类型
type StageId = string // 太宽泛了
这种情况我会直接改类型定义,然后让它重新生成相关代码。
坑 3:过度工程
Claude 有时候会想得太多,比如我让它实现一个简单的队伍 Logo 组件,它给我写了个支持 lazy loading、error boundary、skeleton loading 的复杂组件...
我的原则是:先实现最简单能用的版本,需要优化再说。
性能优化:从 3.2MB 到 180KB
最开始打包完发现 bundle size 有 3.2MB,吓了一跳。分析了一下主要问题:
1. JSON 数据没有 tree-shaking
events.json 和 predictions.json 虽然是静态导入的,但打包时会全部打进 bundle。
解决方案:把数据处理逻辑全部放在 Server Component 里,JSON 数据永远不会发送到客户端。
2. 图标库太大
一开始用的 react-icons,发现打包后有 800KB+。
解决方案:删掉 react-icons,手动写几个 SVG 图标:
typescript
// components/icons.tsx
export function MoonIcon() {
return (
<svg width="20" height="20" viewBox="0 0 20 20">
<path d="M..." fill="currentColor" />
</svg>
)
}
只需要月亮、太阳、链接这几个图标,手写 SVG 体积可以忽略不计。
3. 不必要的 Client Component
最开始有些组件没必要是 Client Component,但我习惯性写了 'use client'。
解决方案:仔细检查每个组件,只在必要时才用 'use client':
- 需要
useState、useEffect等 hooks → Client Component - 需要事件处理(onClick、onChange) → Client Component
- 纯展示逻辑 → Server Component
优化后的 bundle size:
- JS (First Load): 180KB
- CSS: 12KB
- Images: 按需加载(Next.js Image 组件自动优化)
Lighthouse 跑分:
- Performance: 98
- Accessibility: 100
- Best Practices: 100
- SEO: 100
响应式设计:移动优先
CS2 玩家很多都是用手机查看竞猜数据的,所以移动端体验特别重要。
我的设计原则是 Mobile First:
css
/* 默认是移动端样式 */
.container {
padding: 1rem;
font-size: 0.875rem;
}
/* sm: 640px 以上 */
@media (min-width: 640px) {
.container {
padding: 1.5rem;
font-size: 1rem;
}
}
/* md: 768px 以上 */
@media (min-width: 768px) {
.container {
padding: 2rem;
}
}
在 Tailwind 里就是:
tsx
<div className="px-4 sm:px-6 md:px-8 text-sm sm:text-base">
移动端特殊优化
1. 刘海屏适配
iPhone 的刘海屏需要特殊处理:
css
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
2. 表格转卡片
桌面端用表格,移动端用卡片:
tsx
{/* 移动端:Card View */}
<div className="md:hidden">
{stats.map(stat => (
<div key={stat.id} className="card">
<div>{stat.name}</div>
<div>{stat.totalPassed}/{stat.totalStages}</div>
</div>
))}
</div>
{/* 桌面端:Table View */}
<table className="hidden md:table">
<thead>...</thead>
<tbody>
{stats.map(stat => (
<tr key={stat.id}>
<td>{stat.name}</td>
<td>{stat.totalPassed}/{stat.totalStages}</td>
</tr>
))}
</tbody>
</table>
3. 触摸优化
移动端的点击区域要足够大:
css
.touch-target {
min-height: 44px; /* iOS 建议的最小触摸尺寸 */
padding: 0.75rem;
}
明暗主题实现
主题切换是个很常见的需求,但 Tailwind v4 的实现方式和之前不太一样。
主题状态管理
用 React Context + localStorage 实现:
typescript
// components/ThemeProvider.tsx
'use client'
type Theme = 'light' | 'dark' | 'system'
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState<Theme>('system')
useEffect(() => {
const saved = localStorage.getItem('theme') as Theme
if (saved) setTheme(saved)
}, [])
useEffect(() => {
const root = document.documentElement
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches ? 'dark' : 'light'
const effectiveTheme = theme === 'system' ? systemTheme : theme
root.classList.remove('light', 'dark')
root.classList.add(effectiveTheme)
localStorage.setItem('theme', theme)
}, [theme])
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
CSS 变量定义
在 globals.css 里定义主题变量:
css
:root {
--foreground: #18181b;
--color-surface-0: #ffffff;
--color-primary-500: #ff6b35;
--color-win: #22c55e;
--color-lose: #ef4444;
}
.dark {
--foreground: #fafafa;
--color-surface-0: #0a0a0a;
--color-primary-500: #ff8c61;
--color-win: #4ade80;
--color-lose: #f87171;
}
语义化类名
避免到处写 dark: 前缀:
css
/* 不好的做法 */
.text {
@apply text-zinc-900 dark:text-white;
}
/* 好的做法 */
.text-primary {
color: var(--foreground);
}
使用时就很简洁:
tsx
<h1 className="text-primary">标题</h1>
主题切换时会自动应用对应的颜色,不需要任何额外的类名。
部署:Vercel 一把梭
部署选择了 Vercel,理由很简单:
- 和 Next.js 深度集成:零配置部署
- 自动 HTTPS:不用自己搞证书
- 全球 CDN:访问速度快
- 预览部署:每个 PR 自动生成预览链接
部署流程:
bash
# 1. 推送到 GitHub
git push origin main
# 2. Vercel 自动检测并构建
# 3. 几分钟后自动上线
就这么简单,连 CI/CD 配置都不用写。
域名配置
在 Vercel 后台添加自定义域名 major.viki.moe,然后在域名服务商那里添加一条 CNAME 记录指向 Vercel 就行了。HTTPS 证书 Vercel 会自动申请和续期。
遇到的问题和解决方案
问题 1:竞猜数据录入太慢
一开始我是手动看图片一个个录入数据,十几个主播录了两个小时,太痛苦了。
解决方案:写了个简单的数据录入脚本:
typescript
// scripts/add-prediction.ts
const prediction = {
id: 's1mple',
name: 's1mple',
platform: 'twitch',
'stage-1': {
'3-0': ['Spirit', 'Liquid'],
'3-1-or-3-2': ['G2', 'Vitality', 'FaZe', 'MOUZ', 'NAVI', 'Heroic'],
'0-3': ['BIG', '9z']
}
}
// 追加到 predictions.json
虽然还是需要手动输入,但至少不用在 JSON 里手写引号和逗号了。
未来如果数据量大了,可能会考虑做个后台管理界面,或者直接 OCR 识别图片。
问题 2:队伍 Logo 加载失败
有些战队的 Logo 在 Steam CDN 上找不到,导致页面出现破图。
解决方案:实现了一个带 fallback 的 Logo 组件:
typescript
export function TeamLogo({ team }: { team: string }) {
const [error, setError] = useState(false)
if (error) {
return (
<div className="w-8 h-8 rounded bg-surface-1 flex items-center justify-center">
<span className="text-xs font-bold">{team.slice(0, 2)}</span>
</div>
)
}
return (
<Image
src={`https://steamcdn-a.akamaihd.net/apps/csgo/images/teams/${team}.png`}
alt={team}
width={32}
height={32}
onError={() => setError(true)}
/>
)
}
找不到 Logo 就显示战队名称的缩写,体验好很多。
问题 3:阶段进度判断逻辑复杂
这个是最头疼的问题。赛事有多个阶段,每个阶段又有不同的完成标准:
- 瑞士轮:需要所有战绩组都有数据(3-0 两队,3-1 三队...)
- 淘汰赛:需要 winners 和 losers 都有数据
- 决赛:只需要有 winner
而且还要区分"进行中"和"已完成"两种状态。
解决方案 :写了一套状态机逻辑 getEventProgress(),根据实际数据自动判断当前状态。这个函数有 100+ 行,但把所有边界情况都考虑进去了。
测试方法也很简单:手动修改 events.json 里的数据,看页面显示是否正确。
未来计划
目前项目还比较基础,后续打算加一些功能:
1. 历史 Major 数据
把之前几届 Major 的数据也录入进来,可以看主播的历史战绩。
2. 统计图表
用 Chart.js 或者 Recharts 做一些可视化:
- 每个主播的通过率趋势
- 各队伍被竞猜的次数
- 3-0 和 0-3 的命中率对比
3. 竞猜推荐
根据历史数据和当前形势,给出一个"推荐竞猜",帮助玩家做决策。
不过这个得小心,别搞成"割韭菜"工具...
4. 数据导出
支持导出成图片或者 Excel,方便分享。
5. PWA 支持
加个 Service Worker,支持离线访问和桌面安装。
总结
这个项目从想法到上线大概花了一周时间,其中:
- 需求分析和设计:1 天
- 开发和调试:4 天
- 数据录入和测试:2 天
虽然功能还比较简单,但已经能满足基本需求。通过这个项目,我也对 Next.js 16、React 19、Tailwind v4 有了更深的理解。
最大的感受是:好的工具链真的能大幅提升开发效率。Next.js 的 App Router、Tailwind 的语义化类名、TypeScript 的类型检查,每一个都让开发体验更好。
如果你也对 CS2 Major 竞猜感兴趣,欢迎试用这个项目:major.viki.moe
如果你有任何建议或者想分享竞猜数据,欢迎通过以下方式联系我:
- 邮箱:hi@viki.moe
- GitHub:vikiboss/major-winner
- QQ 群:902511365
最后,祝大家都能拿到钻石币!🏆