2801 lines
62 KiB
Markdown
2801 lines
62 KiB
Markdown
|
|
# 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.1:Button 按钮
|
|||
|
|
|
|||
|
|
创建 `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.2:Input 输入框
|
|||
|
|
|
|||
|
|
创建 `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.3:Modal 弹窗
|
|||
|
|
|
|||
|
|
创建 `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.4:Card 卡片
|
|||
|
|
|
|||
|
|
创建 `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.1:MainLayout 主布局
|
|||
|
|
|
|||
|
|
创建 `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.2:AppHeader 顶部导航
|
|||
|
|
|
|||
|
|
创建 `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.3:AppSidebar 侧边栏
|
|||
|
|
|
|||
|
|
创建 `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.1:UserCard 用户卡片
|
|||
|
|
|
|||
|
|
创建 `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.2:GroupCard 小组卡片
|
|||
|
|
|
|||
|
|
创建 `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.3:GameCard 游戏卡片
|
|||
|
|
|
|||
|
|
创建 `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.4:AppointmentCard 预约卡片
|
|||
|
|
|
|||
|
|
创建 `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
|