feat: add user download traffic reports and restore OSS direct downloads
This commit is contained in:
@@ -2330,6 +2330,163 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 下载流量额度与统计 -->
|
||||
<div v-if="user && !user.is_admin" class="settings-section settings-download-traffic" style="margin-bottom: 40px;">
|
||||
<h3 class="settings-section-title" style="margin-bottom: 20px;">
|
||||
<i class="fas fa-tachometer-alt"></i> 下载流量额度与统计
|
||||
</h3>
|
||||
<div class="settings-panel" style="background: var(--bg-card); border: 1px solid var(--glass-border); border-radius: 12px; padding: 18px;">
|
||||
<div style="display: flex; justify-content: space-between; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px;">
|
||||
<div style="font-size: 13px; color: var(--text-secondary);">
|
||||
管理员设置的下载流量限制会在这里实时展示,单位自动按 B/KB/MB/GB/TB 切换
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
style="padding: 5px 10px; font-size: 12px;"
|
||||
:class="{ 'btn-primary': downloadTrafficReport.days === 7 }"
|
||||
@click="setDownloadTrafficReportDays(7)">
|
||||
近7天
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
style="padding: 5px 10px; font-size: 12px;"
|
||||
:class="{ 'btn-primary': downloadTrafficReport.days === 30 }"
|
||||
@click="setDownloadTrafficReportDays(30)">
|
||||
近30天
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
style="padding: 5px 10px; font-size: 12px;"
|
||||
:class="{ 'btn-primary': downloadTrafficReport.days === 90 }"
|
||||
@click="setDownloadTrafficReportDays(90)">
|
||||
近90天
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
style="padding: 5px 10px; font-size: 12px;"
|
||||
:class="{ 'btn-primary': downloadTrafficReport.days === 180 }"
|
||||
@click="setDownloadTrafficReportDays(180)">
|
||||
近180天
|
||||
</button>
|
||||
<button class="btn btn-secondary" style="padding: 5px 10px; font-size: 12px;" @click="loadDownloadTrafficReport(downloadTrafficReport.days)" :disabled="downloadTrafficReport.loading">
|
||||
<i :class="downloadTrafficReport.loading ? 'fas fa-sync-alt fa-spin' : 'fas fa-sync-alt'"></i>
|
||||
{{ downloadTrafficReport.loading ? '加载中...' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="downloadTrafficReport.error" style="margin-bottom: 10px; padding: 10px 12px; border-radius: 8px; background: rgba(239,68,68,0.1); color: #ef4444; font-size: 13px;">
|
||||
<i class="fas fa-exclamation-triangle"></i> {{ downloadTrafficReport.error }}
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px;">
|
||||
<div style="padding: 12px; border-radius: 10px; background: rgba(59,130,246,0.1); border: 1px solid rgba(59,130,246,0.25);">
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">当前限制</div>
|
||||
<div style="font-size: 18px; font-weight: 700; color: #3b82f6;">
|
||||
{{ downloadTrafficIsUnlimited ? '不限' : formatBytes(downloadTrafficQuotaBytes) }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px; border-radius: 10px; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.28);">
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">已使用</div>
|
||||
<div style="font-size: 18px; font-weight: 700; color: #f59e0b;">
|
||||
{{ formatBytes(downloadTrafficUsedBytes) }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px; border-radius: 10px; background: rgba(16,185,129,0.1); border: 1px solid rgba(16,185,129,0.26);">
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">剩余可用</div>
|
||||
<div style="font-size: 18px; font-weight: 700; color: #10b981;">
|
||||
{{ downloadTrafficIsUnlimited ? '不限' : formatBytes(downloadTrafficRemainingBytes || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px; border-radius: 10px; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.28);">
|
||||
<div style="font-size: 12px; color: var(--text-muted); margin-bottom: 6px;">策略</div>
|
||||
<div style="font-size: 14px; font-weight: 700; color: #6366f1;">
|
||||
{{ getDownloadResetCycleText(downloadTrafficResetCycle) }}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">
|
||||
到期: {{ downloadTrafficExpiresAt ? formatDate(downloadTrafficExpiresAt) : '无' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!downloadTrafficIsUnlimited" style="margin-bottom: 12px;">
|
||||
<div style="display: flex; justify-content: space-between; font-size: 12px; color: var(--text-secondary); margin-bottom: 6px;">
|
||||
<span>额度使用率</span>
|
||||
<span>{{ downloadTrafficUsagePercentage }}%</span>
|
||||
</div>
|
||||
<div style="height: 10px; background: rgba(148,163,184,0.24); border-radius: 999px; overflow: hidden;">
|
||||
<div :style="{ width: downloadTrafficUsagePercentage + '%', height: '100%', background: downloadTrafficUsagePercentage > 90 ? '#ef4444' : (downloadTrafficUsagePercentage > 75 ? '#f59e0b' : '#22c55e') }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-bottom: 12px;">
|
||||
<div style="padding: 10px; border-radius: 8px; background: var(--bg-secondary); border: 1px solid var(--glass-border);">
|
||||
<div style="font-size: 12px; color: var(--text-muted);">今天使用</div>
|
||||
<div style="font-weight: 700; color: var(--text-primary); margin-top: 4px;">
|
||||
{{ formatBytes(downloadTrafficReport.summary?.today?.bytes_used || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 10px; border-radius: 8px; background: var(--bg-secondary); border: 1px solid var(--glass-border);">
|
||||
<div style="font-size: 12px; color: var(--text-muted);">近7天</div>
|
||||
<div style="font-weight: 700; color: var(--text-primary); margin-top: 4px;">
|
||||
{{ formatBytes(downloadTrafficReport.summary?.last_7_days?.bytes_used || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 10px; border-radius: 8px; background: var(--bg-secondary); border: 1px solid var(--glass-border);">
|
||||
<div style="font-size: 12px; color: var(--text-muted);">近30天</div>
|
||||
<div style="font-weight: 700; color: var(--text-primary); margin-top: 4px;">
|
||||
{{ formatBytes(downloadTrafficReport.summary?.last_30_days?.bytes_used || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 10px; border-radius: 8px; background: var(--bg-secondary); border: 1px solid var(--glass-border);">
|
||||
<div style="font-size: 12px; color: var(--text-muted);">所选区间</div>
|
||||
<div style="font-weight: 700; color: var(--text-primary); margin-top: 4px;">
|
||||
{{ formatBytes(downloadTrafficReport.summary?.selected_range?.bytes_used || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 10px; border-radius: 8px; background: var(--bg-secondary); border: 1px solid var(--glass-border);">
|
||||
<div style="font-size: 12px; color: var(--text-muted);">历史累计</div>
|
||||
<div style="font-weight: 700; color: var(--text-primary); margin-top: 4px;">
|
||||
{{ formatBytes(downloadTrafficReport.summary?.all_time?.bytes_used || 0) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border: 1px solid var(--glass-border); border-radius: 10px; overflow: hidden;">
|
||||
<div style="padding: 10px 12px; background: var(--bg-secondary); border-bottom: 1px solid var(--glass-border); font-size: 13px; color: var(--text-secondary); display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>按天用量明细(最近 {{ downloadTrafficReport.days }} 天)</span>
|
||||
<span v-if="downloadTrafficReport.summary?.peak_day">峰值: {{ formatReportDateLabel(downloadTrafficReport.summary.peak_day.date) }} / {{ formatBytes(downloadTrafficReport.summary.peak_day.bytes_used || 0) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="downloadTrafficReport.loading && downloadTrafficDailyRowsDesc.length === 0" style="padding: 16px; text-align: center; color: var(--text-muted);">
|
||||
<i class="fas fa-spinner fa-spin"></i> 报表加载中...
|
||||
</div>
|
||||
<div v-else-if="downloadTrafficDailyRowsDesc.length === 0" style="padding: 16px; text-align: center; color: var(--text-muted);">
|
||||
暂无下载流量记录
|
||||
</div>
|
||||
<div v-else style="max-height: 280px; overflow: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="background: rgba(148,163,184,0.12);">
|
||||
<th style="padding: 10px; text-align: left; font-size: 12px; color: var(--text-secondary);">日期</th>
|
||||
<th style="padding: 10px; text-align: right; font-size: 12px; color: var(--text-secondary);">下载流量</th>
|
||||
<th style="padding: 10px; text-align: right; font-size: 12px; color: var(--text-secondary);">下载次数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in downloadTrafficDailyRowsDesc" :key="row.date" style="border-top: 1px solid var(--glass-border);">
|
||||
<td style="padding: 9px 10px; color: var(--text-primary); font-size: 13px;">{{ formatReportDateLabel(row.date) }}</td>
|
||||
<td style="padding: 9px 10px; text-align: right; color: var(--text-primary); font-size: 13px; font-weight: 600;">{{ formatBytes(row.bytes_used || 0) }}</td>
|
||||
<td style="padding: 9px 10px; text-align: right; color: var(--text-secondary); font-size: 13px;">{{ row.download_count || 0 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 界面设置 -->
|
||||
<h3 class="settings-section-title settings-section-gap" style="margin: 40px 0 20px 0;"><i class="fas fa-palette"></i> 界面设置</h3>
|
||||
|
||||
194
frontend/app.js
194
frontend/app.js
@@ -311,6 +311,17 @@ createApp({
|
||||
ossUsageLoading: false,
|
||||
ossUsageError: null,
|
||||
|
||||
// 下载流量报表
|
||||
downloadTrafficReport: {
|
||||
days: 30,
|
||||
loading: false,
|
||||
error: null,
|
||||
quota: null,
|
||||
daily: [],
|
||||
summary: null,
|
||||
lastUpdatedAt: null
|
||||
},
|
||||
|
||||
// 主题设置
|
||||
currentTheme: 'dark', // 当前生效的主题: 'dark' 或 'light'
|
||||
globalTheme: 'dark', // 全局默认主题(管理员设置)
|
||||
@@ -374,6 +385,80 @@ createApp({
|
||||
return Math.min(100, Math.round((this.ossUsedBytes / this.ossQuotaBytes) * 100));
|
||||
},
|
||||
|
||||
downloadTrafficQuotaBytes() {
|
||||
const reportQuota = Number(this.downloadTrafficReport?.quota?.quota);
|
||||
if (Number.isFinite(reportQuota) && reportQuota >= 0) {
|
||||
return reportQuota;
|
||||
}
|
||||
const userQuota = Number(this.user?.download_traffic_quota || 0);
|
||||
return Number.isFinite(userQuota) && userQuota > 0 ? Math.floor(userQuota) : 0;
|
||||
},
|
||||
|
||||
downloadTrafficUsedBytes() {
|
||||
const reportUsed = Number(this.downloadTrafficReport?.quota?.used);
|
||||
if (Number.isFinite(reportUsed) && reportUsed >= 0) {
|
||||
return Math.floor(reportUsed);
|
||||
}
|
||||
const userUsed = Number(this.user?.download_traffic_used || 0);
|
||||
return Number.isFinite(userUsed) && userUsed > 0 ? Math.floor(userUsed) : 0;
|
||||
},
|
||||
|
||||
downloadTrafficIsUnlimited() {
|
||||
if (this.downloadTrafficReport?.quota?.is_unlimited === true) {
|
||||
return true;
|
||||
}
|
||||
return this.downloadTrafficQuotaBytes <= 0;
|
||||
},
|
||||
|
||||
downloadTrafficRemainingBytes() {
|
||||
if (this.downloadTrafficIsUnlimited) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reportRemaining = Number(this.downloadTrafficReport?.quota?.remaining);
|
||||
if (Number.isFinite(reportRemaining) && reportRemaining >= 0) {
|
||||
return Math.floor(reportRemaining);
|
||||
}
|
||||
|
||||
return Math.max(0, this.downloadTrafficQuotaBytes - this.downloadTrafficUsedBytes);
|
||||
},
|
||||
|
||||
downloadTrafficUsagePercentage() {
|
||||
if (this.downloadTrafficIsUnlimited) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const reportPercentage = Number(this.downloadTrafficReport?.quota?.usage_percentage);
|
||||
if (Number.isFinite(reportPercentage) && reportPercentage >= 0) {
|
||||
return Math.min(100, Math.round(reportPercentage));
|
||||
}
|
||||
|
||||
if (this.downloadTrafficQuotaBytes <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.round((this.downloadTrafficUsedBytes / this.downloadTrafficQuotaBytes) * 100));
|
||||
},
|
||||
|
||||
downloadTrafficResetCycle() {
|
||||
return this.downloadTrafficReport?.quota?.reset_cycle
|
||||
|| this.user?.download_traffic_reset_cycle
|
||||
|| 'none';
|
||||
},
|
||||
|
||||
downloadTrafficExpiresAt() {
|
||||
return this.downloadTrafficReport?.quota?.expires_at
|
||||
|| this.user?.download_traffic_quota_expires_at
|
||||
|| null;
|
||||
},
|
||||
|
||||
downloadTrafficDailyRowsDesc() {
|
||||
const rows = Array.isArray(this.downloadTrafficReport?.daily)
|
||||
? this.downloadTrafficReport.daily
|
||||
: [];
|
||||
return [...rows].reverse();
|
||||
},
|
||||
|
||||
// 存储类型显示文本
|
||||
storageTypeText() {
|
||||
return this.storageType === 'local' ? '本地存储' : 'OSS存储';
|
||||
@@ -1460,17 +1545,50 @@ handleDragLeave(e) {
|
||||
this.loadFiles(newPath);
|
||||
},
|
||||
|
||||
downloadFile(file) {
|
||||
async downloadFile(file) {
|
||||
// 构建文件路径
|
||||
const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`;
|
||||
|
||||
// 统一走后端下载接口,确保下载流量可精确计量
|
||||
const hasDownloadTrafficLimit = Number(this.user?.download_traffic_quota || 0) > 0;
|
||||
const canDirectOssDownload = this.storageType === 'oss'
|
||||
&& this.user?.oss_config_source !== 'none'
|
||||
&& !hasDownloadTrafficLimit;
|
||||
|
||||
// OSS 且未启用下载限流:优先使用 OSS 直连下载(速度更快)
|
||||
if (canDirectOssDownload) {
|
||||
const directResult = await this.downloadFromOSS(filePath);
|
||||
if (directResult) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 其他场景走后端下载接口(支持下载流量计量/权限控制)
|
||||
this.downloadFromLocal(filePath);
|
||||
},
|
||||
|
||||
// 保留方法名兼容旧调用,内部统一转发到后端下载
|
||||
downloadFromOSS(filePath) {
|
||||
this.downloadFromLocal(filePath);
|
||||
async downloadFromOSS(filePath) {
|
||||
try {
|
||||
const response = await axios.get(`${this.apiBase}/api/files/download-url`, {
|
||||
params: { path: filePath }
|
||||
});
|
||||
|
||||
if (!response.data?.success || !response.data?.downloadUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = response.data.downloadUrl;
|
||||
link.setAttribute('download', '');
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('OSS直连下载失败,将回退到后端下载:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 本地存储下载
|
||||
@@ -2731,6 +2849,67 @@ handleDragLeave(e) {
|
||||
}
|
||||
},
|
||||
|
||||
async loadDownloadTrafficReport(days = this.downloadTrafficReport.days) {
|
||||
if (!this.isLoggedIn || !this.user || this.user.is_admin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedDays = [7, 30, 90, 180];
|
||||
const normalizedDays = allowedDays.includes(Number(days)) ? Number(days) : 30;
|
||||
|
||||
this.downloadTrafficReport.days = normalizedDays;
|
||||
this.downloadTrafficReport.loading = true;
|
||||
this.downloadTrafficReport.error = null;
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${this.apiBase}/api/user/download-traffic-report?days=${normalizedDays}`,
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
const quota = response.data.quota || null;
|
||||
const report = response.data.report || {};
|
||||
this.downloadTrafficReport.quota = quota;
|
||||
this.downloadTrafficReport.daily = Array.isArray(report.daily) ? report.daily : [];
|
||||
this.downloadTrafficReport.summary = report.summary || null;
|
||||
this.downloadTrafficReport.lastUpdatedAt = new Date().toISOString();
|
||||
|
||||
// 同步到 user 对象,保证文件页/设置页显示一致
|
||||
if (this.user && quota) {
|
||||
this.user.download_traffic_quota = Number(quota.quota || 0);
|
||||
this.user.download_traffic_used = Number(quota.used || 0);
|
||||
this.user.download_traffic_reset_cycle = quota.reset_cycle || 'none';
|
||||
this.user.download_traffic_quota_expires_at = quota.expires_at || null;
|
||||
this.user.download_traffic_last_reset_at = quota.last_reset_at || null;
|
||||
}
|
||||
} else {
|
||||
this.downloadTrafficReport.error = response.data.message || '获取报表失败';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取下载流量报表失败:', error);
|
||||
this.downloadTrafficReport.error = error.response?.data?.message || '获取报表失败';
|
||||
} finally {
|
||||
this.downloadTrafficReport.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
setDownloadTrafficReportDays(days) {
|
||||
const nextDays = Number(days);
|
||||
if (this.downloadTrafficReport.loading || nextDays === this.downloadTrafficReport.days) {
|
||||
return;
|
||||
}
|
||||
this.loadDownloadTrafficReport(nextDays);
|
||||
},
|
||||
|
||||
formatReportDateLabel(dateKey) {
|
||||
if (!dateKey) return '-';
|
||||
const match = String(dateKey).match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (match) {
|
||||
return `${match[2]}-${match[3]}`;
|
||||
}
|
||||
return dateKey;
|
||||
},
|
||||
|
||||
// 刷新存储空间使用统计(根据当前存储类型)
|
||||
async refreshStorageUsage() {
|
||||
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') {
|
||||
@@ -2894,7 +3073,9 @@ handleDragLeave(e) {
|
||||
}
|
||||
break;
|
||||
case 'settings':
|
||||
// 设置页面不需要额外加载数据
|
||||
if (this.user && !this.user.is_admin) {
|
||||
this.loadDownloadTrafficReport();
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
@@ -3612,6 +3793,7 @@ handleDragLeave(e) {
|
||||
} else if (newView === 'settings' && this.user && !this.user.is_admin) {
|
||||
// 普通用户进入设置页面时加载OSS配置
|
||||
this.loadOssConfig();
|
||||
this.loadDownloadTrafficReport();
|
||||
}
|
||||
|
||||
// 记住最后停留的视图(需合法且已登录)
|
||||
|
||||
Reference in New Issue
Block a user