Compare commits

...

10 Commits

Author SHA1 Message Date
b5dcf924fb feat: 新增共享记事本页签(iframe 嵌入 + 存活校验)与集中配置
- 新增 whiteboard 模块:iframe 嵌入 f.zikai.wang/wb/shared,骨架屏+降级外链
- 存活校验:模块声明 requiresAlive,启动时 no-cors 探测 /health 一次,
  服务挂掉则隐藏页签(刷新页面才重探)
- 集中配置 src/config/app.config.js:白板 URL/探针/默认记事本 id 统一管理
- 容错强化:注册表/路由/useProjects 单模块异常不影响其他页签,删除一个页签
  仅需删其模块文件夹并重新 build
- i18n:tabs.whiteboard / common.serviceUnavailable / whiteboard.openExternal
- README 精简重写:仅保留项目结构、基础设施、Ubuntu 从0安装、新增页签
2026-07-22 02:22:59 +00:00
Zikai
19b301b937 fix: 日历用全宽(突破 .container 1080px 限制)
日历页改用全宽容器(calendar-page__inner,max-width:100%),
不受全局 .container 的 1080px 限制,仅留左右内边距。
宽屏 1400px 时 frame 从 1032px -> 1352px,iframe 1350x843;
窄屏仍自适应。aspect-ratio 默认 16:10(min-height 500px),
窄屏回退 4:3。
2026-07-13 14:17:49 +00:00
Zikai
337d516c08 fix: 日历页显示页签+页签样式优化+iframe自适应+骨架屏
- useProjects: 访问 hidden 模块(/cqy)时仍显示其页签并高亮,
  让用户知道当前位置;主页仍隐藏日历页签
- TabNav: 非激活页签加圆角背景与 hover 背景反馈,激活页签
  下边线贯通整宽,更像页签
- Calendar: iframe 去除固定 800x600,改 width/height:100% +
  aspect-ratio 自适应(宽屏16:10,窄屏4:3,min-height 400px)
- Calendar: 加载骨架屏覆盖层(iframe @load 后淡出),
  iframe 始终在 DOM 中确保 load 事件触发
- i18n: 日历页签文案改「cqy课程表」/「CQY Timetable」
headless 验证:/cqy 显示页签且激活、hover 有背景反馈、
iframe 自适应宽窄屏、骨架屏加载后淡出
2026-07-13 14:13:23 +00:00
Zikai
49900d2363 feat: 添加隐藏日历页 /cqy(谷歌日历,不在导航显示)
- 新增 calendar 模块(Calendar.vue + index.js),路由 /cqy
- 模块 meta 设 hidden: true,useProjects 过滤隐藏模块,
  TabNav 不显示日历页签,仅能手动访问 zikai.wang/cqy
- Calendar 组件懒加载(独立 chunk),默认页不下载
- iframe 嵌入谷歌日历(周视图),iframe loading=lazy
- 既有 .htaccess SPA 回退已覆盖 /cqy,线上 200
headless 验证:默认页无日历页签且不加载 Calendar chunk;
直接访问 /cqy 渲染 iframe 且加载 chunk;TabNav 仍只有移动游戏
2026-07-13 14:00:03 +00:00
Zikai
c6dad2b79a refactor: 高内聚低耦合 - 配置集中化与 i18n 统一
结构优化:
- 新增 mobile-game/config.js:集中模块静态配置(资源路径、排行榜端点、
  hero meta、子标签页定义),消除散落在各组件的硬编码
- 新增 composables/useLocale.js:共享语言辅助(t/isZh/pick),
  消除 6 个组件各自重复定义的 L() helper
- 文案全部迁入 i18n locale(mobileGame 命名空间),消除模板内联 L('中','英')
- 数据文件 versions.js 去除 {zh,en} 对象,文案改由 i18n 键(tracks/versions)取
- 各组件改为读 config + t(),不再直接 useI18n,降低与 i18n 实现的耦合

headless 验证:中文(hero/meta/team/timeline/3D置顶) + 英文切换 + 切回中文 全通过
2026-07-12 16:08:25 +00:00
Zikai
b3556b8639 perf: 子标签页按需加载(代码与元素懒加载)
原 v-show + 静态 import 导致三个子标签页的组件代码全部打进 MobileGame
chunk,且首屏即渲染全部(排行榜 onMounted 立即发 AJAX、视频元数据加载)。

改为:
- 三个标签页内容拆为独立组件 TeamLeaderboardTab/IterationsTab/ExperimentTab
- MobileGame 用 defineAsyncComponent + 动态 import,Vite 拆成独立 chunk
- v-if + Suspense:未激活的标签页组件根本不挂载(不发 AJAX、不渲染)
构建产物可见三个独立 chunk;headless 验证:默认团队页仅加载 team chunk,
点迭代记录才加载 IterationsTab chunk,点实验才加载 ExperimentTab chunk。
2026-07-12 15:44:39 +00:00
Zikai
88979a68c2 fix: 去掉「作品集」与「角色/主开发」字样
- 品牌栏与页面标题移除「作品集」副标题,仅保留 Zikai
- HeroSection 移除「角色: 主开发/Lead Developer」meta 项
- TeamCard 移除成员角色(主开发/开发/团队),仅显示姓名
- 清理 i18n locales 中无用的 subtitle 字段及 App.vue 对应样式
2026-07-12 15:41:56 +00:00
Zikai
8e08e619fc fix: 时间显示/3D线置顶强调/子标签页重构
1. fix(HeroSection): period 为 {zh,en} 对象却直接渲染导致显示 [object Object],
   改为字符串 '2022'
