feat: UI redesign v0.0.2 — color unification, navigation improvements, mobile support

- Unify color palette from mixed green/blue/purple to consistent green theme
- Sidebar: add text labels to create/join group buttons for discoverability
- Header: add quick action buttons (create group, join group, notifications)
- Mobile: add hamburger menu with slide-out sidebar and overlay
- Home: add prominent CTA buttons, onboarding card for empty state
- Join group dialog: add search-by-name mode alongside existing ID lookup
- Games library: inline group selector dropdown instead of external selection

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-18 12:24:20 +08:00
parent 277a484f60
commit cfdbaf1095
13 changed files with 1061 additions and 237 deletions
+14
View File
@@ -38,6 +38,20 @@ export async function getGroup(groupId: string): Promise<Group> {
}) as unknown as Group }) as unknown as Group
} }
// 按名称搜索群组
export async function searchGroups(keyword: string): Promise<Group[]> {
if (!keyword.trim()) return []
const user = pb.authStore.model
const filter = `name ~ "${keyword.trim()}" && id != "${user?.id}"`
const result = await pb.collection('groups').getList(1, 20, {
filter,
$autoCancel: false
})
return result.items as unknown as Group[]
}
// 直接加入群组(无需审核时调用) // 直接加入群组(无需审核时调用)
export async function joinGroup(groupId: string) { export async function joinGroup(groupId: string) {
const user = pb.authStore.model const user = pb.authStore.model
+4 -4
View File
@@ -4,9 +4,9 @@
--gg-primary-light: #10b981; --gg-primary-light: #10b981;
--gg-primary-dark: #047857; --gg-primary-dark: #047857;
/* 辅助色:深紫 */ /* 辅助色:翠绿 */
--gg-accent: #7c3aed; --gg-accent: #0d9488;
--gg-accent-light: #8b5cf6; --gg-accent-light: #14b8a6;
/* 背景色(亮色) */ /* 背景色(亮色) */
--gg-bg: #f0fdf4; --gg-bg: #f0fdf4;
@@ -40,7 +40,7 @@
--gg-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1); --gg-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.1);
/* 渐变 */ /* 渐变 */
--gg-gradient: linear-gradient(135deg, #059669 0%, #7c3aed 100%); --gg-gradient: linear-gradient(135deg, #059669 0%, #0d9488 100%);
--gg-gradient-green: linear-gradient(135deg, #10b981 0%, #059669 100%); --gg-gradient-green: linear-gradient(135deg, #10b981 0%, #059669 100%);
--gg-gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); --gg-gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
--gg-gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%); --gg-gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);
@@ -103,7 +103,7 @@ function handleCreateTeam() {
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; } .detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
.detail-meta { display: flex; align-items: center; gap: 12px; } .detail-meta { display: flex; align-items: center; gap: 12px; }
.platform-badge { padding: 4px 14px; background: rgba(168, 85, 247, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; } .platform-badge { padding: 4px 14px; background: rgba(5, 150, 105, 0.15); color: var(--gg-accent); border-radius: 6px; font-size: 13px; font-weight: 600; }
.popularity { font-size: 14px; color: var(--gg-text-secondary); } .popularity { font-size: 14px; color: var(--gg-text-secondary); }
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; } .detail-tags { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
+260 -32
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getGroup, joinGroup, createJoinRequest } from '@/api/groups' import { getGroup, joinGroup, createJoinRequest, searchGroups } from '@/api/groups'
import { useGroupStore } from '@/stores/group'
import type { Group } from '@/types' import type { Group } from '@/types'
import { Search, Link } from '@element-plus/icons-vue'
const props = defineProps<{ const props = defineProps<{
modelValue: boolean modelValue: boolean
@@ -12,46 +14,76 @@ const emit = defineEmits<{
'update:modelValue': [value: boolean] 'update:modelValue': [value: boolean]
}>() }>()
const groupStore = useGroupStore()
const visible = computed({ const visible = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (val) => emit('update:modelValue', val) set: (val) => emit('update:modelValue', val)
}) })
const searchKeyword = ref('')
const searchResults = ref<Group[]>([])
const groupId = ref('') const groupId = ref('')
const groupInfo = ref<Group | null>(null) const selectedGroup = ref<Group | null>(null)
const loading = ref(false) const loading = ref(false)
const joining = ref(false) const joining = ref(false)
const mode = ref<'search' | 'id'>('search')
async function searchGroup() { // 已加入的群组 ID 集合
const joinedGroupIds = computed(() => new Set(groupStore.groups.map(g => g.id)))
async function handleSearch() {
const kw = searchKeyword.value.trim()
if (!kw) {
ElMessage.warning('请输入群组名称')
return
}
loading.value = true
try {
searchResults.value = await searchGroups(kw)
if (searchResults.value.length === 0) {
ElMessage.info('未找到匹配的群组')
}
} catch {
ElMessage.error('搜索失败')
} finally {
loading.value = false
}
}
async function searchById() {
if (!groupId.value.trim()) { if (!groupId.value.trim()) {
ElMessage.warning('请输入群组 ID') ElMessage.warning('请输入群组 ID')
return return
} }
loading.value = true loading.value = true
try { try {
groupInfo.value = await getGroup(groupId.value.trim()) selectedGroup.value = await getGroup(groupId.value.trim())
} catch { } catch {
groupInfo.value = null selectedGroup.value = null
ElMessage.error('未找到该群组') ElMessage.error('未找到该群组')
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function selectFromResults(group: Group) {
selectedGroup.value = group
}
async function handleJoin() { async function handleJoin() {
if (!groupInfo.value) return if (!selectedGroup.value) return
joining.value = true joining.value = true
try { try {
if (groupInfo.value.requireApproval) { if (selectedGroup.value.requireApproval) {
await createJoinRequest(groupInfo.value.id) await createJoinRequest(selectedGroup.value.id)
ElMessage.success('已提交加入申请,等待群主审核') ElMessage.success('已提交加入申请,等待群主审核')
} else { } else {
await joinGroup(groupInfo.value.id) await joinGroup(selectedGroup.value.id)
ElMessage.success('已成功加入群组') ElMessage.success('已成功加入群组')
} }
visible.value = false visible.value = false
groupId.value = '' reset()
groupInfo.value = null
} catch (error: any) { } catch (error: any) {
ElMessage.error(error.message || '操作失败') ElMessage.error(error.message || '操作失败')
} finally { } finally {
@@ -60,36 +92,124 @@ async function handleJoin() {
} }
function reset() { function reset() {
searchKeyword.value = ''
searchResults.value = []
groupId.value = '' groupId.value = ''
groupInfo.value = null selectedGroup.value = null
} }
</script> </script>
<template> <template>
<el-dialog v-model="visible" title="加入群组" width="440px" @close="reset"> <el-dialog v-model="visible" title="加入群组" width="480px" @close="reset">
<div class="join-form"> <div class="join-form">
<div class="form-field"> <!-- 切换模式 -->
<label>群组 ID</label> <div class="mode-tabs">
<div class="search-row"> <button
<el-input v-model="groupId" placeholder="输入群主分享的群组 ID" /> class="mode-tab"
<el-button type="primary" :loading="loading" @click="searchGroup">查找</el-button> :class="{ 'mode-tab--active': mode === 'search' }"
</div> @click="mode = 'search'; selectedGroup = null"
>
<el-icon><Search /></el-icon> 按名称搜索
</button>
<button
class="mode-tab"
:class="{ 'mode-tab--active': mode === 'id' }"
@click="mode = 'id'; selectedGroup = null"
>
<el-icon><Link /></el-icon> ID 查找
</button>
</div> </div>
<div v-if="groupInfo" class="group-preview"> <!-- 搜索模式 -->
<div class="preview-header"> <template v-if="mode === 'search'">
<h3 class="preview-name">{{ groupInfo.name }}</h3> <div class="form-field">
<span class="preview-members">{{ groupInfo.members?.length || 0 }} / {{ groupInfo.maxMembers }} </span> <div class="search-row">
<el-input
v-model="searchKeyword"
placeholder="输入群组名称搜索..."
@keyup.enter="handleSearch"
/>
<el-button type="primary" :loading="loading" @click="handleSearch">搜索</el-button>
</div>
</div> </div>
<p v-if="groupInfo.description" class="preview-desc">{{ groupInfo.description }}</p>
<div class="approval-tag"> <!-- 搜索结果列表 -->
<span v-if="groupInfo.requireApproval" class="tag-approval">需要审核</span> <div v-if="searchResults.length > 0 && !selectedGroup" class="results-list">
<span v-else class="tag-direct">直接加入</span> <div
v-for="group in searchResults"
:key="group.id"
class="result-item"
@click="selectFromResults(group)"
>
<div class="result-info">
<span class="result-name">{{ group.name }}</span>
<span class="result-meta">{{ group.members?.length || 0 }} / {{ group.maxMembers }} </span>
</div>
<div class="result-tags">
<span v-if="joinedGroupIds.has(group.id)" class="tag-joined">已加入</span>
<span v-else-if="group.requireApproval" class="tag-approval">需审核</span>
<span v-else class="tag-direct">可直接加入</span>
</div>
</div>
</div> </div>
<el-button type="primary" :loading="joining" @click="handleJoin" style="width:100%; margin-top: 12px;">
{{ groupInfo.requireApproval ? '申请加入' : '加入群组' }} <!-- 已选中群组预览 -->
</el-button> <div v-if="selectedGroup && mode === 'search'" class="group-preview">
</div> <div class="preview-header">
<h3 class="preview-name">{{ selectedGroup.name }}</h3>
<span class="preview-members">{{ selectedGroup.members?.length || 0 }} / {{ selectedGroup.maxMembers }} </span>
</div>
<p v-if="selectedGroup.description" class="preview-desc">{{ selectedGroup.description }}</p>
<div class="approval-tag">
<span v-if="joinedGroupIds.has(selectedGroup.id)" class="tag-joined">已加入</span>
<span v-else-if="selectedGroup.requireApproval" class="tag-approval">需要审核</span>
<span v-else class="tag-direct">直接加入</span>
</div>
<el-button
v-if="!joinedGroupIds.has(selectedGroup.id)"
type="primary"
:loading="joining"
@click="handleJoin"
style="width: 100%; margin-top: 12px;"
>
{{ selectedGroup.requireApproval ? '申请加入' : '加入群组' }}
</el-button>
<button class="back-btn" @click="selectedGroup = null">返回搜索结果</button>
</div>
</template>
<!-- ID 查找模式 -->
<template v-if="mode === 'id'">
<div class="form-field">
<label>群组 ID</label>
<div class="search-row">
<el-input v-model="groupId" placeholder="输入群主分享的群组 ID" />
<el-button type="primary" :loading="loading" @click="searchById">查找</el-button>
</div>
</div>
<div v-if="selectedGroup && mode === 'id'" class="group-preview">
<div class="preview-header">
<h3 class="preview-name">{{ selectedGroup.name }}</h3>
<span class="preview-members">{{ selectedGroup.members?.length || 0 }} / {{ selectedGroup.maxMembers }} </span>
</div>
<p v-if="selectedGroup.description" class="preview-desc">{{ selectedGroup.description }}</p>
<div class="approval-tag">
<span v-if="joinedGroupIds.has(selectedGroup.id)" class="tag-joined">已加入</span>
<span v-else-if="selectedGroup.requireApproval" class="tag-approval">需要审核</span>
<span v-else class="tag-direct">直接加入</span>
</div>
<el-button
v-if="!joinedGroupIds.has(selectedGroup.id)"
type="primary"
:loading="joining"
@click="handleJoin"
style="width: 100%; margin-top: 12px;"
>
{{ selectedGroup.requireApproval ? '申请加入' : '加入群组' }}
</el-button>
</div>
</template>
</div> </div>
</el-dialog> </el-dialog>
</template> </template>
@@ -98,7 +218,39 @@ function reset() {
.join-form { .join-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 16px;
}
/* ── 模式切换 ── */
.mode-tabs {
display: flex;
gap: 4px;
padding: 4px;
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
}
.mode-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 9px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--gg-text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.mode-tab--active {
background: var(--gg-bg-card);
color: var(--gg-primary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
} }
.form-field { .form-field {
@@ -121,6 +273,54 @@ function reset() {
flex: 1; flex: 1;
} }
/* ── 搜索结果列表 ── */
.results-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
}
.result-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
background: var(--gg-bg);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
cursor: pointer;
transition: all 0.2s;
}
.result-item:hover {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.04);
}
.result-info {
display: flex;
flex-direction: column;
gap: 4px;
}
.result-name {
font-size: 14px;
font-weight: 600;
color: var(--gg-text);
}
.result-meta {
font-size: 12px;
color: var(--gg-text-muted);
}
.result-tags {
flex-shrink: 0;
}
/* ── 群组预览 ── */
.group-preview { .group-preview {
padding: 16px; padding: 16px;
background: var(--gg-bg-card); background: var(--gg-bg-card);
@@ -174,4 +374,32 @@ function reset() {
background: rgba(5, 150, 105, 0.12); background: rgba(5, 150, 105, 0.12);
color: var(--gg-primary); color: var(--gg-primary);
} }
.tag-joined {
display: inline-block;
padding: 3px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: var(--gg-bg-elevated);
color: var(--gg-text-muted);
}
.back-btn {
display: block;
width: 100%;
margin-top: 8px;
padding: 8px;
border: none;
background: transparent;
color: var(--gg-text-muted);
font-size: 13px;
cursor: pointer;
border-radius: var(--gg-radius-sm);
transition: color 0.2s;
}
.back-btn:hover {
color: var(--gg-text);
}
</style> </style>
@@ -173,6 +173,6 @@ async function inviteMember(userId: string, username: string) {
.invite-btn:hover { .invite-btn:hover {
opacity: 0.9; opacity: 0.9;
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3); box-shadow: 0 0 12px rgba(5, 150, 105, 0.3);
} }
</style> </style>
@@ -94,7 +94,7 @@ async function rejectInvitation() {
.invitation-card:hover { .invitation-card:hover {
border-color: var(--gg-primary); border-color: var(--gg-primary);
box-shadow: 0 0 16px rgba(99, 102, 241, 0.12); box-shadow: 0 0 16px rgba(5, 150, 105, 0.12);
} }
.invitation-header { .invitation-header {
@@ -211,6 +211,6 @@ async function rejectInvitation() {
.reject-input textarea:focus { .reject-input textarea:focus {
outline: none; outline: none;
border-color: var(--gg-primary); border-color: var(--gg-primary);
box-shadow: 0 0 8px rgba(99, 102, 241, 0.15); box-shadow: 0 0 8px rgba(5, 150, 105, 0.15);
} }
</style> </style>
@@ -133,7 +133,7 @@ async function handleGameSelected(gameName: string) {
.team-session-panel { .team-session-panel {
background: var(--gg-bg-card); background: var(--gg-bg-card);
border: 1px solid var(--gg-primary); border: 1px solid var(--gg-primary);
box-shadow: 0 0 16px rgba(99, 102, 241, 0.15); box-shadow: 0 0 16px rgba(5, 150, 105, 0.15);
border-radius: var(--gg-radius-md); border-radius: var(--gg-radius-md);
padding: 20px; padding: 20px;
} }
+297 -67
View File
@@ -1,6 +1,6 @@
<!-- src/views/GamesLibrary.vue --> <!-- src/views/GamesLibrary.vue -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, watch } from 'vue'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games' import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games'
import type { Game, GamePlatform } from '@/types' import type { Game, GamePlatform } from '@/types'
@@ -12,6 +12,7 @@ import { Close } from '@element-plus/icons-vue'
const groupStore = useGroupStore() const groupStore = useGroupStore()
const selectedGroupId = ref('')
const games = ref<Game[]>([]) const games = ref<Game[]>([])
const loading = ref(false) const loading = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
@@ -22,19 +23,35 @@ const showDetail = ref(false)
const showAddGame = ref(false) const showAddGame = ref(false)
const showImport = ref(false) const showImport = ref(false)
const currentGroupId = computed(() => groupStore.currentGroupId) const groupOptions = computed(() => groupStore.groups)
const hasGroups = computed(() => groupOptions.value.length > 0)
onMounted(async () => { onMounted(async () => {
if (currentGroupId.value) { if (groupStore.groups.length === 0) {
await groupStore.loadGroups()
}
// 优先使用当前群组,否则选第一个
if (groupStore.currentGroupId) {
selectedGroupId.value = groupStore.currentGroupId
} else if (groupOptions.value.length > 0) {
selectedGroupId.value = groupOptions.value[0].id
}
if (selectedGroupId.value) {
await loadGames() await loadGames()
} }
}) })
watch(selectedGroupId, () => {
searchQuery.value = ''
selectedPlatform.value = ''
loadGames()
})
async function loadGames() { async function loadGames() {
if (!currentGroupId.value) return if (!selectedGroupId.value) return
try { try {
loading.value = true loading.value = true
const result = await getGroupGames(currentGroupId.value, { const result = await getGroupGames(selectedGroupId.value, {
limit: 100, limit: 100,
search: searchQuery.value || undefined, search: searchQuery.value || undefined,
platform: selectedPlatform.value || undefined platform: selectedPlatform.value || undefined
@@ -66,8 +83,8 @@ async function handleDeleteGame(game: Game) {
} }
function handleExport() { function handleExport() {
if (!currentGroupId.value) return if (!selectedGroupId.value) return
exportGames(currentGroupId.value).then(data => { exportGames(selectedGroupId.value).then(data => {
const json = JSON.stringify(data.map(g => ({ const json = JSON.stringify(data.map(g => ({
name: g.name, name: g.name,
platform: g.platform, platform: g.platform,
@@ -78,7 +95,7 @@ function handleExport() {
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `games-export-${currentGroupId.value}.json` a.download = `games-export-${selectedGroupId.value}.json`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${data.length} 个游戏`) ElMessage.success(`已导出 ${data.length} 个游戏`)
@@ -98,35 +115,56 @@ async function handleImportComplete() {
<template> <template>
<div class="games-library"> <div class="games-library">
<!-- 无群组提示 --> <!-- 无群组引导 -->
<div v-if="!currentGroupId" class="no-group"> <div v-if="!hasGroups" class="no-group">
<p class="no-group-text">请先选择一个群组</p> <div class="no-group-icon">🎮</div>
<p class="no-group-hint">在左侧选择或创建群组后即可管理游戏库</p> <p class="no-group-text">还没有群组</p>
<p class="no-group-hint">先创建或加入一个群组然后就可以管理游戏库了</p>
</div> </div>
<template v-else> <template v-else>
<!-- 顶部栏群组选择 + 工具栏 -->
<div class="page-header"> <div class="page-header">
<h1>游戏库</h1> <div class="header-row">
<div class="toolbar"> <el-select
<el-input v-model="selectedGroupId"
v-model="searchQuery" placeholder="选择群组"
placeholder="搜索游戏..." class="group-select"
clearable >
style="width: 240px" <el-option
@input="loadGames" v-for="g in groupOptions"
/> :key="g.id"
<el-select v-model="selectedPlatform" placeholder="平台" clearable style="width: 120px" @change="loadGames"> :label="g.name"
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" /> :value="g.id"
/>
</el-select> </el-select>
<button class="tool-btn primary" @click="showAddGame = true">添加游戏</button> <div class="toolbar">
<button class="tool-btn" @click="showImport = true">导入</button> <el-input
<button class="tool-btn" @click="handleExport">导出</button> v-model="searchQuery"
placeholder="搜索游戏..."
clearable
style="width: 200px"
@input="loadGames"
/>
<el-select v-model="selectedPlatform" placeholder="平台" clearable style="width: 110px" @change="loadGames">
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
</el-select>
</div>
</div>
<div class="actions-row">
<span class="game-count" v-if="!loading">{{ games.length }} 个游戏</span>
<div class="action-btns">
<button class="tool-btn primary" @click="showAddGame = true">添加游戏</button>
<button class="tool-btn" @click="showImport = true">导入</button>
<button class="tool-btn" @click="handleExport">导出</button>
</div>
</div> </div>
</div> </div>
<div v-if="loading" class="loading">加载中...</div> <div v-if="loading" class="loading">加载中...</div>
<div v-else-if="games.length === 0" class="empty"> <div v-else-if="games.length === 0" class="empty">
<div class="empty-icon">📦</div>
<p class="empty-text">暂无游戏</p> <p class="empty-text">暂无游戏</p>
<p class="empty-hint">点击添加游戏导入开始管理游戏库</p> <p class="empty-hint">点击添加游戏导入开始管理游戏库</p>
</div> </div>
@@ -145,9 +183,9 @@ async function handleImportComplete() {
</div> </div>
</div> </div>
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="currentGroupId" @deleted="loadGames" /> <GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="selectedGroupId" @deleted="loadGames" />
<AddGameDialog v-model="showAddGame" :group-id="currentGroupId" @created="handleGameAdded" /> <AddGameDialog v-model="showAddGame" :group-id="selectedGroupId" @created="handleGameAdded" />
<ImportGamesDialog v-model="showImport" :group-id="currentGroupId" @imported="handleImportComplete" /> <ImportGamesDialog v-model="showImport" :group-id="selectedGroupId" @imported="handleImportComplete" />
</template> </template>
</div> </div>
</template> </template>
@@ -156,70 +194,262 @@ async function handleImportComplete() {
.games-library { width: 100%; } .games-library { width: 100%; }
.no-group { .no-group {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100px 32px;
text-align: center; text-align: center;
padding: 120px 32px;
}
.no-group-text { font-size: 18px; font-weight: 600; color: var(--gg-text-secondary); margin: 0 0 8px; }
.no-group-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; }
.page-header { margin-bottom: 24px; }
.page-header h1 {
font-size: 28px; font-weight: 700; margin: 0 0 16px;
background: var(--gg-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
} }
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .no-group-icon {
font-size: 48px;
margin-bottom: 16px;
}
.no-group-text {
font-size: 18px;
font-weight: 600;
color: var(--gg-text-secondary);
margin: 0 0 8px;
}
.no-group-hint {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
/* ── 页面头部 ── */
.page-header {
margin-bottom: 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.header-row {
display: flex;
align-items: center;
gap: 12px;
}
.group-select {
width: 220px;
flex-shrink: 0;
}
.toolbar {
display: flex;
gap: 8px;
align-items: center;
}
.actions-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.game-count {
font-size: 13px;
color: var(--gg-text-muted);
}
.action-btns {
display: flex;
gap: 8px;
}
.tool-btn { .tool-btn {
padding: 8px 18px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm); padding: 7px 16px;
background: var(--gg-bg-card); color: var(--gg-text-secondary); font-size: 13px; font-weight: 500; border: 1px solid var(--gg-border);
cursor: pointer; transition: all 0.2s; white-space: nowrap; border-radius: var(--gg-radius-sm);
background: var(--gg-bg-card);
color: var(--gg-text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
} }
.tool-btn:hover { border-color: var(--gg-primary); color: var(--gg-primary); }
.tool-btn:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.tool-btn.primary { .tool-btn.primary {
background: var(--gg-gradient-green); border-color: transparent; color: white; background: var(--gg-gradient-green);
border-color: transparent;
color: white;
} }
.tool-btn.primary:hover { opacity: 0.9; }
.loading { text-align: center; padding: 80px 32px; color: var(--gg-text-muted); } .tool-btn.primary:hover {
opacity: 0.9;
}
.empty { text-align: center; padding: 80px 32px; } .loading {
.empty-text { font-size: 16px; font-weight: 500; color: var(--gg-text-secondary); margin: 0 0 8px; } text-align: center;
.empty-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; } padding: 80px 32px;
color: var(--gg-text-muted);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 80px 32px;
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
}
.empty-text {
font-size: 16px;
font-weight: 500;
color: var(--gg-text-secondary);
margin: 0 0 8px;
}
.empty-hint {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0;
}
/* ── 游戏网格 ── */
.games-grid { .games-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 16px;
} }
.game-card { .game-card {
position: relative; border-radius: var(--gg-radius-md); overflow: hidden; cursor: pointer; position: relative;
background: var(--gg-bg-card); border: 1px solid var(--gg-border); border-radius: var(--gg-radius-md);
overflow: hidden;
cursor: pointer;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s; transition: transform 0.2s, box-shadow 0.2s, border-color 0.2s;
} }
.game-card:hover { transform: translateY(-4px); border-color: var(--gg-primary); box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15); }
.game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; } .game-card:hover {
transform: translateY(-3px);
border-color: var(--gg-primary);
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.12);
}
.game-info { padding: 14px; } .game-cover {
width: 100%;
aspect-ratio: 3/4;
object-fit: cover;
}
.game-info {
padding: 12px;
}
.game-name { .game-name {
font-size: 16px; font-weight: 700; margin: 0 0 4px; color: var(--gg-text); font-size: 14px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600;
margin: 0 0 4px;
color: var(--gg-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-platform {
font-size: 12px;
color: var(--gg-text-secondary);
margin: 0 0 6px;
}
.game-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
} }
.game-platform { font-size: 13px; color: var(--gg-text-secondary); margin: 0 0 8px; }
.game-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag { .tag {
padding: 3px 10px; background: var(--gg-bg-elevated); border-radius: 6px; padding: 2px 8px;
font-size: 11px; color: var(--gg-primary); font-weight: 500; background: rgba(5, 150, 105, 0.1);
border-radius: 6px;
font-size: 11px;
color: var(--gg-primary);
font-weight: 500;
} }
.delete-btn { .delete-btn {
position: absolute; top: 8px; right: 8px; width: 28px; height: 28px; position: absolute;
border: none; border-radius: 50%; background: rgba(0,0,0,0.5); color: white; top: 8px;
font-size: 12px; cursor: pointer; opacity: 0; transition: opacity 0.2s; right: 8px;
display: flex; align-items: center; justify-content: center; width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: white;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.game-card:hover .delete-btn {
opacity: 1;
}
.delete-btn:hover {
background: var(--gg-danger);
}
/* ── 移动端 ── */
@media (max-width: 768px) {
.header-row {
flex-direction: column;
align-items: stretch;
}
.group-select {
width: 100%;
}
.toolbar {
flex-wrap: wrap;
}
.toolbar .el-input {
width: 100% !important;
}
.actions-row {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.action-btns {
width: 100%;
flex-wrap: wrap;
}
.tool-btn {
flex: 1;
text-align: center;
}
.games-grid {
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
} }
.game-card:hover .delete-btn { opacity: 1; }
.delete-btn:hover { background: var(--gg-danger); }
</style> </style>
+1 -1
View File
@@ -219,7 +219,7 @@ async function refreshMembers() {
font-size: 13px; font-size: 13px;
padding: 4px 14px; padding: 4px 14px;
border-radius: 20px; border-radius: 20px;
background: rgba(99, 102, 241, 0.15); background: rgba(5, 150, 105, 0.15);
color: var(--gg-primary-light); color: var(--gg-primary-light);
font-weight: 500; font-weight: 500;
} }
+257 -86
View File
@@ -4,17 +4,24 @@ import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useTeamStore } from '@/stores/team'
import { getPopularGames } from '@/api/games' import { getPopularGames } from '@/api/games'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue' import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import { UserStatusMap } from '@/types' import { UserStatusMap } from '@/types'
import type { Game } from '@/types' import type { Game } from '@/types'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue' import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
import IdleMembersList from '@/components/team/IdleMembersList.vue' import IdleMembersList from '@/components/team/IdleMembersList.vue'
import { User, Promotion, TrendCharts } from '@element-plus/icons-vue' import { Plus, Search, User, Promotion, TrendCharts } from '@element-plus/icons-vue'
import CreateGroupDialog from '@/components/group/CreateGroupDialog.vue'
import JoinGroupDialog from '@/components/group/JoinGroupDialog.vue'
const router = useRouter() const router = useRouter()
const groupStore = useGroupStore() const groupStore = useGroupStore()
const userStore = useUserStore() const userStore = useUserStore()
const teamStore = useTeamStore()
const showCreateGroup = ref(false)
const showJoinGroup = ref(false)
const popularGames = ref<Game[]>([]) const popularGames = ref<Game[]>([])
const loading = ref(false) const loading = ref(false)
@@ -28,6 +35,9 @@ const statusDotClass = computed(() => {
const statusText = computed(() => UserStatusMap[userStore.userStatus] || '未知') const statusText = computed(() => UserStatusMap[userStore.userStatus] || '未知')
const hasNoGroup = computed(() => groupStore.groups.length === 0)
const hasNoSession = computed(() => !teamStore.currentSession)
onMounted(async () => { onMounted(async () => {
await loadPopularGames() await loadPopularGames()
}) })
@@ -56,33 +66,54 @@ function openGameDetail(game: Game) {
<template> <template>
<div class="home-page"> <div class="home-page">
<!-- 欢迎 --> <!-- 欢迎 + 状态条 -->
<section class="welcome-section"> <section class="welcome-bar">
<h1 class="welcome-title"> <div class="welcome-left">
欢迎回来, <span class="gg-text-gradient">{{ userStore.user?.username || '玩家' }}</span>! <h1 class="welcome-title">
</h1> 欢迎回来, <span class="gg-text-gradient">{{ userStore.user?.username || '玩家' }}</span>
<div class="status-line"> </h1>
<span :class="statusDotClass" /> <div class="status-line">
<span class="status-text">{{ statusText }}</span> <span :class="statusDotClass" />
<span v-if="userStore.user?.statusNote" class="status-note">{{ userStore.user.statusNote }}</span> <span class="status-text">{{ statusText }}</span>
<span v-if="userStore.user?.statusNote" class="status-note">{{ userStore.user.statusNote }}</span>
</div>
</div>
<div class="welcome-actions">
<button class="cta-btn cta-btn--primary" @click="showCreateGroup = true">
<el-icon><Plus /></el-icon> 创建群组
</button>
<button class="cta-btn cta-btn--secondary" @click="showJoinGroup = true">
<el-icon><Search /></el-icon> 加入群组
</button>
</div>
</section>
<!-- 无群组引导 -->
<section v-if="hasNoGroup" class="onboarding-section">
<div class="onboarding-card">
<div class="onboarding-icon"><el-icon :size="48"><User /></el-icon></div>
<h2 class="onboarding-title">开始你的组队之旅</h2>
<p class="onboarding-desc">创建一个群组邀请好友或搜索加入已有群组</p>
<div class="onboarding-actions">
<button class="onboarding-btn onboarding-btn--primary" @click="showCreateGroup = true">
<el-icon><Plus /></el-icon> 创建群组
</button>
<button class="onboarding-btn onboarding-btn--outline" @click="showJoinGroup = true">
<el-icon><Search /></el-icon> 加入群组
</button>
</div>
</div> </div>
</section> </section>
<!-- 主内容双栏 --> <!-- 主内容双栏 -->
<div class="content-grid"> <div v-else class="content-grid">
<!-- 左列: 我的群组 --> <!-- 左列: 我的群组 -->
<section class="section groups-section"> <section class="section groups-section">
<h2 class="section-title"> <h2 class="section-title">
<el-icon class="section-icon"><User /></el-icon> 我的群组 <el-icon class="section-icon"><User /></el-icon> 我的群组
</h2> </h2>
<div v-if="groupStore.groups.length === 0" class="empty-state"> <div class="group-grid">
<div class="empty-icon"><el-icon :size="40"><User /></el-icon></div>
<p class="empty-text">暂无群组</p>
<p class="empty-hint">创建或加入一个群组开始组队冒险吧</p>
</div>
<div v-else class="group-grid">
<div <div
v-for="group in groupStore.groups" v-for="group in groupStore.groups"
:key="group.id" :key="group.id"
@@ -107,7 +138,7 @@ function openGameDetail(game: Game) {
<TeamSessionPanel /> <TeamSessionPanel />
</div> </div>
<div class="idle-section"> <div v-if="!hasNoSession" class="idle-section">
<IdleMembersList status="idle" /> <IdleMembersList status="idle" />
</div> </div>
</section> </section>
@@ -124,9 +155,8 @@ function openGameDetail(game: Game) {
<span>加载中...</span> <span>加载中...</span>
</div> </div>
<div v-else-if="popularGames.length === 0" class="empty-state"> <div v-else-if="popularGames.length === 0" class="empty-games">
<div class="empty-icon"><el-icon :size="40"><TrendCharts /></el-icon></div> <p>暂无热门游戏去群组中添加游戏吧</p>
<p class="empty-text">暂无热门游戏</p>
</div> </div>
<div v-else class="games-scroll"> <div v-else class="games-scroll">
@@ -152,6 +182,8 @@ function openGameDetail(game: Game) {
</section> </section>
<GameDetailDialog v-model="showGameDetail" :game="selectedGame" @create-team="() => {}" /> <GameDetailDialog v-model="showGameDetail" :game="selectedGame" @create-team="() => {}" />
<CreateGroupDialog v-model="showCreateGroup" />
<JoinGroupDialog v-model="showJoinGroup" />
</div> </div>
</template> </template>
@@ -160,12 +192,15 @@ function openGameDetail(game: Game) {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 28px; gap: 24px;
} }
/* ── 欢迎 ── */ /* ── 欢迎 ── */
.welcome-section { .welcome-bar {
padding: 28px 32px; display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 28px;
background: var(--gg-bg-card); background: var(--gg-bg-card);
border: 1px solid var(--gg-border); border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-lg); border-radius: var(--gg-radius-lg);
@@ -173,7 +208,7 @@ function openGameDetail(game: Game) {
overflow: hidden; overflow: hidden;
} }
.welcome-section::before { .welcome-bar::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 0; top: 0;
@@ -183,30 +218,149 @@ function openGameDetail(game: Game) {
background: var(--gg-gradient); background: var(--gg-gradient);
} }
.welcome-left {
display: flex;
flex-direction: column;
gap: 6px;
}
.welcome-title { .welcome-title {
font-size: 28px; font-size: 22px;
font-weight: 700; font-weight: 700;
margin: 0 0 12px; margin: 0;
color: var(--gg-text); color: var(--gg-text);
} }
.status-line { .status-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
} }
.status-text { .status-text {
font-size: 14px; font-size: 13px;
color: var(--gg-text-secondary); color: var(--gg-text-secondary);
font-weight: 500; font-weight: 500;
} }
.status-note { .status-note {
font-size: 13px; font-size: 12px;
color: var(--gg-text-muted); color: var(--gg-text-muted);
} }
.welcome-actions {
display: flex;
gap: 10px;
}
.cta-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: var(--gg-radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.cta-btn--primary {
background: var(--gg-gradient-green);
color: white;
border: none;
}
.cta-btn--primary:hover {
opacity: 0.9;
box-shadow: 0 2px 12px rgba(5, 150, 105, 0.3);
}
.cta-btn--secondary {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
color: var(--gg-text-secondary);
}
.cta-btn--secondary:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
/* ── 新手引导 ── */
.onboarding-section {
padding: 20px 0;
}
.onboarding-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 32px;
background: var(--gg-bg-card);
border: 2px dashed var(--gg-border);
border-radius: var(--gg-radius-lg);
text-align: center;
}
.onboarding-icon {
font-size: 48px;
color: var(--gg-primary);
margin-bottom: 16px;
}
.onboarding-title {
font-size: 20px;
font-weight: 700;
color: var(--gg-text);
margin: 0 0 8px;
}
.onboarding-desc {
font-size: 14px;
color: var(--gg-text-muted);
margin: 0 0 24px;
}
.onboarding-actions {
display: flex;
gap: 12px;
}
.onboarding-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
border-radius: var(--gg-radius-sm);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.onboarding-btn--primary {
background: var(--gg-gradient-green);
color: white;
border: none;
}
.onboarding-btn--primary:hover {
opacity: 0.9;
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
transform: translateY(-1px);
}
.onboarding-btn--outline {
background: var(--gg-bg-card);
border: 2px solid var(--gg-primary);
color: var(--gg-primary);
}
.onboarding-btn--outline:hover {
background: rgba(5, 150, 105, 0.06);
}
/* ── 通用 section ── */ /* ── 通用 section ── */
.section { .section {
display: flex; display: flex;
@@ -214,9 +368,9 @@ function openGameDetail(game: Game) {
} }
.section-title { .section-title {
font-size: 18px; font-size: 16px;
font-weight: 600; font-weight: 600;
margin: 0 0 16px; margin: 0 0 14px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -224,47 +378,47 @@ function openGameDetail(game: Game) {
} }
.section-icon { .section-icon {
font-size: 20px; font-size: 18px;
} }
/* ── 双栏内容 ── */ /* ── 双栏内容 ── */
.content-grid { .content-grid {
display: grid; display: grid;
grid-template-columns: 1fr 340px; grid-template-columns: 1fr 320px;
gap: 28px; gap: 24px;
} }
/* ── 群组区 ── */ /* ── 群组区 ── */
.group-grid { .group-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
gap: 14px; gap: 12px;
} }
.group-card { .group-card {
background: var(--gg-bg-card); background: var(--gg-bg-card);
border: 1px solid var(--gg-border); border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md); border-radius: var(--gg-radius-md);
padding: 18px 20px; padding: 16px 18px;
cursor: pointer; cursor: pointer;
transition: border-color 0.25s, box-shadow 0.25s, transform 0.2s; transition: border-color 0.25s, box-shadow 0.25s, transform 0.2s;
} }
.group-card:hover { .group-card:hover {
border-color: var(--gg-primary); border-color: var(--gg-primary);
box-shadow: 0 0 24px rgba(99, 102, 241, 0.18); box-shadow: 0 0 20px rgba(5, 150, 105, 0.12);
transform: translateY(-2px); transform: translateY(-1px);
} }
.group-card-header { .group-card-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 10px; margin-bottom: 8px;
} }
.group-name { .group-name {
font-size: 15px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--gg-text); color: var(--gg-text);
overflow: hidden; overflow: hidden;
@@ -274,10 +428,10 @@ function openGameDetail(game: Game) {
.member-badge { .member-badge {
font-size: 12px; font-size: 12px;
padding: 3px 10px; padding: 2px 10px;
border-radius: 20px; border-radius: 20px;
background: rgba(99, 102, 241, 0.15); background: rgba(5, 150, 105, 0.12);
color: var(--gg-primary-light); color: var(--gg-primary);
font-weight: 500; font-weight: 500;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -297,7 +451,7 @@ function openGameDetail(game: Game) {
.session-section { .session-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 14px;
} }
.session-card, .session-card,
@@ -313,6 +467,16 @@ function openGameDetail(game: Game) {
margin-top: 0; margin-top: 0;
} }
.empty-games {
padding: 32px;
text-align: center;
color: var(--gg-text-muted);
font-size: 14px;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
}
.games-scroll { .games-scroll {
display: flex; display: flex;
gap: 16px; gap: 16px;
@@ -332,22 +496,22 @@ function openGameDetail(game: Game) {
.game-card { .game-card {
flex-shrink: 0; flex-shrink: 0;
width: 160px; width: 150px;
cursor: pointer; cursor: pointer;
transition: transform 0.25s, box-shadow 0.25s; transition: transform 0.25s, box-shadow 0.25s;
} }
.game-card:hover { .game-card:hover {
transform: translateY(-4px); transform: translateY(-3px);
} }
.game-card:hover .game-cover-wrap { .game-card:hover .game-cover-wrap {
box-shadow: 0 0 20px rgba(99, 102, 241, 0.2); box-shadow: 0 0 16px rgba(5, 150, 105, 0.15);
} }
.game-cover-wrap { .game-cover-wrap {
width: 160px; width: 150px;
height: 200px; height: 190px;
border-radius: var(--gg-radius-md); border-radius: var(--gg-radius-md);
overflow: hidden; overflow: hidden;
background: var(--gg-bg-elevated); background: var(--gg-bg-elevated);
@@ -365,12 +529,12 @@ function openGameDetail(game: Game) {
.game-info { .game-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 4px;
margin-top: 10px; margin-top: 8px;
} }
.game-name { .game-name {
font-size: 14px; font-size: 13px;
font-weight: 500; font-weight: 500;
color: var(--gg-text); color: var(--gg-text);
overflow: hidden; overflow: hidden;
@@ -384,40 +548,11 @@ function openGameDetail(game: Game) {
font-size: 11px; font-size: 11px;
padding: 2px 8px; padding: 2px 8px;
border-radius: 4px; border-radius: 4px;
background: rgba(168, 85, 247, 0.15); background: rgba(5, 150, 105, 0.1);
color: var(--gg-accent-light); color: var(--gg-primary);
font-weight: 500; font-weight: 500;
} }
/* ── 空状态 ── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
background: var(--gg-bg-card);
border: 1px dashed var(--gg-border);
border-radius: var(--gg-radius-md);
}
.empty-icon {
font-size: 40px;
margin-bottom: 12px;
}
.empty-text {
font-size: 15px;
color: var(--gg-text-secondary);
margin: 0 0 4px;
font-weight: 500;
}
.empty-hint {
font-size: 13px;
color: var(--gg-text-muted);
margin: 0;
}
/* ── 加载状态 ── */ /* ── 加载状态 ── */
.loading-state { .loading-state {
display: flex; display: flex;
@@ -441,4 +576,40 @@ function openGameDetail(game: Game) {
@keyframes spin { @keyframes spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── 移动端适配 ── */
@media (max-width: 768px) {
.welcome-bar {
flex-direction: column;
align-items: flex-start;
gap: 14px;
padding: 16px;
}
.welcome-actions {
width: 100%;
}
.cta-btn {
flex: 1;
justify-content: center;
}
.content-grid {
grid-template-columns: 1fr;
}
.group-grid {
grid-template-columns: 1fr;
}
.onboarding-actions {
flex-direction: column;
width: 100%;
}
.onboarding-btn {
justify-content: center;
}
}
</style> </style>
+220 -39
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group' import { useGroupStore } from '@/stores/group'
@@ -10,7 +10,7 @@ import WorkScheduleModal from '@/components/team/WorkScheduleModal.vue'
import NotificationPanel from '@/components/common/NotificationPanel.vue' import NotificationPanel from '@/components/common/NotificationPanel.vue'
import CreateGroupDialog from '@/components/group/CreateGroupDialog.vue' import CreateGroupDialog from '@/components/group/CreateGroupDialog.vue'
import JoinGroupDialog from '@/components/group/JoinGroupDialog.vue' import JoinGroupDialog from '@/components/group/JoinGroupDialog.vue'
import { Monitor, HomeFilled, Grid, Link, AlarmClock, SwitchButton, Bell, Plus } from '@element-plus/icons-vue' import { Monitor, HomeFilled, Grid, Plus, Search, Bell, AlarmClock, SwitchButton } from '@element-plus/icons-vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -22,6 +22,7 @@ const notificationStore = useNotificationStore()
const showScheduleModal = ref(false) const showScheduleModal = ref(false)
const showCreateGroup = ref(false) const showCreateGroup = ref(false)
const showJoinGroup = ref(false) const showJoinGroup = ref(false)
const sidebarOpen = ref(false)
let refreshTimer: ReturnType<typeof setInterval> | null = null let refreshTimer: ReturnType<typeof setInterval> | null = null
@@ -32,7 +33,6 @@ onMounted(async () => {
await notificationStore.loadPendingInvitations() await notificationStore.loadPendingInvitations()
await notificationStore.startListening() await notificationStore.startListening()
// 每30秒轮询刷新群组列表和临时小组状态
refreshTimer = setInterval(async () => { refreshTimer = setInterval(async () => {
await groupStore.loadGroups() await groupStore.loadGroups()
await teamStore.loadActiveSession() await teamStore.loadActiveSession()
@@ -55,17 +55,30 @@ function handleLogout() {
function selectGroup(groupId: string) { function selectGroup(groupId: string) {
groupStore.setCurrentGroup(groupId) groupStore.setCurrentGroup(groupId)
router.push({ name: 'GroupView', params: { id: groupId } }) router.push({ name: 'GroupView', params: { id: groupId } })
sidebarOpen.value = false
} }
function goHome() { function goHome() {
router.push({ name: 'Home' }) router.push({ name: 'Home' })
sidebarOpen.value = false
} }
const pageTitle = computed(() => {
if (route.name === 'GroupView') return groupStore.currentGroup?.name
if (route.name === 'GamesLibrary') return '游戏库'
if (route.name === 'Profile') return '个人中心'
if (route.name === 'Settings') return '设置'
return '首页'
})
</script> </script>
<template> <template>
<div class="app-layout"> <div class="app-layout">
<!-- 移动端遮罩 -->
<div v-if="sidebarOpen" class="sidebar-overlay" @click="sidebarOpen = false" />
<!-- 侧边栏 --> <!-- 侧边栏 -->
<aside class="sidebar"> <aside class="sidebar" :class="{ 'sidebar--open': sidebarOpen }">
<div class="sidebar-top"> <div class="sidebar-top">
<div class="logo" @click="goHome"> <div class="logo" @click="goHome">
<el-icon class="logo-icon"><Monitor /></el-icon> <el-icon class="logo-icon"><Monitor /></el-icon>
@@ -86,16 +99,26 @@ function goHome() {
<div class="sidebar-divider" /> <div class="sidebar-divider" />
<!-- 群组操作区 -->
<div class="sidebar-group-actions">
<button class="group-action-btn group-action-btn--create" @click="showCreateGroup = true">
<el-icon><Plus /></el-icon>
<span>创建群组</span>
</button>
<button class="group-action-btn group-action-btn--join" @click="showJoinGroup = true">
<el-icon><Search /></el-icon>
<span>加入群组</span>
</button>
</div>
<!-- 群组列表 -->
<div class="sidebar-groups"> <div class="sidebar-groups">
<div class="section-header"> <div class="section-header">
<span class="section-title">我的群组</span> <span class="section-title">我的群组</span>
<div class="section-actions">
<button class="section-btn" @click="showCreateGroup = true" title="创建群组"><el-icon><Plus /></el-icon></button>
<button class="section-btn" @click="showJoinGroup = true" title="加入群组"><el-icon><Link /></el-icon></button>
</div>
</div> </div>
<div v-if="groupStore.groups.length === 0" class="empty-groups"> <div v-if="groupStore.groups.length === 0" class="empty-groups">
暂无群组 <p>还没有群组</p>
<p class="empty-hint">点击上方按钮创建或加入</p>
</div> </div>
<div <div
v-for="group in groupStore.groups" v-for="group in groupStore.groups"
@@ -134,10 +157,24 @@ function goHome() {
<!-- 右侧 --> <!-- 右侧 -->
<div class="main-wrapper"> <div class="main-wrapper">
<header class="top-header"> <header class="top-header">
<h2 class="page-title"> <!-- 移动端汉堡按钮 -->
{{ $route.name === 'GroupView' ? groupStore.currentGroup?.name : $route.name === 'GamesLibrary' ? '游戏库' : $route.name === 'Profile' ? '个人中心' : $route.name === 'Settings' ? '设置' : '首页' }} <button class="hamburger-btn" @click="sidebarOpen = !sidebarOpen">
</h2> <span class="hamburger-line" />
<span class="hamburger-line" />
<span class="hamburger-line" />
</button>
<h2 class="page-title">{{ pageTitle }}</h2>
<div class="header-actions"> <div class="header-actions">
<button class="header-action-btn" @click="showCreateGroup = true" title="创建群组">
<el-icon><Plus /></el-icon>
<span class="header-action-label">创建群组</span>
</button>
<button class="header-action-btn" @click="showJoinGroup = true" title="加入群组">
<el-icon><Search /></el-icon>
<span class="header-action-label">加入群组</span>
</button>
<button class="icon-btn" @click="notificationStore.togglePanel()"> <button class="icon-btn" @click="notificationStore.togglePanel()">
<span v-if="notificationStore.unreadCount > 0" class="badge"> <span v-if="notificationStore.unreadCount > 0" class="badge">
{{ notificationStore.unreadCount }} {{ notificationStore.unreadCount }}
@@ -247,6 +284,50 @@ function goHome() {
margin: 12px 20px; margin: 12px 20px;
} }
/* ── 群组操作按钮 ── */
.sidebar-group-actions {
display: flex;
gap: 8px;
padding: 0 12px;
margin-bottom: 4px;
}
.group-action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
border-radius: var(--gg-radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.group-action-btn--create {
background: var(--gg-gradient-green);
color: white;
border: none;
}
.group-action-btn--create:hover {
opacity: 0.9;
box-shadow: 0 2px 12px rgba(5, 150, 105, 0.3);
}
.group-action-btn--join {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
color: var(--gg-text-secondary);
}
.group-action-btn--join:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
/* ── 群组列表 ── */ /* ── 群组列表 ── */
.sidebar-groups { .sidebar-groups {
flex: 1; flex: 1;
@@ -258,44 +339,32 @@ function goHome() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 4px 14px; padding: 8px 14px 4px;
margin-bottom: 8px;
} }
.section-title { .section-title {
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
color: var(--gg-text-muted); color: var(--gg-text-muted);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.section-actions {
display: flex;
gap: 4px;
}
.section-btn {
background: none;
border: 1px solid var(--gg-border);
border-radius: 6px;
color: var(--gg-text-secondary);
font-size: 14px;
cursor: pointer;
padding: 2px 8px;
transition: all 0.2s;
}
.section-btn:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.empty-groups { .empty-groups {
text-align: center; text-align: center;
color: var(--gg-text-muted); padding: 16px 14px;
}
.empty-groups p {
font-size: 13px; font-size: 13px;
padding: 16px; color: var(--gg-text-muted);
margin: 0;
}
.empty-hint {
font-size: 12px !important;
margin-top: 4px !important;
opacity: 0.7;
} }
.group-item { .group-item {
@@ -419,6 +488,7 @@ function goHome() {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 50; z-index: 50;
gap: 12px;
} }
.page-title { .page-title {
@@ -426,12 +496,43 @@ function goHome() {
font-weight: 600; font-weight: 600;
color: var(--gg-text); color: var(--gg-text);
margin: 0; margin: 0;
flex: 1;
} }
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 8px;
}
.header-action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: var(--gg-bg-card);
color: var(--gg-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.header-action-btn:first-child {
background: var(--gg-gradient-green);
color: white;
border: none;
}
.header-action-btn:first-child:hover {
opacity: 0.9;
box-shadow: 0 2px 8px rgba(5, 150, 105, 0.3);
}
.header-action-btn:not(:first-child):hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
} }
.icon-btn { .icon-btn {
@@ -469,4 +570,84 @@ function goHome() {
flex: 1; flex: 1;
padding: 24px 28px; padding: 24px 28px;
} }
/* ── 汉堡按钮 ── */
.hamburger-btn {
display: none;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 36px;
height: 36px;
padding: 8px 6px;
background: none;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
cursor: pointer;
}
.hamburger-line {
display: block;
width: 100%;
height: 2px;
background: var(--gg-text);
border-radius: 1px;
}
/* ── 移动端遮罩 ── */
.sidebar-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 90;
}
/* ── 移动端适配 ── */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: none;
}
.sidebar--open {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
.sidebar-overlay {
display: block;
}
.main-wrapper {
margin-left: 0;
}
.hamburger-btn {
display: flex;
}
.header-action-label {
display: none;
}
.header-action-btn {
padding: 7px 10px;
}
.top-header {
padding: 0 16px;
}
.main-content {
padding: 16px;
}
}
@media (min-width: 769px) and (max-width: 1024px) {
.header-action-label {
display: none;
}
}
</style> </style>
+1 -1
View File
@@ -106,7 +106,7 @@ function handleAvatarUpload() {
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
border: 3px solid var(--gg-primary); border: 3px solid var(--gg-primary);
box-shadow: 0 0 16px rgba(99, 102, 241, 0.25); box-shadow: 0 0 16px rgba(5, 150, 105, 0.25);
} }
.form-group { .form-group {
+2 -2
View File
@@ -81,7 +81,7 @@ const showScheduleModal = ref(false)
.setting-item:hover { .setting-item:hover {
border-color: var(--gg-primary); border-color: var(--gg-primary);
box-shadow: 0 0 12px rgba(99, 102, 241, 0.1); box-shadow: 0 0 12px rgba(5, 150, 105, 0.1);
} }
.setting-info { .setting-info {
@@ -115,7 +115,7 @@ const showScheduleModal = ref(false)
.setting-btn:hover:not(:disabled) { .setting-btn:hover:not(:disabled) {
opacity: 0.9; opacity: 0.9;
box-shadow: 0 0 12px rgba(99, 102, 241, 0.3); box-shadow: 0 0 12px rgba(5, 150, 105, 0.3);
} }
.setting-btn:disabled { .setting-btn:disabled {