fix: 白板页签乐观显示 + 白板切换输入框 + /whiteboard/{id} 直链
- 存活逻辑改为乐观显示:探测中(pending)/存活(up) 都显示页签,仅明确 不可达(down) 才隐藏。修复首屏看不到页签、需刷新才出现的问题 - 白板页加 ID 输入框与切换按钮,可切换不同白板,默认 share - 路由支持 /whiteboard/:boardId 可选参数,直链访问自动填入输入框并切换 - 模块可声明 param 让路由表生成子路径,保持注册表驱动
This commit is contained in:
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export default {
|
|||||||
whiteboard: '共享记事本'
|
whiteboard: '共享记事本'
|
||||||
},
|
},
|
||||||
whiteboard: {
|
whiteboard: {
|
||||||
openExternal: '在新标签页打开'
|
openExternal: '在新标签页打开',
|
||||||
|
boardId: '白板 ID',
|
||||||
|
switch: '切换'
|
||||||
},
|
},
|
||||||
mobileGame: {
|
mobileGame: {
|
||||||
hero: {
|
hero: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 提交输入框:切换白板并更新 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 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">
|
||||||
<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>
|
<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;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ export default {
|
|||||||
order: 2,
|
order: 2,
|
||||||
// 存活探针 URL;useProjects 启动时探测一次,挂掉则不显示页签
|
// 存活探针 URL;useProjects 启动时探测一次,挂掉则不显示页签
|
||||||
requiresAlive: healthUrl('whiteboard'),
|
requiresAlive: healthUrl('whiteboard'),
|
||||||
|
// 接受 /whiteboard/:boardId 子路径,使直链访问能预填白板 id
|
||||||
|
param: 'boardId',
|
||||||
// 懒加载组件 -> 独立 chunk
|
// 懒加载组件 -> 独立 chunk
|
||||||
component: () => import('./Whiteboard.vue')
|
component: () => import('./Whiteboard.vue')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user