feat: phase 4 - 积分竞猜和游戏黑名单 v0.3.0

竞猜功能:发起竞猜、下注、关闭、开奖、奖池分配
黑名单功能:标记游戏、按原因/严重程度筛选、详情展开
修复:双重结算、TOCTOU竞态、订阅泄漏、选项选择兼容性

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
congsh
2026-04-19 00:21:43 +08:00
parent 2d56df940d
commit 60ad9a04cd
24 changed files with 4047 additions and 14 deletions
+102
View File
@@ -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>
+21
View File
@@ -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',
+58 -11
View File
@@ -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>