feat: 新增共享记事本页签(iframe 嵌入 + 存活校验)与集中配置
- 新增 whiteboard 模块:iframe 嵌入 f.zikai.wang/wb/shared,骨架屏+降级外链 - 存活校验:模块声明 requiresAlive,启动时 no-cors 探测 /health 一次, 服务挂掉则隐藏页签(刷新页面才重探) - 集中配置 src/config/app.config.js:白板 URL/探针/默认记事本 id 统一管理 - 容错强化:注册表/路由/useProjects 单模块异常不影响其他页签,删除一个页签 仅需删其模块文件夹并重新 build - i18n:tabs.whiteboard / common.serviceUnavailable / whiteboard.openExternal - README 精简重写:仅保留项目结构、基础设施、Ubuntu 从0安装、新增页签
This commit is contained in:
71
src/composables/useLiveness.js
Normal file
71
src/composables/useLiveness.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
// ★ 存活校验 -- 跨域用 no-cors fetch 探测(resolve=存活,reject/timeout=挂掉)
|
||||
// 设计:应用启动时对每个 url 探测一次,结果在全应用共享并稳定(不再重试)。
|
||||
// 这样依赖存活态的页签在启动后据结果决定显示/隐藏;服务恢复需刷新页面才重探。
|
||||
|
||||
// 模块级共享缓存:url -> { state: ref('pending'|'up'|'down'), promise }
|
||||
const cache = new Map()
|
||||
|
||||
/**
|
||||
* 探测单个 url 是否可达(no-cors:能 resolve 即视为存活)。
|
||||
* @param {string} url 完整 URL
|
||||
* @param {number} timeoutMs 超时毫秒
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
export async function checkAlive(url, timeoutMs = 4000) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取某 url 的存活态 ref(响应式)。
|
||||
* 首次调用触发一次探测,之后所有调用方共用同一个 ref 与探测结果。
|
||||
* 返回的 ref.value: 'pending'(探测中)/ 'up'(存活)/ 'down'(不可达)
|
||||
* @param {string} url
|
||||
* @param {number} [timeoutMs]
|
||||
* @returns {import('vue').Ref<string>}
|
||||
*/
|
||||
export function getLiveness(url, timeoutMs) {
|
||||
// 无 url 视为无需校验,直接视为存活
|
||||
if (!url) return ref('up')
|
||||
let entry = cache.get(url)
|
||||
if (!entry) {
|
||||
const state = ref('pending')
|
||||
const 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
|
||||
* @param {number} [timeoutMs]
|
||||
*/
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,39 @@
|
||||
import modules from '../modules/index.js'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getLiveness, probeAll } from './useLiveness.js'
|
||||
|
||||
// 读取已注册模块列表,供 TabNav 渲染页签
|
||||
// 收集所有声明了 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,忽略 */
|
||||
})
|
||||
}
|
||||
|
||||
// 读取已注册模块列表,供 TabNav 渲染页签。
|
||||
// hidden 模块(如日历页)默认不显示在导航中,但当用户正在访问该页时
|
||||
// 仍显示其页签(描下边高亮),让用户知道当前所在位置。
|
||||
// requiresAlive 模块:探测返回前不显示(避免闪烁);挂掉则保持隐藏。
|
||||
export function useProjects() {
|
||||
const route = useRoute()
|
||||
const projects = computed(() =>
|
||||
modules.filter((m) => !m.hidden || m.id === route.meta.moduleId)
|
||||
modules.filter((m) => {
|
||||
// 隐藏模块:仅当前正在访问时才显示
|
||||
if (m.hidden && m.id !== route.meta.moduleId) return false
|
||||
// 依赖存活的模块:pending/down 时不显示,up 时显示
|
||||
if (m.requiresAlive) {
|
||||
const state = getLiveness(m.requiresAlive)
|
||||
if (state.value !== 'up') return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
)
|
||||
return { projects }
|
||||
}
|
||||
|
||||
30
src/config/app.config.js
Normal file
30
src/config/app.config.js
Normal file
@@ -0,0 +1,30 @@
|
||||
// ★ 集中配置 -- 外部服务 URL、默认参数等统一在此维护
|
||||
// 模块按需 import { appConfig } 使用,改 URL 只改这里即可(需重新 build)。
|
||||
// 每个服务项都带默认值,避免缺失字段导致渲染崩溃。
|
||||
|
||||
export const appConfig = {
|
||||
// 共享记事本(白板)服务
|
||||
whiteboard: {
|
||||
// 服务根地址(含协议与域名)
|
||||
baseUrl: 'https://f.zikai.wang',
|
||||
// 存活探针路径;启动时探测一次,挂掉则隐藏页签
|
||||
healthPath: '/health',
|
||||
// 记事本页面路径生成器
|
||||
pagePath: (boardId) => `/wb/${encodeURIComponent(boardId)}`,
|
||||
// 默认打开的记事本 id
|
||||
defaultBoardId: 'shared',
|
||||
// 探测超时(毫秒)
|
||||
healthTimeoutMs: 4000
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接服务的完整存活探针 URL。
|
||||
* @param {'whiteboard'} service 配置中的服务名
|
||||
* @returns {string} 完整 URL,如 https://f.zikai.wang/health
|
||||
*/
|
||||
export function healthUrl(service) {
|
||||
const cfg = appConfig[service]
|
||||
if (!cfg) return ''
|
||||
return `${cfg.baseUrl}${cfg.healthPath}`
|
||||
}
|
||||
@@ -6,11 +6,16 @@ export default {
|
||||
},
|
||||
common: {
|
||||
loading: 'Loading…',
|
||||
notAvailable: 'Not available'
|
||||
notAvailable: 'Not available',
|
||||
serviceUnavailable: 'Service is temporarily unavailable. Please try again later.'
|
||||
},
|
||||
tabs: {
|
||||
mobileGame: 'Mobile Game Project',
|
||||
calendar: 'CQY Timetable'
|
||||
calendar: 'CQY Timetable',
|
||||
whiteboard: 'Shared Notepad'
|
||||
},
|
||||
whiteboard: {
|
||||
openExternal: 'Open in new tab'
|
||||
},
|
||||
mobileGame: {
|
||||
hero: {
|
||||
|
||||
@@ -5,11 +5,16 @@ export default {
|
||||
},
|
||||
common: {
|
||||
loading: '加载中…',
|
||||
notAvailable: '暂不可用'
|
||||
notAvailable: '暂不可用',
|
||||
serviceUnavailable: '服务暂不可用,请稍后再试。'
|
||||
},
|
||||
tabs: {
|
||||
mobileGame: '移动游戏项目',
|
||||
calendar: 'cqy课程表'
|
||||
calendar: 'cqy课程表',
|
||||
whiteboard: '共享记事本'
|
||||
},
|
||||
whiteboard: {
|
||||
openExternal: '在新标签页打开'
|
||||
},
|
||||
mobileGame: {
|
||||
hero: {
|
||||
|
||||
@@ -10,12 +10,23 @@
|
||||
// order: 1, // 页签排序
|
||||
// component: () => import('./MobileGame.vue') // 懒加载组件
|
||||
// }
|
||||
//
|
||||
// 容错:单个模块解析/导出异常时跳过该模块并告警,不影响其余页签加载。
|
||||
|
||||
const moduleFiles = import.meta.glob('./*/index.js', { eager: true })
|
||||
|
||||
const modules = Object.values(moduleFiles)
|
||||
.map((mod) => mod.default)
|
||||
.map((mod) => {
|
||||
try {
|
||||
return mod?.default
|
||||
} catch (e) {
|
||||
console.warn('[modules] 模块导出解析失败,已跳过:', e)
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
// 字段完整性兜底:缺 id/tabKey/component 的模块无法生成页签与路由,跳过
|
||||
.filter((m) => m && m.id && m.tabKey && typeof m.component === 'function')
|
||||
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99))
|
||||
|
||||
export default modules
|
||||
|
||||
152
src/modules/whiteboard/Whiteboard.vue
Normal file
152
src/modules/whiteboard/Whiteboard.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { appConfig } from '../../config/app.config.js'
|
||||
import { useLocale } from '../../composables/useLocale.js'
|
||||
|
||||
const { t } = useLocale()
|
||||
|
||||
// 完整白板页 URL(公开,可被 iframe 嵌入)
|
||||
const wbConfig = appConfig.whiteboard
|
||||
const boardSrc = computed(
|
||||
() => `${wbConfig.baseUrl}${wbConfig.pagePath(wbConfig.defaultBoardId)}`
|
||||
)
|
||||
|
||||
const loaded = ref(false)
|
||||
const failed = ref(false)
|
||||
|
||||
function onLoad() {
|
||||
loaded.value = true
|
||||
}
|
||||
// iframe 加载失败(跨域无法读 error,靠超时兜底)
|
||||
function scheduleFailCheck() {
|
||||
setTimeout(() => {
|
||||
if (!loaded.value) failed.value = true
|
||||
}, 6000)
|
||||
}
|
||||
scheduleFailCheck()
|
||||
|
||||
// 外链打开(降级时提供)
|
||||
function openExternal() {
|
||||
window.open(boardSrc.value, '_blank', 'noopener')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wb-page">
|
||||
<div class="wb-page__inner">
|
||||
<h1 class="wb-page__title">{{ t('tabs.whiteboard') }}</h1>
|
||||
|
||||
<!-- 降级:iframe 长时间未加载完成 -->
|
||||
<div v-if="failed" class="wb-page__fallback">
|
||||
<p class="wb-page__fallback-text">{{ t('common.serviceUnavailable') }}</p>
|
||||
<button type="button" class="wb-page__open" @click="openExternal">
|
||||
{{ t('whiteboard.openExternal') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="wb-page__frame">
|
||||
<!-- 加载骨架(iframe 加载完成后淡出) -->
|
||||
<Transition name="fade">
|
||||
<div v-if="!loaded" class="wb-page__skeleton">
|
||||
<div class="wb-page__skeleton-text">{{ t('common.loading') }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<iframe
|
||||
:src="boardSrc"
|
||||
class="wb-page__iframe"
|
||||
frameborder="0"
|
||||
:title="t('tabs.whiteboard')"
|
||||
@load="onLoad"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wb-page {
|
||||
padding: var(--space-xl) 0;
|
||||
}
|
||||
/* 全宽容器(不受全局 .container 的 1080px 限制),仅留左右内边距 */
|
||||
.wb-page__inner {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--space-lg);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wb-page__title {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
.wb-page__frame {
|
||||
position: relative;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
width: 100%;
|
||||
/* 记事本以编辑为主,给足高度 */
|
||||
height: 70vh;
|
||||
min-height: 480px;
|
||||
}
|
||||
.wb-page__iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border: 0;
|
||||
}
|
||||
.wb-page__skeleton {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--surface);
|
||||
}
|
||||
.wb-page__skeleton-text {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.wb-page__fallback {
|
||||
text-align: center;
|
||||
padding: var(--space-2xl) var(--space-lg);
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
.wb-page__fallback-text {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
.wb-page__open {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 18px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background var(--transition);
|
||||
}
|
||||
.wb-page__open:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wb-page__frame {
|
||||
height: 65vh;
|
||||
min-height: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
src/modules/whiteboard/index.js
Normal file
14
src/modules/whiteboard/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// 模块元数据 -- 共享记事本(白板)页签
|
||||
// requiresAlive 指向存活探针 URL:启动时探测,服务不可达则隐藏该页签。
|
||||
// 删除本目录即可移除此页签,其余模块不受影响。
|
||||
import { healthUrl } from '../../config/app.config.js'
|
||||
|
||||
export default {
|
||||
id: 'whiteboard',
|
||||
tabKey: 'tabs.whiteboard',
|
||||
order: 2,
|
||||
// 存活探针 URL;useProjects 启动时探测一次,挂掉则不显示页签
|
||||
requiresAlive: healthUrl('whiteboard'),
|
||||
// 懒加载组件 -> 独立 chunk
|
||||
component: () => import('./Whiteboard.vue')
|
||||
}
|
||||
@@ -2,18 +2,29 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import modules from '../modules/index.js'
|
||||
|
||||
const moduleRoutes = modules
|
||||
.map((m) => {
|
||||
try {
|
||||
return {
|
||||
path: `/${m.id}`,
|
||||
name: m.id,
|
||||
component: m.component,
|
||||
meta: { moduleId: m.id }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[router] 模块 ${m?.id} 路由生成失败,已跳过:`, e)
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: modules.length ? `/${modules[0].id}` : '/404'
|
||||
redirect: moduleRoutes.length ? moduleRoutes[0].path : '/404'
|
||||
},
|
||||
// 每个模块一条路由,组件懒加载
|
||||
...modules.map((m) => ({
|
||||
path: `/${m.id}`,
|
||||
name: m.id,
|
||||
component: m.component,
|
||||
meta: { moduleId: m.id }
|
||||
})),
|
||||
...moduleRoutes,
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
|
||||
Reference in New Issue
Block a user