Files
gamegroup_fe/doc/开发步骤文档.md
2026-01-28 14:35:43 +08:00

62 KiB
Raw Permalink Blame History

GameGroup 前端开发步骤文档

项目名称: GameGroupFE 文档版本: v1.0 创建时间: 2026-01-28 技术栈: Vue 3 + TypeScript + Vite + Pinia + Vue Router + Element Plus + Tailwind CSS


📋 目录


阶段一:项目初始化

步骤 1.1:创建 Vite 项目

# 在项目根目录执行
cd /home/congsh/CodeSpace/GameGroupFE
npm create vite@latest . -- --template vue-ts

验证: 检查是否生成以下文件

  • package.json
  • vite.config.ts
  • tsconfig.json
  • src/main.ts
  • src/App.vue
  • index.html

步骤 1.2:安装核心依赖

npm install vue@^3.4.0
npm install vue-router@^4.2.0
npm install pinia@^2.1.0
npm install axios@^1.6.0
npm install element-plus@^2.5.0
npm install @element-plus/icons-vue@^2.3.0

验证: 检查 package.json 中的 dependencies

  • vue: ^3.4.0
  • vue-router: ^4.2.0
  • pinia: ^2.1.0
  • axios: ^1.6.0
  • element-plus: ^2.5.0

步骤 1.3:安装开发依赖

# Tailwind CSS
npm install -D tailwindcss@^3.4.0 postcss@^8.4.0 autoprefixer@^10.4.0

# TypeScript 类型
npm install -D @types/node@^20.0.0

# 代码规范工具
npm install -D eslint@^8.56.0 prettier@^3.1.0

# Sass 支持
npm install -D sass@^1.69.0

验证: 检查 package.json 中的 devDependencies

  • tailwindcss: ^3.4.0
  • postcss: ^8.4.0
  • autoprefixer: ^10.4.0
  • @types/node: ^20.0.0
  • sass: ^1.69.0

步骤 1.4:初始化 Tailwind CSS

npx tailwindcss init -p

验证: 检查是否生成以下文件

  • tailwind.config.js
  • postcss.config.js

步骤 1.5:初始化 Git

git init
git add .
git commit -m "feat: 初始化项目脚手架"

验证: 检查 .git 目录是否存在


阶段二:基础配置

步骤 2.1:配置 Vite

编辑 vite.config.ts:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': resolve(__dirname, 'src')
    }
  },
  server: {
    port: 5173,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true
      }
    }
  },
  build: {
    target: 'es2015',
    outDir: 'dist',
    assetsDir: 'assets',
    sourcemap: false,
    minify: 'terser',
    rollupOptions: {
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router', 'pinia'],
          ui: ['element-plus']
        }
      }
    }
  }
})

验证: 执行 npm run dev,确认开发服务器正常启动

步骤 2.2:配置 TypeScript

编辑 tsconfig.json,添加路径别名:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

验证: 在 .vue 文件中测试导入 import { xxx } from '@/utils'

步骤 2.3:配置 Tailwind CSS

编辑 tailwind.config.js:

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        primary: {
          50: '#f5f3ff',
          100: '#ede9fe',
          200: '#ddd6fe',
          300: '#c4b5fd',
          400: '#a78bfa',
          500: '#8b5cf6',
          600: '#7c3aed',
          700: '#6d28d9',
          800: '#5b21b6',
          900: '#4c1d95',
        },
        accent: {
          50: '#fff7ed',
          100: '#ffedd5',
          200: '#fed7aa',
          300: '#fdba74',
          400: '#fb923c',
          500: '#f97316',
          600: '#ea580c',
          700: '#c2410c',
          800: '#9a3412',
          900: '#7c2d12',
        },
      },
      spacing: {
        '1': '0.25rem',
        '2': '0.5rem',
        '3': '0.75rem',
        '4': '1rem',
        '5': '1.25rem',
        '6': '1.5rem',
        '8': '2rem',
        '10': '2.5rem',
        '12': '3rem',
        '16': '4rem',
        '20': '5rem',
      },
    },
  },
  plugins: [],
}

编辑 src/assets/styles/main.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  body {
    @apply bg-gray-50 text-gray-900;
  }
}

验证: 在 src/main.ts 中引入样式,检查页面样式是否生效

步骤 2.4:配置 Element Plus

编辑 src/main.ts:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'

// Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

// 样式
import './assets/styles/main.css'

const app = createApp(App)

// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

验证: 在页面中测试 <el-button> 组件是否正常显示


阶段三:目录结构创建

步骤 3.1:创建完整目录结构

mkdir -p src/assets/{images,icons,styles}
mkdir -p src/components/{common,business,layout}
mkdir -p src/composables
mkdir -p src/stores
mkdir -p src/api
mkdir -p src/router
mkdir -p src/views/{auth,home,user,group,game,appointment,ledger,schedule,honor,asset,points,bet}
mkdir -p src/types
mkdir -p src/utils
mkdir -p src/constants
mkdir -p src/directives
mkdir -p public

验证: 检查目录结构是否完整创建

步骤 3.2:创建基础文件

# 创建类型定义文件
touch src/types/api.ts
touch src/types/user.ts
touch src/types/group.ts
touch src/types/game.ts
touch src/types/appointment.ts

# 创建常量文件
touch src/constants/enums.ts
touch src/constants/config.ts

# 创建工具函数
touch src/utils/request.ts
touch src/utils/storage.ts
touch src/utils/format.ts
touch src/utils/validate.ts

# 创建API文件
touch src/api/index.ts
touch src/api/auth.ts
touch src/api/user.ts
touch src/api/group.ts
touch src/api/game.ts
touch src/api/appointment.ts

验证: 确认所有文件都已创建


阶段四:基础组件开发

步骤 4.1Button 按钮

创建 src/components/common/Button/Button.vue:

<template>
  <button
    :class="buttonClass"
    :disabled="disabled || loading"
    :type="nativeType"
    @click="handleClick"
  >
    <svg v-if="loading" class="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
      <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
      <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
    </svg>
    <slot />
  </button>
</template>

<script setup lang="ts">
interface Props {
  type?: 'primary' | 'secondary' | 'danger'
  size?: 'large' | 'medium' | 'small'
  loading?: boolean
  disabled?: boolean
  nativeType?: 'button' | 'submit' | 'reset'
}

const props = withDefaults(defineProps<Props>(), {
  type: 'primary',
  size: 'medium',
  nativeType: 'button'
})

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

const buttonClass = computed(() => [
  'g-button',
  `g-button--${props.type}`,
  `g-button--${props.size}`,
  {
    'is-disabled': props.disabled,
    'is-loading': props.loading
  }
])

const handleClick = (e: MouseEvent) => {
  if (!props.disabled && !props.loading) {
    emit('click', e)
  }
}
</script>

