Files

1828 lines
82 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>CloudNest - 文件管理</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/element-plus/dist/index.full.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f0f4f8; overflow-x: hidden; }
/* 主容器 / Main container */
.main-container { display: flex; min-height: 100vh; }
/* 侧边栏 / Sidebar */
.sidebar {
width: 260px;
background: linear-gradient(180deg, #1e293b 0%, #0f172a 100%);
color: white;
position: fixed;
left: 0;
top: 0;
bottom: 0;
z-index: 100;
display: flex;
flex-direction: column;
padding: 20px 0;
box-shadow: 4px 0 20px rgba(0,0,0,0.1);
}
/* Logo区域 / Logo section */
.sidebar-logo {
text-align: center;
padding: 25px 0;
border-bottom: 1px solid rgba(255,255,255,0.1);
margin-bottom: 20px;
}
.logo {
width: 55px;
height: 55px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
border-radius: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 26px;
margin-bottom: 12px;
box-shadow: 0 8px 20px rgba(37,99,235,0.4);
}
.logo-title {
font-size: 22px;
font-weight: 700;
letter-spacing: 3px;
}
/* 导航菜单 / Navigation menu */
.sidebar-nav { flex: 1; padding: 0 12px; }
.nav-item {
display: flex;
align-items: center;
padding: 14px 16px;
border-radius: 12px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.3s ease;
color: #94a3b8;
font-size: 14px;
font-weight: 500;
}
.nav-item:hover {
background: rgba(255,255,255,0.08);
color: white;
transform: translateX(4px);
}
.nav-item.active {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
box-shadow: 0 6px 18px rgba(37,99,235,0.35);
}
.nav-item i { margin-right: 14px; font-size: 18px; width: 22px; text-align: center; }
.nav-item .badge {
margin-left: auto;
background: #ef4444;
color: white;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
min-width: 20px;
}
/* 用户信息 / User info */
.sidebar-footer {
padding: 15px 12px;
border-top: 1px solid rgba(255,255,255,0.1);
}
.user-info {
display: flex;
align-items: center;
padding: 12px;
border-radius: 12px;
background: rgba(255,255,255,0.05);
}
.user-avatar {
width: 42px;
height: 42px;
background: linear-gradient(135deg, #0f766e, #14b8a6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-right: 12px;
font-weight: 600;
}
.user-detail { flex: 1; }
.user-name { font-size: 15px; font-weight: 600; }
.user-role { font-size: 12px; color: #64748b; margin-top: 2px; }
.logout-btn {
background: rgba(239,68,68,0.15);
color: #f87171;
border: none;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s;
font-size: 13px;
}
.logout-btn:hover { background: rgba(239,68,68,0.25); color: #ef4444; }
/* 主内容区 / Main content */
.main-content {
flex: 1;
margin-left: 260px;
display: flex;
flex-direction: column;
}
/* 顶部栏 / Top header */
.top-header {
background: white;
padding: 18px 35px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
position: sticky;
top: 0;
z-index: 50;
}
.header-left { display: flex; align-items: center; }
.header-title { font-size: 22px; font-weight: 700; color: #1e293b; }
.header-breadcrumb {
font-size: 13px;
color: #64748b;
margin-left: 18px;
display: flex;
align-items: center;
}
.header-breadcrumb i { margin: 0 6px; font-size: 10px; }
.header-right { display: flex; align-items: center; gap: 15px; }
/* 搜索框 / Search box */
.search-box { position: relative; }
.search-box input {
width: 280px;
padding: 12px 18px 12px 45px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 14px;
transition: all 0.3s;
outline: none;
background: #f8fafc;
}
.search-box input:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
background: white;
}
.search-box i {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: #94a3b8;
}
/* 上传按钮 / Upload button */
.upload-btn {
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 12px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
display: flex;
align-items: center;
transition: all 0.3s;
box-shadow: 0 6px 18px rgba(37,99,235,0.3);
}
.upload-btn:hover {
transform: translateY(-3px);
box-shadow: 0 10px 25px rgba(37,99,235,0.4);
}
.upload-btn i { margin-right: 10px; }
/* 内容区 / Content area */
.content-area { padding: 35px; }
/* 统计卡片 / Stats cards */
.stats-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 25px;
margin-bottom: 35px;
}
.stat-card {
background: white;
border-radius: 16px;
padding: 28px;
display: flex;
align-items: center;
box-shadow: 0 4px 15px rgba(0,0,0,0.04);
transition: all 0.3s;
}
.stat-card:hover { transform: translateY(-5px); box-shadow: 0 12px 30px rgba(0,0,0,0.08); }
.stat-icon {
width: 58px;
height: 58px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
margin-right: 22px;
}
.stat-icon.blue { background: linear-gradient(135deg, #dbeafe, #bfdbfe); color: #2563eb; }
.stat-icon.green { background: linear-gradient(135deg, #d1fae5, #a7f3d0); color: #059669; }
.stat-icon.orange { background: linear-gradient(135deg, #ffedd5, #fed7aa); color: #f97316; }
.stat-icon.purple { background: linear-gradient(135deg, #ede9fe, #ddd6fe); color: #7c3aed; }
.stat-info { flex: 1; }
.stat-value { font-size: 26px; font-weight: 700; color: #1e293b; }
.stat-label { font-size: 13px; color: #64748b; margin-top: 4px; }
/* 上传区域 / Upload area */
.upload-area {
background: white;
border-radius: 16px;
padding: 45px;
margin-bottom: 35px;
box-shadow: 0 4px 15px rgba(0,0,0,0.04);
}
.drop-zone {
border: 3px dashed #cbd5e1;
border-radius: 16px;
padding: 60px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #f8fafc;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: #2563eb;
background: #eff6ff;
}
.drop-zone i { font-size: 52px; color: #94a3b8; margin-bottom: 18px; transition: all 0.3s; }
.drop-zone:hover i, .drop-zone.dragover i { color: #2563eb; transform: scale(1.1); }
.drop-zone .title { font-size: 20px; font-weight: 600; color: #334155; margin-bottom: 10px; }
.drop-zone .subtitle { font-size: 14px; color: #64748b; }
.drop-zone .highlight { color: #2563eb; font-weight: 600; }
/* 文件列表区 / Files section */
.files-section { background: white; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.04); overflow: hidden; }
.section-header {
padding: 22px 35px;
border-bottom: 1px solid #f1f5f9;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title { font-size: 17px; font-weight: 600; color: #1e293b; }
.section-actions { display: flex; align-items: center; gap: 16px; }
.section-count { font-size: 13px; color: #94a3b8; }
.view-toggle {
display: flex;
background: #f1f5f9;
border-radius: 10px;
padding: 4px;
}
.toggle-btn {
width: 34px;
height: 34px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
transition: all 0.2s;
font-size: 16px;
}
.toggle-btn:hover { background: #e2e8f0; color: #475569; }
.toggle-btn.active { background: white; color: #2563eb; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.action-btn-sm {
padding: 8px 16px;
border-radius: 8px;
border: 2px solid #e2e8f0;
background: white;
cursor: pointer;
font-size: 13px;
transition: all 0.3s;
}
.action-btn-sm:hover { border-color: #2563eb; color: #2563eb; }
.action-btn-sm.active { border-color: #2563eb; background: #eff6ff; color: #2563eb; }
/* 文件容器 / Files container */
.files-container {
padding: 20px;
}
.files-container.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 14px;
}
.files-container.list {
display: flex;
flex-direction: column;
gap: 6px;
}
.file-card {
background: white;
border-radius: 12px;
padding: 16px;
cursor: pointer;
transition: all 0.3s;
position: relative;
border: 1px solid #e2e8f0;
display: flex;
flex-direction: column;
}
.file-card.list {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px 14px;
gap: 12px;
}
.file-card:hover {
box-shadow: 0 8px 24px rgba(0,0,0,0.06);
border-color: #cbd5e1;
}
.file-card.grid:hover {
transform: translateY(-3px);
}
.file-card.selected { border-color: #2563eb; background: #eff6ff; }
.file-preview {
width: 100%;
height: 90px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-bottom: 12px;
overflow: hidden;
background: #f1f5f9;
flex-shrink: 0;
}
.file-card.list .file-preview {
width: 44px;
height: 44px;
margin: 0;
font-size: 22px;
flex-shrink: 0;
}
.thumbnail-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.file-card:hover .thumbnail-image {
transform: scale(1.05);
}
.thumbnail-image.loading {
opacity: 0.5;
background: #f1f5f9;
}
.file-icon {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
}
.file-icon.doc { background: linear-gradient(135deg, #dbeafe, #bfdbfe); color: #2563eb; }
.file-icon.img { background: linear-gradient(135deg, #d1fae5, #a7f3d0); color: #059669; }
.file-icon.pdf { background: linear-gradient(135deg, #fee2e2, #fecaca); color: #dc2626; }
.file-icon.zip { background: linear-gradient(135deg, #ede9fe, #ddd6fe); color: #7c3aed; }
.file-icon.txt { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #d97706; }
.file-icon.other { background: linear-gradient(135deg, #e2e8f0, #cbd5e1); color: #64748b; }
.file-icon.doc { background: linear-gradient(135deg, #dbeafe, #bfdbfe); color: #2563eb; }
.file-icon.img { background: linear-gradient(135deg, #d1fae5, #a7f3d0); color: #059669; }
.file-icon.pdf { background: linear-gradient(135deg, #fee2e2, #fecaca); color: #dc2626; }
.file-icon.zip { background: linear-gradient(135deg, #ede9fe, #ddd6fe); color: #7c3aed; }
.file-icon.txt { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #d97706; }
.file-icon.other { background: linear-gradient(135deg, #e2e8f0, #cbd5e1); color: #64748b; }
.file-info {
flex: 1;
text-align: center;
margin-bottom: 10px;
display: flex;
flex-direction: column;
justify-content: center;
}
.file-card.list .file-info {
flex: 1;
text-align: left;
margin-bottom: 0;
min-width: 0;
justify-content: flex-start;
}
.file-name {
font-size: 14px;
font-weight: 500;
color: #1e293b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 6px;
}
.file-card.list .file-name {
margin-bottom: 0;
font-size: 15px;
}
.file-size { font-size: 12px; color: #94a3b8; }
.file-card.list .file-size { text-align: left; }
.file-meta {
display: flex;
align-items: center;
gap: 24px;
color: #94a3b8;
font-size: 13px;
flex-shrink: 0;
margin-left: auto;
}
.file-date { white-space: nowrap; }
.file-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding-top: 10px;
border-top: 1px solid #f1f5f9;
}
.file-card.list .file-actions {
padding-top: 0;
border-top: none;
margin-left: auto;
flex-shrink: 0;
}
.action-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
transition: all 0.2s;
background: #f8fafc;
color: #64748b;
}
.action-btn:hover {
transform: translateY(-1px);
}
.action-btn.favorite:hover, .action-btn.favorite.active {
background: #fef3c7;
color: #f59e0b;
}
.action-btn.download:hover {
background: #dbeafe;
color: #2563eb;
}
.action-btn.delete:hover {
background: #fee2e2;
color: #dc2626;
}
/* 空状态 / Empty state */
.empty-state {
text-align: center;
padding: 70px 35px;
color: #94a3b8;
}
.empty-state i { font-size: 72px; color: #cbd5e1; margin-bottom: 25px; }
.empty-state .title { font-size: 20px; font-weight: 500; color: #64748b; margin-bottom: 10px; }
.empty-state .subtitle { font-size: 14px; }
/* 上传进度 / Upload progress */
.upload-progress { margin-top: 25px; }
/* 响应式 / Responsive */
/* 平板端 (768px - 1200px) / Tablet (768px - 1200px) */
@media (max-width: 1200px) {
.stats-cards { grid-template-columns: repeat(2, 1fr); }
.search-box input { width: 220px; }
}
/* 移动端 (≤768px) / Mobile (≤768px) */
@media (max-width: 768px) {
/* 侧边栏改为底部导航 / Sidebar becomes bottom navigation */
.sidebar {
width: 100%;
height: 70px;
position: fixed;
left: 0;
bottom: 0;
top: auto;
flex-direction: row;
justify-content: space-around;
align-items: center;
padding: 0;
z-index: 9999;
box-shadow: 0 -4px 20px rgba(0,0,0,0.15);
}
.sidebar-logo { display: none; }
.sidebar-nav {
display: flex;
flex: 1;
padding: 0;
justify-content: space-around;
align-items: center;
}
.nav-item {
margin: 0;
padding: 12px 8px;
border-radius: 8px;
flex-direction: column;
gap: 4px;
min-width: 60px;
}
.nav-item span {
display: block;
font-size: 11px;
text-align: center;
}
.nav-item i {
margin: 0;
font-size: 20px;
}
.sidebar-footer { display: none; }
.main-content {
margin-left: 0;
margin-bottom: 70px;
}
/* 顶部栏 / Top header */
.top-header {
flex-direction: column;
gap: 12px;
padding: 12px 15px;
position: sticky;
top: 0;
z-index: 50;
}
.header-left { flex-direction: column; align-items: flex-start; gap: 4px; }
.header-title { font-size: 18px; }
.header-breadcrumb { margin-left: 0; font-size: 12px; }
.search-box { width: 100%; }
.search-box input {
width: 100%;
padding: 10px 15px 10px 40px;
font-size: 13px;
}
.upload-btn {
padding: 10px 20px;
font-size: 13px;
position: fixed;
right: 15px;
bottom: 85px;
z-index: 100;
box-shadow: 0 6px 20px rgba(37,99,235,0.4);
}
/* 内容区 / Content area */
.content-area { padding: 15px; }
/* 统计卡片 / Stats cards */
.stats-cards {
grid-template-columns: repeat(2, 1fr);
gap: 15px;
margin-bottom: 25px;
}
.stat-card { padding: 20px; }
.stat-icon { width: 50px; height: 50px; font-size: 22px; margin-right: 15px; }
.stat-value { font-size: 22px; }
.stat-label { font-size: 12px; }
/* 上传区域 / Upload area */
.upload-area { padding: 25px; margin-bottom: 25px; }
.drop-zone { padding: 40px 20px; }
.drop-zone i { font-size: 42px; margin-bottom: 12px; }
.drop-zone .title { font-size: 16px; }
.drop-zone .subtitle { font-size: 13px; }
/* 文件列表 / Files section */
.section-header { padding: 15px 20px; }
.section-title { font-size: 15px; }
.section-count { font-size: 12px; }
.files-grid {
grid-template-columns: repeat(2, 1fr);
gap: 15px;
padding: 15px 20px;
}
.file-card { padding: 18px; }
.file-icon { width: 55px; height: 55px; font-size: 28px; margin: 0 auto 12px; }
.file-name { font-size: 13px; margin-bottom: 6px; }
.file-size { font-size: 11px; }
/* 空状态 / Empty state */
.empty-state { padding: 50px 20px; }
.empty-state i { font-size: 56px; margin-bottom: 18px; }
.empty-state .title { font-size: 16px; }
.empty-state .subtitle { font-size: 13px; }
}
/* 小屏手机 (≤480px) / Small mobile (≤480px) */
@media (max-width: 480px) {
.stats-cards { grid-template-columns: 1fr; }
.files-grid { grid-template-columns: 1fr; }
.nav-item { min-width: 50px; }
.nav-item span { font-size: 10px; }
.nav-item i { font-size: 18px; }
.upload-btn { right: 10px; bottom: 80px; padding: 8px 16px; font-size: 12px; }
}
/* 预览模态框 / Preview modal */
.preview-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.8);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.preview-modal {
background: #1e293b;
border-radius: 20px;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
position: relative;
box-shadow: 0 25px 80px rgba(0,0,0,0.5);
}
.preview-modal-header {
padding: 20px 30px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.preview-modal-title {
color: white;
font-size: 18px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.preview-modal-close {
background: rgba(255,255,255,0.1);
border: none;
color: white;
width: 40px;
height: 40px;
border-radius: 12px;
cursor: pointer;
font-size: 20px;
transition: all 0.3s;
}
.preview-modal-close:hover { background: rgba(255,255,255,0.2); }
.preview-modal-content {
padding: 30px;
display: flex;
align-items: center;
justify-content: center;
max-height: calc(90vh - 100px);
overflow: auto;
}
.preview-image { max-width: 100%; max-height: 70vh; border-radius: 12px; }
.preview-pdf { width: 800px; height: 600px; border: none; border-radius: 12px; }
.preview-text {
background: #0f172a;
color: #e2e8f0;
padding: 25px;
border-radius: 12px;
max-width: 800px;
max-height: 70vh;
overflow: auto;
font-family: 'Consolas', monospace;
white-space: pre-wrap;
line-height: 1.8;
font-size: 14px;
}
.preview-unsupported {
text-align: center;
color: #94a3b8;
padding: 60px;
}
.preview-unsupported i { font-size: 64px; margin-bottom: 20px; color: #64748b; }
/* 设置页面 / Settings page */
.settings-section { background: white; border-radius: 16px; box-shadow: 0 4px 15px rgba(0,0,0,0.04); overflow: hidden; }
.settings-tabs {
display: flex;
border-bottom: 2px solid #f1f5f9;
}
.settings-tab {
padding: 20px 35px;
font-size: 15px;
font-weight: 600;
color: #64748b;
cursor: pointer;
transition: all 0.3s;
border-bottom: 3px solid transparent;
}
.settings-tab.active { color: #2563eb; border-bottom-color: #2563eb; }
.settings-tab:hover { color: #2563eb; }
.settings-content { padding: 35px; }
.form-group { margin-bottom: 25px; }
.form-label {
display: block;
font-size: 14px;
font-weight: 600;
color: #334155;
margin-bottom: 10px;
}
.form-input {
width: 100%;
padding: 14px 18px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 15px;
transition: all 0.3s;
outline: none;
}
.form-input:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,0.1); }
.form-input:disabled { background: #f8fafc; color: #94a3b8; cursor: not-allowed; }
.form-btn-group { display: flex; gap: 15px; margin-top: 30px; }
.form-btn {
padding: 14px 30px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
border: 2px solid #e2e8f0;
background: white;
transition: all 0.3s;
}
.form-btn.primary { background: #2563eb; color: white; border-color: #2563eb; }
.form-btn.primary:hover { background: #1d4ed8; }
.form-btn:hover { border-color: #cbd5e1; }
/* 回收站样式 / Recycle bin styles */
.restore-btn {
width: 32px;
height: 32px;
background: #d1fae5;
color: #059669;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.restore-btn:hover { background: #a7f3d0; transform: translateY(-1px); }
.delete-permanently-btn {
width: 32px;
height: 32px;
background: #fee2e2;
color: #dc2626;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.delete-permanently-btn:hover { background: #fecaca; transform: translateY(-1px); }
/* 操作按钮组 / Action buttons */
.file-card-actions {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
opacity: 0;
transition: opacity 0.3s;
}
.file-card:hover .file-card-actions { opacity: 1; }
/* 存储信息 / Storage info */
.storage-info {
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border-radius: 16px;
padding: 35px;
}
.storage-title { font-size: 18px; font-weight: 600; color: #1e293b; margin-bottom: 25px; }
.storage-bar {
height: 20px;
background: #e2e8f0;
border-radius: 10px;
overflow: hidden;
margin-bottom: 15px;
}
.storage-progress {
height: 100%;
background: linear-gradient(90deg, #2563eb, #1d4ed8);
border-radius: 10px;
transition: width 0.5s ease;
}
.storage-text { display: flex; justify-content: space-between; font-size: 14px; color: #64748b; }
</style>
</head>
<body>
<div id="app">
<div class="main-container">
<!-- 侧边栏 / Sidebar -->
<aside class="sidebar">
<div class="sidebar-logo">
<div class="logo"><i class="fa-solid fa-cloud"></i></div>
<div class="logo-title">CloudNest</div>
</div>
<nav class="sidebar-nav">
<div class="nav-item" :class="{ active: currentNav === 'files' }" @click="currentNav = 'files'">
<i class="fa-solid fa-folder-open"></i>
<span>文件管理</span>
</div>
<div class="nav-item" :class="{ active: currentNav === 'upload' }" @click="currentNav = 'upload'; triggerUpload()">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>上传文件</span>
</div>
<div class="nav-item" :class="{ active: currentNav === 'favorites' }" @click="handleFavorites">
<i class="fa-solid fa-star"></i>
<span>收藏夹</span>
</div>
<div class="nav-item" :class="{ active: currentNav === 'recycle' }" @click="handleRecycle">
<i class="fa-solid fa-trash"></i>
<span>回收站</span>
</div>
<div class="nav-item" @click="handleSettings">
<i class="fa-solid fa-gear"></i>
<span>设置</span>
</div>
<div class="nav-item" @click="handleHelp">
<i class="fa-solid fa-circle-question"></i>
<span>帮助</span>
</div>
</nav>
<div class="sidebar-footer">
<div class="user-info">
<div class="user-avatar">{{ username.charAt(0).toUpperCase() }}</div>
<div class="user-detail">
<div class="user-name">{{ username }}</div>
<div class="user-role">个人云存储</div>
</div>
<button class="logout-btn" @click="handleLogout">
<i class="fa-solid fa-right-from-bracket"></i>
</button>
</div>
</div>
</aside>
<!-- 主内容区 / Main content -->
<main class="main-content">
<header class="top-header">
<div class="header-left">
<div class="header-title">{{ pageTitle }}</div>
<div class="header-breadcrumb">
<span>首页</span>
<i class="fa-solid fa-chevron-right"></i>
<span>{{ pageTitle }}</span>
</div>
</div>
<div class="header-right">
<div class="search-box">
<i class="fa-solid fa-search"></i>
<input type="text" v-model="searchKeyword" placeholder="搜索文件..." @keyup.enter="searchFiles">
</div>
<button class="upload-btn" @click="triggerUpload">
<i class="fa-solid fa-plus"></i> 上传文件
</button>
</div>
</header>
<div class="content-area">
<!-- 统计卡片 / Stats cards -->
<div class="stats-cards" v-show="currentNav === 'files'">
<div class="stat-card">
<div class="stat-icon blue"><i class="fa-solid fa-file"></i></div>
<div class="stat-info">
<div class="stat-value">{{ fileCount }}</div>
<div class="stat-label">文件总数</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon green"><i class="fa-solid fa-hard-drive"></i></div>
<div class="stat-info">
<div class="stat-value">{{ formatSize(totalSize) }}</div>
<div class="stat-label">已用空间</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon orange"><i class="fa-solid fa-image"></i></div>
<div class="stat-info">
<div class="stat-value">{{ imageCount }}</div>
<div class="stat-label">图片文件</div>
</div>
</div>
<div class="stat-card">
<div class="stat-icon purple"><i class="fa-solid fa-file-word"></i></div>
<div class="stat-info">
<div class="stat-value">{{ docCount }}</div>
<div class="stat-label">文档文件</div>
</div>
</div>
</div>
<!-- 上传区域 / Upload area -->
<div class="upload-area" v-show="currentNav === 'files' || currentNav === 'upload'">
<div
class="drop-zone"
:class="{ dragover: isDragover }"
@dragover.prevent="isDragover = true"
@dragleave="isDragover = false"
@drop.prevent="handleDrop"
@click="triggerUpload"
>
<i class="fa-solid fa-cloud-arrow-up"></i>
<div class="title">拖拽文件到这里上传</div>
<div class="subtitle"><span class="highlight">点击选择文件</span>,支持多种格式</div>
</div>
<input
type="file"
ref="fileInput"
multiple
style="display: none"
@change="handleFileSelect"
/>
<div v-if="uploadingFiles.length > 0" class="upload-progress">
<el-progress
v-for="(f, index) in uploadingFiles"
:key="index"
:percentage="f.progress"
:status="f.status"
:text-inside="true"
style="margin-bottom: 12px"
>
{{ f.name }}
</el-progress>
</div>
</div>
<!-- 文件列表 / Files section -->
<div class="files-section" v-show="currentNav === 'files'">
<div class="section-header">
<div class="section-title">我的文件</div>
<div class="section-actions">
<div class="section-count">共 {{ fileCount }} 个文件</div>
<div class="view-toggle">
<button
:class="['toggle-btn', { active: viewMode === 'grid' }]"
@click="viewMode = 'grid'"
title="卡片视图"
>
<i class="fa-solid fa-grid-2"></i>
</button>
<button
:class="['toggle-btn', { active: viewMode === 'list' }]"
@click="viewMode = 'list'"
title="列表视图"
>
<i class="fa-solid fa-list"></i>
</button>
</div>
</div>
</div>
<div v-if="fileList.length === 0" class="empty-state">
<i class="fa-solid fa-folder-open"></i>
<div class="title">暂无文件</div>
<div class="subtitle">点击上方按钮或拖拽文件开始上传</div>
</div>
<div v-else :class="['files-container', viewMode]">
<div
v-for="file in filteredFiles"
:key="file.key"
:class="['file-card', viewMode]"
:class="{ selected: selectedFiles.includes(file.key) }"
@click="handlePreview(file)"
>
<div :class="['file-preview', getFileIconClass(file.key)]">
<img
v-if="isImageFile(file.key)"
:src="thumbnailUrls[file.key]"
:alt="getFileName(file.key)"
class="thumbnail-image"
:class="{ loading: !thumbnailUrls[file.key] }"
/>
<div v-else :class="['file-icon']">
<i :class="getFileIcon(file.key)"></i>
</div>
</div>
<div class="file-info">
<div class="file-name" :title="file.key">{{ getFileName(file.key) }}</div>
<div class="file-size" v-if="viewMode === 'grid'">{{ formatSize(file.size || 0) }}</div>
</div>
<div v-if="viewMode === 'list'" class="file-meta">
<div class="file-size">{{ formatSize(file.size || 0) }}</div>
<div class="file-date">{{ file.lastModified || '' }}</div>
</div>
<div class="file-actions">
<button
:class="['action-btn', 'favorite', { active: isFavorite(file.key) }]"
@click.stop="toggleFavorite(file)"
title="收藏"
>
<i class="fa-solid fa-star"></i>
</button>
<button class="action-btn download" @click.stop="handleDownload(file)" title="下载">
<i class="fa-solid fa-download"></i>
</button>
<button class="action-btn delete" @click.stop="handleDelete(file)" title="删除">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 收藏夹 / Favorites -->
<div class="files-section" v-show="currentNav === 'favorites'">
<div class="section-header">
<div class="section-title">我的收藏</div>
<div class="section-actions">
<div class="section-count">共 {{ favoriteList.length }} 个文件</div>
<div class="view-toggle">
<button
:class="['toggle-btn', { active: favoriteViewMode === 'grid' }]"
@click="favoriteViewMode = 'grid'"
title="卡片视图"
>
<i class="fa-solid fa-grid-2"></i>
</button>
<button
:class="['toggle-btn', { active: favoriteViewMode === 'list' }]"
@click="favoriteViewMode = 'list'"
title="列表视图"
>
<i class="fa-solid fa-list"></i>
</button>
</div>
</div>
</div>
<div v-if="favoriteList.length === 0" class="empty-state">
<i class="fa-solid fa-star"></i>
<div class="title">暂无收藏</div>
<div class="subtitle">点击文件卡片上的星形图标添加收藏</div>
</div>
<div v-else :class="['files-container', favoriteViewMode]">
<div
v-for="file in favoriteList"
:key="file.key"
:class="['file-card', favoriteViewMode]"
@click="handlePreview(file)"
>
<div :class="['file-preview', getFileIconClass(file.key)]">
<img
v-if="isImageFile(file.key)"
:src="thumbnailUrls[file.key]"
:alt="getFileName(file.key)"
class="thumbnail-image"
:class="{ loading: !thumbnailUrls[file.key] }"
/>
<div v-else :class="['file-icon']">
<i :class="getFileIcon(file.key)"></i>
</div>
</div>
<div class="file-info">
<div class="file-name" :title="file.key">{{ getFileName(file.key) }}</div>
<div class="file-size" v-if="favoriteViewMode === 'grid'">{{ formatSize(file.size || 0) }}</div>
</div>
<div v-if="favoriteViewMode === 'list'" class="file-meta">
<div class="file-size">{{ formatSize(file.size || 0) }}</div>
</div>
<div class="file-actions">
<button
:class="['action-btn', 'favorite', { active: isFavorite(file.key) }]"
@click.stop="toggleFavorite(file)"
title="取消收藏"
>
<i class="fa-solid fa-star"></i>
</button>
<button class="action-btn download" @click.stop="handleDownload(file)" title="下载">
<i class="fa-solid fa-download"></i>
</button>
<button class="action-btn delete" @click.stop="handleDelete(file)" title="删除">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 回收站 / Recycle bin -->
<div class="files-section" v-show="currentNav === 'recycle'">
<div class="section-header">
<div class="section-title">回收站</div>
<div class="section-actions">
<div class="section-count">共 {{ recycleList.length }} 个文件</div>
<div class="view-toggle">
<button
:class="['toggle-btn', { active: recycleViewMode === 'grid' }]"
@click="recycleViewMode = 'grid'"
title="卡片视图"
>
<i class="fa-solid fa-grid-2"></i>
</button>
<button
:class="['toggle-btn', { active: recycleViewMode === 'list' }]"
@click="recycleViewMode = 'list'"
title="列表视图"
>
<i class="fa-solid fa-list"></i>
</button>
</div>
</div>
</div>
<div v-if="recycleList.length === 0" class="empty-state">
<i class="fa-solid fa-trash"></i>
<div class="title">回收站为空</div>
<div class="subtitle">删除的文件会出现在这里</div>
</div>
<div v-else :class="['files-container', recycleViewMode]">
<div
v-for="file in recycleList"
:key="file.key"
:class="['file-card', recycleViewMode]"
>
<div :class="['file-preview', getFileIconClass(file.key)]">
<img
v-if="isImageFile(file.key)"
:src="thumbnailUrls[file.key]"
:alt="getFileName(file.key)"
class="thumbnail-image"
:class="{ loading: !thumbnailUrls[file.key] }"
/>
<div v-else :class="['file-icon']">
<i :class="getFileIcon(file.key)"></i>
</div>
</div>
<div class="file-info">
<div class="file-name" :title="file.key">{{ getFileName(file.key) }}</div>
<div class="file-size" v-if="recycleViewMode === 'grid'">{{ formatSize(file.size || 0) }}</div>
</div>
<div v-if="recycleViewMode === 'list'" class="file-meta">
<div class="file-size">{{ formatSize(file.size || 0) }}</div>
</div>
<div class="file-actions">
<button class="restore-btn" @click.stop="handleRestore(file)" title="恢复">
<i class="fa-solid fa-rotate-left"></i>
</button>
<button class="delete-permanently-btn" @click.stop="handlePermanentDelete(file)" title="永久删除">
<i class="fa-solid fa-ban"></i>
</button>
</div>
</div>
</div>
</div>
<!-- 设置 / Settings -->
<div class="settings-section" v-show="currentNav === 'settings'">
<div class="settings-tabs">
<div class="settings-tab" :class="{ active: settingsTab === 'profile' }" @click="settingsTab = 'profile'">
个人信息
</div>
<div class="settings-tab" :class="{ active: settingsTab === 'password' }" @click="settingsTab = 'password'">
修改密码
</div>
<div class="settings-tab" :class="{ active: settingsTab === 'storage' }" @click="settingsTab = 'storage'">
存储信息
</div>
</div>
<div class="settings-content">
<!-- 个人信息 / Profile -->
<div v-show="settingsTab === 'profile'">
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" v-model="profileForm.username" disabled>
</div>
<div class="form-group">
<label class="form-label">昵称</label>
<input type="text" class="form-input" v-model="profileForm.nickname" placeholder="请输入昵称">
</div>
<div class="form-group">
<label class="form-label">邮箱</label>
<input type="email" class="form-input" v-model="profileForm.email" placeholder="请输入邮箱">
</div>
<div class="form-group">
<label class="form-label">手机号</label>
<input type="tel" class="form-input" v-model="profileForm.phone" placeholder="请输入手机号">
</div>
<div class="form-btn-group">
<button class="form-btn primary" @click="saveProfile">保存修改</button>
<button class="form-btn" @click="resetProfile">重置</button>
</div>
</div>
<!-- 修改密码 / Change password -->
<div v-show="settingsTab === 'password'">
<div class="form-group">
<label class="form-label">当前密码</label>
<input type="password" class="form-input" v-model="passwordForm.currentPassword" placeholder="请输入当前密码">
</div>
<div class="form-group">
<label class="form-label">新密码</label>
<input type="password" class="form-input" v-model="passwordForm.newPassword" placeholder="请输入新密码">
</div>
<div class="form-group">
<label class="form-label">确认密码</label>
<input type="password" class="form-input" v-model="passwordForm.confirmPassword" placeholder="请再次输入新密码">
</div>
<div class="form-btn-group">
<button class="form-btn primary" @click="changePassword">确认修改</button>
<button class="form-btn" @click="resetPassword">重置</button>
</div>
</div>
<!-- 存储信息 / Storage info -->
<div v-show="settingsTab === 'storage'">
<div class="storage-info">
<div class="storage-title">存储空间使用情况</div>
<div class="storage-bar">
<div class="storage-progress" :style="{ width: storageUsage + '%' }"></div>
</div>
<div class="storage-text">
<span>已使用: {{ formatSize(totalSize) }}</span>
<span>总容量: {{ formatSize(storageLimit) }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 预览模态框 / Preview modal -->
<div v-if="showPreview" class="preview-modal-overlay" @click="closePreview">
<div class="preview-modal" @click.stop>
<div class="preview-modal-header">
<div class="preview-modal-title">{{ previewFile?.key }}</div>
<button class="preview-modal-close" @click="closePreview">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="preview-modal-content">
<img v-if="isImageFile(previewFile?.key)" :src="previewUrl" class="preview-image" alt="预览">
<iframe v-else-if="isPdfFile(previewFile?.key)" :src="previewUrl" class="preview-pdf" title="PDF预览"></iframe>
<div v-else-if="isTextFile(previewFile?.key)" class="preview-text">{{ previewContent }}</div>
<div v-else class="preview-unsupported">
<i class="fa-solid fa-file"></i>
<div class="title">暂不支持预览此文件类型</div>
<div class="subtitle">请下载后查看</div>
</div>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, reactive, computed, onMounted } = Vue;
createApp({
setup() {
const token = localStorage.getItem('cloudnest_token');
const username = ref(localStorage.getItem('cloudnest_username') || '');
if (!token) {
window.location.href = '/login';
return {};
}
const API_HOST = window.location.protocol + '//' + window.location.hostname;
const AUTH_API_BASE = API_HOST + ':8081/api/v1/auth';
const FILE_API_BASE = API_HOST + ':8082/api/v1/files';
const authApi = axios.create({ baseURL: AUTH_API_BASE, timeout: 30000 });
authApi.defaults.headers.common['Authorization'] = 'Bearer ' + token;
const fileApi = axios.create({ baseURL: FILE_API_BASE, timeout: 30000 });
fileApi.defaults.headers.common['Authorization'] = 'Bearer ' + token;
const fileInput = ref(null);
const fileList = ref([]);
const favoriteList = ref([]);
const recycleList = ref([]);
const searchKeyword = ref('');
const currentNav = ref('files');
const settingsTab = ref('profile');
const viewMode = ref('grid');
const favoriteViewMode = ref('grid');
const recycleViewMode = ref('grid');
const thumbnailUrls = ref({});
const isDragover = ref(false);
const uploadingFiles = ref([]);
const selectedFiles = ref([]);
const showPreview = ref(false);
const previewFile = ref(null);
const previewUrl = ref('');
const previewContent = ref('');
const profileForm = reactive({
username: username.value,
nickname: '',
email: '',
phone: ''
});
const passwordForm = reactive({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const pageTitle = computed(() => {
const titles = {
'files': '文件管理',
'upload': '上传文件',
'favorites': '收藏夹',
'recycle': '回收站',
'settings': '设置'
};
return titles[currentNav.value] || '文件管理';
});
const fileCount = computed(() => fileList.value.length);
const totalSize = computed(() => fileList.value.reduce((sum, file) => sum + (file.size || 0), 0));
const storageLimit = ref(52428800);
const storageUsage = computed(() => Math.min((totalSize.value / storageLimit.value) * 100, 100));
const imageCount = computed(() => {
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
return fileList.value.filter(f => imageExts.includes(f.key.split('.').pop().toLowerCase())).length;
});
const docCount = computed(() => {
const docExts = ['doc', 'docx', 'pdf', 'txt', 'xls', 'xlsx', 'ppt', 'pptx'];
return fileList.value.filter(f => docExts.includes(f.key.split('.').pop().toLowerCase())).length;
});
const filteredFiles = computed(() => {
if (!searchKeyword.value) return fileList.value;
return fileList.value.filter(f => f.key.toLowerCase().includes(searchKeyword.value.toLowerCase()));
});
const formatSize = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const getThumbnailUrl = async (key) => {
const fileName = getFileName(key);
if (!isImageFile(fileName)) return '';
if (thumbnailUrls.value[key]) {
return thumbnailUrls.value[key];
}
try {
const response = await fileApi.get('/content/' + fileName, { responseType: 'blob' });
const url = URL.createObjectURL(response.data);
thumbnailUrls.value[key] = url;
return url;
} catch (error) {
return '';
}
};
const getFileName = (key) => key.split('/').pop();
const getFileIcon = (key) => {
const ext = key.split('.').pop().toLowerCase();
const icons = {
'doc': 'fa-solid fa-file-word', 'docx': 'fa-solid fa-file-word',
'pdf': 'fa-solid fa-file-pdf', 'txt': 'fa-solid fa-file-lines',
'jpg': 'fa-solid fa-image', 'jpeg': 'fa-solid fa-image', 'png': 'fa-solid fa-image', 'gif': 'fa-solid fa-image', 'webp': 'fa-solid fa-image',
'zip': 'fa-solid fa-file-zipper', 'rar': 'fa-solid fa-file-zipper',
'mp4': 'fa-solid fa-file-video', 'mp3': 'fa-solid fa-file-audio'
};
return icons[ext] || 'fa-solid fa-file';
};
const getFileIconClass = (key) => {
const ext = key.split('.').pop().toLowerCase();
const classes = {
'doc': 'doc', 'docx': 'doc', 'pdf': 'pdf', 'txt': 'txt',
'jpg': 'img', 'jpeg': 'img', 'png': 'img', 'gif': 'img', 'webp': 'img',
'zip': 'zip', 'rar': 'zip'
};
return classes[ext] || 'other';
};
const isImageFile = (key) => {
if (!key) return false;
const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
return imageExts.includes(key.split('.').pop().toLowerCase());
};
const isPdfFile = (key) => {
if (!key) return false;
return key.split('.').pop().toLowerCase() === 'pdf';
};
const isTextFile = (key) => {
if (!key) return false;
const textExts = ['txt', 'md', 'log'];
return textExts.includes(key.split('.').pop().toLowerCase());
};
const loadFiles = async () => {
try {
const response = await fileApi.get('/');
const files = response.data.data?.files || [];
fileList.value = files.map(f => ({ key: f.key, size: f.size, lastModified: f.last_modified }));
await loadThumbnails();
} catch (error) {
if (error.response?.status === 401) {
localStorage.removeItem('cloudnest_token');
localStorage.removeItem('cloudnest_username');
window.location.href = '/login';
} else {
ElementPlus.ElMessage.error('加载文件列表失败');
}
}
};
const loadThumbnails = async () => {
const imageFiles = fileList.value.filter(f => isImageFile(f.key));
for (const file of imageFiles) {
if (thumbnailUrls.value[file.key]) continue;
try {
const fileName = getFileName(file.key);
const response = await fileApi.get('/content/' + fileName, { responseType: 'blob' });
thumbnailUrls.value[file.key] = URL.createObjectURL(response.data);
} catch (error) {
console.warn('Failed to load thumbnail:', file.key);
}
}
};
const loadFavorites = async () => {
try {
const response = await fileApi.get('/favorites');
const items = response.data.data?.favorites || [];
favoriteList.value = items.map(f => ({ key: f.file_key, size: f.size || 0 }));
await loadThumbnails();
} catch (error) {
ElementPlus.ElMessage.error('加载收藏列表失败');
}
};
const loadRecycle = async () => {
try {
const response = await fileApi.get('/recycle-bin');
const items = response.data.data?.files || [];
// Map backend fields to frontend format / 将后端字段映射为前端格式
recycleList.value = items.map(f => ({ key: f.file_key, size: f.size }));
} catch (error) {
ElementPlus.ElMessage.error('加载回收站失败');
}
};
const isFavorite = (key) => {
return favoriteList.value.some(f => f.key === key);
};
const toggleFavorite = async (file) => {
try {
if (isFavorite(file.key)) {
await fileApi.delete('/favorite/' + getFileName(file.key));
ElementPlus.ElMessage.success('已取消收藏');
} else {
await fileApi.post('/favorite/' + getFileName(file.key));
ElementPlus.ElMessage.success('已添加收藏');
}
await loadFavorites();
await loadFiles();
} catch (error) {
ElementPlus.ElMessage.error('操作失败');
}
};
const handlePreview = async (file) => {
previewFile.value = file;
previewContent.value = '';
showPreview.value = true;
try {
const fileName = getFileName(file.key);
// Get file type info / 获取文件类型信息
const response = await fileApi.get('/preview/' + fileName);
const fileType = response.data.data?.file_type || 'other';
if (fileType === 'text') {
// Fetch text content via content endpoint / 通过 content 端点获取文本内容
const textResponse = await fileApi.get('/content/' + fileName, { responseType: 'text' });
previewContent.value = textResponse.data;
previewUrl.value = '';
} else if (fileType === 'image' || fileType === 'pdf') {
// Build content URL with auth token via blob / 通过 blob 构建带认证的内容 URL
const blobResponse = await fileApi.get('/content/' + fileName, { responseType: 'blob' });
previewUrl.value = URL.createObjectURL(blobResponse.data);
previewContent.value = '';
} else {
previewUrl.value = '';
}
} catch (error) {
ElementPlus.ElMessage.error('获取预览失败');
}
};
const closePreview = () => {
// Revoke blob URL to free memory / 释放 blob URL 以释放内存
if (previewUrl.value && previewUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewUrl.value);
}
showPreview.value = false;
previewFile.value = null;
previewUrl.value = '';
previewContent.value = '';
};
const triggerUpload = () => { fileInput.value.click(); };
const handleFileSelect = (event) => {
uploadFiles(Array.from(event.target.files));
event.target.value = '';
};
const handleDrop = (event) => {
isDragover.value = false;
uploadFiles(Array.from(event.dataTransfer.files));
};
const CHUNK_SIZE = 5 * 1024 * 1024;
const calculateFileHash = async (file) => {
const chunkCount = Math.ceil(file.size / CHUNK_SIZE);
let hash = '';
for (let i = 0; i < chunkCount; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const arrayBuffer = await chunk.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
hash += Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}
return hash.slice(0, 32);
};
const uploadFiles = async (files) => {
for (const file of files) {
const uploadId = Date.now() + Math.random();
uploadingFiles.value.push({ id: uploadId, name: file.name, progress: 0, status: 'success' });
try {
const fileHash = await calculateFileHash(file);
const chunkCount = Math.ceil(file.size / CHUNK_SIZE);
const checkResponse = await fileApi.get('/upload/check', { params: { hash: fileHash, filename: file.name } });
const uploadedChunks = checkResponse.data.data?.uploaded_chunks || [];
const uploadedSet = new Set(uploadedChunks);
if (uploadedSet.size === chunkCount) {
ElementPlus.ElMessage.success(`文件 ${file.name} 已存在,无需重复上传`);
await loadFiles();
continue;
}
for (let i = 0; i < chunkCount; i++) {
if (uploadedSet.has(i)) continue;
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk);
formData.append('hash', fileHash);
formData.append('chunk_index', i.toString());
formData.append('chunk_count', chunkCount.toString());
formData.append('filename', file.name);
formData.append('file_size', file.size.toString());
await fileApi.post('/upload/chunk', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (progress) => {
const idx = uploadingFiles.value.findIndex(f => f.id === uploadId);
if (idx !== -1) {
uploadingFiles.value[idx].progress = Math.round(((i + progress.loaded / chunk.size) / chunkCount) * 100);
}
}
});
}
await fileApi.post('/upload/complete', { hash: fileHash, filename: file.name });
ElementPlus.ElMessage.success(`文件 ${file.name} 上传成功`);
await loadFiles();
} catch (error) {
const idx = uploadingFiles.value.findIndex(f => f.id === uploadId);
if (idx !== -1) uploadingFiles.value[idx].status = 'exception';
ElementPlus.ElMessage.error(`文件 ${file.name} 上传失败`);
} finally {
setTimeout(() => { uploadingFiles.value = uploadingFiles.value.filter(f => f.id !== uploadId); }, 2000);
}
}
};
const handleDownload = async (file) => {
try {
const fileName = getFileName(file.key);
// Download file as blob via backend proxy / 通过后端代理下载文件为 blob
const response = await fileApi.get('/download/' + fileName, { responseType: 'blob' });
const url = URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
ElementPlus.ElMessage.error('下载失败');
}
};
const handleDelete = async (file) => {
ElementPlus.ElMessageBox.confirm(`确定要删除文件 ${getFileName(file.key)} 吗?文件将进入回收站。`, '确认删除', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
await fileApi.post('/soft-delete/' + getFileName(file.key));
ElementPlus.ElMessage.success('删除成功,文件已进入回收站');
await loadFiles();
} catch (error) {
ElementPlus.ElMessage.error('删除失败');
}
}).catch(() => {});
};
const handleRestore = async (file) => {
ElementPlus.ElMessageBox.confirm(`确定要恢复文件 ${getFileName(file.key)} 吗?`, '确认恢复', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'info'
}).then(async () => {
try {
await fileApi.post('/restore/' + getFileName(file.key));
ElementPlus.ElMessage.success('恢复成功');
await loadRecycle();
await loadFiles();
} catch (error) {
ElementPlus.ElMessage.error('恢复失败');
}
}).catch(() => {});
};
const handlePermanentDelete = async (file) => {
ElementPlus.ElMessageBox.confirm(`确定要永久删除文件 ${getFileName(file.key)} 吗?此操作不可恢复!`, '永久删除', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'error'
}).then(async () => {
try {
await fileApi.delete('/recycle-bin/' + getFileName(file.key));
ElementPlus.ElMessage.success('已永久删除');
await loadRecycle();
} catch (error) {
ElementPlus.ElMessage.error('删除失败');
}
}).catch(() => {});
};
const selectFile = (file) => {
const idx = selectedFiles.value.indexOf(file.key);
if (idx === -1) selectedFiles.value.push(file.key);
else selectedFiles.value.splice(idx, 1);
};
const searchFiles = () => { console.log('Search:', searchKeyword.value); };
const handleFavorites = () => {
currentNav.value = 'favorites';
loadFavorites();
};
const handleRecycle = () => {
currentNav.value = 'recycle';
loadRecycle();
};
const handleSettings = () => {
currentNav.value = 'settings';
};
const handleHelp = () => { ElementPlus.ElMessage.info('如有问题请联系管理员'); };
const saveProfile = async () => {
try {
await authApi.put('/profile', profileForm);
ElementPlus.ElMessage.success('保存成功');
} catch (error) {
ElementPlus.ElMessage.error('保存失败');
}
};
const resetProfile = () => {
profileForm.nickname = '';
profileForm.email = '';
profileForm.phone = '';
};
const changePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
ElementPlus.ElMessage.error('两次输入的密码不一致');
return;
}
try {
await authApi.put('/password', passwordForm);
ElementPlus.ElMessage.success('密码修改成功,请重新登录');
handleLogout();
} catch (error) {
ElementPlus.ElMessage.error('密码修改失败');
}
};
const resetPassword = () => {
passwordForm.currentPassword = '';
passwordForm.newPassword = '';
passwordForm.confirmPassword = '';
};
const handleLogout = () => {
ElementPlus.ElMessageBox.confirm('确定要退出登录吗?', '确认退出', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(() => {
localStorage.removeItem('cloudnest_token');
localStorage.removeItem('cloudnest_username');
window.location.href = '/login';
}).catch(() => {});
};
onMounted(() => { loadFiles(); });
return {
username, fileInput, fileList, favoriteList, recycleList, searchKeyword, currentNav, settingsTab,
viewMode, favoriteViewMode, recycleViewMode, thumbnailUrls, isDragover, uploadingFiles, selectedFiles, showPreview, previewFile, previewUrl, previewContent,
profileForm, passwordForm, pageTitle, fileCount, totalSize, storageLimit, storageUsage, imageCount, docCount,
filteredFiles, formatSize, getFileName, getFileIcon, getFileIconClass, isImageFile, isPdfFile, isTextFile,
getThumbnailUrl, isFavorite, loadFiles, loadFavorites, loadRecycle, toggleFavorite, handlePreview, closePreview,
triggerUpload, handleFileSelect, handleDrop, uploadFiles, handleDownload, handleDelete,
handleRestore, handlePermanentDelete, selectFile, searchFiles, handleFavorites, handleRecycle,
handleSettings, handleHelp, saveProfile, resetProfile, changePassword, resetPassword, handleLogout
};
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>