file_system/web/index.html
root 00a0e583a8 添加存储桶删除功能
- 新增 DeleteBucketHandler 处理存储桶删除请求
- 添加 DELETE /buckets API 端点
- 在前端界面添加删除存储桶按钮功能
- 添加存储桶删除请求验证器

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-19 16:40:09 +08:00

578 lines
29 KiB
HTML

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RustFS 高级文件管理系统</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Vue 3 -->
<script src="https://cdn.jsdelivr.net/npm/vue@3.3.4/dist/vue.global.prod.js"></script>
<!-- Axios -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body { background-color: #f8f9fa; font-family: "Microsoft YaHei", sans-serif; }
.card { border-radius: 10px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); border: none; margin-bottom: 20px; }
.card-header { background-color: #fff; border-bottom: 1px solid #eee; font-weight: bold; padding: 15px 20px; }
.btn-primary { background-color: #0d6efd; border: none; }
.progress { height: 20px; border-radius: 10px; }
.file-icon { font-size: 1.2rem; margin-right: 10px; color: #6c757d; }
.action-btn { cursor: pointer; margin-right: 10px; }
.action-btn:hover { color: #0d6efd; }
[v-cloak] { display: none; }
</style>
</head>
<body>
<div id="app" class="container py-4" v-cloak>
<div class="d-flex justify-content-between align-items-center mb-4">
<h1><i class="fas fa-cloud-upload-alt text-primary me-2"></i>RustFS 文件管理系统</h1>
<div>
<a href="/swagger/index.html" target="_blank" class="btn btn-outline-secondary me-2">
<i class="fas fa-book me-1"></i>API 文档
</a>
<button class="btn btn-primary" @click="showCreateBucketModal = true">
<i class="fas fa-plus me-1"></i>新建存储桶
</button>
</div>
</div>
<div class="row">
<!-- Sidebar: Buckets -->
<div class="col-md-3">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
存储桶列表
<button class="btn btn-sm btn-link" @click="loadBuckets"><i class="fas fa-sync-alt"></i></button>
</div>
<div class="list-group list-group-flush">
<div v-for="bucket in buckets" :key="bucket"
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
:class="{ active: currentBucket === bucket }">
<a href="#" class="text-decoration-none flex-grow-1" :class="{ 'text-white': currentBucket === bucket }" @click.prevent="selectBucket(bucket)">
<i class="fas fa-box me-2"></i>{{ bucket }}
</a>
<span class="action-btn" :class="currentBucket === bucket ? 'text-white' : 'text-danger'" @click.stop="deleteBucket(bucket)" title="删除存储桶">
<i class="fas fa-trash-alt"></i>
</span>
</div>
<div v-if="buckets.length === 0" class="list-group-item text-muted text-center py-4">
暂无存储桶
</div>
</div>
</div>
</div>
<!-- Main Content: File List & Upload -->
<div class="col-md-9">
<!-- Toolbar -->
<div class="card mb-3" v-if="currentBucket">
<div class="card-body py-3">
<div class="row g-3 align-items-center">
<div class="col-auto">
<label class="col-form-label fw-bold">{{ currentBucket }}</label>
</div>
<div class="col">
<div class="input-group">
<span class="input-group-text bg-white"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" placeholder="搜索文件名..." v-model="filters.prefix" @keyup.enter="refreshFiles">
</div>
</div>
<div class="col-auto">
<button class="btn btn-success" @click="triggerFileInput">
<i class="fas fa-upload me-1"></i>上传文件
</button>
<input type="file" ref="fileInput" class="d-none" @change="handleFileSelect">
</div>
</div>
</div>
</div>
<!-- File List Table -->
<div class="card" v-if="currentBucket">
<div class="card-body p-0">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-4">文件名</th>
<th>大小</th>
<th>修改时间</th>
<th class="text-end pe-4">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.Key">
<td class="ps-4">
<i :class="getFileIcon(file.Key)" class="file-icon"></i>
{{ file.Key }}
</td>
<td>{{ formatSize(file.Size) }}</td>
<td class="text-muted small">{{ formatDate(file.LastModified) }}</td>
<td class="text-end pe-4">
<span class="action-btn text-primary" @click="previewFile(file.Key)" title="预览">
<i class="fas fa-eye"></i>
</span>
<span class="action-btn text-success" @click="downloadFile(file.Key)" title="下载">
<i class="fas fa-download"></i>
</span>
<span class="action-btn text-danger" @click="deleteFile(file.Key)" title="删除">
<i class="fas fa-trash-alt"></i>
</span>
</td>
</tr>
<tr v-if="files.length === 0 && !loadingFiles">
<td colspan="4" class="text-center py-5 text-muted">
<i class="fas fa-folder-open fa-3x mb-3 d-block opacity-50"></i>
暂无文件
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="card-footer d-flex justify-content-between align-items-center" v-if="nextToken || pageHistory.length > 0">
<button class="btn btn-sm btn-outline-secondary" :disabled="pageHistory.length === 0" @click="prevPage">
<i class="fas fa-chevron-left me-1"></i>上一页
</button>
<span class="text-muted small">当前页数: {{ pageHistory.length + 1 }}</span>
<button class="btn btn-sm btn-outline-secondary" :disabled="!nextToken" @click="nextPage">
下一页<i class="fas fa-chevron-right ms-1"></i>
</button>
</div>
</div>
<div v-else class="text-center py-5">
<i class="fas fa-arrow-left fa-3x text-muted mb-3 d-block opacity-25"></i>
<h4 class="text-muted">请选择一个存储桶</h4>
</div>
<!-- Upload Progress -->
<div class="card mt-3" v-if="uploads.length > 0">
<div class="card-header">上传任务队列</div>
<ul class="list-group list-group-flush">
<li class="list-group-item" v-for="upload in uploads" :key="upload.id">
<div class="d-flex justify-content-between mb-1">
<div>
<span class="fw-bold">{{ upload.file.name }}</span>
<span class="badge bg-secondary ms-2">{{ upload.status }}</span>
</div>
<span class="small text-muted">{{ upload.progress }}%</span>
</div>
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated"
:class="getProgressBarClass(upload.status)"
role="progressbar"
:style="{ width: upload.progress + '%' }"></div>
</div>
<div class="mt-1 small text-danger" v-if="upload.error">{{ upload.error }}</div>
</li>
</ul>
</div>
</div>
</div>
<!-- Create Bucket Modal -->
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="showCreateBucketModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">新建存储桶</h5>
<button type="button" class="btn-close" @click="showCreateBucketModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">存储桶名称</label>
<input type="text" class="form-control" v-model="newBucketName" placeholder="输入名称...">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showCreateBucketModal = false">取消</button>
<button type="button" class="btn btn-primary" @click="createBucket">创建</button>
</div>
</div>
</div>
</div>
<!-- Preview Modal -->
<div class="modal fade show" style="display: block; background: rgba(0,0,0,0.5)" v-if="previewUrl">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">文件预览</h5>
<button type="button" class="btn-close" @click="previewUrl = null"></button>
</div>
<div class="modal-body text-center bg-light p-4">
<img v-if="isPreviewImage" :src="previewUrl" class="img-fluid" style="max-height: 80vh">
<video v-else-if="isPreviewVideo" :src="previewUrl" controls class="w-100" style="max-height: 80vh"></video>
<iframe v-else :src="previewUrl" class="w-100" style="height: 60vh; border:none"></iframe>
</div>
<div class="modal-footer justify-content-center">
<a :href="previewUrl" target="_blank" class="btn btn-outline-primary">
<i class="fas fa-external-link-alt me-1"></i>在新窗口打开
</a>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted } = Vue;
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
createApp({
setup() {
const buckets = ref([]);
const currentBucket = ref(null);
const files = ref([]);
const loadingFiles = ref(false);
const nextToken = ref(null);
const pageHistory = ref([]); // Stack to store tokens for previous pages
const filters = ref({ prefix: '', maxKeys: 20 });
const showCreateBucketModal = ref(false);
const newBucketName = ref('');
const uploads = ref([]); // { id, file, progress, status: 'pending'|'uploading'|'completed'|'failed', error, uploadId, parts }
const previewUrl = ref(null);
const previewType = ref('');
// API Client
const api = axios.create({ baseURL: window.location.origin });
// Load Buckets
const loadBuckets = async () => {
try {
const res = await api.get('/buckets');
buckets.value = res.data.buckets || [];
} catch (err) {
alert('加载存储桶失败: ' + (err.response?.data?.error || err.message));
}
};
// Create Bucket
const createBucket = async () => {
if (!newBucketName.value) return alert('请输入名称');
try {
await api.post('/buckets', { bucket_name: newBucketName.value });
await loadBuckets();
showCreateBucketModal.value = false;
newBucketName.value = '';
} catch (err) {
alert('创建失败: ' + (err.response?.data?.error || err.message));
}
};
// Select Bucket
const selectBucket = (name) => {
currentBucket.value = name;
nextToken.value = null;
pageHistory.value = [];
loadFiles();
};
// Load Files
const loadFiles = async (token = null) => {
loadingFiles.value = true;
try {
const res = await api.get('/files/list', {
params: {
bucket_name: currentBucket.value,
prefix: filters.value.prefix,
max_keys: filters.value.maxKeys,
token: token
}
});
files.value = res.data.Files || [];
nextToken.value = res.data.NextContinuationToken;
} catch (err) {
console.error(err);
alert('加载文件列表失败');
} finally {
loadingFiles.value = false;
}
};
const refreshFiles = () => {
nextToken.value = null;
pageHistory.value = [];
loadFiles();
};
const nextPage = () => {
if (nextToken.value) {
pageHistory.value.push(filters.value.token); // Save current token (which was used to get here) - actually we need to save the *previous* state.
// Simplified: pageHistory stores the token that GENERATED the current page.
// Actually better:
// Page 1: token=null. Res returns T1.
// Page 2: req token=T1. Res returns T2.
// History: [null, T1]
pageHistory.value.push(nextToken.value); // Wait, this logic is tricky without full state.
// Correct logic: push current page's start token to history.
// But wait, listObjectsV2 is stateless.
// Let's just reload with nextToken.
// We need to store the token used to fetch CURRENT page to go back? No, to go back we need token of PREVIOUS page.
// Let's simplistic approach: History stores [token_for_page_1, token_for_page_2...]
// But we don't know token for page 1 (it's null).
// Let's ignore complex history for now and just support Next, and reset on bucket change.
// To support Prev properly, we need to push the token used for *current* view into stack before moving.
// But we don't have "current token" stored in variable clearly except implicitly.
// Let's re-implement: loadFiles takes token.
// We will just use the returned nextToken for next page.
// For prev page, we need a stack of tokens.
loadFiles(nextToken.value);
}
};
// Fix pagination logic later or keep simple "Load More" style?
// User asked for "Pagination", table style usually implies Next/Prev.
// S3 only supports forward paging efficiently. Prev requires caching tokens.
// We will implement simple Next for now, Prev is hard without state.
// Let's actually implement a stack.
// When clicking Next: push current_token (or null for page 1) to stack. load(next_token).
// Refined Pagination
const currentToken = ref(null); // Token used for current page
const loadFilesWrapped = async (token) => {
loadingFiles.value = true;
try {
const res = await api.get('/files/list', {
params: {
bucket_name: currentBucket.value,
prefix: filters.value.prefix,
max_keys: filters.value.maxKeys,
token: token
}
});
files.value = res.data.Files || [];
currentToken.value = token;
nextToken.value = res.data.NextContinuationToken;
} catch (err) {
console.error(err);
} finally {
loadingFiles.value = false;
}
}
const nextP = () => {
if(!nextToken.value) return;
pageHistory.value.push(currentToken.value);
loadFilesWrapped(nextToken.value);
}
const prevPage = () => {
if(pageHistory.value.length === 0) return;
const prevToken = pageHistory.value.pop();
loadFilesWrapped(prevToken);
}
// Override originals
const loadFilesInitial = () => loadFilesWrapped(null);
// File Upload
const triggerFileInput = () => document.querySelector('input[type=file]').click();
const handleFileSelect = async (e) => {
const file = e.target.files[0];
if (!file) return;
e.target.value = ''; // reset
// Create upload task
const uploadTask = {
id: Date.now(),
file: file,
progress: 0,
status: 'pending',
error: null,
uploadId: null,
parts: []
};
uploads.value.unshift(uploadTask);
processUpload(uploadTask);
};
const processUpload = async (task) => {
task.status = 'uploading';
const file = task.file;
const bucket = currentBucket.value;
const key = file.name;
try {
// 1. Init Multipart
const initRes = await api.post('/files/multipart/init', {
bucket_name: bucket,
object_key: key
});
task.uploadId = initRes.data.upload_id;
// 2. Upload Parts
const totalParts = Math.ceil(file.size / CHUNK_SIZE);
const parts = [];
for (let i = 0; i < totalParts; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const partNumber = i + 1;
const formData = new FormData();
formData.append('bucket_name', bucket);
formData.append('object_key', key);
formData.append('upload_id', task.uploadId);
formData.append('part_number', partNumber);
formData.append('file', chunk);
// Retry logic for part
let retries = 3;
let etag = null;
while(retries > 0) {
try {
const partRes = await api.put('/files/multipart/part', formData);
etag = partRes.data.etag;
break;
} catch(e) {
retries--;
if(retries === 0) throw e;
await new Promise(r => setTimeout(r, 1000));
}
}
parts.push({ PartNumber: partNumber, ETag: etag });
// Update Progress
task.progress = Math.round(((i + 1) / totalParts) * 100);
}
// 3. Complete
await api.post('/files/multipart/complete', {
bucket_name: bucket,
object_key: key,
upload_id: task.uploadId,
parts: parts
});
task.status = 'completed';
task.progress = 100;
setTimeout(() => refreshFiles(), 1000);
} catch (err) {
console.error(err);
task.status = 'failed';
task.error = err.response?.data?.error || err.message;
}
};
// Preview
const previewFile = async (key) => {
try {
const res = await api.get('/files/preview', {
params: { bucket_name: currentBucket.value, object_key: key }
});
previewUrl.value = res.data.url;
// Determine type
const ext = key.split('.').pop().toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext)) {
previewType.value = 'image';
} else if (['mp4', 'webm', 'ogg'].includes(ext)) {
previewType.value = 'video';
} else {
previewType.value = 'other';
}
} catch (err) {
alert('无法获取预览链接');
}
};
const isPreviewImage = computed(() => previewType.value === 'image');
const isPreviewVideo = computed(() => previewType.value === 'video');
// Download
const downloadFile = (key) => {
const url = `${window.location.origin}/files/download?bucket_name=${encodeURIComponent(currentBucket.value)}&object_key=${encodeURIComponent(key)}`;
window.open(url, '_blank');
};
// Delete File
const deleteFile = async (key) => {
if (!confirm(`确定要删除文件 "${key}" 吗?此操作不可恢复!`)) return;
try {
await api.delete('/files/delete', {
data: { bucket_name: currentBucket.value, object_key: key }
});
loadFilesWrapped(currentToken.value);
} catch (err) {
alert('删除失败: ' + (err.response?.data?.error || err.message));
}
};
// Delete Bucket
const deleteBucket = async (bucketName) => {
if (!confirm(`确定要删除存储桶 "${bucketName}" 吗?\n注意:存储桶必须为空才能删除!`)) return;
try {
await api.delete('/buckets', {
data: { bucket_name: bucketName }
});
if (currentBucket.value === bucketName) {
currentBucket.value = null;
files.value = [];
}
await loadBuckets();
} catch (err) {
alert('删除存储桶失败: ' + (err.response?.data?.error || err.message));
}
};
// Utils
const 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];
};
const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleString('zh-CN');
};
const getFileIcon = (filename) => {
const ext = filename.split('.').pop().toLowerCase();
if (['jpg', 'png', 'gif'].includes(ext)) return 'fas fa-file-image text-primary';
if (['pdf', 'doc', 'docx'].includes(ext)) return 'fas fa-file-pdf text-danger';
if (['mp4', 'avi'].includes(ext)) return 'fas fa-file-video text-success';
if (['zip', 'rar'].includes(ext)) return 'fas fa-file-archive text-warning';
return 'fas fa-file text-secondary';
};
const getProgressBarClass = (status) => {
if(status === 'completed') return 'bg-success';
if(status === 'failed') return 'bg-danger';
return 'bg-primary';
}
onMounted(() => {
loadBuckets();
});
return {
buckets, currentBucket, files, loadingFiles, nextToken, pageHistory, filters,
showCreateBucketModal, newBucketName, uploads, previewUrl, isPreviewImage, isPreviewVideo,
loadBuckets, createBucket, selectBucket, deleteBucket, refreshFiles: () => loadFilesInitial(),
nextPage: nextP, prevPage,
triggerFileInput, handleFileSelect,
previewFile, downloadFile, deleteFile,
formatSize, formatDate, getFileIcon, getProgressBarClass
};
}
}).mount('#app');
</script>
</body>
</html>