Compare commits

27 Commits

Author SHA1 Message Date
Zikai
8dcf5f0d36 Merge branch 'refactor/cleanup-failfast' into main
清理死代码/提前失败/高内聚低耦合,重写文档对齐现状(无 iframe、无外部域名)。
合并子项目文档修正与死代码清理。
2026-07-28 04:27:57 +00:00
25dbceee2d refactor: 清理死代码/提前失败/高内聚低耦合,重写文档
死代码清理:
- 删除未使用的 UI 组件 Card.vue / Tag.vue
- 移除 app.config.js 死字段 healthTimeoutMs(×2)与 isDev()
- 移除 PageShell.vue 死 prop padded(恒为默认值)
- 移除 useLocale.js 死导出 isZh/pick/locale
- 移除 router/index.js 不可达 try/catch,简化为直接 .map()
- 移除 package.json 重复依赖 ical.js(子模块自带)
- 移除 i18n 死键 common.notAvailable / common.serviceUnavailable
- 移除 vite.config.js 陈旧的 /448 /449 dev proxy(z449 为构建期组件,不走该 URL)

提前失败/日志:
- useI18nLazy.js switchLocale 加载语言包失败时 console.error 记录(原静默吞错)
- modules/index.js 字段不全的模块跳过时 console.warn 告警(原与文档承诺不符、静默跳过)
- deploy.sh 补齐 zWhiteBoard / zPDF_package 的 npm install 与子模块初始化
  (均为构建期组件 import,全新检出时缺少依赖会构建失败)

文档对齐现状(无 iframe、无外部域名):
- README 改写为简洁版,修正 iframe/ical.js/子模块数量过时描述
- architecture.md 修正 requiresAlive 示例(同源 /api/health,非外部域名)
  与「仅白板使用」断言(PDF 也用);移除已删 padded prop
