用 GitHub Issues 当数据库,零成本搭一个「提示词归档库」
没有服务器、没有数据库、没有构建步骤------只用一个 HTML 文件 + GitHub Pages + Issues API,就能上线一个带图片上传、分类筛选、灯箱放大、只读浏览的提示词归档站。本文拆解它的实现思路。
先看成品
打开 codeniu.github.io/prompt-arch... ,你看到的是一个「编辑档案」风格的页面:
- 顶部固定刊头 + 搜索框 + 新建按钮
- 分类目录按标签筛选,每标签显示数量
- 网格卡片布局,每张卡带编号(
No. 0001)、标题、正文预览、缩略图、标签 - 点击卡片进详情,可编辑、复制、删除
- 支持上传「结果截图」,列表最多展示 3 张,点击放大进灯箱,详情页显示全部
- 没配 Token 的访客也能只读浏览
整页只有一个 index.html,没有 React、没有 Vue、没有 webpack、没有 npm install。

为什么选 GitHub Issues 当数据库?
做一个小工具,最烦的不是写代码,而是选型与运维:
| 方案 | 服务器 | 数据库 | 成本 | 痛点 |
|---|---|---|---|---|
| 传统全栈 | 要 | 要 | 服务器费 | 要部署、要备份、要续费 |
| BaaS(Supabase 等) | 不要 | 托管 | 免费额度 | 要注册、要配表、有迁移成本 |
| 纯静态 + localStorage | 不要 | 浏览器 | 0 | 数据不跨设备、易丢 |
| GitHub Pages + Issues | 不要 | Issues | 0 | 天然在 GitHub 上可浏览/搜索/备份 |
GitHub Issues 本质是一个「带富文本 body、labels、状态、时间戳」的 KV 存储,刚好够存「标题 + 正文 + 标签 + 创建时间」这种结构化不强的内容。而且:
- 数据即代码资产:issue 就在仓库里,GitHub 自带搜索、订阅、Webhook
- 零运维:Pages 托管静态文件,Issues 提供 REST API,都不用管
- 天然版本化:issue 的修订历史 GitHub 都帮你记着
- 免费且稳定:个人仓库无限 issue,Pages 流量也够用
适合:提示词收藏、读书笔记、灵感速记、个人 linklog 这类轻结构、低并发、个人或小团队的场景。不适合高并发写入或复杂关联查询。
核心映射:Issue 字段 → 提示词
ini
issue.title → 提示词标题
issue.body → 提示词正文(图片 URL 也藏在 body 里)
issue.labels → 分类标签
issue.state → open=存活,closed=已销毁
issue.number → 档案编号 No.0001
issue.created_at / updated_at → 建立/修订时间
CRUD 直接对应 Issues API:
| 操作 | 界面动作 | GitHub API |
|---|---|---|
| 新建 | 「新建条目」→「归档」 | POST /repos/{o}/{r}/issues |
| 查看 | 点击卡片 | GET /repos/{o}/{r}/issues |
| 编辑 | 详情页「编辑」→「保存修改」 | PATCH .../issues/{number} |
| 删除 | 卡片「销毁」 | PATCH .../issues/{number} state=closed |
删除是「关闭 issue」而非真删------GitHub API 不支持真删 issue,这反而成了优点:误删可恢复。
难点一:图片往哪存?
提示词经常要配「结果截图」。静态站没有后端,图片怎么传?
答案是 GitHub Contents API :把图片 base64 编码后 PUT 到仓库的 images/ 目录,返回的 download_url 就是可直接热链的图片地址。
js
async function ghUploadImage(name, dataUrl) {
const commaIdx = dataUrl.indexOf(',');
const base64 = dataUrl.slice(commaIdx + 1);
const path = `images/${Date.now()}-${rand}.${ext}`;
const res = await fetch(`${ghBase()}/contents/${path}`, {
method: 'PUT',
headers: ghHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
message: `upload image ${path}`,
content: base64, // 不含 data: 前缀的纯 base64
}),
});
const data = await res.json();
return data.download_url; // raw.githubusercontent.com 直链
}
上传拿到 URL 后,图片 URL 怎么和提示词正文一起存? 我没有新建一张表(也没有表),而是把图片 URL 以 Markdown 图片语法塞进 issue body 末尾,用 HTML 注释做分隔块:
markdown
这里是提示词正文......
<!--pa-images-->


