上次分享了一篇使用 cloudflare 作为个人图床的方案 ,但是比较粗糙,仅仅是直接使用 cloudflare 的 R2 的控制台上传图片,自己拼接图片地址,属于能用,但并不好用 的状态。
这次我们更产品一点,做一个可视的个人图床应用,包括图片上传、删除、查看、搜索。如果需要将其真正用于生产,还需要一个域名,因为 cloudflare 的免费 dev 域名是被墙掉的。
最终成品be like:
图床应用-图片列表
图床应用-上传图片
使用到的平台包括 cloudflare 和 GitHub。GitHub 用于存储代码。cloudflare 要使用其如下功能:
Worker 作为计算单元执行存储、查询逻辑
Page 服务作为前端网页托管平台
R2 作为图片存储
D1 作为数据库
对于个人用户,这些服务几乎都是免费的(每日免费额度对个人用户几乎用不完)。
方案架构
注册并创建服务 这并不是一篇面向小白的文章,所以这里假定你已经熟练掌握 GitHub 的使用了。但如果没有使用过 cloudflare ,先去其官网简单创建一个账号。然后开通以下服务:
创建一个 R2 bucket,随便取个名字,比如 image-storage-demo 。创建一个 D1 数据库取名比如 image-storage-record ,创建一张表叫 images ,字段如下:
接下来创建 worker,创建 worker 时需要稍微注意下,要创建两个,分别用于做页面渲染(前端)和业务逻辑(后端)。虽然worker可以同时做前后端,但为了长期的可维护性,这里我们还是区分下前端和服务端。
worker 取个名字比如 image-storage-worker
创建前端项目 在 GitHub 上创建一个仓库,clone到本地,初始化一个前端项目,前端框架这里为了尝鲜,选择了 SolidJS 。关于这个框架的更多信息,可自行查询相关文档。也可以选择自己喜欢的前端框架比如 Vue、React 等,都一样。
npm create vite@latest my-app -- --template solid-ts
即可创建项目。
为了有一些基础的样式,可以使用 tailwindCSS 作为样式库,直接去 tailwindCSS 的官网找 SolidJS 框架的安装指南照着操作即可,嫌麻烦也可以省略,又不是不能用.jpg。
部署前端页面 简单编写一个图片上传组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 import { createSignal } from 'solid-js' function ImageUpload () { const [selectedFile, setSelectedFile] = createSignal (null ) const handleFileChange = (event ) => { const file = event.target .files [0 ] setSelectedFile (file) } const handleUpload = async ( ) => { if (!selectedFile ()) { alert ('Please select a file first' ) return } const formData = new FormData () formData.append ('file' , selectedFile ()) try { const response = await fetch ('https://{上传接口地址}/' , { method : 'POST' , body : formData, }) if (response.ok ) { const result = await response.json () console .log ('result' , result) window .alert ('上传成功' ) } else { console .error ('Upload failed. HTTP status:' , response.status ) } } catch (error) { console .error ('Error during upload:' , error) } } return ( <div class ="max-w-screen-md mx-auto p-4" > <h1 class ="text-2xl font-bold mb-4" > Image Upload</h1 > <input type ="file" accept ="image/*" onChange ={handleFileChange} class ="mb-4" /> <button onClick ={handleUpload} class ="bg-blue-500 text-white py-2 px-4 rounded" > Upload Image </button > </div > ) } export default ImageUpload
注意这里上传图片的接口还没写,等下写完 worker 逻辑再加。引入到 app.ts 里,使用它将其展示出来,即可提交代码到 Github。
这时再去 cloudflare worker page 功能下,选关联已有前端项目,关联这个 solidJS 项目,然后就会自动给分配一个域名并部署,如果你有自己的域名,也可以配置自定义域名,即可无需科学访问。
编写服务端逻辑 需要安装 cloudflare 的命令行工具,叫 wrangler ,具体参考其官方文档。这里简单写下流程:先安装依赖 npm install -g wrangler
,再登录 wrangler login
,接着把项目搞到本地来写 wrangler init --from-dash image-storage-worker
(注意和前面创建 worker 时名字一致),可以额外建立一个 git 仓库存储服务端逻辑代码。
项目到本地后首先编辑 wrangler.toml,加上 你的 R2 bucket 和 D1:
1 2 3 4 5 6 7 [[d1_databases]] binding = "DATABASE" name = "image-storage-record" [[r2_buckets]] binding = 'imageOSS' bucket_name = 'image-storage-demo'
编辑src/index.ts,修改如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 const corsHeaders = { 'Access-Control-Allow-Headers' : '*' , 'Access-Control-Allow-Methods' : '*' , 'Access-Control-Allow-Origin' : '*' , }; export default { async fetch (request, env ) { const requestURL = new URL (request.url ); if (request.method === 'GET' && requestURL.pathname === '/query' ) { return await handleQueryImage (request, env); } if (request.method === 'POST' && requestURL.pathname === '/upload' ) { return await handleImageUpload (request, env); } return new Response ('Invalid request' , { status : 400 }); }, }; const handleImageUpload = async (request, env ) => { const { DATABASE , imageOSS } = env; const formData = await request.formData (); const file = formData.get ('file' ); if (file) { const path = `${file.name} ` ; const imageFullPath = `https://{这里换成你的R2域名}/${path} ` ; await imageOSS.put (path, file); const createdAt = `${+new Date ()} ` ; try { const { success } = await DATABASE .prepare ( `insert into images (imageName, imageUrl, createdAt) values (?, ?, ?)` , ) .bind (path, imageFullPath, createdAt) .run (); return new Response (JSON .stringify ({ url : imageFullPath, success }), { headers : { ...corsHeaders, }, }); } catch (e) { return new Response ( JSON .stringify ({ success : false , error : JSON .stringify (e), }), { headers : { ...corsHeaders, }, }, ); } } }; const handleQueryImage = async (request, env ) => { const { DATABASE } = env; const requestURL = new URL (request.url ); const pageNum = Number (requestURL.searchParams .get ('pageNum' )) || 1 ; const pageSize = Number (requestURL.searchParams .get ('pageSize' )) || 10 ; const offset = (pageNum - 1 ) * pageSize; const sql = `select * from images order by id DESC LIMIT ${pageSize} OFFSET ${offset} ` ; const rows = await DATABASE .batch ([ DATABASE .prepare (sql), DATABASE .prepare (`SELECT COUNT(*) AS total FROM images order by id DESC` ), ]); return new Response ( JSON .stringify ({ results : rows[0 ].results , total : (rows[1 ]?.results && rows[1 ]?.results [0 ]?.total ) || 0 , success : rows[0 ].success && rows[1 ].success , }), { headers : { ...corsHeaders, }, }, ); };
接下来只需要简单的 wrangler deploy
即可部署完成。然后去看下 cloudflare 给你分配的域名,或者是绑定一个自己的自定义域名上去。拿着这个域名我们就可以去连接前后端了。
连接前后端 刚刚前端上传页面留了一个上传地址,填入 https://{刚刚拿到的域名}/upload 即对接好了图片上传,worker收到请求会一边把图片存到 R2 里一边把图片信息记录到 D1 里。 可以尝试下看是否返回了成功。
成功后我们去 cloudflare 的控制台去 D1 里看一眼数据是否入库,再去 R2 里看一眼图片文件是否存在,一切正常后我们再编写一个列表展示页。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 import { createResource, createSignal, For } from 'solid-js' import dayjs from 'dayjs' import copy from 'clipboard-copy' const fetchPics = async ({ pageNum, pageSize } ) => (await fetch (`https://{你的worker地址}/query?pageSize=${pageSize} &pageNum=${pageNum} ` )).json () function ImageHome () { const pageSize = 20 const [pageNo, setPageNo] = createSignal (1 ) const fetchOption = ( ) => { return { pageNum : pageNo (), pageSize, } } const [picList] = createResource (fetchOption, fetchPics) const copyToClipboard = async (text ) => { try { await copy (text) } catch (error) { console .error ('Error copying to clipboard:' , error) } } return ( <div > <div class ="flex p-4 lg:p-10 justify-between lg:pb-0 pb-0 items-center" > <h1 class ="p-4 text-2xl" > 图片列表</h1 > <p > 当前第 {pageNo()} 页,共 {picList.loading ? 'loading' : picList().total} 条数据</p > </div > <div class ="p-4 lg:p-10" > <span > {picList.loading && 'Loading...'}</span > {!picList.loading && ( <div class ="grid 3xl:grid-cols-4 2xl:grid-cols-3 lg:grid-cols-2 sm:grid-cols-1 gap-6 2xl:gap-8 pb-10" > <For each ={picList().results} > {(picItem) => <div class ="flex flex-col overflow-hidden items-center justify-center bg-slate-200 rounded-xl p-0 flex-wrap md:flex-nowrap" > <div class ="flex flex-shrink-0 items-center w-auto h-96 rounded-none mx-auto p-2 justify-center" > <img class ="h-full object-contain" src ={picItem.imageUrl}/ > </div > {/*信息区*/} <div class ="w-full pt-2 pl-5" > <div > 图片名: {picItem.imageName}</div > <div > 创建时间: {dayjs(+picItem.createdAt).format('YYYY/MM/DD HH:mm:ss')}</div > <div > 完整地址: {picItem.imageUrl}</div > </div > {/*操作区*/} <div class ="w-full flex p-3 justify-between" > <div class ="m-2 w-full text-center bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded cursor-pointer" onClick ={() => copyToClipboard(picItem.imageUrl)}>复制 </div > </div > </div > }</For > </div > )} </div > <div class ="fixed bottom-0 left-0 p-4 w-full flex flex-1 mt-8 justify-between bg-white shadow-inner" > {pageNo() > 1 ? ( <p class ="w-22 relative inline-flex justify-center items-center rounded-md border border-gray-300 text-white px-4 py-2 text-sm font-medium bg-blue-500" onClick ={() => (setPageNo((p) => p - 1))} >上一页</p > ) : <p class ="w-20" /> } <p class ="font-medium pt-2" > 第 {pageNo()} 页</p > {!picList.loading && pageNo() * pageSize < picList().total ? ( <p class ="w-20 relative inline-flex justify-center items-center rounded-md border border-gray-300 text-white px-4 py-2 text-sm font-medium bg-blue-500" onClick ={() => (setPageNo((p) => p + 1))} >下一页</p > ) : <p class ="w-20" /> } </div > </div > ) } export default ImageHome
这个组件和图片上传组件可以放在一个页面里,也可以自行查询 SolidJS 关于路由的部分,去分两个页面实现。
本地预览一起正常后,提交代码即可触发自动构建部署,公网访问看看是否一切正常了。
删除图片 就当留个小练习吧,删除逻辑的代码被我隐藏掉了,但是其实很简单,注册个 /delete 路由,接收 id ,编写 sql 即可,请自行实现吧。
结语 好了以上就是全部内容,就可以获得一个10GB的OSS存储空间用于做图床,写博客之类的都可以更方便地贴图了。有问题欢迎留言欢迎交流,如果感兴趣的朋友多,后续可以考虑开源相关代码。