Files
vue-driven-cloud-storage/frontend/app.html
WanWanYun e5c932a351 feat: 新增本地存储文件夹管理功能
新功能概述:
- 支持在本地存储模式下创建文件夹
- 支持删除文件夹(递归删除)
- 支持重命名文件夹(已有功能,天然支持)
- 文件夹配额计算正确

后端改动:

1. 新增创建文件夹API (backend/server.js)
   - POST /api/files/mkdir
   - 参数: path(当前路径), folderName(文件夹名称)
   - 安全检查: 禁止特殊字符(/ \ .. :),防止路径遍历攻击
   - 仅限本地存储使用
   - 创建前检查文件夹是否已存在

2. 改进删除功能 (backend/storage.js)
   - LocalStorageClient.delete() 现在支持删除文件夹
   - 使用 fs.rmSync(path, { recursive: true }) 递归删除
   - 新增 calculateFolderSize() 方法计算文件夹总大小
   - 删除文件夹时正确更新配额使用情况

前端改动:

1. 新建文件夹按钮 (frontend/app.html)
   - 在"上传文件"按钮旁新增"新建文件夹"按钮
   - 仅本地存储模式显示

2. 新建文件夹弹窗 (frontend/app.html)
   - 简洁的创建表单
   - 支持回车键快速创建
   - 使用优化的弹窗关闭逻辑(防止拖动选择文本时误关闭)

3. 前端API调用 (frontend/app.js)
   - 新增 createFolderForm 状态
   - 新增 createFolder() 方法
   - 前端参数验证
   - 创建成功后自动刷新文件列表

4. 右键菜单优化 (frontend/app.html)
   - 文件夹不显示"下载"按钮(文件夹暂不支持打包下载)
   - 文件夹不显示"分享"按钮(分享单个文件夹暂不支持)
   - 文件夹支持"重命名"和"删除"操作

安全性:
- 文件夹名称严格验证,禁止包含 / \ .. : 等特殊字符
- 路径安全检查,防止目录遍历攻击
- 仅限本地存储模式使用(SFTP存储使用上传工具管理)

配额管理:
- 空文件夹不占用配额
- 删除文件夹时正确释放配额(计算所有子文件大小)
- 删除非空文件夹会递归删除所有内容

使用方式:
1. 登录后切换到本地存储模式
2. 点击"新建文件夹"按钮
3. 输入文件夹名称,点击创建
4. 双击文件夹进入,支持多级目录
5. 右键文件夹可重命名或删除

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-15 23:46:20 +08:00

