之前用 Telegraph-Image 的图床,好用是好用,但是依赖 TG,万一 TG 号被封号了,一切完蛋。这次借助 gemini 搓了个仅依赖 CF 的图闯关,还真有点意思。先贴步骤:
核心思路
我们将利用 Cloudflare Workers (无服务器代码) 搭建一个简易的网页,这个网页不仅长得像个上传工具,背后的代码还能帮你把图片存进 R2 存储桶,并自动吐出 Markdown 链接。
第一步:准备 R2 存储桶(如果还没建)
- 登录 Cloudflare 后台。
- 左侧菜单栏点击 R2。
- 点击 创建存储桶 ,起个名字(比如 my-blog-imgs),点击 创建存储桶。
- ⚠️ 关键:绑定域名(为了图片能公开访问且免费)
- 进入刚才建好的桶,点击上方的 设置 标签。
- 向下滚动找到 公开访问 区域里的 自定义域。
- 点击 连接域,输入一个你自己的二级域名(例如 img.yourdomain.com),按照提示保存。
- (状态显示“有效”后,你的图片链接就是 https://img.yourdomain.com/ 文件名)。
第二步:创建 Worker(上传工具的后端)
- 左侧菜单栏点击 Workers 和 Pages。
- 点击 创建应用程序 按钮。
- 点击 创建 Worker 按钮。
- 名称可以保持默认或改为 r2-uploader,点击 部署。
- 现在的页面是“恭喜!Worker 已部署”,先别急着编辑代码,我们要先去设置权限。
第三步:绑定 R2 和设置密码(最重要的一步)
点击刚才创建的 Worker 页面中的 设置 标签,然后选择 变量。
1. 绑定 R2 存储桶(让代码能读写你的桶)
- 找到 R2 存储桶绑定 区域,点击 添加绑定。
- 变量名称 :填入 IMG_BUCKET ( 必须填这个,一字不差,因为代码里要用)。
- R2 存储桶:在下拉菜单中选择你在第一步创建的那个桶(如 my-blog-imgs)。
- 点击 部署 保存。
2. 设置上传密码(防止陌生人乱传图)
- 还在 变量 页面,找到上面的 环境变量 区域,点击 添加变量。
- 变量名称:填入 UPLOAD_TOKEN。
- 值:设置一个属于你的密码(比如 mima123456)。
- 点击 部署 保存。
第四步:写入代码
- 点击页面上方的 编辑代码 按钮。
- 你会看到左侧有一个 worker.js 文件,把里面 所有 的代码全删掉。
- 复制并粘贴 以下代码:
| /** |
| * Cloudflare R2 图床 (修复中文文件名报错版) |
| */ |
| export default {async fetch(request, env) {const url = new URL(request.url); |
| // = 1. 后端逻辑 = |
| if (request.method === ‘POST’) {const auth = request.headers.get(‘Authorization’); |
| if (auth !== env.UPLOAD_TOKEN) {return new Response(’❌ 密码错误’, { status: 403}); |
| } |
| // 🛑 修复点 1:获取文件名时进行解码 |
| // 如果客户端发来的是 “%E6%B5%8B.png”,这里还原成 “测.png” |
| let rawName = request.headers.get(‘File-Name’); |
| let filename = ‘unknown.png’; |
| try {filename = decodeURIComponent(rawName); |
| } catch (e) {filename = rawName; // 如果解码失败,就用原始的} |
| const fileExt = filename.split(’.‘).pop(); |
| const date = new Date(); |
const path = ${date.getFullYear()}/${(date.getMonth()+1).toString().padStart(2,'0')}; |
| // 生成随机文件名,保留原后缀 |
const randomName = ${Date.now()}-${Math.random().toString(36).substring(7)}.${fileExt}; |
const finalPath = ${path}/${randomName}; |
| await env.IMG_BUCKET.put(finalPath, request.body); |
| // ⚠️⚠️⚠️【请修改这里】⚠️⚠️⚠️ |
| // 换成你的自定义域名 |
| const r2Domain = ‘https://img.yourdomain.com’; |
return new Response(${r2Domain}/${finalPath}); |
| } |
| // = 2. 前端界面 = |
| return new Response(html, {headers: { ‘Content-Type’: ‘text/html;charset=UTF-8’}, |
| }); |
| }, |
| }; |
| // 修复后的 HTML 界面 |
| const html = ` |
| :root {—primary: 4f46e5; —primary-hover: 4338ca; —bg-gradient: linear-gradient(135deg, 667eea 0%, 764ba2 100%); —card-bg: rgba(255, 255, 255, 0.95); } |
| body {font-family: ‘Inter’, system-ui, -apple-system, sans-serif; background: var(—bg-gradient); height: 100vh; margin: 0; display: flex; justify-content: center; align-items: center; color: 1f2937; } |
| .container {width: 90%; max-width: 450px; background: var(—card-bg); backdrop-filter: blur(10px); border-radius: 20px; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); padding: 2rem; } |
| .header {text-align: center; margin-bottom: 1.5rem;} |
| .header h1 {margin: 0; font-size: 1.5rem; color: #111827;} |
| .header p {margin: 5px 0 0; color: 6b7280; font-size: 0.9rem;} |
| .auth-group input {width: 100%; padding: 12px 16px; border: 1px solid e5e7eb; border-radius: 10px; font-size: 14px; box-sizing: border-box; background: f9fafb; outline: none; margin-bottom: 1.5rem;} |
| .upload-zone {border: 2px dashed e5e7eb; border-radius: 16px; padding: 2.5rem 1.5rem; text-align: center; cursor: pointer; transition: all 0.3s; background: f9fafb;} |
| .upload-zone:hover, .upload-zone.dragover {border-color: var(—primary); background: eef2ff; transform: scale(1.01); } |
| .upload-zone svg {width: 48px; height: 48px; color: 9ca3af; margin-bottom: 10px;} |
| result-area {display: none; margin-top: 1.5rem; animation: fadeIn 0.4s ease;} |
| @keyframes fadeIn {from { opacity: 0; transform: translateY(10px); } to {opacity: 1; transform: translateY(0); } } |
| .preview-img {width: 100%; height: 160px; object-fit: cover; border-radius: 10px; border: 1px solid e5e7eb; margin-bottom: 1rem;} |
| .link-group {display: flex; gap: 10px;} |
| .btn {flex: 1; padding: 10px; border: none; border-radius: 8px; cursor: pointer; display: flex; justify-content: center; align-items: center; gap: 6px; font-size: 0.9rem;} |
| .btn-primary {background: var(—primary); color: white; } |
| .btn-outline {background: white; border: 1px solid e5e7eb; color: #374151;} |
| status-msg {margin-top: 10px; text-align: center; font-size: 0.9rem; min-height: 20px;} |
| .error {color: ef4444;} .success {color: 10b981;} .loading {color: var(—primary); } |
☁️ R2 图床拖拽图片 / Ctrl+V 粘贴 / 点击上传 |
点击选择图片 |
| const tokenInput = document.getElementById(‘token’); |
| const dropZone = document.getElementById(‘dropZone’); |
| const fileInput = document.getElementById(‘fileInput’); |
| const statusMsg = document.getElementById(‘status-msg’); |
| let imgUrl = ”, mdUrl =”; |
| tokenInput.value = localStorage.getItem(‘r2_token’) | ”; |
| tokenInput.addEventListener(‘input’, () => localStorage.setItem(‘r2_token’, tokenInput.value)); |
| dropZone.onclick = () => fileInput.click(); |
| fileInput.onchange = (e) => handleUpload(e.target.files[0]); |
| dropZone.ondragover = (e) => {e.preventDefault(); dropZone.classList.add(‘dragover’); }; |
| dropZone.ondragleave = () => dropZone.classList.remove(‘dragover’); |
| dropZone.ondrop = (e) => {e.preventDefault(); dropZone.classList.remove(‘dragover’); handleUpload(e.dataTransfer.files[0]); }; |
| document.onpaste = (e) => {const item = e.clipboardData.items[0]; if (item && item.kind === ‘file’) handleUpload(item.getAsFile()); }; |
| async function handleUpload(file) {if (!file) return; |
| if (!file.type.startsWith(‘image/’)) return showStatus(‘⚠️ 只能上传图片’, ‘error’); |
| if (!tokenInput.value) {shake(tokenInput); return showStatus(’🔐 请输入密码’, ‘error’); } |
| document.getElementById(‘result-area’).style.display = ‘none’; |
| showStatus(’⏳ 上传中…’, ‘loading’); |
| try { |
| // 🛑 修复点 2:使用 encodeURIComponent 对文件名编码 |
| // 这样 “微信.png” 会变成 “%E5%BE%AE%E4%BF%A1.png”,浏览器就不会报错了 |
| const res = await fetch(’/’, { |
| method: ‘POST’, |
| headers: { |
| ‘Authorization’: tokenInput.value, |
| ‘File-Name’: encodeURIComponent(file.name) |
| }, |
| body: file |
| }); |
| if (!res.ok) throw new Error(await res.text()); |
| const url = await res.text(); |
| showSuccess(url); |
| } catch (err) {showStatus(’❌ 失败:’ + err.message, ‘error’); |
| } |
| fileInput.value = ”; |
| } |
function showSuccess(url) {imgUrl = url; mdUrl = \\\; |
| document.getElementById(‘preview’).src = url; |
| document.getElementById(‘result-area’).style.display = ‘block’; |
| showStatus(’✅ 上传成功’, ‘success’); |
| } |
| function showStatus(text, type) {statusMsg.innerText = text; statusMsg.className = type;} |
| function copyText(text, btn) {navigator.clipboard.writeText(text).then(() => { const old = btn.innerText; btn.innerText = ’✅ 已复制’; setTimeout(() => btn.innerText = old, 1500); }); } |
| function shake(el) {el.animate([{transform:‘translateX(0)’},{transform:‘translateX(-10px)’},{transform:‘translateX(10px)’},{transform:‘translateX(0)’}],{duration:300}); el.focus();} |
| `; |
- 修改一处代码 :找到代码中写着 const r2Domain = ‘https://img.yourdomain.com‘; 的那一行。 一定要把它改成你在第一步里绑定的域名(例如 https://img.zhangsan.com)。
- 点击右上角的 部署。
第五步:如何使用?
- 部署成功后,你会看到 Cloudflare 给你提供了一个类似 https://r2-uploader.xxxx.workers.dev 的链接。
- 点击打开这个链接,这就是你的图床界面了(建议收藏到浏览器书签)。
- 首次使用:
- 在输入框里填入你在“环境变量”里设置的那个密码。
- 密码会自动保存在浏览器缓存里,下次不用输了。
- 上传方式:
- 写文章时截个图,切到这个网页,直接按 Ctrl + V 粘贴。
- 或者把图片文件 拖进去。
- 获取链接:
- 上传完成后,点击“复制 Markdown 链接”,然后回到你的文章编辑器里粘贴即可。
常见问题排错
- 报错 403 / 密码错误:检查你在网页填的密码,和在 Worker 设置 -> 变量 -> 环境变量里填的 UPLOAD_TOKEN 是否一致。
- 报错 500 / Internal Server Error:
- 检查 Worker 设置 -> 变量 -> R2 存储桶绑定,变量名是不是 IMG_BUCKET(全大写)。
- 检查代码里的 r2Domain 有没有改成你自己的域名。
- 图片能上传但链接打不开:
- 去 R2 存储桶的设置里,检查“自定义域”是不是状态正常的。
- 不要用 Cloudflare 给的 r2.dev 结尾的域名,那个国内访问慢且有次数限制。