- A+
概要
最近部署了免费图床 Telegraph-Image - Github,再次体验到了白嫖开源的快乐。下面我将介绍了这种利用Telegraph和Cloudflare Pages,无需服务器即可搭建无限空间且完全免费的图床的方法ヽ(´▽`)ノ~
Telegraph搭建步骤
-
前提准备:
-
拥有GitHub和Cloudflare账号(可能需要魔法🧙♀️)。
-
Fork项目:
-
访问Telegraph-Image项目地址,并Fork到自己的GitHub账户。
-
Cloudflare Pages创建项目:
-
登录Cloudflare,选择创建项目,链接到刚刚Fork的GitHub项目。
-
选择对应的仓库(默认为
Telegraph-Image-
),其他默认配置进行部署,完成后会生成一个免费的域名。 -
访问部署页面:
仅仅是上传和访问图片还不能满足我们的需求,如何管理我们的图床呢?
-
访问生成的域名
-
进行图片上传,正常情况下系统会返回图片链接。
-
访问图片链接,若能成功加载,则表明图床功能正常˶╹ꇴ╹˶!
-
后台管理配置:
-
在Cloudflare Pages的项目设置中,创建新的命名空间
img_url
-
绑定KV命名空间
-
设置环境变量,添加管理用户和密码。
注意变量名称需完全相同,且为大写英文字母。
变量名称 值 BASIC_USER <后台管理页面登录用户名称> BASIC_PASS <后台管理页面登录用户密码> -
开启图片审查(避免某些人上传一些奇怪的东西🤔)
-
前往https://moderatecontent.com/ 并用邮箱注册一个免费的用于审查图像内容的 API key
-
复制发到邮箱的
API Key
-
打开 Cloudflare Pages 的管理页面,依次点击
设置
,环境变量
,添加环境变量
添加一个
变量名称
为ModerateContentApiKey
,值
为你刚刚第一步获得的API key
,点击保存
即可 -
重新部署程序
-
在图床网址后面加上
/admin
, 访问图床后台。若弹出登录窗口说明BASIC_USER
和BASIC_PASS
配置成功,登录即可看到自己的图床🎉
图片管理:
-
在后台可查看上传的图片数量,进行复制地址、白名单、黑名单和删除操作。
-
如果遇到上传图片后在后台不可见的情况,需通过URL访问图片一次以解决(可通过PicGo上传解决,见下文)。
如果你想要一个更好看好用的后台,可以将
admin.html
替换为我魔改后的版本:<!DOCTYPE html><html lang="zh"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>ImgTC | Admin</title> <!-- Import CSS --> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/element-ui@2.15.3/lib/theme-chalk/index.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free/css/all.min.css"> <style> body { background: linear-gradient(90deg, #ffd7e4 0%, #c8f1ff 100%); font-family: 'Arial', sans-serif; color: #333; margin: 0; padding: 0; } .header-content { display: flex; justify-content: space-between; align-items: center; padding: 10px 20px; background-color: rgba(255, 255, 255, 0.75); backdrop-filter: blur(10px); border-bottom: 1px solid rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); transition: background-color 0.5s ease, box-shadow 0.5s ease; border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; } .header-content:hover { background-color: rgba(255, 255, 255, 0.85); box-shadow: 0 6px 10px rgba(0, 0, 0, 0.2); } .title { font-size: 1.8em; font-weight: bold; cursor: pointer; transition: color 0.3s ease; color: #333; } .title:hover { color: #B39DDB; /* 使用柔和的淡紫色 */ } .search-card { margin-left: auto; margin-right: 20px; } .stats { font-size: 1.2em; margin-right: 20px; display: flex; align-items: center; background: rgba(255, 255, 255, 0.9); padding: 5px 10px; border-radius: 10px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); transition: background-color 0.3s ease, box-shadow 0.3s ease; color: #333; } .stats .fa-database { margin-right: 10px; font-size: 1.5em; transition: color 0.3s ease; color: inherit; } .stats:hover { background-color: #f0eaf8; /* 使用柔和的淡紫色 */ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15); color: #B39DDB; /* 使用柔和的淡紫色 */ } .stats:hover .fa-database { color: #B39DDB; /* 使用柔和的淡紫色 */ } .header-content .actions { display: flex; align-items: center; gap: 15px; } .header-content .actions i { font-size: 1.5em; cursor: pointer; transition: color 0.3s, transform 0.3s; color: #333; } .header-content .actions i:hover { color: #B39DDB; /* 使用柔和的淡紫色 */ transform: scale(1.2); } .header-content .actions .el-dropdown-link i { color: #333; } .header-content .actions .el-dropdown-link i:hover { color: #B39DDB; /* 使用柔和的淡紫色 */ } .header-content .actions .disabled { color: #bbb; pointer-events: none; } .header-content .actions .enabled { color: #B39DDB; /* 使用柔和的淡紫色 */ } .search-card .el-input__inner { border-radius: 20px; width: 300px; height: 40px; font-size: 1.2em; border: none; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1); transition: width 0.3s; } .search-card .el-input__inner:focus { width: 400px; } .main-container { display: flex; flex-direction: column; padding: 20px; min-height: calc(100vh - 80px); } .content { display: grid; grid-template-columns: repeat(5, 1fr); grid-template-rows: repeat(3, 1fr); gap: 20px; padding: 10px; flex-grow: 1; } .el-card { width: 100%; background: rgba(255, 255, 255, 0.6); border-radius: 8px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); overflow: hidden; position: relative; transition: transform 0.3s ease; } .el-card:hover { transform: scale(1.05); } .el-image { width: 100%; height: 200px; object-fit: cover; transition: opacity 0.3s ease; } .el-image:hover { opacity: 0.8; } .file-info { padding: 10px; background: rgba(0, 0, 0, 0.6); color: white; text-align: center; position: absolute; bottom: 0; left: 0; width: 100%; box-sizing: border-box; display: flex; justify-content: center; align-items: center; } .image-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.6); opacity: 0; transition: opacity 0.3s ease; pointer-events: none; } .el-card:hover .image-overlay { opacity: 1; } .overlay-buttons { display: flex; gap: 10px; pointer-events: auto; } .pagination-container { display: flex; justify-content: center; margin-top: 20px; padding-bottom: 20px; } .el-checkbox { position: absolute; top: 10px; right: 10px; transform: scale(1.5); z-index: 10; } </style></head><body> <div id="app"> <el-container> <el-header> <div class="header-content"> <span class="title" @click="refreshDashboard">Dashboard</span> <div class="search-card"> <el-input v-model="search" size="mini" placeholder="输入关键字搜索"></el-input> </div> <span class="stats"> <i class="fas fa-database"></i> 记录总数量: {{ Number }} </span> <div class="actions"> <el-tooltip content="排序" placement="bottom"> <el-dropdown @command="sort" :hide-on-click="false"> <span class="el-dropdown-link"> <i :class="sortIcon"></i> </span> <el-dropdown-menu slot="dropdown"> <el-dropdown-item command="dateDesc" :class="{ 'el-dropdown-menu__item--selected': sortOption === 'dateDesc' }">按时间倒序</el-dropdown-item> <el-dropdown-item command="nameAsc" :class="{ 'el-dropdown-menu__item--selected': sortOption === 'nameAsc' }">按名称升序</el-dropdown-item> </el-dropdown-menu> </el-dropdown> </el-tooltip> <el-tooltip content="批量复制" placement="bottom"> <i class="fas fa-link" :class="{ disabled: selectedFiles.length === 0 }" @click="handleBatchCopy"></i> </el-tooltip> <el-tooltip content="批量删除" placement="bottom"> <i class="fas fa-trash-alt" :class="{ disabled: selectedFiles.length === 0 }" @click="handleBatchDelete"></i> </el-tooltip> <el-tooltip content="退出登录" placement="bottom"> <i class="fas fa-home" @click="handleLogout"></i> </el-tooltip> </div> </div> </el-header> <el-main class="main-container"> <div class="content"> <template v-for="(item, index) in paginatedTableData" :key="index"> <el-card> <el-checkbox v-model="item.selected"></el-checkbox> <el-image :src="'/file/' + item.name" :preview-src-list="['/file/' + item.name]" fit="cover" lazy></el-image> <div class="image-overlay"> <div class="overlay-buttons"> <el-button size="mini" type="primary" @click.stop="handleCopy(index, item.name)">复制地址</el-button> <el-button size="mini" type="danger" @click.stop="handleDelete(index, item.name)">删除</el-button> </div> </div> <div class="file-info">{{ item.name }}</div> </el-card> </template> </div> <div class="pagination-container"> <el-pagination background layout="prev, pager, next" :total="filteredTableData.length" :page-size="pageSize" @current-change="handlePageChange" :current-page.sync="currentPage"> </el-pagination> </div> </el-main> </el-container> </div> <!-- Import Vue before Element --> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script> <!-- Import JavaScript --> <script src="https://cdn.jsdelivr.net/npm/element-ui@2.15.3/lib/index.js"></script> <script> new Vue({ el: '#app', data: { Number: 0, showLogoutButton: false, tableData: [], search: '', currentPage: 1, pageSize: 15, selectedFiles: [], sortOption: 'dateDesc', isUploading: false }, computed: { filteredTableData() { return this.tableData.filter(data => !this.search || data.name.toLowerCase().includes(this.search.toLowerCase())); }, paginatedTableData() { const sortedData = this.sortData(this.filteredTableData); const start = (this.currentPage - 1) * this.pageSize; const end = start + this.pageSize; return sortedData.slice(start, end); }, sortIcon() { return this.sortOption === 'dateDesc' ? 'fas fa-sort-amount-down' : 'fas fa-sort-alpha-up'; } }, watch: { tableData: { handler(newData) { this.selectedFiles = newData.filter(file => file.selected); }, deep: true }, sortOption(newOption) { localStorage.setItem('sortOption', newOption); } }, methods: { refreshDashboard() { location.reload(); }, handleDelete(index, key) { this.$confirm('此操作将永久删除该文件, 是否继续?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { fetch(`./api/manage/delete/${key}`, { method: 'GET', credentials: 'include' }) .then(response => response.ok ? this.tableData.splice(index, 1) : Promise.reject()) .then(() => { this.updateStats(); this.$message.success('删除成功!'); }) .catch(() => this.$message.error('删除失败,请检查网络连接')); }).catch(() => this.$message.info('已取消删除')); }, handleBatchDelete() { this.$confirm('此操作将永久删除选中的文件, 是否继续?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { const promises = this.selectedFiles.map(file => fetch(`./api/manage/delete/${file.name}`, { method: 'GET', credentials: 'include' })); Promise.all(promises) .then(results => { results.forEach((response, index) => { if (response.ok) { const fileIndex = this.tableData.findIndex(file => file.name === this.selectedFiles[index].name); if (fileIndex !== -1) { this.tableData.splice(fileIndex, 1); } } }); this.selectedFiles = []; this.updateStats(); this.$message.success('批量删除成功!'); }) .catch(() => this.$message.error('批量删除失败,请检查网络连接')); }).catch(() => this.$message.info('已取消批量删除')); }, handleBatchCopy() { const links = this.selectedFiles.map(file => `${document.location.origin}/file/${file.name}`).join('\n'); navigator.clipboard ? navigator.clipboard.writeText(links).then(() => this.$message.success('批量复制链接成功~')) : this.copyToClipboardFallback(links); }, copyToClipboardFallback(text) { const textarea = document.createElement('textarea'); document.body.appendChild(textarea); textarea.style.position = 'fixed'; textarea.style.clip = 'rect(0 0 0 0)'; textarea.style.top = '10px'; textarea.value = text; textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); this.$message.success('批量复制链接成功~'); }, handleLogout() { window.location.href = '/'; }, handleCopy(index, key) { const text = `${document.location.origin}/file/${key}`; navigator.clipboard ? navigator.clipboard.writeText(text).then(() => this.$message.success('复制文件链接成功~')) : this.copyToClipboardFallback(text); }, handlePageChange(page) { this.currentPage = page; }, updateStats() { this.Number = this.tableData.length; }, sort(command) { this.sortOption = command; }, sortData(data) { return this.sortOption === 'nameAsc' ? data.sort((a, b) => a.name.localeCompare(b.name)) : data.sort((a, b) => b.metadata.TimeStamp - a.metadata.TimeStamp); } }, mounted() { fetch("./api/manage/check", { method: 'GET', credentials: 'include' }) .then(response => response.text()) .then(result => result === "true" ? this.showLogoutButton = true : window.location.href = "./api/manage/login") .catch(() => this.$message.error('同步数据时出错,请检查网络连接')); fetch("./api/manage/list", { method: 'GET', credentials: 'include' }) .then(response => response.json()) .then(result => { this.tableData = result.map(file => ({ ...file, selected: false })); this.updateStats(); const savedSortOption = localStorage.getItem('sortOption'); if (savedSortOption) { this.sortOption = savedSortOption; } this.sortData(this.tableData); }) .catch(() => this.$message.error('同步数据时出错,请检查网络连接')); } }); </script></body></html>123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
效果如图😯:
注意事项
-
每日限制:
-
使用Cloudflare的免费资源,每日读取次数有限制(100,000次),删除操作有限制(1,000次)。
-
图片大小限制:
-
Telegraph限制上传的图片大小最大为5MB。
-
更新程序:
-
当项目作者更新代码后,通过Sync fork->Update branch进行同步更新,Cloudflare会自动重新部署。
-
其他:
-
遇到问题请查看 Telegraph-Image官方文档-Github
在Typora使用图床
为了在Typora中方便地使用我们搭建的图床,可以将PicGo与Typora结合使用,实现自动上传图片至Telegraph,并生成图片链接。
配置PicGo
-
下载并安装PicGo。
-
打开PicGo,选择“插件设置”,搜索并安装插件
telegraph-image-uploader
-
点击“图床设置”,选择刚刚下载的插件
telegraph-image
,将URL设为你的图床网址(如"https:// xxx.pages.dev",无需后缀),点击确定并设为默认图床
即使你不使用Typora,也可以使用PicGo的窗口上传图片,上传的图片无需手动访问即可在后台显示,且支持批量上传及图片Markdown格式的复制。
配置Typora
-
打开Typora,选择
文件->偏好设置->图像
-
勾选“上传图片”,选择“PicGo(app)”,并填入PicGo的安装路径(即以PicGo.exe的文件路径),完成后重启Typora(请通过在Typora粘贴图片的方式测试是否成功上传,这样图片会自动加载一遍,稍候片刻就可以在后台看到)
-
现在,您在Typora中插入图片时,PicGo会自动将图片上传到Telegraph,并返回图片链接🎉
- 我的微信
- 这是我的微信扫一扫
- 我的微信公众号
- 我的微信公众号扫一扫