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

2801 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# GameGroup 前端开发步骤文档
**项目名称**: GameGroupFE
**文档版本**: v1.0
**创建时间**: 2026-01-28
**技术栈**: Vue 3 + TypeScript + Vite + Pinia + Vue Router + Element Plus + Tailwind CSS
---
## 📋 目录
- [阶段一:项目初始化](#阶段一项目初始化)
- [阶段二:基础配置](#阶段二基础配置)
- [阶段三:目录结构创建](#阶段三目录结构创建)
- [阶段四:基础组件开发](#阶段四基础组件开发)
- [阶段五:布局组件开发](#阶段五布局组件开发)
- [阶段六:业务组件开发](#阶段六业务组件开发)
- [阶段七API服务层](#阶段七api服务层)
- [阶段八:状态管理](#阶段八状态管理)
- [阶段九:路由配置](#阶段九路由配置)
- [阶段十:页面开发](#阶段十页面开发)
- [阶段十一:移动端适配](#阶段十一移动端适配)
- [阶段十二:测试与优化](#阶段十二测试与优化)
---
## 阶段一:项目初始化
### 步骤 1.1:创建 Vite 项目
```bash
# 在项目根目录执行
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:安装核心依赖
```bash
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:安装开发依赖
```bash
# 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
```bash
npx tailwindcss init -p
```
**验证**: 检查是否生成以下文件
- [ ] `tailwind.config.js`
- [ ] `postcss.config.js`
### 步骤 1.5:初始化 Git
```bash
git init
git add .
git commit -m "feat: 初始化项目脚手架"
```
**验证**: 检查 `.git` 目录是否存在
---
## 阶段二:基础配置
### 步骤 2.1:配置 Vite
编辑 `vite.config.ts`:
```typescript
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`,添加路径别名:
```json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
```
**验证**: 在 `.vue` 文件中测试导入 `import { xxx } from '@/utils'`
### 步骤 2.3:配置 Tailwind CSS
编辑 `tailwind.config.js`:
```javascript
/** @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`:
```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`:
```typescript
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:创建完整目录结构
```bash
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:创建基础文件
```bash
# 创建类型定义文件
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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```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`:
```typescript
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`:
```typescript
// 统一响应格式
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`:
```typescript
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`:
```typescript
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`:
```typescript
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`:
```typescript
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`:
```typescript
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`:
```typescript
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`:
```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`:
```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`:
```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`:
```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`:
```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` 中确认移动端断点:
```javascript
theme: {
screens: {
'xs': '0px',
'sm': '640px',
'md': '768px',
'lg': '1024px',
'xl': '1280px',
'2xl': '1536px'
}
}
```
### 步骤 11.3:优化移动端触摸
确保按钮最小触摸目标为 44px × 44px
```scss
// 移动端按钮优化
@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:构建和部署
```bash
# 构建生产版本
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 代码组织
```vue
<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提交规范
```bash
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