1
0
mirror of https://github.com/ialley-workshop-open/uni-halo.git synced 2026-09-12 16:40:40 +08:00

refactor: 优化投票相关组件与工具代码格式与样式

1. 统一uh-article-vote与uh-article-vote-item组件代码缩进与格式
2. 更新投票提示文案与文章卡片样式适配
3. 规范vote.ts工具类代码格式与分号使用
4. 调整文章卡片布局与置顶样式
This commit is contained in:
小莫唐尼
2026-09-09 13:22:50 +08:00
parent 5b091d3c82
commit 004bac175e
4 changed files with 509 additions and 513 deletions
@@ -13,9 +13,13 @@
contentWrapper: string
footer: string
authorGroup: string
avatar: string
nickname: string
infoCol: string
time: string
tagCategory: string
visits: string
pinned: string
}
/** 旧版全局 cardType → 新版 layout;未知值(如 only_text)由 effectiveLayout 兜底 image_top */
@@ -34,19 +38,27 @@
contentWrapper: 'w-full',
footer: 'flex items-center',
authorGroup: 'flex-1 items-center justify-start gap-x-1',
avatar: '',
nickname: '',
infoCol: 'items-center gap-x-1',
time: 'flex-1 text-center',
tagCategory: '',
visits: 'flex-1 justify-end',
pinned: 'right-4 top-4',
},
image_bottom: {
container: 'flex flex-col gap-y-2',
cover: 'order-2',
contentWrapper: 'w-full',
footer: 'flex items-center',
authorGroup: 'flex-1 items-center justify-center gap-x-1',
time: 'flex-1 text-center',
footer: 'order-first flex items-center mb-1',
authorGroup: 'items-center gap-x-2',
avatar: '!h-9 !w-9 !rounded-xl',
nickname: '!text-sm',
infoCol: 'flex-col items-start leading-tight',
time: '',
tagCategory: '',
visits: 'flex-1 justify-center',
visits: '',
pinned: 'right-3 top-3',
},
image_left: {
container: 'flex gap-x-3 !p-2',
@@ -54,9 +66,13 @@
contentWrapper: 'w-0 flex-1 justify-between',
footer: 'flex items-center justify-between',
authorGroup: 'items-center gap-x-1',
avatar: '',
nickname: '',
infoCol: 'items-center gap-x-1',
time: '!hidden',
tagCategory: '!hidden',
visits: '',
pinned: 'left-3 top-3',
},
image_right: {
container: 'flex gap-x-3 !p-2',
@@ -64,9 +80,13 @@
contentWrapper: 'order-1 w-0 flex-1 justify-between',
footer: 'flex items-center justify-between',
authorGroup: 'items-center gap-x-1',
avatar: '',
nickname: '',
infoCol: 'items-center gap-x-1',
time: '!hidden',
tagCategory: '!hidden',
visits: '',
pinned: 'right-3 top-3',
},
}
@@ -104,6 +124,9 @@
const cardLayout = computed(() => CARD_LAYOUTS[effectiveLayout.value])
/** 社交卡片形态(封面在下):左上用户信息(头像 + 昵称/日期垂直)、右上浏览数 */
const isSocialCard = computed(() => effectiveLayout.value === 'image_bottom')
const publishTimeText = computed(() => {
const time = props.article.spec.publishTime
return time ? formatTime({ d: time, f: 'yyyy/MM/dd' }) : ''
@@ -137,7 +160,8 @@
<view class="uh-global-card-glass uh-shadow-xs relative overflow-hidden rounded-xl p-3"
:class="cardLayout.container" @click.stop="handleToArticleDetail()">
<text v-if="article.spec.pinned"
class="text-gray-60 absolute left-2 top-2 z-1 rounded-lg bg-secondary px-2 py-1 text-xs">
class="box-border uh-global-card-glass border text-gray-900 absolute z-1 rounded-md bg-secondary px-1.5 py-0.5 text-xs"
:class="cardLayout.pinned">
置顶
</text>
<image :class="[isGrid ? 'w-full h-24 rounded-lg' : 'w-full h-36 rounded-lg', cardLayout.cover]"
@@ -152,13 +176,13 @@
<view v-if="!isGrid" class="my-1 box-border flex flex-wrap gap-2" :class="cardLayout.tagCategory">
<template v-if="article.categories && article.categories.length !== 0">
<text v-for="cate in article.categories" :key="cate.metadata.name"
class="rounded-xl bg-secondary px-2 py-1 text-xs" @click.stop="handleToCategory(cate)">
class="box-border uh-global-card-glass border uh-shadow-xs rounded-xl bg-secondary px-2 py-0.5 text-xs" @click.stop="handleToCategory(cate)">
{{ cate.spec.displayName }}
</text>
</template>
<template v-if="article.tags && article.tags.length !== 0">
<text v-for="tag in article.tags" :key="tag.metadata.name"
class="rounded-xl bg-secondary px-2 py-1 text-xs">
class="box-border uh-global-card-glass border uh-shadow-xs rounded-xl bg-secondary px-2 py-0.5 text-xs">
# {{ tag.spec.displayName }}
</text>
</template>
@@ -166,11 +190,25 @@
<view class="flex items-center text-xs text-gray-500" :class="cardLayout.footer">
<view v-if="!isGrid" class="flex items-center" :class="cardLayout.authorGroup">
<image :src="article.owner.avatar" class="uh-global-card-glass h-5 w-5 rounded-full"
mode="aspectFill" />
<text class="truncate">{{ article.owner.displayName }}</text>
:class="cardLayout.avatar" mode="aspectFill" />
<template v-if="isSocialCard">
<view class="flex" :class="cardLayout.infoCol">
<text class="truncate" :class="cardLayout.nickname">{{ article.owner.displayName }}</text>
<view class="flex items-center gap-x-2">
<text class="text-gray-400" :class="cardLayout.time">{{ publishTimeText }}</text>
<view class="visits flex items-center gap-x-1 text-gray-400">
浏览
<text class="number">{{ visitCount }}</text>
</view>
</view>
</view>
</template>
<text v-else class="truncate">{{ article.owner.displayName }}</text>
</view>
<text v-if="!isGrid" class="text-gray-400" :class="cardLayout.time">{{ publishTimeText }}</text>
<view class="visits flex items-center gap-x-1" :class="cardLayout.visits">
<text v-if="!isGrid && !isSocialCard" class="text-gray-400"
:class="cardLayout.time">{{ publishTimeText }}</text>
<view v-if="!isSocialCard" class="visits flex items-center gap-x-1" :class="cardLayout.visits">
浏览
<text class="number">{{ visitCount }}</text>
@@ -1,435 +1,397 @@
<script lang="ts" setup>
/**
* 文章详情投票单卡(源自旧项目 components/article-vote,新建复刻)
* 支持 single/multiple/pk 三种类型,已投票/已结束展示百分比,可直接提交
*/
import { computed, ref, watch } from 'vue'
import { getVoteDetail, submitVote } from '@/api/uni-halo'
import { calcVoteState, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
import { computed, ref, watch } from 'vue'
import { getVoteDetail, submitVote } from '@/api/uni-halo'
import { calcVoteState, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
const props = defineProps<{
voteId: string
index?: number
}>()
const props = defineProps<{
voteId : string
index ?: number
}>()
const loading = ref<'loading' | 'success' | 'error'>('loading')
const loadingText = ref('加载中,请稍等...')
const isSubmit = ref(false)
const voteData = ref<IVote | null>(null)
const submitForm = ref<{ voteData: string[] }>({ voteData: [] })
/** 选项 id → 票数(来自 VoteDetail.voteDataList 或 Vote.stats.voteDataList) */
const voteCountMap = ref<Record<string, number>>({})
const loading = ref<'loading' | 'success' | 'error'>('loading')
const loadingText = ref('加载中,请稍等...')
const isSubmit = ref(false)
const voteData = ref<IVote | null>(null)
const submitForm = ref<{ voteData : string[] }>({ voteData: [] })
const voteCountMap = ref<Record<string, number>>({})
/** 是否已投票(本地缓存) */
const isVoted = computed(() => voteCacheUtil.has(props.voteId))
const isVoted = computed(() => voteCacheUtil.has(props.voteId))
/** 投票展示状态(与旧项目一致:未开始/进行中/已结束) */
const voteState = computed(() => {
if (!voteData.value?.spec)
return null
return calcVoteState(voteData.value)
})
const voteState = computed(() => {
if (!voteData.value?.spec) { return null }
return calcVoteState(voteData.value)
})
/** 类型中文名 */
const voteTypeLabel = computed(() => {
const type = voteData.value?.spec?.type
return type ? VOTE_TYPES[type] || type : ''
})
const voteTypeLabel = computed(() => {
const type = voteData.value?.spec?.type
return type ? VOTE_TYPES[type] || type : ''
})
/** 选项票数占比(取自 voteCountMap) */
function handleCalcPercent(option: IVoteOption): number {
const total = voteData.value?.stats?.voteCount || 0
const count = voteCountMap.value[option.id || ''] || 0
if (total === 0)
return 0
return Number(((count / total) * 100).toFixed(2))
}
function handleCalcPercent(option : IVoteOption) : number {
const total = voteData.value?.stats?.voteCount || 0
const count = voteCountMap.value[option.id || ''] || 0
if (total === 0) { return 0 }
return Number(((count / total) * 100).toFixed(2))
}
/** 是否展示百分比结果(已投票 或 已结束) */
const showResult = computed(() => isVoted.value || voteData.value?.spec?.hasEnded || false)
/** 是否展示百分比结果(已投票 或 已结束) */
const showResult = computed(() => isVoted.value || voteData.value?.spec?.hasEnded || false)
async function handleGetData() {
loading.value = 'loading'
loadingText.value = '加载中,请稍等...'
try {
const res = await getVoteDetail(props.voteId)
const detail = res.data as IVoteDetail
const vote = detail.vote || (detail as unknown as IVote)
voteData.value = vote
submitForm.value.voteData = []
// 票数映射:详情 voteDataList 优先,其次 stats.voteDataList
const countList = detail.voteDataList?.length ? detail.voteDataList : vote.stats?.voteDataList
const map: Record<string, number> = {}
;(countList || []).forEach((item) => {
if (item.id)
map[item.id] = item.voteCount || 0
})
voteCountMap.value = map
loading.value = 'success'
}
catch (err) {
console.error('获取投票失败', err)
loading.value = 'error'
loadingText.value = '投票内容加载失败,点击重试'
}
}
async function handleGetData() {
loading.value = 'loading'
loadingText.value = '加载中,请稍等...'
try {
const res = await getVoteDetail(props.voteId)
const detail = res.data as IVoteDetail
const vote = detail.vote || (detail as unknown as IVote)
voteData.value = vote
submitForm.value.voteData = []
// 票数映射:详情 voteDataList 优先,其次 stats.voteDataList
const countList = detail.voteDataList?.length ? detail.voteDataList : vote.stats?.voteDataList
const map : Record<string, number> = {}
; (countList || []).forEach((item) => {
if (item.id)
map[item.id] = item.voteCount || 0
})
voteCountMap.value = map
loading.value = 'success'
}
catch (err) {
console.error('获取投票失败', err)
loading.value = 'error'
loadingText.value = '投票内容加载失败,点击重试'
}
}
function handleSelectSingleOption(option: IVoteOption) {
const spec = voteData.value?.spec
if (!spec)
return
if (voteState.value?.state === '未开始') {
showToast('投票未开始')
return
}
if (spec.hasEnded || isVoted.value)
return
spec.options?.forEach((item) => {
item.checked = option.id === item.id
})
submitForm.value.voteData = (spec.options || []).filter(x => x.checked).map(item => item.id || '')
}
function handleSelectSingleOption(option : IVoteOption) {
const spec = voteData.value?.spec
if (!spec) { return }
if (voteState.value?.state === '未开始') {
showToast('投票未开始')
return
}
if (spec.hasEnded || isVoted.value) { return }
spec.options?.forEach((item) => {
item.checked = option.id === item.id
})
submitForm.value.voteData = (spec.options || []).filter(x => x.checked).map(item => item.id || '')
}
function handleSelectCheckboxOption(option: IVoteOption) {
const spec = voteData.value?.spec
if (!spec)
return
if (voteState.value?.state === '未开始') {
showToast('投票未开始')
return
}
if (spec.hasEnded || isVoted.value)
return
function handleSelectCheckboxOption(option : IVoteOption) {
const spec = voteData.value?.spec
if (!spec) { return }
if (voteState.value?.state === '未开始') {
showToast('投票未开始')
return
}
if (spec.hasEnded || isVoted.value) { return }
const checkedList = (spec.options || []).filter(x => x.checked && x.id !== option.id)
// maxVotes 缺失(0/undefined)时不限制多选数量,避免 0 >= 0 恒真导致无法选择
const maxVotes = spec.maxVotes
if (spec.type === 'multiple' && maxVotes && maxVotes > 0 && checkedList.length >= maxVotes) {
showToast(`最多选择 ${maxVotes}`)
return
}
const checkedList = (spec.options || []).filter(x => x.checked && x.id !== option.id)
// maxVotes 缺失(0/undefined)时不限制多选数量,避免 0 >= 0 恒真导致无法选择
const maxVotes = spec.maxVotes
if (spec.type === 'multiple' && maxVotes && maxVotes > 0 && checkedList.length >= maxVotes) {
showToast(`最多选择 ${maxVotes}`)
return
}
spec.options?.forEach((item) => {
if (option.id === item.id) {
item.checked = !item.checked
}
})
submitForm.value.voteData = (spec.options || []).filter(x => x.checked).map(item => item.id || '')
}
spec.options?.forEach((item) => {
if (option.id === item.id) {
item.checked = !item.checked
}
})
submitForm.value.voteData = (spec.options || []).filter(x => x.checked).map(item => item.id || '')
}
function handleSubmitTip(text: string) {
showToast(text)
}
function handleSubmitTip(text : string) {
showToast(text)
}
async function handleSubmit() {
const spec = voteData.value?.spec
if (!spec)
return
if (submitForm.value.voteData.length === 0) {
showToast('请先选择选项')
return
}
if (!spec.canAnonymously) {
uni.showModal({
title: '提示',
content: '该投票不支持匿名,请到博主的 网站端 进行投票!',
cancelColor: '#666666',
cancelText: '关闭',
confirmText: '复制地址',
success: (res) => {
if (res.confirm) {
uni.setClipboardData({
data: import.meta.env.VITE_SERVER_BASEURL || '',
showToast: false,
success: () => showToast('复制成功'),
})
}
},
})
return
}
async function handleSubmit() {
const spec = voteData.value?.spec
if (!spec) { return }
if (submitForm.value.voteData.length === 0) {
showToast('请先选择选项')
return
}
if (!spec.canAnonymously) {
uni.showModal({
title: '提示',
content: '该投票不支持匿名,请到博主的 网站端 进行投票!',
cancelColor: '#666666',
cancelText: '关闭',
confirmText: '复制地址',
success: (res) => {
if (res.confirm) {
uni.setClipboardData({
data: import.meta.env.VITE_SERVER_BASEURL || '',
showToast: false,
success: () => showToast('复制成功'),
})
}
},
})
return
}
isSubmit.value = true
uni.showLoading({ title: '正在保存...' })
try {
await submitVote(props.voteId, submitForm.value, spec.canAnonymously)
voteCacheUtil.set(props.voteId, {
selected: [...submitForm.value.voteData],
data: voteData.value,
})
showToast('提交成功')
await handleGetData()
}
catch (err) {
console.error('提交投票失败', err)
showToast('提交失败,请重试')
}
finally {
isSubmit.value = false
uni.hideLoading()
}
}
isSubmit.value = true
uni.showLoading({ title: '正在保存...' })
try {
await submitVote(props.voteId, submitForm.value, spec.canAnonymously)
voteCacheUtil.set(props.voteId, {
selected: [...submitForm.value.voteData],
data: voteData.value,
})
showToast('提交成功')
await handleGetData()
}
catch (err) {
console.error('提交投票失败', err)
showToast('提交失败,请重试')
}
finally {
isSubmit.value = false
uni.hideLoading()
}
}
/** 跳转投票详情 */
function handleToVoteDetail() {
uni.navigateTo({
url: `/pages-blog/vote-detail/vote-detail?name=${props.voteId}`,
})
}
/** 跳转投票详情 */
function handleToVoteDetail() {
uni.navigateTo({
url: `/pages-blog/vote-detail/vote-detail?name=${props.voteId}`,
})
}
function showToast(content: string) {
uni.showToast({ icon: 'none', title: content, mask: true })
}
function showToast(content : string) {
uni.showToast({ icon: 'none', title: content, mask: true })
}
/** 格式化时间 */
function formatTime(date?: string, fmt = 'yyyy-MM-dd HH:mm'): string {
return date ? formatTimeUtil({ d: date, f: fmt }) : ''
}
/** 格式化时间 */
function formatTime(date ?: string, fmt = 'yyyy-MM-dd HH:mm') : string {
return date ? formatTimeUtil({ d: date, f: fmt }) : ''
}
watch(() => props.voteId, () => {
handleGetData()
}, { immediate: true })
watch(() => props.voteId, () => {
handleGetData()
}, { immediate: true })
defineExpose({ refresh: handleGetData })
defineExpose({ refresh: handleGetData })
</script>
<template>
<view class="uh-article-vote-item uh-global-card-glass box-border w-full rounded-2xl p-4">
<!-- 加载失败:点击重试 -->
<view v-if="loading === 'error'" class="vote-error py-6 text-center text-[24rpx] text-gray-400" @click="handleGetData">
{{ loadingText }}
</view>
<!-- 加载中 -->
<view v-else-if="loading === 'loading'" class="loading py-6">
<wd-skeleton :row="3" :animated="true" />
</view>
<view class="uh-global-card-glass shadow-none border border-primary box-border w-full rounded-lg p-3">
<view v-if="loading === 'error'" class="vote-error py-6 text-center text-xs text-gray-400"
@click="handleGetData">
{{ loadingText }}
</view>
<view v-else-if="loading === 'loading'" class="loading py-6">
<wd-skeleton :row="3" :animated="true" />
</view>
<template v-else-if="voteData">
<!-- 头部:序号/类型/状态 + 查看详情 -->
<view class="vote-card-head">
<view class="flex items-center justify-between">
<view class="flex flex-wrap items-center gap-1">
<text v-if="props.index !== undefined" class="rounded bg-orange-500 px-2 py-0.5 text-[22rpx] text-white">
{{ props.index + 1 }}
</text>
<text v-if="voteTypeLabel" class="rounded bg-primary/15 px-2 py-0.5 text-[22rpx] text-primary">
{{ voteTypeLabel }}
</text>
<text
v-if="voteState"
class="rounded px-2 py-0.5 text-[22rpx]"
:style="{ color: voteState.color, backgroundColor: `${voteState.color}1a` }"
>
{{ voteState.state }}
</text>
</view>
<text class="shrink-0 text-[22rpx] text-gray-400" @click="handleToVoteDetail">查看投票详情 ></text>
</view>
<view class="title mt-2 text-[30rpx] text-gray-900 font-bold">
{{ voteData.spec?.title }}
</view>
</view>
<template v-else-if="voteData">
<view class="vote-card-head">
<view class="flex items-center justify-between">
<view class="flex flex-wrap items-center gap-1">
<text v-if="props.index !== undefined"
class="rounded-md bg-orange-500 px-2 py-0.5 text-xs text-white">
{{ props.index + 1 }}
</text>
<text v-if="voteTypeLabel" class="rounded-md bg-primary px-2 py-0.5 text-xs text-gray-900">
{{ voteTypeLabel }}
</text>
<text v-if="voteState" class="rounded-md px-2 py-0.5 text-xs"
:style="{ color: voteState.color, backgroundColor: `${voteState.color}1a` }">
{{ voteState.state }}
</text>
</view>
<view class="shrink-0 flex items-center justify-center text-xs text-gray-400"
@click="handleToVoteDetail"> 投票详情 <wd-icon name="right" class="mt-0.5" /> </view>
</view>
<view class="title mt-2 text-sm text-gray-900 font-bold">
{{ voteData.spec?.title }}
</view>
</view>
<view class="vote-card-body mt-3">
<view v-if="voteData.spec?.remark" class="remark mb-3 text-[24rpx] text-gray-400">
{{ voteData.spec.remark }}
</view>
<view class="vote-card-body mt-2">
<view v-if="voteData.spec?.remark" class="remark mb-3 text-xs text-gray-400">
{{ voteData.spec.remark }}
</view>
<!-- 单选 -->
<view v-if="voteData.spec?.type === 'single'" class="flex flex-col gap-2">
<template v-if="showResult">
<view
v-for="(option, optionIndex) in voteData.spec?.options || []"
:key="optionIndex"
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
:class="option.checked ? 'bg-primary/40 text-[#4d7c0f] font-bold' : 'bg-[#e5e5e5]/75'"
:style="{ '--percent': `${handleCalcPercent(option)}%` }"
>
<view class="is-voted-item-content relative z-2 box-border min-h-[72rpx] px-6 py-3">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
{{ option.title }}
</view>
<view class="shrink-0">
{{ handleCalcPercent(option) }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view
v-for="(option, optionIndex) in voteData.spec?.options || []"
:key="optionIndex"
class="vote-select-option box-border rounded-xl bg-[#f6f3ee] px-5 py-4 text-[24rpx]"
:class="option.checked ? 'border-2 border-primary bg-primary/15 text-primary font-bold' : ''"
@click="handleSelectSingleOption(option)"
>
{{ option.title }}
</view>
</template>
</view>
<!-- 单选 -->
<view v-if="voteData.spec?.type === 'single'" class="flex flex-col gap-2">
<template v-if="showResult">
<view v-for="(option, optionIndex) in voteData.spec?.options || []" :key="optionIndex"
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
:class="option.checked ? 'bg-primary/40 text-[#4d7c0f] font-bold' : 'bg-[#e5e5e5]/75'"
:style="{ '--percent': `${handleCalcPercent(option)}%` }">
<view class="is-voted-item-content relative z-2 box-border min-h-[72rpx] px-6 py-3">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
{{ option.title }}
</view>
<view class="shrink-0">
{{ handleCalcPercent(option) }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view v-for="(option, optionIndex) in voteData.spec?.options || []" :key="optionIndex"
class="vote-select-option box-border rounded-xl bg-[#f6f3ee] px-5 py-4 text-[24rpx]"
:class="option.checked ? 'border-2 border-primary bg-primary/15 text-primary font-bold' : ''"
@click="handleSelectSingleOption(option)">
{{ option.title }}
</view>
</template>
</view>
<!-- 多选 -->
<view v-else-if="voteData.spec?.type === 'multiple'" class="flex flex-col gap-2">
<template v-if="showResult">
<view
v-for="(option, optionIndex) in voteData.spec?.options || []"
:key="optionIndex"
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
:class="option.checked ? 'bg-primary/40 text-[#4d7c0f] font-bold' : 'bg-[#e5e5e5]/75'"
:style="{ '--percent': `${handleCalcPercent(option)}%` }"
>
<view class="is-voted-item-content relative z-2 box-border min-h-[72rpx] px-6 py-3">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
{{ option.title }}
</view>
<view class="shrink-0">
{{ handleCalcPercent(option) }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view
v-for="(option, optionIndex) in voteData.spec?.options || []"
:key="optionIndex"
class="vote-select-option box-border rounded-xl bg-[#f6f3ee] px-5 py-4 text-[24rpx]"
:class="option.checked ? 'border-2 border-primary bg-primary/15 text-primary font-bold' : ''"
@click="handleSelectCheckboxOption(option)"
>
{{ option.title }}
</view>
</template>
</view>
<!-- 多选 -->
<view v-else-if="voteData.spec?.type === 'multiple'" class="flex flex-col gap-2">
<template v-if="showResult">
<view v-for="(option, optionIndex) in voteData.spec?.options || []" :key="optionIndex"
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
:class="option.checked ? 'bg-primary/40 text-[#4d7c0f] font-bold' : 'bg-[#e5e5e5]/75'"
:style="{ '--percent': `${handleCalcPercent(option)}%` }">
<view class="is-voted-item-content relative z-2 box-border min-h-[72rpx] px-6 py-3">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
{{ option.title }}
</view>
<view class="shrink-0">
{{ handleCalcPercent(option) }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view v-for="(option, optionIndex) in voteData.spec?.options || []" :key="optionIndex"
class="vote-select-option box-border rounded-xl bg-[#f6f3ee] px-5 py-4 text-[24rpx]"
:class="option.checked ? 'border-2 border-primary bg-primary/15 text-primary font-bold' : ''"
@click="handleSelectCheckboxOption(option)">
{{ option.title }}
</view>
</template>
</view>
<!-- PK -->
<view v-else-if="voteData.spec?.type === 'pk'" class="flex flex-col gap-2">
<!-- PK 对抗条 -->
<view class="pk-container box-border w-full flex">
<view
v-for="(option, optionIndex) in voteData.spec?.options || []"
:key="optionIndex"
class="radio-item"
:class="optionIndex === 0 ? 'radio-left' : 'radio-right'"
:style="{ width: `${handleCalcPercent(option)}%` }"
>
<view class="option-item box-border w-full rounded-xl p-4" :class="optionIndex === 0 ? 'option-item-left' : 'option-item-right'">
{{ handleCalcPercent(option) }}%
</view>
</view>
</view>
<!-- PK 选项列表 -->
<template v-if="showResult">
<view
v-for="(option, optionIndex) in voteData.spec?.options || []"
:key="optionIndex"
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
:class="option.checked ? 'bg-primary/40 text-[#4d7c0f] font-bold' : 'bg-[#e5e5e5]/75'"
:style="{ '--percent': `${handleCalcPercent(option)}%` }"
>
<view class="is-voted-item-content relative z-2 box-border min-h-[72rpx] px-6 py-3">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
选项{{ optionIndex + 1 }}{{ option.title }}
</view>
<view class="shrink-0">
{{ handleCalcPercent(option) }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view
v-for="(option, optionIndex) in voteData.spec?.options || []"
:key="optionIndex"
class="vote-select-option box-border rounded-xl bg-[#f6f3ee] px-5 py-4 text-[24rpx]"
:class="option.checked ? 'border-2 border-primary bg-primary/15 text-primary font-bold' : ''"
@click="handleSelectSingleOption(option)"
>
选项{{ optionIndex + 1 }}{{ option.title }}
</view>
</template>
</view>
</view>
<!-- PK -->
<view v-else-if="voteData.spec?.type === 'pk'" class="flex flex-col gap-2">
<!-- PK 对抗条 -->
<view class="pk-container box-border w-full flex">
<view v-for="(option, optionIndex) in voteData.spec?.options || []" :key="optionIndex"
class="radio-item" :class="optionIndex === 0 ? 'radio-left' : 'radio-right'"
:style="{ width: `${handleCalcPercent(option)}%` }">
<view class="option-item box-border w-full rounded-lg px-3 py-2"
:class="optionIndex === 0 ? 'option-item-left' : 'option-item-right'">
{{ handleCalcPercent(option) }}%
</view>
</view>
</view>
<!-- PK 选项列表 -->
<template v-if="showResult">
<view v-for="(option, optionIndex) in voteData.spec?.options || []" :key="optionIndex"
class="is-voted-item relative box-border overflow-hidden rounded-lg text-xs"
:class="option.checked ? 'bg-primary/40 text-[#4d7c0f] font-bold' : 'bg-[#e5e5e5]/75'"
:style="{ '--percent': `${handleCalcPercent(option)}%` }">
<view class="is-voted-item-content relative z-2 box-border px-3 py-2">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
选项{{ optionIndex + 1 }}{{ option.title }}
</view>
<view class="shrink-0">
{{ handleCalcPercent(option) }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view v-for="(option, optionIndex) in voteData.spec?.options || []" :key="optionIndex"
class="vote-select-option box-border rounded-xl bg-[#f6f3ee] px-5 py-4 text-[24rpx]"
:class="option.checked ? 'border-2 border-primary bg-primary/15 text-primary font-bold' : ''"
@click="handleSelectSingleOption(option)">
选项{{ optionIndex + 1 }}{{ option.title }}
</view>
</template>
</view>
</view>
<!-- 底部:时间 + 参与人数/已投票 -->
<view class="vote-card-foot mt-3 flex items-center justify-between border-t border-[#f7f7f7] pt-2">
<text v-if="voteData.spec?.timeLimit === 'permanent'" class="text-[22rpx] text-gray-400">
结束永久有效
</text>
<text v-else-if="voteState?.state === '未开始'" class="text-[22rpx] text-gray-400">
开始{{ formatTime(voteData.spec?.startDate) }}
</text>
<text v-else class="text-[22rpx] text-gray-400">
结束{{ formatTime(voteData.spec?.endDate) }}
</text>
<view class="flex items-center gap-2">
<text class="text-[22rpx] text-gray-400">{{ voteData.stats?.voteCount || 0 }} 人已参与</text>
<text v-if="isVoted" class="rounded bg-primary/15 px-2 py-0.5 text-[22rpx] text-primary">已投票</text>
</view>
</view>
<!-- 底部:时间 + 参与人数/已投票 -->
<view class="mt-3 flex items-center justify-between pt-2">
<text v-if="voteData.spec?.timeLimit === 'permanent'" class="text-xs text-gray-400">
结束永久有效
</text>
<text v-else-if="voteState?.state === '未开始'" class="text-xs text-gray-400">
开始{{ formatTime(voteData.spec?.startDate) }}
</text>
<text v-else class="text-xs text-gray-400">
结束{{ formatTime(voteData.spec?.endDate) }}
</text>
<view class="flex items-center gap-2">
<text class="text-xs text-gray-400">{{ voteData.stats?.voteCount || 0 }} 人已参与</text>
<text v-if="isVoted" class="rounded bg-primary/15 px-2 py-0.5 text-xs text-primary">已投票</text>
</view>
</view>
<!-- 提交按钮(与旧项目一致:选择后才出现状态机) -->
<view v-if="submitForm.voteData.length !== 0" class="mt-3">
<wd-button v-if="isVoted" disabled block>
您已参与投票
</wd-button>
<wd-button v-else-if="voteState?.state === '未开始'" plain block type="warning" @click="handleSubmitTip('投票未开始')">
投票未开始
</wd-button>
<wd-button v-else-if="voteState?.state === '已结束'" plain block type="danger" @click="handleSubmitTip('投票已结束')">
投票已结束
</wd-button>
<wd-button v-else-if="!voteData.spec?.canAnonymously" plain block type="danger" @click="handleSubmit()">
不支持匿名投票
</wd-button>
<wd-button v-else block type="primary" :loading="isSubmit" :disabled="isSubmit" @click="handleSubmit()">
提交投票
</wd-button>
</view>
</template>
</view>
<!-- 提交按钮(与旧项目一致:选择后才出现状态机) -->
<view v-if="submitForm.voteData.length !== 0" class="mt-3">
<wd-button v-if="isVoted" disabled block>
您已参与投票
</wd-button>
<wd-button v-else-if="voteState?.state === '未开始'" plain block type="warning"
@click="handleSubmitTip('投票未开始')">
投票未开始
</wd-button>
<wd-button v-else-if="voteState?.state === '已结束'" plain block type="danger"
@click="handleSubmitTip('投票已结束')">
投票已结束
</wd-button>
<wd-button v-else-if="!voteData.spec?.canAnonymously" plain block type="danger" @click="handleSubmit()">
不支持匿名投票
</wd-button>
<wd-button v-else block type="primary" :loading="isSubmit" :disabled="isSubmit" @click="handleSubmit()">
提交投票
</wd-button>
</view>
</template>
</view>
</template>
<style scoped lang="scss">
.pk-container {
.radio-item {
flex-grow: 1;
min-width: 30%;
max-width: 70%;
}
.pk-container {
.radio-item {
flex-grow: 1;
min-width: 30%;
max-width: 70%;
}
.option-item-left {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
color: white;
clip-path: polygon(0 0, calc(100% - 40rpx) 0, 100% 100%, 0 100%);
}
.option-item-left {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
color: white;
clip-path: polygon(0 0, calc(100% - 40rpx) 0, 100% 100%, 0 100%);
}
.option-item-right {
background: linear-gradient(90deg, #f87171, #ef4444);
color: white;
clip-path: polygon(0 0, 100% 0, 100% 100%, 40rpx 100%);
text-align: right;
}
}
.option-item-right {
background: linear-gradient(90deg, #f87171, #ef4444);
color: white;
clip-path: polygon(0 0, 100% 0, 100% 100%, 40rpx 100%);
text-align: right;
}
}
.is-voted-item {
&::before {
content: '';
width: var(--percent);
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: #d0d0d0;
z-index: 0;
border-radius: 6rpx;
}
}
.is-voted-item {
&::before {
content: '';
width: var(--percent);
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: #d0d0d0;
z-index: 0;
border-radius: 6rpx;
}
}
</style>
@@ -1,54 +1,46 @@
<script lang="ts" setup>
/**
* 文章详情"相关投票"区块容器(源自旧项目 article-detail 的 vote-wrap 区块)
* 只接收 voteIds;插件可用性检查/展开收起/区块标题/卡片列表全部内置
* 插件未激活或没有投票数据时,整个组件不渲染
*/
import { onMounted, ref } from 'vue'
import { NeedPluginIds } from '@/hooks/usePluginAvailable'
import { onMounted, ref } from 'vue'
import { NeedPluginIds } from '@/hooks/usePluginAvailable'
const props = defineProps<{
voteIds: string[]
}>()
const props = defineProps<{
voteIds : string[]
}>()
/** 展开/收起(与旧项目 voteIsOpen 一致) */
const voteIsOpen = ref(true)
const voteIsOpen = ref(true)
/** 投票插件可用性(检查失败/未安装时整个区块不显示) */
const { available: votePluginAvailable, check: checkVotePluginAvailable } = usePluginAvailable({
pluginId: NeedPluginIds.PluginVote,
tips: '检测到当前插件没有安装或者启用,无法使用投票功能哦,请联系管理员',
})
const { available: votePluginAvailable, check: checkVotePluginAvailable } = usePluginAvailable({
pluginId: NeedPluginIds.PluginVote,
tips: '啊偶,功能正在维护中...',
})
onMounted(() => {
checkVotePluginAvailable()
})
onMounted(() => {
checkVotePluginAvailable()
})
</script>
<template>
<view v-if="votePluginAvailable && voteIds.length > 0" class="box-border px-1">
<view class="uh-global-card-glass uh-shadow-xs mb-3 rounded-xl p-3">
<view class="flex items-center justify-between">
<uh-section-title>相关投票</uh-section-title>
<text class="text-[24rpx] text-gray-400" @click="voteIsOpen = !voteIsOpen">
{{ voteIsOpen ? '收起' : '展开' }}
</text>
</view>
<view v-if="votePluginAvailable && voteIds.length > 0" class="box-border px-3">
<view class="box-border uh-global-card-glass mb-3 rounded-xl p-3">
<uh-section-title>
相关投票
<template #right>
<text class="text-xs text-gray-400" @click="voteIsOpen = !voteIsOpen">
<wd-icon :name="voteIsOpen?'up':'down'"></wd-icon> {{ voteIsOpen ? '收起' : '展开' }}
</text>
</template>
</uh-section-title>
<template v-if="voteIsOpen">
<view class="mt-3 flex flex-col gap-3">
<uh-article-vote-item
v-for="(voteId, voteIdIndex) in voteIds"
:key="voteId"
:vote-id="voteId"
:index="voteIdIndex"
/>
</view>
</template>
<template v-if="voteIsOpen">
<view class="mt-3 flex flex-col gap-3">
<uh-article-vote-item v-for="(voteId, voteIdIndex) in voteIds" :key="voteId" :vote-id="voteId"
:index="voteIdIndex" />
</view>
</template>
<view v-if="!voteIsOpen" class="mt-2 text-center text-[24rpx] text-gray-400" @click="voteIsOpen = !voteIsOpen">
投票已收起点击展开 {{ voteIds.length }} 个投票项
</view>
</view>
</view>
<view v-if="!voteIsOpen" class="mt-3 text-center text-xs text-gray-400 py-3 bg-gray-100 rounded-xl"
@click="voteIsOpen = !voteIsOpen">
投票已收起点击展开 {{ voteIds.length }} 个投票项
</view>
</view>
</view>
</template>
+64 -60
View File
@@ -2,46 +2,46 @@
* 投票工具(源自旧项目 utils/vote.js,按需命名导出)
* 投票状态计算、UID 缓存、投票周期判断
*/
import { getCache, setCache } from './storage'
import { getCache, setCache } from './storage';
/** 投票 UID 缓存 key */
const UnihaloVoteUid = 'unihalo_vote_uid'
const UnihaloVoteUid = 'unihalo_vote_uid';
export type VoteType = 'single' | 'multiple' | 'pk'
export type VoteState = 'not-voted' | 'voting' | 'voted' | 'vote-ended'
export type VoteType = 'single' | 'multiple' | 'pk';
export type VoteState = 'not-voted' | 'voting' | 'voted' | 'vote-ended';
/** 投票类型中文映射(与旧项目一致:key 为插件小写 type) */
export const VOTE_TYPES: Record<string, string> = {
pk: '双选PK',
multiple: '多选',
single: '单选',
}
pk: '双选PK',
multiple: '多选',
single: '单选'
};
/** 投票状态常量(内部状态机) */
export const VOTE_STATES: { NOT_VOTED: VoteState, VOTING: VoteState, VOTED: VoteState, VOTE_ENDED: VoteState } = {
NOT_VOTED: 'not-voted',
VOTING: 'voting',
VOTED: 'voted',
VOTE_ENDED: 'vote-ended',
}
export const VOTE_STATES: { NOT_VOTED: VoteState; VOTING: VoteState; VOTED: VoteState; VOTE_ENDED: VoteState } = {
NOT_VOTED: 'not-voted',
VOTING: 'voting',
VOTED: 'voted',
VOTE_ENDED: 'vote-ended'
};
/** 投票展示状态(与旧项目 VOTE_STATES 一致:中文 + 颜色) */
export const VOTE_STATE_LABELS: Record<string, { state: string, color: string }> = {
: { state: '未开始', color: 'orange' },
: { state: '进行中', color: 'green' },
: { state: '已结束', color: 'red' },
}
export const VOTE_STATE_LABELS: Record<string, { state: string; color: string }> = {
: { state: '未开始', color: 'orange' },
: { state: '进行中', color: 'green' },
: { state: '已结束', color: 'red' }
};
/**
* 获取投票 UID(不存在则生成)
*/
export function getOrCreateVoteUid(): string {
let uid = getCache<string>(UnihaloVoteUid)
if (!uid) {
uid = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
setCache(UnihaloVoteUid, uid)
}
return uid
let uid = getCache<string>(UnihaloVoteUid);
if (!uid) {
uid = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
setCache(UnihaloVoteUid, uid);
}
return uid;
}
/**
@@ -50,22 +50,25 @@ export function getOrCreateVoteUid(): string {
* @param vote 投票对象(含 spec.timeLimit/hasEnded/startDate/endDate)
* @returns { state: '未开始' | '进行中' | '已结束', color: 'orange' | 'green' | 'red' }
*/
export function calcVoteState(
vote: { spec?: { timeLimit?: string, hasEnded?: boolean, startDate?: string, endDate?: string, [key: string]: unknown } },
): { state: string, color: string } {
if (vote.spec?.timeLimit !== 'custom') {
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中']
}
export function calcVoteState(vote: { spec?: { timeLimit?: string; hasEnded?: boolean; startDate?: string; endDate?: string; [key: string]: unknown } }): {
state: string;
color: string;
} {
if (vote.spec?.timeLimit !== 'custom') {
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中'];
}
const nowTime = new Date().getTime()
const startTime = vote.spec?.startDate ? new Date(vote.spec.startDate).getTime() : nowTime
const endTime = vote.spec?.endDate ? new Date(vote.spec.endDate).getTime() : nowTime
const nowTime = new Date().getTime();
const startTime = vote.spec?.startDate ? new Date(vote.spec.startDate).getTime() : nowTime;
const endTime = vote.spec?.endDate ? new Date(vote.spec.endDate).getTime() : nowTime;
if (nowTime < startTime)
return VOTE_STATE_LABELS['未开始']
if (nowTime < endTime)
return VOTE_STATE_LABELS['进行中']
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中']
if (nowTime < startTime) {
return VOTE_STATE_LABELS['未开始'];
}
if (nowTime < endTime) {
return VOTE_STATE_LABELS['进行中'];
}
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中'];
}
/**
@@ -74,35 +77,36 @@ export function calcVoteState(
* @param option 选项(含 count)
*/
export function calcVotePercent(vote: { stats?: { voteCount?: number } }, option: { count?: number }): number {
const total = vote.stats?.voteCount || 0
const count = option.count || 0
if (total === 0)
return 0
return Math.round((count / total) * 100)
const total = vote.stats?.voteCount || 0;
const count = option.count || 0;
if (total === 0) {
return 0;
}
return Math.round((count / total) * 100);
}
/** 投票缓存 key 前缀 */
const VOTE_CACHE_KEY = 'unihalo_vote_'
const VOTE_CACHE_KEY = 'unihalo_vote_';
interface IVoteCacheData {
selected: string[]
data: unknown
selected: string[];
data: unknown;
}
/**
* 投票缓存工具(源自旧项目 utils/vote.js 的 voteCacheUtil)
*/
export const voteCacheUtil = {
/** 是否已缓存(已投票) */
has(name: string): boolean {
return !!getCache<IVoteCacheData>(VOTE_CACHE_KEY + name)
},
/** 获取缓存数据 */
get(name: string): IVoteCacheData | null {
return getCache<IVoteCacheData>(VOTE_CACHE_KEY + name) || null
},
/** 写入缓存 */
set(name: string, data: IVoteCacheData): void {
setCache(VOTE_CACHE_KEY + name, data)
},
}
/** 是否已缓存(已投票) */
has(name: string): boolean {
return !!getCache<IVoteCacheData>(VOTE_CACHE_KEY + name);
},
/** 获取缓存数据 */
get(name: string): IVoteCacheData | null {
return getCache<IVoteCacheData>(VOTE_CACHE_KEY + name) || null;
},
/** 写入缓存 */
set(name: string, data: IVoteCacheData): void {
setCache(VOTE_CACHE_KEY + name, data);
}
};