feat: init mainPage portfolio site
Vue 3 模块化作品集主页,浅色极简风格,中英双语懒加载 i18n。 - 模块注册表(import.meta.glob)约定式收集项目模块,新增页签无需改导航/路由 - i18n: 中文同步注入主 chunk,英文动态 import() 拆独立 chunk,仅切换时下载 - 第一个页签 mobile-game: 展示 448/449 移动游戏毕设(概览/截图轮播/版本时间线/团队/排行榜/ML视频) - 排行榜复用 /449/449rest.php,失败优雅降级 - 构建: vite outDir=/root/html,emptyOutDir=false,产物用 mainPage/ 命名空间 - 样式: CSS 变量令牌,无 UI 框架运行时依赖
This commit is contained in:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# 依赖
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# 构建产物(输出到 /root/html,不入库)
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# 编辑器/系统
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# 本地环境
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
188
README.md
Normal file
188
README.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# mainPage · 作品集主页
|
||||||
|
|
||||||
|
Zikai 的作品集主页 -- 一个 **Vue 3 模块化**的多项目展示站点。
|
||||||
|
采用**浅色极简**美术风格,**中英双语**并支持**懒加载**(默认中文,只看中文的访客不会下载英文资源)。
|
||||||
|
设计目标:高内聚低耦合、可扩展 -- 后续每个新项目都是一个独立「模块」,自动生成页签与路由。
|
||||||
|
|
||||||
|
> 这是「主页」项目本身。它展示的第一个项目页签是 448/449 移动游戏毕设,
|
||||||
|
> 详见同级目录下的 `/root/html/448/README.md` 与 `/root/html/449/README.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层 | 选型 |
|
||||||
|
|---|---|
|
||||||
|
| 框架 | Vue 3(`<script setup>` Composition API) |
|
||||||
|
| 构建 | Vite 5 |
|
||||||
|
| 路由 | vue-router 4(history 模式,路由表由模块注册表动态生成) |
|
||||||
|
| 国际化 | vue-i18n 9(中文同步注入,英文动态 `import()` 懒加载) |
|
||||||
|
| 样式 | 原生 CSS + CSS 变量令牌(无 UI 框架,零运行时样式依赖) |
|
||||||
|
|
||||||
|
Node 版本要求:≥ 18(本项目在 Node 18.19 + npm 9.2 下开发与构建)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
mainPage/
|
||||||
|
├── index.html # Vite 入口模板
|
||||||
|
├── package.json
|
||||||
|
├── vite.config.js # ★ 构建输出配置(见下「构建与部署」)
|
||||||
|
├── .gitignore
|
||||||
|
├── README.md # 本文件
|
||||||
|
├── public/
|
||||||
|
│ └── favicon.svg # 站点图标
|
||||||
|
└── src/
|
||||||
|
├── main.js # 应用入口(挂载 i18n、router、全局样式)
|
||||||
|
├── App.vue # 根布局:顶栏 + 标签导航 + <router-view> + 页脚
|
||||||
|
│
|
||||||
|
├── i18n/ # 国际化(懒加载核心)
|
||||||
|
│ ├── index.js # i18n 实例;仅同步注入中文;loadLocaleAsync() 动态切语言
|
||||||
|
│ └── locales/
|
||||||
|
│ ├── zh-CN.js # 中文(默认,打进主 chunk)
|
||||||
|
│ └── en.js # 英文(动态 import() -> 独立 chunk,切换时才下载)
|
||||||
|
│
|
||||||
|
├── router/
|
||||||
|
│ └── index.js # 路由表遍历模块注册表自动生成
|
||||||
|
│
|
||||||
|
├── modules/ # ★★★ 扩展点:每个项目一个模块文件夹
|
||||||
|
│ ├── index.js # 模块注册表(import.meta.glob 约定式收集)
|
||||||
|
│ └── mobile-game/ # 第一个页签:448/449 移动游戏项目
|
||||||
|
│ ├── index.js # 模块元数据(id / tabKey / order / 懒加载组件)
|
||||||
|
│ ├── MobileGame.vue # 项目展示主页
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── HeroSection.vue # 顶部项目概览横幅
|
||||||
|
│ │ ├── ScreenshotCarousel.vue# 截图轮播(复用 /448/1..3.jpg)
|
||||||
|
│ │ ├── VersionTimeline.vue # 版本演进时间线
|
||||||
|
│ │ ├── TeamCard.vue # 团队成员卡片
|
||||||
|
│ │ └── Leaderboard.vue # 排行榜(AJAX 调 /449/449rest.php,失败降级)
|
||||||
|
│ └── data/
|
||||||
|
│ ├── versions.js # 版本数据(448 各版本信息)
|
||||||
|
│ └── team.js # 团队成员数据
|
||||||
|
│
|
||||||
|
├── components/ # 全局共享组件
|
||||||
|
│ ├── TabNav.vue # 标签页导航(由模块注册表驱动,自动生成页签)
|
||||||
|
│ ├── LanguageSwitcher.vue # 中/英切换按钮
|
||||||
|
│ └── ui/ # 基础 UI 基元
|
||||||
|
│ ├── Card.vue
|
||||||
|
│ └── Tag.vue
|
||||||
|
│
|
||||||
|
├── composables/ # 组合式逻辑
|
||||||
|
│ ├── useProjects.js # 读取已注册模块列表
|
||||||
|
│ └── useI18nLazy.js # 语言懒加载切换逻辑
|
||||||
|
│
|
||||||
|
└── styles/
|
||||||
|
├── variables.css # 主题令牌(浅色极简:色板/圆角/阴影/间距)
|
||||||
|
└── base.css # 重置 + 排版基础
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构要点
|
||||||
|
|
||||||
|
### 1. 模块化扩展(高内聚低耦合)
|
||||||
|
|
||||||
|
**新增一个项目页签 = 新建 `src/modules/<新项目>/` 文件夹**,无需改动导航或路由代码。
|
||||||
|
|
||||||
|
- `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') // 懒加载组件
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `TabNav.vue` 遍历注册表渲染页签;`router/index.js` 遍历注册表生成路由。
|
||||||
|
- 模块自包含组件、数据,模块间无直接引用 -> 高内聚低耦合。
|
||||||
|
|
||||||
|
### 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)独立存在。
|
||||||
|
|
||||||
|
### 3. 浅色极简美术
|
||||||
|
|
||||||
|
主题令牌集中在 `src/styles/variables.css`:白底 `#fff` / 浅灰面 `#f7f8fa`、单一靛蓝强调色 `#4f46e5`、圆角 12px、柔和阴影、细线边框、充足留白。无深色、无渐变铺底、无霓虹;悬浮微交互克制(轻微上移 + 阴影加深)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /root/zikai/mainPage
|
||||||
|
npm install # 首次安装依赖
|
||||||
|
npm run dev # 启动开发服务器 (http://localhost:5173)
|
||||||
|
```
|
||||||
|
|
||||||
|
开发服务器配置了代理:`/448`、`/449` 请求转发到 `http://localhost:8080`
|
||||||
|
(假设该端口有静态服务器在跑,否则截图/排行榜/视频会 404,但页面其余部分正常)。
|
||||||
|
开发时可临时用 `python3 -m http.server 8080 --directory /root/html` 提供这些静态资源。
|
||||||
|
|
||||||
|
## 构建与打包
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build # 产物输出到 /root/html
|
||||||
|
```
|
||||||
|
|
||||||
|
### 输出路径与安全策略(`vite.config.js`)
|
||||||
|
|
||||||
|
- `build.outDir = '/root/html'`,**`emptyOutDir = false`**(★ 不清空 outDir)。
|
||||||
|
`/root/html` 已含 448/449/旧 js/css 等既有内容,绝不能被构建清空。
|
||||||
|
- 用 `mainPage/` 命名空间子目录避免与 `/root/html/js`、`/root/html/css` 旧文件冲突:
|
||||||
|
- JS -> `js/mainPage/[name].[hash].js`
|
||||||
|
- CSS -> `css/mainPage/[name].[hash].css`
|
||||||
|
- 其他资源 -> `assets/mainPage/[name].[hash][extname]`
|
||||||
|
- `base = '/'`,引用既有 448/449 资源用站点根绝对路径(`/448/...`、`/449/...`)。
|
||||||
|
|
||||||
|
### 构建产物示例
|
||||||
|
|
||||||
|
```
|
||||||
|
/root/html/
|
||||||
|
├── index.html # 入口(Vite 注入 <script>/<link>)
|
||||||
|
├── favicon.svg
|
||||||
|
├── js/mainPage/
|
||||||
|
│ ├── index.[hash].js # 主 chunk(含 Vue/router/中文 i18n)
|
||||||
|
│ ├── MobileGame.[hash].js # 移动游戏页(懒加载)
|
||||||
|
│ └── en.[hash].js # 英文语言包(懒加载,仅切英文时下载)
|
||||||
|
└── css/mainPage/
|
||||||
|
├── index.[hash].css # 全局样式
|
||||||
|
└── MobileGame.[hash].css # 移动游戏页样式
|
||||||
|
```
|
||||||
|
|
||||||
|
## 预览构建产物
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview # 本地预览构建后的产物
|
||||||
|
```
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
构建产物直接落在站点根 `/root/html`,由 Web 服务器(如 nginx)以 `/root/html` 为根提供静态服务即可。
|
||||||
|
注意:当前主机上 nginx 默认 root 为 `/var/www/html`(与 `/root/html` 内容同步);
|
||||||
|
若需以 `/root/html` 为根对外服务,请调整 nginx 的 `root` 指令并启用 nginx。
|
||||||
|
主页引用的 `/448/...`、`/449/...` 资源必须能从站点根访问到(448/449 目录本就在 `/root/html` 下)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 如何新增一个项目页签
|
||||||
|
|
||||||
|
1. 复制 `src/modules/mobile-game/` 为 `src/modules/<新项目>/`。
|
||||||
|
2. 编辑 `<新项目>/index.js`,改 `id`、`tabKey`、`order`、`component` 指向。
|
||||||
|
3. 在 `src/i18n/locales/zh-CN.js` 与 `en.js` 的 `tabs` 下新增对应的 `tabKey` 文案。
|
||||||
|
4. 实现模块主组件与子组件(可复用 `components/ui/` 基元)。
|
||||||
|
5. `npm run build` -- 页签与路由自动出现,无需改动 `TabNav` 或 `router`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
- 排行榜依赖 `/449/449rest.php`(PHP + MySQL)在线;后端不可用时组件优雅降级显示「服务暂不可用」。
|
||||||
|
- 演示视频 `/448/ml/449ml.mp4`(约 34 MB)用 `preload="none"`,点播才加载。
|
||||||
|
- 448/449 的 README 总结了既有项目(构建产物,无源码),见各自目录。
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1212
package-lock.json
generated
Normal file
1212
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "mainpage",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Zikai 作品集主页 -- Vue3 模块化多项目展示站,中英双语懒加载,浅色极简风格",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"vue-i18n": "^9.13.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"vite": "^5.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
public/favicon.svg
Normal file
6
public/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="14" fill="#ffffff"/>
|
||||||
|
<rect x="1" y="1" width="62" height="62" rx="13" fill="none" stroke="#eceef1" stroke-width="2"/>
|
||||||
|
<path d="M20 44 L32 18 L44 44" fill="none" stroke="#4f46e5" stroke-width="4.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<line x1="25" y1="36" x2="39" y2="36" stroke="#4f46e5" stroke-width="4.5" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 454 B |
113
src/App.vue
Normal file
113
src/App.vue
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script setup>
|
||||||
|
import TabNav from './components/TabNav.vue'
|
||||||
|
import LanguageSwitcher from './components/LanguageSwitcher.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="container topbar__inner">
|
||||||
|
<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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<TabNav />
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<RouterView v-slot="{ Component }">
|
||||||
|
<Transition name="fade" mode="out-in">
|
||||||
|
<component :is="Component" />
|
||||||
|
</Transition>
|
||||||
|
</RouterView>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container footer__inner">
|
||||||
|
<span>© {{ new Date().getFullYear() }} Zikai Wang</span>
|
||||||
|
<span class="footer__dot">·</span>
|
||||||
|
<span>Built with Vue 3</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.topbar__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.brand__mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: var(--space-lg) 0;
|
||||||
|
margin-top: var(--space-2xl);
|
||||||
|
}
|
||||||
|
.footer__inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.footer__dot {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
58
src/components/LanguageSwitcher.vue
Normal file
58
src/components/LanguageSwitcher.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18nLazy } from '../composables/useI18nLazy.js'
|
||||||
|
|
||||||
|
const { current, switching, switchLocale } = useI18nLazy()
|
||||||
|
|
||||||
|
const locales = [
|
||||||
|
{ code: 'zh-CN', label: '中' },
|
||||||
|
{ code: 'en', label: 'EN' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="lang-switch" role="group" aria-label="language">
|
||||||
|
<button
|
||||||
|
v-for="l in locales"
|
||||||
|
:key="l.code"
|
||||||
|
type="button"
|
||||||
|
class="lang-switch__btn"
|
||||||
|
:class="{ 'is-active': current === l.code }"
|
||||||
|
:disabled="switching"
|
||||||
|
@click="switchLocale(l.code)"
|
||||||
|
>
|
||||||
|
{{ l.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.lang-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.lang-switch__btn {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.lang-switch__btn + .lang-switch__btn {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.lang-switch__btn:hover:not(:disabled):not(.is-active) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
.lang-switch__btn.is-active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.lang-switch__btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
66
src/components/TabNav.vue
Normal file
66
src/components/TabNav.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup>
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { useProjects } from '../composables/useProjects.js'
|
||||||
|
|
||||||
|
const { projects } = useProjects()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<nav class="tab-nav">
|
||||||
|
<div class="container tab-nav__inner">
|
||||||
|
<RouterLink
|
||||||
|
v-for="p in projects"
|
||||||
|
:key="p.id"
|
||||||
|
:to="`/${p.id}`"
|
||||||
|
class="tab-nav__item"
|
||||||
|
active-class="is-active"
|
||||||
|
>
|
||||||
|
{{ $t(p.tabKey) }}
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab-nav {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.tab-nav__inner {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.tab-nav__inner::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tab-nav__item {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-md) var(--space-md);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
.tab-nav__item:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.tab-nav__item.is-active {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.tab-nav__item.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>
|
||||||
28
src/components/ui/Card.vue
Normal file
28
src/components/ui/Card.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<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>
|
||||||
25
src/components/ui/Tag.vue
Normal file
25
src/components/ui/Tag.vue
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<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>
|
||||||
21
src/composables/useI18nLazy.js
Normal file
21
src/composables/useI18nLazy.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { loadLocaleAsync } from '../i18n/index.js'
|
||||||
|
|
||||||
|
const current = ref('zh-CN')
|
||||||
|
const switching = ref(false)
|
||||||
|
|
||||||
|
// 语言懒加载切换逻辑
|
||||||
|
export function useI18nLazy() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { current, switching, switchLocale }
|
||||||
|
}
|
||||||
8
src/composables/useProjects.js
Normal file
8
src/composables/useProjects.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import modules from '../modules/index.js'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// 读取已注册模块列表,供 TabNav 渲染页签
|
||||||
|
export function useProjects() {
|
||||||
|
const projects = ref(modules)
|
||||||
|
return { projects }
|
||||||
|
}
|
||||||
49
src/i18n/index.js
Normal file
49
src/i18n/index.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from './locales/zh-CN.js'
|
||||||
|
|
||||||
|
// ★ 懒加载设计:创建实例时只同步注入中文(默认语言)。
|
||||||
|
// 英文 (en.js) 通过 useI18nLazy 中的动态 import() 按需加载,
|
||||||
|
// 被 Vite/Rollup 拆成独立 chunk,只看中文的访客永不下载英文资源。
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'zh-CN',
|
||||||
|
fallbackLocale: 'zh-CN',
|
||||||
|
messages: {
|
||||||
|
'zh-CN': zhCN
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 已加载语言集合,避免重复 import
|
||||||
|
const loadedLocales = new Set(['zh-CN'])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 懒加载并切换到目标语言。
|
||||||
|
* 首次切换某语言时才动态 import 其语言包并 mergeLocaleMessage,
|
||||||
|
* 之后切换仅在已加载集合内完成(无网络请求)。
|
||||||
|
*/
|
||||||
|
export async function loadLocaleAsync(locale) {
|
||||||
|
if (i18n.locale.value === locale) return
|
||||||
|
if (loadedLocales.has(locale)) {
|
||||||
|
i18n.locale.value = locale
|
||||||
|
document.documentElement.setAttribute('lang', locale)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages
|
||||||
|
if (locale === 'en') {
|
||||||
|
// 动态 import -> 独立 chunk,切换时才下载
|
||||||
|
messages = (await import('./locales/en.js')).default
|
||||||
|
} else {
|
||||||
|
// 未知语言回退到中文
|
||||||
|
i18n.locale.value = 'zh-CN'
|
||||||
|
document.documentElement.setAttribute('lang', 'zh-CN')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
i18n.mergeLocaleMessage(locale, messages)
|
||||||
|
loadedLocales.add(locale)
|
||||||
|
i18n.locale.value = locale
|
||||||
|
document.documentElement.setAttribute('lang', locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n
|
||||||
21
src/i18n/locales/en.js
Normal file
21
src/i18n/locales/en.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// 英文语言包 -- 通过动态 import() 加载,被打成独立 chunk,
|
||||||
|
// 只看中文的访客不会下载此文件。
|
||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
title: 'Zikai',
|
||||||
|
subtitle: 'Portfolio',
|
||||||
|
nav: {
|
||||||
|
projects: 'Projects',
|
||||||
|
language: 'Language'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
loading: 'Loading…',
|
||||||
|
comingSoon: 'Coming soon',
|
||||||
|
notAvailable: 'Not available',
|
||||||
|
backToTop: 'Back to top'
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
mobileGame: 'Mobile Game Project'
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/i18n/locales/zh-CN.js
Normal file
20
src/i18n/locales/zh-CN.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// 中文语言包(默认语言,同步注入主 chunk,随首屏下发)
|
||||||
|
export default {
|
||||||
|
app: {
|
||||||
|
title: 'Zikai',
|
||||||
|
subtitle: '作品集',
|
||||||
|
nav: {
|
||||||
|
projects: '项目',
|
||||||
|
language: '语言'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
common: {
|
||||||
|
loading: '加载中…',
|
||||||
|
comingSoon: '即将上线',
|
||||||
|
notAvailable: '暂不可用',
|
||||||
|
backToTop: '回到顶部'
|
||||||
|
},
|
||||||
|
tabs: {
|
||||||
|
mobileGame: '移动游戏项目'
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/main.js
Normal file
11
src/main.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router/index.js'
|
||||||
|
import i18n from './i18n/index.js'
|
||||||
|
import './styles/variables.css'
|
||||||
|
import './styles/base.css'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(i18n)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
21
src/modules/index.js
Normal file
21
src/modules/index.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// ★ 模块注册表 -- 扩展点核心
|
||||||
|
// 用 import.meta.glob 约定式收集每个子模块的 index.js,
|
||||||
|
// 自动生成统一的项目列表。新增项目页签 = 新建 modules/<新项目>/ 文件夹,
|
||||||
|
// 无需改动导航/路由代码。
|
||||||
|
//
|
||||||
|
// 每个模块 index.js 默认导出形如:
|
||||||
|
// {
|
||||||
|
// id: 'mobile-game', // 唯一标识,同时用作路由 path
|
||||||
|
// tabKey: 'tabs.mobileGame', // i18n 键,用于页签标题
|
||||||
|
// order: 1, // 页签排序
|
||||||
|
// component: () => import('./MobileGame.vue') // 懒加载组件
|
||||||
|
// }
|
||||||
|
|
||||||
|
const moduleFiles = import.meta.glob('./*/index.js', { eager: true })
|
||||||
|
|
||||||
|
const modules = Object.values(moduleFiles)
|
||||||
|
.map((mod) => mod.default)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => (a.order ?? 99) - (b.order ?? 99))
|
||||||
|
|
||||||
|
export default modules
|
||||||
150
src/modules/mobile-game/MobileGame.vue
Normal file
150
src/modules/mobile-game/MobileGame.vue
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import HeroSection from './components/HeroSection.vue'
|
||||||
|
import ScreenshotCarousel from './components/ScreenshotCarousel.vue'
|
||||||
|
import VersionTimeline from './components/VersionTimeline.vue'
|
||||||
|
import TeamCard from './components/TeamCard.vue'
|
||||||
|
import Leaderboard from './components/Leaderboard.vue'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const L = (zh, en) => (locale.value === 'zh-CN' ? zh : en)
|
||||||
|
// 站点根绝对路径,用绑定避免被 Vite 当作构建期静态资源解析
|
||||||
|
const videoPoster = '/448/2.jpg'
|
||||||
|
const videoSrc = '/448/ml/449ml.mp4'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mobile-game">
|
||||||
|
<HeroSection />
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<!-- 概览 + 截图轮播 -->
|
||||||
|
<section class="block">
|
||||||
|
<div class="block__grid">
|
||||||
|
<div class="block__text">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<small>{{ L('项目概览', 'Overview') }}</small>
|
||||||
|
{{ L('从角色控制器到陀螺仪滚球', 'From character controller to gyro ball') }}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{{ L(
|
||||||
|
'项目跨越 448 与 449 两门课程。448 用 Unity 开发游戏本体,产出可浏览器试玩的 WebGL 构建与可安装的 Android APK;449 搭建展示网站与基于 PHP+MySQL 的玩家分数排行榜。主要贡献者 Zikai Wang(zikai-ui / main / Roll-a-Ball 系列)与 Hongxiang He(2D DobyAdventure)。',
|
||||||
|
'The project spans courses 448 and 449. 448 built the game with Unity, producing browser-playable WebGL builds and installable Android APKs; 449 built the showcase site and a PHP+MySQL player leaderboard. Main contributors: Zikai Wang (zikai-ui / main / Roll-a-Ball series) and Hongxiang He (2D DobyAdventure).'
|
||||||
|
) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ L(
|
||||||
|
'玩法经过多版本迭代:早期是键盘控制生命/能量的角色控制器,最终版改为手机陀螺仪倾斜操控的 3D 滚球,含雪花砖掉落、黄色砖弹跳等机制。还用 Unity ML-Agents 做了强化学习实验。',
|
||||||
|
'Gameplay iterated across versions: early builds were keyboard-controlled character controllers with health/power stats; the final version became a gyroscope-tilt 3D rolling-ball game with falling snowflake bricks and bouncing yellow bricks. ML-Agents reinforcement-learning experiments were also conducted.'
|
||||||
|
) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="block__media">
|
||||||
|
<ScreenshotCarousel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 版本时间线 -->
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<small>{{ L('版本演进', 'Version History') }}</small>
|
||||||
|
{{ L('迭代记录', 'Iteration Record') }}
|
||||||
|
</h2>
|
||||||
|
<VersionTimeline />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 双列:团队 + 排行榜 -->
|
||||||
|
<section class="block block--two">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">
|
||||||
|
<small>{{ L('团队', 'Team') }}</small>
|
||||||
|
{{ L('成员', 'Members') }}
|
||||||
|
</h2>
|
||||||
|
<TeamCard />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">
|
||||||
|
<small>{{ L('排行榜', 'Leaderboard') }}</small>
|
||||||
|
{{ L('玩家分数 Top 30', 'Top 30 Scores') }}
|
||||||
|
</h2>
|
||||||
|
<Leaderboard />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ML-Agents 演示视频 -->
|
||||||
|
<section class="block">
|
||||||
|
<h2 class="section-title">
|
||||||
|
<small>{{ L('实验', 'Experiment') }}</small>
|
||||||
|
{{ L('ML-Agents 强化学习演示', 'ML-Agents Reinforcement Learning Demo') }}
|
||||||
|
</h2>
|
||||||
|
<div class="video-wrap">
|
||||||
|
<video controls preload="none" :poster="videoPoster">
|
||||||
|
<source :src="videoSrc" type="video/mp4" />
|
||||||
|
</video>
|
||||||
|
<p class="video-caption">
|
||||||
|
{{ L('胶囊体经 ML-Agents 训练,学习保球不掉与越障。', 'Capsules trained via ML-Agents to keep the ball from falling and to navigate obstacles.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mobile-game {
|
||||||
|
padding-bottom: var(--space-2xl);
|
||||||
|
}
|
||||||
|
.block {
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.block:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.block__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.block__text p {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
.block__text p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.block--two {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrap {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.video-wrap video {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.video-caption {
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.block__grid,
|
||||||
|
.block--two {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
84
src/modules/mobile-game/components/HeroSection.vue
Normal file
84
src/modules/mobile-game/components/HeroSection.vue
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup>
|
||||||
|
import Tag from '../../../components/ui/Tag.vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
course: { zh: '迈阿密大学 · 毕设 448 / 449', en: 'Miami University · Capstone 448 / 449' },
|
||||||
|
stack: { zh: 'Unity · C# · WebGL · Android · PHP', en: 'Unity · C# · WebGL · Android · PHP' },
|
||||||
|
period: { zh: '2022', en: '2022' },
|
||||||
|
role: { zh: '主开发', en: 'Lead Developer' }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="hero">
|
||||||
|
<div class="container">
|
||||||
|
<div class="hero__eyebrow">
|
||||||
|
<Tag>{{ locale === 'zh-CN' ? meta.course.zh : meta.course.en }}</Tag>
|
||||||
|
</div>
|
||||||
|
<h1 class="hero__title">
|
||||||
|
{{ $t('tabs.mobileGame') }}
|
||||||
|
</h1>
|
||||||
|
<p class="hero__desc">
|
||||||
|
{{ locale === 'zh-CN'
|
||||||
|
? '一个跨学期的 Unity 移动游戏毕设项目:448 开发游戏本体(WebGL + Android APK),449 搭建展示网站与玩家排行榜。经历多版本迭代,从角色控制器演进到陀螺仪操控的 3D 滚球游戏,并尝试用 ML-Agents 做强化学习实验。'
|
||||||
|
: 'A cross-semester Unity mobile-game capstone: 448 built the game (WebGL + Android APK), 449 built the showcase site and player leaderboard. Iterated across versions, from a character controller to a gyroscope-controlled 3D rolling-ball game, with ML-Agents reinforcement-learning experiments.' }}
|
||||||
|
</p>
|
||||||
|
<div class="hero__meta">
|
||||||
|
<div class="hero__meta-item">
|
||||||
|
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '技术栈' : 'Stack' }}</span>
|
||||||
|
<span>{{ locale === 'zh-CN' ? meta.stack.zh : meta.stack.en }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero__meta-item">
|
||||||
|
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '时间' : 'Period' }}</span>
|
||||||
|
<span>{{ meta.period }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="hero__meta-item">
|
||||||
|
<span class="hero__meta-label">{{ locale === 'zh-CN' ? '角色' : 'Role' }}</span>
|
||||||
|
<span>{{ locale === 'zh-CN' ? meta.role.zh : meta.role.en }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hero {
|
||||||
|
padding: var(--space-2xl) 0 var(--space-xl);
|
||||||
|
background: linear-gradient(180deg, var(--surface) 0%, var(--bg) 100%);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.hero__eyebrow {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
.hero__title {
|
||||||
|
font-size: 2.3rem;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
.hero__desc {
|
||||||
|
max-width: 720px;
|
||||||
|
font-size: 1.02rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
.hero__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
}
|
||||||
|
.hero__meta-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.hero__meta-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
src/modules/mobile-game/components/Leaderboard.vue
Normal file
130
src/modules/mobile-game/components/Leaderboard.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const { locale, t } = useI18n()
|
||||||
|
|
||||||
|
const rows = ref([])
|
||||||
|
const state = ref('loading') // loading | ok | error
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
// 复用 449 现有后端:POST /449/449rest.php {num} 取分页 JSON
|
||||||
|
async function load() {
|
||||||
|
state.value = 'loading'
|
||||||
|
try {
|
||||||
|
const all = []
|
||||||
|
// 拉取 3 页(每页 10 条,共 top30)
|
||||||
|
for (let page = 0; page < 3; page++) {
|
||||||
|
const res = await fetch('/449/449rest.php', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: `num=${page * 10}`
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
const data = await res.json()
|
||||||
|
// data 形如 { "1": ["name", score], ... }
|
||||||
|
Object.keys(data).forEach((k) => {
|
||||||
|
const entry = data[k]
|
||||||
|
all.push({ rank: all.length + 1, name: entry[0], score: entry[1] })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
rows.value = all
|
||||||
|
state.value = all.length ? 'ok' : 'error'
|
||||||
|
if (!all.length) errorMsg.value = locale.value === 'zh-CN' ? '暂无分数记录' : 'No scores yet'
|
||||||
|
} catch (e) {
|
||||||
|
state.value = 'error'
|
||||||
|
errorMsg.value = locale.value === 'zh-CN'
|
||||||
|
? '排行榜服务暂不可用'
|
||||||
|
: 'Leaderboard service unavailable'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="leaderboard">
|
||||||
|
<div v-if="state === 'loading'" class="leaderboard__state">
|
||||||
|
{{ $t('common.loading') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="state === 'error'" class="leaderboard__state leaderboard__state--error">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-else class="leaderboard__table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>{{ locale === 'zh-CN' ? '玩家' : 'Name' }}</th>
|
||||||
|
<th>{{ locale === 'zh-CN' ? '分数' : 'Score' }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in rows" :key="r.rank" :class="{ 'is-top': r.rank <= 3 }">
|
||||||
|
<td class="leaderboard__rank">{{ r.rank }}</td>
|
||||||
|
<td class="leaderboard__name">{{ r.name }}</td>
|
||||||
|
<td class="leaderboard__score">{{ r.score }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.leaderboard {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
.leaderboard__state {
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.leaderboard__state--error {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.leaderboard__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.leaderboard__table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.leaderboard__table td {
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.leaderboard__table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.leaderboard__table tbody tr:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.leaderboard__rank {
|
||||||
|
width: 50px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
tr.is-top .leaderboard__rank {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.leaderboard__score {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
130
src/modules/mobile-game/components/ScreenshotCarousel.vue
Normal file
130
src/modules/mobile-game/components/ScreenshotCarousel.vue
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { screenshots } from '../data/versions.js'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const current = ref(0)
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
function go(i) {
|
||||||
|
current.value = (i + screenshots.length) % screenshots.length
|
||||||
|
}
|
||||||
|
function next() {
|
||||||
|
go(current.value + 1)
|
||||||
|
}
|
||||||
|
function pause() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function resume() {
|
||||||
|
if (!timer) timer = setInterval(next, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(resume)
|
||||||
|
onUnmounted(pause)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="carousel" @mouseenter="pause" @mouseleave="resume">
|
||||||
|
<div class="carousel__viewport">
|
||||||
|
<div class="carousel__track" :style="{ transform: `translateX(-${current * 100}%)` }">
|
||||||
|
<figure v-for="(s, i) in screenshots" :key="i" class="carousel__slide">
|
||||||
|
<img :src="s.src" :alt="locale === 'zh-CN' ? s.caption.zh : s.caption.en" loading="lazy" />
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="carousel__btn carousel__btn--prev" @click="go(current - 1)" aria-label="prev">‹</button>
|
||||||
|
<button class="carousel__btn carousel__btn--next" @click="go(current + 1)" aria-label="next">›</button>
|
||||||
|
|
||||||
|
<div class="carousel__dots">
|
||||||
|
<button
|
||||||
|
v-for="(s, i) in screenshots"
|
||||||
|
:key="i"
|
||||||
|
class="carousel__dot"
|
||||||
|
:class="{ 'is-active': i === current }"
|
||||||
|
@click="go(i)"
|
||||||
|
:aria-label="`slide ${i + 1}`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.carousel {
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.carousel__viewport {
|
||||||
|
overflow: hidden;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
}
|
||||||
|
.carousel__track {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
transition: transform 0.5s ease;
|
||||||
|
}
|
||||||
|
.carousel__slide {
|
||||||
|
flex: 0 0 100%;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.carousel__slide img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.carousel__btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.carousel__btn:hover {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.carousel__btn--prev { left: 12px; }
|
||||||
|
.carousel__btn--next { right: 12px; }
|
||||||
|
|
||||||
|
.carousel__dots {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.carousel__dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
.carousel__dot.is-active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
width: 22px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
64
src/modules/mobile-game/components/TeamCard.vue
Normal file
64
src/modules/mobile-game/components/TeamCard.vue
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { team } from '../data/team.js'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="team">
|
||||||
|
<div v-for="m in team" :key="m.name" class="team__card">
|
||||||
|
<div class="team__avatar">{{ m.name.charAt(0) }}</div>
|
||||||
|
<div class="team__info">
|
||||||
|
<div class="team__name">{{ m.name }}</div>
|
||||||
|
<div class="team__role">{{ L(m.role) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.team {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
.team__card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: transform var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
.team__card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
.team__avatar {
|
||||||
|
flex: 0 0 40px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--accent-weak);
|
||||||
|
color: var(--accent);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.team__name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.team__role {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
141
src/modules/mobile-game/components/VersionTimeline.vue
Normal file
141
src/modules/mobile-game/components/VersionTimeline.vue
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<script setup>
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { versions } from '../data/versions.js'
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const L = (obj) => (locale.value === 'zh-CN' ? obj.zh : obj.en)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="timeline">
|
||||||
|
<div
|
||||||
|
v-for="(v, i) in versions"
|
||||||
|
:key="v.id"
|
||||||
|
class="timeline__item"
|
||||||
|
>
|
||||||
|
<div class="timeline__marker">
|
||||||
|
<span class="timeline__dot"></span>
|
||||||
|
<span v-if="i < versions.length - 1" class="timeline__line"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline__body">
|
||||||
|
<div class="timeline__head">
|
||||||
|
<span class="timeline__series">{{ v.series }}</span>
|
||||||
|
<span class="timeline__version">v{{ v.version }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline__product">{{ v.product }}</div>
|
||||||
|
<div class="timeline__row">
|
||||||
|
<span class="timeline__label">{{ locale === 'zh-CN' ? '操作' : 'Controls' }}</span>
|
||||||
|
<span>{{ L(v.control) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline__row">
|
||||||
|
<span class="timeline__label">{{ locale === 'zh-CN' ? '说明' : 'Note' }}</span>
|
||||||
|
<span>{{ L(v.note) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline__row">
|
||||||
|
<span class="timeline__label">Unity</span>
|
||||||
|
<span class="timeline__mono">{{ v.unity }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="timeline__links">
|
||||||
|
<a v-if="v.webgl" :href="v.webgl" target="_blank" rel="noopener">
|
||||||
|
{{ locale === 'zh-CN' ? 'WebGL 试玩' : 'Play WebGL' }} ↗
|
||||||
|
</a>
|
||||||
|
<a v-if="v.apk" :href="v.apk" target="_blank" rel="noopener">
|
||||||
|
{{ locale === 'zh-CN' ? '下载 APK' : 'Download APK' }} ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.timeline__item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
.timeline__marker {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.timeline__dot {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
flex: 0 0 12px;
|
||||||
|
}
|
||||||
|
.timeline__line {
|
||||||
|
flex: 1;
|
||||||
|
width: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
margin-top: 4px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
.timeline__body {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
transition: box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
.timeline__body:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.timeline__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.timeline__series {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
.timeline__version {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-weak);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.timeline__product {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
.timeline__row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 0;
|
||||||
|
}
|
||||||
|
.timeline__label {
|
||||||
|
flex: 0 0 48px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.timeline__mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.timeline__links {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-md);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
8
src/modules/mobile-game/data/team.js
Normal file
8
src/modules/mobile-game/data/team.js
Normal file
@@ -0,0 +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' } }
|
||||||
|
]
|
||||||
98
src/modules/mobile-game/data/versions.js
Normal file
98
src/modules/mobile-game/data/versions.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// 448 各版本数据(来自对构建产物与 index.html 的分析)
|
||||||
|
export const versions = [
|
||||||
|
{
|
||||||
|
id: 'zikai-0.0.0',
|
||||||
|
series: 'zikai-ui',
|
||||||
|
version: '0.0.0',
|
||||||
|
unity: '—',
|
||||||
|
product: 'My project / 1.0.2',
|
||||||
|
webgl: '/448/zikai-0.0.0/',
|
||||||
|
apk: null,
|
||||||
|
control: { zh: '鼠标左键操作、右键追踪', en: 'Left mouse: operate, Right mouse: track' },
|
||||||
|
note: { zh: '早期鼠标控制原型', en: 'Early mouse-controlled prototype' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zikai-0.0.1',
|
||||||
|
series: 'zikai-ui',
|
||||||
|
version: '0.0.1',
|
||||||
|
unity: '2020.3.30f1',
|
||||||
|
product: 'zikai-ui / 1.0.2',
|
||||||
|
webgl: '/448/zikai-0.0.1/',
|
||||||
|
apk: '/448/zikai-0.0.1/zikai.apk',
|
||||||
|
control: { zh: '生命 Q/W/E/R、能量 A/S/D/F', en: 'Health Q/W/E/R, Power A/S/D/F' },
|
||||||
|
note: { zh: '引入生命/能量属性系统', en: 'Introduced health/power stat system' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zikai-0.0.2',
|
||||||
|
series: 'zikai-ui',
|
||||||
|
version: '0.0.2',
|
||||||
|
unity: '2020.3.30f1',
|
||||||
|
product: 'zikai-ui / 1.0.2',
|
||||||
|
webgl: '/448/zikai-0.0.2/',
|
||||||
|
apk: '/448/zikai-0.0.2/zikai.apk',
|
||||||
|
control: { zh: '同 0.0.1', en: 'Same as 0.0.1' },
|
||||||
|
note: { zh: '构建产物重命名整理', en: 'Build artifact renaming' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zikai-0.0.3',
|
||||||
|
series: 'zikai-ui',
|
||||||
|
version: '0.0.3',
|
||||||
|
unity: '2020.3.30f1',
|
||||||
|
product: 'zikai-ui / 1.0.2.5',
|
||||||
|
webgl: '/448/zikai-0.0.3/',
|
||||||
|
apk: '/448/zikai-0.0.3/zikai.apk',
|
||||||
|
control: { zh: 'WASD 移动、J 跳跃、K 飞行(测试)', en: 'WASD move, J jump, K fly (testing)' },
|
||||||
|
note: { zh: '改为 WASD 操作,最新版', en: 'Switched to WASD, latest version' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'main-0.0.0',
|
||||||
|
series: 'main',
|
||||||
|
version: '0.0.0',
|
||||||
|
unity: '2020.3.30f1',
|
||||||
|
product: 'zikai-main / 1.0.2.6',
|
||||||
|
webgl: '/448/main-0.0.0/',
|
||||||
|
apk: '/448/main-0.0.0.apk',
|
||||||
|
control: { zh: '角色控制器', en: 'Character controller' },
|
||||||
|
note: { zh: '基线版本', en: 'Baseline' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'main-0.0.1',
|
||||||
|
series: 'main',
|
||||||
|
version: '0.0.1',
|
||||||
|
unity: '2021.3.12f1',
|
||||||
|
product: 'beta / 1.0.2',
|
||||||
|
webgl: '/448/main-0.0.1/',
|
||||||
|
apk: '/448/main-0.0.1/zikai.apk',
|
||||||
|
control: { zh: 'WASD 移动、K 跳跃', en: 'WASD move, K jump' },
|
||||||
|
note: { zh: '升级 Unity,新增屏幕按键', en: 'Upgraded Unity, added on-screen buttons' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ball',
|
||||||
|
series: 'roll-a-ball',
|
||||||
|
version: '0.0.2',
|
||||||
|
unity: '2021.3.12f1',
|
||||||
|
product: '3dRoingBallt2 / 0.0.2',
|
||||||
|
webgl: '/448/ball/',
|
||||||
|
apk: '/448/203wangz199.apk',
|
||||||
|
control: { zh: '手机陀螺仪倾斜移动、上倾/点击跳跃', en: 'Tilt phone to move, tilt up/tap to jump' },
|
||||||
|
note: { zh: '最终版本,3D 滚球', en: 'Final version, 3D rolling ball' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hongXiang-0.0.0',
|
||||||
|
series: 'doby',
|
||||||
|
version: '0.0.0',
|
||||||
|
unity: '2021.2.10f1',
|
||||||
|
product: 'DobyAdventure',
|
||||||
|
webgl: null,
|
||||||
|
apk: '/448/hongXiang-0.0.0/2D game.apk',
|
||||||
|
control: { zh: 'A/D 移动、跳跃、吃金币', en: 'A/D move, jump, collect coins' },
|
||||||
|
note: { zh: '2D 平台跳跃(Hongxiang He)', en: '2D platformer (Hongxiang He)' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 截图(复用 448 现有图片)
|
||||||
|
export const screenshots = [
|
||||||
|
{ src: '/448/1.jpg', caption: { zh: '项目截图 1', en: 'Screenshot 1' } },
|
||||||
|
{ src: '/448/2.jpg', caption: { zh: '项目截图 2', en: 'Screenshot 2' } },
|
||||||
|
{ src: '/448/3.jpg', caption: { zh: '项目截图 3', en: 'Screenshot 3' } }
|
||||||
|
]
|
||||||
8
src/modules/mobile-game/index.js
Normal file
8
src/modules/mobile-game/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// 模块元数据 -- 模块注册表据此自动生成页签与路由
|
||||||
|
export default {
|
||||||
|
id: 'mobile-game',
|
||||||
|
tabKey: 'tabs.mobileGame',
|
||||||
|
order: 1,
|
||||||
|
// 懒加载组件 -> 独立 chunk
|
||||||
|
component: () => import('./MobileGame.vue')
|
||||||
|
}
|
||||||
32
src/router/index.js
Normal file
32
src/router/index.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// 路由表由模块注册表动态生成 -- 新增模块自动产生路由,无需手改。
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import modules from '../modules/index.js'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
redirect: modules.length ? `/${modules[0].id}` : '/404'
|
||||||
|
},
|
||||||
|
// 每个模块一条路由,组件懒加载
|
||||||
|
...modules.map((m) => ({
|
||||||
|
path: `/${m.id}`,
|
||||||
|
name: m.id,
|
||||||
|
component: m.component,
|
||||||
|
meta: { moduleId: m.id }
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'not-found',
|
||||||
|
redirect: '/'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
scrollBehavior() {
|
||||||
|
return { top: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
91
src/styles/base.css
Normal file
91
src/styles/base.css
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
/* 重置 + 排版基础 */
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.65;
|
||||||
|
font-size: 15px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 1.9rem; letter-spacing: -0.01em; }
|
||||||
|
h2 { font-size: 1.4rem; letter-spacing: -0.01em; }
|
||||||
|
h3 { font-size: 1.1rem; }
|
||||||
|
|
||||||
|
p { color: var(--text-secondary); }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul { list-style: none; padding: 0; }
|
||||||
|
|
||||||
|
/* 容器 */
|
||||||
|
.container {
|
||||||
|
max-width: var(--content-max);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 区块标题 */
|
||||||
|
.section-title {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
.section-title small {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 减弱动效偏好 */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/styles/variables.css
Normal file
52
src/styles/variables.css
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/* 浅色极简主题令牌 */
|
||||||
|
:root {
|
||||||
|
/* 背景/表面 */
|
||||||
|
--bg: #ffffff;
|
||||||
|
--surface: #f7f8fa;
|
||||||
|
--surface-2: #f0f2f5;
|
||||||
|
|
||||||
|
/* 文字 */
|
||||||
|
--text-primary: #1f2329;
|
||||||
|
--text-secondary: #6b7280;
|
||||||
|
--text-tertiary: #9ca3af;
|
||||||
|
|
||||||
|
/* 强调色(单一靛蓝) */
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-weak: #eef2ff;
|
||||||
|
--accent-hover: #4338ca;
|
||||||
|
|
||||||
|
/* 线/边 */
|
||||||
|
--border: #eceef1;
|
||||||
|
--border-strong: #e0e2e6;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
|
||||||
|
/* 柔和阴影 */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||||
|
--shadow: 0 1px 3px rgba(17, 24, 39, 0.06), 0 1px 2px rgba(17, 24, 39, 0.04);
|
||||||
|
--shadow-hover: 0 6px 16px rgba(17, 24, 39, 0.08), 0 2px 6px rgba(17, 24, 39, 0.05);
|
||||||
|
|
||||||
|
/* 间距尺度 */
|
||||||
|
--space-xs: 4px;
|
||||||
|
--space-sm: 8px;
|
||||||
|
--space-md: 16px;
|
||||||
|
--space-lg: 24px;
|
||||||
|
--space-xl: 40px;
|
||||||
|
--space-2xl: 64px;
|
||||||
|
|
||||||
|
/* 内容宽度 */
|
||||||
|
--content-max: 1080px;
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: "SF Mono", "Cascadia Code", "JetBrains Mono", Consolas, monospace;
|
||||||
|
|
||||||
|
/* 过渡 */
|
||||||
|
--transition: 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式预留(暂不启用,保持浅色极简) */
|
||||||
42
vite.config.js
Normal file
42
vite.config.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// 构建产物输出到站点根 /root/html,但绝不清空该目录
|
||||||
|
// (里面已有 448/449/js/css 等既有内容)。
|
||||||
|
// 用 mainPage/ 命名空间子目录避免与 /root/html/js、/root/html/css 旧文件冲突。
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
base: '/',
|
||||||
|
build: {
|
||||||
|
outDir: '/root/html',
|
||||||
|
// ★ 安全项:不清空 outDir,保护既有文件
|
||||||
|
emptyOutDir: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: 'js/mainPage/[name].[hash].js',
|
||||||
|
chunkFileNames: 'js/mainPage/[name].[hash].js',
|
||||||
|
assetFileNames: (assetInfo) => {
|
||||||
|
const name = assetInfo.name || ''
|
||||||
|
if (name.endsWith('.css')) {
|
||||||
|
return 'css/mainPage/[name].[hash][extname]'
|
||||||
|
}
|
||||||
|
return 'assets/mainPage/[name].[hash][extname]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// 开发服务器把站点根的静态资源代理到 /root/html,方便引用 /448 /449 等
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/448': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
},
|
||||||
|
'/449': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user