系列第二篇:在 MVP 能展示列表之后,继续补上游戏详情页、深色模式和倒计时组件。
这一篇的重点不是做更多页面,而是把站点从"能看"推进到"更完整、更像一个正式产品"。
游戏详情页:两层数据结构
详情页需要展示比列表页更丰富的信息:游戏截图、系统配置要求、类型标签、发行商等。但这些数据 Epic 的促销 API 不全给------它主要是促销接口,不是游戏详情接口。
解法:两层数据分离。
| 数据层 | 来源 | 内容 | 文件 |
|---|---|---|---|
| 基础信息 | Epic 促销 API | 名称、图片、免费时间、价格 | data/games.json |
| 详情信息 | 暂时手工维护 | 截图、配置要求、类型、开发商 | data/game-details.json |
game-details.json 用 slug 做 key,方便按 slug 查找:
json
{
"down-in-bermuda": {
"title": "Down in Bermuda (逃出百慕大)",
"developer": "Yak & Co",
"publisher": "Yak & Co",
"longDescription": "解开复杂的谜题,逃出百慕大。探索神秘的海岛,找到回家的路...",
"originalPrice": "¥65.00",
"screenshots": [
"https://cdn2.unrealengine.com/.../screenshot1.jpg",
"https://cdn2.unrealengine.com/.../screenshot2.jpg"
],
"genres": ["解谜", "冒险", "独立"],
"features": ["单人", "云存储", "成就"],
"platforms": ["Windows", "macOS"],
"releaseDate": "2021-01-14",
"requirements": {
"minimum": {
"os": "Windows 7",
"processor": "Intel Core i5",
"memory": "4 GB RAM",
"graphics": "NVIDIA GeForce GTX 660",
"storage": "1 GB"
},
"recommended": {
"os": "Windows 10",
"processor": "Intel Core i7",
"memory": "8 GB RAM",
"graphics": "NVIDIA GeForce GTX 970",
"storage": "1 GB"
}
}
}
}
这样设计的好处:即使某个游戏没有详情数据,基础信息也能正常展示;详情数据只在需要的时候才查。
动态路由页面实现
pages/game/[slug].vue,Nuxt 自动把 URL 中的 slug 部分注入到 route.params.slug:
ts
<script setup lang="ts">
import gameDetailsData from '~/data/game-details.json'
const route = useRoute()
const { findBySlug } = useGames()
// 1. 用 slug 从 games.json 里找基础信息
const game = findBySlug(route.params.slug as string)
// 2. 找不到就 404
if (!game) {
throw createError({ statusCode: 404, statusMessage: '未找到该游戏', fatal: true })
}
// 3. 用同一个 slug 查详情数据(可能为 null)
const gameDetail = gameDetailsData[game.slug as keyof typeof gameDetailsData] || null
// 4. 处理 Epic 链接格式(移除旧的 /zh-CN/ 路径)
const gameUrl = computed(() => {
return game.url
.replace('https://store.epicgames.com/site/zh-CN/p/', 'https://store.epicgames.com/p/')
.replace('https://store.epicgames.com/zh-CN/p/', 'https://store.epicgames.com/p/')
})
</script>
模板:分区域展示
页面布局是左 2/3 主内容 + 右 1/3 侧边栏:
vue
<template>
<main class="max-w-6xl mx-auto px-4 py-8" v-if="game">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- 左:主图 + 描述 + 截图 + 配置要求 -->
<div class="lg:col-span-2 space-y-6">
<!-- 主图 + 标题 + 描述 -->
<div class="rounded-xl overflow-hidden bg-white border shadow-sm">
<img :src="game.image" :alt="game.title" class="w-full aspect-[16/9] object-cover" />
<div class="p-6">
<h1>{{ gameDetail?.title || game.title }}</h1>
<p>{{ gameDetail?.developer || game.seller }}</p>
<!-- gameDetail 有就用长描述,没有就用基础描述 -->
<p>{{ gameDetail?.longDescription || game.description }}</p>
</div>
</div>
<!-- 截图轮播(仅 gameDetail 有数据时才显示) -->
<div v-if="gameDetail?.screenshots?.length">
<div class="aspect-video rounded-lg overflow-hidden">
<img :src="gameDetail.screenshots[activeScreenshot]" />
</div>
<!-- 缩略图导航 -->
<div class="grid grid-cols-4 md:grid-cols-8 gap-2 mt-2">
<div
v-for="(shot, i) in gameDetail.screenshots.slice(0, 8)"
:key="i"
:class="activeScreenshot === i ? 'border-brand' : 'border-transparent'"
class="aspect-video rounded-lg overflow-hidden cursor-pointer border-2"
@click="activeScreenshot = i"
>
<img :src="shot" class="w-full h-full object-cover" />
</div>
</div>
</div>
<!-- 系统配置要求 -->
<div v-if="gameDetail?.requirements">
<!-- 最低 / 推荐配置对比表格 -->
</div>
</div>
<!-- 右:价格 + 领取按钮 + 时间信息 + 标签 -->
<div class="lg:col-span-1 sticky top-6">
<span class="text-3xl font-bold text-emerald-500">免费</span>
<span v-if="gameDetail?.originalPrice" class="line-through text-slate-400">
{{ gameDetail.originalPrice }}
</span>
<a :href="gameUrl" target="_blank" class="w-full btn-primary">前往 Epic 领取</a>
<Countdown :end-date="game.endDate" />
</div>
</div>
</main>
</template>
关键细节 :所有用到 gameDetail 的地方都用可选链 ?.,因为大部分游戏没有详情数据,gameDetail 为 null 时直接降级显示基础信息。
踩坑:Vue computed 的声明顺序
最初写截图交互是这样的:
ts
// ❌ 错误写法
const currentShot = computed(() => gameDetail?.screenshots?.[activeIndex.value])
const activeIndex = ref(0) // ← ref 在 computed 后面声明
这在某些 Vue 版本下会产生响应式依赖收集异常,因为 computed 在执行时 activeIndex 还没被初始化为响应式对象。
修复很简单------用普通 ref 记录当前索引,在模板里直接用:
ts
// ✅ 正确写法
const activeScreenshot = ref(0)
// 模板里:gameDetail.screenshots[activeScreenshot]
不需要 computed,ref 本身就是响应式的,模板会自动追踪。
深色模式:useTheme Composable
设计思路
深色模式需要处理三件事:
- 初始化 :读取用户上次的选择(localStorage)或系统偏好(
prefers-color-scheme) - 切换 :修改
<html>上的darkclass,Tailwind 的dark:前缀就靠这个触发 - 持久化:把选择存到 localStorage,下次打开还是这个主题
ts
// composables/useTheme.ts
export const useTheme = () => {
// useState 创建全局共享状态,所有组件用同一个 isDark
const isDark = useState('theme-dark', () => false)
const applyTheme = () => {
if (process.client) { // ← SSR 没有 DOM,必须守卫
document.documentElement.classList.toggle('dark', isDark.value)
}
}
const saveTheme = () => {
if (process.client) {
localStorage.setItem('theme-dark', JSON.stringify(isDark.value))
}
}
const loadTheme = () => {
if (process.client) {
const saved = localStorage.getItem('theme-dark')
if (saved !== null) {
isDark.value = JSON.parse(saved) // 有保存值,用保存的
} else {
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches // 没有,跟系统走
}
applyTheme()
}
}
const toggleTheme = () => {
isDark.value = !isDark.value
applyTheme()
saveTheme()
}
onMounted(() => {
loadTheme() // 组件挂载后加载主题(此时已经在客户端)
})
return { isDark, toggleTheme }
}
关键:process.client 守卫
process.client 是 Nuxt 注入的全局变量,服务端渲染时为 false,客户端运行时为 true。
Nuxt 3 用 nuxt generate 预渲染时,代码实际上是在 Node.js 里跑的------没有 document、window、localStorage,直接访问会抛 ReferenceError,导致整页 500。
所有访问浏览器 API 的代码,都必须包在 if (process.client) 或 onMounted(() => {...}) 里。
Tailwind 深色模式配置
tailwind.config.ts 里设置 darkMode: 'class',就是让 Tailwind 通过 <html class="dark"> 来控制:
ts
export default {
darkMode: 'class', // 不是 'media',手动控制
// ...
}
模板里用 dark: 前缀写暗色样式:
vue
<div class="bg-white dark:bg-slate-800 border-slate-100 dark:border-slate-700">
<p class="text-slate-700 dark:text-slate-300">...</p>
</div>
Header 里的切换按钮
vue
<!-- components/SiteHeader.vue -->
<script setup lang="ts">
const { isDark, toggleTheme } = useTheme()
</script>
<template>
<button @click="toggleTheme" :title="isDark ? '切换到亮色模式' : '切换到暗色模式'">
<Icon v-if="isDark" name="mdi:weather-sunny" />
<Icon v-else name="mdi:weather-night" />
</button>
</template>
useTheme() 用 useState('theme-dark') 创建全局状态,所有组件调用 useTheme() 拿到的是同一个 isDark ref。
倒计时组件
游戏免费时间倒计时用 @vueuse/core 的 useNow:
ts
// components/Countdown.vue
const props = defineProps<{ endDate: string; prefix?: string }>()
const now = useNow({ interval: 60_000 }) // 每分钟更新
const text = computed(() => {
const diff = new Date(props.endDate).getTime() - now.value.getTime()
if (diff <= 0) return '已结束'
const d = Math.floor(diff / 86_400_000)
const h = Math.floor((diff % 86_400_000) / 3_600_000)
const m = Math.floor((diff % 3_600_000) / 60_000)
if (d > 0) return `${d} 天 ${h} 小时`
if (h > 0) return `${h} 小时 ${m} 分`
return `${m} 分钟`
})
const tone = computed(() => {
const diff = new Date(props.endDate).getTime() - now.value.getTime()
if (diff <= 0) return 'text-slate-400'
if (diff < 24 * 3_600_000) return 'text-red-500' // 不足 1 天,红色警告
if (diff < 3 * 24 * 3_600_000) return 'text-orange-500' // 不足 3 天,橙色提示
return 'text-emerald-600' // 充裕时间,绿色
})
用法:
vue
<Countdown :end-date="game.endDate" prefix="剩余 " />
<!-- → "剩余 2 天 3 小时" -->
<Countdown :end-date="game.startDate" prefix="将于 " />
<!-- → "将于 5 天 12 小时" (即将免费) -->
小结
这一阶段完成的功能:
- 游戏详情页 --- 动态路由 + 两层数据结构(基础信息 + 详情信息分离)
- 深色模式 ---
useThemecomposable,process.client守卫避免 SSR 崩溃 - 倒计时组件 --- 用
useNow每分钟刷新免费剩余时间
最值得注意的两个点:
- 所有访问
document/window/localStorage的代码都要加process.client守卫 - Vue 的
computed里引用的响应式变量必须在computed之前声明
下一篇:接入 GamerPower API,把 Steam 免费游戏也聚合进来。
