fix: 白板页签乐观显示 + 白板切换输入框 + /whiteboard/{id} 直链

- 存活逻辑改为乐观显示:探测中(pending)/存活(up) 都显示页签,仅明确
  不可达(down) 才隐藏。修复首屏看不到页签、需刷新才出现的问题
- 白板页加 ID 输入框与切换按钮,可切换不同白板,默认 share
- 路由支持 /whiteboard/:boardId 可选参数,直链访问自动填入输入框并切换
- 模块可声明 param 让路由表生成子路径,保持注册表驱动
This commit is contained in:
2026-07-22 02:43:26 +00:00
parent b5dcf924fb
commit 370388e729
7 changed files with 154 additions and 18 deletions

View File

@@ -20,17 +20,19 @@ if (_distinctUrls.length) {
// 读取已注册模块列表,供 TabNav 渲染页签。 // 读取已注册模块列表,供 TabNav 渲染页签。
// hidden 模块(如日历页)默认不显示在导航中,但当用户正在访问该页时 // hidden 模块(如日历页)默认不显示在导航中,但当用户正在访问该页时
// 仍显示其页签(描下边高亮),让用户知道当前所在位置。 // 仍显示其页签(描下边高亮),让用户知道当前所在位置。
// requiresAlive 模块:探测返回前不显示(避免闪烁);挂掉则保持隐藏。 // requiresAlive 模块:乐观显示 -- 探测中(pending)与存活(up)均显示,
// 仅当明确探测为不可达(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 && m.id !== route.meta.moduleId) return false
// 依赖存活的模块:pending/down 时不显示,up 显示 // 依赖存活的模块:仅 down 时隐藏pending/up 显示
if (m.requiresAlive) { if (m.requiresAlive) {
const state = getLiveness(m.requiresAlive) const state = getLiveness(m.requiresAlive)
if (state.value !== 'up') return false if (state.value === 'down') return false
} }
return true return true
}) })

View File

@@ -12,7 +12,7 @@ export const appConfig = {
// 记事本页面路径生成器 // 记事本页面路径生成器
pagePath: (boardId) => `/wb/${encodeURIComponent(boardId)}`, pagePath: (boardId) => `/wb/${encodeURIComponent(boardId)}`,
// 默认打开的记事本 id // 默认打开的记事本 id
defaultBoardId: 'shared', defaultBoardId: 'share',
// 探测超时(毫秒) // 探测超时(毫秒)
healthTimeoutMs: 4000 healthTimeoutMs: 4000
} }

View File