2. feat(timeline): 3D滚球(最终形态)线移到最前并 featured 强调(渐变背景+★+实心3D徽章),
   其后用分隔标记提示「早期并行演进:2D线」
3. refactor(MobileGame): 团队+排行榜/迭代记录/实验 改为平级子标签页(SubTabs),
   默认显示团队+排行榜;新增 SubTabs.vue 通用子标签组件
   headless 验证:默认页(团队+排行榜) -> 迭代记录(时间线) -> 实验(视频) -> 切回 全通过,
   中英文子标签页文案正常
2026-07-12 15:34:30 +00:00
Zikai
0adb320a47 docs: 部署节更正为 Apache + 补充 SPA .htaccess 回退说明
实际对外服务是 Apache 2.4(非 nginx),DocumentRoot /var/www/html。
history 模式下直接访问 /mobile-game 会 404,已用站点根 .htaccess 回退
(该文件不纳入本项目仓库,仅文档说明)。
2026-07-12 15:24:34 +00:00
Zikai
e8dee338ed fix: EN切换失效、删除贡献者描述、版本按演进线分组(2D->3D)
- fix(i18n): composition 模式下响应式 locale 在 i18n.global 上(非 i18n.locale),
  loadLocaleAsync 与 useI18nLazy 改用 i18n.global.locale;
  useI18nLazy.current 改为派生自全局 locale 的 computed(单一数据源);
  各组件 useI18n() 加 useScope:'global' 确保读到同一 composer。
  headless 验证:默认中文(不下载EN chunk)->点EN加载en.[hash].js并切英文->切回中文 全通过
- content: 删除 MobileGame 概览中「主要贡献者 Zikai Wang... Hongxiang He...」句子
- timeline: 版本数据改为多演进线并行(zikai-ui/main/doby/roll-a-ball),
  每线带 2D/3D 形态标记,3D 线前显示「形态转变:2D -> 3D 滚球」转折标记
2026-07-12 15:03:04 +00:00
33 changed files with 1247 additions and 508 deletions

227
README.md
View File

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

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Zikai · 作品集</title>
<title>Zikai</title>
</head>
<body>
<div id="app"></div>

View File

