feat: 完成基础组件、布局组件和业务组件开发
This commit is contained in:
158
src/components/business/AppointmentCard/AppointmentCard.vue
Normal file
158
src/components/business/AppointmentCard/AppointmentCard.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<div class="appointment-header">
|
||||||
|
<img :src="appointment.game.coverUrl || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" 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">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Clock, User } from '@element-plus/icons-vue'
|
||||||
|
import Card from '@/components/common/Card/Card.vue'
|
||||||
|
import type { AppointmentInfo } from '@/types/appointment'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appointment: AppointmentInfo
|
||||||
|
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: Record<string, any> = {
|
||||||
|
open: 'success',
|
||||||
|
full: 'warning',
|
||||||
|
cancelled: 'danger',
|
||||||
|
finished: 'info'
|
||||||
|
}
|
||||||
|
return typeMap[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusText = (status: string) => {
|
||||||
|
const textMap: Record<string, string> = {
|
||||||
|
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>
|
||||||
96
src/components/business/GameCard/GameCard.vue
Normal file
96
src/components/business/GameCard/GameCard.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<Card :clickable="clickable" @click="handleClick" hoverable>
|
||||||
|
<div class="game-cover">
|
||||||
|
<img :src="game.coverUrl || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" :alt="game.name" />
|
||||||
|
<div class="game-overlay">
|
||||||
|
<el-button type="primary" size="small">查看详情</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-content">
|
||||||
|
<div v-if="showTags && game.tags?.length" 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">
|
||||||
|
import type { GameInfo } from '@/types/game'
|
||||||
|
import Card from '@/components/common/Card/Card.vue'
|
||||||
|
import { User } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
game: GameInfo
|
||||||
|
clickable?: boolean
|
||||||
|
showTags?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
clickable: true,
|
||||||
|
showTags: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [game: GameInfo]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const getGameTagType = (tag: string) => {
|
||||||
|
const typeMap: Record<string, any> = {
|
||||||
|
'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>
|
||||||
102
src/components/business/GroupCard/GroupCard.vue
Normal file
102
src/components/business/GroupCard/GroupCard.vue
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<template>
|
||||||
|
<Card :clickable="clickable" @click="handleClick">
|
||||||
|
<template #header>
|
||||||
|
<div class="group-header">
|
||||||
|
<img :src="group.avatar || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" :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">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import Card from '@/components/common/Card/Card.vue'
|
||||||
|
import type { GroupInfo } from '@/types/group'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
group: GroupInfo
|
||||||
|
clickable?: boolean
|
||||||
|
showTags?: boolean
|
||||||
|
showActions?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
clickable: true,
|
||||||
|
showTags: true,
|
||||||
|
showActions: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [group: GroupInfo]
|
||||||
|
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>
|
||||||
81
src/components/business/UserCard/UserCard.vue
Normal file
81
src/components/business/UserCard/UserCard.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="cardClass" @click="handleClick">
|
||||||
|
<img :src="user.avatar || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" :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-tag v-if="user.isMember" type="primary" size="small">会员</el-tag>
|
||||||
|
<el-tag v-if="user.isOnline" type="success" size="small">在线</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { UserInfo } from '@/types/user'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: UserInfo
|
||||||
|
clickable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
clickable: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [user: UserInfo]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const roleText = computed(() => {
|
||||||
|
const roleMap: Record<string, string> = {
|
||||||
|
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>
|
||||||
106
src/components/common/Button/Button.vue
Normal file
106
src/components/common/Button/Button.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<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">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
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>
|
||||||
3
src/components/common/Button/index.ts
Normal file
3
src/components/common/Button/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Button from './Button.vue'
|
||||||
|
|
||||||
|
export default Button
|
||||||
90
src/components/common/Card/Card.vue
Normal file
90
src/components/common/Card/Card.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<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">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
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>
|
||||||
3
src/components/common/Card/index.ts
Normal file
3
src/components/common/Card/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Card from './Card.vue'
|
||||||
|
|
||||||
|
export default Card
|
||||||
88
src/components/common/Input/Input.vue
Normal file
88
src/components/common/Input/Input.vue
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
<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">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
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>
|
||||||
3
src/components/common/Input/index.ts
Normal file
3
src/components/common/Input/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Input from './Input.vue'
|
||||||
|
|
||||||
|
export default Input
|
||||||
90
src/components/common/Modal/Modal.vue
Normal file
90
src/components/common/Modal/Modal.vue
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<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;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
@apply text-lg font-semibold text-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
3
src/components/common/Modal/index.ts
Normal file
3
src/components/common/Modal/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Modal from './Modal.vue'
|
||||||
|
|
||||||
|
export default Modal
|
||||||
78
src/components/layout/AppHeader.vue
Normal file
78
src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<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 || 'https://cube.elemecdn.com/0/88/03b0d39583f48206768a7534e55bcpng.png'" />
|
||||||
|
</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, computed } 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>
|
||||||
57
src/components/layout/AppSidebar.vue
Normal file
57
src/components/layout/AppSidebar.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<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><UserFilled /></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>
|
||||||
36
src/components/layout/MainLayout.vue
Normal file
36
src/components/layout/MainLayout.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<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>
|
||||||
Reference in New Issue
Block a user