@@ -15,7 +15,9 @@ export default {
whiteboard: 'Shared Notepad' whiteboard: 'Shared Notepad'
}, },
whiteboard: { whiteboard: {
openExternal: 'Open in new tab' openExternal: 'Open in new tab',
boardId: 'Board ID',
switch: 'Switch'
}, },
mobileGame: { mobileGame: {
hero: { hero: {

View File

@@ -14,7 +14,9 @@ export default {
whiteboard: '共享记事本' whiteboard: '共享记事本'
}, },
whiteboard: { whiteboard: {
openExternal: '在新标签页打开' openExternal: '在新标签页打开',
boardId: '白板 ID',
switch: '切换'
}, },
mobileGame: { mobileGame: {
hero: { hero: {

View File

@@ -1,23 +1,70 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { appConfig } from '../../config/app.config.js' import { useRoute, useRouter } from 'vue-router'
import { appConfig, healthUrl } from '../../config/app.config.js'
import { useLocale } from '../../composables/useLocale.js' import { useLocale } from '../../composables/useLocale.js'
import { getLiveness } from '../../composables/useLiveness.js'
const { t } = useLocale() const { t } = useLocale()
const route = useRoute()
const router = useRouter()
// 完整白板页 URL公开可被 iframe 嵌入)
const wbConfig = appConfig.whiteboard const wbConfig = appConfig.whiteboard
const boardSrc = computed(
() => `${wbConfig.baseUrl}${wbConfig.pagePath(wbConfig.defaultBoardId)}` // 当前白板 id优先取路由参数 /whiteboard/:boardId否则用配置默认值
function resolveBoardId(val) {
const id = String(val ?? '').trim()
// 仅允许白板服务接受的字符(与后端正则一致:字母数字 _ -),否则回退默认
return /^[a-zA-Z0-9_-]+$/.test(id) ? id : wbConfig.defaultBoardId
}
const currentBoardId = ref(resolveBoardId(route.params.boardId))
const inputId = ref(currentBoardId.value)
// 路由参数变化时(如浏览器前进/后退、直链进入)同步到当前白板与输入框
watch(
() => route.params.boardId,
(val) => {
const id = resolveBoardId(val)
if (id !== currentBoardId.value) {
currentBoardId.value = id
inputId.value = id
}
}
) )
// 提交输入框:切换白板并更新 URLreplace避免历史栈堆积
function submitBoard() {
const id = resolveBoardId(inputId.value)
inputId.value = id
if (id !== currentBoardId.value) {
currentBoardId.value = id
router.replace(`/whiteboard/${encodeURIComponent(id)}`)
}
}
// 完整白板页 URL公开可被 iframe 嵌入);随 currentBoardId 响应式变化
const boardSrc = computed(
() => `${wbConfig.baseUrl}${wbConfig.pagePath(currentBoardId.value)}`
)
// 存活态:服务挂掉时显示降级提示(页签本身也已隐藏,但用户若直链进入仍可见)
const aliveState = getLiveness(healthUrl('whiteboard'))
const isDown = computed(() => aliveState.value === 'down')
const loaded = ref(false) const loaded = ref(false)
const failed = ref(false) const failed = ref(false)
// 切换白板时重置加载态
watch(boardSrc, () => {
loaded.value = false
failed.value = false
scheduleFailCheck()
})
function onLoad() { function onLoad() {
loaded.value = true loaded.value = true
} }
// iframe 加载失败(跨域无法读 error靠超时兜底
function scheduleFailCheck() { function scheduleFailCheck() {
setTimeout(() => { setTimeout(() => {
if (!loaded.value) failed.value = true if (!loaded.value) failed.value = true
@@ -25,7 +72,6 @@ function scheduleFailCheck() {
} }
scheduleFailCheck() scheduleFailCheck()
// 外链打开(降级时提供)
function openExternal() { function openExternal() {
window.open(boardSrc.value, '_blank', 'noopener') window.open(boardSrc.value, '_blank', 'noopener')
} }
@@ -34,10 +80,38 @@ function openExternal() {
<template> <template>
<div class="wb-page"> <div class="wb-page">
<div class="wb-page__inner"> <div class="wb-page__inner">
<div class="wb-page__head">
<h1 class="wb-page__title">{{ t('tabs.whiteboard') }}</h1> <h1 class="wb-page__title">{{ t('tabs.whiteboard') }}</h1>
<form class="wb-page__switch" @submit.prevent="submitBoard">
<label class="wb-page__switch-label" for="boardInput">
{{ t('whiteboard.boardId') }}
</label>
<input
id="boardInput"
v-model="inputId"
class="wb-page__input"
type="text"
:placeholder="wbConfig.defaultBoardId"
spellcheck="false"
autocomplete="off"
autocapitalize="off"
/>
<button type="submit" class="wb-page__go">
{{ t('whiteboard.switch') }}
</button>
</form>
</div>
<!-- 降级iframe 长时间未加载完成 --> <!-- 服务不可达降级 -->
<div v-if="failed" class="wb-page__fallback"> <div v-if="isDown" 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>
<!-- iframe 加载超时降级 -->
<div v-else-if="failed" class="wb-page__fallback">
<p class="wb-page__fallback-text">{{ t('common.serviceUnavailable') }}</p> <p class="wb-page__fallback-text">{{ t('common.serviceUnavailable') }}</p>
<button type="button" class="wb-page__open" @click="openExternal"> <button type="button" class="wb-page__open" @click="openExternal">
{{ t('whiteboard.openExternal') }} {{ t('whiteboard.openExternal') }}
@@ -75,9 +149,53 @@ function openExternal() {
padding: 0 var(--space-lg); padding: 0 var(--space-lg);
box-sizing: border-box; box-sizing: border-box;
} }
.wb-page__head {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.wb-page__title { .wb-page__title {
font-size: 1.6rem; font-size: 1.6rem;
margin-bottom: var(--space-lg); margin: 0;
}
.wb-page__switch {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.wb-page__switch-label {
font-size: 0.85rem;
color: var(--text-secondary);
}
.wb-page__input {
width: 160px;
padding: 6px 10px;
font-size: 0.9rem;
font-family: var(--font-mono);
color: var(--text-primary);
background: var(--bg);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
transition: border-color var(--transition);
}
.wb-page__input:focus {
outline: none;
border-color: var(--accent);
}
.wb-page__go {
padding: 7px 14px;
font-size: 0.85rem;
font-weight: 500;
color: #fff;
background: var(--accent);
border-radius: var(--radius-sm);
transition: background var(--transition);
}
.wb-page__go:hover {
background: var(--accent-hover);
} }
.wb-page__frame { .wb-page__frame {
position: relative; position: relative;
@@ -89,6 +207,7 @@ function openExternal() {
/* 记事本以编辑为主,给足高度 */ /* 记事本以编辑为主,给足高度 */
height: 70vh; height: 70vh;
min-height: 480px; min-height: 480px;
background: var(--surface);
} }
.wb-page__iframe { .wb-page__iframe {
width: 100%; width: 100%;
@@ -144,6 +263,12 @@ function openExternal() {
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.wb-page__head {
align-items: flex-start;
}
.wb-page__input {
width: 120px;
}
.wb-page__frame { .wb-page__frame {
height: 65vh; height: 65vh;
min-height: 360px; min-height: 360px;

View File

@@ -9,6 +9,8 @@ export default {
order: 2, order: 2,
// 存活探针 URLuseProjects 启动时探测一次,挂掉则不显示页签 // 存活探针 URLuseProjects 启动时探测一次,挂掉则不显示页签
requiresAlive: healthUrl('whiteboard'), requiresAlive: healthUrl('whiteboard'),
// 接受 /whiteboard/:boardId 子路径,使直链访问能预填白板 id
param: 'boardId',
// 懒加载组件 -> 独立 chunk // 懒加载组件 -> 独立 chunk
component: () => import('./Whiteboard.vue') component: () => import('./Whiteboard.vue')
} }

View File

@@ -5,8 +5,11 @@ import modules from '../modules/index.js'
const moduleRoutes = modules const moduleRoutes = modules
.map((m) => { .map((m) => {
try { try {
// 模块可声明 param如 'boardId')以接受 /<id>/<param> 形式的子路径,
// 形如 /whiteboard/share。可选参数 (? 后缀) 使 /whiteboard 仍可访问。
const paramPart = m.param ? `/:${m.param}?` : ''
return { return {
path: `/${m.id}`, path: `/${m.id}${paramPart}`,
name: m.id, name: m.id,
component: m.component, component: m.component,
meta: { moduleId: m.id } meta: { moduleId: m.id }