- add-module.md 同步移除 padded、修正 requiresAlive 示例
- submodule-timeTableFix.md 修正 ical.js 归属(子模块自带,非父项目共享)
- submodule-zPDF_package.md 子项目数量 3 -> 4
- 新增 submodule-zWhiteBoard.md
- readme-fixes.md 记录本次勘误
2026-07-28 11:57:32 +08:00
5b1acbdeb7 feat: 白板页签从 iframe 改为构建期组件挂载
新增 zWhiteBoard 子模块(textarea + WebSocket 实时同步、心跳、断线重连),
Whiteboard.vue 改为组件包装器,经 :locale/:boardId props 透传,与 zPDF_package 范式一致。
复用 zTools2 的 /api/wb/* 与 /api/ws/wb/* 接口,后端无改动;旧版 /api/wb-page/* 保留不删。
清理 iframe 专用配置(pagePath)与降级文案(whiteboard.openExternal)。
2026-07-28 10:39:24 +08:00
1cc2ec1c9f feat: 升级 zPDF_package 子模块,PDF 页签移动端兼容
子模块指针 a48e601 -> cfbf4ad:
- JobList 表格窄屏(≤768px)转卡片式,避免横向滚动
- Uploader 拖拽区缩小、按钮触控目标加大
- 统一移动端断点为 768px(与宿主一致)
2026-07-27 17:38:16 +08:00
4c7245f919 feat: 升级 zPDF_package 子模块,PDF 页签删除任务去掉二次确认
子模块指针 4d12b61 -> a48e601:
- JobList.vue 删除任务的 confirm() 二次确认弹窗移除,点击即删(软删,管理员可恢复)
- i18n 移除已无引用的 deleteConfirm/deleted/deleteFailed 文案
2026-07-27 17:35:40 +08:00
ae540a1a70 feat: PDF 页签从 iframe 改为构建期组件挂载,清理 iframe 配置与文档
前后端分离:UI/交互在 zPDF_package,能力由 zTools2 的 /api/pdf/* REST API 提供。
组件经同源 fetch 调用,httpOnly cookie(zk_pdf)经反代第一方自动携带,无需 CORS。
与 timeTableFix 集成范式完全一致。

- src/modules/pdf/Pdf.vue:iframe 包装器重写为组件挂载包装器,套 PageShell +
  透传 :locale;删除 pageSrc/getLiveness/scheduleFailCheck/openExternal 等 iframe 逻辑
- src/modules/pdf/index.js:注释从 iframe 改为构建期组件 import(requiresAlive 探针保留)
- src/config/app.config.js:移除 pdf.pagePath(iframe src),保留探针配置
- src/i18n/{zh-CN,en}.js:移除 pdf.openExternal(iframe 降级按钮文案,已无用)
- docs/submodule-zPDF_package.md:集成方式改为构建期组件 import,补 cookie 认证说明
- docs/deployment.md:拓扑去 /api/pdf 页面行,措辞改为组件 fetch
- third_party/zPDF_package:子模块指针升级到 4d12b61(去 iframe 表述)
2026-07-27 17:14:07 +08:00
304ebc9448 feat: 配置简化为同源相对路径,统一 /api/ 路由约定
配合 zTools2 路由统一到 /api/,简化环境感知配置:
- app.config.js:去掉 BASE_URL 的 dev/prod 分支,iframe 与探针一律用同源相对路径
  (/api/pdf、/api/health、/api/wb-page/{id}),默认空 baseUrl;保留 VITE_API_BASE 覆盖。
  这样无论 dev(vite proxy)还是任意部署环境(反代),同源相对路径都自动指向当前环境 zTools2,
  不再误打到 f.zikai.wang 等其它环境域名。
- vite.config.js:dev proxy 从 /api /pdf /health /static /upload /files /wb /ws 多条
  简化为单条 /api(含 ws:true,覆盖 /api/ws WebSocket)。
- deploy.sh:Apache vhost 从多条 ProxyPass 简化为单条 /api/,a2enmod 增加 proxy_wstunnel。
- docs/deployment.md:更新拓扑/模式说明,新增路由约定(所有入口在 /api/ 下,反代只需一条规则)。
- 同步更新 zPDF_package 子模块指针(其 vite proxy 亦简化为单条 /api)。
2026-07-27 15:44:49 +08:00
39f3056099 feat: 环境感知配置(dev 经 vite proxy 指向本地 zTools2,prod 指向 f.zikai.wang)
修复 PDF 页签在开发环境返回 {"detail":"Not Found"}:
此前 baseUrl 始终指向线上 f.zikai.wang,开发环境跨域访问线上
既无法命中本地 /pdf 路由又会暴露生产。

- app.config.js: BASE_URL 按 import.meta.env.PROD 切换
  dev -> ''(同源,经 vite proxy 转发到本地 zTools2)
  prod -> https://f.zikai.wang(同源)
  VITE_API_BASE 可显式覆盖
- vite.config.js: dev server 新增 /api /pdf /health /static /upload
  /files /wb /ws 代理到 127.0.0.1:6867,使 iframe 与 fetch 同源命中本地服务

测试环境指向测试环境,生产环境指向生产环境。
2026-07-27 13:59:50 +08:00
66f93c67f5 feat: 新增整体部署脚本与部署指南(组件独立/整体/持久化三模式)
- deploy.sh:整体部署,构建 zMainPage + rsync 到 /var/www/html + 配置
  Apache 反代(/api /pdf /pdf-admin /static 等到 zTools2)+ 重启后端
- docs/deployment.md:三种部署模式文档(组件独立开发 / 整体生产 / systemd 持久化)
- README:补部署指南链接
2026-07-27 13:52:40 +08:00
04f11b5432 feat: 新增 PDF 转换页签(iframe 嵌入 zTools2 /pdf,zPDF_package 子模块)
mainPage 用 iframe 嵌入 zTools2 同源托管的 /pdf 页面(PDF 转换:上传 epub、
进度显示、下载、用户软删/管理员硬删)。同源使 cookie 第一方生效,无需 CORS。

- src/modules/pdf/:页签模块(requiresAlive 存活校验 + iframe + 降级提示)
- src/config/app.config.js:新增 pdf 服务配置(baseUrl/healthPath/pagePath)
- i18n:tabs.pdf 中英文案 + pdf.openExternal
- third_party/zPDF_package:git submodule(源码管理,iframe 集成非构建期编译)
- docs/submodule-zPDF_package.md:集成/升级/移除文档
- README:更新结构、子模块列表、文档链接
2026-07-27 11:35:28 +08:00
3e5c1d5494 feat: 升级 timeTableFix 子模块(时区选择)+ 修正 README 构建路径
- 子模块 timeTableFix 升级至时区选择版本:加载自动检测用户时区,取不到
  回退墨尔本;仅作新建事件默认 tzid 与下载 ICS 的 X-WR-TIMEZONE,不换算
- 修正 README:构建输出到 dist/(非 /root/html),由 npm run deploy rsync
  到站点根;补 npm run deploy 命令;细节记于 docs/readme-fixes.md
- 集成构建验证通过(npm run build)
2026-07-27 09:51:43 +08:00
Zikai
1379e1a607 build: 稳定文件名去hash + HTML压缩 + deploy脚本
- vite.config.js: 文件名去掉 [hash],输出到项目本地 dist/(emptyOutDir=true
  避免旧 hash 残留堆积);新增 html-minifier-terser 插件压缩 index.html。
- package.json: 新增 deploy 脚本,build 后用 rsync 把 dist/ 的 mainPage
  命名空间子目录同步到 Apache 站点根 /root/html,--delete 仅作用于 mainPage
  子目录,不误伤既有旧文件(jquery/pdf.js 等)。
- 站点始终由 Apache 静态服务(DocumentRoot /var/www/html),无 Node 实时渲染。
2026-07-24 09:34:22 +00:00
Zikai
cb50c23c84 chore: 页脚移除 'Built with Vue 3' 字样
- 页脚仅保留版权年份,去掉技术栈宣传文字
- 同步更新 timeTableFix 子模块 gitlink(导入按钮高亮)
- 移除无用的 .footer__dot 样式
2026-07-24 09:25:37 +00:00
Zikai
05a594d0cb docs: 更新 timeTableFix 子模块 gitlink 2026-07-24 09:16:24 +00:00
Zikai
08c1b457e4 docs: 精简 README(结构/依赖/使用),更新子模块 gitlink
- mainPage README 精简为结构/外部依赖/如何使用/开发命令
- 子模块 z449 (9ed486c) / timeTableFix (78d7afe) README 同步精简,细节移至各自 docs/
2026-07-24 09:15:38 +00:00
Zikai
d9309756c7 fix: 子模块 i18n 桥接 -- 透传 :locale prop,修复嵌入页显示 i18n ID
根因:子项目组件用 useI18n({useScope:'global'}) 注入,嵌入时解析到宿主
mainPage 的 i18n 实例(不含子项目文案)-> 文案回退成 key(显示 ID)。

修复:
- z449 / timeTableFix 组件改用 useLocale composable 直读自身模块级 i18n 实例
- ttf 新增独立 i18n(中英 locale + useLocale),抽取所有硬编码中文
- mainPage 侧 Timetable/MobileGameWrapper 包装器透传 :locale prop
- 两个子模块 gitlink 更新至各自修复 commit

验证:构建后两个子模块 chunk 含真实中文文案,无 useI18n 注入残留
2026-07-24 09:02:38 +00:00
Zikai
6ad6c15e9d feat: 拆分移动游戏项目为独立 z449 子模块(git submodule + i18n prop 桥接)
- mobile-game 模块源码迁出至独立仓库 z449(third_party/z449),独立可渲染/维护
- z449 App.vue 为便携式组件:自带独立 vue-i18n 实例与 locale 文案
- mainPage 侧 MobileGameWrapper 透传 :locale prop,子模块 watch 同步语言
- mainPage i18n 精简:仅保留 tabs.mobileGame,删除 mobileGame.* 大块文案
- 文档:新增 docs/submodule-z449.md,更新 README/architecture/add-module
2026-07-23 07:04:08 +00:00
61e65052d4 fix: 补语义令牌修复嵌入页下载按钮不可见 + 停用 /ttf/ 独立入口
- variables.css 补 --success/--danger/--warn 等语义令牌:timeTableFix 嵌入时
  其按钮(btn-success/danger)依赖这些令牌,缺失会导致背景/边框为空、按钮不可见
- docs/submodule-timeTableFix.md 移除 /ttf/ 独立访问说明(已停用)
- 升级 timeTableFix 子模块至 2d7b8d0(README 移除 systemd 部署)
2026-07-23 04:46:40 +00:00
b3960f503a docs: 移除 systemd 部署与 /ttf/ 独立入口说明
本项目仅作为 mainPage submodule 集成,无独立线上入口。
保留独立开发/测试说明(npm run dev/test/build)。
2026-07-23 04:45:49 +00:00
b511998372 chore: 升级 timeTableFix 子模块至 c68d106(前端视觉与 mainPage 协调)
子模块前端改为浅色极简 + 靛蓝主色,与主页视觉统一;
设计令牌对齐,嵌入时由宿主 :root 令牌驱动。
2026-07-23 04:24:34 +00:00
4334dec00d docs: 精简 README,细节拆分到 docs/
README 仅保留概览/结构/基础设施/安装/开发命令;
架构、新增页签、子模块管理细节移至 docs/ 下三个 md:
- docs/architecture.md:模块注册/PageShell/KeepAlive/存活校验
- docs/add-module.md:新增页签步骤 + 元数据字段速查 + 常见变体
- docs/submodule-timeTableFix.md:submodule 升级/clone/移除/独立访问
2026-07-23 04:09:42 +00:00
a844f364dc refactor: 引入 PageShell 统一页面外壳,高内聚低耦合
- 新增可复用 PageShell 组件(titleKey/fluid/padded + actions 插槽),
  统一容器宽度(1080px)、垂直节奏与标题,消除各模块重复包裹
- whiteboard/calendar/mobile-game 全部改用 PageShell,去掉各自手写的
  .container/.__inner/.__title 重复样式;calendar 用 fluid 全宽变体
- timetable 改用 mainPage 侧包装器 Timetable.vue:import 子模块 App.vue
  套 PageShell(对齐其他页宽度),并持有 KeepAlive 匹配名 name:'timetable'
- 子模块 timeTableFix 的 App.vue 去掉 defineOptions(保持纯净),name 约定
  由 mainPage 包装器持有,消除跨仓库脆弱约定;submodule 升级到 17aeea8
- 注册表清理:去掉无效 try/catch,order 缺省改 999(避免与 hidden 页 99 冲突),
  注释文档化全部元数据字段与 KeepAlive 约定
- 新增页签照 timetable 模式复用 PageShell,注册方式一致
2026-07-23 04:04:40 +00:00
91d55f1b11 feat: timeTableFix 改为构建期组件集成 + git submodule
- 弃用 iframe 嵌入:直接 import 子模块 timeTableFix 的 App.vue 为懒加载路由
  组件,共享 mainPage 构建/依赖(ical.js)/KeepAlive 缓存,无运行期割裂
- git submodule 接入 third_party/timeTableFix(固定到 a49a725),.gitmodules
  使用无凭据 URL;.gitignore 忽略子模块 node_modules
- 删旧 iframe 包装组件 Timetable.vue,移除 app.config 中 timeTableFix 配置
- timetable 模块不再 requiresAlive(构建期已打包,无远程依赖)
- README 更新:项目结构、submodule clone/升级流程、存活校验描述
- 子模块样式隔离(scoped + ttf- 前缀)已在 timeTableFix 侧完成,无泄漏
2026-07-23 02:54:00 +00:00
7d81da65cf feat: 新增日程整理页签(iframe 嵌入 timeTableFix /ttf/)
- 新增 timetable 模块:iframe 嵌入同源 /ttf/(Apache 反代到本地 vite preview)
- 声明 requiresAlive 探测 /ttf/,复用现有存活校验:周期探测、失活隐藏页签
  并卸载组件、恢复自动重载(与其他 requiresAlive 模块一致)
- app.config.js 新增 timeTableFix 配置(pageUrl / healthUrl)
- timeTableFix 作为独立项目维护(独立仓库/构建/systemd),mainPage 仅嵌入,
  删除该模块文件夹不影响 timeTableFix 本身与其他页签
- i18n:tabs.timetable(日程整理 / Schedule Organizer)
2026-07-23 02:25:54 +00:00
711026be9f feat: 隐藏页签持久可见 + 页签切换不卸载 + 白板周期存活探测
- 隐藏页签(cqy)访问后保持可见:路由进入即标记 visited,切走不再消失
- 页签切换默认不卸载组件:<KeepAlive :include> 缓存全部模块组件
  仅声明 requiresAlive 的模块在服务失活(down)时移出 include -> 卸载
- 白板存活检测改为加载即探测 + 每 30s 周期重探(startPolling),
  状态在 up/down 间响应式翻转,不再一次性固化
- 各模块组件 defineOptions name 与模块 id 一致,便于 KeepAlive 精确匹配
2026-07-22 03:20:48 +00:00
2c33457c87 fix: 白板页改用 .container 容器,与移动游戏项目页一致的左右留白 2026-07-22 02:53:05 +00:00
370388e729 fix: 白板页签乐观显示 + 白板切换输入框 + /whiteboard/{id} 直链
- 存活逻辑改为乐观显示:探测中(pending)/存活(up) 都显示页签,仅明确
  不可达(down) 才隐藏。修复首屏看不到页签、需刷新才出现的问题
- 白板页加 ID 输入框与切换按钮,可切换不同白板,默认 share
- 路由支持 /whiteboard/:boardId 可选参数,直链访问自动填入输入框并切换
- 模块可声明 param 让路由表生成子路径,保持注册表驱动
2026-07-22 02:43:26 +00:00
55 changed files with 1692 additions and 1589 deletions

3
.gitignore vendored
View File

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

119
README.md
View File

@@ -1,87 +1,79 @@
# mainPage · 作品集主页 # mainPage · 作品集主页
Zikai 的作品集主页 -- Vue 3 模块化多项目展示站。浅色极简、中英双语(英文按需懒加载)。 Zikai 的作品集主页 -- Vue 3 模块化多项目展示站。浅色极简、中英双语(英文按需懒加载)。每个项目页签是一个独立「模块」,由注册表自动生成页签与路由 -- 新增/删除页签只增删一个文件夹,互不影响。
每个项目页签是一个独立「模块」,由注册表自动生成页签与路由 -- 新增/删除页签只增删一个文件夹,互不影响。
--- > 架构细节、新增页签指南、子项目管理见 [`docs/`](./docs/)。
## 项目结构 ## 项目结构
``` ```
mainPage/ mainPage/
├── index.html ├── index.html
├── package.json ├── package.json # vue / vue-i18n / vue-router
├── vite.config.js # 构建输出到 /root/htmlemptyOutDir=false不清空既有文件 ├── vite.config.js # 构建输出到 dist/,再由 npm run deploy rsync 到 /var/www/html
├── deploy.sh # 整体部署:构建 + rsync + Apache 反代 + 重启 zTools2
├── .gitmodules # git submoduletimeTableFix / z449 / zPDF_package / zWhiteBoard
├── README.md ├── README.md
├── docs/ # 架构 / 新增页签 / 子模块管理 / 部署文档
├── public/favicon.svg ├── 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/ ├── config/app.config.js # 集中配置:各服务的同源探针路径(/api/health与默认参数
│ └── app.config.js # ★ 集中配置:外部服务 URL白板地址 / 存活探针等)
├── i18n/ # 国际化(中文同步注入;英文动态 import() 懒加载) ├── i18n/ # 国际化(中文同步注入;英文动态 import() 懒加载)
│ ├── index.js
│ └── locales/{zh-CN,en}.js
├── router/index.js # 路由表由模块注册表自动生成 ├── router/index.js # 路由表由模块注册表自动生成
├── modules/ # ★ 扩展点:每个页签一个文件夹 ├── modules/ # ★ 扩展点:每个页签一个文件夹
│ ├── index.js # 注册表import.meta.glob 约定式收集,带容错) │ ├── index.js # 注册表import.meta.glob 约定式收集,带容错)
│ ├── mobile-game/ # 移动游戏项目页签 │ ├── mobile-game/ # 移动游戏页签(包装 z449 子模块)
│ ├── whiteboard/ # 共享记事本页签(iframe 嵌入 + 存活校验) │ ├── whiteboard/ # 共享记事本页签(包装 zWhiteBoard 子模块 + 存活校验)
│ ├── timetable/ # 日程整理页签(包装 timeTableFix 子模块)
│ ├── pdf/ # PDF 转换页签(包装 zPDF_package 子模块 + 存活校验)
│ └── calendar/ # 隐藏页(仅手动访问 /cqy │ └── calendar/ # 隐藏页(仅手动访问 /cqy
├── components/ # 共享组件TabNav / LanguageSwitcher / ui 基元) ├── components/ # 共享组件TabNav / LanguageSwitcher / ui 基元含 PageShell
├── composables/ # useProjects(页签+存活联动)/ useLiveness / useI18nLazy / useLocale ├── composables/ # useProjects / useLiveness / useI18nLazy / useLocale
└── styles/ # variables.css主题令牌+ base.css └── styles/ # variables.css主题令牌+ base.css
``` ```
## 模块机制(高内聚低耦合) ## 外部依赖
- `src/modules/index.js``import.meta.glob('./*/index.js', { eager: true })` 收集所有模块,单个模块导出异常会跳过并告警,不影响其余页签。
- 每个模块 `index.js` 默认导出元数据:
```js
export default {
id: 'whiteboard', // 唯一标识 + 路由 path
tabKey: 'tabs.whiteboard', // i18n 键
order: 2, // 页签排序
component: () => import('./Whiteboard.vue'), // 懒加载组件(独立 chunk
requiresAlive: 'https://f.zikai.wang/health' // 可选:存活探针 URL挂掉则隐藏页签
}
```
- `TabNav` 遍历注册表渲染页签;`router` 遍历注册表生成路由。
- 删除一个页签 = 删 `src/modules/<id>/` 文件夹并重新 build其余页签不受影响。
### 存活校验
声明了 `requiresAlive` 的模块,应用启动时对探针 URL 做一次 `no-cors` fetch 探测4s 超时)。
探测返回前该页签不显示(避免闪烁);服务存活则显示,不可达则保持隐藏(刷新页面才重探)。
## 依赖的基础设施
| 项 | 要求 | | 项 | 要求 |
|---|---| |---|---|
| Node.js | ≥ 18(开发于 18.19 + npm 9.2 | | Node.js | ≥ 18 |
| Web 服务器 | Apache 2.4(启用 `mod_rewrite`),对外静态服务 `/root/html` | | Web 服务器 | Apache 2.4(启用 `mod_rewrite` / `mod_proxy` / `mod_proxy_wstunnel`),对外静态服务构建产物 |
| SPA 回退 | history 模式需站点根 `.htaccess` 回退到 `index.html`(见下) | | SPA 回退 | history 模式需站点根 `.htaccess` 回退到 `index.html` |
| 后端 | zTools2127.0.0.1:6867由 Apache 反代 `/api/`PDF/白板页签依赖其 REST API 与 WebSocket |
> 共享记事本页签通过 iframe 嵌入外部服务(默认 `https://f.zikai.wang/wb/shared`URL 在 `src/config/app.config.js` 集中配置。该服务需公开且允许被 iframe 嵌入 > 四个子模块均为**构建期组件集成(非 iframe**,共享 mainPage 的构建与 KeepAlive 缓存。各子模块依赖在其自身 `node_modules`,由各自 `package.json` 管理
## Ubuntu 从 0 安装 ## 如何使用
```bash ```bash
# 1. 安装 Node.js 18 # 1. 安装 Node.js 18
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo bash - curl -fsSL https://deb.nodesource.com/setup_18.x | sudo bash -
sudo apt-get install -y nodejs sudo apt-get install -y nodejs
# 2. 取代码 # 2. 取代码(含四个子模块)
git clone https://git.zikai.wang/zikai/zMainPage.git mainPage git clone --recursive https://git.zikai.wang/zikai/zMainPage.git mainPage
cd mainPage cd mainPage
# 若已 clone 但未带子模块git submodule update --init --recursive
npm install npm install
# 子模块需各自安装依赖(构建期编译其 .vue
(cd third_party/timeTableFix && npm install)
(cd third_party/z449 && npm install)
(cd third_party/zPDF_package && npm install)
(cd third_party/zWhiteBoard && npm install)
# 3. 构建(产物输出到 /root/html不清空既有文件 # 3. 构建到 dist/(再由 npm run deploy rsync 同步到 /var/www/html
npm run build npm run build
# 4. 安装并启用 Apache # 4. 安装并启用 Apache
sudo apt-get install -y apache2 sudo apt-get install -y apache2
sudo a2enmod rewrite sudo a2enmod rewrite proxy proxy_http proxy_wstunnel headers
``` ```
站点根放 `.htaccess`Apache 需 `AllowOverride All`)做 SPA 回退: 站点根放 `.htaccess`Apache 需 `AllowOverride All`)做 SPA 回退:
@@ -94,22 +86,43 @@ RewriteRule ^ - [L]
RewriteRule ^ index.html [L] RewriteRule ^ index.html [L]
``` ```
把 `/root/html`(或 `/var/www/html`设为 `DocumentRoot`,访问站点即可。开发预览用 `npm run dev`http://localhost:5173)。 并把 `/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
1. 复制 `src/modules/whiteboard/`(或 `mobile-game/`)为 `src/modules/<新项目>/`。 ```apache
2. 改 `<新项目>/index.js` 的 `id` / `tabKey` / `order` / `component`。 <VirtualHost *:80>
3. 在 `src/i18n/locales/zh-CN.js` 与 `en.js` 的 `tabs` 下加对应文案。 DocumentRoot /var/www/html
4. `npm run build` -- 页签与路由自动出现,无需改导航或路由代码。 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
npm install # 首次安装依赖 npm install # 首次安装依赖
npm run dev # 开发服务器 http://localhost:5173 npm run dev # 开发服务器 http://localhost:5173
npm run build # 构建到 /root/html npm run build # 构建到 dist/
npm run deploy # 构建并 rsync 同步到 /var/www/html
npm run preview # 本地预览构建产物 npm run preview # 本地预览构建产物
``` ```
## 了解更多
- [架构(模块注册 / PageShell / KeepAlive / 存活校验)](./docs/architecture.md)
- [如何新增一个项目页签](./docs/add-module.md)
- [子项目 timeTableFix 管理submodule 升级 / 移除)](./docs/submodule-timeTableFix.md)
- [子项目 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)
- [部署指南(组件独立 / 整体 / 持久化 systemd](./docs/deployment.md)
- [README 勘误记录](./docs/readme-fixes.md)

95
deploy.sh Executable file
View File

@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# 整体部署:构建并部署 zMainPage含子模块+ zTools2后端到生产环境。
#
# 生产拓扑:
# Apache(:80) -> 静态文件 /var/www/htmlzMainPage 构建产物)
# -> 反代 /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 回退 .htaccesshistory 模式)
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_wstunnelWebSocket模块启用
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://localhostApache :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
View 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
View 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
View 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 转换 APIcookie 用户)
├── /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-adminPDF 管理页、http://127.0.0.1:6867/docsAPI 文档)。
> 用户侧 PDF 页面由 zMainPage 的 zPDF_package 组件提供(构建期 importzTools2 仅提供 `/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 转发到本地 zTools2127.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 |
## 防火墙
- 80HTTP/ 443HTTPS对外
- 6867仅 loopback经 Apache 反代,不对外)
- 2022SFTP按需对外

53
docs/readme-fixes.md Normal file
View File

@@ -0,0 +1,53 @@
# README 勘误记录
本文件记录 mainPage README 中发现并修正的错误,便于追溯。
## 2026-07-27构建输出路径描述错误
**原描述(错误)**
- 项目结构注释:`vite.config.js # 构建输出到 /root/htmlemptyOutDir=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-28iframe / 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 各文件同步修正。

View File

@@ -0,0 +1,58 @@
# 子项目 timeTableFixgit submodule
timeTableFixICS 日程整理器)作为 **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 # 推送 mainPagegitlink
```
> 推送顺序:先确保子模块的 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
View File

@@ -0,0 +1,70 @@
# 子项目 z449git 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 # 推送 mainPagegitlink
```
> 推送顺序:先确保子模块的 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()` 注入,否则嵌入时会解析到宿主实例导致文案回退成 keymainPage 侧包装器经 `:locale` prop 透传当前语言。

View File

@@ -0,0 +1,64 @@
# 子项目 zPDF_packagegit submodule
zPDF_packagePDF 转换前端)作为 **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 # 推送 mainPagegitlink
```
> 推送顺序:先确保子模块的 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页签带存活探针。

View File

@@ -0,0 +1,72 @@
# 子项目 zWhiteBoardgit 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 # 推送 mainPagegitlink
```
> 推送顺序:先确保子模块的 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 与路由参数的子模块。

