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:
+60
-20
@@ -230,27 +230,67 @@ export interface IVoteListReq {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface IVoteItem {
|
||||
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
|
||||
}
|
||||
|
||||
/** 投票选项(插件 VoteSpec.options:{id,title}) */
|
||||
export interface IVoteOption {
|
||||
name?: string
|
||||
label?: string
|
||||
count?: number
|
||||
id?: string
|
||||
title?: string
|
||||
[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
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -42,7 +42,7 @@ import type {
|
||||
IRestrictReadCheckRes,
|
||||
ISubmitLinkForm,
|
||||
IUpdateCheckRes,
|
||||
IVote,
|
||||
IVoteDetail,
|
||||
IVoteListReq,
|
||||
IVoteListRes,
|
||||
IVoteSubmitReq,
|
||||
@@ -381,7 +381,7 @@ export function getVoteList(params: IVoteListReq) {
|
||||
* 投票详情
|
||||
*/
|
||||
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 },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 投票卡片(源自旧项目 components/vote-card,新建复刻)
|
||||
* 适配 plugin-vote 真实结构:详情接口返回 VoteDetail(嵌套 vote),选项为 {id,title}
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getVoteDetail } from '@/api/uni-halo'
|
||||
import { calcVoteState, VOTE_STATES, VOTE_TYPES } from '@/utils/vote'
|
||||
import type { IVote, IVoteOption } from '@/api/types/uni-halo'
|
||||
import { VOTE_STATES } from '@/utils/vote'
|
||||
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
|
||||
|
||||
const props = defineProps<{
|
||||
voteName: string
|
||||
@@ -20,8 +21,27 @@ const isSubmit = ref(false)
|
||||
const voteData = ref<IVote | null>(null)
|
||||
const voteTypes = ref<string[]>([])
|
||||
const canAnonymously = ref(true)
|
||||
/** 选项 id → 票数(来自 VoteDetail.voteDataList 或 Vote.stats.voteDataList) */
|
||||
const voteCountMap = ref<Record<string, number>>({})
|
||||
|
||||
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(() => {
|
||||
if (voteState.value === VOTE_STATES.VOTE_ENDED)
|
||||
@@ -35,13 +55,34 @@ const voteLabel = computed(() => {
|
||||
|
||||
const voteResultLabel = computed(() => (voteState.value === VOTE_STATES.VOTED ? '查看结果' : ''))
|
||||
|
||||
const isSingle = computed(() => voteData.value?.spec?.type === 'single')
|
||||
|
||||
/** 选项票数占比(取自 voteCountMap) */
|
||||
function handleCalcPercent(option: IVoteOption): number {
|
||||
const total = voteData.value?.stats?.voteCount || 0
|
||||
const count = voteCountMap.value[option.id || ''] || 0
|
||||
if (total === 0)
|
||||
return 0
|
||||
return Number(((count / total) * 100).toFixed(2))
|
||||
}
|
||||
|
||||
async function handleGetData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVoteDetail(props.voteName)
|
||||
voteData.value = res.data
|
||||
// 已投票项从缓存恢复(简化:根据 options 计数判断)
|
||||
const detail = res.data as IVoteDetail
|
||||
const vote = detail.vote || (detail as unknown as IVote)
|
||||
voteData.value = vote
|
||||
canAnonymously.value = !!vote.spec?.canAnonymously
|
||||
voteTypes.value = []
|
||||
// 票数映射:详情 voteDataList 优先,其次 stats.voteDataList
|
||||
const countList = detail.voteDataList?.length ? detail.voteDataList : vote.stats?.voteDataList
|
||||
const map: Record<string, number> = {}
|
||||
;(countList || []).forEach((item) => {
|
||||
if (item.id)
|
||||
map[item.id] = item.voteCount || 0
|
||||
})
|
||||
voteCountMap.value = map
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取投票失败', err)
|
||||
@@ -54,8 +95,8 @@ async function handleGetData() {
|
||||
function handleSelectOption(option: IVoteOption) {
|
||||
if (voteState.value !== VOTE_STATES.VOTING)
|
||||
return
|
||||
const optionName = option.name || ''
|
||||
if (voteData.value && (voteData.value as { type?: string }).type === VOTE_TYPES.SINGLE) {
|
||||
const optionName = option.id || ''
|
||||
if (isSingle.value) {
|
||||
voteTypes.value = [optionName]
|
||||
}
|
||||
else {
|
||||
@@ -98,25 +139,25 @@ defineExpose({ refresh: handleGetData })
|
||||
|
||||
<view v-else-if="voteData" class="vote-body">
|
||||
<view class="vote-title text-[30rpx] text-gray-900 font-bold">
|
||||
{{ voteData.title }}
|
||||
{{ voteData.spec?.title }}
|
||||
</view>
|
||||
<view class="vote-desc mt-1 text-[24rpx] text-gray-400">
|
||||
{{ voteData.description }}
|
||||
<view v-if="voteData.spec?.remark" class="vote-desc mt-1 text-[24rpx] text-gray-400">
|
||||
{{ voteData.spec.remark }}
|
||||
</view>
|
||||
|
||||
<view class="options mt-5">
|
||||
<view
|
||||
v-for="option in voteData.options"
|
||||
:key="option.name"
|
||||
v-for="option in voteData.spec?.options || []"
|
||||
:key="option.id"
|
||||
class="option mb-4 flex flex-col border-2 rounded-xl p-5"
|
||||
:class="voteTypes.includes(option.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)"
|
||||
>
|
||||
<view class="option-label text-[28rpx] text-gray-700">
|
||||
<text>{{ option.label }}</text>
|
||||
<text>{{ option.title }}</text>
|
||||
</view>
|
||||
<view v-if="voteState === VOTE_STATES.VOTED" class="option-bar mt-3 h-4 overflow-hidden rounded-lg bg-black/5">
|
||||
<view class="option-bar-inner h-full rounded-lg" :style="{ width: `${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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -258,9 +258,9 @@ onReachBottom(() => {
|
||||
</wd-tabs>
|
||||
</view>
|
||||
|
||||
<!-- 骨架屏 -->
|
||||
<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>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
|
||||
@@ -102,15 +102,9 @@ function handleOpenLink() {
|
||||
|
||||
<template>
|
||||
<view class="app-page">
|
||||
<!-- 加载骨架 -->
|
||||
<view v-if="loading === 'loading'" class="p-4">
|
||||
<wd-skeleton :row="3" :animated="true" />
|
||||
</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 v-if="loading !== 'success'">
|
||||
<uh-data-loading :loading-status="loading" @refresh="handleRetry" />
|
||||
</view>
|
||||
|
||||
<!-- 详情内容 -->
|
||||
@@ -120,7 +114,7 @@ function handleOpenLink() {
|
||||
|
||||
<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 }}
|
||||
</view>
|
||||
|
||||
@@ -143,8 +137,10 @@ function handleOpenLink() {
|
||||
|
||||
<!-- 外链(平台差异,条件编译) -->
|
||||
<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>
|
||||
<text class="link-text block break-all text-[26rpx] leading-relaxed text-[#606266]">
|
||||
<view class="mb-3 text-[24rpx] text-[#909399]">
|
||||
相关链接
|
||||
</view>
|
||||
<text class="link-text block break-all text-[26rpx] text-[#606266] leading-relaxed">
|
||||
{{ detail.link }}
|
||||
</text>
|
||||
<!-- #ifndef APP-PLUS -->
|
||||
|
||||
@@ -107,8 +107,9 @@ onShareTimeline(() => ({
|
||||
|
||||
<template>
|
||||
<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>
|
||||
|
||||
<block v-else>
|
||||
|
||||
@@ -167,12 +167,9 @@ init()
|
||||
@on-refresh="handleGetData"
|
||||
/>
|
||||
<template v-else>
|
||||
<!-- 加载/错误 -->
|
||||
<view v-if="loading === 'loading'" class="loading-wrap p-3">
|
||||
<wd-skeleton :row="4" :animated="true" />
|
||||
</view>
|
||||
<view v-else-if="loading === 'error'" class="h-[60vh] flex items-center justify-center content-empty">
|
||||
<wd-empty description="加载异常" />
|
||||
<!-- 加载/错误占位 -->
|
||||
<view v-if="loading !== 'success'">
|
||||
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
|
||||
</view>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
|
||||
@@ -338,8 +338,9 @@ onReachBottom(() => {
|
||||
@on-refresh="handleGetLinkGroupData"
|
||||
/>
|
||||
<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 v-else class="content pt-4">
|
||||
@@ -435,8 +436,9 @@ onReachBottom(() => {
|
||||
@on-refresh="handleGetMiniProgramLinks"
|
||||
/>
|
||||
<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 v-else class="content flex flex-1 flex-col">
|
||||
|
||||
@@ -97,8 +97,9 @@ onReachBottom(() => {
|
||||
|
||||
<template>
|
||||
<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>
|
||||
|
||||
<block v-else>
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
*/
|
||||
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'
|
||||
import type { IVote, IVoteDetail, IVoteOption } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
@@ -81,13 +80,15 @@ async function handleGetData() {
|
||||
pageTitle.value = '加载中...'
|
||||
try {
|
||||
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) {
|
||||
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.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
|
||||
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()
|
||||
@@ -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) => {
|
||||
const checked = handleCalcIsChecked(option)
|
||||
return {
|
||||
const optionWithCount = {
|
||||
...option,
|
||||
value: option.id,
|
||||
label: option.title,
|
||||
count: countMap[option.id || ''] || 0,
|
||||
isVoted: isVoted.value,
|
||||
checked,
|
||||
disabled: isVoted.value,
|
||||
_uh_percent: calcVotePercent(tempVote, option),
|
||||
}
|
||||
return {
|
||||
...optionWithCount,
|
||||
_uh_percent: calcVotePercent(tempVote, optionWithCount),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -258,8 +270,9 @@ onShareTimeline(() => ({
|
||||
|
||||
<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 v-if="loading !== 'success'">
|
||||
<uh-data-loading :loading-status="loading" @refresh="handleGetData" />
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
|
||||
@@ -49,10 +49,10 @@ async function handleGetData() {
|
||||
try {
|
||||
const res = await getVoteList({ ...queryParams.value })
|
||||
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.concat(res.data as IVoteItem[])
|
||||
: (res.data as IVoteItem[])
|
||||
? dataList.value.concat(res.data.items)
|
||||
: res.data.items
|
||||
loadMoreText.value = hasNext.value ? '上拉加载更多' : '呜呜,没有更多数据啦~'
|
||||
}
|
||||
catch (err) {
|
||||
@@ -125,8 +125,9 @@ onReachBottom(() => {
|
||||
@on-refresh="handleGetData"
|
||||
/>
|
||||
<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 v-else class="content flex flex-col gap-4 p-3">
|
||||
@@ -136,8 +137,8 @@ onReachBottom(() => {
|
||||
<block v-else>
|
||||
<uh-vote-card
|
||||
v-for="vote in dataList"
|
||||
:key="vote.name"
|
||||
:vote-name="vote.name"
|
||||
:key="vote.metadata?.name"
|
||||
:vote-name="vote.metadata?.name || ''"
|
||||
@on-vote-success="handleOnVoteSuccess"
|
||||
/>
|
||||
<view class="load-text py-5 text-center text-[24rpx] text-gray-400">
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 入口页(源自旧项目 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 { getQRCodeInfo } from '@/api/uni-halo'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useSettingStore } from '@/store/setting'
|
||||
import { collectSiteDefaults } from '@/utils/preference'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
|
||||
definePage({
|
||||
// 使用 type: "home" 属性设置首页,其他页面不需要设置,默认为page
|
||||
@@ -33,16 +34,8 @@ const DEV_TO_PATH = `/pages-blog/test/test`
|
||||
/* ---------------- 状态 ---------------- */
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const settingStore = useSettingStore()
|
||||
const uniHaloPluginId = 'plugin-uni-halo'
|
||||
const uniHaloPluginAvailableError = '阿偶,检测到当前插件没有安装或者启用,无法启动 uni-halo 哦,请联系管理员'
|
||||
const uniHaloPluginAvailable = ref(true)
|
||||
|
||||
/* ---------------- 逻辑 ---------------- */
|
||||
/** 检查插件可用性 */
|
||||
async function handleCheckPluginAvailable(): Promise<boolean> {
|
||||
uniHaloPluginAvailable.value = await usePluginAvailable(uniHaloPluginId)
|
||||
return uniHaloPluginAvailable.value
|
||||
}
|
||||
/** 维护拦截(插件可用性 + 维护模式检查与跳转封装,见 hooks/use-maintenance-intercept) */
|
||||
const { interceptOrContinue } = useMaintenanceIntercept()
|
||||
|
||||
/** 通过二维码 scene 获取文章 id */
|
||||
async function getPostIdByQRCode(key: string): Promise<string | null> {
|
||||
@@ -69,8 +62,8 @@ onLoad(async (options) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查插件
|
||||
if (!(await handleCheckPluginAvailable()))
|
||||
// 拦截:主插件未激活 或 维护模式开启(任一命中)→ 跳转维护页
|
||||
if (await interceptOrContinue())
|
||||
return
|
||||
|
||||
// 获取配置(统一 bootstrap: getConfigs + audit-data + love-config 并行一次;
|
||||
@@ -109,13 +102,5 @@ onLoad(async (options) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<view class="app-page h-screen w-screen flex items-center justify-center" style="background-color: #fff;" />
|
||||
</template>
|
||||
|
||||
@@ -5,33 +5,55 @@
|
||||
* 时插件下发,键缺失=未维护或已到点自动结束)。logo 复用 appConfig.appInfo.logo
|
||||
* (相对路径经 checkUrl/BASE_API 补全)。双态:scheduled 维护预告(倒计时至 startTime)
|
||||
* / active 维护中(倒计时至 endTime);到点自动重拉判定(服务端状态切换/自动结束)。
|
||||
* 设计见插件 .docs/maintenance-config-design.md §8。
|
||||
* 拦截入口(index/首页跳转,?from=plugin|maintenance):任一命中即展示维护页,
|
||||
* 未配置维护信息时使用默认标题/说明(logo 缺失时展示工具图标),并定时轮询检测
|
||||
* 恢复(插件重新激活 / 维护结束)后自动返回首页。视觉参考 .design/维护页面.html(UnoCSS+scss)。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||||
import { useAppConfigStore } from '@/store/appConfig'
|
||||
import { checkUrl } from '@/utils/url'
|
||||
import { markdownConfig } from '@/config/markdown'
|
||||
import { usePluginAvailable } from '@/utils/plugin'
|
||||
import type { IPublicMaintenance } from '@/api/types/uni-halo'
|
||||
|
||||
definePage({
|
||||
style: {
|
||||
navigationBarTitleText: '站点维护',
|
||||
// 去掉原生导航栏:维护页全屏沉浸式(顶部由 hero 的 --status-bar-height 内边距适配)
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '维护中 · UniHalo',
|
||||
},
|
||||
})
|
||||
|
||||
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 viewState = ref<ViewState>('loading')
|
||||
const maintenance = ref<IPublicMaintenance | null>(null)
|
||||
/** 拦截来源(index/首页 ?from=plugin|maintenance);为空 = 手动进入(保留四态) */
|
||||
const fromReason = ref<FromReason | null>(null)
|
||||
const nowMs = ref(Date.now())
|
||||
/** 刷新按钮旋转中 */
|
||||
const spinning = ref(false)
|
||||
/** Toast 文案(空 = 隐藏) */
|
||||
const toast = ref('')
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
let recoveryTimer: ReturnType<typeof setInterval> | null = null
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let refreshing = false
|
||||
|
||||
const isIntercepted = computed(() => !!fromReason.value)
|
||||
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 || '')
|
||||
|
||||
/** 应用信息 logo(相对插件内置资源路径 → BASE_API 补全) */
|
||||
@@ -51,24 +73,36 @@ const countdownTarget = computed(() => {
|
||||
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
|
||||
if (!target)
|
||||
return ''
|
||||
const remaining = new Date(target).getTime() - nowMs.value
|
||||
if (!Number.isFinite(remaining) || remaining <= 0)
|
||||
const date = new Date(target)
|
||||
if (Number.isNaN(date.getTime()))
|
||||
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')
|
||||
return days > 0
|
||||
? `${days}天${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||
const hm = `${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
return isScheduled.value ? `将于 ${hm} 开始维护` : `预计 ${hm} 前完成 · 实际进度可能提前哦~`
|
||||
})
|
||||
|
||||
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() {
|
||||
nowMs.value = Date.now()
|
||||
const target = countdownTarget.value
|
||||
@@ -91,7 +139,47 @@ function tick() {
|
||||
return
|
||||
const remaining = new Date(target).getTime() - nowMs.value
|
||||
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) {
|
||||
@@ -101,6 +189,7 @@ async function load(force = false) {
|
||||
viewState.value = 'loading'
|
||||
maintenance.value = null
|
||||
stopTimer()
|
||||
stopRecoveryCheck()
|
||||
try {
|
||||
const { ok } = await store.bootstrap({ force })
|
||||
const info = store.configs.maintenance
|
||||
@@ -108,6 +197,14 @@ async function load(force = false) {
|
||||
maintenance.value = info
|
||||
viewState.value = 'maintenance'
|
||||
startTimer()
|
||||
if (isIntercepted.value)
|
||||
startRecoveryCheck()
|
||||
}
|
||||
else if (isIntercepted.value) {
|
||||
// 拦截场景无维护信息(插件未激活/未配置)→ 默认文案
|
||||
maintenance.value = null
|
||||
viewState.value = 'maintenance'
|
||||
startRecoveryCheck()
|
||||
}
|
||||
else if (ok) {
|
||||
// 拉取成功但无 maintenance 键:未维护(或已到点自动结束)→ 服务正常
|
||||
@@ -118,28 +215,67 @@ async function load(force = false) {
|
||||
}
|
||||
}
|
||||
catch {
|
||||
viewState.value = 'error'
|
||||
if (isIntercepted.value) {
|
||||
maintenance.value = null
|
||||
viewState.value = 'maintenance'
|
||||
startRecoveryCheck()
|
||||
}
|
||||
else {
|
||||
viewState.value = 'error'
|
||||
}
|
||||
}
|
||||
finally {
|
||||
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()
|
||||
})
|
||||
|
||||
onUnload(() => {
|
||||
stopTimer()
|
||||
stopRecoveryCheck()
|
||||
if (toastTimer)
|
||||
clearTimeout(toastTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<text class="text-[26rpx] text-[#999]">
|
||||
@@ -147,7 +283,7 @@ onUnload(() => {
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- 服务正常(未维护) -->
|
||||
<!-- 服务正常(未维护,手动进入时) -->
|
||||
<view v-else-if="viewState === 'normal'" class="flex flex-col items-center justify-center px-10 py-48 text-center">
|
||||
<text class="text-[64rpx]">
|
||||
✅
|
||||
@@ -159,13 +295,13 @@ onUnload(() => {
|
||||
如果仍然无法访问,请稍后重试或联系站长
|
||||
</text>
|
||||
<view class="mt-10">
|
||||
<wd-button type="primary" round @click="goHome">
|
||||
<wd-button type="primary" round @click="goIndex">
|
||||
返回首页
|
||||
</wd-button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 拉取失败(通常为服务器停机中) -->
|
||||
<!-- 拉取失败(手动进入,通常为服务器停机中) -->
|
||||
<view v-else-if="viewState === 'error'" class="flex flex-col items-center justify-center px-10 py-48 text-center">
|
||||
<text class="text-[64rpx]">
|
||||
⚠️
|
||||
@@ -183,65 +319,577 @@ onUnload(() => {
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 维护预告 / 维护中 -->
|
||||
<view v-else class="flex flex-col items-center px-8 pt-24">
|
||||
<image
|
||||
v-if="appLogo"
|
||||
class="h-[150rpx] w-[150rpx] border border-[#eee] rounded-full bg-white"
|
||||
:src="appLogo"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<!-- 维护页(参考 .design/维护页面.html) -->
|
||||
<view v-else class="app-container">
|
||||
<!-- ===== Hero(无边界淡出) ===== -->
|
||||
<view class="hero">
|
||||
<view class="blob breathe hero-blob-1 b-white" />
|
||||
<view class="blob b-acc hero-blob-2" />
|
||||
<view class="blob hero-blob-3 b-white" />
|
||||
|
||||
<view class="mt-8 flex items-center justify-center">
|
||||
<view
|
||||
class="rounded-full px-5 py-1 text-[22rpx]"
|
||||
:class="isScheduled ? 'bg-[#e8f1ff] text-[#1e6fff]' : 'bg-[#fdeef1] text-[#f83856]'"
|
||||
>
|
||||
{{ isScheduled ? '维护预告' : '维护中' }}
|
||||
<view class="medal">
|
||||
<view class="medal-bg" />
|
||||
<image
|
||||
v-if="appLogo"
|
||||
class="medal-img"
|
||||
: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 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 v-if="countdownText" class="mt-8 flex flex-col items-center">
|
||||
<text class="text-[24rpx] text-[#999]">
|
||||
{{ countdownPrefix }}
|
||||
</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]">
|
||||
请耐心等待,维护完成后将自动恢复访问
|
||||
<!-- Toast -->
|
||||
<view v-if="toast" class="toast">
|
||||
{{ toast }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 8rpx;
|
||||
min-height: 100vh;
|
||||
background: #e9edf1;
|
||||
}
|
||||
|
||||
/* ========== 应用容器 ========== */
|
||||
.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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
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 { useAppConfigStore } from '@/store/appConfig'
|
||||
import { useSettingStore } from '@/store/setting'
|
||||
@@ -19,6 +19,11 @@ definePage({
|
||||
const appConfigStore = useAppConfigStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
/** 维护拦截(插件可用性 + 维护模式,任一命中跳维护页;与入口页共用 hooks) */
|
||||
const { interceptOrContinue } = useMaintenanceIntercept()
|
||||
/** 是否已被拦截(配置已带维护键时同步置位,避免首载闪跳) */
|
||||
const intercepted = ref(!!appConfigStore.configs.maintenance)
|
||||
|
||||
const haloConfigs = computed(() => appConfigStore.configs)
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
@@ -144,6 +149,11 @@ function handleToTopPage(duration = 500) {
|
||||
|
||||
/* ---------------- 生命周期 ---------------- */
|
||||
|
||||
// 拦截:维护模式开启 / 主插件未激活(任一命中)→ 跳转维护页(tab 切回时重复检查)
|
||||
onShow(async () => {
|
||||
intercepted.value = await interceptOrContinue()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
isLoadMore.value = false
|
||||
queryParams.value.page = 1
|
||||
@@ -167,6 +177,8 @@ onReachBottom(() => {
|
||||
|
||||
// 首次加载
|
||||
onMounted(() => {
|
||||
if (intercepted.value)
|
||||
return
|
||||
handleQuery()
|
||||
})
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user