diff --git a/src/api/types/uni-halo.ts b/src/api/types/uni-halo.ts index 8f7fe77..96bb1be 100644 --- a/src/api/types/uni-halo.ts +++ b/src/api/types/uni-halo.ts @@ -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 } diff --git a/src/api/uni-halo.ts b/src/api/uni-halo.ts index 7f93a10..d182bff 100644 --- a/src/api/uni-halo.ts +++ b/src/api/uni-halo.ts @@ -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>(`/apis/api.vote.kunkunyu.com/v1alpha1/votes/${name}/detail`, { + return http.Get>(`/apis/api.vote.kunkunyu.com/v1alpha1/votes/${name}/detail`, { meta: { requestFrom: RequestFrom.Halo }, }) } diff --git a/src/components/uh-vote-card/uh-vote-card.vue b/src/components/uh-vote-card/uh-vote-card.vue index 712969b..29c20f6 100644 --- a/src/components/uh-vote-card/uh-vote-card.vue +++ b/src/components/uh-vote-card/uh-vote-card.vue @@ -1,11 +1,12 @@ diff --git a/src/pages/maintenance/maintenance.vue b/src/pages/maintenance/maintenance.vue index 8c0648c..328c07c 100644 --- a/src/pages/maintenance/maintenance.vue +++ b/src/pages/maintenance/maintenance.vue @@ -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('loading') const maintenance = ref(null) +/** 拦截来源(index/首页 ?from=plugin|maintenance);为空 = 手动进入(保留四态) */ +const fromReason = ref(null) const nowMs = ref(Date.now()) +/** 刷新按钮旋转中 */ +const spinning = ref(false) +/** Toast 文案(空 = 隐藏) */ +const toast = ref('') let timer: ReturnType | null = null +let recoveryTimer: ReturnType | null = null +let toastTimer: ReturnType | 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) }) diff --git a/src/pages/tabbar/home/home.vue b/src/pages/tabbar/home/home.vue index b26f8f5..49eba8d 100644 --- a/src/pages/tabbar/home/home.vue +++ b/src/pages/tabbar/home/home.vue @@ -1,6 +1,6 @@