初始化

需要注意的是饥荒不够完善:动作、手持工具、细节交互、系统逻辑
This commit is contained in:
李琦
2026-08-15 07:24:34 +08:00
parent aa37fc4119
commit cd1ab90013
150 changed files with 40801 additions and 271 deletions

271
src/views/Battle.vue Normal file
View File

@@ -0,0 +1,271 @@
<script setup>
// 联机对战大厅:创建房间(好友联机 / AI 对战只选难度AI 模型由后台统一配置)或凭邀请码加入
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from '../api/http'
import { useBattleStore } from '../stores/battle'
const route = useRoute()
const router = useRouter()
const battle = useBattleStore()
const games = [
{ code: 'doudizhu', name: '斗地主', icon: '🎴', desc: '3 人经典斗地主,叫地主抢春天' },
{ code: 'chess', name: '中国象棋', icon: '🐘', desc: '楚河汉界,红黑对弈' },
{ code: 'monopoly', name: '大富翁', icon: '🎩', desc: '4 人掷骰买地收租,破产出局' },
{ code: 'ludo', name: '飞行棋', icon: '✈️', desc: '4 人掷 6 起飞,击落对手抢先到达' },
{ code: 'billiards', name: '2D台球', icon: '🎱', desc: '双人八球对决,清台后打黑八' },
]
const validCodes = games.map((g) => g.code)
const game = ref(validCodes.includes(route.query.game) ? route.query.game : 'doudizhu')
const mode = ref('ai') // ai / pvp
const aiDifficulty = ref('medium')
const joinCode = ref('')
const busy = ref(false)
const difficulties = [
{ code: 'easy', name: '简单', icon: '🌱' },
{ code: 'medium', name: '中等', icon: '⚔️' },
{ code: 'hard', name: '困难', icon: '🔥' },
]
// 创建房间
async function create() {
busy.value = true
try {
await battle.connect()
battle.createRoom({
game: game.value,
mode: mode.value,
// AI 模型由后台统一配置,这里只传难度
ai_difficulty: aiDifficulty.value,
})
} catch (e) {
toast('连接对战服务失败,请确认后端已启动')
} finally {
busy.value = false
}
}
// 加入房间
async function join() {
const code = joinCode.value.trim().toUpperCase()
if (code.length !== 6) {
toast('请输入 6 位邀请码')
return
}
busy.value = true
try {
await battle.connect()
battle.joinRoom(code)
} catch (e) {
toast('连接对战服务失败,请确认后端已启动')
} finally {
busy.value = false
}
}
// 进入房间成功后跳转房间页
watch(
() => battle.roomState,
(state) => {
if (state) router.push('/battle/room')
}
)
// 已在房间中(例如刷新后重连)直接进入
onMounted(async () => {
try {
await battle.connect()
if (battle.roomState) router.push('/battle/room')
} catch (e) {
// 后端未启动时静默,等用户点击时再提示
}
})
</script>
<template>
<div>
<h2 class="page-title">联机对战</h2>
<div class="battle-grid">
<!-- 创建房间 -->
<div class="panel">
<h3 class="sec-title">🏠 创建房间</h3>
<p class="sec-sub text-dim">选择游戏与模式创建后把邀请码发给好友</p>
<div class="game-pick">
<button
v-for="g in games"
:key="g.code"
class="game-opt"
:class="{ on: game === g.code }"
@click="game = g.code"
>
<span class="g-icon">{{ g.icon }}</span>
<b>{{ g.name }}</b>
<span class="g-desc text-dim">{{ g.desc }}</span>
</button>
</div>
<div class="row">
<span class="label">对战模式</span>
<div class="seg">
<button class="seg-btn" :class="{ on: mode === 'ai' }" @click="mode = 'ai'">🤖 AI 对战</button>
<button class="seg-btn" :class="{ on: mode === 'pvp' }" @click="mode = 'pvp'">👥 好友联机</button>
</div>
</div>
<p v-if="mode === 'pvp'" class="text-dim" style="font-size: 12px; margin-bottom: 12px">
好友联机模式下开局时空位也会自动由 AI 补位
</p>
<div class="row">
<span class="label">AI 难度</span>
<div class="seg">
<button
v-for="d in difficulties"
:key="d.code"
class="seg-btn"
:class="{ on: aiDifficulty === d.code }"
@click="aiDifficulty = d.code"
>
{{ d.icon }} {{ d.name }}
</button>
</div>
</div>
<button class="btn btn-lg create-btn" :disabled="busy" @click="create">
{{ busy ? '创建中' : '创建房间' }}
</button>
</div>
<!-- 加入房间 -->
<div class="side">
<div class="panel">
<h3 class="sec-title">🎟 加入房间</h3>
<p class="sec-sub text-dim">输入好友分享的 6 位邀请码</p>
<input
v-model="joinCode"
class="input code-input"
maxlength="6"
placeholder="ABC123"
@keyup.enter="join"
/>
<button class="btn btn-accent" style="width: 100%" :disabled="busy" @click="join">加入房间</button>
</div>
<div class="panel tips">
<h3 class="sec-title">💡 对战说明</h3>
<ul class="tip-list text-dim">
<li>获胜可得平台积分奖励后台可配</li>
<li>斗地主 3 · 象棋/台球 2 · 大富翁/飞行棋 4 </li>
<li>空位由 AI 自动补位随时可开</li>
<li>AI 模型由平台统一配置后台AI 设置你只需选择难度</li>
<li>断线 10 分钟内可重连回到对局</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.battle-grid {
display: grid;
grid-template-columns: 1fr 320px;
gap: 16px;
align-items: start;
}
.sec-title {
font-size: 15px;
margin-bottom: 4px;
}
.sec-sub {
font-size: 12px;
margin-bottom: 14px;
}
.game-pick {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 16px;
}
.game-opt {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 10px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
transition: all 0.15s;
}
.game-opt.on {
border-color: var(--primary);
box-shadow: var(--glow);
}
.g-icon {
font-size: 34px;
}
.g-desc {
font-size: 11px;
}
.row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.label {
width: 64px;
font-size: 13px;
color: var(--text-dim);
padding-top: 8px;
}
.seg {
display: flex;
gap: 6px;
flex: 1;
}
.seg-btn {
flex: 1;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.seg-btn.on {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
border-color: transparent;
}
.create-btn {
width: 100%;
margin-top: 6px;
}
.side {
display: flex;
flex-direction: column;
gap: 14px;
}
.code-input {
text-align: center;
font-size: 24px;
letter-spacing: 8px;
font-weight: 800;
text-transform: uppercase;
margin-bottom: 12px;
}
.tip-list {
padding-left: 18px;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
@media (max-width: 900px) {
.battle-grid {
grid-template-columns: 1fr;
}
}
</style>

618
src/views/BattleRoom.vue Normal file
View File

@@ -0,0 +1,618 @@
<script setup>
// 对战房间页:等待区(邀请码/准备/开始)、游戏区(斗地主/象棋)、聊天区与结算弹窗
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import http, { toast } from '../api/http'
import { useBattleStore } from '../stores/battle'
import { useUserStore } from '../stores/user'
import DdzTable from '../components/battle/DdzTable.vue'
import ChessBoard from '../components/battle/ChessBoard.vue'
import MonopolyBoard from '../components/battle/MonopolyBoard.vue'
import LudoBoard from '../components/battle/LudoBoard.vue'
import BilliardsTable from '../components/battle/BilliardsTable.vue'
import PixelAvatar from '../components/PixelAvatar.vue'
const router = useRouter()
const battle = useBattleStore()
const userStore = useUserStore()
const chatInput = ref('')
const chatBox = ref(null)
const st = computed(() => battle.roomState)
const isHost = computed(() => st.value?.host_id === userStore.user?.id)
const mySeatInfo = computed(() => st.value?.seats?.[st.value?.my_seat] || {})
const gameNames = {
doudizhu: '斗地主', chess: '中国象棋',
monopoly: '大富翁', ludo: '飞行棋', billiards: '2D台球',
}
const gameName = computed(() => gameNames[st.value?.game] || st.value?.game)
// 按游戏类型选择对局界面组件
const gameComps = {
doudizhu: DdzTable, chess: ChessBoard,
monopoly: MonopolyBoard, ludo: LudoBoard, billiards: BilliardsTable,
}
const gameComp = computed(() => gameComps[st.value?.game] || DdzTable)
const providerName = computed(() => {
const map = { rule: '本地规则 AI', spark: '星火 Lite', deepseek: 'DeepSeek' }
return map[st.value?.ai_provider] || st.value?.ai_provider
})
const diffName = computed(() => {
const map = { easy: '简单', medium: '中等', hard: '困难' }
return map[st.value?.ai_difficulty] || st.value?.ai_difficulty
})
// 真人数是否满足开局斗地主至少1人+AI补位开始按钮房主可点
const canStart = computed(() => st.value?.status === 'waiting' && isHost.value)
// 房间没了(离开/解散)→ 回对战大厅
watch(st, (v) => {
if (!v) router.replace('/battle')
})
// 新聊天自动滚到底部
watch(
() => battle.chatLog.length,
async () => {
await nextTick()
if (chatBox.value) chatBox.value.scrollTop = chatBox.value.scrollHeight
}
)
function copyCode() {
navigator.clipboard?.writeText(st.value.code)
toast('邀请码已复制,快发给好友吧', 'success')
}
function sendChat() {
const text = chatInput.value.trim()
if (!text) return
battle.sendChat(text)
chatInput.value = ''
}
function leave() {
battle.leaveRoom()
router.replace('/battle')
}
// ---- 房主邀请好友:在线推弹窗、离线落私聊;同一人 60 秒冷却 ----
const INVITE_CD_MS = 60_000
const friendList = ref([])
const inviteUntil = ref({}) // user_id -> 可再次邀请的时间戳(ms)
const nowTick = ref(Date.now())
let inviteTickTimer = null
function startInviteTick() {
if (inviteTickTimer) return
inviteTickTimer = setInterval(() => {
nowTick.value = Date.now()
const next = { ...inviteUntil.value }
let any = false
for (const k of Object.keys(next)) {
if (next[k] <= nowTick.value) delete next[k]
else any = true
}
inviteUntil.value = next
if (!any) {
clearInterval(inviteTickTimer)
inviteTickTimer = null
}
}, 1000)
}
function inviteLeft(uid) {
const left = Math.ceil(((inviteUntil.value[uid] || 0) - nowTick.value) / 1000)
return left > 0 ? left : 0
}
async function loadFriends() {
try {
friendList.value = await http.get('/friends')
} catch (e) { /* 拦截器已提示 */ }
}
function inviteFriend(uid) {
const left = inviteLeft(uid)
if (left > 0) {
toast(`${left} 秒后再邀请该好友`, 'info')
return
}
battle.send('invite', { user_id: uid })
inviteUntil.value = { ...inviteUntil.value, [uid]: Date.now() + INVITE_CD_MS }
startInviteTick()
toast('邀请已发送', 'success')
}
// 成为房主且等待中时拉好友列表
watch(
() => isHost.value && st.value?.status === 'waiting',
(v) => { if (v) loadFriends() },
{ immediate: true }
)
function fmtTime(ts) {
const d = new Date(ts * 1000)
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
// 从全局邀请弹窗跳转而来:连 ws 自动加入房间
async function tryPendingJoin() {
const pendingCode = sessionStorage.getItem('pending_join_code')
if (!pendingCode) return false
sessionStorage.removeItem('pending_join_code')
sessionStorage.removeItem('pending_join_game')
if (battle.roomState) return true
try {
await battle.connect()
battle.joinRoom(pendingCode)
const ok = await new Promise((resolve) => {
if (battle.roomState) return resolve(true)
let stop = null
const t = setTimeout(() => {
stop?.()
resolve(false)
}, 3000)
stop = watch(
() => battle.roomState,
(v) => {
if (v) {
clearTimeout(t)
stop?.()
resolve(true)
}
}
)
})
if (!ok) {
toast('加入房间失败或邀请码已失效')
router.replace('/battle')
}
return true
} catch {
toast('联机服务连接失败,请稍后再试')
router.replace('/battle')
return true
}
}
onMounted(async () => {
window.addEventListener('nlg:pending-join', tryPendingJoin)
const handled = await tryPendingJoin()
if (handled) return
// 刷新后直接进入本页但没有房间状态 → 回大厅
if (!battle.roomState) router.replace('/battle')
})
onBeforeUnmount(() => {
window.removeEventListener('nlg:pending-join', tryPendingJoin)
clearInterval(inviteTickTimer)
})
</script>
<template>
<div v-if="st" class="room">
<!-- 顶栏 -->
<div class="room-bar panel">
<div class="code-box" @click="copyCode" title="点击复制邀请码">
邀请码 <b>{{ st.code }}</b> 📋
</div>
<span class="tag">{{ gameName }}</span>
<span class="tag">{{ st.mode === 'ai' ? 'AI 对战' : '好友联机' }}</span>
<span class="tag text-dim">🤖 {{ providerName }} · {{ diffName }}</span>
<button class="btn btn-danger btn-sm" style="margin-left: auto" @click="leave">退出房间</button>
</div>
<div class="room-body">
<!-- 游戏主区域 -->
<div class="main-zone">
<!-- 等待开局 -->
<div v-if="st.status === 'waiting'" class="panel waiting">
<h3>等待开局</h3>
<p class="text-dim" style="font-size: 13px; margin: 6px 0 18px">
分享邀请码给好友或直接开始空位自动由 AI 补位
</p>
<div class="seats">
<div v-for="s in st.seats" :key="s.index" class="seat" :class="{ empty: !s.occupied }">
<span class="seat-avatar">
<template v-if="s.occupied">
<PixelAvatar :code="s.avatar || (s.is_ai ? '🤖' : 'px:01')" :size="34" />
</template>
<template v-else></template>
</span>
<b>{{ s.occupied ? s.name : '等待加入' }}</b>
<span v-if="s.occupied && s.user_id === st.host_id" class="host-tag">房主</span>
<span v-else-if="s.is_ai" class="ai-tag">AI</span>
<span v-if="s.occupied && !s.is_ai" class="ready-tag" :class="{ ok: s.ready }">
{{ s.ready ? '已准备' : '未准备' }}
</span>
</div>
</div>
<div class="wait-actions">
<button
v-if="!isHost"
class="btn"
:class="{ 'btn-ghost': mySeatInfo.ready }"
@click="battle.setReady(!mySeatInfo.ready)"
>
{{ mySeatInfo.ready ? '取消准备' : '准备' }}
</button>
<button v-if="canStart" class="btn btn-lg" @click="battle.startGame()">开始游戏 🚀</button>
</div>
<!-- 房主邀请好友在线推弹窗离线落私聊 -->
<div v-if="isHost && friendList.length" class="friend-invite">
<div class="fi-head">邀请好友{{ friendList.length }}· 同一人 1 分钟内不可重复</div>
<div class="fi-list">
<button
v-for="f in friendList"
:key="f.user_id"
type="button"
class="fi-item"
:class="{ off: !f.online, sent: inviteLeft(f.user_id) > 0 }"
:disabled="inviteLeft(f.user_id) > 0"
:title="inviteLeft(f.user_id) > 0
? `${inviteLeft(f.user_id)} 秒后可再邀请`
: (f.online ? '点击发送弹窗邀请' : '好友离线:将发送私聊邀请')"
@click="inviteFriend(f.user_id)"
>
<span class="fi-avatar"><PixelAvatar :code="f.avatar || 'px:01'" :size="22" /></span>
<span class="fi-name">{{ f.nickname }}</span>
<span class="fi-dot" :class="{ on: f.online }"></span>
<span class="fi-act">{{
inviteLeft(f.user_id) > 0
? `${inviteLeft(f.user_id)}s`
: (f.online ? '邀请' : '留言')
}}</span>
</button>
</div>
</div>
<!-- 上局战报 -->
<div v-if="st.last_result" class="last-result text-dim">
上局{{ st.last_result.desc }}
</div>
</div>
<!-- 对局中按游戏类型动态挂载对应界面 -->
<component :is="gameComp" v-else />
</div>
<!-- 聊天区 -->
<div class="chat panel">
<h3 class="chat-title">💬 聊天室</h3>
<div ref="chatBox" class="chat-log">
<div v-for="(m, i) in battle.chatLog" :key="i" class="chat-msg" :class="{ sys: m.seat === -1 }">
<template v-if="m.seat === -1">
<span class="sys-text">📢 {{ m.text }}</span>
</template>
<template v-else>
<span class="chat-name" :class="{ ai: m.is_ai }">{{ m.is_ai ? '🤖' : '' }}{{ m.name }}</span>
<span class="chat-time text-dim">{{ fmtTime(m.ts) }}</span>
<div class="chat-text">{{ m.text }}</div>
</template>
</div>
<div v-if="!battle.chatLog.length" class="text-dim" style="font-size: 12px; text-align: center; padding: 20px 0">
暂无消息打个招呼吧
</div>
</div>
<div class="chat-input">
<input v-model="chatInput" class="input" placeholder="说点什么…" maxlength="100" @keyup.enter="sendChat" />
<button class="btn btn-sm" @click="sendChat">发送</button>
</div>
</div>
</div>
<!-- 结算弹窗 -->
<div v-if="battle.battleEnd" class="modal-mask" @click.self="battle.battleEnd = null">
<div class="modal panel">
<h2 class="end-title">
{{ battle.battleEnd.winners?.includes(st.my_seat) ? '🏆 胜利!' : '💪 惜败' }}
</h2>
<p class="end-desc">{{ battle.battleEnd.desc }}</p>
<!-- rewards 以座位号为键换算为玩家昵称展示 -->
<div v-if="battle.battleEnd.rewards" class="rewards">
<div v-for="(pts, seatIdx) in battle.battleEnd.rewards" :key="seatIdx" class="reward-row">
<span>{{ st.seats?.[Number(seatIdx)]?.name || `玩家${seatIdx}` }}</span>
<b :class="pts >= 0 ? 'win' : 'lose'">{{ pts >= 0 ? '+' : '' }}{{ pts }} 积分</b>
</div>
</div>
<p class="text-dim" style="font-size: 12px">用时 {{ battle.battleEnd.duration }} </p>
<button class="btn" style="width: 100%; margin-top: 14px" @click="battle.battleEnd = null">
继续 · 回到房间
</button>
</div>
</div>
</div>
</template>
<style scoped>
.room {
display: flex;
flex-direction: column;
gap: 14px;
}
.room-bar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
flex-wrap: wrap;
}
.code-box {
cursor: pointer;
background: var(--bg-card);
border: 1px dashed var(--primary);
padding: 6px 14px;
border-radius: 10px;
font-size: 13px;
}
.code-box b {
color: var(--primary);
font-size: 16px;
letter-spacing: 2px;
margin: 0 4px;
}
.tag {
background: var(--bg-card);
border: 1px solid var(--border);
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
}
.room-body {
display: grid;
grid-template-columns: 1fr 280px;
gap: 14px;
align-items: start;
}
.waiting {
text-align: center;
padding: 30px 20px;
}
.seats {
display: flex;
justify-content: center;
gap: 14px;
margin: 10px 0 22px;
flex-wrap: wrap;
}
.seat {
width: 130px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.seat.empty {
border-style: dashed;
opacity: 0.65;
}
.seat-avatar {
font-size: 34px;
}
.seat b {
font-size: 13px;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.host-tag {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
font-size: 10px;
padding: 1px 8px;
border-radius: 999px;
}
.ai-tag {
background: rgba(120, 90, 255, 0.25);
color: var(--accent);
font-size: 10px;
padding: 1px 8px;
border-radius: 999px;
}
.ready-tag {
font-size: 11px;
color: var(--text-dim);
}
.ready-tag.ok {
color: #4ade80;
}
.wait-actions {
display: flex;
justify-content: center;
gap: 12px;
}
/* 房主邀请好友栏 */
.friend-invite {
width: 100%;
max-width: 480px;
margin: 18px auto 0;
max-height: 240px;
overflow: auto;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
text-align: left;
box-sizing: border-box;
}
.fi-head {
font-size: 12px;
font-weight: 700;
color: var(--text-dim);
}
.fi-list {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
}
.fi-item {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 10px auto;
align-items: center;
column-gap: 10px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 13px;
transition: background 0.15s, border-color 0.15s;
width: 100%;
box-sizing: border-box;
text-align: left;
}
.fi-item:hover:not(:disabled) {
background: var(--bg-panel);
border-color: var(--primary);
}
.fi-item.off {
opacity: 0.65;
}
.fi-item.sent {
cursor: default;
opacity: 0.75;
}
.fi-item:disabled {
cursor: not-allowed;
}
.fi-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
}
.fi-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
font-weight: 600;
}
.fi-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #888;
justify-self: center;
}
.fi-dot.on {
background: #67d18f;
box-shadow: 0 0 6px rgba(103, 209, 143, 0.6);
}
.fi-act {
font-size: 12px;
color: var(--primary);
white-space: nowrap;
min-width: 2.5em;
text-align: right;
}
.last-result {
margin-top: 18px;
font-size: 12px;
}
.chat {
display: flex;
flex-direction: column;
height: 560px;
padding: 14px;
}
.chat-title {
font-size: 14px;
margin-bottom: 10px;
}
.chat-log {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
}
.chat-msg.sys {
text-align: center;
}
.sys-text {
font-size: 11px;
color: var(--text-dim);
background: var(--bg-card);
padding: 3px 10px;
border-radius: 999px;
}
.chat-name {
font-size: 12px;
font-weight: 700;
color: var(--primary-2);
}
.chat-name.ai {
color: var(--accent);
}
.chat-time {
font-size: 10px;
margin-left: 6px;
}
.chat-text {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 4px 12px 12px 12px;
padding: 6px 10px;
font-size: 13px;
margin-top: 3px;
word-break: break-all;
}
.chat-input {
display: flex;
gap: 8px;
margin-top: 10px;
}
.chat-input .input {
flex: 1;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
backdrop-filter: blur(3px);
}
.modal {
width: min(380px, 90vw);
text-align: center;
padding: 28px 24px;
}
.end-title {
font-size: 26px;
margin-bottom: 8px;
}
.end-desc {
font-size: 14px;
margin-bottom: 16px;
}
.rewards {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.reward-row {
display: flex;
justify-content: space-between;
background: var(--bg-card);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
}
.reward-row .win {
color: #4ade80;
}
.reward-row .lose {
color: #ff6b6b;
}
@media (max-width: 900px) {
.room-body {
grid-template-columns: 1fr;
}
.chat {
height: 300px;
}
}
</style>

484
src/views/Chat.vue Normal file
View File

@@ -0,0 +1,484 @@
<script setup>
// 聊天室:会话列表 + 聊天详情 + 好友管理(申请处理/删除)+ 搜索加好友
// 悬浮小窗是它的精简版,两边共用同一个 chat store状态互通
import { ref, watch, nextTick, onMounted } from 'vue'
import { useChatStore } from '../stores/chat'
import PixelAvatar from '../components/PixelAvatar.vue'
const chat = useChatStore()
const tab = ref('conv') // conv=会话 friend=好友 add=添加好友
const draft = ref('')
const msgBox = ref(null)
const keyword = ref('')
const searching = ref(false)
const results = ref([])
const searched = ref(false)
onMounted(() => {
chat.init()
chat.loadConversations().catch(() => {})
chat.loadFriends().catch(() => {})
chat.refreshRequests()
// 从悬浮小窗「前往聊天室」带过来的会话继续展示
if (chat.activePeer) chat.openChat(chat.activePeer)
})
async function pick(peer) {
await chat.openChat(peer)
}
async function send() {
const text = draft.value.trim()
if (!text) return
draft.value = ''
await chat.sendMsg(text)
}
async function doSearch() {
const kw = keyword.value.trim()
if (!kw) return
searching.value = true
try {
results.value = await chat.searchUsers(kw)
searched.value = true
} finally {
searching.value = false
}
}
// 发申请后就地更新按钮状态
async function addFriend(item) {
const rel = await chat.requestFriend(item.user_id)
item.relation = rel === 'friend' ? 'friend' : 'pending_out'
}
async function removeFriend(f) {
if (!confirm(`确定删除好友「${f.nickname}」吗?聊天记录会保留。`)) return
await chat.removeFriend(f.user_id)
if (chat.activePeer?.user_id === f.user_id) chat.activePeer = null
}
// 新消息自动滚底
watch(
() => chat.messages.length,
async () => {
await nextTick()
if (msgBox.value) msgBox.value.scrollTop = msgBox.value.scrollHeight
}
)
function fmtTime(ts) {
if (!ts) return ''
const d = new Date(ts * 1000)
const now = new Date()
const hm = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
if (d.toDateString() === now.toDateString()) return hm
return `${d.getMonth() + 1}/${d.getDate()} ${hm}`
}
const relationText = { friend: '已是好友', pending_out: '等待验证', pending_in: '待你同意' }
</script>
<template>
<div class="chat-page">
<h1 class="page-title">💬 聊天室</h1>
<div class="chat-layout panel">
<!-- 左侧会话/好友/添加 -->
<aside class="side">
<div class="side-tabs">
<button class="stab" :class="{ on: tab === 'conv' }" @click="tab = 'conv'">
会话<em v-if="chat.unread" class="badge">{{ chat.unread > 99 ? '99+' : chat.unread }}</em>
</button>
<button class="stab" :class="{ on: tab === 'friend' }" @click="tab = 'friend'">
好友<em v-if="chat.reqBadge" class="badge">{{ chat.reqBadge }}</em>
</button>
<button class="stab" :class="{ on: tab === 'add' }" @click="tab = 'add'"> 添加</button>
</div>
<!-- 会话列表 -->
<div v-if="tab === 'conv'" class="side-list">
<div v-if="!chat.conversations.length" class="empty text-dim">
暂无会话<br />好友标签找人开聊
</div>
<div
v-for="cv in chat.conversations"
:key="cv.peer.user_id"
class="row"
:class="{ cur: chat.activePeer?.user_id === cv.peer.user_id }"
@click="pick(cv.peer)"
>
<span class="avatar"><PixelAvatar :code="cv.peer.avatar || 'px:01'" :size="34" /><i v-if="cv.peer.online" class="dot"></i></span>
<span class="mid">
<b>{{ cv.peer.nickname }}</b>
<small class="text-dim">{{ cv.from_me ? '我:' : '' }}{{ cv.last_msg }}</small>
</span>
<span class="side-info">
<small class="text-dim">{{ fmtTime(cv.last_at) }}</small>
<em v-if="cv.unread" class="badge">{{ cv.unread > 99 ? '99+' : cv.unread }}</em>
</span>
</div>
</div>
<!-- 好友管理 -->
<div v-else-if="tab === 'friend'" class="side-list">
<template v-if="chat.requests.received.length">
<div class="group-title">收到的申请</div>
<div v-for="r in chat.requests.received" :key="r.id" class="row static">
<span class="avatar"><PixelAvatar :code="r.user.avatar || 'px:01'" :size="34" /></span>
<span class="mid">
<b>{{ r.user.nickname }}</b>
<small class="text-dim">@{{ r.user.username }} · {{ fmtTime(r.at) }}</small>
</span>
<span class="ops">
<button class="btn btn-sm" @click="chat.respondFriend(r.id, true)">同意</button>
<button class="btn btn-sm btn-ghost" @click="chat.respondFriend(r.id, false)">拒绝</button>
</span>
</div>
</template>
<template v-if="chat.requests.sent.length">
<div class="group-title">我发出的申请</div>
<div v-for="r in chat.requests.sent" :key="r.id" class="row static">
<span class="avatar"><PixelAvatar :code="r.user.avatar || 'px:01'" :size="34" /></span>
<span class="mid">
<b>{{ r.user.nickname }}</b>
<small class="text-dim">等待对方同意</small>
</span>
</div>
</template>
<div class="group-title">我的好友{{ chat.friends.length }}</div>
<div v-if="!chat.friends.length" class="empty text-dim">
还没有好友<br />添加标签搜索用户
</div>
<div v-for="f in chat.friends" :key="f.user_id" class="row static">
<span class="avatar"><PixelAvatar :code="f.avatar || 'px:01'" :size="34" /><i v-if="f.online" class="dot"></i></span>
<span class="mid">
<b>{{ f.nickname }}</b>
<small class="text-dim">{{ f.online ? '🟢 在线' : '离线' }} · @{{ f.username }}</small>
</span>
<span class="ops">
<button class="btn btn-sm" @click="pick(f)">发消息</button>
<button class="btn btn-sm btn-ghost" @click="removeFriend(f)">删除</button>
</span>
</div>
</div>
<!-- 添加好友 -->
<div v-else class="side-list">
<div class="search-box">
<input
v-model="keyword"
class="input"
placeholder="搜索用户名或昵称"
@keyup.enter="doSearch"
/>
<button class="btn btn-sm" :disabled="searching" @click="doSearch">
{{ searching ? '搜索中' : '搜索' }}
</button>
</div>
<div v-if="searched && !results.length" class="empty text-dim">没有找到相关用户</div>
<div v-for="u in results" :key="u.user_id" class="row static">
<span class="avatar"><PixelAvatar :code="u.avatar || 'px:01'" :size="34" /><i v-if="u.online" class="dot"></i></span>
<span class="mid">
<b>{{ u.nickname }}</b>
<small class="text-dim">@{{ u.username }}</small>
</span>
<span class="ops">
<button v-if="u.relation === 'none'" class="btn btn-sm" @click="addFriend(u)">加好友</button>
<small v-else class="text-dim">{{ relationText[u.relation] }}</small>
</span>
</div>
</div>
</aside>
<!-- 右侧聊天窗口 -->
<section class="main">
<template v-if="chat.activePeer">
<div class="chat-head">
<span class="avatar sm"><PixelAvatar :code="chat.activePeer.avatar || 'px:01'" :size="26" /></span>
<b>{{ chat.activePeer.nickname }}</b>
<small class="text-dim">{{ chat.activePeer.online ? '🟢 在线' : '离线' }}</small>
</div>
<div ref="msgBox" class="msgs">
<button v-if="chat.hasMore" class="earlier" @click="chat.loadEarlier()">加载更早的消息</button>
<div
v-for="m in chat.messages"
:key="m.id"
class="bubble-row"
:class="{ mine: m.from_id !== chat.activePeer.user_id }"
>
<span v-if="m.from_id === chat.activePeer.user_id" class="avatar sm">
<PixelAvatar :code="chat.activePeer.avatar || 'px:01'" :size="26" />
</span>
<div class="bubble">
{{ m.content }}
<small class="stamp">{{ fmtTime(m.created_at) }}</small>
</div>
</div>
<div v-if="!chat.messages.length" class="empty text-dim">打个招呼开始聊天吧 👋</div>
</div>
<div class="input-row">
<input
v-model="draft"
class="input"
maxlength="500"
placeholder="输入消息,回车发送(最多 500 字)"
@keyup.enter="send"
/>
<button class="btn" @click="send">发送</button>
</div>
</template>
<div v-else class="placeholder text-dim">
<div class="ph-icon">💬</div>
从左侧选择会话或好友开始聊天<br />
<small>游戏中和大厅里也可以用右下角的聊天小窗随时回消息</small>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.page-title {
font-size: 22px;
margin-bottom: 16px;
}
.chat-layout {
display: flex;
height: calc(100vh - 210px);
min-height: 480px;
padding: 0;
overflow: hidden;
}
.side {
width: 320px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.side-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.stab {
flex: 1;
padding: 11px 4px;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 13px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.stab.on {
color: var(--text);
font-weight: 800;
box-shadow: inset 0 -2px 0 var(--primary);
}
.badge {
min-width: 17px;
height: 17px;
padding: 0 4px;
border-radius: 999px;
background: #e5484d;
color: #fff;
font-size: 10px;
font-weight: 800;
font-style: normal;
display: inline-flex;
align-items: center;
justify-content: center;
}
.side-list {
flex: 1;
overflow-y: auto;
}
.group-title {
padding: 10px 14px 4px;
font-size: 12px;
color: var(--text-dim);
font-weight: 700;
}
.row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
cursor: pointer;
}
.row:hover {
background: var(--bg-card);
}
.row.cur {
background: var(--bg-card);
box-shadow: inset 3px 0 0 var(--primary);
}
.row.static {
cursor: default;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
position: relative;
flex-shrink: 0;
}
.avatar.sm {
width: 30px;
height: 30px;
font-size: 15px;
}
.dot {
position: absolute;
right: 0;
bottom: 0;
width: 11px;
height: 11px;
border-radius: 50%;
background: #4ade80;
border: 2px solid var(--bg-panel);
}
.mid {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.mid b {
font-size: 14px;
}
.mid small {
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.side-info {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 3px;
flex-shrink: 0;
}
.side-info small {
font-size: 11px;
}
.ops {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.search-box {
display: flex;
gap: 8px;
padding: 12px 14px;
}
.search-box .input {
flex: 1;
}
.empty {
text-align: center;
padding: 46px 10px;
font-size: 13px;
line-height: 2;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.chat-head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
.msgs {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.earlier {
border: none;
background: transparent;
color: var(--primary-2);
font-size: 12px;
cursor: pointer;
padding: 4px;
}
.bubble-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.bubble-row.mine {
justify-content: flex-end;
}
.bubble {
max-width: 64%;
padding: 9px 13px;
border-radius: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
font-size: 14px;
line-height: 1.6;
word-break: break-word;
display: flex;
flex-direction: column;
gap: 2px;
}
.bubble-row.mine .bubble {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
border-color: transparent;
color: #fff;
}
.stamp {
font-size: 10px;
opacity: 0.65;
align-self: flex-end;
}
.input-row {
display: flex;
gap: 10px;
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.input-row .input {
flex: 1;
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
line-height: 2;
}
.ph-icon {
font-size: 46px;
opacity: 0.5;
}
@media (max-width: 800px) {
.chat-layout {
flex-direction: column;
height: auto;
}
.side {
width: 100%;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid var(--border);
}
.main {
min-height: 50vh;
}
}
</style>

295
src/views/GameCenter.vue Normal file
View File

@@ -0,0 +1,295 @@
<script setup>
// 游戏中心:游戏商城 / 道具商城 / 购物车 / 我的订单 四个标签页
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import http, { toast } from '../api/http'
import { useUserStore } from '../stores/user'
import GameCard from '../components/GameCard.vue'
const router = useRouter()
const userStore = useUserStore()
const tab = ref('games') // games / props / cart / orders
const games = ref([])
const props = ref([])
const cart = ref({ list: [], total: 0 })
const orders = ref({ list: [], total: 0, page: 1 })
// 只显示需要购买的游戏在商城(免费游戏大厅直接玩)
const paidGames = computed(() => games.value.filter((g) => g.price > 0))
// 加载各标签数据
async function loadGames() {
games.value = await http.get('/games')
}
async function loadProps() {
props.value = await http.get('/shop/props')
}
async function loadCart() {
cart.value = await http.get('/shop/cart')
}
async function loadOrders(page = 1) {
orders.value = await http.get('/shop/orders', { params: { page, size: 10 } })
orders.value.page = page
}
// 切换标签时按需加载
function switchTab(t) {
tab.value = t
if (t === 'games') loadGames()
if (t === 'props') loadProps()
if (t === 'cart') loadCart()
if (t === 'orders') loadOrders(1)
}
// 加入购物车
async function addCart(itemType, itemId) {
await http.post('/shop/cart', { item_type: itemType, item_id: itemId, quantity: 1 })
toast('已加入购物车', 'success')
loadCart()
}
// 立即购买
async function buyNow(itemType, itemId, quantity = 1) {
await http.post('/shop/buy', { item_type: itemType, item_id: itemId, quantity })
toast('购买成功!', 'success')
await userStore.refresh()
if (itemType === 1) loadGames()
}
// 删除购物车项
async function removeCart(id) {
await http.delete(`/shop/cart/${id}`)
loadCart()
}
// 购物车结算
async function checkout() {
if (!cart.value.list.length) {
toast('购物车是空的')
return
}
await http.post('/shop/checkout')
toast('结算成功,商品已到账!', 'success')
await userStore.refresh()
loadCart()
loadGames()
}
// 时间戳格式化
function fmtTime(ts) {
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
onMounted(() => {
loadGames()
loadCart()
})
</script>
<template>
<div>
<h2 class="page-title">游戏中心</h2>
<!-- 标签栏 -->
<div class="tabs">
<button class="tab-btn" :class="{ on: tab === 'games' }" @click="switchTab('games')">🎮 游戏商城</button>
<button class="tab-btn" :class="{ on: tab === 'props' }" @click="switchTab('props')">🎁 道具商城</button>
<button class="tab-btn" :class="{ on: tab === 'cart' }" @click="switchTab('cart')">
🛒 购物车<span v-if="cart.list.length" class="badge">{{ cart.list.length }}</span>
</button>
<button class="tab-btn" :class="{ on: tab === 'orders' }" @click="switchTab('orders')">📦 我的订单</button>
<span class="balance text-dim">余额<b class="text-primary">💰 {{ userStore.user?.points ?? 0 }}</b></span>
</div>
<!-- 游戏商城 -->
<div v-if="tab === 'games'">
<div class="grid">
<GameCard
v-for="g in paidGames"
:key="g.id"
:game="g"
:show-buy="true"
@play="router.push(`/play/${g.code}`)"
@add-cart="addCart(1, g.id)"
@buy="buyNow(1, g.id)"
/>
</div>
<div v-if="!paidGames.length" class="empty">暂无付费游戏</div>
</div>
<!-- 道具商城 -->
<div v-if="tab === 'props'" class="grid props-grid">
<div v-for="p in props" :key="p.id" class="prop-card card">
<div class="prop-icon">{{ p.icon }}</div>
<div class="prop-name">{{ p.name }}</div>
<p class="prop-desc text-dim">{{ p.description }}</p>
<div class="prop-foot">
<span class="prop-price">💰 {{ p.price }}</span>
<div class="actions">
<button class="btn btn-ghost btn-sm" @click="addCart(2, p.id)">加购物车</button>
<button class="btn btn-sm" @click="buyNow(2, p.id)">购买</button>
</div>
</div>
</div>
</div>
<!-- 购物车 -->
<div v-if="tab === 'cart'" class="panel">
<table v-if="cart.list.length" class="table">
<thead>
<tr><th>商品</th><th>类型</th><th>单价</th><th>数量</th><th>小计</th><th></th></tr>
</thead>
<tbody>
<tr v-for="it in cart.list" :key="it.id">
<td>{{ it.icon }} {{ it.name }}</td>
<td><span class="tag">{{ it.item_type === 1 ? '游戏' : '道具' }}</span></td>
<td>💰{{ it.price }}</td>
<td>{{ it.quantity }}</td>
<td class="text-primary">💰{{ it.price * it.quantity }}</td>
<td><button class="btn btn-ghost btn-sm" @click="removeCart(it.id)">删除</button></td>
</tr>
</tbody>
</table>
<div v-else class="empty">购物车空空如也去商城逛逛吧</div>
<div v-if="cart.list.length" class="cart-foot">
<span>合计<b class="text-primary" style="font-size: 18px">💰 {{ cart.total }}</b></span>
<button class="btn btn-lg" @click="checkout">积分结算</button>
</div>
</div>
<!-- 我的订单 -->
<div v-if="tab === 'orders'">
<div v-for="o in orders.list" :key="o.id" class="order panel">
<div class="order-head">
<span class="text-dim">订单号 {{ o.order_no }}</span>
<span class="text-dim">{{ fmtTime(o.created_at) }}</span>
<span class="tag tag-accent">{{ o.status === 1 ? '已完成' : '已取消' }}</span>
<span class="order-total">合计 <b class="text-primary">💰{{ o.total_points }}</b></span>
</div>
<div class="order-items">
<span v-for="it in o.items" :key="it.id" class="order-item">
{{ it.item_name }} ×{{ it.quantity }} <i class="text-dim">(💰{{ it.price }})</i>
</span>
</div>
</div>
<div v-if="!orders.list.length" class="empty">还没有订单记录</div>
<div v-if="orders.total > 10" class="pager">
<button class="btn btn-ghost btn-sm" :disabled="orders.page <= 1" @click="loadOrders(orders.page - 1)">上一页</button>
<span class="text-dim"> {{ orders.page }} / {{ Math.ceil(orders.total / 10) }} </span>
<button class="btn btn-ghost btn-sm" :disabled="orders.page >= Math.ceil(orders.total / 10)" @click="loadOrders(orders.page + 1)">下一页</button>
</div>
</div>
</div>
</template>
<style scoped>
.tabs {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 18px;
flex-wrap: wrap;
}
.tab-btn {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-panel);
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
position: relative;
transition: all 0.15s;
}
.tab-btn.on {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
border-color: transparent;
box-shadow: var(--glow);
}
.badge {
position: absolute;
top: -6px;
right: -6px;
background: var(--accent);
color: #04222a;
font-size: 11px;
font-weight: 800;
border-radius: 999px;
padding: 0 6px;
min-width: 18px;
height: 18px;
line-height: 18px;
}
.balance {
margin-left: auto;
font-size: 14px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 14px;
}
.props-grid {
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
}
.prop-card {
padding: 18px 16px 14px;
text-align: center;
}
.prop-icon {
font-size: 42px;
}
.prop-name {
font-weight: 700;
margin: 8px 0 4px;
}
.prop-desc {
font-size: 12px;
min-height: 48px;
line-height: 16px;
}
.prop-foot {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.prop-price {
color: var(--primary-2);
font-weight: 800;
}
.actions {
display: flex;
gap: 6px;
}
.cart-foot {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 20px;
margin-top: 16px;
}
.order {
margin-bottom: 12px;
}
.order-head {
display: flex;
gap: 14px;
align-items: center;
font-size: 13px;
flex-wrap: wrap;
}
.order-total {
margin-left: auto;
}
.order-items {
margin-top: 10px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.order-item {
background: var(--bg-card);
border: 1px solid var(--border);
padding: 5px 10px;
border-radius: 8px;
font-size: 12px;
}
</style>

1327
src/views/GamePlay.vue Normal file

File diff suppressed because it is too large Load Diff

473
src/views/Lobby.vue Normal file
View File

@@ -0,0 +1,473 @@
<script setup>
// 大厅:公告 + 分类筛选 + 游戏宫格 + 排行速览 + 购买弹窗(不跳转直接买)
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import http, { toast } from '../api/http'
import { useThemeStore } from '../stores/theme'
import { useUserStore } from '../stores/user'
import GameCard from '../components/GameCard.vue'
import PixelAvatar from '../components/PixelAvatar.vue'
import GameCarousel from '../components/GameCarousel.vue'
import GameIcon from '../components/GameIcon.vue'
const router = useRouter()
const themeStore = useThemeStore()
const userStore = useUserStore()
const games = ref([])
const rankTop = ref([])
const category = ref('全部')
const keyword = ref('')
const ownFilter = ref('all') // 拥有状态筛选all=全部 owned=我能玩的 locked=待解锁
// 分类列表(按数据动态生成)
const categories = computed(() => ['全部', ...new Set(games.value.map((g) => g.category))])
// 参与轮播的游戏(后台游戏管理里配置 carousel>0按值升序
const carouselGames = computed(() =>
games.value.filter((g) => g.carousel > 0).sort((a, b) => a.carousel - b.carousel)
)
// 我能玩的数量免费、已购、VIP周免都算
const ownedCount = computed(() => games.value.filter((g) => g.playable).length)
// 筛选后的游戏:拥有状态 + 分类 + 关键词三层过滤
const filtered = computed(() =>
games.value.filter(
(g) =>
(ownFilter.value === 'all' || (ownFilter.value === 'owned' ? g.playable : !g.playable)) &&
(category.value === '全部' || g.category === category.value) &&
(!keyword.value || g.name.includes(keyword.value))
)
)
// 点击游戏卡片:玩不了的付费游戏弹购买详情窗(不跳转),联机游戏去对战大厅,其余进入游玩页
function onPlay(game) {
if (!game.playable && game.price > 0) {
buyGame.value = game
} else if (game.engine === 'online') {
router.push({ path: '/battle', query: { game: game.code } })
} else {
router.push(`/play/${game.code}`)
}
}
// ---- 购买弹窗 ----
const buyGame = ref(null) // 当前弹窗展示的游戏null=关闭)
const buying = ref(false)
const myPoints = computed(() => userStore.user?.points ?? 0)
// 弹窗内直接购买:成功后刷新余额与游戏列表,卡片立即变为可玩
async function doBuy() {
if (buying.value) return
buying.value = true
try {
await http.post('/shop/buy', { item_type: 1, item_id: buyGame.value.id, quantity: 1 })
toast(`已购买「${buyGame.value.name}」,开始玩吧!`, 'success')
buyGame.value = null
await userStore.refresh()
games.value = await http.get('/games')
} finally {
buying.value = false
}
}
// 弹窗内加入购物车(稍后去游戏中心统一结算)
async function doAddCart() {
await http.post('/shop/cart', { item_type: 1, item_id: buyGame.value.id, quantity: 1 })
toast('已加入购物车', 'success')
}
onMounted(async () => {
userStore.refresh()
games.value = await http.get('/games')
const rank = await http.get('/rank/points')
rankTop.value = (rank.list || []).slice(0, 5)
})
</script>
<template>
<div>
<!-- 公告栏 -->
<div v-if="themeStore.announcement" class="notice panel">
<span class="notice-icon">📢</span>
<span class="notice-text">{{ themeStore.announcement }}</span>
</div>
<div class="lobby-grid">
<div class="main-col">
<!-- 游戏轮播后台可配置参与游戏与轮播效果点击直接进游戏/弹购买 -->
<GameCarousel
v-if="carouselGames.length"
:games="carouselGames"
:style-type="themeStore.carouselStyle"
:interval="themeStore.carouselInterval"
@play="onPlay"
/>
<h2 class="page-title">游戏大厅</h2>
<!-- 筛选工具条拥有状态 + 搜索 -->
<div class="filters">
<div class="chips own-chips">
<button class="chip" :class="{ on: ownFilter === 'all' }" @click="ownFilter = 'all'">
全部 {{ games.length }}
</button>
<button class="chip" :class="{ on: ownFilter === 'owned' }" @click="ownFilter = 'owned'">
🎮 我能玩的 {{ ownedCount }}
</button>
<button class="chip" :class="{ on: ownFilter === 'locked' }" @click="ownFilter = 'locked'">
🔒 待解锁 {{ games.length - ownedCount }}
</button>
</div>
<input v-model.trim="keyword" class="input search" placeholder="🔍 搜索游戏…" />
</div>
<!-- 分类筛选 -->
<div class="filters">
<div class="chips">
<button
v-for="c in categories"
:key="c"
class="chip"
:class="{ on: category === c }"
@click="category = c"
>
{{ c }}
</button>
</div>
</div>
<!-- 游戏宫格 -->
<div class="grid">
<GameCard v-for="g in filtered" :key="g.id" :game="g" @play="onPlay" />
</div>
<div v-if="!filtered.length" class="empty">没有找到符合条件的游戏</div>
</div>
<!-- 侧栏排行速览 -->
<aside class="side-col">
<div class="panel">
<h3 class="side-title">🏆 积分排行 TOP5</h3>
<div v-for="(u, i) in rankTop" :key="u.user_id" class="rank-item">
<span class="rank-no" :class="`no-${i + 1}`">{{ i + 1 }}</span>
<span class="rank-avatar"><PixelAvatar :code="u.avatar" :size="24" /></span>
<span class="rank-name">{{ u.nickname }}</span>
<span class="rank-points text-primary">{{ u.total_points }}</span>
</div>
<router-link to="/rank" class="more text-dim">查看完整榜单 </router-link>
</div>
<div class="panel quick">
<h3 class="side-title"> 快捷入口</h3>
<router-link to="/signin" class="quick-link">📅 每日签到领积分</router-link>
<router-link to="/center" class="quick-link">🛒 逛逛游戏中心</router-link>
<router-link to="/battle" class="quick-link"> 找人联机对战</router-link>
</div>
</aside>
</div>
<!-- 购买弹窗展示游戏详情可直接购买或加购物车 -->
<Teleport to="body">
<div v-if="buyGame" class="modal-mask" @click.self="buyGame = null">
<div class="buy-modal panel">
<button class="bm-close" @click="buyGame = null"></button>
<div class="bm-head">
<div class="bm-icon-box"><GameIcon class="bm-icon" :code="buyGame.code" :icon="buyGame.icon" :size="40" /></div>
<div class="bm-title">
<div class="bm-name">{{ buyGame.name }}</div>
<div class="bm-tags">
<span class="bm-tag">{{ buyGame.category }}</span>
<span class="bm-tag">{{ buyGame.engine === 'online' ? '⚔️ 联机对战' : '🕹️ 单机' }}</span>
<span class="bm-tag">🔥 {{ buyGame.play_count }} 次游玩</span>
</div>
</div>
</div>
<p class="bm-desc text-dim">{{ buyGame.description }}</p>
<div class="bm-price-row">
<span class="bm-price-item">价格 <b class="text-primary">💰 {{ buyGame.price }}</b></span>
<span class="bm-price-item">
我的余额
<b :class="myPoints >= buyGame.price ? 'text-primary' : 'bm-lack'">💰 {{ myPoints }}</b>
</span>
</div>
<div v-if="myPoints < buyGame.price" class="bm-warn">
积分不足还差 {{ buyGame.price - myPoints }}
<router-link to="/signin" @click="buyGame = null">去签到赚积分 </router-link>
</div>
<div class="bm-actions">
<button class="btn btn-ghost" @click="doAddCart">🛒 加入购物车</button>
<button class="btn" :disabled="buying || myPoints < buyGame.price" @click="doBuy">
{{ buying ? '购买中' : '立即购买' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.notice {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
margin-bottom: 18px;
overflow: hidden;
}
.notice-icon {
font-size: 18px;
}
.notice-text {
font-size: 13px;
color: var(--text-dim);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lobby-grid {
display: grid;
grid-template-columns: 1fr 260px;
gap: 18px;
align-items: start;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 12px;
flex-wrap: wrap;
}
.own-chips {
flex: 1;
}
.own-chips .chip.on {
font-weight: 700;
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
flex: 1;
}
.chip {
padding: 6px 14px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-panel);
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;
box-shadow: var(--glow);
}
.search {
width: 200px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: 14px;
}
.side-col {
display: flex;
flex-direction: column;
gap: 14px;
position: sticky;
top: 76px;
}
.side-title {
font-size: 14px;
margin-bottom: 12px;
}
.rank-item {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 0;
font-size: 13px;
}
.rank-no {
width: 20px;
height: 20px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
background: var(--bg-card);
color: var(--text-dim);
}
.no-1 {
background: linear-gradient(135deg, #ffd700, #ff9500);
color: #422;
}
.no-2 {
background: linear-gradient(135deg, #e8e8e8, #9e9e9e);
color: #333;
}
.no-3 {
background: linear-gradient(135deg, #d7995b, #a05822);
color: #fff;
}
.rank-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-points {
font-weight: 700;
}
.more {
display: block;
text-align: center;
font-size: 12px;
margin-top: 8px;
}
.more:hover {
color: var(--primary);
}
.quick-link {
display: block;
padding: 10px 12px;
border-radius: calc(var(--radius) / 1.5);
background: var(--bg-card);
border: 1px solid var(--border);
margin-bottom: 8px;
font-size: 13px;
transition: all 0.15s;
}
.quick-link:hover {
border-color: var(--primary);
transform: translateX(3px);
}
@media (max-width: 900px) {
.lobby-grid {
grid-template-columns: 1fr;
}
.side-col {
position: static;
}
}
/* ---- 购买弹窗 ---- */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(3px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
padding: 20px;
}
.buy-modal {
position: relative;
width: 420px;
max-width: 100%;
padding: 22px;
animation: bm-pop 0.18s ease-out;
}
@keyframes bm-pop {
from {
transform: scale(0.92);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.bm-close {
position: absolute;
top: 10px;
right: 12px;
border: 0;
background: transparent;
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
}
.bm-close:hover {
color: var(--text);
}
.bm-head {
display: flex;
gap: 14px;
align-items: center;
}
.bm-icon-box {
width: 72px;
height: 72px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, color-mix(in srgb, var(--primary) 20%, transparent), color-mix(in srgb, var(--accent) 14%, transparent));
border: 1px solid var(--border);
flex: none;
}
.bm-icon {
font-size: 40px;
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.35));
}
.bm-name {
font-size: 18px;
font-weight: 800;
}
.bm-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 7px;
}
.bm-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-dim);
}
.bm-desc {
font-size: 13px;
line-height: 1.7;
margin: 14px 0;
}
.bm-price-row {
display: flex;
justify-content: space-between;
padding: 10px 14px;
border-radius: calc(var(--radius) / 1.5);
background: var(--bg-card);
border: 1px solid var(--border);
font-size: 13px;
}
.bm-price-item b {
font-size: 15px;
}
.bm-lack {
color: #ff7b8a;
}
.bm-warn {
margin-top: 10px;
font-size: 12px;
color: #ff9d6e;
}
.bm-warn a {
color: var(--primary);
}
.bm-actions {
display: flex;
gap: 10px;
margin-top: 16px;
}
.bm-actions .btn {
flex: 1;
}
.bm-actions .btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

161
src/views/Login.vue Normal file
View File

@@ -0,0 +1,161 @@
<script setup>
// 登录/注册页:切换式表单,成功后写入用户 Store 并进入大厅
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import http, { toast } from '../api/http'
import { useUserStore } from '../stores/user'
import { useThemeStore } from '../stores/theme'
import PixelAvatar from '../components/PixelAvatar.vue'
import { AVATAR_LIST } from '../games/pixelAvatars'
const router = useRouter()
const userStore = useUserStore()
const themeStore = useThemeStore()
const mode = ref('login') // login / register
const loading = ref(false)
const form = reactive({ username: '', password: '', nickname: '', avatar: 'px:01' })
// 注册时可选的手绘像素头像32 个)
const avatars = AVATAR_LIST.map((a) => a.code)
// 提交登录或注册
async function submit() {
if (!form.username || !form.password) {
toast('请输入用户名和密码')
return
}
loading.value = true
try {
const url = mode.value === 'login' ? '/auth/login' : '/auth/register'
const data = await http.post(url, form)
userStore.setLogin(data)
toast(mode.value === 'login' ? '欢迎回来!' : '注册成功,赠送积分已到账!', 'success')
router.push('/')
} catch (e) {
// 拦截器已提示
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-wrap">
<div class="login-box panel">
<div class="logo">🕹</div>
<h1 class="title">{{ themeStore.siteName }}</h1>
<p class="subtitle text-dim">37 款小游戏 · 联机对战 · AI 陪玩</p>
<div class="tabs">
<button class="tab" :class="{ on: mode === 'login' }" @click="mode = 'login'">登录</button>
<button class="tab" :class="{ on: mode === 'register' }" @click="mode = 'register'">注册</button>
</div>
<div class="form">
<input v-model.trim="form.username" class="input" placeholder="用户名3-20位" @keyup.enter="submit" />
<input v-model="form.password" class="input" type="password" placeholder="密码6-32位" @keyup.enter="submit" />
<template v-if="mode === 'register'">
<input v-model.trim="form.nickname" class="input" placeholder="昵称(选填)" />
<div class="avatars">
<button
v-for="a in avatars"
:key="a"
class="avatar-opt"
:class="{ on: form.avatar === a }"
@click="form.avatar = a"
>
<PixelAvatar :code="a" :size="30" />
</button>
</div>
</template>
<button class="btn btn-lg submit" :disabled="loading" @click="submit">
{{ loading ? '请稍候' : mode === 'login' ? '进入游戏厅' : '注册并进入' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.login-wrap {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-box {
width: min(400px, 94vw);
text-align: center;
padding: 36px 30px;
}
.logo {
font-size: 54px;
filter: drop-shadow(var(--glow));
}
.title {
font-size: 24px;
font-weight: 800;
margin: 8px 0 4px;
background: linear-gradient(90deg, var(--primary), var(--accent));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.subtitle {
font-size: 13px;
margin-bottom: 20px;
}
.tabs {
display: flex;
background: var(--bg);
border-radius: calc(var(--radius) / 1.4);
padding: 4px;
margin-bottom: 18px;
}
.tab {
flex: 1;
border: none;
background: transparent;
color: var(--text-dim);
padding: 8px;
border-radius: calc(var(--radius) / 1.8);
cursor: pointer;
font-size: 14px;
font-weight: 600;
}
.tab.on {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
box-shadow: var(--glow);
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
}
.avatars {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
max-height: 180px;
overflow-y: auto;
}
.avatar-opt {
padding: 4px 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
transition: all 0.15s;
}
.avatar-opt.on {
border-color: var(--primary);
box-shadow: var(--glow);
transform: scale(1.08);
}
.submit {
margin-top: 6px;
}
</style>

492
src/views/Profile.vue Normal file
View File

@@ -0,0 +1,492 @@
<script setup>
// 个人中心:我的游戏 + 资料编辑 + 积分流水/登录记录/游玩记录/对战记录/道具背包
// 四类记录采用「批量拉取 + 虚拟滚动」:一次拉 200 条只渲染可视行,滚动到底自动续拉
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import http, { toast } from '../api/http'
import { useUserStore } from '../stores/user'
import GameIcon from '../components/GameIcon.vue'
import VirtualList from '../components/VirtualList.vue'
import PixelAvatar from '../components/PixelAvatar.vue'
import { AVATAR_LIST } from '../games/pixelAvatars'
const router = useRouter()
const userStore = useUserStore()
const tab = ref('games') // games / points / logins / records / battles / props
const editing = ref(false)
const editForm = reactive({ nickname: '', avatar: '' })
// 手绘像素头像32 个)
const avatars = AVATAR_LIST.map((a) => a.code)
// 各记录标签的数据page 为下一批要拉的页码
const REC_BATCH = 200
const pageData = reactive({
points: { list: [], total: 0, page: 1, loading: false },
logins: { list: [], total: 0, page: 1, loading: false },
records: { list: [], total: 0, page: 1, loading: false },
battles: { list: [], total: 0, page: 1, loading: false },
})
const bag = ref([])
const myGames = ref([])
// 我的游戏来源标签
const sourceNames = { bought: '已购', weekly: '周免⚡', played: '玩过' }
// 进入游戏:联机游戏去对战大厅,单机游戏直接开玩
function playGame(g) {
if (g.engine === 'online') router.push('/battle')
else router.push(`/play/${g.code}`)
}
// 积分流水类型文案
const pointTypeNames = { 1: '每日签到', 2: '游戏奖励', 3: '商城消费', 4: '对战奖励', 5: '道具使用', 6: '注册赠送', 7: '后台调整', 8: '开通VIP' }
// 对战结果文案
const battleResults = { 1: '胜利', 2: '失败', 3: '平局' }
// URL 映射
const urls = {
points: '/user/points',
logins: '/user/logins',
records: '/user/records',
battles: '/user/battles',
}
// 加载指定标签记录类批量拉取reset=true 从头拉,否则触底续拉下一批)
async function load(t, reset = true) {
if (t === 'props') {
bag.value = await http.get('/user/props')
return
}
if (t === 'games') {
myGames.value = await http.get('/user/mygames')
return
}
const d = pageData[t]
if (d.loading) return
if (reset) d.page = 1
else if (d.page > 1 && d.list.length >= d.total) return // 已全部加载
d.loading = true
try {
const data = await http.get(urls[t], { params: { page: d.page, size: REC_BATCH } })
d.list = reset ? data.list : [...d.list, ...data.list]
d.total = data.total
d.page++
} finally {
d.loading = false
}
}
function switchTab(t) {
tab.value = t
load(t, true)
}
// 保存资料
async function saveProfile() {
if (!editForm.nickname) {
toast('昵称不能为空')
return
}
await http.put('/user/profile', editForm)
toast('资料已更新', 'success')
editing.value = false
await userStore.refresh()
}
function startEdit() {
editForm.nickname = userStore.user?.nickname || ''
editForm.avatar = userStore.user?.avatar || 'px:01'
editing.value = true
}
function fmtTime(ts) {
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
onMounted(async () => {
await userStore.refresh()
load('games')
})
</script>
<template>
<div>
<h2 class="page-title">个人中心</h2>
<!-- 资料卡 -->
<div class="panel profile-card">
<div class="avatar-big"><PixelAvatar :code="userStore.user?.avatar" :size="56" /></div>
<div class="pinfo">
<div class="pname">
{{ userStore.user?.nickname }}
<span v-if="userStore.isAdmin" class="tag tag-primary">超级管理员</span>
</div>
<div class="text-dim" style="font-size: 12px">账号{{ userStore.user?.username }}</div>
</div>
<div class="pstats">
<div class="stat">
<div class="stat-num text-primary">💰 {{ userStore.user?.points ?? 0 }}</div>
<div class="stat-label text-dim">当前积分</div>
</div>
<div class="stat">
<div class="stat-num text-accent">{{ userStore.user?.total_points ?? 0 }}</div>
<div class="stat-label text-dim">累计获得</div>
</div>
</div>
<button class="btn btn-ghost" @click="startEdit"> 编辑资料</button>
</div>
<!-- 标签栏 -->
<div class="tabs">
<button v-for="(label, t) in { games: '🕹️ 我的游戏', points: '💰 积分流水', logins: '🔐 登录记录', records: '🎮 游玩记录', battles: '⚔️ 对战记录', props: '🎒 道具背包' }"
:key="t" class="tab-btn" :class="{ on: tab === t }" @click="switchTab(t)">{{ label }}</button>
</div>
<!-- 我的游戏 -->
<div v-if="tab === 'games'" class="mg-grid">
<div v-for="g in myGames" :key="g.id" class="mg-card card">
<div class="mg-head">
<GameIcon class="mg-icon" :code="g.code" :icon="g.icon" :size="30" />
<div class="mg-title">
<b>{{ g.name }}</b>
<small class="text-dim">{{ g.category }}</small>
</div>
<span class="tag" :class="g.source === 'bought' ? 'tag-primary' : g.source === 'weekly' ? 'tag-accent' : ''">
{{ sourceNames[g.source] }}
</span>
</div>
<div class="mg-stats text-dim">
<span>🎯 {{ g.plays }} </span>
<span v-if="g.best > 0">🏆 最高 {{ g.best }}</span>
<span v-if="g.last_played">🕐 {{ new Date(g.last_played * 1000).toLocaleDateString('zh-CN') }}</span>
<span v-else>还没玩过</span>
</div>
<div class="mg-foot">
<small v-if="g.source === 'bought'" class="text-dim">{{ new Date(g.got_at * 1000).toLocaleDateString('zh-CN') }} 购入</small>
<small v-else-if="g.source === 'weekly'" class="text-dim">本周五 12:00 轮换</small>
<small v-else class="text-dim">免费游戏</small>
<button class="btn btn-sm" @click="playGame(g)">{{ g.engine === 'online' ? '去对战' : '去玩' }}</button>
</div>
</div>
<div v-if="!myGames.length" class="empty" style="grid-column: 1/-1">
还没有属于你的游戏去大厅玩免费游戏或到游戏中心购买开通 VIP 领周免吧
</div>
</div>
<!-- 积分流水虚拟滚动滚动到底自动加载更多 -->
<div v-if="tab === 'points'" class="panel rec-panel">
<div class="rrow r-points rhead text-dim">
<span>时间</span><span>类型</span><span>说明</span><span class="num">变动</span><span class="num">余额</span>
</div>
<VirtualList :items="pageData.points.list" :item-height="40" :max-height="480" @reach-end="load('points', false)">
<template #default="{ item: r }">
<div class="rrow r-points">
<span class="text-dim time">{{ fmtTime(r.created_at) }}</span>
<span><span class="tag">{{ pointTypeNames[r.type] || '其他' }}</span></span>
<span class="ellip" :title="r.remark">{{ r.remark }}</span>
<span class="num" :class="r.change_points >= 0 ? 'gain' : 'cost'">
{{ r.change_points >= 0 ? '+' : '' }}{{ r.change_points }}
</span>
<span class="num text-dim">{{ r.balance }}</span>
</div>
</template>
<template #empty><span class="text-dim">暂无积分流水</span></template>
</VirtualList>
<div class="rec-foot text-dim">已加载 {{ pageData.points.list.length }} / {{ pageData.points.total }} </div>
</div>
<!-- 登录记录 -->
<div v-if="tab === 'logins'" class="panel rec-panel">
<div class="rrow r-logins rhead text-dim">
<span>登录时间</span><span>IP 地址</span><span>设备信息</span>
</div>
<VirtualList :items="pageData.logins.list" :item-height="40" :max-height="480" @reach-end="load('logins', false)">
<template #default="{ item: r }">
<div class="rrow r-logins">
<span class="time">{{ fmtTime(r.created_at) }}</span>
<span>{{ r.ip }}</span>
<span class="text-dim ua ellip" :title="r.user_agent">{{ r.user_agent }}</span>
</div>
</template>
<template #empty><span class="text-dim">暂无登录记录</span></template>
</VirtualList>
<div class="rec-foot text-dim">已加载 {{ pageData.logins.list.length }} / {{ pageData.logins.total }} </div>
</div>
<!-- 游玩记录 -->
<div v-if="tab === 'records'" class="panel rec-panel">
<div class="rrow r-records rhead text-dim">
<span>时间</span><span>游戏</span><span class="num">得分</span><span class="num">获得积分</span><span class="num">时长</span>
</div>
<VirtualList :items="pageData.records.list" :item-height="40" :max-height="480" @reach-end="load('records', false)">
<template #default="{ item: r }">
<div class="rrow r-records">
<span class="text-dim time">{{ fmtTime(r.created_at) }}</span>
<span class="ellip">{{ r.game_icon }} {{ r.game_name }}</span>
<span class="num"><b>{{ r.score }}</b></span>
<span class="num gain">+{{ r.points_gained }}</span>
<span class="num text-dim">{{ r.duration }}</span>
</div>
</template>
<template #empty><span class="text-dim">还没玩过游戏快去大厅试试吧</span></template>
</VirtualList>
<div class="rec-foot text-dim">已加载 {{ pageData.records.list.length }} / {{ pageData.records.total }} </div>
</div>
<!-- 对战记录 -->
<div v-if="tab === 'battles'" class="panel rec-panel">
<div class="rrow r-battles rhead text-dim">
<span>时间</span><span>游戏</span><span>模式</span><span>结果</span><span class="num">积分</span><span>详情</span>
</div>
<VirtualList :items="pageData.battles.list" :item-height="40" :max-height="480" @reach-end="load('battles', false)">
<template #default="{ item: r }">
<div class="rrow r-battles">
<span class="text-dim time">{{ fmtTime(r.created_at) }}</span>
<span class="ellip">{{ r.game_icon }} {{ r.game_name }}</span>
<span class="ellip">
<span class="tag">{{ r.mode === 1 ? '好友联机' : 'AI对战' }}</span>
<span v-if="r.ai_provider" class="tag tag-accent" style="margin-left: 4px">{{ r.ai_provider }}/{{ r.ai_difficulty }}</span>
</span>
<span :class="r.result === 1 ? 'gain' : r.result === 2 ? 'cost' : ''">{{ battleResults[r.result] }}</span>
<span class="num gain">{{ r.points_change > 0 ? `+${r.points_change}` : '-' }}</span>
<span class="text-dim ellip" style="font-size: 12px" :title="r.detail">{{ r.detail }}</span>
</div>
</template>
<template #empty><span class="text-dim">暂无对战记录去联机对战试试</span></template>
</VirtualList>
<div class="rec-foot text-dim">已加载 {{ pageData.battles.list.length }} / {{ pageData.battles.total }} </div>
</div>
<!-- 道具背包 -->
<div v-if="tab === 'props'" class="bag-grid">
<div v-for="p in bag" :key="p.prop_id" class="bag-item card">
<span class="bag-icon">{{ p.icon }}</span>
<div class="bag-name">{{ p.name }} <b class="text-primary">×{{ p.quantity }}</b></div>
<p class="text-dim" style="font-size: 11px">{{ p.description }}</p>
</div>
<div v-if="!bag.length" class="empty" style="grid-column: 1/-1">背包空空的去道具商城看看吧</div>
</div>
<!-- 编辑资料弹窗 -->
<div v-if="editing" class="modal-mask" @click.self="editing = false">
<div class="modal">
<h3 style="margin-bottom: 16px">编辑资料</h3>
<input v-model.trim="editForm.nickname" class="input" placeholder="昵称" style="margin-bottom: 12px" />
<div class="avatars">
<button
v-for="a in avatars"
:key="a"
class="avatar-opt"
:class="{ on: editForm.avatar === a }"
@click="editForm.avatar = a"
>
<PixelAvatar :code="a" :size="30" />
</button>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 18px">
<button class="btn btn-ghost" @click="editing = false">取消</button>
<button class="btn" @click="saveProfile">保存</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.profile-card {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.avatar-big {
width: 64px;
height: 64px;
border-radius: 20px;
background: var(--bg-card);
border: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 34px;
box-shadow: var(--glow);
}
.pname {
font-size: 18px;
font-weight: 800;
display: flex;
gap: 8px;
align-items: center;
}
.pstats {
display: flex;
gap: 26px;
margin-left: auto;
}
.stat {
text-align: center;
}
.stat-num {
font-size: 18px;
font-weight: 800;
}
.stat-label {
font-size: 11px;
margin-top: 2px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.tab-btn {
padding: 7px 15px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-panel);
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
}
.tab-btn.on {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
border-color: transparent;
}
.gain {
color: #5fd6a2;
font-weight: 700;
}
.cost {
color: #ff7b8a;
font-weight: 700;
}
.ua {
font-size: 11px;
}
/* ---- 记录类标签grid 行式 + 虚拟滚动 ---- */
.rec-panel {
padding: 6px 16px 10px;
}
.rrow {
display: grid;
gap: 10px;
align-items: center;
height: 100%;
border-bottom: 1px dashed var(--border);
font-size: 13px;
}
.rhead {
height: 36px;
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.r-points {
grid-template-columns: 150px 92px 1fr 90px 80px;
}
.r-logins {
grid-template-columns: 170px 140px 1fr;
}
.r-records {
grid-template-columns: 150px 1fr 90px 90px 80px;
}
.r-battles {
grid-template-columns: 150px 1fr 180px 56px 76px 1.1fr;
}
.rrow .num {
text-align: right;
}
.rrow .time {
font-size: 12px;
white-space: nowrap;
}
.ellip {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rec-foot {
font-size: 12px;
text-align: center;
padding-top: 8px;
}
.bag-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
gap: 12px;
}
.mg-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.mg-card {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.mg-head {
display: flex;
align-items: center;
gap: 10px;
}
.mg-icon {
font-size: 30px;
}
.mg-title {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.mg-title b {
font-size: 14px;
}
.mg-title small {
font-size: 11px;
}
.mg-stats {
display: flex;
gap: 12px;
font-size: 12px;
flex-wrap: wrap;
}
.mg-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: auto;
}
.mg-foot small {
font-size: 11px;
}
.bag-item {
padding: 16px;
text-align: center;
}
.bag-icon {
font-size: 34px;
}
.bag-name {
font-size: 13px;
font-weight: 700;
margin: 6px 0 4px;
}
.avatars {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 6px;
max-height: 200px;
overflow-y: auto;
}
.avatar-opt {
padding: 4px 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
}
.avatar-opt.on {
border-color: var(--primary);
box-shadow: var(--glow);
}
</style>

147
src/views/Rank.vue Normal file
View File

@@ -0,0 +1,147 @@
<script setup>
// 排行榜:积分总榜 + 单游戏最高分榜(可切换游戏)
import { ref, onMounted } from 'vue'
import http from '../api/http'
import PixelAvatar from '../components/PixelAvatar.vue'
const tab = ref('points') // points / game
const pointsRank = ref({ list: [], my_rank: 0, my_points: 0 })
const games = ref([])
const selectedGame = ref('')
const gameRank = ref({ list: [], my_best: 0, game: null })
async function loadPoints() {
pointsRank.value = await http.get('/rank/points')
}
async function loadGameRank() {
if (!selectedGame.value) return
gameRank.value = await http.get(`/rank/game/${selectedGame.value}`)
}
// 切换到游戏榜时加载游戏列表(排除联机类,联机看对战记录)
async function switchToGame() {
tab.value = 'game'
if (!games.value.length) {
const all = await http.get('/games')
games.value = all.filter((g) => g.engine !== 'online')
if (games.value.length) {
selectedGame.value = games.value[0].code
}
}
loadGameRank()
}
// 名次徽章样式
function rankClass(i) {
return i === 0 ? 'gold' : i === 1 ? 'silver' : i === 2 ? 'bronze' : ''
}
onMounted(loadPoints)
</script>
<template>
<div>
<h2 class="page-title">排行榜</h2>
<div class="tabs">
<button class="tab-btn" :class="{ on: tab === 'points' }" @click="tab = 'points'; loadPoints()">💰 积分总榜</button>
<button class="tab-btn" :class="{ on: tab === 'game' }" @click="switchToGame">🎮 游戏高分榜</button>
<select v-if="tab === 'game'" v-model="selectedGame" class="input game-select" @change="loadGameRank">
<option v-for="g in games" :key="g.code" :value="g.code">{{ g.icon }} {{ g.name }}</option>
</select>
</div>
<!-- 我的成绩 -->
<div class="panel my-row">
<template v-if="tab === 'points'">
<span>我的排名<b class="text-primary"> {{ pointsRank.my_rank }} </b></span>
<span>累计积分<b class="text-accent">{{ pointsRank.my_points }}</b></span>
</template>
<template v-else>
<span>{{ gameRank.game?.name || '' }} 我的最高分<b class="text-primary">{{ gameRank.my_best }}</b></span>
</template>
</div>
<!-- 榜单 -->
<div class="panel">
<table class="table">
<thead>
<tr>
<th style="width: 70px">名次</th>
<th>玩家</th>
<th style="text-align: right">{{ tab === 'points' ? '累计积分' : '最高分' }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(u, i) in tab === 'points' ? pointsRank.list : gameRank.list" :key="u.user_id">
<td><span class="rank-badge" :class="rankClass(i)">{{ i + 1 }}</span></td>
<td><span class="row-avatar"><PixelAvatar :code="u.avatar" :size="20" /></span> {{ u.nickname }}</td>
<td style="text-align: right" class="text-primary"><b>{{ tab === 'points' ? u.total_points : u.best }}</b></td>
</tr>
</tbody>
</table>
<div v-if="!(tab === 'points' ? pointsRank.list : gameRank.list).length" class="empty">暂无上榜数据快去玩几局吧</div>
</div>
</div>
</template>
<style scoped>
.tabs {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 14px;
}
.tab-btn {
padding: 8px 18px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-panel);
color: var(--text-dim);
cursor: pointer;
font-size: 14px;
}
.tab-btn.on {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
border-color: transparent;
box-shadow: var(--glow);
}
.game-select {
width: 190px;
margin-left: auto;
}
.my-row {
display: flex;
gap: 30px;
padding: 12px 18px;
margin-bottom: 14px;
font-size: 14px;
}
.rank-badge {
display: inline-flex;
width: 26px;
height: 26px;
border-radius: 8px;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 12px;
background: var(--bg-card);
color: var(--text-dim);
}
.rank-badge.gold {
background: linear-gradient(135deg, #ffd700, #ff9500);
color: #442200;
}
.rank-badge.silver {
background: linear-gradient(135deg, #ececec, #9e9e9e);
color: #333;
}
.rank-badge.bronze {
background: linear-gradient(135deg, #d7995b, #a05822);
color: #fff;
}
.row-avatar {
font-size: 17px;
margin-right: 4px;
}
</style>

279
src/views/SignIn.vue Normal file
View File

@@ -0,0 +1,279 @@
<script setup>
// 签到中心:本月日历 + 连签进度 + 一键签到
import { ref, computed, onMounted } from 'vue'
import http, { toast } from '../api/http'
import { useUserStore } from '../stores/user'
const userStore = useUserStore()
const status = ref(null) // 签到状态(后端下发)
const signing = ref(false)
const justSigned = ref(null) // 刚签到成功的结果(弹窗)
// 生成日历格子:先补空位再放日期
const calendar = computed(() => {
if (!status.value) return []
const { year, month } = status.value
const firstDay = new Date(year, month - 1, 1).getDay() // 周日=0
const days = new Date(year, month, 0).getDate()
const cells = []
for (let i = 0; i < firstDay; i++) cells.push({ key: `empty-${i}`, empty: true })
for (let d = 1; d <= days; d++) {
cells.push({
key: `day-${d}`,
day: d,
signed: status.value.signed_days.includes(d),
isToday: d === status.value.today,
})
}
return cells
})
// 连签奖励预览7天阶梯
const streakPreview = computed(() => {
const list = []
for (let i = 1; i <= 7; i++) {
list.push({ day: i, points: 10 + 5 * (i - 1) })
}
return list
})
async function load() {
status.value = await http.get('/signin/status')
}
// 执行签到
async function doSign() {
signing.value = true
try {
const res = await http.post('/signin')
justSigned.value = res
userStore.setPoints(res.balance)
toast(`签到成功 +${res.points} 积分!`, 'success')
await load()
} catch (e) {
// 拦截器已提示
} finally {
signing.value = false
}
}
onMounted(load)
</script>
<template>
<div v-if="status">
<h2 class="page-title">签到中心</h2>
<div class="signin-grid">
<!-- 日历 -->
<div class="panel">
<div class="cal-head">
<span class="cal-title">{{ status.year }} {{ status.month }} </span>
<span class="text-dim">本月已签 {{ status.signed_days.length }} </span>
</div>
<div class="week-row">
<span v-for="w in ['日', '一', '二', '三', '四', '五', '六']" :key="w" class="week-cell text-dim">{{ w }}</span>
</div>
<div class="cal-grid">
<div
v-for="c in calendar"
:key="c.key"
class="cal-cell"
:class="{ empty: c.empty, signed: c.signed, today: c.isToday }"
>
<template v-if="!c.empty">
<span class="day-no">{{ c.day }}</span>
<span v-if="c.signed" class="check"></span>
</template>
</div>
</div>
</div>
<!-- 签到操作与奖励 -->
<div class="side">
<div class="panel sign-box">
<div class="streak-num">🔥 连续 <b>{{ status.streak }}</b> </div>
<p class="text-dim" style="font-size: 12px; margin: 6px 0 16px">
{{ status.today_signed ? '今天已经签到啦,明天继续保持~' : `今日签到可得 ${status.today_points} 积分` }}
</p>
<button class="btn btn-lg sign-btn" :disabled="status.today_signed || signing" @click="doSign">
{{ status.today_signed ? ' 今日已签到' : '📅 立即签到' }}
</button>
<p class="next text-dim">明日签到可得 <b class="text-accent">{{ status.next_points }}</b> 积分</p>
<!-- VIP签到加成提示会员显示加成额度非会员引导开通 -->
<p v-if="status.vip_level > 0" class="vip-line">💎 VIP{{ status.vip_level }} 加成每日额外 +{{ status.vip_bonus }} 积分</p>
<router-link v-else to="/vip" class="vip-line vip-link">💎 开通VIP签到最高每日多领 50 积分 </router-link>
</div>
<div class="panel">
<h3 style="font-size: 14px; margin-bottom: 12px">连签奖励阶梯</h3>
<div class="ladder">
<div
v-for="s in streakPreview"
:key="s.day"
class="ladder-item"
:class="{ reached: Math.min(status.streak, 7) >= s.day && status.today_signed, current: s.day === Math.min(status.streak, 7) && !status.today_signed }"
>
<span class="ladder-day">{{ s.day }}</span>
<span class="ladder-points">+{{ s.points }}</span>
</div>
</div>
<p class="text-dim" style="font-size: 11px; margin-top: 10px">连续签到第 7 天起每天固定 +40 积分中断后从头计算</p>
</div>
</div>
</div>
<!-- 签到成功弹窗 -->
<div v-if="justSigned" class="modal-mask" @click.self="justSigned = null">
<div class="modal sign-modal">
<div class="big-icon">🎉</div>
<h3>签到成功</h3>
<p class="got">积分 <b>+{{ justSigned.points }}</b></p>
<p v-if="justSigned.vip_bonus > 0" class="text-dim" style="font-size: 12px"> VIP 加成 +{{ justSigned.vip_bonus }}</p>
<p class="text-dim">已连续签到 {{ justSigned.streak }} 当前余额 💰{{ justSigned.balance }}</p>
<button class="btn" style="margin-top: 16px" @click="justSigned = null">收下奖励</button>
</div>
</div>
</div>
</template>
<style scoped>
.signin-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 16px;
align-items: start;
}
.vip-line {
display: block;
margin-top: 10px;
font-size: 12px;
color: var(--primary-2);
font-weight: 700;
}
.vip-link:hover {
color: var(--primary);
}
.cal-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.cal-title {
font-size: 16px;
font-weight: 800;
}
.week-row,
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 6px;
}
.week-cell {
text-align: center;
font-size: 12px;
padding: 4px 0;
}
.cal-cell {
aspect-ratio: 1;
border-radius: 10px;
background: var(--bg-card);
border: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
position: relative;
}
.cal-cell.empty {
background: transparent;
border: none;
}
.cal-cell.today {
border-color: var(--accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}
.cal-cell.signed {
background: linear-gradient(135deg, color-mix(in srgb, var(--primary) 35%, var(--bg-card)), color-mix(in srgb, var(--primary-2) 25%, var(--bg-card)));
border-color: var(--primary);
}
.day-no {
font-size: 13px;
font-weight: 600;
}
.check {
font-size: 11px;
color: var(--primary-2);
font-weight: 800;
}
.side {
display: flex;
flex-direction: column;
gap: 14px;
}
.sign-box {
text-align: center;
}
.streak-num {
font-size: 18px;
}
.streak-num b {
font-size: 30px;
color: var(--primary);
}
.sign-btn {
width: 100%;
}
.next {
margin-top: 12px;
font-size: 12px;
}
.ladder {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.ladder-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 2px;
text-align: center;
display: flex;
flex-direction: column;
gap: 4px;
}
.ladder-item.reached {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 18%, var(--bg-card));
}
.ladder-item.current {
border-color: var(--accent);
}
.ladder-day {
font-size: 10px;
color: var(--text-dim);
}
.ladder-points {
font-size: 11px;
font-weight: 800;
color: var(--primary-2);
}
.sign-modal {
text-align: center;
}
.big-icon {
font-size: 52px;
}
.got {
font-size: 16px;
margin: 8px 0 4px;
}
.got b {
color: var(--primary);
font-size: 26px;
}
@media (max-width: 900px) {
.signin-grid {
grid-template-columns: 1fr;
}
}
</style>

336
src/views/Vip.vue Normal file
View File

@@ -0,0 +1,336 @@
<script setup>
// VIP中心我的会员状态 + 五档等级权益 + 本周周免游戏 + 开通/续费/升级
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import http, { toast } from '../api/http'
import { useUserStore } from '../stores/user'
const router = useRouter()
const userStore = useUserStore()
const info = ref(null) // /vip/info 响应
const buying = ref(0) // 正在购买的等级(防连点)
const confirmTarget = ref(null) // 待确认购买的等级对象
const nowTs = ref(Math.floor(Date.now() / 1000)) // 用于倒计时的当前时间
let timer = 0
async function load() {
info.value = await http.get('/vip/info')
}
// 我的当前等级0=非VIP
const myLevel = computed(() => info.value?.my.level || 0)
// 周免刷新倒计时文案X天X小时X分
const countdown = computed(() => {
if (!info.value) return ''
let s = info.value.refresh_at - nowTs.value
if (s <= 0) return '刷新中…'
const d = Math.floor(s / 86400)
s %= 86400
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
return `${d}${h}小时${m}`
})
// 到期日期文案
const expireText = computed(() => {
if (!info.value || !info.value.my.active) return ''
return new Date(info.value.my.expire * 1000).toLocaleDateString('zh-CN')
})
// 各等级按钮文案与可用性
function btnState(l) {
if (myLevel.value === 0) return { text: `开通 30 天`, disabled: false }
if (l.level < myLevel.value) return { text: '低于当前等级', disabled: true }
if (l.level === myLevel.value) return { text: '续费 30 天', disabled: false }
return { text: '升级到此档', disabled: false }
}
// 点击购买先弹确认升级会按30天重新计时提示清楚再扣分
function askBuy(l) {
const st = btnState(l)
if (st.disabled || buying.value) return
confirmTarget.value = l
}
async function doBuy() {
const l = confirmTarget.value
confirmTarget.value = null
buying.value = l.level
try {
const res = await http.post('/vip/buy', { level: l.level })
userStore.setPoints(res.balance)
toast(`已开通 ${l.name},有效期至 ${new Date(res.expire * 1000).toLocaleDateString('zh-CN')}`, 'success')
await load()
} catch (e) {
// 拦截器已提示(余额不足等)
} finally {
buying.value = 0
}
}
// 点周免游戏直接去玩
function goPlay(g) {
router.push(`/play/${g.code}`)
}
onMounted(() => {
load()
timer = setInterval(() => (nowTs.value = Math.floor(Date.now() / 1000)), 30000)
})
onBeforeUnmount(() => clearInterval(timer))
</script>
<template>
<div v-if="info">
<h2 class="page-title">VIP 会员中心</h2>
<!-- 我的会员状态 + 周免刷新倒计时 -->
<div class="panel my-card">
<div class="my-left">
<template v-if="info.my.active">
<span class="my-icon">{{ info.levels.find(l => l.level === myLevel)?.icon || '💎' }}</span>
<div>
<div class="my-name">{{ info.my.level_name }}VIP{{ myLevel }}</div>
<div class="text-dim my-sub">有效期至 {{ expireText }}</div>
</div>
</template>
<template v-else>
<span class="my-icon">🪪</span>
<div>
<div class="my-name">尚未开通 VIP</div>
<div class="text-dim my-sub">开通后每周白嫖付费游戏签到还有加成</div>
</div>
</template>
</div>
<div class="my-right">
<div class="text-dim refresh-label"> 周免刷新倒计时每周五 12:00</div>
<div class="refresh-count">{{ countdown }}</div>
</div>
</div>
<!-- 等级卡片 -->
<div class="levels">
<div
v-for="l in info.levels"
:key="l.level"
class="panel level-card"
:class="{ mine: l.level === myLevel }"
>
<span v-if="l.level === myLevel" class="mine-badge">当前</span>
<div class="lv-head">
<span class="lv-icon">{{ l.icon }}</span>
<div class="lv-name">{{ l.name }}</div>
<div class="lv-tier text-dim">VIP{{ l.level }}</div>
</div>
<div class="lv-price"><b>{{ l.price_points }}</b> 积分 / 30</div>
<ul class="lv-benefits">
<li> 每周 <b>{{ l.weekly_free_quota }}</b> 款周免付费游戏</li>
<li>📅 签到每日额外 <b>+{{ l.signin_bonus }}</b> 积分</li>
</ul>
<!-- 本周周免批次 -->
<div class="weekly">
<div class="weekly-title text-dim">本周周免</div>
<div class="weekly-list">
<span
v-for="g in l.weekly_games"
:key="g.id"
class="weekly-chip"
:title="`原价 ${g.price} 积分点击直接去玩需该等级VIP`"
@click="myLevel >= l.level ? goPlay(g) : null"
:class="{ locked: myLevel < l.level }"
>
{{ g.icon }} {{ g.name }}
</span>
</div>
</div>
<button
class="btn lv-btn"
:class="{ 'btn-accent': l.level === myLevel }"
:disabled="btnState(l).disabled || buying === l.level"
@click="askBuy(l)"
>
{{ buying === l.level ? '开通中…' : btnState(l).text }}
</button>
</div>
</div>
<p class="text-dim tips">
说明周免批次每周五中午 12:00 自动轮换高等级批次包含低等级全部周免同等级续费在原到期时间上顺延升级立即生效并按 30 天重新计时
</p>
<!-- 购买确认弹窗 -->
<div v-if="confirmTarget" class="modal-mask" @click.self="confirmTarget = null">
<div class="modal panel confirm">
<h3>{{ confirmTarget.icon }} {{ confirmTarget.name }}</h3>
<p class="text-dim confirm-desc">
将花费 <b class="text-primary">{{ confirmTarget.price_points }}</b> 积分开通 30
<template v-if="myLevel > 0 && confirmTarget.level > myLevel">升级立即生效剩余天数不折算</template>
<template v-else-if="confirmTarget.level === myLevel">在原到期时间上顺延 30 </template>
</p>
<p class="text-dim confirm-desc">当前余额💰 {{ userStore.user?.points ?? 0 }}</p>
<div class="confirm-btns">
<button class="btn" style="flex: 1" @click="doBuy">确认开通</button>
<button class="btn btn-ghost" @click="confirmTarget = null">再想想</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.my-card {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding: 18px 22px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.my-left {
display: flex;
align-items: center;
gap: 14px;
}
.my-icon {
font-size: 42px;
}
.my-name {
font-size: 18px;
font-weight: 800;
}
.my-sub {
font-size: 12px;
margin-top: 4px;
}
.my-right {
text-align: right;
}
.refresh-label {
font-size: 12px;
}
.refresh-count {
font-size: 20px;
font-weight: 800;
color: var(--primary-2);
margin-top: 4px;
}
.levels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px;
}
.level-card {
position: relative;
padding: 18px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.level-card.mine {
border-color: var(--primary);
box-shadow: var(--glow);
}
.mine-badge {
position: absolute;
top: 10px;
right: 10px;
font-size: 11px;
background: var(--primary);
color: #fff;
padding: 1px 8px;
border-radius: 999px;
font-weight: 700;
}
.lv-head {
text-align: center;
}
.lv-icon {
font-size: 36px;
display: block;
}
.lv-name {
font-weight: 800;
font-size: 16px;
margin-top: 6px;
}
.lv-tier {
font-size: 11px;
}
.lv-price {
text-align: center;
font-size: 13px;
color: var(--primary-2);
}
.lv-price b {
font-size: 20px;
}
.lv-benefits {
list-style: none;
font-size: 12px;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 0;
border-top: 1px dashed var(--border);
border-bottom: 1px dashed var(--border);
}
.weekly {
flex: 1;
}
.weekly-title {
font-size: 11px;
margin-bottom: 6px;
}
.weekly-list {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.weekly-chip {
font-size: 11px;
background: var(--bg-card);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 999px;
cursor: pointer;
transition: all 0.15s;
}
.weekly-chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.weekly-chip.locked {
opacity: 0.55;
cursor: not-allowed;
}
.lv-btn {
width: 100%;
}
.tips {
font-size: 12px;
margin-top: 14px;
line-height: 1.8;
}
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.confirm {
width: min(380px, 90vw);
padding: 22px;
}
.confirm-desc {
font-size: 13px;
margin: 10px 0;
line-height: 1.7;
}
.confirm-btns {
display: flex;
gap: 10px;
margin-top: 14px;
}
</style>

293
src/views/admin/AdminAI.vue Normal file
View File

@@ -0,0 +1,293 @@
<script setup>
// 后台 AI 设置:选择全站统一的对战 AI 模型(玩家前台只选难度),
// 配置讯飞星火 / DeepSeek 的 API Key并可一键测试连通性
// 保存到数据库后立即生效(覆盖 config.yaml 同名项大模型不可用时对战自动用本地规则AI兜底
import { ref, onMounted } from 'vue'
import http, { toast } from '../../api/http'
import NavIcon from '../../components/NavIcon.vue'
const conf = ref(null) // {provider, spark, deepseek, guide}
const saving = ref(false)
const testing = ref('') // 正在测试的提供方
const testResult = ref({}) // provider -> {ok, text}
// 可选的全站AI模型
const providerOpts = [
{ code: 'rule', name: '本地规则 AI', icon: 'brain', desc: '内置算法,零成本秒级响应,无需任何配置' },
{ code: 'spark', name: '讯飞星火 Lite', icon: 'flame', desc: '大模型陪玩会聊天Lite 免费(需配置 APIPassword' },
{ code: 'deepseek', name: 'DeepSeek', icon: 'whale', desc: '大模型陪玩棋风犀利,按 token 计费(需配置 Key' },
]
async function load() {
conf.value = await http.get('/admin/ai')
}
// 保存全站模型选择与两家配置
async function save() {
saving.value = true
try {
const res = await http.put('/admin/ai', {
provider: conf.value.provider,
spark: {
api_key: conf.value.spark.api_key,
base_url: conf.value.spark.base_url,
model: conf.value.spark.model,
},
deepseek: {
api_key: conf.value.deepseek.api_key,
base_url: conf.value.deepseek.base_url,
model: conf.value.deepseek.model,
},
})
conf.value = { ...conf.value, ...res }
toast('AI 配置已保存,立即生效', 'success')
} finally {
saving.value = false
}
}
// 测试连通性:后端会真实调用一次大模型
async function test(provider) {
testing.value = provider
testResult.value = { ...testResult.value, [provider]: null }
try {
// 放宽超时真实调用大模型可能较慢服务端上限15秒
const res = await http.post('/admin/ai/test', { provider }, { timeout: 20000 })
testResult.value = {
...testResult.value,
[provider]: { ok: true, text: `连接成功(${res.latency_ms}ms${res.reply}` },
}
} catch (e) {
testResult.value = {
...testResult.value,
[provider]: { ok: false, text: e?.message || '连接失败' },
}
} finally {
testing.value = ''
}
}
onMounted(load)
</script>
<template>
<div v-if="conf">
<h2 class="page-title">AI 设置</h2>
<div class="panel intro">
<p>全站统一使用下方选择的 AI 模型陪玩<b>玩家前台只选择难度</b>选了大模型但未配置 Key调用超时或返回非法着法时自动降级为<b>本地规则AI</b>游戏永远可玩</p>
<p class="text-dim">大模型目前用于斗地主 / 中国象棋的决策与台词大富翁飞行棋2D台球始终由内置规则 AI 执行配置保存在数据库优先级高于后端 config.yaml保存后立即生效已开局的房间不受影响</p>
</div>
<!-- 全站AI模型选择 -->
<div class="panel provider-panel">
<h3 class="sec-head"><NavIcon name="robot" :size="16" /> 对战使用的 AI 模型</h3>
<div class="provider-list">
<button
v-for="p in providerOpts"
:key="p.code"
class="provider-opt"
:class="{ on: conf.provider === p.code }"
@click="conf.provider = p.code"
>
<span class="p-icon"><NavIcon :name="p.icon" :size="22" /></span>
<b>{{ p.name }}</b>
<span class="p-desc text-dim">{{ p.desc }}</span>
<span v-if="conf.provider === p.code" class="p-cur">使用中</span>
</button>
</div>
</div>
<div class="cards">
<!-- 讯飞星火 -->
<div class="panel ai-card">
<div class="ai-head">
<h3><NavIcon name="flame" :size="16" /> 讯飞星火 Lite</h3>
<span class="src" :class="{ ok: conf.spark.key_source !== '未配置' }">{{ conf.spark.key_source }}</span>
</div>
<p class="guide text-dim">{{ conf.guide.spark }}</p>
<label class="field">
<span>APIPassword</span>
<input v-model="conf.spark.api_key" class="input" placeholder="控制台复制的 APIPassword" />
</label>
<label class="field">
<span>接口地址</span>
<input v-model="conf.spark.base_url" class="input" placeholder="https://spark-api-open.xf-yun.com/v1" />
</label>
<label class="field">
<span>模型名</span>
<input v-model="conf.spark.model" class="input" placeholder="lite" />
</label>
<button class="btn btn-ghost btn-sm test-btn" :disabled="testing === 'spark'" @click="test('spark')">
<NavIcon name="plug" :size="14" />{{ testing === 'spark' ? '测试中…' : '测试连接' }}
</button>
<p v-if="testResult.spark" class="test-line" :class="testResult.spark.ok ? 'ok' : 'bad'">
{{ testResult.spark.text }}
</p>
</div>
<!-- DeepSeek -->
<div class="panel ai-card">
<div class="ai-head">
<h3><NavIcon name="whale" :size="16" /> DeepSeek</h3>
<span class="src" :class="{ ok: conf.deepseek.key_source !== '未配置' }">{{ conf.deepseek.key_source }}</span>
</div>
<p class="guide text-dim">{{ conf.guide.deepseek }}</p>
<label class="field">
<span>API Key</span>
<input v-model="conf.deepseek.api_key" class="input" placeholder="sk- 开头的密钥" />
</label>
<label class="field">
<span>接口地址</span>
<input v-model="conf.deepseek.base_url" class="input" placeholder="https://api.deepseek.com/v1" />
</label>
<label class="field">
<span>模型名</span>
<input v-model="conf.deepseek.model" class="input" placeholder="deepseek-chat" />
</label>
<button class="btn btn-ghost btn-sm test-btn" :disabled="testing === 'deepseek'" @click="test('deepseek')">
<NavIcon name="plug" :size="14" />{{ testing === 'deepseek' ? '测试中…' : '测试连接' }}
</button>
<p v-if="testResult.deepseek" class="test-line" :class="testResult.deepseek.ok ? 'ok' : 'bad'">
{{ testResult.deepseek.text }}
</p>
</div>
</div>
<button class="btn save-btn" :disabled="saving" @click="save">
<NavIcon name="save" :size="15" />{{ saving ? '保存中…' : '保存全部配置' }}
</button>
<p class="text-dim foot">提示清空 Key 保存后会回退使用 config.yaml 里的值两处都为空则该提供方不可用对战自动使用规则AI</p>
</div>
</template>
<style scoped>
.intro {
padding: 14px 18px;
font-size: 13px;
line-height: 1.9;
margin-bottom: 16px;
}
.provider-panel {
padding: 16px 18px;
margin-bottom: 16px;
}
.sec-head {
font-size: 14px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.sec-head svg,
.ai-head h3 svg {
color: var(--primary-2);
flex-shrink: 0;
}
.test-btn,
.save-btn {
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
}
.provider-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 10px;
}
.provider-opt {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text);
cursor: pointer;
font-size: 14px;
text-align: left;
transition: all 0.15s;
}
.provider-opt.on {
border-color: var(--accent);
box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 40%, transparent);
}
.p-icon {
color: var(--primary-2);
display: inline-flex;
}
.p-desc {
font-size: 12px;
line-height: 1.5;
}
.p-cur {
position: absolute;
top: 10px;
right: 12px;
font-size: 11px;
font-weight: 800;
color: var(--accent);
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 14px;
}
.ai-card {
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ai-head {
display: flex;
justify-content: space-between;
align-items: center;
}
.ai-head h3 {
font-size: 15px;
display: flex;
align-items: center;
gap: 6px;
}
.src {
font-size: 11px;
padding: 2px 10px;
border-radius: 999px;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-dim);
}
.src.ok {
color: #4ade80;
border-color: #2f9e6e;
}
.guide {
font-size: 12px;
line-height: 1.7;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12px;
color: var(--text-dim);
}
.test-line {
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
.test-line.ok {
color: #4ade80;
}
.test-line.bad {
color: #ff6b6b;
}
.save-btn {
margin-top: 16px;
}
.foot {
font-size: 12px;
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,358 @@
<script setup>
// 后台数据概览:核心指标卡片 + 运营分析(日活趋势/游玩时长/最火游戏)+ 站点信息设置
import { ref, computed, onMounted } from 'vue'
import http, { toast } from '../../api/http'
import { useThemeStore } from '../../stores/theme'
import GameIcon from '../../components/GameIcon.vue'
import NavIcon from '../../components/NavIcon.vue'
const theme = useThemeStore()
const stats = ref(null)
const ana = ref(null)
const siteName = ref('')
const announcement = ref('')
const saving = ref(false)
const cards = [
{ key: 'user_count', name: '用户总数', icon: 'users' },
{ key: 'game_count', name: '上架游戏', icon: 'lobby' },
{ key: 'order_count', name: '订单总数', icon: 'orders' },
{ key: 'today_plays', name: '今日游玩局数', icon: 'play' },
{ key: 'today_signs', name: '今日签到人数', icon: 'signin' },
{ key: 'today_points', name: '今日发放积分', icon: 'coin' },
]
// 秒 → 「x小时y分」/「y分z秒」可读文本
function fmtDur(sec) {
sec = Math.max(0, Math.round(sec || 0))
if (sec >= 3600) return `${Math.floor(sec / 3600)}小时${Math.floor((sec % 3600) / 60)}`
if (sec >= 60) return `${Math.floor(sec / 60)}${sec % 60}`
return `${sec}`
}
// 日活对比昨日的变化标签
const dauDelta = computed(() => {
if (!ana.value) return null
const t = ana.value.dau_today, y = ana.value.dau_yesterday
if (y === 0) return t > 0 ? { text: '新增', up: true } : null
const pct = Math.round(((t - y) / y) * 100)
return { text: `${pct >= 0 ? '+' : ''}${pct}% 较昨日`, up: pct >= 0 }
})
// 趋势图柱高(按最大值归一化,最矮保留 4% 便于看到有数据的日子)
const trendMax = computed(() => Math.max(1, ...(ana.value?.dau_trend || []).map((d) => d.count)))
function barH(n) {
return n === 0 ? 2 : Math.max(4, Math.round((n / trendMax.value) * 100))
}
async function load() {
stats.value = await http.get('/admin/stats')
siteName.value = theme.siteName
announcement.value = theme.announcement
ana.value = await http.get('/admin/analytics')
}
// 保存站点名与公告,保存后刷新全站配置
async function saveSite() {
saving.value = true
try {
await http.put('/admin/site', { site_name: siteName.value, announcement: announcement.value })
toast('已保存', 'success')
await theme.fetchConfig()
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div>
<h2 class="page-title">数据概览</h2>
<div v-if="stats" class="stat-grid">
<div v-for="c in cards" :key="c.key" class="panel stat-card">
<span class="stat-icon"><NavIcon :name="c.icon" :size="30" /></span>
<div>
<div class="stat-num">{{ stats[c.key] }}</div>
<div class="stat-name text-dim">{{ c.name }}</div>
</div>
</div>
</div>
<!-- 运营分析 -->
<template v-if="ana">
<div class="stat-grid">
<div class="panel stat-card">
<span class="stat-icon"><NavIcon name="trend" :size="30" /></span>
<div>
<div class="stat-num">
{{ ana.dau_today }}
<small v-if="dauDelta" class="delta" :class="dauDelta.up ? 'up' : 'down'">{{ dauDelta.text }}</small>
</div>
<div class="stat-name text-dim">今日日活去重用户</div>
</div>
</div>
<div class="panel stat-card">
<span class="stat-icon online"><NavIcon name="pulse" :size="30" /></span>
<div>
<div class="stat-num">{{ ana.online_now }}</div>
<div class="stat-name text-dim">当前在线人数</div>
</div>
</div>
<div class="panel stat-card">
<span class="stat-icon"><NavIcon name="clock" :size="30" /></span>
<div>
<div class="stat-num">{{ fmtDur(ana.play_seconds_today) }}</div>
<div class="stat-name text-dim">今日游玩总时长{{ ana.plays_today }} </div>
</div>
</div>
<div class="panel stat-card">
<span class="stat-icon"><NavIcon name="hourglass" :size="30" /></span>
<div>
<div class="stat-num">{{ fmtDur(ana.avg_seconds) }}</div>
<div class="stat-name text-dim">平均单局时长累计 {{ fmtDur(ana.play_seconds_total) }}</div>
</div>
</div>
</div>
<div class="ana-row">
<!-- 日活趋势 -->
<div class="panel trend-panel">
<h3 class="form-title"><NavIcon name="trend" :size="16" /> 14 天日活趋势</h3>
<div class="trend-chart">
<div v-for="d in ana.dau_trend" :key="d.full" class="trend-col" :title="`${d.full}${d.count} 人`">
<span class="trend-num" v-if="d.count">{{ d.count }}</span>
<span class="trend-bar" :style="{ height: barH(d.count) + '%' }"></span>
<span class="trend-date">{{ d.date }}</span>
</div>
</div>
</div>
<!-- 最火游戏 -->
<div class="panel hot-panel">
<h3 class="form-title"><NavIcon name="flame" :size="16" /> 最火游戏 7 天局数</h3>
<div v-if="!ana.hot_games.length" class="text-dim hot-empty"> 7 天还没有游玩数据</div>
<div v-for="(g, i) in ana.hot_games" :key="g.game_id" class="hot-row">
<span class="hot-rank" :class="'r' + (i + 1)">{{ i + 1 }}</span>
<GameIcon class="hot-icon" :code="g.code" :icon="g.icon" :size="22" />
<span class="hot-mid">
<b>{{ g.name }}</b>
<small class="text-dim">{{ g.category }} · 平均 {{ fmtDur(g.avg_seconds) }}/ · 历史 {{ g.play_count }} </small>
</span>
<span class="hot-plays">{{ g.plays_7d }} </span>
</div>
</div>
</div>
</template>
<div class="panel site-form">
<h3 class="form-title"><NavIcon name="tag" :size="16" /> 站点信息</h3>
<div class="form-row">
<label>站点名称</label>
<input v-model="siteName" class="input" maxlength="20" placeholder="显示在导航栏与标题" />
</div>
<div class="form-row">
<label>大厅公告</label>
<textarea v-model="announcement" class="input" rows="3" maxlength="200" placeholder="显示在游戏大厅顶部"></textarea>
</div>
<button class="btn" :disabled="saving" @click="saveSite">{{ saving ? '保存中' : '保存修改' }}</button>
</div>
</div>
</template>
<style scoped>
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.stat-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--primary-2);
background: color-mix(in srgb, var(--primary) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--primary) 25%, transparent);
}
.stat-icon.online {
color: #4ade80;
background: rgba(74, 222, 128, 0.1);
border-color: rgba(74, 222, 128, 0.25);
}
.stat-num {
font-size: 26px;
font-weight: 800;
color: var(--primary-2);
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.stat-name {
font-size: 12px;
}
.delta {
font-size: 11px;
font-weight: 700;
padding: 2px 6px;
border-radius: 999px;
}
.delta.up {
color: #4ade80;
background: rgba(74, 222, 128, 0.12);
}
.delta.down {
color: #ff8a8e;
background: rgba(229, 72, 77, 0.12);
}
.ana-row {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 14px;
margin-bottom: 16px;
}
@media (max-width: 1000px) {
.ana-row {
grid-template-columns: 1fr;
}
}
.trend-chart {
display: flex;
align-items: flex-end;
gap: 6px;
height: 190px;
padding-top: 18px;
}
.trend-col {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
gap: 4px;
min-width: 0;
}
.trend-num {
font-size: 10px;
color: var(--text-dim);
}
.trend-bar {
width: 100%;
max-width: 26px;
border-radius: 4px 4px 0 0;
background: linear-gradient(180deg, var(--primary-2), var(--primary));
box-shadow: var(--glow);
transition: height 0.4s;
}
.trend-date {
font-size: 10px;
color: var(--text-dim);
white-space: nowrap;
transform: scale(0.9);
}
.hot-empty {
padding: 30px 0;
text-align: center;
font-size: 13px;
}
.hot-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px dashed var(--border);
}
.hot-row:last-child {
border-bottom: none;
}
.hot-rank {
width: 22px;
height: 22px;
border-radius: 6px;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-dim);
font-size: 12px;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hot-rank.r1 {
background: linear-gradient(135deg, #f5c518, #e8940a);
color: #201500;
border-color: transparent;
}
.hot-rank.r2 {
background: linear-gradient(135deg, #d7dde4, #9aa7b5);
color: #1b222b;
border-color: transparent;
}
.hot-rank.r3 {
background: linear-gradient(135deg, #e0955c, #b06030);
color: #2b1200;
border-color: transparent;
}
.hot-icon {
font-size: 22px;
}
.hot-mid {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.hot-mid b {
font-size: 13px;
}
.hot-mid small {
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hot-plays {
font-weight: 800;
color: var(--primary-2);
font-size: 14px;
flex-shrink: 0;
}
.site-form {
max-width: 560px;
}
.form-title {
font-size: 15px;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.form-title svg {
color: var(--primary-2);
flex-shrink: 0;
}
.form-row {
margin-bottom: 12px;
}
.form-row label {
display: block;
font-size: 13px;
color: var(--text-dim);
margin-bottom: 6px;
}
textarea.input {
resize: vertical;
font-family: inherit;
}
</style>

View File

@@ -0,0 +1,439 @@
<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>

View File

@@ -0,0 +1,106 @@
<script setup>
// 后台管理布局:左侧菜单 + 右侧内容区(菜单图标全部为手绘 SVG
import { useRoute } from 'vue-router'
import NavIcon from '../../components/NavIcon.vue'
const route = useRoute()
const menus = [
{ path: '/admin', name: '数据概览', icon: 'stats' },
{ path: '/admin/themes', name: '主题设置', icon: 'palette' },
{ path: '/admin/users', name: '用户管理', icon: 'users' },
{ path: '/admin/games', name: '游戏管理', icon: 'lobby' },
{ path: '/admin/orders', name: '订单管理', icon: 'orders' },
{ path: '/admin/vip', name: 'VIP设置', icon: 'vip' },
{ path: '/admin/ai', name: 'AI 设置', icon: 'robot' },
{ path: '/admin/version', name: '桌面版本', icon: 'download' },
]
</script>
<template>
<div class="admin-layout">
<aside class="admin-side panel">
<h3 class="admin-title"><NavIcon name="admin" :size="17" /> 管理后台</h3>
<nav class="admin-nav">
<RouterLink
v-for="m in menus"
:key="m.path"
:to="m.path"
class="admin-link"
:class="{ on: route.path === m.path }"
>
<NavIcon :name="m.icon" :size="18" />{{ m.name }}
</RouterLink>
</nav>
</aside>
<main class="admin-main">
<RouterView />
</main>
</div>
</template>
<style scoped>
.admin-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 16px;
align-items: start;
}
.admin-side {
padding: 16px 12px;
position: sticky;
top: 76px;
}
.admin-title {
font-size: 15px;
padding: 0 8px 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 6px;
}
.admin-title svg {
color: var(--primary-2);
flex-shrink: 0;
}
.admin-link svg {
flex-shrink: 0;
opacity: 0.85;
}
.admin-nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.admin-link {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 10px;
color: var(--text-dim);
text-decoration: none;
font-size: 14px;
transition: all 0.15s;
}
.admin-link:hover {
background: var(--bg-card);
color: var(--text);
}
.admin-link.on {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
}
@media (max-width: 800px) {
.admin-layout {
grid-template-columns: 1fr;
}
.admin-side {
position: static;
}
.admin-nav {
flex-direction: row;
flex-wrap: wrap;
}
}
</style>

View File

@@ -0,0 +1,232 @@
<script setup>
// 订单管理:订单号 / 用户账号搜索 + 时间范围筛选 + 筛选结果积分合计
// 列表「批量拉取 + 虚拟滚动」:一次拉 200 条只渲染可视行,滚动到底自动续拉
import { ref, onMounted } from 'vue'
import http from '../../api/http'
import NavIcon from '../../components/NavIcon.vue'
import VirtualList from '../../components/VirtualList.vue'
const BATCH = 200
const list = ref([])
const total = ref(0)
const sumPoints = ref(0) // 当前筛选条件下的订单积分合计(后端聚合)
const orderNo = ref('')
const userKw = ref('')
const rangeKey = ref('') // ''=全部 today=今天 7d=近7天 30d=近30天
const loading = ref(false)
const listRef = ref(null)
let nextPage = 1
const RANGES = [
{ value: '', label: '全部' },
{ value: 'today', label: '今天' },
{ value: '7d', label: '近 7 天' },
{ value: '30d', label: '近 30 天' },
]
async function load(reset = false) {
if (loading.value) return
if (reset) nextPage = 1
else if (nextPage > 1 && list.value.length >= total.value) return
loading.value = true
try {
const data = await http.get('/admin/orders', {
params: {
page: nextPage,
size: BATCH,
order_no: orderNo.value.trim(),
user: userKw.value.trim(),
range: rangeKey.value,
},
})
list.value = reset ? data.list : [...list.value, ...data.list]
total.value = data.total
sumPoints.value = data.sum_points || 0
nextPage++
} finally {
loading.value = false
}
}
function search() {
listRef.value?.scrollToTop()
load(true)
}
function resetSearch() {
orderNo.value = ''
userKw.value = ''
rangeKey.value = ''
search()
}
function setRange(r) {
rangeKey.value = r
search()
}
function fmtTime(ts) {
if (!ts) return '-'
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
onMounted(() => load(true))
</script>
<template>
<div>
<h2 class="page-title">订单管理</h2>
<!-- 搜索 + 时间范围筛选 -->
<div class="toolbar">
<input v-model="orderNo" class="input" placeholder="搜索订单号" style="max-width: 220px" @keyup.enter="search" />
<input v-model="userKw" class="input" placeholder="搜索用户账号" style="max-width: 180px" @keyup.enter="search" />
<button class="btn btn-sm" @click="search">搜索</button>
<button v-if="orderNo || userKw || rangeKey" class="btn btn-ghost btn-sm" @click="resetSearch">重置</button>
<div class="range-tabs">
<button v-for="r in RANGES" :key="r.value" class="chip" :class="{ on: rangeKey === r.value }" @click="setRange(r.value)">
{{ r.label }}
</button>
</div>
</div>
<!-- 筛选结果汇总 -->
<div class="sum-bar text-dim">
筛选结果<b>{{ total }}</b> 合计
<b class="sum-points"><NavIcon name="coin" :size="14" /> {{ sumPoints }}</b> 积分
</div>
<div class="panel list-wrap">
<div class="hscroll">
<div class="tmin">
<div class="orow ohead text-dim">
<span>订单号</span><span>用户</span><span>商品</span><span>金额积分</span><span>状态</span><span>下单时间</span>
</div>
<VirtualList ref="listRef" :items="list" :item-height="46" :max-height="560" @reach-end="load()">
<template #default="{ item: o }">
<div class="orow">
<span class="mono">{{ o.order_no }}</span>
<span class="c-user">
{{ o.nickname }}
<small class="text-dim">@{{ o.username }}</small>
</span>
<span class="text-dim remark" :title="o.remark">{{ o.remark }}</span>
<span class="amount"><NavIcon name="coin" :size="13" /> {{ o.total_points }}</span>
<span class="st-ok">已完成</span>
<span class="text-dim time">{{ fmtTime(o.created_at) }}</span>
</div>
</template>
<template #empty><span class="text-dim">暂无订单</span></template>
</VirtualList>
</div>
</div>
<div class="list-foot text-dim">
已加载 {{ list.length }} / {{ total }} <span v-if="loading"> · 加载中</span>
<span v-else-if="list.length && list.length < total"> · 滚动到底部自动加载更多</span>
</div>
</div>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 10px;
flex-wrap: wrap;
align-items: center;
}
.range-tabs {
display: flex;
gap: 6px;
}
.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;
}
.sum-bar {
font-size: 13px;
margin-bottom: 10px;
}
.sum-bar b {
color: var(--text);
}
.sum-points {
color: var(--primary-2) !important;
display: inline-flex;
align-items: center;
gap: 3px;
}
.list-wrap {
padding: 6px 14px 12px;
}
.hscroll {
overflow-x: auto;
}
.tmin {
min-width: 900px;
}
.orow {
display: grid;
grid-template-columns: 180px 1.2fr 1.8fr 110px 60px 150px;
gap: 8px;
align-items: center;
height: 100%;
border-bottom: 1px dashed var(--border);
font-size: 13px;
padding: 0 2px;
}
.ohead {
height: 38px;
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.mono {
font-family: monospace;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c-user {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c-user small {
font-size: 11px;
}
.remark {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.amount {
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--primary-2);
font-weight: 700;
}
.st-ok {
color: #4ade80;
}
.time {
font-size: 12px;
white-space: nowrap;
}
.list-foot {
font-size: 12px;
text-align: center;
padding-top: 10px;
}
</style>

View File

@@ -0,0 +1,164 @@
<script setup>
// 主题设置:预览全部主题,一键切换全站生效
import { ref, onMounted } from 'vue'
import http, { toast } from '../../api/http'
import { useThemeStore } from '../../stores/theme'
const themeStore = useThemeStore()
const themes = ref([])
const active = ref('')
const switching = ref('')
async function load() {
const data = await http.get('/admin/themes')
themes.value = data.list
active.value = data.active
}
// 切换主题:写库后重新拉配置,本端立即生效(其他用户刷新/下次拉取生效)
async function switchTheme(code) {
if (code === active.value) return
switching.value = code
try {
await http.post('/admin/theme', { code })
active.value = code
await themeStore.fetchConfig()
toast('主题已切换,全站生效', 'success')
} finally {
switching.value = ''
}
}
// 解析主题的 CSS 变量 JSON 用于色卡预览
function varsOf(t) {
try {
return JSON.parse(t.css_vars || '{}')
} catch (e) {
return {}
}
}
onMounted(load)
</script>
<template>
<div>
<h2 class="page-title">主题设置</h2>
<p class="text-dim" style="font-size: 13px; margin-bottom: 16px">
切换后立即写入站点配置所有用户下次加载页面即应用新主题
</p>
<div class="theme-grid">
<div v-for="t in themes" :key="t.code" class="panel theme-card" :class="{ on: active === t.code }">
<!-- 色卡预览 -->
<div
class="preview"
:style="{
background: varsOf(t)['--bg'] || '#111',
borderColor: varsOf(t)['--border'] || '#333',
}"
>
<div class="pv-bar" :style="{ background: varsOf(t)['--bg-panel'] || '#222' }">
<span class="pv-dot" :style="{ background: varsOf(t)['--primary'] || '#4f8cff' }"></span>
<span class="pv-dot" :style="{ background: varsOf(t)['--accent'] || '#ffd500' }"></span>
</div>
<div class="pv-body">
<div class="pv-card" :style="{ background: varsOf(t)['--bg-card'] || '#1a1a2a' }">
<div class="pv-line" :style="{ background: varsOf(t)['--primary'] || '#4f8cff' }"></div>
<div class="pv-line short" :style="{ background: varsOf(t)['--text-dim'] || '#888' }"></div>
</div>
<div
class="pv-btn"
:style="{ background: `linear-gradient(135deg, ${varsOf(t)['--primary'] || '#4f8cff'}, ${varsOf(t)['--primary-2'] || '#8cb8ff'})` }"
></div>
</div>
</div>
<div class="theme-info">
<div>
<b>{{ t.name }}</b>
<span class="text-dim code">{{ t.code }}</span>
</div>
<p class="text-dim desc">{{ t.description }}</p>
<button
class="btn btn-sm"
:class="{ 'btn-ghost': active !== t.code }"
:disabled="active === t.code || switching === t.code"
@click="switchTheme(t.code)"
>
{{ active === t.code ? '✓ 使用中' : switching === t.code ? '切换中…' : '启用主题' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 14px;
}
.theme-card {
padding: 0;
overflow: hidden;
transition: box-shadow 0.2s;
}
.theme-card.on {
box-shadow: 0 0 0 2px var(--primary), var(--glow);
}
.preview {
height: 130px;
border-bottom: 1px solid;
padding: 8px;
}
.pv-bar {
height: 20px;
border-radius: 6px;
display: flex;
align-items: center;
gap: 4px;
padding: 0 8px;
margin-bottom: 8px;
}
.pv-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.pv-body {
display: flex;
gap: 8px;
}
.pv-card {
flex: 1;
border-radius: 8px;
padding: 8px;
}
.pv-line {
height: 8px;
border-radius: 4px;
margin-bottom: 6px;
}
.pv-line.short {
width: 60%;
opacity: 0.5;
}
.pv-btn {
width: 46px;
height: 46px;
border-radius: 10px;
align-self: flex-end;
}
.theme-info {
padding: 12px 14px 14px;
}
.code {
font-size: 11px;
margin-left: 6px;
}
.desc {
font-size: 12px;
margin: 6px 0 10px;
min-height: 32px;
}
</style>

View File

@@ -0,0 +1,596 @@
<script setup>
// 用户管理:关键词搜索 + 状态筛选、封禁二次确认、积分管理(充值/扣除/调整 + 完整流水、VIP调整
// 列表采用「批量拉取 + 虚拟滚动」:一次拉 200 条只渲染可视行,滚动到底自动续拉,数据再多也不卡
import { ref, computed, onMounted } from 'vue'
import http, { toast } from '../../api/http'
import NavIcon from '../../components/NavIcon.vue'
import VirtualList from '../../components/VirtualList.vue'
import PixelAvatar from '../../components/PixelAvatar.vue'
const BATCH = 200 // 每批拉取条数
const list = ref([])
const total = ref(0)
const keyword = ref('')
const statusFilter = ref(0) // 0=全部 1=正常 2=封禁
const loading = ref(false)
const listRef = ref(null)
let nextPage = 1
const STATUS_TABS = [
{ value: 0, label: '全部' },
{ value: 1, label: '正常' },
{ value: 2, label: '封禁' },
]
// 拉取一批reset=true 表示条件变化从头拉,否则触底续拉下一批
async function load(reset = false) {
if (loading.value) return
if (reset) nextPage = 1
else if (nextPage > 1 && list.value.length >= total.value) return // 已全部加载
loading.value = true
try {
const data = await http.get('/admin/users', {
params: { page: nextPage, size: BATCH, keyword: keyword.value, status: statusFilter.value },
})
list.value = reset ? data.list : [...list.value, ...data.list]
total.value = data.total
nextPage++
} finally {
loading.value = false
}
}
function search() {
listRef.value?.scrollToTop()
load(true)
}
function setStatus(s) {
statusFilter.value = s
search()
}
// ---- 封禁 / 解封(封禁需 3 秒内二次点击确认,防误点)----
const confirmBanId = ref(0)
let confirmTimer = 0
function askToggleBan(u) {
if (u.status !== 1) {
doToggleBan(u) // 解封无破坏性,直接执行
return
}
if (confirmBanId.value === u.id) {
clearTimeout(confirmTimer)
confirmBanId.value = 0
doToggleBan(u)
return
}
confirmBanId.value = u.id
clearTimeout(confirmTimer)
confirmTimer = setTimeout(() => (confirmBanId.value = 0), 3000)
}
async function doToggleBan(u) {
const target = u.status === 1 ? 2 : 1
await http.put(`/admin/users/${u.id}`, { status: target })
toast(target === 2 ? `已封禁 ${u.nickname}` : `已解封 ${u.nickname}`, 'success')
u.status = target // 就地更新该行,保持滚动位置
}
// ---- 积分管理(弹窗 + 完整流水虚拟滚动)----
const adjustTarget = ref(null)
const adjustOp = ref('recharge')
const adjustValue = ref(null)
const adjustRemark = ref('')
const OPS = [
{ key: 'recharge', label: '充值积分', valLabel: '充值数量', hint: '为用户增加积分(正向流水,同时计入累计积分)' },
{ key: 'deduct', label: '扣除积分', valLabel: '扣除数量', hint: '从用户余额中扣减,余额不足会被拒绝' },
{ key: 'set', label: '调整积分', valLabel: '目标积分', hint: '直接把余额设定为目标值,按差额记一条流水' },
]
const curOp = computed(() => OPS.find((o) => o.key === adjustOp.value))
const setDelta = computed(() => Number(adjustValue.value || 0) - (adjustTarget.value?.points ?? 0))
// 流水同样批量拉取 + 虚拟滚动(替代翻页)
const FLOW_BATCH = 300
const flow = ref({ list: [], total: 0 })
const flowLoading = ref(false)
let flowPage = 1
const pointTypeNames = { 1: '每日签到', 2: '游戏奖励', 3: '商城消费', 4: '对战奖励', 5: '道具使用', 6: '注册赠送', 7: '后台调整', 8: '开通VIP' }
function openAdjust(u) {
adjustTarget.value = u
adjustOp.value = 'recharge'
adjustValue.value = null
adjustRemark.value = ''
loadFlow(true)
}
async function loadFlow(reset = false) {
if (flowLoading.value) return
if (reset) flowPage = 1
else if (flowPage > 1 && flow.value.list.length >= flow.value.total) return
flowLoading.value = true
try {
const data = await http.get(`/admin/users/${adjustTarget.value.id}/points`, {
params: { page: flowPage, size: FLOW_BATCH },
})
flow.value = {
list: reset ? data.list : [...flow.value.list, ...data.list],
total: data.total,
}
flowPage++
} finally {
flowLoading.value = false
}
}
async function doAdjust() {
const v = Number(adjustValue.value)
if (adjustOp.value === 'set') {
if (adjustValue.value === null || adjustValue.value === '' || Number.isNaN(v) || v < 0) {
toast('请输入不小于 0 的目标积分')
return
}
} else if (!v || v <= 0) {
toast(`请输入大于 0 的${curOp.value.valLabel}`)
return
}
await http.put(`/admin/users/${adjustTarget.value.id}`, {
points_op: adjustOp.value,
points_value: v,
remark: adjustRemark.value,
})
toast(`${curOp.value.label}成功`, 'success')
adjustValue.value = null
adjustRemark.value = ''
// 弹窗保持打开:重拉流水,用最新一条的余额同步弹窗头部与表格行(不整表重拉,保住滚动位置)
await loadFlow(true)
const balance = flow.value.list[0]?.balance
if (balance !== undefined) {
adjustTarget.value = { ...adjustTarget.value, points: balance }
const row = list.value.find((x) => x.id === adjustTarget.value.id)
if (row) row.points = balance
}
}
// ---- VIP 调整 ----
const vipTarget = ref(null)
const vipLevel = ref(0)
const vipAddDays = ref(0)
function openVip(u) {
vipTarget.value = u
vipLevel.value = u.vip_level || 0
vipAddDays.value = 0
}
async function doVip() {
await http.put(`/admin/users/${vipTarget.value.id}`, {
vip_level: Number(vipLevel.value),
vip_add_days: Number(vipAddDays.value) || 0,
})
toast('VIP已更新', 'success')
vipTarget.value = null
load(true) // 到期时间由后端计算,重拉保证显示准确
}
function vipText(u) {
if (!u.vip_level) return '-'
const expired = u.vip_expire * 1000 < Date.now()
const date = new Date(u.vip_expire * 1000).toLocaleDateString('zh-CN')
return expired ? `VIP${u.vip_level}(已过期)` : `VIP${u.vip_level}${date}`
}
function fmtTime(ts) {
if (!ts) return '-'
return new Date(ts * 1000).toLocaleString('zh-CN', { hour12: false })
}
onMounted(() => load(true))
</script>
<template>
<div>
<h2 class="page-title">用户管理</h2>
<div class="toolbar">
<input v-model="keyword" class="input" placeholder="搜索用户名 / 昵称" style="max-width: 260px" @keyup.enter="search" />
<button class="btn btn-sm" @click="search">搜索</button>
<div class="status-tabs">
<button
v-for="t in STATUS_TABS"
:key="t.value"
class="chip"
:class="{ on: statusFilter === t.value }"
@click="setStatus(t.value)"
>
{{ t.label }}
</button>
</div>
<span class="text-dim count"> {{ total }} </span>
</div>
<div class="panel list-wrap">
<div class="hscroll">
<div class="tmin">
<!-- 表头固定在虚拟列表外 -->
<div class="urow uhead text-dim">
<span>ID</span><span>用户</span><span>角色</span><span>积分</span><span>累计积分</span>
<span>VIP</span><span>状态</span><span>注册时间</span><span>操作</span>
</div>
<VirtualList ref="listRef" :items="list" :item-height="50" :max-height="560" @reach-end="load()">
<template #default="{ item: u }">
<div class="urow">
<span class="text-dim">{{ u.id }}</span>
<span class="c-user">
<i class="c-avatar"><PixelAvatar :code="u.avatar || 'px:01'" :size="22" /></i>
<span class="c-names">
<b>{{ u.nickname }}</b>
<small class="text-dim">@{{ u.username }}</small>
</span>
</span>
<span>
<span v-if="u.role === 1" class="tag-admin">超管</span>
<span v-else class="text-dim">普通</span>
</span>
<span>{{ u.points }}</span>
<span>{{ u.total_points }}</span>
<span class="vip-cell" :class="{ dim: !u.vip_level }">{{ vipText(u) }}</span>
<span :class="u.status === 1 ? 'st-ok' : 'st-ban'">{{ u.status === 1 ? '正常' : '封禁' }}</span>
<span class="text-dim time">{{ fmtTime(u.created_at) }}</span>
<span class="ops">
<button class="btn btn-ghost btn-sm" @click="openAdjust(u)">积分管理</button>
<button class="btn btn-ghost btn-sm" @click="openVip(u)">调VIP</button>
<button
v-if="u.role !== 1"
class="btn btn-sm"
:class="u.status === 1 ? 'btn-danger' : ''"
@click="askToggleBan(u)"
>
{{ confirmBanId === u.id ? '确认封禁' : u.status === 1 ? '封禁' : '解封' }}
</button>
</span>
</div>
</template>
<template #empty><span class="text-dim">暂无数据</span></template>
</VirtualList>
</div>
</div>
<div class="list-foot text-dim">
已加载 {{ list.length }} / {{ total }} <span v-if="loading"> · 加载中</span>
<span v-else-if="list.length && list.length < total"> · 滚动到底部自动加载更多</span>
</div>
</div>
<!-- 积分管理弹窗先选操作类型下方虚拟滚动展示该用户完整积分流水 -->
<div v-if="adjustTarget" class="modal-mask" @click.self="adjustTarget = null">
<div class="modal panel adjust-modal">
<button class="modal-close" @click="adjustTarget = null"></button>
<h3 style="margin-bottom: 4px">积分管理</h3>
<p class="text-dim" style="font-size: 13px; margin-bottom: 12px">
{{ adjustTarget.nickname }}
<span style="font-size: 11px">@{{ adjustTarget.username }}</span>
·当前余额 <b class="text-primary balance"><NavIcon name="coin" :size="14" /> {{ adjustTarget.points }}</b>
</p>
<div class="op-tabs">
<button v-for="op in OPS" :key="op.key" class="op-tab" :class="{ on: adjustOp === op.key }" @click="adjustOp = op.key">
{{ op.label }}
</button>
</div>
<p class="op-hint text-dim">{{ curOp.hint }}</p>
<div class="adjust-form">
<input v-model.number="adjustValue" type="number" min="0" class="input" :placeholder="curOp.valLabel" @keyup.enter="doAdjust" />
<input v-model="adjustRemark" class="input" placeholder="备注(选填,记入流水)" maxlength="50" @keyup.enter="doAdjust" />
<button class="btn" @click="doAdjust">确认{{ curOp.label.slice(0, 2) }}</button>
</div>
<p v-if="adjustOp === 'set' && adjustValue !== null && adjustValue !== ''" class="set-preview" :class="setDelta >= 0 ? 'gain' : 'cost'">
{{ adjustTarget.points }} {{ adjustValue }}变动 {{ setDelta >= 0 ? '+' : '' }}{{ setDelta }}
</p>
<!-- 完整积分流水虚拟滚动滚动到底自动续拉 -->
<h4 class="flow-title">
积分流水
<span class="text-dim" style="font-weight: 400; font-size: 12px">
{{ flow.total }} <template v-if="flow.list.length < flow.total">已加载 {{ flow.list.length }}</template>
</span>
</h4>
<div class="flow-wrap">
<div class="frow fhead text-dim">
<span>时间</span><span>类型</span><span>说明</span><span class="num">变动</span><span class="num">余额</span>
</div>
<VirtualList :items="flow.list" :item-height="36" :max-height="288" @reach-end="loadFlow()">
<template #default="{ item: r }">
<div class="frow">
<span class="text-dim flow-time">{{ fmtTime(r.created_at) }}</span>
<span><span class="flow-tag">{{ pointTypeNames[r.type] || '其他' }}</span></span>
<span class="flow-remark" :title="r.remark">{{ r.remark }}</span>
<span class="num" :class="r.change_points >= 0 ? 'gain' : 'cost'">
{{ r.change_points >= 0 ? '+' : '' }}{{ r.change_points }}
</span>
<span class="num text-dim">{{ r.balance }}</span>
</div>
</template>
<template #empty><span class="text-dim">暂无积分流水</span></template>
</VirtualList>
</div>
</div>
</div>
<!-- VIP调整弹窗 -->
<div v-if="vipTarget" class="modal-mask" @click.self="vipTarget = null">
<div class="modal panel">
<h3 style="margin-bottom: 4px">调整VIP</h3>
<p class="text-dim" style="font-size: 13px; margin-bottom: 14px">
{{ vipTarget.nickname }}当前 {{ vipText(vipTarget) }}
</p>
<label class="text-dim" style="font-size: 12px">VIP等级0=取消VIP</label>
<select v-model.number="vipLevel" class="input" style="margin: 6px 0 10px">
<option :value="0">0 - 非VIP</option>
<option v-for="n in 5" :key="n" :value="n">{{ n }} - VIP{{ n }}</option>
</select>
<label class="text-dim" style="font-size: 12px">增减天数正加负减设置新等级时默认已给30天</label>
<input v-model.number="vipAddDays" type="number" class="input" placeholder="0" style="margin: 6px 0 14px" />
<div style="display: flex; gap: 10px">
<button class="btn" style="flex: 1" @click="doVip">确认更新</button>
<button class="btn btn-ghost" @click="vipTarget = null">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 14px;
align-items: center;
flex-wrap: wrap;
}
.status-tabs {
display: flex;
gap: 6px;
}
.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;
}
.count {
font-size: 12px;
margin-left: auto;
}
.list-wrap {
padding: 6px 14px 12px;
}
.hscroll {
overflow-x: auto;
}
.tmin {
min-width: 950px;
}
/* 行式表格:表头与数据行共用同一 grid 模板,宽度天然对齐 */
.urow {
display: grid;
grid-template-columns: 44px 1.6fr 54px 70px 78px 1.2fr 46px 126px 218px;
gap: 8px;
align-items: center;
height: 100%;
border-bottom: 1px dashed var(--border);
font-size: 13px;
padding: 0 2px;
}
.uhead {
height: 38px;
font-size: 12px;
border-bottom: 1px solid var(--border);
}
.c-user {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.c-avatar {
font-style: normal;
font-size: 20px;
flex-shrink: 0;
}
.c-names {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.3;
}
.c-names b {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.c-names small {
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-admin {
background: linear-gradient(135deg, #ffb800, #ff6a00);
color: #fff;
font-size: 11px;
padding: 1px 8px;
border-radius: 999px;
}
.st-ok {
color: #4ade80;
}
.st-ban {
color: #ff6b6b;
}
.vip-cell {
font-size: 12px;
color: var(--primary-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.vip-cell.dim {
color: var(--text-dim);
}
.time {
font-size: 11px;
white-space: nowrap;
}
.ops {
display: flex;
gap: 6px;
}
/* 操作列按钮较多,压紧内边距避免在像素字体主题下换行 */
.ops .btn {
white-space: nowrap;
padding: 5px 8px;
}
.list-foot {
font-size: 12px;
text-align: center;
padding-top: 10px;
}
.balance {
display: inline-flex;
align-items: center;
gap: 3px;
}
.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;
}
/* ---- 积分管理弹窗 ---- */
.adjust-modal {
width: min(680px, 94vw);
position: relative;
max-height: 88vh;
overflow-y: auto;
}
.modal-close {
position: absolute;
top: 12px;
right: 14px;
border: 0;
background: transparent;
color: var(--text-dim);
font-size: 15px;
cursor: pointer;
}
.modal-close:hover {
color: var(--text);
}
.op-tabs {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.op-tab {
flex: 1;
padding: 8px 0;
border-radius: calc(var(--radius) / 1.5);
border: 1px solid var(--border);
background: var(--bg-card);
color: var(--text-dim);
cursor: pointer;
font-size: 13px;
transition: all 0.15s;
}
.op-tab:hover {
color: var(--text);
border-color: var(--primary);
}
.op-tab.on {
background: linear-gradient(135deg, var(--primary), var(--primary-2));
color: #fff;
border-color: transparent;
font-weight: 700;
}
.op-hint {
font-size: 12px;
margin-bottom: 10px;
}
.adjust-form {
display: grid;
grid-template-columns: 130px 1fr auto;
gap: 8px;
}
.set-preview {
font-size: 12px;
margin-top: 8px;
}
.gain {
color: #4ade80;
}
.cost {
color: #ff6b6b;
}
.flow-title {
font-size: 14px;
margin: 16px 0 8px;
}
.flow-wrap {
border: 1px solid var(--border);
border-radius: calc(var(--radius) / 1.5);
overflow: hidden;
padding: 0 10px 4px;
}
/* 流水行5 列 grid */
.frow {
display: grid;
grid-template-columns: 138px 84px 1fr 76px 70px;
gap: 8px;
align-items: center;
height: 100%;
font-size: 12px;
border-bottom: 1px dashed var(--border);
}
.fhead {
height: 32px;
font-size: 11px;
border-bottom: 1px solid var(--border);
}
.num {
text-align: right;
}
.flow-time {
white-space: nowrap;
font-size: 11px;
}
.flow-tag {
font-size: 11px;
padding: 1px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-card);
white-space: nowrap;
}
.flow-remark {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 560px) {
.adjust-form {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,228 @@
<script setup>
// 后台:桌面端版本管理 — 上传安装包(从文件名解析版本)、改更新说明、只保留最近 3 个包
import { ref, onMounted, computed } from 'vue'
import http, { toast, serverBase } from '../../api/http'
import NavIcon from '../../components/NavIcon.vue'
const data = ref(null)
const notes = ref('')
const force = ref(false)
const saving = ref(false)
const uploading = ref(false)
const fileInput = ref(null)
const pickedName = ref('')
const progress = ref(0)
const releases = computed(() => data.value?.releases || [])
function fmtSize(n) {
if (!n && n !== 0) return '-'
if (n < 1024) return n + ' B'
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'
return (n / 1024 / 1024).toFixed(1) + ' MB'
}
function fmtTime(ts) {
if (!ts) return '-'
const d = new Date(ts * 1000)
const p = (x) => String(x).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
function absURL(u) {
if (!u) return ''
if (/^https?:\/\//i.test(u)) return u
const base = serverBase() || (typeof location !== 'undefined' ? location.origin : '')
return base.replace(/\/+$/, '') + (u.startsWith('/') ? u : '/' + u)
}
async function load() {
data.value = await http.get('/admin/version')
notes.value = data.value.release_notes || ''
force.value = Number(data.value.force) === 1
}
async function saveMeta() {
saving.value = true
try {
data.value = await http.put('/admin/version', {
release_notes: notes.value,
force: force.value ? 1 : 0,
})
toast('已保存', 'success')
} finally {
saving.value = false
}
}
function onPick(e) {
const f = e.target.files?.[0]
pickedName.value = f ? f.name : ''
}
async function upload() {
const f = fileInput.value?.files?.[0]
if (!f) {
toast('请先选择安装包')
return
}
uploading.value = true
progress.value = 0
try {
const fd = new FormData()
fd.append('file', f)
fd.append('release_notes', notes.value || '')
fd.append('force', force.value ? '1' : '0')
data.value = await http.post('/admin/version/upload', fd, {
timeout: 300000,
onUploadProgress: (ev) => {
if (ev.total) progress.value = Math.round((ev.loaded / ev.total) * 100)
},
})
notes.value = data.value.release_notes || ''
force.value = Number(data.value.force) === 1
pickedName.value = ''
if (fileInput.value) fileInput.value.value = ''
toast(`已发布 v${data.value.version},自动保留最近 ${data.value.keep} 个包`, 'success')
} finally {
uploading.value = false
progress.value = 0
}
}
async function remove(item) {
if (!confirm(`确定删除 ${item.filename}`)) return
data.value = await http.delete('/admin/version/' + encodeURIComponent(item.filename))
toast('已删除', 'success')
}
onMounted(load)
</script>
<template>
<div v-if="data">
<h2 class="page-title">桌面版本</h2>
<div class="panel intro">
<p>
上传 Electron 安装包 <code>PixelArcade-Setup-1.0.1.exe</code>系统从<strong>文件名</strong>解析版本号
写入公开接口 <code>/api/version/latest</code>桌面端检查更新会引导下载磁盘上<strong>只保留最近 {{ data.keep }} </strong>安装包
</p>
</div>
<div class="panel cur">
<h3 class="sec-head"><NavIcon name="download" :size="16" /> 当前对外版本</h3>
<div class="cur-row">
<div class="kv"><span class="k">版本号</span><b class="v">{{ data.version || '-' }}</b></div>
<div class="kv grow">
<span class="k">下载地址</span>
<a v-if="data.download_url" class="v link" :href="absURL(data.download_url)" target="_blank" rel="noopener">{{ absURL(data.download_url) }}</a>
<span v-else class="v text-dim">尚未上传</span>
</div>
</div>
<label class="field">
<span>更新说明</span>
<textarea v-model="notes" rows="2" placeholder="一行简短文案,桌面端更新弹窗展示" />
</label>
<label class="force">
<input v-model="force" type="checkbox" />
强制更新桌面端不允许跳过
</label>
<div class="actions">
<button class="btn primary" :disabled="saving" @click="saveMeta">
<NavIcon name="save" :size="15" /> {{ saving ? '保存中…' : '保存说明' }}
</button>
</div>
</div>
<div class="panel upload">
<h3 class="sec-head"><NavIcon name="upload" :size="16" /> 上传安装包</h3>
<div class="pick">
<input ref="fileInput" type="file" accept=".exe,.msi,.dmg,.zip,.7z" @change="onPick" />
<span class="picked text-dim">{{ pickedName || '未选择文件' }}</span>
</div>
<div v-if="uploading" class="bar"><i :style="{ width: progress + '%' }" /></div>
<div class="actions">
<button class="btn primary" :disabled="uploading || !pickedName" @click="upload">
{{ uploading ? `上传中 ${progress}%` : '上传并发布' }}
</button>
</div>
</div>
<div class="panel list">
<h3 class="sec-head">已保留的安装包{{ releases.length }} / {{ data.keep }}</h3>
<table v-if="releases.length" class="tbl">
<thead>
<tr>
<th>版本</th>
<th>文件名</th>
<th>大小</th>
<th>上传时间</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="r in releases" :key="r.filename" :class="{ cur: r.is_current }">
<td>
<b>v{{ r.version || '?' }}</b>
<span v-if="r.is_current" class="badge">当前</span>
</td>
<td class="fn">
<a :href="absURL(r.url)" target="_blank" rel="noopener">{{ r.filename }}</a>
</td>
<td>{{ fmtSize(r.size) }}</td>
<td>{{ fmtTime(r.mod_time) }}</td>
<td class="ops">
<button class="btn ghost danger" @click="remove(r)">删除</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="text-dim empty">暂无安装包请先上传</p>
</div>
</div>
</template>
<style scoped>
.intro { padding: 14px 16px; margin-bottom: 14px; line-height: 1.65; color: var(--text-dim); }
.intro code { font-size: 12px; padding: 1px 6px; border-radius: 4px; background: var(--bg-2, rgba(0,0,0,.06)); }
.panel { padding: 16px; margin-bottom: 14px; }
.sec-head { display: flex; align-items: center; gap: 6px; font-size: 14px; margin: 0 0 12px; }
.cur-row { display: flex; flex-wrap: wrap; gap: 16px 28px; margin-bottom: 12px; }
.kv { display: flex; flex-direction: column; gap: 4px; min-width: 120px; }
.kv.grow { flex: 1; min-width: 220px; }
.k { font-size: 12px; color: var(--text-dim); }
.v { font-size: 15px; word-break: break-all; }
.link { color: var(--primary-2); text-decoration: none; }
.link:hover { text-decoration: underline; }
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; font-size: 13px; }
.field textarea {
resize: vertical; min-height: 56px; padding: 8px 10px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg, transparent); color: inherit; font: inherit;
}
.force { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 12px; cursor: pointer; }
.actions { display: flex; gap: 10px; }
.btn {
display: inline-flex; align-items: center; gap: 6px;
border: 0; border-radius: 8px; padding: 8px 16px; font-size: 13px; cursor: pointer;
background: var(--bg-2, #2a2e52); color: inherit;
}
.btn:disabled { opacity: .55; cursor: not-allowed; }
.btn.primary { background: linear-gradient(135deg, var(--primary), var(--primary-2, var(--primary))); color: #fff; }
.btn.ghost { background: transparent; border: 1px solid var(--border); }
.btn.danger { color: #ff7b8a; }
.pick { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.picked { font-size: 13px; }
.bar { height: 6px; border-radius: 99px; background: var(--border); overflow: hidden; margin-bottom: 12px; }
.bar i { display: block; height: 100%; background: var(--primary); transition: width .15s; }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th, .tbl td { text-align: left; padding: 10px 8px; border-bottom: 1px solid var(--border); }
.tbl th { color: var(--text-dim); font-weight: 500; }
.tbl tr.cur td { background: color-mix(in srgb, var(--primary) 8%, transparent); }
.fn a { color: inherit; word-break: break-all; }
.badge {
display: inline-block; margin-left: 6px; font-size: 11px; padding: 1px 6px; border-radius: 99px;
background: color-mix(in srgb, var(--primary) 22%, transparent); color: var(--primary-2, var(--primary));
}
.ops { white-space: nowrap; }
.empty { padding: 8px 0; }
</style>

View File

@@ -0,0 +1,100 @@
<script setup>
// 后台 VIP 设置:调整 5 个等级的价格、周免数量与签到加成
import { ref, onMounted } from 'vue'
import http, { toast } from '../../api/http'
const list = ref([]) // 等级配置(本地可编辑副本)
const saving = ref(0) // 正在保存的行 id
async function load() {
list.value = await http.get('/admin/vip/levels')
}
// 保存单行配置
async function save(row) {
if (row.price_points < 0 || row.weekly_free_quota < 0 || row.signin_bonus < 0) {
toast('数值不能为负')
return
}
saving.value = row.id
try {
await http.put(`/admin/vip/levels/${row.id}`, {
name: row.name,
price_points: Number(row.price_points),
weekly_free_quota: Number(row.weekly_free_quota),
signin_bonus: Number(row.signin_bonus),
description: row.description,
})
toast(`VIP${row.level} 配置已保存`, 'success')
} finally {
saving.value = 0
}
}
onMounted(load)
</script>
<template>
<div>
<h2 class="page-title">VIP 设置</h2>
<p class="text-dim intro">
周免规则每周五中午 12:00 周期种子洗牌付费游戏自动轮换一批周免等级越高批次越大高等级包含低等级全部周免
改动保存后立即生效无需重启
</p>
<div class="panel table-wrap">
<table class="table">
<thead>
<tr>
<th>等级</th><th>名称</th><th>30天价格积分</th><th>周免数量/</th><th>签到加成积分/</th><th>权益描述</th><th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in list" :key="row.id">
<td class="lv">{{ row.icon }} VIP{{ row.level }}</td>
<td><input v-model="row.name" class="input cell" maxlength="16" /></td>
<td><input v-model.number="row.price_points" type="number" min="0" class="input cell num" /></td>
<td><input v-model.number="row.weekly_free_quota" type="number" min="0" max="30" class="input cell num" /></td>
<td><input v-model.number="row.signin_bonus" type="number" min="0" class="input cell num" /></td>
<td><input v-model="row.description" class="input cell wide" maxlength="100" /></td>
<td>
<button class="btn btn-sm" :disabled="saving === row.id" @click="save(row)">
{{ saving === row.id ? '保存中' : '保存' }}
</button>
</td>
</tr>
<tr v-if="!list.length">
<td colspan="7" class="text-dim" style="text-align: center; padding: 30px">
暂无数据请先执行 sql/upgrade_vip_ai.sql 或最新 init.sql
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.intro {
font-size: 12px;
margin-bottom: 14px;
line-height: 1.8;
}
.table-wrap {
padding: 6px 14px 14px;
overflow-x: auto;
}
.lv {
font-weight: 700;
white-space: nowrap;
}
.cell {
padding: 6px 8px;
font-size: 13px;
}
.num {
width: 90px;
}
.wide {
min-width: 220px;
}
</style>