Files
gamegroup2/frontend/src/components-mobile/game/GameDetailSheetMobile.vue
T
锦麟 王 6ba671d2c3 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
2026-06-18 11:17:38 +08:00

554 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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>