@@ -11,8 +11,8 @@
},
"dependencies": {
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"vue-i18n": "^9.13.1"
"vue-i18n": "^9.13.1",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",

View File

@@ -10,8 +10,6 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
<div class="brand">
<span class="brand__mark">Z</span>
<span class="brand__title">{{ $t('app.title') }}</span>
<span class="brand__sep">·</span>
<span class="brand__sub">{{ $t('app.subtitle') }}</span>
</div>
<LanguageSwitcher />
</div>
@@ -73,14 +71,6 @@ import LanguageSwitcher from './components/LanguageSwitcher.vue'
font-size: 0.95rem;
font-weight: 700;
}
.brand__sep {
color: var(--text-tertiary);
}
.brand__sub {
color: var(--text-secondary);
font-weight: 400;
font-size: 0.92rem;
}
.main {
flex: 1;

View File

@@ -2,6 +2,7 @@
import { useI18nLazy } from '../composables/useI18nLazy.js'
const { current, switching, switchLocale } = useI18nLazy()
// current 直接派生自全局 locale无需 useI18n
const locales = [
{ code: 'zh-CN', label: '中' },

View File

@@ -45,22 +45,24 @@ const { projects } = useProjects()
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
transition: color var(--transition);
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
transition: color var(--transition), background var(--transition);
}
.tab-nav__item:hover {
color: var(--text-primary);
background: var(--surface);
}
.tab-nav__item.is-active {
color: var(--accent);
background: var(--surface);
}
.tab-nav__item.is-active::after {
content: '';
position: absolute;
left: var(--space-md);
right: var(--space-md);
left: 0;
right: 0;
bottom: -1px;
height: 2px;
background: var(--accent);
border-radius: 2px;
}
</style>

View File

@@ -1,17 +1,19 @@
import { ref } from 'vue'
import { loadLocaleAsync } from '../i18n/index.js'
import { ref, computed } from 'vue'
import i18n, { loadLocaleAsync } from '../i18n/index.js'
const current = ref('zh-CN')
const switching = ref(false)
// 语言懒加载切换逻辑
// current 派生自全局 i18n composer 的 localecomposition 模式下在 i18n.global
// 与各组件内 useI18n({ useScope: 'global' }) 读到的是同一个 ref。
export function useI18nLazy() {
const current = computed(() => i18n.global.locale.value)
async function switchLocale(locale) {
if (switching.value || current.value === locale) return
switching.value = true
try {
await loadLocaleAsync(locale)
current.value = locale
} finally {
switching.value = false
}

View File

@@ -0,0 +1,71 @@
import { ref } from 'vue'
// ★ 存活校验 -- 跨域用 no-cors fetch 探测resolve=存活reject/timeout=挂掉)
// 设计:应用启动时对每个 url 探测一次,结果在全应用共享并稳定(不再重试)。
// 这样依赖存活态的页签在启动后据结果决定显示/隐藏;服务恢复需刷新页面才重探。
// 模块级共享缓存url -> { state: ref('pending'|'up'|'down'), promise }
const cache = new Map()
/**
* 探测单个 url 是否可达no-cors能 resolve 即视为存活)。
* @param {string} url 完整 URL
* @param {number} timeoutMs 超时毫秒
* @returns {Promise<boolean>}
*/
export async function checkAlive(url, timeoutMs = 4000) {
try {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
await fetch(url, {
mode: 'no-cors', // 跨域不读响应体resolve 即存活
cache: 'no-store',
signal: controller.signal,
redirect: 'follow'
})
clearTimeout(timer)
return true
} catch {
return false
}
}
/**
* 获取某 url 的存活态 ref响应式
* 首次调用触发一次探测,之后所有调用方共用同一个 ref 与探测结果。
* 返回的 ref.value: 'pending'(探测中)/ 'up'(存活)/ 'down'(不可达)
* @param {string} url
* @param {number} [timeoutMs]
* @returns {import('vue').Ref<string>}
*/
export function getLiveness(url, timeoutMs) {
// 无 url 视为无需校验,直接视为存活
if (!url) return ref('up')
let entry = cache.get(url)
if (!entry) {
const state = ref('pending')
const promise = checkAlive(url, timeoutMs).then((ok) => {
state.value = ok ? 'up' : 'down'
return ok
})
entry = { state, promise }
cache.set(url, entry)
}
return entry.state
}
/**
* 批量探测多个 url并行用于启动时一次性探测所有依赖服务。
* 返回时各 url 的 getLiveness() ref 已更新到位。
* @param {string[]} urls
* @param {number} [timeoutMs]
*/
export async function probeAll(urls, timeoutMs) {
const unique = [...new Set(urls.filter(Boolean))]
await Promise.all(
unique.map((u) => {
getLiveness(u, timeoutMs) // 首次访问即注册并触发探测
return cache.get(u).promise
})
)
}

View File

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

View File

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

30
src/config/app.config.js Normal file
View File

@@ -0,0 +1,30 @@
// ★ 集中配置 -- 外部服务 URL、默认参数等统一在此维护
// 模块按需 import { appConfig } 使用,改 URL 只改这里即可(需重新 build
// 每个服务项都带默认值,避免缺失字段导致渲染崩溃。
export const appConfig = {
// 共享记事本(白板)服务
whiteboard: {
// 服务根地址(含协议与域名)
baseUrl: 'https://f.zikai.wang',
// 存活探针路径;启动时探测一次,挂掉则隐藏页签
healthPath: '/health',
// 记事本页面路径生成器
pagePath: (boardId) => `/wb/${encodeURIComponent(boardId)}`,
// 默认打开的记事本 id
defaultBoardId: 'shared',
// 探测超时(毫秒)
healthTimeoutMs: 4000
}
}
/**
* 拼接服务的完整存活探针 URL。
* @param {'whiteboard'} service 配置中的服务名
* @returns {string} 完整 URL如 https://f.zikai.wang/health
*/
export function healthUrl(service) {
const cfg = appConfig[service]
if (!cfg) return ''
return `${cfg.baseUrl}${cfg.healthPath}`
}

View File

@@ -22,9 +22,10 @@ const loadedLocales = new Set(['zh-CN'])
* 之后切换仅在已加载集合内完成(无网络请求)。
*/
export async function loadLocaleAsync(locale) {
if (i18n.locale.value === locale) return
// composition 模式下响应式 locale 在 i18n.global
if (i18n.global.locale.value === locale) return
if (loadedLocales.has(locale)) {
i18n.locale.value = locale
i18n.global.locale.value = locale
document.documentElement.setAttribute('lang', locale)
return
}
@@ -35,14 +36,14 @@ export async function loadLocaleAsync(locale) {
messages = (await import('./locales/en.js')).default
} else {
// 未知语言回退到中文
i18n.locale.value = 'zh-CN'
i18n.global.locale.value = 'zh-CN'
document.documentElement.setAttribute('lang', 'zh-CN')
return
}
i18n.mergeLocaleMessage(locale, messages)
i18n.global.mergeLocaleMessage(locale, messages)
loadedLocales.add(locale)
i18n.locale.value = locale
i18n.global.locale.value = locale
document.documentElement.setAttribute('lang', locale)
}

View File

@@ -2,20 +2,102 @@
// 只看中文的访客不会下载此文件。
export default {
app: {
title: 'Zikai',
subtitle: 'Portfolio',
nav: {
projects: 'Projects',
language: 'Language'
}
title: 'Zikai'
},
common: {
loading: 'Loading…',
comingSoon: 'Coming soon',
notAvailable: 'Not available',
backToTop: 'Back to top'
serviceUnavailable: 'Service is temporarily unavailable. Please try again later.'
},
tabs: {
mobileGame: 'Mobile Game Project'
mobileGame: 'Mobile Game Project',
calendar: 'CQY Timetable',
whiteboard: 'Shared Notepad'
},
whiteboard: {
openExternal: 'Open in new tab'
},
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}'
}
}
}

View File

@@ -1,20 +1,104 @@
// 中文语言包(默认语言,同步注入主 chunk随首屏下发
export default {
app: {
title: 'Zikai',
subtitle: '作品集',
nav: {
projects: '项目',
language: '语言'
}
title: 'Zikai'
},
common: {
loading: '加载中…',
comingSoon: '即将上线',
notAvailable: '暂不可用',
backToTop: '回到顶部'
serviceUnavailable: '服务暂不可用,请稍后再试。'
},
tabs: {
mobileGame: '移动游戏项目'
mobileGame: '移动游戏项目',
calendar: 'cqy课程表',
whiteboard: '共享记事本'
},
whiteboard: {
openExternal: '在新标签页打开'
},
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}'
}
}
}

View File

