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

chore: 批量新增项目依赖、工具函数、页面与组件资源

1. 新增mp-html、qs等生产依赖,补全项目基础库
2. 新增平台判断、缓存、工具函数等通用工具集
3. 新增标签页、网站浏览页、关于页等业务页面
4. 新增分类卡片、通知弹窗等业务组件
5. 新增uts-progressNotification、liu-poster、uhalo-upgrade等uni模块
6. 补充audio/video组件样式补件,修复uni-components路径缺失问题
7. 新增环境变量Halo个人令牌配置项
8. 重构store导出结构,新增appConfig/halo/setting三个状态模块
9. 新增tsconfig编译目标配置,适配更高版本ES语法
This commit is contained in:
小莫唐尼
2026-08-31 19:56:16 +08:00
parent ba5b77568b
commit 067f3ed98a
147 changed files with 20866 additions and 325 deletions
+416
View File
@@ -0,0 +1,416 @@
<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 dayjs from 'dayjs'
import { getVoteDetail, submitVote } from '@/api/uni-halo'
import { calcVotePercent, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IVote, IVoteOption } from '@/api/types/uni-halo'
definePage({
style: {
navigationBarTitleText: '投票详情',
enablePullDownRefresh: true,
},
})
/* ---------------- 状态 ---------------- */
const loading = ref<'loading' | 'success' | 'error'>('loading')
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)
/* ---------------- 工具 ---------------- */
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 handleCalcIsChecked(option: { id?: string }): boolean {
const data = voteCacheUtil.get(name.value)
if (!data)
return false
return data.selected.includes(option.id || '')
}
/* ---------------- 数据加载 ---------------- */
async function handleGetData() {
loading.value = 'loading'
pageTitle.value = '加载中...'
try {
const res = await getVoteDetail(name.value)
const tempVote = res.data as typeof vote.value
if (tempVote) {
pageTitle.value = `投票详情(${VOTE_TYPES[(tempVote.spec?.type || 'SINGLE') as keyof typeof VOTE_TYPES] || tempVote.spec?.type}`
tempVote.spec = tempVote.spec || {}
tempVote.spec.isVoted = isVoted.value
tempVote.spec.disabled = isVoted.value
tempVote.spec._uh_type = VOTE_TYPES[(tempVote.spec.type || 'SINGLE') as keyof typeof VOTE_TYPES] || 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' }
tempVote.spec.hasEnded = true
}
else if (startTime > now) {
tempVote.spec._uh_state = { state: '未开始', color: 'orange' }
}
else {
tempVote.spec._uh_state = { state: '进行中', color: 'green' }
}
// 选项计算
tempVote.spec.options = (tempVote.spec.options || []).map((option) => {
const checked = handleCalcIsChecked(option)
return {
...option,
value: option.id,
label: option.title,
isVoted: isVoted.value,
checked,
disabled: isVoted.value,
_uh_percent: calcVotePercent(tempVote, option),
}
})
}
vote.value = tempVote
detail.value = res
setTimeout(() => {
loading.value = 'success'
}, 200)
}
catch (err) {
console.error(err)
loading.value = 'error'
pageTitle.value = '加载失败,请重试...'
}
finally {
setTimeout(() => {
uni.hideLoading()
uni.stopPullDownRefresh()
uni.setNavigationBarTitle({ title: pageTitle.value })
}, 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 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
}
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)
}
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()
}
}
/* ---------------- 生命周期 ---------------- */
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()
})
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: '',
}))
</script>
<template>
<view class="app-page box-border min-h-screen w-screen flex flex-col py-6 pb-[160rpx]" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6">
<wd-skeleton :row="4" :animated="true" />
</view>
<block v-else>
<view v-if="!vote" class="empty h-[60vh] flex items-center justify-center">
<wd-empty description="未查询到数据" />
</view>
<block v-else>
<!-- 投票信息 -->
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<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="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-[#03a9f4]' : '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="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<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">
<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'"
: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 }}
</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-[#03a9f4] bg-[#03a9f4]/15 text-[#03a9f4]' : ''"
@click="vote.spec?.type === 'multiple' ? handleSelectCheckboxOption(option) : handleSelectSingleOption(option)"
>
{{ vote.spec?.type === 'pk' ? `选项${optionIndex + 1}` : '' }}{{ option.title }}
</view>
</template>
</view>
</view>
<!-- 投票统计 -->
<view class="vote-card mx-6 mb-6 flex flex-col overflow-hidden rounded-xl bg-white p-6 shadow-sm">
<view class="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="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` }">
<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>
</block>
</block>
</view>
</template>
<style scoped lang="scss">
.vote-card {
.sub-title {
&::before {
content: '';
width: 8rpx;
height: 28rpx;
position: absolute;
left: 0;
top: 6rpx;
background: #03a9f4;
border-radius: 6rpx;
}
}
.is-voted-item {
&::before {
content: '';
width: var(--percent);
position: absolute;
left: 0;
top: 0;
bottom: 0;
background-color: #d0d0d0;
z-index: 0;
border-radius: 6rpx;
}
}
}
</style>