mirror of
https://github.com/ialley-workshop-open/uni-halo.git
synced 2026-09-13 00:50:40 +08:00
refactor: 统一加载状态组件,重构投票相关类型与逻辑,新增维护拦截功能
1. 替换所有页面的wd-skeleton与空状态为uh-data-loading组件 2. 重构投票相关API类型与列表/详情逻辑,适配Halo扩展对象结构 3. 新增维护拦截hook与维护页,统一插件可用性与维护模式检查跳转 4. 优化多个页面的代码结构与样式细节
This commit is contained in:
@@ -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