<!--/pa-images-->
读取时用正则把这块切出来:
js
const IMG_START = '<!--pa-images-->';
const IMG_END = '<!--/pa-images-->';
const IMG_RE = /!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/g;
function parseBody(rawBody) {
const body = rawBody || '';
const startIdx = body.indexOf(IMG_START);
if (startIdx === -1) return { content: body, images: [] };
// ... 切出图片块,正则提 URL,正文去掉这块
}
好处:一次 API 调用同时拿到正文和图片,不用再查仓库目录;issue 在 GitHub 网页端打开也能直接看到图片预览;正文与图片强绑定,不会错配。
难点二:列表最多 3 张,详情显示全部
同一个数据源,两种展示密度。实现上很简单------卡片只取前 3 个 URL,第 3 张如果还有剩余就叠一个 +N 遮罩:
js
function renderCardThumbs(number, images) {
if (!images?.length) return '';
const show = images.slice(0, 3);
const extra = images.length - 3;
return show.map((url, i) => {
const overlay = (i === 2 && extra > 0)
? `<div class="more-overlay">+${extra}</div>` : '';
return `<div class="card-thumb" onclick="openCardLightbox(${number}, ${i})">
<img src="${url}" />${overlay}
</div>`;
}).join('');
}
点击任意缩略图都打开灯箱 ,支持键盘 ←/→ 切换、Esc 关闭、点背景关闭。灯箱本身就是一个 fixed 全屏遮罩 + 居中大图 + 计数器 1 / 5:
js
function openLightbox(images, idx) {
lightboxImgs = images;
lightboxIdx = Math.max(0, Math.min(idx, images.length - 1));
$('lightboxImg').src = images[lightboxIdx];
$('lightbox').classList.add('show');
}
难点三:没配 Token 也能看
静态站最大的体验问题是------新访客不可能先去配 Token 。但 GitHub 对未鉴权请求限制很严:每 IP 每小时仅 60 次,频繁刷新就 403。
解法是只读模式:检测到没有 Token 时,回退到页面所在的部署仓库,用公开 API 读 issues(写操作按钮全部隐藏)。优先级是:
ini
?repo=owner/repo → localStorage 配置 → 部署仓库兜底
js
function getReadRepo() {
const q = new URLSearchParams(location.search).get('repo');
if (q?.includes('/')) { /* URL 参数指定 */ }
if (cfg.owner && cfg.repo) return { owner: cfg.owner, repo: cfg.repo };
return PAGES_REPO; // { owner: 'Codeniu', repo: 'prompt-archive' }
}
写按钮按只读状态显隐:
js
if (readOnly()) $('addBtn').hidden = true; // 顶部「新建条目」
$('editToggleBtn').hidden = !isView || ro; // 详情页「编辑」
${readOnly() ? '' : `<button>销毁</button>`} // 卡片「销毁」
403 限流时给专门状态页 + 「配置 Token」按钮(鉴权后 5000 次/小时):
js
if (isRateLimit && readOnly()) {
throw new Error('GitHub API 限流(未鉴权每小时仅 60 次)。点 ⚙ 配置 Token 后可提升至 5000 次/小时。');
}
Token 的安全性
很多人怕「Token 写在前端会不会泄露」。这里的关键区分是:
- 写操作(创建/编辑/删除 issue、上传图片)需要 Token ,但 Token 只存在你自己浏览器 的 localStorage,不会打包进静态文件、不会随请求 URL 暴露(走
Authorization头) - 读操作在只读模式下不需要 Token,用公开 API
也就是说,别人访问你的站,看到的是只读视图,不需要、也拿不到你的 Token。只有你自己点 ⚙ 配置后,才在本机获得写权限。公开仓库用最小权限的 public_repo scope 即可。
设计语言:让它看起来不像默认 SaaS
前端最容易踩的坑是「看起来像教程默认产物」------蓝紫渐变、玻璃拟态、卡片网格 + 中间一张「Most Popular」徽章。这个项目反其道行之,走「编辑档案」风格:
- 配色 :暖米黄纸张底(
oklch(96% 0.014 80))+ 朱砂红强调(oklch(54% 0.180 28)),稀缺使用红色,只在编号、标签、按钮上点睛 - 字体三联 :Fraunces 衬线扛标题(带
opsz/SOFT变量轴)、JetBrains Mono 写提示词正文(代码感)、系统 sans 做 UI 控件 - 卡片 :无阴影、无圆角(
--r-sharp: 2px),靠 1px 细线分隔,带No. 0001编号像档案条目 - 弹窗 :直角书写稿纸风格,6px 偏移硬阴影(
box-shadow: 6px 6px 0 var(--rule-strong)) - 动效 :尊重
prefers-reduced-motion,焦点环用:focus-visible
用 oklch 而非 hex/rgb 是因为它的感知均匀性更好------调亮度时颜色不会突变脏色。
同一套设计,两种部署形态
项目里其实有两份实现,共用同一套视觉:
| 形态 | 文件 | 后端 | 数据存储 | 适合 |
|---|---|---|---|---|
| 静态版 | docs/index.html |
无 | GitHub Issues | 零成本上线、数据在 GitHub |
| 自托管版 | server.py + index.html |
Python 标准库 | 本地 prompts.json |
内网、不想数据上云 |
自托管版后端只用标准库(http.server + json),零依赖:
bash
python3 server.py
# → Prompt collector running on http://localhost:8000
API 很朴素:
| 方法 | 路径 | 说明 |
|---|---|---|
GET |
/api/prompts |
列出全部 |
POST |
/api/prompts |
新建 |
PUT |
/api/prompts/{id} |
修订 |
DELETE |
/api/prompts/{id} |
删除 |
两套实现刻意保持视觉一致,迁移成本低------本地用自托管版写,想公开就推到 GitHub Pages,数据搬一下即可。
一些不显然的工程细节
1. GitHub Issues API 会把 PR 也混进来。 GET /issues 端点同时返回 issue 和 PR,必须前端过滤:
js
return issues.filter(i => !i.pull_request);
2. Issue 的 labels 必须先存在才能附加。 新建 issue 时如果标签没建过会 422,所以要先 POST /labels 逐个建(422 = 已存在,忽略):
js
async function ensureLabelsExist(tags) {
await Promise.allSettled(tags.map(t =>
fetch(`${ghBase()}/labels`, { method: 'POST', body: JSON.stringify({ name: t, ... }) })
));
}
3. 图片上传文件名要唯一。 用 Date.now() + 随机串 避免重名覆盖:
js
const path = `images/${Date.now()}-${Math.random().toString(36).slice(2,8)}.${ext}`;
4. 编辑回填要把已有图片带回来。 进入编辑模式时把 issue 现有图片 URL 作为 type: 'existing' 塞回 pending 队列,保存时跳过上传、直接保留:
js
pendingImages = images.map(url => ({ type: 'existing', url }));
// 保存时:
if (item.type === 'existing') uploadedUrls.push(item.url);
else uploadedUrls.push(await ghUploadImage(item.name, item.data));
局限与适用边界
诚实地说这套方案的边界:
- 并发写入:GitHub API 不是为高并发设计的,多人同时编辑会有冲突
- 限流:未鉴权 60 次/小时,鉴权 5000 次/小时,不适合高频刷新
- 查询能力弱:只能拉全量再前端筛选,不能像 SQL 那么灵活
- 单仓库 issue 上限:虽然很大,但不是无限
- 图片走 raw.githubusercontent.com:偶尔被 CDN 缓存延迟,不适合强实时
适合:个人提示词库、读书笔记、灵感速记、小团队 linklog、FAQ 归档。 不适合:高并发、强一致、复杂关联查询的生产系统。
总结
这个项目想说明的是:很多小工具不需要全栈框架 。当你愿意把「数据库」让给 GitHub Issues、把「后端」让给 Pages 静态托管、把「图片存储」让给 Contents API,你能用一个 HTML 文件换来一个零成本、零运维、数据天然在 GitHub 上可浏览归档的小站。
不是说它比全栈方案「更好」,而是说在轻结构、低并发、个人场景 下,它的成本/收益比非常好看。下次你想做个小工具时,不妨先问自己:这件事,是不是一个 issue 就能装下?
完整代码与部署步骤见 GitHub 仓库,在线 Demo 见 GitHub Pages。MIT 协议,欢迎自取。