Files
gamegroup_fe/doc/开发步骤文档.md

2801 lines
62 KiB
Markdown
Raw Permalink Normal View History

2026-01-28 14:35:43 +08:00
# 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