60ad9a04cd
竞猜功能:发起竞猜、下注、关闭、开奖、奖池分配 黑名单功能:标记游戏、按原因/严重程度筛选、详情展开 修复:双重结算、TOCTOU竞态、订阅泄漏、选项选择兼容性 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
651 lines
15 KiB
Vue
651 lines
15 KiB
Vue
<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>
|