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:
2026-07-22 02:22:59 +00:00
parent 19b301b937
commit b5dcf924fb
10 changed files with 409 additions and 179 deletions

234
README.md
View File

@@ -1,209 +1,115 @@
# mainPage · 作品集主页
Zikai 的作品集主页 -- 一个 **Vue 3 模块化**的多项目展示站
采用**浅色极简**美术风格,**中英双语**并支持**懒加载**(默认中文,只看中文的访客不会下载英文资源)
设计目标:高内聚低耦合、可扩展 -- 后续每个新项目都是一个独立「模块」,自动生成页签与路由。
> 这是「主页」项目本身。它展示的第一个项目页签是 448/449 移动游戏毕设,
> 详见同级目录下的 `/root/html/448/README.md` 与 `/root/html/449/README.md`。
Zikai 的作品集主页 -- Vue 3 模块化多项目展示站。浅色极简、中英双语(英文按需懒加载)
每个项目页签是一个独立「模块」,由注册表自动生成页签与路由 -- 新增/删除页签只增删一个文件夹,互不影响
---
## 技术栈
| 层 | 选型 |
|---|---|
| 框架 | Vue 3`<script setup>` Composition API |
| 构建 | Vite 5 |
| 路由 | vue-router 4history 模式,路由表由模块注册表动态生成) |
| 国际化 | vue-i18n 9中文同步注入英文动态 `import()` 懒加载) |
| 样式 | 原生 CSS + CSS 变量令牌(无 UI 框架,零运行时样式依赖) |
Node 版本要求:≥ 18本项目在 Node 18.19 + npm 9.2 下开发与构建)。
---
## 目录结构
## 项目结构
```
mainPage/
├── index.html # Vite 入口模板
├── index.html
├── package.json
├── vite.config.js # ★ 构建输出配置(见下「构建与部署」
├── .gitignore
├── README.md # 本文件
├── public/
│ └── favicon.svg # 站点图标
├── vite.config.js # 构建输出到 /root/htmlemptyOutDir=false不清空既有文件
├── README.md
├── public/favicon.svg
└── src/
├── main.js # 应用入口(挂载 i18nrouter全局样式)
├── main.js # 应用入口(挂载 i18n / router / 全局样式)
├── App.vue # 根布局:顶栏 + 标签导航 + <router-view> + 页脚
├── i18n/ # 国际化(懒加载核心
├── index.js # i18n 实例仅同步注入中文loadLocaleAsync() 动态切语言
── locales/
├── zh-CN.js # 中文(默认,打进主 chunk
│ └── en.js # 英文(动态 import() -> 独立 chunk切换时才下载
├── router/
── index.js # 路由表遍历模块注册表自动生成
├── modules/ # ★★★ 扩展点:每个项目一个模块文件夹
│ ├── index.js # 模块注册表import.meta.glob 约定式收集
│ └── mobile-game/ # 第一个页签448/449 移动游戏项目
│ ├── index.js # 模块元数据id / tabKey / order / 懒加载组件)
│ ├── MobileGame.vue # 项目展示主页
│ ├── components/
│ │ ├── HeroSection.vue # 顶部项目概览横幅
│ │ ├── ScreenshotCarousel.vue# 截图轮播(复用 /448/1..3.jpg
│ │ ├── VersionTimeline.vue # 版本演进时间线
│ │ ├── TeamCard.vue # 团队成员卡片
│ │ └── Leaderboard.vue # 排行榜AJAX 调 /449/449rest.php失败降级
│ └── data/
│ ├── versions.js # 版本数据448 各版本信息)
│ └── team.js # 团队成员数据
├── components/ # 全局共享组件
│ ├── TabNav.vue # 标签页导航(由模块注册表驱动,自动生成页签)
│ ├── LanguageSwitcher.vue # 中/英切换按钮
│ └── ui/ # 基础 UI 基元
│ ├── Card.vue
│ └── Tag.vue
├── composables/ # 组合式逻辑
│ ├── useProjects.js # 读取已注册模块列表
│ └── useI18nLazy.js # 语言懒加载切换逻辑
└── styles/
├── variables.css # 主题令牌(浅色极简:色板/圆角/阴影/间距)
└── base.css # 重置 + 排版基础
├── config/
│ └── app.config.js # ★ 集中配置:外部服务 URL白板地址 / 存活探针等
├── i18n/ # 国际化(中文同步注入;英文动态 import() 懒加载)
── index.js
└── locales/{zh-CN,en}.js
├── router/index.js # 路由表由模块注册表自动生成
├── modules/ # ★ 扩展点:每个页签一个文件夹
│ ├── index.js # 注册表import.meta.glob 约定式收集,带容错)
── mobile-game/ # 移动游戏项目页签
├── whiteboard/ # 共享记事本页签iframe 嵌入 + 存活校验)
│ └── calendar/ # 隐藏页(仅手动访问 /cqy
├── components/ # 共享组件TabNav / LanguageSwitcher / ui 基元
├── composables/ # useProjects页签+存活联动)/ useLiveness / useI18nLazy / useLocale
└── styles/ # variables.css主题令牌+ base.css
```
---
## 模块机制(高内聚低耦合)
## 架构要点
### 1. 模块化扩展(高内聚低耦合)
**新增一个项目页签 = 新建 `src/modules/<新项目>/` 文件夹**,无需改动导航或路由代码。
- `src/modules/index.js``import.meta.glob('./*/index.js', { eager: true })` 约定式收集所有模块。
- `src/modules/index.js``import.meta.glob('./*/index.js', { eager: true })` 收集所有模块,单个模块导出异常会跳过并告警,不影响其余页签。
- 每个模块 `index.js` 默认导出元数据:
```js
export default {
id: 'mobile-game', // 唯一标识,同时作路由 path
tabKey: 'tabs.mobileGame', // i18n 键,页签标题
order: 1, // 页签排序
component: () => import('./MobileGame.vue') // 懒加载组件
id: 'whiteboard', // 唯一标识 + 路由 path
tabKey: 'tabs.whiteboard', // i18n 键
order: 2, // 页签排序
component: () => import('./Whiteboard.vue'), // 懒加载组件(独立 chunk
requiresAlive: 'https://f.zikai.wang/health' // 可选:存活探针 URL挂掉则隐藏页签
}
```
- `TabNav.vue` 遍历注册表渲染页签;`router/index.js` 遍历注册表生成路由。
- 模块自包含组件、数据,模块间无直接引用 -> 高内聚低耦合
- `TabNav` 遍历注册表渲染页签;`router` 遍历注册表生成路由。
- 删除一个页签 = 删 `src/modules/<id>/` 文件夹并重新 build其余页签不受影响
### 2. i18n 懒加载(中文页不加载英文资源)
### 存活校验
- 创建 i18n 实例时**只同步注入中文** `zh-CN`(随主 chunk 下发)。
- `en.js` 通过 `() => import('./locales/en.js')` 注册Vite/Rollup 自动拆成**独立 chunk** `en.[hash].js`
- 切换英文时 `useI18nLazy` 先 `await import()` 拉取英文 chunk`mergeLocaleMessage` 后再切 `locale`。
- 结果:只看中文的访客**永不请求英文 chunk**;首次切英文才下载,之后切换无网络请求。
- 构建产物可见 `js/mainPage/en.[hash].js`(约 0.27 KB独立存在。
声明了 `requiresAlive` 的模块,应用启动时对探针 URL 做一次 `no-cors` fetch 探测4s 超时)。
探测返回前该页签不显示(避免闪烁);服务存活则显示,不可达则保持隐藏(刷新页面才重探)
### 3. 浅色极简美术
## 依赖的基础设施
主题令牌集中在 `src/styles/variables.css`:白底 `#fff` / 浅灰面 `#f7f8fa`、单一靛蓝强调色 `#4f46e5`、圆角 12px、柔和阴影、细线边框、充足留白。无深色、无渐变铺底、无霓虹悬浮微交互克制轻微上移 + 阴影加深)。
| 项 | 要求 |
|---|---|
| Node.js | ≥ 18开发于 18.19 + npm 9.2 |
| Web 服务器 | Apache 2.4(启用 `mod_rewrite`),对外静态服务 `/root/html` |
| SPA 回退 | history 模式需站点根 `.htaccess` 回退到 `index.html`(见下) |
---
> 共享记事本页签通过 iframe 嵌入外部服务(默认 `https://f.zikai.wang/wb/shared`URL 在 `src/config/app.config.js` 集中配置。该服务需公开且允许被 iframe 嵌入。
## 开发
## Ubuntu 从 0 安装
```bash
cd /root/zikai/mainPage
npm install # 首次安装依赖
npm run dev # 启动开发服务器 (http://localhost:5173)
# 1. 安装 Node.js 18
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo bash -
sudo apt-get install -y nodejs
# 2. 取代码
git clone https://git.zikai.wang/zikai/zMainPage.git mainPage
cd mainPage
npm install
# 3. 构建(产物输出到 /root/html不清空既有文件
npm run build
# 4. 安装并启用 Apache
sudo apt-get install -y apache2
sudo a2enmod rewrite
```
开发服务器配置了代理:`/448`、`/449` 请求转发到 `http://localhost:8080`
(假设该端口有静态服务器在跑,否则截图/排行榜/视频会 404但页面其余部分正常
开发时可临时用 `python3 -m http.server 8080 --directory /root/html` 提供这些静态资源。
## 构建与打包
```bash
npm run build # 产物输出到 /root/html
```
### 输出路径与安全策略(`vite.config.js`
- `build.outDir = '/root/html'`**`emptyOutDir = false`**(★ 不清空 outDir
`/root/html` 已含 448/449/旧 js/css 等既有内容,绝不能被构建清空。
- 用 `mainPage/` 命名空间子目录避免与 `/root/html/js`、`/root/html/css` 旧文件冲突:
- JS -> `js/mainPage/[name].[hash].js`
- CSS -> `css/mainPage/[name].[hash].css`
- 其他资源 -> `assets/mainPage/[name].[hash][extname]`
- `base = '/'`,引用既有 448/449 资源用站点根绝对路径(`/448/...`、`/449/...`)。
### 构建产物示例
```
/root/html/
├── index.html # 入口Vite 注入 <script>/<link>
├── favicon.svg
├── js/mainPage/
│ ├── index.[hash].js # 主 chunk含 Vue/router/中文 i18n
│ ├── MobileGame.[hash].js # 移动游戏页(懒加载)
│ └── en.[hash].js # 英文语言包(懒加载,仅切英文时下载)
└── css/mainPage/
├── index.[hash].css # 全局样式
└── MobileGame.[hash].css # 移动游戏页样式
```
## 预览构建产物
```bash
npm run preview # 本地预览构建后的产物
```
## 部署
构建产物直接落在站点根 `/root/html`,由 Web 服务器以该目录为根提供静态服务。
本机实际情况:**Apache 2.4**(非 nginx对外服务 `https://zikai.wang`
`DocumentRoot /var/www/html``/var/www/html` 与 `/root/html` 内容同步),
`mod_rewrite` 已启用且 `AllowOverride All`。
### ★ SPA 回退history 模式必需)
本站用 Vue Router **history 模式**,客户端路由如 `/mobile-game` 并非真实文件。
直接访问或刷新此类 URL 时,服务器会找不到文件而 404。已在 `/root/html/.htaccess`
配置回退(该文件在 `/var/www/html` 同步可见):
站点根放 `.htaccess`Apache 需 `AllowOverride All`)做 SPA 回退:
```apache
RewriteEngine On
# 真实文件/目录直接放行
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
# 其余路由回退到 index.html由前端路由处理
RewriteRule ^ index.html [L]
```
> 该 `.htaccess` 是站点根的部署配置,不属于本项目源码,故未纳入 `mainPage` git 仓库
> 若重新部署/迁移服务器,需在站点根放置此文件(并确保 Apache 允许 `.htaccess` 覆盖)。
主页引用的 `/448/...`、`/449/...` 资源必须能从站点根访问到448/449 目录本就在 `/root/html` 下)。
---
把 `/root/html`(或 `/var/www/html`)设为 `DocumentRoot`,访问站点即可。开发预览用 `npm run dev`http://localhost:5173
## 如何新增一个项目页签
1. 复制 `src/modules/mobile-game/` 为 `src/modules/<新项目>/`。
2. 编辑 `<新项目>/index.js`,改 `id``tabKey``order``component` 指向
3. 在 `src/i18n/locales/zh-CN.js` 与 `en.js` 的 `tabs` 下新增对应的 `tabKey` 文案。
4. 实现模块主组件与子组件(可复用 `components/ui/` 基元)
5. `npm run build` -- 页签与路由自动出现,无需改动 `TabNav` 或 `router`。
1. 复制 `src/modules/whiteboard/`(或 `mobile-game/`为 `src/modules/<新项目>/`。
2. `<新项目>/index.js` `id` / `tabKey` / `order` / `component`。
3. 在 `src/i18n/locales/zh-CN.js` 与 `en.js` 的 `tabs` 下加对应文案。
4. `npm run build` -- 页签与路由自动出现,无需改导航或路由代码
---
## 已知限制
## 开发命令
- 排行榜依赖 `/449/449rest.php`PHP + MySQL在线后端不可用时组件优雅降级显示「服务暂不可用」。
- 演示视频 `/448/ml/449ml.mp4`(约 34 MB用 `preload="none"`,点播才加载。
- 448/449 的 README 总结了既有项目(构建产物,无源码),见各自目录。
```bash
npm install # 首次安装依赖
npm run dev # 开发服务器 http://localhost:5173
npm run build # 构建到 /root/html
npm run preview # 本地预览构建产物
```

View 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
})
)
}

