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:
@@ -0,0 +1,228 @@
|
|||||||
|
<!-- src/components-mobile/game/AddGameSheetMobile.vue -->
|
||||||
|
<!-- 添加游戏表单(绑定到当前群组):名称/别名/平台/标签/封面 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { addGame, getAllPlatforms } from '@/api/games'
|
||||||
|
import type { GamePlatform } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
groupId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
'saved': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
aliases: '',
|
||||||
|
platform: '' as GamePlatform | '',
|
||||||
|
tags: ''
|
||||||
|
})
|
||||||
|
const coverFile = ref<File | null>(null)
|
||||||
|
const coverPreview = ref('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
form.value = { name: '', aliases: '', platform: '', tags: '' }
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
if (input.files?.[0]) {
|
||||||
|
coverFile.value = input.files[0]
|
||||||
|
coverPreview.value = URL.createObjectURL(input.files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCover() {
|
||||||
|
coverFile.value = null
|
||||||
|
coverPreview.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!form.value.name.trim()) {
|
||||||
|
showFailToast('请输入游戏名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
saving.value = true
|
||||||
|
const aliases = form.value.aliases.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
const tags = form.value.tags.split(',').map(t => t.trim()).filter(Boolean)
|
||||||
|
await addGame(props.groupId, {
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
aliases: aliases.length > 0 ? aliases : undefined,
|
||||||
|
platform: form.value.platform || undefined,
|
||||||
|
tags: tags.length > 0 ? tags : undefined,
|
||||||
|
coverFile: coverFile.value || undefined
|
||||||
|
})
|
||||||
|
showSuccessToast('添加成功')
|
||||||
|
emit('saved')
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '添加失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '80%' }"
|
||||||
|
>
|
||||||
|
<div class="add-sheet">
|
||||||
|
<div class="sheet-title">添加游戏</div>
|
||||||
|
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field v-model="form.name" label="名称" placeholder="游戏名称" required />
|
||||||
|
<van-field v-model="form.aliases" label="别名" placeholder="逗号分隔,如 LOL,英雄联盟" />
|
||||||
|
<van-field name="platform" label="平台">
|
||||||
|
<template #input>
|
||||||
|
<select v-model="form.platform" 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="form.tags" label="标签" placeholder="逗号分隔,如 MOBA,竞技" />
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<!-- 封面上传 -->
|
||||||
|
<div class="cover-field">
|
||||||
|
<div class="field-label">封面图</div>
|
||||||
|
<div class="cover-upload">
|
||||||
|
<div v-if="coverPreview" class="cover-preview">
|
||||||
|
<img :src="coverPreview" alt="" />
|
||||||
|
<button class="cover-clear" @click="clearCover">
|
||||||
|
<van-icon name="cross" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<label v-else class="cover-picker">
|
||||||
|
<van-icon name="photograph" size="28" />
|
||||||
|
<span>上传封面</span>
|
||||||
|
<input type="file" accept="image/*" class="file-input" @change="onFileChange" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<van-button type="primary" block round :loading="saving" @click="handleSubmit">
|
||||||
|
添加游戏
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.add-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-field {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--gg-text-secondary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview {
|
||||||
|
position: relative;
|
||||||
|
width: 90px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--gg-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-clear {
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
right: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker {
|
||||||
|
width: 90px;
|
||||||
|
height: 120px;
|
||||||
|
border: 1px dashed var(--gg-border);
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-actions {
|
||||||
|
padding: 20px 16px 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<!-- src/components-mobile/game/ImportGamesSheetMobile.vue -->
|
||||||
|
<!-- 批量导入游戏:每行一个游戏名(可含平台/标签),绑定到当前群组 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { importGames } from '@/api/games'
|
||||||
|
import { showSuccessToast, showFailToast } from 'vant'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
groupId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:show': [value: boolean]
|
||||||
|
'imported': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.show,
|
||||||
|
set: (val) => emit('update:show', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const gamesText = ref('')
|
||||||
|
const importing = ref(false)
|
||||||
|
const result = ref<{ success: number; failed: number } | null>(null)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
gamesText.value = ''
|
||||||
|
result.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
const lines = gamesText.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||||||
|
if (lines.length === 0) {
|
||||||
|
showFailToast('请输入游戏名称(每行一个)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
importing.value = true
|
||||||
|
result.value = null
|
||||||
|
// 每行解析:支持 "游戏名" 或 "游戏名 | 平台" 或 "游戏名 | 平台 | 标签1,标签2"
|
||||||
|
const games = lines.map(line => {
|
||||||
|
const parts = line.split('|').map(s => s.trim())
|
||||||
|
const name = parts[0]
|
||||||
|
const platform = parts[1] || undefined
|
||||||
|
const tags = parts[2] ? parts[2].split(',').map(t => t.trim()).filter(Boolean) : undefined
|
||||||
|
return { name, platform: platform as any, tags }
|
||||||
|
})
|
||||||
|
const results = await importGames(props.groupId, games)
|
||||||
|
const success = results.filter(r => r.success).length
|
||||||
|
const failed = results.length - success
|
||||||
|
result.value = { success, failed }
|
||||||
|
if (failed === 0) {
|
||||||
|
showSuccessToast(`成功导入 ${success} 个游戏`)
|
||||||
|
emit('imported')
|
||||||
|
} else {
|
||||||
|
showFailToast(`成功 ${success},失败 ${failed}`)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '导入失败')
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-popup
|
||||||
|
v-model:show="visible"
|
||||||
|
position="bottom"
|
||||||
|
round
|
||||||
|
closeable
|
||||||
|
:style="{ height: '70%' }"
|
||||||
|
>
|
||||||
|
<div class="import-sheet">
|
||||||
|
<div class="sheet-title">批量导入游戏</div>
|
||||||
|
|
||||||
|
<div class="hint">
|
||||||
|
每行输入一个游戏,支持以下格式:
|
||||||
|
<code>游戏名</code> 或
|
||||||
|
<code>游戏名 | 平台</code> 或
|
||||||
|
<code>游戏名 | 平台 | 标签1,标签2</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-cell-group inset>
|
||||||
|
<van-field
|
||||||
|
v-model="gamesText"
|
||||||
|
type="textarea"
|
||||||
|
placeholder="每行一个游戏,可用 | 分隔平台和标签"
|
||||||
|
rows="8"
|
||||||
|
autosize
|
||||||
|
/>
|
||||||
|
</van-cell-group>
|
||||||
|
|
||||||
|
<div v-if="result" class="result-bar">
|
||||||
|
<van-tag type="success">成功 {{ result.success }}</van-tag>
|
||||||
|
<van-tag v-if="result.failed > 0" type="danger">失败 {{ result.failed }}</van-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sheet-actions">
|
||||||
|
<van-button type="primary" block round :loading="importing" @click="handleImport">
|
||||||
|
开始导入
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.import-sheet {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
padding: 0 16px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint code {
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-actions {
|
||||||
|
padding: 20px 16px 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -129,7 +129,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'GamesLibrary',
|
name: 'GamesLibrary',
|
||||||
component: view(
|
component: view(
|
||||||
() => import('@/views/GamesLibrary.vue'),
|
() => import('@/views/GamesLibrary.vue'),
|
||||||
mobilePlaceholder
|
() => import('@/views-mobile/GamesLibraryMobile.vue')
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
<!-- src/views-mobile/GamesLibraryMobile.vue -->
|
||||||
|
<!-- 手机端游戏库:按群组划分 + 搜索 + 平台筛选 + 瀑布流 + 添加/导入/详情 -->
|
||||||
|
<!-- 对齐 uat PC 端 GamesLibrary(游戏绑定群组,非全局) -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useGroupStore } from '@/stores/group'
|
||||||
|
import { getGroupGames, deleteGame, getAllPlatforms, getGameCoverUrl } from '@/api/games'
|
||||||
|
import { pb } from '@/api/pocketbase'
|
||||||
|
import type { Game, GamePlatform } from '@/types'
|
||||||
|
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||||
|
import GameDetailSheetMobile from '@/components-mobile/game/GameDetailSheetMobile.vue'
|
||||||
|
import AddGameSheetMobile from '@/components-mobile/game/AddGameSheetMobile.vue'
|
||||||
|
import ImportGamesSheetMobile from '@/components-mobile/game/ImportGamesSheetMobile.vue'
|
||||||
|
|
||||||
|
const groupStore = useGroupStore()
|
||||||
|
|
||||||
|
const selectedGroupId = ref('')
|
||||||
|
const games = ref<Game[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedPlatform = ref<GamePlatform | ''>('')
|
||||||
|
|
||||||
|
const showGroupPicker = ref(false)
|
||||||
|
const platforms = getAllPlatforms()
|
||||||
|
|
||||||
|
const showDetail = ref(false)
|
||||||
|
const selectedGame = ref<Game | null>(null)
|
||||||
|
const showAddGame = ref(false)
|
||||||
|
const showImport = ref(false)
|
||||||
|
|
||||||
|
const groupOptions = computed(() => groupStore.groups)
|
||||||
|
const hasGroups = computed(() => groupOptions.value.length > 0)
|
||||||
|
const currentGroupName = computed(() => {
|
||||||
|
const g = groupOptions.value.find(x => x.id === selectedGroupId.value)
|
||||||
|
return g?.name || '选择群组'
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentUserId = computed(() => pb.authStore.model?.id || '')
|
||||||
|
|
||||||
|
function canModifyGame(game: Game): boolean {
|
||||||
|
if (groupStore.isGroupOwner) return true
|
||||||
|
if (groupStore.isGroupAdmin) return true
|
||||||
|
if (game.addedBy === currentUserId.value) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (groupStore.groups.length === 0) {
|
||||||
|
await groupStore.loadGroups()
|
||||||
|
}
|
||||||
|
if (groupStore.currentGroupId) {
|
||||||
|
selectedGroupId.value = groupStore.currentGroupId
|
||||||
|
} else if (groupOptions.value.length > 0) {
|
||||||
|
selectedGroupId.value = groupOptions.value[0].id
|
||||||
|
}
|
||||||
|
if (selectedGroupId.value) {
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedGroupId, () => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedPlatform.value = ''
|
||||||
|
loadGames()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadGames() {
|
||||||
|
if (!selectedGroupId.value) return
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const result = await getGroupGames(selectedGroupId.value, {
|
||||||
|
limit: 100,
|
||||||
|
search: searchQuery.value || undefined,
|
||||||
|
platform: selectedPlatform.value || undefined
|
||||||
|
})
|
||||||
|
games.value = result.items
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载游戏失败:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
function onSearchInput() {
|
||||||
|
if (searchTimer) clearTimeout(searchTimer)
|
||||||
|
searchTimer = setTimeout(() => loadGames(), 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGameDetail(game: Game) {
|
||||||
|
selectedGame.value = game
|
||||||
|
showDetail.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteGame(game: Game) {
|
||||||
|
showConfirmDialog({
|
||||||
|
title: '删除游戏',
|
||||||
|
message: `确定要删除「${game.name}」吗?`
|
||||||
|
}).then(async () => {
|
||||||
|
try {
|
||||||
|
await deleteGame(game.id)
|
||||||
|
showSuccessToast('删除成功')
|
||||||
|
await loadGames()
|
||||||
|
} catch (e: any) {
|
||||||
|
showFailToast(e.message || '删除失败')
|
||||||
|
}
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onGroupConfirm({ selectedValues }: { selectedValues: string[] }) {
|
||||||
|
if (selectedValues[0]) {
|
||||||
|
selectedGroupId.value = selectedValues[0]
|
||||||
|
// 同步到 store,供子组件(详情/添加)判断权限
|
||||||
|
groupStore.setCurrentGroup(selectedGroupId.value)
|
||||||
|
}
|
||||||
|
showGroupPicker.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupPickerColumns = computed(() =>
|
||||||
|
groupOptions.value.map(g => ({ text: g.name, value: g.id }))
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleDetailDeleted() {
|
||||||
|
showDetail.value = false
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGameSaved() {
|
||||||
|
showAddGame.value = false
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportComplete() {
|
||||||
|
showImport.value = false
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="games-mobile">
|
||||||
|
<!-- 无群组引导 -->
|
||||||
|
<div v-if="!hasGroups" class="no-group">
|
||||||
|
<van-empty description="还没有群组" image-size="100">
|
||||||
|
<p class="no-group-hint">先创建或加入群组,再管理游戏库</p>
|
||||||
|
</van-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- 顶部:群组选择 + 工具栏 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="group-selector" @click="showGroupPicker = true">
|
||||||
|
<van-icon name="friends-o" />
|
||||||
|
<span class="group-name">{{ currentGroupName }}</span>
|
||||||
|
<van-icon name="arrow-down" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-search
|
||||||
|
v-model="searchQuery"
|
||||||
|
placeholder="搜索游戏"
|
||||||
|
shape="round"
|
||||||
|
@update:model-value="onSearchInput"
|
||||||
|
@search="loadGames"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 平台筛选横滑 -->
|
||||||
|
<div class="platform-bar">
|
||||||
|
<van-tag
|
||||||
|
:type="selectedPlatform === '' ? 'primary' : 'default'"
|
||||||
|
size="medium"
|
||||||
|
round
|
||||||
|
class="platform-tag"
|
||||||
|
@click="selectedPlatform = ''; loadGames()"
|
||||||
|
>全部</van-tag>
|
||||||
|
<van-tag
|
||||||
|
v-for="p in platforms"
|
||||||
|
:key="p"
|
||||||
|
:type="selectedPlatform === p ? 'primary' : 'default'"
|
||||||
|
size="medium"
|
||||||
|
round
|
||||||
|
class="platform-tag"
|
||||||
|
@click="selectedPlatform = p; loadGames()"
|
||||||
|
>{{ p }}</van-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="action-row">
|
||||||
|
<van-button type="primary" size="small" round icon="plus" @click="showAddGame = true">
|
||||||
|
添加游戏
|
||||||
|
</van-button>
|
||||||
|
<van-button size="small" round icon="down" @click="showImport = true">
|
||||||
|
导入
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 游戏列表 -->
|
||||||
|
<div v-if="loading" class="loading-box">
|
||||||
|
<van-loading size="24px">加载中...</van-loading>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="games.length === 0" class="empty">
|
||||||
|
<van-empty :description="searchQuery ? '未找到游戏' : '暂无游戏'" image-size="100">
|
||||||
|
<van-button v-if="!searchQuery" type="primary" size="small" round @click="showAddGame = true">
|
||||||
|
添加第一个游戏
|
||||||
|
</van-button>
|
||||||
|
</van-empty>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="games-grid">
|
||||||
|
<div
|
||||||
|
v-for="game in games"
|
||||||
|
:key="game.id"
|
||||||
|
class="game-card"
|
||||||
|
@click="openGameDetail(game)"
|
||||||
|
>
|
||||||
|
<div class="cover-wrap">
|
||||||
|
<img
|
||||||
|
:src="getGameCoverUrl(game) || '/game-placeholder.svg'"
|
||||||
|
:alt="game.name"
|
||||||
|
class="game-cover"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="canModifyGame(game)"
|
||||||
|
class="delete-btn"
|
||||||
|
@click.stop="handleDeleteGame(game)"
|
||||||
|
>
|
||||||
|
<van-icon name="cross" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="game-info">
|
||||||
|
<div class="game-name">{{ game.name }}</div>
|
||||||
|
<div v-if="game.platform" class="game-platform">{{ game.platform }}</div>
|
||||||
|
<div v-if="game.tags?.length" class="game-tags">
|
||||||
|
<span v-for="tag in game.tags.slice(0, 2)" :key="tag" class="tag">{{ tag }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 群组选择器 -->
|
||||||
|
<van-popup v-model:show="showGroupPicker" position="bottom" round>
|
||||||
|
<van-picker
|
||||||
|
:columns="groupPickerColumns"
|
||||||
|
:default-index="groupOptions.findIndex(g => g.id === selectedGroupId)"
|
||||||
|
@confirm="onGroupConfirm"
|
||||||
|
@cancel="showGroupPicker = false"
|
||||||
|
/>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 详情面板 -->
|
||||||
|
<GameDetailSheetMobile
|
||||||
|
v-model:show="showDetail"
|
||||||
|
:game="selectedGame"
|
||||||
|
:group-id="selectedGroupId"
|
||||||
|
@deleted="handleDetailDeleted"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 添加游戏 -->
|
||||||
|
<AddGameSheetMobile
|
||||||
|
v-model:show="showAddGame"
|
||||||
|
:group-id="selectedGroupId"
|
||||||
|
@saved="handleGameSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 导入游戏 -->
|
||||||
|
<ImportGamesSheetMobile
|
||||||
|
v-model:show="showImport"
|
||||||
|
:group-id="selectedGroupId"
|
||||||
|
@imported="handleImportComplete"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.games-mobile {
|
||||||
|
padding-bottom: 16px;
|
||||||
|
min-height: 60vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-group {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-group-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin: 8px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-selector {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--gg-radius-sm);
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.platform-tag {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row .van-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.games-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card {
|
||||||
|
background: var(--gg-bg-card);
|
||||||
|
border-radius: var(--gg-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--gg-shadow);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-cover {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3/4;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--gg-bg-elevated);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-info {
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--gg-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-platform {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--gg-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 1px 6px;
|
||||||
|
background: rgba(5, 150, 105, 0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--gg-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user