@@ -0,0 +1,103 @@
<script setup>
import { ref, computed } from 'vue'
import { useLocale } from '../../composables/useLocale.js'
const { t } = useLocale()
// 谷歌日历 embed URL周视图
const calendarSrc = computed(() =>
'https://calendar.google.com/calendar/embed?height=600&wkst=2&ctz=Australia%2FMelbourne&showPrint=0&mode=WEEK' +
'&src=Y2E0YTE5ZWYwNDM3OTYyYzY5YjEyYzYxYWVmOGVlZjY3MGU5ZDdmYjRlNzJlOTFjNDJjMzllM2Q2NGU5ZTAzNkBncm91cC5jYWxlbmRhci5nb29nbGUuY29t' +
'&src=emguYXVzdHJhbGlhbiNob2xpZGF5QGdyb3VwLnYuY2FsZW5kYXIuZ29vZ2xlLmNvbQ' +
'&color=%237986cb&color=%230b8043'
)
const loaded = ref(false)
</script>
<template>
<div class="calendar-page">
<div class="calendar-page__inner">
<h1 class="calendar-page__title">{{ t('tabs.calendar') }}</h1>
<div class="calendar-page__frame">
<!-- 加载占位骨架覆盖层iframe 加载完成后淡出 -->
<Transition name="fade">
<div v-if="!loaded" class="calendar-page__skeleton">
<div class="calendar-page__skeleton-text">{{ t('common.loading') }}</div>
</div>
</Transition>
<iframe
:src="calendarSrc"
class="calendar-page__iframe"
frameborder="0"
scrolling="no"
:title="t('tabs.calendar')"
@load="loaded = true"
/>
</div>
</div>
</div>
</template>
<style scoped>
.calendar-page {
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;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
/* 自适应宽高:宽屏 16:9窄屏更高 */
width: 100%;
aspect-ratio: 16 / 10;
min-height: 500px;
}
.calendar-page__iframe {
width: 100%;
height: 100%;
display: block;
border: 0;
}
.calendar-page__skeleton {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
}
.calendar-page__skeleton-text {
color: var(--text-tertiary);
font-size: 0.95rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.calendar-page__frame {
aspect-ratio: 4 / 3;
min-height: 400px;
}
}
</style>

View File

@@ -0,0 +1,10 @@
// 模块元数据 -- 隐藏页,仅可通过手动访问 /cqy 进入
// hidden: true 使其不出现在 TabNav 导航中,但路由仍正常生成
export default {
id: 'cqy',
tabKey: 'tabs.calendar',
order: 99,
hidden: true,
// 懒加载组件 -> 独立 chunk
component: () => import('./Calendar.vue')
}

View File

@@ -10,12 +10,23 @@
// order: 1, // 页签排序
// component: () => import('./MobileGame.vue') // 懒加载组件
// }
//
// 容错:单个模块解析/导出异常时跳过该模块并告警,不影响其余页签加载。
const moduleFiles = import.meta.glob('./*/index.js', { eager: true })
const modules = Object.values(moduleFiles)
.map((mod) => mod.default)
.map((mod) => {
try {
return mod?.default
} catch (e) {
console.warn('[modules] 模块导出解析失败,已跳过:', e)
return null
}
})
.filter(Boolean)
// 字段完整性兜底:缺 id/tabKey/component 的模块无法生成页签与路由,跳过
.filter((m) => m && m.id && m.tabKey && typeof m.component === 'function')
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99))
export default modules

View File

