feat(mobile): stage 7 - games library (group-scoped, detail/comments/favorites/add/import)

- rewrite GamesLibraryMobile.vue for uat model (games bound to group, not global):
  group selector + search + platform filter + 2-col grid + add/import entries
- new GameDetailSheetMobile.vue: cover/name/aliases/tags/platform + favorite/edit/delete
  + quick-team + comments list with rating
- new AddGameSheetMobile.vue: name/aliases/platform/tags/cover-upload (bound to group)
- new ImportGamesSheetMobile.vue: bulk import via text (name | platform | tags per line)
- router: wire GamesLibrary mobile view
- diverges from master: uat games API requires groupId (addGame/importGames/getGroupGames)
  vs master's global getPopularGames/searchGames; mobile rewritten to match uat PC behavior

build verified: vue-tsc + vite build pass
This commit is contained in:
锦麟 王
2026-06-18 11:17:38 +08:00
parent 1cc23a0836
commit 6ba671d2c3
5 changed files with 1364 additions and 1 deletions
@@ -0,0 +1,553 @@
<!-- src/components-mobile/game/GameDetailSheetMobile.vue -->
<!-- 游戏详情底部面板封面/名称/别名/标签/收藏/编辑/删除/快速组队 + 评论评分 -->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import type { Game, GamePlatform, GameComment } from '@/types'
import {
toggleFavorite, isFavorite, deleteGame, updateGame, getGameCoverUrl, getAllPlatforms,
getGameComments, addComment
} from '@/api/games'
import { useGroupStore } from '@/stores/group'
import { createTeamSession } from '@/api/sessions'
import { useTeamStore } from '@/stores/team'
import { pb } from '@/api/pocketbase'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{
show: boolean
game: Game | null
groupId?: string
}>()
const emit = defineEmits<{
'update:show': [value: boolean]
'deleted': []
}>()
const router = useRouter()
const groupStore = useGroupStore()
const teamStore = useTeamStore()
const platforms = getAllPlatforms()
const visible = computed({
get: () => props.show,
set: (val) => emit('update:show', val)
})
const favorited = ref(false)
const editing = ref(false)
const editName = ref('')
const editAliases = ref('')
const editPlatform = ref<GamePlatform | ''>('')
const editTags = ref('')
const saving = ref(false)
// 评论
const comments = ref<GameComment[]>([])
const newContent = ref('')
const newRating = ref(0)
const commentLoading = ref(false)
const commentSubmitting = ref(false)
const canModify = computed(() => {
if (!props.game) return false
const userId = pb.authStore.model?.id
if (!userId) return false
if (groupStore.isGroupOwner) return true
if (groupStore.isGroupAdmin) return true
if (props.game.addedBy === userId) return true
return false
})
// 打开时初始化
watch(() => props.show, async (val) => {
if (val && props.game) {
editing.value = false
try {
favorited.value = await isFavorite(props.game.id)
} catch { /* ignore */ }
await loadComments()
}
})
async function loadComments() {
if (!props.game) return
try {
commentLoading.value = true
comments.value = await getGameComments(props.game.id)
} catch { /* ignore */ } finally {
commentLoading.value = false
}
}
function startEdit() {
if (!props.game) return
editName.value = props.game.name
editAliases.value = (props.game.aliases || []).join(', ')
editPlatform.value = props.game.platform || ''
editTags.value = (props.game.tags || []).join(', ')
editing.value = true
}
async function handleSave() {
if (!props.game || !editName.value.trim()) {
showFailToast('请输入游戏名称')
return
}
try {
saving.value = true
const aliases = editAliases.value.split(',').map(t => t.trim()).filter(Boolean)
const tags = editTags.value.split(',').map(t => t.trim()).filter(Boolean)
await updateGame(props.game.id, {
name: editName.value.trim(),
aliases: aliases.length > 0 ? aliases : undefined,
platform: editPlatform.value || undefined,
tags: tags.length > 0 ? tags : undefined
})
showSuccessToast('修改成功')
editing.value = false
emit('deleted') // 触发父组件重新加载
} catch (e: any) {
showFailToast(e.message || '修改失败')
} finally {
saving.value = false
}
}
async function handleFavorite() {
if (!props.game) return
try {
favorited.value = await toggleFavorite(props.game.id)
} catch (e: any) {
showFailToast(e.message || '操作失败')
}
}
async function handleDelete() {
if (!props.game) return
const game = props.game
showConfirmDialog({
title: '删除游戏',
message: `确定要删除「${game.name}」吗?`
}).then(async () => {
try {
await deleteGame(game.id)
showSuccessToast('删除成功')
visible.value = false
emit('deleted')
} catch (e: any) {
showFailToast(e.message || '删除失败')
}
}).catch(() => {})
}
async function handleCreateTeam() {
if (!props.game || !props.groupId) return
try {
const me = pb.authStore.model?.id
if (!me) return
const session = await createTeamSession({
sourceGroup: props.groupId,
name: `${props.game.name}小队`,
gameName: props.game.name,
members: [me]
})
await teamStore.loadActiveSession()
showSuccessToast('已发起组队')
visible.value = false
router.push({
name: 'VoiceRoom',
params: { groupId: props.groupId, sessionId: session.id }
})
} catch (e: any) {
showFailToast(e.message || '组队失败')
}
}
async function handleSubmitComment() {
if (!props.game) return
if (!newContent.value.trim()) {
showFailToast('请输入评论内容')
return
}
try {
commentSubmitting.value = true
await addComment(props.game.id, newContent.value.trim(), newRating.value || undefined)
newContent.value = ''
newRating.value = 0
await loadComments()
showSuccessToast('评论成功')
} catch (e: any) {
showFailToast(e.message || '评论失败')
} finally {
commentSubmitting.value = false
}
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
</script>
<template>
<van-popup
v-model:show="visible"
position="bottom"
round
closeable
:style="{ height: '85%' }"
>
<div v-if="game" class="detail-sheet">
<!-- 编辑模式 -->
<template v-if="editing">
<div class="sheet-title">编辑游戏</div>
<van-cell-group inset>
<van-field v-model="editName" label="名称" placeholder="游戏名称" required />
<van-field v-model="editAliases" label="别名" placeholder="逗号分隔" />
<van-field name="platform" label="平台">
<template #input>
<select v-model="editPlatform" class="native-select">
<option value="">不指定</option>
<option v-for="p in platforms" :key="p" :value="p">{{ p }}</option>
</select>
</template>
</van-field>
<van-field v-model="editTags" label="标签" placeholder="逗号分隔" />
</van-cell-group>
<div class="edit-actions">
<van-button block round @click="editing = false">取消</van-button>
<van-button type="primary" block round :loading="saving" @click="handleSave">保存</van-button>
</div>
</template>
<!-- 详情模式 -->
<template v-else>
<div class="detail-cover-wrap">
<img
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
:alt="game.name"
class="detail-cover"
/>
</div>
<h2 class="detail-name">{{ game.name }}</h2>
<div v-if="game.aliases?.length" class="detail-aliases">
<span v-for="alias in game.aliases" :key="alias" class="alias-tag">{{ alias }}</span>
</div>
<div class="detail-meta">
<van-tag v-if="game.platform" type="primary" size="medium">{{ game.platform }}</van-tag>
<span class="popularity">🔥 {{ game.popularCount }} 人游玩</span>
</div>
<div v-if="game.tags?.length" class="detail-tags">
<van-tag v-for="tag in game.tags" :key="tag" plain size="medium">{{ tag }}</van-tag>
</div>
<!-- 操作按钮 -->
<div class="detail-actions">
<van-button
:type="favorited ? 'warning' : 'default'"
size="small"
round
:icon="favorited ? 'star' : 'star-o'"
@click="handleFavorite"
>{{ favorited ? '已收藏' : '收藏' }}</van-button>
<van-button v-if="canModify" size="small" round icon="edit" @click="startEdit">编辑</van-button>
<van-button v-if="canModify" size="small" round plain type="danger" icon="delete-o" @click="handleDelete">删除</van-button>
</div>
<van-button type="primary" block round class="team-btn" @click="handleCreateTeam">
快速组队
</van-button>
<!-- 评论区 -->
<div class="comments-section">
<div class="comments-title">评论 ({{ comments.length }})</div>
<!-- 评论输入 -->
<div class="comment-form">
<div class="rating-row">
<span class="rating-label">评分</span>
<div class="stars">
<van-icon
v-for="star in 5"
:key="star"
name="star"
:class="{ active: star <= newRating }"
size="18"
@click="newRating = star"
/>
</div>
</div>
<van-field
v-model="newContent"
type="textarea"
placeholder="写下你的评价..."
rows="2"
autosize
class="comment-input"
/>
<van-button
type="primary"
size="small"
round
:loading="commentSubmitting"
@click="handleSubmitComment"
>发表</van-button>
</div>
<!-- 评论列表 -->
<div v-if="commentLoading" class="comment-loading">
<van-loading size="20px">加载中...</van-loading>
</div>
<div v-else-if="comments.length === 0" class="comment-empty">
暂无评论快来抢沙发
</div>
<div v-else class="comment-list">
<div v-for="c in comments" :key="c.id" class="comment-item">
<img
:src="c.expand?.author?.avatar || '/default-avatar.svg'"
class="comment-avatar"
alt=""
/>
<div class="comment-body">
<div class="comment-head">
<span class="comment-author">{{ c.expand?.author?.name || c.expand?.author?.username || '匿名' }}</span>
<span v-if="c.rating" class="comment-rating">
<van-icon v-for="s in c.rating" :key="s" name="star" class="star-filled" size="12" />
</span>
</div>
<div class="comment-content">{{ c.content }}</div>
<div class="comment-time">{{ formatDate(c.created) }}</div>
</div>
</div>
</div>
</div>
</template>
</div>
</van-popup>
</template>
<style scoped>
.detail-sheet {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 16px 0 24px;
height: 100%;
overflow-y: auto;
}
.sheet-title {
font-size: 16px;
font-weight: 600;
padding: 4px 0 8px;
}
.native-select {
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
padding: 6px 10px;
font-size: 14px;
background: var(--gg-bg-card);
width: 100%;
}
.edit-actions {
display: flex;
gap: 10px;
width: 100%;
padding: 0 16px;
}
.detail-cover-wrap {
width: 160px;
height: 210px;
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;
padding: 0 16px;
}
.detail-aliases {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
padding: 0 16px;
}
.alias-tag {
padding: 3px 10px;
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
}
.detail-meta {
display: flex;
align-items: center;
gap: 12px;
}
.popularity {
font-size: 13px;
color: var(--gg-text-secondary);
}
.detail-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: center;
padding: 0 16px;
}
.detail-actions {
display: flex;
gap: 10px;
}
.team-btn {
width: calc(100% - 32px);
}
/* 评论区 */
.comments-section {
width: 100%;
padding: 8px 16px 0;
border-top: 8px solid var(--gg-bg);
}
.comments-title {
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
margin-bottom: 12px;
}
.comment-form {
background: var(--gg-bg);
border-radius: var(--gg-radius-sm);
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.rating-row {
display: flex;
align-items: center;
gap: 8px;
}
.rating-label {
font-size: 13px;
color: var(--gg-text-secondary);
}
.stars {
display: flex;
gap: 2px;
}
.stars .van-icon {
color: var(--gg-text-muted);
}
.stars .van-icon.active {
color: var(--gg-warning);
}
.comment-input {
background: transparent;
padding: 0;
}
.comment-form .van-button {
align-self: flex-end;
}
.comment-loading, .comment-empty {
text-align: center;
padding: 20px;
font-size: 13px;
color: var(--gg-text-muted);
}
.comment-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.comment-item {
display: flex;
gap: 10px;
}
.comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.comment-body {
flex: 1;
min-width: 0;
}
.comment-head {
display: flex;
align-items: center;
gap: 8px;
}
.comment-author {
font-size: 13px;
font-weight: 600;
color: var(--gg-text);
}
.comment-rating .star-filled {
color: var(--gg-warning);
}
.comment-content {
font-size: 13px;
color: var(--gg-text);
line-height: 1.5;
margin-top: 4px;
word-break: break-word;
}
.comment-time {
font-size: 11px;
color: var(--gg-text-muted);
margin-top: 4px;
}
</style>