<style scoped lang="scss">
.g-button {
  @apply inline-flex items-center justify-center gap-2 rounded-lg font-semibold transition-all duration-200;

  &:active {
    @apply scale-95;
  }

  &--primary {
    @apply bg-primary-500 text-white;

    &:hover:not(.is-disabled) {
      @apply bg-primary-600 -translate-y-0.5 shadow-lg;
    }
  }

  &--secondary {
    @apply bg-transparent text-primary-500 border-2 border-primary-500;

    &:hover:not(.is-disabled) {
      @apply bg-primary-50;
    }
  }

  &--danger {
    @apply bg-red-500 text-white;

    &:hover:not(.is-disabled) {
      @apply bg-red-600;
    }
  }

  &--large {
    @apply h-12 px-6 text-base;
  }

  &--medium {
    @apply h-10 px-5 text-sm;
  }

  &--small {
    @apply h-8 px-4 text-xs;
  }

  &.is-disabled {
    @apply opacity-60 cursor-not-allowed;
  }

  &.is-loading {
    @apply pointer-events-none;
  }
}
</style>

验证: 在页面中使用 <Button type="primary">点击</Button>,检查样式和交互

步骤 4.2Input 输入框

创建 src/components/common/Input/Input.vue:

<template>
  <div class="g-input-wrapper">
    <input
      v-model="inputValue"
      :type="type"
      :placeholder="placeholder"
      :disabled="disabled"
      :class="inputClass"
      @focus="handleFocus"
      @blur="handleBlur"
    />
    <button v-if="clearable && inputValue" @click="handleClear" class="clear-btn">×</button>
  </div>
</template>

<script setup lang="ts">
interface Props {
  modelValue: string | number
  type?: 'text' | 'password' | 'email' | 'tel'
  placeholder?: string
  disabled?: boolean
  clearable?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  type: 'text',
  clearable: false
})

const emit = defineEmits<{
  'update:modelValue': [value: string]
  focus: [event: FocusEvent]
  blur: [event: FocusEvent]
}>()

const inputValue = computed({
  get: () => props.modelValue,
  set: (val) => emit('update:modelValue', val)
})

const focused = ref(false)

const inputClass = computed(() => [
  'g-input',
  {
    'is-focused': focused.value,
    'is-disabled': props.disabled
  }
])

const handleFocus = (e: FocusEvent) => {
  focused.value = true
  emit('focus', e)
}

const handleBlur = (e: FocusEvent) => {
  focused.value = false
  emit('blur', e)
}

const handleClear = () => {
  inputValue.value = ''
}
</script>

<style scoped lang="scss">
.g-input-wrapper {
  @apply relative;
}

.g-input {
  @apply w-full px-4 py-2.5 rounded-lg border-2 border-gray-200 transition-all duration-200;

  &:focus {
    @apply border-primary-500 outline-none shadow-md;
  }

  &.is-disabled {
    @apply bg-gray-100 cursor-not-allowed;
  }
}

.clear-btn {
  @apply absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 text-xl;
}
</style>

验证: 测试输入、清除、禁用等功能

步骤 4.3Modal 弹窗

创建 src/components/common/Modal/Modal.vue:

<template>
  <Teleport to="body">
    <Transition name="modal">
      <div v-if="visible" class="modal-overlay" @click="handleMaskClick">
        <div class="modal-container" @click.stop>
          <div v-if="title" class="modal-header">
            <h3>{{ title }}</h3>
            <button @click="handleClose" class="close-btn">×</button>
          </div>
          <div class="modal-body">
            <slot />
          </div>
          <div v-if="$slots.footer" class="modal-footer">
            <slot name="footer" />
          </div>
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup lang="ts">
interface Props {
  visible: boolean
  title?: string
  closeOnClickModal?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  closeOnClickModal: true
})

const emit = defineEmits<{
  'update:visible': [value: boolean]
}>()

const handleMaskClick = () => {
  if (props.closeOnClickModal) {
    emit('update:visible', false)
  }
}

const handleClose = () => {
  emit('update:visible', false)
}
</script>

<style scoped lang="scss">
.modal-overlay {
  @apply fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4;
}

.modal-container {
  @apply bg-white rounded-xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-hidden flex flex-col;
}

.modal-header {
  @apply flex items-center justify-between px-6 py-4 border-b;
}

.modal-body {
  @apply px-6 py-4 overflow-y-auto;
}

.modal-footer {
  @apply px-6 py-4 border-t flex justify-end gap-3;
}

.close-btn {
  @apply text-2xl text-gray-400 hover:text-gray-600 transition-colors;
}

.modal-enter-active,
.modal-leave-active {
  @apply transition-all duration-300;
}

.modal-enter-from,
.modal-leave-to {
  @apply opacity-0;

  .modal-container {
    @apply scale-95;
  }
}
</style>

验证: 测试弹窗的打开、关闭、点击遮罩关闭等功能

步骤 4.4Card 卡片

创建 src/components/common/Card/Card.vue:

<template>
  <div :class="cardClass" @click="handleClick">
    <div v-if="$slots.header" class="card-header">
      <slot name="header" />
    </div>
    <div class="card-body">
      <slot />
    </div>
    <div v-if="$slots.footer" class="card-footer">
      <slot name="footer" />
    </div>
  </div>
</template>

<script setup lang="ts">
interface Props {
  hoverable?: boolean
  clickable?: boolean
  shadow?: 'always' | 'hover' | 'never'
}

const props = withDefaults(defineProps<Props>(), {
  shadow: 'hover',
  hoverable: false,
  clickable: false
})

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()

const cardClass = computed(() => [
  'g-card',
  `g-card--shadow-${props.shadow}`,
  {
    'is-hoverable': props.hoverable,
    'is-clickable': props.clickable
  }
])

const handleClick = (e: MouseEvent) => {
  if (props.clickable) {
    emit('click', e)
  }
}
</script>

<style scoped lang="scss">
.g-card {
  @apply bg-white rounded-xl p-6 transition-all duration-300;

  &--shadow-always {
    @apply shadow-md;
  }

  &--shadow-hover {
    @apply shadow hover:shadow-lg;
  }

  &--shadow-never {
    @apply shadow-none;
  }

  &.is-hoverable:hover {
    @apply -translate-y-1 shadow-xl;
  }

  &.is-clickable {
    @apply cursor-pointer;

    &:hover {
      @apply -translate-y-1 shadow-xl;
    }

    &:active {
      @apply scale-98;
    }
  }
}

.card-header {
  @apply mb-4;
}

.card-footer {
  @apply mt-4 pt-4 border-t;
}
</style>

验证: 测试卡片的悬停、点击效果

步骤 4.5:其他基础组件

使用相同模式创建:

  • Loading - 加载组件
  • Avatar - 头像组件
  • Badge - 徽章组件
  • Tag - 标签组件

阶段五:布局组件开发

步骤 5.1MainLayout 主布局

创建 src/components/layout/MainLayout.vue:

<template>
  <div class="main-layout">
    <AppHeader />
    <div class="main-container">
      <AppSidebar />
      <main class="main-content">
        <RouterView />
      </main>
    </div>
  </div>
