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>
|
<script setup>
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import TabNav from './components/TabNav.vue'
|
import TabNav from './components/TabNav.vue'
|
||||||
import LanguageSwitcher from './components/LanguageSwitcher.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -20,7 +52,9 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<Transition name="fade" mode="out-in">
|
<Transition name="fade" mode="out-in">
|
||||||
<component :is="Component" />
|
<KeepAlive :include="keepInclude">
|
||||||
|
<component :is="Component" />
|
||||||
|
</KeepAlive>
|
||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
// ★ 存活校验 -- 跨域用 no-cors fetch 探测(resolve=存活,reject/timeout=挂掉)
|
// ★ 存活校验 -- 跨域用 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()
|
const cache = new Map()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,26 +14,28 @@ const cache = new Map()
|
|||||||
* @returns {Promise<boolean>}
|
* @returns {Promise<boolean>}
|
||||||
*/
|
*/
|
||||||
export async function checkAlive(url, timeoutMs = 4000) {
|
export async function checkAlive(url, timeoutMs = 4000) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController()
|
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
||||||
await fetch(url, {
|
await fetch(url, {
|
||||||
mode: 'no-cors', // 跨域不读响应体,resolve 即存活
|
mode: 'no-cors', // 跨域不读响应体,resolve 即存活
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: 'follow'
|
redirect: 'follow'
|
||||||
})
|
})
|
||||||
clearTimeout(timer)
|
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取某 url 的存活态 ref(响应式)。
|
* 获取某 url 的存活态 ref(响应式)。
|
||||||
* 首次调用触发一次探测,之后所有调用方共用同一个 ref 与探测结果。
|
* 首次调用立即触发一次探测;之后所有调用方共用同一个 ref。
|
||||||
* 返回的 ref.value: 'pending'(探测中)/ 'up'(存活)/ 'down'(不可达)
|
* 要周期重探,额外调用 startPolling(url)。
|
||||||
|
* ref.value: 'pending'(探测中)/ 'up'(存活)/ 'down'(不可达)
|
||||||
* @param {string} url
|
* @param {string} url
|
||||||
* @param {number} [timeoutMs]
|
* @param {number} [timeoutMs]
|
||||||
* @returns {import('vue').Ref<string>}
|
* @returns {import('vue').Ref<string>}
|
||||||
@@ -44,28 +46,39 @@ export function getLiveness(url, timeoutMs) {
|
|||||||
let entry = cache.get(url)
|
let entry = cache.get(url)
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
const state = ref('pending')
|
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'
|
state.value = ok ? 'up' : 'down'
|
||||||
return ok
|
return ok
|
||||||
})
|
})
|
||||||
entry = { state, promise }
|
|
||||||
cache.set(url, entry)
|
|
||||||
}
|
}
|
||||||
return entry.state
|
return entry.state
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量探测多个 url(并行),用于启动时一次性探测所有依赖服务。
|
* 对某 url 启动周期探测(首次立即探测 + 每 intervalMs 一次)。
|
||||||
* 返回时各 url 的 getLiveness() ref 已更新到位。
|
* 幂等:重复调用不会叠加定时器。返回停止函数。
|
||||||
* @param {string[]} urls
|
* @param {string} url
|
||||||
|
* @param {number} [intervalMs] 默认 30s
|
||||||
* @param {number} [timeoutMs]
|
* @param {number} [timeoutMs]
|
||||||
|
* @returns {() => void} 停止定时器
|
||||||
*/
|
*/
|
||||||
export async function probeAll(urls, timeoutMs) {
|
export function startPolling(url, intervalMs = 30000, timeoutMs = 4000) {
|
||||||
const unique = [...new Set(urls.filter(Boolean))]
|
if (!url) return () => {}
|
||||||
await Promise.all(
|
getLiveness(url, timeoutMs) // 确保缓存项存在并已触发首次探测
|
||||||
unique.map((u) => {
|
const entry = cache.get(url)
|
||||||
getLiveness(u, timeoutMs) // 首次访问即注册并触发探测
|
if (entry.timer) return () => clearInterval(entry.timer)
|
||||||
return cache.get(u).promise
|
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 modules from '../modules/index.js'
|
||||||
import { computed } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { getLiveness, probeAll } from './useLiveness.js'
|
import { getLiveness } from './useLiveness.js'
|
||||||
|
|
||||||
// 收集所有声明了 requiresAlive 的模块探针 URL,启动时一次性并行探测。
|
// 记录「曾经被访问过」的模块 id。隐藏页签(如 cqy)一旦通过 URL 访问,
|
||||||
// probeAll 内部对同一 url 去重并缓存,与 getLiveness 共享结果。
|
// 即加入此集合,使其在导航中保持可见 -- 不会因切走其他页签而消失。
|
||||||
const aliveUrls = modules
|
const visited = ref(new Set())
|
||||||
.map((m) => m.requiresAlive)
|
|
||||||
.filter(Boolean)
|
/**
|
||||||
const _distinctUrls = [...new Set(aliveUrls)]
|
* 标记某模块已被访问(隐藏页签访问后保持可见)。
|
||||||
// 启动即触发探测(模块级执行一次),结果缓存在 useLiveness 的 cache 中。
|
* @param {string} moduleId
|
||||||
// useProjects 通过 getLiveness 读取同一 ref,无需等待即可响应式更新。
|
*/
|
||||||
if (_distinctUrls.length) {
|
export function markVisited(moduleId) {
|
||||||
probeAll(_distinctUrls).catch(() => {
|
if (moduleId && !visited.value.has(moduleId)) {
|
||||||
/* 探测失败已记录为 down,忽略 */
|
visited.value = new Set(visited.value).add(moduleId)
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取已注册模块列表,供 TabNav 渲染页签。
|
// 读取已注册模块列表,供 TabNav 渲染页签。
|
||||||
// hidden 模块(如日历页)默认不显示在导航中,但当用户正在访问该页时
|
// hidden 模块:默认不显示,但「当前正在访问」或「曾经访问过」后保持显示。
|
||||||
// 仍显示其页签(描下边高亮),让用户知道当前所在位置。
|
|
||||||
// requiresAlive 模块:乐观显示 -- 探测中(pending)与存活(up)均显示,
|
// requiresAlive 模块:乐观显示 -- 探测中(pending)与存活(up)均显示,
|
||||||
// 仅当明确探测为不可达(down)时才隐藏。这样首屏即可看到页签,探测完成
|
// 仅当明确探测为不可达(down)时才隐藏。
|
||||||
// 仅在「服务挂掉」这一种情况下才把页签收掉。
|
|
||||||
export function useProjects() {
|
export function useProjects() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const projects = computed(() =>
|
const projects = computed(() =>
|
||||||
modules.filter((m) => {
|
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 都显示
|
// 依赖存活的模块:仅 down 时隐藏;pending/up 都显示
|
||||||
if (m.requiresAlive) {
|
if (m.requiresAlive) {
|
||||||
const state = getLiveness(m.requiresAlive)
|
const state = getLiveness(m.requiresAlive)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配
|
||||||
|
defineOptions({ name: 'cqy' })
|
||||||
import { useLocale } from '../../composables/useLocale.js'
|
import { useLocale } from '../../composables/useLocale.js'
|
||||||
|
|
||||||
const { t } = useLocale()
|
const { t } = useLocale()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, defineAsyncComponent } from 'vue'
|
import { ref, defineAsyncComponent } from 'vue'
|
||||||
|
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配
|
||||||
|
defineOptions({ name: 'mobile-game' })
|
||||||
import HeroSection from './components/HeroSection.vue'
|
import HeroSection from './components/HeroSection.vue'
|
||||||
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
|
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
|
||||||
import SubTabs from './components/SubTabs.vue'
|
import SubTabs from './components/SubTabs.vue'
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配;
|
||||||
|
// 白板后端失活时把它从 include 移除即可卸载组件
|
||||||
|
defineOptions({ name: 'whiteboard' })
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { appConfig, healthUrl } from '../../config/app.config.js'
|
import { appConfig, healthUrl } from '../../config/app.config.js'
|
||||||
import { useLocale } from '../../composables/useLocale.js'
|
import { useLocale } from '../../composables/useLocale.js'
|
||||||
|
|||||||
Reference in New Issue
Block a user