feat: 完成基础组件、布局组件和业务组件开发

This commit is contained in:
UGREEN USER
2026-01-28 14:41:09 +08:00
parent 1c831dbe69
commit b657c3d8aa
15 changed files with 994 additions and 0 deletions

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,3 @@
import Button from './Button.vue'
export default Button

View 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>

View File

@@ -0,0 +1,3 @@
import Card from './Card.vue'
export default Card

View 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>

View File

@@ -0,0 +1,3 @@
import Input from './Input.vue'
export default Input

View 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>

View File

@@ -0,0 +1,3 @@
import Modal from './Modal.vue'
export default Modal

View 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>

View 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>

View 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>