Files
gamegroup2/frontend/src/components-mobile/poll/PollListMobile.vue
T
锦麟 王 1cc23a0836 feat(mobile): stage 6 - polls + bets
- migrate PollListMobile.vue (list + detail/vote + create + settle)
- migrate BetListMobile.vue (list + detail/place-bet + create + close/settle)
- GroupViewMobile: wire polls/bets tabs (replace placeholders)
- verified: polls API (8 fns) + bets API (8 fns) match uat

build verified: vue-tsc + vite build pass
2026-06-18 11:11:40 +08:00

464 lines
11 KiB
Vue

<!-- src/components-mobile/poll/PollListMobile.vue -->
<!-- 手机端投票列表 + 详情 + 创建 -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { listPolls, getPoll, getPollOptions, getPollVotes, getUserVote, votePoll, createPoll, settlePoll } from '@/api/polls'
import type { Poll, PollOption, PollVote } from '@/types'
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
const props = defineProps<{ groupId: string }>()
const polls = ref<Poll[]>([])
const loading = ref(false)
const viewingPollId = ref<string | null>(null)
// 详情数据
const currentPoll = ref<Poll | null>(null)
const options = ref<PollOption[]>([])
const votes = ref<PollVote[]>([])
const userVote = ref<PollVote | null>(null)
const detailLoading = ref(false)
let unsub: (() => void) | null = null
async function loadPolls() {
loading.value = true
try {
polls.value = await listPolls(props.groupId)
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
onMounted(async () => {
await loadPolls()
})
onUnmounted(() => {
if (unsub) { (unsub as () => void)() }
})
async function viewPoll(pollId: string) {
viewingPollId.value = pollId
detailLoading.value = true
try {
const [poll, opts, allVotes, myVote] = await Promise.all([
getPoll(pollId),
getPollOptions(pollId),
getPollVotes(pollId),
getUserVote(pollId)
])
currentPoll.value = poll
options.value = opts
votes.value = allVotes
userVote.value = myVote
} catch (e) {
showFailToast('加载失败')
} finally {
detailLoading.value = false
}
}
function backToList() {
viewingPollId.value = null
currentPoll.value = null
loadPolls()
}
// 每个选项的票数
function optionVoteCount(optionId: string): number {
return votes.value.filter(v => v.option === optionId).length
}
function totalVotes(): number {
return votes.value.length || 1
}
async function doVote(optionId: string) {
if (!currentPoll.value || currentPoll.value.status === 'settled') return
if (userVote.value) {
showFailToast('你已经投过票了')
return
}
try {
await votePoll(currentPoll.value.id, optionId)
showSuccessToast('投票成功')
await viewPoll(currentPoll.value.id)
} catch (e: any) {
showFailToast(e.message || '投票失败')
}
}
async function doSettle() {
if (!currentPoll.value) return
showConfirmDialog({
title: '结算投票',
message: '结算后将结束投票,确定吗?'
}).then(async () => {
try {
await settlePoll(currentPoll.value!.id)
showSuccessToast('已结算')
await viewPoll(currentPoll.value!.id)
} catch (e: any) {
showFailToast(e.message || '结算失败')
}
}).catch(() => {})
}
// 创建投票
const showCreate = ref(false)
const createForm = ref({
title: '',
type: 'option' as 'option' | 'rollcall',
anonymous: false,
deadline: '',
optionsText: ''
})
const createLoading = ref(false)
async function handleCreate() {
if (!createForm.value.title.trim()) {
showFailToast('请输入标题')
return
}
const opts = createForm.value.optionsText.split('\n').map(s => s.trim()).filter(Boolean)
if (opts.length < 2) {
showFailToast('至少输入 2 个选项(每行一个)')
return
}
createLoading.value = true
try {
await createPoll({
group: props.groupId,
title: createForm.value.title.trim(),
type: createForm.value.type,
anonymous: createForm.value.anonymous,
deadline: createForm.value.deadline || undefined,
options: opts
})
showSuccessToast('创建成功')
showCreate.value = false
createForm.value = { title: '', type: 'option', anonymous: false, deadline: '', optionsText: '' }
await loadPolls()
} catch (e: any) {
showFailToast(e.message || '创建失败')
} finally {
createLoading.value = false
}
}
function timeAgo(dateStr: string): string {
const min = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000)
if (min < 60) return `${min}分钟前`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}小时前`
return new Date(dateStr).toLocaleDateString('zh-CN')
}
</script>
<template>
<div class="poll-mobile">
<!-- 详情视图 -->
<div v-if="viewingPollId && currentPoll" class="poll-detail">
<van-nav-bar
:title="currentPoll.title"
left-arrow
@click-left="backToList"
/>
<div v-if="detailLoading" class="loading-box">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else class="detail-content">
<div class="detail-header">
<van-tag :type="currentPoll.status === 'settled' ? 'default' : 'success'">
{{ currentPoll.status === 'settled' ? '已结算' : '进行中' }}
</van-tag>
<span class="detail-meta">{{ votes.length }} 人参与 · {{ timeAgo(currentPoll.created) }}</span>
</div>
<div class="options-list">
<div
v-for="opt in options"
:key="opt.id"
class="option-item"
:class="{
'option-voted': userVote?.option === opt.id,
'option-disabled': currentPoll.status === 'settled' || !!userVote
}"
@click="doVote(opt.id)"
>
<div class="option-bar">
<div
class="option-bar-fill"
:style="{ width: `${(optionVoteCount(opt.id) / totalVotes()) * 100}%` }"
/>
</div>
<div class="option-content">
<span class="option-text">{{ opt.content }}</span>
<span class="option-count">{{ optionVoteCount(opt.id) }} </span>
</div>
</div>
</div>
<div v-if="currentPoll.status === 'active'" class="detail-actions">
<van-button
type="warning"
block
round
plain
@click="doSettle"
>
结算投票
</van-button>
</div>
</div>
</div>
<!-- 列表视图 -->
<template v-else>
<div class="list-header">
<van-button type="primary" size="small" round icon="plus" @click="showCreate = true">
发起投票
</van-button>
</div>
<van-pull-refresh v-model="loading" @refresh="loadPolls">
<div v-if="polls.length === 0 && !loading" class="empty">
<van-empty description="暂无投票" image-size="100" />
</div>
<div class="poll-list">
<div
v-for="poll in polls"
:key="poll.id"
class="poll-card"
@click="viewPoll(poll.id)"
>
<div class="poll-card-header">
<van-tag :type="poll.status === 'settled' ? 'default' : 'success'" size="medium">
{{ poll.status === 'settled' ? '已结算' : '进行中' }}
</van-tag>
<span class="poll-time">{{ timeAgo(poll.created) }}</span>
</div>
<div class="poll-title">{{ poll.title }}</div>
<div class="poll-card-footer">
<span class="poll-type">{{ poll.type === 'rollcall' ? '点名' : '选项投票' }}</span>
<van-icon name="arrow" />
</div>
</div>
</div>
</van-pull-refresh>
</template>
<!-- 创建弹层 -->
<van-popup
v-model:show="showCreate"
position="bottom"
round
closeable
:style="{ height: '80%' }"
>
<div class="popup-content">
<div class="popup-title">发起投票</div>
<van-cell-group inset>
<van-field v-model="createForm.title" label="标题" placeholder="投票主题" required />
<van-field name="radio" label="类型">
<template #input>
<van-radio-group v-model="createForm.type" direction="horizontal">
<van-radio name="option">选项投票</van-radio>
<van-radio name="rollcall">点名</van-radio>
</van-radio-group>
</template>
</van-field>
<van-field
v-model="createForm.optionsText"
type="textarea"
label="选项"
placeholder="每行输入一个选项"
rows="4"
/>
<van-field
v-model="createForm.deadline"
label="截止时间"
placeholder="留空则不截止"
/>
<van-cell title="匿名投票" center>
<template #right-icon>
<van-switch v-model="createForm.anonymous" size="22px" />
</template>
</van-cell>
</van-cell-group>
<div class="popup-actions">
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
创建
</van-button>
</div>
</div>
</van-popup>
</div>
</template>
<style scoped>
.poll-mobile {
padding: 12px;
}
.loading-box {
display: flex;
justify-content: center;
padding: 40px;
}
.empty {
padding: 30px 0;
}
.list-header {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.poll-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.poll-card {
background: var(--gg-bg-card);
border-radius: var(--gg-radius-md);
padding: 14px;
box-shadow: var(--gg-shadow);
}
.poll-card:active {
opacity: 0.85;
}
.poll-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.poll-time {
font-size: 12px;
color: var(--gg-text-muted);
}
.poll-title {
font-size: 16px;
font-weight: 600;
color: var(--gg-text);
}
.poll-card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 10px;
color: var(--gg-text-muted);
font-size: 13px;
}
/* 详情 */
.detail-content {
padding: 12px;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.detail-meta {
font-size: 12px;
color: var(--gg-text-muted);
}
.options-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.option-item {
position: relative;
background: var(--gg-bg-card);
border-radius: var(--gg-radius-sm);
overflow: hidden;
border: 1px solid var(--gg-border);
}
.option-voted {
border-color: var(--gg-primary);
}
.option-bar {
position: absolute;
inset: 0;
}
.option-bar-fill {
height: 100%;
background: rgba(5, 150, 105, 0.08);
transition: width 0.3s;
}
.option-content {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
}
.option-text {
font-size: 14px;
color: var(--gg-text);
font-weight: 500;
}
.option-count {
font-size: 13px;
color: var(--gg-primary);
font-weight: 600;
}
.option-disabled {
opacity: 0.9;
}
.detail-actions {
margin-top: 20px;
}
/* 弹层 */
.popup-content {
padding: 16px 0 24px;
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.popup-title {
text-align: center;
font-size: 16px;
font-weight: 600;
padding: 8px 0 16px;
}
.popup-actions {
padding: 20px 16px 0;
}
</style>