542 lines
27 KiB
HTML
542 lines
27 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">
|
|
<a v-for="bucket in buckets" :key="bucket"
|
|
href="#"
|
|
class="list-group-item list-group-item-action"
|
|
:class="{ active: currentBucket === bucket }"
|
|
@click.prevent="selectBucket(bucket)">
|
|
<i class="fas fa-box me-2"></i>{{ bucket }}
|
|
</a>
|
|
<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>
|
|
</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');
|
|
};
|
|
|
|
// 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, refreshFiles: () => loadFilesInitial(),
|
|
nextPage: nextP, prevPage,
|
|
triggerFileInput, handleFileSelect,
|
|
previewFile, downloadFile,
|
|
formatSize, formatDate, getFileIcon, getProgressBarClass
|
|
};
|
|
}
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
</html>
|