mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-12 16:40:40 +08:00
feat: 新增文章投票功能,优化多项页面细节
1. 新增文章详情页投票组件,支持投票插件检查、展开收起 2. 新增下划线文本、投票卡片、投票列表页组件 3. 优化自定义tabbar样式与返回逻辑 4. 更新投票工具类与投票详情页逻辑 5. 优化公告详情页样式与功能 6. 修复部分样式与类型定义问题
This commit is contained in:
@@ -0,0 +1,421 @@
|
|||||||
|
<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'
|
||||||
|
|
||||||
|
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 isVoted = computed(() => voteCacheUtil.has(props.voteId))
|
||||||
|
|
||||||
|
/** 投票展示状态(与旧项目一致:未开始/进行中/已结束) */
|
||||||
|
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 : ''
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 选项票数占比(取自 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否展示百分比结果(已投票 或 已结束) */
|
||||||
|
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 = '投票内容加载失败,点击重试'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const checkedList = (spec.options || []).filter(x => x.checked && x.id !== option.id)
|
||||||
|
if (spec.type === 'multiple' && checkedList.length >= (spec.maxVotes || 0)) {
|
||||||
|
showToast(`最多选择 ${spec.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 || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 }) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.voteId, () => {
|
||||||
|
handleGetData()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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] font-bold text-gray-900">
|
||||||
|
{{ 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 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>
|
||||||
|
|
||||||
|
<!-- PK -->
|
||||||
|
<view v-else-if="voteData.spec?.type === 'pk'" class="flex flex-col gap-2">
|
||||||
|
<!-- PK 对抗条 -->
|
||||||
|
<view class="pk-container box-border flex w-full">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 底部:时间 + 参与人数/已投票 -->
|
||||||
|
<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 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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 文章详情"相关投票"区块容器(源自旧项目 article-detail 的 vote-wrap 区块)
|
||||||
|
* 只接收 voteIds;插件可用性检查/展开收起/区块标题/卡片列表全部内置
|
||||||
|
* 插件未激活或没有投票数据时,整个组件不渲染
|
||||||
|
*/
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { NeedPluginIds } from '@/hooks/usePluginAvailable'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
voteIds: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 展开/收起(与旧项目 voteIsOpen 一致) */
|
||||||
|
const voteIsOpen = ref(true)
|
||||||
|
|
||||||
|
/** 投票插件可用性(检查失败/未安装时整个区块不显示) */
|
||||||
|
const { available: votePluginAvailable, check: checkVotePluginAvailable } = usePluginAvailable({
|
||||||
|
pluginId: NeedPluginIds.PluginVote,
|
||||||
|
tips: '检测到当前插件没有安装或者启用,无法使用投票功能哦,请联系管理员',
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
@@ -1,256 +1,265 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { DataLoadingStatus } from '@/hooks/useDataLoading'
|
import type { DataLoadingStatus } from '@/hooks/useDataLoading'
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
/** 加载状态(取值同 useDataLoading 返回的 status) */
|
/** 加载状态(取值同 useDataLoading 返回的 status) */
|
||||||
loadingStatus?: DataLoadingStatus
|
loadingStatus ?: DataLoadingStatus
|
||||||
/** 占位区最小高度 */
|
/** 占位区最小高度 */
|
||||||
minHeight?: string
|
minHeight ?: string
|
||||||
loadingText?: string
|
loadingText ?: string
|
||||||
errorText?: string
|
errorText ?: string
|
||||||
emptyText?: string
|
emptyText ?: string
|
||||||
/** 各态副文案(留空则不显示副行) */
|
/** 各态副文案(留空则不显示副行) */
|
||||||
loadingSubText?: string
|
loadingSubText ?: string
|
||||||
errorSubText?: string
|
errorSubText ?: string
|
||||||
emptySubText?: string
|
emptySubText ?: string
|
||||||
useLoadingButton?: boolean
|
useLoadingButton ?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<IProps>(), {
|
const props = withDefaults(defineProps<IProps>(), {
|
||||||
loadingStatus: 'loading',
|
loadingStatus: 'loading',
|
||||||
minHeight: '80vh',
|
minHeight: '80vh',
|
||||||
loadingText: '稍等,正在加载中哦',
|
loadingText: '稍等,正在加载中哦',
|
||||||
errorText: '哎呀,加载失败了呢~',
|
errorText: '哎呀,加载失败了呢~',
|
||||||
emptyText: '啊偶,暂时没有数据呢~',
|
emptyText: '啊偶,暂时没有数据呢~',
|
||||||
loadingSubText: '',
|
loadingSubText: '',
|
||||||
errorSubText: '请检查网络连接,或稍后再试',
|
errorSubText: '请检查网络连接,或稍后再试',
|
||||||
emptySubText: '稍后再来看看吧~',
|
emptySubText: '稍后再来看看吧~',
|
||||||
useLoadingButton: true,
|
useLoadingButton: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{ (e: 'refresh'): void }>()
|
const emit = defineEmits<{ (e : 'refresh') : void }>()
|
||||||
|
|
||||||
const isLoading = computed(() => props.loadingStatus === 'loading')
|
const isLoading = computed(() => props.loadingStatus === 'loading')
|
||||||
|
|
||||||
const statusScene = computed(() => {
|
const statusScene = computed(() => {
|
||||||
switch (props.loadingStatus) {
|
switch (props.loadingStatus) {
|
||||||
case 'error':
|
case 'error':
|
||||||
return {
|
return {
|
||||||
icon: '-injury',
|
icon: '-injury',
|
||||||
stageClass: 'stage-error',
|
stageClass: 'stage-error',
|
||||||
mainTextClass: 'text-red-400',
|
mainTextClass: 'text-red-400',
|
||||||
mainText: props.errorText,
|
mainText: props.errorText,
|
||||||
subText: props.errorSubText,
|
subText: props.errorSubText,
|
||||||
}
|
}
|
||||||
case 'empty':
|
case 'empty':
|
||||||
return {
|
return {
|
||||||
icon: '-confused',
|
icon: '-confused',
|
||||||
stageClass: 'stage-empty',
|
stageClass: 'stage-empty',
|
||||||
mainTextClass: 'text-gray-900',
|
mainTextClass: 'text-gray-900',
|
||||||
mainText: props.emptyText,
|
mainText: props.emptyText,
|
||||||
subText: props.emptySubText,
|
subText: props.emptySubText,
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: '-happy-1',
|
icon: '-happy-1',
|
||||||
stageClass: 'stage-loading',
|
stageClass: 'stage-loading',
|
||||||
mainTextClass: 'text-primary',
|
mainTextClass: 'text-primary',
|
||||||
mainText: props.loadingText,
|
mainText: props.loadingText,
|
||||||
subText: props.loadingSubText,
|
subText: props.loadingSubText,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view
|
<view class="w-full flex flex-col items-center justify-center gap-y-4 text-sm"
|
||||||
class="w-full flex flex-col items-center justify-center gap-y-4 text-sm"
|
:style="{ minHeight: props.minHeight }">
|
||||||
:style="{ minHeight: props.minHeight }"
|
<view class="scene relative h-[250rpx] w-[250rpx] flex items-center justify-center"
|
||||||
>
|
:class="statusScene.stageClass">
|
||||||
<view class="scene relative h-[250rpx] w-[250rpx] flex items-center justify-center" :class="statusScene.stageClass">
|
<view class="glow absolute inset-0 m-auto h-[220rpx] w-[220rpx] rounded-full" />
|
||||||
<view class="glow absolute inset-0 m-auto h-[220rpx] w-[220rpx] rounded-full" />
|
<view class="deco-dot dot-a absolute rounded-full" />
|
||||||
<view class="deco-dot dot-a absolute rounded-full" />
|
<view class="deco-dot dot-b absolute rounded-full" />
|
||||||
<view class="deco-dot dot-b absolute rounded-full" />
|
<view class="bubble relative h-[150rpx] w-[150rpx] flex items-center justify-center rounded-full">
|
||||||
<view class="bubble relative h-[150rpx] w-[150rpx] flex items-center justify-center rounded-full">
|
<text class="bubble-icon">
|
||||||
<text class="bubble-icon">
|
<wd-icon class-prefix="uhemoji-icon" :name="statusScene.icon" size="120rpx" />
|
||||||
<wd-icon class-prefix="uhemoji-icon" :name="statusScene.icon" size="120rpx" />
|
</text>
|
||||||
</text>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 文案区 -->
|
<!-- 文案区 -->
|
||||||
<view class="flex flex-col items-center">
|
<view class="flex flex-col items-center">
|
||||||
<view class="flex items-center justify-center text-sm font-bold" :class="statusScene.mainTextClass">
|
<view class="flex items-center justify-center text-sm font-bold" :class="statusScene.mainTextClass">
|
||||||
<text>{{ statusScene.mainText }}</text>
|
<text>{{ statusScene.mainText }}</text>
|
||||||
<view v-if="isLoading" class="ml-1 flex items-end gap-1">
|
<view v-if="isLoading" class="ml-1 flex items-end gap-1">
|
||||||
<view v-for="n in 3" :key="n" class="typing-dot bg-primary" :style="{ animationDelay: `${(n - 1) * 0.15}s` }" />
|
<view v-for="n in 3" :key="n" class="typing-dot bg-primary"
|
||||||
</view>
|
:style="{ animationDelay: `${(n - 1) * 0.15}s` }" />
|
||||||
</view>
|
</view>
|
||||||
<text v-if="statusScene.subText" class="mt-3 text-xs text-gray-500">
|
</view>
|
||||||
{{ statusScene.subText }}
|
<text v-if="statusScene.subText" class="mt-3 text-xs text-gray-500">
|
||||||
</text>
|
{{ statusScene.subText }}
|
||||||
<uh-button v-if="props.useLoadingButton" class="mt-5" @click="emit('refresh')">
|
</text>
|
||||||
刷新试试
|
<view v-if="props.useLoadingButton" class="mt-5 uh-global-card-glass uh-shadow-xs border rounded-lg">
|
||||||
</uh-button>
|
<uh-button custom-class="!rounded-lg" @click="emit('refresh')">
|
||||||
</view>
|
刷新试试
|
||||||
</view>
|
</uh-button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.bubble {
|
.bubble {
|
||||||
animation: bubble-float 2s ease-in-out infinite;
|
animation: bubble-float 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bubble-icon {
|
.bubble-icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glow {
|
.glow {
|
||||||
animation: glow-pulse 2.4s ease-in-out infinite;
|
animation: glow-pulse 2.4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-loading .glow {
|
.stage-loading .glow {
|
||||||
background: rgba(185, 228, 36, 0.32);
|
background: rgba(185, 228, 36, 0.32);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-error .glow {
|
.stage-error .glow {
|
||||||
background: rgba(248, 113, 113, 0.24);
|
background: rgba(248, 113, 113, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-empty .glow {
|
.stage-empty .glow {
|
||||||
background: rgba(217, 249, 157, 0.5);
|
background: rgba(217, 249, 157, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.deco-dot {
|
.deco-dot {
|
||||||
animation: deco-float 2s ease-in-out infinite;
|
animation: deco-float 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot-a {
|
.dot-a {
|
||||||
top: 16rpx;
|
top: 16rpx;
|
||||||
left: 10rpx;
|
left: 10rpx;
|
||||||
width: 22rpx;
|
width: 22rpx;
|
||||||
height: 22rpx;
|
height: 22rpx;
|
||||||
background: rgba(163, 230, 53, 0.9);
|
background: rgba(163, 230, 53, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot-b {
|
.dot-b {
|
||||||
top: 4rpx;
|
top: 4rpx;
|
||||||
right: 14rpx;
|
right: 14rpx;
|
||||||
width: 14rpx;
|
width: 14rpx;
|
||||||
height: 14rpx;
|
height: 14rpx;
|
||||||
background: rgba(217, 249, 157, 0.95);
|
background: rgba(217, 249, 157, 0.95);
|
||||||
animation-delay: -0.7s;
|
animation-delay: -0.7s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— 三态表情差异化动效(均无限循环,柔和不抢眼) —— */
|
/* —— 三态表情差异化动效(均无限循环,柔和不抢眼) —— */
|
||||||
.stage-loading .bubble-icon {
|
.stage-loading .bubble-icon {
|
||||||
animation: sway 1.6s ease-in-out infinite;
|
animation: sway 1.6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-error .bubble-icon {
|
.stage-error .bubble-icon {
|
||||||
animation: head-shake 2.8s ease-in-out infinite;
|
animation: head-shake 2.8s ease-in-out infinite;
|
||||||
transform-origin: 50% 85%;
|
transform-origin: 50% 85%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-empty .bubble-icon {
|
.stage-empty .bubble-icon {
|
||||||
animation: sigh 3s ease-in-out infinite;
|
animation: sigh 3s ease-in-out infinite;
|
||||||
transform-origin: 50% 85%;
|
transform-origin: 50% 85%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— 加载中三点跳动(错峰延迟经模板 :style 注入,避开 WXSS 不支持的 :nth-child) —— */
|
/* —— 加载中三点跳动(错峰延迟经模板 :style 注入,避开 WXSS 不支持的 :nth-child) —— */
|
||||||
.typing-dot {
|
.typing-dot {
|
||||||
width: 10rpx;
|
width: 10rpx;
|
||||||
height: 10rpx;
|
height: 10rpx;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: dot-jump 1s ease-in-out infinite;
|
animation: dot-jump 1s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— keyframes —— */
|
/* —— keyframes —— */
|
||||||
@keyframes bubble-float {
|
@keyframes bubble-float {
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
0%,
|
||||||
transform: translateY(-14rpx);
|
100% {
|
||||||
}
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes glow-pulse {
|
50% {
|
||||||
0%,
|
transform: translateY(-14rpx);
|
||||||
100% {
|
}
|
||||||
transform: scale(1);
|
}
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
@keyframes glow-pulse {
|
||||||
transform: scale(1.1);
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes deco-float {
|
0%,
|
||||||
0%,
|
100% {
|
||||||
100% {
|
transform: scale(1);
|
||||||
transform: translateY(0);
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: translateY(-12rpx);
|
transform: scale(1.1);
|
||||||
}
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes dot-jump {
|
@keyframes deco-float {
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
0%,
|
||||||
transform: translateY(-8rpx);
|
100% {
|
||||||
}
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 歪头左右打量(loading) */
|
50% {
|
||||||
@keyframes sway {
|
transform: translateY(-12rpx);
|
||||||
0%,
|
}
|
||||||
100% {
|
}
|
||||||
transform: rotate(-4deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
@keyframes dot-jump {
|
||||||
transform: rotate(4deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 缓慢左右摇头(error) */
|
0%,
|
||||||
@keyframes head-shake {
|
100% {
|
||||||
0%,
|
transform: translateY(0);
|
||||||
100% {
|
}
|
||||||
transform: rotate(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
25% {
|
50% {
|
||||||
transform: rotate(6deg);
|
transform: translateY(-8rpx);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
75% {
|
/* 歪头左右打量(loading) */
|
||||||
transform: rotate(-6deg);
|
@keyframes sway {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 轻轻叹气缩肩(empty) */
|
0%,
|
||||||
@keyframes sigh {
|
100% {
|
||||||
0%,
|
transform: rotate(-4deg);
|
||||||
100% {
|
}
|
||||||
transform: scaleY(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
30%,
|
50% {
|
||||||
70% {
|
transform: rotate(4deg);
|
||||||
transform: scaleY(0.92);
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/* 缓慢左右摇头(error) */
|
||||||
|
@keyframes head-shake {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
25% {
|
||||||
|
transform: rotate(6deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
transform: rotate(-6deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 轻轻叹气缩肩(empty) */
|
||||||
|
@keyframes sigh {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
30%,
|
||||||
|
70% {
|
||||||
|
transform: scaleY(0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onPageScroll } from '@dcloudio/uni-app'
|
import { onPageScroll } from '@dcloudio/uni-app'
|
||||||
import { ref, computed, useSlots, onMounted } from 'vue'
|
import { ref, computed, useSlots, onMounted } from 'vue'
|
||||||
|
import { tabbarList } from '@/tabbar/config'
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
useBack : boolean;
|
useBack : boolean;
|
||||||
@@ -57,8 +58,25 @@
|
|||||||
return props.scrollTitle;
|
return props.scrollTitle;
|
||||||
})
|
})
|
||||||
|
|
||||||
// todo:注意:如果是从分享进来的,我们需要处理为返回 home页面
|
// 如果是从分享进来的,我们需要处理为返回 home页面
|
||||||
|
const homePage = 'pages/index/index'
|
||||||
|
|
||||||
|
const allEntryPages = computed<string[]>(() => {
|
||||||
|
return [
|
||||||
|
homePage,
|
||||||
|
'pages/maintenance/maintenance',
|
||||||
|
...tabbarList.map(item => item.pagePath),
|
||||||
|
] as string[];
|
||||||
|
})
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
|
const currentPage = getCurrentPages()[0]
|
||||||
|
if (!allEntryPages.value.some(pagePath => pagePath == currentPage.route)) {
|
||||||
|
uni.reLaunch({
|
||||||
|
url: `/${homePage}`
|
||||||
|
})
|
||||||
|
return;
|
||||||
|
}
|
||||||
uni.navigateBack({ delta: 1 })
|
uni.navigateBack({ delta: 1 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface IProps {
|
||||||
|
customClass?: string
|
||||||
|
lineClass?: string
|
||||||
|
textClass?: string
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<IProps>(), {
|
||||||
|
customClass: '',
|
||||||
|
lineClass: '!h-2/5 -rotate-5',
|
||||||
|
textClass: 'font-bold'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<text class="shrink-0 relative" :class="[props.customClass]">
|
||||||
|
<text class="font-bold relative z-2" :class="[props.textClass]">
|
||||||
|
<slot></slot>
|
||||||
|
</text>
|
||||||
|
<text class="absolute z-1 -right-1 -bottom-0.5 transform rounded-xl bg-secondary h-3/5 w-4/5" :class="[props.lineClass]"></text>
|
||||||
|
</text>
|
||||||
|
</template>
|
||||||
|
|
||||||
@@ -1,175 +1,99 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
/**
|
/**
|
||||||
* 投票卡片(源自旧项目 components/vote-card,新建复刻)
|
* 投票卡片(源自旧项目 components/vote-card,新建复刻)
|
||||||
* 适配 plugin-vote 真实结构:详情接口返回 VoteDetail(嵌套 vote),选项为 {id,title}
|
* 纯展示卡片:列表接口一次返回,页面加工后传入,点击跳转详情
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||||
import { getVoteDetail } from '@/api/uni-halo'
|
|
||||||
import { VOTE_STATES } from '@/utils/vote'
|
interface IVoteCardOption {
|
||||||
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
|
id?: string
|
||||||
|
title?: string
|
||||||
|
checked?: boolean
|
||||||
|
_uh_percent?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IVoteCardData {
|
||||||
|
metadata?: { name?: string, [key: string]: unknown }
|
||||||
|
spec?: {
|
||||||
|
title?: string
|
||||||
|
remark?: string
|
||||||
|
type?: string
|
||||||
|
timeLimit?: string
|
||||||
|
startDate?: string
|
||||||
|
endDate?: string
|
||||||
|
isVoted?: boolean
|
||||||
|
_uh_type?: string
|
||||||
|
_uh_state?: { state: string, color: string }
|
||||||
|
options?: IVoteCardOption[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
stats?: { voteCount?: number, voteDataList?: { id?: string, voteCount?: number }[] }
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
voteName: string
|
vote: IVoteCardData
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
/** 跳转投票详情 */
|
||||||
(e: 'on-vote-success'): void
|
function handleToDetail() {
|
||||||
}>()
|
uni.navigateTo({
|
||||||
|
url: `/pages-blog/vote-detail/vote-detail?name=${props.vote?.metadata?.name || ''}`,
|
||||||
const loading = ref(true)
|
})
|
||||||
const isSubmit = ref(false)
|
|
||||||
const voteData = ref<IVote | null>(null)
|
|
||||||
const voteTypes = ref<string[]>([])
|
|
||||||
const canAnonymously = ref(true)
|
|
||||||
/** 选项 id → 票数(来自 VoteDetail.voteDataList 或 Vote.stats.voteDataList) */
|
|
||||||
const voteCountMap = ref<Record<string, number>>({})
|
|
||||||
|
|
||||||
/** 投票状态(基于插件字段 spec.startDate/endDate/hasEnded/canAnonymously) */
|
|
||||||
const voteState = computed(() => {
|
|
||||||
const spec = voteData.value?.spec
|
|
||||||
if (!spec)
|
|
||||||
return VOTE_STATES.NOT_VOTED
|
|
||||||
const now = Date.now()
|
|
||||||
const startTime = spec.startDate ? new Date(spec.startDate).getTime() : now
|
|
||||||
const endTime = spec.endDate ? new Date(spec.endDate).getTime() : now
|
|
||||||
if (spec.hasEnded || endTime < now)
|
|
||||||
return VOTE_STATES.VOTE_ENDED
|
|
||||||
if (startTime > now)
|
|
||||||
return VOTE_STATES.NOT_VOTED
|
|
||||||
if (voteTypes.value.length !== 0)
|
|
||||||
return VOTE_STATES.VOTED
|
|
||||||
if (!spec.canAnonymously)
|
|
||||||
return VOTE_STATES.NOT_VOTED
|
|
||||||
return VOTE_STATES.VOTING
|
|
||||||
})
|
|
||||||
|
|
||||||
const voteLabel = computed(() => {
|
|
||||||
if (voteState.value === VOTE_STATES.VOTE_ENDED)
|
|
||||||
return '投票已结束'
|
|
||||||
if (voteState.value === VOTE_STATES.VOTED)
|
|
||||||
return '已参与'
|
|
||||||
if (voteState.value === VOTE_STATES.VOTING)
|
|
||||||
return '投票中'
|
|
||||||
return '开始投票'
|
|
||||||
})
|
|
||||||
|
|
||||||
const voteResultLabel = computed(() => (voteState.value === VOTE_STATES.VOTED ? '查看结果' : ''))
|
|
||||||
|
|
||||||
const isSingle = computed(() => voteData.value?.spec?.type === 'single')
|
|
||||||
|
|
||||||
/** 选项票数占比(取自 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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetData() {
|
/** 格式化时间 */
|
||||||
loading.value = true
|
function formatTime(date?: string, fmt = 'yyyy-MM-dd HH:mm'): string {
|
||||||
try {
|
return date ? formatTimeUtil({ d: date, f: fmt }) : ''
|
||||||
const res = await getVoteDetail(props.voteName)
|
|
||||||
const detail = res.data as IVoteDetail
|
|
||||||
const vote = detail.vote || (detail as unknown as IVote)
|
|
||||||
voteData.value = vote
|
|
||||||
canAnonymously.value = !!vote.spec?.canAnonymously
|
|
||||||
voteTypes.value = []
|
|
||||||
// 票数映射:详情 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
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error('获取投票失败', err)
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectOption(option: IVoteOption) {
|
|
||||||
if (voteState.value !== VOTE_STATES.VOTING)
|
|
||||||
return
|
|
||||||
const optionName = option.id || ''
|
|
||||||
if (isSingle.value) {
|
|
||||||
voteTypes.value = [optionName]
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const index = voteTypes.value.indexOf(optionName)
|
|
||||||
if (index === -1) {
|
|
||||||
voteTypes.value.push(optionName)
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
voteTypes.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
if (voteTypes.value.length === 0) {
|
|
||||||
uni.showToast({ icon: 'none', title: '请先选择选项' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
isSubmit.value = true
|
|
||||||
uni.showLoading({ title: '提交中...' })
|
|
||||||
setTimeout(() => {
|
|
||||||
uni.hideLoading()
|
|
||||||
isSubmit.value = false
|
|
||||||
emit('on-vote-success')
|
|
||||||
}, 500)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(() => props.voteName, () => {
|
|
||||||
handleGetData()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
defineExpose({ refresh: handleGetData })
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="uh-vote-card uh-global-card-glass box-border w-full rounded-2xl p-4">
|
<view class="uh-vote-card uh-global-card-glass box-border w-full rounded-2xl p-4" @click="handleToDetail">
|
||||||
<view v-if="loading" class="loading py-6">
|
<!-- 头部:类型/状态/已投票 -->
|
||||||
<wd-skeleton :row="2" :animated="true" />
|
<view class="flex items-center justify-between">
|
||||||
|
<view class="flex flex-wrap items-center gap-1">
|
||||||
|
<text
|
||||||
|
v-if="vote.spec?._uh_type"
|
||||||
|
class="rounded-md bg-secondary px-1.5 py-0.5 text-[22rpx] text-[#4d7c0f]"
|
||||||
|
>
|
||||||
|
{{ vote.spec._uh_type }}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
v-if="vote.spec?._uh_state"
|
||||||
|
class="rounded-md px-1.5 py-0.5 text-[22rpx]"
|
||||||
|
:style="{ color: vote.spec._uh_state.color, backgroundColor: `${vote.spec._uh_state.color}1a` }"
|
||||||
|
>
|
||||||
|
{{ vote.spec._uh_state.state }}
|
||||||
|
</text>
|
||||||
|
<text v-if="vote.spec?.isVoted" class="rounded-md bg-primary/15 px-1.5 py-0.5 text-[22rpx] text-primary">
|
||||||
|
已投票
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<wd-icon name="arrow-right" size="32rpx" class="text-primary" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-else-if="voteData" class="vote-body">
|
<!-- 标题 + 备注 -->
|
||||||
<view class="vote-title text-[30rpx] text-gray-900 font-bold">
|
<view class="vote-title mt-2 text-[30rpx] font-bold text-gray-900">
|
||||||
{{ voteData.spec?.title }}
|
{{ vote.spec?.title }}
|
||||||
</view>
|
</view>
|
||||||
<view v-if="voteData.spec?.remark" class="vote-desc mt-1 text-[24rpx] text-gray-400">
|
<view v-if="vote.spec?.remark" class="vote-desc mt-1 text-[24rpx] text-gray-400">
|
||||||
{{ voteData.spec.remark }}
|
{{ vote.spec.remark }}
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="options mt-5">
|
<!-- 底部:时间 + 参与人数 -->
|
||||||
<view
|
<view class="vote-card-foot mt-3 flex items-center justify-between border-t border-black/5 pt-2">
|
||||||
v-for="option in voteData.spec?.options || []"
|
<text v-if="vote.spec?.timeLimit === 'permanent'" class="text-[22rpx] text-gray-400">
|
||||||
:key="option.id"
|
结束:永久有效
|
||||||
class="option mb-4 flex flex-col border-2 rounded-xl p-5"
|
</text>
|
||||||
:class="voteTypes.includes(option.id || '') ? 'border-[#b9e424] bg-[#f0f7d9]' : 'border-transparent bg-[#f6f3ee]'"
|
<text v-else-if="vote.spec?._uh_state?.state === '未开始'" class="text-[22rpx] text-gray-400">
|
||||||
@click="handleSelectOption(option)"
|
开始:{{ formatTime(vote.spec?.startDate) }}
|
||||||
>
|
</text>
|
||||||
<view class="option-label text-[28rpx] text-gray-700">
|
<text v-else class="text-[22rpx] text-gray-400">
|
||||||
<text>{{ option.title }}</text>
|
结束:{{ formatTime(vote.spec?.endDate) }}
|
||||||
</view>
|
</text>
|
||||||
<view v-if="voteState === VOTE_STATES.VOTED" class="option-bar mt-3 h-4 overflow-hidden rounded-lg bg-black/5">
|
<text class="text-[22rpx] text-gray-400">{{ vote.stats?.voteCount || 0 }} 人已参与</text>
|
||||||
<view class="option-bar-inner h-full rounded-lg" :style="{ width: `${handleCalcPercent(option)}%`, background: 'linear-gradient(90deg, #B9E424, #D7F94C)' }" />
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="voteState === VOTE_STATES.VOTING" class="submit-btn mt-3">
|
|
||||||
<wd-button type="primary" size="small" block :loading="isSubmit" @click="handleSubmit">
|
|
||||||
{{ voteLabel }}
|
|
||||||
</wd-button>
|
|
||||||
</view>
|
|
||||||
<view v-else class="vote-tip mt-4 text-center text-[24rpx] text-gray-400">
|
|
||||||
{{ voteLabel }}{{ voteResultLabel }}
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -456,6 +456,11 @@
|
|||||||
})
|
})
|
||||||
|
|
||||||
const globalAppSettings = computed(() => settingStore.settings)
|
const globalAppSettings = computed(() => settingStore.settings)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
console.log('获取当前所有的页面')
|
||||||
|
console.log(getCurrentPages())
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -566,6 +571,9 @@
|
|||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 相关投票(容器内置插件检查/展开收起,无数据或插件未激活自动不渲染) -->
|
||||||
|
<uh-article-vote :vote-ids="result?._voteIds || []" />
|
||||||
|
|
||||||
<view class="box-border px-1">
|
<view class="box-border px-1">
|
||||||
<!-- 版权声明 -->
|
<!-- 版权声明 -->
|
||||||
<view v-if="postDetailConfig?.copyrightEnabled" class="box-border px-2 mb-3">
|
<view v-if="postDetailConfig?.copyrightEnabled" class="box-border px-2 mb-3">
|
||||||
|
|||||||
+115
-132
@@ -1,154 +1,137 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
/**
|
/**
|
||||||
* 公告详情页(plugin-uni-halo 通知公告,2026-09-03 客户端接入)
|
* 公告详情页(plugin-uni-halo 通知公告,2026-09-03 客户端接入)
|
||||||
* 公开 GET /notices/{name} 返回完整 Notice extension(metadata+spec,spec 内嵌
|
* 公开 GET /notices/{name} 返回完整 Notice extension(metadata+spec,spec 内嵌
|
||||||
* typeDisplayName/typeColor);正文 content 为富文本 HTML,mp-html 渲染。
|
* typeDisplayName/typeColor);正文 content 为富文本 HTML,mp-html 渲染。
|
||||||
* 不存在/删除中返回 404 → 空态提示。UIUX 见 .docs/notice-module-client-design.md
|
* 不存在/删除中返回 404 → 空态提示。UIUX 见 .docs/notice-module-client-design.md
|
||||||
*/
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { getNoticeDetail } from '@/api/uni-halo'
|
import { getNoticeDetail } from '@/api/uni-halo'
|
||||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||||
import { checkIsUrl } from '@/utils/url'
|
import { checkImageUrl, checkIsUrl } from '@/utils/url'
|
||||||
import { markdownConfig } from '@/config/markdown'
|
import { markdownConfig } from '@/config/markdown'
|
||||||
import type { INoticeDetail } from '@/api/types/uni-halo'
|
import type { INoticeDetail } from '@/api/types/uni-halo'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
style: {
|
style: {
|
||||||
navigationBarTitleText: '公告详情',
|
navigationBarTitleText: '公告详情',
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const detail = ref<INoticeDetail | null>(null)
|
const detail = ref<INoticeDetail | null>(null)
|
||||||
|
|
||||||
const spec = computed(() => detail.value?.spec)
|
const spec = computed(() => detail.value?.spec)
|
||||||
const content = computed(() => spec.value?.content || '')
|
const content = computed(() => spec.value?.content || '')
|
||||||
const title = computed(() => spec.value?.title || '')
|
const title = computed(() => spec.value?.title || '')
|
||||||
const typeColor = computed(() => spec.value?.typeColor || '')
|
const typeColor = computed(() => spec.value?.typeColor || '')
|
||||||
const typeDisplayName = computed(() => spec.value?.typeDisplayName || '')
|
const typeDisplayName = computed(() => spec.value?.typeDisplayName || '')
|
||||||
const cover = computed(() => spec.value?.cover || '')
|
/** 封面图:相对路径(如 /upload/...)经 checkImageUrl 补全为完整地址后再渲染 */
|
||||||
const publishTime = computed(() => formatDate(spec.value?.publishTime))
|
const cover = computed(() => {
|
||||||
const hasLink = computed(() => !!spec.value?.link && checkIsUrl(spec.value?.link || ''))
|
const raw = spec.value?.cover || ''
|
||||||
|
return raw ? checkImageUrl(raw) : ''
|
||||||
|
})
|
||||||
|
const publishTime = computed(() => formatDate(spec.value?.publishTime))
|
||||||
|
const hasLink = computed(() => !!spec.value?.link && checkIsUrl(spec.value?.link || ''))
|
||||||
|
|
||||||
function formatDate(value?: string): string {
|
function formatDate(value ?: string) : string {
|
||||||
if (!value)
|
if (!value) { return '' }
|
||||||
return ''
|
const date = new Date(value)
|
||||||
const date = new Date(value)
|
if (Number.isNaN(date.getTime())) { return '' }
|
||||||
if (Number.isNaN(date.getTime()))
|
const pad = (n : number) => String(n).padStart(2, '0')
|
||||||
return ''
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
}
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleToExternal() {
|
function handleCopy() {
|
||||||
if (!spec.value?.link)
|
if (!spec.value?.link) { return }
|
||||||
return
|
uni.setClipboardData({
|
||||||
uni.navigateTo({
|
data: `${title.value} ${spec.value.link}`,
|
||||||
url: `/pages-blog/website/website?data=${JSON.stringify({
|
success: () => {
|
||||||
title: title.value || '公告原文',
|
uni.showToast({
|
||||||
url: encodeURIComponent(spec.value.link),
|
title: '复制成功',
|
||||||
})}`,
|
icon: 'none',
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({
|
||||||
|
title: '复制失败',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** 加载公告详情(状态机;404/无数据 → 空态,其余错误 → error 态,可重试) */
|
/** 加载公告详情(状态机;404/无数据 → 空态,其余错误 → error 态,可重试) */
|
||||||
async function loadDetail() {
|
async function loadDetail() {
|
||||||
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
updateLoadingStatus(DataLoadingStatusEnum.Loading)
|
||||||
if (!name.value) {
|
if (!name.value) {
|
||||||
updateLoadingStatus(DataLoadingStatusEnum.Empty)
|
updateLoadingStatus(DataLoadingStatusEnum.Empty)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await getNoticeDetail(name.value)
|
const res = await getNoticeDetail(name.value)
|
||||||
detail.value = res.data || null
|
detail.value = res.data || null
|
||||||
updateLoadingStatus(
|
updateLoadingStatus(
|
||||||
detail.value?.spec ? DataLoadingStatusEnum.Success : DataLoadingStatusEnum.Empty,
|
detail.value?.spec ? DataLoadingStatusEnum.Success : DataLoadingStatusEnum.Empty,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error('公告详情加载失败', err)
|
console.error('公告详情加载失败', err)
|
||||||
const code = (err as { code?: number }).code
|
const code = (err as { code ?: number }).code
|
||||||
updateLoadingStatus(code === 404 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Error)
|
updateLoadingStatus(code === 404 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
name.value = options?.name || ''
|
name.value = options?.name || ''
|
||||||
loadDetail()
|
loadDetail()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="notice-detail min-h-screen w-screen bg-white pb-12">
|
<view class="box-border min-h-screen w-screen bg-page pb-safe">
|
||||||
<!-- 自定义导航 -->
|
<!-- 自定义导航 -->
|
||||||
<uh-navbar default-title="公告详情" title-color="text-gray-900" />
|
<uh-navbar default-title="公告详情" title-color="text-gray-900" />
|
||||||
|
|
||||||
<!-- 加载/错误/空态(状态机) -->
|
<!-- 加载/错误/空态(状态机) -->
|
||||||
<uh-data-loading
|
<uh-data-loading v-if="loadingStatus !== 'success'" :loading-status="loadingStatus" min-height="55vh"
|
||||||
v-if="loadingStatus !== 'success'" :loading-status="loadingStatus" min-height="55vh"
|
error-text="公告加载失败" empty-text="公告不存在或已下线" empty-sub-text="" @refresh="loadDetail" />
|
||||||
error-text="公告加载失败" empty-text="公告不存在或已下线" empty-sub-text=""
|
|
||||||
@refresh="loadDetail"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 正文 -->
|
<!-- 正文 -->
|
||||||
<view v-else class="px-6 py-6">
|
<view v-else class="box-border p-4">
|
||||||
<image
|
<image v-if="cover" class="mb-5 h-[320rpx] w-full rounded-xl" :src="cover" mode="aspectFill" />
|
||||||
v-if="cover"
|
<view class="text-[36rpx] font-bold leading-snug text-gray-900">
|
||||||
class="mb-5 h-[320rpx] w-full rounded-xl"
|
{{ title }}
|
||||||
:src="cover"
|
</view>
|
||||||
mode="aspectFill"
|
|
||||||
/>
|
|
||||||
<view class="text-[36rpx] font-bold leading-snug text-[#222]">
|
|
||||||
{{ title }}
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="mt-3 flex items-center gap-2">
|
<view class="mt-3 flex items-center gap-2">
|
||||||
<view
|
<view v-if="typeDisplayName" class="rounded px-2 py-0.5 text-[20rpx]" :style="{
|
||||||
v-if="typeDisplayName"
|
color: typeColor || '#f83856',
|
||||||
class="rounded px-2 py-0.5 text-[20rpx]"
|
backgroundColor: typeColor ? `${typeColor}1a` : '#fdeef1',
|
||||||
:style="{
|
}">
|
||||||
color: typeColor || '#f83856',
|
{{ typeDisplayName }}
|
||||||
backgroundColor: typeColor ? `${typeColor}1a` : '#fdeef1',
|
</view>
|
||||||
}"
|
<text class="text-[22rpx] text-gray-500">
|
||||||
>
|
{{ publishTime }}
|
||||||
{{ typeDisplayName }}
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="text-[22rpx] text-[#bbb]">
|
|
||||||
{{ publishTime }}
|
|
||||||
</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="mt-4 border-t border-[#f0f0f0] pt-5">
|
<view class="box-border w-full mt-2 pt-4 pb-6">
|
||||||
<mp-html
|
<mp-html :content="content" lazy-load :domain="markdownConfig.domain ?? ''" scroll-table selectable
|
||||||
:content="content"
|
:tag-style="markdownConfig.tagStyle" :container-style="markdownConfig.containStyle"
|
||||||
lazy-load
|
:show-line-number="false" copy-by-long-press />
|
||||||
:domain="markdownConfig.domain ?? ''"
|
</view>
|
||||||
scroll-table
|
|
||||||
selectable
|
|
||||||
:tag-style="markdownConfig.tagStyle"
|
|
||||||
:container-style="markdownConfig.containStyle"
|
|
||||||
:show-line-number="false"
|
|
||||||
copy-by-long-press
|
|
||||||
/>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view v-if="hasLink" class="mt-10">
|
<view v-if="hasLink" class="fixed left-0 right-0 bottom-0 pb-safe px-4 box-border">
|
||||||
<wd-button type="primary" block round @click="handleToExternal">
|
<view class="w-full h-full uh-global-card-glass border rounded-full mb-4">
|
||||||
查看原文 ↗
|
<uh-button custom-class="w-full !rounded-full py-2.5 font-medium" @click="handleCopy">
|
||||||
</wd-button>
|
复制原文地址
|
||||||
</view>
|
</uh-button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.notice-detail {
|
|
||||||
:deep(img) {
|
|
||||||
max-width: 100%;
|
|
||||||
border-radius: 8rpx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
import { getVoteDetail, submitVote } from '@/api/uni-halo'
|
import { getVoteDetail, submitVote } from '@/api/uni-halo'
|
||||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||||
import { calcVotePercent, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
|
import { calcVotePercent, calcVoteState, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
|
||||||
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
|
||||||
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
|
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
|
||||||
|
|
||||||
@@ -85,27 +85,17 @@ async function handleGetData() {
|
|||||||
const detailRes = res.data as IVoteDetail
|
const detailRes = res.data as IVoteDetail
|
||||||
const tempVote = detailRes.vote as typeof vote.value
|
const tempVote = detailRes.vote as typeof vote.value
|
||||||
if (tempVote) {
|
if (tempVote) {
|
||||||
const typeKey = ((tempVote.spec?.type || 'SINGLE') as string).toUpperCase()
|
const typeKey = ((tempVote.spec?.type || 'single') as string).toLowerCase()
|
||||||
pageTitle.value = `投票详情(${VOTE_TYPES[typeKey as keyof typeof VOTE_TYPES] || tempVote.spec?.type})`
|
pageTitle.value = `投票详情(${VOTE_TYPES[typeKey] || tempVote.spec?.type})`
|
||||||
tempVote.spec = tempVote.spec || {}
|
tempVote.spec = tempVote.spec || {}
|
||||||
tempVote.spec.isVoted = isVoted.value
|
tempVote.spec.isVoted = isVoted.value
|
||||||
tempVote.spec.disabled = isVoted.value
|
tempVote.spec.disabled = isVoted.value
|
||||||
tempVote.spec._uh_type = VOTE_TYPES[typeKey as keyof typeof VOTE_TYPES] || tempVote.spec.type
|
tempVote.spec._uh_type = VOTE_TYPES[typeKey] || tempVote.spec.type
|
||||||
|
|
||||||
// 计算状态
|
// 计算状态(与旧项目 calcVoteState 一致,含 timeLimit 非 custom 时 hasEnded 兜底)
|
||||||
const startTime = tempVote.spec.startDate ? new Date(tempVote.spec.startDate).getTime() : Date.now()
|
tempVote.spec._uh_state = calcVoteState(tempVote)
|
||||||
const endTime = tempVote.spec.endDate ? new Date(tempVote.spec.endDate).getTime() : Date.now()
|
if (tempVote.spec._uh_state.state === '已结束')
|
||||||
const now = Date.now()
|
|
||||||
if (endTime < now) {
|
|
||||||
tempVote.spec._uh_state = { state: '已结束', color: 'red' }
|
|
||||||
tempVote.spec.hasEnded = true
|
tempVote.spec.hasEnded = true
|
||||||
}
|
|
||||||
else if (startTime > now) {
|
|
||||||
tempVote.spec._uh_state = { state: '未开始', color: 'orange' }
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
tempVote.spec._uh_state = { state: '进行中', color: 'green' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选项计算
|
// 选项计算
|
||||||
// 插件选项为 {id,title},票数在 VoteDetail.voteDataList / Vote.stats.voteDataList
|
// 插件选项为 {id,title},票数在 VoteDetail.voteDataList / Vote.stats.voteDataList
|
||||||
@@ -272,7 +262,7 @@ onShareTimeline(() => ({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="app-page box-border min-h-screen w-screen flex flex-col pb-[160rpx]" style="background-color: #fafafd;">
|
<view class="app-page box-border min-h-screen w-screen flex flex-col bg-page pb-[160rpx]">
|
||||||
<!-- 自定义导航 -->
|
<!-- 自定义导航 -->
|
||||||
<uh-navbar :default-title="pageTitle" title-color="text-gray-900" />
|
<uh-navbar :default-title="pageTitle" title-color="text-gray-900" />
|
||||||
|
|
||||||
@@ -288,11 +278,11 @@ onShareTimeline(() => ({
|
|||||||
<block v-else>
|
<block v-else>
|
||||||
<template v-if="vote">
|
<template v-if="vote">
|
||||||
<!-- 投票信息 -->
|
<!-- 投票信息 -->
|
||||||
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
|
<view class="uh-global-card-glass box-border mx-6 mb-6 flex flex-col rounded-2xl p-6">
|
||||||
<view class="sub-title relative box-border pl-6 text-[30rpx]">
|
<view class="sub-title relative box-border pl-6 text-[30rpx]">
|
||||||
投票信息
|
投票信息
|
||||||
</view>
|
</view>
|
||||||
<view class="vote-card-body flex flex-col gap-3 rounded-xl bg-[#f3f4f6] p-6 text-[28rpx] text-[#3f3f3f]">
|
<view class="vote-card-body mt-3 flex flex-col gap-3 rounded-xl bg-page p-6 text-[28rpx] text-[#3f3f3f]">
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text>投票类型:</text>
|
<text>投票类型:</text>
|
||||||
<text class="tag">{{ vote.spec?._uh_type }}</text>
|
<text class="tag">{{ vote.spec?._uh_type }}</text>
|
||||||
@@ -303,7 +293,7 @@ onShareTimeline(() => ({
|
|||||||
</view>
|
</view>
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text>投票方式:</text>
|
<text>投票方式:</text>
|
||||||
<text class="tag" :class="vote.spec?.canAnonymously ? 'text-[#03a9f4]' : 'text-[#f44336]'">
|
<text class="tag" :class="vote.spec?.canAnonymously ? 'text-primary' : 'text-[#f44336]'">
|
||||||
{{ vote.spec?.canAnonymously ? '匿名' : '不匿名' }}
|
{{ vote.spec?.canAnonymously ? '匿名' : '不匿名' }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -318,7 +308,7 @@ onShareTimeline(() => ({
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 投票内容 -->
|
<!-- 投票内容 -->
|
||||||
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
|
<view class="uh-global-card-glass box-border mx-6 mb-6 flex flex-col rounded-2xl p-6">
|
||||||
<view class="sub-title relative box-border pl-6 text-[30rpx]">
|
<view class="sub-title relative box-border pl-6 text-[30rpx]">
|
||||||
投票内容
|
投票内容
|
||||||
</view>
|
</view>
|
||||||
@@ -333,18 +323,33 @@ onShareTimeline(() => ({
|
|||||||
<text v-if="vote.spec?.type === 'multiple'" class="sub-title-count text-[24rpx] font-normal">(最多选择 {{ vote.spec?.maxVotes }} 项)</text>
|
<text v-if="vote.spec?.type === 'multiple'" class="sub-title-count text-[24rpx] font-normal">(最多选择 {{ vote.spec?.maxVotes }} 项)</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="options mt-6 flex flex-col gap-4">
|
<view class="options mt-6 flex flex-col gap-4">
|
||||||
|
<!-- PK 对抗条(与旧项目 pk-container 一致) -->
|
||||||
|
<view v-if="vote.spec?.type === 'pk'" class="pk-container box-border flex w-full">
|
||||||
|
<view
|
||||||
|
v-for="(option, optionIndex) in vote.spec?.options"
|
||||||
|
:key="optionIndex"
|
||||||
|
class="radio-item flex-grow"
|
||||||
|
:class="optionIndex === 0 ? 'radio-left' : 'radio-right'"
|
||||||
|
:style="{ width: `${option._uh_percent}%` }"
|
||||||
|
>
|
||||||
|
<view class="option-item box-border w-full rounded-xl p-6" :class="optionIndex === 0 ? 'option-item-left' : 'option-item-right'">
|
||||||
|
{{ option._uh_percent }}%
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<template v-if="isVoted || isEnded">
|
<template v-if="isVoted || isEnded">
|
||||||
<view
|
<view
|
||||||
v-for="(option, optionIndex) in vote.spec?.options"
|
v-for="(option, optionIndex) in vote.spec?.options"
|
||||||
:key="optionIndex"
|
:key="optionIndex"
|
||||||
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
|
class="is-voted-item relative box-border min-h-[72rpx] overflow-hidden rounded-xl text-[24rpx]"
|
||||||
:class="option.checked ? 'bg-[#03a9f4]/35 text-white' : 'bg-[#e5e5e5]/75'"
|
:class="option.checked ? 'bg-primary/40 text-[#4d7c0f] font-bold' : 'bg-[#e5e5e5]/75'"
|
||||||
:style="{ '--percent': `${option._uh_percent}%` }"
|
:style="{ '--percent': `${option._uh_percent}%` }"
|
||||||
>
|
>
|
||||||
<view class="is-voted-item-content relative z-2 box-border min-h-[72rpx] px-6 py-3">
|
<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 items-center justify-between">
|
||||||
<view class="flex-1 text-left">
|
<view class="flex-1 text-left">
|
||||||
{{ option.title }}
|
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}:` : '' }}{{ option.title }}
|
||||||
</view>
|
</view>
|
||||||
<view class="shrink-0">
|
<view class="shrink-0">
|
||||||
{{ option._uh_percent }}%
|
{{ option._uh_percent }}%
|
||||||
@@ -358,7 +363,7 @@ onShareTimeline(() => ({
|
|||||||
v-for="(option, optionIndex) in vote.spec?.options"
|
v-for="(option, optionIndex) in vote.spec?.options"
|
||||||
:key="optionIndex"
|
:key="optionIndex"
|
||||||
class="vote-select-option box-border rounded-xl bg-[#f3f4f6] px-6 py-5 text-[24rpx]"
|
class="vote-select-option box-border rounded-xl bg-[#f3f4f6] px-6 py-5 text-[24rpx]"
|
||||||
:class="option.checked ? 'border-2 border-[#03a9f4] bg-[#03a9f4]/15 text-[#03a9f4]' : ''"
|
:class="option.checked ? 'border-2 border-primary bg-primary/15 text-primary font-bold' : ''"
|
||||||
@click="vote.spec?.type === 'multiple' ? handleSelectCheckboxOption(option) : handleSelectSingleOption(option)"
|
@click="vote.spec?.type === 'multiple' ? handleSelectCheckboxOption(option) : handleSelectSingleOption(option)"
|
||||||
>
|
>
|
||||||
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}:` : '' }}{{ option.title }}
|
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}:` : '' }}{{ option.title }}
|
||||||
@@ -368,7 +373,7 @@ onShareTimeline(() => ({
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 投票统计 -->
|
<!-- 投票统计 -->
|
||||||
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
|
<view class="uh-global-card-glass box-border mx-6 mb-6 flex flex-col rounded-2xl p-6">
|
||||||
<view class="sub-title relative box-border pl-6 text-[30rpx]">
|
<view class="sub-title relative box-border pl-6 text-[30rpx]">
|
||||||
投票统计
|
投票统计
|
||||||
</view>
|
</view>
|
||||||
@@ -378,7 +383,7 @@ onShareTimeline(() => ({
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 提交按钮 -->
|
<!-- 提交按钮 -->
|
||||||
<view class="vote-submit fixed bottom-0 left-0 z-99 box-border w-screen border-t-2 border-[#eee] bg-white/98 px-9 py-6 shadow-sm" :style="{ paddingBottom: `${safeAreaBottom}rpx` }">
|
<view class="vote-submit fixed bottom-0 left-0 z-99 box-border w-screen border-t border-black/5 bg-white/90 px-9 py-6 shadow-sm backdrop-blur" :style="{ paddingBottom: `${safeAreaBottom}rpx` }">
|
||||||
<wd-button v-if="isVoted" disabled block>
|
<wd-button v-if="isVoted" disabled block>
|
||||||
您已参与投票
|
您已参与投票
|
||||||
</wd-button>
|
</wd-button>
|
||||||
@@ -413,7 +418,7 @@ onShareTimeline(() => ({
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 6rpx;
|
top: 6rpx;
|
||||||
background: #03a9f4;
|
background: var(--wot-color-theme, #b9e424);
|
||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -431,5 +436,25 @@ onShareTimeline(() => ({
|
|||||||
border-radius: 6rpx;
|
border-radius: 6rpx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pk-container {
|
||||||
|
.radio-item {
|
||||||
|
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-right {
|
||||||
|
background: linear-gradient(90deg, #f87171, #ef4444);
|
||||||
|
color: white;
|
||||||
|
clip-path: polygon(0 0, 100% 0, 100% 100%, 40rpx 100%);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+219
-10
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
/**
|
/**
|
||||||
* 投票列表页(源自旧项目 pagesA/votes,新建复刻)
|
* 投票列表页(源自旧项目 pagesA/votes,新建复刻)
|
||||||
* 展示投票列表,每个投票项用 uh-vote-card 渲染
|
* 展示投票列表,支持搜索 + 类型/状态/排序/是否已投筛选,每个投票项用 uh-vote-card 渲染
|
||||||
*/
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
@@ -9,6 +9,8 @@ import { getVoteList } from '@/api/uni-halo'
|
|||||||
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
|
||||||
import { NeedPluginIds } from '@/hooks/usePluginAvailable'
|
import { NeedPluginIds } from '@/hooks/usePluginAvailable'
|
||||||
import { useAppConfigStore } from '@/store/appConfig'
|
import { useAppConfigStore } from '@/store/appConfig'
|
||||||
|
import { debounce } from '@/utils/debounce'
|
||||||
|
import { calcVoteState, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
|
||||||
import type { IVoteItem } from '@/api/types/uni-halo'
|
import type { IVoteItem } from '@/api/types/uni-halo'
|
||||||
|
|
||||||
definePage({
|
definePage({
|
||||||
@@ -38,10 +40,129 @@ async function handlePluginRefresh() {
|
|||||||
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
|
||||||
const dataList = ref<IVoteItem[]>([])
|
const dataList = ref<IVoteItem[]>([])
|
||||||
const hasNext = ref(false)
|
const hasNext = ref(false)
|
||||||
const queryParams = ref({ page: 1, size: 10 })
|
|
||||||
const isLoadMore = ref(false)
|
const isLoadMore = ref(false)
|
||||||
const loadMoreText = ref('加载中...')
|
const loadMoreText = ref('加载中...')
|
||||||
|
/** 是否已投过滤(前端过滤,接口无此参数) */
|
||||||
|
const filterIsVoted = ref<boolean | undefined>(undefined)
|
||||||
|
const queryParams = ref<Record<string, unknown>>({
|
||||||
|
keyword: '',
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
sort: undefined,
|
||||||
|
type: undefined,
|
||||||
|
hasEnded: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ---------------- 筛选 ---------------- */
|
||||||
|
interface IFilterOption {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IFilterItem {
|
||||||
|
key: 'type' | 'hasEnded' | 'sort' | 'isVoted'
|
||||||
|
label: string
|
||||||
|
options: IFilterOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 筛选维度(与旧项目 tm-dropDownMenu 一致) */
|
||||||
|
const filterConfig: IFilterItem[] = [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: '类型',
|
||||||
|
options: [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '单选', value: 'single' },
|
||||||
|
{ label: '多选', value: 'multiple' },
|
||||||
|
{ label: '双选PK', value: 'pk' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'hasEnded',
|
||||||
|
label: '状态',
|
||||||
|
options: [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '进行中', value: 'false' },
|
||||||
|
{ label: '已结束', value: 'true' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sort',
|
||||||
|
label: '排序',
|
||||||
|
options: [
|
||||||
|
{ label: '默认排序', value: '' },
|
||||||
|
{ label: '较近创建', value: 'metadata.creationTimestamp,desc' },
|
||||||
|
{ label: '较早创建', value: 'metadata.creationTimestamp,asc' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'isVoted',
|
||||||
|
label: '是否已投',
|
||||||
|
options: [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '未投票', value: 'false' },
|
||||||
|
{ label: '已投票', value: 'true' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 各维度当前选中值(空串 = 全部) */
|
||||||
|
const filterValues = ref<Record<string, string>>({ type: '', hasEnded: '', sort: '', isVoted: '' })
|
||||||
|
|
||||||
|
/** 当前选中中文标签(用于筛选栏展示) */
|
||||||
|
const filterLabels = computed(() => {
|
||||||
|
const map: Record<string, string> = {}
|
||||||
|
for (const f of filterConfig) {
|
||||||
|
const cur = filterValues.value[f.key]
|
||||||
|
map[f.key] = f.options.find(o => o.value === cur)?.label || '全部'
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 筛选弹层 */
|
||||||
|
const filterPopup = ref<{ show: boolean, item: IFilterItem | null }>({ show: false, item: null })
|
||||||
|
|
||||||
|
function handleOpenFilter(item: IFilterItem) {
|
||||||
|
filterPopup.value = { show: true, item }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectFilter(option: IFilterOption) {
|
||||||
|
const item = filterPopup.value.item
|
||||||
|
if (!item)
|
||||||
|
return
|
||||||
|
filterValues.value[item.key] = option.value
|
||||||
|
filterPopup.value.show = false
|
||||||
|
|
||||||
|
if (item.key === 'isVoted') {
|
||||||
|
filterIsVoted.value = option.value === '' ? undefined : option.value === 'true'
|
||||||
|
}
|
||||||
|
else if (item.key === 'hasEnded') {
|
||||||
|
queryParams.value.hasEnded = option.value === '' ? undefined : option.value === 'true'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
queryParams.value[item.key] = option.value === '' ? undefined : option.value
|
||||||
|
}
|
||||||
|
|
||||||
|
queryParams.value.page = 1
|
||||||
|
isLoadMore.value = false
|
||||||
|
handleGetData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 搜索 ---------------- */
|
||||||
|
/** 实时搜索:输入防抖 400ms 后触发(对应旧版 tm-search 的 @input) */
|
||||||
|
const handleOnInput = debounce(() => {
|
||||||
|
queryParams.value.page = 1
|
||||||
|
isLoadMore.value = false
|
||||||
|
handleGetData()
|
||||||
|
}, 400)
|
||||||
|
|
||||||
|
function handleOnSearch() {
|
||||||
|
queryParams.value.page = 1
|
||||||
|
isLoadMore.value = false
|
||||||
|
handleGetData()
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 数据加载 ---------------- */
|
||||||
async function handleGetData() {
|
async function handleGetData() {
|
||||||
if (calcAuditModeEnabled.value) {
|
if (calcAuditModeEnabled.value) {
|
||||||
updateLoadingStatus(
|
updateLoadingStatus(
|
||||||
@@ -61,9 +182,31 @@ async function handleGetData() {
|
|||||||
try {
|
try {
|
||||||
const res = await getVoteList({ ...queryParams.value })
|
const res = await getVoteList({ ...queryParams.value })
|
||||||
hasNext.value = res.data.hasNext || false
|
hasNext.value = res.data.hasNext || false
|
||||||
|
|
||||||
|
// 加工列表数据(与旧项目一致):isVoted/_uh_state/_uh_type
|
||||||
|
const tempItems = res.data.items.map((item) => {
|
||||||
|
item.spec = item.spec || {}
|
||||||
|
item.spec.disabled = true
|
||||||
|
item.spec.isVoted = voteCacheUtil.has(item.metadata?.name || '')
|
||||||
|
item.spec._uh_state = calcVoteState(item)
|
||||||
|
item.spec._uh_type = VOTE_TYPES[item.spec.type || ''] || item.spec.type
|
||||||
|
return item
|
||||||
|
})
|
||||||
|
|
||||||
dataList.value = isLoadMore.value
|
dataList.value = isLoadMore.value
|
||||||
? dataList.value.concat(res.data.items)
|
? dataList.value.concat(tempItems)
|
||||||
: res.data.items
|
: tempItems
|
||||||
|
|
||||||
|
// 未投优先排序(与旧项目一致)
|
||||||
|
dataList.value = dataList.value.sort((a, b) => {
|
||||||
|
return Number(a.spec?.isVoted) - Number(b.spec?.isVoted)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 是否已投过滤(与旧项目一致)
|
||||||
|
if (filterIsVoted.value !== undefined) {
|
||||||
|
dataList.value = dataList.value.filter(x => x.spec?.isVoted === filterIsVoted.value)
|
||||||
|
}
|
||||||
|
|
||||||
updateLoadingStatus(
|
updateLoadingStatus(
|
||||||
dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
|
dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
|
||||||
)
|
)
|
||||||
@@ -82,8 +225,14 @@ async function handleGetData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOnVoteSuccess() {
|
/** 卡片点击跳详情(与旧项目 VoteCard @on-click 一致) */
|
||||||
uni.showToast({ icon: 'none', title: '投票成功!' })
|
function handleOnVoteClick(vote: IVoteItem) {
|
||||||
|
const name = vote.metadata?.name
|
||||||
|
if (!name)
|
||||||
|
return
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages-blog/vote-detail/vote-detail?name=${name}`,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToTopPage(duration = 500) {
|
function handleToTopPage(duration = 500) {
|
||||||
@@ -119,8 +268,12 @@ onPullDownRefresh(() => {
|
|||||||
onReachBottom(() => {
|
onReachBottom(() => {
|
||||||
if (!uniHaloPluginAvailable.value)
|
if (!uniHaloPluginAvailable.value)
|
||||||
return
|
return
|
||||||
|
if (calcAuditModeEnabled.value) {
|
||||||
|
uni.showToast({ icon: 'none', title: '没有更多数据了' })
|
||||||
|
return
|
||||||
|
}
|
||||||
if (hasNext.value) {
|
if (hasNext.value) {
|
||||||
queryParams.value.page += 1
|
queryParams.value.page = Number(queryParams.value.page) + 1
|
||||||
isLoadMore.value = true
|
isLoadMore.value = true
|
||||||
handleGetData()
|
handleGetData()
|
||||||
}
|
}
|
||||||
@@ -143,6 +296,39 @@ onReachBottom(() => {
|
|||||||
@on-refresh="handlePluginRefresh"
|
@on-refresh="handlePluginRefresh"
|
||||||
/>
|
/>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<!-- 顶部搜索框 -->
|
||||||
|
<view class="box-border w-screen px-3 pt-2">
|
||||||
|
<view class="uh-global-card-glass flex h-9 items-center gap-3 rounded-full px-5">
|
||||||
|
<wd-icon name="search" size="16px" />
|
||||||
|
<input
|
||||||
|
v-model="queryParams.keyword as string"
|
||||||
|
class="flex-1 text-[26rpx] text-gray-900"
|
||||||
|
placeholder="搜索投票..."
|
||||||
|
placeholder-class="text-gray-400"
|
||||||
|
confirm-type="search"
|
||||||
|
@input="handleOnInput"
|
||||||
|
@confirm="handleOnSearch"
|
||||||
|
>
|
||||||
|
<view v-if="queryParams.keyword" class="flex items-center" @click="queryParams.keyword = ''; handleOnSearch()">
|
||||||
|
<wd-icon name="close" size="14px" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<!-- 筛选栏 -->
|
||||||
|
<view class="flex items-center justify-between py-2">
|
||||||
|
<view
|
||||||
|
v-for="f in filterConfig"
|
||||||
|
:key="f.key"
|
||||||
|
class="flex flex-1 items-center justify-center gap-1"
|
||||||
|
@click="handleOpenFilter(f)"
|
||||||
|
>
|
||||||
|
<text class="text-[24rpx]" :class="filterValues[f.key] ? 'text-primary font-bold' : 'text-gray-600'">
|
||||||
|
{{ filterLabels[f.key] }}
|
||||||
|
</text>
|
||||||
|
<wd-icon name="arrow-down" size="10px" color="#9ca3af" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- 加载/错误/空占位(状态机) -->
|
<!-- 加载/错误/空占位(状态机) -->
|
||||||
<view v-if="loadingStatus !== 'success'">
|
<view v-if="loadingStatus !== 'success'">
|
||||||
<uh-data-loading
|
<uh-data-loading
|
||||||
@@ -157,17 +343,40 @@ onReachBottom(() => {
|
|||||||
<uh-vote-card
|
<uh-vote-card
|
||||||
v-for="vote in dataList"
|
v-for="vote in dataList"
|
||||||
:key="vote.metadata?.name"
|
:key="vote.metadata?.name"
|
||||||
:vote-name="vote.metadata?.name || ''"
|
:vote="vote"
|
||||||
@on-vote-success="handleOnVoteSuccess"
|
@click="handleOnVoteClick(vote)"
|
||||||
/>
|
/>
|
||||||
<view class="load-text py-5 text-center text-[24rpx] text-gray-400">
|
<view class="load-text py-5 text-center text-[24rpx] text-gray-400">
|
||||||
{{ loadMoreText }}
|
{{ loadMoreText }}
|
||||||
</view>
|
</view>
|
||||||
<view class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 h-[72rpx] w-[72rpx] flex items-center justify-center rounded-full" @click="handleToTopPage()">
|
<view
|
||||||
|
class="to-top-btn uh-global-card-glass fixed bottom-[100rpx] right-6 z-6 flex h-[72rpx] w-[72rpx] items-center justify-center rounded-full"
|
||||||
|
@click="handleToTopPage()"
|
||||||
|
>
|
||||||
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
<wd-icon name="arrow-up" size="20px" color="#6b7280" />
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 筛选弹层 -->
|
||||||
|
<wd-popup v-model="filterPopup.show" position="bottom" custom-style="border-radius: 24rpx 24rpx 0 0;">
|
||||||
|
<view v-if="filterPopup.item" class="box-border p-6 pb-10">
|
||||||
|
<view class="mb-4 text-center text-[30rpx] font-bold text-gray-900">
|
||||||
|
{{ filterPopup.item.label }}
|
||||||
|
</view>
|
||||||
|
<view class="flex flex-col gap-2">
|
||||||
|
<view
|
||||||
|
v-for="opt in filterPopup.item.options"
|
||||||
|
:key="opt.label"
|
||||||
|
class="box-border rounded-xl px-5 py-3 text-center text-[28rpx]"
|
||||||
|
:class="filterValues[filterPopup.item.key] === opt.value ? 'bg-primary/15 text-primary font-bold' : 'bg-[#f6f3ee] text-gray-700'"
|
||||||
|
@click="handleSelectFilter(opt)"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</wd-popup>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -321,11 +321,9 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="relative z-2 mt-[64rpx] text-[54rpx] font-black leading-[64rpx]">
|
<view class="relative z-2 mt-8 text-6 font-black leading-8">
|
||||||
{{ title }}
|
<uh-text-underline>{{ title }}</uh-text-underline>
|
||||||
</view>
|
</view>
|
||||||
<view
|
|
||||||
class="relative z-2 mx-auto mt-[8rpx] h-[8rpx] w-[224rpx] rounded-full from-transparent via-[#a7e93b] to-transparent bg-gradient-to-r" />
|
|
||||||
<view class="relative z-2 mt-6 px-[16rpx] text-[25rpx] text-balck/50 font-medium leading-[1.7]">
|
<view class="relative z-2 mt-6 px-[16rpx] text-[25rpx] text-balck/50 font-medium leading-[1.7]">
|
||||||
<template v-if="fromReason!=='plugin' && noticeLines.length > 0">
|
<template v-if="fromReason!=='plugin' && noticeLines.length > 0">
|
||||||
<text v-for="(line, index) in noticeLines" :key="index" class="block">{{ line }}</text>
|
<text v-for="(line, index) in noticeLines" :key="index" class="block">{{ line }}</text>
|
||||||
|
|||||||
@@ -16,33 +16,37 @@ function getImageByIndex(index: number, item: CustomTabBarItem) {
|
|||||||
}
|
}
|
||||||
return tabbarStore.curIdx === index ? item.iconActive : item.icon
|
return tabbarStore.curIdx === index ? item.iconActive : item.icon
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isActive(index: number) {
|
||||||
|
return tabbarStore.curIdx === index
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view class="flex flex-col items-center justify-center">
|
<view class="box-border py-0.5 flex flex-col items-center justify-center rounded-full">
|
||||||
<template v-if="item.iconType === 'uiLib'">
|
<template v-if="item.iconType === 'uiLib'">
|
||||||
<!-- TODO: 以下内容请根据选择的UI库自行替换 -->
|
<!-- TODO: 以下内容请根据选择的UI库自行替换 -->
|
||||||
<!-- 如:<wd-icon name="home" /> (https://wot-design-uni.cn/component/icon.html) -->
|
<!-- 如:<wd-icon name="home" /> (https://wot-design-uni.cn/component/icon.html) -->
|
||||||
<!-- 如:<uv-icon name="home" /> (https://www.uvui.cn/components/icon.html) -->
|
<!-- 如:<uv-icon name="home" /> (https://www.uvui.cn/components/icon.html) -->
|
||||||
<!-- 如:<sar-icon name="image" /> (https://sard.wzt.zone/sard-uniapp-docs/components/icon)(sar没有home图标^_^) -->
|
<!-- 如:<sar-icon name="image" /> (https://sard.wzt.zone/sard-uniapp-docs/components/icon)(sar没有home图标^_^) -->
|
||||||
<!-- <wd-icon :name="item.icon" size="20" /> -->
|
<text class="text-primary"><wd-icon :name="item.icon" size="52rpx" /></text>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="item.iconType === 'unocss' || item.iconType === 'iconfont'">
|
<template v-if="item.iconType === 'unocss' || item.iconType === 'iconfont'">
|
||||||
<view :class="[item.icon, isBulge ? 'text-80px' : 'text-20px']" />
|
<view class="flex-1" :class="[item.icon, isBulge ? 'text-80px' : 'text-20px']" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="item.iconType === 'image'">
|
<template v-if="item.iconType === 'image'">
|
||||||
<image :src="getImageByIndex(index, item)" mode="scaleToFill" :class="isBulge ? 'h-80px w-80px' : 'h-24px w-24px'" />
|
<image :src="getImageByIndex(index, item)" mode="scaleToFill" class="shrink-0" :class="[isBulge?'h-26px w-26px':'h-20px w-20px']" />
|
||||||
</template>
|
</template>
|
||||||
<view v-if="!isBulge" class="mt-2px text-12px">
|
<view v-if="!isBulge" class="mt-1px text-10px shrink-0">
|
||||||
{{ getI18nText(item.text) }}
|
{{ getI18nText(item.text) }}
|
||||||
</view>
|
</view>
|
||||||
<!-- 角标显示 -->
|
<!-- 角标显示 -->
|
||||||
<view v-if="item.badge">
|
<view v-if="item.badge">
|
||||||
<template v-if="item.badge === 'dot'">
|
<template v-if="item.badge === 'dot'">
|
||||||
<view class="absolute right-0 top-0 h-2 w-2 rounded-full bg-#f56c6c" />
|
<view class="absolute right-0 -top-2 h-2 w-2 rounded-full bg-red-400" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<view class="absolute top-0 box-border h-5 min-w-5 center rounded-full bg-#f56c6c px-1 text-center text-xs text-white -right-3">
|
<view class="absolute -top-2 -right-2 z-20 box-border h-5 min-w-5 center rounded-full bg-red-400 px-1 text-center text-xs text-white ">
|
||||||
{{ item.badge > 99 ? '99+' : item.badge }}
|
{{ item.badge > 99 ? '99+' : item.badge }}
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { CustomTabBarItem } from './types'
|
||||||
|
import { getI18nText } from './i18n'
|
||||||
|
import { tabbarStore } from './store'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
item: CustomTabBarItem
|
||||||
|
index: number
|
||||||
|
isBulge?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function getImageByIndex(index: number, item: CustomTabBarItem) {
|
||||||
|
if (!item.iconActive) {
|
||||||
|
console.warn('image 模式下,需要配置 iconActive (高亮时的图片),否则无法切换高亮图片')
|
||||||
|
return item.icon
|
||||||
|
}
|
||||||
|
return tabbarStore.curIdx === index ? item.iconActive : item.icon
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="flex flex-col items-center justify-center">
|
||||||
|
<template v-if="item.iconType === 'uiLib'">
|
||||||
|
<!-- TODO: 以下内容请根据选择的UI库自行替换 -->
|
||||||
|
<!-- 如:<wd-icon name="home" /> (https://wot-design-uni.cn/component/icon.html) -->
|
||||||
|
<!-- 如:<uv-icon name="home" /> (https://www.uvui.cn/components/icon.html) -->
|
||||||
|
<!-- 如:<sar-icon name="image" /> (https://sard.wzt.zone/sard-uniapp-docs/components/icon)(sar没有home图标^_^) -->
|
||||||
|
<!-- <wd-icon :name="item.icon" size="20" /> -->
|
||||||
|
</template>
|
||||||
|
<template v-if="item.iconType === 'unocss' || item.iconType === 'iconfont'">
|
||||||
|
<view :class="[item.icon, isBulge ? 'text-80px' : 'text-20px']" />
|
||||||
|
</template>
|
||||||
|
<template v-if="item.iconType === 'image'">
|
||||||
|
<image :src="getImageByIndex(index, item)" mode="scaleToFill" :class="isBulge ? 'h-80px w-80px' : 'h-24px w-24px'" />
|
||||||
|
</template>
|
||||||
|
<view v-if="!isBulge" class="mt-2px text-12px">
|
||||||
|
{{ getI18nText(item.text) }}
|
||||||
|
</view>
|
||||||
|
<!-- 角标显示 -->
|
||||||
|
<view v-if="item.badge">
|
||||||
|
<template v-if="item.badge === 'dot'">
|
||||||
|
<view class="absolute right-0 top-0 h-2 w-2 rounded-full bg-#f56c6c" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<view class="absolute top-0 box-border h-5 min-w-5 center rounded-full bg-#f56c6c px-1 text-center text-xs text-white -right-3">
|
||||||
|
{{ item.badge > 99 ? '99+' : item.badge }}
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
+178
-128
@@ -1,5 +1,5 @@
|
|||||||
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
|
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages';
|
||||||
import type { CustomTabBarItem, NativeTabBarItem } from './types'
|
import type { CustomTabBarItem, NativeTabBarItem } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* tabbar 选择的策略,更详细的介绍见 tabbar.md 文件
|
* tabbar 选择的策略,更详细的介绍见 tabbar.md 文件
|
||||||
@@ -10,163 +10,213 @@ import type { CustomTabBarItem, NativeTabBarItem } from './types'
|
|||||||
* 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致配置不生效
|
* 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致配置不生效
|
||||||
*/
|
*/
|
||||||
export const TABBAR_STRATEGY_MAP = {
|
export const TABBAR_STRATEGY_MAP = {
|
||||||
NO_TABBAR: 0,
|
NO_TABBAR: 0,
|
||||||
NATIVE_TABBAR: 1,
|
NATIVE_TABBAR: 1,
|
||||||
CUSTOM_TABBAR: 2,
|
CUSTOM_TABBAR: 2
|
||||||
}
|
};
|
||||||
|
|
||||||
// TODO: 1/3. 通过这里切换使用tabbar的策略
|
// TODO: 1/3. 通过这里切换使用tabbar的策略
|
||||||
// 如果是使用 NO_TABBAR(0),nativeTabbarList 和 customTabbarList 都不生效
|
// 如果是使用 NO_TABBAR(0),nativeTabbarList 和 customTabbarList 都不生效
|
||||||
// 如果是使用 NATIVE_TABBAR(1),只需要配置 nativeTabbarList,customTabbarList 不生效
|
// 如果是使用 NATIVE_TABBAR(1),只需要配置 nativeTabbarList,customTabbarList 不生效
|
||||||
// 如果是使用 CUSTOM_TABBAR(2),只需要配置 customTabbarList,nativeTabbarList 不生效
|
// 如果是使用 CUSTOM_TABBAR(2),只需要配置 customTabbarList,nativeTabbarList 不生效
|
||||||
export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.NATIVE_TABBAR
|
export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.CUSTOM_TABBAR;
|
||||||
|
|
||||||
// TODO: 2/3. 使用 NATIVE_TABBAR 时,更新下面的 tabbar 配置
|
// TODO: 2/3. 使用 NATIVE_TABBAR 时,更新下面的 tabbar 配置
|
||||||
export const nativeTabbarList: NativeTabBarItem[] = [
|
export const nativeTabbarList: NativeTabBarItem[] = [
|
||||||
{
|
{
|
||||||
iconPath: 'static/tabbar/select_home.png',
|
iconPath: 'static/tabbar/select_home.png',
|
||||||
selectedIconPath: 'static/tabbar/select_home_active.png',
|
selectedIconPath: 'static/tabbar/select_home_active.png',
|
||||||
pagePath: 'pages/tabbar/home/home',
|
pagePath: 'pages/tabbar/home/home',
|
||||||
text: '%tabbar.home%',
|
text: '%tabbar.home%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconPath: 'static/tabbar/select_category.png',
|
iconPath: 'static/tabbar/select_category.png',
|
||||||
selectedIconPath: 'static/tabbar/select_category_active.png',
|
selectedIconPath: 'static/tabbar/select_category_active.png',
|
||||||
pagePath: 'pages/tabbar/category/category',
|
pagePath: 'pages/tabbar/category/category',
|
||||||
text: '%tabbar.category%',
|
text: '%tabbar.category%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconPath: 'static/tabbar/select_gallery.png',
|
iconPath: 'static/tabbar/select_gallery.png',
|
||||||
selectedIconPath: 'static/tabbar/select_gallery_active.png',
|
selectedIconPath: 'static/tabbar/select_gallery_active.png',
|
||||||
pagePath: 'pages/tabbar/gallery/gallery',
|
pagePath: 'pages/tabbar/gallery/gallery',
|
||||||
text: '%tabbar.gallery%',
|
text: '%tabbar.gallery%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconPath: 'static/tabbar/select_links.png',
|
iconPath: 'static/tabbar/select_links.png',
|
||||||
selectedIconPath: 'static/tabbar/select_links_active.png',
|
selectedIconPath: 'static/tabbar/select_links_active.png',
|
||||||
pagePath: 'pages/tabbar/moments/moments',
|
pagePath: 'pages/tabbar/moments/moments',
|
||||||
text: '%tabbar.moments%',
|
text: '%tabbar.moments%'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconPath: 'static/tabbar/select_mine.png',
|
iconPath: 'static/tabbar/select_mine.png',
|
||||||
selectedIconPath: 'static/tabbar/select_mine_active.png',
|
selectedIconPath: 'static/tabbar/select_mine_active.png',
|
||||||
pagePath: 'pages/tabbar/about/about',
|
pagePath: 'pages/tabbar/about/about',
|
||||||
text: '%tabbar.about%',
|
text: '%tabbar.about%'
|
||||||
},
|
}
|
||||||
]
|
];
|
||||||
|
|
||||||
// TODO: 3/3. 使用 CUSTOM_TABBAR 时,更新下面的 tabbar 配置
|
// TODO: 3/3. 使用 CUSTOM_TABBAR 时,更新下面的 tabbar 配置
|
||||||
// 如果需要配置鼓包,需要在 'tabbar/store.ts' 里面设置,最后在 `tabbar/index.vue` 里面更改鼓包的图片
|
// 如果需要配置鼓包,需要在 'tabbar/store.ts' 里面设置,最后在 `tabbar/index.vue` 里面更改鼓包的图片
|
||||||
export const customTabbarList: CustomTabBarItem[] = [
|
export const customTabbarList: CustomTabBarItem[] = [
|
||||||
{
|
// {
|
||||||
text: '%tabbar.home%',
|
// text: '%tabbar.home%',
|
||||||
pagePath: 'pages/index/index',
|
// pagePath: 'pages/index/index',
|
||||||
// 注意 unocss 图标需要如下处理:(二选一)
|
// // 注意 unocss 图标需要如下处理:(二选一)
|
||||||
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
// // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
||||||
// 2)配置到 unocss.config.ts 的 safelist 中
|
// // 2)配置到 unocss.config.ts 的 safelist 中
|
||||||
iconType: 'unocss',
|
// iconType: 'unocss',
|
||||||
icon: 'i-carbon-home',
|
// icon: 'i-carbon-home',
|
||||||
// badge: 'dot',
|
// // badge: 'dot',
|
||||||
},
|
// },
|
||||||
// 鼓包配置示例(2025-12-31)
|
// // 鼓包配置示例(2025-12-31)
|
||||||
// 中间鼓包tabbarItem配置:通常是扫描按钮、发布按钮、更多按钮等,点击触发业务逻辑
|
// // 中间鼓包tabbarItem配置:通常是扫描按钮、发布按钮、更多按钮等,点击触发业务逻辑
|
||||||
// {
|
// // {
|
||||||
// pagePath: 'pages/me/me',
|
// // pagePath: 'pages/me/me',
|
||||||
// text: '我的',
|
// // text: '我的',
|
||||||
// // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
// // // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
||||||
// // 2)配置到 unocss.config.ts 的 safelist 中
|
// // // 2)配置到 unocss.config.ts 的 safelist 中
|
||||||
// iconType: 'image',
|
// // iconType: 'image',
|
||||||
// icon: '/static/tabbar/scan.png',
|
// // icon: '/static/tabbar/scan.png',
|
||||||
// isBulge: true,
|
// // isBulge: true,
|
||||||
// },
|
// // },
|
||||||
{
|
// {
|
||||||
pagePath: 'pages/tabbar/home/home',
|
// pagePath: 'pages/tabbar/home/home',
|
||||||
text: '%tabbar.i18n%',
|
// text: '%tabbar.i18n%',
|
||||||
iconType: 'unocss',
|
// iconType: 'unocss',
|
||||||
icon: 'i-carbon-ibm-watson-language-translator',
|
// icon: 'i-carbon-ibm-watson-language-translator',
|
||||||
// badge: 10,
|
// // badge: 10,
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
pagePath: 'pages/tabbar/about/about',
|
// pagePath: 'pages/tabbar/about/about',
|
||||||
text: '%tabbar.about%',
|
// text: '%tabbar.about%',
|
||||||
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
// // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
|
||||||
// 2)配置到 unocss.config.ts 的 safelist 中
|
// // 2)配置到 unocss.config.ts 的 safelist 中
|
||||||
iconType: 'unocss',
|
// iconType: 'unocss',
|
||||||
icon: 'i-carbon-menu',
|
// icon: 'i-carbon-menu',
|
||||||
// badge: 10,
|
// // badge: 10,
|
||||||
roles: ['admin'],
|
// roles: ['admin'],
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
pagePath: 'pages/tabbar/moments/moments',
|
// pagePath: 'pages/tabbar/moments/moments',
|
||||||
text: '%tabbar.me%',
|
// text: '%tabbar.me%',
|
||||||
iconType: 'unocss',
|
// iconType: 'unocss',
|
||||||
icon: 'i-carbon-user',
|
// icon: 'i-carbon-user',
|
||||||
// badge: 10,
|
// // badge: 10,
|
||||||
},
|
// },
|
||||||
|
|
||||||
// 其他类型演示
|
// 其他类型演示
|
||||||
// 1、uiLib
|
// 1、uiLib
|
||||||
// {
|
// {
|
||||||
// pagePath: 'pages/index/index',
|
// pagePath: 'pages/index/index',
|
||||||
// text: '首页',
|
// text: '首页',
|
||||||
// iconType: 'uiLib',
|
// iconType: 'uiLib',
|
||||||
// icon: 'home',
|
// icon: 'home',
|
||||||
// },
|
// },
|
||||||
// 2、iconfont
|
// 2、iconfont
|
||||||
// {
|
// {
|
||||||
// pagePath: 'pages/index/index',
|
// pagePath: 'pages/index/index',
|
||||||
// text: '首页',
|
// text: '首页',
|
||||||
// // 注意 iconfont 图标需要额外加上 'iconfont',如下
|
// // 注意 iconfont 图标需要额外加上 'iconfont',如下
|
||||||
// iconType: 'iconfont',
|
// iconType: 'iconfont',
|
||||||
// icon: 'iconfont icon-my',
|
// icon: 'iconfont icon-my',
|
||||||
// },
|
// },
|
||||||
// 3、image
|
// 3、image
|
||||||
// {
|
// {
|
||||||
// pagePath: 'pages/index/index',
|
// pagePath: 'pages/index/index',
|
||||||
// text: '首页',
|
// text: '首页',
|
||||||
// // 使用 ‘image’时,需要配置 icon + iconActive 2张图片
|
// // 使用 ‘image’时,需要配置 icon + iconActive 2张图片
|
||||||
// iconType: 'image',
|
// iconType: 'image',
|
||||||
// icon: '/static/tabbar/home.png',
|
// icon: '/static/tabbar/home.png',
|
||||||
// iconActive: '/static/tabbar/homeHL.png',
|
// iconActive: '/static/tabbar/homeHL.png',
|
||||||
// },
|
// },
|
||||||
]
|
{
|
||||||
|
icon: '/static/tabbar/select_home.png',
|
||||||
|
iconActive: '/static/tabbar/select_home_active.png',
|
||||||
|
iconType: 'image',
|
||||||
|
pagePath: 'pages/tabbar/home/home',
|
||||||
|
text: '%tabbar.home%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '/static/tabbar/select_category.png',
|
||||||
|
iconActive: '/static/tabbar/select_category_active.png',
|
||||||
|
iconType: 'image',
|
||||||
|
pagePath: 'pages/tabbar/category/category',
|
||||||
|
text: '%tabbar.category%'
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
icon: '/static/tabbar/select_gallery.png',
|
||||||
|
iconActive: '/static/tabbar/select_gallery_active.png',
|
||||||
|
iconType: 'image',
|
||||||
|
pagePath: 'pages/tabbar/gallery/gallery',
|
||||||
|
text: '%tabbar.gallery%'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '/static/tabbar/select_links.png',
|
||||||
|
iconActive: '/static/tabbar/select_links_active.png',
|
||||||
|
iconType: 'image',
|
||||||
|
pagePath: 'pages/tabbar/moments/moments',
|
||||||
|
text: '%tabbar.moments%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '/static/tabbar/select_mine.png',
|
||||||
|
iconActive: '/static/tabbar/select_mine_active.png',
|
||||||
|
iconType: 'image',
|
||||||
|
pagePath: 'pages/tabbar/about/about',
|
||||||
|
text: '%tabbar.about%'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 搜索按钮
|
||||||
|
// {
|
||||||
|
// icon: 'search-line',
|
||||||
|
// iconType: 'uiLib',
|
||||||
|
// isRightButton: true,
|
||||||
|
// pagePath: 'pages-blog/search/search',
|
||||||
|
// text: '%tabbar.search%',
|
||||||
|
// onClick: (item: CustomTabBarItem) => {
|
||||||
|
// uni.navigateTo({
|
||||||
|
// url: item.pagePath
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否启用 tabbar 缓存
|
* 是否启用 tabbar 缓存
|
||||||
* NATIVE_TABBAR(1) 和 CUSTOM_TABBAR(2) 时,需要tabbar缓存
|
* NATIVE_TABBAR(1) 和 CUSTOM_TABBAR(2) 时,需要tabbar缓存
|
||||||
*/
|
*/
|
||||||
export const tabbarCacheEnable = [TABBAR_STRATEGY_MAP.NATIVE_TABBAR, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy)
|
export const tabbarCacheEnable = [TABBAR_STRATEGY_MAP.NATIVE_TABBAR, TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否启用自定义 tabbar
|
* 是否启用自定义 tabbar
|
||||||
* CUSTOM_TABBAR(2) 时,启用自定义tabbar
|
* CUSTOM_TABBAR(2) 时,启用自定义tabbar
|
||||||
*/
|
*/
|
||||||
export const customTabbarEnable = [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy)
|
export const customTabbarEnable = [TABBAR_STRATEGY_MAP.CUSTOM_TABBAR].includes(selectedTabbarStrategy);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否需要隐藏原生 tabbar
|
* 是否需要隐藏原生 tabbar
|
||||||
* CUSTOM_TABBAR(2) 时,需要隐藏原生tabbar
|
* CUSTOM_TABBAR(2) 时,需要隐藏原生tabbar
|
||||||
*/
|
*/
|
||||||
export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR
|
export const needHideNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR;
|
||||||
|
|
||||||
const _tabbarList = customTabbarEnable ? customTabbarList.map(item => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList
|
const _tabbarList = customTabbarEnable ? customTabbarList.filter((item) => !item.isRightButton).map((item) => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList;
|
||||||
export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList
|
export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList;
|
||||||
|
|
||||||
// NATIVE_TABBAR(1) 时,显示原生Tabbar,在i18n的情况下需要 setTabbarItem (框架已经处理)
|
// NATIVE_TABBAR(1) 时,显示原生Tabbar,在i18n的情况下需要 setTabbarItem (框架已经处理)
|
||||||
export const isNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NATIVE_TABBAR
|
export const isNativeTabbar = selectedTabbarStrategy === TABBAR_STRATEGY_MAP.NATIVE_TABBAR;
|
||||||
|
|
||||||
const _tabbar: TabBar = {
|
const _tabbar: TabBar = {
|
||||||
// 只有微信小程序支持 custom。App 和 H5 不生效
|
// 只有微信小程序支持 custom。App 和 H5 不生效
|
||||||
custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
|
custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
|
||||||
color: '#303133',
|
color: '#303133',
|
||||||
selectedColor: '#03a9f4',
|
selectedColor: '#03a9f4',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
borderStyle: 'white',
|
borderStyle: 'white',
|
||||||
// height: '50px',
|
// height: '50px',
|
||||||
// fontSize: '10px',
|
// fontSize: '10px',
|
||||||
// iconWidth: '24px',
|
// iconWidth: '24px',
|
||||||
// spacing: '3px',
|
// spacing: '3px',
|
||||||
list: _tabbarList as unknown as TabBar['list'],
|
list: _tabbarList as unknown as TabBar['list']
|
||||||
}
|
};
|
||||||
|
|
||||||
export const tabBar = tabbarCacheEnable ? _tabbar : undefined
|
export const tabBar = tabbarCacheEnable ? _tabbar : undefined;
|
||||||
|
|||||||
+135
-130
@@ -1,144 +1,149 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// i-carbon-code
|
// i-carbon-code
|
||||||
import { customTabbarEnable, needHideNativeTabbar, tabbarCacheEnable } from './config'
|
import { customTabbarEnable, needHideNativeTabbar, tabbarCacheEnable } from './config'
|
||||||
import { setTabbarItem } from './i18n'
|
import { setTabbarItem } from './i18n'
|
||||||
import { tabbarList, tabbarStore } from './store'
|
import { tabbarList, tabbarStore } from './store'
|
||||||
import TabbarItem from './TabbarItem.vue'
|
import TabbarItem from './TabbarItem.vue'
|
||||||
|
|
||||||
// #ifdef MP-WEIXIN
|
// #ifdef MP-WEIXIN
|
||||||
// 将自定义节点设置成虚拟的(去掉自定义组件包裹层),更加接近Vue组件的表现,能更好的使用flex属性
|
// 将自定义节点设置成虚拟的(去掉自定义组件包裹层),更加接近Vue组件的表现,能更好的使用flex属性
|
||||||
defineOptions({
|
defineOptions({
|
||||||
virtualHost: true,
|
virtualHost: true,
|
||||||
})
|
})
|
||||||
// #endif
|
// #endif
|
||||||
|
|
||||||
/**
|
function handleClick(index : number) {
|
||||||
* 中间的鼓包tabbarItem的点击事件
|
// 点击原来的不做操作
|
||||||
*/
|
if (index === tabbarStore.curIdx) {
|
||||||
function handleClickBulge() {
|
return
|
||||||
uni.showToast({
|
}
|
||||||
title: '点击了中间的鼓包tabbarItem',
|
const list = tabbarList.value
|
||||||
icon: 'none',
|
if (!list[index]) {
|
||||||
})
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClick(index: number) {
|
const url = list[index].pagePath
|
||||||
// 点击原来的不做操作
|
tabbarStore.setCurIdx(index)
|
||||||
if (index === tabbarStore.curIdx) {
|
if (tabbarCacheEnable) {
|
||||||
return
|
uni.switchTab({ url })
|
||||||
}
|
}
|
||||||
const list = tabbarList.value
|
else {
|
||||||
if (!list[index]) {
|
uni.navigateTo({ url })
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
if (list[index].isBulge) {
|
// #ifndef MP-WEIXIN || MP-ALIPAY
|
||||||
handleClickBulge()
|
// 因为有了 custom:true, 微信里面不需要多余的hide操作
|
||||||
return
|
onLoad(() => {
|
||||||
}
|
// 解决原生 tabBar 未隐藏导致有2个 tabBar 的问题
|
||||||
const url = list[index].pagePath
|
needHideNativeTabbar
|
||||||
tabbarStore.setCurIdx(index)
|
&& uni.hideTabBar({
|
||||||
if (tabbarCacheEnable) {
|
fail(err) {
|
||||||
uni.switchTab({ url })
|
console.log('hideTabBar fail: ', err)
|
||||||
}
|
},
|
||||||
else {
|
success(res) {
|
||||||
uni.navigateTo({ url })
|
// console.log('hideTabBar success: ', res)
|
||||||
}
|
},
|
||||||
}
|
})
|
||||||
// #ifndef MP-WEIXIN || MP-ALIPAY
|
})
|
||||||
// 因为有了 custom:true, 微信里面不需要多余的hide操作
|
// #endif
|
||||||
onLoad(() => {
|
|
||||||
// 解决原生 tabBar 未隐藏导致有2个 tabBar 的问题
|
|
||||||
needHideNativeTabbar
|
|
||||||
&& uni.hideTabBar({
|
|
||||||
fail(err) {
|
|
||||||
console.log('hideTabBar fail: ', err)
|
|
||||||
},
|
|
||||||
success(res) {
|
|
||||||
// console.log('hideTabBar success: ', res)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// #endif
|
|
||||||
|
|
||||||
// #ifdef MP-ALIPAY
|
// #ifdef MP-ALIPAY
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 解决支付宝自定义tabbar 未隐藏导致有2个 tabBar 的问题; 注意支付宝很特别,需要在 onMounted 钩子调用
|
// 解决支付宝自定义tabbar 未隐藏导致有2个 tabBar 的问题; 注意支付宝很特别,需要在 onMounted 钩子调用
|
||||||
customTabbarEnable // 另外,支付宝里面,只要是 customTabbar 都需要隐藏
|
customTabbarEnable // 另外,支付宝里面,只要是 customTabbar 都需要隐藏
|
||||||
&& uni.hideTabBar({
|
&& uni.hideTabBar({
|
||||||
fail(err) {
|
fail(err) {
|
||||||
console.log('hideTabBar fail: ', err)
|
console.log('hideTabBar fail: ', err)
|
||||||
},
|
},
|
||||||
success(res) {
|
success(res) {
|
||||||
// console.log('hideTabBar success: ', res)
|
// console.log('hideTabBar success: ', res)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
// #endif
|
// #endif
|
||||||
const activeColor = 'var(--wot-color-theme, #1890ff)'
|
const rightButton = computed(() => {
|
||||||
const inactiveColor = '#666'
|
const index = tabbarList.value.findIndex(item => item.isRightButton)
|
||||||
function getColorByIndex(index: number) {
|
if (-1 !== index) {
|
||||||
return tabbarStore.curIdx === index ? activeColor : inactiveColor
|
return {
|
||||||
}
|
index,
|
||||||
|
item: tabbarList.value[index],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
item: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const noRightTabbarList = computed(() => {
|
||||||
|
return tabbarList.value.filter(item => !item.isRightButton)
|
||||||
|
})
|
||||||
|
const activeColor = 'var(--wot-color-theme, #1890ff)'
|
||||||
|
const inactiveColor = '#333'
|
||||||
|
function getColorByIndex(index : number) {
|
||||||
|
return tabbarStore.curIdx === index ? activeColor : inactiveColor
|
||||||
|
}
|
||||||
|
|
||||||
// 注意,上面处理的是自定义tabbar,下面处理的是原生tabbar,参考:https://unibest.tech/base/10-i18n
|
function isActive(index : number) {
|
||||||
onShow(() => {
|
return tabbarStore.curIdx === index
|
||||||
setTabbarItem()
|
}
|
||||||
})
|
|
||||||
|
function handleClickRightButton() {
|
||||||
|
if (typeof rightButton.value.item?.onClick === 'function') {
|
||||||
|
rightButton.value.item.onClick(rightButton.value.item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意,上面处理的是自定义tabbar,下面处理的是原生tabbar,参考:https://unibest.tech/base/10-i18n
|
||||||
|
onShow(() => {
|
||||||
|
setTabbarItem()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view v-if="customTabbarEnable" class="h-50px pb-safe">
|
<view v-if="customTabbarEnable" class="h-56px pb-safe bg-page">
|
||||||
<view class="border-and-fixed bg-white" @touchmove.stop.prevent>
|
<view class="border-and-fixed w-full px-2">
|
||||||
<view class="h-50px flex items-center">
|
<view class="flex box-border w-full items-center justify-between gap-x-2" @touchmove.stop.prevent>
|
||||||
<view
|
<view
|
||||||
v-for="(item, index) in tabbarList" :key="index"
|
class="flex-1 box-border uh-global-card-glass bg-white/75 border rounded-full p-1 h-52px flex items-center gap-x-1">
|
||||||
class="flex flex-1 flex-col items-center justify-center"
|
<view v-for="(item, index) in noRightTabbarList" :key="index" class="text-gray-900 flex-1"
|
||||||
:style="{ color: getColorByIndex(index) }"
|
:style="{ color: getColorByIndex(index) }" @click="handleClick(index)">
|
||||||
@click="handleClick(index)"
|
<TabbarItem :item="item" :index="index" class="relative"
|
||||||
>
|
:class="[isActive(index)?'uh-global-card-glass bg-white/30 border':'']" />
|
||||||
<view v-if="item.isBulge" class="relative">
|
</view>
|
||||||
<!-- 中间一个鼓包tabbarItem的处理 -->
|
</view>
|
||||||
<view class="bulge">
|
<view v-if="rightButton.item"
|
||||||
<TabbarItem :item="item" :index="index" class="text-center" is-bulge />
|
class="shrink-0 uh-global-card-glass border bg-white/65 rounded-full h-52px w-52px p-1 flex items-center justify-center"
|
||||||
</view>
|
@click="handleClickRightButton()">
|
||||||
</view>
|
<TabbarItem :item="rightButton.item" :index="rightButton.index" :is-bulge="true" />
|
||||||
<TabbarItem v-else :item="item" :index="index" class="relative px-3 text-center" />
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
<view class="pb-safe" />
|
||||||
|
</view>
|
||||||
<view class="pb-safe" />
|
</view>
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.border-and-fixed {
|
.border-and-fixed {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 24rpx;
|
||||||
left: 0;
|
z-index: 1000;
|
||||||
right: 0;
|
box-sizing: border-box;
|
||||||
z-index: 1000;
|
}
|
||||||
border-top: 1px solid #eee;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
// 中间鼓包的样式
|
|
||||||
.bulge {
|
|
||||||
position: absolute;
|
|
||||||
top: -20px;
|
|
||||||
left: 50%;
|
|
||||||
transform-origin: top center;
|
|
||||||
transform: translateX(-50%) scale(0.5) translateY(-33%);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 250rpx;
|
|
||||||
height: 250rpx;
|
|
||||||
border-radius: 50%;
|
|
||||||
background-color: #fff;
|
|
||||||
box-shadow: inset 0 0 0 1px #fefefe;
|
|
||||||
|
|
||||||
&:active {
|
// 中间鼓包的样式
|
||||||
// opacity: 0.8;
|
.bulge {
|
||||||
}
|
position: absolute;
|
||||||
}
|
top: -20px;
|
||||||
|
left: 50%;
|
||||||
|
transform-origin: top center;
|
||||||
|
transform: translateX(-50%) scale(0.5) translateY(-33%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 250rpx;
|
||||||
|
height: 250rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: inset 0 0 0 1px #fefefe;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// i-carbon-code
|
||||||
|
import { customTabbarEnable, needHideNativeTabbar, tabbarCacheEnable } from './config'
|
||||||
|
import { setTabbarItem } from './i18n'
|
||||||
|
import { tabbarList, tabbarStore } from './store'
|
||||||
|
import TabbarItem from './TabbarItem.vue'
|
||||||
|
|
||||||
|
// #ifdef MP-WEIXIN
|
||||||
|
// 将自定义节点设置成虚拟的(去掉自定义组件包裹层),更加接近Vue组件的表现,能更好的使用flex属性
|
||||||
|
defineOptions({
|
||||||
|
virtualHost: true,
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中间的鼓包tabbarItem的点击事件
|
||||||
|
*/
|
||||||
|
function handleClickBulge() {
|
||||||
|
uni.showToast({
|
||||||
|
title: '点击了中间的鼓包tabbarItem',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(index: number) {
|
||||||
|
// 点击原来的不做操作
|
||||||
|
if (index === tabbarStore.curIdx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const list = tabbarList.value
|
||||||
|
if (!list[index]) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (list[index].isBulge) {
|
||||||
|
handleClickBulge()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = list[index].pagePath
|
||||||
|
tabbarStore.setCurIdx(index)
|
||||||
|
if (tabbarCacheEnable) {
|
||||||
|
uni.switchTab({ url })
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
uni.navigateTo({ url })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #ifndef MP-WEIXIN || MP-ALIPAY
|
||||||
|
// 因为有了 custom:true, 微信里面不需要多余的hide操作
|
||||||
|
onLoad(() => {
|
||||||
|
// 解决原生 tabBar 未隐藏导致有2个 tabBar 的问题
|
||||||
|
needHideNativeTabbar
|
||||||
|
&& uni.hideTabBar({
|
||||||
|
fail(err) {
|
||||||
|
console.log('hideTabBar fail: ', err)
|
||||||
|
},
|
||||||
|
success(res) {
|
||||||
|
// console.log('hideTabBar success: ', res)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef MP-ALIPAY
|
||||||
|
onMounted(() => {
|
||||||
|
// 解决支付宝自定义tabbar 未隐藏导致有2个 tabBar 的问题; 注意支付宝很特别,需要在 onMounted 钩子调用
|
||||||
|
customTabbarEnable // 另外,支付宝里面,只要是 customTabbar 都需要隐藏
|
||||||
|
&& uni.hideTabBar({
|
||||||
|
fail(err) {
|
||||||
|
console.log('hideTabBar fail: ', err)
|
||||||
|
},
|
||||||
|
success(res) {
|
||||||
|
// console.log('hideTabBar success: ', res)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
// #endif
|
||||||
|
const activeColor = 'var(--wot-color-theme, #1890ff)'
|
||||||
|
const inactiveColor = '#666'
|
||||||
|
function getColorByIndex(index: number) {
|
||||||
|
return tabbarStore.curIdx === index ? activeColor : inactiveColor
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意,上面处理的是自定义tabbar,下面处理的是原生tabbar,参考:https://unibest.tech/base/10-i18n
|
||||||
|
onShow(() => {
|
||||||
|
setTabbarItem()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view v-if="customTabbarEnable" class="h-50px pb-safe">
|
||||||
|
<view class="border-and-fixed bg-white" @touchmove.stop.prevent>
|
||||||
|
<view class="h-50px flex items-center">
|
||||||
|
<view
|
||||||
|
v-for="(item, index) in tabbarList" :key="index"
|
||||||
|
class="flex flex-1 flex-col items-center justify-center"
|
||||||
|
:style="{ color: getColorByIndex(index) }"
|
||||||
|
@click="handleClick(index)"
|
||||||
|
>
|
||||||
|
<view v-if="item.isBulge" class="relative">
|
||||||
|
<!-- 中间一个鼓包tabbarItem的处理 -->
|
||||||
|
<view class="bulge">
|
||||||
|
<TabbarItem :item="item" :index="index" class="text-center" is-bulge />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<TabbarItem v-else :item="item" :index="index" class="relative px-3 text-center" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="pb-safe" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.border-and-fixed {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
// 中间鼓包的样式
|
||||||
|
.bulge {
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
left: 50%;
|
||||||
|
transform-origin: top center;
|
||||||
|
transform: translateX(-50%) scale(0.5) translateY(-33%);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 250rpx;
|
||||||
|
height: 250rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
box-shadow: inset 0 0 0 1px #fefefe;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
// opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+30
-26
@@ -1,37 +1,41 @@
|
|||||||
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
|
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages';
|
||||||
import type { UserRole } from '@/api/types/login'
|
import type { UserRole } from '@/api/types/login';
|
||||||
import type { RemoveLeadingSlashFromUnion } from '@/typings'
|
import type { RemoveLeadingSlashFromUnion } from '@/typings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 原生 tabbar 的单个选项配置
|
* 原生 tabbar 的单个选项配置
|
||||||
*/
|
*/
|
||||||
export type NativeTabBarItem = TabBar['list'][number] & {
|
export type NativeTabBarItem = TabBar['list'][number] & {
|
||||||
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
|
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>;
|
||||||
}
|
};
|
||||||
|
|
||||||
/** badge 显示一个数字或 小红点(样式可以直接在 tabbar/index.vue 里面修改) */
|
/** badge 显示一个数字或 小红点(样式可以直接在 tabbar/index.vue 里面修改) */
|
||||||
export type CustomTabBarItemBadge = number | 'dot'
|
export type CustomTabBarItemBadge = number | 'dot';
|
||||||
|
|
||||||
/** 自定义 tabbar 的单个选项配置 */
|
/** 自定义 tabbar 的单个选项配置 */
|
||||||
export interface CustomTabBarItem {
|
export interface CustomTabBarItem {
|
||||||
text: string
|
text: string;
|
||||||
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
|
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>;
|
||||||
/** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */
|
/** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */
|
||||||
iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image'
|
iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image';
|
||||||
/**
|
/**
|
||||||
* icon 的路径
|
* icon 的路径
|
||||||
* - uiLib: wot-design-uni 图标的 icon prop
|
* - uiLib: wot-design-uni 图标的 icon prop
|
||||||
* - unocss: unocss 图标的类名
|
* - unocss: unocss 图标的类名
|
||||||
* - iconfont: iconfont 图标的类名
|
* - iconfont: iconfont 图标的类名
|
||||||
* - image: 图片的路径
|
* - image: 图片的路径
|
||||||
*/
|
*/
|
||||||
icon: string
|
icon: string;
|
||||||
/** 只有在 image 模式下才需要,传递的是高亮的图片 */
|
/** 只有在 image 模式下才需要,传递的是高亮的图片 */
|
||||||
iconActive?: string
|
iconActive?: string;
|
||||||
/** badge 显示一个数字或 小红点 */
|
/** badge 显示一个数字或 小红点 */
|
||||||
badge?: CustomTabBarItemBadge
|
badge?: CustomTabBarItemBadge;
|
||||||
/** 是否是中间的鼓包tabbarItem */
|
/** 是否是中间的鼓包tabbarItem */
|
||||||
isBulge?: boolean
|
isBulge?: boolean;
|
||||||
// roles 不写 → 所有用户都能看到;roles 写了 → 只有匹配角色可见
|
// roles 不写 → 所有用户都能看到;roles 写了 → 只有匹配角色可见
|
||||||
roles?: UserRole[]
|
roles?: UserRole[];
|
||||||
|
// 自定义右侧按钮
|
||||||
|
isRightButton?: boolean;
|
||||||
|
// 点击事件回调
|
||||||
|
onClick?: (item: CustomTabBarItem) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-28
@@ -7,16 +7,17 @@ import { getCache, setCache } from './storage'
|
|||||||
/** 投票 UID 缓存 key */
|
/** 投票 UID 缓存 key */
|
||||||
const UnihaloVoteUid = 'unihalo_vote_uid'
|
const UnihaloVoteUid = 'unihalo_vote_uid'
|
||||||
|
|
||||||
export type VoteType = 'SINGLE' | 'MULTIPLE'
|
export type VoteType = 'single' | 'multiple' | 'pk'
|
||||||
export type VoteState = 'not-voted' | 'voting' | 'voted' | 'vote-ended'
|
export type VoteState = 'not-voted' | 'voting' | 'voted' | 'vote-ended'
|
||||||
|
|
||||||
/** 投票类型常量 */
|
/** 投票类型中文映射(与旧项目一致:key 为插件小写 type) */
|
||||||
export const VOTE_TYPES: { SINGLE: VoteType, MULTIPLE: VoteType } = {
|
export const VOTE_TYPES: Record<string, string> = {
|
||||||
SINGLE: 'SINGLE',
|
pk: '双选PK',
|
||||||
MULTIPLE: 'MULTIPLE',
|
multiple: '多选',
|
||||||
|
single: '单选',
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 投票状态常量 */
|
/** 投票状态常量(内部状态机) */
|
||||||
export const VOTE_STATES: { NOT_VOTED: VoteState, VOTING: VoteState, VOTED: VoteState, VOTE_ENDED: VoteState } = {
|
export const VOTE_STATES: { NOT_VOTED: VoteState, VOTING: VoteState, VOTED: VoteState, VOTE_ENDED: VoteState } = {
|
||||||
NOT_VOTED: 'not-voted',
|
NOT_VOTED: 'not-voted',
|
||||||
VOTING: 'voting',
|
VOTING: 'voting',
|
||||||
@@ -24,6 +25,13 @@ export const VOTE_STATES: { NOT_VOTED: VoteState, VOTING: VoteState, VOTED: Vote
|
|||||||
VOTE_ENDED: 'vote-ended',
|
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' },
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取投票 UID(不存在则生成)
|
* 获取投票 UID(不存在则生成)
|
||||||
*/
|
*/
|
||||||
@@ -37,33 +45,31 @@ export function getOrCreateVoteUid(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算投票状态
|
* 计算投票展示状态(与旧项目 calcVoteState 一致)
|
||||||
* @param vote 投票对象(含 startTime/endTime/options)
|
* 非 custom 期限(permanent 等)直接看 hasEnded;custom 按起止时间判断
|
||||||
* @param voteTypes 已投票项
|
* @param vote 投票对象(含 spec.timeLimit/hasEnded/startDate/endDate)
|
||||||
* @param canAnonymously 是否允许匿名
|
* @returns { state: '未开始' | '进行中' | '已结束', color: 'orange' | 'green' | 'red' }
|
||||||
*/
|
*/
|
||||||
export function calcVoteState(
|
export function calcVoteState(
|
||||||
vote: { startTime?: string, endTime?: string, [key: string]: unknown },
|
vote: { spec?: { timeLimit?: string, hasEnded?: boolean, startDate?: string, endDate?: string, [key: string]: unknown } },
|
||||||
voteTypes: string[],
|
): { state: string, color: string } {
|
||||||
canAnonymously: boolean,
|
if (vote.spec?.timeLimit !== 'custom') {
|
||||||
): VoteState {
|
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中']
|
||||||
const now = Date.now()
|
}
|
||||||
const startTime = vote.startTime ? new Date(vote.startTime).getTime() : now
|
|
||||||
const endTime = vote.endTime ? new Date(vote.endTime).getTime() : now
|
|
||||||
|
|
||||||
if (endTime < now)
|
const nowTime = new Date().getTime()
|
||||||
return VOTE_STATES.VOTE_ENDED
|
const startTime = vote.spec?.startDate ? new Date(vote.spec.startDate).getTime() : nowTime
|
||||||
if (startTime > now)
|
const endTime = vote.spec?.endDate ? new Date(vote.spec.endDate).getTime() : nowTime
|
||||||
return VOTE_STATES.NOT_VOTED
|
|
||||||
if (voteTypes.length !== 0)
|
if (nowTime < startTime)
|
||||||
return VOTE_STATES.VOTED
|
return VOTE_STATE_LABELS['未开始']
|
||||||
if (!canAnonymously)
|
if (nowTime < endTime)
|
||||||
return VOTE_STATES.NOT_VOTED
|
return VOTE_STATE_LABELS['进行中']
|
||||||
return VOTE_STATES.VOTING
|
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算选项票数占比
|
* 计算选项票数占比(与旧项目一致:整数百分比)
|
||||||
* @param vote 投票对象(含 stats.voteCount)
|
* @param vote 投票对象(含 stats.voteCount)
|
||||||
* @param option 选项(含 count)
|
* @param option 选项(含 count)
|
||||||
*/
|
*/
|
||||||
@@ -72,7 +78,7 @@ export function calcVotePercent(vote: { stats?: { voteCount?: number } }, option
|
|||||||
const count = option.count || 0
|
const count = option.count || 0
|
||||||
if (total === 0)
|
if (total === 0)
|
||||||
return 0
|
return 0
|
||||||
return Number(((count / total) * 100).toFixed(2))
|
return Math.round((count / total) * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 投票缓存 key 前缀 */
|
/** 投票缓存 key 前缀 */
|
||||||
|
|||||||
Reference in New Issue
Block a user