feat: 隐藏页签持久可见 + 页签切换不卸载 + 白板周期存活探测
- 隐藏页签(cqy)访问后保持可见:路由进入即标记 visited,切走不再消失 - 页签切换默认不卸载组件:<KeepAlive :include> 缓存全部模块组件 仅声明 requiresAlive 的模块在服务失活(down)时移出 include -> 卸载 - 白板存活检测改为加载即探测 + 每 30s 周期重探(startPolling), 状态在 up/down 间响应式翻转,不再一次性固化 - 各模块组件 defineOptions name 与模块 id 一致,便于 KeepAlive 精确匹配
This commit is contained in:
36
src/App.vue
36
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user