一个Cloudflare R2对象存储文件管理客户端(H5实现)

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

支持一一般性操作:文件上传、下载、删除、目录增删改、重命名等操作


先到cf控制面板创建r2对象存储的api token:
QQ图片20241103161128.png(76.01 KB)
配置参数说明:
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>


回复列表(3|隐藏机器人聊天)
添加新回复
回复需要登录