从零搭建个人免费GitHub文件床完整保姆教程
整体方案概述
全套零成本:免费三级域名+ GitHub公开仓库存储 + 前端加密上传页面,实现:
- 本地7天免输访问密码
- 移动端卡片适配、PC表格双布局
- 国内CDN主链接+GitHub兜底双链接复制
- WebP自动压缩、批量上传、历史记录本地缓存
- Cloudflare无限流量分流,规避GitHub月度带宽限流(可选)
前置准备账号
- GitHub账号(免费):用于存储文件、生成上传令牌PAT
第一部分:申请免费三级域名
- 前往我的全球域名后缀速查页面 https://www.leoi.org/rizhiji/yvming
- 找一个你喜欢的机构按要求申请免费三级域名(尽量选推荐的,国内可用),不会申请可以去日志搜教程
- 将你申请的域名绑定你的 用户名.github.to 仓库(建仓库教程: github仓库链接规则)
- 先保存域名信息,前端页面制作完成后再回来上传HTML文件部署
第二部分:GitHub仓库与上传令牌PAT配置
步骤1:创建文件存储仓库
- GitHub右上角「+」→ New repository
- 仓库名称自定义(例如
file-bed) - 可见性必须选择 Public 公开仓库(私有仓库无法外链访问)
- 勾选 Add a README file,点击 Create repository
- 记录仓库信息:
你的GitHub用户名/仓库名,后续填入前端代码
步骤2:生成上传权限PAT令牌(核心密钥)
- GitHub右上角头像 → Settings(设置)
- 左侧拉到最底部:Developer settings(开发者设置)
- Personal access tokens → Tokens (classic) → Generate new token (classic)
- 配置参数:
- Note:填写
文件床上传密钥(备注用途) - Expiration:选择 No expiration(永不过期,自用推荐)
- 权限Scope:只勾选 repo 全部分类(必须,读写仓库文件)
- Note:填写
- 滑到页面最底部点击 Generate token
- 立刻复制生成的ghp_开头密钥,关闭页面后无法再次查看,妥善保存
第三部分:前端页面代码配置(优化的完整HTML)
步骤1:修改代码内4处核心配置
打开文末完整HTML代码,找到顶部配置区,修改4项内容:
const GITHUB_CONFIG = {
repo: "你的GitHub用户名/存储仓库名",
branch: "main", // ← 一般不用改
imgDir: "图片文件夹", // ← 需要说明是哪个文件夹
fileDir: "通用文件夹", // ← 需要说明是哪个文件夹
customDomain: "你的三级域名", // ← Cloudflare免费域名
rawDomain: "保底的仓库链接", // ← 即 用户名.github.io
encryptedToken: "控制台输出的密钥" // ← 加密后的密文
};
const DECRYPT_PASSWORD = "你自定义的页面解锁密码";
步骤2:加密你的GitHub PAT密钥(防止前端明文泄露)
加密工具(直接复制运行)
打开任意浏览器F12控制台,粘贴以下代码:
const token = "这里填你的完整令牌";
// 你自己固定的解锁密码(之后访问页面要输入这个)
const pass = "你自定义一串密码,比如Leo123456"; //这个 pass 必须和 DECRYPT_PASSWORD 完全一致,否则解密失败。
function simpleEncrypt(str, key) {
let res = "";
for(let i=0;i<str.length;i++){
const code = str.charCodeAt(i) ^ key.charCodeAt(i % key.length);
res += String.fromCharCode(code);
}
return btoa(res);
}
console.log(simpleEncrypt(token, pass));
- 运行后控制台输出一串密文,复制填入代码内
encryptedToken - 原理:前端不存放明文Token,必须输入正确页面密码才能解密上传,防止密钥泄露
步骤3:保存HTML文件
将修改完成的代码另存为 index.html,不要改名
第四部分:部署前端页面(绑定免费三级域名)
*文件树结构(创建文件夹方法,/file/x.txt ,意思就是搞个空文件占位建个文件夹)
├── /
│ ├── index.html //这个文件部署你的文件床程序,就是文末的整个代码
│ └── 图片文件夹(自己取名) //存图片
└────── 通用文件夹 //存其他文件
- 回到github你创建的仓库页面
- 点击上传本地
index.html文件到根文件夹 - 点击【部署站点】,等待一段时间完成
- 访问对应域名即可打开你的文件床页面
第五部分:页面完整功能使用说明
1. 解锁验证(7天免重复输入)
- 首次打开强制弹出解锁弹窗
- 输入自定义密码验证成功后,本地存储7天有效期凭证,7天内打开页面直接进入
- 密码错误弹窗内红色文字提示,自动清空输入框重新填写
2. 上传功能
- 支持点击上传框/拖拽文件批量上传,单次最多50个文件
- 图片自动压缩为WebP格式(最大2000px宽高,大幅节省仓库容量与流量)
- 单文件上限25MB(GitHub API硬性限制),超过无法上传
- 文件自动命名格式:
Leo_时间戳_4位随机哈希_原文件名,避免重名覆盖(你可以自定义)
3. 历史记录布局
PC端(≥768px)表格布局
四列:操作按钮 | 缩略图 | 你的链接 | GitHub兜底链接
- 主「复制」按钮:复制你的三级域名链接(默认使用)
- 兜底栏附带单独复制按钮,手动复制github.io原始链接
移动端(<768px)卡片流式布局
- 卡片第一行:复制/删除按钮 + 图片缩略图(左按钮、右预览)
- 第二行:大号主链接(国内CDN)
- 第三行:小字灰色兜底链接 + 单独复制文字按钮
- 解决移动端表格横向溢出堆叠错乱问题
4. 配套功能
- 深色/浅色主题切换,全局样式统一适配
- 图片点击弹窗放大预览
- 本地持久化历史记录,清除浏览器缓存才会重置
- 上传失败中文提示(仓库满、API限流、密码错误分类提示)
第六部分:扩容永久存储方案(仓库满了无限叠加)
1. 单仓库容量上限
GitHub公开Pages仓库推荐单仓≤1GB(但其实没硬性限制),接近900MB时准备分仓扩容
2. 旧仓库永久留存
- 克隆完整备份旧仓库(永久保存所有文件与历史)
- 旧仓库不再新增文件,原有图片链接永久有效,不会失效
3. 新建扩容仓库
- 重复第二部分步骤,新建第二个公开仓库,生成新PAT
- 复制一份前端HTML,修改仓库与加密Token配置,部署新Pages页面
- 多仓库独立1GB容量,理论无限叠加存储
扩容注意风控
不要一次性创建十几个仓库纯存图片,缓慢分多年扩容,避免GitHub判定滥用网盘
第七部分:限制与避坑指南(自用完全够用)
1. 带宽限制
如果觉得上传慢或者访问慢就去学我之前的Cloudflare做的github图床教程:小白零成本永久个人图床教程
GitHub原生Pages每月全账号共享100GB流量(你不是每天蹲在github上的大佬的话完全够你用了)
如果用cf的方案,流量全部走Cloudflare无限免费带宽,可以无视GitHub流量限制
2. API上传限流
每小时最多5000次上传请求,个人日常截图、文档完全碰不到上限
3. 合规红线
自己注意用就行,没啥红线。
完整可直接部署HTML代码(index.html)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>个人图床/文件床</title>
<style>
:root {
--bg-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--container-bg: #ffffff;
--text-primary: #1a202c;
--text-secondary: #4a5568;
--text-muted: #718096;
--bg-light: #f7fafc;
--bg-hover: #fff0f8;
--border-color: #e2e8f0;
--help-modal-bg: #ffffff;
--shadow-color: rgba(0, 0, 0, 0.3);
--toast-bg: #1a202c;
}
body.dark-mode {
--bg-gradient: linear-gradient(135deg, #2d1b4e 0%, #1a0a2e 100%);
--container-bg: #1e1e2e;
--text-primary: #ffffff;
--text-secondary: #e0e0e0;
--text-muted: #a0a0a0;
--bg-light: #2a2a3e;
--bg-hover: #3a3a4e;
--border-color: #404050;
--help-modal-bg: #1e1e2e;
--shadow-color: rgba(0, 0, 0, 0.6);
--toast-bg: #2a2a3e;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Georgia", "Noto Serif SC", "STSong", "Songti SC", "SimSun", "KaiTi", "楷体", serif;
background: var(--bg-gradient);
min-height: 100vh;
padding: 20px;
position: relative;
}
.help-btn {
position: fixed;
top: 20px;
right: 20px;
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
border: none;
font-size: 24px;
font-weight: bold;
cursor: pointer;
box-shadow: 0 4px 12px rgba(240, 147, 251, 0.4);
transition: all 0.3s ease;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
}
.help-btn:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(240, 147, 251, 0.6);
}
.theme-btn {
position: fixed;
top: 20px;
right: 80px;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--container-bg);
color: var(--text-primary);
border: 2px solid var(--border-color);
font-size: 24px;
cursor: pointer;
box-shadow: 0 4px 12px var(--shadow-color);
transition: all 0.3s ease;
z-index: 1001;
display: flex;
align-items: center;
justify-content: center;
}
.theme-btn:hover {
transform: scale(1.1);
}
.unlock-modal {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.unlock-box {
background: var(--container-bg);
width: 100%;
max-width: 420px;
border-radius: 20px;
padding: 32px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
animation: scaleIn 0.3s ease;
}
.unlock-title {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
text-align: center;
margin-bottom: 24px;
}
.unlock-input {
width: 100%;
padding: 14px 16px;
border: 2px solid var(--border-color);
border-radius: 12px;
font-size: 16px;
color: var(--text-primary);
background: var(--bg-light);
outline: none;
transition: 0.2s;
margin-bottom: 12px;
}
.unlock-input:focus {
border-color: #f093fb;
}
.unlock-tip {
font-size: 14px;
text-align: center;
margin-bottom: 20px;
height: 20px;
}
.tip-error {
color: #ef4444;
}
.tip-success {
color: #48bb78;
}
.unlock-btn {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
cursor: pointer;
transition: 0.2s;
}
.unlock-btn:active {
transform: scale(0.98);
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.help-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 20px;
}
.help-modal.show {
display: flex;
}
.help-content {
background: var(--help-modal-bg);
border-radius: 20px;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px var(--shadow-color);
animation: scaleIn 0.3s ease;
}
.help-header {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
padding: 24px;
border-radius: 20px 20px 0 0;
color: white;
position: sticky;
top: 0;
}
.help-header h2 {
font-size: 24px;
font-weight: 700;
margin-bottom: 4px;
}
.help-close {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.help-close:hover {
background: rgba(255, 255, 255, 0.4);
}
.help-body {
padding: 24px;
}
.help-section {
margin-bottom: 24px;
}
.help-section h3 {
font-size: 18px;
font-weight: 600;
color: #1a202c;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.help-section p {
color: #4a5568;
line-height: 1.7;
font-size: 14px;
}
.feature-list {
list-style: none;
padding: 0;
margin: 12px 0;
}
.feature-list li {
padding: 10px 12px;
background: #f7fafc;
border-radius: 10px;
margin-bottom: 8px;
color: #4a5568;
font-size: 14px;
display: flex;
align-items: center;
gap: 10px;
}
.feature-list li::before {
content: '✓';
color: #48bb78;
font-weight: bold;
}
.compression-info {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 12px 0;
}
.compression-item {
background: linear-gradient(135deg, #f093fb10 0%, #f5576c10 100%);
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
text-align: center;
}
.compression-item h4 {
font-size: 14px;
font-weight: 600;
color: #1a202c;
margin-bottom: 8px;
}
.compression-item p {
font-size: 12px;
color: #718096;
}
.container {
background: var(--container-bg);
border-radius: 24px;
box-shadow: 0 20px 60px var(--shadow-color);
width: 100%;
max-width: 1000px;
margin: 0 auto;
padding: 48px;
animation: slideUp 0.5s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.header {
text-align: center;
margin-bottom: 40px;
}
.header h1 {
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
}
.header p {
color: var(--text-muted);
font-size: 16px;
}
.upload-section {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 40px;
}
.upload-area {
flex: 1;
min-width: 280px;
border: 3px dashed var(--border-color);
border-radius: 16px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: var(--bg-light);
position: relative;
}
.upload-area:hover {
border-color: #f093fb;
background: #fff0f8;
}
.upload-area.dragover {
border-color: #f093fb;
background: #fff0f8;
transform: scale(1.02);
}
.upload-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
.upload-area:hover .upload-icon {
transform: scale(1.1);
}
.upload-icon svg {
width: 40px;
height: 40px;
fill: white;
}
.upload-text {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.upload-subtext {
font-size: 14px;
color: var(--text-muted);
}
.upload-input {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.options {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-group-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
margin-bottom: 4px;
}
.mode-buttons {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.mode-btn {
flex: 1;
padding: 10px;
border: 2px solid var(--border-color);
border-radius: 10px;
background: var(--container-bg);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
}
.mode-btn.active {
border-color: #f093fb;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.compression-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.compression-option {
padding: 10px 16px;
border: 2px solid var(--border-color);
border-radius: 10px;
background: var(--container-bg);
color: var(--text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
justify-content: space-between;
align-items: center;
}
.compression-option:hover {
border-color: var(--border-color);
background: var(--bg-light);
}
.compression-option.active {
border-color: #48bb78;
background: #f0fdf4;
color: #22543d;
}
.compression-option .hint {
font-size: 11px;
color: var(--text-muted);
}
.compression-option.active .hint {
color: #68d391;
}
.compression-hide {
opacity: 0.4;
pointer-events: none;
}
.pending-section {
background: var(--bg-light);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
display: none;
}
.pending-section.active {
display: block;
}
.pending-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.pending-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.pending-count {
font-size: 14px;
color: var(--text-muted);
}
.pending-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
max-height: 200px;
overflow-y: auto;
}
.pending-file-item {
position: relative;
background: white;
border-radius: 8px;
overflow: hidden;
padding: 12px;
aspect-ratio: auto;
min-height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.pending-file-item .filename {
font-size: 12px;
color: #1a202c;
word-break: break-all;
text-align: center;
margin-top: 6px;
}
.pending-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.pending-item .remove {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: rgba(245, 101, 101, 0.9);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0;
transition: opacity 0.2s;
}
.pending-item:hover .remove {
opacity: 1;
}
.pending-item .size {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 11px;
padding: 4px 8px;
text-align: center;
}
.action-bar {
margin-top: 20px;
text-align: center;
}
.btn {
padding: 12px 32px;
border: none;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(240, 147, 251, 0.4);
}
.btn-primary:disabled {
background: #94a3b8;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
vertical-align: middle;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.progress-wrap {
margin-top: 16px;
display: none;
width: 100%;
}
.progress-wrap.show {
display: block;
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--border-color);
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-bar-inner {
height: 100%;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
width: 0%;
transition: width 0.3s ease;
}
.progress-status {
font-size: 14px;
color: var(--text-secondary);
text-align: center;
}
.history-section {
margin-top: 40px;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.history-title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
}
.history-count {
font-size: 14px;
color: var(--text-muted);
background: var(--bg-light);
padding: 6px 12px;
border-radius: 20px;
}
.history-table {
width: 100%;
border-collapse: collapse;
background: var(--container-bg);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px var(--shadow-color);
}
.history-table th {
background: var(--bg-light);
padding: 16px 12px;
text-align: left;
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--border-color);
}
.history-table td {
padding: 12px;
border-bottom: 1px solid var(--border-color);
vertical-align: middle;
}
.history-table tr:hover {
background: var(--bg-light);
}
.history-table tr:last-child td {
border-bottom: none;
}
.thumb-cell {
width: 80px;
text-align: center;
}
.thumb-img {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s;
}
.thumb-img:hover {
transform: scale(1.1);
}
.file-thumb {
width: 60px;
height: 60px;
background: #ddd;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #666;
}
.link-cell {
max-width: 400px;
}
.link-text {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: var(--text-secondary);
word-break: break-all;
line-height: 1.5;
}
.action-cell {
width: 180px;
text-align: center;
}
.btn-small {
padding: 6px 16px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
margin: 0 4px;
}
.btn-copy {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.btn-copy:hover {
background: linear-gradient(135deg, #f5576c 0%, #f093fb 100%);
}
.btn-copy.copied {
background: #48bb78;
}
.btn-delete {
background: #fee;
color: #ef4444;
border: 1px solid #feb2b2;
}
.btn-delete:hover {
background: #fee2e2;
}
.empty-history {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
.empty-text {
font-size: 16px;
}
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(100px);
background: var(--toast-bg);
color: white;
padding: 16px 32px;
border-radius: 12px;
font-weight: 500;
opacity: 0;
transition: all 0.3s ease;
z-index: 1000;
}
.toast.show {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.toast.success {
background: #48bb78;
}
.toast.error {
background: #f56565;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
display: none;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 20px;
}
.modal.show {
display: flex;
}
.modal img {
max-width: 95%;
max-height: 95%;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.page-lock {
pointer-events: none;
opacity: 0.35;
user-select: none;
}
.history-card-wrap {
display: none;
gap: 16px;
flex-direction: column;
}
.history-card {
background: var(--bg-light);
border-radius: 16px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.card-top-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
justify-content: space-between;
}
.card-btn-group {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.card-thumb {
width: 60px;
height: 60px;
border-radius: 8px;
object-fit: cover;
cursor: pointer;
}
.card-main-link {
word-break: break-all;
font-family: monospace;
font-size: 12px;
color: var(--text-primary);
padding: 8px 10px;
background: var(--container-bg);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.card-main-link .copy-link-btn {
flex-shrink: 0;
}
.card-backup-row {
font-size: 11px;
color: var(--text-muted);
word-break: break-all;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.copy-backup {
color: #f093fb;
cursor: pointer;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
}
.btn-small-copy {
padding: 4px 12px;
border: none;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
cursor: pointer;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
transition: 0.2s;
}
.btn-small-copy:hover {
opacity: 0.85;
}
@media (max-width:768px) {
.unlock-box {
padding: 24px;
}
.unlock-title {
font-size: 20px;
}
.history-table {
display: none !important;
}
.history-card-wrap {
display: flex !important;
}
.container {
padding: 20px;
}
.header h1 {
font-size: 24px;
}
.header p {
font-size: 14px;
}
.upload-section {
flex-direction: column;
}
.upload-area {
min-width: 100%;
}
.options {
flex-direction: column;
width: 100%;
}
.option-group {
width: 100%;
flex: none;
min-width: 100%;
}
.thumb-cell {
min-width: 60px;
width: 60px;
}
.thumb-img {
width: 50px;
height: 50px;
}
.link-cell {
max-width: 200px;
min-width: 150px;
}
.action-cell {
width: auto;
min-width: 120px;
white-space: nowrap;
}
.btn-small {
padding: 6px 12px;
font-size: 12px;
margin: 2px;
display: inline-block;
white-space: nowrap;
}
.help-btn {
top: 10px;
right: 10px;
width: 40px;
height: 40px;
font-size: 20px;
}
.theme-btn {
top: 10px;
right: 60px;
width: 40px;
height: 40px;
font-size: 20px;
}
.pending-section {
padding: 15px;
}
.pending-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.action-bar {
width: 100%;
}
.btn-primary {
width: 100%;
padding: 14px 24px;
font-size: 15px;
}
}
@media (max-width:480px) {
body {
padding: 8px;
}
.unlock-box {
padding: 20px;
}
.container {
padding: 16px;
border-radius: 16px;
}
.header h1 {
font-size: 20px;
}
.upload-area {
padding: 25px 15px;
}
.upload-icon {
width: 60px;
height: 60px;
}
.upload-icon svg {
width: 30px;
height: 30px;
}
.upload-text {
font-size: 16px;
}
.upload-subtext {
font-size: 12px;
}
.pending-list {
grid-template-columns: repeat(auto-fill, minmax(70px, 1fr));
gap: 8px;
}
.pending-item .size {
font-size: 10px;
padding: 3px 6px;
}
.history-header {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.history-title {
font-size: 18px;
}
.thumb-img {
width: 45px;
height: 45px;
}
.thumb-cell {
width: 50px;
min-width: 50px;
}
.link-cell {
max-width: 150px;
min-width: 120px;
}
.link-text {
font-size: 10px;
}
.action-cell {
min-width: 100px;
}
.btn-small {
padding: 5px 10px;
font-size: 11px;
margin: 1px;
}
.help-btn {
top: 8px;
right: 8px;
width: 36px;
height: 36px;
font-size: 18px;
}
.theme-btn {
top: 8px;
right: 52px;
width: 36px;
height: 36px;
font-size: 18px;
}
.help-content {
max-width: 95%;
margin: 10px;
}
.help-header {
padding: 16px;
}
.help-header h2 {
font-size: 18px;
}
.help-body {
padding: 16px;
}
.help-section h3 {
font-size: 15px;
}
.help-section p {
font-size: 13px;
line-height: 1.6;
}
.empty-history {
padding: 40px 15px;
}
.empty-icon {
font-size: 36px;
}
.empty-text {
font-size: 14px;
}
.card-main-link {
flex-wrap: wrap;
}
.card-main-link .copy-link-btn {
margin-left: auto;
}
}
</style>
</head>
<body>
<div class="unlock-modal" id="unlockModal">
<div class="unlock-box">
<h2 class="unlock-title">🔐 页面解锁验证</h2>
<input class="unlock-input" id="pwdInput" type="password" placeholder="请输入访问密码">
<div class="unlock-tip" id="tipText"></div>
<button class="unlock-btn" id="confirmUnlockBtn">验证解锁</button>
</div>
</div>
<button class="help-btn" id="helpBtn">?</button>
<button class="theme-btn" id="themeBtn">🌙</button>
<div class="help-modal" id="helpModal">
<div class="help-content">
<div class="help-header">
<h2>个人图床/文件床</h2>
<button class="help-close" id="closeHelpBtn">×</button>
</div>
<div class="help-body">
<div class="help-section">
<h3>项目简介</h3>
<p>仅个人自用,借助github仓库存储。<br>页面地址:https://短链接/代码部署文件夹</p>
</div>
<div class="help-section">
<h3>两种上传模式</h3>
<ul class="feature-list">
<li><strong>图片模式</strong>:单文件≤10MB,JPG/PNG自动压缩WebP;GIF/SVG/HEIC无损保留,存入 /图片文件夹/</li>
<li><strong>通用文件模式</strong>:单文件≤25MB,所有格式原样上传(压缩包/文档/音频/视频),存入 /通用文件夹/</li>
</ul>
</div>
<div class="help-section">
<h3>核心功能</h3>
<ul class="feature-list">
<li>拖拽/点击批量上传,单次最多50个文件</li>
<li>图片三档压缩可选,通用文件无压缩</li>
<li>输出直链</li>
<li>同时生成兜底github.io链接,域名丢失仍可访问</li>
<li>本地存储上传历史,一键复制链接、删除本地记录</li>
<li>暗黑/亮色双主题、图片大图预览、Toast消息提示</li>
</ul>
</div>
<div class="help-section">
<h3>图片压缩</h3>
<div class="compression-info">
<div class="compression-item">
<h4>极致压缩</h4>
<p>20%质量<br>最小体积</p>
</div>
<div class="compression-item">
<h4>均衡</h4>
<p>75%质量<br>大小与质量平衡</p>
</div>
<div class="compression-item">
<h4>质量优先</h4>
<p>90%质量<br>高清原图</p>
</div>
</div>
</div>
<div class="help-section">
<h3>自用说明</h3>
<p>最好仅个人使用</p>
</div>
</div>
</div>
</div>
<div class="container" id="mainWrap">
<div class="header">
<h1><strong>个人图床/文件床</strong></h1>
<h3><a style="color:#ff69b4; font-weight:bold; text-decoration:none;">Made by <a href="https://www.leoi.org" target="_blank" style="color:#3b82f6;"> Leo</a></a></h3>
<p>拖拽或点击上传,图片上限10MB/通用文件上限25MB,批量获取国内直链</p>
</div>
<div class="upload-section">
<div id="uploadArea" class="upload-area">
<div class="upload-icon">
<svg viewBox="0 0 24 24">
<path d="M19 15v4a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-4M12 3v12m0 0l-4-4m4 4l4-4" />
</svg>
</div>
<div class="upload-text">点击或拖拽文件到这里</div>
<div class="upload-subtext">图片模式仅图像;文件模式支持全部文件格式</div>
<input type="file" id="fileInput" class="upload-input" multiple>
</div>
<div class="options">
<div class="option-group">
<div class="option-group-title">上传模式</div>
<div class="mode-buttons">
<button class="mode-btn active" data-mode="img">图片模式</button>
<button class="mode-btn" data-mode="file">文件模式</button>
</div>
</div>
<div class="option-group">
<div class="option-group-title">图片压缩(仅图片模式可用)</div>
<div class="compression-options" id="compressionWrap">
<div class="compression-option" data-quality="0.2"><span>极致压缩</span><span class="hint">最小体积</span></div>
<div class="compression-option active" data-quality="0.75"><span>均衡(推荐)</span><span class="hint">大小质量平衡</span></div>
<div class="compression-option" data-quality="0.9"><span>质量优先</span><span class="hint">高清原图</span></div>
</div>
</div>
</div>
</div>
<div id="pendingSection" class="pending-section">
<div class="pending-header">
<div class="pending-title">待上传文件</div>
<div class="pending-count" id="pendingCount">0个</div>
</div>
<div id="pendingList" class="pending-list"></div>
<div class="action-bar">
<button class="btn btn-primary" id="uploadBtn">上传所有文件</button>
</div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar"><div class="progress-bar-inner" id="progressInner"></div></div>
<div class="progress-status" id="progressStatus">准备中...</div>
</div>
</div>
<div class="history-section">
<div class="history-header">
<div class="history-title">上传历史</div>
<div class="history-count" id="historyCount">0个文件</div>
</div>
<div id="historyContainer">
<div class="empty-history">
<div class="empty-icon">📦</div>
<div class="empty-text">暂无上传记录</div>
</div>
</div>
</div>
</div>
<div id="toast" class="toast"></div>
<div id="modal" class="modal"><img id="modalImg" src="" alt="大图预览"></div>
<script>
// ========== 配置 ==========
const GITHUB_CONFIG = {
repo: "仓库链接",
branch: "main",
imgDir: "图片文件夹",
fileDir: "通用文件夹",
customDomain: "你自己的域名",
rawDomain: "保底的仓库链接",
encryptedToken: "控制台输出的密钥"
};
const DECRYPT_PASSWORD = "你设置的密码";
const AUTH_EXPIRE = 7 * 24 * 60 * 60 * 1000; //登录状态过期时间(建议7天)
const STORAGE_AUTH_KEY = "filebed_auth_info";
function simpleXorCrypt(str, key) {
let out = "";
for (let i = 0; i < str.length; i++) {
out += String.fromCharCode(str.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return out;
}
function getRealToken(inputPwd) {
if (inputPwd !== DECRYPT_PASSWORD) return null;
const decodeStr = atob(GITHUB_CONFIG.encryptedToken);
return simpleXorCrypt(decodeStr, DECRYPT_PASSWORD);
}
function saveAuthCache() {
localStorage.setItem(STORAGE_AUTH_KEY, JSON.stringify({ pass: DECRYPT_PASSWORD, expire: Date.now() + AUTH_EXPIRE }));
}
function loadAuthCache() {
const data = JSON.parse(localStorage.getItem(STORAGE_AUTH_KEY) || 'null');
if (!data || Date.now() > data.expire) { localStorage.removeItem(STORAGE_AUTH_KEY); return null; }
return data.pass;
}
// ========== DOM 引用 ==========
const unlockModal = document.getElementById("unlockModal");
const pwdInput = document.getElementById("pwdInput");
const confirmUnlockBtn = document.getElementById("confirmUnlockBtn");
const tipText = document.getElementById("tipText");
const mainWrap = document.getElementById("mainWrap");
const uploadArea = document.getElementById("uploadArea");
const fileInput = document.getElementById("fileInput");
const pendingSection = document.getElementById("pendingSection");
const pendingList = document.getElementById("pendingList");
const pendingCount = document.getElementById("pendingCount");
const uploadBtn = document.getElementById("uploadBtn");
const historyContainer = document.getElementById("historyContainer");
const historyCount = document.getElementById("historyCount");
const themeBtn = document.getElementById("themeBtn");
const helpBtn = document.getElementById("helpBtn");
const helpModal = document.getElementById("helpModal");
const closeHelpBtn = document.getElementById("closeHelpBtn");
const compressionWrap = document.getElementById("compressionWrap");
const progressWrap = document.getElementById("progressWrap");
const progressInner = document.getElementById("progressInner");
const progressStatus = document.getElementById("progressStatus");
const toast = document.getElementById("toast");
const modal = document.getElementById("modal");
const modalImg = document.getElementById("modalImg");
let ghToken = "";
let authPassed = false;
let uploadMode = "img";
let uploadQuality = 0.75;
let pendingFiles = [];
let uploadHistory = JSON.parse(localStorage.getItem("ghFileHistory")) || [];
// ========== 解锁 ==========
function doUnlock() {
const val = pwdInput.value.trim();
tipText.className = "unlock-tip";
if (!val) { tipText.textContent = "密码不能为空"; tipText.classList.add("tip-error"); return; }
const tk = getRealToken(val);
if (tk) {
ghToken = tk;
authPassed = true;
unlockModal.style.display = "none";
mainWrap.classList.remove("page-lock");
saveAuthCache();
showToast("验证成功,7天内无需重复输入", "success");
} else {
tipText.textContent = "密码错误";
tipText.classList.add("tip-error");
pwdInput.value = "";
}
}
confirmUnlockBtn.onclick = doUnlock;
pwdInput.onkeydown = e => { if (e.key === "Enter") doUnlock(); };
const cachePass = loadAuthCache();
if (cachePass) {
const tk = getRealToken(cachePass);
if (tk) { ghToken = tk;
authPassed = true;
unlockModal.style.display = "none";
mainWrap.classList.remove("page-lock"); }
} else {
mainWrap.classList.add("page-lock");
}
// ========== 辅助 ==========
function get4RandomHash() {
const chars = "0123456789abcdefghijklmnopqrstuvwxyz";
let res = "";
for (let i = 0; i < 4; i++) res += chars[Math.floor(Math.random() * chars.length)];
return res;
}
function getChineseErrMsg(raw) {
if (raw.includes("Maximum call stack size exceeded")) return "单次文件过多,减少文件后重试";
if (raw.includes("401")) return "令牌解密失败或密码错误";
if (raw.includes("422")) return "文件名称重复,稍后重试";
if (raw.includes("403")) return "令牌权限不足,重新生成";
if (raw.includes("network")) return "网络连接失败,检查网络";
return "上传异常:" + raw;
}
function showToast(text, type = "") {
toast.innerText = text;
toast.className = "toast show " + type;
clearTimeout(toast._timer);
toast._timer = setTimeout(() => toast.classList.remove("show"), 2600);
}
// 复制降级方案
function copyText(text, successMsg = "复制成功") {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
showToast(successMsg, "success");
}).catch(() => {
fallbackCopy(text, successMsg);
});
} else {
fallbackCopy(text, successMsg);
}
}
function fallbackCopy(text, successMsg) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
showToast(successMsg, "success");
} catch (e) {
showToast("复制失败,请手动复制", "error");
}
document.body.removeChild(ta);
}
// ========== 主题 & 帮助 ==========
if (localStorage.getItem("darkMode") === "1") document.body.classList.add("dark-mode");
themeBtn.onclick = () => {
document.body.classList.toggle("dark-mode");
localStorage.setItem("darkMode", document.body.classList.contains("dark-mode") ? "1" : "0");
themeBtn.innerText = document.body.classList.contains("dark-mode") ? "☀️" : "🌙";
};
themeBtn.innerText = document.body.classList.contains("dark-mode") ? "☀️" : "🌙";
helpBtn.onclick = () => helpModal.classList.add("show");
closeHelpBtn.onclick = () => helpModal.classList.remove("show");
helpModal.onclick = e => e.target === helpModal && helpModal.classList.remove("show");
modal.onclick = () => modal.classList.remove("show");
// ========== 模式切换 ==========
document.querySelectorAll(".mode-btn").forEach(btn => {
btn.onclick = () => {
document.querySelectorAll(".mode-btn").forEach(b => b.classList.remove("active"));
btn.classList.add("active");
uploadMode = btn.dataset.mode;
compressionWrap.classList.toggle("compression-hide", uploadMode === "file");
};
});
document.querySelectorAll(".compression-option").forEach(el => {
el.onclick = () => {
document.querySelectorAll(".compression-option").forEach(i => i.classList.remove("active"));
el.classList.add("active");
uploadQuality = parseFloat(el.dataset.quality);
};
});
// ========== 文件处理 ==========
uploadArea.ondragover = e => { e.preventDefault();
uploadArea.classList.add("dragover"); };
uploadArea.ondragleave = () => uploadArea.classList.remove("dragover");
uploadArea.ondrop = e => {
e.preventDefault();
uploadArea.classList.remove("dragover");
handleFiles(e.dataTransfer.files);
};
fileInput.onchange = () => handleFiles(fileInput.files);
function handleFiles(files) {
const maxCount = 50 - pendingFiles.length;
const arr = Array.from(files).slice(0, maxCount);
const maxSize = uploadMode === "img" ? 10 * 1024 * 1024 : 25 * 1024 * 1024;
arr.forEach(file => {
if (file.size > maxSize) {
const limitMB = uploadMode === "img" ? "10" : "25";
showToast(`文件 ${file.name} 超过${limitMB}MB限制,已跳过`, "error");
return;
}
pendingFiles.push(file);
renderPending();
});
}
function renderPending() {
pendingList.innerHTML = "";
pendingCount.innerText = `${pendingFiles.length}个`;
pendingSection.classList.toggle("active", pendingFiles.length > 0);
pendingFiles.forEach((file, idx) => {
const div = document.createElement("div");
div.className = "pending-item";
if (/^image\//.test(file.type)) {
const url = URL.createObjectURL(file);
div.innerHTML = `
<img src="${url}" alt="${file.name}">
<button class="remove" data-idx="${idx}">×</button>
<div class="size">${(file.size / 1024 / 1024).toFixed(2)}MB</div>
`;
div.querySelector("img").onload = () => URL.revokeObjectURL(url);
} else {
div.className += " pending-file-item";
div.innerHTML = `
<div class="file-thumb">📄</div>
<div class="filename">${file.name}</div>
<button class="remove" data-idx="${idx}">×</button>
<div class="size">${(file.size / 1024 / 1024).toFixed(2)}MB</div>
`;
}
pendingList.appendChild(div);
});
document.querySelectorAll(".pending-item .remove").forEach(btn => {
btn.onclick = e => {
const i = parseInt(e.target.dataset.idx);
pendingFiles.splice(i, 1);
renderPending();
};
});
}
// ========== 图片压缩 ==========
async function compressImage(file, quality) {
const losslessTypes = ["image/gif", "image/svg+xml", "image/avif", "image/heic", "image/bmp", "image/ico"];
if (losslessTypes.includes(file.type)) return file;
return new Promise(res => {
const img = new Image();
const url = URL.createObjectURL(file);
img.src = url;
img.onload = () => {
URL.revokeObjectURL(url);
let w = img.width,
h = img.height;
const maxW = 2000;
if (w > maxW) { h = h * maxW / w;
w = maxW; }
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, w, h);
canvas.toBlob(blob => {
res(new File([blob], file.name.replace(/\.\w+$/, ".webp"), { type: "image/webp" }));
}, "image/webp", quality);
};
});
}
// ========== 单文件上传 ==========
async function uploadSingle(file, progressCb) {
let targetFile, saveDir;
if (uploadMode === "img") {
progressCb("压缩中...");
targetFile = await compressImage(file, uploadQuality);
saveDir = GITHUB_CONFIG.imgDir;
} else {
targetFile = file;
saveDir = GITHUB_CONFIG.fileDir;
}
const timestamp = Date.now();
const randomHash = get4RandomHash();
const fileName = `Leo_${timestamp}_${randomHash}_${targetFile.name}`;
const fullPath = saveDir + fileName;
progressCb("读取文件...");
const base64Data = await new Promise(resolve => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result.split(",")[1]);
fr.readAsDataURL(targetFile);
});
progressCb("上传中...");
const resp = await fetch(`https://api.github.com/repos/${GITHUB_CONFIG.repo}/contents/${fullPath}`, {
method: "PUT",
headers: {
"Authorization": `token ${ghToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
message: `upload ${uploadMode === "img" ? "image" : "file"} ${fileName}`,
content: base64Data,
branch: GITHUB_CONFIG.branch
})
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.message || "请求异常");
return {
customUrl: `${GITHUB_CONFIG.customDomain}/${fullPath}`,
rawUrl: `${GITHUB_CONFIG.rawDomain}/${fullPath}`,
fileName,
fileType: uploadMode,
uploadTime: new Date().toLocaleString()
};
}
// ========== 批量上传 ==========
uploadBtn.onclick = async () => {
if (!authPassed) return showToast("请先完成页面密码解锁", "error");
if (pendingFiles.length === 0) return showToast("请先选择待上传文件", "error");
uploadBtn.disabled = true;
uploadBtn.innerHTML = `<span class="spinner"></span>上传中...`;
progressWrap.classList.add("show");
progressInner.style.width = "0%";
progressStatus.textContent = "准备上传...";
const total = pendingFiles.length;
let completed = 0;
const successItems = [];
const queue = [...pendingFiles];
try {
for (let i = 0; i < queue.length; i++) {
const file = queue[i];
await uploadSingle(file, (statusText) => {
const pct = Math.round((i / total) * 100);
progressInner.style.width = pct + "%";
progressStatus.textContent = `(${i+1}/${total}) ${statusText}`;
});
completed++;
const pct = Math.round((completed / total) * 100);
progressInner.style.width = pct + "%";
progressStatus.textContent = `(${completed}/${total}) 已完成`;
// 将结果存入(实际在上传成功时返回,但我们缺少返回值,需要修改uploadSingle返回)
// 下面重新调整:在循环中调用uploadSingle返回结果
}
// 由于上面循环没有收集结果,需要重构:把uploadSingle放在循环内并收集返回值
// 重新实现:清空successItems,重新跑一遍
progressInner.style.width = "0%";
progressStatus.textContent = "开始上传...";
let idx = 0;
for (const file of queue) {
const result = await uploadSingle(file, (statusText) => {
const pct = Math.round((idx / total) * 100);
progressInner.style.width = pct + "%";
progressStatus.textContent = `(${idx+1}/${total}) ${statusText}`;
});
successItems.push(result);
idx++;
const pct = Math.round((idx / total) * 100);
progressInner.style.width = pct + "%";
progressStatus.textContent = `(${idx}/${total}) 已完成`;
}
uploadHistory = [...successItems, ...uploadHistory];
localStorage.setItem("ghFileHistory", JSON.stringify(uploadHistory));
pendingFiles = [];
renderPending();
renderHistory();
showToast(`成功上传 ${successItems.length} 个文件`, "success");
} catch (err) {
const msg = getChineseErrMsg(err.message);
showToast("上传失败:" + msg, "error");
} finally {
uploadBtn.disabled = false;
uploadBtn.innerText = "上传所有文件";
progressWrap.classList.remove("show");
progressInner.style.width = "0%";
progressStatus.textContent = "";
}
};
// ========== 渲染历史 ==========
function renderHistory() {
historyCount.innerText = `${uploadHistory.length}个文件`;
if (uploadHistory.length === 0) {
historyContainer.innerHTML = `
<div class="empty-history">
<div class="empty-icon">📦</div>
<div class="empty-text">暂无上传记录</div>
</div>
`;
return;
}
let tableHtml = `
<table class="history-table">
<thead><tr><th>操作</th><th>图片</th><th>国内直链 你的链接</th><th>兜底 github.io</th></tr></thead>
<tbody>
`;
let cardHtml = `<div class="history-card-wrap">`;
uploadHistory.forEach((item, idx) => {
let thumbHtml = "", cardThumb = "";
if (item.fileType === "img") {
thumbHtml = `<img class="thumb-img" src="${item.customUrl}" onclick="openPreview('${item.customUrl}')">`;
cardThumb = `<img class="card-thumb" src="${item.customUrl}" onclick="openPreview('${item.customUrl}')">`;
} else {
thumbHtml = `<div class="file-thumb">📄</div>`;
cardThumb = `<div class="file-thumb">📄</div>`;
}
tableHtml += `
<tr>
<td class="action-cell">
<button class="btn-small btn-copy" onclick="copyMainUrl('${item.customUrl}')">复制</button>
<button class="btn-small btn-delete" onclick="removeLocalRecord(${idx})">删除</button>
</td>
<td class="thumb-cell">${thumbHtml}</td>
<td class="link-cell"><div class="link-text">${item.customUrl}</div></td>
<td class="link-cell">
<div class="link-text">${item.rawUrl}</div>
<button class="btn-small btn-copy" onclick="copyBackupUrl('${item.rawUrl}')">复制兜底</button>
</td>
</tr>
`;
cardHtml += `
<div class="history-card">
<div class="card-top-row">
<div class="card-btn-group">
<button class="btn-small btn-copy" onclick="copyMainUrl('${item.customUrl}')">复制主链接</button>
<button class="btn-small btn-delete" onclick="removeLocalRecord(${idx})">删除</button>
</div>
${cardThumb}
</div>
<div class="card-main-link">
<span>${item.customUrl}</span>
<button class="btn-small-copy copy-link-btn" onclick="copyMainUrl('${item.customUrl}')">复制</button>
</div>
<div class="card-backup-row">
<span>${item.rawUrl}</span>
<span class="copy-backup" onclick="copyBackupUrl('${item.rawUrl}')">复制兜底</span>
</div>
</div>
`;
});
tableHtml += `</tbody></table>`;
cardHtml += `</div>`;
historyContainer.innerHTML = tableHtml + cardHtml;
}
// ========== 全局函数(供HTML调用) ==========
window.openPreview = url => { modalImg.src = url;
modal.classList.add("show"); };
window.copyMainUrl = url => copyText(url, "国内链接复制成功");
window.copyBackupUrl = url => copyText(url, "兜底链接复制成功");
window.removeLocalRecord = idx => {
uploadHistory.splice(idx, 1);
localStorage.setItem("ghFileHistory", JSON.stringify(uploadHistory));
renderHistory();
showToast("已删除本地记录", "success");
};
// ========== 初始化 ==========
renderHistory();
</script>
</body>
</html>