13 KiB
13 KiB
webgame-collection 设计文档
2026-09-17 · 状态:待评审
1. 项目目标
一个收集"静态网页游戏"(无需服务端、纯前端交互的游戏)的开源目录站:
- 站点本身是纯静态页面(Vue3 构建产物),无服务端依赖
- 数据源为仓库内 JSON,一游戏一文件,通过 PR 提交收录
- 数据结构严格(JSON Schema 校验),并设计数据抽象层,未来可无缝接入后端 API
- 内置 markdown 文档渲染系统,访问文档时显示文档目录(TOC)
- 部署链路:GitHub Actions 构建镜像 → 推送 GHCR → 调 keel.sh webhook → k3s 拉取并滚动更新
2. 非目标(v1 不做)
- 站内 iframe 试玩(一律新标签外链跳转)
- 用户账号、评分、评论、收藏
- 后端 API、服务端搜索、分页(数据量预计 < 1000,纯前端处理)
- 多语言 i18n(中文为主;schema 不预留多语言字段)
- HPA、CDN、监控(纯静态 nginx,无必要)
3. 仓库结构
webgame-collection/ # 仓库根
├─ src/ # 完整站点工程 = 唯一构建输入
│ ├─ package.json # npm/vite 工程在此,cd src 后 npm run dev/build
│ ├─ vite.config.ts / index.html / tsconfig.json
│ ├─ app/ # Vue 源码
│ │ ├─ main.ts / App.vue
│ │ ├─ router/ # 路由
│ │ ├─ views/ # 页面
│ │ ├─ components/ # 组件
│ │ ├─ data/ # 数据抽象层(repository 接口 + fetch 实现)
│ │ └─ lib/ # markdown 渲染、筛选/排序等纯函数
│ ├─ games/ # 游戏 JSON 数据源(一游戏一文件,PR 提交点)
│ ├─ docs/ # 站点文档源(*.md,frontmatter: title/order)
│ ├─ assets/covers/ # 仓库内封面图(文件名 = 游戏 id)
│ ├─ schema/game.schema.json # JSON Schema(draft 2020-12)
│ ├─ scripts/build-data.mjs # 校验 + 生成 public/data/*
│ └─ public/ # favicon 等手写资源;public/data/ 为生成物(gitignore)
├─ docs/ # 只服务 GitHub 仓库,不参与站点构建
│ ├─ README.md # GitHub 首页会自动展示 docs/README.md
│ ├─ CONTRIBUTING.md # GitHub 也会识别 docs/CONTRIBUTING.md
│ └─ superpowers/specs/ # 设计文档与实施计划
├─ deploy/ # Dockerfile、nginx.conf、k8s manifests
├─ LICENSE # MIT
├─ .gitignore # node_modules、src/dist、src/public/data 等
└─ .github/workflows/ # validate.yml(PR 校验)、publish.yml(构建发布)
约束:站点构建只针对 src/(npm 工程根 = src/);Docker 构建上下文为仓库根,但只拷贝 src/ 与 deploy/nginx.conf。
4. 游戏数据 schema
4.1 文件与命名
- 位置:
src/games/<id>.json,一游戏一文件 <id>:^[a-z0-9-]{1,64}$,且必须等于文件名(CI 强制,防止 id/文件名漂移)
4.2 字段定义
| 字段 | 必填 | 类型 | 规则 |
|---|---|---|---|
id |
是 | string | ^[a-z0-9-]{1,64}$,等于文件名 |
name |
是 | string | 1–60 字符 |
url |
是 | string | 仅 https,合法 URL |
author.name |
是 | string | 1–60 字符 |
author.url |
否 | string | 仅 https |
description |
是 | string | 1–140 字符,一句话简介(列表页展示) |
intro |
否 | string | Markdown,≤ 5000 字符(详情页渲染) |
durationMinutes.min |
是 | int | ≥ 1 |
durationMinutes.max |
是 | int | ≥ min,≤ 600 |
type |
是 | enum | 见 4.3 |
tags |
是 | string[] | ≤ 8 个;每个 1–12 字符、无首尾空白、不重复 |
cover |
否 | string | https 外链,或 /data/assets/covers/<文件名>(对应文件必须存在于 src/assets/covers/,文件名须为 <id>.<png|jpg|jpeg|webp|avif|gif>) |
addedAt |
是 | string | YYYY-MM-DD |
additionalProperties: false(所有对象层级),未知字段直接报错,保证严格。
4.3 type 枚举与中文标签
| 值 | 中文 |
|---|---|
puzzle |
解谜 |
action |
动作 |
idle |
放置 |
strategy |
策略 |
simulation |
模拟 |
narrative |
文字叙事 |
music |
音乐 |
creative |
创意 |
casual |
休闲 |
other |
其他 |
4.4 示例
{
"id": "2048",
"name": "2048",
"url": "https://play2048.co/",
"author": { "name": "Gabriele Cirulli", "url": "https://github.com/gabrielecirulli/2048" },
"description": "滑动合并数字方块的经典益智游戏。",
"intro": "可选,Markdown 长简介。",
"durationMinutes": { "min": 5, "max": 20 },
"type": "puzzle",
"tags": ["数字", "休闲"],
"cover": "/data/assets/covers/2048.png",
"addedAt": "2026-09-17"
}
4.5 校验规则(构建脚本 + PR CI 同源)
- JSON Schema 校验(Ajv,draft 2020-12)
id等于文件名- 全部游戏
id全局唯一 tags去重、无首尾空白、长度与数量限制cover为本地路径时,文件必须存在且扩展名合法- 校验失败输出可读错误(文件、字段、原因),退出码非 0
5. 生成数据(构建产物)
scripts/build-data.mjs 在校验通过后生成,写入 src/public/data/(gitignore,predev/prebuild 自动执行):
| 产物 | 内容 |
|---|---|
public/data/index.json |
{ schemaVersion, generatedAt, games: GameSummary[] },GameSummary = 除 intro 外的全部字段(目录页搜索/筛选用) |
public/data/games/<id>.json |
完整游戏对象(含 intro),详情页按需拉取 |
public/data/docs.json |
{ generatedAt, docs: [{ slug, title, order, content }] },content 为 markdown 原文(内联,文档数少,一次请求) |
public/data/assets/covers/* |
从 src/assets/covers/ 复制 |
脚本参数:无参 = 校验 + 生成;--check = 只校验(PR CI 用)。
6. 文档系统
- 数据源:
src/docs/*.md,slug = 文件名(^[a-z0-9-]+$) - frontmatter:
title必填;order可选(数字,缺省 999,升序排序,同序按文件名) - 缺失
title、文件名非法、slug 重复 → 构建失败 - 渲染:markdown-it(
linkify + typographer),标题自动生成锚点(slug 化保留中文,重名自动去重) - TOC:从 markdown-it token 流提取
h2/h3,右侧固定目录,IntersectionObserver滚动高亮,点击平滑滚动 - 内容为仓库内 PR 审核过的可信来源,不引 DOMPurify;将来若接入用户投稿内容再加
7. 前端架构
7.1 技术栈
Vue 3 + TypeScript + Vite · Vue Router · Tailwind CSS v4 · markdown-it · Vitest · vue-tsc。 不引 Pinia(数据量小,模块级 composable + URL query 足够)。
7.2 路由
| 路由 | 页面 |
|---|---|
/ |
目录页:搜索 + 类型/时长/标签筛选 + 排序,响应式卡片网格 |
/games/:id |
详情页:封面、元信息、intro markdown、"开始游戏"新标签外链、返回 |
/docs/:slug |
文档页:左侧文档列表 + 右侧 TOC + 正文 |
/docs |
重定向到第一篇文档 |
* |
404(含"游戏不存在") |
7.3 筛选状态
- 状态全部同步 URL query:
q(关键词)、type、tag(逗号分隔多选)、dur、sort q匹配name / description / author.name / tags(忽略大小写,includes,中文可用)dur桶(按durationMinutes.max判定,无重叠):short≤5 分钟;mid(5, 30];long>30sort:new(addedAt 倒序,默认)/name(中文 locale 排序)/duration(min 升序)
7.4 数据抽象层(未来接后端的唯一边界)
// app/data/repository.ts —— 页面只依赖此接口
interface ContentRepository {
listGames(): Promise<GameSummary[]>
getGame(id: string): Promise<Game> // 不存在抛 NotFoundError
listDocs(): Promise<DocMeta[]>
getDoc(slug: string): Promise<Doc>
}
app/data/staticRepo.ts:fetch 实现,GET{base}/index.json、{base}/games/<id>.json、{base}/docs.json;内存缓存;错误类型化app/data/index.ts:导出单例;未来换ApiRepository只改这一处base=import.meta.env.VITE_DATA_BASE_URL,默认/data;未来后端的 URL 结构照抄/data即可无缝切换- 抽象层做轻量运行时校验(按 TS 类型检查响应体字段),不只信构建期
- 页面消费
loading / error / data三态
7.5 视觉与兜底
- Tailwind v4,深色为主的现代目录站风格;卡片网格 1/2/3/4 列响应式
- 封面加载失败或无封面:游戏名首字 + 按 id 哈希取色的确定性渐变占位卡(稳定不闪)
- 空状态(筛选无结果)、加载骨架、错误重试面板
7.6 测试(Vitest,跑在 src/)
- 筛选/排序/搜索纯函数(含中文)
- TOC slug 化、提取、去重
- repository URL 拼接与错误映射(404 → NotFoundError)
build-data.mjs校验函数单测(合法/非法样例)
8. 部署
8.1 镜像构建
deploy/Dockerfile 多阶段:
node:24-alpine:拷src/package*.json→npm ci→ 拷src/→npm run buildnginx:alpine:拷deploy/nginx.conf与构建产物dist/
.dockerignore 排除 docs/、deploy/k8s/、.git、node_modules、src/dist、src/public/data。
镜像名:ghcr.io/<owner>/webgame-collection:<sha-短哈希> + :latest;包设为 public,k3s 免 imagePullSecret。
8.2 nginx 配置
- SPA fallback:
try_files $uri /index.html - gzip 开启
- 缓存:
/assets/(Vite 哈希产物)max-age=31536000, immutable;/data/*ETag 协商缓存(no-cache);index.html不缓存
8.3 GitHub Actions
validate.yml(PR 触发,paths: src/**):
- setup-node 24 + npm cache(
cache-dependency-path: src/package-lock.json) cd src && npm cinpm run validate:data(只校验,错误信息面向贡献者)npm run check(vue-tsc --noEmit && vitest run && vite build)
publish.yml(push main):
permissions: contents: read, packages: write- 登录 GHCR(
GITHUB_TOKEN),docker/build-push-action推sha-<短哈希>+latest - 调 keel native webhook:POST
${{ secrets.KEEL_WEBHOOK_URL }}(/v1/webhooks/native),body{"name":"<镜像名>","tag":"latest"};keel 开启AUTHENTICATED_WEBHOOKS时带 Basic auth(用户名keel,密码KEEL_TOKEN);secret 未配置则跳过并 log 提示(依赖轮询兜底) concurrency保证同时间仅一次发布
8.4 k3s 清单(deploy/k8s/)
namespace.yaml、deployment.yaml:2 副本、RollingUpdate、readinessProbe GET/、资源 requests cpu 10m / 内存 16Mi,limits cpu 200m / 内存 128Miservice.yaml:ClusterIP:80ingress.yaml:k3s 默认 Traefik;host 与 TLS 用占位注释,按实际集群填- keel 注解(Deployment,依据 keel 官方文档:
latest这类可变标签更新用force+match-tag对比 manifest digest;设置trigger: poll后 webhook 事件仍然有效):
keel.sh/policy: force
keel.sh/match-tag: "true"
keel.sh/trigger: poll # 轮询兜底(webhook 未配置/失败时)
keel.sh/pollSchedule: "@every 5m"
- 不引入 HPA(纯静态 nginx)
8.5 需要一次性提供/配置的项
- GitHub 仓库与 owner(决定镜像路径
ghcr.io/<owner>/webgame-collection) - GHCR 包可见性设为 public
- k3s 中部署 keel.sh(或确认已有),取得 webhook URL 与 token → 写入 GH 仓库 secrets
- 域名与 Ingress host(
deploy/k8s/ingress.yaml占位处)
9. 本地开发与验收
- 开发:
cd src && npm run dev(predev 自动生成数据) - 门槛:
npm run check(vue-tsc + vitest + vite build) - 生产形态:
docker build -f deploy/Dockerfile . && docker run -p 8080:80 <image>,人工过一遍目录/筛选/详情/文档 TOC - 端到端:合并一个游戏 JSON 的 PR → validate 通过;push main → GHCR 新镜像 → keel 更新 k3s → 站点出现新游戏
10. 未来接后端迁移路径
- 后端提供与
/data相同结构的接口(或标准 REST),URL 可直接沿用 - 前端新增
ApiRepository implements ContentRepository,改app/data/index.ts单例导出即可,页面与组件零改动 - 数据 schema 不变,PR 流程与校验规则继续适用于仓库内数据
- 用户投稿/审核等有服务端的行为届时另开 spec,不在本设计内
11. 验收标准
src/games/新增合法 JSON 通过npm run validate:data;非法样例(id 与文件名不符、tags 重复、cover 文件缺失、未知字段)全部报错npm run dev目录页可搜索、按类型/时长/标签筛选、排序,URL query 可分享/刷新保持- 详情页展示完整信息与 intro markdown,"开始游戏"新标签打开外链
- 文档页渲染 markdown,左侧列表 + 右侧 TOC 滚动高亮、锚点跳转正确
- 无封面/封面加载失败显示确定性占位卡
docker build产出镜像可跑通生产形态,缓存头符合 8.2- PR 触发 validate.yml;push main 触发 publish.yml 推送 GHCR 并调 keel webhook
deploy/k8s/清单在 k3s 可 apply,keel 能拉新镜像滚动更新