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>
This commit is contained in:
@@ -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,67 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# seed-games.sh - 批量导入热门游戏数据到 PocketBase
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PB_URL="${PB_URL:-http://192.168.1.14:8090}"
|
|
||||||
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@example.com}"
|
|
||||||
ADMIN_PASS="${ADMIN_PASS:-admin123456}"
|
|
||||||
|
|
||||||
echo "获取管理员 Token..."
|
|
||||||
TOKEN=$(curl -s -X POST "$PB_URL/api/admins/auth-with-password" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"identity\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASS\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
|
||||||
|
|
||||||
echo "Token 获取成功,开始导入游戏..."
|
|
||||||
|
|
||||||
create_game() {
|
|
||||||
local name="$1" platform="$2" tags="$3" count="$4"
|
|
||||||
curl -s -X POST "$PB_URL/api/collections/games/records" \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"name\":\"$name\",\"platform\":\"$platform\",\"tags\":$tags,\"popularCount\":$count}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f' ✓ {d.get(\"name\",\"ERROR\")}')"
|
|
||||||
}
|
|
||||||
|
|
||||||
# PC 游戏
|
|
||||||
create_game "League of Legends" "PC" '["MOBA","竞技","5v5"]' 10000
|
|
||||||
create_game "Counter-Strike 2" "PC" '["FPS","射击","竞技"]' 9500
|
|
||||||
create_game "Valorant" "PC" '["FPS","射击","战术"]' 9000
|
|
||||||
create_game "Dota 2" "PC" '["MOBA","竞技","策略"]' 8500
|
|
||||||
create_game "Apex Legends" "PC" '["FPS","大逃杀","吃鸡"]' 8000
|
|
||||||
create_game "PUBG" "PC" '["大逃杀","吃鸡","射击"]' 7500
|
|
||||||
create_game "Overwatch 2" "PC" '["FPS","英雄射击","团队"]' 7000
|
|
||||||
create_game "Minecraft" "PC" '["沙盒","生存","建造"]' 6500
|
|
||||||
create_game "Genshin Impact" "PC" '["RPG","开放世界","二次元"]' 6000
|
|
||||||
create_game "Elden Ring" "PC" '["RPG","魂系","开放世界"]' 5500
|
|
||||||
create_game "Teamfight Tactics" "PC" '["自走棋","策略","休闲"]' 5000
|
|
||||||
create_game "Call of Duty: Warzone" "PC" '["FPS","大逃杀","射击"]' 4500
|
|
||||||
create_game "Fortnite" "PC" '["大逃杀","建造","射击"]' 4000
|
|
||||||
create_game "Dead by Daylight" "PC" '["恐怖","非对称","多人"]' 3500
|
|
||||||
create_game "Among Us" "PC" '["社交","推理","休闲"]' 3000
|
|
||||||
create_game "It Takes Two" "PC" '["合作","双人","冒险"]' 2500
|
|
||||||
create_game "Baldurs Gate 3" "PC" '["RPG","回合制","合作"]' 2000
|
|
||||||
|
|
||||||
# PS5
|
|
||||||
create_game "Helldivers 2" "PS5" '["射击","合作","PvE"]' 1800
|
|
||||||
create_game "Final Fantasy XIV" "PS5" '["MMORPG","RPG","多人"]' 1500
|
|
||||||
create_game "Spider-Man 2" "PS5" '["动作","冒险","开放世界"]' 1200
|
|
||||||
create_game "God of War Ragnarok" "PS5" '["动作","冒险","单人"]' 1000
|
|
||||||
|
|
||||||
# Switch
|
|
||||||
create_game "Mario Kart 8" "Switch" '["竞速","休闲","派对"]' 2000
|
|
||||||
create_game "Zelda: TOTK" "Switch" '["冒险","开放世界","解谜"]' 1800
|
|
||||||
create_game "Super Smash Bros" "Switch" '["格斗","派对","竞技"]' 1500
|
|
||||||
create_game "Pokemon Scarlet" "Switch" '["RPG","收集","冒险"]' 1200
|
|
||||||
|
|
||||||
# Mobile
|
|
||||||
create_game "Honor of Kings" "Mobile" '["MOBA","竞技","5v5"]' 9000
|
|
||||||
create_game "PUBG Mobile" "Mobile" '["大逃杀","吃鸡","射击"]' 7000
|
|
||||||
create_game "Genshin Impact" "Mobile" '["RPG","开放世界","二次元"]' 5500
|
|
||||||
create_game "Call of Duty Mobile" "Mobile" '["FPS","射击","多人"]' 4000
|
|
||||||
create_game "Arena of Valor" "Mobile" '["MOBA","竞技","5v5"]' 3000
|
|
||||||
|
|
||||||
# Xbox
|
|
||||||
create_game "Halo Infinite" "Xbox" '["FPS","射击","竞技"]' 3000
|
|
||||||
create_game "Forza Horizon 5" "Xbox" '["竞速","开放世界","休闲"]' 2500
|
|
||||||
create_game "Starfield" "Xbox" '["RPG","开放世界","太空"]' 2000
|
|
||||||
|
|
||||||
echo "导入完成!"
|
|
||||||
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 232 B |
|
Before Width: | Height: | Size: 327 B After Width: | Height: | Size: 327 B |
+122
-65
@@ -1,88 +1,127 @@
|
|||||||
// src/api/games.ts
|
|
||||||
import pb from './pocketbase'
|
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
|
page?: number
|
||||||
limit?: number
|
limit?: number
|
||||||
platform?: GamePlatform
|
platform?: GamePlatform
|
||||||
search?: string
|
search?: string
|
||||||
}): Promise<{ items: Game[], total: number }> {
|
}): Promise<{ items: Game[], total: number }> {
|
||||||
const { page = 1, limit = 20, platform, search } = options || {}
|
const { page = 1, limit = 50, platform, search } = options || {}
|
||||||
|
let filter = `group="${groupId}"`
|
||||||
let filter = ''
|
if (platform) filter += ` && platform="${platform}"`
|
||||||
if (platform) {
|
if (search) filter += ` && name ~ "${search}"`
|
||||||
filter = `platform="${platform}"`
|
|
||||||
}
|
|
||||||
if (search) {
|
|
||||||
const searchFilter = `name ~ "${search}"`
|
|
||||||
filter = filter ? `${filter} && ${searchFilter}` : searchFilter
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pb.collection('games').getList(page, limit, {
|
const result = await pb.collection('games').getList(page, limit, {
|
||||||
filter,
|
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[]> {
|
export async function addGame(groupId: string, data: {
|
||||||
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: {
|
|
||||||
name: string
|
name: string
|
||||||
platform: GamePlatform
|
platform?: GamePlatform
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
cover?: string
|
cover?: string
|
||||||
}) {
|
}) {
|
||||||
return pb.collection('games').create(data)
|
const user = pb.authStore.model
|
||||||
}
|
return pb.collection('games').create({
|
||||||
|
...data,
|
||||||
// 更新游戏热度
|
group: groupId,
|
||||||
export async function incrementGamePopularity(gameId: string) {
|
addedBy: user?.id,
|
||||||
const game = await pb.collection('games').getOne(gameId)
|
popularCount: 0
|
||||||
return pb.collection('games').update(gameId, {
|
|
||||||
popularCount: (game.popularCount || 0) + 1
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取游戏详情
|
// 删除游戏
|
||||||
export async function getGame(gameId: string): Promise<Game> {
|
export async function deleteGame(gameId: string) {
|
||||||
return pb.collection('games').getOne(gameId) as unknown as Game
|
return pb.collection('games').delete(gameId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按平台获取游戏
|
// 导入游戏(批量添加)
|
||||||
export async function getGamesByPlatform(platform: GamePlatform): Promise<Game[]> {
|
export async function importGames(groupId: string, games: Array<{
|
||||||
const result = await pb.collection('games').getList(1, 50, {
|
name: string
|
||||||
filter: `platform="${platform}"`,
|
platform?: GamePlatform
|
||||||
sort: '-popularCount'
|
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']
|
return ['PC', 'PS5', 'Xbox', 'Switch', 'Mobile']
|
||||||
}
|
}
|
||||||
|
|
||||||
// 订阅游戏变更
|
// 搜索游戏(用于 GameSelectDialog,全局搜索或群组内搜索)
|
||||||
export function subscribeGames(callback: (game: Game) => void) {
|
export async function searchGames(query: string, groupId?: string, limit = 20): Promise<Game[]> {
|
||||||
return pb.collection('games').subscribe('*', (payload) => {
|
if (!query.trim()) return []
|
||||||
callback(payload.record as unknown as Game)
|
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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,108 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
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"
|
||||||
|
>★</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">{{ '★'.repeat(c.rating) }}</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>
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import type { Game } from '@/types'
|
import type { Game } from '@/types'
|
||||||
|
import { toggleFavorite, isFavorite, deleteGame } from '@/api/games'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import GameComments from './GameComments.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
game: Game | null
|
game: Game | null
|
||||||
|
groupId?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: boolean]
|
'update:modelValue': [value: boolean]
|
||||||
'create-team': [gameName: string]
|
'create-team': [gameName: string]
|
||||||
|
'deleted': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
@@ -17,6 +22,32 @@ const visible = computed({
|
|||||||
set: (val) => emit('update:modelValue', val)
|
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() {
|
function handleCreateTeam() {
|
||||||
if (props.game) {
|
if (props.game) {
|
||||||
emit('create-team', props.game.name)
|
emit('create-team', props.game.name)
|
||||||
@@ -26,15 +57,11 @@ function handleCreateTeam() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<el-dialog v-model="visible" width="440px" :show-close="true">
|
<el-dialog v-model="visible" width="480px" :show-close="true">
|
||||||
<template v-if="game">
|
<template v-if="game">
|
||||||
<div class="game-detail">
|
<div class="game-detail">
|
||||||
<div class="detail-cover-wrap">
|
<div class="detail-cover-wrap">
|
||||||
<img
|
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="detail-cover" />
|
||||||
:src="game.cover || '/game-placeholder.png'"
|
|
||||||
:alt="game.name"
|
|
||||||
class="detail-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="detail-name">{{ game.name }}</h2>
|
<h2 class="detail-name">{{ game.name }}</h2>
|
||||||
@@ -48,84 +75,51 @@ function handleCreateTeam() {
|
|||||||
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-actions">
|
||||||
|
<button class="fav-btn" :class="{ active: favorited }" @click="handleFavorite">
|
||||||
|
{{ favorited ? '★ 已收藏' : '☆ 收藏' }}
|
||||||
|
</button>
|
||||||
|
<button class="delete-detail-btn" @click="handleDelete">删除</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
|
<el-button type="primary" class="team-btn" @click="handleCreateTeam">
|
||||||
快速组队
|
快速组队
|
||||||
</el-button>
|
</el-button>
|
||||||
|
|
||||||
|
<GameComments v-if="game.id" :game-id="game.id" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.game-detail {
|
.game-detail { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 8px 0; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-cover-wrap {
|
.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); }
|
||||||
width: 200px;
|
.detail-cover { width: 100%; height: 100%; object-fit: cover; }
|
||||||
height: 260px;
|
|
||||||
border-radius: var(--gg-radius-md);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--gg-bg-elevated);
|
|
||||||
border: 1px solid var(--gg-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-cover {
|
.detail-name { margin: 0; font-size: 22px; font-weight: 700; color: var(--gg-text); text-align: center; }
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-name {
|
.detail-meta { display: flex; align-items: center; gap: 12px; }
|
||||||
margin: 0;
|
.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; }
|
||||||
font-size: 22px;
|
.popularity { font-size: 14px; color: var(--gg-text-secondary); }
|
||||||
font-weight: 700;
|
|
||||||
color: var(--gg-text);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-meta {
|
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
||||||
display: flex;
|
.tag { padding: 4px 12px; background: var(--gg-bg-elevated); border-radius: 6px; font-size: 12px; color: var(--gg-primary); font-weight: 500; }
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-badge {
|
.detail-actions { display: flex; gap: 12px; width: 100%; justify-content: center; }
|
||||||
padding: 4px 14px;
|
.fav-btn {
|
||||||
background: rgba(168, 85, 247, 0.15);
|
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||||
color: var(--gg-accent);
|
background: transparent; color: var(--gg-text-secondary); font-size: 14px; cursor: pointer; transition: all 0.2s;
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
.fav-btn:hover { border-color: #f59e0b; color: #f59e0b; }
|
||||||
|
.fav-btn.active { border-color: #f59e0b; color: #f59e0b; background: rgba(245, 158, 11, 0.1); }
|
||||||
|
|
||||||
.popularity {
|
.delete-detail-btn {
|
||||||
font-size: 14px;
|
padding: 8px 20px; border: 1px solid var(--gg-border); border-radius: var(--gg-radius-sm);
|
||||||
color: var(--gg-text-secondary);
|
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); }
|
||||||
|
|
||||||
.detail-tags {
|
.team-btn { width: 100%; margin-top: 4px; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-btn {
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
</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,cover(tags 用分号分隔)</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 class="members-list">
|
||||||
<div v-for="member in members" :key="member.id" class="member-row">
|
<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">
|
<div class="member-info">
|
||||||
<span class="member-name">{{ member.username }}</span>
|
<span class="member-name">{{ member.username }}</span>
|
||||||
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
|
<span v-if="member.id === group.owner" class="owner-badge">群主</span>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function confirmCustomGame() {
|
|||||||
@click="selectGame(game)"
|
@click="selectGame(game)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="game.cover || '/game-placeholder.png'"
|
:src="game.cover || '/game-placeholder.svg'"
|
||||||
:alt="game.name"
|
:alt="game.name"
|
||||||
class="game-cover"
|
class="game-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async function inviteMember(userId: string, username: string) {
|
|||||||
class="member-item"
|
class="member-item"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="member.avatar || '/default-avatar.png'"
|
:src="member.avatar || '/default-avatar.svg'"
|
||||||
:alt="member.username"
|
:alt="member.username"
|
||||||
class="avatar"
|
class="avatar"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function rejectInvitation() {
|
|||||||
<div class="invitation-card">
|
<div class="invitation-card">
|
||||||
<div class="invitation-header">
|
<div class="invitation-header">
|
||||||
<img
|
<img
|
||||||
:src="invitation.expand?.from?.avatar || '/default-avatar.png'"
|
:src="invitation.expand?.from?.avatar || '/default-avatar.svg'"
|
||||||
:alt="invitation.expand?.from?.username"
|
:alt="invitation.expand?.from?.username"
|
||||||
class="avatar"
|
class="avatar"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<!-- src/components/team/TeamSessionPanel.vue -->
|
<!-- src/components/team/TeamSessionPanel.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useTeamStore } from '@/stores/team'
|
import { useTeamStore } from '@/stores/team'
|
||||||
import { useGroupStore } from '@/stores/group'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { useUserStore } from '@/stores/user'
|
||||||
import { TeamStatusMap } from '@/types'
|
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 teamStore = useTeamStore()
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const session = computed(() => teamStore.currentSession)
|
const session = computed(() => teamStore.currentSession)
|
||||||
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
|
const statusText = computed(() => session.value ? TeamStatusMap[session.value.status] : '')
|
||||||
|
const showGameSelect = ref(false)
|
||||||
|
|
||||||
const memberDetails = computed(() => {
|
const memberDetails = computed(() => {
|
||||||
if (!session.value) return []
|
if (!session.value) return []
|
||||||
@@ -43,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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -66,7 +99,7 @@ async function endGame() {
|
|||||||
class="member-item"
|
class="member-item"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="member.avatar || '/default-avatar.png'"
|
:src="member.avatar || '/default-avatar.svg'"
|
||||||
:alt="member.username"
|
:alt="member.username"
|
||||||
class="avatar"
|
class="avatar"
|
||||||
/>
|
/>
|
||||||
@@ -87,8 +120,13 @@ async function endGame() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="no-session">
|
<div v-else class="no-session">
|
||||||
<p>暂未加入任何临时小组</p>
|
<p class="no-session-text">暂未加入任何临时小组</p>
|
||||||
|
<button class="create-team-btn" @click="showGameSelect = true">
|
||||||
|
创建临时小队
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<GameSelectDialog v-model="showGameSelect" @select="handleGameSelected" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -251,9 +289,30 @@ async function endGame() {
|
|||||||
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: 48px 32px;
|
padding: 40px 32px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-session-text {
|
||||||
color: var(--gg-text-muted);
|
color: var(--gg-text-muted);
|
||||||
font-size: 15px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import type { TeamSession, TeamStatus } from '@/types'
|
import type { TeamSession, TeamStatus } from '@/types'
|
||||||
import { getActiveTeamSession, createTeamSession, updateTeamStatus, endGame } from '@/api/sessions'
|
import { getActiveTeamSession, createTeamSession, updateTeamStatus, endGame } from '@/api/sessions'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
import { getPendingInvitations, sendBulkInvitations } from '@/api/invitations'
|
import { getPendingInvitations, sendBulkInvitations } from '@/api/invitations'
|
||||||
|
|
||||||
export const useTeamStore = defineStore('team', () => {
|
export const useTeamStore = defineStore('team', () => {
|
||||||
@@ -43,8 +44,12 @@ export const useTeamStore = defineStore('team', () => {
|
|||||||
const session = await createTeamSession(data)
|
const session = await createTeamSession(data)
|
||||||
currentSession.value = session
|
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
|
return session
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ export interface Game {
|
|||||||
tags?: string[]
|
tags?: string[]
|
||||||
cover?: string
|
cover?: string
|
||||||
popularCount: number
|
popularCount: number
|
||||||
|
group: string
|
||||||
|
addedBy?: string
|
||||||
created: string
|
created: string
|
||||||
updated: string
|
updated: string
|
||||||
}
|
}
|
||||||
@@ -126,3 +128,26 @@ export interface WorkSchedule {
|
|||||||
workdays: number[]
|
workdays: number[]
|
||||||
workStartTime: string
|
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
|
||||||
|
}
|
||||||
|
|||||||
+125
-107
@@ -1,9 +1,15 @@
|
|||||||
<!-- src/views/GamesLibrary.vue -->
|
<!-- src/views/GamesLibrary.vue -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
import { getGames, getAllPlatforms } from '@/api/games'
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { getGroupGames, deleteGame, exportGames, getAllPlatforms } from '@/api/games'
|
||||||
import type { Game, GamePlatform } from '@/types'
|
import type { Game, GamePlatform } from '@/types'
|
||||||
import GameDetailDialog from '@/components/game/GameDetailDialog.vue'
|
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'
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
const games = ref<Game[]>([])
|
const games = ref<Game[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -12,16 +18,23 @@ const selectedPlatform = ref<GamePlatform | ''>('')
|
|||||||
const platforms = getAllPlatforms()
|
const platforms = getAllPlatforms()
|
||||||
const selectedGame = ref<Game | null>(null)
|
const selectedGame = ref<Game | null>(null)
|
||||||
const showDetail = ref(false)
|
const showDetail = ref(false)
|
||||||
|
const showAddGame = ref(false)
|
||||||
|
const showImport = ref(false)
|
||||||
|
|
||||||
|
const currentGroupId = computed(() => groupStore.currentGroupId)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (currentGroupId.value) {
|
||||||
await loadGames()
|
await loadGames()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadGames() {
|
async function loadGames() {
|
||||||
|
if (!currentGroupId.value) return
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const result = await getGames({
|
const result = await getGroupGames(currentGroupId.value, {
|
||||||
limit: 50,
|
limit: 100,
|
||||||
search: searchQuery.value || undefined,
|
search: searchQuery.value || undefined,
|
||||||
platform: selectedPlatform.value || undefined
|
platform: selectedPlatform.value || undefined
|
||||||
})
|
})
|
||||||
@@ -33,55 +46,93 @@ async function loadGames() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
loadGames()
|
|
||||||
}
|
|
||||||
|
|
||||||
function openGameDetail(game: Game) {
|
function openGameDetail(game: Game) {
|
||||||
selectedGame.value = game
|
selectedGame.value = game
|
||||||
showDetail.value = true
|
showDetail.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreateTeam(_gameName: string) {
|
async function handleDeleteGame(game: Game) {
|
||||||
// TODO: 导航到群组页面触发组队流程
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="games-library">
|
<div class="games-library">
|
||||||
|
<!-- 无群组提示 -->
|
||||||
|
<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">
|
<div class="page-header">
|
||||||
<h1>游戏库</h1>
|
<h1>游戏库</h1>
|
||||||
<div class="filters">
|
<div class="toolbar">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="搜索游戏..."
|
placeholder="搜索游戏..."
|
||||||
clearable
|
clearable
|
||||||
@input="handleSearch"
|
style="width: 240px"
|
||||||
/>
|
@input="loadGames"
|
||||||
<el-select v-model="selectedPlatform" placeholder="选择平台" clearable @change="loadGames">
|
|
||||||
<el-option
|
|
||||||
v-for="platform in platforms"
|
|
||||||
:key="platform"
|
|
||||||
:label="platform"
|
|
||||||
:value="platform"
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</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>
|
</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">
|
||||||
<p>暂无游戏</p>
|
<p class="empty-text">暂无游戏</p>
|
||||||
|
<p class="empty-hint">点击「添加游戏」或「导入」开始管理游戏库</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="games-grid">
|
<div v-else class="games-grid">
|
||||||
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
|
<div v-for="game in games" :key="game.id" class="game-card" @click="openGameDetail(game)">
|
||||||
<img
|
<img :src="game.cover || '/game-placeholder.svg'" :alt="game.name" class="game-cover" />
|
||||||
:src="game.cover || '/game-placeholder.png'"
|
|
||||||
:alt="game.name"
|
|
||||||
class="game-cover"
|
|
||||||
/>
|
|
||||||
<div class="game-info">
|
<div class="game-info">
|
||||||
<h3 class="game-name">{{ game.name }}</h3>
|
<h3 class="game-name">{{ game.name }}</h3>
|
||||||
<p class="game-platform">{{ game.platform }}</p>
|
<p class="game-platform">{{ game.platform }}</p>
|
||||||
@@ -89,118 +140,85 @@ function handleCreateTeam(_gameName: string) {
|
|||||||
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
<span v-for="tag in game.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="delete-btn" @click.stop="handleDeleteGame(game)" title="删除">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<GameDetailDialog
|
<GameDetailDialog v-model="showDetail" :game="selectedGame" :group-id="currentGroupId" @deleted="loadGames" />
|
||||||
v-model="showDetail"
|
<AddGameDialog v-model="showAddGame" :group-id="currentGroupId" @created="handleGameAdded" />
|
||||||
:game="selectedGame"
|
<ImportGamesDialog v-model="showImport" :group-id="currentGroupId" @imported="handleImportComplete" />
|
||||||
@create-team="handleCreateTeam"
|
</template>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.games-library {
|
.games-library { width: 100%; }
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
.no-group {
|
||||||
margin-bottom: 28px;
|
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 {
|
.page-header h1 {
|
||||||
font-size: 28px;
|
font-size: 28px; font-weight: 700; margin: 0 0 16px;
|
||||||
font-weight: 700;
|
background: var(--gg-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
|
||||||
margin: 0 0 16px;
|
|
||||||
background: var(--gg-gradient);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.toolbar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters .el-input {
|
.tool-btn {
|
||||||
width: 300px;
|
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 {
|
.loading { text-align: center; padding: 80px 32px; color: var(--gg-text-muted); }
|
||||||
text-align: center;
|
|
||||||
padding: 80px 32px;
|
|
||||||
color: var(--gg-text-muted);
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
.empty { text-align: center; padding: 80px 32px; }
|
||||||
text-align: center;
|
.empty-text { font-size: 16px; font-weight: 500; color: var(--gg-text-secondary); margin: 0 0 8px; }
|
||||||
padding: 80px 32px;
|
.empty-hint { font-size: 14px; color: var(--gg-text-muted); margin: 0; }
|
||||||
color: var(--gg-text-muted);
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.games-grid {
|
.games-grid {
|
||||||
display: grid;
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.game-card {
|
.game-card {
|
||||||
border-radius: var(--gg-radius-md);
|
position: relative; border-radius: var(--gg-radius-md); overflow: hidden; cursor: pointer;
|
||||||
overflow: hidden;
|
background: var(--gg-bg-card); border: 1px solid var(--gg-border);
|
||||||
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-card:hover {
|
.game-cover { width: 100%; aspect-ratio: 3/4; object-fit: cover; }
|
||||||
transform: translateY(-4px);
|
|
||||||
border-color: var(--gg-primary);
|
|
||||||
box-shadow: 0 4px 20px rgba(5, 150, 105, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-cover {
|
.game-info { padding: 14px; }
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 3/4;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-info {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.game-name {
|
.game-name {
|
||||||
font-size: 16px;
|
font-size: 16px; font-weight: 700; margin: 0 0 4px; color: var(--gg-text);
|
||||||
font-weight: 700;
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
.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;
|
padding: 3px 10px; background: var(--gg-bg-elevated); border-radius: 6px;
|
||||||
background: var(--gg-bg-elevated);
|
font-size: 11px; color: var(--gg-primary); font-weight: 500;
|
||||||
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>
|
</style>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function refreshMembers() {
|
|||||||
<div v-if="list.length === 0" class="empty-row">暂无</div>
|
<div v-if="list.length === 0" class="empty-row">暂无</div>
|
||||||
<div v-else class="member-row-list">
|
<div v-else class="member-row-list">
|
||||||
<div v-for="m in list" :key="m.id" class="member-row">
|
<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 class="member-name">{{ m.username }}</span>
|
||||||
<span v-if="m.statusNote" class="member-note">{{ m.statusNote }}</span>
|
<span v-if="m.statusNote" class="member-note">{{ m.statusNote }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function openGameDetail(game: Game) {
|
|||||||
>
|
>
|
||||||
<div class="game-cover-wrap">
|
<div class="game-cover-wrap">
|
||||||
<img
|
<img
|
||||||
:src="game.cover || '/game-placeholder.png'"
|
:src="game.cover || '/game-placeholder.svg'"
|
||||||
:alt="game.name"
|
:alt="game.name"
|
||||||
class="game-cover"
|
class="game-cover"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ function goHome() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<img
|
<img
|
||||||
:src="userStore.user?.avatar || '/default-avatar.png'"
|
:src="userStore.user?.avatar || '/default-avatar.svg'"
|
||||||
class="user-avatar"
|
class="user-avatar"
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function handleAvatarUpload() {
|
|||||||
<el-card class="profile-card">
|
<el-card class="profile-card">
|
||||||
<div class="profile-form">
|
<div class="profile-form">
|
||||||
<div class="avatar-section">
|
<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>
|
<el-button @click="handleAvatarUpload">更换头像</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user