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

refactor: 整体优化项目页面与组件样式与结构

1.  移除冗余组件注释与老旧类名,统一样式规范
2.  新增自定义导航栏适配,修复导航栏层级与返回按钮逻辑
3.  调整列表页面布局,统一添加box-border与间距规范
4.  优化友链弹窗与联系页面样式,修复文案与交互细节
5.  重构关于与免责声明页面,适配动态站点信息
6.  统一瞬间页面与投票页面的UI风格与代码结构
This commit is contained in:
小莫唐尼
2026-09-08 16:04:49 +08:00
parent 7b79a2c5a3
commit d8d08f9689
14 changed files with 1031 additions and 1060 deletions
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { checkUrl } from '@/utils/url'
import { useAppConfigStore } from '@/store/appConfig'
import type { DataLoadingStatus } from '@/hooks/useDataLoading'
interface IProps {
@@ -31,6 +33,16 @@
const emit = defineEmits<{ (e : 'refresh') : void }>()
const appConfigStore = useAppConfigStore()
const appInfo = computed(() => {
const _appInfo = (appConfigStore.configs?.appConfig?.appInfo as any)
return {
name: _appInfo.name ?? 'UniHalo',
logo: checkUrl(_appInfo?.logo ?? 'https://uni-halo.925i.cn/logo.png')
}
})
const isLoading = computed(() => props.loadingStatus === 'loading')
const statusScene = computed(() => {
@@ -64,9 +76,13 @@
</script>
<template>
<view class="w-full flex flex-col items-center justify-center gap-y-4 text-sm"
<view class="relative 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"
<!-- logo背景 -->
<image v-if="false" :src="appInfo.logo" class="absolute left-1/2 top-1/2 -translate-1/2 z-0 opacity-10 w-46 h-46"></image>
<view class="scene relative z-1 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" />
@@ -79,7 +95,7 @@
</view>
<!-- 文案区 -->
<view class="flex flex-col items-center">
<view class="relative z-2 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">
+3 -3
View File
@@ -87,11 +87,11 @@
<template>
<view class="w-full box-border">
<view class="box-border pt-safe w-full fixed left-0 top-0 z-50" :class="customCalss" :style="[customStyle]">
<view class="box-border pt-safe w-full fixed left-0 top-0 z-100" :class="customCalss" :style="[customStyle]">
<view class="w-full h-[46px] flex items-center gap-x-4 box-border px-3 backdrop-blur-[2rpx]">
<!-- 左边 -->
<view class="shrink-0" @click="handleBack()">
<view
<view class="shrink-0 min-w-18" @click="handleBack()">
<view v-if="props.useBack"
class="uh-global-card-glass h-7 px-3 rounded-full border flex items-center gap-x-2 text-gray-900 text-sm">
<wd-icon name="arrow-left" size="32rpx"></wd-icon>
<view class="w-[1px] h-4 bg-white/60" />
+1 -5
View File
@@ -1,8 +1,4 @@
<script lang="ts" setup>
/**
* 投票卡片(源自旧项目 components/vote-card,新建复刻)
* 纯展示卡片:列表接口一次返回,页面加工后传入,点击跳转详情
*/
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
interface IVoteCardOption {
@@ -50,7 +46,7 @@ function formatTime(date?: string, fmt = 'yyyy-MM-dd HH:mm'): string {
</script>
<template>
<view class="uh-vote-card uh-global-card-glass box-border w-full rounded-2xl p-4" @click="handleToDetail">
<view class="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">
+87 -81
View File
@@ -1,93 +1,99 @@
<script lang="ts" setup>
/**
* 关于项目页(源自旧项目 pagesA/about,新建复刻)
* 数字名片式设计:Hero 名片卡(渐变光斑透卡 + 徽章) + 彩色瓦片链接列表,点击复制
*/
import { onLoad } from '@dcloudio/uni-app'
import { onLoad } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig'
import { checkUrl } from '@/utils/url'
definePage({
style: {
navigationBarTitleText: '关于项目',
navigationStyle: 'custom',
},
})
definePage({
style: {
navigationBarTitleText: '关于项目',
navigationStyle: 'custom',
},
})
const links = [
{ title: '开源组织', value: '巷子工坊', copy: 'https://www.ialley.cn', tip: '巷子工坊官网已复制成功!', tileColor: '#5c6bc0', tileLetter: '组' },
{ title: '开源作者', value: '小莫唐尼', copy: 'https://www.xiaoxiaomo.cn', tip: '作者主页地址已复制', tileColor: '#26a69a', tileLetter: '作' },
{ title: '作者博客', value: 'https://blog.xiaoxiaomo.cn', copy: 'https://blog.xiaoxiaomo.cn', tip: '作者博客地址已复制', tileColor: '#7e57c2', tileLetter: '博' },
{ title: '文档地址', value: 'https://uni-halo.925i.cn', copy: 'https://uni-halo.925i.cn', tip: '项目码云仓库已复制', tileColor: '#039be5', tileLetter: '文' },
{ title: '码云仓库', value: 'https://gitee.com/ialley-workshop-open/uni-halo', copy: 'https://gitee.com/ialley-workshop-open/uni-halo', tip: '码云仓库地址已复制', tileColor: '#c71d23', tileLetter: '码' },
{ title: 'Github', value: 'https://github.com/ialley-workshop-open/uni-halo', copy: 'https://github.com/ialley-workshop-open/uni-halo', tip: 'Github地址已复制', tileColor: '#24292f', tileLetter: 'G' },
]
const appConfigStore = useAppConfigStore()
function copyText(content: string, tips: string) {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
const appInfo = computed(() => {
const _appInfo = (appConfigStore.configs?.appConfig?.appInfo as any)
return {
name: _appInfo.name ?? 'UniHalo',
logo: checkUrl(_appInfo?.logo ?? 'https://uni-halo.925i.cn/logo.png')
}
})
onLoad(() => {
uni.setNavigationBarTitle({ title: '关于项目' })
})
const links = [
{ title: '开源组织', value: '巷子工坊', copy: 'https://www.ialley.cn', tip: '巷子工坊官网已复制成功!', tileColor: '#5c6bc0', tileLetter: '组' },
{ title: '开源作者', value: '小莫唐尼', copy: 'https://www.xiaoxiaomo.cn', tip: '作者主页地址已复制', tileColor: '#26a69a', tileLetter: '作' },
{ title: '作者博客', value: 'https://blog.xiaoxiaomo.cn', copy: 'https://blog.xiaoxiaomo.cn', tip: '作者博客地址已复制', tileColor: '#7e57c2', tileLetter: '博' },
{ title: '文档地址', value: 'https://uni-halo.925i.cn', copy: 'https://uni-halo.925i.cn', tip: '项目码云仓库已复制', tileColor: '#039be5', tileLetter: '文' },
{ title: '码云仓库', value: 'https://gitee.com/ialley-workshop-open/uni-halo', copy: 'https://gitee.com/ialley-workshop-open/uni-halo', tip: '码云仓库地址已复制', tileColor: '#c71d23', tileLetter: '码' },
{ title: 'Github', value: 'https://github.com/ialley-workshop-open/uni-halo', copy: 'https://github.com/ialley-workshop-open/uni-halo', tip: 'Github地址已复制', tileColor: '#24292f', tileLetter: 'G' },
]
function copyText(content : string, tips : string) {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
onLoad(() => {
uni.setNavigationBarTitle({ title: '关于项目' })
})
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col overflow-hidden bg-page px-4 pb-8 pt-6">
<!-- 自定义导航 -->
<uh-navbar default-title="关于项目" title-color="text-gray-900" />
<view class="box-border min-h-screen w-screen flex flex-col overflow-hidden bg-page px-4 pb-8 pt-2">
<!-- 自定义导航 -->
<uh-navbar default-title="关于项目" title-color="text-gray-900" />
<!-- Hero 名片卡(主题色光斑透过毛玻璃形成柔和渐变) -->
<view class="hero-wrap relative">
<view class="absolute h-[220rpx] w-[220rpx] rounded-full bg-[rgba(185,228,36,0.32)] -right-8 -top-8" />
<view class="absolute top-[150rpx] h-[180rpx] w-[180rpx] rounded-full bg-[rgba(215,249,76,0.45)] -left-10" />
<view class="hero-card uh-global-card-glass relative flex flex-col items-center rounded-3xl px-6 pb-7 pt-10">
<image class="logo-img h-[140rpx] w-[140rpx] rounded-3xl shadow-lg" src="https://uni-halo.925i.cn/logo.png" mode="aspectFill" />
<view class="mt-4 text-[40rpx] text-gray-900 font-bold">
uni-halo
</view>
<view class="mt-3 flex items-center gap-2">
<text class="rounded-full bg-secondary px-3 py-1 text-[20rpx] text-[#4d7c0f]">AGPL-3.0 开源协议</text>
<text class="rounded-full bg-[#f6f3ee] px-3 py-1 text-[20rpx] text-gray-500">uni-app × Halo</text>
</view>
<view class="mt-4 text-center text-[24rpx] text-gray-500 leading-relaxed">
基于 uni-app 打造的 Halo 博客跨端客户端
</view>
</view>
</view>
<view class="relative">
<view class="absolute h-[220rpx] w-[220rpx] rounded-full bg-[rgba(185,228,36,0.32)] -right-8 -top-8" />
<view
class="absolute top-[150rpx] h-[180rpx] w-[180rpx] rounded-full bg-[rgba(215,249,76,0.45)] -left-10" />
<view class="uh-global-card-glass relative flex flex-col items-center rounded-2xl px-6 pb-7 pt-10">
<image class="h-[140rpx] w-[140rpx] rounded-3xl shadow-lg" :src="appInfo.logo" mode="aspectFill" />
<view class="mt-4 text-xl text-gray-900 font-bold">
{{appInfo.name}}
</view>
<view class="mt-3 flex items-center gap-2">
<text class="rounded-full bg-secondary px-3 py-1 text-[20rpx] text-[#4d7c0f]">AGPL-3.0 开源协议</text>
<text class="rounded-full bg-[#f6f3ee] px-3 py-1 text-[20rpx] text-gray-500">UniApp × Halo</text>
</view>
<view class="mt-4 text-center text-xs text-gray-500 leading-relaxed">
基于 uni-app 打造的 Halo 博客跨端客户端
</view>
</view>
</view>
<!-- 相关链接 -->
<uh-section-title class="mb-3 mt-6 text-[30rpx]">
相关链接
</uh-section-title>
<view class="link-list uh-global-card-glass overflow-hidden rounded-2xl">
<view
v-for="(link, index) in links"
:key="link.title"
class="link-item flex items-center gap-3 px-4 py-4"
:class="index < links.length - 1 ? 'border-b border-black/5' : ''"
@click="copyText(link.copy, link.tip)"
>
<view class="tile h-[76rpx] w-[76rpx] flex shrink-0 items-center justify-center rounded-xl border border-black/5" :style="{ backgroundColor: `${link.tileColor}1A` }">
<text class="text-[30rpx] font-bold" :style="{ color: link.tileColor }">{{ link.tileLetter }}</text>
</view>
<view class="min-w-0 flex flex-1 flex-col">
<text class="text-[28rpx] text-gray-900 font-bold">{{ link.title }}</text>
<view class="mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
{{ link.value }}
</view>
</view>
<wd-icon name="copy" size="28rpx" color="#c8c2b4" class="shrink-0" />
</view>
</view>
<!-- 相关链接 -->
<uh-section-title class="mb-3 mt-6">
相关链接
</uh-section-title>
<view class="link-list uh-global-card-glass overflow-hidden rounded-2xl">
<view v-for="(link, index) in links" :key="link.title" class="link-item flex items-center gap-3 px-4 py-4"
:class="index < links.length - 1 ? 'border-b border-black/5' : ''"
@click="copyText(link.copy, link.tip)">
<view
class="tile h-[76rpx] w-[76rpx] flex shrink-0 items-center justify-center rounded-xl border border-black/5"
:style="{ backgroundColor: `${link.tileColor}1A` }">
<text class="text-[30rpx] font-bold" :style="{ color: link.tileColor }">{{ link.tileLetter }}</text>
</view>
<view class="min-w-0 flex flex-1 flex-col">
<text class="text-[28rpx] text-gray-900 font-bold">{{ link.title }}</text>
<view class="mt-1 overflow-hidden text-ellipsis whitespace-nowrap text-[24rpx] text-gray-400">
{{ link.value }}
</view>
</view>
<wd-icon name="copy" size="28rpx" color="#c8c2b4" class="shrink-0" />
</view>
</view>
<!-- 版权 -->
<view class="copyright mt-auto pt-8 text-center text-[22rpx] text-gray-400">
<view> 2022 uni-halo 开源项目丨巷子工坊@小莫唐尼 </view>
</view>
</view>
<!-- 版权 -->
<view class="copyright mt-auto pt-8 text-center text-[22rpx] text-gray-400">
<view> 2022 uni-halo 开源项目丨巷子工坊@小莫唐尼 </view>
</view>
</view>
</template>
+6 -10
View File
@@ -1,8 +1,4 @@
<script lang="ts" setup>
/**
* 联系博主页(源自旧项目 pagesA/contact,新建复刻)
* 数字名片式设计:Hero 名片卡(渐变光斑透卡) + 品牌色字母瓦片联系方式列表,点击复制
*/
import { computed, ref, watch, watchEffect } from 'vue'
import { useAppConfigStore } from '@/store/appConfig'
import { DataLoadingStatusEnum, useDataLoadingStatus } from '@/hooks/useDataLoadingStatus'
@@ -94,7 +90,7 @@ watch(socialConfig, () => {
</script>
<template>
<view class="app-page box-border min-h-screen w-screen overflow-hidden bg-page px-4 pb-10 pt-6">
<view class="box-border min-h-screen w-screen overflow-hidden bg-page px-4 pb-10 pt-2">
<!-- 自定义导航 -->
<uh-navbar default-title="联系博主" title-color="text-gray-900" />
@@ -102,7 +98,7 @@ watch(socialConfig, () => {
<view class="hero-wrap relative">
<view class="absolute h-[220rpx] w-[220rpx] rounded-full bg-[rgba(185,228,36,0.32)] -right-8 -top-8" />
<view class="absolute top-[150rpx] h-[180rpx] w-[180rpx] rounded-full bg-[rgba(215,249,76,0.45)] -left-10" />
<view class="hero-card uh-global-card-glass relative flex flex-col items-center rounded-3xl px-6 pb-6 pt-10">
<view class="hero-card uh-global-card-glass uh-shadow-xs relative flex flex-col items-center rounded-2xl px-6 pb-6 pt-10">
<view class="avatar relative box-border h-[150rpx] w-[150rpx] overflow-hidden border-5 border-white/90 rounded-full shadow-lg">
<image class="avatar-img h-full w-full" :src="bloggerInfo.avatar" mode="aspectFill" />
</view>
@@ -125,26 +121,26 @@ watch(socialConfig, () => {
empty-text="暂无联系方式" empty-sub-text="" @refresh="handleGetData"
/>
<template v-else>
<uh-section-title class="mb-3 mt-6 text-[30rpx]">
<uh-section-title class="mb-3 mt-6">
联系方式
</uh-section-title>
<view class="flex flex-col gap-3">
<view
v-for="item in result.filter(i => i.value)"
:key="item.key"
class="item uh-global-card-glass flex items-center gap-3 rounded-2xl px-4 py-3"
class="box-border uh-global-card-glass uh-shadow-xs flex items-center gap-3 rounded-2xl px-4 py-3"
@click="handleOnClick(item)"
>
<view class="tile h-[76rpx] w-[76rpx] flex shrink-0 items-center justify-center rounded-xl" :style="{ backgroundColor: platformMeta[item.key]?.color || '#8a8a7a' }">
<text class="text-[30rpx] text-white font-bold">{{ platformMeta[item.key]?.letter || item.name.slice(0, 1) }}</text>
</view>
<view class="min-w-0 flex flex-1 flex-col">
<text class="text-[24rpx] text-gray-400">{{ item.name }}</text>
<text class="text-xs text-gray-500">{{ item.name }}</text>
<view class="mt-1 break-all text-[26rpx] text-gray-900 leading-snug">
{{ item.value }}
</view>
</view>
<wd-icon name="copy" size="28rpx" color="#c8c2b4" class="shrink-0" />
<wd-icon name="copy" size="28rpx" class="shrink-0 text-gray-400" />
</view>
</view>
</template>
+72 -69
View File
@@ -1,83 +1,86 @@
<script lang="ts" setup>
/**
/**
* 免责声明页(源自旧项目 pagesA/disclaimers,新建复刻)
*/
import { computed } from 'vue'
import { useAppConfigStore } from '@/store/appConfig'
import { computed } from 'vue'
import { useAppConfigStore } from '@/store/appConfig'
definePage({
style: {
navigationBarTitleText: '免责声明',
navigationStyle: 'custom',
},
})
definePage({
style: {
navigationBarTitleText: '免责声明',
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const appConfigStore = useAppConfigStore()
const haloConfigs = computed(() => appConfigStore.configs)
const disclaimersContent = computed(() => {
const basicConfig = haloConfigs.value.basicConfig as { disclaimers?: { content?: string } } | undefined
return basicConfig?.disclaimers?.content || ''
})
const disclaimersContent = computed(() => {
const basicConfig = haloConfigs.value.basicConfig as { disclaimers ?: { content ?: string } } | undefined
return basicConfig?.disclaimers?.content || ''
})
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname?: string, email?: string } | undefined
return {
nickname: blogger?.nickname || '',
email: blogger?.email || '',
}
})
const bloggerInfo = computed(() => {
const blogger = haloConfigs.value.authorConfig?.blogger as { nickname ?: string, email ?: string } | undefined
return {
nickname: blogger?.nickname || '',
email: blogger?.email || '',
}
})
function copyText(content: string, tips = '复制成功') {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
function copyText(content : string, tips = '复制成功') {
uni.setClipboardData({
data: content,
showToast: false,
success: () => {
uni.showToast({ icon: 'none', title: tips })
},
})
}
</script>
<template>
<view class="box-border min-h-screen bg-page ">
<!-- 自定义导航 -->
<uh-navbar default-title="免责声明" title-color="text-gray-900" />
<view class="box-border min-h-screen bg-page p-3 pt-2">
<!-- 自定义导航 -->
<uh-navbar default-title="免责声明" title-color="text-gray-900" />
<!-- 通过配置 -->
<view v-if="disclaimersContent" style="min-height: 100%;" v-html="disclaimersContent" />
<view class="w-full h-full uh-global-card-glass uh-shadow-xs rounded-xl">
<!-- 通过配置 -->
<view v-if="disclaimersContent" v-html="disclaimersContent" />
<!-- 静态写法 -->
<view v-else class="box-border p-4 pt-0 text-sm text-gray-900 leading-7">
<view class="item mt-6">
1本应用属于个人非盈利性质的网站所有转载的文章都以遵循原作者的版权声明注明了文章来源
</view>
<view class="item mt-6">
2如果原文没有版权声明按照目前互联网开放的原则本博客将在不通知作者的情况下转载文章
</view>
<view class="item mt-6">
3如果原文明确注明"禁止转载"本博客将不会转载
</view>
<view class="item mt-6">
4如果本博客转载的文章不符合作者的版权声明或者作者不想让本博客转载您的文章请邮件告知
<text class="email mx-3 text-primary" @click="copyText(bloggerInfo.email, '电子邮箱已复制到剪贴板!')">{{ bloggerInfo.email }}</text>
博主将会在第一时间删除相关信息
</view>
<view class="item mt-6">
5本博客转载文章仅为留做备份和知识点分享的目的
</view>
<view class="item mt-6">
6本博客将尽力确保所提供信息的准确性及可靠性但不保证信息的正确性和完整性且不对因信息的不正确或遗漏导致的任何损失或损害承担相关责任
</view>
<view class="item mt-6">
7本博客所发布转载的文章其版权均归原作者所有如其他自媒体网站或个人从本博客下载使用请在转载有关文章时务必尊重该文章的著作权保留本博客注明的"原文来源"或者自行去原文处复制版权声明并自负版权等法律责任
</view>
<view class="item mt-6">
8本博客的所有原创文章皆可以任意转载但转载时务必请注明出处
</view>
<view class="item mt-6">
9尊重原创知识共享
</view>
</view>
</view>
<!-- 静态写法 -->
<view v-else class="box-border p-4 pt-0 text-sm text-gray-900 leading-6">
<view class="item mt-4">
1本应用属于个人非盈利性质的网站所有转载的文章都以遵循原作者的版权声明注明了文章来源
</view>
<view class="item mt-4">
2如果原文没有版权声明按照目前互联网开放的原则本博客将在不通知作者的情况下转载文章
</view>
<view class="item mt-4">
3如果原文明确注明"禁止转载"本博客将不会转载
</view>
<view class="item mt-4">
4如果本博客转载的文章不符合作者的版权声明或者作者不想让本博客转载您的文章请邮件告知
<text class="email mx-3 text-primary"
@click="copyText(bloggerInfo.email, '电子邮箱已复制到剪贴板!')">{{ bloggerInfo.email }}</text>
博主将会在第一时间删除相关信息
</view>
<view class="item mt-4">
5本博客转载文章仅为留做备份和知识点分享的目的
</view>
<view class="item mt-4">
6本博客将尽力确保所提供信息的准确性及可靠性但不保证信息的正确性和完整性且不对因信息的不正确或遗漏导致的任何损失或损害承担相关责任
</view>
<view class="item mt-4">
7本博客所发布转载的文章其版权均归原作者所有如其他自媒体网站或个人从本博客下载使用请在转载有关文章时务必尊重该文章的著作权保留本博客注明的"原文来源"或者自行去原文处复制版权声明并自负版权等法律责任
</view>
<view class="item mt-4">
8本博客的所有原创文章皆可以任意转载但转载时务必请注明出处
</view>
<view class="item mt-4">
9尊重原创知识共享
</view>
</view>
</view>
</view>
</template>
+11 -10
View File
@@ -402,10 +402,10 @@
</view>
<!-- 详情弹窗 -->
<uh-glass-popup v-model="detail.show" position="center" custom-class="w-[90vw] rounded-xl !border">
<uh-glass-popup v-model="detail.show" position="bottom" :z-index="999" custom-class="rounded-xl !border">
<view class="relative w-full flex items-center justify-around box-border px-4 pt-4">
<view class="w-full flex flex-col gap-y-1">
<text class="text-md font-bold">小程序详情</text>
<text class="text-md font-bold">站点详情</text>
</view>
<view
class="absolute right-4 top-4 w-6 h-6 uh-global-card-glass shadow-none border rounded-lg text-center"
@@ -416,26 +416,27 @@
<scroll-view v-if="detail.data" :scroll-y="true" :show-scrollbar="false"
class="box-border p-4 max-h-[60vh]">
<view class="flex">
<image class="poup-logo h-[140rpx] w-[140rpx] shrink-0 rounded-full"
<image class="h-20 w-20 shrink-0 rounded-2xl uh-global-card-glass"
:src="checkImageUrl(detail.data.spec.logo)" mode="aspectFill" />
<view class="ml-4 flex flex-1 flex-col gap-y-1 justify-center">
<view class="poup-name text-[34rpx] text-gray-900 font-bold">
<view class="text-lg text-gray-900 font-bold">
{{ detail.data.spec.displayName }}
</view>
<view class="text-xs text-gray-500">
{{ detail.data.spec.groupName }}
<view class="flex items-center gap-x-2">
<text class="uh-global-card-glass border uh-shadow-xs text-xs text-gray-500 rounded-lg bg-secondary px-2 py-0.5 text-gray-900">{{ detail.data.spec.groupName }}</text>
<text class="uh-global-card-glass border uh-shadow-xs text-xs text-gray-500 rounded-lg bg-secondary px-2 py-0.5 text-gray-900">
复制地址
</text>
</view>
<view @click="handleCopyLink(detail.data)">
<text
class="rounded-lg bg-secondary px-2 py-1 text-xs text-gray-900">{{ detail.data.spec.url }}</text>
class="text-xs text-gray-900">{{ detail.data.spec.url }}</text>
</view>
</view>
</view>
<view class="poup-desc mt-4 text-[28rpx] text-gray-600 leading-[1.6]">
{{ detail.data.spec.description || '这个博主很懒,没写简介~' }}
</view>
<image class="poup-img mt-4 h-[320rpx] w-full rounded-xl"
:src="calcSiteThumbnail(detail.data.spec.url)" mode="aspectFill" />
</scroll-view>
</uh-glass-popup>
@@ -500,7 +501,7 @@
</view>
<!-- 小程序详情弹窗 -->
<uh-glass-popup v-model="miniDetail.show" position="center" custom-class="w-[90vw] rounded-xl !border">
<uh-glass-popup v-model="miniDetail.show" :z-index="999" position="bottom" custom-class="rounded-xl !border">
<view class="relative w-full flex items-center justify-around box-border px-4 pt-4">
<view class="w-full flex flex-col gap-y-1">
<text class="text-md font-bold">小程序详情</text>
+5 -5
View File
@@ -184,18 +184,18 @@
<wd-sticky>
<scroll-view scroll-x class="w-full whitespace-nowrap" :show-scrollbar="false">
<view class="flex gap-2 px-3 pb-1 pt-2">
<view class="uh-global-card-glass uh-shadow-xs inline-block border rounded-2xl px-4 py-1.5 text-sm"
<view class="box-border flex gap-2 px-3 pb-1 pt-2">
<view class="flex-1 uh-global-card-glass uh-shadow-xs inline-flex border rounded-2xl px-4 py-1.5 text-sm"
:class="activeType === '' ? 'bg-primary font-bold' : 'text-gray-500'" @click="activeType = ''">
全部
</view>
<view v-for="(type) in typeOptions" :key="type.typeName || '__none__'"
class="uh-global-card-glass uh-shadow-xs inline-flex items-center gap-1 border rounded-2xl px-4 py-1.5 text-sm"
<view v-for="(type) in typeOptions" :key="type.typeDisplayName"
class="flex-1 box-border uh-global-card-glass uh-shadow-xs inline-flex items-center gap-1 border rounded-2xl px-4 py-1.5 text-sm"
:class="activeType === type.typeName ? 'bg-primary font-bold' : 'text-gray-500'"
@click="activeType = activeType === type.typeName ? '' : type.typeName">
<view v-if="type.typeColor" class="shrink-0 h-2 w-2 rounded-full"
:style="{ backgroundColor: type.typeColor }" />
<text class="shrink-0">{{ type.typeDisplayName }}</text>
<view class="shrink-0">{{ type.typeDisplayName }}</view>
</view>
</view>
</scroll-view>
+411 -422
View File
@@ -1,460 +1,449 @@
<script lang="ts" setup>
/**
* 投票详情页(源自旧项目 pagesA/vote-detail,新建复刻)
* 支持 single/multiple/pk 三种投票类型,已投票展示结果
*/
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, calcVoteState, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
import { computed, ref } 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, calcVoteState, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '投票详情',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
definePage({
style: {
navigationBarTitleText: '投票详情',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
/* ---------------- 状态 ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const submitLoading = ref(false)
const pageTitle = ref('加载中...')
const safeAreaBottom = ref(24)
const name = ref('')
const detail = ref<unknown>(null)
const vote = ref<(IVote & {
spec?: {
title?: string
remark?: string
type?: string
maxVotes?: number
startDate?: string
endDate?: string
timeLimit?: string
canAnonymously?: boolean
options?: (IVoteOption & {
id?: string
title?: string
count?: number
checked?: boolean
isVoted?: boolean
disabled?: boolean
_uh_percent?: number
})[]
isVoted?: boolean
hasEnded?: boolean
disabled?: boolean
_uh_type?: string
_uh_state?: { state: string, color: string }
}
stats?: { voteCount?: number }
}) | null>(null)
const submitForm = ref<{ voteData: string[] }>({ voteData: [] })
/* ---------------- 状态 ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const submitLoading = ref(false)
const pageTitle = ref('加载中...')
const safeAreaBottom = ref(24)
const name = ref('')
const detail = ref<unknown>(null)
const vote = ref<(IVote & {
spec ?: {
title ?: string
remark ?: string
type ?: string
maxVotes ?: number
startDate ?: string
endDate ?: string
timeLimit ?: string
canAnonymously ?: boolean
options ?: (IVoteOption & {
id ?: string
title ?: string
count ?: number
checked ?: boolean
isVoted ?: boolean
disabled ?: boolean
_uh_percent ?: number
})[]
isVoted ?: boolean
hasEnded ?: boolean
disabled ?: boolean
_uh_type ?: string
_uh_state ?: { state : string, color : string }
}
stats ?: { voteCount ?: number }
}) | null>(null)
const submitForm = ref<{ voteData : string[] }>({ voteData: [] })
/* ---------------- 计算属性 ---------------- */
const isVoted = computed(() => voteCacheUtil.has(name.value))
const isEnded = computed(() => vote.value?.spec?.hasEnded || false)
/* ---------------- 计算属性 ---------------- */
const isVoted = computed(() => voteCacheUtil.has(name.value))
const isEnded = computed(() => vote.value?.spec?.hasEnded || false)
/* ---------------- 工具 ---------------- */
function formatTime(date?: string, fmt = 'yyyy-MM-dd HH:mm'): string {
// 与旧项目一致:yyyy-MM-dd HH:mm
return date ? formatTimeUtil({ d: date, f: fmt }) : ''
}
/* ---------------- 工具 ---------------- */
function formatTime(date ?: string, fmt = 'yyyy-MM-dd HH:mm') : string {
// 与旧项目一致:yyyy-MM-dd HH:mm
return date ? formatTimeUtil({ d: date, f: fmt }) : ''
}
function showToast(content: string) {
uni.showToast({ icon: 'none', title: content, mask: true })
}
function showToast(content : string) {
uni.showToast({ icon: 'none', title: content, mask: true })
}
function handleCalcIsChecked(option: { id?: string }): boolean {
const data = voteCacheUtil.get(name.value)
if (!data)
return false
return data.selected.includes(option.id || '')
}
function handleCalcIsChecked(option : { id ?: string }) : boolean {
const data = voteCacheUtil.get(name.value)
if (!data)
return false
return data.selected.includes(option.id || '')
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
pageTitle.value = '加载中...'
try {
const res = await getVoteDetail(name.value)
const detailRes = res.data as IVoteDetail
const tempVote = detailRes.vote as typeof vote.value
if (tempVote) {
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] || tempVote.spec.type
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
pageTitle.value = '加载中...'
try {
const res = await getVoteDetail(name.value)
const detailRes = res.data as IVoteDetail
const tempVote = detailRes.vote as typeof vote.value
if (tempVote) {
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] || tempVote.spec.type
// 计算状态(与旧项目 calcVoteState 一致,含 timeLimit 非 custom 时 hasEnded 兜底)
tempVote.spec._uh_state = calcVoteState(tempVote)
if (tempVote.spec._uh_state.state === '已结束')
tempVote.spec.hasEnded = true
// 计算状态(与旧项目 calcVoteState 一致,含 timeLimit 非 custom 时 hasEnded 兜底)
tempVote.spec._uh_state = calcVoteState(tempVote)
if (tempVote.spec._uh_state.state === '已结束')
tempVote.spec.hasEnded = true
// 选项计算
// 插件选项为 {id,title},票数在 VoteDetail.voteDataList / Vote.stats.voteDataList
const countList = (detailRes.voteDataList || tempVote.stats?.voteDataList || []) as { id?: string, voteCount?: number }[]
const countMap: Record<string, number> = {}
countList.forEach((item) => {
if (item.id)
countMap[item.id] = item.voteCount || 0
})
tempVote.spec.options = (tempVote.spec.options || []).map((option) => {
const checked = handleCalcIsChecked(option)
const optionWithCount = {
...option,
value: option.id,
label: option.title,
count: countMap[option.id || ''] || 0,
isVoted: isVoted.value,
checked,
disabled: isVoted.value,
}
return {
...optionWithCount,
_uh_percent: calcVotePercent(tempVote, optionWithCount),
}
})
}
vote.value = tempVote
detail.value = res
setTimeout(() => {
updateLoadingStatus(
tempVote ? DataLoadingStatusEnum.Success : DataLoadingStatusEnum.Empty,
)
}, 200)
}
catch (err) {
console.error(err)
updateLoadingStatus(DataLoadingStatusEnum.Error)
pageTitle.value = '加载失败,请重试...'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 200)
}
}
// 选项计算
// 插件选项为 {id,title},票数在 VoteDetail.voteDataList / Vote.stats.voteDataList
const countList = (detailRes.voteDataList || tempVote.stats?.voteDataList || []) as { id ?: string, voteCount ?: number }[]
const countMap : Record<string, number> = {}
countList.forEach((item) => {
if (item.id)
countMap[item.id] = item.voteCount || 0
})
tempVote.spec.options = (tempVote.spec.options || []).map((option) => {
const checked = handleCalcIsChecked(option)
const optionWithCount = {
...option,
value: option.id,
label: option.title,
count: countMap[option.id || ''] || 0,
isVoted: isVoted.value,
checked,
disabled: isVoted.value,
}
return {
...optionWithCount,
_uh_percent: calcVotePercent(tempVote, optionWithCount),
}
})
}
vote.value = tempVote
detail.value = res
setTimeout(() => {
updateLoadingStatus(
tempVote ? DataLoadingStatusEnum.Success : DataLoadingStatusEnum.Empty,
)
}, 200)
}
catch (err) {
console.error(err)
updateLoadingStatus(DataLoadingStatusEnum.Error)
pageTitle.value = '加载失败,请重试...'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 200)
}
}
/* ---------------- 交互 ---------------- */
function handleSelectSingleOption(option: { id?: string }) {
if (vote.value?.spec?._uh_state?.state === '未开始') {
showToast('投票未开始')
return
}
if (vote.value?.spec?.hasEnded)
return
if (vote.value?.spec?.disabled)
return
vote.value!.spec!.options!.forEach((item) => {
item.checked = option.id === item.id
})
submitForm.value.voteData = vote.value!.spec!.options!.filter(x => x.checked).map(item => item.id || '')
}
/* ---------------- 交互 ---------------- */
function handleSelectSingleOption(option : { id ?: string }) {
if (vote.value?.spec?._uh_state?.state === '未开始') {
showToast('投票未开始')
return
}
if (vote.value?.spec?.hasEnded)
return
if (vote.value?.spec?.disabled)
return
vote.value!.spec!.options!.forEach((item) => {
item.checked = option.id === item.id
})
submitForm.value.voteData = vote.value!.spec!.options!.filter(x => x.checked).map(item => item.id || '')
}
function handleSelectCheckboxOption(option: { id?: string }) {
if (vote.value?.spec?._uh_state?.state === '未开始') {
showToast('投票未开始')
return
}
if (vote.value?.spec?.hasEnded)
return
if (vote.value?.spec?.disabled)
return
function handleSelectCheckboxOption(option : { id ?: string }) {
if (vote.value?.spec?._uh_state?.state === '未开始') {
showToast('投票未开始')
return
}
if (vote.value?.spec?.hasEnded)
return
if (vote.value?.spec?.disabled)
return
const checkedList = vote.value!.spec!.options!.filter(x => x.checked && x.id !== option.id)
if (vote.value?.spec?.type === 'multiple' && checkedList.length >= (vote.value.spec.maxVotes || 0)) {
showToast(`最多选择 ${vote.value.spec.maxVotes}`)
return
}
const checkedList = vote.value!.spec!.options!.filter(x => x.checked && x.id !== option.id)
if (vote.value?.spec?.type === 'multiple' && checkedList.length >= (vote.value.spec.maxVotes || 0)) {
showToast(`最多选择 ${vote.value.spec.maxVotes}`)
return
}
vote.value!.spec!.options!.forEach((item) => {
if (option.id === item.id) {
item.checked = !item.checked
}
})
submitForm.value.voteData = vote.value!.spec!.options!.filter(x => x.checked).map(item => item.id || '')
}
vote.value!.spec!.options!.forEach((item) => {
if (option.id === item.id) {
item.checked = !item.checked
}
})
submitForm.value.voteData = vote.value!.spec!.options!.filter(x => x.checked).map(item => item.id || '')
}
function handleSubmitTip(text: string) {
showToast(text)
}
function handleSubmitTip(text : string) {
showToast(text)
}
async function handleSubmit() {
if (!vote.value?.spec?.canAnonymously) {
uni.showModal({
title: '提示',
content: '该投票不支持匿名,请到博主的 网站端 进行投票!',
cancelColor: '#666666',
cancelText: '关闭',
confirmText: '复制地址',
success: (res) => {
if (res.confirm) {
uni.setClipboardData({
data: import.meta.env.VITE_SERVER_BASEURL || '',
showToast: false,
success: () => {
showToast('复制成功')
},
})
}
},
})
return
}
async function handleSubmit() {
if (!vote.value?.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
}
submitLoading.value = true
uni.showLoading({ title: '正在保存...' })
try {
await submitVote(name.value, submitForm.value, vote.value.spec.canAnonymously)
showToast('提交成功')
voteCacheUtil.set(name.value, {
selected: [...submitForm.value.voteData],
data: vote.value,
})
setTimeout(() => {
uni.startPullDownRefresh()
submitLoading.value = false
}, 1500)
}
catch (err) {
console.error(err)
showToast('提交失败,请重试')
submitLoading.value = false
}
finally {
uni.hideLoading()
}
}
submitLoading.value = true
uni.showLoading({ title: '正在保存...' })
try {
await submitVote(name.value, submitForm.value, vote.value.spec.canAnonymously)
showToast('提交成功')
voteCacheUtil.set(name.value, {
selected: [...submitForm.value.voteData],
data: vote.value,
})
setTimeout(() => {
uni.startPullDownRefresh()
submitLoading.value = false
}, 1500)
}
catch (err) {
console.error(err)
showToast('提交失败,请重试')
submitLoading.value = false
}
finally {
uni.hideLoading()
}
}
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
name.value = options?.name || ''
// #ifndef H5
const systemInfo = uni.getSystemInfoSync()
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom ? systemInfo.safeAreaInsets.bottom + 12 : 24
// #endif
handleGetData()
})
/* ---------------- 生命周期 ---------------- */
onLoad((options) => {
name.value = options?.name || ''
// #ifndef H5
const systemInfo = uni.getSystemInfoSync()
safeAreaBottom.value = systemInfo.safeAreaInsets?.bottom ? systemInfo.safeAreaInsets.bottom + 12 : 24
// #endif
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
onPullDownRefresh(() => {
handleGetData()
})
onShareAppMessage(() => ({
path: `/pages-blog/vote-detail/vote-detail?name=${name.value}`,
title: vote.value?.spec?.title || '来投个票吧',
imageUrl: '',
}))
onShareAppMessage(() => ({
path: `/pages-blog/vote-detail/vote-detail?name=${name.value}`,
title: vote.value?.spec?.title || '来投个票吧',
imageUrl: '',
}))
onShareTimeline(() => ({
title: vote.value?.spec?.title || '来投个票吧',
query: name.value ? `name=${name.value}` : '',
imageUrl: '',
}))
onShareTimeline(() => ({
title: vote.value?.spec?.title || '来投个票吧',
query: name.value ? `name=${name.value}` : '',
imageUrl: '',
}))
</script>
<template>
<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" />
<view class="box-border min-h-screen w-screen flex flex-col bg-page pb-safe">
<!-- 自定义导航 -->
<uh-navbar :default-title="pageTitle" title-color="text-gray-900" />
<!-- 加载/错误/空占位(状态机) -->
<view v-if="loadingStatus !== 'success'">
<uh-data-loading
:loading-status="loadingStatus"
empty-text="未查询到数据"
@refresh="handleGetData"
/>
</view>
<!-- 加载/错误/空占位(状态机) -->
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
empty-text="未查询到数据" @refresh="handleGetData" />
<block v-else>
<template v-if="vote">
<!-- 投票信息 -->
<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 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>
</view>
<view class="info-row">
<text>投票状态</text>
<text class="tag" :style="{ color: vote.spec?._uh_state?.color }">{{ vote.spec?._uh_state?.state }}</text>
</view>
<view class="info-row">
<text>投票方式</text>
<text class="tag" :class="vote.spec?.canAnonymously ? 'text-primary' : 'text-[#f44336]'">
{{ vote.spec?.canAnonymously ? '匿名' : '不匿名' }}
</text>
</view>
<view class="info-row">
<text>开始时间{{ formatTime(vote.spec?.startDate) }}</text>
</view>
<view class="info-row">
<text v-if="vote.spec?.timeLimit === 'permanent'">结束时间永久有效</text>
<text v-else>结束时间{{ formatTime(vote.spec?.endDate) }}</text>
</view>
</view>
</view>
<view v-else class="box-border px-3 pt-2 pb-16 flex flex-col gap-y-3">
<!-- 投票信息 -->
<view class="uh-global-card-glass box-border flex flex-col gap-y-3 rounded-2xl p-3">
<uh-section-title> 投票信息 </uh-section-title>
<view class="flex flex-col gap-3 rounded-xl bg-gray-100 p-4 text-sm text-gray-600">
<view class="info-row">
<text>投票类型</text>
<text class="tag">{{ vote.spec?._uh_type }}</text>
</view>
<view class="info-row">
<text>投票状态</text>
<text class="tag"
:style="{ color: vote.spec?._uh_state?.color }">{{ vote.spec?._uh_state?.state }}</text>
</view>
<view class="info-row">
<text>投票方式</text>
<text class="tag" :class="vote.spec?.canAnonymously ? 'text-primary' : 'text-[#f44336]'">
{{ vote.spec?.canAnonymously ? '匿名' : '不匿名' }}
</text>
</view>
<view class="info-row">
<text>开始时间{{ formatTime(vote.spec?.startDate) }}</text>
</view>
<view class="info-row">
<text v-if="vote.spec?.timeLimit === 'permanent'">结束时间永久有效</text>
<text v-else>结束时间{{ formatTime(vote.spec?.endDate) }}</text>
</view>
</view>
</view>
<!-- 投票内容 -->
<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="sub-content mb-3 pt-3 text-[30rpx] text-[#2b2f33] font-bold">
{{ vote.spec?.title }}
</view>
<view v-if="vote.spec?.remark" class="sub-remark mb-9 pt-3 text-[28rpx] text-[#3f3f3f]">
{{ vote.spec.remark }}
</view>
<view class="sub-title relative box-border pl-6 text-[30rpx]">
投票选项
<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>
<!-- 投票内容 -->
<view class="uh-global-card-glass box-border flex flex-col rounded-2xl p-3 gap-3">
<uh-section-title> 投票内容 </uh-section-title>
<view class="box-border flex flex-col gap-y-2 p-4 rounded-xl bg-gray-100">
<view class="text-sm text-gray-900 font-bold">
{{ vote.spec?.title }}
</view>
<view v-if="vote.spec?.remark" class="text-xs text-gray-500">
{{ vote.spec.remark }}
</view>
</view>
<view class="w-full flex flex-col gap-y-2">
<view class="relative box-border text-sm flex items-center gap-x-2">
投票选项
<text v-if="vote.spec?.type === 'multiple'"
class="text-xs font-normal">最多选择
{{ vote.spec?.maxVotes }}
</text>
</view>
<view class="options flex flex-col gap-3">
<!-- 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 py-3 px-3"
: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-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">
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}` : '' }}{{ option.title }}
</view>
<view class="shrink-0">
{{ option._uh_percent }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view
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-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 }}
</view>
</template>
</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 overflow-hidden rounded-xl text-xs"
:class="option.checked ? 'bg-primary text-gray-900 font-bold' : 'bg-gray-100'"
:style="{ '--percent': `${option._uh_percent}%` }">
<view class="is-voted-item-content relative z-2 box-border px-4 py-3">
<view class="flex items-center justify-between">
<view class="flex-1 text-left">
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}` : '' }}{{ option.title }}
</view>
<view class="shrink-0">
{{ option._uh_percent }}%
</view>
</view>
</view>
</view>
</template>
<template v-else>
<view v-for="(option, optionIndex) in vote.spec?.options" :key="optionIndex"
class="vote-select-option box-border rounded-xl bg-gray-100 px-6 py-5 text-xs"
: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 }}
</view>
</template>
</view>
</view>
</view>
<!-- 投票统计 -->
<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="stat-text mt-3 text-[26rpx] text-[#606266]">
{{ vote.stats?.voteCount || 0 }} 人已参与
</view>
</view>
<!-- 投票统计 -->
<view class="uh-global-card-glass box-border flex flex-col rounded-2xl p-3">
<uh-section-title> 投票统计 </uh-section-title>
<view class="stat-text mt-3 text-xs text-gray-600">
{{ vote.stats?.voteCount || 0 }} 人已参与
</view>
</view>
<!-- 提交按钮 -->
<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>
<wd-button v-else-if="vote.spec?._uh_state?.state === '未开始'" plain block type="warning" @click="handleSubmitTip('投票未开始')">
投票未开始
</wd-button>
<wd-button v-else-if="vote.spec?._uh_state?.state === '已结束'" plain block type="danger" @click="handleSubmitTip('投票已结束')">
投票已结束
</wd-button>
<wd-button v-else-if="!vote.spec?.canAnonymously" plain block type="danger" @click="handleSubmit()">
不支持匿名投票
</wd-button>
<wd-button v-else-if="submitForm.voteData.length === 0" plain block @click="handleSubmitTip('请选择选项')">
提交投票请选择选项
</wd-button>
<wd-button v-else block type="primary" :loading="submitLoading" :disabled="submitLoading" @click="handleSubmit()">
提交投票
</wd-button>
</view>
</template>
</block>
</view>
<!-- 提交按钮 -->
<view class="fixed bottom-0 left-0 z-99 box-border w-screen pb-safe px-3">
<view
class="uh-global-card-glass border rounded-xl w-full flex items-center justify-center gap-x-2 mb-2">
<uh-button v-if="isVoted" custom-class="flex-1 py-2 !rounded-xl">
您已参与投票
</uh-button>
<uh-button v-else-if="vote.spec?._uh_state?.state === '未开始'" custom-class="flex-1 py-2 !rounded-xl"
@click="handleSubmitTip('投票未开始')">
投票未开始
</uh-button>
<uh-button v-else-if="vote.spec?._uh_state?.state === '已结束'" custom-class="flex-1 py-2 !rounded-xl"
@click="handleSubmitTip('投票已结束')">
投票已结束
</uh-button>
<uh-button v-else-if="!vote.spec?.canAnonymously" custom-class="flex-1 py-2 !rounded-xl"
@click="handleSubmit()">
不支持匿名投票
</uh-button>
<uh-button v-else-if="submitForm.voteData.length === 0" custom-class="flex-1 py-2 !rounded-xl"
@click="handleSubmitTip('请选择选项')">
提交投票请选择选项
</uh-button>
<uh-button v-else custom-class="flex-1 py-2 !rounded-xl" @click="handleSubmit()">
提交投票
</uh-button>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
.vote-card {
.sub-title {
&::before {
content: '';
width: 8rpx;
height: 28rpx;
position: absolute;
left: 0;
top: 6rpx;
background: var(--wot-color-theme, #b9e424);
border-radius: 6rpx;
}
}
.vote-card {
.sub-title {
&::before {
content: '';
width: 8rpx;
height: 28rpx;
position: absolute;
left: 0;
top: 6rpx;
background: var(--wot-color-theme, #b9e424);
border-radius: 6rpx;
}
}
.is-voted-item {
&::before {
content: '';
width: var(--percent);
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: #d0d0d0;
z-index: 0;
border-radius: 6rpx;
}
}
.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;
}
}
.pk-container {
.radio-item {
min-width: 30%;
max-width: 70%;
}
.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-left {
background: linear-gradient(90deg, #3b82f6, #60a5fa);
color: white;
clip-path: polygon(0 0, calc(100% - 40rpx) 0, 100% 100%, 0 100%);
}
.option-item-right {
background: linear-gradient(90deg, #f87171, #ef4444);
color: white;
clip-path: polygon(0 0, 100% 0, 100% 100%, 40rpx 100%);
text-align: right;
}
}
}
.option-item-right {
background: linear-gradient(90deg, #f87171, #ef4444);
color: white;
clip-path: polygon(0 0, 100% 0, 100% 100%, 40rpx 100%);
text-align: right;
}
}
}
</style>
+308 -343
View File
@@ -1,382 +1,347 @@
<script lang="ts" setup>
/**
* 投票列表页(源自旧项目 pagesA/votes,新建复刻)
* 展示投票列表,支持搜索 + 类型/状态/排序/是否已投筛选,每个投票项用 uh-vote-card 渲染
*/
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
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'
import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
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({
style: {
navigationBarTitleText: '投票中心',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
definePage({
style: {
navigationBarTitleText: '投票中心',
enablePullDownRefresh: true,
navigationStyle: 'custom',
},
})
const appConfigStore = useAppConfigStore()
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
const appConfigStore = useAppConfigStore()
const calcAuditModeEnabled = computed(() => appConfigStore.auditModeEnabled)
/** 依赖插件(plugin-vote,参考 gallery 对象传参模式) */
const { pluginId, checking, tips, available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable({
pluginId: NeedPluginIds.PluginVote,
tips: '检测到当前插件没有安装或者启用,无法使用投票功能哦,请联系管理员',
})
/** 依赖插件(plugin-vote,参考 gallery 对象传参模式) */
const { pluginId, checking, tips, available: uniHaloPluginAvailable, check: checkPluginAvailable } = usePluginAvailable({
pluginId: NeedPluginIds.PluginVote,
tips: '检测到当前插件没有安装或者启用,无法使用投票功能哦,请联系管理员',
})
/** 重新检测插件:可用则拉取数据(供 uh-plugin-unavailable 刷新按钮) */
async function handlePluginRefresh() {
if (await checkPluginAvailable())
handleGetData()
}
/** 重新检测插件:可用则拉取数据(供 uh-plugin-unavailable 刷新按钮) */
async function handlePluginRefresh() {
if (await checkPluginAvailable())
handleGetData()
}
/* ---------------- 状态 ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const dataList = ref<IVoteItem[]>([])
const hasNext = ref(false)
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,
})
/* ---------------- 状态 ---------------- */
const { loadingStatus, updateLoadingStatus } = useDataLoadingStatus()
const dataList = ref<IVoteItem[]>([])
const hasNext = ref(false)
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 IFilterOption {
label : string
value : string
}
interface IFilterItem {
key: 'type' | 'hasEnded' | 'sort' | 'isVoted'
label: string
options: IFilterOption[]
}
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' },
],
},
]
/** 筛选维度(与旧项目 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 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 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 })
/** 筛选弹层 */
const filterPopup = ref<{ show : boolean, item : IFilterItem | null }>({ show: false, item: null })
function handleOpenFilter(item: IFilterItem) {
filterPopup.value = { show: true, item }
}
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
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
}
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()
}
queryParams.value.page = 1
isLoadMore.value = false
handleGetData()
}
/* ---------------- 搜索 ---------------- */
/** 实时搜索:输入防抖 400ms 后触发(对应旧版 tm-search 的 @input) */
const handleOnInput = debounce(() => {
queryParams.value.page = 1
isLoadMore.value = false
handleGetData()
}, 400)
/* ---------------- 搜索 ---------------- */
/** 实时搜索:输入防抖 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()
}
function handleOnSearch() {
queryParams.value.page = 1
isLoadMore.value = false
handleGetData()
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
updateLoadingStatus(
dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
)
loadMoreText.value = '呜呜,没有更多数据啦~'
uni.stopPullDownRefresh()
return
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
if (calcAuditModeEnabled.value) {
updateLoadingStatus(
dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
)
loadMoreText.value = '呜呜,没有更多数据啦~'
uni.stopPullDownRefresh()
return
}
uni.showLoading({ mask: true, title: '加载中...' })
if (!isLoadMore.value) {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
}
loadMoreText.value = '加载中...'
uni.showLoading({ mask: true, title: '加载中...' })
if (!isLoadMore.value) {
updateLoadingStatus(DataLoadingStatusEnum.Loading)
}
loadMoreText.value = '加载中...'
try {
const res = await getVoteList({ ...queryParams.value })
hasNext.value = res.data.hasNext || false
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
})
// 加工列表数据(与旧项目一致):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(tempItems)
: tempItems
dataList.value = isLoadMore.value
? dataList.value.concat(tempItems)
: tempItems
// 未投优先排序(与旧项目一致)
dataList.value = dataList.value.sort((a, b) => {
return Number(a.spec?.isVoted) - Number(b.spec?.isVoted)
})
// 未投优先排序(与旧项目一致)
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)
}
// 是否已投过滤(与旧项目一致)
if (filterIsVoted.value !== undefined) {
dataList.value = dataList.value.filter(x => x.spec?.isVoted === filterIsVoted.value)
}
updateLoadingStatus(
dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
)
loadMoreText.value = hasNext.value ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}
catch (err) {
console.error(err)
updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
updateLoadingStatus(
dataList.value.length === 0 ? DataLoadingStatusEnum.Empty : DataLoadingStatusEnum.Success,
)
loadMoreText.value = hasNext.value ? '上拉加载更多' : '呜呜,没有更多数据啦~'
}
catch (err) {
console.error(err)
updateLoadingStatus(DataLoadingStatusEnum.Error)
loadMoreText.value = '加载失败,请下拉刷新!'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
}, 500)
}
}
/** 卡片点击跳详情(与旧项目 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}`,
})
}
/** 卡片点击跳详情(与旧项目 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) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
function handleToTopPage(duration = 500) {
uni.pageScrollTo({
scrollTop: 0,
duration,
fail: (err) => {
console.error('回顶失败', err)
},
})
}
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
/* ---------------- 生命周期 ---------------- */
onLoad(async () => {
await checkPluginAvailable()
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
handleGetData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
handleGetData()
})
onPullDownRefresh(() => {
if (!uniHaloPluginAvailable.value) {
uni.stopPullDownRefresh()
return
}
isLoadMore.value = false
queryParams.value.page = 1
handleGetData()
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
return
}
if (hasNext.value) {
queryParams.value.page = Number(queryParams.value.page) + 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
onReachBottom(() => {
if (!uniHaloPluginAvailable.value)
return
if (calcAuditModeEnabled.value) {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
return
}
if (hasNext.value) {
queryParams.value.page = Number(queryParams.value.page) + 1
isLoadMore.value = true
handleGetData()
}
else {
uni.showToast({ icon: 'none', title: '没有更多数据了' })
}
})
</script>
<template>
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
<!-- 自定义导航 -->
<uh-navbar default-title="投票中心" title-color="text-gray-900" />
<view class="app-page min-h-screen w-screen flex flex-col bg-page">
<!-- 自定义导航 -->
<uh-navbar default-title="投票中心" title-color="text-gray-900" />
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="pluginId"
:error-text="tips"
:checking="checking"
@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>
<uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="pluginId" :error-text="tips"
:checking="checking" @on-refresh="handlePluginRefresh" />
<!-- 加载/错误/空占位(状态机) -->
<view v-if="loadingStatus !== 'success'">
<uh-data-loading
:loading-status="loadingStatus"
empty-text="博主还未发布投票~"
@refresh="handleGetData"
/>
</view>
<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="box-border flex items-center justify-between mt-1 py-2 gap-x-2">
<view v-for="f in filterConfig" :key="f.key"
class="uh-global-card-glass border rounded-full box-border flex flex-1 items-center justify-center gap-1 px-2 py-1 text-gray-500"
:class="[filterValues[f.key]?'bg-secondary text-gray-900 font-bold':'bg-white/80 text-gray-600']" @click="handleOpenFilter(f)">
<text class="text-xs truncate" >
{{ filterLabels[f.key] }}
</text>
<wd-icon name="arrow-down" size="10px" />
</view>
</view>
</view>
<view v-else class="content flex flex-col gap-4 p-3">
<block v-if="dataList.length !== 0">
<uh-vote-card
v-for="vote in dataList"
:key="vote.metadata?.name"
: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 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>
<!-- 加载/错误/空占位(状态机) -->
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus" empty-text="博主还未发布投票~" min-height="65vh" @refresh="handleGetData" />
<!-- 筛选弹层 -->
<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 v-else class="box-border flex flex-col gap-4 p-3">
<block v-if="dataList.length !== 0">
<uh-vote-card v-for="vote in dataList" :key="vote.metadata?.name" :vote="vote"
@click="handleOnVoteClick(vote)" />
<view class="box-border py-5 text-center text-xs text-gray-400">
{{ loadMoreText }}
</view>
</block>
</view>
</template>
<!-- 筛选弹层 -->
<uh-glass-popup v-model="filterPopup.show" :z-index="99" position="bottom" custom-class="rounded-2xl">
<view v-if="filterPopup.item" class="box-border p-4">
<view class="mb-4 text-center text-md 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="uh-global-card-glass border box-border rounded-xl px-5 py-2 text-center text-sm"
:class="filterValues[filterPopup.item.key] === opt.value ? 'bg-primary text-gray-900 font-bold' : 'text-gray-700'"
@click="handleSelectFilter(opt)">
{{ opt.label }}
</view>
</view>
</view>
</uh-glass-popup>
</view>
</template>
+5 -2
View File
@@ -12,6 +12,7 @@
definePage({
style: {
navigationBarTitleText: '分类',
navigationStyle: 'custom',
enablePullDownRefresh: true,
backgroundColor: '#f6f3ee',
},
@@ -151,11 +152,13 @@
</script>
<template>
<view class="box-border min-h-screen w-screen flex flex-col bg-page p-3">
<view class="box-border min-h-screen w-screen flex flex-col bg-page">
<uh-navbar :use-back="false" default-title="分类" title-color="text-gray-900"></uh-navbar>
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus" />
<block v-else>
<view class="grid grid-cols-2 gap-2.5">
<view class="box-border grid grid-cols-2 gap-2.5 p-3 pt-0">
<view v-for="(item, index) in dataList" :key="index"
class="uh-global-card-glass relative box-border w-full overflow-hidden rounded-xl"
@click="handleToCategory(item)">
+5 -2
View File
@@ -11,6 +11,7 @@
definePage({
style: {
navigationBarTitleText: '图库',
navigationStyle: 'custom',
enablePullDownRefresh: true,
},
})
@@ -191,11 +192,13 @@
<template>
<view class="min-h-screen w-screen flex flex-col bg-page pb-6">
<uh-navbar :use-back="false" default-title="我的图库" title-color="text-gray-900"></uh-navbar>
<uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="pluginId" :error-text="tips"
:checking="checking" @on-refresh="checkPluginAvailable" />
<template v-else>
<wd-sticky v-if="category.list.length!==0">
<scroll-view :scroll-x="true" class="w-full whitespace-nowrap pt-3">
<wd-sticky v-if="category.list.length!==0" class="w-full">
<scroll-view :scroll-x="true" class="w-full whitespace-nowrap pt-2">
<view v-for="(cate, index) in category.list" :key="cate.spec.displayName"
class="uh-global-card-glass uh-shadow-xs mb-1 ml-3 inline-block border rounded-2xl px-4 py-1 text-sm"
:class="{ 'bg-primary text-gray-900 font-bold': index === category.activeIndex }"
+2 -5
View File
@@ -210,17 +210,14 @@
min-height="36vh" @refresh="handleQuery" />
<block v-else>
<view class="flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
<view class="box-border flex flex-col gap-y-3 p-3 pt-0" :class="globalAppSettings.layout.home">
<uh-article-card v-for="(article, index) in articleList" :key="index"
from="home" :article="article" :audit-mode="calcAuditModeEnabled"
/>
</view>
<view class="load-text mt-3 pb-5 text-center text-xs text-gray-400">
<view class="box-border mt-3 pb-5 text-center text-xs text-gray-400">
{{ loadMoreText }}
</view>
<view v-if="articleList.length > 10" class="to-top-btn" @click="handleToTopPage()">
<wd-icon name="arrow-up" size="20px" color="#03a9f4" />
</view>
</block>
</view>
<uh-notify-dialog />
+91 -95
View File
@@ -23,6 +23,7 @@
definePage({
style: {
navigationBarTitleText: '瞬间',
navigationStyle: 'custom',
enablePullDownRefresh: true,
},
})
@@ -287,116 +288,111 @@
</script>
<template>
<view class="box-border min-h-screen w-screen flex flex-col bg-page py-3">
<view class="box-border min-h-screen w-screen flex flex-col bg-page ">
<uh-navbar :use-back="false" default-title="我的日常" title-color="text-gray-900"></uh-navbar>
<uh-plugin-unavailable v-if="!uniHaloPluginAvailable" :plugin-id="pluginId" :error-text="tips"
:checking="checking" @on-refresh="handlePluginRefresh" />
<template v-else>
<!-- 加载失败(可重试) -->
<uh-data-loading v-if="loadingStatus !== DataLoadingStatusEnum.Success" :loading-status="loadingStatus"
min-height="60vh" @refresh="handleGetData" />
<view v-else class="flex flex-col gap-3 px-3">
<view v-if="dataList.length === 0"
class="min-h-[70vh] w-full flex items-center justify-center content-empty">
<wd-empty :description="t('common.empty')" />
</view>
<view v-else class="box-border flex flex-col gap-3 p-3 pt-0">
<block v-else>
<!-- 瞬间卡片-->
<view v-for="moment in dataList" :key="moment.metadata.name"
class="moment-card uh-shadow-xs overflow-hidden rounded-[24rpx] bg-white">
<!-- 作者 -->
<view class="box-border flex items-center px-4 pt-4">
<view class="flex-1 flex items-center">
<image class="avatar h-[72rpx] w-[72rpx] shrink-0 rounded-full"
:src="checkAvatarUrl(moment.owner?.avatar || bloggerInfo.avatar)"
mode="aspectFill" />
<view class="ml-3 flex flex-col">
<view class="text-sm text-gray-900 font-bold">
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="mt-0.5 text-xs text-gray-400">
{{ formatMomentTime(moment.spec.releaseTime) }}
</view>
<!-- 瞬间卡片-->
<view v-for="moment in dataList" :key="moment.metadata.name"
class="uh-global-card-glass uh-shadow-xs overflow-hidden rounded-xl">
<!-- 作者 -->
<view class="box-border flex items-center px-4 pt-4">
<view class="flex-1 flex items-center">
<image class="avatar h-[72rpx] w-[72rpx] shrink-0 rounded-full"
:src="checkAvatarUrl(moment.owner?.avatar || bloggerInfo.avatar)" mode="aspectFill" />
<view class="ml-3 flex flex-col">
<view class="text-sm text-gray-900 font-bold">
{{ moment.owner?.displayName || bloggerInfo.nickname }}
</view>
<view class="mt-0.5 text-xs text-gray-400">
{{ formatMomentTime(moment.spec.releaseTime) }}
</view>
</view>
<view class="shrink-0">
<uh-button custom-class="!py-1 bg-secondary font-semibold">详情</uh-button>
</view>
</view>
<!-- 正文-->
<view class="box-border px-4 pt-3">
<view class="relative box-border bg-gray-100 p-2 rounded-lg">
<mp-html lazy-load :domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif" scroll-table selectable
:tag-style="markdownConfig.tagStyle" :container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''" :markdown="true" :show-line-number="true"
:show-language-name="true" copy-by-long-press
@click.stop="handleToMomentDetail(moment)" />
</view>
</view>
<!-- 图片 -->
<view v-if="moment.images && moment.images.length !== 0"
class="images flex flex-wrap items-start px-4 pt-3">
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex"
class="image-item box-border p-1"
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
<image mode="aspectFill" class="image-src h-full w-full rounded-lg" :src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])" />
</view>
</view>
<!-- 音频 -->
<view v-if="moment.audios && moment.audios.length !== 0"
class="audio-list flex flex-col gap-3 px-4 pt-3">
<uh-audio-player v-for="audio in moment.audios" :key="audio.url" :src="audio.url"
:poster="bloggerInfo.avatar" :name="`来自${siteName}的声音`"
:author="bloggerInfo.nickname" />
</view>
<!-- 视频 -->
<view v-if="moment.videos && moment.videos.length !== 0"
class="video-list flex flex-col gap-3 px-4 pt-3">
<video v-for="(video, index) in moment.videos" :id="`video_${video.id}`" :key="index"
class="video-src h-[400rpx] w-full rounded-xl" :src="video.url" :show-mute-btn="true"
:controls="true" :show-center-play-btn="true" :enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')" @pause="onVideoPause(video.id || '')"
@ended="onVideoEnded" />
</view>
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0"
class="box-border px-4 mt-4 flex flex-wrap gap-2">
<text v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex"
class="py-1 px-2 text-xs rounded-xl bg-secondary">
# {{ tag }}
</text>
</view>
<!-- (点赞/评论) -->
<view
class="mt-2 mb-1 box-border w-full flex items-center justify-between gap-x-12 border-t border-black/5 py-3 px-4 text-xs text-gray-400">
<view class="flex items-center gap-x-1">
<wd-icon class-prefix="uhemoji-icon" name="-kiss-" size="32rpx" />
<text class="text-sm text-gray-600">点赞 {{ moment.stats.upvote || 0 }}</text>
</view>
<view class="flex items-center gap-x-1">
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
<text class="text-sm text-gray-600">评论 {{ moment.stats.totalComment || 0 }}</text>
</view>
<view class="flex items-center gap-x-1" @click.stop="handleToggleMomentFavorite(moment)">
<wd-icon class-prefix="uhemoji-icon" name="-smile-" size="32rpx" />
<text class="text-sm text-gray-600"
:style="isMomentFavorite(moment) ? { color: '#ffb300' } : ''">{{ isMomentFavorite(moment) ? '已收藏' : '收藏' }}</text>
</view>
<view class="shrink-0">
<uh-button custom-class="!py-1 bg-secondary font-semibold">详情</uh-button>
</view>
</view>
<view class="load-text pb-5 pt-1 text-center text-xs text-gray-500">
{{ loadMoreText }}
<!-- 正文-->
<view class="box-border px-4 pt-3">
<view class="relative box-border bg-page p-3 rounded-lg">
<mp-html lazy-load :domain="markdownConfig.domain ?? ''"
:loading-img="markdownConfig.loadingGif" scroll-table selectable
:tag-style="markdownConfig.tagStyle" :container-style="markdownConfig.containStyle"
:content="moment.spec.newHtml || ''" :markdown="true" :show-line-number="true"
:show-language-name="true" copy-by-long-press
@click.stop="handleToMomentDetail(moment)" />
</view>
</view>
</block>
<!-- 图片 -->
<view v-if="moment.images && moment.images.length !== 0"
class="box-border flex flex-wrap items-start px-3 pt-3">
<view v-for="(image, mediumIndex) in moment.images" :key="mediumIndex"
class="image-item box-border p-1"
:class="moment.images && moment.images.length === 1 ? 'h-[350rpx] w-full' : (moment.images && moment.images.length === 2 ? 'h-[250rpx] w-1/2' : 'h-[200rpx] w-1/3')">
<image mode="aspectFill" class="image-src h-full w-full rounded-lg" :src="image.url"
@click="handlePreview(mediumIndex, moment.images || [])" />
</view>
</view>
<!-- 音频 -->
<view v-if="moment.audios && moment.audios.length !== 0"
class="box-border flex flex-col gap-3 px-4 pt-3">
<uh-audio-player v-for="audio in moment.audios" :key="audio.url" :src="audio.url"
:poster="bloggerInfo.avatar" :name="`来自${siteName}的声音`" :author="bloggerInfo.nickname" />
</view>
<!-- 视频 -->
<view v-if="moment.videos && moment.videos.length !== 0"
class="box-border flex flex-col gap-3 px-4 pt-3">
<video v-for="(video, index) in moment.videos" :id="`video_${video.id}`" :key="index"
class="video-src h-[400rpx] w-full rounded-xl" :src="video.url" :show-mute-btn="true"
:controls="true" :show-center-play-btn="true" :enable-progress-gesture="true"
@play="onVideoPlay(video.id || '')" @pause="onVideoPause(video.id || '')"
@ended="onVideoEnded" />
</view>
<view v-if="moment.spec.tags && moment.spec.tags.length !== 0"
class="box-border px-4 mt-3 flex flex-wrap gap-2">
<text v-for="(tag, tagIndex) in moment.spec.tags" :key="tagIndex"
class="py-1 px-2 text-xs rounded-xl bg-secondary">
# {{ tag }}
</text>
</view>
<!-- (点赞/评论) -->
<view
class="mt-2 mb-1 box-border w-full flex items-center justify-between gap-x-12 border-t border-black/5 py-3 px-4 text-xs text-gray-400">
<view class="flex items-center gap-x-1">
<wd-icon class-prefix="uhemoji-icon" name="-kiss-" size="32rpx" />
<text class="text-sm text-gray-600">点赞 {{ moment.stats.upvote || 0 }}</text>
</view>
<view class="flex items-center gap-x-1">
<wd-icon class-prefix="uhemoji-icon" name="-thinking" size="32rpx" />
<text class="text-sm text-gray-600">评论 {{ moment.stats.totalComment || 0 }}</text>
</view>
<view class="flex items-center gap-x-1" @click.stop="handleToggleMomentFavorite(moment)">
<wd-icon class-prefix="uhemoji-icon" name="-smile-" size="32rpx" />
<text class="text-sm text-gray-600"
:style="isMomentFavorite(moment) ? { color: '#ffb300' } : ''">{{ isMomentFavorite(moment) ? '已收藏' : '收藏' }}</text>
</view>
</view>
</view>
<view class="load-text pb-5 pt-1 text-center text-xs text-gray-500">
{{ loadMoreText }}
</view>
</view>
</template>
</view>