@@ -1,16 +1,23 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { ref, defineAsyncComponent } from 'vue'
import HeroSection from './components/HeroSection.vue'
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
import VersionTimeline from './components/VersionTimeline.vue'
import TeamCard from './components/TeamCard.vue'
import Leaderboard from './components/Leaderboard.vue'
import SubTabs from './components/SubTabs.vue'
import { useLocale } from '../../composables/useLocale.js'
import { subTabs, defaultSubTab } from './config.js'
const { locale } = useI18n()
const L = (zh, en) => (locale.value === 'zh-CN' ? zh : en)
// 站点根绝对路径,用绑定避免被 Vite 当作构建期静态资源解析
const videoPoster = '/448/2.jpg'
const videoSrc = '/448/ml/449ml.mp4'
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>
@@ -18,26 +25,16 @@ const videoSrc = '/448/ml/449ml.mp4'
<HeroSection />
<div class="container">
<!-- 概览 + 截图轮播 -->
<!-- 概览 + 截图轮播始终显示 -->
<section class="block">
<div class="block__grid">
<div class="block__text">
<h2 class="section-title">
<small>{{ L('项目概览', 'Overview') }}</small>
{{ L('从角色控制器到陀螺仪滚球', 'From character controller to gyro ball') }}
<small>{{ t('mobileGame.overview.label') }}</small>
{{ t('mobileGame.overview.title') }}
</h2>
<p>
{{ L(
'项目跨越 448 与 449 两门课程。448 用 Unity 开发游戏本体,产出可浏览器试玩的 WebGL 构建与可安装的 Android APK449 搭建展示网站与基于 PHP+MySQL 的玩家分数排行榜。主要贡献者 Zikai Wangzikai-ui / main / Roll-a-Ball 系列)与 Hongxiang He2D DobyAdventure。',
'The project spans courses 448 and 449. 448 built the game with Unity, producing browser-playable WebGL builds and installable Android APKs; 449 built the showcase site and a PHP+MySQL player leaderboard. Main contributors: Zikai Wang (zikai-ui / main / Roll-a-Ball series) and Hongxiang He (2D DobyAdventure).'
) }}
</p>
<p>
{{ L(
'玩法经过多版本迭代:早期是键盘控制生命/能量的角色控制器,最终版改为手机陀螺仪倾斜操控的 3D 滚球,含雪花砖掉落、黄色砖弹跳等机制。还用 Unity ML-Agents 做了强化学习实验。',
'Gameplay iterated across versions: early builds were keyboard-controlled character controllers with health/power stats; the final version became a gyroscope-tilt 3D rolling-ball game with falling snowflake bricks and bouncing yellow bricks. ML-Agents reinforcement-learning experiments were also conducted.'
) }}
</p>
<p>{{ t('mobileGame.overview.p1') }}</p>
<p>{{ t('mobileGame.overview.p2') }}</p>
</div>
<div class="block__media">
<ScreenshotCarousel />
@@ -45,47 +42,18 @@ const videoSrc = '/448/ml/449ml.mp4'
</div>
</section>
<!-- 版本时间线 -->
<!-- 子标签页区域按需加载v-if 确保未激活的不渲染 -->
<section class="block">
<h2 class="section-title">
<small>{{ L('版本演进', 'Version History') }}</small>
{{ L('迭代记录', 'Iteration Record') }}
</h2>
<VersionTimeline />
</section>
<SubTabs v-model="activeTab" :tabs="tabs" />
<!-- 双列团队 + 排行榜 -->
<section class="block block--two">
<div>
<h2 class="section-title">
<small>{{ L('团队', 'Team') }}</small>
{{ L('成员', 'Members') }}
</h2>
<TeamCard />
</div>
<div>
<h2 class="section-title">
<small>{{ L('排行榜', 'Leaderboard') }}</small>
{{ L('玩家分数 Top 30', 'Top 30 Scores') }}
</h2>
<Leaderboard />
</div>
</section>
<!-- ML-Agents 演示视频 -->
<section class="block">
<h2 class="section-title">
<small>{{ L('实验', 'Experiment') }}</small>
{{ L('ML-Agents 强化学习演示', 'ML-Agents Reinforcement Learning Demo') }}
</h2>
<div class="video-wrap">
<video controls preload="none" :poster="videoPoster">
<source :src="videoSrc" type="video/mp4" />
</video>
<p class="video-caption">
{{ L('胶囊体经 ML-Agents 训练,学习保球不掉与越障。', 'Capsules trained via ML-Agents to keep the ball from falling and to navigate obstacles.') }}
</p>
</div>
<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>
@@ -114,36 +82,15 @@ const videoSrc = '/448/ml/449ml.mp4'
.block__text p:last-child {
margin-bottom: 0;
}
.block--two {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-xl);
align-items: start;
}
.video-wrap {
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
background: var(--surface);
box-shadow: var(--shadow-sm);
}
.video-wrap video {
width: 100%;
display: block;
aspect-ratio: 16 / 9;
background: #000;
}
.video-caption {
padding: var(--space-md) var(--space-lg);
font-size: 0.88rem;
color: var(--text-secondary);
margin: 0;
.tab-loading {
padding: var(--space-xl);
text-align: center;
color: var(--text-tertiary);
font-size: 0.9rem;
}
@media (max-width: 768px) {
.block__grid,
.block--two {
.block__grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,43 @@
<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,43 +1,23 @@
<script setup>
import Tag from '../../../components/ui/Tag.vue'
import { useI18n } from 'vue-i18n'
import { useLocale } from '../../../composables/useLocale.js'
import { heroMeta } from '../config.js'
const { locale } = useI18n()
const meta = {
course: { zh: '迈阿密大学 · 毕设 448 / 449', en: 'Miami University · Capstone 448 / 449' },
stack: { zh: 'Unity · C# · WebGL · Android · PHP', en: 'Unity · C# · WebGL · Android · PHP' },
period: { zh: '2022', en: '2022' },
role: { zh: '主开发', en: 'Lead Developer' }
}
const { t } = useLocale()
</script>
<template>
<section class="hero">
<div class="container">
<div class="hero__eyebrow">
<Tag>{{ locale === 'zh-CN' ? meta.course.zh : meta.course.en }}</Tag>
<Tag>{{ t(heroMeta.courseKey) }}</Tag>
</div>
<h1 class="hero__title">
{{ $t('tabs.mobileGame') }}
</h1>
<p class="hero__desc">
{{ locale === 'zh-CN'
? '一个跨学期的 Unity 移动游戏毕设项目448 开发游戏本体WebGL + Android APK449 搭建展示网站与玩家排行榜。经历多版本迭代,从角色控制器演进到陀螺仪操控的 3D 滚球游戏,并尝试用 ML-Agents 做强化学习实验。'
: 'A cross-semester Unity mobile-game capstone: 448 built the game (WebGL + Android APK), 449 built the showcase site and player leaderboard. Iterated across versions, from a character controller to a gyroscope-controlled 3D rolling-ball game, with ML-Agents reinforcement-learning experiments.' }}
</p>
<h1 class="hero__title">{{ t('mobileGame.hero.title') }}</h1>
<p class="hero__desc">{{ t('mobileGame.hero.desc') }}</p>
<div class="hero__meta">
<div class="hero__meta-item">
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '技术栈' : 'Stack' }}</span>
<span>{{ locale === 'zh-CN' ? meta.stack.zh : meta.stack.en }}</span>
</div>
<div class="hero__meta-item">
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '时间' : 'Period' }}</span>
<span>{{ meta.period }}</span>
</div>
<div class="hero__meta-item">
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '角色' : 'Role' }}</span>
<span>{{ locale === 'zh-CN' ? meta.role.zh : meta.role.en }}</span>
<div 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>

View File