</template>

<script setup lang="ts">
import AppHeader from './AppHeader.vue'
import AppSidebar from './AppSidebar.vue'
</script>

<style scoped lang="scss">
.main-layout {
  @apply min-h-screen bg-gray-50;
}

.main-container {
  @apply flex;
}

.main-content {
  @apply flex-1 ml-64 p-6;
}

@media (max-width: 768px) {
  .main-content {
    @apply ml-0 p-4;
  }
}
</style>

步骤 5.2AppHeader 顶部导航

创建 src/components/layout/AppHeader.vue:

<template>
  <header class="app-header">
    <div class="header-left">
      <h1 class="logo">GameGroup</h1>
    </div>
    <div class="header-center">
      <el-input
        v-model="searchKeyword"
        placeholder="搜索游戏、小组..."
        prefix-icon="Search"
        class="search-input"
      />
    </div>
    <div class="header-right">
      <el-badge :value="unreadCount" class="notification-badge">
        <el-button circle icon="Bell" />
      </el-badge>
      <el-dropdown>
        <div class="user-avatar">
          <el-avatar :src="userInfo?.avatar" />
        </div>
        <template #dropdown>
          <el-dropdown-menu>
            <el-dropdown-item @click="router.push('/user/profile')">
              个人资料
            </el-dropdown-item>
            <el-dropdown-item @click="router.push('/user/settings')">
              设置
            </el-dropdown-item>
            <el-dropdown-item divided @click="handleLogout">
              退出登录
            </el-dropdown-item>
          </el-dropdown-menu>
        </template>
      </el-dropdown>
    </div>
  </header>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const router = useRouter()
const authStore = useAuthStore()

const searchKeyword = ref('')
const unreadCount = ref(0)
const userInfo = computed(() => authStore.userInfo)

const handleLogout = () => {
  authStore.logout()
  router.push('/login')
}
</script>

<style scoped lang="scss">
.app-header {
  @apply fixed top-0 left-0 right-0 h-16 bg-white shadow-sm flex items-center justify-between px-6 z-40;
}

.logo {
  @apply text-2xl font-bold bg-gradient-to-r from-primary-500 to-accent-500 bg-clip-text text-transparent;
}

.header-center {
  @apply flex-1 max-w-xl mx-8;
}

.search-input {
  @apply w-full;
}

.header-right {
  @apply flex items-center gap-4;
}
</style>

步骤 5.3AppSidebar 侧边栏

创建 src/components/layout/AppSidebar.vue:

<template>
  <aside class="app-sidebar">
    <el-menu
      :default-active="activeMenu"
      :router="true"
      class="sidebar-menu"
    >
      <el-menu-item index="/">
        <el-icon><HomeFilled /></el-icon>
        <span>首页</span>
      </el-menu-item>

      <el-menu-item index="/groups">
        <el-icon><Users /></el-icon>
        <span>小组管理</span>
      </el-menu-item>

      <el-menu-item index="/games">
        <el-icon><Grid /></el-icon>
        <span>游戏库</span>
      </el-menu-item>

      <el-menu-item index="/appointments">
        <el-icon><Calendar /></el-icon>
        <span>预约管理</span>
      </el-menu-item>

      <el-menu-item index="/points/ranking">
        <el-icon><Trophy /></el-icon>
        <span>积分排行</span>
      </el-menu-item>

      <el-menu-item index="/honors">
        <el-icon><Medal /></el-icon>
        <span>荣誉墙</span>
      </el-menu-item>
    </el-menu>
  </aside>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const activeMenu = computed(() => route.path)
</script>

<style scoped lang="scss">
.app-sidebar {
  @apply fixed left-0 top-16 bottom-0 w-64 bg-white shadow-sm overflow-y-auto z-30;
}

.sidebar-menu {
  @apply border-r-0;
}
</style>

验证: 检查布局组件是否正确显示和响应


阶段六:业务组件开发

步骤 6.1UserCard 用户卡片

创建 src/components/business/UserCard/UserCard.vue:

<template>
  <div :class="cardClass" @click="handleClick">
    <img :src="user.avatar" :alt="user.username" class="user-avatar" />
    <div class="user-info">
      <h4 class="user-name">{{ user.nickname || user.username }}</h4>
      <p class="user-role">{{ roleText }}</p>
      <div class="user-badges">
        <el-badge v-if="user.isMember" type="primary" value="会员" />
        <el-badge v-if="user.isOnline" type="success" value="在线" />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
interface User {
  id: string
  username: string
  nickname?: string
  avatar: string
  role?: string
  isMember?: boolean
  isOnline?: boolean
}

interface Props {
  user: User
  clickable?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  clickable: false
})

const emit = defineEmits<{
  click: [user: User]
}>()

const roleText = computed(() => {
  const roleMap = {
    owner: '组长',
    admin: '管理员',
    member: '成员'
  }
  return roleMap[props.user.role] || '成员'
})

const cardClass = computed(() => [
  'user-card',
  { 'is-clickable': props.clickable }
])

const handleClick = () => {
  if (props.clickable) {
    emit('click', props.user)
  }
}
</script>

<style scoped lang="scss">
.user-card {
  @apply flex gap-3 p-4 bg-white rounded-xl transition-all duration-200;

  &.is-clickable {
    @apply cursor-pointer;

    &:hover {
      @apply shadow-lg -translate-y-0.5;
    }
  }
}

.user-avatar {
  @apply w-14 h-14 rounded-full object-cover border-2 border-primary-200;
}

.user-name {
  @apply text-base font-semibold text-gray-900;
}

.user-role {
  @apply text-sm text-gray-500 mt-1;
}

.user-badges {
  @apply flex gap-2 mt-2;
}
</style>

步骤 6.2GroupCard 小组卡片

创建 src/components/business/GroupCard/GroupCard.vue:

<template>
  <Card :clickable="clickable" @click="handleClick">
    <template #header>
      <div class="group-header">
        <img :src="group.avatar" :alt="group.name" class="group-avatar" />
        <div class="group-info">
          <h4 class="group-name">{{ group.name }}</h4>
          <p class="group-members">{{ group.currentMembers }}/{{ group.maxMembers }}</p>
        </div>
      </div>
    </template>

    <p v-if="group.description" class="group-description">
      {{ group.description }}
    </p>

    <div v-if="showTags && group.tags?.length" class="group-tags">
      <el-tag
        v-for="tag in group.tags"
        :key="tag"
        size="small"
        type="info"
      >
        {{ tag }}
      </el-tag>
    </div>

    <template #footer v-if="showActions">
      <div class="group-actions">
        <el-button type="primary" size="small" @click.stop="handleJoin">
          {{ isInGroup ? '进入' : '加入' }}
        </el-button>
      </div>
    </template>
  </Card>
</template>

<script setup lang="ts">
interface Group {
  id: string
  name: string
  avatar: string
  description?: string
  currentMembers: number
  maxMembers: number
  myRole?: 'owner' | 'admin' | 'member'
  tags?: string[]
}

