feat: phase 4 - 积分竞猜和游戏黑名单 v0.3.0
竞猜功能:发起竞猜、下注、关闭、开奖、奖池分配 黑名单功能:标记游戏、按原因/严重程度筛选、详情展开 修复:双重结算、TOCTOU竞态、订阅泄漏、选项选择兼容性 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
<!-- src/views/BlacklistView.vue -->
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useGroupStore } from '@/stores/group'
|
||||
import BlacklistMain from '@/components/gameBlacklist/BlacklistMain.vue'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const groupStore = useGroupStore()
|
||||
const groupId = route.params.groupId as string
|
||||
|
||||
const group = computed(() => groupStore.currentGroup)
|
||||
|
||||
onMounted(async () => {
|
||||
await groupStore.setCurrentGroup(groupId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="blacklist-view">
|
||||
<!-- 页面头部 -->
|
||||
<section class="page-header">
|
||||
<router-link :to="`/group/${groupId}`" class="back-link">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回群组
|
||||
</router-link>
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">游戏黑名单</h1>
|
||||
<span class="group-badge">{{ group?.name }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 黑名单内容 -->
|
||||
<BlacklistMain />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blacklist-view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
padding: 20px 24px;
|
||||
background: var(--gg-bg-card);
|
||||
border: 1px solid var(--gg-border);
|
||||
border-radius: var(--gg-radius-lg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: var(--gg-gradient);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--gg-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--gg-primary);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: var(--gg-text);
|
||||
}
|
||||
|
||||
.group-badge {
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
background: rgba(5, 150, 105, 0.15);
|
||||
color: var(--gg-primary-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,27 @@ interface LogEntry {
|
||||
}
|
||||
|
||||
const logs = ref<LogEntry[]>([
|
||||
{
|
||||
version: 'v0.3.0',
|
||||
date: '2026-04-19',
|
||||
title: '四期功能:积分竞猜、游戏黑名单',
|
||||
items: [
|
||||
{ type: 'feat', text: '积分竞猜:群组成员可发起竞猜,设置选项、截止时间、下注积分范围' },
|
||||
{ type: 'feat', text: '竞猜下注:成员选择选项并下注积分,每人仅限下注一次' },
|
||||
{ type: 'feat', text: '竞猜开奖:发起人关闭竞猜后选择正确答案,奖池按比例分配给赢家' },
|
||||
{ type: 'feat', text: '竞猜自动关闭:到达截止时间自动关闭竞猜' },
|
||||
{ type: 'feat', text: '游戏黑名单:标记体验差的游戏(外挂、挂机、环境差等),提醒队友避坑' },
|
||||
{ type: 'feat', text: '黑名单筛选:按原因(队友坑/外挂多/坑货多/环境差)和严重程度筛选' },
|
||||
{ type: 'feat', text: '黑名单详情展开:点击游戏卡片展开查看所有标记记录' },
|
||||
{ type: 'feat', text: '竞猜列表展示奖池进度条和「已下注」状态标签' },
|
||||
{ type: 'fix', text: '修复下注选项在 Chrome/Edge 无法选中的问题,改为按钮卡片式选择' },
|
||||
{ type: 'fix', text: '修复 point_logs schema 不支持竞猜动作和负数积分的问题' },
|
||||
{ type: 'fix', text: '修复竞猜双重结算和 TOCTOU 竞态安全漏洞' },
|
||||
{ type: 'fix', text: '修复黑名单条目允许非创建者修改的安全问题' },
|
||||
{ type: 'fix', text: '修复 BetDetail 订阅累积和群组切换时订阅未清理的内存泄漏' },
|
||||
{ type: 'fix', text: '修复 GroupView 定时器变量覆盖导致内存泄漏' },
|
||||
]
|
||||
},
|
||||
{
|
||||
version: 'v0.2.0',
|
||||
date: '2026-04-18',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useGroupStore } from '@/stores/group'
|
||||
import { useTeamStore } from '@/stores/team'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import { settlePoll } from '@/api/polls'
|
||||
import { closeBet } from '@/api/bets'
|
||||
import TeamSessionPanel from '@/components/team/TeamSessionPanel.vue'
|
||||
import IdleMembersList from '@/components/team/IdleMembersList.vue'
|
||||
import GroupMembersPanel from '@/components/group/GroupMembersPanel.vue'
|
||||
@@ -13,18 +14,22 @@ import PollList from '@/components/poll/PollList.vue'
|
||||
import PollDetail from '@/components/poll/PollDetail.vue'
|
||||
import MemoryGrid from '@/components/memory/MemoryGrid.vue'
|
||||
import GroupStatsPanel from '@/components/stats/GroupStatsPanel.vue'
|
||||
import { Promotion, Opportunity, UserFilled, Wallet, Box } from '@element-plus/icons-vue'
|
||||
import { DataLine, PictureFilled, TrendCharts } from '@element-plus/icons-vue'
|
||||
import BetList from '@/components/bet/BetList.vue'
|
||||
import BetDetail from '@/components/bet/BetDetail.vue'
|
||||
import { Promotion, Opportunity, UserFilled, Wallet, Box, Warning } from '@element-plus/icons-vue'
|
||||
import { DataLine, PictureFilled, TrendCharts, Trophy } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const groupStore = useGroupStore()
|
||||
const teamStore = useTeamStore()
|
||||
|
||||
const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : 'activity')
|
||||
const activeTab = ref(route.query.tab === 'polls' ? 'polls' : route.query.tab === 'memories' ? 'memories' : route.query.tab === 'stats' ? 'stats' : route.query.tab === 'bets' ? 'bets' : 'activity')
|
||||
const viewingPollId = ref<string | null>(null)
|
||||
const viewingBetId = ref<string | null>(null)
|
||||
|
||||
const unsubFns: (() => Promise<void>)[] = []
|
||||
let settleTimer: ReturnType<typeof setInterval> | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let betTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const groupId = route.params.id as string
|
||||
|
||||
@@ -89,7 +94,10 @@ onMounted(async () => {
|
||||
}))
|
||||
|
||||
// 投票结算定时器:每 60 秒检查过期投票
|
||||
settleTimer = setInterval(checkExpiredPolls, 60000)
|
||||
pollTimer = setInterval(checkExpiredPolls, 60000)
|
||||
|
||||
// 竞猜自动关闭定时器:每 60 秒检查
|
||||
betTimer = setInterval(checkExpiredBets, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(async () => {
|
||||
@@ -97,9 +105,13 @@ onUnmounted(async () => {
|
||||
try { await unsub() } catch (_) {}
|
||||
}
|
||||
unsubFns.length = 0
|
||||
if (settleTimer) {
|
||||
clearInterval(settleTimer)
|
||||
settleTimer = null
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
if (betTimer) {
|
||||
clearInterval(betTimer)
|
||||
betTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
@@ -111,10 +123,8 @@ async function checkExpiredPolls() {
|
||||
const currentGroupId = groupStore.currentGroupId
|
||||
const user = pb.authStore.model
|
||||
if (!currentGroupId || !user) return
|
||||
|
||||
|
||||
try {
|
||||
// 优化:只有当前登录用户是投票发起人时,才主动去结算自己发起的投票
|
||||
// 这避免了群里有 10 个成员在线时,10 个客户端同时发起请求的问题
|
||||
const result = await pb.collection('polls').getList(1, 50, {
|
||||
filter: `group="${currentGroupId}" && status="active" && deadline!="" && creator="${user.id}"`,
|
||||
$autoCancel: false
|
||||
@@ -133,6 +143,31 @@ async function checkExpiredPolls() {
|
||||
console.error('检查过期投票失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkExpiredBets() {
|
||||
const currentGroupId = groupStore.currentGroupId
|
||||
const user = pb.authStore.model
|
||||
if (!currentGroupId || !user) return
|
||||
|
||||
try {
|
||||
const result = await pb.collection('bets').getList(1, 50, {
|
||||
filter: `group="${currentGroupId}" && status="open" && creator="${user.id}"`,
|
||||
$autoCancel: false
|
||||
})
|
||||
const now = new Date()
|
||||
for (const bet of result.items as any[]) {
|
||||
if (bet.deadline && new Date(bet.deadline) <= now) {
|
||||
try {
|
||||
await closeBet(bet.id)
|
||||
} catch (e) {
|
||||
console.error('关闭竞猜失败:', bet.id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('检查过期竞猜失败:', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -162,6 +197,10 @@ async function checkExpiredPolls() {
|
||||
<router-link :to="`/group/${groupId}/assets`" class="meta-link">
|
||||
<el-icon><Box /></el-icon> 资产
|
||||
</router-link>
|
||||
<span class="meta-divider">|</span>
|
||||
<router-link :to="`/group/${groupId}/blacklist`" class="meta-link">
|
||||
<el-icon><Warning /></el-icon> 黑名单
|
||||
</router-link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -251,6 +290,14 @@ async function checkExpiredPolls() {
|
||||
</template>
|
||||
<GroupStatsPanel />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane name="bets">
|
||||
<template #label>
|
||||
<span class="fancy-tab"><el-icon><Trophy /></el-icon> 竞猜</span>
|
||||
</template>
|
||||
<BetDetail v-if="viewingBetId" :bet-id="viewingBetId" @back="viewingBetId = null" />
|
||||
<BetList v-else @view-bet="viewingBetId = $event" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user