@@ -0,0 +1,16 @@
<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,24 +1,24 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useLocale } from '../../../composables/useLocale.js'
import { leaderboard } from '../config.js'
const { locale, t } = useI18n()
const { t } = useLocale()
const rows = ref([])
const state = ref('loading') // loading | ok | error
const errorMsg = ref('')
// 复用 449 现有后端POST /449/449rest.php {num} 取分页 JSON
// 复用 449 现有后端POST endpoint {num} 取分页 JSON
async function load() {
state.value = 'loading'
try {
const all = []
// 拉取 3 页(每页 10 条,共 top30
for (let page = 0; page < 3; page++) {
const res = await fetch('/449/449rest.php', {
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 * 10}`
body: `num=${page * leaderboard.perPage}`
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
@@ -30,12 +30,10 @@ async function load() {
}
rows.value = all
state.value = all.length ? 'ok' : 'error'
if (!all.length) errorMsg.value = locale.value === 'zh-CN' ? '暂无分数记录' : 'No scores yet'
if (!all.length) errorMsg.value = t('mobileGame.leaderboard.noScores')
} catch (e) {
state.value = 'error'
errorMsg.value = locale.value === 'zh-CN'
? '排行榜服务暂不可用'
: 'Leaderboard service unavailable'
errorMsg.value = t('mobileGame.leaderboard.error')
}
}
@@ -45,7 +43,7 @@ onMounted(load)
<template>
<div class="leaderboard">
<div v-if="state === 'loading'" class="leaderboard__state">
{{ $t('common.loading') }}
{{ t('common.loading') }}
</div>
<div v-else-if="state === 'error'" class="leaderboard__state leaderboard__state--error">
@@ -55,9 +53,9 @@ onMounted(load)
<table v-else class="leaderboard__table">
<thead>
<tr>
<th>#</th>
<th>{{ locale === 'zh-CN' ? '玩家' : 'Name' }}</th>
<th>{{ locale === 'zh-CN' ? '分数' : 'Score' }}</th>
<th>{{ t('mobileGame.leaderboard.colRank') }}</th>
<th>{{ t('mobileGame.leaderboard.colName') }}</th>
<th>{{ t('mobileGame.leaderboard.colScore') }}</th>
</tr>
</thead>
<tbody>

View File

@@ -1,14 +1,15 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { screenshots } from '../data/versions.js'
import { useLocale } from '../../../composables/useLocale.js'
import { assets } from '../config.js'
const { locale } = useI18n()
const { t } = useLocale()
const images = assets.screenshots
const current = ref(0)
let timer = null
function go(i) {
current.value = (i + screenshots.length) % screenshots.length
current.value = (i + images.length) % images.length
}
function next() {
go(current.value + 1)
@@ -31,8 +32,8 @@ onUnmounted(pause)
<div class="carousel" @mouseenter="pause" @mouseleave="resume">
<div class="carousel__viewport">
<div class="carousel__track" :style="{ transform: `translateX(-${current * 100}%)` }">
<figure v-for="(s, i) in screenshots" :key="i" class="carousel__slide">
<img :src="s.src" :alt="locale === 'zh-CN' ? s.caption.zh : s.caption.en" loading="lazy" />
<figure 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>
@@ -42,7 +43,7 @@ onUnmounted(pause)
<div class="carousel__dots">
<button
v-for="(s, i) in screenshots"
v-for="(src, i) in images"
:key="i"
class="carousel__dot"
:class="{ 'is-active': i === current }"

View File

@@ -0,0 +1,65 @@
<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,9 +1,5 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { team } from '../data/team.js'
const { locale } = useI18n()
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
</script>
<template>
@@ -12,7 +8,6 @@ const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
<div class="team__avatar">{{ m.name.charAt(0) }}</div>
<div class="team__info">
<div class="team__name">{{ m.name }}</div>
<div class="team__role">{{ L(m.role) }}</div>
</div>
</div>
</div>
@@ -57,8 +52,4 @@ const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
font-size: 0.95rem;
color: var(--text-primary);
}
.team__role {
font-size: 0.82rem;
color: var(--text-tertiary);
}
</style>

View File

@@ -0,0 +1,40 @@
<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,49 +1,99 @@
<script setup>
import { useI18n } from 'vue-i18n'
import { versions } from '../data/versions.js'
import { computed } from 'vue'
import { useLocale } from '../../../composables/useLocale.js'
import { versions, tracks } from '../data/versions.js'
const { locale } = useI18n()
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
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="(v, i) in versions"
:key="v.id"
class="timeline__item"
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__marker">
<span class="timeline__dot"></span>
<span v-if="i < versions.length - 1" class="timeline__line"></span>
<!-- 线标题 -->
<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>
<div class="timeline__body">
<div class="timeline__head">
<span class="timeline__series">{{ v.series }}</span>
<span class="timeline__version">v{{ v.version }}</span>
</div>
<div class="timeline__product">{{ v.product }}</div>
<div class="timeline__row">
<span class="timeline__label">{{ locale === 'zh-CN' ? '操作' : 'Controls' }}</span>
<span>{{ L(v.control) }}</span>
</div>
<div class="timeline__row">
<span class="timeline__label">{{ locale === 'zh-CN' ? '说明' : 'Note' }}</span>
<span>{{ L(v.note) }}</span>
</div>
<div class="timeline__row">
<span class="timeline__label">Unity</span>
<span class="timeline__mono">{{ v.unity }}</span>
</div>
<div class="timeline__links">
<a v-if="v.webgl" :href="v.webgl" target="_blank" rel="noopener">
{{ locale === 'zh-CN' ? 'WebGL 试玩' : 'Play WebGL' }}
</a>
<a v-if="v.apk" :href="v.apk" target="_blank" rel="noopener">
{{ locale === 'zh-CN' ? '下载 APK' : 'Download APK' }}
</a>
</div>
<!-- 形态转变标记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>
@@ -51,42 +101,102 @@ const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
<style scoped>
.timeline {
position: relative;
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-lg);
padding-bottom: var(--space-md);
}
.timeline__marker {
position: relative;
flex: 0 0 16px;
flex: 0 0 14px;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 4px;
padding-top: 6px;
}
.timeline__dot {
width: 12px;
height: 12px;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--bg);
border: 2px solid var(--accent);
flex: 0 0 12px;
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: 24px;
min-height: 20px;
}
.timeline__body {
flex: 1;
padding: var(--space-md) var(--space-lg);
padding: var(--space-sm) var(--space-md);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
transition: box-shadow var(--transition);
}
@@ -97,37 +207,32 @@ const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
display: flex;
align-items: baseline;
gap: var(--space-sm);
margin-bottom: 2px;
}
.timeline__series {
font-weight: 600;
color: var(--text-primary);
font-size: 1.02rem;
margin-bottom: var(--space-xs);
}
.timeline__version {
font-family: var(--font-mono);
font-size: 0.85rem;
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.85rem;
font-size: 0.82rem;
color: var(--text-tertiary);
margin-bottom: var(--space-sm);
}
.timeline__row {
display: flex;
gap: var(--space-sm);
font-size: 0.9rem;
font-size: 0.88rem;
color: var(--text-secondary);
padding: 2px 0;
padding: 1px 0;
}
.timeline__label {
flex: 0 0 48px;
flex: 0 0 52px;
color: var(--text-tertiary);
font-size: 0.82rem;
font-size: 0.8rem;
}
.timeline__mono {
font-family: var(--font-mono);
@@ -135,7 +240,27 @@ const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
.timeline__links {
display: flex;
gap: var(--space-md);
margin-top: var(--space-sm);
font-size: 0.88rem;
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

@@ -0,0 +1,46 @@
// 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 +1,8 @@
// 团队成员(来自 449 展示页)
export const team = [
{ name: 'Zikai Wang', role: { zh: '主开发', en: 'Lead Developer' } },
{ name: 'Hongxiang He', role: { zh: '开发', en: 'Developer' } },
{ name: 'Zhuoyue Sun', role: { zh: '团队', en: 'Team' } },
{ name: 'Xingchen Zhou', role: { zh: '团队', en: 'Team' } },
{ name: 'Yidong Yao', role: { zh: '团队', en: 'Team' } }
{ name: 'Zikai Wang' },
{ name: 'Hongxiang He' },
{ name: 'Zhuoyue Sun' },
{ name: 'Xingchen Zhou' },
{ name: 'Yidong Yao' }
]

View File

@@ -1,98 +1,48 @@
// 448 版本数据(来自对构建产物与 index.html 的分析
export const versions = [
// 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: 'zikai-0.0.0',
series: 'zikai-ui',
version: '0.0.0',
unity: '—',
product: 'My project / 1.0.2',
webgl: '/448/zikai-0.0.0/',
apk: null,
control: { zh: '鼠标左键操作、右键追踪', en: 'Left mouse: operate, Right mouse: track' },
note: { zh: '早期鼠标控制原型', en: 'Early mouse-controlled prototype' }
id: 'roll-a-ball',
form: '3D',
featured: true
},
{
id: 'zikai-0.0.1',
series: 'zikai-ui',
version: '0.0.1',
unity: '2020.3.30f1',
product: 'zikai-ui / 1.0.2',
webgl: '/448/zikai-0.0.1/',
apk: '/448/zikai-0.0.1/zikai.apk',
control: { zh: '生命 Q/W/E/R、能量 A/S/D/F', en: 'Health Q/W/E/R, Power A/S/D/F' },
note: { zh: '引入生命/能量属性系统', en: 'Introduced health/power stat system' }
id: 'zikai-ui',
form: '2D'
},
{
id: 'zikai-0.0.2',
series: 'zikai-ui',
version: '0.0.2',
unity: '2020.3.30f1',
product: 'zikai-ui / 1.0.2',
webgl: '/448/zikai-0.0.2/',
apk: '/448/zikai-0.0.2/zikai.apk',
control: { zh: '同 0.0.1', en: 'Same as 0.0.1' },
note: { zh: '构建产物重命名整理', en: 'Build artifact renaming' }
id: 'main',
form: '2D'
},
{
id: 'zikai-0.0.3',
series: 'zikai-ui',
version: '0.0.3',
unity: '2020.3.30f1',
product: 'zikai-ui / 1.0.2.5',
webgl: '/448/zikai-0.0.3/',
apk: '/448/zikai-0.0.3/zikai.apk',
control: { zh: 'WASD 移动、J 跳跃、K 飞行(测试)', en: 'WASD move, J jump, K fly (testing)' },
note: { zh: '改为 WASD 操作,最新版', en: 'Switched to WASD, latest version' }
},
{
id: 'main-0.0.0',
series: 'main',
version: '0.0.0',
unity: '2020.3.30f1',
product: 'zikai-main / 1.0.2.6',
webgl: '/448/main-0.0.0/',
apk: '/448/main-0.0.0.apk',
control: { zh: '角色控制器', en: 'Character controller' },
note: { zh: '基线版本', en: 'Baseline' }
},
{
id: 'main-0.0.1',
series: 'main',
version: '0.0.1',
unity: '2021.3.12f1',
product: 'beta / 1.0.2',
webgl: '/448/main-0.0.1/',
apk: '/448/main-0.0.1/zikai.apk',
control: { zh: 'WASD 移动、K 跳跃', en: 'WASD move, K jump' },
note: { zh: '升级 Unity新增屏幕按键', en: 'Upgraded Unity, added on-screen buttons' }
},
{
id: 'ball',
series: 'roll-a-ball',
version: '0.0.2',
unity: '2021.3.12f1',
product: '3dRoingBallt2 / 0.0.2',
webgl: '/448/ball/',
apk: '/448/203wangz199.apk',
control: { zh: '手机陀螺仪倾斜移动、上倾/点击跳跃', en: 'Tilt phone to move, tilt up/tap to jump' },
note: { zh: '最终版本3D 滚球', en: 'Final version, 3D rolling ball' }
},
{
id: 'hongXiang-0.0.0',
series: 'doby',
version: '0.0.0',
unity: '2021.2.10f1',
product: 'DobyAdventure',
webgl: null,
apk: '/448/hongXiang-0.0.0/2D game.apk',
control: { zh: 'A/D 移动、跳跃、吃金币', en: 'A/D move, jump, collect coins' },
note: { zh: '2D 平台跳跃Hongxiang He', en: '2D platformer (Hongxiang He)' }
id: 'doby',
form: '2D'
}
]
// 截图(复用 448 现有图片)
export const screenshots = [
{ src: '/448/1.jpg', caption: { zh: '项目截图 1', en: 'Screenshot 1' } },
{ src: '/448/2.jpg', caption: { zh: '项目截图 2', en: 'Screenshot 2' } },
{ src: '/448/3.jpg', caption: { zh: '项目截图 3', en: 'Screenshot 3' } }
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

@@ -0,0 +1,152 @@
<script setup>
import { ref, computed } from 'vue'
import { appConfig } from '../../config/app.config.js'
import { useLocale } from '../../composables/useLocale.js'
const { t } = useLocale()
// 完整白板页 URL公开可被 iframe 嵌入)
const wbConfig = appConfig.whiteboard
const boardSrc = computed(
() => `${wbConfig.baseUrl}${wbConfig.pagePath(wbConfig.defaultBoardId)}`
)
const loaded = ref(false)
const failed = ref(false)
function onLoad() {
loaded.value = true
}
// iframe 加载失败(跨域无法读 error靠超时兜底
function scheduleFailCheck() {
setTimeout(() => {
if (!loaded.value) failed.value = true
}, 6000)
}
scheduleFailCheck()
// 外链打开(降级时提供)
function openExternal() {
window.open(boardSrc.value, '_blank', 'noopener')
}
</script>
<template>
<div class="wb-page">
<div class="wb-page__inner">
<h1 class="wb-page__title">{{ t('tabs.whiteboard') }}</h1>
<!-- 降级iframe 长时间未加载完成 -->
<div v-if="failed" class="wb-page__fallback">
<p class="wb-page__fallback-text">{{ t('common.serviceUnavailable') }}</p>
<button type="button" class="wb-page__open" @click="openExternal">
{{ t('whiteboard.openExternal') }}
</button>
</div>
<div v-else class="wb-page__frame">
<!-- 加载骨架iframe 加载完成后淡出 -->
<Transition name="fade">
<div v-if="!loaded" class="wb-page__skeleton">
<div class="wb-page__skeleton-text">{{ t('common.loading') }}</div>
</div>
</Transition>
<iframe
:src="boardSrc"
class="wb-page__iframe"
frameborder="0"
:title="t('tabs.whiteboard')"
@load="onLoad"
/>
</div>
</div>
</div>
</template>
<style scoped>
.wb-page {
padding: var(--space-xl) 0;
}
/* 全宽容器(不受全局 .container 的 1080px 限制),仅留左右内边距 */
.wb-page__inner {
width: 100%;
max-width: 100%;
margin: 0 auto;
padding: 0 var(--space-lg);
box-sizing: border-box;
}
.wb-page__title {
font-size: 1.6rem;
margin-bottom: var(--space-lg);
}
.wb-page__frame {
position: relative;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
width: 100%;
/* 记事本以编辑为主,给足高度 */
height: 70vh;
min-height: 480px;
}
.wb-page__iframe {
width: 100%;
height: 100%;
display: block;
border: 0;
}
.wb-page__skeleton {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
}
.wb-page__skeleton-text {
color: var(--text-tertiary);
font-size: 0.95rem;
}
.wb-page__fallback {
text-align: center;
padding: var(--space-2xl) var(--space-lg);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
background: var(--surface);
}
.wb-page__fallback-text {
color: var(--text-secondary);
margin-bottom: var(--space-md);
}
.wb-page__open {
display: inline-flex;
align-items: center;
padding: 8px 18px;
font-size: 0.9rem;
font-weight: 500;
color: #fff;
background: var(--accent);
border-radius: var(--radius-sm);
transition: background var(--transition);
}
.wb-page__open:hover {
background: var(--accent-hover);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.wb-page__frame {
height: 65vh;
min-height: 360px;
}
}
</style>

View File

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

View File

@@ -2,18 +2,29 @@
import { createRouter, createWebHistory } from 'vue-router'
import modules from '../modules/index.js'
const moduleRoutes = modules
.map((m) => {
try {
return {
path: `/${m.id}`,
name: m.id,
component: m.component,
meta: { moduleId: m.id }
}
} catch (e) {
console.warn(`[router] 模块 ${m?.id} 路由生成失败,已跳过:`, e)
return null
}
})
.filter(Boolean)
const routes = [
{
path: '/',
redirect: modules.length ? `/${modules[0].id}` : '/404'
redirect: moduleRoutes.length ? moduleRoutes[0].path : '/404'
},
// 每个模块一条路由,组件懒加载
...modules.map((m) => ({
path: `/${m.id}`,
name: m.id,
component: m.component,
meta: { moduleId: m.id }
})),
...moduleRoutes,
{
path: '/:pathMatch(.*)*',
name: 'not-found',