interface Props {
  group: Group
  clickable?: boolean
  showTags?: boolean
  showActions?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  clickable: true,
  showTags: true,
  showActions: false
})

const emit = defineEmits<{
  click: [group: Group]
  join: [groupId: string]
}>()

const isInGroup = computed(() => !!props.group.myRole)

const handleClick = () => {
  if (props.clickable) {
    emit('click', props.group)
  }
}

const handleJoin = () => {
  emit('join', props.group.id)
}
</script>

<style scoped lang="scss">
.group-header {
  @apply flex gap-3;
}

.group-avatar {
  @apply w-12 h-12 rounded-lg object-cover;
}

.group-name {
  @apply text-lg font-semibold text-gray-900;
}

.group-members {
  @apply text-sm text-gray-500 mt-1;
}

.group-description {
  @apply text-sm text-gray-600 line-clamp-2;
}

.group-tags {
  @apply flex gap-2 mt-3 flex-wrap;
}

.group-actions {
  @apply flex justify-end;
}
</style>

步骤 6.3GameCard 游戏卡片

创建 src/components/business/GameCard/GameCard.vue:

<template>
  <Card :clickable="clickable" @click="handleClick" hoverable>
    <div class="game-cover">
      <img :src="game.coverUrl" :alt="game.name" />
      <div class="game-overlay">
        <el-button type="primary" size="small">查看详情</el-button>
      </div>
    </div>

    <div class="game-content">
      <div class="game-tags">
        <el-tag
          v-for="tag in game.tags"
          :key="tag"
          size="small"
          :type="getGameTagType(tag)"
        >
          {{ tag }}
        </el-tag>
      </div>
      <h4 class="game-title">{{ game.name }}</h4>
      <p class="game-players">
        <el-icon><User /></el-icon>
        {{ game.minPlayers }}-{{ game.maxPlayers }}
      </p>
    </div>
  </Card>
</template>

<script setup lang="ts">
interface Game {
  id: string
  name: string
  coverUrl: string
  description?: string
  tags: string[]
  minPlayers: number
  maxPlayers: number
  platform: string
}

interface Props {
  game: Game
  clickable?: boolean
  showTags?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  clickable: true,
  showTags: true
})

const emit = defineEmits<{
  click: [game: Game]
}>()

const getGameTagType = (tag: string) => {
  const typeMap: Record<string, string> = {
    'MOBA': 'danger',
    'FPS': 'warning',
    'RPG': 'success',
    '策略': 'primary'
  }
  return typeMap[tag] || 'info'
}

const handleClick = () => {
  if (props.clickable) {
    emit('click', props.game)
  }
}
</script>

<style scoped lang="scss">
.game-cover {
  @apply relative rounded-lg overflow-hidden -mx-6 -mt-6 mb-4;
  height: 180px;

  img {
    @apply w-full h-full object-cover;
  }

  .game-overlay {
    @apply absolute inset-0 bg-black/50 flex items-center justify-center opacity-0 transition-opacity duration-200;

    &:hover {
      @apply opacity-100;
    }
  }
}

.game-tags {
  @apply flex gap-2 mb-2;
}

.game-title {
  @apply text-lg font-semibold text-gray-900 mb-2;
}

.game-players {
  @apply text-sm text-gray-500 flex items-center gap-1;
}
</style>

步骤 6.4AppointmentCard 预约卡片

创建 src/components/business/AppointmentCard/AppointmentCard.vue:

<template>
  <Card>
    <div class="appointment-header">
      <img :src="appointment.game.coverUrl" class="game-icon" />
      <div class="appointment-info">
        <h4 class="appointment-title">{{ appointment.title }}</h4>
        <p class="appointment-group">{{ appointment.group.name }}</p>
      </div>
      <el-tag :type="getStatusType(appointment.status)" size="small">
        {{ getStatusText(appointment.status) }}
      </el-tag>
    </div>

    <div class="appointment-time">
      <el-icon><Clock /></el-icon>
      <span>{{ formatTimeRange(appointment.startTime, appointment.endTime) }}</span>
    </div>

    <div class="appointment-participants">
      <el-icon><User /></el-icon>
      <span>{{ appointment.currentParticipants }}/{{ appointment.maxParticipants }}</span>
      <el-progress
        :percentage="participantPercentage"
        :stroke-width="6"
        :show-text="false"
      />
    </div>

    <template #footer v-if="showActions">
      <div class="appointment-actions">
        <el-button
          v-if="!appointment.isParticipant"
          type="primary"
          size="small"
          :disabled="appointment.isFull"
          @click="handleJoin"
        >
          {{ appointment.isFull ? '已满' : '加入' }}
        </el-button>
        <el-button
          v-else
          type="danger"
          size="small"
          plain
          @click="handleLeave"
        >
          退出
        </el-button>
        <el-button size="small" @click="handleViewDetail">
          查看详情
        </el-button>
      </div>
    </template>
  </Card>
</template>

<script setup lang="ts">
interface Appointment {
  id: string
  title: string
  startTime: string
  endTime: string
  currentParticipants: number
  maxParticipants: number
  status: 'open' | 'full' | 'cancelled' | 'finished'
  game: { coverUrl: string }
  group: { name: string }
  isParticipant: boolean
  isCreator: boolean
  isFull: boolean
}

interface Props {
  appointment: Appointment
  showActions?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  showActions: true
})

const emit = defineEmits<{
  join: [id: string]
  leave: [id: string]
  viewDetail: [id: string]
}>()

const participantPercentage = computed(() => {
  return (props.appointment.currentParticipants / props.appointment.maxParticipants) * 100
})

const getStatusType = (status: string) => {
  const typeMap = {
    open: 'success',
    full: 'warning',
    cancelled: 'danger',
    finished: 'info'
  }
  return typeMap[status] || 'info'
}

const getStatusText = (status: string) => {
  const textMap = {
    open: '未开始',
    full: '已满',
    cancelled: '已取消',
    finished: '已结束'
  }
  return textMap[status] || status
}

const formatTimeRange = (start: string, end: string) => {
  const startDate = new Date(start)
  const endDate = new Date(end)
  return `${formatDate(startDate)} ${formatTime(startDate)} - ${formatTime(endDate)}`
}

const formatDate = (date: Date) => {
  const month = date.getMonth() + 1
  const day = date.getDate()
  return `${month}${day}日`
}

const formatTime = (date: Date) => {
  const hours = date.getHours().toString().padStart(2, '0')
  const minutes = date.getMinutes().toString().padStart(2, '0')
  return `${hours}:${minutes}`
}

const handleJoin = () => {
  emit('join', props.appointment.id)
}

const handleLeave = () => {
  emit('leave', props.appointment.id)
}

const handleViewDetail = () => {
  emit('viewDetail', props.appointment.id)
}
</script>

<style scoped lang="scss">
.appointment-header {
  @apply flex gap-3 mb-4;
}

.game-icon {
  @apply w-12 h-12 rounded-lg object-cover;
}

