Compare commits

..

5 Commits

Author SHA1 Message Date
congsh 89d8ecec82 fix: allow authenticated users to search/view groups for joining
Changed groups collection listRule and viewRule from member-only to
any authenticated user, so users can find and join groups. Also
improved registration error messages with Chinese validation feedback.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 23:43:59 +08:00
congsh d7f9c6860a fix: improve registration error messages with specific validation feedback
Extract PocketBase validation errors (duplicate email/username/password)
and display them in Chinese. Also improve login error message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 22:35:22 +08:00
congsh 83b7472594 refactor: replace all emoji icons with Element Plus SVG icons
Use @element-plus/icons-vue components for consistent, scalable vector
icons across sidebar navigation, section headers, ratings, and buttons.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 21:36:44 +08:00
congsh 4b97c99e56 feat: add game library CRUD/import/export/favorites/comments, fix team creation
- Game library: add/delete games per group, JSON/CSV import/export, favorites, star ratings & comments
- Fix team session creation: add creator to members array, handle null currentGroup
- Fix image loading: rename SVG files from .png to .svg extensions
- Add PocketBase migrations for game_comments and game_favorites collections
- Remove seed data script

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 21:03:20 +08:00
congsh 802712c662 feat: complete Phase 1 - game library, lifecycle, realtime sync
- Seed 33 popular games across 5 platforms via admin API script
- Add GameDetailDialog with game info and quick-team button
- Update GamesLibrary with game card click to open detail dialog
- Update Home hot games to open detail dialog instead of navigating
- Rewrite invitation accept: frontend auto-joins team + updates status
- Add user status reset on team dissolution (endGame)
- Add start game / dissolve buttons to TeamSessionPanel lifecycle
- Integrate realtime subscriptions in GroupView and Layout
- Add notification store realtime invitation listener
- Add placeholder images for game covers and avatars
- Remove Go hooks, add JS hooks placeholder + Docker mount

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 20:23:39 +08:00
32 changed files with 1278 additions and 399 deletions
+1
View File
@@ -10,6 +10,7 @@ services:
volumes:
- ./pb_data:/pb_data
- ./pb_migrations:/pb_migrations
- ./pb_hooks:/pb_hooks
environment:
- GO_ENV=production
restart: unless-stopped
-5
View File
@@ -1,5 +0,0 @@
module gamegroup-hooks
go 1.21
require github.com/pocketbase/pocketbase v0.22.4
-159
View File
@@ -1,159 +0,0 @@
package main
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
// ── Groups ──
app.OnRecordBeforeCreateRequest("groups").Add(func(e *core.RecordCreateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
e.Record.Set("owner", authRecord.Id)
e.Record.Set("members", []string{authRecord.Id})
return nil
})
app.OnRecordBeforeUpdateRequest("groups").Add(func(e *core.RecordUpdateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
original, err := app.Dao().FindRecordById("groups", e.Record.Id)
if err != nil {
return apis.NewNotFoundError("群组不存在", nil)
}
if original.GetString("owner") != authRecord.Id {
return apis.NewForbiddenError("只有群组所有者可以更新群组", nil)
}
// 保护 owner 字段不被篡改
e.Record.Set("owner", original.GetString("owner"))
// 验证 members 数量
members := e.Record.GetStringSlice("members")
maxMembers := e.Record.GetInt("maxMembers")
if maxMembers > 0 && len(members) > maxMembers {
return apis.NewBadRequestError("成员数量超过上限", nil)
}
return nil
})
app.OnRecordBeforeDeleteRequest("groups").Add(func(e *core.RecordDeleteEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
isOwner, err := isGroupOwner(app, e.Record.Id, authRecord.Id)
if err != nil || !isOwner {
return apis.NewForbiddenError("只有群组所有者可以删除群组", nil)
}
return nil
})
// ── Team Sessions ──
app.OnRecordBeforeCreateRequest("team_sessions").Add(func(e *core.RecordCreateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
groupId := e.Record.GetString("sourceGroup")
isMember, err := isGroupMember(app, groupId, authRecord.Id)
if err != nil || !isMember {
return apis.NewForbiddenError("只有群组成员可以创建团队会话", nil)
}
return nil
})
// ── Invitations ──
app.OnRecordBeforeCreateRequest("invitations").Add(func(e *core.RecordCreateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
// 强制设置 from 为当前用户
e.Record.Set("from", authRecord.Id)
e.Record.Set("status", "pending")
return nil
})
// 接受邀请:自动加入 team session + 更新用户状态
app.OnRecordBeforeUpdateRequest("invitations").Add(func(e *core.RecordUpdateEvent) error {
authRecord, _ := e.HttpContext.AuthRecord()
if authRecord == nil {
return apis.NewForbiddenError("需要登录", nil)
}
// 只有 recipient 可以更新邀请
if e.Record.GetString("to") != authRecord.Id {
return apis.NewForbiddenError("无权操作此邀请", nil)
}
newStatus := e.Record.GetString("status")
if newStatus == "accepted" {
teamSessionId := e.Record.GetString("teamSession")
teamSession, err := app.Dao().FindRecordById("team_sessions", teamSessionId)
if err != nil {
return apis.NewNotFoundError("临时小组不存在", nil)
}
// 将用户加入 team session members
members := teamSession.GetStringSlice("members")
alreadyIn := false
for _, m := range members {
if m == authRecord.Id {
alreadyIn = true
break
}
}
if !alreadyIn {
members = append(members, authRecord.Id)
teamSession.Set("members", members)
if err := app.Dao().SaveRecord(teamSession); err != nil {
return apis.NewBadRequestError("加入临时小组失败", nil)
}
}
// 更新用户状态为 in_team
user, err := app.Dao().FindRecordById("users", authRecord.Id)
if err == nil {
user.Set("status", "in_team")
app.Dao().SaveRecord(user)
}
}
return nil
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
func isGroupMember(app *pocketbase.PocketBase, groupId string, userId string) (bool, error) {
group, err := app.Dao().FindRecordById("groups", groupId)
if err != nil {
return false, err
}
members := group.GetStringSlice("members")
for _, member := range members {
if member == userId {
return true, nil
}
}
return false, nil
}
func isGroupOwner(app *pocketbase.PocketBase, groupId string, userId string) (bool, error) {
group, err := app.Dao().FindRecordById("groups", groupId)
if err != nil {
return false, err
}
return group.GetString("owner") == userId, nil
}
+5
View File
@@ -0,0 +1,5 @@
// JS hooks placeholder - PocketBase 0.22.4 muchobien image may not support JS VM
// Key logic handled in frontend instead:
// - Groups: frontend sets owner + members on create
// - Invitations: frontend auto-joins team session on accept
// - Team dissolution: frontend resets member statuses on end
@@ -84,8 +84,8 @@ migrate((db) => {
}
],
"indexes": [],
"listRule": "owner = @request.auth.id || members.id = @request.auth.id",
"viewRule": "owner = @request.auth.id || members.id = @request.auth.id",
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": "owner = @request.auth.id",
"deleteRule": "owner = @request.auth.id",
@@ -0,0 +1,87 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "4f73q31qelxycnu",
"created": "2026-04-17 12:37:24.610Z",
"updated": "2026-04-17 12:37:24.610Z",
"name": "game_comments",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "gc_game",
"name": "game",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "x5adjlc0txf16r8",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gc_author",
"name": "author",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gc_content",
"name": "content",
"type": "text",
"required": true,
"presentable": false,
"unique": false,
"options": {
"min": null,
"max": 1000,
"pattern": ""
}
},
{
"system": false,
"id": "gc_rating",
"name": "rating",
"type": "number",
"required": false,
"presentable": false,
"unique": false,
"options": {
"min": 1,
"max": 5,
"noDecimal": true
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": null,
"deleteRule": "author = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("4f73q31qelxycnu");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,59 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const collection = new Collection({
"id": "b7dbz8i2snrgw9g",
"created": "2026-04-17 12:37:35.573Z",
"updated": "2026-04-17 12:37:35.573Z",
"name": "game_favorites",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "gf_game",
"name": "game",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "x5adjlc0txf16r8",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
},
{
"system": false,
"id": "gf_user",
"name": "user",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}
],
"indexes": [],
"listRule": "@request.auth.id != \"\"",
"viewRule": "@request.auth.id != \"\"",
"createRule": "@request.auth.id != \"\"",
"updateRule": null,
"deleteRule": "user = @request.auth.id",
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("b7dbz8i2snrgw9g");
return dao.deleteCollection(collection);
})
@@ -0,0 +1,66 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
collection.listRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
collection.viewRule = "group.owner = @request.auth.id || group.members.id = @request.auth.id"
collection.createRule = "@request.auth.id != \"\""
collection.updateRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
collection.deleteRule = "group.owner = @request.auth.id || addedBy = @request.auth.id"
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "sf_group",
"name": "group",
"type": "relation",
"required": true,
"presentable": false,
"unique": false,
"options": {
"collectionId": "es63bkyiblpnxdf",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}))
// add
collection.schema.addField(new SchemaField({
"system": false,
"id": "sf_addedBy",
"name": "addedBy",
"type": "relation",
"required": false,
"presentable": false,
"unique": false,
"options": {
"collectionId": "_pb_users_auth_",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": null
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("x5adjlc0txf16r8")
collection.listRule = "@request.auth.id != \"\""
collection.viewRule = "@request.auth.id != \"\""
collection.createRule = null
collection.updateRule = null
collection.deleteRule = null
// remove
collection.schema.removeField("sf_group")
// remove
collection.schema.removeField("sf_addedBy")
return dao.saveCollection(collection)
})
+1
View File
@@ -7,6 +7,7 @@ services:
volumes:
- ./backend/pb_data:/pb_data
- ./backend/pb_migrations:/pb_migrations
- ./backend/pb_hooks:/pb_hooks
environment:
- GO_ENV=production
restart: unless-stopped
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect fill="#e8f5e9" width="64" height="64" rx="32"/>
<text x="32" y="40" text-anchor="middle" fill="#059669" font-size="28">👤</text>
</svg>

After

Width:  |  Height:  |  Size: 232 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="260" viewBox="0 0 200 260">
<rect fill="#e8f5e9" width="200" height="260"/>
<text x="100" y="120" text-anchor="middle" fill="#059669" font-size="48">🎮</text>
<text x="100" y="155" text-anchor="middle" fill="#6b7280" font-size="14">暂无封面</text>
</svg>

After

Width:  |  Height:  |  Size: 327 B

+122 -65
View File
@@ -1,88 +1,127 @@
// src/api/games.ts
import pb from './pocketbase'
import type { Game, GamePlatform } from '@/types'
import type { Game, GamePlatform, GameComment } from '@/types'
// 获取游戏列表
export async function getGames(options?: {
// 获取群组的游戏列表
export async function getGroupGames(groupId: string, options?: {
page?: number
limit?: number
platform?: GamePlatform
search?: string
}): Promise<{ items: Game[], total: number }> {
const { page = 1, limit = 20, platform, search } = options || {}
let filter = ''
if (platform) {
filter = `platform="${platform}"`
}
if (search) {
const searchFilter = `name ~ "${search}"`
filter = filter ? `${filter} && ${searchFilter}` : searchFilter
}
const { page = 1, limit = 50, platform, search } = options || {}
let filter = `group="${groupId}"`
if (platform) filter += ` && platform="${platform}"`
if (search) filter += ` && name ~ "${search}"`
const result = await pb.collection('games').getList(page, limit, {
filter,
sort: '-popularCount'
sort: '-created'
})
return {
items: result.items as unknown as Game[],
total: result.totalItems
}
return { items: result.items as unknown as Game[], total: result.totalItems }
}
// 获取热门游戏
export async function getPopularGames(limit = 10): Promise<Game[]> {
const result = await pb.collection('games').getList(1, limit, {
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
// 搜索游戏
export async function searchGames(query: string, limit = 20): Promise<Game[]> {
if (!query.trim()) return []
const result = await pb.collection('games').getList(1, limit, {
filter: `name ~ "${query}"`,
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
// 添加游戏(需要管理员权限)
export async function addGame(data: {
// 添加游戏到群组
export async function addGame(groupId: string, data: {
name: string
platform: GamePlatform
platform?: GamePlatform
tags?: string[]
cover?: string
}) {
return pb.collection('games').create(data)
}
// 更新游戏热度
export async function incrementGamePopularity(gameId: string) {
const game = await pb.collection('games').getOne(gameId)
return pb.collection('games').update(gameId, {
popularCount: (game.popularCount || 0) + 1
const user = pb.authStore.model
return pb.collection('games').create({
...data,
group: groupId,
addedBy: user?.id,
popularCount: 0
})
}
// 获取游戏详情
export async function getGame(gameId: string): Promise<Game> {
return pb.collection('games').getOne(gameId) as unknown as Game
// 删除游戏
export async function deleteGame(gameId: string) {
return pb.collection('games').delete(gameId)
}
// 按平台获取游戏
export async function getGamesByPlatform(platform: GamePlatform): Promise<Game[]> {
const result = await pb.collection('games').getList(1, 50, {
filter: `platform="${platform}"`,
sort: '-popularCount'
// 导入游戏(批量添加)
export async function importGames(groupId: string, games: Array<{
name: string
platform?: GamePlatform
tags?: string[]
cover?: string
}>) {
const results = []
for (const game of games) {
try {
const result = await addGame(groupId, game)
results.push({ success: true, data: result })
} catch (error: any) {
results.push({ success: false, error: error.message, data: game })
}
}
return results
}
// 导出游戏(获取全部数据)
export async function exportGames(groupId: string): Promise<Game[]> {
const result = await pb.collection('games').getFullList({
filter: `group="${groupId}"`,
sort: '-created'
})
return result as unknown as Game[]
}
// 收藏/取消收藏
export async function toggleFavorite(gameId: string): Promise<boolean> {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
const existing = await pb.collection('game_favorites').getList(1, 1, {
filter: `game="${gameId}" && user="${user.id}"`
})
return result.items as unknown as Game[]
if (existing.items.length > 0) {
await pb.collection('game_favorites').delete(existing.items[0].id)
return false
} else {
await pb.collection('game_favorites').create({
game: gameId,
user: user.id
})
return true
}
}
// 检查是否已收藏
export async function isFavorite(gameId: string): Promise<boolean> {
const user = pb.authStore.model
if (!user) return false
const result = await pb.collection('game_favorites').getList(1, 1, {
filter: `game="${gameId}" && user="${user.id}"`
})
return result.items.length > 0
}
// 添加评论
export async function addComment(gameId: string, content: string, rating?: number) {
const user = pb.authStore.model
if (!user) throw new Error('未登录')
return pb.collection('game_comments').create({
game: gameId,
author: user.id,
content,
rating
})
}
// 获取游戏评论
export async function getGameComments(gameId: string): Promise<GameComment[]> {
const result = await pb.collection('game_comments').getList(1, 50, {
filter: `game="${gameId}"`,
sort: '-created',
expand: 'author'
})
return result.items as unknown as GameComment[]
}
// 获取所有平台
@@ -90,9 +129,27 @@ export function getAllPlatforms(): GamePlatform[] {
return ['PC', 'PS5', 'Xbox', 'Switch', 'Mobile']
}
// 订阅游戏变更
export function subscribeGames(callback: (game: Game) => void) {
return pb.collection('games').subscribe('*', (payload) => {
callback(payload.record as unknown as Game)
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
if (!query.trim()) return []
let filter = `name ~ "${query}"`
if (groupId) filter += ` && group="${groupId}"`
const result = await pb.collection('games').getList(1, limit, {
filter,
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
// 获取热门游戏(全局,按 popularCount 排序)
export async function getPopularGames(limit = 10): Promise<Game[]> {
const user = pb.authStore.model
if (!user) return []
// 获取用户所在群组的游戏
const result = await pb.collection('games').getList(1, limit, {
sort: '-popularCount'
})
return result.items as unknown as Game[]
}
+22 -1
View File
@@ -77,8 +77,29 @@ export async function respondInvitation(
updateData.rejectReason = rejectReason
}
// 后端 hook 会自动处理:加入 team members + 更新用户状态
// 更新邀请状态
await pb.collection('invitations').update(invitationId, updateData)
// 接受邀请:前端处理加入 team session + 更新用户状态
if (response === 'accepted') {
const user = pb.authStore.model
if (!user) return
// 获取邀请详情以找到 team session
const invitation = await pb.collection('invitations').getOne(invitationId) as any
const teamSessionId = invitation.teamSession
// 加入 team session
const session = await pb.collection('team_sessions').getOne(teamSessionId) as any
const members: string[] = session.members || []
if (!members.includes(user.id)) {
members.push(user.id)
await pb.collection('team_sessions').update(teamSessionId, { members })
}
// 更新用户状态为 in_team
await pb.collection('users').update(user.id, { status: 'in_team' })
}
}
// 订阅邀请变更
+16 -2
View File
@@ -49,9 +49,23 @@ export async function updateTeamStatus(sessionId: string, status: TeamStatus): P
return pb.collection('team_sessions').update(sessionId, updateData) as unknown as TeamSession
}
// 结束游戏(解散临时小组)
// 结束游戏(解散临时小组 + 重置成员状态
export async function endGame(sessionId: string) {
return updateTeamStatus(sessionId, 'dissolved')
const session = await pb.collection('team_sessions').getOne(sessionId) as any
const members: string[] = session.members || []
// 解散临时小组
await updateTeamStatus(sessionId, 'dissolved')
// 重置所有成员状态为 idle
for (const memberId of members) {
try {
const user = await pb.collection('users').getOne(memberId) as any
if (user && user.status === 'in_team') {
await pb.collection('users').update(memberId, { status: 'idle' })
}
} catch (_) {}
}
}
// 加入临时小组
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { addGame } from '@/api/games'
import type { GamePlatform } from '@/types'
import { ElMessage } from 'element-plus'
import { getAllPlatforms } from '@/api/games'
const props = defineProps<{
modelValue: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'created': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const name = ref('')
const platform = ref<GamePlatform | ''>('')
const tagsInput = ref('')
const cover = ref('')
const loading = ref(false)
const platforms = getAllPlatforms()
async function handleSubmit() {
if (!name.value.trim()) {
ElMessage.warning('请输入游戏名称')
return
}
try {
loading.value = true
const tags = tagsInput.value.split(',').map(t => t.trim()).filter(Boolean)
await addGame(props.groupId, {
name: name.value.trim(),
platform: platform.value || undefined,
tags: tags.length > 0 ? tags : undefined,
cover: cover.value.trim() || undefined
})
ElMessage.success('添加成功')
name.value = ''
platform.value = ''
tagsInput.value = ''
cover.value = ''
emit('created')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || '添加失败')
} finally {
loading.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" title="添加游戏" width="440px">
<div class="form-fields">
<div class="field">
<label>游戏名称 *</label>
<el-input v-model="name" placeholder="输入游戏名称" maxlength="100" />
</div>
<div class="field">
<label>平台</label>
<el-select v-model="platform" placeholder="选择平台" clearable style="width: 100%">
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
</el-select>
</div>
<div class="field">
<label>标签</label>
<el-input v-model="tagsInput" placeholder="逗号分隔,如: MOBA,竞技,5v5" />
</div>
<div class="field">
<label>封面图 URL</label>
<el-input v-model="cover" placeholder="https://example.com/cover.jpg" />
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">添加</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.form-fields { display: flex; flex-direction: column; gap: 18px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field label { font-size: 13px; font-weight: 500; color: var(--gg-text-secondary); }
</style>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getGameComments, addComment } from '@/api/games'
import type { GameComment } from '@/types'
import { ElMessage } from 'element-plus'
import { StarFilled } from '@element-plus/icons-vue'
const props = defineProps<{ gameId: string }>()
const comments = ref<GameComment[]>([])
const newContent = ref('')
const newRating = ref(0)
const loading = ref(false)
const submitting = ref(false)
onMounted(async () => {
await loadComments()
})
async function loadComments() {
try {
loading.value = true
comments.value = await getGameComments(props.gameId)
} catch { /* ignore */ } finally { loading.value = false }
}
async function handleSubmit() {
if (!newContent.value.trim()) {
ElMessage.warning('请输入评论内容')
return
}
try {
submitting.value = true
await addComment(props.gameId, newContent.value.trim(), newRating.value || undefined)
newContent.value = ''
newRating.value = 0
await loadComments()
ElMessage.success('评论成功')
} catch (error: any) {
ElMessage.error(error.message || '评论失败')
} finally { submitting.value = false }
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<div class="comments-section">
<h4 class="comments-title">评论 ({{ comments.length }})</h4>
<div class="comment-form">
<div class="rating-row">
<span class="rating-label">评分:</span>
<span
v-for="star in 5" :key="star"
class="star" :class="{ active: star <= newRating }"
@click="newRating = star"
><el-icon :size="20"><StarFilled /></el-icon></span>
<span v-if="newRating > 0" class="rating-clear" @click="newRating = 0">清除</span>
</div>
<div class="input-row">
<el-input v-model="newContent" placeholder="写下你的评论..." maxlength="1000" show-word-limit type="textarea" :rows="2" />
<el-button type="primary" :loading="submitting" @click="handleSubmit">发表</el-button>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="comments.length === 0" class="empty">暂无评论</div>
<div v-else class="comment-list">
<div v-for="c in comments" :key="c.id" class="comment-item">
<div class="comment-header">
<span class="comment-author">{{ c.expand?.author?.username || '用户' }}</span>
<span v-if="c.rating" class="comment-rating">
<el-icon v-for="i in c.rating" :key="i" :size="12"><StarFilled /></el-icon>
</span>
<span class="comment-date">{{ formatDate(c.created) }}</span>
</div>
<p class="comment-content">{{ c.content }}</p>
</div>
</div>
</div>
</template>
<style scoped>
.comments-section { margin-top: 20px; }
.comments-title { font-size: 15px; font-weight: 600; margin: 0 0 16px; color: var(--gg-text); }
.comment-form { margin-bottom: 20px; }
.rating-row { display: flex; align-items: center; gap: 4px; margin-bottom: 10px; }
.rating-label { font-size: 13px; color: var(--gg-text-secondary); }
.star { font-size: 20px; cursor: pointer; color: var(--gg-border); transition: color 0.15s; }
.star.active { color: #f59e0b; }
.rating-clear { font-size: 12px; color: var(--gg-text-muted); cursor: pointer; margin-left: 8px; }
.input-row { display: flex; gap: 10px; align-items: flex-end; }
.input-row .el-input { flex: 1; }
.loading, .empty { text-align: center; padding: 16px; color: var(--gg-text-muted); font-size: 13px; }
.comment-list { display: flex; flex-direction: column; gap: 12px; }
.comment-item {
padding: 12px 14px; background: var(--gg-bg); border-radius: var(--gg-radius-sm);
border: 1px solid var(--gg-border);
}
.comment-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.comment-author { font-size: 13px; font-weight: 600; color: var(--gg-text); }
.comment-rating { font-size: 12px; color: #f59e0b; }
.comment-date { font-size: 12px; color: var(--gg-text-muted); margin-left: auto; }
.comment-content { font-size: 14px; color: var(--gg-text-secondary); margin: 0; line-height: 1.6; }
</style>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import type { Game } from '@/types'
import { toggleFavorite, isFavorite, deleteGame } from '@/api/games'
import { ElMessage, ElMessageBox } from 'element-plus'
import { TrendCharts, StarFilled, Star, Delete } from '@element-plus/icons-vue'
import GameComments from './GameComments.vue'
const props = defineProps<{
modelValue: boolean
game: Game | null
groupId?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'create-team': [gameName: string]
'deleted': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const favorited = ref(false)
onMounted(async () => {
if (props.game) {
favorited.value = await isFavorite(props.game.id)
}
})
async function handleFavorite() {
if (!props.game) return
favorited.value = await toggleFavorite(props.game.id)
}
async function handleDelete() {
if (!props.game) return
try {
await ElMessageBox.confirm(`确定要删除「${props.game.name}」吗?`, '确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
})
await deleteGame(props.game.id)
ElMessage.success('删除成功')
visible.value = false
emit('deleted')
} catch { /* 用户取消 */ }
}
function handleCreateTeam() {
if (props.game) {
emit('create-team', props.game.name)
visible.value = false
}
}
</script>
<template>
<el-dialog v-model="visible" width="480px" :show-close="true">
<template v-if="game">
<div class="game-detail">
<div class="detail-cover-wrap">
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
</div>
<h2 class="detail-name">{{ game.name }}</h2>
<div class="detail-meta">
<span v-if="game.platform" class="platform-badge">{{ game.platform }}</span>
<span class="popularity"><el-icon><TrendCharts /></el-icon> {{ game.popularCount }} 人游玩</span>
</div>
<div v-if="game.tags?.length" class="detail-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<div class="detail-actions">
<button class="fav-btn" :class="{ active: favorited }" @click="handleFavorite">
<el-icon><StarFilled v-if="favorited" /><Star v-else /></el-icon>
{{ favorited ? '已收藏' : '收藏' }}
</button>
<button class="delete-detail-btn" @click="handleDelete"><el-icon><Delete /></el-icon> 删除</button>
</div>
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
快速组队
</el-button>
<GameComments v-if="game.id" :game-id="game.id" />
</div>
</template>
</el-dialog>
</template>
<style scoped>
.game-detail { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 8px 0; }
.detail-cover-wrap { width: 200px; height: 260px; border-radius: var(--gg-radius-md); overflow: hidden; background: var(--gg-bg-elevated); border: 1px solid var(--gg-border); }
.detail-cover { width: 100%; height: 100%; object-fit: cover; }
.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; }
.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; }
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.tag { padding: 4px 12px; background: var(--gg-bg-elevated); border-radius: 6px; font-size: 12px; color: var(--gg-primary); font-weight: 500; }
.detail-actions { display: flex; gap: 12px; width: 100%; justify-content: center; }
.fav-btn {
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
background: transparent; color: var(--gg-text-secondary); font-size: 14px; cursor: pointer; transition: all 0.2s;
}
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
.delete-detail-btn {
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
background: transparent; color: var(--gg-text-muted); font-size: 14px; cursor: pointer; transition: all 0.2s;
}
.delete-detail-btn:hover { border-color: var(--gg-danger); color: var(--gg-danger); }
.team-btn { width: 100%; margin-top: 4px; }
</style>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { importGames } from '@/api/games'
import { ElMessage } from 'element-plus'
const props = defineProps<{
modelValue: boolean
groupId: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'imported': []
}>()
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const previewData = ref<any[]>([])
const importing = ref(false)
function handleFileUpload(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const text = e.target?.result as string
if (file.name.endsWith('.csv')) {
parseCSV(text)
} else {
const json = JSON.parse(text)
previewData.value = Array.isArray(json) ? json : [json]
}
} catch {
ElMessage.error('文件解析失败')
}
}
reader.readAsText(file)
}
function parseCSV(text: string) {
const lines = text.split('\n').filter(l => l.trim())
if (lines.length < 2) { previewData.value = []; return }
const headers = lines[0].split(',').map(h => h.trim().toLowerCase())
const data = []
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',').map(v => v.trim())
const obj: any = {}
headers.forEach((h, idx) => {
if (h === 'tags' && values[idx]) {
obj[h] = values[idx].split(';').map((t: string) => t.trim())
} else {
obj[h] = values[idx] || undefined
}
})
if (obj.name) data.push(obj)
}
previewData.value = data
}
async function handleImport() {
if (previewData.value.length === 0) {
ElMessage.warning('没有数据可导入')
return
}
try {
importing.value = true
const results = await importGames(props.groupId, previewData.value)
const success = results.filter(r => r.success).length
ElMessage.success(`成功导入 ${success}/${results.length} 个游戏`)
previewData.value = []
emit('imported')
visible.value = false
} catch (error: any) {
ElMessage.error(error.message || '导入失败')
} finally {
importing.value = false
}
}
function reset() {
previewData.value = []
}
</script>
<template>
<el-dialog v-model="visible" title="导入游戏" width="500px" @close="reset">
<div class="import-content">
<div class="upload-area">
<p class="upload-hint">上传 JSON CSV 文件</p>
<input type="file" accept=".json,.csv" class="file-input" @change="handleFileUpload" />
</div>
<div class="format-hint">
<p><strong>JSON 格式:</strong> [{"name":"LOL","platform":"PC","tags":["MOBA"]}]</p>
<p><strong>CSV 格式:</strong> name,platform,tags,covertags 用分号分隔</p>
</div>
<div v-if="previewData.length > 0" class="preview">
<h4>预览 ({{ previewData.length }} 个游戏)</h4>
<div class="preview-list">
<div v-for="(item, idx) in previewData" :key="idx" class="preview-item">
{{ item.name }} <span v-if="item.platform" class="preview-platform">{{ item.platform }}</span>
</div>
</div>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="importing" :disabled="previewData.length === 0" @click="handleImport">
确认导入
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.import-content { display: flex; flex-direction: column; gap: 16px; }
.upload-area {
border: 2px dashed var(--gg-border); border-radius: var(--gg-radius-md);
padding: 32px; text-align: center;
}
.upload-hint { margin: 0 0 12px; color: var(--gg-text-secondary); font-size: 14px; }
.file-input { font-size: 14px; }
.format-hint { font-size: 12px; color: var(--gg-text-muted); }
.format-hint p { margin: 4px 0; }
.preview h4 { margin: 0 0 8px; font-size: 14px; color: var(--gg-text); }
.preview-list { max-height: 200px; overflow-y: auto; }
.preview-item {
padding: 8px 12px; border-bottom: 1px solid var(--gg-border);
font-size: 13px; color: var(--gg-text-secondary);
}
.preview-platform {
font-size: 11px; padding: 2px 8px; border-radius: 4px;
background: var(--gg-bg-elevated); color: var(--gg-accent); margin-left: 8px;
}
</style>
@@ -58,7 +58,7 @@ async function removeMember(userId: string, username: string) {
<div class="members-list">
<div v-for="member in members" :key="member.id" class="member-row">
<img :src="member.avatar || '/default-avatar.png'" class="member-avatar" alt="" />
<img :src="member.avatar || '/default-avatar.svg'" class="member-avatar" alt="" />
<div class="member-info">
<span class="member-name">{{ member.username }}</span>
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
@@ -82,7 +82,7 @@ function confirmCustomGame() {
@click="selectGame(game)"
>
<img
:src="game.cover || '/game-placeholder.png'"
:src="game.cover || '/game-placeholder.svg'"
:alt="game.name"
class="game-cover"
/>
@@ -59,7 +59,7 @@ async function inviteMember(userId: string, username: string) {
class="member-item"
>
<img
:src="member.avatar || '/default-avatar.png'"
:src="member.avatar || '/default-avatar.svg'"
:alt="member.username"
class="avatar"
/>
@@ -49,7 +49,7 @@ async function rejectInvitation() {
<div class="invitation-card">
<div class="invitation-header">
<img
:src="invitation.expand?.from?.avatar || '/default-avatar.png'"
:src="invitation.expand?.from?.avatar || '/default-avatar.svg'"
:alt="invitation.expand?.from?.username"
class="avatar"
/>
@@ -1,16 +1,20 @@
<!-- src/components/team/TeamSessionPanel.vue -->
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useTeamStore } from '@/stores/team'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { TeamStatusMap } from '@/types'
import { ElMessageBox } from 'element-plus'
import { ElMessageBox, ElMessage } from 'element-plus'
import GameSelectDialog from '@/components/team/GameSelectDialog.vue'
const teamStore = useTeamStore()
const groupStore = useGroupStore()
const userStore = useUserStore()
const session = computed(() => teamStore.currentSession)
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
const showGameSelect = ref(false)
const memberDetails = computed(() => {
if (!session.value) return []
@@ -19,6 +23,14 @@ const memberDetails = computed(() => {
.filter((m): m is NonNullable<typeof m> => Boolean(m))
})
async function startGame() {
try {
await teamStore.updateStatus('playing')
} catch {
// ignore
}
}
async function endGame() {
try {
await ElMessageBox.confirm(
@@ -35,6 +47,35 @@ async function endGame() {
// 用户取消
}
}
async function handleGameSelected(gameName: string) {
let group = groupStore.currentGroup
if (!group) {
if (groupStore.groups.length > 0) {
group = groupStore.groups[0]
await groupStore.setCurrentGroup(group.id)
} else {
ElMessage.warning('请先创建或加入一个群组')
return
}
}
if (teamStore.currentSession) return
const userId = userStore.userId
try {
await teamStore.createSession({
sourceGroup: group.id,
name: `${gameName} 小队`,
gameName,
members: [userId]
})
ElMessage.success('临时小队创建成功!')
} catch (error: any) {
console.error('创建临时小队失败:', error)
ElMessage.error('创建临时小队失败:' + (error.message || '未知错误'))
}
}
</script>
<template>
@@ -58,7 +99,7 @@ async function endGame() {
class="member-item"
>
<img
:src="member.avatar || '/default-avatar.png'"
:src="member.avatar || '/default-avatar.svg'"
:alt="member.username"
class="avatar"
/>
@@ -67,14 +108,25 @@ async function endGame() {
</div>
</div>
<button class="end-game-btn" @click="endGame">
<button v-if="session.status === 'recruiting'" class="start-game-btn" @click="startGame">
开始游戏
</button>
<button v-else-if="session.status === 'playing'" class="end-game-btn" @click="endGame">
游戏结束
</button>
<button v-if="session.status === 'recruiting'" class="dissolve-btn" @click="endGame">
解散小组
</button>
</div>
<div v-else class="no-session">
<p>暂未加入任何临时小组</p>
<p class="no-session-text">暂未加入任何临时小组</p>
<button class="create-team-btn" @click="showGameSelect = true">
创建临时小队
</button>
</div>
<GameSelectDialog v-model="showGameSelect" @select="handleGameSelected" />
</template>
<style scoped>
@@ -177,6 +229,43 @@ async function endGame() {
color: var(--gg-text);
}
.start-game-btn {
width: 100%;
padding: 12px;
margin-bottom: 8px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: white;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s, box-shadow 0.2s;
}
.start-game-btn:hover {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(5, 150, 105, 0.3);
}
.dissolve-btn {
width: 100%;
padding: 10px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: transparent;
color: var(--gg-text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.dissolve-btn:hover {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.end-game-btn {
width: 100%;
padding: 12px;
@@ -200,9 +289,30 @@ async function endGame() {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
padding: 48px 32px;
padding: 40px 32px;
text-align: center;
}
.no-session-text {
color: var(--gg-text-muted);
font-size: 15px;
margin: 0 0 20px;
}
.create-team-btn {
padding: 12px 32px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient-green);
color: white;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: box-shadow 0.3s, transform 0.15s;
}
.create-team-btn:hover {
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.3);
transform: translateY(-1px);
}
</style>
+21 -1
View File
@@ -1,12 +1,14 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Invitation } from '@/types'
import { getPendingInvitations } from '@/api/invitations'
import { getPendingInvitations, subscribeInvitations } from '@/api/invitations'
import { pb } from '@/api/pocketbase'
export const useNotificationStore = defineStore('notification', () => {
const pendingInvitations = ref<Invitation[]>([])
const loading = ref(false)
const showPanel = ref(false)
let unsubFn: (() => Promise<void> | void) | null = null
const unreadCount = computed(() => pendingInvitations.value.length)
@@ -21,6 +23,22 @@ export const useNotificationStore = defineStore('notification', () => {
}
}
async function startListening() {
const user = pb.authStore.model
if (!user) return
unsubFn = await subscribeInvitations(() => {
loadPendingInvitations()
})
}
function stopListening() {
if (unsubFn) {
unsubFn()
unsubFn = null
}
}
function removeInvitation(invitationId: string) {
pendingInvitations.value = pendingInvitations.value.filter(i => i.id !== invitationId)
}
@@ -35,6 +53,8 @@ export const useNotificationStore = defineStore('notification', () => {
showPanel,
unreadCount,
loadPendingInvitations,
startListening,
stopListening,
removeInvitation,
togglePanel
}
+7 -2
View File
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { TeamSession, TeamStatus } from '@/types'
import { getActiveTeamSession, createTeamSession, updateTeamStatus, endGame } from '@/api/sessions'
import { pb } from '@/api/pocketbase'
import { getPendingInvitations, sendBulkInvitations } from '@/api/invitations'
export const useTeamStore = defineStore('team', () => {
@@ -43,8 +44,12 @@ export const useTeamStore = defineStore('team', () => {
const session = await createTeamSession(data)
currentSession.value = session
// 发送邀请
await sendBulkInvitations(data.members, session.id)
// 发送邀请(排除创建者自己)
const currentUserId = pb.authStore.model?.id
const others = data.members.filter(id => id !== currentUserId)
if (others.length > 0) {
await sendBulkInvitations(others, session.id)
}
return session
} catch (error: any) {
+9 -1
View File
@@ -42,7 +42,7 @@ export const useUserStore = defineStore('user', () => {
await pb.collection('users').authWithPassword(email, password)
await initUser()
} catch (error: any) {
throw new Error(error.message || '登录失败')
throw new Error('邮箱或密码错误')
} finally {
loading.value = false
}
@@ -55,6 +55,14 @@ export const useUserStore = defineStore('user', () => {
await pb.collection('users').create(data)
await login(data.email, data.password)
} catch (error: any) {
const validation = error?.data?.data || error?.data
if (validation) {
const messages: string[] = []
if (validation.email) messages.push('邮箱无效或已被使用')
if (validation.username) messages.push('用户名无效或已被使用')
if (validation.password) messages.push('密码不符合要求')
if (messages.length > 0) throw new Error(messages.join(''))
}
throw new Error(error.message || '注册失败')
} finally {
loading.value = false
+25
View File
@@ -117,6 +117,8 @@ export interface Game {
tags?: string[]
cover?: string
popularCount: number
group: string
addedBy?: string
created: string
updated: string
}
@@ -126,3 +128,26 @@ export interface WorkSchedule {
workdays: number[]
workStartTime: string
}
// 游戏评论
export interface GameComment {
id: string
game: string
author: string
content: string
rating?: number
created: string
updated: string
expand?: {
author?: User
}
}
// 游戏收藏
export interface GameFavorite {
id: string
game: string
user: string
created: string
updated: string
}
+161 -128
View File
@@ -1,25 +1,41 @@
<!-- src/views/GamesLibrary.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { getGames, getAllPlatforms } from '@/api/games'
import { ref, onMounted, computed } from 'vue'
import { useGroupStore } from '@/stores/group'
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games'
import type { Game, GamePlatform } from '@/types'
import { ElCard, ElInput, ElSelect, ElOption } from 'element-plus'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import AddGameDialog from '@/components/game/AddGameDialog.vue'
import ImportGamesDialog from '@/components/game/ImportGamesDialog.vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Close } from '@element-plus/icons-vue'
const groupStore = useGroupStore()
const games = ref<Game[]>([])
const loading = ref(false)
const searchQuery = ref('')
const selectedPlatform = ref<GamePlatform | ''>('')
const platforms = getAllPlatforms()
const selectedGame = ref<Game | null>(null)
const showDetail = ref(false)
const showAddGame = ref(false)
const showImport = ref(false)
const currentGroupId = computed(() => groupStore.currentGroupId)
onMounted(async () => {
await loadGames()
if (currentGroupId.value) {
await loadGames()
}
})
async function loadGames() {
if (!currentGroupId.value) return
try {
loading.value = true
const result = await getGames({
limit: 50,
const result = await getGroupGames(currentGroupId.value, {
limit: 100,
search: searchQuery.value || undefined,
platform: selectedPlatform.value || undefined
})
@@ -31,162 +47,179 @@ async function loadGames() {
}
}
function handleSearch() {
loadGames()
function openGameDetail(game: Game) {
selectedGame.value = game
showDetail.value = true
}
async function handleDeleteGame(game: Game) {
try {
await ElMessageBox.confirm(`确定要删除「${game.name}」吗?`, '确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteGame(game.id)
ElMessage.success('删除成功')
await loadGames()
} catch { /* 用户取消 */ }
}
function handleExport() {
if (!currentGroupId.value) return
exportGames(currentGroupId.value).then(data => {
const json = JSON.stringify(data.map(g => ({
name: g.name,
platform: g.platform,
tags: g.tags,
cover: g.cover
})), null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `games-export-${currentGroupId.value}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success(`已导出 ${data.length} 个游戏`)
})
}
async function handleGameAdded() {
showAddGame.value = false
await loadGames()
}
async function handleImportComplete() {
showImport.value = false
await loadGames()
}
</script>
<template>
<div class="games-library">
<div class="page-header">
<h1>游戏库</h1>
<div class="filters">
<el-input
v-model="searchQuery"
placeholder="搜索游戏..."
clearable
@input="handleSearch"
/>
<el-select v-model="selectedPlatform" placeholder="选择平台" clearable @change="loadGames">
<el-option
v-for="platform in platforms"
:key="platform"
:label="platform"
:value="platform"
<!-- 无群组提示 -->
<div v-if="!currentGroupId" class="no-group">
<p class="no-group-text">请先选择一个群组</p>
<p class="no-group-hint">在左侧选择或创建群组后即可管理游戏库</p>
</div>
<template v-else>
<div class="page-header">
<h1>游戏库</h1>
<div class="toolbar">
<el-input
v-model="searchQuery"
placeholder="搜索游戏..."
clearable
style="width: 240px"
@input="loadGames"
/>
</el-select>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="games.length === 0" class="empty">
<p>暂无游戏</p>
</div>
<div v-else class="games-grid">
<el-card v-for="game in games" :key="game.id" class="game-card">
<img
:src="game.cover || '/game-placeholder.png'"
:alt="game.name"
class="game-cover"
/>
<div class="game-info">
<h3 class="game-name">{{ game.name }}</h3>
<p class="game-platform">{{ game.platform }}</p>
<div class="game-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<el-select v-model="selectedPlatform" placeholder="平台" clearable style="width: 120px" @change="loadGames">
<el-option v-for="p in platforms" :key="p" :label="p" :value="p" />
</el-select>
<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>
</el-card>
</div>
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="games.length === 0" class="empty">
<p class="empty-text">暂无游戏</p>
<p class="empty-hint">点击添加游戏导入开始管理游戏库</p>
</div>
<div v-else class="games-grid">
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="game-cover" />
<div class="game-info">
<h3 class="game-name">{{ game.name }}</h3>
<p class="game-platform">{{ game.platform }}</p>
<div class="game-tags">
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
<button class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除"><el-icon><Close /></el-icon></button>
</div>
</div>
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="currentGroupId" @deleted="loadGames" />
<AddGameDialog v-model="showAddGame" :group-id="currentGroupId" @created="handleGameAdded" />
<ImportGamesDialog v-model="showImport" :group-id="currentGroupId" @imported="handleImportComplete" />
</template>
</div>
</template>
<style scoped>
.games-library {
width: 100%;
}
.games-library { width: 100%; }
.page-header {
margin-bottom: 28px;
.no-group {
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;
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;
}
.filters {
display: flex;
gap: 12px;
}
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.filters .el-input {
width: 300px;
.tool-btn {
padding: 8px 18px; 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; 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.primary {
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);
font-size: 16px;
}
.loading { text-align: center; padding: 80px 32px; color: var(--gg-text-muted); }
.empty {
text-align: center;
padding: 80px 32px;
color: var(--gg-text-muted);
font-size: 15px;
}
.empty { text-align: center; padding: 80px 32px; }
.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 {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;
}
.game-card {
border-radius: var(--gg-radius-md);
overflow: hidden;
cursor: pointer;
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
position: relative; 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;
}
.game-card:hover { transform: translateY(-4px); border-color: var(--gg-primary); box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15); }
.game-card:hover {
transform: translateY(-4px);
border-color: var(--gg-primary);
box-shadow: 0 0 20px rgba(99, 102, 241, 0.15);
}
.game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; }
.game-cover {
width: 100%;
aspect-ratio: 3/4;
object-fit: cover;
border-radius: var(--gg-radius-sm) var(--gg-radius-sm) 0 0;
mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 70%, transparent 100%);
}
.game-info {
padding: 14px;
}
.game-info { padding: 14px; }
.game-name {
font-size: 16px;
font-weight: 700;
margin: 0 0 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--gg-text);
}
.game-platform {
font-size: 13px;
color: var(--gg-text-secondary);
margin: 0 0 8px;
}
.game-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
font-size: 16px; font-weight: 700; margin: 0 0 4px; color: var(--gg-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.game-platform { font-size: 13px; color: var(--gg-text-secondary); margin: 0 0 8px; }
.game-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.tag {
padding: 3px 10px;
background: var(--gg-bg-elevated);
border-radius: 6px;
font-size: 11px;
color: var(--gg-primary-light);
font-weight: 500;
padding: 3px 10px; background: var(--gg-bg-elevated); border-radius: 6px;
font-size: 11px; color: var(--gg-primary); font-weight: 500;
}
.delete-btn {
position: absolute; top: 8px; right: 8px; 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); }
</style>
+35 -5
View File
@@ -1,14 +1,20 @@
<!-- src/views/GroupView.vue -->
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { onMounted, onUnmounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { useTeamStore } from '@/stores/team'
import { pb } from '@/api/pocketbase'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
import IdleMembersList from '@/components/team/IdleMembersList.vue'
import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue'
import { Promotion, Opportunity, UserFilled } from '@element-plus/icons-vue'
const route = useRoute()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const unsubFns: (() => Promise<void>)[] = []
const groupId = route.params.id as string
@@ -53,6 +59,30 @@ const statusColors: Record<string, string> = {
onMounted(async () => {
await groupStore.setCurrentGroup(groupId)
// 订阅用户状态变更
unsubFns.push(await pb.collection('users').subscribe('*', () => {
groupStore.setCurrentGroup(groupId)
}))
// 订阅群组变更
unsubFns.push(await pb.collection('groups').subscribe('*', (payload) => {
if (payload.record.id === groupId) {
groupStore.setCurrentGroup(groupId)
}
}))
// 订阅临时小组变更
unsubFns.push(await pb.collection('team_sessions').subscribe('*', () => {
teamStore.loadActiveSession()
}))
})
onUnmounted(async () => {
for (const unsub of unsubFns) {
try { await unsub() } catch (_) {}
}
unsubFns.length = 0
})
async function refreshMembers() {
@@ -89,7 +119,7 @@ async function refreshMembers() {
<!-- 当前临时小组 -->
<section class="panel-block">
<h2 class="block-title">
<span class="title-icon">&#x2694;</span> 当前临时小组
<span class="title-icon"><el-icon><Promotion /></el-icon></span> 当前临时小组
</h2>
<div class="panel-card">
<TeamSessionPanel />
@@ -99,7 +129,7 @@ async function refreshMembers() {
<!-- 空闲成员 -->
<section class="panel-block">
<h2 class="block-title">
<span class="title-icon">&#x1F7E2;</span> 空闲成员
<span class="title-icon"><el-icon><Opportunity /></el-icon></span> 空闲成员
</h2>
<div class="panel-card">
<IdleMembersList status="idle" />
@@ -110,7 +140,7 @@ async function refreshMembers() {
<section class="panel-block">
<div class="block-title-row">
<h2 class="block-title">
<span class="title-icon">&#x1F465;</span> 所有成员
<span class="title-icon"><el-icon><UserFilled /></el-icon></span> 所有成员
</h2>
<button class="refresh-btn" @click="refreshMembers">刷新</button>
</div>
@@ -124,7 +154,7 @@ async function refreshMembers() {
<div v-if="list.length === 0" class="empty-row">暂无</div>
<div v-else class="member-row-list">
<div v-for="m in list" :key="m.id" class="member-row">
<img :src="m.avatar || '/default-avatar.png'" :alt="m.username" class="avatar" />
<img :src="m.avatar || '/default-avatar.svg'" :alt="m.username" class="avatar" />
<span class="member-name">{{ m.username }}</span>
<span v-if="m.statusNote" class="member-note">{{ m.statusNote }}</span>
</div>
+18 -7
View File
@@ -5,10 +5,12 @@ import { useRouter } from 'vue-router'
import { useGroupStore } from '@/stores/group'
import { useUserStore } from '@/stores/user'
import { getPopularGames } from '@/api/games'
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
import { UserStatusMap } from '@/types'
import type { Game } from '@/types'
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
import IdleMembersList from '@/components/team/IdleMembersList.vue'
import { User, Promotion, TrendCharts } from '@element-plus/icons-vue'
const router = useRouter()
const groupStore = useGroupStore()
@@ -16,6 +18,8 @@ const userStore = useUserStore()
const popularGames = ref<Game[]>([])
const loading = ref(false)
const selectedGame = ref<Game | null>(null)
const showGameDetail = ref(false)
const statusDotClass = computed(() => {
const s = userStore.userStatus
@@ -43,6 +47,11 @@ function selectGroup(groupId: string) {
groupStore.setCurrentGroup(groupId)
router.push({ name: 'GroupView', params: { id: groupId } })
}
function openGameDetail(game: Game) {
selectedGame.value = game
showGameDetail.value = true
}
</script>
<template>
@@ -64,11 +73,11 @@ function selectGroup(groupId: string) {
<!-- 左列: 我的群组 -->
<section class="section groups-section">
<h2 class="section-title">
<span class="section-icon">&#x1F3AE;</span> 我的群组
<el-icon class="section-icon"><User /></el-icon> 我的群组
</h2>
<div v-if="groupStore.groups.length === 0" class="empty-state">
<div class="empty-icon">&#x1F3AE;</div>
<div class="empty-icon"><el-icon :size="40"><User /></el-icon></div>
<p class="empty-text">暂无群组</p>
<p class="empty-hint">创建或加入一个群组开始组队冒险吧</p>
</div>
@@ -92,7 +101,7 @@ function selectGroup(groupId: string) {
<!-- 右列: 当前临时小组 -->
<section class="section session-section">
<h2 class="section-title">
<span class="section-icon">&#x2694;</span> 当前临时小组
<el-icon class="section-icon"><Promotion /></el-icon> 当前临时小组
</h2>
<div class="session-card">
<TeamSessionPanel />
@@ -107,7 +116,7 @@ function selectGroup(groupId: string) {
<!-- 热门游戏 -->
<section class="section games-section">
<h2 class="section-title">
<span class="section-icon">&#x1F525;</span> 热门游戏
<span class="section-icon"><el-icon><TrendCharts /></el-icon></span> 热门游戏
</h2>
<div v-if="loading" class="loading-state">
@@ -116,7 +125,7 @@ function selectGroup(groupId: string) {
</div>
<div v-else-if="popularGames.length === 0" class="empty-state">
<div class="empty-icon">&#x1F3AE;</div>
<div class="empty-icon"><el-icon :size="40"><TrendCharts /></el-icon></div>
<p class="empty-text">暂无热门游戏</p>
</div>
@@ -125,11 +134,11 @@ function selectGroup(groupId: string) {
v-for="game in popularGames"
:key="game.id"
class="game-card"
@click="router.push({ name: 'GamesLibrary' })"
@click="openGameDetail(game)"
>
<div class="game-cover-wrap">
<img
:src="game.cover || '/game-placeholder.png'"
:src="game.cover || '/game-placeholder.svg'"
:alt="game.name"
class="game-cover"
/>
@@ -141,6 +150,8 @@ function selectGroup(groupId: string) {
</div>
</div>
</section>
<GameDetailDialog v-model="showGameDetail" :game="selectedGame" @create-team="() => {}" />
</div>
</template>
+16 -10
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useGroupStore } from '@/stores/group'
@@ -10,6 +10,7 @@ import WorkScheduleModal from '@/components/team/WorkScheduleModal.vue'
import NotificationPanel from '@/components/common/NotificationPanel.vue'
import CreateGroupDialog from '@/components/group/CreateGroupDialog.vue'
import JoinGroupDialog from '@/components/group/JoinGroupDialog.vue'
import { Monitor, HomeFilled, Grid, Link, AlarmClock, SwitchButton, Bell, Plus } from '@element-plus/icons-vue'
const router = useRouter()
const route = useRoute()
@@ -27,6 +28,11 @@ onMounted(async () => {
await groupStore.loadGroups()
await teamStore.loadActiveSession()
await notificationStore.loadPendingInvitations()
await notificationStore.startListening()
})
onUnmounted(() => {
notificationStore.stopListening()
})
function handleLogout() {
@@ -50,18 +56,18 @@ function goHome() {
<aside class="sidebar">
<div class="sidebar-top">
<div class="logo" @click="goHome">
<span class="logo-icon">🎮</span>
<el-icon class="logo-icon"><Monitor /></el-icon>
<span class="logo-text">Game Group</span>
</div>
</div>
<nav class="sidebar-nav">
<router-link to="/" class="nav-item" active-class="nav-item--active">
<span class="nav-icon">🏠</span>
<el-icon class="nav-icon"><HomeFilled /></el-icon>
<span>首页</span>
</router-link>
<router-link to="/games" class="nav-item" active-class="nav-item--active">
<span class="nav-icon">🎯</span>
<el-icon class="nav-icon"><Grid /></el-icon>
<span>游戏库</span>
</router-link>
</nav>
@@ -72,8 +78,8 @@ function goHome() {
<div class="section-header">
<span class="section-title">我的群组</span>
<div class="section-actions">
<button class="section-btn" @click="showCreateGroup = true" title="创建群组">+</button>
<button class="section-btn" @click="showJoinGroup = true" title="加入群组">&#x1F517;</button>
<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 v-if="groupStore.groups.length === 0" class="empty-groups">
@@ -96,18 +102,18 @@ function goHome() {
<div class="user-section">
<StatusToggle />
<button class="schedule-btn" @click="showScheduleModal = true" title="设置工作时间">
<el-icon><AlarmClock /></el-icon>
</button>
</div>
<div class="user-info">
<img
:src="userStore.user?.avatar || '/default-avatar.png'"
:src="userStore.user?.avatar || '/default-avatar.svg'"
class="user-avatar"
alt=""
/>
<span class="user-name">{{ userStore.user?.username }}</span>
<button class="logout-btn" @click="handleLogout" title="退出登录">
🚪
<el-icon><SwitchButton /></el-icon>
</button>
</div>
</div>
@@ -124,7 +130,7 @@ function goHome() {
<span v-if="notificationStore.unreadCount > 0" class="badge">
{{ notificationStore.unreadCount }}
</span>
🔔
<el-icon><Bell /></el-icon>
</button>
</div>
</header>
+1 -1
View File
@@ -36,7 +36,7 @@ function handleAvatarUpload() {
<el-card class="profile-card">
<div class="profile-form">
<div class="avatar-section">
<img :src="avatar || '/default-avatar.png'" class="avatar" />
<img :src="avatar || '/default-avatar.svg'" class="avatar" />
<el-button @click="handleAvatarUpload">更换头像</el-button>
</div>