从0开始做自己的免费游戏聚合站(二):游戏详情页、深色模式与倒计时组件

系列第二篇:在 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 的地方都用可选链 ?.,因为大部分游戏没有详情数据,gameDetailnull 时直接降级显示基础信息。

踩坑: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]

不需要 computedref 本身就是响应式的,模板会自动追踪。


深色模式:useTheme Composable

设计思路

深色模式需要处理三件事:

  1. 初始化 :读取用户上次的选择(localStorage)或系统偏好(prefers-color-scheme
  2. 切换 :修改 <html> 上的 dark class,Tailwind 的 dark: 前缀就靠这个触发
  3. 持久化:把选择存到 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 里跑的------没有 documentwindowlocalStorage,直接访问会抛 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/coreuseNow

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 小时" (即将免费) -->

小结

这一阶段完成的功能:

  1. 游戏详情页 --- 动态路由 + 两层数据结构(基础信息 + 详情信息分离)
  2. 深色模式 --- useTheme composable,process.client 守卫避免 SSR 崩溃
  3. 倒计时组件 --- 用 useNow 每分钟刷新免费剩余时间

最值得注意的两个点:

  • 所有访问 document/window/localStorage 的代码都要加 process.client 守卫
  • Vue 的 computed 里引用的响应式变量必须在 computed 之前声明

下一篇:接入 GamerPower API,把 Steam 免费游戏也聚合进来。

相关推荐
魔法阵维护师12 小时前
从零开发游戏需要学习的c#模块,第二十九章(经验值与升级系统)
学习·游戏·c#
wanhengidc13 小时前
云手机 算力终端应用
运维·服务器·网络·游戏·智能手机
wanhengidc14 小时前
服务器如何防范病毒攻击
运维·服务器·游戏
charley.layabox1 天前
大连理工,将 LayaAir AI 游戏设计带进校园
人工智能·游戏
Mark White1 天前
行为树(Behavior Tree):从 ROS 机器人到 Unity 游戏 AI 的统一决策范式
游戏·unity·机器人
魔法阵维护师1 天前
从零开发游戏需要学习的c#模块,第二十七章(远程攻击 —— 发射子弹)
学习·游戏·c#
Raink老师1 天前
【AI面试临阵磨枪-75】游戏 AI Agent:NPC、剧情生成、攻略助手、社区问答、黑话适配
人工智能·游戏·面试
mascon2 天前
解决苹果手机在游戏中意外触发下拉菜单的方法
游戏·智能手机
yjcode7892 天前
游戏交易点卡充值源码系统制造厂
游戏·游戏交易