feat: 隐藏页签持久可见 + 页签切换不卸载 + 白板周期存活探测

- 隐藏页签(cqy)访问后保持可见:路由进入即标记 visited,切走不再消失
- 页签切换默认不卸载组件:<KeepAlive :include> 缓存全部模块组件
  仅声明 requiresAlive 的模块在服务失活(down)时移出 include -> 卸载
- 白板存活检测改为加载即探测 + 每 30s 周期重探(startPolling),
  状态在 up/down 间响应式翻转,不再一次性固化
- 各模块组件 defineOptions name 与模块 id 一致,便于 KeepAlive 精确匹配
This commit is contained in:
2026-07-22 03:20:48 +00:00
parent 2c33457c87
commit 711026be9f
6 changed files with 98 additions and 42 deletions

View File

@@ -1,6 +1,38 @@
<script setup>
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import TabNav from './components/TabNav.vue'
import LanguageSwitcher from './components/LanguageSwitcher.vue'
import modules from './modules/index.js'
import { markVisited } from './composables/useProjects.js'
import { getLiveness, startPolling } from './composables/useLiveness.js'
import { healthUrl } from './config/app.config.js'
const route = useRoute()
// 隐藏页签访问后保持可见:路由切到某模块即标记已访问
watch(
() => route.meta.moduleId,
(id) => markVisited(id),
{ immediate: true }
)
// KeepAlive 缓存名单:默认缓存所有模块组件名(切换页签不卸载)。
// 声明了 requiresAlive 的模块在服务失活(down)时移出名单 -> 卸载。
const aliveNames = modules.filter((m) => m.requiresAlive)
aliveNames.forEach((m) => {
// 加载即探测 + 每 30s 周期重探
startPolling(m.requiresAlive)
})
const keepInclude = computed(() =>
modules
.filter((m) => {
if (!m.requiresAlive) return true // 无存活依赖:始终缓存
const state = getLiveness(m.requiresAlive)
return state.value !== 'down' // 失活时不缓存 -> 卸载
})
.map((m) => m.id)
)
</script>
<template>
@@ -20,7 +52,9 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
<main class="main">
<RouterView v-slot="{ Component }">
<Transition name="fade" mode="out-in">
<component :is="Component" />
<KeepAlive :include="keepInclude">
<component :is="Component" />
</KeepAlive>
</Transition>
</RouterView>
</main>

View File

