1
0
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:
小莫唐尼
2026-09-08 13:21:49 +08:00
parent e1c84ff701
commit dbe70e3b49
18 changed files with 1802 additions and 867 deletions
@@ -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>
+225 -216
View File
@@ -1,256 +1,265 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DataLoadingStatus } from '@/hooks/useDataLoading'
import { computed } from 'vue'
import type { DataLoadingStatus } from '@/hooks/useDataLoading'
interface IProps {
/** 加载状态(取值同 useDataLoading 返回的 status) */
loadingStatus?: DataLoadingStatus
/** 占位区最小高度 */
minHeight?: string
loadingText?: string
errorText?: string
emptyText?: string
/** 各态副文案(留空则不显示副行) */
loadingSubText?: string
errorSubText?: string
emptySubText?: string
useLoadingButton?: boolean
}
interface IProps {
/** 加载状态(取值同 useDataLoading 返回的 status) */
loadingStatus ?: DataLoadingStatus
/** 占位区最小高度 */
minHeight ?: string
loadingText ?: string
errorText ?: string
emptyText ?: string
/** 各态副文案(留空则不显示副行) */
loadingSubText ?: string
errorSubText ?: string
emptySubText ?: string
useLoadingButton ?: boolean
}
const props = withDefaults(defineProps<IProps>(), {
loadingStatus: 'loading',
minHeight: '80vh',
loadingText: '稍等,正在加载中哦',
errorText: '哎呀,加载失败了呢~',
emptyText: '啊偶,暂时没有数据呢~',
loadingSubText: '',
errorSubText: '请检查网络连接,或稍后再试',
emptySubText: '稍后再来看看吧~',
useLoadingButton: true,
})
const props = withDefaults(defineProps<IProps>(), {
loadingStatus: 'loading',
minHeight: '80vh',
loadingText: '稍等,正在加载中哦',
errorText: '哎呀,加载失败了呢~',
emptyText: '啊偶,暂时没有数据呢~',
loadingSubText: '',
errorSubText: '请检查网络连接,或稍后再试',
emptySubText: '稍后再来看看吧~',
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(() => {
switch (props.loadingStatus) {
case 'error':
return {
icon: '-injury',
stageClass: 'stage-error',
mainTextClass: 'text-red-400',
mainText: props.errorText,
subText: props.errorSubText,
}
case 'empty':
return {
icon: '-confused',
stageClass: 'stage-empty',
mainTextClass: 'text-gray-900',
mainText: props.emptyText,
subText: props.emptySubText,
}
default:
return {
icon: '-happy-1',
stageClass: 'stage-loading',
mainTextClass: 'text-primary',
mainText: props.loadingText,
subText: props.loadingSubText,
}
}
})
const statusScene = computed(() => {
switch (props.loadingStatus) {
case 'error':
return {
icon: '-injury',
stageClass: 'stage-error',
mainTextClass: 'text-red-400',
mainText: props.errorText,
subText: props.errorSubText,
}
case 'empty':
return {
icon: '-confused',
stageClass: 'stage-empty',
mainTextClass: 'text-gray-900',
mainText: props.emptyText,
subText: props.emptySubText,
}
default:
return {
icon: '-happy-1',
stageClass: 'stage-loading',
mainTextClass: 'text-primary',
mainText: props.loadingText,
subText: props.loadingSubText,
}
}
})
</script>
<template>
<view
class="w-full flex flex-col items-center justify-center gap-y-4 text-sm"
:style="{ minHeight: props.minHeight }"
>
<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="deco-dot dot-a 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">
<text class="bubble-icon">
<wd-icon class-prefix="uhemoji-icon" :name="statusScene.icon" size="120rpx" />
</text>
</view>
</view>
<view class="w-full flex flex-col items-center justify-center gap-y-4 text-sm"
:style="{ minHeight: props.minHeight }">
<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="deco-dot dot-a 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">
<text class="bubble-icon">
<wd-icon class-prefix="uhemoji-icon" :name="statusScene.icon" size="120rpx" />
</text>
</view>
</view>
<!-- 文案区 -->
<view class="flex flex-col items-center">
<view class="flex items-center justify-center text-sm font-bold" :class="statusScene.mainTextClass">
<text>{{ statusScene.mainText }}</text>
<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>
</view>
<text v-if="statusScene.subText" class="mt-3 text-xs text-gray-500">
{{ statusScene.subText }}
</text>
<uh-button v-if="props.useLoadingButton" class="mt-5" @click="emit('refresh')">
刷新试试
</uh-button>
</view>
</view>
<!-- 文案区 -->
<view class="flex flex-col items-center">
<view class="flex items-center justify-center text-sm font-bold" :class="statusScene.mainTextClass">
<text>{{ statusScene.mainText }}</text>
<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>
</view>
<text v-if="statusScene.subText" class="mt-3 text-xs text-gray-500">
{{ statusScene.subText }}
</text>
<view v-if="props.useLoadingButton" class="mt-5 uh-global-card-glass uh-shadow-xs border rounded-lg">
<uh-button custom-class="!rounded-lg" @click="emit('refresh')">
刷新试试
</uh-button>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.bubble {
animation: bubble-float 2s ease-in-out infinite;
}
.bubble {
animation: bubble-float 2s ease-in-out infinite;
}
.bubble-icon {
display: inline-block;
}
.bubble-icon {
display: inline-block;
}
.glow {
animation: glow-pulse 2.4s ease-in-out infinite;
}
.glow {
animation: glow-pulse 2.4s ease-in-out infinite;
}
.stage-loading .glow {
background: rgba(185, 228, 36, 0.32);
}
.stage-loading .glow {
background: rgba(185, 228, 36, 0.32);
}
.stage-error .glow {
background: rgba(248, 113, 113, 0.24);
}
.stage-error .glow {
background: rgba(248, 113, 113, 0.24);
}
.stage-empty .glow {
background: rgba(217, 249, 157, 0.5);
}
.stage-empty .glow {
background: rgba(217, 249, 157, 0.5);
}
.deco-dot {
animation: deco-float 2s ease-in-out infinite;
}
.deco-dot {
animation: deco-float 2s ease-in-out infinite;
}
.dot-a {
top: 16rpx;
left: 10rpx;
width: 22rpx;
height: 22rpx;
background: rgba(163, 230, 53, 0.9);
}
.dot-a {
top: 16rpx;
left: 10rpx;
width: 22rpx;
height: 22rpx;
background: rgba(163, 230, 53, 0.9);
}
.dot-b {
top: 4rpx;
right: 14rpx;
width: 14rpx;
height: 14rpx;
background: rgba(217, 249, 157, 0.95);
animation-delay: -0.7s;
}
.dot-b {
top: 4rpx;
right: 14rpx;
width: 14rpx;
height: 14rpx;
background: rgba(217, 249, 157, 0.95);
animation-delay: -0.7s;
}
/* —— 三态表情差异化动效(均无限循环,柔和不抢眼) —— */
.stage-loading .bubble-icon {
animation: sway 1.6s ease-in-out infinite;
}
/* —— 三态表情差异化动效(均无限循环,柔和不抢眼) —— */
.stage-loading .bubble-icon {
animation: sway 1.6s ease-in-out infinite;
}
.stage-error .bubble-icon {
animation: head-shake 2.8s ease-in-out infinite;
transform-origin: 50% 85%;
}
.stage-error .bubble-icon {
animation: head-shake 2.8s ease-in-out infinite;
transform-origin: 50% 85%;
}
.stage-empty .bubble-icon {
animation: sigh 3s ease-in-out infinite;
transform-origin: 50% 85%;
}
.stage-empty .bubble-icon {
animation: sigh 3s ease-in-out infinite;
transform-origin: 50% 85%;
}
/* —— 加载中三点跳动(错峰延迟经模板 :style 注入,避开 WXSS 不支持的 :nth-child) —— */
.typing-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
animation: dot-jump 1s ease-in-out infinite;
}
/* —— 加载中三点跳动(错峰延迟经模板 :style 注入,避开 WXSS 不支持的 :nth-child) —— */
.typing-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
animation: dot-jump 1s ease-in-out infinite;
}
/* —— keyframes —— */
@keyframes bubble-float {
0%,
100% {
transform: translateY(0);
}
/* —— keyframes —— */
@keyframes bubble-float {
50% {
transform: translateY(-14rpx);
}
}
0%,
100% {
transform: translateY(0);
}
@keyframes glow-pulse {
0%,
100% {
transform: scale(1);
opacity: 0.55;
}
50% {
transform: translateY(-14rpx);
}
}
50% {
transform: scale(1.1);
opacity: 0.9;
}
}
@keyframes glow-pulse {
@keyframes deco-float {
0%,
100% {
transform: translateY(0);
}
0%,
100% {
transform: scale(1);
opacity: 0.55;
}
50% {
transform: translateY(-12rpx);
}
}
50% {
transform: scale(1.1);
opacity: 0.9;
}
}
@keyframes dot-jump {
0%,
100% {
transform: translateY(0);
}
@keyframes deco-float {
50% {
transform: translateY(-8rpx);
}
}
0%,
100% {
transform: translateY(0);
}
/* 歪头左右打量(loading) */
@keyframes sway {
0%,
100% {
transform: rotate(-4deg);
}
50% {
transform: translateY(-12rpx);
}
}
50% {
transform: rotate(4deg);
}
}
@keyframes dot-jump {
/* 缓慢左右摇头(error) */
@keyframes head-shake {
0%,
100% {
transform: rotate(0);
}
0%,
100% {
transform: translateY(0);
}
25% {
transform: rotate(6deg);
}
50% {
transform: translateY(-8rpx);
}
}
75% {
transform: rotate(-6deg);
}
}
/* 歪头左右打量(loading) */
@keyframes sway {
/* 轻轻叹气缩肩(empty) */
@keyframes sigh {
0%,
100% {
transform: scaleY(1);
}
0%,
100% {
transform: rotate(-4deg);
}
30%,
70% {
transform: scaleY(0.92);
}
}
</style>
50% {
transform: rotate(4deg);
}
}
/* 缓慢左右摇头(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>
+19 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onPageScroll } from '@dcloudio/uni-app'
import { ref, computed, useSlots, onMounted } from 'vue'
import { tabbarList } from '@/tabbar/config'
interface IProps {
useBack : boolean;
@@ -57,8 +58,25 @@
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() {
const currentPage = getCurrentPages()[0]
if (!allEntryPages.value.some(pagePath => pagePath == currentPage.route)) {
uni.reLaunch({
url: `/${homePage}`
})
return;
}
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>
+79 -155
View File
@@ -1,175 +1,99 @@
<script lang="ts" setup>
/**
* 投票卡片(源自旧项目 components/vote-card,新建复刻)
* 适配 plugin-vote 真实结构:详情接口返回 VoteDetail(嵌套 vote),选项为 {id,title}
* 纯展示卡片:列表接口一次返回,页面加工后传入,点击跳转详情
*/
import { computed, ref, watch } from 'vue'
import { getVoteDetail } from '@/api/uni-halo'
import { VOTE_STATES } from '@/utils/vote'
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
interface IVoteCardOption {
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<{
voteName: string
vote: IVoteCardData
}>()
const emit = defineEmits<{
(e: 'on-vote-success'): void
}>()
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))
/** 跳转投票详情 */
function handleToDetail() {
uni.navigateTo({
url: `/pages-blog/vote-detail/vote-detail?name=${props.vote?.metadata?.name || ''}`,
})
}
async function handleGetData() {
loading.value = true
try {
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 formatTime(date?: string, fmt = 'yyyy-MM-dd HH:mm'): string {
return date ? formatTimeUtil({ d: date, f: fmt }) : ''
}
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>
<template>
<view class="uh-vote-card uh-global-card-glass box-border w-full rounded-2xl p-4">
<view v-if="loading" class="loading py-6">
<wd-skeleton :row="2" :animated="true" />
<view class="uh-vote-card uh-global-card-glass box-border w-full rounded-2xl p-4" @click="handleToDetail">
<!-- 头部:类型/状态/已投票 -->
<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 v-else-if="voteData" class="vote-body">
<view class="vote-title text-[30rpx] text-gray-900 font-bold">
{{ voteData.spec?.title }}
</view>
<view v-if="voteData.spec?.remark" class="vote-desc mt-1 text-[24rpx] text-gray-400">
{{ voteData.spec.remark }}
</view>
<!-- 标题 + 备注 -->
<view class="vote-title mt-2 text-[30rpx] font-bold text-gray-900">
{{ vote.spec?.title }}
</view>
<view v-if="vote.spec?.remark" class="vote-desc mt-1 text-[24rpx] text-gray-400">
{{ vote.spec.remark }}
</view>
<view class="options mt-5">
<view
v-for="option in voteData.spec?.options || []"
:key="option.id"
class="option mb-4 flex flex-col border-2 rounded-xl p-5"
:class="voteTypes.includes(option.id || '') ? 'border-[#b9e424] bg-[#f0f7d9]' : 'border-transparent bg-[#f6f3ee]'"
@click="handleSelectOption(option)"
>
<view class="option-label text-[28rpx] text-gray-700">
<text>{{ option.title }}</text>
</view>
<view v-if="voteState === VOTE_STATES.VOTED" class="option-bar mt-3 h-4 overflow-hidden rounded-lg bg-black/5">
<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 class="vote-card-foot mt-3 flex items-center justify-between border-t border-black/5 pt-2">
<text v-if="vote.spec?.timeLimit === 'permanent'" class="text-[22rpx] text-gray-400">
结束永久有效
</text>
<text v-else-if="vote.spec?._uh_state?.state === '未开始'" class="text-[22rpx] text-gray-400">
开始{{ formatTime(vote.spec?.startDate) }}
</text>
<text v-else class="text-[22rpx] text-gray-400">
结束{{ formatTime(vote.spec?.endDate) }}
</text>
<text class="text-[22rpx] text-gray-400">{{ vote.stats?.voteCount || 0 }} 人已参与</text>
</view>
</view>
</template>
@@ -456,6 +456,11 @@
})
const globalAppSettings = computed(() => settingStore.settings)
onMounted(() => {
console.log('获取当前所有的页面')
console.log(getCurrentPages())
})
</script>
<template>
@@ -566,6 +571,9 @@
</template>
</view>
<!-- 相关投票(容器内置插件检查/展开收起,无数据或插件未激活自动不渲染) -->
<uh-article-vote :vote-ids="result?._voteIds || []" />
<view class="box-border px-1">
<!-- 版权声明 -->
<view v-if="postDetailConfig?.copyrightEnabled" class="box-border px-2 mb-3">
+116 -133
View File
@@ -1,154 +1,137 @@
<script lang="ts" setup>
/**
/**
* 公告详情页(plugin-uni-halo 通知公告,2026-09-03 客户端接入)
* 公开 GET /notices/{name} 返回完整 Notice extension(metadata+spec,spec 内嵌
* typeDisplayName/typeColor);正文 content 为富文本 HTML,mp-html 渲染。
* 不存在/删除中返回 404 → 空态提示。UIUX 见 .docs/notice-module-client-design.md
*/
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getNoticeDetail } from '@/api/uni-halo'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import { checkIsUrl } from '@/utils/url'
import { markdownConfig } from '@/config/markdown'
import type { INoticeDetail } from '@/api/types/uni-halo'
import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getNoticeDetail } from '@/api/uni-halo'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
import { checkImageUrl, checkIsUrl } from '@/utils/url'
import { markdownConfig } from '@/config/markdown'
import type { INoticeDetail } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '公告详情',
navigationStyle: 'custom',
},
})
definePage({
style: {
navigationBarTitleText: '公告详情',
navigationStyle: 'custom',
},
})
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const name = ref('')
const detail = ref<INoticeDetail | null>(null)
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const name = ref('')
const detail = ref<INoticeDetail | null>(null)
const spec = computed(() => detail.value?.spec)
const content = computed(() => spec.value?.content || '')
const title = computed(() => spec.value?.title || '')
const typeColor = computed(() => spec.value?.typeColor || '')
const typeDisplayName = computed(() => spec.value?.typeDisplayName || '')
const cover = computed(() => spec.value?.cover || '')
const publishTime = computed(() => formatDate(spec.value?.publishTime))
const hasLink = computed(() => !!spec.value?.link && checkIsUrl(spec.value?.link || ''))
const spec = computed(() => detail.value?.spec)
const content = computed(() => spec.value?.content || '')
const title = computed(() => spec.value?.title || '')
const typeColor = computed(() => spec.value?.typeColor || '')
const typeDisplayName = computed(() => spec.value?.typeDisplayName || '')
/** 封面图:相对路径(如 /upload/...)经 checkImageUrl 补全为完整地址后再渲染 */
const cover = computed(() => {
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 {
if (!value)
return ''
const date = new Date(value)
if (Number.isNaN(date.getTime()))
return ''
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function formatDate(value ?: string) : string {
if (!value) { return '' }
const date = new Date(value)
if (Number.isNaN(date.getTime())) { return '' }
const pad = (n : number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
function handleToExternal() {
if (!spec.value?.link)
return
uni.navigateTo({
url: `/pages-blog/website/website?data=${JSON.stringify({
title: title.value || '公告原文',
url: encodeURIComponent(spec.value.link),
})}`,
})
}
function handleCopy() {
if (!spec.value?.link) { return }
uni.setClipboardData({
data: `${title.value} ${spec.value.link}`,
success: () => {
uni.showToast({
title: '复制成功',
icon: 'none',
})
},
fail: () => {
uni.showToast({
title: '复制失败',
icon: 'none',
})
}
})
}
/** 加载公告详情(状态机;404/无数据 → 空态,其余错误 → error 态,可重试) */
async function loadDetail() {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
if (!name.value) {
updateLoadingStatus(DataLoadingStatusEnum.Empty)
return
}
try {
const res = await getNoticeDetail(name.value)
detail.value = res.data || null
updateLoadingStatus(
detail.value?.spec ? DataLoadingStatusEnum.Success : DataLoadingStatusEnum.Empty,
)
}
catch (err) {
console.error('公告详情加载失败', err)
const code = (err as { code?: number }).code
updateLoadingStatus(code === 404 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Error)
}
}
/** 加载公告详情(状态机;404/无数据 → 空态,其余错误 → error 态,可重试) */
async function loadDetail() {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
if (!name.value) {
updateLoadingStatus(DataLoadingStatusEnum.Empty)
return
}
try {
const res = await getNoticeDetail(name.value)
detail.value = res.data || null
updateLoadingStatus(
detail.value?.spec ? DataLoadingStatusEnum.Success : DataLoadingStatusEnum.Empty,
)
}
catch (err) {
console.error('公告详情加载失败', err)
const code = (err as { code ?: number }).code
updateLoadingStatus(code === 404 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Error)
}
}
onLoad((options) => {
name.value = options?.name || ''
loadDetail()
})
onLoad((options) => {
name.value = options?.name || ''
loadDetail()
})
</script>
<template>
<view class="notice-detail min-h-screen w-screen bg-white pb-12">
<!-- 自定义导航 -->
<uh-navbar default-title="公告详情" title-color="text-gray-900" />
<view class="box-border min-h-screen w-screen bg-page pb-safe">
<!-- 自定义导航 -->
<uh-navbar default-title="公告详情" title-color="text-gray-900" />
<!-- 加载/错误/空态(状态机) -->
<uh-data-loading
v-if="loadingStatus !== 'success'" :loading-status="loadingStatus" min-height="55vh"
error-text="公告加载失败" empty-text="公告不存在或已下线" empty-sub-text=""
@refresh="loadDetail"
/>
<!-- 加载/错误/空态(状态机) -->
<uh-data-loading v-if="loadingStatus !== 'success'" :loading-status="loadingStatus" min-height="55vh"
error-text="公告加载失败" empty-text="公告不存在或已下线" empty-sub-text="" @refresh="loadDetail" />
<!-- 正文 -->
<view v-else class="px-6 py-6">
<image
v-if="cover"
class="mb-5 h-[320rpx] w-full rounded-xl"
:src="cover"
mode="aspectFill"
/>
<view class="text-[36rpx] font-bold leading-snug text-[#222]">
{{ title }}
</view>
<!-- 正文 -->
<view v-else class="box-border p-4">
<image v-if="cover" class="mb-5 h-[320rpx] w-full rounded-xl" :src="cover" mode="aspectFill" />
<view class="text-[36rpx] font-bold leading-snug text-gray-900">
{{ title }}
</view>
<view class="mt-3 flex items-center gap-2">
<view
v-if="typeDisplayName"
class="rounded px-2 py-0.5 text-[20rpx]"
:style="{
color: typeColor || '#f83856',
backgroundColor: typeColor ? `${typeColor}1a` : '#fdeef1',
}"
>
{{ typeDisplayName }}
</view>
<text class="text-[22rpx] text-[#bbb]">
{{ publishTime }}
</text>
</view>
<view class="mt-3 flex items-center gap-2">
<view v-if="typeDisplayName" class="rounded px-2 py-0.5 text-[20rpx]" :style="{
color: typeColor || '#f83856',
backgroundColor: typeColor ? `${typeColor}1a` : '#fdeef1',
}">
{{ typeDisplayName }}
</view>
<text class="text-[22rpx] text-gray-500">
{{ publishTime }}
</text>
</view>
<view class="mt-4 border-t border-[#f0f0f0] pt-5">
<mp-html
:content="content"
lazy-load
:domain="markdownConfig.domain ?? ''"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
:show-line-number="false"
copy-by-long-press
/>
</view>
<view class="box-border w-full mt-2 pt-4 pb-6">
<mp-html :content="content" lazy-load :domain="markdownConfig.domain ?? ''" 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">
<wd-button type="primary" block round @click="handleToExternal">
查看原文
</wd-button>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.notice-detail {
:deep(img) {
max-width: 100%;
border-radius: 8rpx;
}
}
</style>
<view v-if="hasLink" class="fixed left-0 right-0 bottom-0 pb-safe px-4 box-border">
<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">
复制原文地址
</uh-button>
</view>
</view>
</view>
</view>
</template>
+53 -28
View File
@@ -7,7 +7,7 @@ import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getVoteDetail, submitVote } from '@/api/uni-halo'
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 type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
@@ -85,27 +85,17 @@ async function handleGetData() {
const detailRes = res.data as IVoteDetail
const tempVote = detailRes.vote as typeof vote.value
if (tempVote) {
const typeKey = ((tempVote.spec?.type || 'SINGLE') as string).toUpperCase()
pageTitle.value = `投票详情(${VOTE_TYPES[typeKey as keyof typeof VOTE_TYPES] || tempVote.spec?.type}`
const typeKey = ((tempVote.spec?.type || 'single') as string).toLowerCase()
pageTitle.value = `投票详情(${VOTE_TYPES[typeKey] || tempVote.spec?.type}`
tempVote.spec = tempVote.spec || {}
tempVote.spec.isVoted = 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
// 计算状态
const startTime = tempVote.spec.startDate ? new Date(tempVote.spec.startDate).getTime() : Date.now()
const endTime = tempVote.spec.endDate ? new Date(tempVote.spec.endDate).getTime() : Date.now()
const now = Date.now()
if (endTime < now) {
tempVote.spec._uh_state = { state: '已结束', color: 'red' }
// 计算状态(与旧项目 calcVoteState 一致,含 timeLimit 非 custom 时 hasEnded 兜底)
tempVote.spec._uh_state = calcVoteState(tempVote)
if (tempVote.spec._uh_state.state === '已结束')
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
@@ -272,7 +262,7 @@ onShareTimeline(() => ({
</script>
<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" />
@@ -288,11 +278,11 @@ onShareTimeline(() => ({
<block v-else>
<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>
<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">
<text>投票类型</text>
<text class="tag">{{ vote.spec?._uh_type }}</text>
@@ -303,7 +293,7 @@ onShareTimeline(() => ({
</view>
<view class="info-row">
<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 ? '匿名' : '不匿名' }}
</text>
</view>
@@ -318,7 +308,7 @@ onShareTimeline(() => ({
</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>
@@ -333,18 +323,33 @@ onShareTimeline(() => ({
<text v-if="vote.spec?.type === 'multiple'" class="sub-title-count text-[24rpx] font-normal">最多选择 {{ vote.spec?.maxVotes }} </text>
</view>
<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">
<view
v-for="(option, optionIndex) in vote.spec?.options"
:key="optionIndex"
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}%` }"
>
<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 }}
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}` : '' }}{{ option.title }}
</view>
<view class="shrink-0">
{{ option._uh_percent }}%
@@ -358,7 +363,7 @@ onShareTimeline(() => ({
v-for="(option, optionIndex) in vote.spec?.options"
:key="optionIndex"
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)"
>
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}` : '' }}{{ option.title }}
@@ -368,7 +373,7 @@ onShareTimeline(() => ({
</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>
@@ -378,7 +383,7 @@ onShareTimeline(() => ({
</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>
@@ -413,7 +418,7 @@ onShareTimeline(() => ({
position: absolute;
left: 0;
top: 6rpx;
background: #03a9f4;
background: var(--wot-color-theme, #b9e424);
border-radius: 6rpx;
}
}
@@ -431,5 +436,25 @@ onShareTimeline(() => ({
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>
+219 -10
View File
@@ -1,7 +1,7 @@
<script lang="ts" setup>
/**
* 投票列表页(源自旧项目 pagesA/votes,新建复刻)
* 展示投票列表,每个投票项用 uh-vote-card 渲染
* 展示投票列表,支持搜索 + 类型/状态/排序/是否已投筛选,每个投票项用 uh-vote-card 渲染
*/
import { computed, ref } from 'vue'
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 { NeedPluginIds } from '@/hooks/usePluginAvailable'
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'
definePage({
@@ -38,10 +40,129 @@ async function handlePluginRefresh() {
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const dataList = ref<IVoteItem[]>([])
const hasNext = ref(false)
const queryParams = ref({ page: 1, size: 10 })
const isLoadMore = ref(false)
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() {
if (calcAuditModeEnabled.value) {
updateLoadingStatus(
@@ -61,9 +182,31 @@ async function handleGetData() {
try {
const res = await getVoteList({ ...queryParams.value })
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.concat(res.data.items)
: res.data.items
? dataList.value.concat(tempItems)
: 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(
dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
)
@@ -82,8 +225,14 @@ async function handleGetData() {
}
}
function handleOnVoteSuccess() {
uni.showToast({ icon: 'none', title: '投票成功!' })
/** 卡片点击跳详情(与旧项目 VoteCard @on-click 一致) */
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) {
@@ -119,8 +268,12 @@ onPullDownRefresh(() => {
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
return
}
if (hasNext.value) {
queryParams.value.page += 1
queryParams.value.page = Number(queryParams.value.page) + 1
isLoadMore.value = true
handleGetData()
}
@@ -143,6 +296,39 @@ onReachBottom(() => {
@on-refresh="handlePluginRefresh"
/>
<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'">
<uh-data-loading
@@ -157,17 +343,40 @@ onReachBottom(() => {
<uh-vote-card
v-for="vote in dataList"
:key="vote.metadata?.name"
:vote-name="vote.metadata?.name || ''"
@on-vote-success="handleOnVoteSuccess"
:vote="vote"
@click="handleOnVoteClick(vote)"
/>
<view class="load-text py-5 text-center text-[24rpx] text-gray-400">
{{ loadMoreText }}
</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" />
</view>
</block>
</view>
</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>
</template>
+2 -4
View File
@@ -321,11 +321,9 @@
</view>
</view>
<view class="relative z-2 mt-[64rpx] text-[54rpx] font-black leading-[64rpx]">
{{ title }}
<view class="relative z-2 mt-8 text-6 font-black leading-8">
<uh-text-underline>{{ title }}</uh-text-underline>
</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]">
<template v-if="fromReason!=='plugin' && noticeLines.length > 0">
<text v-for="(line, index) in noticeLines" :key="index" class="block">{{ line }}</text>
+11 -7
View File
@@ -16,33 +16,37 @@ function getImageByIndex(index: number, item: CustomTabBarItem) {
}
return tabbarStore.curIdx === index ? item.iconActive : item.icon
}
function isActive(index: number) {
return tabbarStore.curIdx === index
}
</script>
<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'">
<!-- 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" /> -->
<text class="text-primary"><wd-icon :name="item.icon" size="52rpx" /></text>
</template>
<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 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>
<view v-if="!isBulge" class="mt-2px text-12px">
<view v-if="!isBulge" class="mt-1px text-10px shrink-0">
{{ 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" />
<view class="absolute right-0 -top-2 h-2 w-2 rounded-full bg-red-400" />
</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">
<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 }}
</view>
</template>
+51
View File
@@ -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
View File
@@ -1,5 +1,5 @@
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
import type { CustomTabBarItem, NativeTabBarItem } from './types'
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages';
import type { CustomTabBarItem, NativeTabBarItem } from './types';
/**
* tabbar 选择的策略,更详细的介绍见 tabbar.md 文件
@@ -10,163 +10,213 @@ import type { CustomTabBarItem, NativeTabBarItem } from './types'
* 温馨提示:本文件的任何代码更改了之后,都需要重新运行,否则 pages.json 不会更新导致配置不生效
*/
export const TABBAR_STRATEGY_MAP = {
NO_TABBAR: 0,
NATIVE_TABBAR: 1,
CUSTOM_TABBAR: 2,
}
NO_TABBAR: 0,
NATIVE_TABBAR: 1,
CUSTOM_TABBAR: 2
};
// TODO: 1/3. 通过这里切换使用tabbar的策略
// 如果是使用 NO_TABBAR(0)nativeTabbarList 和 customTabbarList 都不生效
// 如果是使用 NATIVE_TABBAR(1),只需要配置 nativeTabbarListcustomTabbarList 不生效
// 如果是使用 CUSTOM_TABBAR(2),只需要配置 customTabbarListnativeTabbarList 不生效
export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.NATIVE_TABBAR
export const selectedTabbarStrategy = TABBAR_STRATEGY_MAP.CUSTOM_TABBAR;
// TODO: 2/3. 使用 NATIVE_TABBAR 时,更新下面的 tabbar 配置
export const nativeTabbarList: NativeTabBarItem[] = [
{
iconPath: 'static/tabbar/select_home.png',
selectedIconPath: 'static/tabbar/select_home_active.png',
pagePath: 'pages/tabbar/home/home',
text: '%tabbar.home%',
},
{
iconPath: 'static/tabbar/select_category.png',
selectedIconPath: 'static/tabbar/select_category_active.png',
pagePath: 'pages/tabbar/category/category',
text: '%tabbar.category%',
},
{
iconPath: 'static/tabbar/select_gallery.png',
selectedIconPath: 'static/tabbar/select_gallery_active.png',
pagePath: 'pages/tabbar/gallery/gallery',
text: '%tabbar.gallery%',
},
{
iconPath: 'static/tabbar/select_links.png',
selectedIconPath: 'static/tabbar/select_links_active.png',
pagePath: 'pages/tabbar/moments/moments',
text: '%tabbar.moments%',
},
{
iconPath: 'static/tabbar/select_mine.png',
selectedIconPath: 'static/tabbar/select_mine_active.png',
pagePath: 'pages/tabbar/about/about',
text: '%tabbar.about%',
},
]
{
iconPath: 'static/tabbar/select_home.png',
selectedIconPath: 'static/tabbar/select_home_active.png',
pagePath: 'pages/tabbar/home/home',
text: '%tabbar.home%'
},
{
iconPath: 'static/tabbar/select_category.png',
selectedIconPath: 'static/tabbar/select_category_active.png',
pagePath: 'pages/tabbar/category/category',
text: '%tabbar.category%'
},
{
iconPath: 'static/tabbar/select_gallery.png',
selectedIconPath: 'static/tabbar/select_gallery_active.png',
pagePath: 'pages/tabbar/gallery/gallery',
text: '%tabbar.gallery%'
},
{
iconPath: 'static/tabbar/select_links.png',
selectedIconPath: 'static/tabbar/select_links_active.png',
pagePath: 'pages/tabbar/moments/moments',
text: '%tabbar.moments%'
},
{
iconPath: 'static/tabbar/select_mine.png',
selectedIconPath: 'static/tabbar/select_mine_active.png',
pagePath: 'pages/tabbar/about/about',
text: '%tabbar.about%'
}
];
// TODO: 3/3. 使用 CUSTOM_TABBAR 时,更新下面的 tabbar 配置
// 如果需要配置鼓包,需要在 'tabbar/store.ts' 里面设置,最后在 `tabbar/index.vue` 里面更改鼓包的图片
export const customTabbarList: CustomTabBarItem[] = [
{
text: '%tabbar.home%',
pagePath: 'pages/index/index',
// 注意 unocss 图标需要如下处理:(二选一)
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// 2)配置到 unocss.config.ts 的 safelist 中
iconType: 'unocss',
icon: 'i-carbon-home',
// badge: 'dot',
},
// 鼓包配置示例(2025-12-31
// 中间鼓包tabbarItem配置:通常是扫描按钮、发布按钮、更多按钮等,点击触发业务逻辑
// {
// pagePath: 'pages/me/me',
// text: '我的',
// // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// // 2)配置到 unocss.config.ts 的 safelist 中
// iconType: 'image',
// icon: '/static/tabbar/scan.png',
// isBulge: true,
// },
{
pagePath: 'pages/tabbar/home/home',
text: '%tabbar.i18n%',
iconType: 'unocss',
icon: 'i-carbon-ibm-watson-language-translator',
// badge: 10,
},
{
pagePath: 'pages/tabbar/about/about',
text: '%tabbar.about%',
// 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// 2)配置到 unocss.config.ts 的 safelist 中
iconType: 'unocss',
icon: 'i-carbon-menu',
// badge: 10,
roles: ['admin'],
},
{
pagePath: 'pages/tabbar/moments/moments',
text: '%tabbar.me%',
iconType: 'unocss',
icon: 'i-carbon-user',
// badge: 10,
},
// {
// text: '%tabbar.home%',
// pagePath: 'pages/index/index',
// // 注意 unocss 图标需要如下处理:(二选一)
// // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// // 2)配置到 unocss.config.ts 的 safelist 中
// iconType: 'unocss',
// icon: 'i-carbon-home',
// // badge: 'dot',
// },
// // 鼓包配置示例(2025-12-31
// // 中间鼓包tabbarItem配置:通常是扫描按钮、发布按钮、更多按钮等,点击触发业务逻辑
// // {
// // pagePath: 'pages/me/me',
// // text: '我的',
// // // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// // // 2)配置到 unocss.config.ts 的 safelist 中
// // iconType: 'image',
// // icon: '/static/tabbar/scan.png',
// // isBulge: true,
// // },
// {
// pagePath: 'pages/tabbar/home/home',
// text: '%tabbar.i18n%',
// iconType: 'unocss',
// icon: 'i-carbon-ibm-watson-language-translator',
// // badge: 10,
// },
// {
// pagePath: 'pages/tabbar/about/about',
// text: '%tabbar.about%',
// // 1)在fg-tabbar.vue页面上引入一下并注释掉(见tabbar/index.vue代码第2行)
// // 2)配置到 unocss.config.ts 的 safelist 中
// iconType: 'unocss',
// icon: 'i-carbon-menu',
// // badge: 10,
// roles: ['admin'],
// },
// {
// pagePath: 'pages/tabbar/moments/moments',
// text: '%tabbar.me%',
// iconType: 'unocss',
// icon: 'i-carbon-user',
// // badge: 10,
// },
// 其他类型演示
// 1、uiLib
// {
// pagePath: 'pages/index/index',
// text: '首页',
// iconType: 'uiLib',
// icon: 'home',
// },
// 2、iconfont
// {
// pagePath: 'pages/index/index',
// text: '首页',
// // 注意 iconfont 图标需要额外加上 'iconfont',如下
// iconType: 'iconfont',
// icon: 'iconfont icon-my',
// },
// 3、image
// {
// pagePath: 'pages/index/index',
// text: '首页',
// // 使用 image’时,需要配置 icon + iconActive 2张图片
// iconType: 'image',
// icon: '/static/tabbar/home.png',
// iconActive: '/static/tabbar/homeHL.png',
// },
]
// 其他类型演示
// 1、uiLib
// {
// pagePath: 'pages/index/index',
// text: '首页',
// iconType: 'uiLib',
// icon: 'home',
// },
// 2、iconfont
// {
// pagePath: 'pages/index/index',
// text: '首页',
// // 注意 iconfont 图标需要额外加上 'iconfont',如下
// iconType: 'iconfont',
// icon: 'iconfont icon-my',
// },
// 3、image
// {
// pagePath: 'pages/index/index',
// text: '首页',
// // 使用 image’时,需要配置 icon + iconActive 2张图片
// iconType: 'image',
// icon: '/static/tabbar/home.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 缓存
* 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
* 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
* 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
export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList
const _tabbarList = customTabbarEnable ? customTabbarList.filter((item) => !item.isRightButton).map((item) => ({ text: item.text, pagePath: item.pagePath })) : nativeTabbarList;
export const tabbarList = customTabbarEnable ? customTabbarList : nativeTabbarList;
// 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 = {
// 只有微信小程序支持 custom。App 和 H5 不生效
custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
color: '#303133',
selectedColor: '#03a9f4',
backgroundColor: '#ffffff',
borderStyle: 'white',
// height: '50px',
// fontSize: '10px',
// iconWidth: '24px',
// spacing: '3px',
list: _tabbarList as unknown as TabBar['list'],
}
// 只有微信小程序支持 custom。App 和 H5 不生效
custom: selectedTabbarStrategy === TABBAR_STRATEGY_MAP.CUSTOM_TABBAR,
color: '#303133',
selectedColor: '#03a9f4',
backgroundColor: '#ffffff',
borderStyle: 'white',
// height: '50px',
// fontSize: '10px',
// iconWidth: '24px',
// spacing: '3px',
list: _tabbarList as unknown as TabBar['list']
};
export const tabBar = tabbarCacheEnable ? _tabbar : undefined
export const tabBar = tabbarCacheEnable ? _tabbar : undefined;
+136 -131
View File
@@ -1,144 +1,149 @@
<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'
// 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
// #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
}
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
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
}
// #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 rightButton = computed(() => {
const index = tabbarList.value.findIndex(item => item.isRightButton)
if (-1 !== index) {
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
onShow(() => {
setTabbarItem()
})
function isActive(index : number) {
return tabbarStore.curIdx === index
}
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>
<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>
<view v-if="customTabbarEnable" class="h-56px pb-safe bg-page">
<view class="border-and-fixed w-full px-2">
<view class="flex box-border w-full items-center justify-between gap-x-2" @touchmove.stop.prevent>
<view
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">
<view v-for="(item, index) in noRightTabbarList" :key="index" class="text-gray-900 flex-1"
:style="{ color: getColorByIndex(index) }" @click="handleClick(index)">
<TabbarItem :item="item" :index="index" class="relative"
:class="[isActive(index)?'uh-global-card-glass bg-white/30 border':'']" />
</view>
</view>
<view v-if="rightButton.item"
class="shrink-0 uh-global-card-glass border bg-white/65 rounded-full h-52px w-52px p-1 flex items-center justify-center"
@click="handleClickRightButton()">
<TabbarItem :item="rightButton.item" :index="rightButton.index" :is-bulge="true" />
</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;
.border-and-fixed {
position: fixed;
bottom: 24rpx;
z-index: 1000;
box-sizing: border-box;
}
&:active {
// opacity: 0.8;
}
}
</style>
// 中间鼓包的样式
.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>
+144
View File
@@ -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
View File
@@ -1,37 +1,41 @@
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages'
import type { UserRole } from '@/api/types/login'
import type { RemoveLeadingSlashFromUnion } from '@/typings'
import type { TabBar } from '@uni-helper/vite-plugin-uni-pages';
import type { UserRole } from '@/api/types/login';
import type { RemoveLeadingSlashFromUnion } from '@/typings';
/**
* 原生 tabbar 的单个选项配置
*/
export type NativeTabBarItem = TabBar['list'][number] & {
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
}
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>;
};
/** badge 显示一个数字或 小红点(样式可以直接在 tabbar/index.vue 里面修改) */
export type CustomTabBarItemBadge = number | 'dot'
export type CustomTabBarItemBadge = number | 'dot';
/** 自定义 tabbar 的单个选项配置 */
export interface CustomTabBarItem {
text: string
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>
/** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */
iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image'
/**
* icon 的路径
* - uiLib: wot-design-uni 图标的 icon prop
* - unocss: unocss 图标的类名
* - iconfont: iconfont 图标的类名
* - image: 图片的路径
*/
icon: string
/** 只有在 image 模式下才需要,传递的是高亮的图片 */
iconActive?: string
/** badge 显示一个数字或 小红点 */
badge?: CustomTabBarItemBadge
/** 是否是中间的鼓包tabbarItem */
isBulge?: boolean
// roles 不写 → 所有用户都能看到;roles 写了 → 只有匹配角色可见
roles?: UserRole[]
text: string;
pagePath: RemoveLeadingSlashFromUnion<_LocationUrl>;
/** 图标类型,不建议用 image 模式,因为需要配置 2 张图,更麻烦 */
iconType: 'uiLib' | 'unocss' | 'iconfont' | 'image';
/**
* icon 的路径
* - uiLib: wot-design-uni 图标的 icon prop
* - unocss: unocss 图标的类名
* - iconfont: iconfont 图标的类名
* - image: 图片的路径
*/
icon: string;
/** 只有在 image 模式下才需要,传递的是高亮的图片 */
iconActive?: string;
/** badge 显示一个数字或 小红点 */
badge?: CustomTabBarItemBadge;
/** 是否是中间的鼓包tabbarItem */
isBulge?: boolean;
// roles 不写 → 所有用户都能看到;roles 写了 → 只有匹配角色可见
roles?: UserRole[];
// 自定义右侧按钮
isRightButton?: boolean;
// 点击事件回调
onClick?: (item: CustomTabBarItem) => void;
}
+34 -28
View File
@@ -7,16 +7,17 @@ import { getCache, setCache } from './storage'
/** 投票 UID 缓存 key */
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 const VOTE_TYPES: { SINGLE: VoteType, MULTIPLE: VoteType } = {
SINGLE: 'SINGLE',
MULTIPLE: 'MULTIPLE',
/** 投票类型中文映射(与旧项目一致:key 为插件小写 type) */
export const VOTE_TYPES: Record<string, string> = {
pk: '双选PK',
multiple: '多选',
single: '单选',
}
/** 投票状态常量 */
/** 投票状态常量(内部状态机) */
export const VOTE_STATES: { NOT_VOTED: VoteState, VOTING: VoteState, VOTED: VoteState, VOTE_ENDED: VoteState } = {
NOT_VOTED: 'not-voted',
VOTING: 'voting',
@@ -24,6 +25,13 @@ export const VOTE_STATES: { NOT_VOTED: VoteState, VOTING: VoteState, VOTED: Vote
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(不存在则生成)
*/
@@ -37,33 +45,31 @@ export function getOrCreateVoteUid(): string {
}
/**
* 计算投票状态
* @param vote 投票对象(含 startTime/endTime/options)
* @param voteTypes 已投票项
* @param canAnonymously 是否允许匿名
* 计算投票展示状态(与旧项目 calcVoteState 一致)
* 非 custom 期限(permanent 等)直接看 hasEnded;custom 按起止时间判断
* @param vote 投票对象(含 spec.timeLimit/hasEnded/startDate/endDate)
* @returns { state: '未开始' | '进行中' | '已结束', color: 'orange' | 'green' | 'red' }
*/
export function calcVoteState(
vote: { startTime?: string, endTime?: string, [key: string]: unknown },
voteTypes: string[],
canAnonymously: boolean,
): VoteState {
const now = Date.now()
const startTime = vote.startTime ? new Date(vote.startTime).getTime() : now
const endTime = vote.endTime ? new Date(vote.endTime).getTime() : now
vote: { spec?: { timeLimit?: string, hasEnded?: boolean, startDate?: string, endDate?: string, [key: string]: unknown } },
): { state: string, color: string } {
if (vote.spec?.timeLimit !== 'custom') {
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中']
}
if (endTime < now)
return VOTE_STATES.VOTE_ENDED
if (startTime > now)
return VOTE_STATES.NOT_VOTED
if (voteTypes.length !== 0)
return VOTE_STATES.VOTED
if (!canAnonymously)
return VOTE_STATES.NOT_VOTED
return VOTE_STATES.VOTING
const nowTime = new Date().getTime()
const startTime = vote.spec?.startDate ? new Date(vote.spec.startDate).getTime() : nowTime
const endTime = vote.spec?.endDate ? new Date(vote.spec.endDate).getTime() : nowTime
if (nowTime < startTime)
return VOTE_STATE_LABELS['未开始']
if (nowTime < endTime)
return VOTE_STATE_LABELS['进行中']
return vote.spec?.hasEnded ? VOTE_STATE_LABELS['已结束'] : VOTE_STATE_LABELS['进行中']
}
/**
* 计算选项票数占比
* 计算选项票数占比(与旧项目一致:整数百分比)
* @param vote 投票对象(含 stats.voteCount)
* @param option 选项(含 count)
*/
@@ -72,7 +78,7 @@ export function calcVotePercent(vote: { stats?: { voteCount?: number } }, option
const count = option.count || 0
if (total === 0)
return 0
return Number(((count / total) * 100).toFixed(2))
return Math.round((count / total) * 100)
}
/** 投票缓存 key 前缀 */