229
package-lock.json generated
View File

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

View File

@@ -7,7 +7,8 @@
"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",
@@ -16,6 +17,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"
} }
} }

View File

@@ -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>
@@ -20,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>
@@ -28,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>
@@ -88,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 {

View File

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

View 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);
}
/* 标准容器宽度复用全局 .containermax-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>

View File

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

View File

@@ -14,6 +14,9 @@ export function useI18nLazy() {
switching.value = true switching.value = true
try { try {
await loadLocaleAsync(locale) await loadLocaleAsync(locale)
} catch (e) {
// 语言包加载失败不能静默:记录到控制台,便于排查(如 chunk 404
console.error('[mainPage] 加载语言包失败', locale, e)
} finally { } finally {
switching.value = false switching.value = false
} }

View File

@@ -1,10 +1,10 @@
import { ref } from 'vue' import { ref } from 'vue'
// ★ 存活校验 -- 跨域用 no-cors fetch 探测resolve=存活reject/timeout=挂掉) // ★ 存活校验 -- 跨域用 no-cors fetch 探测resolve=存活reject/timeout=挂掉)
// 设计:应用启动时对每个 url 探测一次,结果在全应用共享并稳定(不再重试)。 // 探测时机:首次访问立即探测一次,之后按周期重复探测(默认 30s
// 这样依赖存活态的页签在启动后据结果决定显示/隐藏;服务恢复需刷新页面才重探 // 结果随时间在 up/down 间翻转,依赖该态的页签/缓存会响应式更新
// 模块级共享缓存url -> { state: ref('pending'|'up'|'down'), promise } // 模块级共享缓存url -> { state: ref('pending'|'up'|'down'), promise, timer }
const cache = new Map() const cache = new Map()
/** /**
@@ -14,26 +14,28 @@ const cache = new Map()
* @returns {Promise<boolean>} * @returns {Promise<boolean>}
*/ */
export async function checkAlive(url, timeoutMs = 4000) { export async function checkAlive(url, timeoutMs = 4000) {
try {
const controller = new AbortController() const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs) const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
await fetch(url, { await fetch(url, {
mode: 'no-cors', // 跨域不读响应体resolve 即存活 mode: 'no-cors', // 跨域不读响应体resolve 即存活
cache: 'no-store', cache: 'no-store',
signal: controller.signal, signal: controller.signal,
redirect: 'follow' redirect: 'follow'
}) })
clearTimeout(timer)
return true return true
} catch { } catch {
return false return false
} finally {
clearTimeout(timer)
} }
} }
/** /**
* 获取某 url 的存活态 ref响应式 * 获取某 url 的存活态 ref响应式
* 首次调用触发一次探测之后所有调用方共用同一个 ref 与探测结果 * 首次调用立即触发一次探测之后所有调用方共用同一个 ref。
* 返回的 ref.value: 'pending'(探测中)/ 'up'(存活)/ 'down'(不可达) * 要周期重探,额外调用 startPolling(url)。
* ref.value: 'pending'(探测中)/ 'up'(存活)/ 'down'(不可达)
* @param {string} url * @param {string} url
* @param {number} [timeoutMs] * @param {number} [timeoutMs]
* @returns {import('vue').Ref<string>} * @returns {import('vue').Ref<string>}
@@ -44,28 +46,39 @@ export function getLiveness(url, timeoutMs) {
let entry = cache.get(url) let entry = cache.get(url)
if (!entry) { if (!entry) {
const state = ref('pending') const state = ref('pending')
const promise = checkAlive(url, timeoutMs).then((ok) => { entry = { state, promise: null, timer: null }
cache.set(url, entry)
// 首次访问立即探测一次
entry.promise = checkAlive(url, timeoutMs).then((ok) => {
state.value = ok ? 'up' : 'down' state.value = ok ? 'up' : 'down'
return ok return ok
}) })
entry = { state, promise }
cache.set(url, entry)
} }
return entry.state return entry.state
} }
/** /**
* 批量探测多个 url并行用于启动时一次性探测所有依赖服务 * 对某 url 启动周期探测(首次立即探测 + 每 intervalMs 一次)
* 返回时各 url 的 getLiveness() ref 已更新到位 * 幂等:重复调用不会叠加定时器。返回停止函数
* @param {string[]} urls * @param {string} url
* @param {number} [intervalMs] 默认 30s
* @param {number} [timeoutMs] * @param {number} [timeoutMs]
* @returns {() => void} 停止定时器
*/ */
export async function probeAll(urls, timeoutMs) { export function startPolling(url, intervalMs = 30000, timeoutMs = 4000) {
const unique = [...new Set(urls.filter(Boolean))] if (!url) return () => {}
await Promise.all( getLiveness(url, timeoutMs) // 确保缓存项存在并已触发首次探测
unique.map((u) => { const entry = cache.get(url)
getLiveness(u, timeoutMs) // 首次访问即注册并触发探测 if (entry.timer) return () => clearInterval(entry.timer)
return cache.get(u).promise 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
}
}
} }

View File

@@ -1,15 +1,11 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
// 共享的语言辅助 composable消除各组件重复定义 L() // 共享的语言辅助 composable消除各组件重复定义
// 用法: // 用法:
// const { t, locale, isZh, pick } = useLocale() // const { t } = useLocale()
// t('some.key') // 走 i18n推荐,文案在 locale 文件) // t('some.key') // 走 i18n文案在 locale 文件)
// isZh.value // 判断当前是否中文 // 页签包装器透传 locale 给子模块时直接读 i18n.global.locale见各 wrapper
// pick({ zh, en }) // 从 {zh,en} 对象取当前语言值(用于数据文件)
export function useLocale() { export function useLocale() {
const { t, locale } = useI18n({ useScope: 'global' }) const { t } = useI18n({ useScope: 'global' })
const isZh = computed(() => locale.value === 'zh-CN') return { t }
const pick = (obj) => (isZh.value ? obj.zh : obj.en)
return { t, locale, isZh, pick }
} }

View File

@@ -1,36 +1,40 @@
import modules from '../modules/index.js' import modules from '../modules/index.js'
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { getLiveness, probeAll } from './useLiveness.js' import { getLiveness } from './useLiveness.js'
// 收集所有声明了 requiresAlive 的模块探针 URL启动时一次性并行探测。 // 记录「曾经被访问过」的模块 id。隐藏页签如 cqy一旦通过 URL 访问,
// probeAll 内部对同一 url 去重并缓存,与 getLiveness 共享结果 // 即加入此集合,使其在导航中保持可见 -- 不会因切走其他页签而消失
const aliveUrls = modules const visited = ref(new Set())
.map((m) => m.requiresAlive)
.filter(Boolean) /**
const _distinctUrls = [...new Set(aliveUrls)] * 标记某模块已被访问(隐藏页签访问后保持可见)。
// 启动即触发探测(模块级执行一次),结果缓存在 useLiveness 的 cache 中。 * @param {string} moduleId
// useProjects 通过 getLiveness 读取同一 ref无需等待即可响应式更新。 */
if (_distinctUrls.length) { export function markVisited(moduleId) {
probeAll(_distinctUrls).catch(() => { if (moduleId && !visited.value.has(moduleId)) {
/* 探测失败已记录为 down忽略 */ visited.value = new Set(visited.value).add(moduleId)
}) }
} }
// 读取已注册模块列表,供 TabNav 渲染页签。 // 读取已注册模块列表,供 TabNav 渲染页签。
// hidden 模块(如日历页)默认不显示在导航中,但当用户正在访问该页时 // hidden 模块:默认不显示,但「当前正在访问」或「曾经访问过」后保持显示。
// 仍显示其页签(描下边高亮),让用户知道当前所在位置。 // requiresAlive 模块:乐观显示 -- 探测中(pending)与存活(up)均显示,
// requiresAlive 模块:探测返回前不显示(避免闪烁);挂掉则保持隐藏。 // 仅当明确探测为不可达(down)时才隐藏。
export function useProjects() { export function useProjects() {
const route = useRoute() const route = useRoute()
const projects = computed(() => const projects = computed(() =>
modules.filter((m) => { modules.filter((m) => {
// 隐藏模块:当前正在访问才显示 // 隐藏模块:当前正在访问 或 曾经访问过 才显示
if (m.hidden && m.id !== route.meta.moduleId) return false if (m.hidden) {
// 依赖存活的模块pending/down 时不显示up 时显示 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) { if (m.requiresAlive) {
const state = getLiveness(m.requiresAlive) const state = getLiveness(m.requiresAlive)
if (state.value !== 'up') return false if (state.value === 'down') return false
} }
return true return true
}) })

View File

@@ -1,27 +1,40 @@
// ★ 集中配置 -- 外部服务 URL、默认参数等统一在此维护 // ★ 集中配置 -- 外部服务 URL、默认参数等统一在此维护
// 模块按需 import { appConfig } 使用,改 URL 只改这里即可(需重新 build // 模块按需 import { appConfig } 使用,改 URL 只改这里即可(需重新 build
// 每个服务项都带默认值,避免缺失字段导致渲染崩溃。 // 每个服务项都带默认值,避免缺失字段导致渲染崩溃。
//
// 路由约定zTools2 所有入口API/静态/探针/WS统一挂在 /api/ 下,
// fetch 与探针一律用**同源相对路径**/api/pdf/jobs、/api/health 等)。
// 无论是 vite devserver.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 = { export const appConfig = {
// 共享记事本(白板)服务 // 共享记事本(白板)服务
whiteboard: { whiteboard: {
// 服务根地址(含协议与域名) // 服务根地址(含协议与域名);默认空串 = 同源相对路径
baseUrl: 'https://f.zikai.wang', baseUrl: BASE_URL,
// 存活探针路径;启动时探测一次,挂掉则隐藏页签 // 存活探针路径;周期探测,挂掉则隐藏页签并卸载组件
healthPath: '/health', healthPath: '/api/health',
// 记事本页面路径生成器 // 默认打开的记事本 idWhiteboard.vue 的 resolveBoardId 回退用)
pagePath: (boardId) => `/wb/${encodeURIComponent(boardId)}`, defaultBoardId: 'share'
// 默认打开的记事本 id },
defaultBoardId: 'shared', // PDF 转换服务zTools2 提供 /api/pdf/* REST APIzPDF_package 组件同源 fetch 调用)
// 探测超时(毫秒) pdf: {
healthTimeoutMs: 4000 // 服务根地址(含协议与域名);默认空串 = 同源相对路径
baseUrl: BASE_URL,
// 存活探针路径;周期探测,挂掉则隐藏页签并卸载组件
healthPath: '/api/health'
} }
} }
/** /**
* 拼接服务的完整存活探针 URL。 * 取某服务的存活探针 URL。
* @param {'whiteboard'} service 配置中的服务名 * @param {keyof typeof appConfig} service 配置中的服务名
* @returns {string} 完整 URL https://f.zikai.wang/health * @returns {string} 完整探针 URL同源相对 /api/health 或绝对地址
*/ */
export function healthUrl(service) { export function healthUrl(service) {
const cfg = appConfig[service] const cfg = appConfig[service]

View File

@@ -5,99 +5,20 @@ export default {
title: 'Zikai' title: 'Zikai'
}, },
common: { common: {
loading: 'Loading…', loading: 'Loading…'
notAvailable: 'Not available',
serviceUnavailable: 'Service is temporarily unavailable. Please try again later.'
}, },
tabs: { tabs: {
mobileGame: 'Mobile Game Project', mobileGame: 'Mobile Game Project',
calendar: 'CQY Timetable', calendar: 'CQY Timetable',
whiteboard: 'Shared Notepad' whiteboard: 'Shared Notepad',
timetable: 'Schedule Organizer',
pdf: 'PDF Converter'
}, },
whiteboard: { whiteboard: {
openExternal: 'Open in new tab' boardId: 'Board ID',
}, switch: 'Switch'
mobileGame: {
hero: {
title: 'Mobile Game Project',
desc: '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.'
},
meta: {
course: 'Miami University · Capstone 448 / 449',
stack: 'Stack',
stackValue: 'Unity · C# · WebGL · Android · PHP',
period: 'Period',
periodValue: '2022'
},
overview: {
label: 'Overview',
title: 'From character controller to gyro ball',
p1: '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. The game evolved along several parallel lines, from 2D character controllers and platformers, ultimately shifting to a gyroscope-controlled 3D rolling ball, with ML-Agents reinforcement-learning experiments.',
p2: 'Gameplay iterated across versions: early builds were keyboard-controlled 2D 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.'
},
subtabs: {
team: 'Team · Leaderboard',
iterations: 'Iterations',
experiment: 'Experiment'
},
team: {
label: 'Team',
title: 'Members'
},
leaderboard: {
label: 'Leaderboard',
title: 'Top 30 Scores',
colRank: '#',
colName: 'Name',
colScore: 'Score',
noScores: 'No scores yet',
error: 'Leaderboard service unavailable'
},
iterations: {
label: 'Version History',
title: 'Iteration Record',
colControls: 'Controls',
colNote: 'Note',
colUnity: 'Unity',
playWebgl: 'Play WebGL',
downloadApk: 'Download APK',
pivotText: 'Earlier parallel lines: 2D'
},
experiment: {
label: 'Experiment',
title: 'ML-Agents Reinforcement Learning Demo',
caption: 'Capsules trained via ML-Agents to keep the ball from falling and to navigate obstacles.'
},
tracks: {
'roll-a-ball': {
name: '3D Rolling Ball (Final Form)',
desc: 'The final form as the project shifted from 2D to 3D. Tilt the phone to roll the ball; snowflake bricks fall, yellow bricks bounce.'
},
'zikai-ui': {
name: 'zikai-ui Character Controller',
desc: 'The most iterated line. A 2D character with health/power stats; controls evolved from mouse, QWER/ASDF keys, finally to WASD.'
},
'main': {
name: 'main Character Controller',
desc: '2D character controller; upgraded Unity 2020 -> 2021, added on-screen buttons and jump.'
},
'doby': {
name: 'DobyAdventure',
desc: '2D side-scrolling platformer: collect coins, score, health, touch joystick.'
}
},
versions: {
'zikai-0.0.0': { control: 'Left mouse: operate, Right mouse: track', note: 'Early mouse-controlled prototype' },
'zikai-0.0.1': { control: 'Health Q/W/E/R, Power A/S/D/F', note: 'Introduced health/power stat system' },
'zikai-0.0.2': { control: 'Same as 0.0.1', note: 'Build artifact renaming' },
'zikai-0.0.3': { control: 'WASD move, J jump, K fly (testing)', note: 'Switched to WASD, latest of this line' },
'main-0.0.0': { control: 'Character controller', note: 'Baseline' },
'main-0.0.1': { control: 'WASD move, K jump', note: 'Upgraded Unity, added on-screen buttons' },
'hongXiang-0.0.0': { control: 'A/D move, jump, collect coins', note: '2D platformer' },
'ball': { control: 'Tilt phone to move, tilt up/tap to jump', note: 'Final version, project shifted to 3D rolling ball' }
},
screenshots: {
caption: 'Screenshot {n}'
}
} }
// 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.
} }

View File

@@ -4,101 +4,20 @@ export default {
title: 'Zikai' title: 'Zikai'
}, },
common: { common: {
loading: '加载中…', loading: '加载中…'
notAvailable: '暂不可用',
serviceUnavailable: '服务暂不可用,请稍后再试。'
}, },
tabs: { tabs: {
mobileGame: '移动游戏项目', mobileGame: '移动游戏项目',
calendar: 'cqy课程表', calendar: 'cqy课程表',
whiteboard: '共享记事本' whiteboard: '共享记事本',
timetable: '日程整理',
pdf: 'PDF 转换'
}, },
whiteboard: { whiteboard: {
openExternal: '在新标签页打开' boardId: '白板 ID',
}, switch: '切换'
mobileGame: {
hero: {
title: '移动游戏项目',
desc: '一个跨学期的 Unity 移动游戏毕设项目448 开发游戏本体WebGL + Android APK449 搭建展示网站与玩家排行榜。经历多版本迭代,从角色控制器演进到陀螺仪操控的 3D 滚球游戏,并尝试用 ML-Agents 做强化学习实验。'
},
meta: {
course: '迈阿密大学 · 毕设 448 / 449',
stack: '技术栈',
stackValue: 'Unity · C# · WebGL · Android · PHP',
period: '时间',
periodValue: '2022'
},
overview: {
label: '项目概览',
title: '从角色控制器到陀螺仪滚球',
p1: '项目跨越 448 与 449 两门课程。448 用 Unity 开发游戏本体,产出可浏览器试玩的 WebGL 构建与可安装的 Android APK449 搭建展示网站与基于 PHP+MySQL 的玩家分数排行榜。游戏经历多条并行演进线,从 2D 角色控制器与平台跳跃,最终转向手机陀螺仪操控的 3D 滚球,并尝试用 ML-Agents 做强化学习实验。',
p2: '玩法经过多版本迭代:早期是键盘控制生命/能量的 2D 角色控制器,最终版改为手机陀螺仪倾斜操控的 3D 滚球,含雪花砖掉落、黄色砖弹跳等机制。'
},
subtabs: {
team: '团队 · 排行榜',
iterations: '迭代记录',
experiment: '实验'
},
team: {
label: '团队',
title: '成员'
},
leaderboard: {
label: '排行榜',
title: '玩家分数 Top 30',
colRank: '#',
colName: '玩家',
colScore: '分数',
noScores: '暂无分数记录',
error: '排行榜服务暂不可用'
},
iterations: {
label: '版本演进',
title: '迭代记录',
colControls: '操作',
colNote: '说明',
colUnity: 'Unity',
playWebgl: 'WebGL 试玩',
downloadApk: '下载 APK',
pivotText: '早期并行演进2D 线'
},
experiment: {
label: '实验',
title: 'ML-Agents 强化学习演示',
caption: '胶囊体经 ML-Agents 训练,学习保球不掉与越障。'
},
// 版本演进线与版本数据中的可翻译文案
tracks: {
'roll-a-ball': {
name: '3D 滚球(最终形态)',
desc: '项目从 2D 转向 3D 的最终形态。手机陀螺仪倾斜操控滚球,含雪花砖掉落、黄色砖弹跳等机制。'
},
'zikai-ui': {
name: 'zikai-ui 角色控制器',
desc: '迭代次数最多的线。2D 角色带生命/能量属性操作方式从鼠标、QWER/ASDF 键盘最终改为 WASD。'
},
'main': {
name: 'main 角色控制器',
desc: '2D 角色控制器,从 Unity 2020 升级到 2021新增屏幕按键与跳跃。'
},
'doby': {
name: 'DobyAdventure',
desc: '2D 横版平台跳跃,吃金币、计分、血量、触屏摇杆。'
}
},
// 各版本的操作与说明文案key = 版本 id
versions: {
'zikai-0.0.0': { control: '鼠标左键操作、右键追踪', note: '早期鼠标控制原型' },
'zikai-0.0.1': { control: '生命 Q/W/E/R、能量 A/S/D/F', note: '引入生命/能量属性系统' },
'zikai-0.0.2': { control: '同 0.0.1', note: '构建产物重命名整理' },
'zikai-0.0.3': { control: 'WASD 移动、J 跳跃、K 飞行(测试)', note: '改为 WASD 操作,本线最新版' },
'main-0.0.0': { control: '角色控制器', note: '基线版本' },
'main-0.0.1': { control: 'WASD 移动、K 跳跃', note: '升级 Unity新增屏幕按键' },
'hongXiang-0.0.0': { control: 'A/D 移动、跳跃、吃金币', note: '2D 平台跳跃' },
'ball': { control: '手机陀螺仪倾斜移动、上倾/点击跳跃', note: '最终版本,项目转向 3D 滚球' }
},
screenshots: {
caption: '项目截图 {n}'
}
} }
// 注移动游戏项目mobile-game的文案由子模块 z449 自有 i18n 提供,
// mainPage 仅保留 tabs.mobileGame 页签标题。切换语言时经 :locale prop 同步给 z449。
// PDF 转换页签的文案由子模块 zPDF_package 自有 i18n 提供mainPage 仅保留 tabs.pdf。
} }

