Files
gamegroup2/frontend/src/components/bet/BetDetail.vue
T
congsh 60ad9a04cd feat: phase 4 - 积分竞猜和游戏黑名单 v0.3.0
竞猜功能:发起竞猜、下注、关闭、开奖、奖池分配
黑名单功能:标记游戏、按原因/严重程度筛选、详情展开
修复:双重结算、TOCTOU竞态、订阅泄漏、选项选择兼容性

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 00:21:43 +08:00

651 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Loading } from '@element-plus/icons-vue'
import { getBet, getBetOptions, getBetEntries, placeBet, closeBet, subscribeBets } from '@/api/bets'
import { pb } from '@/api/pocketbase'
import { displayName } from '@/types'
import type { Bet, BetOption, BetEntry } from '@/types'
import BetEntryList from './BetEntryList.vue'
import SettleBetDialog from './SettleBetDialog.vue'
const props = defineProps<{
betId: string
}>()
const emit = defineEmits<{
back: []
}>()
// 数据
const bet = ref<Bet | null>(null)
const options = ref<BetOption[]>([])
const entries = ref<BetEntry[]>([])
const loading = ref(false)
// 下注表单
const selectedOption = ref('')
const stakeAmount = ref(1)
// 结算弹窗
const showSettle = ref(false)
// 实时订阅
let unsubscribeFn: (() => void) | null = null
// 计算属性
const creatorName = computed(() => displayName(bet.value?.expand?.creator))
const totalPool = computed(() => entries.value.reduce((sum, e) => sum + e.stake, 0))
// 各选项的下注总额和人数
const optionStats = computed(() => {
const stats: Record<string, { totalStake: number; count: number }> = {}
for (const opt of options.value) {
stats[opt.id] = { totalStake: 0, count: 0 }
}
for (const e of entries.value) {
if (!stats[e.option]) {
stats[e.option] = { totalStake: 0, count: 0 }
}
stats[e.option].totalStake += e.stake
stats[e.option].count += 1
}
return stats
})
// 当前用户
const currentUserId = computed(() => pb.authStore.model?.id)
const isCreator = computed(() => bet.value?.creator === currentUserId.value)
// 当前用户的下注记录
const currentUserEntry = computed(() =>
entries.value.find((e) => e.user === currentUserId.value) || null
)
// 状态
const isOpen = computed(() => bet.value?.status === 'open')
const isClosed = computed(() => bet.value?.status === 'closed')
const isSettled = computed(() => bet.value?.status === 'settled')
// 结果选项ID
const resultOptionId = computed(() => bet.value?.resultOption || '')
// 加载数据
async function loadDetail() {
loading.value = true
try {
const [betData, optionsData, entriesData] = await Promise.all([
getBet(props.betId),
getBetOptions(props.betId),
getBetEntries(props.betId),
])
bet.value = betData
options.value = optionsData
entries.value = entriesData
} catch (error) {
console.error('加载竞猜详情失败:', error)
} finally {
loading.value = false
}
}
// 下注
async function handlePlaceBet() {
if (!selectedOption.value) {
ElMessage.warning('请选择一个选项')
return
}
if (!stakeAmount.value || stakeAmount.value < 1) {
ElMessage.warning('请输入有效的积分数')
return
}
try {
await placeBet(props.betId, selectedOption.value, stakeAmount.value)
ElMessage.success('下注成功')
await loadDetail()
} catch (error: any) {
ElMessage.error(error.message || '下注失败')
}
}
// 关闭竞猜
async function handleClose() {
try {
await ElMessageBox.confirm('确定要关闭竞猜吗?关闭后将不能再下注。', '关闭竞猜', {
confirmButtonText: '确定关闭',
cancelButtonText: '取消',
type: 'warning',
})
await closeBet(props.betId)
ElMessage.success('竞猜已关闭')
await loadDetail()
} catch {
// 用户取消
}
}
// 实时订阅
async function startSubscription() {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
if (!bet.value) return
const groupId = bet.value.group
unsubscribeFn = await subscribeBets(groupId, () => {
loadDetail()
})
}
onMounted(async () => {
await loadDetail()
startSubscription()
})
onUnmounted(() => {
if (unsubscribeFn) {
unsubscribeFn()
unsubscribeFn = null
}
})
</script>
<template>
<div class="bet-detail">
<!-- 顶部操作栏 -->
<div class="bet-detail__header">
<el-button text @click="emit('back')">
<el-icon><ArrowLeft /></el-icon>
返回
</el-button>
<div class="bet-detail__actions">
<button
v-if="isCreator && isOpen"
class="action-btn action-btn--close"
@click="handleClose"
>
关闭竞猜
</button>
<button
v-if="isCreator && isClosed"
class="action-btn action-btn--settle"
@click="showSettle = true"
>
开奖
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="loading" class="bet-detail__loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>加载中...</span>
</div>
<!-- 竞猜内容 -->
<template v-else-if="bet">
<!-- 标题区域 -->
<div class="bet-detail__title-area">
<h2 class="bet-detail__title">{{ bet.title }}</h2>
<div class="bet-detail__meta">
<span class="bet-detail__meta-item">
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
{{ creatorName }} 发起
</span>
<span class="bet-detail__meta-item">
<svg class="meta-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
奖池 {{ totalPool }} 积分
</span>
<span class="bet-detail__meta-item">
下注范围 {{ bet.minStake }}~{{ bet.maxStake }} 积分
</span>
</div>
</div>
<!-- 描述 -->
<div v-if="bet.description" class="bet-detail__desc">
{{ bet.description }}
</div>
<!-- 选项列表 -->
<div class="bet-detail__options">
<div
v-for="option in options"
:key="option.id"
:class="[
'bet-detail__option',
{
'bet-detail__option--winner': isSettled && resultOptionId === option.id,
'bet-detail__option--loser': isSettled && resultOptionId !== option.id,
},
]"
>
<div class="bet-detail__option-header">
<span class="bet-detail__option-name">
{{ option.content }}
<span v-if="isSettled && resultOptionId === option.id" class="winner-badge">
正确答案
</span>
</span>
<div class="bet-detail__option-stats">
<span class="bet-detail__option-count">
{{ (optionStats[option.id]?.count || 0) }}
</span>
<span class="bet-detail__option-stake">
{{ optionStats[option.id]?.totalStake || 0 }} 积分
</span>
</div>
</div>
<div class="bet-detail__option-bar">
<div
class="bet-detail__option-fill"
:class="{ 'bet-detail__option-fill--winner': isSettled && resultOptionId === option.id }"
:style="{
width: totalPool > 0
? ((optionStats[option.id]?.totalStake || 0) / totalPool * 100) + '%'
: '0%',
}"
/>
</div>
</div>
</div>
<!-- 下注区域状态 open 且未下注时 -->
<div v-if="isOpen && !currentUserEntry" class="bet-detail__bet-area">
<h4 class="bet-detail__bet-title">下注</h4>
<div class="bet-detail__bet-form">
<div class="bet-detail__bet-option">
<label>选择选项</label>
<div class="bet-detail__option-pick">
<button
v-for="option in options"
:key="option.id"
type="button"
:class="['bet-detail__pick-card', { 'bet-detail__pick-card--active': selectedOption === option.id }]"
@click="selectedOption = option.id"
>
{{ option.content }}
</button>
</div>
</div>
<div class="bet-detail__bet-stake">
<label>下注积分</label>
<el-input-number
v-model="stakeAmount"
:min="bet.minStake"
:max="bet.maxStake"
:step="1"
/>
</div>
<button
class="bet-detail__bet-btn"
:disabled="!selectedOption"
@click="handlePlaceBet"
>
下注
</button>
</div>
</div>
<!-- 已下注提示 -->
<div v-if="isOpen && currentUserEntry" class="bet-detail__already-bet">
<svg class="bet-detail__already-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span>
你已下注 <strong>{{ currentUserEntry.stake }}</strong> 积分选择{{ currentUserEntry.expand?.option?.content || '未知' }}
</span>
</div>
<!-- 下注记录 -->
<div class="bet-detail__entries">
<h4 class="bet-detail__entries-title">下注记录</h4>
<BetEntryList :entries="entries" :show-result="isSettled" />
</div>
</template>
<!-- 开奖弹窗 -->
<SettleBetDialog
v-if="bet"
v-model="showSettle"
:bet-id="betId"
:options="options"
@settled="loadDetail"
/>
</div>
</template>
<style scoped>
.bet-detail {
display: flex;
flex-direction: column;
gap: 16px;
}
/* 顶部操作栏 */
.bet-detail__header {
display: flex;
justify-content: space-between;
align-items: center;
}
.bet-detail__actions {
display: flex;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border: none;
border-radius: var(--gg-radius-sm);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s, border-color 0.2s, color 0.2s;
}
.action-btn--close {
background: var(--gg-bg-card);
border: 1px solid var(--gg-border);
color: var(--gg-text-secondary);
}
.action-btn--close:hover {
border-color: var(--gg-danger);
color: var(--gg-danger);
}
.action-btn--settle {
background: var(--gg-gradient);
color: #fff;
}
.action-btn--settle:hover {
opacity: 0.85;
}
/* 加载中 */
.bet-detail__loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 40px 0;
color: var(--gg-text-secondary);
}
/* 标题区域 */
.bet-detail__title-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.bet-detail__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--gg-text);
}
.bet-detail__meta {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.bet-detail__meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--gg-text-secondary);
}
.meta-icon {
width: 14px;
height: 14px;
flex-shrink: 0;
}
/* 描述 */
.bet-detail__desc {
font-size: 14px;
color: var(--gg-text-secondary);
line-height: 1.6;
padding: 10px 14px;
background: var(--gg-bg-elevated);
border-radius: var(--gg-radius-sm);
}
/* 选项列表 */
.bet-detail__options {
display: flex;
flex-direction: column;
gap: 10px;
}
.bet-detail__option {
padding: 12px 16px;
border: 1px solid var(--gg-border);
border-radius: 8px;
transition: all 0.2s;
}
.bet-detail__option--winner {
border-color: var(--gg-success);
background: rgba(16, 185, 129, 0.06);
}
.bet-detail__option--loser {
opacity: 0.5;
}
.bet-detail__option-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.bet-detail__option-name {
font-size: 14px;
font-weight: 500;
color: var(--gg-text);
display: flex;
align-items: center;
gap: 6px;
}
.winner-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
background: rgba(16, 185, 129, 0.12);
color: var(--gg-success);
}
.bet-detail__option-stats {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.bet-detail__option-count {
font-size: 12px;
color: var(--gg-text-secondary);
}
.bet-detail__option-stake {
font-size: 12px;
font-weight: 600;
color: var(--gg-primary);
}
.bet-detail__option-bar {
height: 6px;
background: var(--gg-bg-elevated);
border-radius: 3px;
overflow: hidden;
}
.bet-detail__option-fill {
height: 100%;
background: var(--gg-primary);
border-radius: 3px;
transition: width 0.3s ease;
}
.bet-detail__option-fill--winner {
background: var(--gg-success);
}
/* 下注区域 */
.bet-detail__bet-area {
padding: 16px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-md);
background: var(--gg-bg-card);
}
.bet-detail__bet-title {
margin: 0 0 12px;
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
}
.bet-detail__bet-form {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.bet-detail__bet-option,
.bet-detail__bet-stake {
display: flex;
flex-direction: column;
gap: 6px;
}
.bet-detail__bet-option label,
.bet-detail__bet-stake label {
font-size: 13px;
color: var(--gg-text-secondary);
}
.bet-detail__option-pick {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.bet-detail__pick-card {
padding: 6px 16px;
border: 1px solid var(--gg-border);
border-radius: var(--gg-radius-sm);
background: var(--gg-bg-card);
font-size: 13px;
color: var(--gg-text-secondary);
cursor: pointer;
transition: all 0.2s;
user-select: none;
-webkit-tap-highlight-color: transparent;
}
.bet-detail__pick-card:hover {
border-color: var(--gg-primary);
color: var(--gg-primary);
}
.bet-detail__pick-card--active {
border-color: var(--gg-primary);
background: rgba(5, 150, 105, 0.08);
color: var(--gg-primary);
font-weight: 500;
}
.bet-detail__bet-btn {
padding: 8px 24px;
border: none;
border-radius: var(--gg-radius-sm);
background: var(--gg-gradient);
color: #fff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.bet-detail__bet-btn:hover {
opacity: 0.85;
}
.bet-detail__bet-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 已下注提示 */
.bet-detail__already-bet {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(5, 150, 105, 0.06);
border-radius: var(--gg-radius-sm);
font-size: 14px;
color: var(--gg-text-secondary);
}
.bet-detail__already-bet strong {
color: var(--gg-primary);
}
.bet-detail__already-icon {
width: 18px;
height: 18px;
color: var(--gg-success);
flex-shrink: 0;
}
/* 下注记录 */
.bet-detail__entries {
display: flex;
flex-direction: column;
gap: 10px;
}
.bet-detail__entries-title {
margin: 0;
font-size: 15px;
font-weight: 600;
color: var(--gg-text);
}
/* 响应式 */
@media (max-width: 640px) {
.bet-detail__bet-form {
flex-direction: column;
align-items: stretch;
}
}
</style>