昨天
@上善若水 同学推荐的Cloudflare R2对象存储试了下不错,搭建私人存储挺好的,就是文件管理起来麻烦了一点,每次要登录到cf控制面板,不利于管理,个人更偏向百度网盘客户端之类的管理方式,于是试着利用R2 API接口,使用H5+JS实现了一个客户端:

支持一一般性操作:文件上传、下载、删除、目录增删改、重命名等操作
先到cf控制面板创建r2对象存储的api token:

配置参数说明:
Account ID:账号id,可以在后台中S3 API这一项中看到,
https://xxx.r2.cloudflarestorage.com/yyy中的xxx就是Account ID
Access Key ID:访问密钥 ID
Secret Access Key:机密访问密钥
Bucket Name:存储空间名称,就是创建时的填的空间名称,可以在后台中S3 API这一项中看到,
https://xxx.r2.cloudflarestorage.com/yyy中的yyy就是Bucket Name
Bucket Domain:存储空间绑定的域名,无需https://前缀
注意这个源码不能直接在浏览器允许,由于浏览器cors限制,需要浏览器设置--disable-web-security参数允许跨域,详见
https://www.haorooms.com/post/chrome_cros_yx于是我封装成了exe程序,可以直接双击打开使用,使用的aardio套壳,可能会报毒,但文件很小,只有2M,比Electron打包巨无敌好得多
R2 Storage Manager.exe(2.23 MB)源码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>R2 Storage Manager</title>
<link
href="https://s4.zstatic.net/ajax/libs/bootstrap/5.3.0/css/bootstrap.min.css"
rel="stylesheet"
/>
<link
href="https://s4.zstatic.net/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css"
rel="stylesheet"
/>
<style>
.file-item {
cursor: pointer;
padding: 8px;
border-radius: 4px;
}
.file-item:hover {
background-color: #f8f9fa;
}
.folder {
color: #ffd700;
}
.file {
color: #6c757d;
}
.breadcrumb-item {
cursor: pointer;
}
.loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.8);
display: none;
justify-content: center;
align-items: center;
z-index: 9999;
}
</style>
</head>
<body>
<div class="container-fluid">
<!-- Header -->
<div class="row bg-light py-3 mb-3">
<div class="col">
<h4>R2 Storage Manager</h4>
</div>
<div class="col-auto">
<div class="btn-group">
<button
class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#configModal"
>
<i class="bi bi-gear"></i> Config
</button>
</div>
</div>
</div>
<!-- Main Content -->
<div class="row">
<!-- Toolbar -->
<div class="col-12 mb-3">
<div class="btn-group">
<button
class="btn btn-success"
onclick="handleUpload()"
>
<i class="bi bi-upload"></i> Upload
</button>
<button
class="btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#newFolderModal"
>
<i class="bi bi-folder-plus"></i> New Folder
</button>
<button
class="btn btn-danger"
id="deleteBtn"
style="display: none"
>
<i class="bi bi-trash"></i> Delete
</button>
<button
class="btn btn-warning"
id="renameBtn"
style="display: none"
>
<i class="bi bi-pencil"></i> Rename
</button>
</div>
<input
type="file"
id="fileInput"
multiple
style="display: none"
/>
</div>
<!-- Breadcrumb -->
<div class="col-12 mb-3">
<nav aria-label="breadcrumb">
<ol
class="breadcrumb"
id="breadcrumb"
>
<li
class="breadcrumb-item"
onclick="navigateTo('')"
>
Root
</li>
</ol>
</nav>
</div>
<!-- File List -->
<div class="col-12">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th width="40px">
<input
type="checkbox"
class="form-check-input"
id="selectAll"
/>
</th>
<th>Name</th>
<th width="200px">Size</th>
<th width="200px">Last Modified</th>
<th width="200px">Operate</th>
</tr>
</thead>
<tbody id="fileList"></tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Config Modal -->
<div
class="modal fade"
id="configModal"
tabindex="-1"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">R2 Configuration</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
></button>
</div>
<div class="modal-body">
<form id="configForm">
<div class="mb-3">
<label class="form-label">Account ID</label>
<input
type="text"
class="form-control"
id="accountId"
required
/>
</div>
<div class="mb-3">
<label class="form-label">Access Key ID</label>
<input
type="text"
class="form-control"
id="accessKeyId"
required
/>
</div>
<div class="mb-3">
<label class="form-label">Secret Access Key</label>
<input
type="password"
class="form-control"
id="secretAccessKey"
required
/>
</div>
<div class="mb-3">
<label class="form-label">Bucket Name</label>
<input
type="text"
class="form-control"
id="bucketName"
required
/>
</div>
<div class="mb-3">
<label class="form-label">Bucket Domain</label>
<input
type="text"
class="form-control"
id="bucketDomain"
placeholder="Optional"
/>
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
onclick="saveConfig()"
>
Save
</button>
</div>
</div>
</div>
</div>
<!-- New Folder Modal -->
<div
class="modal fade"
id="newFolderModal"
tabindex="-1"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create New Folder</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Folder Name</label>
<input
type="text"
class="form-control"
id="newFolderName"
/>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
onclick="createFolder()"
>
Create
</button>
</div>
</div>
</div>
</div>
<!-- Rename Modal -->
<div
class="modal fade"
id="renameModal"
tabindex="-1"
>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rename</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">New Name</label>
<input
type="text"
class="form-control"
id="newName"
/>
</div>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
onclick="renameItem()"
>
Rename
</button>
</div>
</div>
</div>
</div>
<!-- Loading Overlay -->
<div class="loading">
<div
class="spinner-border text-primary"
role="status"
>
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script src="https://s4.zstatic.net/ajax/libs/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
<script src="https://s4.zstatic.net/ajax/libs/aws-sdk/2.1691.0/aws-sdk.min.js"></script>
<script>
let s3Client = null;
let currentPath = '';
let selectedItems = new Set();
let config;
// 初始化
function init() {
config = loadConfig();
if (config) {
setupS3Client(config);
loadFiles();
}
}
function loadConfig() {
config = localStorage.getItem('r2Config');
if (config) {
const parsedConfig = JSON.parse(config);
document.getElementById('accountId').value = parsedConfig.accountId;
document.getElementById('accessKeyId').value = parsedConfig.accessKeyId;
document.getElementById('secretAccessKey').value =
parsedConfig.secretAccessKey;
document.getElementById('bucketName').value = parsedConfig.bucketName;
document.getElementById('bucketDomain').value =
parsedConfig.bucketDomain || '';
return parsedConfig;
}
return null;
}
function saveConfig() {
config = {
accountId: document.getElementById('accountId').value,
accessKeyId: document.getElementById('accessKeyId').value,
secretAccessKey: document.getElementById('secretAccessKey').value,
bucketName: document.getElementById('bucketName').value,
bucketDomain: document.getElementById('bucketDomain').value,
};
localStorage.setItem('r2Config', JSON.stringify(config));
setupS3Client(config);
loadFiles();
bootstrap.Modal.getInstance(document.getElementById('configModal')).hide();
}
// 设置S3客户端
function setupS3Client(config) {
AWS.config.update({
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
region: 'auto',
});
s3Client = new AWS.S3({
endpoint: `https://${config.accountId}.r2.cloudflarestorage.com`,
signatureVersion: 'v4',
params: { Bucket: config.bucketName },
});
}
// 加载文件列表
async function loadFiles() {
showLoading();
try {
const response = await s3Client
.listObjectsV2({
Prefix: currentPath,
Delimiter: '/',
})
.promise();
const fileList = document.getElementById('fileList');
fileList.innerHTML = '';
// 添加返回上级目录按钮
if (currentPath) {
let parentPath = currentPath.split('/').slice(0, -2).join('/') + '/';
parentPath = parentPath === '/' ? '' : parentPath;
fileList.innerHTML += `
<tr class="file-item" onclick="navigateTo('${parentPath}')">
<td></td>
<td><i class="bi bi-arrow-up"></i> ..</td>
<td></td>
<td></td>
<td></td>
</tr>
`;
}
// 显示文件夹
response.CommonPrefixes?.forEach((prefix) => {
const folderName = prefix.Prefix.split('/').slice(-2)[0];
fileList.innerHTML += `
<tr class="file-item" data-path="${prefix.Prefix}">
<td><input type="checkbox" class="form-check-input" onclick="toggleSelect(event, '${prefix.Prefix}')"></td>
<td><i class="bi bi-folder folder"></i> ${folderName}</td>
<td>-</td>
<td>-</td>
<td>-</td>
</tr>
`;
});
// 显示文件
response.Contents?.forEach((item) => {
if (item.Key !== currentPath) {
const fileName = item.Key.split('/').pop();
if (fileName) {
const downloadUrl = config.bucketDomain
? `https://${config.bucketDomain}/${item.Key}`
: s3Client.getSignedUrl('getObject', {
Key: item.Key,
Expires: 3600,
});
fileList.innerHTML += `
<tr class="file-item" data-path="${item.Key}">
<td><input type="checkbox" class="form-check-input" onclick="toggleSelect(event, '${
item.Key
}')"></td>
<td><i class="bi bi-file-earmark file"></i> ${fileName}</td>
<td>${formatSize(item.Size)}</td>
<td>${new Date(item.LastModified).toLocaleString()}</td>
<td>
<button class="btn btn-sm btn-primary" onclick="downloadFile('${downloadUrl}')">
<i class="bi bi-download"></i>
</button>
<button class="btn btn-sm btn-secondary" onclick="copyLink('${downloadUrl}')">
<i class="bi bi-link-45deg"></i>
</button>
</td>
</tr>
`;
}
}
});
updateBreadcrumb();
} catch (error) {
console.error('Error loading files:', error);
alert('Error loading files');
}
hideLoading();
}
// 更新面包屑导航
function updateBreadcrumb() {
const breadcrumb = document.getElementById('breadcrumb');
breadcrumb.innerHTML =
'<li class="breadcrumb-item" onclick="navigateTo(\'\')">Root</li>';
if (currentPath) {
const paths = currentPath.split('/').filter((p) => p);
let currentPathBuilder = '';
paths.forEach((path) => {
currentPathBuilder += path + '/';
breadcrumb.innerHTML += `
<li class="breadcrumb-item" onclick="navigateTo('${currentPathBuilder}')">
${path}
</li>
`;
});
}
}
// 导航到指定路径
function navigateTo(path) {
currentPath = path;
selectedItems.clear();
updateToolbar();
loadFiles();
}
// 处理文件上传
function handleUpload() {
document.getElementById('fileInput').click();
}
// 上传文件
async function uploadFiles(files) {
showLoading();
try {
for (const file of files) {
const key = currentPath + file.name;
await s3Client
.putObject({
Key: key,
Body: file,
})
.promise();
}
loadFiles();
} catch (error) {
console.error('Error uploading files:', error);
alert('Error uploading files');
}
hideLoading();
}
// 创建文件夹
async function createFolder() {
const folderName = document.getElementById('newFolderName').value.trim();
if (folderName) {
showLoading();
try {
await s3Client
.putObject({
Key: currentPath + folderName + '/',
Body: '',
})
.promise();
loadFiles();
bootstrap.Modal.getInstance(
document.getElementById('newFolderModal')
).hide();
} catch (error) {
console.error('Error creating folder:', error);
alert('Error creating folder');
}
hideLoading();
}
}
// 删除选中项
async function deleteSelected() {
if (confirm('Are you sure you want to delete selected items?')) {
showLoading();
try {
for (const key of selectedItems) {
if (key.endsWith('/')) {
// 删除文件夹及其内容
const response = await s3Client
.listObjectsV2({
Prefix: key,
})
.promise();
const deletePromises = response.Contents.map((item) =>
s3Client.deleteObject({ Key: item.Key }).promise()
);
await Promise.all(deletePromises);
} else {
// 删除单个文件
await s3Client.deleteObject({ Key: key }).promise();
}
}
selectedItems.clear();
updateToolbar();
loadFiles();
} catch (error) {
console.error('Error deleting items:', error);
alert('Error deleting items');
}
hideLoading();
}
}
// 重命名选中项
async function renameItem() {
const newName = document.getElementById('newName').value.trim();
if (newName && selectedItems.size === 1) {
const oldKey = Array.from(selectedItems)[0];
const isFolder = oldKey.endsWith('/');
const newKey = currentPath + newName + (isFolder ? '/' : '');
showLoading();
try {
if (isFolder) {
// 重命名文件夹及其内容
const response = await s3Client
.listObjectsV2({
Prefix: oldKey,
})
.promise();
for (const item of response.Contents) {
const itemNewKey = item.Key.replace(oldKey, newKey);
await s3Client
.copyObject({
CopySource: encodeURIComponent(config.bucketName + '/' + item.Key),
Key: itemNewKey,
})
.promise();
await s3Client.deleteObject({ Key: item.Key }).promise();
}
} else {
// 重命名单个文件
await s3Client
.copyObject({
CopySource: encodeURIComponent(config.bucketName + '/' + oldKey),
Key: newKey,
})
.promise();
await s3Client.deleteObject({ Key: oldKey }).promise();
}
selectedItems.clear();
updateToolbar();
loadFiles();
bootstrap.Modal.getInstance(
document.getElementById('renameModal')
).hide();
} catch (error) {
console.error('Error renaming item:', error);
alert('Error renaming item');
}
hideLoading();
}
}
// 切换选择状态
function toggleSelect(event, key) {
event.stopPropagation();
if (event.target.checked) {
selectedItems.add(key);
} else {
selectedItems.delete(key);
}
updateToolbar();
}
// 更新工具栏
function updateToolbar() {
const deleteBtn = document.getElementById('deleteBtn');
const renameBtn = document.getElementById('renameBtn');
if (selectedItems.size > 0) {
deleteBtn.style.display = 'inline-block';
renameBtn.style.display =
selectedItems.size === 1 ? 'inline-block' : 'none';
} else {
deleteBtn.style.display = 'none';
renameBtn.style.display = 'none';
}
}
// 格式化文件大小
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 显示加载动画
function showLoading() {
document.querySelector('.loading').style.display = 'flex';
}
// 隐藏加载动画
function hideLoading() {
document.querySelector('.loading').style.display = 'none';
}
// 增加下载和复制链接功能
function downloadFile(url) {
window.open(url, '_blank');
}
function copyLink(url) {
navigator.clipboard
.writeText(url)
.then(() => {
alert('Download link copied to clipboard!');
})
.catch((err) => {
console.error('Failed to copy link:', err);
alert('Failed to copy link');
});
}
// 事件监听器
document.addEventListener('DOMContentLoaded', () => {
init();
// 文件选择事件
document.getElementById('fileInput').addEventListener('change', (e) => {
if (e.target.files.length > 0) {
uploadFiles(e.target.files);
e.target.value = '';
}
});
// 全选/取消全选
document.getElementById('selectAll').addEventListener('change', (e) => {
const checkboxes = document.querySelectorAll(
'#fileList input[type="checkbox"]'
);
checkboxes.forEach((checkbox) => {
checkbox.checked = e.target.checked;
const key = checkbox.closest('tr').dataset.path;
if (key) {
if (e.target.checked) {
selectedItems.add(key);
} else {
selectedItems.delete(key);
}
}
});
updateToolbar();
});
// 文件/文件夹点击事件
document.getElementById('fileList').addEventListener('click', (e) => {
const tr = e.target.closest('tr');
if (tr && !e.target.closest('input[type="checkbox"]')) {
const path = tr.dataset.path;
if (path && path.endsWith('/')) {
navigateTo(path);
}
}
});
// 删除按钮点击事件
document
.getElementById('deleteBtn')
.addEventListener('click', deleteSelected);
// 重命名按钮点击事件
document.getElementById('renameBtn').addEventListener('click', () => {
const key = Array.from(selectedItems)[0];
const name = key.split('/').slice(-2)[0];
document.getElementById('newName').value = name;
new bootstrap.Modal(document.getElementById('renameModal')).show();
});
});
</script>
</body>
</html>
@张小强,也可以用alist挂载。
一加ace2Pro(灰|24+1024)