diff --git a/src/App.vue b/src/App.vue
index 5605156..bce4813 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,6 +1,38 @@
@@ -20,7 +52,9 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
-
+
+
+
diff --git a/src/composables/useLiveness.js b/src/composables/useLiveness.js
index 513d40f..74d31c4 100644
--- a/src/composables/useLiveness.js
+++ b/src/composables/useLiveness.js
@@ -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}
*/
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}
@@ -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
+ }
+ }
}
diff --git a/src/composables/useProjects.js b/src/composables/useProjects.js
index ce20c37..0ed2665 100644
--- a/src/composables/useProjects.js
+++ b/src/composables/useProjects.js
@@ -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)
diff --git a/src/modules/calendar/Calendar.vue b/src/modules/calendar/Calendar.vue
index 17837c0..bda8df9 100644
--- a/src/modules/calendar/Calendar.vue
+++ b/src/modules/calendar/Calendar.vue
@@ -1,5 +1,7 @@