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
This commit is contained in:
@@ -0,0 +1,410 @@
|
||||
<!-- src/components-mobile/bet/BetListMobile.vue -->
|
||||
<!-- 手机端竞猜列表 + 详情 + 下注 + 创建 + 结算 -->
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { listBets, getBet, getBetOptions, getBetEntries, placeBet, createBet, closeBet, settleBet } from '@/api/bets'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import type { Bet, BetOption, BetEntry } from '@/types'
|
||||
import { showSuccessToast, showFailToast, showConfirmDialog } from 'vant'
|
||||
|
||||
const props = defineProps<{ groupId: string }>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const bets = ref<Bet[]>([])
|
||||
const loading = ref(false)
|
||||
const viewingBetId = ref<string | null>(null)
|
||||
|
||||
const currentBet = ref<Bet | null>(null)
|
||||
const options = ref<BetOption[]>([])
|
||||
const entries = ref<BetEntry[]>([])
|
||||
const detailLoading = ref(false)
|
||||
|
||||
async function loadBets() {
|
||||
loading.value = true
|
||||
try {
|
||||
bets.value = await listBets(props.groupId)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadBets()
|
||||
})
|
||||
|
||||
async function viewBet(betId: string) {
|
||||
viewingBetId.value = betId
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const [bet, opts, ents] = await Promise.all([
|
||||
getBet(betId),
|
||||
getBetOptions(betId),
|
||||
getBetEntries(betId)
|
||||
])
|
||||
currentBet.value = bet
|
||||
options.value = opts
|
||||
entries.value = ents
|
||||
} catch (e) {
|
||||
showFailToast('加载失败')
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
viewingBetId.value = null
|
||||
currentBet.value = null
|
||||
loadBets()
|
||||
}
|
||||
|
||||
function optionEntryCount(optionId: string): number {
|
||||
return entries.value.filter(e => e.option === optionId).length
|
||||
}
|
||||
|
||||
function optionTotalStake(optionId: string): number {
|
||||
return entries.value.filter(e => e.option === optionId).reduce((sum, e) => sum + e.stake, 0)
|
||||
}
|
||||
|
||||
const myEntry = () => entries.value.find(e => e.user === userStore.userId)
|
||||
|
||||
// 下注
|
||||
const showBet = ref(false)
|
||||
const selectedOption = ref<string>('')
|
||||
const stakeAmount = ref(0)
|
||||
const placeLoading = ref(false)
|
||||
|
||||
function openBetSheet(optionId: string) {
|
||||
if (!currentBet.value || currentBet.value.status !== 'open') return
|
||||
if (myEntry()) {
|
||||
showFailToast('你已经下注了')
|
||||
return
|
||||
}
|
||||
selectedOption.value = optionId
|
||||
stakeAmount.value = currentBet.value.minStake
|
||||
showBet.value = true
|
||||
}
|
||||
|
||||
async function handlePlaceBet() {
|
||||
if (!currentBet.value) return
|
||||
if (stakeAmount.value < currentBet.value.minStake || stakeAmount.value > currentBet.value.maxStake) {
|
||||
showFailToast(`下注范围 ${currentBet.value.minStake}-${currentBet.value.maxStake}`)
|
||||
return
|
||||
}
|
||||
placeLoading.value = true
|
||||
try {
|
||||
await placeBet(currentBet.value.id, selectedOption.value, stakeAmount.value)
|
||||
showSuccessToast('下注成功')
|
||||
showBet.value = false
|
||||
await viewBet(currentBet.value.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '下注失败')
|
||||
} finally {
|
||||
placeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建竞猜
|
||||
const showCreate = ref(false)
|
||||
const createForm = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
optionsText: '',
|
||||
minStake: 1,
|
||||
maxStake: 100,
|
||||
deadline: ''
|
||||
})
|
||||
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
|
||||
}
|
||||
if (!createForm.value.deadline) {
|
||||
showFailToast('请设置截止时间')
|
||||
return
|
||||
}
|
||||
createLoading.value = true
|
||||
try {
|
||||
await createBet({
|
||||
group: props.groupId,
|
||||
title: createForm.value.title.trim(),
|
||||
description: createForm.value.description.trim() || undefined,
|
||||
options: opts,
|
||||
minStake: createForm.value.minStake,
|
||||
maxStake: createForm.value.maxStake,
|
||||
deadline: createForm.value.deadline
|
||||
})
|
||||
showSuccessToast('创建成功')
|
||||
showCreate.value = false
|
||||
createForm.value = { title: '', description: '', optionsText: '', minStake: 1, maxStake: 100, deadline: '' }
|
||||
await loadBets()
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '创建失败')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭/结算(创建者)
|
||||
async function handleClose() {
|
||||
if (!currentBet.value) return
|
||||
showConfirmDialog({ title: '关闭竞猜', message: '关闭后将停止下注,确定?' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await closeBet(currentBet.value!.id)
|
||||
showSuccessToast('已关闭')
|
||||
await viewBet(currentBet.value!.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '操作失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const showSettle = ref(false)
|
||||
const settleOption = ref('')
|
||||
|
||||
async function handleSettle() {
|
||||
if (!currentBet.value || !settleOption.value) return
|
||||
try {
|
||||
await settleBet(currentBet.value.id, settleOption.value)
|
||||
showSuccessToast('已结算')
|
||||
showSettle.value = false
|
||||
await viewBet(currentBet.value.id)
|
||||
} catch (e: any) {
|
||||
showFailToast(e.message || '结算失败')
|
||||
}
|
||||
}
|
||||
|
||||
const isCreator = () => currentBet.value?.creator === userStore.userId
|
||||
|
||||
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="bet-mobile">
|
||||
<!-- 详情 -->
|
||||
<div v-if="viewingBetId && currentBet">
|
||||
<van-nav-bar :title="currentBet.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="currentBet.status === 'open' ? 'success' : currentBet.status === 'closed' ? 'warning' : 'default'">
|
||||
{{ currentBet.status === 'open' ? '下注中' : currentBet.status === 'closed' ? '待结算' : '已结算' }}
|
||||
</van-tag>
|
||||
<span class="detail-meta">范围 {{ currentBet.minStake }}-{{ currentBet.maxStake }} 积分</span>
|
||||
</div>
|
||||
|
||||
<p v-if="currentBet.description" class="detail-desc">{{ currentBet.description }}</p>
|
||||
|
||||
<div class="options-list">
|
||||
<div
|
||||
v-for="opt in options"
|
||||
:key="opt.id"
|
||||
class="option-item"
|
||||
@click="currentBet.status === 'open' && openBetSheet(opt.id)"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span class="option-text">{{ opt.content }}</span>
|
||||
<span v-if="currentBet.resultOption === opt.id" class="winner-badge">🏆 胜出</span>
|
||||
</div>
|
||||
<div class="option-stats">
|
||||
<span>{{ optionEntryCount(opt.id) }} 注</span>
|
||||
<span>{{ optionTotalStake(opt.id) }} 积分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的结果 -->
|
||||
<div v-if="currentBet.status === 'settled' && myEntry()" class="my-result">
|
||||
<van-notice-bar
|
||||
:type="myEntry()?.won ? 'success' : 'warning'"
|
||||
wrapable
|
||||
>
|
||||
{{ myEntry()?.won ? `🎉 你赢了!` : '本次未中奖' }}
|
||||
</van-notice-bar>
|
||||
</div>
|
||||
|
||||
<!-- 创建者操作 -->
|
||||
<div v-if="isCreator()" class="detail-actions">
|
||||
<van-button v-if="currentBet.status === 'open'" type="warning" block round plain @click="handleClose">
|
||||
关闭下注
|
||||
</van-button>
|
||||
<van-button v-if="currentBet.status === 'closed'" type="primary" block round @click="showSettle = true">
|
||||
结算
|
||||
</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="loadBets">
|
||||
<div v-if="bets.length === 0 && !loading" class="empty">
|
||||
<van-empty description="暂无竞猜" image-size="100" />
|
||||
</div>
|
||||
|
||||
<div class="bet-list">
|
||||
<div
|
||||
v-for="bet in bets"
|
||||
:key="bet.id"
|
||||
class="bet-card"
|
||||
@click="viewBet(bet.id)"
|
||||
>
|
||||
<div class="bet-card-header">
|
||||
<van-tag :type="bet.status === 'open' ? 'success' : bet.status === 'closed' ? 'warning' : 'default'" size="medium">
|
||||
{{ bet.status === 'open' ? '下注中' : bet.status === 'closed' ? '待结算' : '已结算' }}
|
||||
</van-tag>
|
||||
<span class="bet-stake">{{ bet.minStake }}-{{ bet.maxStake }} 积分</span>
|
||||
</div>
|
||||
<div class="bet-title">{{ bet.title }}</div>
|
||||
<div class="bet-card-footer">
|
||||
<span class="bet-time">{{ timeAgo(bet.created) }}</span>
|
||||
<van-icon name="arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-pull-refresh>
|
||||
</template>
|
||||
|
||||
<!-- 下注弹层 -->
|
||||
<van-action-sheet v-model:show="showBet" title="下注">
|
||||
<div class="bet-sheet-content">
|
||||
<div class="stake-display">
|
||||
<span class="stake-num">{{ stakeAmount }}</span>
|
||||
<span class="stake-unit">积分</span>
|
||||
</div>
|
||||
<van-stepper
|
||||
v-model="stakeAmount"
|
||||
:min="currentBet?.minStake"
|
||||
:max="currentBet?.maxStake"
|
||||
integer
|
||||
/>
|
||||
<div class="bet-sheet-actions">
|
||||
<van-button type="primary" block round :loading="placeLoading" @click="handlePlaceBet">
|
||||
确认下注
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
|
||||
<!-- 创建弹层 -->
|
||||
<van-popup v-model:show="showCreate" position="bottom" round closeable :style="{ height: '85%' }">
|
||||
<div class="popup-content">
|
||||
<div class="popup-title">发起竞猜</div>
|
||||
<van-cell-group inset>
|
||||
<van-field v-model="createForm.title" label="标题" placeholder="竞猜主题" required />
|
||||
<van-field v-model="createForm.description" label="说明" placeholder="可选" />
|
||||
<van-field
|
||||
v-model="createForm.optionsText"
|
||||
type="textarea"
|
||||
label="选项"
|
||||
placeholder="每行一个选项"
|
||||
rows="3"
|
||||
/>
|
||||
<van-field name="stepper" label="最低下注">
|
||||
<template #input><van-stepper v-model="createForm.minStake" min="1" /></template>
|
||||
</van-field>
|
||||
<van-field name="stepper" label="最高下注">
|
||||
<template #input><van-stepper v-model="createForm.maxStake" min="1" /></template>
|
||||
</van-field>
|
||||
<van-field v-model="createForm.deadline" label="截止时间" placeholder="如 2026-12-31 23:59" required />
|
||||
</van-cell-group>
|
||||
<div class="popup-actions">
|
||||
<van-button type="primary" block round :loading="createLoading" @click="handleCreate">
|
||||
创建
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 结算弹层 -->
|
||||
<van-action-sheet v-model:show="showSettle" title="选择胜出选项">
|
||||
<div class="settle-content">
|
||||
<div
|
||||
v-for="opt in options"
|
||||
:key="opt.id"
|
||||
class="settle-option"
|
||||
:class="{ 'settle-selected': settleOption === opt.id }"
|
||||
@click="settleOption = opt.id"
|
||||
>
|
||||
{{ opt.content }}
|
||||
</div>
|
||||
<div class="settle-actions">
|
||||
<van-button type="primary" block round @click="handleSettle">确认结算</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-action-sheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bet-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; }
|
||||
|
||||
.bet-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.bet-card { background: var(--gg-bg-card); border-radius: var(--gg-radius-md); padding: 14px; box-shadow: var(--gg-shadow); }
|
||||
.bet-card:active { opacity: 0.85; }
|
||||
.bet-card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.bet-stake { font-size: 12px; color: var(--gg-text-muted); }
|
||||
.bet-title { font-size: 16px; font-weight: 600; color: var(--gg-text); }
|
||||
.bet-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: 12px; }
|
||||
.detail-meta { font-size: 12px; color: var(--gg-text-muted); }
|
||||
.detail-desc { font-size: 14px; color: var(--gg-text-secondary); margin: 0 0 16px; line-height: 1.5; }
|
||||
|
||||
.options-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.option-item { background: var(--gg-bg-card); border-radius: var(--gg-radius-sm); padding: 12px 14px; border: 1px solid var(--gg-border); }
|
||||
.option-row { display: flex; align-items: center; justify-content: space-between; }
|
||||
.option-text { font-size: 14px; font-weight: 500; color: var(--gg-text); }
|
||||
.winner-badge { font-size: 13px; color: var(--gg-warning); font-weight: 600; }
|
||||
.option-stats { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: var(--gg-text-muted); }
|
||||
|
||||
.my-result { margin-top: 16px; }
|
||||
.detail-actions { margin-top: 20px; }
|
||||
|
||||
/* 下注弹层 */
|
||||
.bet-sheet-content { padding: 24px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
.stake-display { display: flex; align-items: baseline; gap: 4px; }
|
||||
.stake-num { font-size: 40px; font-weight: 700; color: var(--gg-primary); }
|
||||
.stake-unit { font-size: 14px; color: var(--gg-text-muted); }
|
||||
.bet-sheet-actions { width: 100%; }
|
||||
|
||||
/* 弹层通用 */
|
||||
.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; }
|
||||
|
||||
.settle-content { padding: 16px; }
|
||||
.settle-option { padding: 14px; background: var(--gg-bg); border-radius: var(--gg-radius-sm); margin-bottom: 8px; text-align: center; border: 1px solid var(--gg-border); font-size: 14px; }
|
||||
.settle-selected { background: rgba(5, 150, 105, 0.1); border-color: var(--gg-primary); color: var(--gg-primary); font-weight: 600; }
|
||||
.settle-actions { margin-top: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,463 @@
|
||||
<!-- 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>
|
||||
@@ -8,6 +8,8 @@ import { useGroupStore } from '@/stores/group'
|
||||
import { pb } from '@/api/pocketbase'
|
||||
import ActivityFeedMobile from '@/components-mobile/group/ActivityFeedMobile.vue'
|
||||
import MemberListMobile from '@/components-mobile/group/MemberListMobile.vue'
|
||||
import PollListMobile from '@/components-mobile/poll/PollListMobile.vue'
|
||||
import BetListMobile from '@/components-mobile/bet/BetListMobile.vue'
|
||||
import Placeholder from '@/views-mobile/Placeholder.vue'
|
||||
import { Wallet, Box, Warning } from '@element-plus/icons-vue'
|
||||
|
||||
@@ -123,7 +125,10 @@ function goBlacklist() {
|
||||
<!-- 阶段 4 已迁移 -->
|
||||
<ActivityFeedMobile v-if="activeTab === 'activity'" :group-id="groupId" />
|
||||
<MemberListMobile v-else-if="activeTab === 'members'" :group-id="groupId" />
|
||||
<!-- 以下 tab 子组件在阶段 6/9 迁移后接入,现暂用占位 -->
|
||||
<!-- 阶段 6 已迁移 -->
|
||||
<PollListMobile v-else-if="activeTab === 'polls'" :group-id="groupId" />
|
||||
<BetListMobile v-else-if="activeTab === 'bets'" :group-id="groupId" />
|
||||
<!-- 以下 tab 子组件在阶段 9 迁移后接入,现暂用占位 -->
|
||||
<Placeholder v-else />
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
Reference in New Issue
Block a user