Files
nl-game/src/views/admin/AdminGames.vue
李琦 cd1ab90013 初始化
需要注意的是饥荒不够完善:动作、手持工具、细节交互、系统逻辑
2026-08-15 07:24:34 +08:00

440 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.
<script setup>
// 游戏管理:改价格、上下架、调排序、积分规则(换算比 + 单局上限)、大厅轮播配置
// 支持名称搜索 + 分类/状态筛选(前端过滤),列表虚拟滚动限高
import { ref, computed, onMounted } from 'vue'
import http, { toast } from '../../api/http'
import { useThemeStore } from '../../stores/theme'
import NavIcon from '../../components/NavIcon.vue'
import GameIcon from '../../components/GameIcon.vue'
import VirtualList from '../../components/VirtualList.vue'
const themeStore = useThemeStore()
const list = ref([])
const loading = ref(true)
const loadError = ref('')
const editing = ref(null) // 正在编辑的游戏(副本)
// ---- 搜索与筛选(纯前端过滤,全量数据本来就一次拉回)----
const keyword = ref('')
const catFilter = ref('') // ''=全部分类
const statusFilter = ref(0) // 0=全部 1=上架 2=下架
const categoryNames = { casual: '休闲', puzzle: '益智', action: '动作', shooting: '射击', board: '棋牌', sport: '竞速' }
const engineNames = { canvas: 'Canvas', three: '3D', online: '联机' }
const categories = computed(() => {
const set = new Set(list.value.map((g) => g.category))
return [...set]
})
const filtered = computed(() =>
list.value.filter((g) => {
if (keyword.value && !g.name.toLowerCase().includes(keyword.value.trim().toLowerCase())) return false
if (catFilter.value && g.category !== catFilter.value) return false
if (statusFilter.value && g.status !== statusFilter.value) return false
return true
})
)
// ---- 大厅轮播全局设置(效果样式 + 自动播放间隔,存 site_configs----
const CAROUSEL_STYLES = [
{ code: 'slide', name: '横向滑动', desc: '经典 banner 左右滑' },
{ code: 'fade', name: '淡入淡出', desc: '原地渐隐切换' },
{ code: 'coverflow', name: '封面流', desc: '中间大两侧 3D 倾斜' },
{ code: 'cards', name: '卡片堆叠', desc: '切换时向左飞出' },
]
const carouselStyle = ref('slide')
const carouselInterval = ref(5)
const savingCarousel = ref(false)
async function saveCarousel() {
savingCarousel.value = true
try {
await http.put('/admin/site', {
carousel_style: carouselStyle.value,
carousel_interval: Number(carouselInterval.value),
})
await themeStore.fetchConfig() // 立即拉回配置,大厅下次进入即生效
toast('轮播设置已保存', 'success')
} finally {
savingCarousel.value = false
}
}
async function load() {
loading.value = true
loadError.value = ''
try {
list.value = await http.get('/admin/games')
} catch (e) {
// 请求失败时给出页面内提示(比如后端没启动),而不是只留一张空表
loadError.value = '加载失败,请确认后端服务已启动后重试'
} finally {
loading.value = false
}
}
function openEdit(g) {
editing.value = {
id: g.id, name: g.name, price: g.price, sort: g.sort,
score_per_point: g.score_per_point, max_points_per_play: g.max_points_per_play,
carousel: g.carousel || 0,
}
}
async function saveEdit() {
await http.put(`/admin/games/${editing.value.id}`, {
price: Number(editing.value.price),
sort: Number(editing.value.sort),
score_per_point: Number(editing.value.score_per_point),
max_points_per_play: Number(editing.value.max_points_per_play),
carousel: Number(editing.value.carousel) || 0,
})
toast('已保存', 'success')
editing.value = null
load()
}
// 快捷切换轮播参与状态:开=排到当前最大值+1关=0
async function toggleCarousel(g) {
const value = g.carousel > 0 ? 0 : Math.max(0, ...list.value.map((x) => x.carousel || 0)) + 1
await http.put(`/admin/games/${g.id}`, { carousel: value })
toast(value > 0 ? `${g.name}」已加入大厅轮播` : `${g.name}」已移出轮播`, 'success')
load()
}
// 上/下架切换
async function toggleStatus(g) {
await http.put(`/admin/games/${g.id}`, { status: g.status === 1 ? 2 : 1 })
toast(g.status === 1 ? `已下架「${g.name}` : `已上架「${g.name}`, 'success')
load()
}
onMounted(() => {
load()
// 轮播全局配置来自公开站点配置themeStore 启动时已拉取)
carouselStyle.value = themeStore.carouselStyle
carouselInterval.value = themeStore.carouselInterval
})
</script>
<template>
<div>
<h2 class="page-title">游戏管理</h2>
<!-- 大厅轮播设置效果样式 + 自动播放间隔参与的游戏在下方表格逐个开启 -->
<div class="panel carousel-conf">
<div class="cc-head">
<b class="cc-title"><NavIcon name="carousel" :size="16" /> 大厅轮播</b>
<span class="text-dim cc-tip">在下方表格给游戏开启轮播后大厅顶部会出现轮播卡片点击直接进入游戏</span>
</div>
<div class="cc-row">
<div class="cc-styles">
<button
v-for="s in CAROUSEL_STYLES"
:key="s.code"
class="chip"
:class="{ on: carouselStyle === s.code }"
:title="s.desc"
@click="carouselStyle = s.code"
>
{{ s.name }}
</button>
</div>
<label class="cc-interval">
间隔
<input v-model.number="carouselInterval" type="number" min="2" max="30" class="input" />
</label>
<button class="btn btn-sm" :disabled="savingCarousel" @click="saveCarousel">
{{ savingCarousel ? '保存中' : '保存轮播设置' }}
</button>
</div>
</div>
<!-- 搜索与筛选 -->
<div class="toolbar">
<input v-model="keyword" class="input" placeholder="搜索游戏名称" style="max-width: 220px" />
<div class="filter-tabs">
<button class="chip" :class="{ on: catFilter === '' }" @click="catFilter = ''">全部分类</button>
<button v-for="c in categories" :key="c" class="chip" :class="{ on: catFilter === c }" @click="catFilter = c">
{{ categoryNames[c] || c }}
</button>
</div>
<div class="filter-tabs">
<button class="chip" :class="{ on: statusFilter === 0 }" @click="statusFilter = 0">全部</button>
<button class="chip" :class="{ on: statusFilter === 1 }" @click="statusFilter = 1">上架</button>
<button class="chip" :class="{ on: statusFilter === 2 }" @click="statusFilter = 2">下架</button>
</div>
<span class="text-dim count">{{ filtered.length }} / {{ list.length }} </span>
</div>
<div class="panel list-wrap">
<div v-if="loading" class="state-tip text-dim">加载中</div>
<div v-else-if="loadError" class="state-tip">
<span class="st-ban">{{ loadError }}</span>
<button class="btn btn-sm" style="margin-left: 10px" @click="load">重新加载</button>
</div>
<div v-else-if="!list.length" class="state-tip text-dim">
没有游戏数据请确认已执行 sql/init.sql 初始化数据库
</div>
<div v-else class="hscroll">
<div class="tmin">
<div class="grow ghead text-dim">
<span>排序</span><span>游戏</span><span>分类</span><span>引擎</span><span>价格</span>
<span>积分规则</span><span>游玩次数</span><span>轮播</span><span>状态</span><span>操作</span>
</div>
<VirtualList :items="filtered" :item-height="46" :max-height="520">
<template #default="{ item: g }">
<div class="grow">
<span class="text-dim">{{ g.sort }}</span>
<span class="c-game">
<GameIcon :code="g.code" :icon="g.icon" :size="20" />
<b>{{ g.name }}</b>
</span>
<span class="text-dim">{{ categoryNames[g.category] || g.category }}</span>
<span class="text-dim">{{ engineNames[g.engine] || g.engine }}</span>
<span>
<span v-if="g.price === 0" class="free">免费</span>
<span v-else class="amount"><NavIcon name="coin" :size="13" /> {{ g.price }}</span>
</span>
<span class="text-dim rule">
<template v-if="g.score_per_point > 0">{{ g.score_per_point }}分兑1积分 · 上限{{ g.max_points_per_play }}</template>
<template v-else>不兑积分</template>
</span>
<span class="text-dim">{{ g.play_count }}</span>
<span>
<button
class="car-toggle"
:class="{ on: g.carousel > 0 }"
:title="g.carousel > 0 ? `轮播中(顺序 ${g.carousel}),点击移出` : '点击加入大厅轮播'"
@click="toggleCarousel(g)"
>
<template v-if="g.carousel > 0"><NavIcon name="carousel" :size="13" /> #{{ g.carousel }}</template>
<template v-else></template>
</button>
</span>
<span :class="g.status === 1 ? 'st-ok' : 'st-ban'">{{ g.status === 1 ? '上架' : '下架' }}</span>
<span class="ops">
<button class="btn btn-ghost btn-sm" @click="openEdit(g)">编辑</button>
<button class="btn btn-sm" :class="g.status === 1 ? 'btn-danger' : ''" @click="toggleStatus(g)">
{{ g.status === 1 ? '下架' : '上架' }}
</button>
</span>
</div>
</template>
<template #empty><span class="text-dim">没有符合筛选条件的游戏</span></template>
</VirtualList>
</div>
</div>
</div>
<!-- 编辑弹窗 -->
<div v-if="editing" class="modal-mask" @click.self="editing = null">
<div class="modal panel">
<h3 style="margin-bottom: 14px">编辑{{ editing.name }}</h3>
<label class="lbl">价格积分0 为免费</label>
<input v-model.number="editing.price" type="number" min="0" class="input" style="margin-bottom: 10px" />
<label class="lbl">积分换算多少游戏得分兑 1 积分0 = 该游戏不兑积分</label>
<input v-model.number="editing.score_per_point" type="number" min="0" class="input" style="margin-bottom: 10px" />
<label class="lbl">单局积分上限防刷分</label>
<input v-model.number="editing.max_points_per_play" type="number" min="0" class="input" style="margin-bottom: 10px" />
<label class="lbl">排序越小越靠前</label>
<input v-model.number="editing.sort" type="number" class="input" style="margin-bottom: 10px" />
<label class="lbl">大厅轮播顺序0 = 不参与轮播数字越小越靠前</label>
<input v-model.number="editing.carousel" type="number" min="0" class="input" style="margin-bottom: 14px" />
<div style="display: flex; gap: 10px">
<button class="btn" style="flex: 1" @click="saveEdit">保存</button>
<button class="btn btn-ghost" @click="editing = null">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.carousel-conf {
margin-bottom: 14px;
padding: 14px 16px;
}
.cc-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.cc-title {
display: inline-flex;
align-items: center;
gap: 6px;
}
.cc-title svg {
color: var(--primary-2);
}
.cc-tip {
font-size: 12px;
}
.cc-row {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.cc-styles {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.chip {
padding: 6px 14px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.chip:hover {
color: var(--text);
border-color: var(--primary);
}
.chip.on {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
border-color: transparent;
}
.cc-interval {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-dim);
}
.cc-interval .input {
width: 64px;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 12px;
align-items: center;
flex-wrap: wrap;
}
.filter-tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.count {
font-size: 12px;
margin-left: auto;
white-space: nowrap;
}
.list-wrap {
padding: 6px 14px 12px;
}
.hscroll {
overflow-x: auto;
}
.tmin {
min-width: 1000px;
}
.grow {
display: grid;
grid-template-columns: 46px 1.6fr 62px 66px 90px 1.6fr 72px 84px 48px 138px;
gap: 8px;
align-items: center;
height: 100%;
border-bottom: 1px dashed var(--border);
font-size: 13px;
padding: 0 2px;
}
.ghead {
height: 38px;
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.c-game {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.c-game b {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.state-tip {
padding: 40px 0;
text-align: center;
font-size: 14px;
}
.rule {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.free {
color: #4ade80;
}
.amount {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--primary-2);
}
.st-ok {
color: #4ade80;
}
.st-ban {
color: #ff6b6b;
}
.car-toggle {
display: inline-flex;
align-items: center;
gap: 3px;
border: 1px solid var(--border);
background: transparent;
color: var(--text-dim);
border-radius: 999px;
padding: 3px 10px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.car-toggle:hover {
border-color: var(--primary);
color: var(--primary);
}
.car-toggle.on {
background: color-mix(in srgb, var(--primary) 18%, transparent);
border-color: var(--primary);
color: var(--primary);
font-weight: 700;
}
.ops {
display: flex;
gap: 6px;
}
.ops .btn {
white-space: nowrap;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
width: min(360px, 90vw);
padding: 22px;
}
.lbl {
display: block;
font-size: 12px;
color: var(--text-dim);
margin-bottom: 6px;
}
</style>