原项目:https://github.com/0-RTT/JSimages
https://img.kkmjj.ccwu.cc/【[email protected]】
一、变量和机密:
1、USERNAME,【9527kkk】管理员用户名
2、PASSWORD,@KKK9527kkk@【密码】
3、ADMIN_PATH,9527kkk【管理后台路径】
4、DOMAIN,img.kkmjj.ccwu.cc【自定义域名】
可选
5、ENABLE_AUTH,true或false【访客验证(设置为 true 开启,不设置或设置为 false 则关闭)】
6、MAXSIZEMB,【单文件最大支持大小(单位:MB,默认值为 10)】
二、
1、R2_BUCKET,【R2 存储桶名称】
2、DATABASE,【D1 数据库绑定变量名称】,D1 数据库,控制台执行以下 SQL 语句:
CREATE TABLE media (
url TEXT PRIMARY KEY
);
三、配置缓存
进入 Cloudflare Dashboard
进入 网站 → 选择你的自定义域名 → 缓存 → Cache Rules → 创建缓存规则
选择 缓存所有内容模板
设置 边缘 TTL → 忽略缓存控制标头,使用此 TTL → 30天(根据需要设置)
点击 部署
四、代码
const CONTENTTYPEMAP = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'webp': 'image/webp',
'bmp': 'image/bmp',
'svg': 'image/svg+xml',
'mp4': 'video/mp4',
'avi': 'video/x-msvideo',
'mov': 'video/quicktime',
'webm': 'video/webm'
};
const CACHE_CONFIG = {
HTML: 3600,
IMAGE: 86400,
API: 300
};
function extractConfig(env) {
return {
domain: env.DOMAIN,
database: env.DATABASE,
username: env.USERNAME,
password: env.PASSWORD,
adminPath: env.ADMIN_PATH,
enableAuth: env.ENABLE_AUTH === 'true',
r2Bucket: env.R2_BUCKET,
maxSize: (env.MAXSIZEMB ? parseInt(env.MAXSIZEMB, 10) : 10) 1024 1024
};
}
function createCachedResponse(body, contentType, cacheMaxAge) {
return new Response(body, {
headers: {
'Content-Type': contentType,
'Cache-Control': public, max-age=${cacheMaxAge},
'CDN-Cache-Control': public, max-age=${cacheMaxAge}
}
});
}
function jsonResponse(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { 'Content-Type': 'application/json' }
});
}
function unauthorizedResponse() {
return new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="Admin"' }
});
}
function getFileExtension(url) {
return url.split('.').pop().toLowerCase();
}
function getContentType(extension) {
return CONTENTTYPEMAP[extension] || 'application/octet-stream';
}
function escapeHtml(text) {
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return String(text).replace(/[&<>"']/g, m => map[m]);
}
export default {
async fetch(request, env) {
const { pathname } = new URL(request.url);
const config = extractConfig(env);
switch (pathname) {
case '/':
return await handleRootRequest(request, config);
case /${config.adminPath}:
return await handleAdminRequest(request, config);
case '/upload':
return request.method === 'POST'
? await handleUploadRequest(request, config)
: new Response('Method Not Allowed', { status: 405 });
case '/bing-images':
return handleBingImagesRequest();
case '/delete-images':
return await handleDeleteImagesRequest(request, config);
case '/folders':
return request.method === 'GET'
? await handleGetFoldersRequest(request, config)
: new Response('Method Not Allowed', { status: 405 });
case '/folders/create':
return request.method === 'POST'
? await handleCreateFolderRequest(request, config)
: new Response('Method Not Allowed', { status: 405 });
case '/folders/delete':
return request.method === 'POST'
? await handleDeleteFolderRequest(request, config)
: new Response('Method Not Allowed', { status: 405 });
case '/edit-file':
return request.method === 'POST'
? await handleEditFileRequest(request, config)
: new Response('Method Not Allowed', { status: 405 });
default:
return await handleImageRequest(request, config);
}
}
};
function authenticate(request, username, password) {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Basic ')) return false;
try {
const base64Credentials = authHeader.split(' ')[1];
const credentials = atob(base64Credentials).split(':');
return credentials[0] === username && credentials[1] === password;
} catch {
return false;
}
}
async function handleRootRequest(request, config) {
const cache = caches.default;
const cacheKey = new Request(request.url);
if (config.enableAuth && !authenticate(request, config.username, config.password)) {
return unauthorizedResponse();
}
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) {
return cachedResponse;
}
const response = createCachedResponse(`
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="JSimages-基于CloudFlare的图床服务">
<meta name="keywords" content="JSimages,Workers图床, Pages图床,R2储存, Cloudflare, Workers, 图床">
<title>JSimages-基于CloudFlare的图床服务</title>
<link rel="icon" href="https://mjj.de5.net/folderimg-logo1780180216844_immich.png" type="image/x-icon">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.6.1/css/bootstrap.min.css" integrity="sha512-T584yQ/tdRR5QwOpfvDfVQUidzfgc2339Lc8uBDtcp/wYu80d7jwBgAxbyMh0a9YM9F8N3tdErpFI8iaGx6x5g==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-fileinput/5.2.7/css/fileinput.min.css" integrity="sha512-qPjB0hQKYTx1Za9Xip5h0PXcxaR1cRbHuZHo9z+gb5IgM6ZOTtIH4QLITCxcCp/8RMXtw2Z85MIZLv6LfGTLiw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/2.1.4/toastr.min.css" integrity="sha512-6S2HWzVFxruDlZxI3sXOZZ4/eJ8AcxkQH1+JjSe/ONCEqR9L4Ysq5JdT5ipqtzU7WHalNwzwBv+iE51gNHJNqQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css" integrity="sha512-1ycn6IcaQQ40/MKBW2W4Rhis/DbILU74C1vSrLJxCq57o941Ym01SwNsOMqvEBFlcgUa6xLiPY/NS5R+E6ztJQ==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<style>
body {
margin: 0;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
position: relative;
}
.background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: cover;
z-index: -1;
transition: opacity 1s ease-in-out;
opacity: 1;
}
.card {
background-color: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: none;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
padding: 30px;
width: 90%;
max-width: 520px;
text-align: center;
margin: 0 auto;
position: relative;
}
.title {
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 20px;
letter-spacing: 0.5px;
}
.uniform-height {
margin-top: 20px;
}
#viewCacheBtn {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: rgba(102, 126, 234, 0.5);
cursor: pointer;
font-size: 22px;
transition: all 0.3s ease;
}
#viewCacheBtn:hover {
color: #667eea;
transform: scale(1.1);
}
#compressionToggleBtn {
position: absolute;
top: 15px;
right: 55px;
background: none;
border: none;
color: rgba(102, 126, 234, 0.5);
cursor: pointer;
font-size: 22px;
transition: all 0.3s ease;
}
#compressionToggleBtn:hover {
color: #667eea;
transform: scale(1.1);
}
#cacheContent {
margin-top: 20px;
max-height: 250px;
border-radius: 8px;
overflow-y: auto;
}
.cache-title {
text-align: left;
margin-bottom: 10px;
}
.cache-item {
display: block;
cursor: pointer;
border-radius: 8px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
text-align: left;
padding: 12px 15px;
margin-bottom: 8px;
background: white;
border: 1px solid rgba(102, 126, 234, 0.1);
}
.cache-item:hover {
background-color: rgba(102, 126, 234, 0.05);
border-color: rgba(102, 126, 234, 0.3);
transform: translateX(5px);
}
.upload-hint {
color: #999;
font-size: 14px;
margin-top: 15px;
line-height: 1.6;
}
.upload-hint i {
color: #667eea;
margin-right: 5px;
}
.folder-select-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 1rem;
}
.folder-select-label {
font-weight: 600;
color: #555;
margin-bottom: 0;
white-space: nowrap;
flex-shrink: 0;
font-size: 14px;
}
#folderSelect.folder-select-input {
flex: 1;
min-width: 0;
height: 44px;
line-height: 1.4;
border-radius: 8px;
border: 2px solid rgba(102, 126, 234, 0.2);
padding: 8px 15px;
font-size: 14px;
box-sizing: border-box;
}
.project-link {
font-size: 14px;
text-align: center;
margin-top: 15px;
margin-bottom: 0;
color: #999;
line-height: 1.6;
}
.project-link a {
color: #667eea;
text-decoration: none;
transition: color 0.3s ease;
}
.project-link a:hover {
color: #764ba2;
text-decoration: underline;
}
textarea.form-control {
max-height: 200px;
overflow-y: hidden;
resize: none;
}
.upload-progress {
display: none;
margin-top: 15px;
text-align: center;
}
.progress-text {
font-size: 14px;
font-weight: 500;
color: #667eea;
letter-spacing: 0.5px;
}
.thumbnail-container {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 15px;
justify-content: center;
}
.thumbnail-item {
position: relative;
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.thumbnail-item:hover {
transform: scale(1.05);
}
.thumbnail-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-item video {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbnail-item .file-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 24px;
}
.thumbnail-item .remove-btn {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: white;
border: none;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.thumbnail-item:hover .remove-btn {
opacity: 1;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border: none !important;
color: white !important;
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3) !important;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4) !important;
}
.btn-primary:active, .btn-primary:focus {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3) !important;
}
.file-drop-zone {
border: 2px dashed #667eea !important;
border-radius: 12px !important;
background: rgba(102, 126, 234, 0.05) !important;
transition: all 0.3s ease !important;
}
.file-drop-zone:hover {
border-color: #764ba2 !important;
background: rgba(102, 126, 234, 0.1) !important;
}
.file-drop-zone-title {
color: #667eea !important;
font-weight: 500 !important;
}
.btn-danger, .fileinput-remove {
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
.btn-danger:hover, .fileinput-remove:hover {
transform: translateY(-2px);
}
.btn-danger:active, .fileinput-remove:active {
transform: translateY(0);
}
.btn-light {
border-radius: 8px !important;
font-weight: 500 !important;
transition: all 0.3s ease !important;
}
.btn-light:hover {
transform: translateY(-2px);
}
.btn-light:active {
transform: translateY(0);
}
@media (max-width: 768px) {
.card {
width: 95%;
max-width: 100%;
padding: 20px;
border-radius: 12px;
}
.title {
font-size: 24px;
}
#viewCacheBtn, #compressionToggleBtn {
font-size: 20px;
}
.btn-primary, .btn-danger, .btn-light {
min-height: 44px;
min-width: 44px;
}
.cache-item {
padding: 15px;
}
}
</style>
</head>
<body>
<div class="background" id="background"></div>
<div class="card">
<div class="title">JSimages</div>
<button type="button" class="btn" id="viewCacheBtn" title="查看历史记录"><i class="fas fa-clock"></i></button>
<button type="button" class="btn" id="compressionToggleBtn"><i class="fas fa-compress"></i></button>
<div class="card-body">
<form id="uploadForm" action="/upload" method="post" enctype="multipart/form-data">
<div class="form-group folder-select-row">
<label for="folderSelect" class="folder-select-label">选择文件夹:</label>
<select id="folderSelect" class="form-control folder-select-input">
<option value="">📁 /</option>
</select>
</div>
<div class="file-input-container">
<input id="fileInput" name="file" type="file" class="form-control-file" data-browse-on-zone-click="true" multiple>
</div>
<div class="upload-hint">
<i class="fas fa-info-circle"></i>支持拖拽上传 · 多文件上传 · Ctrl+V 粘贴上传
</div>
<div class="form-group mb-3 uniform-height" style="display: none;">
<button type="button" class="btn btn-light mr-2" id="urlBtn">URL</button>
<button type="button" class="btn btn-light mr-2" id="bbcodeBtn">BBCode</button>
<button type="button" class="btn btn-light" id="markdownBtn">Markdown</button>
</div>
<div class="form-group mb-3 uniform-height" style="display: none;">
<textarea class="form-control" id="fileLink" readonly></textarea>
</div>
<div class="upload-progress" id="uploadProgress">
<div class="progress-text" id="progressText">上传中... 0%</div>
</div>
<div class="thumbnail-container" id="thumbnailContainer"></div>
<div id="cacheContent" style="display: none;"></div>
</form>
</div>
<p class="project-link">
<a href="https://www.199881.xyz" target="_blank" rel="noopener noreferrer">导航</a>
<a href="https://img.211119.xyz" target="_blank" rel="noopener noreferrer">tg图床</a>
<a href="https://ngohome.eu.org/" target="_blank" rel="noopener noreferrer">图床1</a>
<a href="https://img.wszx.us.to/" target="_blank" rel="noopener noreferrer">图床2</a>
<a href="https://9527.dedyn.io/" target="_blank" rel="noopener noreferrer">servoo床</a>
<a href="./9527kkk" target="_blank" rel="noopener noreferrer">管理</a>
<br />
开源-<a href="https://github.com/0-RTT/JSimages" target="_blank" rel="noopener noreferrer">GitHub</a>
<!-- 开站时间开始20260530 -->
<span id="timeDate">载入天数...</span>
<script language="javascript">
var now = new Date();
function createtime(){
var grt= new Date("05/31/2026 00:00:00");/---这里是网站的启用时间:月/日/年--/
now.setTime(now.getTime()+250);
days = (now - grt ) / 1000 / 60 / 60 / 24;
dnum = Math.floor(days);
document.getElementById("timeDate").innerHTML = "稳定运行 "+dnum+" 天";
}
setInterval("createtime()",250);
</script>
<span <p> | 总访问量 <span id="busuanzisitepv"></span> 次 | @ws01</p></span>
<script defer src="https://bsz.211119.xyz/js"></script>
</p>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js" integrity="sha512-894YE6QWD5I59HgZOGReFYm4dnWc1Qt5NtvYSaNcOP+u1T9qYdvdihz0PPSiiqn/+/3e7Jo4EaG7TubfWGUrMQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-fileinput/5.2.7/js/fileinput.min.js" integrity="sha512-CCLv901EuJXf3k0OrE5qix8s2HaCDpjeBERR2wVHUwzEIc7jfiK9wqJFssyMOc1lJ/KvYKsDenzxbDTAQ4nh1w==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-fileinput/5.2.7/js/locales/zh.min.js" integrity="sha512-IizKWmZY3aznnbFx/Gj8ybkRyKk7wm+d7MKmEgOMRQDN1D1wmnDRupfXn6X04pwIyKFWsmFVgrcl0j6W3Z5FDQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/2.1.4/toastr.min.js" integrity="sha512-lbwH47l/tPXJYG9AcFNoJaTMhGvYWhVM9YI43CT+uteTRRaiLCui8snIgyAN8XWgNjNhCqlAUdzZptso6OCoFQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
function formatLinks(urls, format) {
switch (format) {
case 'url':
return urls.join('\\n\\n');
case 'bbcode':
return urls.map(url => '[img]' + url + '[/img]').join('\\n\\n');
case 'markdown':
return urls.map(url => '
').join('\\n\\n');
default:
return urls.join('\\n');
}
}
async function fetchBingImages() {
try {
const response = await fetch('/bing-images');
if (!response.ok) {
throw new Error('获取背景图片失败: HTTP ' + response.status);
}
const data = await response.json();
return data.data?.map(image => image.url) || [];
} catch (error) {
console.error('获取Bing背景图片失败:', error);
return [];
}
}
async function setBackgroundImages() {
const images = await fetchBingImages();
if (images.length === 0) return;
const bg1 = document.getElementById('background');
const bg2 = document.createElement('div');
bg2.className = 'background';
bg2.style.opacity = 0;
document.body.insertBefore(bg2, bg1.nextSibling);
let index = 0;
let currentBg = bg1;
let nextBg = bg2;
bg1.style.backgroundImage = 'url(' + images[0] + ')';
setInterval(() => {
index = (index + 1) % images.length;
nextBg.style.backgroundImage = 'url(' + images[index] + ')';
nextBg.style.opacity = 0;
setTimeout(() => {
nextBg.style.opacity = 1;
currentBg.style.opacity = 0;
}, 50);
setTimeout(() => {
const temp = currentBg;
currentBg = nextBg;
nextBg = temp;
}, 1000);
}, 5000);
}
$(document).ready(function() {
let originalImageURLs = [];
let thumbnailData = [];
let isCacheVisible = false;
let enableCompression = true;
let selectedFolder = '';
initFileInput();
loadFolders();
setBackgroundImages();
const tooltipText = enableCompression ? '关闭压缩' : '开启压缩';
$('#compressionToggleBtn').attr('title', tooltipText);
$('#compressionToggleBtn').on('click', function() {
enableCompression = !enableCompression;
const icon = $(this).find('i');
icon.toggleClass('fa-compress fa-expand');
const tooltipText = enableCompression ? '关闭压缩' : '开启压缩';
$(this).attr('title', tooltipText);
});
async function loadFolders() {
try {
const response = await fetch('/folders');
const data = await response.json();
const select = $('#folderSelect');
data.folders.forEach(folder => {
select.append('<option value="' + folder + '">📂 ' + folder + '</option>');
});
} catch (error) {
console.error('加载文件夹失败:', error);
}
}
$('#folderSelect').on('change', function() {
selectedFolder = $(this).val();
});
function initFileInput() {
$("#fileInput").fileinput({
theme: 'fa',
language: 'zh',
browseClass: "btn btn-primary",
removeClass: "btn btn-danger",
showUpload: false,
showPreview: false,
}).on('filebatchselected', handleFileSelection)
.on('fileclear', handleFileClear);
}
async function handleFileSelection() {
const files = $('#fileInput')[0].files;
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fileHash = await calculateFileHash(file);
const cachedData = getCachedData(fileHash);
if (cachedData) {
handleCachedFile(cachedData);
} else {
await uploadFile(file, fileHash);
}
}
}
function getCachedData(fileHash) {
const cacheData = JSON.parse(localStorage.getItem('uploadCache')) || [];
return cacheData.find(item => item.hash === fileHash);
}
function handleCachedFile(cachedData) {
if (!originalImageURLs.includes(cachedData.url)) {
originalImageURLs.push(cachedData.url);
updateFileLinkDisplay();
toastr.info('已从缓存中读取数据');
}
}
function updateFileLinkDisplay() {
$('#fileLink').val(originalImageURLs.join('\\n\\n'));
$('.form-group').show();
adjustTextareaHeight($('#fileLink')[0]);
}
function addThumbnail(file, url) {
const container = $('#thumbnailContainer');
const index = thumbnailData.length;
const previewUrl = URL.createObjectURL(file);
thumbnailData.push({ previewUrl, url, file });
let thumbnailContent = '';
if (file.type.startsWith('image/')) {
thumbnailContent = '<img src="' + previewUrl + '" alt="thumbnail">';
} else if (file.type.startsWith('video/')) {
thumbnailContent = '<video src="' + previewUrl + '" muted></video>';
} else {
const ext = file.name.split('.').pop().toUpperCase();
thumbnailContent = '<div class="file-icon">' + ext + '</div>';
}
const thumbnailHtml = '<div class="thumbnail-item" data-index="' + index + '">' +
thumbnailContent +
'<button class="remove-btn" title="移除">×</button>' +
'</div>';
container.append(thumbnailHtml);
}
function removeThumbnail(index) {
const item = thumbnailData[index];
if (item && item.previewUrl) {
URL.revokeObjectURL(item.previewUrl);
}
thumbnailData[index] = null;
const urlToRemove = item ? item.url : null;
if (urlToRemove) {
originalImageURLs = originalImageURLs.filter(u => u !== urlToRemove);
updateFileLinkDisplay();
if (originalImageURLs.length === 0) {
hideButtonsAndTextarea();
}
}
$('.thumbnail-item[data-index="' + index + '"]').remove();
}
function clearAllThumbnails() {
thumbnailData.forEach(item => {
if (item && item.previewUrl) {
URL.revokeObjectURL(item.previewUrl);
}
});
thumbnailData = [];
$('#thumbnailContainer').empty();
}
$(document).on('click', '.thumbnail-item .remove-btn', function(e) {
e.stopPropagation();
const index = $(this).parent().data('index');
removeThumbnail(index);
});
async function calculateFileHash(file) {
const chunkSize = 1024 * 1024;
const chunk = file.size > chunkSize ? file.slice(0, chunkSize) : file;
const arrayBuffer = await chunk.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hash = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join('');
return hash + '-' + file.size + '-' + file.lastModified;
}
function isFileInCache(fileHash) {
const cacheData = JSON.parse(localStorage.getItem('uploadCache')) || [];
return cacheData.some(item => item.hash === fileHash);
}
async function uploadFile(file, fileHash) {
const originalFile = file;
try {
const interfaceInfo = {
enableCompression: enableCompression
};
if (file.type.startsWith('image/') && file.type !== 'image/gif' && interfaceInfo.enableCompression) {
toastr.info('正在压缩...', '', { timeOut: 0 });
const compressedFile = await compressImage(file);
file = compressedFile;
toastr.clear();
}
const formData = new FormData();
formData.append('file', file, file.name);
formData.append('folder', selectedFolder);
$('#uploadProgress').show();
$('#progressText').text('上传中... 0%');
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
$('#progressText').text('上传中... ' + percentComplete + '%');
}
});
const uploadPromise = new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText));
} catch (e) {
reject(new Error('响应解析失败'));
}
} else {
try {
const errorData = JSON.parse(xhr.responseText);
reject(new Error(errorData.error || '上传失败'));
} catch (e) {
reject(new Error('上传失败: HTTP ' + xhr.status));
}
}
};
xhr.onerror = () => reject(new Error('网络错误,请检查网络连接'));
xhr.ontimeout = () => reject(new Error('上传超时,请重试'));
xhr.open('POST', '/upload');
xhr.timeout = 120000;
xhr.send(formData);
});
const responseData = await uploadPromise;
$('#uploadProgress').hide();
if (responseData.error) {
toastr.error(responseData.error);
} else {
originalImageURLs.push(responseData.data);
addThumbnail(originalFile, responseData.data);
$('#fileLink').val(originalImageURLs.join('\\n\\n'));
$('.form-group').show();
adjustTextareaHeight($('#fileLink')[0]);
toastr.success('上传成功! 点击下方按钮复制链接', '', {
timeOut: 3000,
progressBar: true
});
saveToLocalCache(responseData.data, file.name, fileHash);
}
} catch (error) {
console.error('处理文件时出现错误:', error);
$('#uploadProgress').hide();
let errorMsg = '文件处理失败';
if (error.message.includes('网络')) {
errorMsg = '网络错误,请检查网络连接';
} else if (error.message.includes('超时')) {
errorMsg = '上传超时,请重试';
} else if (error.message) {
errorMsg = error.message;
}
toastr.error(errorMsg);
} finally {
toastr.clear();
}
}
$(document).on('paste', async function(event) {
const clipboardData = event.originalEvent.clipboardData;
if (clipboardData && clipboardData.items) {
for (let i = 0; i < clipboardData.items.length; i++) {
const item = clipboardData.items[i];
if (item.kind === 'file') {
const pasteFile = item.getAsFile();
const dataTransfer = new DataTransfer();
const existingFiles = $('#fileInput')[0].files;
for (let j = 0; j < existingFiles.length; j++) {
dataTransfer.items.add(existingFiles[j]);
}
dataTransfer.items.add(pasteFile);
$('#fileInput')[0].files = dataTransfer.files;
$('#fileInput').trigger('change');
break;
}
}
}
});
const $card = $('.card');
$card.on('dragover', function(e) {
e.preventDefault();
e.stopPropagation();
$(this).css('background-color', 'rgba(255, 255, 255, 0.95)');
});
$card.on('dragleave', function(e) {
e.preventDefault();
e.stopPropagation();
$(this).css('background-color', 'rgba(255, 255, 255, 0.9)');
});
$card.on('drop', function(e) {
e.preventDefault();
e.stopPropagation();
$(this).css('background-color', 'rgba(255, 255, 255, 0.9)');
const files = e.originalEvent.dataTransfer.files;
if (files.length > 0) {
const dataTransfer = new DataTransfer();
for (let i = 0; i < files.length; i++) {
dataTransfer.items.add(files[i]);
}
$('#fileInput')[0].files = dataTransfer.files;
$('#fileInput').trigger('change');
}
});
async function compressImage(file, quality = 0.75) {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => {
const targetWidth = image.width;
const targetHeight = image.height;
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = targetWidth;
canvas.height = targetHeight;
ctx.drawImage(image, 0, 0, targetWidth, targetHeight);
canvas.toBlob((blob) => {
const compressedFile = new File([blob], file.name, { type: 'image/jpeg' });
toastr.success('图片压缩成功!');
resolve(compressedFile);
}, 'image/jpeg', quality);
};
const reader = new FileReader();
reader.onload = (event) => {
image.src = event.target.result;
};
reader.readAsDataURL(file);
});
}
$('#urlBtn, #bbcodeBtn, #markdownBtn').on('click', function() {
const fileLinks = originalImageURLs.map(url => url.trim()).filter(url => url !== '');
if (fileLinks.length > 0) {
const formatMap = {
'urlBtn': 'url',
'bbcodeBtn': 'bbcode',
'markdownBtn': 'markdown'
};
const format = formatMap[$(this).attr('id')];
const formattedLinks = formatLinks(fileLinks, format);
$('#fileLink').val(formattedLinks);
adjustTextareaHeight($('#fileLink')[0]);
copyToClipboardWithToastr(formattedLinks);
}
});
function handleFileClear(event) {
$('#fileLink').val('');
adjustTextareaHeight($('#fileLink')[0]);
hideButtonsAndTextarea();
originalImageURLs = [];
clearAllThumbnails();
}
function adjustTextareaHeight(textarea) {
textarea.style.height = '1px';
textarea.style.height = (textarea.scrollHeight > 200 ? 200 : textarea.scrollHeight) + 'px';
if (textarea.scrollHeight > 200) {
textarea.style.overflowY = 'auto';
} else {
textarea.style.overflowY = 'hidden';
}
}
function copyToClipboardWithToastr(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
toastr.success('已复制到剪贴板', '', { timeOut: 300 });
}).catch(() => {
toastr.error('复制失败');
});
} else {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
toastr.success('已复制到剪贴板', '', { timeOut: 300 });
} catch (err) {
toastr.error('复制失败');
}
document.body.removeChild(textarea);
}
}
function hideButtonsAndTextarea() {
$('#urlBtn, #bbcodeBtn, #markdownBtn, #fileLink').parent('.form-group').hide();
}
function saveToLocalCache(url, fileName, fileHash) {
const timestamp = new Date().toLocaleString('zh-CN', { hour12: false });
const cacheData = JSON.parse(localStorage.getItem('uploadCache')) || [];
cacheData.push({ url, fileName, hash: fileHash, timestamp });
localStorage.setItem('uploadCache', JSON.stringify(cacheData));
}
$('#viewCacheBtn').on('click', function() {
const cacheData = JSON.parse(localStorage.getItem('uploadCache')) || [];
const cacheContent = $('#cacheContent');
cacheContent.empty();
if (isCacheVisible) {
cacheContent.hide();
$('#fileLink').val('');
$('#fileLink').parent('.form-group').hide();
isCacheVisible = false;
} else {
if (cacheData.length > 0) {
const html = cacheData.reverse().map((item) =>
'<div class="cache-item" data-url="' + item.url + '">' +
item.timestamp + ' - ' + item.fileName +
'</div><br>'
).join('');
cacheContent.html(html).show();
} else {
cacheContent.html('<div>还没有记录哦!</div>').show();
}
isCacheVisible = true;
}
});
$(document).on('click', '.cache-item', function() {
const url = $(this).data('url');
originalImageURLs = [];
$('#fileLink').val('');
originalImageURLs.push(url);
$('#fileLink').val(originalImageURLs.map(url => url.trim()).join('\\n\\n'));
$('.form-group').show();
adjustTextareaHeight($('#fileLink')[0]);
});
});
</script>
</body>
</html>
`, 'text/html;charset=UTF-8', CACHE_CONFIG.HTML);
await cache.put(cacheKey, response.clone());
return response;
}
async function handleAdminRequest(request, config) {
if (!authenticate(request, config.username, config.password)) {
return unauthorizedResponse();
}
const url = new URL(request.url);
const page = parseInt(url.searchParams.get('page') || '1', 10);
return await generateAdminPage(config.database, page);
}
async function generateAdminPage(DATABASE, page = 1) {
try {
const pageSize = 28;
const offset = (page - 1) * pageSize;
const totalCount = await DATABASE.prepare('SELECT COUNT(*) as count FROM media WHERE url NOT LIKE \'folder:%\'').first();
const totalPages = Math.ceil(totalCount.count / pageSize);
const mediaData = await fetchMediaData(DATABASE, pageSize, offset);
// 获取所有文件夹 - 添加错误处理以兼容没有folder列的数据库
let folders = [];
try {
const foldersResult = await DATABASE.prepare('SELECT url FROM media WHERE url LIKE \'folder:%\' ORDER BY url ASC').all();
folders = foldersResult.results
.map(row => row.url.replace(/^folder:/, ''))
.filter((f, i, arr) => arr.indexOf(f) === i);
} catch (error) {
console.log('获取文件夹失败:', error);
}
const foldersHtml = folders.map(folder => '<div class="folder-item" data-folder="' + escapeHtml(folder) + '" onclick="filterByFolder(\'' + folder.replace(/'/g, "\\'") + '\', event)">'
+ '<span class="folder-name">📂 ' + escapeHtml(folder) + '</span>'
+ '<button type="button" class="folder-delete-btn" onclick="deleteFolder(\'' + folder.replace(/'/g, "\\'") + '\'); event.stopPropagation();">删除</button>'
+ '</div>').join('');
const mediaHtml = mediaData.map(({ url, folder, filename }) => {
const fileExtension = url.split('.').pop().toLowerCase();
let timestamp = url.split('/').pop().split('.')[0];
// 如果是文件夹格式或包含文件名格式,提取时间戳
if (timestamp.includes('folder_')) {
const match = timestamp.match(/^folder(.+?)(\d+)(?:_.*)?$/);
if (match) {
timestamp = match[2];
}
} else {
const match = timestamp.match(/^(\d+)(?:_.*)?$/);
if (match) {
timestamp = match[1];
}
}
const mediaType = escapeHtml(fileExtension);
const escapedUrl = escapeHtml(url);
const supportedImageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg', 'ico'];
const supportedVideoExtensions = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm'];
const supportedAudioExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac'];
const editableExtensions = ['txt', 'json', 'js'];
const isImage = supportedImageExtensions.includes(fileExtension);
const isVideo = supportedVideoExtensions.includes(fileExtension);
const isAudio = supportedAudioExtensions.includes(fileExtension);
const isEditable = editableExtensions.includes(fileExtension);
const isSupported = isImage || isVideo || isAudio || isEditable;
const backgroundStyle = isSupported ? '' : 'style="font-size: 50px; display: flex; justify-content: center; align-items: center;"';
const icon = isSupported ? '' : '📁';
const folderDisplay = folder ? '<div class="file-folder">' + escapeHtml(folder) + '</div>' : '';
const uploadTimeStr = escapeHtml(new Date(parseInt(timestamp)).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }));
const rawBasename = url.split('/').pop();
const defaultName = escapeHtml(rawBasename);
let displayFileName = filename ? escapeHtml(filename) : defaultName;
if (!filename) {
const rawBaseWithoutExt = rawBasename.replace(/\.[^.]+$/, '');
const folderNameMatch = rawBaseWithoutExt.match(/^folder(.+?)(\d+)_(.+)$/);
const simpleNameMatch = rawBaseWithoutExt.match(/^\d+_(.+)$/);
if (folderNameMatch) {
displayFileName = escapeHtml(decodeURIComponent(folderNameMatch[3]) + '.' + fileExtension);
} else if (simpleNameMatch) {
displayFileName = escapeHtml(decodeURIComponent(simpleNameMatch[1]) + '.' + fileExtension);
}
}
const maxDisplayLength = 24;
const truncatedName = displayFileName.length > maxDisplayLength
? displayFileName.slice(0, maxDisplayLength - 3) + '...'
: displayFileName;
const mediaTypeDisplay = truncatedName;
let mediaContent = '';
if (isVideo) {
mediaContent = '<video class="gallery-video" preload="none" controls><source data-src="' + escapedUrl + '" type="video/' + escapeHtml(fileExtension) + '">您的浏览器不支持视频标签。</video>';
} else if (isAudio) {
mediaContent = '<audio class="gallery-audio" preload="none" controls><source data-src="' + escapedUrl + '" type="audio/' + escapeHtml(fileExtension) + '">您的浏览器不支持音频标签。</audio>';
} else if (isImage) {
mediaContent = '<img class="gallery-image lazy" data-src="' + escapedUrl + '" alt="Image">';
} else if (isEditable) {
mediaContent = '<div class="editable-file-preview">可编辑文件</div>'
+ '<button class="edit-file-button" onclick="event.stopPropagation(); openFileEditor(this.parentElement);">✏️</button>';
} else {
mediaContent = icon;
}
const containerClass = isEditable ? 'media-container editable-file' : 'media-container';
return '<div class="' + containerClass + '" data-key="' + escapedUrl + '" data-folder="' + escapeHtml(folder) + '" onclick="toggleImageSelection(this)" ' + backgroundStyle + '>'
+ '<div class="skeleton"></div>'
+ '<div class="media-type">' + mediaTypeDisplay + '</div>'
+ mediaContent
+ '<div class="upload-time">'
+ folderDisplay
+ '上传时间: ' + uploadTimeStr
+ '</div>'
+ '</div>';
}).join('');
const html = `
<!DOCTYPE html>
<html>
<head>
<title>图库</title>
<link rel="icon" href="https://mjj.de5.net/folderimg-logo1780180216844_immich.png" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8f0 100%);
min-height: 100vh;
margin: 0;
padding: 20px;
}
.page-title {
font-size: 32px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-align: center;
margin-bottom: 20px;
letter-spacing: 0.5px;
}
.header {
position: sticky;
top: 10px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
z-index: 1000;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px 20px;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.15);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.6);
flex-wrap: wrap;
}
.header-left {
flex: 1;
display: flex;
gap: 15px;
align-items: center;
color: #555;
font-weight: 500;
}
.header-right {
display: flex;
gap: 10px;
justify-content: flex-end;
flex: 1;
flex-wrap: wrap;
}
.folder-section {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 20px;
margin-bottom: 20px;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.15);
border: 1px solid rgba(255, 255, 255, 0.6);
}
.folder-section-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.folder-create-form {
display: flex;
gap: 10px;
margin-bottom: 15px;
flex-wrap: wrap;
}
.folder-create-form input {
flex: 1;
min-width: 150px;
padding: 10px 15px;
border: 2px solid rgba(102, 126, 234, 0.2);
border-radius: 8px;
font-size: 14px;
}
.folder-create-form button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
padding: 10px 20px;
cursor: pointer;
font-weight: 500;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
transition: all 0.3s ease;
}
.folder-create-form button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.folder-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.folder-item {
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(102, 126, 234, 0.05);
border: 1px solid rgba(102, 126, 234, 0.2);
border-radius: 8px;
padding: 12px;
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.folder-item:hover {
background: rgba(102, 126, 234, 0.1);
border-color: rgba(102, 126, 234, 0.4);
}
.folder-item.active {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.2) 0%, rgba(118, 75, 162, 0.2) 100%);
border-color: #667eea;
box-shadow: 0 0 12px rgba(102, 126, 234, 0.3);
}
.folder-name {
font-weight: 500;
color: #333;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-delete-btn {
background: #ff6b6b;
color: white;
border: none;
border-radius: 4px;
padding: 5px 10px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s ease;
margin-left: 8px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
}
.folder-item:hover .folder-delete-btn {
opacity: 1;
pointer-events: auto;
}
.folder-delete-btn:hover {
background: #ff5252;
transform: translateY(-1px);
}
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.media-container {
position: relative;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.08);
border: 1px solid rgba(255, 255, 255, 0.6);
aspect-ratio: 1 / 1;
transition: all 0.3s ease;
cursor: pointer;
}
.media-container:hover {
transform: translateY(-4px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.2);
border-color: rgba(102, 126, 234, 0.3);
}
.media-container.selected {
border: 2px solid #667eea;
background: rgba(102, 126, 234, 0.1);
box-shadow: 0 0 20px rgba(102, 126, 234, 0.3);
}
.media-type {
position: absolute;
top: 10px;
left: 10px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 4px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
z-index: 10;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.edit-file-button {
position: absolute;
top: 10px;
right: 10px;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
color: #333;
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
z-index: 11;
}
.edit-file-button:hover {
transform: scale(1.05);
background: white;
}
.file-folder {
background: rgba(102, 126, 234, 0.15);
color: #667eea;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
margin-bottom: 5px;
display: inline-block;
}
.upload-time {
position: absolute;
bottom: 10px;
left: 10px;
right: 10px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(4px);
padding: 8px 10px;
border-radius: 8px;
color: #555;
font-size: 12px;
z-index: 10;
display: none;
}
.gallery-image, .gallery-video {
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.4s ease;
opacity: 0;
}
.gallery-audio {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
transition: opacity 0.4s ease;
opacity: 0;
}
.gallery-image.loaded, .gallery-video.loaded, .gallery-audio.loaded {
opacity: 1;
}
.skeleton {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 16px;
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.skeleton.hidden {
display: none;
}
.editable-file-preview {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
color: white;
}
.editable-file:hover {
transform: scale(1.02);
cursor: pointer;
}
#editor-modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.editor-content {
background: white;
margin: 5% auto;
padding: 20px;
border-radius: 16px;
width: 90%;
max-width: 900px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 2px solid #667eea;
}
.editor-filename {
font-size: 18px;
font-weight: 600;
color: #333;
}
.editor-buttons {
display: flex;
gap: 10px;
}
.editor-save, .editor-cancel {
padding: 10px 20px;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
transition: all 0.3s ease;
}
.editor-save {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.editor-save:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.editor-cancel {
background: #f0f0f0;
color: #333;
}
.editor-cancel:hover {
background: #e0e0e0;
}
.editor-textarea {
flex: 1;
border: 2px solid #ddd;
border-radius: 8px;
padding: 15px;
font-family: 'Courier New', monospace;
font-size: 14px;
resize: none;
line-height: 1.5;
overflow-y: auto;
}
.editor-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 8px rgba(102, 126, 234, 0.3);
}
.footer {
margin-top: 30px;
text-align: center;
font-size: 16px;
color: #999;
padding: 20px;
background: rgba(255, 255, 255, 0.6);
border-radius: 12px;
backdrop-filter: blur(8px);
}
.delete-button, .copy-button, .front-button, .logout-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
padding: 10px 20px;
cursor: pointer;
transition: all 0.3s ease;
width: auto;
font-weight: 500;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.delete-button:hover, .copy-button:hover, .front-button:hover, .logout-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.delete-button:active, .copy-button:active, .front-button:active, .logout-button:active {
transform: translateY(0);
}
.hidden {
display: none;
}
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(12px);
min-width: 140px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
z-index: 1001;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.6);
overflow: hidden;
right: 0;
}
.dropdown-content button {
color: #333;
padding: 12px 16px;
text-decoration: none;
display: block;
background: none;
border: none;
width: 100%;
text-align: left;
font-size: 14px;
transition: all 0.2s ease;
cursor: pointer;
}
.dropdown-content button:hover {
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
color: #667eea;
}
.dropdown:hover .dropdown-content {
display: block;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
margin: 25px 0;
flex-wrap: wrap;
padding: 15px;
background: rgba(255, 255, 255, 0.6);
border-radius: 16px;
backdrop-filter: blur(8px);
}
.pagination button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
padding: 10px 24px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
}
.pagination button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.pagination button:disabled {
background: linear-gradient(135deg, #ccc 0%, #aaa 100%);
cursor: not-allowed;
box-shadow: none;
}
.pagination .page-info {
color: #555;
font-weight: 500;
padding: 0 15px;
}
.empty-state {
text-align: center;
padding: 80px 20px;
color: #999;
font-size: 18px;
background: rgba(255, 255, 255, 0.6);
border-radius: 16px;
backdrop-filter: blur(8px);
}
.empty-state i {
font-size: 72px;
margin-bottom: 20px;
display: block;
opacity: 0.4;
}
@media (max-width: 768px) {
body {
padding: 15px;
}
.page-title {
font-size: 24px;
margin-bottom: 15px;
}
.header {
top: 5px;
padding: 12px 15px;
border-radius: 12px;
}
.header-left, .header-right {
flex: 1 1 100%;
justify-content: flex-start;
}
.header-left {
font-size: 14px;
}
.header-right {
margin-top: 10px;
}
.gallery {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.media-container {
border-radius: 12px;
}
.delete-button, .copy-button, .front-button, .logout-button {
padding: 8px 16px;
font-size: 14px;
min-height: 44px;
border-radius: 8px;
}
.pagination {
padding: 12px;
border-radius: 12px;
}
.pagination button {
padding: 8px 16px;
font-size: 14px;
}
.pagination .page-info {
font-size: 14px;
}
}
</style>
<script>
let selectedCount = 0;
const selectedKeys = new Set();
let isAllSelected = false;
function toggleImageSelection(container) {
const key = container.getAttribute('data-key');
container.classList.toggle('selected');
const uploadTime = container.querySelector('.upload-time');
if (container.classList.contains('selected')) {
selectedKeys.add(key);
selectedCount++;
uploadTime.style.display = 'block';
} else {
selectedKeys.delete(key);
selectedCount--;
uploadTime.style.display = 'none';
}
updateDeleteButton();
}
function updateDeleteButton() {
const deleteButton = document.getElementById('delete-button');
const countDisplay = document.getElementById('selected-count');
countDisplay.textContent = selectedCount;
const headerRight = document.querySelector('.header-right');
if (selectedCount > 0) {
headerRight.classList.remove('hidden');
} else {
headerRight.classList.add('hidden');
}
}
async function deleteSelectedImages() {
if (selectedKeys.size === 0) return;
const confirmation = confirm('你确定要删除选中的媒体文件吗?此操作无法撤回。');
if (!confirmation) return;
const keysToDelete = Array.from(selectedKeys);
const response = await fetch('/delete-images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(keysToDelete)
});
if (response.ok) {
// 局部更新 DOM,添加淡出动画
const containers = document.querySelectorAll('.media-container');
const containersToRemove = [];
containers.forEach(container => {
const key = container.getAttribute('data-key');
if (selectedKeys.has(key)) {
containersToRemove.push(container);
container.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
container.style.opacity = '0';
container.style.transform = 'scale(0.8)';
}
});
// 等待动画完成后移除元素
setTimeout(() => {
containersToRemove.forEach(container => container.remove());
// 更新媒体文件总数
const totalCountElement = document.querySelector('.header-left span:first-child');
const currentTotal = parseInt(totalCountElement.textContent.match(/\d+/)[0]);
const newTotal = currentTotal - keysToDelete.length;
totalCountElement.textContent = '媒体文件 ' + newTotal + ' 个';
// 更新分页信息
const pageInfo = document.querySelector('.page-info');
if (pageInfo) {
const match = pageInfo.textContent.match(/共 (\d+) 个/);
if (match) {
pageInfo.textContent = pageInfo.textContent.replace(/共 \d+ 个/, '共 ' + newTotal + ' 个');
}
}
// 重置选择状态
selectedKeys.clear();
selectedCount = 0;
isAllSelected = false;
updateDeleteButton();
alert('选中的媒体已删除');
}, 300);
} else {
alert('删除失败');
}
}
function copyFormattedLinks(format) {
const urls = Array.from(selectedKeys).map(url => url.trim()).filter(url => url !== '');
const formattedLinks = formatLinks(urls, format);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(formattedLinks).then(() => {
alert('复制成功');
}).catch(() => {
alert('复制失败');
});
} else {
const textarea = document.createElement('textarea');
textarea.value = formattedLinks;
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
alert('复制成功');
} catch (err) {
alert('复制失败');
}
document.body.removeChild(textarea);
}
}
function formatLinks(urls, format) {
switch (format) {
case 'url':
return urls.join('\\n\\n');
case 'bbcode':
return urls.map(url => '[img]' + url + '[/img]').join('\\n\\n');
case 'markdown':
return urls.map(url => '
').join('\\n\\n');
default:
return urls.join('\\n');
}
}
function selectAllImages() {
const mediaContainers = Array.from(document.querySelectorAll('.media-container'));
const batchSize = 20;
let index = 0;
function processBatch() {
const end = Math.min(index + batchSize, mediaContainers.length);
for (let i = index; i < end; i++) {
const container = mediaContainers[i];
if (isAllSelected) {
container.classList.remove('selected');
const key = container.getAttribute('data-key');
selectedKeys.delete(key);
container.querySelector('.upload-time').style.display = 'none';
} else {
if (!container.classList.contains('selected')) {
container.classList.add('selected');
const key = container.getAttribute('data-key');
selectedKeys.add(key);
container.querySelector('.upload-time').style.display = 'block';
}
}
}
index = end;
if (index < mediaContainers.length) {
requestAnimationFrame(processBatch);
} else {
if (isAllSelected) {
selectedCount = 0;
} else {
selectedCount = selectedKeys.size;
}
isAllSelected = !isAllSelected;
updateDeleteButton();
}
}
requestAnimationFrame(processBatch);
}
function openFrontend() {
window.open('/', '_blank');
}
function logout() {
if (confirm('确定要退出登录吗?')) {
// 清除认证信息并跳转到首页
window.location.href = '/';
}
}
async function createFolder() {
const folderName = document.getElementById('newFolderInput').value.trim();
if (!folderName) {
alert('请输入文件夹名称');
return;
}
try {
const response = await fetch('/folders/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ folder: folderName })
});
const data = await response.json();
if (response.ok) {
alert('文件夹创建成功');
location.reload();
} else {
alert('创建失败: ' + data.error);
}
} catch (error) {
alert('创建文件夹失败: ' + error.message);
}
}
function filterByFolder(folderName, event) {
if (event) {
event.stopPropagation();
}
const gallery = document.querySelector('.gallery');
const mediaContainers = gallery.querySelectorAll('.media-container');
const folderItems = document.querySelectorAll('.folder-item');
// 更新文件夹选中状态
folderItems.forEach(item => {
item.classList.remove('active');
});
// 根据文件夹名字找到对应的文件夹项并标记为 active
if (folderName === '') {
const allFilesBtn = document.getElementById('allFilesBtn');
if (allFilesBtn) {
allFilesBtn.classList.add('active');
}
} else {
folderItems.forEach(item => {
if (item.getAttribute('data-folder') === folderName) {
item.classList.add('active');
}
});
}
// 过滤文件显示
mediaContainers.forEach(container => {
const dataFolder = container.getAttribute('data-folder') || '';
if (folderName === '' || dataFolder === folderName) {
container.style.display = '';
} else {
container.style.display = 'none';
}
});
}
async function deleteFolder(folderName) {
if (!confirm('确定要删除文件夹 "' + folderName + '" 及其中的所有文件吗?此操作无法撤回。')) {
return;
}
try {
const response = await fetch('/folders/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ folder: folderName })
});
const data = await response.json();
if (response.ok) {
alert('文件夹已删除');
location.reload();
} else {
alert('删除失败: ' + data.error);
}
} catch (error) {
alert('删除文件夹失败: ' + error.message);
}
}
document.addEventListener('DOMContentLoaded', () => {
const mediaContainers = document.querySelectorAll('.media-container[data-key]');
const options = {
root: null,
rootMargin: '100px',
threshold: 0.01
};
const mediaObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const container = entry.target;
const skeleton = container.querySelector('.skeleton');
const video = container.querySelector('video');
const audio = container.querySelector('audio');
if (video) {
const source = video.querySelector('source');
if (source && source.dataset.src) {
source.src = source.dataset.src;
video.load();
video.onloadeddata = () => {
video.classList.add('loaded');
if (skeleton) skeleton.classList.add('hidden');
};
}
} else if (audio) {
const source = audio.querySelector('source');
if (source && source.dataset.src) {
source.src = source.dataset.src;
audio.load();
audio.onloadeddata = () => {
audio.classList.add('loaded');
if (skeleton) skeleton.classList.add('hidden');
};
}
} else {
const img = container.querySelector('img');
if (img && img.dataset.src && !img.src) {
img.src = img.dataset.src;
img.onload = () => {
img.classList.add('loaded');
if (skeleton) skeleton.classList.add('hidden');
};
img.onerror = () => {
if (skeleton) skeleton.classList.add('hidden');
};
} else if (!img) {
if (skeleton) skeleton.classList.add('hidden');
}
}
observer.unobserve(container);
}
});
}, options);
mediaContainers.forEach(container => {
mediaObserver.observe(container);
});
});
</script>
</head>
<body>
<h1 class="page-title">图库管理</h1>
<div class="folder-section">
<div class="folder-section-title">📂 文件夹管理</div>
<div class="folder-create-form">
<input type="text" id="newFolderInput" placeholder="输入新文件夹名称...">
<button onclick="createFolder()">创建文件夹</button>
</div>
${foldersHtml ? `
<div class="folder-list">
<div class="folder-item active" id="allFilesBtn" data-folder="" onclick="filterByFolder('', event)">
<span class="folder-name">📁 全部文件</span>
</div>
${foldersHtml}
</div>
` : '<div style="color: #999;">暂无文件夹</div>'}
</div>
<div class="header">
<div class="header-left">
<span>媒体文件 ${totalCount.count} 个</span>
<span>已选中: <span id="selected-count">0</span>个</span>
<button class="front-button" onclick="openFrontend()">打开前端</button>
<button class="logout-button" onclick="logout()">退出</button>
</div>
<div class="header-right hidden">
<div class="dropdown">
<button class="copy-button">复制</button>
<div class="dropdown-content">
<button onclick="copyFormattedLinks('url')">URL</button>
<button onclick="copyFormattedLinks('bbcode')">BBCode</button>
<button onclick="copyFormattedLinks('markdown')">Markdown</button>
</div>
</div>
<button id="select-all-button" class="delete-button" onclick="selectAllImages()">全选</button>
<button id="delete-button" class="delete-button" onclick="deleteSelectedImages()">删除</button>
</div>
</div>
<div class="gallery">
${mediaData.length === 0 ? '<div class="empty-state"><i>📁</i><div>暂无媒体文件</div></div>' : mediaHtml}
</div>
<div id="editor-modal">
<div class="editor-content">
<div class="editor-header">
<div class="editor-filename" id="editor-filename"></div>
<div class="editor-buttons">
<button class="editor-save" onclick="saveFileContent()">保存</button>
<button class="editor-cancel" onclick="closeFileEditor()">取消</button>
</div>
</div>
<textarea class="editor-textarea" id="editor-textarea"></textarea>
</div>
</div>
${mediaData.length > 0 ? '<div class="pagination">' +
'<button onclick="goToPage(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>上一页</button>' +
'<span class="page-info">第 ' + page + ' / ' + totalPages + ' 页 (共 ' + totalCount.count + ' 个)</span>' +
'<button onclick="goToPage(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>下一页</button>' +
'</div>' : ''}
<div class="footer">
到底啦
</div>
<script>
let currentEditingFileUrl = '';
function goToPage(pageNum) {
const url = new URL(window.location.href);
url.searchParams.set('page', pageNum);
window.location.href = url.toString();
}
async function openFileEditor(container) {
const fileUrl = container.getAttribute('data-key');
const filename = container.querySelector('.media-type').textContent;
try {
const response = await fetch(fileUrl);
if (!response.ok) throw new Error('文件不存在');
const fileContent = await response.text();
document.getElementById('editor-filename').textContent = filename;
document.getElementById('editor-textarea').value = fileContent;
document.getElementById('editor-modal').style.display = 'block';
currentEditingFileUrl = fileUrl;
} catch (error) {
alert('加载文件失败: ' + error.message);
}
}
function closeFileEditor() {
document.getElementById('editor-modal').style.display = 'none';
currentEditingFileUrl = '';
}
async function saveFileContent() {
if (!currentEditingFileUrl) {
alert('未选中任何文件');
return;
}
const newContent = document.getElementById('editor-textarea').value;
const saveBtn = document.querySelector('.editor-save');
saveBtn.disabled = true;
saveBtn.textContent = '保存中...';
try {
const response = await fetch('/edit-file', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: currentEditingFileUrl,
content: newContent
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '保存失败');
}
alert('文件保存成功!');
closeFileEditor();
} catch (error) {
alert('保存失败: ' + error.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = '保存';
}
}
// 点击modal背景关闭编辑器
window.onclick = function(event) {
const modal = document.getElementById('editor-modal');
if (event.target === modal) {
closeFileEditor();
}
};
</script>
</body>
</html>
`;
return new Response(html, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
} catch (error) {
console.error('生成管理页面出错:', error);
return new Response('页面生成失败: ' + error.message, { status: 500, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
}
async function fetchMediaData(DATABASE, limit = null, offset = 0) {
let query = 'SELECT url FROM media WHERE url NOT LIKE \'folder:%\' ORDER BY url DESC';
if (limit !== null) {
query += ' LIMIT ' + limit + ' OFFSET ' + offset;
}
const result = await DATABASE.prepare(query).all();
return result.results.map(row => {
const url = row.url;
let folder = '';
let filename = '';
const fileExtension = url.split('.').pop().toLowerCase();
const folderMatch = url.match(/\/folder([^]+)(\d+)(.+?)\./);
if (folderMatch) {
folder = folderMatch[1];
filename = decodeURIComponent(folderMatch[3]) + '.' + fileExtension;
} else {
const simpleMatch = url.match(/\/(\d+)_(.+?)\./);
if (simpleMatch) {
filename = decodeURIComponent(simpleMatch[2]) + '.' + fileExtension;
}
}
return { url: url, folder: folder, filename: filename };
});
}
async function handleUploadRequest(request, config) {
try {
const formData = await request.formData();
const file = formData.get('file');
const folder = formData.get('folder') || '';
if (!file) throw new Error('缺少文件');
if (file.size > config.maxSize) {
return jsonResponse({ error: '文件大小超过' + (config.maxSize / (1024 * 1024)) + 'MB限制' }, 413);
}
if (config.enableAuth && !authenticate(request, config.username, config.password)) {
return unauthorizedResponse();
}
const timestamp = Date.now();
const fileExtension = getFileExtension(file.name);
// 提取原始文件名(不含扩展名),用于在管理页面显示
const originalFileName = file.name.substring(0, file.name.lastIndexOf('.') || file.name.length);
const encodedFileName = encodeURIComponent(originalFileName);
// 如果选择了文件夹,R2 key 使用文件夹前缀
let r2Key = '' + timestamp;
let dbURL = 'https://' + config.domain + '/' + timestamp + '_' + encodedFileName + '.' + fileExtension;
if (folder && folder.trim() !== '') {
r2Key = 'folder_' + folder + '/' + timestamp;
dbURL = 'https://' + config.domain + '/folder' + folder + '' + timestamp + '_' + encodedFileName + '.' + fileExtension;
}
await config.r2Bucket.put(r2Key, file.stream(), {
httpMetadata: { contentType: file.type }
});
await config.database.prepare('INSERT INTO media (url) VALUES (?) ON CONFLICT(url) DO NOTHING').bind(dbURL).run();
return jsonResponse({ data: dbURL });
} catch (error) {
console.error('R2 上传错误:', error);
return jsonResponse({ error: error.message }, 500);
}
}
async function handleImageRequest(request, config) {
const requestedUrl = request.url;
const cache = caches.default;
const cacheKey = new Request(requestedUrl);
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) return cachedResponse;
// 验证URL是否在数据库中存在
const result = await config.database.prepare('SELECT url FROM media WHERE url = ?').bind(requestedUrl).first();
if (!result) {
const notFoundResponse = new Response('资源不存在', { status: 404 });
await cache.put(cacheKey, notFoundResponse.clone());
return notFoundResponse;
}
// 从虚拟URL中提取真实的R2 key
const urlParts = requestedUrl.split('/');
const fileName = urlParts[urlParts.length - 1];
const fileExtension = getFileExtension(fileName);
const fileNameWithoutExt = fileName.split('.')[0];
let timestamp = fileNameWithoutExt;
let r2Key = timestamp;
// 如果URL中有文件夹前缀,需要提取出文件夹名和时间戳
if (timestamp.includes('folder_')) {
// 格式:folderfoldernametimestampfilename 或 folderfoldername_timestamp
const match = timestamp.match(/^folder(.+?)(\d+)(?:_.*)?$/);
if (match) {
const folderName = match[1];
timestamp = match[2];
r2Key = 'folder_' + folderName + '/' + timestamp;
}
} else {
// 格式:timestamp_filename 或 timestamp
const match = fileNameWithoutExt.match(/^(\d+)(?:_.*)?$/);
if (match) {
timestamp = match[1];
r2Key = timestamp;
}
}
const object = await config.r2Bucket.get(r2Key);
if (!object) {
return new Response('获取文件内容失败', { status: 404 });
}
const contentType = getContentType(fileExtension);
const headers = new Headers();
headers.set('Content-Type', contentType);
headers.set('Content-Disposition', 'inline');
headers.set('Cache-Control', public, max-age=${CACHE_CONFIG.IMAGE});
headers.set('CDN-Cache-Control', public, max-age=${CACHE_CONFIG.IMAGE});
const responseToCache = new Response(object.body, { status: 200, headers });
await cache.put(cacheKey, responseToCache.clone());
return responseToCache;
}
async function handleBingImagesRequest() {
const cache = caches.default;
const cacheKey = new Request('https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=5');
const cachedResponse = await cache.match(cacheKey);
if (cachedResponse) return cachedResponse;
const res = await fetch(cacheKey);
if (!res.ok) {
return new Response('请求 Bing API 失败', { status: res.status });
}
const bingData = await res.json();
const images = bingData.images.map(image => ({
url: https://cn.bing.com${image.url}
}));
const returnData = {
status: true,
message: "操作成功",
data: images
};
const response = createCachedResponse(
JSON.stringify(returnData),
'application/json',
CACHE_CONFIG.API
);
await cache.put(cacheKey, response.clone());
return response;
}
async function handleDeleteImagesRequest(request, config) {
if (!authenticate(request, config.username, config.password)) {
return unauthorizedResponse();
}
if (request.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405 });
}
try {
const keysToDelete = await request.json();
if (!Array.isArray(keysToDelete) || keysToDelete.length === 0) {
return jsonResponse({ message: '没有要删除的项' }, 400);
}
const placeholders = keysToDelete.map(() => '?').join(',');
const cache = caches.default;
const [dbResult] = await Promise.all([
config.database.prepare(
'DELETE FROM media WHERE url IN (' + placeholders + ')'
).bind(...keysToDelete).run(),
Promise.all(keysToDelete.map(async (url) => {
const cacheKey = new Request(url);
await cache.delete(cacheKey);
const urlParts = url.split('/');
const fileName = urlParts[urlParts.length - 1];
let timestamp = fileName.split('.')[0];
let r2Key = timestamp;
// 从虚拟URL中提取真实的R2 key
if (timestamp.includes('folder_')) {
const match = timestamp.match(/^folder(.+)(\d+)$/);
if (match) {
const folderName = match[1];
timestamp = match[2];
r2Key = 'folder_' + folderName + '/' + timestamp;
}
}
await config.r2Bucket.delete(r2Key);
}))
]);
if (dbResult.changes === 0) {
return jsonResponse({ message: '未找到要删除的项' }, 404);
}
return jsonResponse({ message: '删除成功' });
} catch (error) {
return jsonResponse({ error: '删除失败', details: error.message }, 500);
}
}
async function handleGetFoldersRequest(request, config) {
try {
const result = await config.database.prepare('SELECT url FROM media WHERE url LIKE \'folder:%\' ORDER BY url ASC').all();
const folders = result.results
.map(row => row.url.replace(/^folder:/, ''))
.filter((f, i, arr) => arr.indexOf(f) === i);
return jsonResponse({ folders });
} catch (error) {
console.log('获取文件夹失败:', error);
return jsonResponse({ folders: [] });
}
}
async function handleCreateFolderRequest(request, config) {
if (!authenticate(request, config.username, config.password)) {
return unauthorizedResponse();
}
try {
const body = await request.json();
const folderName = body.folder;
if (!folderName || folderName.trim() === '') {
return jsonResponse({ error: '文件夹名不能为空' }, 400);
}
// 创建一个占位符记录来标记文件夹
const placeholderUrl = 'folder:' + folderName;
await config.database.prepare('INSERT INTO media (url) VALUES (?) ON CONFLICT(url) DO NOTHING').bind(placeholderUrl).run();
return jsonResponse({ message: '文件夹创建成功', folder: folderName });
} catch (error) {
return jsonResponse({ error: '创建文件夹失败', details: error.message }, 500);
}
}
async function handleDeleteFolderRequest(request, config) {
if (!authenticate(request, config.username, config.password)) {
return unauthorizedResponse();
}
try {
const body = await request.json();
const folderName = body.folder;
if (!folderName || folderName.trim() === '') {
return jsonResponse({ error: '文件夹名不能为空' }, 400);
}
// 删除该文件夹下的所有文件(通过 URL 前缀匹配)
const folderPrefix = '/folder' + folderName + '';
const filesResult = await config.database.prepare('SELECT url FROM media WHERE url LIKE ?').bind('%' + folderPrefix + '%').all();
const files = filesResult.results.map(row => row.url).filter(url => !url.startsWith('folder:'));
if (files.length > 0) {
const placeholders = files.map(() => '?').join(',');
await config.database.prepare('DELETE FROM media WHERE url IN (' + placeholders + ')').bind(...files).run();
// 删除 R2 中的文件
for (const url of files) {
try {
const urlParts = url.split('/');
const fileName = urlParts[urlParts.length - 1];
let timestamp = fileName.split('.')[0];
let r2Key = timestamp;
// 从虚拟URL中提取真实的R2 key
if (timestamp.includes('folder_')) {
const match = timestamp.match(/^folder(.+)(\d+)$/);
if (match) {
const folderName = match[1];
timestamp = match[2];
r2Key = 'folder_' + folderName + '/' + timestamp;
}
}
await config.r2Bucket.delete(r2Key);
} catch (e) {
console.error('删除 R2 文件失败:', e);
}
}
}
// 删除文件夹占位符
const placeholderUrl = 'folder:' + folderName;
await config.database.prepare('DELETE FROM media WHERE url = ?').bind(placeholderUrl).run();
return jsonResponse({ message: '文件夹删除成功' });
} catch (error) {
return jsonResponse({ error: '删除文件夹失败', details: error.message }, 500);
}
}
async function handleEditFileRequest(request, config) {
if (!authenticate(request, config.username, config.password)) {
return unauthorizedResponse();
}
try {
const body = await request.json();
const fileUrl = body.url;
const newContent = body.content;
if (!fileUrl) {
return jsonResponse({ error: '文件URL不能为空' }, 400);
}
// 验证文件是否在数据库中存在
const result = await config.database.prepare('SELECT url FROM media WHERE url = ?').bind(fileUrl).first();
if (!result) {
return jsonResponse({ error: '文件不存在' }, 404);
}
// 从URL中提取R2 key
const urlParts = fileUrl.split('/');
const fileName = urlParts[urlParts.length - 1];
const fileExtension = getFileExtension(fileName);
const fileNameWithoutExt = fileName.split('.')[0];
let timestamp = fileNameWithoutExt;
let r2Key = timestamp;
// 如果URL中有文件夹前缀,需要提取出文件夹名和时间戳
if (timestamp.includes('folder_')) {
const match = timestamp.match(/^folder(.+?)(\d+)(?:_.*)?$/);
if (match) {
const folderName = match[1];
timestamp = match[2];
r2Key = 'folder_' + folderName + '/' + timestamp;
}
} else {
const match = fileNameWithoutExt.match(/^(\d+)(?:_.*)?$/);
if (match) {
timestamp = match[1];
r2Key = timestamp;
}
}
// 检查是否是可编辑的文件类型
const editableExtensions = ['txt', 'json', 'js'];
if (!editableExtensions.includes(fileExtension)) {
return jsonResponse({ error: '该文件类型不支持编辑' }, 400);
}
// 更新R2中的文件内容
const contentType = fileExtension === 'json' ? 'application/json'
: fileExtension === 'js' ? 'application/javascript'
: 'text/plain';
await config.r2Bucket.put(r2Key, newContent, {
httpMetadata: { contentType: contentType }
});
return jsonResponse({ message: '文件保存成功' });
} catch (error) {
console.error('编辑文件错误:', error);
return jsonResponse({ error: '保存文件失败', details: error.message }, 500);
}
}