feat: add share security, resumable upload, global search and reservation ops panel

This commit is contained in:
2026-02-17 23:36:30 +08:00
parent 3c75986566
commit 1a1c64c0e7
4 changed files with 2745 additions and 22 deletions

View File

@@ -1969,6 +1969,68 @@
</div>
</div>
</div>
<div style="margin: 10px 0 14px 0; position: relative;">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<input
type="text"
class="form-input"
v-model="globalSearchKeyword"
@input="triggerGlobalSearch"
@focus="globalSearchVisible = !!globalSearchKeyword"
placeholder="全局搜索文件名(跨全部目录)"
style="flex: 1; min-width: 220px;">
<select class="form-input" v-model="globalSearchType" @change="runGlobalSearch" style="width: 120px;">
<option value="all">全部</option>
<option value="file">仅文件</option>
<option value="directory">仅文件夹</option>
</select>
<button class="btn btn-secondary" @click="runGlobalSearch" :disabled="globalSearchLoading" style="min-width: 86px;">
<i :class="globalSearchLoading ? 'fas fa-spinner fa-spin' : 'fas fa-search'"></i>
搜索
</button>
<button class="btn btn-secondary" @click="clearGlobalSearch()" style="min-width: 72px;">
清空
</button>
</div>
<div v-if="globalSearchVisible" style="position: absolute; left: 0; right: 0; top: calc(100% + 8px); z-index: 30; background: var(--bg-card); border: 1px solid var(--glass-border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.22); max-height: 320px; overflow: auto;">
<div v-if="globalSearchLoading" style="padding: 12px; color: var(--text-secondary);">
<i class="fas fa-spinner fa-spin"></i> 正在搜索...
</div>
<div v-else-if="globalSearchError" style="padding: 12px; color: #ef4444;">
<i class="fas fa-circle-exclamation"></i> {{ globalSearchError }}
</div>
<div v-else-if="globalSearchResults.length === 0" style="padding: 12px; color: var(--text-secondary);">
暂无匹配结果
</div>
<div v-else>
<button
v-for="item in globalSearchResults"
:key="item.path"
class="btn"
@click="jumpToSearchResult(item)"
style="display: block; width: 100%; border: none; border-bottom: 1px solid var(--glass-border); border-radius: 0; text-align: left; background: transparent; padding: 10px 12px;">
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center;">
<div style="min-width: 0;">
<div style="font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<i class="fas" :class="item.isDirectory ? 'fa-folder' : 'fa-file'" style="margin-right: 6px; color: #667eea;"></i>
{{ item.name }}
</div>
<div style="font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ item.path }}
</div>
</div>
<div style="font-size: 12px; color: var(--text-muted); white-space: nowrap;">
{{ item.isDirectory ? '文件夹' : (item.sizeFormatted || '-') }}
</div>
</div>
</button>
<div v-if="globalSearchMeta?.truncated" style="padding: 10px 12px; font-size: 12px; color: #f59e0b;">
结果已截断,请缩小关键词范围
</div>
</div>
</div>
</div>
<div @dragenter="handleDragEnter" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop" class="files-container" :class="{ 'drag-over': isDragging }">
<div v-if="files.length === 0" class="empty-hint files-empty-state">
<i class="fas fa-folder-open"></i>
@@ -2209,6 +2271,49 @@
<label class="form-label">自定义天数</label>
<input type="number" class="form-input" v-model.number="shareFileForm.customDays" min="1" max="365">
</div>
<div class="form-group" style="margin-top: 12px;">
<label class="share-password-toggle">
<input type="checkbox" v-model="shareFileForm.enableAdvancedSecurity">
<span>{{ shareFileForm.enableAdvancedSecurity ? '已启用高级安全策略' : '高级安全策略(可选)' }}</span>
</label>
</div>
<div v-if="shareFileForm.enableAdvancedSecurity" style="padding: 12px; border: 1px dashed var(--glass-border); border-radius: 10px; margin-top: 8px;">
<div class="form-group" style="margin-bottom: 10px;">
<label class="share-password-toggle">
<input type="checkbox" v-model="shareFileForm.maxDownloadsEnabled">
<span>限制下载次数</span>
</label>
<input v-if="shareFileForm.maxDownloadsEnabled" type="number" class="form-input" v-model.number="shareFileForm.maxDownloads" min="1" max="1000000" style="margin-top: 8px;" placeholder="例如 10 次">
</div>
<div class="form-group" style="margin-bottom: 10px;">
<label class="form-label">IP 白名单(可选)</label>
<textarea class="form-input" v-model="shareFileForm.ipWhitelist" rows="2" placeholder="支持逗号/空格分隔例如1.2.3.4, 5.6.7.*"></textarea>
</div>
<div class="form-group" style="margin-bottom: 10px;">
<label class="form-label">设备限制</label>
<select class="form-input" v-model="shareFileForm.deviceLimit">
<option value="all">全部设备</option>
<option value="mobile">仅移动端</option>
<option value="desktop">仅桌面端</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0;">
<label class="share-password-toggle">
<input type="checkbox" v-model="shareFileForm.accessTimeEnabled">
<span>限制访问时段</span>
</label>
<div v-if="shareFileForm.accessTimeEnabled" style="display: flex; gap: 8px; margin-top: 8px;">
<input type="time" class="form-input" v-model="shareFileForm.accessTimeStart" style="flex: 1;">
<input type="time" class="form-input" v-model="shareFileForm.accessTimeEnd" style="flex: 1;">
</div>
</div>
</div>
<div v-if="shareResult" class="share-success-panel" style="margin-top: 15px;">
<div class="share-success-head">
<i class="fas fa-circle-check"></i>
@@ -3591,6 +3696,109 @@
</div>
</div>
<!-- 下载预扣运维面板 -->
<div class="card" style="margin-bottom: 30px;">
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px;">
<h3 style="margin: 0;"><i class="fas fa-gauge-high"></i> 下载预扣运维面板</h3>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button class="btn btn-secondary" @click="cleanupReservations" :disabled="reservationMonitor.cleaning">
<i :class="reservationMonitor.cleaning ? 'fas fa-spinner fa-spin' : 'fas fa-broom'"></i>
{{ reservationMonitor.cleaning ? '清理中...' : '清理历史' }}
</button>
<button class="btn btn-primary" @click="loadDownloadReservationMonitor(reservationMonitor.page)" :disabled="reservationMonitor.loading">
<i :class="reservationMonitor.loading ? 'fas fa-spinner fa-spin' : 'fas fa-sync'"></i>
刷新
</button>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px;">
<select class="form-input" v-model="reservationMonitor.filters.status" @change="loadDownloadReservationMonitor(1)" style="width: 140px;">
<option value="">全部状态</option>
<option value="pending">待确认</option>
<option value="confirmed">已确认</option>
<option value="expired">已过期</option>
<option value="cancelled">已取消</option>
</select>
<input class="form-input" v-model="reservationMonitor.filters.userId" @keyup.enter="loadDownloadReservationMonitor(1)" placeholder="用户ID" style="width: 120px;">
<input class="form-input" v-model="reservationMonitor.filters.keyword" @input="triggerReservationKeywordSearch" placeholder="用户名 / 对象Key / 来源" style="flex: 1; min-width: 220px;">
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; font-size: 12px;">
<span class="share-chip info">总数 {{ reservationMonitor.summary?.total || 0 }}</span>
<span class="share-chip warn">待确认 {{ reservationMonitor.summary?.pending || 0 }}</span>
<span class="share-chip success">已确认 {{ reservationMonitor.summary?.confirmed || 0 }}</span>
<span class="share-chip danger">已过期 {{ reservationMonitor.summary?.expired || 0 }}</span>
<span class="share-chip info">待确认剩余 {{ formatBytes(reservationMonitor.summary?.pending_remaining_bytes || 0) }}</span>
<span class="share-chip info">即将过期 {{ reservationMonitor.summary?.pending_expiring_soon || 0 }}</span>
</div>
<div v-if="reservationMonitor.loading" style="padding: 24px; text-align: center; color: var(--text-muted);">
<i class="fas fa-spinner fa-spin"></i> 正在加载预扣数据...
</div>
<div v-else-if="reservationMonitor.rows.length === 0" style="padding: 24px; text-align: center; color: var(--text-muted);">
暂无预扣记录
</div>
<div v-else style="overflow-x: auto;">
<table class="share-list-table" style="min-width: 920px;">
<thead>
<tr>
<th style="padding: 8px;">ID</th>
<th style="padding: 8px;">用户</th>
<th style="padding: 8px;">来源</th>
<th style="padding: 8px;">已预扣</th>
<th style="padding: 8px;">剩余</th>
<th style="padding: 8px;">状态</th>
<th style="padding: 8px;">到期</th>
<th style="padding: 8px;">对象</th>
<th style="padding: 8px;">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in reservationMonitor.rows" :key="row.id">
<td style="padding: 8px; text-align: center;">{{ row.id }}</td>
<td style="padding: 8px; text-align: center;">
<div>#{{ row.user_id }}</div>
<div style="font-size: 12px; color: var(--text-secondary);">{{ row.username || '-' }}</div>
</td>
<td style="padding: 8px; text-align: center;">{{ row.source || '-' }}</td>
<td style="padding: 8px; text-align: center;">{{ formatBytes(row.reserved_bytes || 0) }}</td>
<td style="padding: 8px; text-align: center;">{{ formatBytes(row.remaining_bytes || 0) }}</td>
<td style="padding: 8px; text-align: center; font-weight: 600;" :style="{ color: getReservationStatusColor(row.status) }">
{{ getReservationStatusText(row.status) }}
</td>
<td style="padding: 8px; text-align: center; white-space: nowrap;">{{ formatDate(row.expires_at) }}</td>
<td style="padding: 8px;">
<span style="display: inline-block; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="row.object_key || '-'">
{{ row.object_key || '-' }}
</span>
</td>
<td style="padding: 8px; text-align: center;">
<button v-if="row.status === 'pending'" class="btn btn-secondary" @click="cancelReservation(row)" style="padding: 4px 10px; font-size: 12px;">
<i class="fas fa-ban"></i> 释放
</button>
<span v-else style="color: var(--text-muted); font-size: 12px;">-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="reservationMonitor.totalPages > 1" style="margin-top: 12px; display: flex; justify-content: center; gap: 8px;">
<button class="btn btn-secondary" @click="changeReservationPage(reservationMonitor.page - 1)" :disabled="reservationMonitor.page <= 1">
上一页
</button>
<span style="display: flex; align-items: center; color: var(--text-secondary);">
{{ reservationMonitor.page }} / {{ reservationMonitor.totalPages }}
</span>
<button class="btn btn-secondary" @click="changeReservationPage(reservationMonitor.page + 1)" :disabled="reservationMonitor.page >= reservationMonitor.totalPages">
下一页
</button>
</div>
</div>
<!-- 系统日志 -->
<div class="card" style="margin-bottom: 30px;">
<div class="admin-log-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">