.appointment-title {
  @apply text-base font-semibold text-gray-900;
}

.appointment-group {
  @apply text-sm text-gray-500 mt-1;
}

.appointment-time,
.appointment-participants {
  @apply flex items-center gap-2 text-sm text-gray-600 mb-3;
}

.appointment-actions {
  @apply flex justify-end gap-2;
}
</style>

验证: 测试所有业务组件的显示和交互


阶段七API服务层

步骤 7.1:配置 Axios

创建 src/utils/request.ts:

import axios, { AxiosInstance, AxiosError, AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'

// 创建 axios 实例
const service: AxiosInstance = axios.create({
  baseURL: '/api',
  timeout: 15000,
  headers: {
    'Content-Type': 'application/json'
  }
})

// 请求拦截器
service.interceptors.request.use(
  (config) => {
    // 从 localStorage 获取 token
    const token = localStorage.getItem('access_token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  (response: AxiosResponse) => {
    const { code, message, data } = response.data

    if (code === 0) {
      return data
    } else {
      ElMessage.error(message || '请求失败')
      return Promise.reject(new Error(message))
    }
  },
  (error: AxiosError) => {
    if (error.response?.status === 401) {
      ElMessage.error('登录已过期,请重新登录')
      localStorage.removeItem('access_token')
      localStorage.removeItem('refresh_token')
      window.location.href = '/login'
    } else {
      ElMessage.error(error.message || '网络错误')
    }
    return Promise.reject(error)
  }
)

export default service

步骤 7.2定义API类型

创建 src/types/api.ts:

// 统一响应格式
export interface ApiResponse<T = any> {
  code: number
  message: string
  data: T
  timestamp: number
}

// 分页响应
export interface PageResponse<T> {
  items: T[]
  total: number
  page: number
  limit: number
  totalPages: number
}

步骤 7.3创建认证API

创建 src/api/auth.ts:

import request from '@/utils/request'

export interface LoginParams {
  account: string
  password: string
}

export interface RegisterParams {
  username: string
  password: string
  email?: string
  phone?: string
}

export interface LoginResponse {
  user: UserInfo
  accessToken: string
  refreshToken: string
}

export const authApi = {
  // 登录
  login(params: LoginParams) {
    return request.post<LoginResponse>('/auth/login', params)
  },

  // 注册
  register(params: RegisterParams) {
    return request.post<LoginResponse>('/auth/register', params)
  },

  // 刷新令牌
  refreshToken(refreshToken: string) {
    return request.post<{ accessToken: string; refreshToken: string }>('/auth/refresh', {
      refreshToken
    })
  }
}

步骤 7.4创建用户API

创建 src/api/user.ts:

import request from '@/utils/request'

export const userApi = {
  // 获取当前用户信息
  getProfile() {
    return request.get<UserInfo>('/users/me')
  },

  // 获取指定用户信息
  getUserInfo(id: string) {
    return request.get<UserInfo>(`/users/${id}`)
  },

  // 更新用户信息
  updateProfile(data: Partial<UserInfo>) {
    return request.put<UserInfo>('/users/me', data)
  },

  // 修改密码
  changePassword(data: { oldPassword: string; newPassword: string }) {
    return request.put('/users/me/password', data)
  }
}

步骤 7.5创建小组API

创建 src/api/group.ts:

import request from '@/utils/request'

export const groupApi = {
  // 创建小组
  create(data: CreateGroupParams) {
    return request.post<GroupInfo>('/groups', data)
  },

  // 加入小组
  join(data: { groupId: string; nickname?: string }) {
    return request.post<GroupInfo>('/groups/join', data)
  },

  // 获取我的小组列表
  getMyGroups() {
    return request.get<GroupInfo[]>('/groups/my')
  },

  // 获取小组详情
  getGroupDetail(id: string) {
    return request.get<GroupInfo>(`/groups/${id}`)
  },

  // 更新小组信息
  updateGroup(id: string, data: Partial<GroupInfo>) {
    return request.put<GroupInfo>(`/groups/${id}`, data)
  },

  // 退出小组
  leaveGroup(id: string) {
    return request.delete(`/groups/${id}/leave`)
  },

  // 解散小组
  dissolveGroup(id: string) {
    return request.delete(`/groups/${id}`)
  }
}

验证: 测试API调用是否正常检查请求和响应格式


阶段八:状态管理

步骤 8.1创建认证Store

创建 src/stores/auth.ts:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '@/api/auth'
import type { UserInfo } from '@/types/user'

export const useAuthStore = defineStore('auth', () => {
  // State
  const userInfo = ref<UserInfo | null>(null)
  const accessToken = ref<string>('')
  const refreshToken = ref<string>('')

  // Getters
  const isLoggedIn = computed(() => !!accessToken.value && !!userInfo.value)

  // Actions
  async function login(account: string, password: string) {
    const data = await authApi.login({ account, password })
    userInfo.value = data.user
    accessToken.value = data.accessToken
    refreshToken.value = data.refreshToken

    // 保存到 localStorage
    localStorage.setItem('access_token', data.accessToken)
    localStorage.setItem('refresh_token', data.refreshToken)
  }

  async function register(params: {
    username: string
    password: string
    email?: string
    phone?: string
  }) {
    const data = await authApi.register(params)
    userInfo.value = data.user
    accessToken.value = data.accessToken
    refreshToken.value = data.refreshToken

    localStorage.setItem('access_token', data.accessToken)
    localStorage.setItem('refresh_token', data.refreshToken)
  }

  function logout() {
    userInfo.value = null
    accessToken.value = ''
    refreshToken.value = ''

    localStorage.removeItem('access_token')
    localStorage.removeItem('refresh_token')
  }

  function initAuth() {
    const token = localStorage.getItem('access_token')
    const refresh = localStorage.getItem('refresh_token')
    if (token) {
      accessToken.value = token
    }
    if (refresh) {
      refreshToken.value = refresh
    }
  }

  return {
    userInfo,
    accessToken,
    refreshToken,
    isLoggedIn,
    login,
    register,
    logout,
    initAuth
  }
})

步骤 8.2创建应用Store

创建 src/stores/app.ts:

import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useAppStore = defineStore('app', () => {
  const theme = ref<'light' | 'dark'>('light')
  const sidebarCollapsed = ref(false)
  const loading = ref(false)

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
  }

  function toggleSidebar() {
    sidebarCollapsed.value = !sidebarCollapsed.value
  }

  function setLoading(value: boolean) {
    loading.value = value
  }

  return {
    theme,
    sidebarCollapsed,
    loading,
    toggleTheme,
    toggleSidebar,
    setLoading
  }
})

验证: 测试状态管理是否正常工作


阶段九:路由配置

步骤 9.1:配置路由

创建 src/router/index.ts:

import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'

const routes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/auth/Login.vue'),
    meta: { requiresAuth: false }
  },
  {
    path: '/register',
    name: 'Register',
    component: () => import('@/views/auth/Register.vue'),
    meta: { requiresAuth: false }
  },
  {
    path: '/',
    component: () => import('@/components/layout/MainLayout.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: '',
        name: 'Home',
        component: () => import('@/views/home/index.vue')
      },
      {
        path: 'user/profile',
        name: 'UserProfile',
        component: () => import('@/views/user/Profile.vue')
      },
      {
        path: 'user/settings',
        name: 'UserSettings',
        component: () => import('@/views/user/Settings.vue')
      },
      {
        path: 'groups',
        name: 'GroupList',
        component: () => import('@/views/group/List.vue')
      },
      {
        path: 'groups/create',
        name: 'GroupCreate',
        component: () => import('@/views/group/Create.vue')
      },
      {
        path: 'groups/:id',
        name: 'GroupDetail',
        component: () => import('@/views/group/Detail.vue')
      },
      {
        path: 'games',
        name: 'GameList',
        component: () => import('@/views/game/List.vue')
      },
      {
        path: 'games/:id',
        name: 'GameDetail',
        component: () => import('@/views/game/Detail.vue')
      },
      {
        path: 'appointments',
        name: 'AppointmentList',
        component: () => import('@/views/appointment/List.vue')
      },
      {
        path: 'appointments/create',
        name: 'AppointmentCreate',
        component: () => import('@/views/appointment/Create.vue')
      },
      {
        path: 'points/ranking/:groupId',
        name: 'PointsRanking',
        component: () => import('@/views/points/Ranking.vue')
      },
      {
        path: 'honors/:groupId',
        name: 'Honors',
        component: () => import('@/views/honor/Timeline.vue')
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 路由守卫
router.beforeEach((to, from, next) => {
  const authStore = useAuthStore()
  const requiresAuth = to.meta.requiresAuth !== false

  if (requiresAuth && !authStore.isLoggedIn) {
    next({
      name: 'Login',
      query: { redirect: to.fullPath }
    })
  } else {
    next()
  }
})

export default router

验证: 测试路由导航和权限控制是否正常


阶段十:页面开发

步骤 10.1:登录页面

创建 src/views/auth/Login.vue:

<template>
  <div class="auth-page">
    <div class="auth-container">
      <div class="auth-header">
        <h1 class="logo">GameGroup</h1>
        <p class="subtitle">游戏社群管理平台</p>
      </div>

      <el-form
        ref="formRef"
        :model="formData"
        :rules="formRules"
        class="auth-form"
        @submit.prevent="handleLogin"
      >
        <el-form-item prop="account">
          <el-input
            v-model="formData.account"
            placeholder="用户名/邮箱/手机号"
            size="large"
            prefix-icon="User"
          />
        </el-form-item>

        <el-form-item prop="password">
          <el-input
            v-model="formData.password"
            type="password"
            placeholder="密码"
            size="large"
            prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item>
          <el-button
            type="primary"
            size="large"
            :loading="loading"
            native-type="submit"
            class="submit-btn"
          >
            登录
          </el-button>
        </el-form-item>
      </el-form>

      <div class="auth-footer">
        <p>还没有账号<router-link to="/register" class="link">立即注册</router-link></p>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'

const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()

const formRef = ref<FormInstance>()
const loading = ref(false)

const formData = reactive({
  account: '',
  password: ''
})

const formRules: FormRules = {
  account: [
    { required: true, message: '请输入账号', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少6位', trigger: 'blur' }
  ]
}

const handleLogin = async () => {
  if (!formRef.value) return

  await formRef.value.validate(async (valid) => {
    if (!valid) return

    loading.value = true
    try {
      await authStore.login(formData.account, formData.password)
      ElMessage.success('登录成功')

      const redirect = (route.query.redirect as string) || '/'
      router.push(redirect)
    } catch (error) {
      console.error('登录失败:', error)
    } finally {
      loading.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.auth-page {
  @apply min-h-screen bg-gradient-to-br from-primary-100 to-accent-100 flex items-center justify-center p-4;
}

.auth-container {
  @apply bg-white rounded-2xl shadow-2xl p-8 w-full max-w-md;
}

.auth-header {
  @apply text-center mb-8;
}

.logo {
  @apply text-3xl font-bold bg-gradient-to-r from-primary-500 to-accent-500 bg-clip-text text-transparent;
}

.subtitle {
  @apply text-gray-500 mt-2;
}

.auth-form {
  @apply space-y-4;
}

.submit-btn {
  @apply w-full;
}

.auth-footer {
  @apply text-center mt-6 text-sm text-gray-500;

  .link {
    @apply text-primary-500 hover:text-primary-600 font-medium;
  }
}
</style>

验证: 测试登录功能和表单验证

步骤 10.2:注册页面

创建 src/views/auth/Register.vue:

<template>
  <div class="auth-page">
    <div class="auth-container">
      <div class="auth-header">
        <h1 class="logo">GameGroup</h1>
        <p class="subtitle">创建新账号</p>
      </div>

      <el-form
        ref="formRef"
        :model="formData"
        :rules="formRules"
        class="auth-form"
        @submit.prevent="handleRegister"
      >
        <el-form-item prop="username">
          <el-input
            v-model="formData.username"
            placeholder="用户名"
            size="large"
            prefix-icon="User"
          />
        </el-form-item>

        <el-form-item prop="email">
          <el-input
            v-model="formData.email"
            placeholder="邮箱(可选)"
            size="large"
            prefix-icon="Message"
          />
        </el-form-item>

        <el-form-item prop="phone">
          <el-input
            v-model="formData.phone"
            placeholder="手机号(可选)"
            size="large"
            prefix-icon="Phone"
          />
        </el-form-item>

        <el-form-item prop="password">
          <el-input
            v-model="formData.password"
            type="password"
            placeholder="密码"
            size="large"
            prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item prop="confirmPassword">
          <el-input
            v-model="formData.confirmPassword"
            type="password"
            placeholder="确认密码"
            size="large"
            prefix-icon="Lock"
            show-password
          />
        </el-form-item>

        <el-form-item>
          <el-button
            type="primary"
            size="large"
            :loading="loading"
            native-type="submit"
            class="submit-btn"
          >
            注册
          </el-button>
        </el-form-item>
      </el-form>

      <div class="auth-footer">
        <p>已有账号<router-link to="/login" class="link">立即登录</router-link></p>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'

const router = useRouter()
const authStore = useAuthStore()

const formRef = ref<FormInstance>()
const loading = ref(false)

const formData = reactive({
  username: '',
  email: '',
  phone: '',
  password: '',
  confirmPassword: ''
})

const validateConfirmPassword = (rule: any, value: string, callback: any) => {
  if (value !== formData.password) {
    callback(new Error('两次密码不一致'))
  } else {
    callback()
  }
}

const formRules: FormRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 3, max: 20, message: '用户名长度为3-20个字符', trigger: 'blur' }
  ],
  email: [
    { type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
  ],
  phone: [
    { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 20, message: '密码长度为6-20个字符', trigger: 'blur' }
  ],
  confirmPassword: [
    { required: true, message: '请确认密码', trigger: 'blur' },
    { validator: validateConfirmPassword, trigger: 'blur' }
  ]
}

const handleRegister = async () => {
  if (!formRef.value) return

  await formRef.value.validate(async (valid) => {
    if (!valid) return

    loading.value = true
    try {
      await authStore.register({
        username: formData.username,
        email: formData.email || undefined,
        phone: formData.phone || undefined,
        password: formData.password
      })
      ElMessage.success('注册成功')
      router.push('/')
    } catch (error) {
      console.error('注册失败:', error)
    } finally {
      loading.value = false
    }
  })
}
</script>

<style scoped lang="scss">
// 使用与登录页相同的样式
</style>

验证: 测试注册功能和表单验证

步骤 10.3:首页

创建 src/views/home/index.vue:

<template>
  <div class="home-page">
    <!-- 欢迎卡片 -->
    <Card class="welcome-card">
      <h2>欢迎{{ userInfo?.nickname || userInfo?.username }}</h2>
      <p class="welcome-text">今天有 {{ todayAppointments }} 个预约待参加</p>
    </Card>

    <!-- 我的小组 -->
    <section class="section">
      <div class="section-header">
        <h3>我的小组</h3>
        <el-button text type="primary" @click="router.push('/groups')">
          查看全部
        </el-button>
      </div>
      <div v-if="groups.length" class="group-grid">
        <GroupCard
          v-for="group in groups.slice(0, 3)"
          :key="group.id"
          :group="group"
          @click="router.push(`/groups/${group.id}`)"
        />
      </div>
      <el-empty v-else description="暂无小组" />
    </section>

    <!-- 即将开始的预约 -->
    <section class="section">
      <div class="section-header">
        <h3>即将开始的活动</h3>
        <el-button text type="primary" @click="router.push('/appointments')">
          查看全部
        </el-button>
      </div>
      <div v-if="upcomingAppointments.length" class="appointment-list">
        <AppointmentCard
          v-for="appointment in upcomingAppointments"
          :key="appointment.id"
          :appointment="appointment"
          @view-detail="router.push(`/appointments/${appointment.id}`)"
        />
      </div>
      <el-empty v-else description="暂无即将开始的预约" />
    </section>

    <!-- 热门游戏 -->
    <section class="section">
      <div class="section-header">
        <h3>热门游戏</h3>
        <el-button text type="primary" @click="router.push('/games')">
          查看全部
        </el-button>
      </div>
      <div v-if="popularGames.length" class="game-grid">
        <GameCard
          v-for="game in popularGames"
          :key="game.id"
          :game="game"
          @click="router.push(`/games/${game.id}`)"
        />
      </div>
      <el-empty v-else description="暂无游戏" />
    </section>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import Card from '@/components/common/Card/Card.vue'
import GroupCard from '@/components/business/GroupCard/GroupCard.vue'
import GameCard from '@/components/business/GameCard/GameCard.vue'
import AppointmentCard from '@/components/business/AppointmentCard/AppointmentCard.vue'
import { groupApi } from '@/api/group'
import { gameApi } from '@/api/game'
import { appointmentApi } from '@/api/appointment'

const router = useRouter()
const authStore = useAuthStore()

const userInfo = computed(() => authStore.userInfo)
const groups = ref<GroupInfo[]>([])
const popularGames = ref<GameInfo[]>([])
const upcomingAppointments = ref<AppointmentInfo[]>([])

const todayAppointments = computed(() => {
  return upcomingAppointments.value.length
})

onMounted(async () => {
  await loadGroups()
  await loadPopularGames()
  await loadUpcomingAppointments()
})

async function loadGroups() {
  try {
    groups.value = await groupApi.getMyGroups()
  } catch (error) {
    console.error('加载小组失败:', error)
  }
}

async function loadPopularGames() {
  try {
    popularGames.value = await gameApi.getPopularGames(6)
  } catch (error) {
    console.error('加载游戏失败:', error)
  }
}

async function loadUpcomingAppointments() {
  try {
    upcomingAppointments.value = await appointmentApi.getMyAppointments({
      status: 'open'
    })
  } catch (error) {
    console.error('加载预约失败:', error)
  }
}
</script>

<style scoped lang="scss">
.home-page {
  @apply space-y-6;
}

.welcome-card {
  @apply bg-gradient-to-r from-primary-500 to-accent-500 text-white;

  h2 {
    @apply text-2xl font-bold mb-2;
  }

  .welcome-text {
    @apply text-white/90;
  }
}

.section {
  @apply bg-white rounded-xl p-6;
}

.section-header {
  @apply flex items-center justify-between mb-4;

  h3 {
    @apply text-lg font-semibold text-gray-900;
  }
}

.group-grid,
.game-grid {
  @apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4;
}

.appointment-list {
  @apply space-y-4;
}
</style>

验证: 测试首页数据显示和导航跳转

步骤 10.4:小组列表页

创建 src/views/group/List.vue:

<template>
  <div class="group-list-page">
    <div class="page-header">
      <h1>小组管理</h1>
      <el-button type="primary" @click="router.push('/groups/create')">
        <el-icon><Plus /></el-icon>
        创建小组
      </el-button>
    </div>

    <el-tabs v-model="activeTab" class="group-tabs">
      <el-tab-pane label="全部" name="all" />
      <el-tab-pane label="我创建的" name="created" />
      <el-tab-pane label="我加入的" name="joined" />
    </el-tabs>

    <div v-loading="loading" class="group-grid">
      <GroupCard
        v-for="group in filteredGroups"
        :key="group.id"
        :group="group"
        @click="router.push(`/groups/${group.id}`)"
      />
    </div>

    <el-empty v-if="!loading && !filteredGroups.length" description="暂无小组" />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import GroupCard from '@/components/business/GroupCard/GroupCard.vue'
import { groupApi } from '@/api/group'

const router = useRouter()
const loading = ref(false)
const activeTab = ref('all')
const groups = ref<GroupInfo[]>([])

const filteredGroups = computed(() => {
  if (activeTab.value === 'all') {
    return groups.value
  } else if (activeTab.value === 'created') {
    return groups.value.filter(g => g.myRole === 'owner')
  } else {
    return groups.value.filter(g => g.myRole && g.myRole !== 'owner')
  }
})

onMounted(async () => {
  await loadGroups()
})

async function loadGroups() {
  loading.value = true
  try {
    groups.value = await groupApi.getMyGroups()
  } catch (error) {
    console.error('加载小组失败:', error)
  } finally {
    loading.value = false
  }
}
</script>

<style scoped lang="scss">
.group-list-page {
  @apply space-y-6;
}

.page-header {
  @apply flex items-center justify-between;
}

.group-tabs {
  @apply bg-white rounded-xl p-4;
}

.group-grid {
  @apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4;
}
</style>

验证: 测试小组列表显示和筛选功能

步骤 10.5:创建其他页面

按照相同模式创建以下页面:

  • src/views/user/Profile.vue - 个人资料
  • src/views/user/Settings.vue - 设置
  • src/views/group/Create.vue - 创建小组
  • src/views/group/Detail.vue - 小组详情
  • src/views/game/List.vue - 游戏列表
  • src/views/game/Detail.vue - 游戏详情
  • src/views/appointment/List.vue - 预约列表
  • src/views/appointment/Create.vue - 创建预约
  • src/views/points/Ranking.vue - 积分排行榜
  • src/views/honor/Timeline.vue - 荣誉墙时间轴

阶段十一:移动端适配

步骤 11.1:创建移动端布局

创建 src/components/layout/MobileLayout.vue:

<template>
  <div class="mobile-layout">
    <!-- 顶部栏 -->
    <div class="mobile-header">
      <el-button icon="Menu" @click="sidebarVisible = true" />
      <h1 class="logo">GameGroup</h1>
      <div class="header-actions">
        <el-badge :value="unreadCount">
          <el-button circle icon="Bell" />
        </el-badge>
        <el-avatar :src="userInfo?.avatar" size="small" />
      </div>
    </div>

    <!-- 主内容区 -->
    <div class="mobile-content">
      <RouterView />
    </div>

    <!-- 底部Tab导航 -->
    <div class="mobile-tabs">
      <div
        v-for="tab in tabs"
        :key="tab.path"
        :class="['tab-item', { active: isActive(tab.path) }]"
        @click="navigate(tab.path)"
      >
        <el-icon>
          <component :is="tab.icon" />
        </el-icon>
        <span>{{ tab.label }}</span>
      </div>
    </div>

    <!-- 侧边栏抽屉 -->
    <el-drawer v-model="sidebarVisible" direction="ltr" size="280px">
      <AppSidebar />
    </el-drawer>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AppSidebar from './AppSidebar.vue'

const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()

const sidebarVisible = ref(false)
const unreadCount = ref(0)

const userInfo = computed(() => authStore.userInfo)

const tabs = [
  { path: '/', icon: 'HomeFilled', label: '首页' },
  { path: '/groups', icon: 'UserFilled', label: '小组' },
  { path: '/games', icon: 'Grid', label: '游戏' },
  { path: '/appointments', icon: 'Calendar', label: '预约' }
]

const isActive = (path: string) => {
  return route.path.startsWith(path)
}

const navigate = (path: string) => {
  router.push(path)
}
</script>

<style scoped lang="scss">
.mobile-layout {
  @apply flex flex-col h-screen bg-gray-50;
}

.mobile-header {
  @apply flex items-center justify-between px-4 py-3 bg-white shadow-sm sticky top-0 z-50;
}

.logo {
  @apply text-lg font-bold bg-gradient-to-r from-primary-500 to-accent-500 bg-clip-text text-transparent;
}

.header-actions {
  @apply flex items-center gap-3;
}

.mobile-content {
  @apply flex-1 overflow-y-auto pb-20;
}

.mobile-tabs {
  @apply flex justify-around items-center py-2 bg-white border-t fixed bottom-0 left-0 right-0 z-50;
}

.tab-item {
  @apply flex flex-col items-center gap-1 py-2 px-4 text-gray-400 transition-colors;

  &.active {
    @apply text-primary-500;
  }
}

@media (min-width: 768px) {
  .mobile-layout {
    @apply hidden;
  }
}
</style>

步骤 11.2:添加响应式断点

tailwind.config.js 中确认移动端断点:

theme: {
  screens: {
    'xs': '0px',
    'sm': '640px',
    'md': '768px',
    'lg': '1024px',
    'xl': '1280px',
    '2xl': '1536px'
  }
}

步骤 11.3:优化移动端触摸

确保按钮最小触摸目标为 44px × 44px

// 移动端按钮优化
@media (max-width: 768px) {
  .g-button {
    min-height: 44px;
    min-width: 44px;
  }
}

验证: 在不同设备尺寸下测试页面显示效果


阶段十二:测试与优化

步骤 12.1:功能测试清单

  • 认证功能

    • 登录成功
    • 登录失败处理
    • 注册成功
    • 注册验证
    • Token 刷新
    • 退出登录
  • 用户中心

    • 查看个人资料
    • 编辑个人资料
    • 修改密码
    • 设置功能
  • 小组管理

    • 创建小组
    • 加入小组
    • 查看小组详情
    • 编辑小组信息
    • 退出小组
    • 成员管理
  • 游戏库

    • 游戏列表展示
    • 游戏搜索
    • 游戏筛选
    • 查看游戏详情
  • 预约系统

    • 创建预约
    • 查看预约列表
    • 加入预约
    • 退出预约
    • 预约状态管理

步骤 12.2:性能优化

  • 启用路由懒加载
  • 组件按需加载
  • 图片懒加载
  • 长列表虚拟滚动
  • 防抖和节流
  • 代码分割

步骤 12.3:响应式测试

测试设备:

  • iPhone SE (375px)
  • iPhone 12 Pro (390px)
  • iPad (768px)
  • iPad Pro (1024px)
  • Desktop (1920px)

步骤 12.4:构建和部署

# 构建生产版本
npm run build

# 预览构建结果
npm run preview

# 检查构建文件大小
ls -lh dist/

验证: 确认构建输出正常,文件大小合理


附录:开发规范

A.1 命名规范

  • 组件文件: PascalCase (UserCard.vue)
  • 普通文件: kebab-case (user-profile.ts)
  • 变量/函数: camelCase (getUserInfo)
  • 常量: UPPER_SNAKE_CASE (API_BASE_URL)
  • 类型/接口: PascalCase (UserProfile)

A.2 代码组织

<script setup lang="ts">
// 1. 导入
import { ref, computed } from 'vue'

// 2. Props定义
interface Props {}
const props = defineProps<Props>()

// 3. Emits定义
interface Emits {}
const emit = defineEmits<Emits>()

// 4. 响应式数据
const state = ref()

// 5. 计算属性
const computed = computed(() => {})

// 6. 方法
const method = () => {}

// 7. 生命周期
onMounted(() => {})
</script>

A.3 Git提交规范

feat: 新功能
fix: 修复bug
docs: 文档更新
style: 代码格式调整
refactor: 重构
test: 测试相关
chore: 构建/工具相关

总结

本文档提供了 GameGroup 前端项目的完整开发步骤,涵盖从项目初始化到部署上线的全过程。

预计开发周期: 55个工作日约11周

关键里程碑:

  • Week 1-2: 项目初始化、基础组件、布局
  • Week 3-5: 核心业务模块(认证、用户、小组、游戏)
  • Week 6-8: 高级功能模块(预约、积分、荣誉等)
  • Week 9-10: 移动端适配、性能优化
  • Week 11: 测试、修复、部署

持续更新: 本文档应随开发进度持续更新和完善。


文档维护: Frontend Team 创建时间: 2026-01-28 最后更新: 2026-01-28