- 新增可复用 PageShell 组件(titleKey/fluid/padded + actions 插槽), 统一容器宽度(1080px)、垂直节奏与标题,消除各模块重复包裹 - whiteboard/calendar/mobile-game 全部改用 PageShell,去掉各自手写的 .container/.__inner/.__title 重复样式;calendar 用 fluid 全宽变体 - timetable 改用 mainPage 侧包装器 Timetable.vue:import 子模块 App.vue 套 PageShell(对齐其他页宽度),并持有 KeepAlive 匹配名 name:'timetable' - 子模块 timeTableFix 的 App.vue 去掉 defineOptions(保持纯净),name 约定 由 mainPage 包装器持有,消除跨仓库脆弱约定;submodule 升级到 17aeea8 - 注册表清理:去掉无效 try/catch,order 缺省改 999(避免与 hidden 页 99 冲突), 注释文档化全部元数据字段与 KeepAlive 约定 - 新增页签照 timetable 模式复用 PageShell,注册方式一致
245 lines
6.2 KiB
Vue
245 lines
6.2 KiB
Vue
<script setup>
|
||
import { ref, computed, watch } from 'vue'
|
||
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配;
|
||
// 白板后端失活时把它从 include 移除即可卸载组件
|
||
defineOptions({ name: 'whiteboard' })
|
||
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'
|
||
import PageShell from '../../components/ui/PageShell.vue'
|
||
|
||
const { t } = useLocale()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
const wbConfig = appConfig.whiteboard
|
||
|
||
// 当前白板 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
|
||
}
|
||
function scheduleFailCheck() {
|
||
setTimeout(() => {
|
||
if (!loaded.value) failed.value = true
|
||
}, 6000)
|
||
}
|
||
scheduleFailCheck()
|
||
|
||
function openExternal() {
|
||
window.open(boardSrc.value, '_blank', 'noopener')
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<PageShell title-key="tabs.whiteboard">
|
||
<template #actions>
|
||
<form class="wb-switch" @submit.prevent="submitBoard">
|
||
<label class="wb-switch__label" for="boardInput">
|
||
{{ t('whiteboard.boardId') }}
|
||
</label>
|
||
<input
|
||
id="boardInput"
|
||
v-model="inputId"
|
||
class="wb-switch__input"
|
||
type="text"
|
||
:placeholder="wbConfig.defaultBoardId"
|
||
spellcheck="false"
|
||
autocomplete="off"
|
||
autocapitalize="off"
|
||
/>
|
||
<button type="submit" class="wb-switch__go">
|
||
{{ t('whiteboard.switch') }}
|
||
</button>
|
||
</form>
|
||
</template>
|
||
|
||
<!-- 服务不可达 / iframe 加载超时降级 -->
|
||
<div v-if="isDown || failed" class="wb-fallback">
|
||
<p class="wb-fallback__text">{{ t('common.serviceUnavailable') }}</p>
|
||
<button type="button" class="wb-fallback__open" @click="openExternal">
|
||
{{ t('whiteboard.openExternal') }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else class="wb-frame">
|
||
<!-- 加载骨架(iframe 加载完成后淡出) -->
|
||
<Transition name="fade">
|
||
<div v-if="!loaded" class="wb-frame__skeleton">
|
||
<div class="wb-frame__skeleton-text">{{ t('common.loading') }}</div>
|
||
</div>
|
||
</Transition>
|
||
<iframe
|
||
:src="boardSrc"
|
||
class="wb-frame__iframe"
|
||
frameborder="0"
|
||
:title="t('tabs.whiteboard')"
|
||
@load="onLoad"
|
||
/>
|
||
</div>
|
||
</PageShell>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.wb-switch {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--space-xs);
|
||
}
|
||
.wb-switch__label {
|
||
font-size: 0.85rem;
|
||
color: var(--text-secondary);
|
||
}
|
||
.wb-switch__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-switch__input:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
}
|
||
.wb-switch__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-switch__go:hover {
|
||
background: var(--accent-hover);
|
||
}
|
||
.wb-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;
|
||
background: var(--surface);
|
||
}
|
||
.wb-frame__iframe {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
border: 0;
|
||
}
|
||
.wb-frame__skeleton {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--surface);
|
||
}
|
||
.wb-frame__skeleton-text {
|
||
color: var(--text-tertiary);
|
||
font-size: 0.95rem;
|
||
}
|
||
.wb-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-fallback__text {
|
||
color: var(--text-secondary);
|
||
margin-bottom: var(--space-md);
|
||
}
|
||
.wb-fallback__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-fallback__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-switch__input {
|
||
width: 120px;
|
||
}
|
||
.wb-frame {
|
||
height: 65vh;
|
||
min-height: 360px;
|
||
}
|
||
}
|
||
</style>
|