View File

@@ -103,7 +103,15 @@ createApp({
enablePassword: false,
password: "",
expiryType: "never",
customDays: 7
customDays: 7,
enableAdvancedSecurity: false,
maxDownloadsEnabled: false,
maxDownloads: 10,
ipWhitelist: '',
deviceLimit: 'all',
accessTimeEnabled: false,
accessTimeStart: '09:00',
accessTimeEnd: '23:00'
},
shareResult: null,
showDirectLinkModal: false,
@@ -150,6 +158,15 @@ createApp({
isDragging: false,
modalMouseDownTarget: null, // 模态框鼠标按下的目标
// 全局搜索(文件页)
globalSearchKeyword: '',
globalSearchType: 'all', // all/file/directory
globalSearchLoading: false,
globalSearchError: '',
globalSearchResults: [],
globalSearchMeta: null,
globalSearchVisible: false,
// 管理员
adminUsers: [],
adminUsersLoading: false,
@@ -246,6 +263,23 @@ createApp({
}
},
// 下载预扣运维面板(管理员-监控)
reservationMonitor: {
loading: false,
cleaning: false,
rows: [],
summary: null,
page: 1,
pageSize: 20,
total: 0,
totalPages: 1,
filters: {
status: 'pending',
keyword: '',
userId: ''
}
},
// 监控页整体加载遮罩(避免刷新时闪一下空态)
monitorTabLoading: initialAdminTab === 'monitor',
@@ -1542,6 +1576,7 @@ handleDragLeave(e) {
this.loading = true;
// 确保路径不为undefined
this.currentPath = path || '/';
this.globalSearchVisible = false;
try {
const response = await axios.get(`${this.apiBase}/api/files`, {
@@ -1580,6 +1615,77 @@ handleDragLeave(e) {
}
},
triggerGlobalSearch() {
const keyword = String(this.globalSearchKeyword || '').trim();
if (!keyword) {
this.clearGlobalSearch(false);
return;
}
if (!this._debouncedGlobalSearch) {
this._debouncedGlobalSearch = this.debounce(() => {
this.runGlobalSearch();
}, 260);
}
this._debouncedGlobalSearch();
},
async runGlobalSearch() {
const keyword = String(this.globalSearchKeyword || '').trim();
if (!keyword) {
this.clearGlobalSearch(false);
return;
}
this.globalSearchLoading = true;
this.globalSearchError = '';
this.globalSearchVisible = true;
try {
const response = await axios.get(`${this.apiBase}/api/files/search`, {
params: {
keyword,
path: '/',
type: this.globalSearchType || 'all',
limit: 80
}
});
if (response.data?.success) {
this.globalSearchResults = Array.isArray(response.data.items) ? response.data.items : [];
this.globalSearchMeta = response.data.meta || null;
} else {
this.globalSearchResults = [];
this.globalSearchMeta = null;
this.globalSearchError = response.data?.message || '搜索失败';
}
} catch (error) {
this.globalSearchResults = [];
this.globalSearchMeta = null;
this.globalSearchError = error.response?.data?.message || '搜索失败';
} finally {
this.globalSearchLoading = false;
}
},
clearGlobalSearch(clearKeyword = true) {
if (clearKeyword) {
this.globalSearchKeyword = '';
}
this.globalSearchLoading = false;
this.globalSearchError = '';
this.globalSearchResults = [];
this.globalSearchMeta = null;
this.globalSearchVisible = false;
},
async jumpToSearchResult(item) {
if (!item || !item.parent_path) return;
this.clearGlobalSearch(false);
await this.loadFiles(item.parent_path);
this.showToast('info', '已定位', `已定位到 ${item.name}`);
},
async handleFileClick(file) {
// 修复:长按后会触发一次 click需要忽略避免误打开文件/目录
if (this.longPressTriggered) {
@@ -2221,6 +2327,14 @@ handleDragLeave(e) {
this.shareFileForm.password = '';
this.shareFileForm.expiryType = 'never';
this.shareFileForm.customDays = 7;
this.shareFileForm.enableAdvancedSecurity = false;
this.shareFileForm.maxDownloadsEnabled = false;
this.shareFileForm.maxDownloads = 10;
this.shareFileForm.ipWhitelist = '';
this.shareFileForm.deviceLimit = 'all';
this.shareFileForm.accessTimeEnabled = false;
this.shareFileForm.accessTimeStart = '09:00';
this.shareFileForm.accessTimeEnd = '23:00';
this.shareResult = null; // 清空上次的分享结果
this.showShareFileModal = true;
},
@@ -2267,6 +2381,45 @@ handleDragLeave(e) {
return { valid: true, value: password };
},
buildShareSecurityPayload() {
if (!this.shareFileForm.enableAdvancedSecurity) {
return { valid: true, payload: {} };
}
const payload = {};
if (this.shareFileForm.maxDownloadsEnabled) {
const maxDownloads = Number(this.shareFileForm.maxDownloads);
if (!Number.isInteger(maxDownloads) || maxDownloads < 1 || maxDownloads > 1000000) {
return { valid: false, message: '下载次数上限需为 1 到 1000000 的整数' };
}
payload.max_downloads = maxDownloads;
}
const whitelistText = String(this.shareFileForm.ipWhitelist || '').trim();
if (whitelistText) {
payload.ip_whitelist = whitelistText;
}
if (!['all', 'mobile', 'desktop'].includes(this.shareFileForm.deviceLimit)) {
return { valid: false, message: '设备限制参数无效' };
}
payload.device_limit = this.shareFileForm.deviceLimit;
if (this.shareFileForm.accessTimeEnabled) {
const start = String(this.shareFileForm.accessTimeStart || '').trim();
const end = String(this.shareFileForm.accessTimeEnd || '').trim();
const timeReg = /^([01]\d|2[0-3]):([0-5]\d)$/;
if (!timeReg.test(start) || !timeReg.test(end)) {
return { valid: false, message: '访问时段格式必须为 HH:mm' };
}
payload.access_time_start = start;
payload.access_time_end = end;
}
return { valid: true, payload };
},
buildShareResult(data, options = {}) {
return {
...data,
@@ -2295,19 +2448,24 @@ handleDragLeave(e) {
const expiryDays = expiryCheck.value;
const password = passwordCheck.value;
const securityCheck = this.buildShareSecurityPayload();
if (!securityCheck.valid) {
this.showToast('warning', '提示', securityCheck.message);
return;
}
// 根据是否为文件夹决定share_type
const shareType = this.shareFileForm.isDirectory ? 'directory' : 'file';
const response = await axios.post(
`${this.apiBase}/api/share/create`,
{
Object.assign({
share_type: shareType, // 修复文件夹使用directory类型
file_path: this.shareFileForm.filePath,
file_name: this.shareFileForm.fileName,
password,
expiry_days: expiryDays
},
}, securityCheck.payload),
);
if (response.data.success) {
@@ -2421,12 +2579,27 @@ handleDragLeave(e) {
this.totalBytes = file.size;
try {
const fileHash = await this.computeQuickFileHash(file);
const instantUploaded = await this.checkInstantUpload(file, fileHash);
if (instantUploaded) {
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
return;
}
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') {
// ===== OSS 直连上传(不经过后端) =====
await this.uploadToOSSDirect(file);
await this.uploadToOSSDirect(file, fileHash);
} else {
// ===== 本地存储上传(经过后端 =====
await this.uploadToLocal(file);
// ===== 本地存储优先分片上传(断点续传 =====
const resumableOk = await this.uploadToLocalResumable(file, fileHash);
if (!resumableOk) {
await this.uploadToLocal(file, fileHash);
}
}
} catch (error) {
console.error('上传失败:', error);
@@ -2441,8 +2614,85 @@ handleDragLeave(e) {
}
},
async computeQuickFileHash(file) {
try {
if (!file || !window.crypto?.subtle) {
return null;
}
const sampleSize = 2 * 1024 * 1024; // 2MB
const fullHashLimit = 8 * 1024 * 1024; // 8MB
const chunks = [];
if (file.size <= fullHashLimit) {
chunks.push(new Uint8Array(await file.arrayBuffer()));
} else {
const first = await file.slice(0, sampleSize).arrayBuffer();
const middleStart = Math.max(0, Math.floor(file.size / 2) - Math.floor(sampleSize / 2));
const middle = await file.slice(middleStart, middleStart + sampleSize).arrayBuffer();
const lastStart = Math.max(0, file.size - sampleSize);
const last = await file.slice(lastStart).arrayBuffer();
const meta = new TextEncoder().encode(`${file.name}|${file.size}|${file.lastModified}`);
chunks.push(
new Uint8Array(first),
new Uint8Array(middle),
new Uint8Array(last),
meta
);
}
const totalLength = chunks.reduce((sum, arr) => sum + arr.length, 0);
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const arr of chunks) {
merged.set(arr, offset);
offset += arr.length;
}
const digest = await window.crypto.subtle.digest('SHA-256', merged.buffer);
const hashHex = Array.from(new Uint8Array(digest))
.map(byte => byte.toString(16).padStart(2, '0'))
.join('');
return hashHex || null;
} catch (error) {
console.warn('计算文件哈希失败,跳过秒传:', error);
return null;
}
},
async checkInstantUpload(file, fileHash) {
if (!file || !fileHash) {
return false;
}
try {
const response = await axios.post(`${this.apiBase}/api/files/instant-upload/check`, {
filename: file.name,
path: this.currentPath,
size: file.size,
file_hash: fileHash
});
if (response.data?.success && response.data.instant) {
this.showToast('success', '秒传成功', response.data.message || `文件 ${file.name} 已秒传`);
return true;
}
return false;
} catch (error) {
const status = Number(error.response?.status || 0);
const message = error.response?.data?.message;
// 4xx 视为明确业务失败直接抛给外层5xx/网络错误降级为普通上传
if (status >= 400 && status < 500 && message) {
throw new Error(message);
}
console.warn('秒传检查失败,降级普通上传:', error);
return false;
}
},
// OSS 直连上传
async uploadToOSSDirect(file) {
async uploadToOSSDirect(file, fileHash = null) {
try {
// 预检查 OSS 配额(后端也会做强校验,未配置默认 1GB
const ossQuota = Number(this.user?.oss_storage_quota || DEFAULT_OSS_STORAGE_QUOTA_BYTES);
@@ -2458,7 +2708,8 @@ handleDragLeave(e) {
filename: file.name,
path: this.currentPath,
contentType: file.type || 'application/octet-stream',
size: file.size
size: file.size,
fileHash: fileHash || undefined
}
});
@@ -2513,8 +2764,101 @@ handleDragLeave(e) {
}
},
// 本地分片上传(断点续传)
async uploadToLocalResumable(file, fileHash = null) {
// 本地存储配额预检查
const estimatedUsage = this.localUsed + file.size;
if (estimatedUsage > this.localQuota) {
this.showToast(
'error',
'配额不足',
`文件大小 ${this.formatBytes(file.size)},剩余配额 ${this.formatBytes(this.localQuota - this.localUsed)}`
);
return true;
}
try {
const initResponse = await axios.post(`${this.apiBase}/api/upload/resumable/init`, {
filename: file.name,
path: this.currentPath,
size: file.size,
chunk_size: 4 * 1024 * 1024,
file_hash: fileHash || undefined
});
if (!initResponse.data?.success) {
throw new Error(initResponse.data?.message || '初始化分片上传失败');
}
const sessionId = initResponse.data.session_id;
const chunkSize = Number(initResponse.data.chunk_size || 4 * 1024 * 1024);
const totalChunks = Number(initResponse.data.total_chunks || Math.ceil(file.size / chunkSize));
const uploadedSet = new Set(Array.isArray(initResponse.data.uploaded_chunks) ? initResponse.data.uploaded_chunks : []);
let uploadedBytes = Number(initResponse.data.uploaded_bytes || 0);
if (uploadedBytes > 0 && file.size > 0) {
this.uploadedBytes = uploadedBytes;
this.uploadProgress = Math.min(100, Math.round((uploadedBytes / file.size) * 100));
}
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex += 1) {
if (uploadedSet.has(chunkIndex)) {
continue;
}
const start = chunkIndex * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const chunkBlob = file.slice(start, end);
const formData = new FormData();
formData.append('session_id', sessionId);
formData.append('chunk_index', String(chunkIndex));
formData.append('chunk', chunkBlob, `${file.name}.part${chunkIndex}`);
const chunkResp = await axios.post(`${this.apiBase}/api/upload/resumable/chunk`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 30 * 60 * 1000
});
if (!chunkResp.data?.success) {
throw new Error(chunkResp.data?.message || '上传分片失败');
}
uploadedBytes = Number(chunkResp.data.uploaded_bytes || uploadedBytes + chunkBlob.size);
this.uploadedBytes = uploadedBytes;
this.totalBytes = file.size;
this.uploadProgress = file.size > 0
? Math.min(100, Math.round((uploadedBytes / file.size) * 100))
: 0;
}
const completeResp = await axios.post(`${this.apiBase}/api/upload/resumable/complete`, {
session_id: sessionId
});
if (!completeResp.data?.success) {
throw new Error(completeResp.data?.message || '完成分片上传失败');
}
this.showToast('success', '上传成功', `文件 ${file.name} 已上传`);
this.uploadProgress = 0;
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
return true;
} catch (error) {
if (error.response?.status === 404) {
// 后端未启用分片接口时自动降级
return false;
}
throw error;
}
},
// 本地存储上传(经过后端)
async uploadToLocal(file) {
async uploadToLocal(file, fileHash = null) {
// 本地存储配额预检查
const estimatedUsage = this.localUsed + file.size;
if (estimatedUsage > this.localQuota) {
@@ -2527,12 +2871,15 @@ handleDragLeave(e) {
this.uploadedBytes = 0;
this.totalBytes = 0;
this.uploadingFileName = '';
return;
return true;
}
const formData = new FormData();
formData.append('file', file);
formData.append('path', this.currentPath);
if (fileHash) {
formData.append('file_hash', fileHash);
}
const response = await axios.post(`${this.apiBase}/api/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -2553,6 +2900,7 @@ handleDragLeave(e) {
await this.loadFiles(this.currentPath);
await this.refreshStorageUsage();
}
return true;
},
// ===== 分享管理 =====
@@ -3940,11 +4288,13 @@ handleDragLeave(e) {
this.monitorTabLoading = true;
this.healthCheck.loading = true;
this.systemLogs.loading = true;
this.reservationMonitor.loading = true;
try {
await Promise.all([
this.loadHealthCheck(),
this.loadSystemLogs(1)
this.loadSystemLogs(1),
this.loadDownloadReservationMonitor(1)
]);
} catch (e) {
// 子方法内部已处理错误
@@ -4128,6 +4478,106 @@ handleDragLeave(e) {
}
},
async loadDownloadReservationMonitor(page = this.reservationMonitor.page) {
this.reservationMonitor.loading = true;
try {
const response = await axios.get(`${this.apiBase}/api/admin/download-reservations`, {
params: {
page: Math.max(1, Number(page) || 1),
pageSize: this.reservationMonitor.pageSize,
status: this.reservationMonitor.filters.status || undefined,
keyword: (this.reservationMonitor.filters.keyword || '').trim() || undefined,
user_id: (this.reservationMonitor.filters.userId || '').trim() || undefined
}
});
if (response.data?.success) {
const rows = Array.isArray(response.data.reservations) ? response.data.reservations : [];
const pagination = response.data.pagination || {};
this.reservationMonitor.rows = rows;
this.reservationMonitor.summary = response.data.summary || null;
this.reservationMonitor.total = Number(pagination.total || rows.length);
this.reservationMonitor.totalPages = Number(pagination.totalPages || 1);
this.reservationMonitor.page = Number(pagination.page || page || 1);
}
} catch (error) {
console.error('加载预扣监控失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '加载预扣监控失败');
} finally {
this.reservationMonitor.loading = false;
}
},
triggerReservationKeywordSearch() {
this.reservationMonitor.page = 1;
if (!this._debouncedReservationQuery) {
this._debouncedReservationQuery = this.debounce(() => {
this.loadDownloadReservationMonitor(1);
}, 260);
}
this._debouncedReservationQuery();
},
async changeReservationPage(nextPage) {
const page = Math.max(1, Number(nextPage) || 1);
if (page === this.reservationMonitor.page) return;
await this.loadDownloadReservationMonitor(page);
},
getReservationStatusText(status) {
if (status === 'pending') return '待确认';
if (status === 'confirmed') return '已确认';
if (status === 'expired') return '已过期';
if (status === 'cancelled') return '已取消';
return status || '-';
},
getReservationStatusColor(status) {
if (status === 'pending') return '#f59e0b';
if (status === 'confirmed') return '#22c55e';
if (status === 'expired') return '#ef4444';
if (status === 'cancelled') return '#94a3b8';
return 'var(--text-secondary)';
},
async cancelReservation(row) {
if (!row || !row.id) return;
if (!confirm(`确认释放预扣 #${row.id} 吗?`)) return;
try {
const response = await axios.post(`${this.apiBase}/api/admin/download-reservations/${row.id}/cancel`);
if (response.data?.success) {
this.showToast('success', '成功', response.data.message || '预扣额度已释放');
await this.loadDownloadReservationMonitor(this.reservationMonitor.page);
}
} catch (error) {
console.error('释放预扣失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '释放预扣失败');
}
},
async cleanupReservations() {
if (this.reservationMonitor.cleaning) return;
if (!confirm('确认清理过期/历史预扣记录吗?')) return;
this.reservationMonitor.cleaning = true;
try {
const response = await axios.post(`${this.apiBase}/api/admin/download-reservations/cleanup`, {
keep_days: 7
});
if (response.data?.success) {
this.showToast('success', '成功', response.data.message || '预扣清理完成');
await this.loadDownloadReservationMonitor(1);
}
} catch (error) {
console.error('清理预扣失败:', error);
this.showToast('error', '错误', error.response?.data?.message || '清理预扣失败');
} finally {
this.reservationMonitor.cleaning = false;
}
},
// ===== 调试模式管理 =====
// 切换调试模式