@@ -1,10 +1,10 @@
import { ref } from 'vue'
// ★ 存活校验 -- 跨域用 no-cors fetch 探测resolve=存活reject/timeout=挂掉)
// 设计:应用启动时对每个 url 探测一次,结果在全应用共享并稳定(不再重试)。
// 这样依赖存活态的页签在启动后据结果决定显示/隐藏;服务恢复需刷新页面才重探
// 探测时机:首次访问立即探测一次,之后按周期重复探测(默认 30s
// 结果随时间在 up/down 间翻转,依赖该态的页签/缓存会响应式更新
// 模块级共享缓存url -> { state: ref('pending'|'up'|'down'), promise }
// 模块级共享缓存url -> { state: ref('pending'|'up'|'down'), promise, timer }
const cache = new Map()
/**
@@ -14,26 +14,28 @@ const cache = new Map()
* @returns {Promise<boolean>}
*/
export async function checkAlive(url, timeoutMs = 4000) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
await fetch(url, {
mode: 'no-cors', // 跨域不读响应体resolve 即存活
cache: 'no-store',
signal: controller.signal,
redirect: 'follow'
})
clearTimeout(timer)
return true
} catch {
return false
} finally {
clearTimeout(timer)
}
}
/**
* 获取某 url 的存活态 ref响应式
* 首次调用触发一次探测之后所有调用方共用同一个 ref 与探测结果
* 返回的 ref.value: 'pending'(探测中)/ 'up'(存活)/ 'down'(不可达)
* 首次调用立即触发一次探测之后所有调用方共用同一个 ref。
* 要周期重探,额外调用 startPolling(url)。
* ref.value: 'pending'(探测中)/ 'up'(存活)/ 'down'(不可达)
* @param {string} url
* @param {number} [timeoutMs]
* @returns {import('vue').Ref<string>}
@@ -44,28 +46,39 @@ export function getLiveness(url, timeoutMs) {
let entry = cache.get(url)
if (!entry) {
const state = ref('pending')
const promise = checkAlive(url, timeoutMs).then((ok) => {
entry = { state, promise: null, timer: null }
cache.set(url, entry)
// 首次访问立即探测一次
entry.promise = checkAlive(url, timeoutMs).then((ok) => {
state.value = ok ? 'up' : 'down'
return ok
})
entry = { state, promise }
cache.set(url, entry)
}
return entry.state
}
/**
* 批量探测多个 url并行用于启动时一次性探测所有依赖服务
* 返回时各 url 的 getLiveness() ref 已更新到位
* @param {string[]} urls
* 对某 url 启动周期探测(首次立即探测 + 每 intervalMs 一次)
* 幂等:重复调用不会叠加定时器。返回停止函数
* @param {string} url
* @param {number} [intervalMs] 默认 30s
* @param {number} [timeoutMs]
* @returns {() => void} 停止定时器
*/
export async function probeAll(urls, timeoutMs) {
const unique = [...new Set(urls.filter(Boolean))]
await Promise.all(
unique.map((u) => {
getLiveness(u, timeoutMs) // 首次访问即注册并触发探测
return cache.get(u).promise
export function startPolling(url, intervalMs = 30000, timeoutMs = 4000) {
if (!url) return () => {}
getLiveness(url, timeoutMs) // 确保缓存项存在并已触发首次探测
const entry = cache.get(url)
if (entry.timer) return () => clearInterval(entry.timer)
entry.timer = setInterval(() => {
checkAlive(url, timeoutMs).then((ok) => {
entry.state.value = ok ? 'up' : 'down'
})
)
}, intervalMs)
return () => {
if (entry.timer) {
clearInterval(entry.timer)
entry.timer = null
}
}
}

View File

@@ -1,34 +1,36 @@
import modules from '../modules/index.js'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { getLiveness, probeAll } from './useLiveness.js'
import { getLiveness } from './useLiveness.js'
// 收集所有声明了 requiresAlive 的模块探针 URL启动时一次性并行探测。
// probeAll 内部对同一 url 去重并缓存,与 getLiveness 共享结果
const aliveUrls = modules
.map((m) => m.requiresAlive)
.filter(Boolean)
const _distinctUrls = [...new Set(aliveUrls)]
// 启动即触发探测(模块级执行一次),结果缓存在 useLiveness 的 cache 中。
// useProjects 通过 getLiveness 读取同一 ref无需等待即可响应式更新。
if (_distinctUrls.length) {
probeAll(_distinctUrls).catch(() => {
/* 探测失败已记录为 down忽略 */
})
// 记录「曾经被访问过」的模块 id。隐藏页签如 cqy一旦通过 URL 访问,
// 即加入此集合,使其在导航中保持可见 -- 不会因切走其他页签而消失
const visited = ref(new Set())
/**
* 标记某模块已被访问(隐藏页签访问后保持可见)。
* @param {string} moduleId
*/
export function markVisited(moduleId) {
if (moduleId && !visited.value.has(moduleId)) {
visited.value = new Set(visited.value).add(moduleId)
}
}
// 读取已注册模块列表,供 TabNav 渲染页签。
// hidden 模块(如日历页)默认不显示在导航中,但当用户正在访问该页时
// 仍显示其页签(描下边高亮),让用户知道当前所在位置。
// hidden 模块:默认不显示,但「当前正在访问」或「曾经访问过」后保持显示。
// requiresAlive 模块:乐观显示 -- 探测中(pending)与存活(up)均显示,
// 仅当明确探测为不可达(down)时才隐藏。这样首屏即可看到页签,探测完成
// 仅在「服务挂掉」这一种情况下才把页签收掉。
// 仅当明确探测为不可达(down)时才隐藏。
export function useProjects() {
const route = useRoute()
const projects = computed(() =>
modules.filter((m) => {
// 隐藏模块:当前正在访问才显示
if (m.hidden && m.id !== route.meta.moduleId) return false
// 隐藏模块:当前正在访问 或 曾经访问过 才显示
if (m.hidden) {
const current = m.id === route.meta.moduleId
const wasVisited = visited.value.has(m.id)
if (!current && !wasVisited) return false
}
// 依赖存活的模块:仅 down 时隐藏pending/up 都显示
if (m.requiresAlive) {
const state = getLiveness(m.requiresAlive)

View File

@@ -1,5 +1,7 @@
<script setup>
import { ref, computed } from 'vue'
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配
defineOptions({ name: 'cqy' })
import { useLocale } from '../../composables/useLocale.js'
const { t } = useLocale()

View File

@@ -1,5 +1,7 @@
<script setup>
import { ref, defineAsyncComponent } from 'vue'
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配
defineOptions({ name: 'mobile-game' })
import HeroSection from './components/HeroSection.vue'
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
import SubTabs from './components/SubTabs.vue'

View File

@@ -1,5 +1,8 @@
<script setup>
import { ref, computed, watch } from 'vue'
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配;
// 白板后端失活时把它从 include 移除即可卸载组件
defineOptions({ name: 'whiteboard' })
import { useRoute, useRouter } from 'vue-router'
import { appConfig, healthUrl } from '../../config/app.config.js'
import { useLocale } from '../../composables/useLocale.js'