View File

@@ -1,6 +1,9 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
// 组件名与模块 id 一致,供 <KeepAlive :include> 按 id 精确匹配
defineOptions({ name: 'cqy' })
import { useLocale } from '../../composables/useLocale.js' import { useLocale } from '../../composables/useLocale.js'
import PageShell from '../../components/ui/PageShell.vue'
const { t } = useLocale() const { t } = useLocale()
@@ -16,46 +19,29 @@ const loaded = ref(false)
</script> </script>
<template> <template>
<div class="calendar-page"> <!-- 日历用全宽变体fluid突破 1080px仅留左右内边距 -->
<div class="calendar-page__inner"> <PageShell title-key="tabs.calendar" fluid>
<h1 class="calendar-page__title">{{ t('tabs.calendar') }}</h1> <div class="cal-frame">
<div class="calendar-page__frame">
<!-- 加载占位骨架覆盖层iframe 加载完成后淡出 --> <!-- 加载占位骨架覆盖层iframe 加载完成后淡出 -->
<Transition name="fade"> <Transition name="fade">
<div v-if="!loaded" class="calendar-page__skeleton"> <div v-if="!loaded" class="cal-frame__skeleton">
<div class="calendar-page__skeleton-text">{{ t('common.loading') }}</div> <div class="cal-frame__skeleton-text">{{ t('common.loading') }}</div>
</div> </div>
</Transition> </Transition>
<iframe <iframe
:src="calendarSrc" :src="calendarSrc"
class="calendar-page__iframe" class="cal-frame__iframe"
frameborder="0" frameborder="0"
scrolling="no" scrolling="no"
:title="t('tabs.calendar')" :title="t('tabs.calendar')"
@load="loaded = true" @load="loaded = true"
/> />
</div> </div>
</div> </PageShell>
</div>
</template> </template>
<style scoped> <style scoped>
.calendar-page { .cal-frame {
padding: var(--space-2xl) 0;
}
/* 日历页用全宽容器(不受全局 .container 的 1080px 限制),仅留左右内边距 */
.calendar-page__inner {
width: 100%;
max-width: 100%;
margin: 0 auto;
padding: 0 var(--space-lg);
box-sizing: border-box;
}
.calendar-page__title {
font-size: 1.6rem;
margin-bottom: var(--space-lg);
}
.calendar-page__frame {
position: relative; position: relative;
border-radius: var(--radius); border-radius: var(--radius);
overflow: hidden; overflow: hidden;
@@ -66,13 +52,13 @@ const loaded = ref(false)
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
min-height: 500px; min-height: 500px;
} }
.calendar-page__iframe { .cal-frame__iframe {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
border: 0; border: 0;
} }
.calendar-page__skeleton { .cal-frame__skeleton {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: 1; z-index: 1;
@@ -81,7 +67,7 @@ const loaded = ref(false)
justify-content: center; justify-content: center;
background: var(--surface); background: var(--surface);
} }
.calendar-page__skeleton-text { .cal-frame__skeleton-text {
color: var(--text-tertiary); color: var(--text-tertiary);
font-size: 0.95rem; font-size: 0.95rem;
} }
@@ -95,7 +81,7 @@ const loaded = ref(false)
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.calendar-page__frame { .cal-frame {
aspect-ratio: 4 / 3; aspect-ratio: 4 / 3;
min-height: 400px; min-height: 400px;
} }

