440 lines
14 KiB
Vue
440 lines
14 KiB
Vue
<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>
|