图床,但是不(完全)需要服务器后台。 图片放 Cloudflare R2,元数据(分级 / 隐藏 / 相册)放 D1, 逻辑跑在 Worker 上,前端是一个纯静态单页 —— 没有服务器要运维,也没有一样东西在收费额度之外。 图片出网流量本身免费,所以「图被人看」这件事不产生账单。
在线实例:https://picbed.ohtoai.top/(公开画廊,游客视角)
| 功能 | 说明 |
|---|---|
| 相册 | 相册是数据库里的一列,不是目录前缀。重命名 / 移动 / 删除都只改数据,已分享出去的直链永远不会失效 |
| 上传 | 拖拽 / 点击 / Ctrl+V 粘贴,支持批量;拖整个文件夹会递归扫描并保留目录结构 |
| 缩略图 | 上传时在浏览器里用 canvas 生成(居中正方形 WebP,512×512),相册首屏流量降一到两个数量级 |
| 大图灯箱 | 点开即看,一键复制 URL / Markdown / HTML,或按图片名字存盘 |
| 流式下载 | 自己 fetch 字节,所以网格和灯箱都有真实百分比进度;慢网下还能「先糊后清」 |
| 分级与隐藏 | 两个独立维度,决定图片是否出现在相册里;直链始终可访问(相册是陈列,不是门禁) |
| 公开画廊 | 游客视角,按分级档位陈列;相册可以直接把链接分享给别人 |
| 界面风格 | 九套皮肤 + 一个正交的 CRT 滤镜,访客也能换(纯外观,不影响数据) |
| 批量整理 | 多选批量删除 / 移动 / 改分级 / 隐藏;粘贴远程图片 URL 直接转存进桶 |
| 成本 | 图片出网 ¥0;Worker、D1、GitHub Pages 都在免费额度内 |
九套皮肤收在顶栏「风格」里,另有 CRT 滤镜可以叠在任意一套之上。下面四张是同一屏内容:
![]() 功能机 LCD · 暗橄榄(默认) |
![]() 早期智能机 · 拟物 |
![]() Y2K · 霓虹翻盖机 |
![]() PDA · Windows Mobile |
| 值 | 外观 |
|---|---|
lcd-olive |
功能机 LCD(默认)· 暗橄榄:亮版压暗去饱和,不反色 |
lcd |
功能机 LCD · 亮版砖绿:灰绿底 + 网点 |
lcd-inv |
功能机 LCD · 反色砖绿:ink 与 paper 对调,深绿屏 + 发光像素 |
lcd-amber |
功能机 LCD · 琥珀终端 |
lcd-backlight |
功能机 LCD · 诺基亚背光(暗青绿) |
skeuo |
早期智能机拟物:亚麻纹理、高光玻璃按钮、相框式卡片 |
y2k |
Y2K 霓虹翻盖机:深紫夜空 + 扫描线 + 青粉霓虹 |
pda |
PDA / Windows Mobile:银灰窗口 + 3D 倒角 |
modern |
现代简约:原来的设计,带跟随系统的深色档 |
选中的皮肤存在本机 localStorage,优先于 config.js 里的 skin。
皮肤挂在 <html data-skin> 上,由 <head> 里的一小段内联脚本在首帧前贴好 —— 否则会先按默认皮肤画一遍再跳过去。
皮肤的写法与 CRT 滤镜的实现细节
加一套新皮肤:在 index.html 的 /* 复古皮肤 */ 一节里加一段 html[data-skin="名字"] 规则,
先重定义设计令牌(--bg / --panel / --border / --radius / --shadow-* …),再补该皮肤特有的形态规则;
最后把名字加进主脚本的 SKINS 数组即可。令牌是主力 —— 主站大部分组件都拿令牌长的,
换了令牌它们会自己跟上,不必逐个点名。
LCD 一族更进一步:五个变体共用同一套形态规则(选择器统一写 [data-skin^="lcd"] 前缀匹配),
区别只在 --lcd-* 那几个变量上,所以加一版新配色只要写一个变量块。
注意变量的方向别搞反 —— LCD 里「亮 = 有像素」,抬一档的面永远比纸面亮、沉一档的永远比纸面暗;
反色版是对调 ink 与 paper 的角色,不是把整个调色板取反。照片也跟着反色就成负片了
(太阳会变成黑点),所以 --lcd-photo-filter 保留照片自己的明暗,只染成屏幕色再压暗。
CRT 滤镜是个独立开关(config.js 里的 crt):它在整页之上蒙一层扫描线 + 荫罩 + 四角暗角 +
极轻微的亮度闪烁,外加镜头畸变(整屏按球面鼓出来、四角往里收),和皮肤正交 ——
皮肤决定「这台设备长什么样」,CRT 决定「这块屏好不好」,所以两者能任意组合,
开关状态单存一个键,换皮肤不影响它。
全部落在一个 position: fixed 的覆盖层上(#crtLayer),pointer-events: none,只做光学效果、
不碰任何布局,也不需要皮肤配合。强度是这几个变量,嫌重嫌轻改它们即可:
--crt-scan: .22; /* 扫描线深度 */
--crt-grille: .045; /* 荫罩(RGB 细条纹)强度 */
--crt-vignette: .55; /* 四角压暗(径向) */
--crt-edge: .42; /* 四条边往里压暗的深度 —— 「管面往边缘卷下去」那一圈 */
--crt-curve: .045; /* 屏幕形状:黑边的弧度。纯覆盖层,一点命中代价都不花 */
--crt-warp: .045; /* 画面位移:让画面本身也弯。**这条要花命中代价**,见下面的「已知取舍」 */
--crt-sweep: .22; /* 滚动扫描亮带的亮度 */
--crt-roll: 6s; /* 亮带滚一整屏要多久 */两个弧度分开是有意的:屏幕形状(黑边、以及它盖住的那圈)只是画上去的,不影响任何交互;
画面位移会真的把内容和光标错开,所以单独一个旋钮,代价单独算。--crt-warp: 0 就是「只要屏幕
形状、不要位移」那一档。
镜头畸变挂的是 backdrop-filter: url(#crtWarp),而不是给页面套一层 filter:
backdrop-filter 只吃背景、不参与布局,页面结构一格都不用动,灯箱/弹窗那些 fixed 层
也不会被 filter 拽成「相对 body 定位」(那会把它们从视口里甩出去)。
代价是只有 Chromium 系认这个写法,Safari / Firefox 解析不了 backdrop-filter 里的 url():
@supports 会整个拦住,它们拿到的是没弯的平面扫描线 —— 不是坏掉,只是不弯。
位移图是主脚本现画的(一张 R 存 x 位移、G 存 y 位移的 PNG,128 = 不动),因为
feDisplacementMap 的 scale 只有一个数、横竖共用,而横向位移要按屏宽、纵向按屏高归一化,
差着一个 h/w。画图时那一步钳位别删:桶形畸变在边缘要往画面外采样,而 Chromium 对出界
的处理是镜像回绕(feDisplacementMap 的 edgeMode 它不认),不钳的话屏顶会冒出一条
上下翻转的顶栏 —— 看着像重影,其实是照镜子。夹回边界内就变成「最边上那行像素被拉长」。
光栅黑边(#crtBezel)画的就是那片被钳住的区域 —— 采样点被钉在画面边缘,本来就是拉花的,
索性照显像管的样子压黑,四角因此收出圆角。它的边界解的是 |x|·(1 + k·(x² + y²)) = 1
(采样点刚好落在画面边缘的等值线),所以和钳位区严丝合缝:黑的正好是该黑的那一圈,
边缘往里一个像素都不多吃。注意这条曲线对 x 和对 y 是同一个条件,可见区是一条闭合曲线的内部,
不是四条边各管各的。
已知取舍:画面位移和浏览器的命中检测是两套坐标 —— 效果是画上去的,版式一个像素没动, 命中检测也就永远按没弯的算。默认强度下两者相差:画面中间约 5px,顶栏右上角那片 30px 出头, 是真会点错。主脚本用两段补偿把它找回来:
- 动作事件(
click/dblclick/contextmenu):按同一个映射倒推回版式坐标, 再补发到那个元素上。这里有个坑别踩:不能因为「原目标是它的父/子节点」就跳过 —— 点卡片看得见的边缘时,原始落点在卡片缝隙里(原目标是#galleryGrid), 而它恰恰包含那张卡片,一放行点击就落空了。- 悬停高亮与光标形状:这两样没有 API 能改(
:hover由浏览器按真实指针位置算), 所以干脆不用:hover—— 启动时把样式表里带:hover的规则镜像一份成.crt-hover、 把原规则接上:not(*)废掉,之后按补偿后的位置自己挂类。这站的:hover选择器形态很干净 (没有:is()/:not()/:has(),也没藏在@media里),字符串替换是安全的; 真遇到复杂的会整段安静退回去,不影响别的。还是补不了的:框选/拖拽跨着一长串
mousemove,只补起点会把矩形画歪,不如不补。 想彻底没有这层错位,把--crt-warp调到 0 —— 那时画面是平的,补偿整段歇工, 屏幕形状改由--crt-curve扛(黑边弧度仍在不花命中代价)。
滚动扫描亮带(#crtSweep)是老显示器帧不同步时那条爬过屏幕的亮带。要点是它得是弯的:
屏是球面,扫过去的亮线在屏上就是一截弧。做法不是画条直带子再加圆角 —— 那样只是两头变尖、
整条还是直的 —— 而是拿一个横向圆角半径等于半宽的椭圆:拱高就等于纵向圆角半径,
弧才真的压进可视区。盒子比视口宽出 36%,是为了把收尖的两个端点挪到屏外,
否则尖点正好落在左右边缘上,看着像条两头拉尖的梭子。
这条带子很淡,别靠翻整页截图判断它有没有生效 —— 整页图会被缩略显示,淡淡的纵向渐变 正好被洗掉(这一轮就是这么误判了两次的)。两个可靠办法:
- 跑本地的截图脚手架(
_site/theme-shot.js,不入库):它把亮带定住不动,与「强度 0」的同一行做差, 打出峰值差与半高宽度。正常应当在亮带中线处得到 +45 上下、半高宽约 35px 的平滑鼓包。 - 或者裁一条 150px 宽的窄条(窄图不会被缩放),鼓包一眼可见。
管面感是三样叠出来的,缺一样都会露馅:只做径向暗角的话暗的只有四角、四条边还是平的, 看着像贴了张暗角贴纸;加上一圈边缘内阴影才像玻璃朝观察者鼓出来;反光用椭圆而不是斜条 (斜条是窗玻璃,椭圆高光才是球面管)。
关于桶形畸变:这里没有做几何形变,是刻意的。真做需要给页面祖先挂 SVG
feDisplacementMap,那会带来两个问题 —— 一是按规范 filter 会让元素成为
position: fixed 后代的包含块,而灯箱 / toast / 右键菜单 / 上传悬浮球全是 fixed,
它们会退化成跟着页面滚走;二是它会把照片本身掰弯(大图刚改成不偏色,再掰弯就矛盾了),
而且整页每帧都要重新栅格化,图多的相册滚动会明显变卡。
闪烁刻意压得很小:一是有光敏风险(WCAG 2.3.1 卡在每秒 3 次,这里约 5.5 秒一次),
二是幅度一大看久了累;prefers-reduced-motion 下会整个停掉。
- 原图走和卡片同一套流式下载,进度条画在信息区;关掉灯箱就掐掉在途请求 —— 几 MB 的东西, 用户已经不想看了就别接着下
- 那一格一打开就按原图尺寸占好了位,所以整张图落下来时不会把灯箱重新布局一次。 尺寸不必等整张图:首块字节里的文件头(JPEG 的 SOF、PNG 的 IHDR)一被解析就够了, 而首块本来就要读。量到的尺寸按图片键记着,鼠标在卡片上停够(悬停预取)或看过一次, 下次点开是零等待
- 量不出尺寸就退回改造前的行为(图到了才撑开):格式解不开,或文件头被超长的 EXIF / ICC 顶到了首块之外
窄屏不做另一套页面:同一份 HTML 靠 CSS 重排 —— 顶栏折成两行,网格退到两列,灯箱照常。
![]() |
|
浏览器
│ ① 管理接口(上传 / 列表 / 改分级 / 缩略图…)
▼
┌───────────────────┐ ┌────────────────┐
│ Worker picbed-r2 │──────▶ │ D1 images │ 分级、隐藏、相册、缩略图键
│ picbed-worker. │ └────────────────┘
│ ohtoai.top │
└───────────────────┘
│ ② 写
▼
┌───────────────────┐
│ R2 picbed-images │◀──── ③ 图片直链(绝大多数请求)
└───────────────────┘ img.ohtoai.top / album.ohtoai.top
▲ 走 Cloudflare 边缘缓存,**不经过 Worker**,
│ 也不计 Worker 请求数;R2 出网流量本身免费
访客看图
几个刻意的设计:
- 图片字节不经过 Worker。直链由 R2 桶的自定义域名直接提供,Worker 只管元数据和上传。
is_hidden和rating只影响「相册里列不列」,不拦直链。任何状态的图,拿到直链都能打开 —— 相册是「陈列」,不是「门禁」。想给直链加门禁的话,见worker-r2/src/access.js末尾的扩展位说明。- 两种不可见是独立的:
is_hidden=1(主动隐藏)和rating='unspecified'(未分类)都会让图不进相册, 但原因不同,后台用不同徽标区分。 - 键不可变:每次上传都生成新键,相册名不参与其中。所以移动/重命名相册只改一列数据, 已分享出去的链接永远不会失效。
前端是静态页,后端是 Worker + R2 + D1 —— 整套都能开在自己的 Cloudflare 账号下,仍在免费额度内。
cd worker-r2
npx wrangler login
npx wrangler d1 create picbed-db # 建库,把 database_id 填进 wrangler.toml
npx wrangler d1 execute picbed-db --remote --file=schema.sql
npx wrangler r2 bucket create picbed-images --location apac # 建桶(位置建完不能改)
npx wrangler r2 bucket cors set picbed-images --file=r2-cors.json
npx wrangler secret put PICBED_PASSWORD # 不设的话管理接口一律 503(故意 fail closed)
npx wrangler deploy然后改 config.js 里的三个地址(Worker 地址、图片直链域名、仓库地址),把仓库推到 GitHub
并开启 Pages 即可 —— 工作流已经写好(.github/workflows/pages.yml)。
还要在 Cloudflare 控制台做两件事(wrangler 建不了):给 R2 桶绑自定义域名、
别开 r2.dev 测试域名。
完整步骤、接口说明、配置项、以及「以后给已有的库加列/加索引」都在
worker-r2/README.md。
picbed/
├─ index.html # 前端页面(上传 / 相册 / 公开画廊,纯静态单页)
├─ config.js # 站点配置(后端地址、图片域名、仓库地址、默认界面风格)
├─ docs/preview/ # README 里的预览图
├─ worker-r2/ # 后端:Worker + R2 + D1
│ ├─ src/
│ │ ├─ index.js # 入口与路由
│ │ ├─ access.js # 相册可见性判定(分级参数的唯一解释处)
│ │ ├─ api.js # 上传 / 列表 / 改分级 / 缩略图 / 相册操作 / 转存
│ │ ├─ db.js # D1 读写
│ │ └─ admin.html # 后端自带的精简管理页(应急 + 批量操作用)
│ ├─ schema.sql # 建表
│ └─ README.md # ★ 部署步骤、接口说明、配置项
页面顶部两个标签:公开画廊(游客视角)与我的相册(管理视角)。 本机存过密码的话,打开会直接进管理视图;没密码时点「我的相册」会引导你去设置里填。 存的是密码本身而不是会话,所以换台设备要重填一次 —— 后端只认一个共享密码,没有账号体系。
每张图有两个独立的状态:
| 取值 | 说明 | |
|---|---|---|
rating |
G / R12 / R15 / R18 / unspecified |
unspecified = 未分类,不进任何相册(等于旧版的「未公开」) |
is_hidden |
0 / 1 | 主动隐藏,不进任何相册 |
在管理视图里点图片卡片右下角的图标切换隐藏、改分级走右键菜单或灯箱。
两者都不影响直链 —— 隐藏的图,拿到 URL 的人照样能打开。
访客默认只看到 G。想放宽范围,在地址后面加参数(页面上故意不放控件):
| 参数 | 陈列的分级 |
|---|---|
| (无) | G |
?rating=r12 |
G, R12 |
?rating=r15 |
G, R12, R15 |
?rating=r18 |
G, R12, R15, R18 |
?rating=r15_only |
仅 R15(_only 不累进) |
R15 / R18 默认是糊住的(类似 Twitter 的做法):即使 ?rating= 把它们列了出来,卡片上也是一层遮罩,
点一下才显示 —— 而且被遮住的卡片不会去下载图片,是点到才下。
想整体放开,再加一个安静参数:?reveal=1。
rating 和 reveal 是两件事:前者决定「列不列出来」,后者决定「糊不糊」。
点进任意相册,地址栏里会带上 ?album=<相册名>(如 ?album=旅行/2024),
把这个网址复制给别人,对方打开就直接进那个相册。浏览器的前进/后退键也能在相册之间走。
右键相册卡片 →「复制相册链接」即可。分享链接里不会带上 reveal ——
要不要显示敏感内容由打开的人自己决定。
- 拖拽 / 点击 / Ctrl+V 粘贴,支持批量;拖整个文件夹会递归扫描并保留目录结构
(
A/2024/x.jpg→ 相册A/2024,子文件夹层层照搬,多深都行;最外层那个文件夹名也算一层) - 上传前可在面板里选默认分级(默认
unspecified,即传完不进相册,要手动设过才公开) - 上传时会自动生成缩略图(居中正方形 WebP,512×512);生成失败不影响原图,网格会退回原图
- 单张上限 50MB
- 在上传时由浏览器生成,作为独立对象存进 R2。不是给原图加查询参数 (R2 会忽略未知查询参数、返回原图,那样「缩略图取不到就退回原图」的逻辑永远不会触发)
- 卡片在下载时会显示百分比进度;缩略图没有渐进显形(那是服务端编码能力, canvas 生成的是基线 WebP),原图的渐进显形取决于原文件本身
- 旧的存量图可以用工具栏的 「补缩略图」 批量补:浏览器里逐张拉原图 → 生成 → 上传, 可随时刷新中断,下次接着补
- 相册 = 数据库里的一列,形如
2026/10/03或旅行(可以用/分层) - 新建相册:往目标相册传第一张图,相册就存在了。「新建相册」按钮的作用是先带你进入那个相册
- 重命名和移动是两个入口:重命名只改这一层的名字,移动只换父级、名字不变 (从前合成一个「新的位置(含相册名)」的输入框,改个名字也得先看懂整条路径)。 两者都是整棵子树一条 SQL 搞定,不搬 R2 里的文件,图片直链不变
- 移动时的相册树会自动展开到你当前所在的那一层并标出「当前位置」,不用一层层点开; 顶上还有一行「全部(根目录)」可直接选
- 删除相册:会递归删掉子相册和里面所有图片(确认框会写明数量和体积),不可恢复
右键卡片 →「重命名」,或点开图片后用灯箱里的「重命名」改的是这张图的名字,
它有三个用处:卡片与灯箱的标题、复制 Markdown / HTML 时的 alt、以及下载存盘的文件名。
- 不影响直链:键(URL 里的
2026/10/03/164503_e72bf439.jpg那一段)是不可变的, 改名字属于纯元数据改动,之前分享出去的链接照样有效 - 想让存盘的文件就叫这个名字,用下载按钮(灯箱里和卡片右键菜单里都有):它把字节取回来再存,文件名由前端指定。
右键「图片另存为」做不到这件事 —— 那个名字由浏览器按 URL 决定,服务端影响不了
(
Content-Disposition: inline; filename=不被另存为采纳,而attachment会强制下载、 图片就没法内嵌显示了,对图床是自毁) - 存盘名会净化一层:显示名里的 Windows 非法字符(
"<>|等)换成_; 显示名没写扩展名时,自动补上真实的扩展名(如.jpg)
- 上传面板:右下角悬浮按钮,含进行中的队列、本次结果、上传历史(仅存本机浏览器)
- 多选模式:批量删除、批量移动、批量改分级、批量隐藏
- 复制链接:URL / Markdown / HTML 三种格式(Markdown 与 HTML 的
alt用图片的名字) - 下载:灯箱里的按钮或卡片右键菜单,存盘文件名用图片的名字(详见上面的「图片的名字」)
- 转存:粘贴远程图片 URL,由 Worker 抓取后存进 R2(大小上限与上传相同,15 秒超时)
- 直链卡片显示的是
blob:地址属正常 —— 那是流式下载器把图片读进内存后的临时引用, 要分享请用卡片上的复制按钮
https://picbed-worker.ohtoai.top/admin —— 一个平铺表格,按条件筛选 + 批量改分级/隐藏/删除。
它跟后端同源、不依赖前端和 R2 的 CORS 配置,所以前端出问题时它是应急通道。
日常传图和整理相册请用站点。
| 问题 | 原因 / 解决 |
|---|---|
| 管理接口报 503 | 服务端没设 PICBED_PASSWORD(故意 fail closed) |
| 上传的图不出现在相册里 | 默认分级是 unspecified(不进相册),去改成分级即可 |
| 隐藏了图片但直链还能打开 | 这是设计如此 —— 隐藏只影响相册陈列 |
| 缩略图 404 了一下又正常显示 | 那张图还没补缩略图,前端自动退回原图。点「补缩略图」 |
| 相册里图片加载慢 | 同上:没有缩略图时下载的是原图 |
| 上传报 CORS 错 | 检查 Worker 的 ALLOWED_ORIGINS 是否包含站点域名 |
| 老链接打不开 | album.ohtoai.top 与 img.ohtoai.top 都指向 R2 桶,键名与迁移前一致 |
wrangler … --local 跑完不回命令行 |
wrangler 在 Windows 上回收 workerd 有问题;SQL 已执行完,Ctrl+C 即可 |
用 wrangler r2 object put 传了对象,直链却 404 |
该子命令默认写本地模拟桶,要加 --remote |
- 成本:图片出网流量 0 元(R2 出网免费,这是迁移最大的收益 —— 之前 COS 是 0.5 元/GB)。 存储 644MB 落在 R2 免费额度(10GB)内。Worker / D1 / GitHub Pages 都在免费额度内。
- 密钥:R2 的 API Token 只在迁移时用过(rclone),已可作废;
Worker 侧只有
PICBED_PASSWORD(管理接口的共享密码,wrangler secret)。 - 直链是公开的:图片域名谁拿到 URL 都能打开,隐藏和分级只管相册陈列。 介意的话给 R2 桶开防盗链,或把敏感图放别的桶。
- 管理接口的跨域:只有
ALLOWED_ORIGINS里列出的来源能拿到 CORS 头,且不带 credentials。
本站原先跑在腾讯云 COS 上(图片直连 COS 自定义域名,Worker 只负责签临时密钥, 「公开」靠零字节标记对象实现)。现在已整体迁到 Cloudflare R2 + D1:
- 326 张存量图片已搬到 R2,键名保持不变,
album.ohtoai.top与img.ohtoai.top都指向 R2 桶, 旧链接依然有效 - 公开标记被 D1 里的
rating取代;相册从「目录前缀」变成数据库里的一列 - 迁移脚本、种子 SQL、迁移手册都是一次性的,用完已经从仓库里清掉了(需要时翻 git 历史)
- 腾讯云那边的资源(COS 桶、子账号密钥)尚未删除,作为冷备份保留