2221 lines
92 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>玩玩云 - 文件管理平台</title>
<script src="libs/vue.global.prod.js"></script>
<script src="libs/axios.min.js"></script>
<link rel="stylesheet" href="libs/fontawesome/css/all.min.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
#app { min-height: 100vh; }
.auth-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
.auth-box {
background: white;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 40px;
width: 100%;
max-width: 500px;
}
.auth-title {
font-size: 28px;
font-weight: 700;
text-align: center;
margin-bottom: 30px;
color: #667eea;
}
.form-group { margin-bottom: 20px; }
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.form-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
}
.form-input:focus {
outline: none;
border-color: #667eea;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.btn-primary {
background: #667eea;
color: white;
width: 100%;
}
.btn-primary:hover { background: #5568d3; }
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary:disabled:hover {
background: #667eea;
}
.alert { padding: 12px; border-radius: 8px; margin-bottom: 15px; }
.alert-error { background: #f8d7da; color: #721c24; }
.alert-success { background: #d4edda; color: #155724; }
.auth-switch {
text-align: center;
margin-top: 20px;
color: #666;
}
.auth-switch a {
color: #667eea;
cursor: pointer;
text-decoration: none;
}
.navbar {
background: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand {
font-size: 24px;
font-weight: 700;
color: #667eea;
display: flex;
align-items: center;
gap: 10px;
}
.navbar-menu {
display: flex;
gap: 20px;
align-items: center;
}
.nav-item {
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
color: #666;
font-weight: 500;
}
.nav-item:hover {
background: #f0f0f0;
color: #667eea;
}
.nav-item.active {
background: #667eea;
color: white;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
background: #f8f9fa;
border-radius: 20px;
}
.main-container {
max-width: 1200px;
margin: 30px auto;
padding: 0 20px;
}
.card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 30px;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.alert-info { background: #d1ecf1; color: #0c5460; }
/* 文件网格视图 */
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 20px;
padding: 10px;
}
.file-grid-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 10px 10px 10px;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
min-height: 180px;
position: relative;
}
.file-grid-item:hover {
background: #f5f5f5;
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.file-icon {
margin-bottom: 10px;
}
.file-thumbnail {
width: 64px;
height: 64px;
object-fit: cover;
border-radius: 8px;
}
.file-name {
font-size: 13px;
color: #333;
word-break: break-all;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
min-height: 34px;
}
.file-size {
font-size: 12px;
color: #999;
margin-top: 5px;
margin-bottom: 8px;
}
.file-actions {
display: flex;
gap: 5px;
justify-content: center;
align-items: center;
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
}
.empty-hint {
text-align: center;
color: #999;
padding: 40px;
font-size: 14px;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-icon {
background: none;
border: none;
padding: 8px;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
color: #667eea;
}
.btn-icon:hover {
background: #f0f0f0;
color: #5568d3;
}
.file-actions {
display: flex;
gap: 5px;
margin-top: 8px;
justify-content: center;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
width: 500px;
max-width: 90%;
max-height: 90vh;
overflow-y: auto;
}
/* 移动端适配 */
@media (max-width: 768px) {
/* 导航栏移动端优化 */
.navbar {
padding: 10px 15px;
flex-wrap: wrap;
}
.navbar-brand {
font-size: 18px;
gap: 8px;
}
.navbar-menu {
width: 100%;
margin-top: 10px;
gap: 8px;
flex-wrap: wrap;
justify-content: space-between;
}
.nav-item {
padding: 6px 10px;
font-size: 13px;
flex: 1;
text-align: center;
min-width: auto;
}
.user-info {
padding: 6px 12px;
font-size: 13px;
gap: 6px;
}
.btn-danger {
padding: 6px 12px;
font-size: 13px;
}
/* 主容器移动端优化 */
.main-container {
margin: 15px auto;
padding: 0 10px;
}
.card {
padding: 15px;
border-radius: 8px;
}
/* 文件网格视图移动端优化 */
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 12px;
padding: 5px;
}
.file-grid-item {
padding: 10px 5px;
}
.file-icon i {
font-size: 48px !important;
}
/* 图片缩略图移动端适配 */
.file-thumbnail {
width: 48px !important;
height: 48px !important;
}
/* 视频图标容器移动端适配 */
.file-icon div[style*="background: linear-gradient"] {
width: 48px !important;
height: 48px !important;
}
.file-icon div[style*="background: linear-gradient"] i {
font-size: 24px !important;
}
.file-name {
font-size: 12px;
}
.file-size {
font-size: 11px;
}
/* 文件操作按钮移动端优化 */
.file-actions {
gap: 3px;
margin-top: 5px;
}
.btn-icon {
padding: 5px;
font-size: 12px;
}
/* 工具栏移动端优化 - 修正版 */
.card > div[style*="display: flex"][style*="justify-content: space-between"] {
flex-direction: column !important;
gap: 15px !important;
}
.card > div[style*="display: flex"][style*="justify-content: space-between"] > div {
width: 100% !important;
justify-content: center !important;
}
.card > div[style*="display: flex"][style*="justify-content: space-between"] > div button {
flex: 1;
max-width: calc(50% - 5px);
}
.file-list table {
font-size: 13px;
}
.file-list th,
.file-list td {
padding: 8px 5px !important;
}
.file-list th:nth-child(3),
.file-list td:nth-child(3) {
display: none; /* 隐藏修改时间列 */
}
.file-list .file-icon {
font-size: 16px !important;
}
/* 列表视图视频图标容器移动端适配 */
.file-list div[style*="background: linear-gradient"] {
width: 28px !important;
height: 28px !important;
}
.file-list div[style*="background: linear-gradient"] i {
font-size: 14px !important;
}
/* 模态框移动端优化 */
.modal-content {
padding: 20px;
width: 95%;
}
.modal-content h3 {
font-size: 18px;
margin-bottom: 15px;
}
.form-group {
margin-bottom: 15px;
}
.form-input {
padding: 10px 12px;
font-size: 14px;
}
.btn {
padding: 8px 16px;
font-size: 14px;
}
/* 认证页面移动端优化 */
.auth-box {
padding: 25px;
}
.auth-title {
font-size: 22px;
margin-bottom: 20px;
}
/* 管理员表格移动端优化 */
.card table {
font-size: 12px;
}
.card table th,
.card table td {
padding: 6px 4px !important;
}
.card table button {
padding: 4px 8px;
font-size: 11px;
margin: 1px;
}
.card table button i {
font-size: 10px;
}
}
/* 超小屏幕优化 (手机竖屏) */
@media (max-width: 480px) {
.navbar-brand {
font-size: 16px;
}
.nav-item {
font-size: 11px;
padding: 5px 8px;
}
.file-grid {
grid-template-columns: repeat(auto-fill, minmax(75px, 1fr));
gap: 8px;
}
.file-icon i {
font-size: 40px !important;
}
/* 视频图标容器超小屏幕适配 */
.file-icon div[style*="background: linear-gradient"] {
width: 40px !important;
height: 40px !important;
}
.file-icon div[style*="background: linear-gradient"] i {
font-size: 20px !important;
}
.file-name {
font-size: 11px;
}
/* 列表视图在超小屏幕隐藏文件大小列 */
.file-list th:nth-child(2),
.file-list td:nth-child(2) {
font-size: 11px;
}
}
/* 拖拽上传样式 */
.files-container {
position: relative;
min-height: 200px;
}
.drag-drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(102, 126, 234, 0.1);
backdrop-filter: blur(2px);
border: 3px dashed #667eea;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
.drag-drop-content {
text-align: center;
padding: 40px;
}
.files-container.drag-over {
background: rgba(102, 126, 234, 0.05);
border-radius: 12px;
}
/* 拖拽上传样式 */
.files-container {
position: relative;
min-height: 200px;
}
.drag-drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(102, 126, 234, 0.1);
backdrop-filter: blur(2px);
border: 3px dashed #667eea;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
.drag-drop-content {
text-align: center;
padding: 40px;
}
.files-container.drag-over {
background: rgba(102, 126, 234, 0.05);
border-radius: 12px;
}
</style>
</head>
<body>
<div id="app">
<div class="auth-container" v-if="!isLoggedIn">
<div class="auth-box">
<div class="auth-title">
<i class="fas fa-cloud"></i>
{{ isLogin ? '登录' : '注册' }}
</div>
<div v-if="errorMessage" class="alert alert-error">{{ errorMessage }}</div>
<div v-if="successMessage" class="alert alert-success">{{ successMessage }}</div>
<form v-if="isLogin" @submit.prevent="handleLogin">
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" v-model="loginForm.username" required>
</div>
<div class="form-group">
<label class="form-label">密码</label>
<input type="password" class="form-input" v-model="loginForm.password" required>
</div>
<div style="text-align: right; margin-bottom: 15px;">
<a @click="showForgotPasswordModal = true" style="color: #667eea; cursor: pointer; font-size: 14px; text-decoration: none;">
忘记密码?
</a>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-right-to-bracket"></i> 登录
</button>
</form>
<form v-else @submit.prevent="handleRegister">
<div class="form-group">
<label class="form-label">用户名(3-20字符)</label>
<input type="text" class="form-input" v-model="registerForm.username" required minlength="3" maxlength="20">
</div>
<div class="form-group">
<label class="form-label">邮箱 (可选)</label>
<input type="email" class="form-input" v-model="registerForm.email">
</div>
<div class="form-group">
<label class="form-label">密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="registerForm.password" required minlength="6">
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-user-plus"></i> 注册
</button>
</form>
<div class="auth-switch">
{{ isLogin ? '还没有账号?' : '已有账号?' }}
<a @click="toggleAuthMode">{{ isLogin ? '立即注册' : '去登录' }}</a>
</div>
</div>
</div>
<!-- 导航栏 -->
<div class="navbar" v-if="isLoggedIn">
<div class="navbar-brand">
<i class="fas fa-cloud"></i> 玩玩云
</div>
<div class="navbar-menu">
<div v-if="user && !user.is_admin" class="nav-item" :class="{active: currentView === 'files'}" @click="switchView('files')">
<i class="fas fa-folder"></i> 我的文件
</div>
<div v-if="user && !user.is_admin" class="nav-item" :class="{active: currentView === 'shares'}" @click="switchView('shares')">
<i class="fas fa-share-alt"></i> 我的分享
</div>
<div v-if="user && user.is_admin" class="nav-item" :class="{active: currentView === 'admin'}" @click="switchView('admin')">
<i class="fas fa-user-shield"></i> 管理员
</div>
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="switchView('settings')">
<i class="fas fa-cog"></i> 设置
</div>
<div class="user-info">
<i class="fas fa-user-circle"></i>
<span>{{ user.username }}</span>
</div>
<button class="btn btn-danger" @click="logout">
<i class="fas fa-power-off"></i> 退出
</button>
</div>
</div>
<!-- 文件视图 -->
<div v-if="isLoggedIn && currentView === 'files'" class="main-container">
<div class="card">
<!-- 存储信息显示 -->
<div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 15px;">
<div style="display: flex; align-items: center; gap: 10px;">
<span style="font-weight: 600; color: #667eea;">
<i :class="storageType === 'local' ? 'fas fa-hard-drive' : 'fas fa-server'"></i>
当前存储: {{ storageTypeText }}
</span>
</div>
<div v-if="storageType === 'local'" style="flex: 1; max-width: 400px;">
<div style="margin-bottom: 5px; font-size: 12px; color: #666; display: flex; justify-content: space-between;">
<span>配额使用情况</span>
<span>{{ localUsedFormatted }} / {{ localQuotaFormatted }} ({{ quotaPercentage }}%)</span>
</div>
<div style="width: 100%; height: 20px; background: #e0e0e0; border-radius: 10px; overflow: hidden;">
<div :style="{
width: quotaPercentage + '%',
height: '100%',
background: quotaPercentage > 90 ? '#dc3545' : quotaPercentage > 75 ? '#ffc107' : '#28a745',
transition: 'width 0.3s'
}"></div>
</div>
</div>
</div>
<!-- 工具栏 -->
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<div style="display: flex; gap: 10px;">
<!-- 本地存储:显示网页上传按钮 -->
<button v-if="storageType === 'local'" class="btn btn-primary" @click="$refs.fileUploadInput.click()">
<i class="fas fa-upload"></i> 上传文件
</button>
<button v-if="storageType === 'local'" class="btn btn-primary" @click="showCreateFolderModal = true">
<i class="fas fa-folder-plus"></i> 新建文件夹
</button>
<!-- SFTP存储显示下载上传工具按钮 -->
<button v-else class="btn btn-primary" @click="downloadUploadTool" :disabled="downloadingTool">
<i :class="downloadingTool ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i>
{{ downloadingTool ? '生成中...' : '下载上传工具' }}
</button>
<button class="btn btn-primary" @click="showShareAllModal = true">
<i class="fas fa-share-nodes"></i> 分享所有文件
</button>
</div>
<div style="display: flex; gap: 10px;">
<button class="btn" :class="fileViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="fileViewMode = 'grid'">
<i class="fas fa-th-large"></i> 大图标
</button>
<button class="btn" :class="fileViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="fileViewMode = 'list'">
<i class="fas fa-list"></i> 列表
</button>
</div>
</div>
<!-- 隐藏的文件上传input -->
<input type="file" ref="fileUploadInput" @change="handleFileSelect" style="display: none;" multiple>
<!-- 加载中 -->
<div v-if="loading" class="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<!-- 文件列表 -->
<div v-else @dragenter="handleDragEnter" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop" class="files-container" :class="{ 'drag-over': isDragging }">
<p v-if="files.length === 0" class="empty-hint">文件夹是空的</p>
<!-- 拖拽提示层 -->
<div v-if="isDragging && storageType === 'local'" class="drag-drop-overlay">
<div class="drag-drop-content">
<i class="fas fa-cloud-upload-alt" style="font-size: 64px; color: #667eea; margin-bottom: 20px;"></i>
<div style="font-size: 24px; font-weight: 600; color: #333; margin-bottom: 10px;">拖放文件到这里上传</div>
<div style="font-size: 14px; color: #666;">松开鼠标即可开始上传</div>
</div>
</div>
<!-- 大图标视图 -->
<div v-else-if="fileViewMode === 'grid'" class="file-grid">
<div v-for="file in files" :key="file.name" class="file-grid-item" @click="handleFileClick(file)" @contextmenu.prevent="showFileContextMenu(file, $event)" @touchstart="handleLongPressStart(file, $event)" @touchend="handleLongPressEnd">
<div class="file-icon">
<!-- 图片缩略图 -->
<img v-if="file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i) && getThumbnailUrl(file)"
:src="getThumbnailUrl(file)"
:alt="file.name"
class="file-thumbnail">
<!-- 视频图标(不预加载,避免慢) -->
<div v-else-if="file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)"
style="width: 64px; height: 64px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 8px; display: flex; align-items: center; justify-content: center; position: relative;">
<i class="fas fa-play-circle" style="font-size: 32px; color: white;"></i>
</div>
<!-- 文件夹图标 -->
<i v-else-if="file.isDirectory" class="fas fa-folder" style="font-size: 64px; color: #FFC107;"></i>
<!-- 其他文件类型图标 -->
<i v-else-if="file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)" class="fas fa-file-audio" style="font-size: 64px; color: #FF5722;"></i>
<i v-else-if="file.name.match(/\.(pdf)$/i)" class="fas fa-file-pdf" style="font-size: 64px; color: #F44336;"></i>
<i v-else-if="file.name.match(/\.(doc|docx)$/i)" class="fas fa-file-word" style="font-size: 64px; color: #2196F3;"></i>
<i v-else-if="file.name.match(/\.(xls|xlsx)$/i)" class="fas fa-file-excel" style="font-size: 64px; color: #4CAF50;"></i>
<i v-else-if="file.name.match(/\.(zip|rar|7z|tar|gz)$/i)" class="fas fa-file-archive" style="font-size: 64px; color: #795548;"></i>
<i v-else class="fas fa-file" style="font-size: 64px; color: #9E9E9E;"></i>
</div>
<div class="file-name" :title="file.name">{{ file.name }}</div>
<div class="file-size">{{ file.isDirectory ? '文件夹' : file.sizeFormatted }}</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="file-list">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead>
<tr style="border-bottom: 2px solid #ddd; background: #f5f5f5;">
<th style="padding: 12px; text-align: left; width: 40%;">文件名</th>
<th style="padding: 12px; text-align: left; width: 15%;">大小</th>
<th style="padding: 12px; text-align: left; width: 25%;">修改时间</th>
</tr>
</thead>
<tbody>
<tr v-for="file in files" :key="file.name"
style="border-bottom: 1px solid #eee; cursor: pointer;"
@click="handleFileClick(file)"
@contextmenu.prevent="showFileContextMenu(file, $event)"
@touchstart="handleLongPressStart(file, $event)"
@touchend="handleLongPressEnd"
@mouseover="$event.currentTarget.style.background='#f9f9f9'"
@mouseout="$event.currentTarget.style.background='white'">
<td style="padding: 10px;">
<div style="display: flex; align-items: center; gap: 10px; overflow: hidden;">
<!-- 图片缩略图 -->
<img v-if="file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg|webp)$/i) && getThumbnailUrl(file)"
:src="getThumbnailUrl(file)"
:alt="file.name"
style="width: 32px; height: 32px; object-fit: cover; border-radius: 4px; flex-shrink: 0;">
<!-- 视频图标 -->
<div v-else-if="file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv|webm)$/i)"
style="width: 32px; height: 32px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; flex-shrink: 0;">
<i class="fas fa-play" style="font-size: 14px; color: white;"></i>
</div>
<!-- 文件夹图标 -->
<i v-else-if="file.isDirectory" class="fas fa-folder" style="font-size: 20px; color: #FFC107; flex-shrink: 0;"></i>
<!-- 其他文件类型图标 -->
<i v-else-if="file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)" class="fas fa-file-audio" style="font-size: 20px; color: #FF5722; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(pdf)$/i)" class="fas fa-file-pdf" style="font-size: 20px; color: #F44336; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(doc|docx)$/i)" class="fas fa-file-word" style="font-size: 20px; color: #2196F3; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(xls|xlsx)$/i)" class="fas fa-file-excel" style="font-size: 20px; color: #4CAF50; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(zip|rar|7z|tar|gz)$/i)" class="fas fa-file-archive" style="font-size: 20px; color: #795548; flex-shrink: 0;"></i>
<i v-else class="fas fa-file" style="font-size: 20px; color: #9E9E9E; flex-shrink: 0;"></i>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;" :title="file.name">{{ file.name }}</span>
</div>
</td>
<td style="padding: 10px; color: #666;">{{ file.isDirectory ? '-' : file.sizeFormatted }}</td>
<td style="padding: 10px; color: #666;">{{ formatDate(file.modifiedTime) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 重命名模态框 -->
<div v-if="showRenameModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showRenameModal')">
<div class="modal-content" @click.stop>
<h3 style="margin-bottom: 20px;">重命名文件</h3>
<div class="form-group">
<label class="form-label">新文件名</label>
<input type="text" class="form-input" v-model="renameForm.newName" @keyup.enter="renameFile()">
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="renameFile()" style="flex: 1;">
<i class="fas fa-check"></i> 确定
</button>
<button class="btn btn-secondary" @click="showRenameModal = false" style="flex: 1;">
<i class="fas fa-times"></i> 取消
</button>
</div>
</div>
</div>
<!-- 新建文件夹模态框 -->
<div v-if="showCreateFolderModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showCreateFolderModal')">
<div class="modal-content" @click.stop>
<h3 style="margin-bottom: 20px;">
<i class="fas fa-folder-plus"></i> 新建文件夹
</h3>
<div class="form-group">
<label class="form-label">文件夹名称</label>
<input type="text" class="form-input" v-model="createFolderForm.folderName" @keyup.enter="createFolder()" placeholder="请输入文件夹名称" autofocus>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="createFolder()" style="flex: 1;">
<i class="fas fa-check"></i> 创建
</button>
<button class="btn btn-secondary" @click="showCreateFolderModal = false; createFolderForm.folderName = ''" style="flex: 1;">
<i class="fas fa-times"></i> 取消
</button>
</div>
</div>
</div>
<!-- 分享所有文件模态框 -->
<div v-if="showShareAllModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showShareAllModal')">
<div class="modal-content" @click.stop>
<h3 style="margin-bottom: 20px;">分享所有文件</h3>
<div class="form-group">
<label class="form-label">密码保护(可选)</label>
<input type="password" class="form-input" v-model="shareAllForm.password" placeholder="留空则无需密码">
</div>
<div class="form-group">
<label class="form-label">有效期</label>
<select class="form-input" v-model="shareAllForm.expiryType">
<option value="never">永久</option>
<option value="7">7天</option>
<option value="30">30天</option>
<option value="custom">自定义</option>
</select>
</div>
<div v-if="shareAllForm.expiryType === 'custom'" class="form-group">
<label class="form-label">自定义天数</label>
<input type="number" class="form-input" v-model.number="shareAllForm.customDays" min="1" max="365">
</div>
<div v-if="shareResult" class="alert alert-success" style="margin-top: 15px;">
<strong>分享链接:</strong><br>
<a :href="shareResult.share_url" target="_blank">{{ shareResult.share_url }}</a>
<div v-if="shareResult.expires_at" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #c3e6cb;">
<strong>到期时间:</strong>
<span :style="{color: isExpiringSoon(shareResult.expires_at) ? '#ffc107' : '#28a745'}"><i class="fas fa-clock"></i> {{ formatExpireTime(shareResult.expires_at) }}</span>
</div>
<div v-else style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #c3e6cb;">
<strong>有效期:</strong>
<span style="color: #28a745;"><i class="fas fa-infinity"></i> 永久有效</span>
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="createShareAll()" style="flex: 1;">
<i class="fas fa-share"></i> 创建分享
</button>
<button class="btn btn-secondary" @click="showShareAllModal = false; shareResult = null" style="flex: 1;">
<i class="fas fa-times"></i> 关闭
</button>
</div>
</div>
</div>
<!-- 分享单个文件模态框 -->
<div v-if="showShareFileModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showShareFileModal')">
<div class="modal-content" @click.stop>
<h3 style="margin-bottom: 20px;">分享文件</h3>
<p style="color: #666; margin-bottom: 15px;">文件: <strong>{{ shareFileForm.fileName }}</strong></p>
<div class="form-group">
<label class="form-label">密码保护(可选)</label>
<input type="password" class="form-input" v-model="shareFileForm.password" placeholder="留空则无需密码">
</div>
<div class="form-group">
<label class="form-label">有效期</label>
<select class="form-input" v-model="shareFileForm.expiryType">
<option value="never">永久</option>
<option value="7">7天</option>
<option value="30">30天</option>
<option value="custom">自定义</option>
</select>
</div>
<div v-if="shareFileForm.expiryType === 'custom'" class="form-group">
<label class="form-label">自定义天数</label>
<input type="number" class="form-input" v-model.number="shareFileForm.customDays" min="1" max="365">
</div>
<div v-if="shareResult" class="alert alert-success" style="margin-top: 15px;">
<strong>分享链接:</strong><br>
<a :href="shareResult.share_url" target="_blank">{{ shareResult.share_url }}</a>
<div v-if="shareResult.expires_at" style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #c3e6cb;">
<strong>到期时间:</strong>
<span :style="{color: isExpiringSoon(shareResult.expires_at) ? '#ffc107' : '#28a745'}"><i class="fas fa-clock"></i> {{ formatExpireTime(shareResult.expires_at) }}</span>
</div>
<div v-else style="margin-top: 10px; padding-top: 10px; border-top: 1px solid #c3e6cb;">
<strong>有效期:</strong>
<span style="color: #28a745;"><i class="fas fa-infinity"></i> 永久有效</span>
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="createShareFile()" style="flex: 1;">
<i class="fas fa-share"></i> 创建分享
</button>
<button class="btn btn-secondary" @click="showShareFileModal = false; shareResult = null" style="flex: 1;">
<i class="fas fa-times"></i> 关闭
</button>
</div>
</div>
</div>
<!-- 设置视图 -->
<div v-if="isLoggedIn && currentView === 'settings'" class="main-container">
<div class="card">
<!-- 存储管理 - 仅用户可选择 -->
<div v-if="user && !user.is_admin && storagePermission === 'user_choice'" style="margin-bottom: 40px;">
<h3 style="margin-bottom: 20px;">
<i class="fas fa-database"></i> 存储管理
</h3>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px;">
<div style="margin-bottom: 15px;">
<span style="font-weight: 600; color: #333;">当前存储方式: </span>
<span style="color: #667eea; font-weight: 600;">{{ storageTypeText }}</span>
</div>
<div v-if="storageType === 'local'" style="margin-bottom: 15px;">
<span style="font-weight: 600; color: #333;">配额使用: </span>
<span>{{ localUsedFormatted }} / {{ localQuotaFormatted }} ({{ quotaPercentage }}%)</span>
<div style="margin-top: 8px; width: 100%; height: 18px; background: #e0e0e0; border-radius: 9px; overflow: hidden;">
<div :style="{
width: quotaPercentage + '%',
height: '100%',
background: quotaPercentage > 90 ? '#dc3545' : quotaPercentage > 75 ? '#ffc107' : '#28a745',
transition: 'width 0.3s'
}"></div>
</div>
</div>
<div style="display: flex; gap: 15px; flex-wrap: wrap;">
<button
class="btn"
:class="storageType === 'local' ? 'btn-primary' : 'btn-secondary'"
@click="switchStorage('local')"
:disabled="storageType === 'local'">
<i class="fas fa-hard-drive"></i> 本地存储
</button>
<button
class="btn"
:class="storageType === 'sftp' ? 'btn-primary' : 'btn-secondary'"
@click="switchStorage('sftp')"
:disabled="storageType === 'sftp'">
<i class="fas fa-server"></i> SFTP存储
</button>
</div>
<div style="margin-top: 15px; padding: 10px; background: #fff3cd; border-radius: 6px; font-size: 13px; color: #856404;">
<i class="fas fa-info-circle"></i>
<strong>提示:</strong> 本地存储速度快但有配额限制SFTP存储需先配置服务器信息
</div>
</div>
</div>
<!-- 本地存储信息 - 仅本地存储权限 -->
<div v-if="user && !user.is_admin && storagePermission === 'local_only'" style="margin-bottom: 40px;">
<h3 style="margin-bottom: 20px;">
<i class="fas fa-hard-drive"></i> 本地存储
</h3>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px;">
<div style="margin-bottom: 15px;">
<span style="font-weight: 600; color: #333;">存储方式: </span>
<span style="color: #667eea; font-weight: 600;">本地存储</span>
<span style="margin-left: 10px; padding: 4px 12px; background: #28a745; color: white; border-radius: 12px; font-size: 12px;">
<i class="fas fa-lock"></i> 仅本地
</span>
</div>
<div style="margin-bottom: 15px;">
<span style="font-weight: 600; color: #333;">配额使用: </span>
<span>{{ localUsedFormatted }} / {{ localQuotaFormatted }} ({{ quotaPercentage }}%)</span>
<div style="margin-top: 8px; width: 100%; height: 18px; background: #e0e0e0; border-radius: 9px; overflow: hidden;">
<div :style="{
width: quotaPercentage + '%',
height: '100%',
background: quotaPercentage > 90 ? '#dc3545' : quotaPercentage > 75 ? '#ffc107' : '#28a745',
transition: 'width 0.3s'
}"></div>
</div>
</div>
<div style="padding: 10px; background: #d1ecf1; border-left: 4px solid #0c5460; border-radius: 6px; font-size: 13px; color: #0c5460;">
<i class="fas fa-info-circle"></i>
<strong>说明:</strong> 管理员已将您的存储权限设置为"仅本地存储"您的文件存储在服务器本地速度快但有配额限制。如需使用SFTP存储请联系管理员修改权限设置。
</div>
</div>
</div>
<!-- SFTP存储信息 - 仅SFTP权限 -->
<div v-if="user && !user.is_admin && storagePermission === 'sftp_only' && user.has_ftp_config" style="margin-bottom: 40px;">
<h3 style="margin-bottom: 20px;">
<i class="fas fa-server"></i> SFTP存储
</h3>
<div style="background: #f8f9fa; padding: 20px; border-radius: 8px;">
<div style="margin-bottom: 15px;">
<span style="font-weight: 600; color: #333;">存储方式: </span>
<span style="color: #667eea; font-weight: 600;">SFTP存储</span>
<span style="margin-left: 10px; padding: 4px 12px; background: #17a2b8; color: white; border-radius: 12px; font-size: 12px;">
<i class="fas fa-lock"></i> 仅SFTP
</span>
</div>
<div style="margin-bottom: 15px;">
<span style="font-weight: 600; color: #333;">服务器: </span>
<span>{{ user.ftp_host }}:{{ user.ftp_port }}</span>
</div>
<div style="padding: 10px; background: #d1ecf1; border-left: 4px solid #0c5460; border-radius: 6px; font-size: 13px; color: #0c5460;">
<i class="fas fa-info-circle"></i>
<strong>说明:</strong> 管理员已将您的存储权限设置为"仅SFTP存储"您的文件存储在远程SFTP服务器上。如需使用本地存储请联系管理员修改权限设置。
</div>
</div>
</div>
<!-- SFTP配置 - 仅普通用户且权限允许SFTP -->
<div v-if="user && !user.is_admin && (storagePermission === 'sftp_only' || (storagePermission === 'user_choice' && (storageType === 'sftp' || forceSftpConfigVisible)))" id="sftp-config-section">
<h3 style="margin-bottom: 20px;">SFTP配置</h3>
<div v-if="user && !user.has_ftp_config" class="alert alert-info">
请配置SFTP服务器
</div>
<!-- 上传配置文件 -->
<div style="margin-bottom: 25px;">
<div style="border: 2px dashed #667eea; border-radius: 8px; padding: 20px; text-align: center; background: #f8f9ff; cursor: pointer; transition: all 0.3s;"
@click="$refs.configFileInput.click()"
@dragover.prevent="$event.currentTarget.style.background='#e8ecf7'"
@dragleave.prevent="$event.currentTarget.style.background='#f8f9ff'"
@drop.prevent="handleConfigFileDrop">
<i class="fas fa-cloud-upload-alt" style="font-size: 48px; color: #667eea; margin-bottom: 10px;"></i>
<p style="margin: 10px 0; font-size: 16px; color: #333; font-weight: 500;">
快速导入配置文件
</p>
<p style="margin: 5px 0; font-size: 14px; color: #666;">
点击选择或拖拽 .inf 文件到此处
</p>
<p style="margin: 5px 0; font-size: 12px; color: #999;">
导入后请检查配置信息并点击保存按钮
</p>
<input type="file" accept=".inf" @change="handleConfigFileUpload" ref="configFileInput" style="display: none;">
</div>
</div>
<form @submit.prevent="updateFtpConfig">
<div class="form-group">
<label class="form-label">主机地址</label>
<input type="text" class="form-input" v-model="ftpConfigForm.ftp_host" required>
</div>
<div class="form-group">
<label class="form-label">端口</label>
<input type="number" class="form-input" v-model="ftpConfigForm.ftp_port" required>
</div>
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" v-model="ftpConfigForm.ftp_user" required>
</div>
<div class="form-group">
<label class="form-label">密码 (留空保留现有密码)</label>
<input type="password" class="form-input" v-model="ftpConfigForm.ftp_password" placeholder="留空保留现有密码">
</div>
<div class="form-group">
<label class="form-label">HTTP下载基础URL (可选)</label>
<input type="text" class="form-input" v-model="ftpConfigForm.http_download_base_url" placeholder="例如: http://example.com/files">
<small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">
配置后可直接通过HTTP下载文件。URL格式: 基础URL/文件路径,例如: http://example.com/files/test.exe
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 保存SFTP配置
</button>
</form>
</div>
<!-- 账号设置 -->
<h3 style="margin: 40px 0 20px 0;">账号设置</h3>
<!-- 管理员可以改用户名 -->
<form v-if="user && user.is_admin" @submit.prevent="updateUsername" style="margin-bottom: 30px;">
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" v-model="usernameForm.newUsername" :placeholder="user.username" minlength="3" maxlength="20" required>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> 修改用户名
</button>
</form>
<!-- 所有用户都可以改密码 -->
<form @submit.prevent="changePassword">
<div class="form-group">
<div class="form-group">
<label class="form-label">当前密码</label>
<input type="password" class="form-input" v-model="changePasswordForm.current_password" placeholder="输入当前密码" required>
</div>
<label class="form-label">新密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="changePasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-key"></i> 修改密码
</button>
</form>
</div>
</div>
<!-- 分享视图 -->
<div v-if="isLoggedIn && currentView === 'shares'" class="main-container">
<div class="card">
<!-- 标题和工具栏 -->
<div style="margin-bottom: 20px; display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0;">我的分享</h3>
<div style="display: flex; gap: 10px;">
<button class="btn" :class="shareViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="shareViewMode = 'grid'">
<i class="fas fa-th-large"></i> 大图标
</button>
<button class="btn" :class="shareViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="shareViewMode = 'list'">
<i class="fas fa-list"></i> 列表
</button>
</div>
</div>
<!-- 空状态 -->
<div v-if="shares.length === 0" class="alert alert-info">
还没有创建任何分享
</div>
<!-- 大图标视图 -->
<div v-else-if="shareViewMode === 'grid'" class="file-grid">
<div v-for="share in shares" :key="share.id" class="file-grid-item">
<div class="file-icon">
<i class="fas fa-share-alt" style="font-size: 64px; color: #667eea;"></i>
</div>
<div class="file-name" :title="share.share_path">{{ share.share_path }}</div>
<div class="file-size" style="font-size: 12px; color: #666;">
访问: {{ share.view_count }} | 下载: {{ share.download_count }}
</div>
<div class="file-actions">
<button class="btn-icon" @click.stop="window.open(share.share_url, '_blank')" title="打开分享">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="btn-icon" @click.stop="copyShareLink(share.share_url)" title="复制链接">
<i class="fas fa-copy"></i>
</button>
<button class="btn-icon" style="color: #dc3545;" @click.stop="deleteShare(share.id)" title="删除">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
<!-- 列表视图 -->
<table v-else style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead>
<tr style="border-bottom: 2px solid #ddd;">
<th style="padding: 10px; text-align: left; width: 20%;">文件路径</th>
<th style="padding: 10px; text-align: left; width: 30%;">分享链接</th>
<th style="padding: 10px; text-align: center; width: 10%;">访问次数</th>
<th style="padding: 10px; text-align: center; width: 10%;">下载次数</th>
<th style="padding: 10px; text-align: center; width: 20%;">到期时间</th>
<th style="padding: 10px; text-align: center; width: 10%;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="share in shares" :key="share.id" style="border-bottom: 1px solid #eee;">
<td style="padding: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="share.share_path">{{ share.share_path }}</td>
<td style="padding: 10px; overflow: hidden;">
<a :href="share.share_url" target="_blank" style="color: #667eea; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="share.share_url">{{ share.share_url }}</a>
</td>
<td style="padding: 10px; text-align: center;">{{ share.view_count }}</td>
<td style="padding: 10px; text-align: center;">{{ share.download_count }}</td>
<td style="padding: 10px; text-align: center;">
<span v-if="!share.expires_at" style="color: #28a745;"><i class="fas fa-infinity"></i> 永久有效</span>
<span v-else :style="{color: isExpiringSoon(share.expires_at) ? '#ffc107' : isExpired(share.expires_at) ? '#dc3545' : '#667eea'}" :title="share.expires_at"><i class="fas fa-clock"></i> {{ formatExpireTime(share.expires_at) }}</span>
</td>
<td style="padding: 10px; text-align: center;">
<button class="btn" style="background: #dc3545; color: white;" @click="deleteShare(share.id)">
<i class="fas fa-trash"></i> 删除
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 管理员视图 -->
<div v-if="isLoggedIn && currentView === 'admin' && user && user.is_admin" class="main-container">
<div class="card">
<!-- 调试模式开关 -->
<div class="card" style="margin-bottom: 30px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<h3 style="margin-bottom: 10px; color: white;">
<i class="fas fa-bug"></i> 调试模式
</h3>
<p style="margin: 0; font-size: 14px; opacity: 0.9;">
{{ debugMode ? '已启用 - F12和开发者工具已解锁' : '已禁用 - F12和开发者工具被锁定' }}
</p>
</div>
<button @click="toggleDebugMode" class="btn" :style="{background: debugMode ? '#28a745' : '#dc3545', color: 'white', border: 'none', padding: '12px 24px', fontSize: '16px', fontWeight: '600', cursor: 'pointer', borderRadius: '8px', boxShadow: '0 4px 12px rgba(0,0,0,0.2)'}">
<i :class="debugMode ? 'fas fa-toggle-on' : 'fas fa-toggle-off'"></i>
{{ debugMode ? '关闭调试' : '开启调试' }}
</button>
</div>
</div>
<!-- 服务器存储统计 -->
<div class="card" style="margin-bottom: 30px;">
<h3 style="margin-bottom: 20px;">
<i class="fas fa-hdd"></i> 服务器存储统计
</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px;">
<!-- 磁盘总容量 -->
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 12px; color: white;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="font-size: 14px; opacity: 0.9; margin-bottom: 8px;">磁盘总容量</div>
<div style="font-size: 28px; font-weight: 700;">{{ formatBytes(serverStorageStats.totalDisk) }}</div>
</div>
<i class="fas fa-database" style="font-size: 48px; opacity: 0.3;"></i>
</div>
</div>
<!-- 已使用空间 -->
<div style="background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); padding: 20px; border-radius: 12px; color: white;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="font-size: 14px; opacity: 0.9; margin-bottom: 8px;">已使用空间</div>
<div style="font-size: 28px; font-weight: 700;">{{ formatBytes(serverStorageStats.usedDisk) }}</div>
<div style="font-size: 12px; opacity: 0.8; margin-top: 4px;">
{{ serverStorageStats.totalDisk > 0 ? Math.round((serverStorageStats.usedDisk / serverStorageStats.totalDisk) * 100) : 0 }}% 使用率
</div>
</div>
<i class="fas fa-chart-pie" style="font-size: 48px; opacity: 0.3;"></i>
</div>
</div>
<!-- 可用空间 -->
<div style="background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); padding: 20px; border-radius: 12px; color: white;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="font-size: 14px; opacity: 0.9; margin-bottom: 8px;">可用空间</div>
<div style="font-size: 28px; font-weight: 700;">{{ formatBytes(serverStorageStats.availableDisk) }}</div>
</div>
<i class="fas fa-folder-open" style="font-size: 48px; opacity: 0.3;"></i>
</div>
</div>
<!-- 用户配额总和 -->
<div style="background: linear-gradient(135deg, #fa709a 0%, #fee140 100%); padding: 20px; border-radius: 12px; color: white;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="font-size: 14px; opacity: 0.9; margin-bottom: 8px;">用户配额总和</div>
<div style="font-size: 28px; font-weight: 700;">{{ formatBytes(serverStorageStats.totalUserQuotas) }}</div>
<div style="font-size: 12px; opacity: 0.8; margin-top: 4px;">
{{ serverStorageStats.totalUsers }} 个用户
</div>
</div>
<i class="fas fa-users" style="font-size: 48px; opacity: 0.3;"></i>
</div>
</div>
<!-- 用户实际使用 -->
<div style="background: linear-gradient(135deg, #30cfd0 0%, #330867 100%); padding: 20px; border-radius: 12px; color: white;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="font-size: 14px; opacity: 0.9; margin-bottom: 8px;">用户实际使用</div>
<div style="font-size: 28px; font-weight: 700;">{{ formatBytes(serverStorageStats.totalUserUsed) }}</div>
<div style="font-size: 12px; opacity: 0.8; margin-top: 4px;">
{{ serverStorageStats.totalUserQuotas > 0 ? Math.round((serverStorageStats.totalUserUsed / serverStorageStats.totalUserQuotas) * 100) : 0 }}% 配额使用率
</div>
</div>
<i class="fas fa-cloud-upload-alt" style="font-size: 48px; opacity: 0.3;"></i>
</div>
</div>
<!-- 配额剩余 -->
<div style="background: linear-gradient(135deg, #a8edea 0%, #fed6e3 100%); padding: 20px; border-radius: 12px; color: #333;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="font-size: 14px; opacity: 0.8; margin-bottom: 8px;">安全可分配配额</div>
<div style="font-size: 28px; font-weight: 700;">
{{ formatBytes(Math.max(0, serverStorageStats.availableDisk - (serverStorageStats.totalUserQuotas - serverStorageStats.totalUserUsed))) }}
</div>
<div style="font-size: 12px; opacity: 0.7; margin-top: 4px;">
可用空间 - 未使用的配额
</div>
</div>
<i class="fas fa-boxes" style="font-size: 48px; opacity: 0.2;"></i>
</div>
</div>
</div>
<!-- 存储警告提示 -->
<div v-if="serverStorageStats.totalDisk > 0 && ((serverStorageStats.usedDisk / serverStorageStats.totalDisk) > 0.9)"
style="margin-top: 20px; padding: 15px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 6px; color: #856404;">
<i class="fas fa-exclamation-triangle"></i>
<strong>警告:</strong> 磁盘使用率已超过90%,建议及时清理空间或扩容!
</div>
<div v-if="serverStorageStats.totalUserQuotas > serverStorageStats.totalDisk"
style="margin-top: 20px; padding: 15px; background: #f8d7da; border-left: 4px solid #dc3545; border-radius: 6px; color: #721c24;">
<i class="fas fa-exclamation-circle"></i>
<strong>配额超分配:</strong> 用户配额总和 ({{ formatBytes(serverStorageStats.totalUserQuotas) }}) 已超过磁盘总容量 ({{ formatBytes(serverStorageStats.totalDisk) }})
</div>
</div>
<h3 style="margin-bottom: 20px;">用户管理</h3>
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 900px;">
<thead>
<tr style="border-bottom: 2px solid #ddd; background: #f5f5f5;">
<th style="padding: 10px; text-align: left; width: 5%;">ID</th>
<th style="padding: 10px; text-align: left; width: 12%;">用户名</th>
<th style="padding: 10px; text-align: left; width: 15%;">邮箱</th>
<th style="padding: 10px; text-align: center; width: 10%;">存储权限</th>
<th style="padding: 10px; text-align: center; width: 10%;">当前存储</th>
<th style="padding: 10px; text-align: center; width: 13%;">配额使用</th>
<th style="padding: 10px; text-align: center; width: 10%;">状态</th>
<th style="padding: 10px; text-align: center; width: 25%;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="u in adminUsers" :key="u.id" style="border-bottom: 1px solid #eee;">
<td style="padding: 10px;">{{ u.id }}</td>
<td style="padding: 10px; overflow: hidden;">
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="u.username">
<span>{{ u.username }}</span>
<span v-if="u.is_admin" style="color: #28a745; margin-left: 5px; white-space: nowrap;" title="管理员">
<i class="fas fa-crown"></i>
</span>
</div>
</td>
<td style="padding: 10px; font-size: 12px; color: #666; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="u.email">{{ u.email }}</td>
<td style="padding: 10px; text-align: center; font-size: 12px;">
<span v-if="u.storage_permission === 'local_only'" style="background: #667eea; color: white; padding: 3px 8px; border-radius: 4px;">仅本地</span>
<span v-else-if="u.storage_permission === 'sftp_only'" style="background: #6c757d; color: white; padding: 3px 8px; border-radius: 4px;">仅SFTP</span>
<span v-else style="background: #28a745; color: white; padding: 3px 8px; border-radius: 4px;">用户选择</span>
</td>
<td style="padding: 10px; text-align: center; font-size: 12px;">
<span v-if="u.current_storage_type === 'local'" style="color: #667eea;">
<i class="fas fa-hard-drive"></i> 本地
</span>
<span v-else style="color: #6c757d;">
<i class="fas fa-server"></i> SFTP
</span>
</td>
<td style="padding: 10px; text-align: center; font-size: 12px;">
<div v-if="u.current_storage_type === 'local'">
<div>{{ formatBytes(u.local_storage_used) }} / {{ formatBytes(u.local_storage_quota) }}</div>
<div style="font-size: 11px; color: #999;">
{{ Math.round((u.local_storage_used / u.local_storage_quota) * 100) }}%
</div>
</div>
<span v-else style="color: #999;">-</span>
</td>
<td style="padding: 10px; text-align: center;">
<span v-if="u.is_banned" style="color: #dc3545; font-weight: 600;">已封禁</span>
<span v-else style="color: #28a745;">正常</span>
</td>
<td style="padding: 10px; text-align: center;">
<div style="display: flex; gap: 3px; justify-content: center; flex-wrap: wrap;">
<button class="btn" style="background: #667eea; color: white; font-size: 11px; padding: 5px 10px;" @click="openEditStorageModal(u)" title="存储设置">
<i class="fas fa-database"></i> 存储
</button>
<button v-if="!u.is_banned" class="btn" style="background: #ffc107; color: #000; font-size: 11px; padding: 5px 10px;" @click="banUser(u.id, true)">
<i class="fas fa-ban"></i> 封禁
</button>
<button v-else class="btn" style="background: #28a745; color: white; font-size: 11px; padding: 5px 10px;" @click="banUser(u.id, false)">
<i class="fas fa-check"></i> 解封
</button>
<button v-if="u.has_ftp_config" class="btn" style="background: #17a2b8; color: white; font-size: 11px; padding: 5px 10px;" @click="openFileInspection(u)">
<i class="fas fa-folder-open"></i> 文件
</button>
<button class="btn" style="background: #dc3545; color: white; font-size: 11px; padding: 5px 10px;" @click="deleteUser(u.id)">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 密码重置审核区域 -->
<div class="card" style="margin-top: 30px;">
<h3 style="margin-bottom: 20px;">密码重置审核</h3>
<div v-if="passwordResetRequests.length === 0" class="alert alert-info">
暂无待审核的密码重置请求
</div>
<table v-else style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead>
<tr style="border-bottom: 2px solid #ddd;">
<th style="padding: 10px; text-align: left;">用户名</th>
<th style="padding: 10px; text-align: left;">邮箱</th>
<th style="padding: 10px; text-align: left;">提交时间</th>
<th style="padding: 10px; text-align: center;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="req in passwordResetRequests" :key="req.id" style="border-bottom: 1px solid #eee;">
<td style="padding: 10px;">{{ req.username }}</td>
<td style="padding: 10px;">{{ req.email }}</td>
<td style="padding: 10px;">{{ formatDate(req.created_at) }}</td>
<td style="padding: 10px; text-align: center;">
<button class="btn" style="background: #28a745; color: white; margin: 2px;" @click="reviewPasswordReset(req.id, true)">
<i class="fas fa-check"></i> 批准
</button>
<button class="btn" style="background: #dc3545; color: white; margin: 2px;" @click="reviewPasswordReset(req.id, false)">
<i class="fas fa-times"></i> 拒绝
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 上传工具管理区域 -->
<div class="card" style="margin-top: 30px;">
<h3 style="margin-bottom: 20px;">
<i class="fas fa-cloud-upload-alt"></i> 上传工具管理
</h3>
<!-- 工具状态显示 -->
<div v-if="uploadToolStatus !== null">
<div v-if="uploadToolStatus.exists" style="padding: 15px; background: #d4edda; border-left: 4px solid #28a745; border-radius: 6px; margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between;">
<div>
<div style="color: #155724; font-weight: 600; margin-bottom: 5px;">
<i class="fas fa-check-circle"></i> 上传工具已存在
</div>
<div style="color: #155724; font-size: 13px;">
文件大小: {{ uploadToolStatus.fileInfo.sizeMB }} MB
</div>
<div style="color: #155724; font-size: 12px; margin-top: 3px;">
最后修改: {{ formatDate(uploadToolStatus.fileInfo.modifiedAt) }}
</div>
</div>
<button class="btn btn-primary" @click="checkUploadTool" style="background: #28a745;">
<i class="fas fa-sync-alt"></i> 重新检测
</button>
</div>
</div>
<div v-else style="padding: 15px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 6px; margin-bottom: 20px;">
<div style="color: #856404; font-weight: 600; margin-bottom: 5px;">
<i class="fas fa-exclamation-triangle"></i> 上传工具不存在
</div>
<div style="color: #856404; font-size: 13px;">
普通用户将无法下载上传工具,请上传工具文件
</div>
</div>
</div>
<!-- 操作按钮组 -->
<div style="display: flex; gap: 10px; margin-top: 15px; flex-wrap: wrap;">
<button class="btn btn-primary" @click="checkUploadTool" :disabled="checkingUploadTool" style="background: #17a2b8;">
<i class="fas fa-search" v-if="!checkingUploadTool"></i>
<i class="fas fa-spinner fa-spin" v-else></i>
{{ checkingUploadTool ? '检测中...' : '检测上传工具' }}
</button>
<button v-if="uploadToolStatus && !uploadToolStatus.exists" class="btn btn-primary" @click="$refs.uploadToolInput.click()" :disabled="uploadingTool" style="background: #28a745;">
<i class="fas fa-upload" v-if="!uploadingTool"></i>
<i class="fas fa-spinner fa-spin" v-else></i>
{{ uploadingTool ? '上传中...' : '上传工具文件' }}
</button>
<input ref="uploadToolInput" type="file" accept=".exe" style="display: none;" @change="handleUploadToolFile">
</div>
<!-- 使用说明 -->
<div style="margin-top: 20px; padding: 12px; background: #e7f3ff; border-left: 4px solid #2196F3; border-radius: 6px;">
<div style="color: #0c5460; font-size: 13px; line-height: 1.6;">
<strong><i class="fas fa-info-circle"></i> 说明:</strong>
<ul style="margin: 8px 0 0 20px; padding-left: 0;">
<li>上传工具文件应为 .exe 格式,大小通常在 20-50 MB</li>
<li>上传后,普通用户可以在设置页面下载该工具</li>
<li>如果安装脚本下载失败,可以在这里手动上传</li>
</ul>
</div>
</div>
</div>
</div>
<!-- 忘记密码模态框 -->
<div v-if="showForgotPasswordModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showForgotPasswordModal')">
<div class="modal-content" @click.stop>
<h3 style="margin-bottom: 20px;">忘记密码 - 提交重置请求</h3>
<p style="color: #666; margin-bottom: 15px; font-size: 14px;">
请输入您的用户名和新密码,提交后需要等待管理员审核批准
</p>
<div class="form-group">
<label class="form-label">用户名</label>
<input type="text" class="form-input" v-model="forgotPasswordForm.username" placeholder="请输入用户名" required>
</div>
<div class="form-group">
<label class="form-label">新密码 (至少6字符)</label>
<input type="password" class="form-input" v-model="forgotPasswordForm.new_password" placeholder="输入新密码" minlength="6" required>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="requestPasswordReset" style="flex: 1;">
<i class="fas fa-paper-plane"></i> 提交请求
</button>
<button class="btn btn-secondary" @click="showForgotPasswordModal = false; forgotPasswordForm = {username: '', new_password: ''}" style="flex: 1;">
<i class="fas fa-times"></i> 取消
</button>
</div>
</div>
</div>
<!-- 文件审查模态框 -->
<div v-if="showFileInspectionModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showFileInspectionModal')">
<div class="modal-content" @click.stop style="max-width: 900px; max-height: 80vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h3 style="margin: 0;">
<i class="fas fa-eye"></i> 文件审查 - {{ inspectionUser?.username }}
<span style="background: #ffc107; color: #000; padding: 4px 8px; border-radius: 4px; font-size: 12px; margin-left: 10px;">只读模式</span>
</h3>
<button class="btn-icon" @click="showFileInspectionModal = false" style="font-size: 20px;">
<i class="fas fa-times"></i>
</button>
</div>
<!-- 路径导航和视图切换 -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; gap: 15px;">
<div style="background: #f5f5f5; padding: 10px; border-radius: 6px; display: flex; align-items: center; gap: 10px; flex: 1;">
<button class="btn-icon" @click="navigateInspectionToRoot" title="返回根目录">
<i class="fas fa-home"></i>
</button>
<button class="btn-icon" @click="navigateInspectionUp" :disabled="inspectionPath === '/'" title="上一级">
<i class="fas fa-arrow-up"></i>
</button>
<span style="flex: 1; color: #666; font-family: monospace;">{{ inspectionPath }}</span>
</div>
<div style="display: flex; gap: 10px;">
<button class="btn" :class="inspectionViewMode === 'grid' ? 'btn-primary' : 'btn-secondary'" @click="inspectionViewMode = 'grid'">
<i class="fas fa-th-large"></i> 大图标
</button>
<button class="btn" :class="inspectionViewMode === 'list' ? 'btn-primary' : 'btn-secondary'" @click="inspectionViewMode = 'list'">
<i class="fas fa-list"></i> 列表
</button>
</div>
</div>
<!-- 加载中 -->
<div v-if="inspectionLoading" class="loading">
<div class="spinner"></div>
<p>加载中...</p>
</div>
<!-- 文件列表 -->
<div v-else>
<p v-if="inspectionFiles.length === 0" class="empty-hint">文件夹是空的</p>
<!-- 大图标视图 -->
<div v-else-if="inspectionViewMode === 'grid'" class="file-grid">
<div v-for="file in inspectionFiles" :key="file.name" class="file-grid-item" @dblclick="handleInspectionFileClick(file)">
<div class="file-icon">
<i v-if="file.isDirectory" class="fas fa-folder" style="font-size: 64px; color: #FFC107;"></i>
<i v-else-if="file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg)$/i)" class="fas fa-file-image" style="font-size: 64px; color: #4CAF50;"></i>
<i v-else-if="file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv)$/i)" class="fas fa-file-video" style="font-size: 64px; color: #9C27B0;"></i>
<i v-else-if="file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)" class="fas fa-file-audio" style="font-size: 64px; color: #FF5722;"></i>
<i v-else-if="file.name.match(/\.(pdf)$/i)" class="fas fa-file-pdf" style="font-size: 64px; color: #F44336;"></i>
<i v-else-if="file.name.match(/\.(doc|docx)$/i)" class="fas fa-file-word" style="font-size: 64px; color: #2196F3;"></i>
<i v-else-if="file.name.match(/\.(xls|xlsx)$/i)" class="fas fa-file-excel" style="font-size: 64px; color: #4CAF50;"></i>
<i v-else-if="file.name.match(/\.(zip|rar|7z|tar|gz)$/i)" class="fas fa-file-archive" style="font-size: 64px; color: #795548;"></i>
<i v-else class="fas fa-file" style="font-size: 64px; color: #9E9E9E;"></i>
</div>
<div class="file-name" :title="file.name">{{ file.name }}</div>
<div class="file-size">{{ file.isDirectory ? '文件夹' : file.sizeFormatted }}</div>
</div>
</div>
<!-- 列表视图 -->
<div v-else class="file-list">
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
<thead>
<tr style="border-bottom: 2px solid #ddd; background: #f5f5f5;">
<th style="padding: 12px; text-align: left; width: 50%;">文件名</th>
<th style="padding: 12px; text-align: left; width: 25%;">大小</th>
<th style="padding: 12px; text-align: left; width: 25%;">修改时间</th>
</tr>
</thead>
<tbody>
<tr v-for="file in inspectionFiles" :key="file.name"
style="border-bottom: 1px solid #eee; cursor: pointer;"
@dblclick="handleInspectionFileClick(file)"
@mouseover="$event.currentTarget.style.background='#f9f9f9'"
@mouseout="$event.currentTarget.style.background='white'">
<td style="padding: 10px;">
<div style="display: flex; align-items: center; gap: 10px; overflow: hidden;">
<i v-if="file.isDirectory" class="fas fa-folder" style="font-size: 20px; color: #FFC107; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(jpg|jpeg|png|gif|bmp|svg)$/i)" class="fas fa-file-image" style="font-size: 20px; color: #4CAF50;"></i>
<i v-else-if="file.name.match(/\.(mp4|avi|mov|wmv|flv|mkv)$/i)" class="fas fa-file-video" style="font-size: 20px; color: #9C27B0;"></i>
<i v-else-if="file.name.match(/\.(mp3|wav|flac|aac|ogg)$/i)" class="fas fa-file-audio" style="font-size: 20px; color: #FF5722; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(pdf)$/i)" class="fas fa-file-pdf" style="font-size: 20px; color: #F44336; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(doc|docx)$/i)" class="fas fa-file-word" style="font-size: 20px; color: #2196F3; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(xls|xlsx)$/i)" class="fas fa-file-excel" style="font-size: 20px; color: #4CAF50; flex-shrink: 0;"></i>
<i v-else-if="file.name.match(/\.(zip|rar|7z|tar|gz)$/i)" class="fas fa-file-archive" style="font-size: 20px; color: #795548; flex-shrink: 0;"></i>
<i v-else class="fas fa-file" style="font-size: 20px; color: #9E9E9E; flex-shrink: 0;"></i>
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0;" :title="file.name">{{ file.name }}</span>
</div>
</td>
<td style="padding: 10px; color: #666;">{{ file.isDirectory ? '-' : file.sizeFormatted }}</td>
<td style="padding: 10px; color: #666;">{{ formatDate(file.modifiedAt) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div style="margin-top: 15px; padding: 10px; background: #fff3cd; border-radius: 6px; color: #856404;">
<i class="fas fa-info-circle"></i> 只读模式:双击文件夹可进入,无法下载、修改或删除文件
</div>
</div>
</div>
<!-- Toast 通知容器 -->
<div style="position: fixed; top: 20px; right: 20px; z-index: 2000; max-width: 350px;">
<div v-for="toast in toasts" :key="toast.id"
:style="{
background: toast.type === 'error' ? '#f8d7da' : toast.type === 'success' ? '#d4edda' : '#d1ecf1',
color: toast.type === 'error' ? '#721c24' : toast.type === 'success' ? '#155724' : '#0c5460',
padding: '15px',
borderRadius: '8px',
marginBottom: '10px',
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
display: 'flex',
alignItems: 'start',
gap: '12px',
animation: toast.hiding ? 'slideOut 0.5s ease-out forwards' : 'slideIn 0.5s ease-out',
opacity: toast.hiding ? 0 : 1,
transform: toast.hiding ? 'translateX(400px)' : 'translateX(0)'
}">
<i :class="toast.icon" style="font-size: 20px; margin-top: 2px;"></i>
<div style="flex: 1;">
<div style="font-weight: 600; margin-bottom: 4px;">{{ toast.title }}</div>
<div style="font-size: 14px;">{{ toast.message }}</div>
</div>
</div>
</div>
<!-- 上传进度条 -->
<div v-if="uploadProgress > 0 && uploadProgress < 100"
style="position: fixed; bottom: 20px; right: 20px; z-index: 2000; width: 350px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 20px; animation: slideIn 0.3s ease-out;">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 12px;">
<i class="fas fa-cloud-upload-alt" style="font-size: 24px; color: #667eea;"></i>
<div style="flex: 1;">
<div style="font-weight: 600; color: #333; margin-bottom: 4px;">正在上传文件</div>
<div style="font-size: 13px; color: #666;">{{ uploadingFileName }}</div>
<div v-if="totalBytes > 0" style="font-size: 12px; color: #999; margin-top: 2px;"> {{ formatFileSize(uploadedBytes) }} / {{ formatFileSize(totalBytes) }} </div>
</div>
<div style="font-size: 20px; font-weight: 700; color: #667eea;">{{ uploadProgress }}%</div>
</div>
<div style="width: 100%; height: 8px; background: #e0e0e0; border-radius: 4px; overflow: hidden;">
<div :style="{
width: uploadProgress + '%',
height: '100%',
background: 'linear-gradient(90deg, #667eea 0%, #764ba2 100%)',
transition: 'width 0.3s ease',
borderRadius: '4px'
}"></div>
</div>
</div>
<!-- 右键菜单 -->
<div v-if="showContextMenu" class="context-menu" :style="{
left: contextMenuX + 'px',
top: contextMenuY + 'px'
}" @click.stop>
<div v-if="isPreviewable(contextMenuFile)" class="context-menu-item" @click="contextMenuAction('preview')">
<i class="fas fa-eye"></i> 预览
</div>
<!-- 文件夹不显示下载和分享按钮 -->
<div v-if="!contextMenuFile.isDirectory" class="context-menu-item" @click="contextMenuAction('download')">
<i class="fas fa-download"></i> 下载
</div>
<div class="context-menu-item" @click="contextMenuAction('rename')">
<i class="fas fa-edit"></i> 重命名
</div>
<div v-if="!contextMenuFile.isDirectory" class="context-menu-item" @click="contextMenuAction('share')">
<i class="fas fa-share"></i> 分享
</div>
<div class="context-menu-divider"></div>
<div class="context-menu-item context-menu-item-danger" @click="contextMenuAction('delete')">
<i class="fas fa-trash"></i> 删除
</div>
</div>
<!-- 管理员:编辑用户存储权限模态框 -->
<div v-if="showEditStorageModal" class="modal-overlay" @mousedown.self="handleModalMouseDown" @mouseup.self="handleModalMouseUp('showEditStorageModal')">
<div class="modal-content" @click.stop>
<h3 style="margin-bottom: 20px;">
<i class="fas fa-database"></i> 存储权限设置 - {{ editStorageForm.username }}
</h3>
<div class="form-group">
<label class="form-label">存储权限</label>
<select class="form-input" v-model="editStorageForm.storage_permission">
<option value="local_only">仅本地存储</option>
<option value="sftp_only">仅SFTP存储</option>
<option value="user_choice">用户选择</option>
</select>
<small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">
仅本地:用户只能使用本地存储 | 仅SFTP用户只能使用SFTP | 用户选择:用户可自由切换
</small>
</div>
<div class="form-group">
<label class="form-label">本地存储配额</label>
<div style="display: flex; gap: 10px;">
<input type="number" class="form-input" v-model.number="editStorageForm.local_storage_quota_value" min="1" max="102400" step="1" style="flex: 1;">
<select class="form-input" v-model="editStorageForm.quota_unit" style="width: 100px;">
<option value="MB">MB</option>
<option value="GB">GB</option>
</select>
</div>
<small style="color: #666; font-size: 12px; margin-top: 5px; display: block;">
配额范围: 1MB - 100GB | 建议: 大配额使用GB小配额使用MB
</small>
</div>
<div style="padding: 12px; background: #f8f9fa; border-radius: 6px; margin-bottom: 20px;">
<div style="font-size: 13px; color: #666; line-height: 1.6;">
<strong style="color: #333;">配额说明:</strong><br>
• 默认配额: 1GB<br>
• 当前配额: {{ editStorageForm.local_storage_quota_value }} {{ editStorageForm.quota_unit }}
({{ editStorageForm.quota_unit === 'GB' ? (editStorageForm.local_storage_quota_value * 1024).toFixed(0) : editStorageForm.local_storage_quota_value }} MB)<br>
• 配额仅影响本地存储SFTP存储不受此限制
</div>
</div>
<div style="display: flex; gap: 10px; margin-top: 20px;">
<button class="btn btn-primary" @click="updateUserStorage" style="flex: 1;">
<i class="fas fa-save"></i> 保存设置
</button>
<button class="btn btn-secondary" @click="showEditStorageModal = false" style="flex: 1;">
<i class="fas fa-times"></i> 取消
</button>
</div>
</div>
</div>
<!-- 图片预览模态框 -->
<div v-if="showImageViewer" class="modal-overlay" @click="closeMediaViewer">
<div class="media-viewer-content" @click.stop>
<div class="media-viewer-header">
<span class="media-viewer-title">{{ currentMediaName }}</span>
<div class="media-viewer-actions">
<button class="media-viewer-btn" @click="downloadCurrentMedia" title="下载">
<i class="fas fa-download"></i>
</button>
<button class="media-viewer-btn" @click="closeMediaViewer" title="关闭">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="media-viewer-body">
<img :src="currentMediaUrl" :alt="currentMediaName" class="media-viewer-image">
</div>
</div>
</div>
<!-- 视频播放器模态框 -->
<div v-if="showVideoPlayer" class="modal-overlay" @click="closeMediaViewer">
<div class="media-viewer-content" @click.stop>
<div class="media-viewer-header">
<span class="media-viewer-title">{{ currentMediaName }}</span>
<div class="media-viewer-actions">
<button class="media-viewer-btn" @click="downloadCurrentMedia" title="下载">
<i class="fas fa-download"></i>
</button>
<button class="media-viewer-btn" @click="closeMediaViewer" title="关闭">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="media-viewer-body">
<video controls :src="currentMediaUrl" class="media-viewer-video">
您的浏览器不支持视频播放
</video>
</div>
</div>
</div>
<!-- 音频播放器模态框 -->
<div v-if="showAudioPlayer" class="modal-overlay" @click="closeMediaViewer">
<div class="media-viewer-content audio-player" @click.stop>
<div class="media-viewer-header">
<span class="media-viewer-title">{{ currentMediaName }}</span>
<div class="media-viewer-actions">
<button class="media-viewer-btn" @click="downloadCurrentMedia" title="下载">
<i class="fas fa-download"></i>
</button>
<button class="media-viewer-btn" @click="closeMediaViewer" title="关闭">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="media-viewer-body">
<div class="audio-player-icon">
<i class="fas fa-music"></i>
</div>
<audio controls :src="currentMediaUrl" class="media-viewer-audio">
您的浏览器不支持音频播放
</audio>
</div>
</div>
</div>
</div>
<style>
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(400px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(400px);
}
}
/* 右键菜单样式 */
.context-menu {
position: fixed;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
min-width: 160px;
z-index: 10000;
overflow: hidden;
animation: contextMenuFadeIn 0.15s ease-out;
}
@keyframes contextMenuFadeIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.context-menu-item {
padding: 12px 16px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 12px;
color: #333;
font-size: 14px;
}
.context-menu-item:hover {
background: #f5f5f5;
}
.context-menu-item i {
width: 16px;
text-align: center;
}
.context-menu-item-danger {
color: #dc3545;
}
.context-menu-item-danger:hover {
background: #fff5f5;
}
.context-menu-divider {
height: 1px;
background: #e0e0e0;
margin: 4px 0;
}
/* 移动端优化 */
@media (max-width: 768px) {
.context-menu {
min-width: 180px;
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
}
.context-menu-item {
padding: 14px 18px;
font-size: 15px;
}
}
/* 媒体预览器样式 */
.media-viewer-content {
background: white;
border-radius: 12px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 10px 40px rgba(0,0,0,0.3);
}
.media-viewer-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: #667eea;
color: white;
}
.media-viewer-title {
font-size: 16px;
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: 15px;
}
.media-viewer-actions {
display: flex;
gap: 10px;
}
.media-viewer-btn {
background: rgba(255,255,255,0.2);
border: none;
color: white;
width: 36px;
height: 36px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
font-size: 16px;
}
.media-viewer-btn:hover {
background: rgba(255,255,255,0.3);
}
.media-viewer-body {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: #f5f5f5;
flex: 1;
overflow: auto;
}
.media-viewer-image {
max-width: 100%;
max-height: 75vh;
object-fit: contain;
border-radius: 8px;
}
.media-viewer-video {
max-width: 100%;
max-height: 75vh;
border-radius: 8px;
}
.media-viewer-audio {
width: 100%;
max-width: 500px;
}
.audio-player .media-viewer-body {
flex-direction: column;
gap: 30px;
}
.audio-player-icon {
font-size: 80px;
color: #667eea;
}
.audio-player-icon i {
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.6;
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.media-viewer-content {
max-width: 95vw;
max-height: 95vh;
}
.media-viewer-header {
padding: 12px 15px;
}
.media-viewer-title {
font-size: 14px;
}
.media-viewer-btn {
width: 32px;
height: 32px;
font-size: 14px;
}
.media-viewer-body {
padding: 15px;
}
.media-viewer-image,
.media-viewer-video {
max-height: 70vh;
}
.audio-player-icon {
font-size: 64px;
}
}
/* 拖拽上传样式 */
.files-container {
position: relative;
min-height: 200px;
}
.drag-drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(102, 126, 234, 0.1);
backdrop-filter: blur(2px);
border: 3px dashed #667eea;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
.drag-drop-content {
text-align: center;
padding: 40px;
}
.files-container.drag-over {
background: rgba(102, 126, 234, 0.05);
border-radius: 12px;
}
/* 拖拽上传样式 */
.files-container {
position: relative;
min-height: 200px;
}
.drag-drop-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(102, 126, 234, 0.1);
backdrop-filter: blur(2px);
border: 3px dashed #667eea;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
pointer-events: none;
}
.drag-drop-content {
text-align: center;
padding: 40px;
}
.files-container.drag-over {
background: rgba(102, 126, 234, 0.05);
border-radius: 12px;
}
</style>
<script src="app.js?v=20251110001"></script>
<!-- 开发者工具保护 -->
<script>
// 检查是否启用调试模式
const isDebugMode = localStorage.getItem('debugMode') === 'true';
// 禁用右键菜单(调试模式下不禁用)
if (!isDebugMode) {
document.addEventListener('contextmenu', function(e) {
e.preventDefault();
return false;
});
}
// 禁用F12和常见开发者工具快捷键调试模式下不禁用
if (!isDebugMode) {
document.addEventListener('keydown', function(e) {
// F12
if (e.key === 'F12' || e.keyCode === 123) {
e.preventDefault();
return false;
}
// Ctrl+Shift+I (开发者工具)
if (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.keyCode === 73)) {
e.preventDefault();
return false;
}
// Ctrl+Shift+J (控制台)
if (e.ctrlKey && e.shiftKey && (e.key === 'J' || e.keyCode === 74)) {
e.preventDefault();
return false;
}
// Ctrl+U (查看源代码)
if (e.ctrlKey && (e.key === 'U' || e.keyCode === 85)) {
e.preventDefault();
return false;
}
// Ctrl+Shift+C (元素选择器)
if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.keyCode === 67)) {
e.preventDefault();
return false;
}
});
}
// 检测开发者工具是否打开(调试模式下不检测)
if (!isDebugMode) {
(function() {
const threshold = 160;
let isDevToolsOpen = false;
setInterval(function() {
const widthThreshold = window.outerWidth - window.innerWidth > threshold;
const heightThreshold = window.outerHeight - window.innerHeight > threshold;
const orientation = widthThreshold ? 'vertical' : 'horizontal';
if (!(heightThreshold && widthThreshold) &&
((window.Firebug && window.Firebug.chrome && window.Firebug.chrome.isInitialized) || widthThreshold || heightThreshold)) {
if (!isDevToolsOpen) {
isDevToolsOpen = true;
// 检测到开发者工具打开,可以选择清空页面或重定向
// document.body.innerHTML = '<h1 style="text-align:center;margin-top:20%;">请关闭开发者工具</h1>';
console.clear();
}
} else {
isDevToolsOpen = false;
}
}, 500);
})();
}
// 禁用console输出调试模式下不禁用
if (!isDebugMode && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
console.log = function() {};
console.info = function() {};
console.warn = function() {};
console.error = function() {};
console.debug = function() {};
}
</script>
</body>
</html>