464 lines
11 KiB
Vue
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>
|