View File

@@ -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
View 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}`
}

View File

@@ -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: {

View File

@@ -5,11 +5,16 @@ export default {
},
common: {
loading: '加载中…',
notAvailable: '暂不可用'
notAvailable: '暂不可用',
serviceUnavailable: '服务暂不可用,请稍后再试。'
},
tabs: {
mobileGame: '移动游戏项目',
calendar: 'cqy课程表'
calendar: 'cqy课程表',
whiteboard: '共享记事本'
},
whiteboard: {
openExternal: '在新标签页打开'
},
mobileGame: {
hero: {

View File

@@ -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

View 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>

View File

@@ -0,0 +1,14 @@
// 模块元数据 -- 共享记事本(白板)页签
// requiresAlive 指向存活探针 URL启动时探测服务不可达则隐藏该页签。
// 删除本目录即可移除此页签,其余模块不受影响。
import { healthUrl } from '../../config/app.config.js'
export default {
id: 'whiteboard',
tabKey: 'tabs.whiteboard',
order: 2,
// 存活探针 URLuseProjects 启动时探测一次,挂掉则不显示页签
requiresAlive: healthUrl('whiteboard'),
// 懒加载组件 -> 独立 chunk
component: () => import('./Whiteboard.vue')
}

View File

@@ -2,18 +2,29 @@
import { createRouter, createWebHistory } from 'vue-router'
import modules from '../modules/index.js'
const routes = [
{
path: '/',
redirect: modules.length ? `/${modules[0].id}` : '/404'
},
// 每个模块一条路由,组件懒加载
...modules.map((m) => ({
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: moduleRoutes.length ? moduleRoutes[0].path : '/404'
},
// 每个模块一条路由,组件懒加载
...moduleRoutes,
{
path: '/:pathMatch(.*)*',
name: 'not-found',