fix: 白板页签乐观显示 + 白板切换输入框 + /whiteboard/{id} 直链
- 存活逻辑改为乐观显示:探测中(pending)/存活(up) 都显示页签,仅明确 不可达(down) 才隐藏。修复首屏看不到页签、需刷新才出现的问题 - 白板页加 ID 输入框与切换按钮,可切换不同白板,默认 share - 路由支持 /whiteboard/:boardId 可选参数,直链访问自动填入输入框并切换 - 模块可声明 param 让路由表生成子路径,保持注册表驱动
This commit is contained in:
@@ -20,17 +20,19 @@ if (_distinctUrls.length) {
|
||||
// 读取已注册模块列表,供 TabNav 渲染页签。
|
||||
// hidden 模块(如日历页)默认不显示在导航中,但当用户正在访问该页时
|
||||
// 仍显示其页签(描下边高亮),让用户知道当前所在位置。
|
||||
// requiresAlive 模块:探测返回前不显示(避免闪烁);挂掉则保持隐藏。
|
||||
// requiresAlive 模块:乐观显示 -- 探测中(pending)与存活(up)均显示,
|
||||
// 仅当明确探测为不可达(down)时才隐藏。这样首屏即可看到页签,探测完成
|
||||
// 仅在「服务挂掉」这一种情况下才把页签收掉。
|
||||
export function useProjects() {
|
||||
const route = useRoute()
|
||||
const projects = computed(() =>
|
||||
modules.filter((m) => {
|
||||
// 隐藏模块:仅当前正在访问时才显示
|
||||
if (m.hidden && m.id !== route.meta.moduleId) return false
|
||||
// 依赖存活的模块:pending/down 时不显示,up 时显示
|
||||
// 依赖存活的模块:仅 down 时隐藏;pending/up 都显示
|
||||
if (m.requiresAlive) {
|
||||
const state = getLiveness(m.requiresAlive)
|
||||
if (state.value !== 'up') return false
|
||||
if (state.value === 'down') return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ export const appConfig = {
|
||||
// 记事本页面路径生成器
|
||||
pagePath: (boardId) => `/wb/${encodeURIComponent(boardId)}`,
|
||||
// 默认打开的记事本 id
|
||||
defaultBoardId: 'shared',
|
||||
defaultBoardId: 'share',
|
||||
// 探测超时(毫秒)
|
||||
healthTimeoutMs: 4000
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ export default {
|
||||
whiteboard: 'Shared Notepad'
|
||||
},
|
||||
whiteboard: {
|
||||
openExternal: 'Open in new tab'
|
||||
openExternal: 'Open in new tab',
|
||||
boardId: 'Board ID',
|
||||
switch: 'Switch'
|
||||
},
|
||||
mobileGame: {
|
||||
hero: {
|
||||
|
||||
@@ -14,7 +14,9 @@ export default {
|
||||
whiteboard: '共享记事本'
|
||||
},
|
||||
whiteboard: {
|
||||
openExternal: '在新标签页打开'
|
||||
openExternal: '在新标签页打开',
|
||||
boardId: '白板 ID',
|
||||
switch: '切换'
|
||||
},
|
||||
mobileGame: {
|
||||
hero: {
|
||||
|
||||
@@ -1,23 +1,70 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { appConfig } from '../../config/app.config.js'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { appConfig, healthUrl } from '../../config/app.config.js'
|
||||
import { useLocale } from '../../composables/useLocale.js'
|
||||
import { getLiveness } from '../../composables/useLiveness.js'
|
||||
|
||||
const { t } = useLocale()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 完整白板页 URL(公开,可被 iframe 嵌入)
|
||||
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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 提交输入框:切换白板并更新 URL(replace,避免历史栈堆积)
|
||||
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 failed = ref(false)
|
||||
|
||||
// 切换白板时重置加载态
|
||||
watch(boardSrc, () => {
|
||||
loaded.value = false
|
||||
failed.value = false
|
||||
scheduleFailCheck()
|
||||
})
|
||||
|
||||
function onLoad() {
|
||||
loaded.value = true
|
||||
}
|
||||
// iframe 加载失败(跨域无法读 error,靠超时兜底)
|
||||
function scheduleFailCheck() {
|
||||
setTimeout(() => {
|
||||
if (!loaded.value) failed.value = true
|
||||
@@ -25,7 +72,6 @@ function scheduleFailCheck() {
|
||||
}
|
||||
scheduleFailCheck()
|
||||
|
||||
// 外链打开(降级时提供)
|
||||
function openExternal() {
|
||||
window.open(boardSrc.value, '_blank', 'noopener')
|
||||
}
|
||||
@@ -34,10 +80,38 @@ function openExternal() {
|
||||
<template>
|
||||
<div class="wb-page">
|
||||
<div class="wb-page__inner">
|
||||
<h1 class="wb-page__title">{{ t('tabs.whiteboard') }}</h1>
|
||||
<div class="wb-page__head">
|
||||
<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>
|
||||
<button type="button" class="wb-page__open" @click="openExternal">
|
||||
{{ t('whiteboard.openExternal') }}
|
||||
@@ -75,9 +149,53 @@ function openExternal() {
|
||||
padding: 0 var(--space-lg);
|
||||
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 {
|
||||
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 {
|
||||
position: relative;
|
||||
@@ -89,6 +207,7 @@ function openExternal() {
|
||||
/* 记事本以编辑为主,给足高度 */
|
||||
height: 70vh;
|
||||
min-height: 480px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.wb-page__iframe {
|
||||
width: 100%;
|
||||
@@ -144,6 +263,12 @@ function openExternal() {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wb-page__head {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.wb-page__input {
|
||||
width: 120px;
|
||||
}
|
||||
.wb-page__frame {
|
||||
height: 65vh;
|
||||
min-height: 360px;
|
||||
|
||||
@@ -9,6 +9,8 @@ export default {
|
||||
order: 2,
|
||||
// 存活探针 URL;useProjects 启动时探测一次,挂掉则不显示页签
|
||||
requiresAlive: healthUrl('whiteboard'),
|
||||
// 接受 /whiteboard/:boardId 子路径,使直链访问能预填白板 id
|
||||
param: 'boardId',
|
||||
// 懒加载组件 -> 独立 chunk
|
||||
component: () => import('./Whiteboard.vue')
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import modules from '../modules/index.js'
|
||||
const moduleRoutes = modules
|
||||
.map((m) => {
|
||||
try {
|
||||
// 模块可声明 param(如 'boardId')以接受 /<id>/<param> 形式的子路径,
|
||||
// 形如 /whiteboard/share。可选参数 (? 后缀) 使 /whiteboard 仍可访问。
|
||||
const paramPart = m.param ? `/:${m.param}?` : ''
|
||||
return {
|
||||
path: `/${m.id}`,
|
||||
path: `/${m.id}${paramPart}`,
|
||||
name: m.id,
|
||||
component: m.component,
|
||||
meta: { moduleId: m.id }
|
||||
|
||||
Reference in New Issue
Block a user