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

refactor: 统一加载状态组件,重构投票相关类型与逻辑,新增维护拦截功能

1. 替换所有页面的wd-skeleton与空状态为uh-data-loading组件
2. 重构投票相关API类型与列表/详情逻辑,适配Halo扩展对象结构
3. 新增维护拦截hook与维护页,统一插件可用性与维护模式检查跳转
4. 优化多个页面的代码结构与样式细节
This commit is contained in:
小莫唐尼
2026-09-04 04:19:50 +08:00
parent d0dd7d7a46
commit 2fbd3fb19a
15 changed files with 987 additions and 178 deletions
+60 -20
View File
@@ -230,27 +230,67 @@ export interface IVoteListReq {
[key: string]: unknown [key: string]: unknown
} }
export interface IVoteItem { /** 投票选项(插件 VoteSpec.options:{id,title}) */
name: string
title: string
description?: string
[key: string]: unknown
}
export type IVoteListRes = IVoteItem[]
export interface IVote {
name: string
title: string
description?: string
options?: IVoteOption[]
[key: string]: unknown
}
export interface IVoteOption { export interface IVoteOption {
name?: string id?: string
label?: string title?: string
count?: number [key: string]: unknown
}
/** 投票列表项(Halo 扩展对象,标识在 metadata.name、内容在 spec) */
export interface IVoteItem {
metadata?: { name?: string, [key: string]: unknown }
spec?: {
title?: string
remark?: string
type?: string
[key: string]: unknown
}
[key: string]: unknown
}
/** 投票列表响应(Halo 标准 ListResult 结构,与 posts/categories 等列表接口一致) */
export interface IVoteListRes {
items: IVoteItem[]
page?: number
size?: number
total?: number
hasNext?: boolean
}
/** 投票(Halo 扩展对象) */
export interface IVote {
metadata: { name: string, [key: string]: unknown }
spec?: {
title?: string
remark?: string
type?: 'single' | 'multiple' | 'pk' | string
maxVotes?: number
options?: IVoteOption[]
timeLimit?: 'custom' | 'permanent' | 'thirty' | 'seven' | 'one' | string
startDate?: string
endDate?: string
owner?: string
hasEnded?: boolean
canAnonymously?: boolean
canSeeVoters?: boolean
[key: string]: unknown
}
stats?: {
voteCount?: number
voteUser?: number
voteDataList?: { id?: string, voteCount?: number }[]
}
[key: string]: unknown
}
/** 投票详情(插件 VoteDetail:嵌套 vote + 统计) */
export interface IVoteDetail {
vote: IVote
voteDataList?: { id?: string, voteCount?: number }[]
userVoteData?: string[]
voteCount?: number
voteUser?: number
[key: string]: unknown [key: string]: unknown
} }
+2 -2
View File
@@ -42,7 +42,7 @@ import type {
IRestrictReadCheckRes, IRestrictReadCheckRes,
ISubmitLinkForm, ISubmitLinkForm,
IUpdateCheckRes, IUpdateCheckRes,
IVote, IVoteDetail,
IVoteListReq, IVoteListReq,
IVoteListRes, IVoteListRes,
IVoteSubmitReq, IVoteSubmitReq,
@@ -381,7 +381,7 @@ export function getVoteList(params: IVoteListReq) {
* 投票详情 * 投票详情
*/ */
export function getVoteDetail(name: string) { export function getVoteDetail(name: string) {
return http.Get<IResponse<IVote>>(`/apis/api.vote.kunkunyu.com/v1alpha1/votes/${name}/detail`, { return http.Get<IResponse<IVoteDetail>>(`/apis/api.vote.kunkunyu.com/v1alpha1/votes/${name}/detail`, {
meta: { requestFrom: RequestFrom.Halo }, meta: { requestFrom: RequestFrom.Halo },
}) })
} }
+56 -15
View File
@@ -1,11 +1,12 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 投票卡片(源自旧项目 components/vote-card,新建复刻) * 投票卡片(源自旧项目 components/vote-card,新建复刻)
* 适配 plugin-vote 真实结构:详情接口返回 VoteDetail(嵌套 vote),选项为 {id,title}
*/ */
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { getVoteDetail } from '@/api/uni-halo' import { getVoteDetail } from '@/api/uni-halo'
import { calcVoteState, VOTE_STATES, VOTE_TYPES } from '@/utils/vote' import { VOTE_STATES } from '@/utils/vote'
import type { IVote, IVoteOption } from '@/api/types/uni-halo' import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
const props = defineProps<{ const props = defineProps<{
voteName: string voteName: string
@@ -20,8 +21,27 @@ const isSubmit = ref(false)
const voteData = ref<IVote | null>(null) const voteData = ref<IVote | null>(null)
const voteTypes = ref<string[]>([]) const voteTypes = ref<string[]>([])
const canAnonymously = ref(true) const canAnonymously = ref(true)
/** 选项 id → 票数(来自 VoteDetail.voteDataList 或 Vote.stats.voteDataList) */
const voteCountMap = ref<Record<string, number>>({})
const voteState = computed(() => calcVoteState(voteData.value || {}, voteTypes.value, canAnonymously.value)) /** 投票状态(基于插件字段 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(() => { const voteLabel = computed(() => {
if (voteState.value === VOTE_STATES.VOTE_ENDED) if (voteState.value === VOTE_STATES.VOTE_ENDED)
@@ -35,13 +55,34 @@ const voteLabel = computed(() => {
const voteResultLabel = computed(() => (voteState.value === VOTE_STATES.VOTED ? '查看结果' : '')) const voteResultLabel = computed(() => (voteState.value === VOTE_STATES.VOTED ? '查看结果' : ''))
const isSingle = computed(() => voteData.value?.spec?.type === 'single')
/** 选项票数占比(取自 voteCountMap) */
function handleCalcPercent(option: IVoteOption): number {
const total = voteData.value?.stats?.voteCount || 0
const count = voteCountMap.value[option.id || ''] || 0
if (total === 0)
return 0
return Number(((count / total) * 100).toFixed(2))
}
async function handleGetData() { async function handleGetData() {
loading.value = true loading.value = true
try { try {
const res = await getVoteDetail(props.voteName) const res = await getVoteDetail(props.voteName)
voteData.value = res.data const detail = res.data as IVoteDetail
// 已投票项从缓存恢复(简化:根据 options 计数判断) const vote = detail.vote || (detail as unknown as IVote)
voteData.value = vote
canAnonymously.value = !!vote.spec?.canAnonymously
voteTypes.value = [] 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) { catch (err) {
console.error('获取投票失败', err) console.error('获取投票失败', err)
@@ -54,8 +95,8 @@ async function handleGetData() {
function handleSelectOption(option: IVoteOption) { function handleSelectOption(option: IVoteOption) {
if (voteState.value !== VOTE_STATES.VOTING) if (voteState.value !== VOTE_STATES.VOTING)
return return
const optionName = option.name || '' const optionName = option.id || ''
if (voteData.value && (voteData.value as { type?: string }).type === VOTE_TYPES.SINGLE) { if (isSingle.value) {
voteTypes.value = [optionName] voteTypes.value = [optionName]
} }
else { else {
@@ -98,25 +139,25 @@ defineExpose({ refresh: handleGetData })
<view v-else-if="voteData" class="vote-body"> <view v-else-if="voteData" class="vote-body">
<view class="vote-title text-[30rpx] text-gray-900 font-bold"> <view class="vote-title text-[30rpx] text-gray-900 font-bold">
{{ voteData.title }} {{ voteData.spec?.title }}
</view> </view>
<view class="vote-desc mt-1 text-[24rpx] text-gray-400"> <view v-if="voteData.spec?.remark" class="vote-desc mt-1 text-[24rpx] text-gray-400">
{{ voteData.description }} {{ voteData.spec.remark }}
</view> </view>
<view class="options mt-5"> <view class="options mt-5">
<view <view
v-for="option in voteData.options" v-for="option in voteData.spec?.options || []"
:key="option.name" :key="option.id"
class="option mb-4 flex flex-col border-2 rounded-xl p-5" class="option mb-4 flex flex-col border-2 rounded-xl p-5"
:class="voteTypes.includes(option.name || '') ? 'border-[#b9e424] bg-[#f0f7d9]' : 'border-transparent bg-[#f6f3ee]'" :class="voteTypes.includes(option.id || '') ? 'border-[#b9e424] bg-[#f0f7d9]' : 'border-transparent bg-[#f6f3ee]'"
@click="handleSelectOption(option)" @click="handleSelectOption(option)"
> >
<view class="option-label text-[28rpx] text-gray-700"> <view class="option-label text-[28rpx] text-gray-700">
<text>{{ option.label }}</text> <text>{{ option.title }}</text>
</view> </view>
<view v-if="voteState === VOTE_STATES.VOTED" class="option-bar mt-3 h-4 overflow-hidden rounded-lg bg-black/5"> <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: `${option.count || 0}%`, background: 'linear-gradient(90deg, #B9E424, #D7F94C)' }" /> <view class="option-bar-inner h-full rounded-lg" :style="{ width: `${handleCalcPercent(option)}%`, background: 'linear-gradient(90deg, #B9E424, #D7F94C)' }" />
</view> </view>
</view> </view>
</view> </view>
+72
View File
@@ -0,0 +1,72 @@
/**
* 维护拦截 hook(2026-09-04)
* 统一「主插件未激活 / 维护模式开启」两项检查与维护页跳转,供入口页与首页等
* 页面复用(auto-import 已配置 src/hooks,页面直接调用无需 import)。
* 拦截规则:任一命中即跳转 /pages/maintenance/maintenance?from=reason,
* 维护页按 reason 展示默认(未配置维护信息)或配置文案。设计见插件
* .docs/maintenance-config-design.md §8。
*/
import { usePluginAvailable } from '@/utils/plugin'
import { useAppConfigStore } from '@/store/appConfig'
/** 维护拦截原因:plugin 主插件未激活 / maintenance 维护模式开启 */
export type MaintenanceInterceptReason = 'plugin' | 'maintenance'
export interface IMaintenanceInterceptResult {
/** 是否命中拦截(需要跳转维护页) */
intercepted: boolean
/** 命中原因;未命中为 null */
reason: MaintenanceInterceptReason | null
}
/** 维护页路径 */
export const MAINTENANCE_PAGE_PATH = '/pages/maintenance/maintenance'
/** 主插件 ID(与 utils/plugin NeedPluginIds.PluginUniHalo 一致) */
export const MAINTENANCE_PLUGIN_ID = 'plugin-uni-halo'
/**
* 维护拦截能力:检查 + 跳转封装
*
* @example
* const { interceptOrContinue } = useMaintenanceIntercept()
* onLoad(async () => { if (await interceptOrContinue()) return })
*/
export function useMaintenanceIntercept() {
const appConfigStore = useAppConfigStore()
/**
* 检查是否命中拦截(插件可用性 + 维护模式)。
* @param force 是否强制刷新配置(默认 false 走 bootstrap TTL 缓存)
*/
async function checkIntercept(force = false): Promise<IMaintenanceInterceptResult> {
const pluginAvailable = await usePluginAvailable(MAINTENANCE_PLUGIN_ID)
if (!pluginAvailable)
return { intercepted: true, reason: 'plugin' }
const { ok } = await appConfigStore.bootstrap({ force })
if (!ok)
return { intercepted: false, reason: null }
if (appConfigStore.configs.maintenance)
return { intercepted: true, reason: 'maintenance' }
return { intercepted: false, reason: null }
}
/** 跳转维护页(带原因参数,供维护页区分默认/配置文案) */
function redirectToMaintenance(reason: MaintenanceInterceptReason) {
uni.redirectTo({ url: `${MAINTENANCE_PAGE_PATH}?from=${reason}` })
}
/**
* 一站式:检查并跳转维护页。
* @returns true = 已命中并跳转,调用方应中断后续逻辑;false = 放行
*/
async function interceptOrContinue(force = false): Promise<boolean> {
const { intercepted, reason } = await checkIntercept(force)
if (intercepted && reason)
redirectToMaintenance(reason)
return intercepted
}
return { checkIntercept, redirectToMaintenance, interceptOrContinue }
}
+3 -3
View File
@@ -258,9 +258,9 @@ onReachBottom(() => {
</wd-tabs> </wd-tabs>
</view> </view>
<!-- 骨架屏 --> <!-- 加载/错误占位 -->
<view v-if="loading !== 'success'" class="loading-wrap p-3"> <view v-if="loading !== 'success'">
<wd-skeleton :row="3" :animated="true" /> <uh-data-loading :loading-status="loading" @refresh="handleGetData" />
</view> </view>
<!-- 内容区域 --> <!-- 内容区域 -->
+8 -12
View File
@@ -102,15 +102,9 @@ function handleOpenLink() {
<template> <template>
<view class="app-page"> <view class="app-page">
<!-- 加载骨架 --> <!-- 加载/错误占位 -->
<view v-if="loading === 'loading'" class="p-4"> <view v-if="loading !== 'success'">
<wd-skeleton :row="3" :animated="true" /> <uh-data-loading :loading-status="loading" @refresh="handleRetry" />
</view>
<!-- 加载失败 -->
<view v-else-if="loading === 'error'" class="flex flex-col items-center gap-4 py-20">
<wd-empty description="详情加载失败" />
<wd-button size="small" @click="handleRetry">重新加载</wd-button>
</view> </view>
<!-- 详情内容 --> <!-- 详情内容 -->
@@ -120,7 +114,7 @@ function handleOpenLink() {
<view class="px-4"> <view class="px-4">
<!-- 标题 --> <!-- 标题 -->
<view class="mt-6 text-[34rpx] font-bold leading-snug text-[#303133]"> <view class="mt-6 text-[34rpx] text-[#303133] font-bold leading-snug">
{{ detail.title }} {{ detail.title }}
</view> </view>
@@ -143,8 +137,10 @@ function handleOpenLink() {
<!-- 外链(平台差异,条件编译) --> <!-- 外链(平台差异,条件编译) -->
<view v-if="detail.link" class="link-card mt-8 rounded-xl bg-[#f7f7f9] p-4"> <view v-if="detail.link" class="link-card mt-8 rounded-xl bg-[#f7f7f9] p-4">
<view class="mb-3 text-[24rpx] text-[#909399]">相关链接</view> <view class="mb-3 text-[24rpx] text-[#909399]">
<text class="link-text block break-all text-[26rpx] leading-relaxed text-[#606266]"> 相关链接
</view>
<text class="link-text block break-all text-[26rpx] text-[#606266] leading-relaxed">
{{ detail.link }} {{ detail.link }}
</text> </text>
<!-- #ifndef APP-PLUS --> <!-- #ifndef APP-PLUS -->
@@ -107,8 +107,9 @@ onShareTimeline(() => ({
<template> <template>
<view class="app-page min-h-screen w-screen flex flex-col py-6" style="background-color: #fafafd;"> <view class="app-page min-h-screen w-screen flex flex-col py-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6"> <!-- 加载/错误占位 -->
<wd-skeleton :row="4" :animated="true" /> <view v-if="loading !== 'success'">
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
</view> </view>
<block v-else> <block v-else>
+3 -6
View File
@@ -167,12 +167,9 @@ init()
@on-refresh="handleGetData" @on-refresh="handleGetData"
/> />
<template v-else> <template v-else>
<!-- 加载/错误 --> <!-- 加载/错误占位 -->
<view v-if="loading === 'loading'" class="loading-wrap p-3"> <view v-if="loading !== 'success'">
<wd-skeleton :row="4" :animated="true" /> <uh-data-loading :loading-status="loading" @refresh="handleGetData" />
</view>
<view v-else-if="loading === 'error'" class="h-[60vh] flex items-center justify-center content-empty">
<wd-empty description="加载异常" />
</view> </view>
<!-- 内容区域 --> <!-- 内容区域 -->
+6 -4
View File
@@ -338,8 +338,9 @@ onReachBottom(() => {
@on-refresh="handleGetLinkGroupData" @on-refresh="handleGetLinkGroupData"
/> />
<template v-else> <template v-else>
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen p-3"> <!-- 加载/错误占位 -->
<wd-skeleton :row="5" :animated="true" /> <view v-if="loading !== 'success'">
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
</view> </view>
<view v-else class="content pt-4"> <view v-else class="content pt-4">
@@ -435,8 +436,9 @@ onReachBottom(() => {
@on-refresh="handleGetMiniProgramLinks" @on-refresh="handleGetMiniProgramLinks"
/> />
<template v-else> <template v-else>
<view v-if="miniLoading !== 'success'" class="loading-wrap min-h-screen p-3"> <!-- 加载/错误占位 -->
<wd-skeleton :row="5" :animated="true" /> <view v-if="miniLoading !== 'success'">
<uh-data-loading :loading-status="miniLoading" @refresh="handleGetMiniProgramLinks" />
</view> </view>
<view v-else class="content flex flex-1 flex-col"> <view v-else class="content flex flex-1 flex-col">
+3 -2
View File
@@ -97,8 +97,9 @@ onReachBottom(() => {
<template> <template>
<view class="app-page min-h-screen w-screen flex flex-col py-6" style="background-color: #fafafd;"> <view class="app-page min-h-screen w-screen flex flex-col py-6" style="background-color: #fafafd;">
<view v-if="loading !== 'success'" class="loading-wrap min-h-screen px-6"> <!-- 加载/错误占位 -->
<wd-skeleton :row="4" :animated="true" /> <view v-if="loading !== 'success'">
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
</view> </view>
<block v-else> <block v-else>
+22 -9
View File
@@ -5,11 +5,10 @@
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app' import { onLoad, onPullDownRefresh, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
import { getVoteDetail, submitVote } from '@/api/uni-halo' import { getVoteDetail, submitVote } from '@/api/uni-halo'
import { calcVotePercent, VOTE_TYPES, voteCacheUtil } from '@/utils/vote' import { calcVotePercent, VOTE_TYPES, voteCacheUtil } from '@/utils/vote'
import { formatTime as formatTimeUtil } from '@/utils/formatTime' import { formatTime as formatTimeUtil } from '@/utils/formatTime'
import type { IVote, IVoteOption } from '@/api/types/uni-halo' import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
definePage({ definePage({
style: { style: {
@@ -81,13 +80,15 @@ async function handleGetData() {
pageTitle.value = '加载中...' pageTitle.value = '加载中...'
try { try {
const res = await getVoteDetail(name.value) const res = await getVoteDetail(name.value)
const tempVote = res.data as typeof vote.value const detailRes = res.data as IVoteDetail
const tempVote = detailRes.vote as typeof vote.value
if (tempVote) { if (tempVote) {
pageTitle.value = `投票详情(${VOTE_TYPES[(tempVote.spec?.type || 'SINGLE') as keyof typeof VOTE_TYPES] || tempVote.spec?.type}` const typeKey = ((tempVote.spec?.type || 'SINGLE') as string).toUpperCase()
pageTitle.value = `投票详情(${VOTE_TYPES[typeKey as keyof typeof VOTE_TYPES] || tempVote.spec?.type}`
tempVote.spec = tempVote.spec || {} tempVote.spec = tempVote.spec || {}
tempVote.spec.isVoted = isVoted.value tempVote.spec.isVoted = isVoted.value
tempVote.spec.disabled = isVoted.value tempVote.spec.disabled = isVoted.value
tempVote.spec._uh_type = VOTE_TYPES[(tempVote.spec.type || 'SINGLE') as keyof typeof VOTE_TYPES] || tempVote.spec.type tempVote.spec._uh_type = VOTE_TYPES[typeKey as keyof typeof VOTE_TYPES] || tempVote.spec.type
// 计算状态 // 计算状态
const startTime = tempVote.spec.startDate ? new Date(tempVote.spec.startDate).getTime() : Date.now() const startTime = tempVote.spec.startDate ? new Date(tempVote.spec.startDate).getTime() : Date.now()
@@ -105,16 +106,27 @@ async function handleGetData() {
} }
// 选项计算 // 选项计算
// 插件选项为 {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) => { tempVote.spec.options = (tempVote.spec.options || []).map((option) => {
const checked = handleCalcIsChecked(option) const checked = handleCalcIsChecked(option)
return { const optionWithCount = {
...option, ...option,
value: option.id, value: option.id,
label: option.title, label: option.title,
count: countMap[option.id || ''] || 0,
isVoted: isVoted.value, isVoted: isVoted.value,
checked, checked,
disabled: isVoted.value, disabled: isVoted.value,
_uh_percent: calcVotePercent(tempVote, option), }
return {
...optionWithCount,
_uh_percent: calcVotePercent(tempVote, optionWithCount),
} }
}) })
} }
@@ -258,8 +270,9 @@ onShareTimeline(() => ({
<template> <template>
<view class="app-page box-border min-h-screen w-screen flex flex-col py-6 pb-[160rpx]" style="background-color: #fafafd;"> <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 v-if="loading !== 'success'">
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
</view> </view>
<block v-else> <block v-else>
+8 -7
View File
@@ -49,10 +49,10 @@ async function handleGetData() {
try { try {
const res = await getVoteList({ ...queryParams.value }) const res = await getVoteList({ ...queryParams.value })
loading.value = 'success' loading.value = 'success'
hasNext.value = (res.data as unknown as { hasNext?: boolean }).hasNext || false hasNext.value = res.data.hasNext || false
dataList.value = isLoadMore.value dataList.value = isLoadMore.value
? dataList.value.concat(res.data as IVoteItem[]) ? dataList.value.concat(res.data.items)
: (res.data as IVoteItem[]) : res.data.items
loadMoreText.value = hasNext.value ? '上拉加载更多' : '呜呜,没有更多数据啦~' loadMoreText.value = hasNext.value ? '上拉加载更多' : '呜呜,没有更多数据啦~'
} }
catch (err) { catch (err) {
@@ -125,8 +125,9 @@ onReachBottom(() => {
@on-refresh="handleGetData" @on-refresh="handleGetData"
/> />
<template v-else> <template v-else>
<view v-if="loading !== 'success'" class="loading-wrap p-3"> <!-- 加载/错误占位 -->
<wd-skeleton :row="3" :animated="true" /> <view v-if="loading !== 'success'">
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
</view> </view>
<view v-else class="content flex flex-col gap-4 p-3"> <view v-else class="content flex flex-col gap-4 p-3">
@@ -136,8 +137,8 @@ onReachBottom(() => {
<block v-else> <block v-else>
<uh-vote-card <uh-vote-card
v-for="vote in dataList" v-for="vote in dataList"
:key="vote.name" :key="vote.metadata?.name"
:vote-name="vote.name" :vote-name="vote.metadata?.name || ''"
@on-vote-success="handleOnVoteSuccess" @on-vote-success="handleOnVoteSuccess"
/> />
<view class="load-text py-5 text-center text-[24rpx] text-gray-400"> <view class="load-text py-5 text-center text-[24rpx] text-gray-400">
+9 -24
View File
@@ -1,15 +1,16 @@
<script lang="ts" setup> <script lang="ts" setup>
/** /**
* 入口页(源自旧项目 pages/index/index.vue,新建复刻) * 入口页(源自旧项目 pages/index/index.vue,新建复刻)
* 职责:检查插件可用性 → 获取配置 → 二维码 scene 跳文章 → 审计模式 mock → 启动页/首页分流 * 职责:检查插件可用性 + 维护模式 → 重定向维护页 → 获取配置 → 二维码 scene 跳文章 →
* 审计模式 mock → 启动页/首页分流
* 拦截规则(2026-09-04):主插件未激活或维护模式开启(任一命中)均跳转维护页,
* 不再展示 uh-plugin-unavailable 组件;维护页按 from 参数区分原因展示默认/配置文案。
*/ */
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { getQRCodeInfo } from '@/api/uni-halo' import { getQRCodeInfo } from '@/api/uni-halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting' import { useSettingStore } from '@/store/setting'
import { collectSiteDefaults } from '@/utils/preference' import { collectSiteDefaults } from '@/utils/preference'
import { usePluginAvailable } from '@/utils/plugin'
definePage({ definePage({
// 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page // 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
@@ -33,16 +34,8 @@ const DEV_TO_PATH = `/pages-blog/test/test`
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const uniHaloPluginId = 'plugin-uni-halo' /** 维护拦截(插件可用性 + 维护模式检查与跳转封装,见 hooks/use-maintenance-intercept) */
const uniHaloPluginAvailableError = '阿偶,检测到当前插件没有安装或者启用,无法启动 uni-halo 哦,请联系管理员' const { interceptOrContinue } = useMaintenanceIntercept()
const uniHaloPluginAvailable = ref(true)
/* ---------------- 逻辑 ---------------- */
/** 检查插件可用性 */
async function handleCheckPluginAvailable(): Promise<boolean> {
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
return uniHaloPluginAvailable.value
}
/** 通过二维码 scene 获取文章 id */ /** 通过二维码 scene 获取文章 id */
async function getPostIdByQRCode(key: string): Promise<string | null> { async function getPostIdByQRCode(key: string): Promise<string | null> {
@@ -69,8 +62,8 @@ onLoad(async (options) => {
return return
} }
// 检查插件 // 拦截:主插件未激活 或 维护模式开启(任一命中)→ 跳转维护页
if (!(await handleCheckPluginAvailable())) if (await interceptOrContinue())
return return
// 获取配置(统一 bootstrap: getConfigs + audit-data + love-config 并行一次; // 获取配置(统一 bootstrap: getConfigs + audit-data + love-config 并行一次;
@@ -109,13 +102,5 @@ onLoad(async (options) => {
</script> </script>
<template> <template>
<view class="app-page h-screen w-screen flex items-center justify-center" style="background-color: #fff;"> <view class="app-page h-screen w-screen flex items-center justify-center" style="background-color: #fff;" />
<uh-plugin-unavailable
v-if="!uniHaloPluginAvailable"
:plugin-id="uniHaloPluginId"
:error-text="uniHaloPluginAvailableError"
:use-border="false"
:use-decoration="false"
/>
</view>
</template> </template>
+719 -71
View File
@@ -5,33 +5,55 @@
* 时插件下发,键缺失=未维护或已到点自动结束)。logo 复用 appConfig.appInfo.logo * 时插件下发,键缺失=未维护或已到点自动结束)。logo 复用 appConfig.appInfo.logo
* (相对路径经 checkUrl/BASE_API 补全)。双态:scheduled 维护预告(倒计时至 startTime) * (相对路径经 checkUrl/BASE_API 补全)。双态:scheduled 维护预告(倒计时至 startTime)
* / active 维护中(倒计时至 endTime);到点自动重拉判定(服务端状态切换/自动结束)。 * / active 维护中(倒计时至 endTime);到点自动重拉判定(服务端状态切换/自动结束)。
* 设计见插件 .docs/maintenance-config-design.md §8。 * 拦截入口(index/首页跳转,?from=plugin|maintenance):任一命中即展示维护页,
* 未配置维护信息时使用默认标题/说明(logo 缺失时展示工具图标),并定时轮询检测
* 恢复(插件重新激活 / 维护结束)后自动返回首页。视觉参考 .design/维护页面.html(UnoCSS+scss)。
*/ */
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app' import { onLoad, onUnload } from '@dcloudio/uni-app'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { checkUrl } from '@/utils/url' import { checkUrl } from '@/utils/url'
import { markdownConfig } from '@/config/markdown' import { markdownConfig } from '@/config/markdown'
import { usePluginAvailable } from '@/utils/plugin'
import type { IPublicMaintenance } from '@/api/types/uni-halo' import type { IPublicMaintenance } from '@/api/types/uni-halo'
definePage({ definePage({
style: { style: {
navigationBarTitleText: '站点维护', // 去掉原生导航栏:维护页全屏沉浸式(顶部由 hero 的 --status-bar-height 内边距适配)
navigationStyle: 'custom',
navigationBarTitleText: '维护中 · UniHalo',
}, },
}) })
type ViewState = 'loading' | 'normal' | 'error' | 'maintenance' type ViewState = 'loading' | 'normal' | 'error' | 'maintenance'
type FromReason = 'plugin' | 'maintenance'
const uniHaloPluginId = 'plugin-uni-halo'
/** 默认维护标题(拦截场景未配置维护信息时展示) */
const DEFAULT_MAINTENANCE_TITLE = '我们正在加油升级!'
/** 恢复检测轮询间隔(ms):插件激活/维护结束探测 */
const RECOVERY_POLL_INTERVAL = 30 * 1000
const store = useAppConfigStore() const store = useAppConfigStore()
const viewState = ref<ViewState>('loading') const viewState = ref<ViewState>('loading')
const maintenance = ref<IPublicMaintenance | null>(null) const maintenance = ref<IPublicMaintenance | null>(null)
/** 拦截来源(index/首页 ?from=plugin|maintenance);为空 = 手动进入(保留四态) */
const fromReason = ref<FromReason | null>(null)
const nowMs = ref(Date.now()) const nowMs = ref(Date.now())
/** 刷新按钮旋转中 */
const spinning = ref(false)
/** Toast 文案(空 = 隐藏) */
const toast = ref('')
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
let recoveryTimer: ReturnType<typeof setInterval> | null = null
let toastTimer: ReturnType<typeof setTimeout> | null = null
let refreshing = false let refreshing = false
const isIntercepted = computed(() => !!fromReason.value)
const isScheduled = computed(() => maintenance.value?.status === 'scheduled') const isScheduled = computed(() => maintenance.value?.status === 'scheduled')
const title = computed(() => maintenance.value?.title || (isScheduled.value ? '即将维护' : '站点维护中')) const title = computed(() => maintenance.value?.title || DEFAULT_MAINTENANCE_TITLE)
/** 配置的富文本说明(未配置时展示设计稿默认文案) */
const description = computed(() => maintenance.value?.description || '') const description = computed(() => maintenance.value?.description || '')
/** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */ /** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */
@@ -51,24 +73,36 @@ const countdownTarget = computed(() => {
return isScheduled.value ? info.startTime || '' : info.endTime || '' return isScheduled.value ? info.startTime || '' : info.endTime || ''
}) })
const countdownPrefix = computed(() => (isScheduled.value ? '距维护开始还有' : '预计恢复还有')) const countdownPrefix = computed(() => (isScheduled.value ? '距维护开始' : '预计恢复倒计时'))
const countdownText = computed(() => { /** 倒计时四格(天/时/分/秒),无目标或已归零为 null */
const countdownParts = computed(() => {
const target = countdownTarget.value
if (!target)
return null
const remaining = Math.max(0, Math.floor((new Date(target).getTime() - nowMs.value) / 1000))
if (remaining <= 0)
return null
const pad = (n: number) => String(n).padStart(2, '0')
return {
days: pad(Math.floor(remaining / 86400)),
hours: pad(Math.floor((remaining % 86400) / 3600)),
minutes: pad(Math.floor((remaining % 3600) / 60)),
seconds: pad(remaining % 60),
}
})
/** 倒计时备注(本地时间):scheduled → 开始时刻 / active → 预计完成时刻 */
const etaNote = computed(() => {
const target = countdownTarget.value const target = countdownTarget.value
if (!target) if (!target)
return '' return ''
const remaining = new Date(target).getTime() - nowMs.value const date = new Date(target)
if (!Number.isFinite(remaining) || remaining <= 0) if (Number.isNaN(date.getTime()))
return '' return ''
const total = Math.floor(remaining / 1000)
const days = Math.floor(total / 86400)
const hours = Math.floor((total % 86400) / 3600)
const minutes = Math.floor((total % 3600) / 60)
const seconds = total % 60
const pad = (n: number) => String(n).padStart(2, '0') const pad = (n: number) => String(n).padStart(2, '0')
return days > 0 const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`
? `${days}${pad(hours)}:${pad(minutes)}:${pad(seconds)}` return isScheduled.value ? `将于 ${hm} 开始维护` : `预计 ${hm} 前完成 · 实际进度可能提前哦~`
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
}) })
function startTimer() { function startTimer() {
@@ -83,7 +117,21 @@ function stopTimer() {
} }
} }
/** 每秒刷新倒计时;目标时刻已到 → 重拉一次(服务端可能已切换 scheduled→active 或自动结束) */ function startRecoveryCheck() {
stopRecoveryCheck()
recoveryTimer = setInterval(() => {
void silentCheck()
}, RECOVERY_POLL_INTERVAL)
}
function stopRecoveryCheck() {
if (recoveryTimer) {
clearInterval(recoveryTimer)
recoveryTimer = null
}
}
/** 每秒刷新倒计时;目标时刻已到 → 静默重拉(服务端可能已切换 scheduled→active 或自动结束) */
function tick() { function tick() {
nowMs.value = Date.now() nowMs.value = Date.now()
const target = countdownTarget.value const target = countdownTarget.value
@@ -91,7 +139,47 @@ function tick() {
return return
const remaining = new Date(target).getTime() - nowMs.value const remaining = new Date(target).getTime() - nowMs.value
if (Number.isFinite(remaining) && remaining <= 0) if (Number.isFinite(remaining) && remaining <= 0)
void load(true) void silentCheck()
}
/**
* 恢复检测(拦截场景):强制刷新配置;插件已激活且无维护信息 → 重定向入口页重新走拦截流程。
* 拉取失败(服务器停机)保持维护页不打扰。
*/
async function silentCheck() {
if (refreshing)
return
refreshing = true
try {
await store.bootstrap({ force: true })
if (fromReason.value === 'plugin') {
const available = await usePluginAvailable(uniHaloPluginId)
if (!available) {
const info = store.configs.maintenance
if (info) {
maintenance.value = info
viewState.value = 'maintenance'
startTimer()
}
return
}
}
const info = store.configs.maintenance
if (info) {
maintenance.value = info
viewState.value = 'maintenance'
startTimer()
return
}
// 恢复:插件可用且无维护信息(或维护已到点自动结束) → 重定向入口页重新检查
goIndex()
}
catch {
// 服务器停机/接口异常 → 维持维护页
}
finally {
refreshing = false
}
} }
async function load(force = false) { async function load(force = false) {
@@ -101,6 +189,7 @@ async function load(force = false) {
viewState.value = 'loading' viewState.value = 'loading'
maintenance.value = null maintenance.value = null
stopTimer() stopTimer()
stopRecoveryCheck()
try { try {
const { ok } = await store.bootstrap({ force }) const { ok } = await store.bootstrap({ force })
const info = store.configs.maintenance const info = store.configs.maintenance
@@ -108,6 +197,14 @@ async function load(force = false) {
maintenance.value = info maintenance.value = info
viewState.value = 'maintenance' viewState.value = 'maintenance'
startTimer() startTimer()
if (isIntercepted.value)
startRecoveryCheck()
}
else if (isIntercepted.value) {
// 拦截场景无维护信息(插件未激活/未配置)→ 默认文案
maintenance.value = null
viewState.value = 'maintenance'
startRecoveryCheck()
} }
else if (ok) { else if (ok) {
// 拉取成功但无 maintenance 键:未维护(或已到点自动结束)→ 服务正常 // 拉取成功但无 maintenance 键:未维护(或已到点自动结束)→ 服务正常
@@ -118,28 +215,67 @@ async function load(force = false) {
} }
} }
catch { catch {
viewState.value = 'error' if (isIntercepted.value) {
maintenance.value = null
viewState.value = 'maintenance'
startRecoveryCheck()
}
else {
viewState.value = 'error'
}
} }
finally { finally {
refreshing = false refreshing = false
} }
} }
function goHome() { /**
uni.switchTab({ url: '/pages/tabbar/home/home' }) * 检查通过 → 重定向入口页(index):由入口页重新执行「插件可用性 + 维护模式」拦截,
* 通过后再进入首页/文章详情等(保证任何时刻都从完整门禁通过)。
*/
function goIndex() {
stopTimer()
stopRecoveryCheck()
uni.reLaunch({ url: '/pages/index/index' })
} }
onLoad(() => { /** Toast 提示(1.8s 自动消失) */
function showToast(message: string) {
toast.value = message
if (toastTimer)
clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toast.value = ''
}, 1800)
}
/** 刷新看看:转圈 → 静默重查(恢复则回入口页),仍在维护则提示 */
async function handleRefresh() {
if (spinning.value)
return
spinning.value = true
await silentCheck()
spinning.value = false
if (viewState.value === 'maintenance')
showToast('站点仍在维护中,请稍后再试 🙏')
}
onLoad((options) => {
const from = options?.from
fromReason.value = from === 'plugin' || from === 'maintenance' ? from : null
void load() void load()
}) })
onUnload(() => { onUnload(() => {
stopTimer() stopTimer()
stopRecoveryCheck()
if (toastTimer)
clearTimeout(toastTimer)
}) })
</script> </script>
<template> <template>
<view class="maintenance-page min-h-screen bg-[#fafafa] pb-16"> <view class="maintenance-page">
<!-- 加载中 --> <!-- 加载中 -->
<view v-if="viewState === 'loading'" class="flex flex-col items-center justify-center py-48"> <view v-if="viewState === 'loading'" class="flex flex-col items-center justify-center py-48">
<text class="text-[26rpx] text-[#999]"> <text class="text-[26rpx] text-[#999]">
@@ -147,7 +283,7 @@ onUnload(() => {
</text> </text>
</view> </view>
<!-- 服务正常(未维护) --> <!-- 服务正常(未维护,手动进入时) -->
<view v-else-if="viewState === 'normal'" class="flex flex-col items-center justify-center px-10 py-48 text-center"> <view v-else-if="viewState === 'normal'" class="flex flex-col items-center justify-center px-10 py-48 text-center">
<text class="text-[64rpx]"> <text class="text-[64rpx]">
@@ -159,13 +295,13 @@ onUnload(() => {
如果仍然无法访问请稍后重试或联系站长 如果仍然无法访问请稍后重试或联系站长
</text> </text>
<view class="mt-10"> <view class="mt-10">
<wd-button type="primary" round @click="goHome"> <wd-button type="primary" round @click="goIndex">
返回首页 返回首页
</wd-button> </wd-button>
</view> </view>
</view> </view>
<!-- 拉取失败(通常为服务器停机中) --> <!-- 拉取失败(手动进入,通常为服务器停机中) -->
<view v-else-if="viewState === 'error'" class="flex flex-col items-center justify-center px-10 py-48 text-center"> <view v-else-if="viewState === 'error'" class="flex flex-col items-center justify-center px-10 py-48 text-center">
<text class="text-[64rpx]"> <text class="text-[64rpx]">
@@ -183,65 +319,577 @@ onUnload(() => {
</view> </view>
</view> </view>
<!-- 维护预告 / 维护 --> <!-- 维护(参考 .design/维护页面.html) -->
<view v-else class="flex flex-col items-center px-8 pt-24"> <view v-else class="app-container">
<image <!-- ===== Hero(无边界淡出) ===== -->
v-if="appLogo" <view class="hero">
class="h-[150rpx] w-[150rpx] border border-[#eee] rounded-full bg-white" <view class="blob breathe hero-blob-1 b-white" />
:src="appLogo" <view class="blob b-acc hero-blob-2" />
mode="aspectFill" <view class="blob hero-blob-3 b-white" />
/>
<view class="mt-8 flex items-center justify-center"> <view class="medal">
<view <view class="medal-bg" />
class="rounded-full px-5 py-1 text-[22rpx]" <image
:class="isScheduled ? 'bg-[#e8f1ff] text-[#1e6fff]' : 'bg-[#fdeef1] text-[#f83856]'" v-if="appLogo"
> class="medal-img"
{{ isScheduled ? '维护预告' : '维护中' }} :src="appLogo"
mode="aspectFill"
/>
<wd-icon v-else class="medal-emoji" name="tool" size="52px" />
<view class="gear gear-1">
<wd-icon name="settings" size="24px" />
</view>
<view class="gear gear-2">
<wd-icon name="settings" size="16px" />
</view>
<view class="sticker st-acc medal-st">
MAINTENANCE
</view>
</view>
<view class="hero-h1">
{{ title }}
</view>
<view class="doodle" />
<view class="hero-sub">
<mp-html
v-if="description"
:content="description"
lazy-load
:domain="markdownConfig.domain ?? ''"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
copy-by-long-press
/>
<template v-else>
<text>为了给你带来更好的体验站点正在维护升级中</text>
<text class="block">
别担心你的数据都安然无恙 💚
</text>
</template>
</view> </view>
</view> </view>
<view class="mt-6 text-center text-[40rpx] text-[#222] font-bold"> <!-- ===== 内容区 ===== -->
{{ title }} <view class="layer">
<view class="bridge bridge-1" />
<view class="bridge bridge-2" />
<view class="content">
<!-- 恢复倒计时 -->
<view v-if="countdownParts" class="eta-card">
<view class="eta-wm">
GO!
</view>
<view class="eta-k">
<wd-icon name="clock-circle" size="14px" />
{{ countdownPrefix }}
</view>
<view class="eta-clock">
<view class="eta-cell hot">
<text class="cell-num">{{ countdownParts.days }}</text>
<text class="cell-unit"> DAY</text>
</view>
<view class="eta-cell">
<text class="cell-num">{{ countdownParts.hours }}</text>
<text class="cell-unit"> HR</text>
</view>
<view class="eta-cell">
<text class="cell-num">{{ countdownParts.minutes }}</text>
<text class="cell-unit"> MIN</text>
</view>
<view class="eta-cell">
<text class="cell-num">{{ countdownParts.seconds }}</text>
<text class="cell-unit"> SEC</text>
</view>
</view>
<view v-if="etaNote" class="eta-note">
{{ etaNote }}
</view>
</view>
<!-- 操作 -->
<view class="cta-row">
<view class="btn-refresh ink-btn press" @click="handleRefresh">
<wd-icon class="refresh-ic" :class="{ spinning }" name="refresh" size="17px" />
<text>刷新看看</text>
</view>
</view>
<!-- 页脚 -->
<view class="foot">
<view class="sticker st-acc rot-l">
<wd-icon name="face-smile-fill" size="22px" />
</view>
<view class="foot-text">
升级期间给你带来不便非常抱歉
<text class="block">
去喝杯奶茶等等吧
</text>
</view>
</view>
</view>
</view> </view>
<view v-if="countdownText" class="mt-8 flex flex-col items-center"> <!-- Toast -->
<text class="text-[24rpx] text-[#999]"> <view v-if="toast" class="toast">
{{ countdownPrefix }} {{ toast }}
</text>
<text
class="mt-2 text-[44rpx] font-bold tabular-nums"
:class="isScheduled ? 'text-[#1e6fff]' : 'text-[#f83856]'"
>
{{ countdownText }}
</text>
</view>
<view v-if="description" class="mt-10 w-full rounded-2xl bg-white p-6">
<mp-html
:content="description"
lazy-load
:domain="markdownConfig.domain ?? ''"
scroll-table
selectable
:tag-style="markdownConfig.tagStyle"
:container-style="markdownConfig.containStyle"
copy-by-long-press
/>
</view>
<view v-if="!description && !countdownText" class="mt-10 text-center text-[24rpx] text-[#999]">
请耐心等待维护完成后将自动恢复访问
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
/* 设计令牌(.design/维护页面.html 对齐,px → rpx ×2) */
$ink: #17181a;
$ink2: #8a9099;
$ink3: #c9cdd4;
$sun: #ffd53d;
$acc: #b8ec3f;
$acc-deep: #a7e93b;
$acc-soft: #ebfabf;
$acc-pale: #f4fbe0;
$acc-ink: #63a002;
$hero: linear-gradient(175deg, #d9f77f 0%, #e8fbaf 52%, #f5fae8 100%);
$page: #f7f9f1;
$glow: rgba(168, 232, 52, 0.5);
.maintenance-page { .maintenance-page {
:deep(img) { min-height: 100vh;
max-width: 100%; background: #e9edf1;
border-radius: 8rpx; }
/* ========== 应用容器 ========== */
.app-container {
max-width: 960rpx;
margin: 0 auto;
min-height: 100vh;
display: flex;
flex-direction: column;
background: $page;
box-shadow: 0 0 60rpx rgba(50, 60, 25, 0.12);
}
/* ========== Hero ========== */
.hero {
position: relative;
overflow: hidden;
flex-shrink: 0;
padding: calc(var(--status-bar-height) + 76rpx) 40rpx 64rpx;
background: $hero;
text-align: center;
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(52rpx);
pointer-events: none;
z-index: 0;
}
.b-white {
background: rgba(255, 255, 255, 0.4);
}
.b-acc {
background: $acc;
opacity: 0.28;
}
.breathe {
animation: breathe 5s ease-in-out infinite;
}
@keyframes breathe {
0%,
100% {
transform: scale(1);
opacity: 0.28;
}
50% {
transform: scale(1.18);
opacity: 0.4;
} }
} }
.hero-blob-1 {
left: -60rpx;
top: 104rpx;
width: 260rpx;
height: 260rpx;
}
.hero-blob-2 {
right: -48rpx;
top: 40rpx;
width: 200rpx;
height: 200rpx;
}
.hero-blob-3 {
right: 72rpx;
bottom: -40rpx;
width: 160rpx;
height: 160rpx;
}
/* 勋章 */
.medal {
position: relative;
width: 236rpx;
height: 236rpx;
margin: 44rpx auto 0;
z-index: 2;
}
.medal-bg {
position: absolute;
inset: 0;
border-radius: 50%;
background: linear-gradient(135deg, $acc-soft, $acc);
box-shadow:
0 0 0 12rpx #fff,
0 28rpx 60rpx rgba(98, 124, 44, 0.22);
animation: bob 3.2s ease-in-out infinite;
}
.medal-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border-radius: 50%;
z-index: 1;
}
.medal-emoji {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: $ink;
}
@keyframes bob {
0%,
100% {
transform: translateY(0) rotate(-2deg);
}
50% {
transform: translateY(-14rpx) rotate(2deg);
}
}
.gear {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
animation: spin 6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.gear-1 {
width: 68rpx;
height: 68rpx;
right: -28rpx;
top: -16rpx;
}
.gear-2 {
width: 48rpx;
height: 48rpx;
left: -32rpx;
bottom: 16rpx;
animation-duration: 4.5s;
animation-direction: reverse;
}
.sticker {
display: inline-flex;
align-items: center;
background: #fff;
border: 4rpx solid #fff;
border-radius: 999rpx;
box-shadow: 0 10rpx 28rpx rgba(90, 110, 45, 0.18);
font-weight: 800;
line-height: 1;
white-space: nowrap;
position: relative;
z-index: 2;
}
.st-acc {
background: $acc-soft;
border-color: $acc-soft;
}
.medal-st {
position: absolute;
left: 50%;
transform: translateX(-50%) rotate(-5deg);
bottom: -28rpx;
font-size: 22rpx;
padding: 12rpx 24rpx;
}
.rot-l {
transform: rotate(-7deg);
}
/* 标题与副标题 */
.hero-h1 {
margin-top: 64rpx;
font-size: 54rpx;
line-height: 64rpx;
font-weight: 900;
position: relative;
z-index: 2;
}
.doodle {
width: 224rpx;
height: 8rpx;
margin: 8rpx auto 0;
border-radius: 999rpx;
background: linear-gradient(90deg, transparent, $acc-deep, transparent);
position: relative;
z-index: 2;
}
.hero-sub {
margin-top: 20rpx;
font-size: 25rpx;
font-weight: 500;
color: rgba(23, 24, 26, 0.55);
position: relative;
z-index: 2;
line-height: 1.7;
}
/* ========== 内容区 ========== */
.layer {
position: relative;
flex: 1;
}
.layer > :not(.bridge) {
position: relative;
z-index: 1;
}
.bridge {
position: absolute;
border-radius: 999rpx;
filter: blur(44rpx);
pointer-events: none;
z-index: 0;
}
.bridge-1 {
top: -52rpx;
left: 64rpx;
width: 192rpx;
height: 192rpx;
background: $acc-soft;
opacity: 0.9;
}
.bridge-2 {
top: 300rpx;
right: -52rpx;
width: 168rpx;
height: 168rpx;
background: $sun;
opacity: 0.2;
}
.content {
padding: 32rpx 32rpx 68rpx;
display: flex;
flex-direction: column;
gap: 28rpx;
align-items: center;
}
/* 倒计时卡 */
.eta-card {
border-radius: 36rpx;
padding: 36rpx 32rpx;
text-align: center;
position: relative;
overflow: hidden;
width: 100%;
background: #fff;
border: 1rpx solid rgba(112, 138, 42, 0.08);
box-shadow: 0 4rpx 24rpx rgba(98, 124, 44, 0.08);
}
.eta-wm {
position: absolute;
right: -12rpx;
bottom: -48rpx;
font-size: 176rpx;
font-weight: 900;
color: $acc-deep;
opacity: 0.1;
line-height: 1;
pointer-events: none;
}
.eta-k {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
font-size: 20rpx;
font-weight: 800;
letter-spacing: 2px;
color: $ink2;
}
.eta-clock {
margin-top: 22rpx;
display: flex;
justify-content: center;
gap: 10rpx;
}
.eta-cell {
min-width: 118rpx;
padding: 18rpx 8rpx 16rpx;
border-radius: 24rpx;
background: $acc-pale;
}
.cell-num {
display: block;
font-size: 42rpx;
font-weight: 900;
line-height: 1;
color: $ink;
font-variant-numeric: tabular-nums;
}
.eta-cell.hot .cell-num {
color: $acc-ink;
}
.cell-unit {
display: block;
margin-top: 10rpx;
font-size: 18rpx;
font-weight: 800;
color: $ink2;
letter-spacing: 1px;
}
.eta-note {
margin-top: 24rpx;
font-size: 21rpx;
color: $ink3;
font-weight: 500;
}
/* 操作区 */
.cta-row {
display: flex;
gap: 20rpx;
width: 100%;
margin-top: 4rpx;
}
.btn-refresh {
flex: 1;
height: 100rpx;
border-radius: 999rpx;
font-size: 28rpx;
font-weight: 800;
display: flex;
align-items: center;
justify-content: center;
gap: 16rpx;
}
.ink-btn {
color: #fff;
background: linear-gradient(150deg, #3a3e44 0%, #1d1f22 55%, #151619 100%);
box-shadow:
0 10rpx 28rpx -10rpx rgba(21, 23, 25, 0.4),
0 12rpx 36rpx -10rpx $glow,
inset 0 2rpx 0 rgba(255, 255, 255, 0.16);
}
.refresh-ic {
display: inline-block;
}
.refresh-ic.spinning {
animation: spin 0.7s linear;
}
.press {
transition: transform 0.12s ease;
}
.press:active {
transform: scale(0.94);
}
/* 页脚 */
.foot {
margin-top: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.foot .sticker {
font-size: 44rpx;
padding: 14rpx 22rpx;
}
.foot-text {
margin-top: 18rpx;
font-size: 21rpx;
color: $ink3;
font-weight: 500;
text-align: center;
line-height: 1.7;
}
/* Toast */
.toast {
position: fixed;
left: 50%;
bottom: calc(72rpx + env(safe-area-inset-bottom));
transform: translateX(-50%);
padding: 18rpx 36rpx;
border-radius: 999rpx;
font-size: 24rpx;
font-weight: 700;
color: #fff;
background: linear-gradient(150deg, #3a3e44 0%, #1d1f22 55%, #151619 100%);
box-shadow:
0 10rpx 28rpx -10rpx rgba(21, 23, 25, 0.4),
0 12rpx 36rpx -10rpx $glow,
inset 0 2rpx 0 rgba(255, 255, 255, 0.16);
white-space: nowrap;
z-index: 60;
}
:deep(img) {
max-width: 100%;
border-radius: 8rpx;
}
</style> </style>
+13 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app' import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
import { getPostList } from '@/api/halo' import { getPostList } from '@/api/halo'
import { useAppConfigStore } from '@/store/appConfig' import { useAppConfigStore } from '@/store/appConfig'
import { useSettingStore } from '@/store/setting' import { useSettingStore } from '@/store/setting'
@@ -19,6 +19,11 @@ definePage({
const appConfigStore = useAppConfigStore() const appConfigStore = useAppConfigStore()
const settingStore = useSettingStore() const settingStore = useSettingStore()
/** 维护拦截(插件可用性 + 维护模式,任一命中跳维护页;与入口页共用 hooks) */
const { interceptOrContinue } = useMaintenanceIntercept()
/** 是否已被拦截(配置已带维护键时同步置位,避免首载闪跳) */
const intercepted = ref(!!appConfigStore.configs.maintenance)
const haloConfigs = computed(() => appConfigStore.configs) const haloConfigs = computed(() => appConfigStore.configs)
/* ---------------- 状态 ---------------- */ /* ---------------- 状态 ---------------- */
@@ -144,6 +149,11 @@ function handleToTopPage(duration = 500) {
/* ---------------- 生命周期 ---------------- */ /* ---------------- 生命周期 ---------------- */
// 拦截:维护模式开启 / 主插件未激活(任一命中)→ 跳转维护页(tab 切回时重复检查)
onShow(async () => {
intercepted.value = await interceptOrContinue()
})
onPullDownRefresh(() => { onPullDownRefresh(() => {
isLoadMore.value = false isLoadMore.value = false
queryParams.value.page = 1 queryParams.value.page = 1
@@ -167,6 +177,8 @@ onReachBottom(() => {
// 首次加载 // 首次加载
onMounted(() => { onMounted(() => {
if (intercepted.value)
return
handleQuery() handleQuery()
}) })
</script> </script>