View File

@@ -3,30 +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) => { .map(([path, mod]) => ({ path, mod: mod?.default }))
try { .filter(({ mod }) => mod != null)
return mod?.default // 字段完整性兜底:缺 id/tabKey/component 的模块无法生成页签与路由,跳过并告警
} catch (e) { .filter(({ path, mod }) => {
console.warn('[modules] 模块导出解析失败,已跳过:', e) if (mod.id && mod.tabKey && typeof mod.component === 'function') return true
return null console.warn('[mainPage] 模块缺少必填字段,已跳过', path)
} return false
}) })
.filter(Boolean) .map(({ mod }) => mod)
// 字段完整性兜底:缺 id/tabKey/component 的模块无法生成页签与路由,跳过 // order 缺省 999排在所有显式 order 页签之后hidden 页常用 99 之类显式值)
.filter((m) => m && m.id && m.tabKey && typeof m.component === 'function') .sort((a, b) => (a.order ?? 999) - (b.order ?? 999))
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99))
export default modules export default modules

View File

@@ -1,97 +0,0 @@
<script setup>
import { ref, defineAsyncComponent } from 'vue'
import HeroSection from './components/HeroSection.vue'
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
import SubTabs from './components/SubTabs.vue'
import { useLocale } from '../../composables/useLocale.js'
import { subTabs, defaultSubTab } from './config.js'
const { t } = useLocale()
// 子标签页配置转为 SubTabs 需要的 { id, labelKey } 形式
const tabs = subTabs
const activeTab = ref(defaultSubTab)
// ★ 按需加载:三个子标签页用 defineAsyncComponent 动态 import
// 被 Vite/Rollup 拆成独立 chunk只有切到对应标签页时才下载与渲染。
// 配合 v-if未激活的标签页组件根本不挂载不执行 setup、不发 AJAX
const TeamLeaderboardTab = defineAsyncComponent(() => import('./components/TeamLeaderboardTab.vue'))
const IterationsTab = defineAsyncComponent(() => import('./components/IterationsTab.vue'))
const ExperimentTab = defineAsyncComponent(() => import('./components/ExperimentTab.vue'))
</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>{{ t('mobileGame.overview.label') }}</small>
{{ t('mobileGame.overview.title') }}
</h2>
<p>{{ t('mobileGame.overview.p1') }}</p>
<p>{{ t('mobileGame.overview.p2') }}</p>
</div>
<div class="block__media">
<ScreenshotCarousel />
</div>
</div>
</section>
<!-- 子标签页区域按需加载v-if 确保未激活的不渲染 -->
<section class="block">
<SubTabs v-model="activeTab" :tabs="tabs" />
<Suspense>
<TeamLeaderboardTab v-if="activeTab === 'team'" />
<IterationsTab v-else-if="activeTab === 'iterations'" />
<ExperimentTab v-else-if="activeTab === 'experiment'" />
<template #fallback>
<div class="tab-loading">{{ t('common.loading') }}</div>
</template>
</Suspense>
</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;
}
.tab-loading {
padding: var(--space-xl);
text-align: center;
color: var(--text-tertiary);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.block__grid {
grid-template-columns: 1fr;
}
}
</style>

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

