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,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>