Compare commits
36 Commits
15af291da0
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 25dbceee2d | |||
| 5b1acbdeb7 | |||
| 1cc2ec1c9f | |||
| 4c7245f919 | |||
| ae540a1a70 | |||
| 304ebc9448 | |||
| 39f3056099 | |||
| 66f93c67f5 | |||
| 04f11b5432 | |||
| 3e5c1d5494 | |||
|
|
1379e1a607 | ||
|
|
cb50c23c84 | ||
|
|
05a594d0cb | ||
|
|
08c1b457e4 | ||
|
|
d9309756c7 | ||
|
|
6ad6c15e9d | ||
| 61e65052d4 | |||
| b3960f503a | |||
| b511998372 | |||
| 4334dec00d | |||
| a844f364dc | |||
| 91d55f1b11 | |||
| 7d81da65cf | |||
| 711026be9f | |||
| 2c33457c87 | |||
| 370388e729 | |||
| b5dcf924fb | |||
|
|
19b301b937 | ||
|
|
337d516c08 | ||
|
|
49900d2363 | ||
|
|
c6dad2b79a | ||
|
|
b3556b8639 | ||
|
|
88979a68c2 | ||
|
|
8e08e619fc | ||
|
|
0adb320a47 | ||
|
|
e8dee338ed |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
|||||||
# 依赖
|
# 依赖
|
||||||
node_modules/
|
node_modules/
|
||||||
|
# 子模块的依赖(node_modules 已被上一条覆盖,以下仅作显式说明)
|
||||||
|
third_party/timeTableFix/node_modules/
|
||||||
|
third_party/z449/node_modules/
|
||||||
|
|
||||||
# 构建产物(输出到 /root/html,不入库)
|
# 构建产物(输出到 /root/html,不入库)
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
12
.gitmodules
vendored
Normal file
12
.gitmodules
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[submodule "third_party/timeTableFix"]
|
||||||
|
path = third_party/timeTableFix
|
||||||
|
url = https://git.zikai.wang/zikai/timeTableFix.git
|
||||||
|
[submodule "third_party/z449"]
|
||||||
|
path = third_party/z449
|
||||||
|
url = https://git.zikai.wang/zikai/z449.git
|
||||||
|
[submodule "third_party/zPDF_package"]
|
||||||
|
path = third_party/zPDF_package
|
||||||
|
url = https://git.zikai.wang/zikai/zPDF_package.git
|
||||||
|
[submodule "third_party/zWhiteBoard"]
|
||||||
|
path = third_party/zWhiteBoard
|
||||||
|
url = https://git.zikai.wang/zikai/zWhiteBoard.git
|
||||||
278
README.md
278
README.md
@@ -1,188 +1,128 @@
|
|||||||
# mainPage · 作品集主页
|
# mainPage · 作品集主页
|
||||||
|
|
||||||
Zikai 的作品集主页 -- 一个 **Vue 3 模块化**的多项目展示站点。
|
Zikai 的作品集主页 -- Vue 3 模块化多项目展示站。浅色极简、中英双语(英文按需懒加载)。每个项目页签是一个独立「模块」,由注册表自动生成页签与路由 -- 新增/删除页签只增删一个文件夹,互不影响。
|
||||||
采用**浅色极简**美术风格,**中英双语**并支持**懒加载**(默认中文,只看中文的访客不会下载英文资源)。
|
|
||||||
设计目标:高内聚低耦合、可扩展 -- 后续每个新项目都是一个独立「模块」,自动生成页签与路由。
|
|
||||||
|
|
||||||
> 这是「主页」项目本身。它展示的第一个项目页签是 448/449 移动游戏毕设,
|
> 架构细节、新增页签指南、子项目管理见 [`docs/`](./docs/)。
|
||||||
> 详见同级目录下的 `/root/html/448/README.md` 与 `/root/html/449/README.md`。
|
|
||||||
|
|
||||||
---
|
## 项目结构
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
| 层 | 选型 |
|
|
||||||
|---|---|
|
|
||||||
| 框架 | Vue 3(`<script setup>` Composition API) |
|
|
||||||
| 构建 | Vite 5 |
|
|
||||||
| 路由 | vue-router 4(history 模式,路由表由模块注册表动态生成) |
|
|
||||||
| 国际化 | vue-i18n 9(中文同步注入,英文动态 `import()` 懒加载) |
|
|
||||||
| 样式 | 原生 CSS + CSS 变量令牌(无 UI 框架,零运行时样式依赖) |
|
|
||||||
|
|
||||||
Node 版本要求:≥ 18(本项目在 Node 18.19 + npm 9.2 下开发与构建)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 目录结构
|
|
||||||
|
|
||||||
```
|
```
|
||||||
mainPage/
|
mainPage/
|
||||||
├── index.html # Vite 入口模板
|
├── index.html
|
||||||
├── package.json
|
├── package.json # vue / vue-i18n / vue-router
|
||||||
├── vite.config.js # ★ 构建输出配置(见下「构建与部署」)
|
├── vite.config.js # 构建输出到 dist/,再由 npm run deploy rsync 到 /var/www/html
|
||||||
├── .gitignore
|
├── deploy.sh # 整体部署:构建 + rsync + Apache 反代 + 重启 zTools2
|
||||||
├── README.md # 本文件
|
├── .gitmodules # git submodule:timeTableFix / z449 / zPDF_package / zWhiteBoard
|
||||||
├── public/
|
├── README.md
|
||||||
│ └── favicon.svg # 站点图标
|
├── docs/ # 架构 / 新增页签 / 子模块管理 / 部署文档
|
||||||
|
├── public/favicon.svg
|
||||||
|
├── third_party/ # ★ git submodule 子项目
|
||||||
|
│ ├── timeTableFix/ # ICS 日程整理器(纯前端)
|
||||||
|
│ ├── z449/ # 移动游戏项目展示页(纯前端)
|
||||||
|
│ ├── zPDF_package/ # PDF 转换前端(同源调用 zTools2 /api/pdf/*)
|
||||||
|
│ └── zWhiteBoard/ # 共享记事本前端(同源调用 zTools2 /api/wb/* + /api/ws/wb/*)
|
||||||
└── src/
|
└── src/
|
||||||
├── main.js # 应用入口(挂载 i18n、router、全局样式)
|
├── main.js # 应用入口(挂载 i18n / router / 全局样式)
|
||||||
├── App.vue # 根布局:顶栏 + 标签导航 + <router-view> + 页脚
|
├── App.vue # 根布局:顶栏 + 标签导航 + <KeepAlive router-view> + 页脚
|
||||||
│
|
├── config/app.config.js # 集中配置:各服务的同源探针路径(/api/health)与默认参数
|
||||||
├── i18n/ # 国际化(懒加载核心)
|
├── i18n/ # 国际化(中文同步注入;英文动态 import() 懒加载)
|
||||||
│ ├── index.js # i18n 实例;仅同步注入中文;loadLocaleAsync() 动态切语言
|
├── router/index.js # 路由表由模块注册表自动生成
|
||||||
│ └── locales/
|
├── modules/ # ★ 扩展点:每个页签一个文件夹
|
||||||
│ ├── zh-CN.js # 中文(默认,打进主 chunk)
|
│ ├── index.js # 注册表(import.meta.glob 约定式收集,带容错)
|
||||||
│ └── en.js # 英文(动态 import() -> 独立 chunk,切换时才下载)
|
│ ├── mobile-game/ # 移动游戏页签(包装 z449 子模块)
|
||||||
│
|
│ ├── whiteboard/ # 共享记事本页签(包装 zWhiteBoard 子模块 + 存活校验)
|
||||||
├── router/
|
│ ├── timetable/ # 日程整理页签(包装 timeTableFix 子模块)
|
||||||
│ └── index.js # 路由表遍历模块注册表自动生成
|
│ ├── pdf/ # PDF 转换页签(包装 zPDF_package 子模块 + 存活校验)
|
||||||
│
|
│ └── calendar/ # 隐藏页(仅手动访问 /cqy)
|
||||||
├── modules/ # ★★★ 扩展点:每个项目一个模块文件夹
|
├── components/ # 共享组件(TabNav / LanguageSwitcher / ui 基元含 PageShell)
|
||||||
│ ├── index.js # 模块注册表(import.meta.glob 约定式收集)
|
├── composables/ # useProjects / useLiveness / useI18nLazy / useLocale
|
||||||
│ └── mobile-game/ # 第一个页签:448/449 移动游戏项目
|
└── styles/ # variables.css(主题令牌)+ base.css
|
||||||
│ ├── 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 # 重置 + 排版基础
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## 外部依赖
|
||||||
|
|
||||||
## 架构要点
|
| 项 | 要求 |
|
||||||
|
|---|---|
|
||||||
|
| Node.js | ≥ 18 |
|
||||||
|
| Web 服务器 | Apache 2.4(启用 `mod_rewrite` / `mod_proxy` / `mod_proxy_wstunnel`),对外静态服务构建产物 |
|
||||||
|
| SPA 回退 | history 模式需站点根 `.htaccess` 回退到 `index.html` |
|
||||||
|
| 后端 | zTools2(127.0.0.1:6867),由 Apache 反代 `/api/`;PDF/白板页签依赖其 REST API 与 WebSocket |
|
||||||
|
|
||||||
### 1. 模块化扩展(高内聚低耦合)
|
> 四个子模块均为**构建期组件集成(非 iframe)**,共享 mainPage 的构建与 KeepAlive 缓存。各子模块依赖在其自身 `node_modules`,由各自 `package.json` 管理。
|
||||||
|
|
||||||
**新增一个项目页签 = 新建 `src/modules/<新项目>/` 文件夹**,无需改动导航或路由代码。
|
## 如何使用
|
||||||
|
|
||||||
- `src/modules/index.js` 用 `import.meta.glob('./*/index.js', { eager: true })` 约定式收集所有模块。
|
```bash
|
||||||
- 每个模块 `index.js` 默认导出元数据:
|
# 1. 安装 Node.js 18
|
||||||
```js
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo bash -
|
||||||
export default {
|
sudo apt-get install -y nodejs
|
||||||
id: 'mobile-game', // 唯一标识,同时作路由 path
|
|
||||||
tabKey: 'tabs.mobileGame', // i18n 键,页签标题
|
# 2. 取代码(含四个子模块)
|
||||||
order: 1, // 页签排序
|
git clone --recursive https://git.zikai.wang/zikai/zMainPage.git mainPage
|
||||||
component: () => import('./MobileGame.vue') // 懒加载组件
|
cd mainPage
|
||||||
}
|
# 若已 clone 但未带子模块:git submodule update --init --recursive
|
||||||
```
|
npm install
|
||||||
- `TabNav.vue` 遍历注册表渲染页签;`router/index.js` 遍历注册表生成路由。
|
# 子模块需各自安装依赖(构建期编译其 .vue)
|
||||||
- 模块自包含组件、数据,模块间无直接引用 -> 高内聚低耦合。
|
(cd third_party/timeTableFix && npm install)
|
||||||
|
(cd third_party/z449 && npm install)
|
||||||
### 2. i18n 懒加载(中文页不加载英文资源)
|
(cd third_party/zPDF_package && npm install)
|
||||||
|
(cd third_party/zWhiteBoard && npm install)
|
||||||
- 创建 i18n 实例时**只同步注入中文** `zh-CN`(随主 chunk 下发)。
|
|
||||||
- `en.js` 通过 `() => import('./locales/en.js')` 注册,Vite/Rollup 自动拆成**独立 chunk** `en.[hash].js`。
|
# 3. 构建到 dist/(再由 npm run deploy rsync 同步到 /var/www/html)
|
||||||
- 切换英文时 `useI18nLazy` 先 `await import()` 拉取英文 chunk,`mergeLocaleMessage` 后再切 `locale`。
|
npm run build
|
||||||
- 结果:只看中文的访客**永不请求英文 chunk**;首次切英文才下载,之后切换无网络请求。
|
|
||||||
- 构建产物可见 `js/mainPage/en.[hash].js`(约 0.27 KB)独立存在。
|
# 4. 安装并启用 Apache
|
||||||
|
sudo apt-get install -y apache2
|
||||||
### 3. 浅色极简美术
|
sudo a2enmod rewrite proxy proxy_http proxy_wstunnel headers
|
||||||
|
```
|
||||||
主题令牌集中在 `src/styles/variables.css`:白底 `#fff` / 浅灰面 `#f7f8fa`、单一靛蓝强调色 `#4f46e5`、圆角 12px、柔和阴影、细线边框、充足留白。无深色、无渐变铺底、无霓虹;悬浮微交互克制(轻微上移 + 阴影加深)。
|
|
||||||
|
站点根放 `.htaccess`(Apache 需 `AllowOverride All`)做 SPA 回退:
|
||||||
---
|
|
||||||
|
```apache
|
||||||
## 开发
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
RewriteRule ^ index.html [L]
|
||||||
|
```
|
||||||
|
|
||||||
|
并把 `/var/www/html` 设为 `DocumentRoot`。后端 zTools2 需监听 `127.0.0.1:6867`,并在 vhost 加反代规则(见下方 Apache 配置)。整体部署可用 `./deploy.sh` 一键完成(详见 [`docs/deployment.md`](./docs/deployment.md))。
|
||||||
|
|
||||||
|
### Apache 反代 zTools2(/api/ -> 127.0.0.1:6867)
|
||||||
|
|
||||||
|
```apache
|
||||||
|
<VirtualHost *:80>
|
||||||
|
DocumentRoot /var/www/html
|
||||||
|
ProxyPreserveHost On
|
||||||
|
# 所有 zTools2 入口(API/静态/探针/WS)统一在 /api/ 下,一条规则即可
|
||||||
|
ProxyPass /api/ http://127.0.0.1:6867/api/
|
||||||
|
ProxyPassReverse /api/ http://127.0.0.1:6867/api/
|
||||||
|
<Directory /var/www/html>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
```
|
||||||
|
|
||||||
|
> WebSocket 走 `/api/ws/wb/{id}`,需 `a2enmod proxy_wstunnel` 以透传 Upgrade 头。
|
||||||
|
|
||||||
|
## 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /root/zikai/mainPage
|
|
||||||
npm install # 首次安装依赖
|
npm install # 首次安装依赖
|
||||||
npm run dev # 启动开发服务器 (http://localhost:5173)
|
npm run dev # 开发服务器 http://localhost:5173
|
||||||
|
npm run build # 构建到 dist/
|
||||||
|
npm run deploy # 构建并 rsync 同步到 /var/www/html
|
||||||
|
npm run preview # 本地预览构建产物
|
||||||
```
|
```
|
||||||
|
|
||||||
开发服务器配置了代理:`/448`、`/449` 请求转发到 `http://localhost:8080`
|
## 了解更多
|
||||||
(假设该端口有静态服务器在跑,否则截图/排行榜/视频会 404,但页面其余部分正常)。
|
|
||||||
开发时可临时用 `python3 -m http.server 8080 --directory /root/html` 提供这些静态资源。
|
|
||||||
|
|
||||||
## 构建与打包
|
- [架构(模块注册 / PageShell / KeepAlive / 存活校验)](./docs/architecture.md)
|
||||||
|
- [如何新增一个项目页签](./docs/add-module.md)
|
||||||
```bash
|
- [子项目 timeTableFix 管理(submodule 升级 / 移除)](./docs/submodule-timeTableFix.md)
|
||||||
npm run build # 产物输出到 /root/html
|
- [子项目 z449 管理(submodule + i18n prop 桥接)](./docs/submodule-z449.md)
|
||||||
```
|
- [子项目 zPDF_package 管理(submodule + 同源 API 集成)](./docs/submodule-zPDF_package.md)
|
||||||
|
- [子项目 zWhiteBoard 管理(submodule + 同源 API/WS 集成)](./docs/submodule-zWhiteBoard.md)
|
||||||
### 输出路径与安全策略(`vite.config.js`)
|
- [部署指南(组件独立 / 整体 / 持久化 systemd)](./docs/deployment.md)
|
||||||
|
- [README 勘误记录](./docs/readme-fixes.md)
|
||||||
- `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 服务器(如 nginx)以 `/root/html` 为根提供静态服务即可。
|
|
||||||
注意:当前主机上 nginx 默认 root 为 `/var/www/html`(与 `/root/html` 内容同步);
|
|
||||||
若需以 `/root/html` 为根对外服务,请调整 nginx 的 `root` 指令并启用 nginx。
|
|
||||||
主页引用的 `/448/...`、`/449/...` 资源必须能从站点根访问到(448/449 目录本就在 `/root/html` 下)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 如何新增一个项目页签
|
|
||||||
|
|
||||||
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`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 已知限制
|
|
||||||
|
|
||||||
- 排行榜依赖 `/449/449rest.php`(PHP + MySQL)在线;后端不可用时组件优雅降级显示「服务暂不可用」。
|
|
||||||
- 演示视频 `/448/ml/449ml.mp4`(约 34 MB)用 `preload="none"`,点播才加载。
|
|
||||||
- 448/449 的 README 总结了既有项目(构建产物,无源码),见各自目录。
|
|
||||||
|
|||||||
95
deploy.sh
Executable file
95
deploy.sh
Executable file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 整体部署:构建并部署 zMainPage(含子模块)+ zTools2(后端)到生产环境。
|
||||||
|
#
|
||||||
|
# 生产拓扑:
|
||||||
|
# Apache(:80) -> 静态文件 /var/www/html(zMainPage 构建产物)
|
||||||
|
# -> 反代 /api/ 到 zTools2(127.0.0.1:6867)(页面/静态/探针/WS/API 全在 /api/ 下)
|
||||||
|
# zTools2(127.0.0.1:6867) -> MySQL(3306) + uploads/ 磁盘
|
||||||
|
#
|
||||||
|
# 前提:zMainPage 与 zTools2 在同一主机(zMainPage 父项目,zTools2 同级目录)。
|
||||||
|
# 用法:./deploy.sh [--no-restart] (--no-restart 仅更新文件不重启 zTools2)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
ROOT="$(pwd)"
|
||||||
|
RESTART=1
|
||||||
|
[[ "${1:-}" == "--no-restart" ]] && RESTART=0
|
||||||
|
|
||||||
|
# 兄弟项目路径:zTools2 与 zMainPage 同级
|
||||||
|
ZTOOLS2="../zTools2"
|
||||||
|
ZTOOLS2_DIR="$(cd "$ZTOOLS2" 2>/dev/null && pwd)" || {
|
||||||
|
echo "未找到 zTools2(期望在 $ZTOOLS2);请确保与 zMainPage 同级目录" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
WEB_ROOT="${WEB_ROOT:-/var/www/html}"
|
||||||
|
|
||||||
|
echo "==> [1/4] 构建 zMainPage(含子模块)"
|
||||||
|
# 子模块依赖需各自安装(构建期编译其 .vue)
|
||||||
|
[[ -d third_party/timeTableFix/node_modules ]] || (cd third_party/timeTableFix && npm install)
|
||||||
|
[[ -d third_party/z449/node_modules ]] || (cd third_party/z449 && npm install)
|
||||||
|
[[ -d third_party/zPDF_package/node_modules ]] || (cd third_party/zPDF_package && npm install)
|
||||||
|
[[ -d third_party/zWhiteBoard/node_modules ]] || (cd third_party/zWhiteBoard && npm install)
|
||||||
|
# 确保子模块源码存在(全新检出时)
|
||||||
|
[[ -d third_party/zPDF_package/src ]] || git submodule update --init third_party/zPDF_package
|
||||||
|
[[ -d third_party/zWhiteBoard/src ]] || git submodule update --init third_party/zWhiteBoard
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
echo "==> [2/4] 部署 zMainPage 构建产物到 $WEB_ROOT"
|
||||||
|
sudo mkdir -p "$WEB_ROOT/js/mainPage" "$WEB_ROOT/css/mainPage"
|
||||||
|
sudo rsync -a --delete dist/js/mainPage/ "$WEB_ROOT/js/mainPage/"
|
||||||
|
sudo rsync -a --delete dist/css/mainPage/ "$WEB_ROOT/css/mainPage/"
|
||||||
|
sudo rsync -a dist/index.html "$WEB_ROOT/index.html"
|
||||||
|
[[ -f dist/favicon.svg ]] && sudo rsync -a dist/favicon.svg "$WEB_ROOT/favicon.svg"
|
||||||
|
# SPA 回退 .htaccess(history 模式)
|
||||||
|
cat > /tmp/zmainpage_htaccess <<'HT'
|
||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f [OR]
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
RewriteRule ^ index.html [L]
|
||||||
|
HT
|
||||||
|
sudo cp /tmp/zmainpage_htaccess "$WEB_ROOT/.htaccess"
|
||||||
|
|
||||||
|
echo "==> [3/4] 配置 Apache 反代(zMainPage -> zTools2)"
|
||||||
|
# 确保 proxy / rewrite / headers / proxy_wstunnel(WebSocket)模块启用
|
||||||
|
sudo a2enmod proxy proxy_http proxy_wstunnel rewrite headers >/dev/null 2>&1 || true
|
||||||
|
# 写入 vhost 配置(仅当不存在时,避免覆盖已有 HTTPS 配置)
|
||||||
|
VHOST="/etc/apache2/sites-available/zmainpage.conf"
|
||||||
|
if [[ ! -f "$VHOST" ]]; then
|
||||||
|
sudo tee "$VHOST" >/dev/null <<'VH'
|
||||||
|
<VirtualHost *:80>
|
||||||
|
DocumentRoot /var/www/html
|
||||||
|
# zTools2 反代:所有入口(页面/静态/探针/WS/API)统一在 /api/ 下,一条规则即可;
|
||||||
|
# WebSocket 走 /api/ws/wb/{id},由 proxy_wstunnel 透传 Upgrade 头
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyPass /api/ http://127.0.0.1:6867/api/
|
||||||
|
ProxyPassReverse /api/ http://127.0.0.1:6867/api/
|
||||||
|
# SPA 回退:非文件非目录的请求回退到 index.html
|
||||||
|
<Directory /var/www/html>
|
||||||
|
AllowOverride All
|
||||||
|
Require all granted
|
||||||
|
</Directory>
|
||||||
|
</VirtualHost>
|
||||||
|
VH
|
||||||
|
sudo a2dissite 000-default >/dev/null 2>&1 || true
|
||||||
|
sudo a2ensite zmainpage >/dev/null
|
||||||
|
echo " 已写入 $VHOST 并启用"
|
||||||
|
else
|
||||||
|
echo " $VHOST 已存在,跳过(如需更新请手动编辑)"
|
||||||
|
fi
|
||||||
|
sudo systemctl reload apache2
|
||||||
|
|
||||||
|
echo "==> [4/4] 重启 zTools2 后端"
|
||||||
|
if [[ $RESTART -eq 1 ]]; then
|
||||||
|
(cd "$ZTOOLS2_DIR" && ./stop.sh >/dev/null 2>&1 || true)
|
||||||
|
(cd "$ZTOOLS2_DIR" && ./start.sh)
|
||||||
|
else
|
||||||
|
echo " --no-restart:跳过 zTools2 重启"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "部署完成。"
|
||||||
|
echo " - zMainPage: http://localhost(Apache :80,静态文件 + SPA)"
|
||||||
|
echo " - zTools2: http://127.0.0.1:6867(经 Apache 反代 /api/)"
|
||||||
|
echo " - PDF 页签: http://localhost -> 构建期组件嵌入,同源调用 zTools2 /api/pdf/*"
|
||||||
114
docs/add-module.md
Normal file
114
docs/add-module.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# 新增一个项目页签
|
||||||
|
|
||||||
|
新增页签 = 新建 `src/modules/<新项目>/` 文件夹,无需改动导航或路由代码。注册表自动收集并生成页签与路由。
|
||||||
|
|
||||||
|
## 步骤
|
||||||
|
|
||||||
|
### 1. 建模块文件夹与元数据
|
||||||
|
|
||||||
|
复制 `src/modules/timetable/`(最简范式)或 `whiteboard/`(带存活校验)为 `src/modules/<新项目>/`。
|
||||||
|
|
||||||
|
`index.js` 默认导出元数据(字段含义见 [architecture.md](./architecture.md#模块注册表扩展点核心)):
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
id: 'my-project', // 必填:唯一标识,同时作路由 path / KeepAlive 匹配名
|
||||||
|
tabKey: 'tabs.myProject', // 必填:i18n 键
|
||||||
|
order: 4, // 可选:页签排序,缺省 999
|
||||||
|
component: () => import('./MyProject.vue') // 必填:懒加载组件
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 实现组件,用 PageShell 包裹
|
||||||
|
|
||||||
|
组件内 `defineOptions({ name: <id> })`(KeepAlive 匹配用),并用 `<PageShell>` 统一容器宽度与标题:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
defineOptions({ name: 'my-project' })
|
||||||
|
import PageShell from '../../components/ui/PageShell.vue'
|
||||||
|
import { useLocale } from '../../composables/useLocale.js'
|
||||||
|
const { t } = useLocale()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageShell title-key="tabs.myProject">
|
||||||
|
<!-- 页面内容 -->
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
PageShell props:
|
||||||
|
- `titleKey`:i18n 键,渲染页面标题;不传则无标题
|
||||||
|
- `fluid`:true 突破 1080px 全宽(如日历页)
|
||||||
|
- `#actions` 具名插槽:标题旁的操作区(如白板切换表单)
|
||||||
|
|
||||||
|
### 3. 加 i18n 文案
|
||||||
|
|
||||||
|
在 `src/i18n/locales/zh-CN.js` 与 `en.js` 的 `tabs` 下加对应文案:
|
||||||
|
|
||||||
|
```js
|
||||||
|
tabs: {
|
||||||
|
// ...
|
||||||
|
myProject: '我的项目', // zh-CN
|
||||||
|
myProject: 'My Project', // en
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
页签与路由自动出现。
|
||||||
|
|
||||||
|
## 常见变体
|
||||||
|
|
||||||
|
### 隐藏页签(仅 URL 访问)
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default { id: 'secret', tabKey: 'tabs.secret', order: 99, hidden: true, component: () => import('./Secret.vue') }
|
||||||
|
```
|
||||||
|
|
||||||
|
`hidden: true` 的页签不在导航显示,但 URL 访问后保持可见(访问过的隐藏页签不会因切走而消失)。
|
||||||
|
|
||||||
|
### 依赖外部服务存活(挂掉则隐藏并卸载)
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { healthUrl } from '../../config/app.config.js'
|
||||||
|
export default {
|
||||||
|
id: 'my-service', tabKey: 'tabs.myService', order: 5,
|
||||||
|
requiresAlive: healthUrl('myService'), // 同源探针 URL(/api/health)
|
||||||
|
component: () => import('./MyService.vue')
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务失活时页签隐藏、组件卸载;恢复后自动重新加载。详见 [architecture.md](./architecture.md#存活校验requiresalive)。
|
||||||
|
|
||||||
|
### 路由参数(如 /my-project/:id)
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default { id: 'my-project', tabKey: 'tabs.myProject', order: 4, param: 'itemId', component: () => import('./MyProject.vue') }
|
||||||
|
```
|
||||||
|
|
||||||
|
生成路由 `/<id>/:<param>?`,组件内用 `useRoute().params.<param>` 读取。
|
||||||
|
|
||||||
|
### 集成独立子项目(git submodule)
|
||||||
|
|
||||||
|
参照 `timetable` 模块(无 i18n 子项目)或 `mobile-game` 模块(带独立 i18n 的子项目):建 mainPage 侧包装器组件 import 子项目 `App.vue`,包装器持有 `defineOptions({ name })`。子项目保持纯净。
|
||||||
|
|
||||||
|
- **无 i18n 的子项目**(如 timeTableFix):包装器直接 import App.vue 即可。
|
||||||
|
- **带独立 i18n 的子项目**(如 z449):子项目 App.vue 接 `:locale` prop,包装器把 mainPage 当前语言透传(`<Z449App :locale="locale" />`),子项目 `watch` 同步到自己的 i18n 实例。详见 [submodule-z449.md](./submodule-z449.md)。
|
||||||
|
|
||||||
|
## 元数据字段速查
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `id` | 是 | 唯一标识,路由 path / KeepAlive 匹配名 |
|
||||||
|
| `tabKey` | 是 | i18n 键,页签与页面标题 |
|
||||||
|
| `component` | 是 | 懒加载组件函数 |
|
||||||
|
| `order` | 否 | 排序,缺省 999 |
|
||||||
|
| `hidden` | 否 | 隐藏页签,URL 访问后保持可见 |
|
||||||
|
| `requiresAlive` | 否 | 存活探针 URL,失活则隐藏并卸载 |
|
||||||
|
| `param` | 否 | 路由参数名,生成 `/:<param>?` |
|
||||||
91
docs/architecture.md
Normal file
91
docs/architecture.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# 架构
|
||||||
|
|
||||||
|
mainPage 是一个 Vue 3 模块化多项目展示站。核心设计目标:**高内聚低耦合** -- 每个项目页签是一个独立「模块」,由注册表自动生成页签与路由,新增/删除页签只增删一个文件夹,互不影响。
|
||||||
|
|
||||||
|
## 模块注册表(扩展点核心)
|
||||||
|
|
||||||
|
`src/modules/index.js` 用 `import.meta.glob('./*/index.js', { eager: true })` 约定式收集每个子模块的 `index.js`。每个模块默认导出元数据:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default {
|
||||||
|
id: 'whiteboard', // 必填:唯一标识,同时作路由 path / KeepAlive 匹配名
|
||||||
|
tabKey: 'tabs.whiteboard', // 必填:i18n 键,页签标题与 PageShell 标题均用此
|
||||||
|
component: () => import('./Whiteboard.vue'), // 必填:懒加载组件(函数形式,独立 chunk)
|
||||||
|
order: 2, // 可选:页签排序,缺省 999(排在显式页签之后)
|
||||||
|
hidden: true, // 可选:不在导航显示,但 URL 访问后保持可见(如 cqy)
|
||||||
|
requiresAlive: healthUrl('whiteboard'), // 可选:存活探针 URL(同源 /api/health),服务失活则隐藏页签并卸载组件
|
||||||
|
param: 'boardId', // 可选:路由参数名,生成 /<id>/:<param>? 子路径
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 元数据字段 -> 消费方矩阵
|
||||||
|
|
||||||
|
| 字段 | 注册表 | router | useProjects | App.vue | TabNav |
|
||||||
|
|------|--------|--------|-------------|---------|--------|
|
||||||
|
| `id` | 必填校验 | path+name+meta.moduleId | visited 集合 / 路由关联 | keepInclude 名单 | `:to` + `:key` |
|
||||||
|
| `tabKey` | 必填校验 | - | - | - | 标题文案 |
|
||||||
|
| `component` | 必填校验 | 路由 component | - | RouterView 渲染 | - |
|
||||||
|
| `order` | 排序 | 决定 `/` 重定向目标 | 继承顺序 | - | 继承顺序 |
|
||||||
|
| `hidden` | 透传 | - | 可见性门控 | - | - |
|
||||||
|
| `requiresAlive` | 透传 | - | 可见性门控 | keepInclude + startPolling | - |
|
||||||
|
| `param` | 透传 | `/:<param>?` 后缀 | - | - | - |
|
||||||
|
|
||||||
|
### 容错
|
||||||
|
|
||||||
|
注册表对每个模块做字段完整性校验(缺 `id`/`tabKey`/`component` 的模块跳过),单个模块异常不影响其余页签加载。
|
||||||
|
|
||||||
|
## PageShell(统一页面外壳)
|
||||||
|
|
||||||
|
`src/components/ui/PageShell.vue` 是可复用的页面外壳组件,统一所有页面的:
|
||||||
|
|
||||||
|
- **容器宽度**:默认复用全局 `.container`(`max-width: 1080px`,居中,左右 `padding`);`fluid` 变体突破限制全宽(如日历页)
|
||||||
|
- **垂直节奏**:`padding: var(--space-xl) 0 var(--space-2xl)`
|
||||||
|
- **标题**:`titleKey` prop -> i18n 文案,渲染为 `<h1>`
|
||||||
|
- **操作区**:`#actions` 具名插槽,放在标题旁(如白板的切换白板表单)
|
||||||
|
|
||||||
|
各模块根组件用 `<PageShell>` 包裹内容,不再各写一套 `.container`/标题/间距。新增页签照此复用。
|
||||||
|
|
||||||
|
## KeepAlive 缓存与卸载
|
||||||
|
|
||||||
|
`App.vue` 用 `<KeepAlive :include="keepInclude">` 包裹路由组件,切换页签不卸载(状态保留)。
|
||||||
|
|
||||||
|
- `keepInclude` 是模块 id 列表,默认包含所有模块。
|
||||||
|
- 声明了 `requiresAlive` 的模块,当存活探测为 `down` 时移出名单 -> 组件卸载;恢复 `up` 后重新纳入。
|
||||||
|
- **匹配约定**:KeepAlive 按组件 `name` 匹配,因此每个模块组件需 `defineOptions({ name: <模块 id> })`。
|
||||||
|
- 内置模块(whiteboard/calendar)直接在组件内声明。
|
||||||
|
- 子模块集成(timetable/mobile-game)由 mainPage 侧包装器持有 name,子项目自身不持 name(保持纯净)。
|
||||||
|
|
||||||
|
## 子模块集成(git submodule + 构建期组件)
|
||||||
|
|
||||||
|
部分页签由独立仓库的子项目经 git submodule 集成,mainPage 侧用包装器 import 子项目 `App.vue` 作为路由组件(非 iframe),共享构建与 KeepAlive 缓存:
|
||||||
|
|
||||||
|
- **timeTableFix**(`third_party/timeTableFix`,日程整理页签):自带独立 `vue-i18n` 实例与中英 locale 文案。经 `:locale` prop 桥接父项目语言。详见 [submodule-timeTableFix.md](./submodule-timeTableFix.md)。
|
||||||
|
- **z449**(`third_party/z449`,移动游戏页签):自带独立 `vue-i18n` 实例与 locale 文案。mainPage 包装器把当前语言经 `:locale` prop 透传,子项目 `App.vue` `watch` 该 prop 同步到自己的 i18n 实例,从而跟随父项目语言开关响应式切换(不共享 messages,仅同步 locale 值)。详见 [submodule-z449.md](./submodule-z449.md)。
|
||||||
|
- **zPDF_package**(`third_party/zPDF_package`,PDF 转换页签):经同源 fetch 调用 zTools2 的 `/api/pdf/*` REST API。详见 [submodule-zPDF_package.md](./submodule-zPDF_package.md)。
|
||||||
|
- **zWhiteBoard**(`third_party/zWhiteBoard`,共享记事本页签):经同源 fetch/WS 调用 zTools2 的 `/api/wb/*` 与 `/api/ws/wb/*`。详见 [submodule-zWhiteBoard.md](./submodule-zWhiteBoard.md)。
|
||||||
|
|
||||||
|
> **i18n 桥接关键点**:子项目组件用各自 `useLocale` composable **直接读自身模块级 i18n 实例**(`i18n.global`),而非 `useI18n({ useScope: 'global' })`。后者在嵌入时会解析到宿主已安装的 i18n 实例(不含子项目文案),导致文案回退成 i18n key(显示 ID 而非文字)。
|
||||||
|
|
||||||
|
## 存活校验(requiresAlive)
|
||||||
|
|
||||||
|
`src/composables/useLiveness.js` 提供跨域存活探测:
|
||||||
|
|
||||||
|
- 探测方式:`fetch(url, { mode: 'no-cors', cache: 'no-store' })`,resolve 即存活,4s 超时。
|
||||||
|
- 时机:应用加载即探测一次,之后每 30s 周期重探(`startPolling`)。
|
||||||
|
- 结果在全应用共享(模块级 `cache` Map),`useProjects`(页签可见性)与 `App.vue`(KeepAlive 卸载)共用同一 ref。
|
||||||
|
- **乐观显示**:探测中(pending)/存活(up)都显示页签,仅明确不可达(down)才隐藏并卸载;恢复后自动重新加载。
|
||||||
|
- 探针 URL 由 `healthUrl(service)` 拼出同源相对路径(`/api/health`),经 vite dev proxy / Apache 反代转发到 zTools2,与环境无关。
|
||||||
|
|
||||||
|
目前白板与 PDF 页签使用(`requiresAlive: healthUrl('whiteboard'|'pdf')`),二者探针均指向 zTools2 的 `/api/health`。
|
||||||
|
|
||||||
|
## 路由
|
||||||
|
|
||||||
|
`src/router/index.js` 遍历注册表生成路由:每个模块一条路由,path = `/<id>` + 可选 `/:<param>?`。history 模式,需站点根 `.htaccess` 做 SPA 回退(见 README 安装步骤)。
|
||||||
|
|
||||||
|
## 国际化
|
||||||
|
|
||||||
|
`src/i18n/` -- 中文同步注入主 chunk(默认语言),英文通过动态 `import()` 懒加载成独立 chunk,只看中文的访客不会下载英文资源。各模块 `tabKey` 指向 `tabs.*` 下的文案。
|
||||||
|
|
||||||
|
## 集中配置
|
||||||
|
|
||||||
|
`src/config/app.config.js` -- 各服务的同源探针路径(`/api/health`)与默认参数集中管理,模块按需 import。改路径只改这里(需重新 build)。
|
||||||
151
docs/deployment.md
Normal file
151
docs/deployment.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# 部署指南
|
||||||
|
|
||||||
|
zPDF_package 功能涉及三个项目协同:**zMainPage**(父站,Vue3 静态站)、**zTools2**(后端 API + PDF 页面)、**zPDF_package**(PDF 转换前端源码,zMainPage 子模块)。提供三种部署模式,按需选用。
|
||||||
|
|
||||||
|
## 拓扑
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器 -> Apache(:80)
|
||||||
|
├── 静态文件 /var/www/html (zMainPage 构建产物:主页 + 页签)
|
||||||
|
└── 反代 /api/ -> zTools2(127.0.0.1:6867)
|
||||||
|
├── /api/pdf/* PDF 转换 API(cookie 用户)
|
||||||
|
├── /api/pdf-admin PDF 管理页(Basic Auth)
|
||||||
|
├── /api/static/pdf* 前端静态资源
|
||||||
|
└── MySQL(3306) + uploads/
|
||||||
|
```
|
||||||
|
|
||||||
|
PDF 页签 = zMainPage 构建期 import zPDF_package 的 `App.vue` 作为组件(非 iframe),经同源 fetch 调用 zTools2 的 `/api/pdf/*` REST API(**同源相对路径**,cookie 第一方生效,与环境无关)。
|
||||||
|
|
||||||
|
> **路由约定**:zTools2 所有入口(页面 / 静态 / 探针 / WS / API)统一挂在 `/api/` 下,反代与 vite proxy 只需一条 `/api/` 规则。前端组件经同源相对路径(`/api/pdf/jobs`、`/api/health` 等)调用,自动指向**当前环境**的 zTools2,无需区分 dev/prod,也不会误打到其它环境域名。详见 zTools2 README「路由约定」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模式一:组件独立部署(开发/调试)
|
||||||
|
|
||||||
|
各组件独立运行,互不依赖部署,适合开发调试。
|
||||||
|
|
||||||
|
### 1. zTools2 后端独立运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd zTools2
|
||||||
|
./setup.sh # 首次:venv + 依赖 + 建库 + SFTP 密钥
|
||||||
|
./start.sh # 启动 HTTP(127.0.0.1:6867) + SFTP(2022)
|
||||||
|
# 或:python -m uvicorn app.main:app --host 127.0.0.1 --port 6867
|
||||||
|
```
|
||||||
|
|
||||||
|
访问:http://127.0.0.1:6867/api/pdf-admin(PDF 管理页)、http://127.0.0.1:6867/docs(API 文档)。
|
||||||
|
|
||||||
|
> 用户侧 PDF 页面由 zMainPage 的 zPDF_package 组件提供(构建期 import),zTools2 仅提供 `/api/pdf/*` REST API 与 `/api/pdf-admin` 管理页。
|
||||||
|
|
||||||
|
### 2. zPDF_package 前端独立运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd zPDF_package
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5175
|
||||||
|
```
|
||||||
|
|
||||||
|
`vite.config.js` 已配代理:`/api` 转发到 `127.0.0.1:6867`(需 zTools2 在跑),覆盖 `/api/pdf/*` 与 `/api/admin/pdf/*`。
|
||||||
|
|
||||||
|
### 3. zMainPage 独立运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd zMainPage
|
||||||
|
git submodule update --init --recursive # 含 timeTableFix / z449 / zPDF_package
|
||||||
|
npm install
|
||||||
|
(cd third_party/timeTableFix && npm install)
|
||||||
|
(cd third_party/z449 && npm install)
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
```
|
||||||
|
|
||||||
|
> 独立模式下 PDF 页签的 zPDF_package 组件经同源相对路径 `/api/pdf/*` 调用 zTools2,经 vite proxy 转发到本地 zTools2(127.0.0.1:6867)。如需指向其它环境的 zTools2,改 `src/config/app.config.js` 的 `pdf.baseUrl` 为该环境地址(含协议与域名)后重新 `npm run dev`,或设 `VITE_API_BASE` 环境变量。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模式二:整体部署(生产,一次性)
|
||||||
|
|
||||||
|
一条命令构建并部署全部组件到生产。**前提**:zMainPage 与 zTools2 在同一主机的同级目录。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd zMainPage
|
||||||
|
./deploy.sh # 构建 + 部署 + 配置 Apache 反代 + 重启 zTools2
|
||||||
|
./deploy.sh --no-restart # 仅更新文件,不重启 zTools2(如 zTools2 由 systemd 管理)
|
||||||
|
```
|
||||||
|
|
||||||
|
`deploy.sh` 做的事:
|
||||||
|
1. 构建 zMainPage(含子模块),rsync 到 `/var/www/html`
|
||||||
|
2. 写入 `.htaccess`(SPA history 回退)
|
||||||
|
3. 配置 Apache vhost(`/etc/apache2/sites-available/zmainpage.conf`)反代 `/api/` 到 `127.0.0.1:6867`(含 WebSocket,靠 proxy_wstunnel 透传 `/api/ws`)
|
||||||
|
4. 重启 zTools2(除非 `--no-restart`)
|
||||||
|
|
||||||
|
部署后访问 http://localhost/ 即主页,PDF 页签渲染 zPDF_package 组件并经同源 fetch 调用 `/api/pdf/*`。
|
||||||
|
|
||||||
|
> 已有 HTTPS vhost 时:`deploy.sh` 不会覆盖(仅首次写入)。手动在现有 vhost 加 `ProxyPass /api/ http://127.0.0.1:6867/api/` 一条规则即可(需 `a2enmod proxy_wstunnel` 以支持 WebSocket)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 模式三:持久化部署(systemd,开机自启 + 崩溃重启)
|
||||||
|
|
||||||
|
zTools2 后端需长期运行(PDF 转换是异步后台任务)。用 systemd 管理实现开机自启与崩溃自动重启。
|
||||||
|
|
||||||
|
### 安装 systemd 服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd zTools2
|
||||||
|
./deploy/install-systemd.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本自动:
|
||||||
|
- 用实际路径填充 `deploy/ztools2.service` 模板(自动检测 `.venv/bin/uvicorn` 或系统 `uvicorn`)
|
||||||
|
- 写入 `/etc/systemd/system/ztools2.service`
|
||||||
|
- `systemctl enable --now ztools2`(开机自启 + 立即启动)
|
||||||
|
|
||||||
|
### 运维命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status ztools2 # 状态
|
||||||
|
systemctl restart ztools2 # 重启(更新代码后)
|
||||||
|
systemctl stop ztools2 # 停止
|
||||||
|
journalctl -u ztools2 -f # 实时日志
|
||||||
|
```
|
||||||
|
|
||||||
|
### 卸载
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl disable --now ztools2
|
||||||
|
sudo rm /etc/systemd/system/ztools2.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### 与整体部署配合
|
||||||
|
|
||||||
|
生产推荐:**systemd 管理 zTools2 + deploy.sh 部署前端**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 首次
|
||||||
|
cd zTools2 && ./setup.sh && ./deploy/install-systemd.sh # 后端持久化
|
||||||
|
cd ../zMainPage && ./deploy.sh --no-restart # 前端部署(不重启后端,systemd 管)
|
||||||
|
# 后续更新前端
|
||||||
|
cd zMainPage && ./deploy.sh --no-restart
|
||||||
|
# 后续更新后端代码
|
||||||
|
cd zTools2 && sudo systemctl restart ztools2
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
| 项 | 要求 |
|
||||||
|
|----|------|
|
||||||
|
| OS | Ubuntu 22.04+(systemd + apache2) |
|
||||||
|
| Python | ≥ 3.10(本仓开发用 3.14,需 SQLAlchemy ≥ 2.0.37) |
|
||||||
|
| Node.js | ≥ 18 |
|
||||||
|
| MySQL | 8.x |
|
||||||
|
| Apache | 2.4(启用 proxy / proxy_http / proxy_wstunnel / rewrite / headers) |
|
||||||
|
| weasyprint 系统库 | libpango-1.0-0 / libpangoft2-1.0-0 / libcairo2 / libgdk-pixbuf-2.0-0 |
|
||||||
|
|
||||||
|
## 防火墙
|
||||||
|
|
||||||
|
- 80(HTTP)/ 443(HTTPS):对外
|
||||||
|
- 6867:仅 loopback(经 Apache 反代,不对外)
|
||||||
|
- 2022(SFTP):按需对外
|
||||||
53
docs/readme-fixes.md
Normal file
53
docs/readme-fixes.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
# README 勘误记录
|
||||||
|
|
||||||
|
本文件记录 mainPage README 中发现并修正的错误,便于追溯。
|
||||||
|
|
||||||
|
## 2026-07-27:构建输出路径描述错误
|
||||||
|
|
||||||
|
**原描述(错误)**:
|
||||||
|
|
||||||
|
- 项目结构注释:`vite.config.js # 构建输出到 /root/html(emptyOutDir=false,不清空既有文件)`
|
||||||
|
- 如何使用:`# 3. 构建(产物输出到 /root/html,不清空既有文件)`
|
||||||
|
- 开发命令:`npm run build # 构建到 /root/html`
|
||||||
|
|
||||||
|
**实际行为**(见 `vite.config.js`):
|
||||||
|
|
||||||
|
- `npm run build` 输出到项目本地 `dist/`(`emptyOutDir: true`,每次清空)。
|
||||||
|
- `npm run deploy` 才用 rsync 把 `dist/` 同步到 Apache 站点根 `/root/html`(脚本:`vite build && rsync ... dist/... /root/html/...`)。
|
||||||
|
|
||||||
|
**修正**:三处均改为说明构建输出到 `dist/`,由 `npm run deploy` rsync 到 `/root/html`,并在「开发命令」补 `npm run deploy` 一行。
|
||||||
|
|
||||||
|
> 背景:本次升级 timeTableFix 子模块(新增时区选择)时,按 README 从 0 构建复现发现该不一致。
|
||||||
|
|
||||||
|
## 2026-07-28:部署路径统一为 /var/www/html
|
||||||
|
|
||||||
|
**原描述(不一致)**:
|
||||||
|
|
||||||
|
- `package.json` 的 `npm run deploy` 脚本 rsync 到 `/root/html`(依赖 `/root/html` 软链到 `/var/www/html`)。
|
||||||
|
- `deploy.sh` 与 README 用 `/var/www/html`(Apache 标准 DocumentRoot,不依赖软链)。
|
||||||
|
- `vite.config.js` 顶部注释写 `/root/html(软链到 /var/www/html)`。
|
||||||
|
|
||||||
|
**问题**:全新 Ubuntu 安装时 `/root/html` 软链不存在,`npm run deploy` 会失败或写到错误位置;三处路径不统一。
|
||||||
|
|
||||||
|
**修正**:`package.json` deploy 脚本与 `vite.config.js` 注释统一为 `/var/www/html`,与 `deploy.sh`、README、Apache DocumentRoot 一致。
|
||||||
|
|
||||||
|
## 2026-07-28:iframe / ical.js / 子模块数量描述过时
|
||||||
|
|
||||||
|
**原描述(错误)**:
|
||||||
|
|
||||||
|
- README「外部依赖」称白板页「iframe 嵌入外部服务 `https://f.zikai.wang`」。
|
||||||
|
- README「项目结构」注释称 zPDF_package「iframe 嵌入 zTools2」、白板「iframe 嵌入」。
|
||||||
|
- README「package.json」注释含 `ical.js`(子模块依赖被误列在父项目)。
|
||||||
|
- README 子模块只列 timeTableFix / z449,漏 zPDF_package / zWhiteBoard;克隆后安装依赖步骤也漏后两者。
|
||||||
|
- architecture.md 的 `requiresAlive` 示例为 `https://f.zikai.wang/health`,且称「仅白板页签使用」(PDF 也用)。
|
||||||
|
- architecture.md / add-module.md 提及 PageShell 的 `padded` prop(已被移除)。
|
||||||
|
- submodule-timeTableFix.md 称「共享 mainPage 的依赖 `ical.js`」(实际子模块自带)。
|
||||||
|
|
||||||
|
**实际行为**:
|
||||||
|
|
||||||
|
- 四个子模块(timeTableFix / z449 / zPDF_package / zWhiteBoard)均为**构建期组件 import**(非 iframe)。
|
||||||
|
- 白板与 PDF 页签的探针均为同源相对路径 `/api/health`(经反代/vite proxy 指向 zTools2),非外部域名。
|
||||||
|
- `ical.js` 仅 timeTableFix 使用,由其自身 `package.json` 管理;mainPage 的 `package.json` 不再列。
|
||||||
|
- PageShell 已无 `padded` prop(默认即上下间距)。
|
||||||
|
|
||||||
|
**修正**:README 改写为简洁版,结构/外部依赖/克隆步骤均对齐现状;docs 各文件同步修正。
|
||||||
58
docs/submodule-timeTableFix.md
Normal file
58
docs/submodule-timeTableFix.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 子项目 timeTableFix(git submodule)
|
||||||
|
|
||||||
|
timeTableFix(ICS 日程整理器)作为 **git submodule** 被 mainPage 引入,位于 `third_party/timeTableFix`,独立仓库为 https://git.zikai.wang/zikai/timeTableFix.git 。
|
||||||
|
|
||||||
|
## 集成方式
|
||||||
|
|
||||||
|
mainPage 侧的 `src/modules/timetable/Timetable.vue` 包装器 import 子模块的 `src/App.vue` 作为组件,套 `PageShell` 统一容器与标题,并持有 KeepAlive 匹配名 `defineOptions({ name: 'timetable' })`。
|
||||||
|
|
||||||
|
- 子模块 `App.vue` 保持纯净(不持 name、不依赖宿主),可独立 `npm run build` / `npm test` / 部署。
|
||||||
|
- 构建期集成(非 iframe),共享 mainPage 的构建与 KeepAlive 缓存。子模块自身依赖(如 `ical.js`)在 `third_party/timeTableFix/node_modules`,由其自身 `package.json` 管理。
|
||||||
|
- 子模块样式已隔离(scoped + `ttf-` 前缀类名),全局重置仅在独立 app 的 `main.js` 加载,不污染宿主。
|
||||||
|
|
||||||
|
## 克隆(含子模块)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recursive https://git.zikai.wang/zikai/zMainPage.git mainPage
|
||||||
|
```
|
||||||
|
|
||||||
|
若已 clone 但未带子模块:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
> 构建前需在子模块目录内 `npm install`(mainPage 的 Vite 编译子模块 `.vue`,但子模块依赖如 `ical.js` 须各自安装):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd third_party/timeTableFix && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 升级子模块到最新版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule update --remote third_party/timeTableFix # 拉取最新
|
||||||
|
cd third_party/timeTableFix && git checkout master # 固定到目标分支/commit
|
||||||
|
cd ../..
|
||||||
|
git add third_party/timeTableFix # 更新 gitlink 指针
|
||||||
|
git commit -m "chore: 升级 timeTableFix 子模块"
|
||||||
|
npm run build # 重新构建 mainPage
|
||||||
|
git push # 推送 mainPage(gitlink)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 推送顺序:先确保子模块的 commit 已推到 timeTableFix.git,再推 mainPage 的 gitlink,否则别人 clone mainPage 时拉不到子模块对应 commit。
|
||||||
|
|
||||||
|
## 移除子模块集成(仅移除 mainPage 页签,不影响 timeTableFix 本身)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule deinit -f third_party/timeTableFix
|
||||||
|
git rm third_party/timeTableFix
|
||||||
|
git commit -m "chore: 移除 timeTableFix 子模块集成"
|
||||||
|
```
|
||||||
|
|
||||||
|
同时删 `src/modules/timetable/` 文件夹即移除页签。timeTableFix 独立仓库不受影响。
|
||||||
|
|
||||||
|
> timeTableFix 仅作为 submodule 被 mainPage 构建期集成,无独立线上入口。独立开发/测试用其自身的 `npm run dev` / `npm test`。
|
||||||
70
docs/submodule-z449.md
Normal file
70
docs/submodule-z449.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# 子项目 z449(git submodule + i18n 桥接)
|
||||||
|
|
||||||
|
z449(移动游戏项目展示页)作为 **git submodule** 被 mainPage 引入,位于 `third_party/z449`,独立仓库为 https://git.zikai.wang/zikai/z449.git 。
|
||||||
|
|
||||||
|
## 集成方式
|
||||||
|
|
||||||
|
mainPage 侧的 `src/modules/mobile-game/MobileGameWrapper.vue` 包装器 import 子模块的 `src/App.vue` 作为组件,并持有 KeepAlive 匹配名 `defineOptions({ name: 'mobile-game' })`。
|
||||||
|
|
||||||
|
- 子模块 `App.vue` 是**便携式组件**:嵌入与独立运行两用。它自带独立 `vue-i18n` 实例与自己的 locale 文案(`mobileGame.*`),不共享 mainPage 的 messages。
|
||||||
|
- **i18n 桥接**:mainPage 把当前语言经 `:locale` prop 透传给子模块的 `App.vue`;后者 `watch` 该 prop,同步到自己的 i18n 实例 `locale`。这样切换语言时,移动游戏页内容跟随父项目响应式切换,文案来自 z449 自有 locale。
|
||||||
|
- 子模块独立运行时不接收 prop,默认中文。
|
||||||
|
- 构建期集成(非 iframe),共享 mainPage 的构建与 KeepAlive 缓存。
|
||||||
|
- 子模块样式已隔离(scoped),全局重置/令牌仅在独立 app 的 `main.js` 加载,嵌入时不加载,复用宿主 `:root` 令牌级联。
|
||||||
|
|
||||||
|
## 克隆(含子模块)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recursive https://git.zikai.wang/zikai/zMainPage.git mainPage
|
||||||
|
```
|
||||||
|
|
||||||
|
若已 clone 但未带子模块:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
> 构建前需在子模块目录内 `npm install`(mainPage 的 Vite 编译子模块 `.vue`,但子模块依赖如 `vue-i18n` 须各自安装):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd third_party/z449 && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 升级子模块到最新版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule update --remote third_party/z449 # 拉取最新
|
||||||
|
cd third_party/z449 && git checkout master # 固定到目标分支/commit
|
||||||
|
cd ../..
|
||||||
|
git add third_party/z449 # 更新 gitlink 指针
|
||||||
|
git commit -m "chore: 升级 z449 子模块"
|
||||||
|
npm run build # 重新构建 mainPage
|
||||||
|
git push # 推送 mainPage(gitlink)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 推送顺序:先确保子模块的 commit 已推到 z449.git,再推 mainPage 的 gitlink,否则别人 clone mainPage 时拉不到子模块对应 commit。
|
||||||
|
|
||||||
|
## 移除子模块集成(仅移除 mainPage 页签,不影响 z449 本身)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule deinit -f third_party/z449
|
||||||
|
git rm third_party/z449
|
||||||
|
git commit -m "chore: 移除 z449 子模块集成"
|
||||||
|
```
|
||||||
|
|
||||||
|
同时删 `src/modules/mobile-game/` 文件夹即移除页签。z449 独立仓库不受影响。
|
||||||
|
|
||||||
|
> z449 仅作为 submodule 被 mainPage 构建期集成,无独立线上入口。独立开发/测试用其自身的 `npm run dev` / `npm run build`。
|
||||||
|
|
||||||
|
## 与 timeTableFix 子模块的差异
|
||||||
|
|
||||||
|
| 维度 | 早期 timeTableFix | timeTableFix(现已 i18n) | z449 |
|
||||||
|
|------|-------------------|---------------------------|------|
|
||||||
|
| i18n | 无(硬编码中文) | 自带独立 vue-i18n,中英双语 | 自带独立 vue-i18n,中英双语 |
|
||||||
|
| 语言同步 | 无需 | 经 `:locale` prop 桥接 | 经 `:locale` prop 桥接 |
|
||||||
|
| 文案来源 | 组件内硬编码 | ttf 自有 locale 文件(单一来源) | z449 自有 locale 文件(单一来源) |
|
||||||
|
|
||||||
|
两个子项目均采用相同的 i18n 桥接模式:各自维护独立 `vue-i18n` 实例与 locale 文案,组件内用 `useLocale` composable **直接读自身实例**(而非 `useI18n()` 注入,否则嵌入时会解析到宿主实例导致文案回退成 key),mainPage 侧包装器经 `:locale` prop 透传当前语言。
|
||||||
64
docs/submodule-zPDF_package.md
Normal file
64
docs/submodule-zPDF_package.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# 子项目 zPDF_package(git submodule)
|
||||||
|
|
||||||
|
zPDF_package(PDF 转换前端)作为 **git submodule** 被 mainPage 引入,位于 `third_party/zPDF_package`,独立仓库为 https://git.zikai.wang/zikai/zPDF_package.git 。
|
||||||
|
|
||||||
|
## 集成方式
|
||||||
|
|
||||||
|
mainPage 侧的 `src/modules/pdf/Pdf.vue` 包装器 import 子模块的 `src/App.vue` 作为组件,套 `PageShell` 统一容器与标题,并持有 KeepAlive 匹配名 `defineOptions({ name: 'pdf' })`。
|
||||||
|
|
||||||
|
- 子模块 `App.vue` 保持纯净(不持 name、不依赖宿主),可独立 `npm run build` / `npm test` / 部署。
|
||||||
|
- **构建期集成(非 iframe)**,共享 mainPage 的构建与 KeepAlive 缓存。前后端分离:UI/交互在 zPDF_package,能力由 zTools2 的 `/api/pdf/*` REST API 提供。
|
||||||
|
- 子模块样式已隔离(scoped + `zpdf-` 前缀类名),全局重置仅在独立 app 的 `main.js` 加载,不污染宿主。
|
||||||
|
- 页签带 `requiresAlive: healthUrl('pdf')`,zTools2 不可达时隐藏页签并卸载组件。
|
||||||
|
- **认证**:用户身份凭 httpOnly cookie(`zk_pdf`,samesite=lax)标识。该 cookie 在首次 `POST /api/pdf/jobs`(上传)的响应里种下,与页面路由无关。组件经同源 fetch 调用 `/api/pdf/*`,反代使 `/api/` 与主页同源,cookie 自动携带,无需 CORS。
|
||||||
|
|
||||||
|
## 克隆(含子模块)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recursive https://git.zikai.wang/zikai/zMainPage.git mainPage
|
||||||
|
```
|
||||||
|
|
||||||
|
若已 clone 但未带子模块:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
## 升级子模块到最新版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule update --remote third_party/zPDF_package # 拉取最新
|
||||||
|
cd third_party/zPDF_package && git checkout master # 固定到目标分支/commit
|
||||||
|
cd ../..
|
||||||
|
git add third_party/zPDF_package # 更新 gitlink 指针
|
||||||
|
git commit -m "chore: 升级 zPDF_package 子模块"
|
||||||
|
npm run build # 重新构建 mainPage
|
||||||
|
git push # 推送 mainPage(gitlink)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 推送顺序:先确保子模块的 commit 已推到 zPDF_package.git,再推 mainPage 的 gitlink,否则别人 clone mainPage 时拉不到子模块对应 commit。
|
||||||
|
|
||||||
|
## 移除子模块集成(仅移除 mainPage 页签,不影响 zPDF_package 本身)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule deinit -f third_party/zPDF_package
|
||||||
|
git rm third_party/zPDF_package
|
||||||
|
git commit -m "chore: 移除 zPDF_package 子模块集成"
|
||||||
|
```
|
||||||
|
|
||||||
|
同时删 `src/modules/pdf/` 文件夹即移除页签。zPDF_package 独立仓库不受影响。
|
||||||
|
|
||||||
|
## 与其他子模块的差异
|
||||||
|
|
||||||
|
| 维度 | timeTableFix / z449 | zPDF_package |
|
||||||
|
|------|---------------------|--------------|
|
||||||
|
| 集成方式 | 构建期组件 import(共享构建/KeepAlive) | 构建期组件 import(共享构建/KeepAlive) |
|
||||||
|
| 后端依赖 | 无(纯前端) | 有(zTools2 `/api/pdf/*`) |
|
||||||
|
| 同源/CORS | 不涉及 | 同源经反代,无需 CORS |
|
||||||
|
| cookie | 不涉及 | httpOnly `zk_pdf`,同源自动携带(首次上传种下) |
|
||||||
|
| mainPage 是否编译子模块 | 是(import .vue) | 是(import .vue) |
|
||||||
|
|
||||||
|
> 四个子项目(timeTableFix / z449 / zPDF_package / zWhiteBoard)均采用构建期组件 import 集成,无 iframe。zPDF_package 与 zWhiteBoard 额外依赖 zTools2 后端 API,页签带存活探针。
|
||||||
72
docs/submodule-zWhiteBoard.md
Normal file
72
docs/submodule-zWhiteBoard.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# 子项目 zWhiteBoard(git submodule)
|
||||||
|
|
||||||
|
zWhiteBoard(共享记事本前端)作为 **git submodule** 被 mainPage 引入,位于 `third_party/zWhiteBoard`,独立仓库为 https://git.zikai.wang/zikai/zWhiteBoard.git 。
|
||||||
|
|
||||||
|
## 集成方式
|
||||||
|
|
||||||
|
mainPage 侧的 `src/modules/whiteboard/Whiteboard.vue` 包装器 import 子模块的 `src/App.vue` 作为组件,套 `PageShell` 统一容器与标题,并持有 KeepAlive 匹配名 `defineOptions({ name: 'whiteboard' })`。
|
||||||
|
|
||||||
|
- 子模块 `App.vue` 保持纯净(不持 name、不依赖宿主),可独立 `npm run build` / `npm test` / 部署。
|
||||||
|
- **构建期集成(非 iframe)**,共享 mainPage 的构建与 KeepAlive 缓存。前后端分离:UI/交互在 zWhiteBoard,能力由 zTools2 的 `/api/wb/*` REST API 与 `/api/ws/wb/*` WebSocket 提供。
|
||||||
|
- 子模块样式已隔离(scoped),全局重置仅在独立 app 的 `main.js` 加载,不污染宿主。
|
||||||
|
- 页签带 `requiresAlive: healthUrl('whiteboard')`(同源 `/api/health`),zTools2 不可达时隐藏页签并卸载组件。
|
||||||
|
- **身份标识**:白板不依赖 cookie。客户端 id 由 WebSocket 首帧 `hello` 携带或服务端生成(`client_id`),用于区分广播来源。组件经同源 fetch/WS 调用,反代使 `/api/` 与主页同源,无需 CORS。
|
||||||
|
- 包装器解析路由参数 `/whiteboard/:boardId` 并经 `:board-id` prop 透传给子模块,支持直链预填白板 id。
|
||||||
|
|
||||||
|
## 克隆(含子模块)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone --recursive https://git.zikai.wang/zikai/zMainPage.git mainPage
|
||||||
|
```
|
||||||
|
|
||||||
|
若已 clone 但未带子模块:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
> 构建前需在子模块目录内 `npm install`(mainPage 的 Vite 编译子模块 `.vue`,但子模块依赖如 `vue-i18n` 须各自安装):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd third_party/zWhiteBoard && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## 升级子模块到最新版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule update --remote third_party/zWhiteBoard # 拉取最新
|
||||||
|
cd third_party/zWhiteBoard && git checkout master # 固定到目标分支/commit
|
||||||
|
cd ../..
|
||||||
|
git add third_party/zWhiteBoard # 更新 gitlink 指针
|
||||||
|
git commit -m "chore: 升级 zWhiteBoard 子模块"
|
||||||
|
npm run build # 重新构建 mainPage
|
||||||
|
git push # 推送 mainPage(gitlink)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 推送顺序:先确保子模块的 commit 已推到 zWhiteBoard.git,再推 mainPage 的 gitlink,否则别人 clone mainPage 时拉不到子模块对应 commit。
|
||||||
|
|
||||||
|
## 移除子模块集成(仅移除 mainPage 页签,不影响 zWhiteBoard 本身)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd mainPage
|
||||||
|
git submodule deinit -f third_party/zWhiteBoard
|
||||||
|
git rm third_party/zWhiteBoard
|
||||||
|
git commit -m "chore: 移除 zWhiteBoard 子模块集成"
|
||||||
|
```
|
||||||
|
|
||||||
|
同时删 `src/modules/whiteboard/` 文件夹即移除页签。zWhiteBoard 独立仓库不受影响。
|
||||||
|
|
||||||
|
## 与其他子模块的差异
|
||||||
|
|
||||||
|
| 维度 | timeTableFix / z449 | zPDF_package | zWhiteBoard |
|
||||||
|
|------|---------------------|--------------|-------------|
|
||||||
|
| 集成方式 | 构建期组件 import(共享构建/KeepAlive) | 构建期组件 import(共享构建/KeepAlive) | 构建期组件 import(共享构建/KeepAlive) |
|
||||||
|
| 后端依赖 | 无(纯前端) | 有(zTools2 `/api/pdf/*`) | 有(zTools2 `/api/wb/*` + `/api/ws/wb/*`) |
|
||||||
|
| 同源/CORS | 不涉及 | 同源经反代,无需 CORS | 同源经反代,无需 CORS |
|
||||||
|
| 身份标识 | 不涉及 | httpOnly `zk_pdf` cookie,同源自动携带 | WS 首帧 `client_id`(无 cookie) |
|
||||||
|
| 路由参数 | 无 | 无 | `:boardId`(直链预填白板 id) |
|
||||||
|
| mainPage 是否编译子模块 | 是(import .vue) | 是(import .vue) | 是(import .vue) |
|
||||||
|
|
||||||
|
> 四个子项目均采用构建期组件 import 集成,无 iframe。zWhiteBoard 与 zPDF_package 额外依赖 zTools2 后端 API,页签带存活探针;zWhiteBoard 是唯一带 WebSocket 与路由参数的子模块。
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Zikai · 作品集</title>
|
<title>Zikai</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
229
package-lock.json
generated
229
package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"html-minifier-terser": "^7.2.0",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -468,11 +469,50 @@
|
|||||||
"url": "https://github.com/sponsors/kazupon"
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
|
"version": "0.3.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/resolve-uri": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@jridgewell/source-map": {
|
||||||
|
"version": "0.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
|
||||||
|
"integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/gen-mapping": "^0.3.5",
|
||||||
|
"@jridgewell/trace-mapping": "^0.3.25"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@jridgewell/trace-mapping": {
|
||||||
|
"version": "0.3.31",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||||
|
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/resolve-uri": "^3.1.0",
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
|
||||||
@@ -913,11 +953,70 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
|
||||||
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
|
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.17.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
|
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/camel-case": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"pascal-case": "^3.1.2",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/clean-css": {
|
||||||
|
"version": "5.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
||||||
|
"integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"source-map": "~0.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "10.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||||
|
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/dot-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"no-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/entities": {
|
"node_modules/entities": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
@@ -986,6 +1085,48 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-minifier-terser": {
|
||||||
|
"version": "7.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-7.2.0.tgz",
|
||||||
|
"integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"camel-case": "^4.1.2",
|
||||||
|
"clean-css": "~5.3.2",
|
||||||
|
"commander": "^10.0.0",
|
||||||
|
"entities": "^4.4.0",
|
||||||
|
"param-case": "^3.0.4",
|
||||||
|
"relateurl": "^0.2.7",
|
||||||
|
"terser": "^5.15.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"html-minifier-terser": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^14.13.1 || >=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/html-minifier-terser/node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lower-case": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.21",
|
"version": "0.30.21",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||||
@@ -1011,6 +1152,36 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/no-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"lower-case": "^2.0.2",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/param-case": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"dot-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pascal-case": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"no-case": "^3.0.4",
|
||||||
|
"tslib": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1043,6 +1214,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/relateurl": {
|
||||||
|
"version": "0.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
|
||||||
|
"integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
@@ -1087,6 +1267,15 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/source-map": {
|
||||||
|
"version": "0.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
|
"dev": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1095,6 +1284,46 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/source-map-support": {
|
||||||
|
"version": "0.5.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
|
||||||
|
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"source-map": "^0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/terser": {
|
||||||
|
"version": "5.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz",
|
||||||
|
"integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/source-map": "^0.3.3",
|
||||||
|
"acorn": "^8.15.0",
|
||||||
|
"commander": "^2.20.0",
|
||||||
|
"source-map-support": "~0.5.20"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"terser": "bin/terser"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/terser/node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
|
|||||||
@@ -7,15 +7,17 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"deploy": "vite build && rsync -a --delete dist/js/mainPage/ /var/www/html/js/mainPage/ && rsync -a --delete dist/css/mainPage/ /var/www/html/css/mainPage/ && rsync -a dist/index.html /var/www/html/index.html && rsync -a dist/favicon.svg /var/www/html/favicon.svg"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0",
|
"vue-i18n": "^9.13.1",
|
||||||
"vue-i18n": "^9.13.1"
|
"vue-router": "^4.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"html-minifier-terser": "^7.2.0",
|
||||||
"vite": "^5.2.0"
|
"vite": "^5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/App.vue
49
src/App.vue
@@ -1,6 +1,38 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
import TabNav from './components/TabNav.vue'
|
import TabNav from './components/TabNav.vue'
|
||||||
import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
||||||
|
import modules from './modules/index.js'
|
||||||
|
import { markVisited } from './composables/useProjects.js'
|
||||||
|
import { getLiveness, startPolling } from './composables/useLiveness.js'
|
||||||
|
import { healthUrl } from './config/app.config.js'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 隐藏页签访问后保持可见:路由切到某模块即标记已访问
|
||||||
|
watch(
|
||||||
|
() => route.meta.moduleId,
|
||||||
|
(id) => markVisited(id),
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeepAlive 缓存名单:默认缓存所有模块组件名(切换页签不卸载)。
|
||||||
|
// 声明了 requiresAlive 的模块在服务失活(down)时移出名单 -> 卸载。
|
||||||
|
const aliveNames = modules.filter((m) => m.requiresAlive)
|
||||||
|
aliveNames.forEach((m) => {
|
||||||
|
// 加载即探测 + 每 30s 周期重探
|
||||||
|
startPolling(m.requiresAlive)
|
||||||
|
})
|
||||||
|
const keepInclude = computed(() =>
|
||||||
|
modules
|
||||||
|
.filter((m) => {
|
||||||
|
if (!m.requiresAlive) return true // 无存活依赖:始终缓存
|
||||||
|
const state = getLiveness(m.requiresAlive)
|
||||||
|
return state.value !== 'down' // 失活时不缓存 -> 卸载
|
||||||
|
})
|
||||||
|
.map((m) => m.id)
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -10,8 +42,6 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
|||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="brand__mark">Z</span>
|
<span class="brand__mark">Z</span>
|
||||||
<span class="brand__title">{{ $t('app.title') }}</span>
|
<span class="brand__title">{{ $t('app.title') }}</span>
|
||||||
<span class="brand__sep">·</span>
|
|
||||||
<span class="brand__sub">{{ $t('app.subtitle') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +52,9 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
|||||||
<main class="main">
|
<main class="main">
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
<Transition name="fade" mode="out-in">
|
<Transition name="fade" mode="out-in">
|
||||||
|
<KeepAlive :include="keepInclude">
|
||||||
<component :is="Component" />
|
<component :is="Component" />
|
||||||
|
</KeepAlive>
|
||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
</main>
|
</main>
|
||||||
@@ -30,8 +62,6 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer__inner">
|
<div class="container footer__inner">
|
||||||
<span>© {{ new Date().getFullYear() }} Zikai Wang</span>
|
<span>© {{ new Date().getFullYear() }} Zikai Wang</span>
|
||||||
<span class="footer__dot">·</span>
|
|
||||||
<span>Built with Vue 3</span>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,14 +103,6 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.brand__sep {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
.brand__sub {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main {
|
.main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -98,9 +120,6 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
|||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.footer__dot {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useI18nLazy } from '../composables/useI18nLazy.js'
|
import { useI18nLazy } from '../composables/useI18nLazy.js'
|
||||||
|
|
||||||
const { current, switching, switchLocale } = useI18nLazy()
|
const { current, switching, switchLocale } = useI18nLazy()
|
||||||
|
// current 直接派生自全局 locale,无需 useI18n
|
||||||
|
|
||||||
const locales = [
|
const locales = [
|
||||||
{ code: 'zh-CN', label: '中' },
|
{ code: 'zh-CN', label: '中' },
|
||||||
|
|||||||
@@ -45,22 +45,24 @@ const { projects } = useProjects()
|
|||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: color var(--transition);
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
transition: color var(--transition), background var(--transition);
|
||||||
}
|
}
|
||||||
.tab-nav__item:hover {
|
.tab-nav__item:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.tab-nav__item.is-active {
|
.tab-nav__item.is-active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.tab-nav__item.is-active::after {
|
.tab-nav__item.is-active::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: var(--space-md);
|
left: 0;
|
||||||
right: var(--space-md);
|
right: 0;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
border-radius: 2px;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
hoverable: { type: Boolean, default: false }
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="card" :class="{ 'card--hover': hoverable }">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.card {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
padding: var(--space-lg);
|
|
||||||
}
|
|
||||||
.card--hover {
|
|
||||||
transition: transform var(--transition), box-shadow var(--transition);
|
|
||||||
}
|
|
||||||
.card--hover:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-hover);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
73
src/components/ui/PageShell.vue
Normal file
73
src/components/ui/PageShell.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useLocale } from '../../composables/useLocale.js'
|
||||||
|
|
||||||
|
// ★ 可复用页面外壳 -- 统一页面的容器宽度、垂直节奏与标题,消除各模块重复包裹。
|
||||||
|
// 高内聚:所有页面布局令牌集中于此;低耦合:模块只声明 titleKey/fluid,
|
||||||
|
// 不关心 .container 实现。新增页签照此复用,无需各写一套 wrapper。
|
||||||
|
//
|
||||||
|
// Props:
|
||||||
|
// titleKey: i18n 键(如 'tabs.whiteboard'),渲染为页面主标题;不传则无标题
|
||||||
|
// fluid: true 时突破 1080px 限制(全宽 + 左右 padding),如日历页
|
||||||
|
const props = defineProps({
|
||||||
|
titleKey: { type: String, default: '' },
|
||||||
|
fluid: { type: Boolean, default: false }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t } = useLocale()
|
||||||
|
const title = computed(() => (props.titleKey ? t(props.titleKey) : ''))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-shell">
|
||||||
|
<div :class="fluid ? 'page-shell__fluid' : 'container page-shell__inner'">
|
||||||
|
<div v-if="title || $slots.actions" class="page-shell__head">
|
||||||
|
<h1 v-if="title" class="page-shell__title">{{ title }}</h1>
|
||||||
|
<div v-if="$slots.actions" class="page-shell__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-shell {
|
||||||
|
padding: var(--space-xl) 0 var(--space-2xl);
|
||||||
|
}
|
||||||
|
/* 标准容器宽度复用全局 .container(max-width 1080px,居中,左右 padding) */
|
||||||
|
.page-shell__inner {
|
||||||
|
/* 宽度由 .container 控制,此处仅作语义占位 */
|
||||||
|
}
|
||||||
|
/* 全宽变体:突破 1080px,仅留左右内边距(如日历页) */
|
||||||
|
.page-shell__fluid {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.page-shell__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
.page-shell__title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.page-shell__actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page-shell__head {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
tag: { type: String, default: 'span' }
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<component :is="tag" class="tag">
|
|
||||||
<slot />
|
|
||||||
</component>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-weak);
|
|
||||||
border-radius: 999px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { loadLocaleAsync } from '../i18n/index.js'
|
import i18n, { loadLocaleAsync } from '../i18n/index.js'
|
||||||
|
|
||||||
const current = ref('zh-CN')
|
|
||||||
const switching = ref(false)
|
const switching = ref(false)
|
||||||
|
|
||||||
// 语言懒加载切换逻辑
|
// 语言懒加载切换逻辑
|
||||||
|
// current 派生自全局 i18n composer 的 locale(composition 模式下在 i18n.global),
|
||||||
|
// 与各组件内 useI18n({ useScope: 'global' }) 读到的是同一个 ref。
|
||||||
export function useI18nLazy() {
|
export function useI18nLazy() {
|
||||||
|
const current = computed(() => i18n.global.locale.value)
|
||||||
|
|
||||||
async function switchLocale(locale) {
|
async function switchLocale(locale) {
|
||||||
if (switching.value || current.value === locale) return
|
if (switching.value || current.value === locale) return
|
||||||
switching.value = true
|
switching.value = true
|
||||||
try {
|
try {
|
||||||
await loadLocaleAsync(locale)
|
await loadLocaleAsync(locale)
|
||||||
current.value = locale
|
} catch (e) {
|
||||||
|
// 语言包加载失败不能静默:记录到控制台,便于排查(如 chunk 404)
|
||||||
|
console.error('[mainPage] 加载语言包失败', locale, e)
|
||||||
} finally {
|
} finally {
|
||||||
switching.value = false
|
switching.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
84
src/composables/useLiveness.js
Normal file
84
src/composables/useLiveness.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// ★ 存活校验 -- 跨域用 no-cors fetch 探测(resolve=存活,reject/timeout=挂掉)
|
||||||
|
// 探测时机:首次访问立即探测一次,之后按周期重复探测(默认 30s),
|
||||||
|
// 结果随时间在 up/down 间翻转,依赖该态的页签/缓存会响应式更新。
|
||||||
|
|
||||||
|
// 模块级共享缓存:url -> { state: ref('pending'|'up'|'down'), promise, timer }
|
||||||
|
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) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
await fetch(url, {
|
||||||
|
mode: 'no-cors', // 跨域不读响应体,resolve 即存活
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: controller.signal,
|
||||||
|
redirect: 'follow'
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取某 url 的存活态 ref(响应式)。
|
||||||
|
* 首次调用立即触发一次探测;之后所有调用方共用同一个 ref。
|
||||||
|
* 要周期重探,额外调用 startPolling(url)。
|
||||||
|
* 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')
|
||||||
|
entry = { state, promise: null, timer: null }
|
||||||
|
cache.set(url, entry)
|
||||||
|
// 首次访问立即探测一次
|
||||||
|
entry.promise = checkAlive(url, timeoutMs).then((ok) => {
|
||||||
|
state.value = ok ? 'up' : 'down'
|
||||||
|
return ok
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entry.state
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对某 url 启动周期探测(首次立即探测 + 每 intervalMs 一次)。
|
||||||
|
* 幂等:重复调用不会叠加定时器。返回停止函数。
|
||||||
|
* @param {string} url
|
||||||
|
* @param {number} [intervalMs] 默认 30s
|
||||||
|
* @param {number} [timeoutMs]
|
||||||
|
* @returns {() => void} 停止定时器
|
||||||
|
*/
|
||||||
|
export function startPolling(url, intervalMs = 30000, timeoutMs = 4000) {
|
||||||
|
if (!url) return () => {}
|
||||||
|
getLiveness(url, timeoutMs) // 确保缓存项存在并已触发首次探测
|
||||||
|
const entry = cache.get(url)
|
||||||
|
if (entry.timer) return () => clearInterval(entry.timer)
|
||||||
|
entry.timer = setInterval(() => {
|
||||||
|
checkAlive(url, timeoutMs).then((ok) => {
|
||||||
|
entry.state.value = ok ? 'up' : 'down'
|
||||||
|
})
|
||||||
|
}, intervalMs)
|
||||||
|
return () => {
|
||||||
|
if (entry.timer) {
|
||||||
|
clearInterval(entry.timer)
|
||||||
|
entry.timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/composables/useLocale.js
Normal file
11
src/composables/useLocale.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
// 共享的语言辅助 composable,消除各组件重复定义。
|
||||||
|
// 用法:
|
||||||
|
// const { t } = useLocale()
|
||||||
|
// t('some.key') // 走 i18n(文案在 locale 文件)
|
||||||
|
// 页签包装器透传 locale 给子模块时直接读 i18n.global.locale(见各 wrapper)。
|
||||||
|
export function useLocale() {
|
||||||
|
const { t } = useI18n({ useScope: 'global' })
|
||||||
|
return { t }
|
||||||
|
}
|
||||||
@@ -1,8 +1,43 @@
|
|||||||
import modules from '../modules/index.js'
|
import modules from '../modules/index.js'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { getLiveness } from './useLiveness.js'
|
||||||
|
|
||||||
// 读取已注册模块列表,供 TabNav 渲染页签
|
// 记录「曾经被访问过」的模块 id。隐藏页签(如 cqy)一旦通过 URL 访问,
|
||||||
|
// 即加入此集合,使其在导航中保持可见 -- 不会因切走其他页签而消失。
|
||||||
|
const visited = ref(new Set())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记某模块已被访问(隐藏页签访问后保持可见)。
|
||||||
|
* @param {string} moduleId
|
||||||
|
*/
|
||||||
|
export function markVisited(moduleId) {
|
||||||
|
if (moduleId && !visited.value.has(moduleId)) {
|
||||||
|
visited.value = new Set(visited.value).add(moduleId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取已注册模块列表,供 TabNav 渲染页签。
|
||||||
|
// hidden 模块:默认不显示,但「当前正在访问」或「曾经访问过」后保持显示。
|
||||||
|
// requiresAlive 模块:乐观显示 -- 探测中(pending)与存活(up)均显示,
|
||||||
|
// 仅当明确探测为不可达(down)时才隐藏。
|
||||||
export function useProjects() {
|
export function useProjects() {
|
||||||
const projects = ref(modules)
|
const route = useRoute()
|
||||||
|
const projects = computed(() =>
|
||||||
|
modules.filter((m) => {
|
||||||
|
// 隐藏模块:当前正在访问 或 曾经访问过 才显示
|
||||||
|
if (m.hidden) {
|
||||||
|
const current = m.id === route.meta.moduleId
|
||||||
|
const wasVisited = visited.value.has(m.id)
|
||||||
|
if (!current && !wasVisited) return false
|
||||||
|
}
|
||||||
|
// 依赖存活的模块:仅 down 时隐藏;pending/up 都显示
|
||||||
|
if (m.requiresAlive) {
|
||||||
|
const state = getLiveness(m.requiresAlive)
|
||||||
|
if (state.value === 'down') return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
)
|
||||||
return { projects }
|
return { projects }
|
||||||
}
|
}
|
||||||
|
|||||||
43
src/config/app.config.js
Normal file
43
src/config/app.config.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// ★ 集中配置 -- 外部服务 URL、默认参数等统一在此维护
|
||||||
|
// 模块按需 import { appConfig } 使用,改 URL 只改这里即可(需重新 build)。
|
||||||
|
// 每个服务项都带默认值,避免缺失字段导致渲染崩溃。
|
||||||
|
//
|
||||||
|
// 路由约定:zTools2 所有入口(API/静态/探针/WS)统一挂在 /api/ 下,
|
||||||
|
// fetch 与探针一律用**同源相对路径**(/api/pdf/jobs、/api/health 等)。
|
||||||
|
// 无论是 vite dev(server.proxy 转发 /api 到本地 zTools2)、还是任意部署环境
|
||||||
|
// (Apache/Nginx 反代 /api 到当前环境的 zTools2),同源相对路径都自动指向
|
||||||
|
// **当前环境**的服务,无需区分 dev/prod,也不会误打到其它环境域名。
|
||||||
|
// 罕见需要时可设 VITE_API_BASE(含协议与域名)显式覆盖为绝对地址。
|
||||||
|
|
||||||
|
// 默认空串 = 同源相对路径;VITE_API_BASE 可覆盖为绝对地址(如指向独立测试环境域名)
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE || ''
|
||||||
|
|
||||||
|
export const appConfig = {
|
||||||
|
// 共享记事本(白板)服务
|
||||||
|
whiteboard: {
|
||||||
|
// 服务根地址(含协议与域名);默认空串 = 同源相对路径
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
// 存活探针路径;周期探测,挂掉则隐藏页签并卸载组件
|
||||||
|
healthPath: '/api/health',
|
||||||
|
// 默认打开的记事本 id(Whiteboard.vue 的 resolveBoardId 回退用)
|
||||||
|
defaultBoardId: 'share'
|
||||||
|
},
|
||||||
|
// PDF 转换服务(zTools2 提供 /api/pdf/* REST API;zPDF_package 组件同源 fetch 调用)
|
||||||
|
pdf: {
|
||||||
|
// 服务根地址(含协议与域名);默认空串 = 同源相对路径
|
||||||
|
baseUrl: BASE_URL,
|
||||||
|
// 存活探针路径;周期探测,挂掉则隐藏页签并卸载组件
|
||||||
|
healthPath: '/api/health'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取某服务的存活探针 URL。
|
||||||
|
* @param {keyof typeof appConfig} service 配置中的服务名
|
||||||
|
* @returns {string} 完整探针 URL,如同源相对 /api/health 或绝对地址
|
||||||
|
*/
|
||||||
|
export function healthUrl(service) {
|
||||||
|
const cfg = appConfig[service]
|
||||||
|
if (!cfg) return ''
|
||||||
|
return `${cfg.baseUrl}${cfg.healthPath}`
|
||||||
|
}
|
||||||
@@ -22,9 +22,10 @@ const loadedLocales = new Set(['zh-CN'])
|
|||||||
* 之后切换仅在已加载集合内完成(无网络请求)。
|
* 之后切换仅在已加载集合内完成(无网络请求)。
|
||||||
*/
|
*/
|
||||||
export async function loadLocaleAsync(locale) {
|
export async function loadLocaleAsync(locale) {
|
||||||
if (i18n.locale.value === locale) return
|
// composition 模式下响应式 locale 在 i18n.global 上
|
||||||
|
if (i18n.global.locale.value === locale) return
|
||||||
if (loadedLocales.has(locale)) {
|
if (loadedLocales.has(locale)) {
|
||||||
i18n.locale.value = locale
|
i18n.global.locale.value = locale
|
||||||
document.documentElement.setAttribute('lang', locale)
|
document.documentElement.setAttribute('lang', locale)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -35,14 +36,14 @@ export async function loadLocaleAsync(locale) {
|
|||||||
messages = (await import('./locales/en.js')).default
|
messages = (await import('./locales/en.js')).default
|
||||||
} else {
|
} else {
|
||||||
// 未知语言回退到中文
|
// 未知语言回退到中文
|
||||||
i18n.locale.value = 'zh-CN'
|
i18n.global.locale.value = 'zh-CN'
|
||||||
document.documentElement.setAttribute('lang', 'zh-CN')
|
document.documentElement.setAttribute('lang', 'zh-CN')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
i18n.mergeLocaleMessage(locale, messages)
|
i18n.global.mergeLocaleMessage(locale, messages)
|
||||||
loadedLocales.add(locale)
|
loadedLocales.add(locale)
|
||||||
i18n.locale.value = locale
|
i18n.global.locale.value = locale
|
||||||
document.documentElement.setAttribute('lang', locale)
|
document.documentElement.setAttribute('lang', locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,23 @@
|
|||||||
// 只看中文的访客不会下载此文件。
|
// 只看中文的访客不会下载此文件。
|
||||||
export default {
|
export default {
|
||||||
app: {
|
app: {
|
||||||
title: 'Zikai',
|
title: 'Zikai'
|
||||||
subtitle: 'Portfolio',
|
|
||||||
nav: {
|
|
||||||
projects: 'Projects',
|
|
||||||
language: 'Language'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
loading: 'Loading…',
|
loading: 'Loading…'
|
||||||
comingSoon: 'Coming soon',
|
|
||||||
notAvailable: 'Not available',
|
|
||||||
backToTop: 'Back to top'
|
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
mobileGame: 'Mobile Game Project'
|
mobileGame: 'Mobile Game Project',
|
||||||
|
calendar: 'CQY Timetable',
|
||||||
|
whiteboard: 'Shared Notepad',
|
||||||
|
timetable: 'Schedule Organizer',
|
||||||
|
pdf: 'PDF Converter'
|
||||||
|
},
|
||||||
|
whiteboard: {
|
||||||
|
boardId: 'Board ID',
|
||||||
|
switch: 'Switch'
|
||||||
}
|
}
|
||||||
|
// Mobile-game copy is provided by the z449 submodule's own i18n;
|
||||||
|
// mainPage keeps only tabs.mobileGame. Locale syncs to z449 via :locale prop.
|
||||||
|
// PDF tab copy is provided by the zPDF_package submodule's own i18n; mainPage keeps only tabs.pdf.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
// 中文语言包(默认语言,同步注入主 chunk,随首屏下发)
|
// 中文语言包(默认语言,同步注入主 chunk,随首屏下发)
|
||||||
export default {
|
export default {
|
||||||
app: {
|
app: {
|
||||||
title: 'Zikai',
|
title: 'Zikai'
|
||||||
subtitle: '作品集',
|
|
||||||
nav: {
|
|
||||||
projects: '项目',
|
|
||||||
language: '语言'
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
loading: '加载中…',
|
loading: '加载中…'
|
||||||
comingSoon: '即将上线',
|
|
||||||
notAvailable: '暂不可用',
|
|
||||||
backToTop: '回到顶部'
|
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
mobileGame: '移动游戏项目'
|
mobileGame: '移动游戏项目',
|
||||||
|
calendar: 'cqy课程表',
|
||||||
|
whiteboard: '共享记事本',
|
||||||
|
timetable: '日程整理',
|
||||||
|
pdf: 'PDF 转换'
|
||||||
|
},
|
||||||
|
whiteboard: {
|
||||||
|
boardId: '白板 ID',
|
||||||
|
switch: '切换'
|
||||||
}
|
}
|
||||||
|
// 注:移动游戏项目(mobile-game)的文案由子模块 z449 自有 i18n 提供,
|
||||||
|
// mainPage 仅保留 tabs.mobileGame 页签标题。切换语言时经 :locale prop 同步给 z449。
|
||||||
|
// PDF 转换页签的文案由子模块 zPDF_package 自有 i18n 提供,mainPage 仅保留 tabs.pdf。
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/modules/calendar/Calendar.vue
Normal file
89
src/modules/calendar/Calendar.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配
|
||||||
|
defineOptions({ name: 'cqy' })
|
||||||
|
import { useLocale } from '../../composables/useLocale.js'
|
||||||
|
import PageShell from '../../components/ui/PageShell.vue'
|
||||||
|
|
||||||
|
const { t } = useLocale()
|
||||||
|
|
||||||
|
// 谷歌日历 embed URL(周视图)
|
||||||
|
const calendarSrc = computed(() =>
|
||||||
|
'https://calendar.google.com/calendar/embed?height=600&wkst=2&ctz=Australia%2FMelbourne&showPrint=0&mode=WEEK' +
|
||||||
|
'&src=Y2E0YTE5ZWYwNDM3OTYyYzY5YjEyYzYxYWVmOGVlZjY3MGU5ZDdmYjRlNzJlOTFjNDJjMzllM2Q2NGU5ZTAzNkBncm91cC5jYWxlbmRhci5nb29nbGUuY29t' +
|
||||||
|
'&src=emguYXVzdHJhbGlhbiNob2xpZGF5QGdyb3VwLnYuY2FsZW5kYXIuZ29vZ2xlLmNvbQ' +
|
||||||
|
'&color=%237986cb&color=%230b8043'
|
||||||
|
)
|
||||||
|
|
||||||
|
const loaded = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<!-- 日历用全宽变体(fluid):突破 1080px,仅留左右内边距 -->
|
||||||
|
<PageShell title-key="tabs.calendar" fluid>
|
||||||
|
<div class="cal-frame">
|
||||||
|
<!-- 加载占位骨架(覆盖层,iframe 加载完成后淡出) -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="!loaded" class="cal-frame__skeleton">
|
||||||
|
<div class="cal-frame__skeleton-text">{{ t('common.loading') }}</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<iframe
|
||||||
|
:src="calendarSrc"
|
||||||
|
class="cal-frame__iframe"
|
||||||
|
frameborder="0"
|
||||||
|
scrolling="no"
|
||||||
|
:title="t('tabs.calendar')"
|
||||||
|
@load="loaded = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cal-frame {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
/* 自适应宽高:宽屏 16:9,窄屏更高 */
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 10;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
.cal-frame__iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.cal-frame__skeleton {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.cal-frame__skeleton-text {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.cal-frame {
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
src/modules/calendar/index.js
Normal file
10
src/modules/calendar/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// 模块元数据 -- 隐藏页,仅可通过手动访问 /cqy 进入
|
||||||
|
// hidden: true 使其不出现在 TabNav 导航中,但路由仍正常生成
|
||||||
|
export default {
|
||||||
|
id: 'cqy',
|
||||||
|
tabKey: 'tabs.calendar',
|
||||||
|
order: 99,
|
||||||
|
hidden: true,
|
||||||
|
// 懒加载组件 -> 独立 chunk
|
||||||
|
component: () => import('./Calendar.vue')
|
||||||
|
}
|
||||||
@@ -3,19 +3,33 @@
|
|||||||
// 自动生成统一的项目列表。新增项目页签 = 新建 modules/<新项目>/ 文件夹,
|
// 自动生成统一的项目列表。新增项目页签 = 新建 modules/<新项目>/ 文件夹,
|
||||||
// 无需改动导航/路由代码。
|
// 无需改动导航/路由代码。
|
||||||
//
|
//
|
||||||
// 每个模块 index.js 默认导出形如:
|
// 每个模块 index.js 默认导出元数据:
|
||||||
// {
|
// {
|
||||||
// id: 'mobile-game', // 唯一标识,同时用作路由 path
|
// id: 'mobile-game', // 必填:唯一标识,同时用作路由 path / KeepAlive 匹配名
|
||||||
// tabKey: 'tabs.mobileGame', // i18n 键,用于页签标题
|
// tabKey: 'tabs.mobileGame', // 必填:i18n 键,页签标题与 PageShell 标题均用此
|
||||||
// order: 1, // 页签排序
|
// component: () => import(...), // 必填:懒加载组件(函数形式,独立 chunk)
|
||||||
// component: () => import('./MobileGame.vue') // 懒加载组件
|
// order: 1, // 可选:页签排序,缺省 999(排在显式页签之后)
|
||||||
|
// hidden: true, // 可选:不在导航显示,但 URL 访问后保持可见(如 cqy)
|
||||||
|
// requiresAlive: 'url', // 可选:存活探针 URL,服务失活则隐藏页签并卸载组件
|
||||||
|
// param: 'boardId', // 可选:路由参数名,生成 /<id>/:<param>? 子路径
|
||||||
// }
|
// }
|
||||||
|
//
|
||||||
|
// 容错:字段不完整的模块跳过并告警,单个模块异常不影响其余页签加载。
|
||||||
|
// KeepAlive 约定:组件需 defineOptions({ name: id }) 以匹配缓存名单(见 App.vue)。
|
||||||
|
|
||||||
const moduleFiles = import.meta.glob('./*/index.js', { eager: true })
|
const moduleFiles = import.meta.glob('./*/index.js', { eager: true })
|
||||||
|
|
||||||
const modules = Object.values(moduleFiles)
|
const modules = Object.entries(moduleFiles)
|
||||||
.map((mod) => mod.default)
|
.map(([path, mod]) => ({ path, mod: mod?.default }))
|
||||||
.filter(Boolean)
|
.filter(({ mod }) => mod != null)
|
||||||
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99))
|
// 字段完整性兜底:缺 id/tabKey/component 的模块无法生成页签与路由,跳过并告警
|
||||||
|
.filter(({ path, mod }) => {
|
||||||
|
if (mod.id && mod.tabKey && typeof mod.component === 'function') return true
|
||||||
|
console.warn('[mainPage] 模块缺少必填字段,已跳过', path)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.map(({ mod }) => mod)
|
||||||
|
// order 缺省 999:排在所有显式 order 页签之后(hidden 页常用 99 之类显式值)
|
||||||
|
.sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
|
||||||
|
|
||||||
export default modules
|
export default modules
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import HeroSection from './components/HeroSection.vue'
|
|
||||||
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
|
|
||||||
import VersionTimeline from './components/VersionTimeline.vue'
|
|
||||||
import TeamCard from './components/TeamCard.vue'
|
|
||||||
import Leaderboard from './components/Leaderboard.vue'
|
|
||||||
|
|
||||||
const { locale } = useI18n()
|
|
||||||
const L = (zh, en) => (locale.value === 'zh-CN' ? zh : en)
|
|
||||||
// 站点根绝对路径,用绑定避免被 Vite 当作构建期静态资源解析
|
|
||||||
const videoPoster = '/448/2.jpg'
|
|
||||||
const videoSrc = '/448/ml/449ml.mp4'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mobile-game">
|
|
||||||
<HeroSection />
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<!-- 概览 + 截图轮播 -->
|
|
||||||
<section class="block">
|
|
||||||
<div class="block__grid">
|
|
||||||
<div class="block__text">
|
|
||||||
<h2 class="section-title">
|
|
||||||
<small>{{ L('项目概览', 'Overview') }}</small>
|
|
||||||
{{ L('从角色控制器到陀螺仪滚球', 'From character controller to gyro ball') }}
|
|
||||||
</h2>
|
|
||||||
<p>
|
|
||||||
{{ L(
|
|
||||||
'项目跨越 448 与 449 两门课程。448 用 Unity 开发游戏本体,产出可浏览器试玩的 WebGL 构建与可安装的 Android APK;449 搭建展示网站与基于 PHP+MySQL 的玩家分数排行榜。主要贡献者 Zikai Wang(zikai-ui / main / Roll-a-Ball 系列)与 Hongxiang He(2D DobyAdventure)。',
|
|
||||||
'The project spans courses 448 and 449. 448 built the game with Unity, producing browser-playable WebGL builds and installable Android APKs; 449 built the showcase site and a PHP+MySQL player leaderboard. Main contributors: Zikai Wang (zikai-ui / main / Roll-a-Ball series) and Hongxiang He (2D DobyAdventure).'
|
|
||||||
) }}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{{ L(
|
|
||||||
'玩法经过多版本迭代:早期是键盘控制生命/能量的角色控制器,最终版改为手机陀螺仪倾斜操控的 3D 滚球,含雪花砖掉落、黄色砖弹跳等机制。还用 Unity ML-Agents 做了强化学习实验。',
|
|
||||||
'Gameplay iterated across versions: early builds were keyboard-controlled character controllers with health/power stats; the final version became a gyroscope-tilt 3D rolling-ball game with falling snowflake bricks and bouncing yellow bricks. ML-Agents reinforcement-learning experiments were also conducted.'
|
|
||||||
) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="block__media">
|
|
||||||
<ScreenshotCarousel />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 版本时间线 -->
|
|
||||||
<section class="block">
|
|
||||||
<h2 class="section-title">
|
|
||||||
<small>{{ L('版本演进', 'Version History') }}</small>
|
|
||||||
{{ L('迭代记录', 'Iteration Record') }}
|
|
||||||
</h2>
|
|
||||||
<VersionTimeline />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 双列:团队 + 排行榜 -->
|
|
||||||
<section class="block block--two">
|
|
||||||
<div>
|
|
||||||
<h2 class="section-title">
|
|
||||||
<small>{{ L('团队', 'Team') }}</small>
|
|
||||||
{{ L('成员', 'Members') }}
|
|
||||||
</h2>
|
|
||||||
<TeamCard />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 class="section-title">
|
|
||||||
<small>{{ L('排行榜', 'Leaderboard') }}</small>
|
|
||||||
{{ L('玩家分数 Top 30', 'Top 30 Scores') }}
|
|
||||||
</h2>
|
|
||||||
<Leaderboard />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ML-Agents 演示视频 -->
|
|
||||||
<section class="block">
|
|
||||||
<h2 class="section-title">
|
|
||||||
<small>{{ L('实验', 'Experiment') }}</small>
|
|
||||||
{{ L('ML-Agents 强化学习演示', 'ML-Agents Reinforcement Learning Demo') }}
|
|
||||||
</h2>
|
|
||||||
<div class="video-wrap">
|
|
||||||
<video controls preload="none" :poster="videoPoster">
|
|
||||||
<source :src="videoSrc" type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
<p class="video-caption">
|
|
||||||
{{ L('胶囊体经 ML-Agents 训练,学习保球不掉与越障。', 'Capsules trained via ML-Agents to keep the ball from falling and to navigate obstacles.') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.mobile-game {
|
|
||||||
padding-bottom: var(--space-2xl);
|
|
||||||
}
|
|
||||||
.block {
|
|
||||||
padding: var(--space-xl) 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.block:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.block__grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--space-xl);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
.block__text p {
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
.block__text p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.block--two {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: var(--space-xl);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-wrap {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
.video-wrap video {
|
|
||||||
width: 100%;
|
|
||||||
display: block;
|
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
background: #000;
|
|
||||||
}
|
|
||||||
.video-caption {
|
|
||||||
padding: var(--space-md) var(--space-lg);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.block__grid,
|
|
||||||
.block--two {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
20
src/modules/mobile-game/MobileGameWrapper.vue
Normal file
20
src/modules/mobile-game/MobileGameWrapper.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
// ★ mobile-game 页签的 mainPage 侧包装器。
|
||||||
|
// 子模块 z449 的 App.vue 作为便携式组件被 import,本包装器负责:
|
||||||
|
// 1. 持有 KeepAlive 匹配用的组件名(与模块 id 一致),子模块自身不持 name
|
||||||
|
// 2. 把 mainPage 当前语言经 :locale prop 透传给子模块的独立 i18n 实例,
|
||||||
|
// 使其跟随父项目语言开关响应式切换(不共享 messages,仅同步 locale 值)
|
||||||
|
// 子模块独立运行时不经过本包装器,保持纯净。
|
||||||
|
defineOptions({ name: 'mobile-game' })
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import i18n from '../../i18n/index.js'
|
||||||
|
import Z449App from '../../../third_party/z449/src/App.vue'
|
||||||
|
|
||||||
|
// 透传父项目当前语言给子应用(响应式:切语言时子应用内容跟随)
|
||||||
|
const locale = computed(() => i18n.global.locale.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Z449App :locale="locale" />
|
||||||
|
</template>
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Tag from '../../../components/ui/Tag.vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
const { locale } = useI18n()
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
course: { zh: '迈阿密大学 · 毕设 448 / 449', en: 'Miami University · Capstone 448 / 449' },
|
|
||||||
stack: { zh: 'Unity · C# · WebGL · Android · PHP', en: 'Unity · C# · WebGL · Android · PHP' },
|
|
||||||
period: { zh: '2022', en: '2022' },
|
|
||||||
role: { zh: '主开发', en: 'Lead Developer' }
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<section class="hero">
|
|
||||||
<div class="container">
|
|
||||||
<div class="hero__eyebrow">
|
|
||||||
<Tag>{{ locale === 'zh-CN' ? meta.course.zh : meta.course.en }}</Tag>
|
|
||||||
</div>
|
|
||||||
<h1 class="hero__title">
|
|
||||||
{{ $t('tabs.mobileGame') }}
|
|
||||||
</h1>
|
|
||||||
<p class="hero__desc">
|
|
||||||
{{ locale === 'zh-CN'
|
|
||||||
? '一个跨学期的 Unity 移动游戏毕设项目:448 开发游戏本体(WebGL + Android APK),449 搭建展示网站与玩家排行榜。经历多版本迭代,从角色控制器演进到陀螺仪操控的 3D 滚球游戏,并尝试用 ML-Agents 做强化学习实验。'
|
|
||||||
: 'A cross-semester Unity mobile-game capstone: 448 built the game (WebGL + Android APK), 449 built the showcase site and player leaderboard. Iterated across versions, from a character controller to a gyroscope-controlled 3D rolling-ball game, with ML-Agents reinforcement-learning experiments.' }}
|
|
||||||
</p>
|
|
||||||
<div class="hero__meta">
|
|
||||||
<div class="hero__meta-item">
|
|
||||||
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '技术栈' : 'Stack' }}</span>
|
|
||||||
<span>{{ locale === 'zh-CN' ? meta.stack.zh : meta.stack.en }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="hero__meta-item">
|
|
||||||
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '时间' : 'Period' }}</span>
|
|
||||||
<span>{{ meta.period }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="hero__meta-item">
|
|
||||||
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '角色' : 'Role' }}</span>
|
|
||||||
<span>{{ locale === 'zh-CN' ? meta.role.zh : meta.role.en }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.hero {
|
|
||||||
padding: var(--space-2xl) 0 var(--space-xl);
|
|
||||||
background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 100%);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
.hero__eyebrow {
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
.hero__title {
|
|
||||||
font-size: 2.3rem;
|
|
||||||
margin-bottom: var(--space-md);
|
|
||||||
}
|
|
||||||
.hero__desc {
|
|
||||||
max-width: 720px;
|
|
||||||
font-size: 1.02rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
.hero__meta {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xl);
|
|
||||||
}
|
|
||||||
.hero__meta-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.hero__meta-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
|
|
||||||
const { locale, t } = useI18n()
|
|
||||||
|
|
||||||
const rows = ref([])
|
|
||||||
const state = ref('loading') // loading | ok | error
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
// 复用 449 现有后端:POST /449/449rest.php {num} 取分页 JSON
|
|
||||||
async function load() {
|
|
||||||
state.value = 'loading'
|
|
||||||
try {
|
|
||||||
const all = []
|
|
||||||
// 拉取 3 页(每页 10 条,共 top30)
|
|
||||||
for (let page = 0; page < 3; page++) {
|
|
||||||
const res = await fetch('/449/449rest.php', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
||||||
body: `num=${page * 10}`
|
|
||||||
})
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const data = await res.json()
|
|
||||||
// data 形如 { "1": ["name", score], ... }
|
|
||||||
Object.keys(data).forEach((k) => {
|
|
||||||
const entry = data[k]
|
|
||||||
all.push({ rank: all.length + 1, name: entry[0], score: entry[1] })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
rows.value = all
|
|
||||||
state.value = all.length ? 'ok' : 'error'
|
|
||||||
if (!all.length) errorMsg.value = locale.value === 'zh-CN' ? '暂无分数记录' : 'No scores yet'
|
|
||||||
} catch (e) {
|
|
||||||
state.value = 'error'
|
|
||||||
errorMsg.value = locale.value === 'zh-CN'
|
|
||||||
? '排行榜服务暂不可用'
|
|
||||||
: 'Leaderboard service unavailable'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="leaderboard">
|
|
||||||
<div v-if="state === 'loading'" class="leaderboard__state">
|
|
||||||
{{ $t('common.loading') }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="state === 'error'" class="leaderboard__state leaderboard__state--error">
|
|
||||||
{{ errorMsg }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table v-else class="leaderboard__table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>#</th>
|
|
||||||
<th>{{ locale === 'zh-CN' ? '玩家' : 'Name' }}</th>
|
|
||||||
<th>{{ locale === 'zh-CN' ? '分数' : 'Score' }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="r in rows" :key="r.rank" :class="{ 'is-top': r.rank <= 3 }">
|
|
||||||
<td class="leaderboard__rank">{{ r.rank }}</td>
|
|
||||||
<td class="leaderboard__name">{{ r.name }}</td>
|
|
||||||
<td class="leaderboard__score">{{ r.score }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.leaderboard {
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg);
|
|
||||||
}
|
|
||||||
.leaderboard__state {
|
|
||||||
padding: var(--space-xl);
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.leaderboard__state--error {
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.leaderboard__table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
font-size: 0.92rem;
|
|
||||||
}
|
|
||||||
.leaderboard__table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
background: var(--surface);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
}
|
|
||||||
.leaderboard__table td {
|
|
||||||
padding: var(--space-sm) var(--space-md);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.leaderboard__table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.leaderboard__table tbody tr:hover {
|
|
||||||
background: var(--surface);
|
|
||||||
}
|
|
||||||
.leaderboard__rank {
|
|
||||||
width: 50px;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
tr.is-top .leaderboard__rank {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.leaderboard__score {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { screenshots } from '../data/versions.js'
|
|
||||||
|
|
||||||
const { locale } = useI18n()
|
|
||||||
const current = ref(0)
|
|
||||||
let timer = null
|
|
||||||
|
|
||||||
function go(i) {
|
|
||||||
current.value = (i + screenshots.length) % screenshots.length
|
|
||||||
}
|
|
||||||
function next() {
|
|
||||||
go(current.value + 1)
|
|
||||||
}
|
|
||||||
function pause() {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
timer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function resume() {
|
|
||||||
if (!timer) timer = setInterval(next, 5000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(resume)
|
|
||||||
onUnmounted(pause)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="carousel" @mouseenter="pause" @mouseleave="resume">
|
|
||||||
<div class="carousel__viewport">
|
|
||||||
<div class="carousel__track" :style="{ transform: `translateX(-${current * 100}%)` }">
|
|
||||||
<figure v-for="(s, i) in screenshots" :key="i" class="carousel__slide">
|
|
||||||
<img :src="s.src" :alt="locale === 'zh-CN' ? s.caption.zh : s.caption.en" loading="lazy" />
|
|
||||||
</figure>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="carousel__btn carousel__btn--prev" @click="go(current - 1)" aria-label="prev">‹</button>
|
|
||||||
<button class="carousel__btn carousel__btn--next" @click="go(current + 1)" aria-label="next">›</button>
|
|
||||||
|
|
||||||
<div class="carousel__dots">
|
|
||||||
<button
|
|
||||||
v-for="(s, i) in screenshots"
|
|
||||||
:key="i"
|
|
||||||
class="carousel__dot"
|
|
||||||
:class="{ 'is-active': i === current }"
|
|
||||||
@click="go(i)"
|
|
||||||
:aria-label="`slide ${i + 1}`"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.carousel {
|
|
||||||
position: relative;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--surface);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.carousel__viewport {
|
|
||||||
overflow: hidden;
|
|
||||||
aspect-ratio: 4 / 3;
|
|
||||||
}
|
|
||||||
.carousel__track {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
transition: transform 0.5s ease;
|
|
||||||
}
|
|
||||||
.carousel__slide {
|
|
||||||
flex: 0 0 100%;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.carousel__slide img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.carousel__btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
line-height: 1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
transition: all var(--transition);
|
|
||||||
}
|
|
||||||
.carousel__btn:hover {
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
.carousel__btn--prev { left: 12px; }
|
|
||||||
.carousel__btn--next { right: 12px; }
|
|
||||||
|
|
||||||
.carousel__dots {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 12px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.carousel__dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(255, 255, 255, 0.6);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
transition: all var(--transition);
|
|
||||||
}
|
|
||||||
.carousel__dot.is-active {
|
|
||||||
background: var(--accent);
|
|
||||||
border-color: var(--accent);
|
|
||||||
width: 22px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { team } from '../data/team.js'
|
|
||||||
|
|
||||||
const { locale } = useI18n()
|
|
||||||
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="team">
|
|
||||||
<div v-for="m in team" :key="m.name" class="team__card">
|
|
||||||
<div class="team__avatar">{{ m.name.charAt(0) }}</div>
|
|
||||||
<div class="team__info">
|
|
||||||
<div class="team__name">{{ m.name }}</div>
|
|
||||||
<div class="team__role">{{ L(m.role) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.team {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: var(--space-md);
|
|
||||||
}
|
|
||||||
.team__card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-md);
|
|
||||||
padding: var(--space-md) var(--space-lg);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: transform var(--transition), box-shadow var(--transition);
|
|
||||||
}
|
|
||||||
.team__card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: var(--shadow-hover);
|
|
||||||
}
|
|
||||||
.team__avatar {
|
|
||||||
flex: 0 0 40px;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--accent-weak);
|
|
||||||
color: var(--accent);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1.05rem;
|
|
||||||
}
|
|
||||||
.team__name {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
.team__role {
|
|
||||||
font-size: 0.82rem;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { useI18n } from 'vue-i18n'
|
|
||||||
import { versions } from '../data/versions.js'
|
|
||||||
|
|
||||||
const { locale } = useI18n()
|
|
||||||
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="timeline">
|
|
||||||
<div
|
|
||||||
v-for="(v, i) in versions"
|
|
||||||
:key="v.id"
|
|
||||||
class="timeline__item"
|
|
||||||
>
|
|
||||||
<div class="timeline__marker">
|
|
||||||
<span class="timeline__dot"></span>
|
|
||||||
<span v-if="i < versions.length - 1" class="timeline__line"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timeline__body">
|
|
||||||
<div class="timeline__head">
|
|
||||||
<span class="timeline__series">{{ v.series }}</span>
|
|
||||||
<span class="timeline__version">v{{ v.version }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="timeline__product">{{ v.product }}</div>
|
|
||||||
<div class="timeline__row">
|
|
||||||
<span class="timeline__label">{{ locale === 'zh-CN' ? '操作' : 'Controls' }}</span>
|
|
||||||
<span>{{ L(v.control) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="timeline__row">
|
|
||||||
<span class="timeline__label">{{ locale === 'zh-CN' ? '说明' : 'Note' }}</span>
|
|
||||||
<span>{{ L(v.note) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="timeline__row">
|
|
||||||
<span class="timeline__label">Unity</span>
|
|
||||||
<span class="timeline__mono">{{ v.unity }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="timeline__links">
|
|
||||||
<a v-if="v.webgl" :href="v.webgl" target="_blank" rel="noopener">
|
|
||||||
{{ locale === 'zh-CN' ? 'WebGL 试玩' : 'Play WebGL' }} ↗
|
|
||||||
</a>
|
|
||||||
<a v-if="v.apk" :href="v.apk" target="_blank" rel="noopener">
|
|
||||||
{{ locale === 'zh-CN' ? '下载 APK' : 'Download APK' }} ↗
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.timeline {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.timeline__item {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-md);
|
|
||||||
padding-bottom: var(--space-lg);
|
|
||||||
}
|
|
||||||
.timeline__marker {
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
.timeline__dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--bg);
|
|
||||||
border: 2px solid var(--accent);
|
|
||||||
flex: 0 0 12px;
|
|
||||||
}
|
|
||||||
.timeline__line {
|
|
||||||
flex: 1;
|
|
||||||
width: 2px;
|
|
||||||
background: var(--border);
|
|
||||||
margin-top: 4px;
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
.timeline__body {
|
|
||||||
flex: 1;
|
|
||||||
padding: var(--space-md) var(--space-lg);
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transition: box-shadow var(--transition);
|
|
||||||
}
|
|
||||||
.timeline__body:hover {
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.timeline__head {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
.timeline__series {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-size: 1.02rem;
|
|
||||||
}
|
|
||||||
.timeline__version {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--accent);
|
|
||||||
background: var(--accent-weak);
|
|
||||||
padding: 1px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.timeline__product {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
margin-bottom: var(--space-sm);
|
|
||||||
}
|
|
||||||
.timeline__row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 2px 0;
|
|
||||||
}
|
|
||||||
.timeline__label {
|
|
||||||
flex: 0 0 48px;
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
.timeline__mono {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
.timeline__links {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-top: var(--space-sm);
|
|
||||||
font-size: 0.88rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// 团队成员(来自 449 展示页)
|
|
||||||
export const team = [
|
|
||||||
{ name: 'Zikai Wang', role: { zh: '主开发', en: 'Lead Developer' } },
|
|
||||||
{ name: 'Hongxiang He', role: { zh: '开发', en: 'Developer' } },
|
|
||||||
{ name: 'Zhuoyue Sun', role: { zh: '团队', en: 'Team' } },
|
|
||||||
{ name: 'Xingchen Zhou', role: { zh: '团队', en: 'Team' } },
|
|
||||||
{ name: 'Yidong Yao', role: { zh: '团队', en: 'Team' } }
|
|
||||||
]
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
// 448 各版本数据(来自对构建产物与 index.html 的分析)
|
|
||||||
export const versions = [
|
|
||||||
{
|
|
||||||
id: 'zikai-0.0.0',
|
|
||||||
series: 'zikai-ui',
|
|
||||||
version: '0.0.0',
|
|
||||||
unity: '—',
|
|
||||||
product: 'My project / 1.0.2',
|
|
||||||
webgl: '/448/zikai-0.0.0/',
|
|
||||||
apk: null,
|
|
||||||
control: { zh: '鼠标左键操作、右键追踪', en: 'Left mouse: operate, Right mouse: track' },
|
|
||||||
note: { zh: '早期鼠标控制原型', en: 'Early mouse-controlled prototype' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zikai-0.0.1',
|
|
||||||
series: 'zikai-ui',
|
|
||||||
version: '0.0.1',
|
|
||||||
unity: '2020.3.30f1',
|
|
||||||
product: 'zikai-ui / 1.0.2',
|
|
||||||
webgl: '/448/zikai-0.0.1/',
|
|
||||||
apk: '/448/zikai-0.0.1/zikai.apk',
|
|
||||||
control: { zh: '生命 Q/W/E/R、能量 A/S/D/F', en: 'Health Q/W/E/R, Power A/S/D/F' },
|
|
||||||
note: { zh: '引入生命/能量属性系统', en: 'Introduced health/power stat system' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zikai-0.0.2',
|
|
||||||
series: 'zikai-ui',
|
|
||||||
version: '0.0.2',
|
|
||||||
unity: '2020.3.30f1',
|
|
||||||
product: 'zikai-ui / 1.0.2',
|
|
||||||
webgl: '/448/zikai-0.0.2/',
|
|
||||||
apk: '/448/zikai-0.0.2/zikai.apk',
|
|
||||||
control: { zh: '同 0.0.1', en: 'Same as 0.0.1' },
|
|
||||||
note: { zh: '构建产物重命名整理', en: 'Build artifact renaming' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'zikai-0.0.3',
|
|
||||||
series: 'zikai-ui',
|
|
||||||
version: '0.0.3',
|
|
||||||
unity: '2020.3.30f1',
|
|
||||||
product: 'zikai-ui / 1.0.2.5',
|
|
||||||
webgl: '/448/zikai-0.0.3/',
|
|
||||||
apk: '/448/zikai-0.0.3/zikai.apk',
|
|
||||||
control: { zh: 'WASD 移动、J 跳跃、K 飞行(测试)', en: 'WASD move, J jump, K fly (testing)' },
|
|
||||||
note: { zh: '改为 WASD 操作,最新版', en: 'Switched to WASD, latest version' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'main-0.0.0',
|
|
||||||
series: 'main',
|
|
||||||
version: '0.0.0',
|
|
||||||
unity: '2020.3.30f1',
|
|
||||||
product: 'zikai-main / 1.0.2.6',
|
|
||||||
webgl: '/448/main-0.0.0/',
|
|
||||||
apk: '/448/main-0.0.0.apk',
|
|
||||||
control: { zh: '角色控制器', en: 'Character controller' },
|
|
||||||
note: { zh: '基线版本', en: 'Baseline' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'main-0.0.1',
|
|
||||||
series: 'main',
|
|
||||||
version: '0.0.1',
|
|
||||||
unity: '2021.3.12f1',
|
|
||||||
product: 'beta / 1.0.2',
|
|
||||||
webgl: '/448/main-0.0.1/',
|
|
||||||
apk: '/448/main-0.0.1/zikai.apk',
|
|
||||||
control: { zh: 'WASD 移动、K 跳跃', en: 'WASD move, K jump' },
|
|
||||||
note: { zh: '升级 Unity,新增屏幕按键', en: 'Upgraded Unity, added on-screen buttons' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ball',
|
|
||||||
series: 'roll-a-ball',
|
|
||||||
version: '0.0.2',
|
|
||||||
unity: '2021.3.12f1',
|
|
||||||
product: '3dRoingBallt2 / 0.0.2',
|
|
||||||
webgl: '/448/ball/',
|
|
||||||
apk: '/448/203wangz199.apk',
|
|
||||||
control: { zh: '手机陀螺仪倾斜移动、上倾/点击跳跃', en: 'Tilt phone to move, tilt up/tap to jump' },
|
|
||||||
note: { zh: '最终版本,3D 滚球', en: 'Final version, 3D rolling ball' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'hongXiang-0.0.0',
|
|
||||||
series: 'doby',
|
|
||||||
version: '0.0.0',
|
|
||||||
unity: '2021.2.10f1',
|
|
||||||
product: 'DobyAdventure',
|
|
||||||
webgl: null,
|
|
||||||
apk: '/448/hongXiang-0.0.0/2D game.apk',
|
|
||||||
control: { zh: 'A/D 移动、跳跃、吃金币', en: 'A/D move, jump, collect coins' },
|
|
||||||
note: { zh: '2D 平台跳跃(Hongxiang He)', en: '2D platformer (Hongxiang He)' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
// 截图(复用 448 现有图片)
|
|
||||||
export const screenshots = [
|
|
||||||
{ src: '/448/1.jpg', caption: { zh: '项目截图 1', en: 'Screenshot 1' } },
|
|
||||||
{ src: '/448/2.jpg', caption: { zh: '项目截图 2', en: 'Screenshot 2' } },
|
|
||||||
{ src: '/448/3.jpg', caption: { zh: '项目截图 3', en: 'Screenshot 3' } }
|
|
||||||
]
|
|
||||||
@@ -3,6 +3,6 @@ export default {
|
|||||||
id: 'mobile-game',
|
id: 'mobile-game',
|
||||||
tabKey: 'tabs.mobileGame',
|
tabKey: 'tabs.mobileGame',
|
||||||
order: 1,
|
order: 1,
|
||||||
// 懒加载组件 -> 独立 chunk
|
// 懒加载组件 -> 独立 chunk(包装器 import 子模块 z449 的 App.vue)
|
||||||
component: () => import('./MobileGame.vue')
|
component: () => import('./MobileGameWrapper.vue')
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/modules/pdf/Pdf.vue
Normal file
49
src/modules/pdf/Pdf.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup>
|
||||||
|
// ★ pdf 页签的 mainPage 侧包装器。
|
||||||
|
// 子模块 zPDF_package 的 App.vue 作为便携式组件被 import,本包装器负责:
|
||||||
|
// 1. 套 PageShell 统一容器宽度与标题(与其他页签对齐)
|
||||||
|
// 2. 持有 KeepAlive 匹配用的组件名(与模块 id 一致),子模块自身不持 name
|
||||||
|
// 3. 把 mainPage 当前语言经 :locale prop 透传给子模块的独立 i18n 实例,
|
||||||
|
// 使其跟随父项目语言开关响应式切换(不共享 messages,仅同步 locale 值)
|
||||||
|
// 前后端分离:UI/交互在 zPDF_package,能力由 zTools2 的 /api/pdf/* REST API 提供,
|
||||||
|
// 同源 fetch 自动携带 httpOnly cookie(zk_pdf),无需 CORS。与 timeTableFix 集成范式一致。
|
||||||
|
// 子模块独立运行时不经过本包装器,保持纯净。
|
||||||
|
defineOptions({ name: 'pdf' })
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import PageShell from '../../components/ui/PageShell.vue'
|
||||||
|
import i18n from '../../i18n/index.js'
|
||||||
|
import ZPdfApp from '../../../third_party/zPDF_package/src/App.vue'
|
||||||
|
|
||||||
|
// 透传父项目当前语言给子应用(响应式:切语言时子应用内容跟随)
|
||||||
|
const locale = computed(() => i18n.global.locale.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageShell title-key="tabs.pdf">
|
||||||
|
<div class="pdf__frame">
|
||||||
|
<ZPdfApp :locale="locale" />
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 让子应用填满外壳容器,保留其内部布局自洽;转换页以上传与列表为主,给足高度 */
|
||||||
|
.pdf__frame {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
/* 转换页以上传与列表为主,给足高度 */
|
||||||
|
height: 78vh;
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.pdf__frame {
|
||||||
|
height: 72vh;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
17
src/modules/pdf/index.js
Normal file
17
src/modules/pdf/index.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// 模块元数据 -- PDF 转换页签
|
||||||
|
// 构建期组件集成:mainPage 侧包装器 Pdf.vue import 子模块 zPDF_package 的 App.vue,
|
||||||
|
// 套 PageShell 统一容器与标题。zPDF_package 作为 git submodule 独立维护,
|
||||||
|
// 其 UI/交互经同源 fetch 调用 zTools2 的 /api/pdf/* REST API(前后端分离)。
|
||||||
|
// requiresAlive 指向存活探针 URL:启动时探测,zTools2 不可达则隐藏该页签。
|
||||||
|
// 升级:cd third_party/zPDF_package && git pull,回 mainPage 根 git add 该子模块并重新 build。
|
||||||
|
import { healthUrl } from '../../config/app.config.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'pdf',
|
||||||
|
tabKey: 'tabs.pdf',
|
||||||
|
order: 4,
|
||||||
|
// 存活探针 URL;useProjects 启动时探测一次,挂掉则不显示页签
|
||||||
|
requiresAlive: healthUrl('pdf'),
|
||||||
|
// 懒加载组件 -> 独立 chunk
|
||||||
|
component: () => import('./Pdf.vue')
|
||||||
|
}
|
||||||
46
src/modules/timetable/Timetable.vue
Normal file
46
src/modules/timetable/Timetable.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup>
|
||||||
|
// ★ timetable 页签的 mainPage 侧包装器。
|
||||||
|
// 子模块 timeTableFix 的 App.vue 作为便携式组件被 import,本包装器负责:
|
||||||
|
// 1. 套 PageShell 统一容器宽度与标题(与其他页签对齐)
|
||||||
|
// 2. 持有 KeepAlive 匹配用的组件名(与模块 id 一致),子模块自身不持 name
|
||||||
|
// 3. 把 mainPage 当前语言经 :locale prop 透传给子模块的独立 i18n 实例,
|
||||||
|
// 使其跟随父项目语言开关响应式切换(不共享 messages,仅同步 locale 值)
|
||||||
|
// 子模块独立运行时不经过本包装器,保持纯净。
|
||||||
|
defineOptions({ name: 'timetable' })
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import PageShell from '../../components/ui/PageShell.vue'
|
||||||
|
import i18n from '../../i18n/index.js'
|
||||||
|
import TimeTableFixApp from '../../../third_party/timeTableFix/src/App.vue'
|
||||||
|
|
||||||
|
// 透传父项目当前语言给子应用(响应式:切语言时子应用内容跟随)
|
||||||
|
const locale = computed(() => i18n.global.locale.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageShell title-key="tabs.timetable">
|
||||||
|
<div class="timetable__frame">
|
||||||
|
<TimeTableFixApp :locale="locale" />
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 让子应用填满外壳容器,保留其内部 flex 布局自洽 */
|
||||||
|
.timetable__frame {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
height: 75vh;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.timetable__frame {
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 380px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
10
src/modules/timetable/index.js
Normal file
10
src/modules/timetable/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// 模块元数据 -- 日程整理(timeTableFix)页签
|
||||||
|
// 构建期组件集成:mainPage 侧包装器 Timetable.vue import 子模块 App.vue,
|
||||||
|
// 套 PageShell 统一容器与标题。timeTableFix 作为 git submodule 独立维护。
|
||||||
|
// 升级:cd third_party/timeTableFix && git pull,回 mainPage 根 git add 该子模块并重新 build。
|
||||||
|
export default {
|
||||||
|
id: 'timetable',
|
||||||
|
tabKey: 'tabs.timetable',
|
||||||
|
order: 3,
|
||||||
|
component: () => import('./Timetable.vue')
|
||||||
|
}
|
||||||
149
src/modules/whiteboard/Whiteboard.vue
Normal file
149
src/modules/whiteboard/Whiteboard.vue
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
<script setup>
|
||||||
|
// ★ whiteboard 页签的 mainPage 侧包装器。
|
||||||
|
// 子模块 zWhiteBoard 的 App.vue 作为便携式组件被 import,本包装器负责:
|
||||||
|
// 1. 套 PageShell 统一容器宽度与标题(与其他页签对齐)
|
||||||
|
// 2. 持有 KeepAlive 匹配用的组件名(与模块 id 一致),子模块自身不持 name
|
||||||
|
// 3. 把 mainPage 当前语言经 :locale prop 透传给子模块的独立 i18n 实例
|
||||||
|
// 4. 解析路由参数 /whiteboard/:boardId 并经 :boardId prop 透传给子模块(直链预填 + 切换表单)
|
||||||
|
// 前后端分离:UI/交互在 zWhiteBoard,能力由 zTools2 的 /api/wb/* 与 /api/ws/wb/* 提供,
|
||||||
|
// 同源 fetch/WS 自动携带 httpOnly cookie,无需 CORS。与 zPDF_package 集成范式一致。
|
||||||
|
// 子模块独立运行时不经过本包装器,保持纯净。
|
||||||
|
defineOptions({ name: 'whiteboard' })
|
||||||
|
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { appConfig } from '../../config/app.config.js'
|
||||||
|
import { useLocale } from '../../composables/useLocale.js'
|
||||||
|
import i18n from '../../i18n/index.js'
|
||||||
|
import PageShell from '../../components/ui/PageShell.vue'
|
||||||
|
import ZWbApp from '../../../third_party/zWhiteBoard/src/App.vue'
|
||||||
|
|
||||||
|
const { t } = useLocale()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const wbConfig = appConfig.whiteboard
|
||||||
|
|
||||||
|
// 透传父项目当前语言给子应用(响应式:切语言时子应用内容跟随)
|
||||||
|
const locale = computed(() => i18n.global.locale.value)
|
||||||
|
|
||||||
|
// 当前白板 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)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<div class="whiteboard__frame">
|
||||||
|
<ZWbApp :locale="locale" :board-id="currentBoardId" />
|
||||||
|
</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);
|
||||||
|
}
|
||||||
|
/* 让子应用填满外壳容器,保留其内部布局自洽;记事本以编辑为主,给足高度 */
|
||||||
|
.whiteboard__frame {
|
||||||
|
position: relative;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--surface);
|
||||||
|
height: 78vh;
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.wb-switch__input {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
.whiteboard__frame {
|
||||||
|
height: 72vh;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
src/modules/whiteboard/index.js
Normal file
19
src/modules/whiteboard/index.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// 模块元数据 -- 共享记事本(白板)页签
|
||||||
|
// 构建期组件集成:mainPage 侧包装器 Whiteboard.vue import 子模块 zWhiteBoard 的 App.vue,
|
||||||
|
// 套 PageShell 统一容器与标题。zWhiteBoard 作为 git submodule 独立维护,
|
||||||
|
// 其 UI/交互经同源 fetch/WS 调用 zTools2 的 /api/wb/* 与 /api/ws/wb/*(前后端分离)。
|
||||||
|
// requiresAlive 指向存活探针 URL:启动时探测,zTools2 不可达则隐藏该页签。
|
||||||
|
// 升级:cd third_party/zWhiteBoard && git pull,回 mainPage 根 git add 该子模块并重新 build。
|
||||||
|
import { healthUrl } from '../../config/app.config.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
id: 'whiteboard',
|
||||||
|
tabKey: 'tabs.whiteboard',
|
||||||
|
order: 2,
|
||||||
|
// 存活探针 URL;useProjects 启动时探测一次,挂掉则不显示页签
|
||||||
|
requiresAlive: healthUrl('whiteboard'),
|
||||||
|
// 接受 /whiteboard/:boardId 子路径,使直链访问能预填白板 id
|
||||||
|
param: 'boardId',
|
||||||
|
// 懒加载组件 -> 独立 chunk
|
||||||
|
component: () => import('./Whiteboard.vue')
|
||||||
|
}
|
||||||
@@ -2,18 +2,25 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import modules from '../modules/index.js'
|
import modules from '../modules/index.js'
|
||||||
|
|
||||||
const routes = [
|
const moduleRoutes = modules.map((m) => {
|
||||||
{
|
// 模块可声明 param(如 'boardId')以接受 /<id>/<param> 形式的子路径,
|
||||||
path: '/',
|
// 形如 /whiteboard/share。可选参数 (? 后缀) 使 /whiteboard 仍可访问。
|
||||||
redirect: modules.length ? `/${modules[0].id}` : '/404'
|
const paramPart = m.param ? `/:${m.param}?` : ''
|
||||||
},
|
return {
|
||||||
// 每个模块一条路由,组件懒加载
|
path: `/${m.id}${paramPart}`,
|
||||||
...modules.map((m) => ({
|
|
||||||
path: `/${m.id}`,
|
|
||||||
name: m.id,
|
name: m.id,
|
||||||
component: m.component,
|
component: m.component,
|
||||||
meta: { moduleId: m.id }
|
meta: { moduleId: m.id }
|
||||||
})),
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: moduleRoutes.length ? moduleRoutes[0].path : '/404'
|
||||||
|
},
|
||||||
|
// 每个模块一条路由,组件懒加载
|
||||||
|
...moduleRoutes,
|
||||||
{
|
{
|
||||||
path: '/:pathMatch(.*)*',
|
path: '/:pathMatch(.*)*',
|
||||||
name: 'not-found',
|
name: 'not-found',
|
||||||
|
|||||||
@@ -15,6 +15,14 @@
|
|||||||
--accent-weak: #eef2ff;
|
--accent-weak: #eef2ff;
|
||||||
--accent-hover: #4338ca;
|
--accent-hover: #4338ca;
|
||||||
|
|
||||||
|
/* 语义色(供嵌入的子项目如 timeTableFix 复用,保证嵌入时按钮/徽章可见) */
|
||||||
|
--success: #2e7d32;
|
||||||
|
--success-weak: #e8f5e9;
|
||||||
|
--danger: #c62828;
|
||||||
|
--danger-weak: #fbe9e7;
|
||||||
|
--warn: #e65100;
|
||||||
|
--warn-weak: #fff3e0;
|
||||||
|
|
||||||
/* 线/边 */
|
/* 线/边 */
|
||||||
--border: #eceef1;
|
--border: #eceef1;
|
||||||
--border-strong: #e0e2e6;
|
--border-strong: #e0e2e6;
|
||||||
|
|||||||
1
third_party/timeTableFix
vendored
Submodule
1
third_party/timeTableFix
vendored
Submodule
Submodule third_party/timeTableFix added at cf941c50bc
1
third_party/z449
vendored
Submodule
1
third_party/z449
vendored
Submodule
Submodule third_party/z449 added at a91c379096
1
third_party/zPDF_package
vendored
Submodule
1
third_party/zPDF_package
vendored
Submodule
Submodule third_party/zPDF_package added at cfbf4ad2b4
1
third_party/zWhiteBoard
vendored
Submodule
1
third_party/zWhiteBoard
vendored
Submodule
Submodule third_party/zWhiteBoard added at d7d7450296
@@ -1,42 +1,66 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { minify } from 'html-minifier-terser'
|
||||||
|
|
||||||
|
// 构建到项目本地 dist/(每次清空,避免旧产物残留),
|
||||||
|
// 再由 `npm run deploy` 或 `deploy.sh` 用 rsync 同步到 Apache 站点根 /var/www/html。
|
||||||
|
// 文件名去掉内容 hash,保持稳定;JS 由 esbuild 压缩,HTML 由下方插件压缩。
|
||||||
|
// 用 mainPage/ 命名空间子目录避免与 /var/www/html/js、/var/www/html/css 既有旧文件冲突。
|
||||||
|
function minifyHtml() {
|
||||||
|
return {
|
||||||
|
name: 'minify-html',
|
||||||
|
apply: 'build',
|
||||||
|
transformIndexHtml: {
|
||||||
|
order: 'post',
|
||||||
|
handler(html) {
|
||||||
|
return minify(html, {
|
||||||
|
collapseWhitespace: true,
|
||||||
|
removeComments: true,
|
||||||
|
removeRedundantAttributes: true,
|
||||||
|
removeScriptTypeAttributes: true,
|
||||||
|
removeStyleLinkTypeAttributes: true,
|
||||||
|
collapseBooleanAttributes: true,
|
||||||
|
minifyCSS: true,
|
||||||
|
minifyJS: true,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 构建产物输出到站点根 /root/html,但绝不清空该目录
|
|
||||||
// (里面已有 448/449/js/css 等既有内容)。
|
|
||||||
// 用 mainPage/ 命名空间子目录避免与 /root/html/js、/root/html/css 旧文件冲突。
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue(), minifyHtml()],
|
||||||
base: '/',
|
base: '/',
|
||||||
build: {
|
build: {
|
||||||
outDir: '/root/html',
|
outDir: 'dist',
|
||||||
// ★ 安全项:不清空 outDir,保护既有文件
|
// ★ dist 在项目内,安全清空,杜绝旧 hash 文件堆积
|
||||||
emptyOutDir: false,
|
emptyOutDir: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'js/mainPage/[name].[hash].js',
|
// 稳定文件名:去掉 [hash],便于部署与长效缓存策略
|
||||||
chunkFileNames: 'js/mainPage/[name].[hash].js',
|
entryFileNames: 'js/mainPage/[name].js',
|
||||||
|
chunkFileNames: 'js/mainPage/[name].js',
|
||||||
assetFileNames: (assetInfo) => {
|
assetFileNames: (assetInfo) => {
|
||||||
const name = assetInfo.name || ''
|
const name = assetInfo.name || ''
|
||||||
if (name.endsWith('.css')) {
|
if (name.endsWith('.css')) {
|
||||||
return 'css/mainPage/[name].[hash][extname]'
|
return 'css/mainPage/[name][extname]'
|
||||||
}
|
|
||||||
return 'assets/mainPage/[name].[hash][extname]'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return 'assets/mainPage/[name][extname]'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
// 开发服务器把站点根的静态资源代理到 /root/html,方便引用 /448 /449 等
|
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/448': {
|
// zTools2 后端同源代理:所有入口(API/静态/探针/WS)统一在 /api/ 下,一条规则即可。
|
||||||
target: 'http://localhost:8080',
|
// ws:true 让 /api/ws/wb/{id} 的 WebSocket 也走此代理。
|
||||||
changeOrigin: true
|
// 同源相对路径与环境无关:dev 命中本地 zTools2,生产由反代命中当前环境 zTools2。
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:6867',
|
||||||
|
changeOrigin: true,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
'/449': {
|
|
||||||
target: 'http://localhost:8080',
|
|
||||||
changeOrigin: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user