View File

@@ -1,43 +0,0 @@
<script setup>
import { useLocale } from '../../../composables/useLocale.js'
import { assets } from '../config.js'
const { t } = useLocale()
</script>
<template>
<div class="tab-experiment">
<h2 class="section-title">
<small>{{ t('mobileGame.experiment.label') }}</small>
{{ t('mobileGame.experiment.title') }}
</h2>
<div class="video-wrap">
<video controls preload="none" :poster="assets.video.poster">
<source :src="assets.video.src" type="video/mp4" />
</video>
<p class="video-caption">{{ t('mobileGame.experiment.caption') }}</p>
</div>
</div>
</template>
<style scoped>
.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;
}
</style>

View File

@@ -1,64 +0,0 @@
<script setup>
import Tag from '../../../components/ui/Tag.vue'
import { useLocale } from '../../../composables/useLocale.js'
import { heroMeta } from '../config.js'
const { t } = useLocale()
</script>
<template>
<section class="hero">
<div class="container">
<div class="hero__eyebrow">
<Tag>{{ t(heroMeta.courseKey) }}</Tag>
</div>
<h1 class="hero__title">{{ t('mobileGame.hero.title') }}</h1>
<p class="hero__desc">{{ t('mobileGame.hero.desc') }}</p>
<div class="hero__meta">
<div v-for="item in heroMeta.items" :key="item.labelKey" class="hero__meta-item">
<span class="hero__meta-label">{{ t(item.labelKey) }}</span>
<span>{{ t(item.valueKey) }}</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>

View File

@@ -1,16 +0,0 @@
<script setup>
import { useLocale } from '../../../composables/useLocale.js'
import VersionTimeline from './VersionTimeline.vue'
const { t } = useLocale()
</script>
<template>
<div class="tab-iterations">
<h2 class="section-title">
<small>{{ t('mobileGame.iterations.label') }}</small>
{{ t('mobileGame.iterations.title') }}
</h2>
<VersionTimeline />
</div>
</template>

View File

@@ -1,128 +0,0 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useLocale } from '../../../composables/useLocale.js'
import { leaderboard } from '../config.js'
const { t } = useLocale()
const rows = ref([])
const state = ref('loading') // loading | ok | error
const errorMsg = ref('')
// 复用 449 现有后端POST endpoint {num} 取分页 JSON
async function load() {
state.value = 'loading'
try {
const all = []
for (let page = 0; page < leaderboard.pages; page++) {
const res = await fetch(leaderboard.endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `num=${page * leaderboard.perPage}`
})
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 = t('mobileGame.leaderboard.noScores')
} catch (e) {
state.value = 'error'
errorMsg.value = t('mobileGame.leaderboard.error')
}
}
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>{{ t('mobileGame.leaderboard.colRank') }}</th>
<th>{{ t('mobileGame.leaderboard.colName') }}</th>
<th>{{ t('mobileGame.leaderboard.colScore') }}</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>

View File

