feat: add share security, resumable upload, global search and reservation ops panel
This commit is contained in:
472
frontend/app.js
472
frontend/app.js
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
// ===== 调试模式管理 =====
|
||||
|
||||
// 切换调试模式
|
||||
|
||||
Reference in New Issue
Block a user