@@ -1,131 +0,0 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useLocale } from '../../../composables/useLocale.js'
import { assets } from '../config.js'
const { t } = useLocale()
const images = assets.screenshots
const current = ref(0)
let timer = null
function go(i) {
current.value = (i + images.length) % images.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="(src, i) in images" :key="i" class="carousel__slide">
<img :src="src" :alt="t('mobileGame.screenshots.caption', { n: i + 1 })" 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="(src, i) in images"
: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>

View File

@@ -1,65 +0,0 @@
<script setup>
import { useLocale } from '../../../composables/useLocale.js'
const props = defineProps({
tabs: { type: Array, required: true }, // [{ id, labelKey }]
modelValue: { type: String, required: true }
})
const emit = defineEmits(['update:modelValue'])
const { t } = useLocale()
</script>
<template>
<div class="subtabs">
<button
v-for="tab in tabs"
:key="tab.id"
type="button"
class="subtabs__btn"
:class="{ 'is-active': modelValue === tab.id }"
@click="emit('update:modelValue', tab.id)"
>
{{ t(tab.labelKey) }}
</button>
</div>
</template>
<style scoped>
.subtabs {
display: flex;
gap: var(--space-xs);
border-bottom: 1px solid var(--border);
margin-bottom: var(--space-lg);
overflow-x: auto;
scrollbar-width: none;
}
.subtabs::-webkit-scrollbar {
display: none;
}
.subtabs__btn {
position: relative;
padding: var(--space-sm) var(--space-md);
font-size: 0.92rem;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
transition: color var(--transition);
}
.subtabs__btn:hover {
color: var(--text-primary);
}
.subtabs__btn.is-active {
color: var(--accent);
}
.subtabs__btn.is-active::after {
content: '';
position: absolute;
left: var(--space-md);
right: var(--space-md);
bottom: -1px;
height: 2px;
background: var(--accent);
border-radius: 2px;
}
</style>

View File

@@ -1,55 +0,0 @@
<script setup>
import { team } from '../data/team.js'
</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>
</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);
}
</style>

View File

@@ -1,40 +0,0 @@
<script setup>
import { useLocale } from '../../../composables/useLocale.js'
import TeamCard from './TeamCard.vue'
import Leaderboard from './Leaderboard.vue'
const { t } = useLocale()
</script>
<template>
<div class="tab-team block--two">
<div>
<h2 class="section-title">
<small>{{ t('mobileGame.team.label') }}</small>
{{ t('mobileGame.team.title') }}
</h2>
<TeamCard />
</div>
<div>
<h2 class="section-title">
<small>{{ t('mobileGame.leaderboard.label') }}</small>
{{ t('mobileGame.leaderboard.title') }}
</h2>
<Leaderboard />
</div>
</div>
</template>
<style scoped>
.block--two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xl);
align-items: start;
}
@media (max-width: 768px) {
.block--two {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,266 +0,0 @@
<script setup>
import { computed } from 'vue'
import { useLocale } from '../../../composables/useLocale.js'
import { versions, tracks } from '../data/versions.js'
const { t } = useLocale()
// 按 tracks 数组顺序分组3D 最终形态在前);同一线内按 order 排序
const grouped = computed(() => {
return tracks
.map((tr) => {
const items = versions
.filter((v) => v.track === tr.id)
.sort((a, b) => a.order - b.order)
return { track: tr, items }
})
.filter((g) => g.items.length > 0)
})
const is3D = (track) => track.form === '3D'
const isFeatured = (track) => track.featured
// 形态转变标记:出现在 3D 线首位之后、2D 线之前
const showPivotAfter = (gi) => gi === 0 && grouped.value.length > 1 && is3D(grouped.value[0].track)
// i18n 取值辅助track/version 的文案在 locale 的 mobileGame.tracks / mobileGame.versions 下
const trackName = (trackId) => t(`mobileGame.tracks.${trackId}.name`)
const trackDesc = (trackId) => t(`mobileGame.tracks.${trackId}.desc`)
const vControl = (versionId) => t(`mobileGame.versions.${versionId}.control`)
const vNote = (versionId) => t(`mobileGame.versions.${versionId}.note`)
</script>
<template>
<div class="timeline">
<div
v-for="(g, gi) in grouped"
:key="g.track.id"
class="timeline__track"
:class="{
'timeline__track--3d': is3D(g.track),
'timeline__track--featured': isFeatured(g.track)
}"
>
<!-- 线标题 -->
<div class="timeline__track-head">
<span class="timeline__form" :class="{ 'is-3d': is3D(g.track) }">{{ g.track.form }}</span>
<span class="timeline__track-name">{{ trackName(g.track.id) }}</span>
<span v-if="isFeatured(g.track)" class="timeline__star"></span>
</div>
<p class="timeline__track-desc">{{ trackDesc(g.track.id) }}</p>
<!-- 该线下的版本 -->
<div class="timeline__items">
<div
v-for="(v, i) in g.items"
:key="v.id"
class="timeline__item"
>
<div class="timeline__marker">
<span class="timeline__dot"></span>
<span v-if="i < g.items.length - 1" class="timeline__line"></span>
</div>
<div class="timeline__body">
<div class="timeline__head">
<span class="timeline__version">v{{ v.version }}</span>
<span class="timeline__product">{{ v.product }}</span>
</div>
<div class="timeline__row">
<span class="timeline__label">{{ t('mobileGame.iterations.colControls') }}</span>
<span>{{ vControl(v.id) }}</span>
</div>
<div class="timeline__row">
<span class="timeline__label">{{ t('mobileGame.iterations.colNote') }}</span>
<span>{{ vNote(v.id) }}</span>
</div>
<div class="timeline__row">
<span class="timeline__label">{{ t('mobileGame.iterations.colUnity') }}</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">
{{ t('mobileGame.iterations.playWebgl') }}
</a>
<a v-if="v.apk" :href="v.apk" target="_blank" rel="noopener">
{{ t('mobileGame.iterations.downloadApk') }}
</a>
</div>
</div>
</div>
</div>
<!-- 形态转变标记3D 线之后2D 线之前 -->
<div v-if="showPivotAfter(gi)" class="timeline__pivot">
<span class="timeline__pivot-line"></span>
<span class="timeline__pivot-text">{{ t('mobileGame.iterations.pivotText') }}</span>
<span class="timeline__pivot-line"></span>
</div>
</div>
</div>
</template>
<style scoped>
.timeline {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
/* 最终形态强调 */
.timeline__track--featured {
padding: var(--space-lg);
background: linear-gradient(180deg, var(--accent-weak) 0%, transparent 100%);
border: 1px solid var(--accent-weak);
border-radius: var(--radius);
}
.timeline__track--featured .timeline__track-name {
font-size: 1.2rem;
}
.timeline__track--featured .timeline__track-desc {
color: var(--text-primary);
}
.timeline__track-head {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-xs);
}
.timeline__form {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 0.74rem;
font-weight: 600;
border-radius: 999px;
background: var(--surface-2);
color: var(--text-secondary);
letter-spacing: 0.04em;
}
.timeline__form.is-3d {
background: var(--accent);
color: #fff;
}
.timeline__track-name {
font-weight: 600;
font-size: 1.05rem;
color: var(--text-primary);
}
.timeline__star {
color: var(--accent);
font-size: 0.9rem;
}
.timeline__track-desc {
font-size: 0.9rem;
color: var(--text-secondary);
margin: 0 0 var(--space-md);
max-width: 640px;
}
.timeline__items {
padding-left: 4px;
}
.timeline__item {
display: flex;
gap: var(--space-md);
padding-bottom: var(--space-md);
}
.timeline__marker {
position: relative;
flex: 0 0 14px;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 6px;
}
.timeline__dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bg);
border: 2px solid var(--accent);
flex: 0 0 10px;
}
.timeline__track--3d .timeline__dot {
background: var(--accent);
}
.timeline__line {
flex: 1;
width: 2px;
background: var(--border);
margin-top: 4px;
min-height: 20px;
}
.timeline__body {
flex: 1;
padding: var(--space-sm) var(--space-md);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
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: var(--space-xs);
}
.timeline__version {
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--accent);
background: var(--accent-weak);
padding: 1px 8px;
border-radius: 6px;
font-weight: 600;
}
.timeline__product {
font-size: 0.82rem;
color: var(--text-tertiary);
}
.timeline__row {
display: flex;
gap: var(--space-sm);
font-size: 0.88rem;
color: var(--text-secondary);
padding: 1px 0;
}
.timeline__label {
flex: 0 0 52px;
color: var(--text-tertiary);
font-size: 0.8rem;
}
.timeline__mono {
font-family: var(--font-mono);
}
.timeline__links {
display: flex;
gap: var(--space-md);
margin-top: var(--space-xs);
font-size: 0.85rem;
}
/* 形态转变标记 */
.timeline__pivot {
display: flex;
align-items: center;
gap: var(--space-md);
margin: var(--space-md) 0 0;
}
.timeline__pivot-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, transparent, var(--border-strong), transparent);
}
.timeline__pivot-text {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-tertiary);
white-space: nowrap;
letter-spacing: 0.02em;
}
</style>

View File

@@ -1,46 +0,0 @@
// mobile-game 模块的静态配置(集中管理,避免散落在各组件)
// 文案走 i18n见 locales 的 mobileGame 命名空间),此处只放纯配置与资源路径。
// 站点根绝对路径的资源引用
export const assets = {
// 截图轮播图源
screenshots: [
'/448/1.jpg',
'/448/2.jpg',
'/448/3.jpg'
],
// ML-Agents 演示视频
video: {
src: '/448/ml/449ml.mp4',
poster: '/448/2.jpg'
}
}
// 排行榜后端(复用 449 现有 PHP 接口)
export const leaderboard = {
endpoint: '/449/449rest.php',
// 拉取页数 x 每页条数 = 总条数
pages: 3,
perPage: 10
}
// Hero 区元数据i18n 键,文案在 locales/mobileGame.meta 下)
export const heroMeta = {
// meta 项顺序labelKey -> valueKey
items: [
{ labelKey: 'mobileGame.meta.stack', valueKey: 'mobileGame.meta.stackValue' },
{ labelKey: 'mobileGame.meta.period', valueKey: 'mobileGame.meta.periodValue' }
],
// 顶部 eyebrow tag
courseKey: 'mobileGame.meta.course'
}
// 子标签页定义id 与 i18n 键)
export const subTabs = [
{ id: 'team', labelKey: 'mobileGame.subtabs.team' },
{ id: 'iterations', labelKey: 'mobileGame.subtabs.iterations' },
{ id: 'experiment', labelKey: 'mobileGame.subtabs.experiment' }
]
// 默认激活的子标签页
export const defaultSubTab = 'team'

View File

@@ -1,8 +0,0 @@
// 团队成员(来自 449 展示页)
export const team = [
{ name: 'Zikai Wang' },
{ name: 'Hongxiang He' },
{ name: 'Zhuoyue Sun' },
{ name: 'Xingchen Zhou' },
{ name: 'Yidong Yao' }
]

View File

@@ -1,48 +0,0 @@
// 448 版本演进数据(纯结构化数据,文案走 i18nmobileGame.tracks / mobileGame.versions
//
// 项目是多条线并行演进的且经历了「2D -> 3D 滚球」的形态转变:
// - roll-a-ball3Dfeatured项目转向 3D 的最终形态,置顶强调
// - zikai-ui2D角色控制器0.0.0 -> 0.0.3
// - main2D角色控制器0.0.0 -> 0.0.1
// - doby2D2D 平台跳跃
//
// track 字段标识所属演进线form 标识 2D/3Dorder 用于线内排序。
// name/note/control 等文案通过 i18n 键 tracks[trackId] / versions[versionId] 取。
export const tracks = [
{
id: 'roll-a-ball',
form: '3D',
featured: true
},
{
id: 'zikai-ui',
form: '2D'
},
{
id: 'main',
form: '2D'
},
{
id: 'doby',
form: '2D'
}
]
export const versions = [
// zikai-ui 线
{ id: 'zikai-0.0.0', track: 'zikai-ui', version: '0.0.0', unity: '-', product: 'My project / 1.0.2', webgl: '/448/zikai-0.0.0/', apk: null, order: 1 },
{ id: 'zikai-0.0.1', track: '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', order: 2 },
{ id: 'zikai-0.0.2', track: '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', order: 3 },
{ id: 'zikai-0.0.3', track: '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', order: 4 },
// main 线
{ id: 'main-0.0.0', track: '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', order: 2 },
{ id: 'main-0.0.1', track: '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', order: 5 },
// doby 线
{ id: 'hongXiang-0.0.0', track: 'doby', version: '0.0.0', unity: '2021.2.10f1', product: 'DobyAdventure', webgl: null, apk: '/448/hongXiang-0.0.0/2D game.apk', order: 3 },
// roll-a-ball 线3D项目形态转变
{ id: 'ball', track: 'roll-a-ball', version: '0.0.2', unity: '2021.3.12f1', product: '3dRoingBallt2 / 0.0.2', webgl: '/448/ball/', apk: '/448/203wangz199.apk', order: 6 }
]

View File

@@ -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
View 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 cookiezk_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
View 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,
// 存活探针 URLuseProjects 启动时探测一次,挂掉则不显示页签
requiresAlive: healthUrl('pdf'),
// 懒加载组件 -> 独立 chunk
component: () => import('./Pdf.vue')
}

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

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

View File

@@ -1,152 +1,149 @@
<script setup> <script setup>
import { ref, computed } from 'vue' // ★ 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 { appConfig } from '../../config/app.config.js'
import { useLocale } from '../../composables/useLocale.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 { t } = useLocale()
const route = useRoute()
const router = useRouter()
// 完整白板页 URL公开可被 iframe 嵌入)
const wbConfig = appConfig.whiteboard const wbConfig = appConfig.whiteboard
const boardSrc = computed(
() => `${wbConfig.baseUrl}${wbConfig.pagePath(wbConfig.defaultBoardId)}` // 透传父项目当前语言给子应用(响应式:切语言时子应用内容跟随)
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
}
}
) )
const loaded = ref(false) // 提交输入框:切换白板并更新 URLreplace避免历史栈堆积
const failed = ref(false) function submitBoard() {
const id = resolveBoardId(inputId.value)
function onLoad() { inputId.value = id
loaded.value = true if (id !== currentBoardId.value) {
} currentBoardId.value = id
// iframe 加载失败(跨域无法读 error靠超时兜底 router.replace(`/whiteboard/${encodeURIComponent(id)}`)
function scheduleFailCheck() { }
setTimeout(() => {
if (!loaded.value) failed.value = true
}, 6000)
}
scheduleFailCheck()
// 外链打开(降级时提供)
function openExternal() {
window.open(boardSrc.value, '_blank', 'noopener')
} }
</script> </script>
<template> <template>
<div class="wb-page"> <PageShell title-key="tabs.whiteboard">
<div class="wb-page__inner"> <template #actions>
<h1 class="wb-page__title">{{ t('tabs.whiteboard') }}</h1> <form class="wb-switch" @submit.prevent="submitBoard">
<label class="wb-switch__label" for="boardInput">
<!-- 降级iframe 长时间未加载完成 --> {{ t('whiteboard.boardId') }}
<div v-if="failed" class="wb-page__fallback"> </label>
<p class="wb-page__fallback-text">{{ t('common.serviceUnavailable') }}</p> <input
<button type="button" class="wb-page__open" @click="openExternal"> id="boardInput"
{{ t('whiteboard.openExternal') }} v-model="inputId"
</button> class="wb-switch__input"
</div> type="text"
:placeholder="wbConfig.defaultBoardId"
<div v-else class="wb-page__frame"> spellcheck="false"
<!-- 加载骨架iframe 加载完成后淡出 --> autocomplete="off"
<Transition name="fade"> autocapitalize="off"
<div v-if="!loaded" class="wb-page__skeleton">
<div class="wb-page__skeleton-text">{{ t('common.loading') }}</div>
</div>
</Transition>
<iframe
:src="boardSrc"
class="wb-page__iframe"
frameborder="0"
:title="t('tabs.whiteboard')"
@load="onLoad"
/> />
<button type="submit" class="wb-switch__go">
{{ t('whiteboard.switch') }}
</button>
</form>
</template>
<div class="whiteboard__frame">
<ZWbApp :locale="locale" :board-id="currentBoardId" />
</div> </div>
</div> </PageShell>
</div>
</template> </template>
<style scoped> <style scoped>
.wb-page { .wb-switch {
padding: var(--space-xl) 0;
}
/* 全宽容器(不受全局 .container 的 1080px 限制),仅留左右内边距 */
.wb-page__inner {
width: 100%;
max-width: 100%;
margin: 0 auto;
padding: 0 var(--space-lg);
box-sizing: border-box;
}
.wb-page__title {
font-size: 1.6rem;
margin-bottom: var(--space-lg);
}
.wb-page__frame {
position: relative;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
width: 100%;
/* 记事本以编辑为主,给足高度 */
height: 70vh;
min-height: 480px;
}
.wb-page__iframe {
width: 100%;
height: 100%;
display: block;
border: 0;
}
.wb-page__skeleton {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
}
.wb-page__skeleton-text {
color: var(--text-tertiary);
font-size: 0.95rem;
}
.wb-page__fallback {
text-align: center;
padding: var(--space-2xl) var(--space-lg);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
background: var(--surface);
}
.wb-page__fallback-text {
color: var(--text-secondary);
margin-bottom: var(--space-md);
}
.wb-page__open {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 8px 18px; 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-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; font-weight: 500;
color: #fff; color: #fff;
background: var(--accent); background: var(--accent);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: background var(--transition); transition: background var(--transition);
} }
.wb-page__open:hover { .wb-switch__go:hover {
background: var(--accent-hover); background: var(--accent-hover);
} }
.fade-enter-active, /* 让子应用填满外壳容器,保留其内部布局自洽;记事本以编辑为主,给足高度 */
.fade-leave-active { .whiteboard__frame {
transition: opacity 0.4s ease; 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;
} }
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.wb-page__frame { .wb-switch__input {
height: 65vh; width: 120px;
min-height: 360px; }
.whiteboard__frame {
height: 72vh;
min-height: 400px;
} }
} }
</style> </style>

View File

@@ -1,6 +1,9 @@
// 模块元数据 -- 共享记事本(白板)页签 // 模块元数据 -- 共享记事本(白板)页签
// requiresAlive 指向存活探针 URL启动时探测服务不可达则隐藏该页签。 // 构建期组件集成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' import { healthUrl } from '../../config/app.config.js'
export default { export default {
@@ -9,6 +12,8 @@ export default {
order: 2, order: 2,
// 存活探针 URLuseProjects 启动时探测一次,挂掉则不显示页签 // 存活探针 URLuseProjects 启动时探测一次,挂掉则不显示页签
requiresAlive: healthUrl('whiteboard'), requiresAlive: healthUrl('whiteboard'),
// 接受 /whiteboard/:boardId 子路径,使直链访问能预填白板 id
param: 'boardId',
// 懒加载组件 -> 独立 chunk // 懒加载组件 -> 独立 chunk
component: () => import('./Whiteboard.vue') component: () => import('./Whiteboard.vue')
} }

View File

@@ -2,21 +2,17 @@
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 moduleRoutes = modules const moduleRoutes = modules.map((m) => {
.map((m) => { // 模块可声明 param如 'boardId')以接受 /<id>/<param> 形式的子路径,
try { // 形如 /whiteboard/share。可选参数 (? 后缀) 使 /whiteboard 仍可访问。
const paramPart = m.param ? `/:${m.param}?` : ''
return { return {
path: `/${m.id}`, path: `/${m.id}${paramPart}`,
name: m.id, name: m.id,
component: m.component, component: m.component,
meta: { moduleId: m.id } meta: { moduleId: m.id }
} }
} catch (e) { })
console.warn(`[router] 模块 ${m?.id} 路由生成失败,已跳过:`, e)
return null
}
})
.filter(Boolean)
const routes = [ const routes = [
{ {

View File

@@ -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/z449 vendored Submodule

Submodule third_party/z449 added at a91c379096

1
third_party/zPDF_package vendored Submodule

1
third_party/zWhiteBoard vendored Submodule

Submodule third_party/zWhiteBoard added at d7d7450296

View File

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