feat(quota): add downloadable traffic quota with local/OSS/share metering

This commit is contained in:
2026-02-17 16:52:26 +08:00
parent b0e89df5c4
commit 2629237f9e
5 changed files with 750 additions and 84 deletions

View File

@@ -3118,18 +3118,19 @@
<div class="card">
<h3 style="margin-bottom: 20px;">用户管理</h3>
<div class="admin-users-table-wrap" style="overflow-x: auto;">
<table class="admin-users-table" style="width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 760px;">
<table class="admin-users-table" style="width: 100%; border-collapse: collapse; table-layout: fixed; min-width: 900px;">
<thead>
<tr style="background: rgba(255,255,255,0.05);">
<th style="padding: 10px; text-align: left; width: 4%;">ID</th>
<th style="padding: 10px; text-align: left; width: 10%;">用户名</th>
<th style="padding: 10px; text-align: center; width: 10%;">角色</th>
<th style="padding: 10px; text-align: left; width: 9%;">用户名</th>
<th style="padding: 10px; text-align: center; width: 9%;">角色</th>
<th style="padding: 10px; text-align: left; width: 14%;">邮箱</th>
<th style="padding: 10px; text-align: center; width: 9%;">存储权限</th>
<th style="padding: 10px; text-align: center; width: 9%;">当前存储</th>
<th style="padding: 10px; text-align: center; width: 12%;">配额使用</th>
<th style="padding: 10px; text-align: center; width: 8%;">状态</th>
<th style="padding: 10px; text-align: center; width: 24%;">操作</th>
<th style="padding: 10px; text-align: center; width: 8%;">存储权限</th>
<th style="padding: 10px; text-align: center; width: 8%;">当前存储</th>
<th style="padding: 10px; text-align: center; width: 11%;">存储配额</th>
<th style="padding: 10px; text-align: center; width: 11%;">下载流量</th>
<th style="padding: 10px; text-align: center; width: 7%;">状态</th>
<th style="padding: 10px; text-align: center; width: 19%;">操作</th>
</tr>
</thead>
<tbody>
@@ -3176,6 +3177,20 @@
</div>
</div>
</td>
<td style="padding: 10px; text-align: center; font-size: 12px;">
<div v-if="u.download_traffic_quota > 0">
<div>{{ formatBytes(u.download_traffic_used || 0) }} / {{ formatBytes(u.download_traffic_quota) }}</div>
<div style="font-size: 11px; color: var(--text-muted);">
{{ getAdminUserDownloadQuotaPercentage(u) }}%
</div>
</div>
<div v-else>
<div>{{ formatBytes(u.download_traffic_used || 0) }} / 不限</div>
<div style="font-size: 11px; color: var(--text-muted);">
不限
</div>
</div>
</td>
<td style="padding: 10px; text-align: center;">
<span v-if="u.is_banned" style="color: #ef4444; font-weight: 600;">已封禁</span>
<span v-else-if="!u.is_verified" style="color: #f59e0b; font-weight: 600;">未激活</span>
@@ -3517,13 +3532,43 @@
</small>
</div>
<div class="form-group">
<label class="form-label">下载流量配额</label>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<label style="display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary);">
<input type="checkbox" v-model="editStorageForm.download_quota_unlimited">
不限流量
</label>
</div>
<div v-if="!editStorageForm.download_quota_unlimited" style="display: flex; gap: 10px;">
<input
type="number"
class="form-input"
v-model.number="editStorageForm.download_traffic_quota_value"
min="1"
max="10240"
step="1"
style="flex: 1;">
<select class="form-input" v-model="editStorageForm.download_quota_unit" style="width: 100px;">
<option value="MB">MB</option>
<option value="GB">GB</option>
<option value="TB">TB</option>
</select>
</div>
<small style="color: var(--text-secondary); font-size: 12px; margin-top: 5px; display: block;">
下载流量范围: 不限 或 1MB - 10TB按实际下载字节扣减
</small>
</div>
<div style="padding: 12px; background: rgba(255,255,255,0.03); border-radius: 6px; margin-bottom: 20px;">
<div style="font-size: 13px; color: var(--text-secondary); line-height: 1.6;">
<strong style="color: var(--text-primary);">配额说明:</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>
• 当前 OSS 配额: {{ editStorageForm.oss_storage_quota_value + ' ' + editStorageForm.oss_quota_unit }}
• 当前 OSS 配额: {{ editStorageForm.oss_storage_quota_value + ' ' + editStorageForm.oss_quota_unit }}<br>
• 已用下载流量: {{ formatBytes(editStorageForm.download_traffic_used || 0) }}<br>
• 当前下载流量配额: {{ editStorageForm.download_quota_unlimited ? '不限' : (editStorageForm.download_traffic_quota_value + ' ' + editStorageForm.download_quota_unit) }}
</div>
</div>
@@ -4884,6 +4929,11 @@
text-align: center !important;
}
body.enterprise-netdisk .file-list-table td.file-list-action-col {
overflow: visible;
text-overflow: clip;
}
body.enterprise-netdisk .file-list-action-col .btn {
margin: 0 auto;
}
@@ -5885,7 +5935,15 @@
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
flex-wrap: wrap;
margin-left: auto;
justify-content: flex-end;
min-width: 0;
flex: 1 1 auto;
}
body.enterprise-netdisk .files-content-head-actions > * {
min-width: 0;
}
body.enterprise-netdisk .files-head-action-btn {
@@ -6242,6 +6300,177 @@
}
</style>
<script src="app.js?v=20260212007"></script>
<style>
/* ===== Files Header Action Layout v3 ===== */
@media (min-width: 992px) {
body.enterprise-netdisk .files-content-head.files-content-head-compact {
display: grid;
grid-template-columns: max-content minmax(220px, 1fr) minmax(420px, 560px);
align-items: center;
column-gap: 10px;
row-gap: 8px;
}
body.enterprise-netdisk .files-content-head-meta {
width: 100%;
margin-left: 0;
justify-content: flex-end;
}
body.enterprise-netdisk .files-content-head-actions {
width: 100%;
margin-left: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
align-items: stretch;
}
body.enterprise-netdisk .files-head-action-btn,
body.enterprise-netdisk .files-head-folder-btn,
body.enterprise-netdisk .files-head-view-toggle {
width: 100%;
min-width: 0;
}
body.enterprise-netdisk .files-head-action-btn,
body.enterprise-netdisk .files-head-view-toggle .btn {
height: 36px;
padding: 0 10px;
font-size: 12px;
}
body.enterprise-netdisk .files-head-view-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
padding: 0;
border: none;
background: transparent;
}
body.enterprise-netdisk .files-head-view-toggle .btn {
min-width: 0;
}
}
@media (max-width: 991px) and (min-width: 769px) {
body.enterprise-netdisk .files-content-head-actions {
width: 100%;
margin-left: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
body.enterprise-netdisk .files-head-action-btn,
body.enterprise-netdisk .files-head-folder-btn,
body.enterprise-netdisk .files-head-view-toggle {
width: 100%;
min-width: 0;
}
body.enterprise-netdisk .files-head-action-btn,
body.enterprise-netdisk .files-head-view-toggle .btn {
height: 34px;
padding: 0 8px;
}
body.enterprise-netdisk .files-head-view-toggle {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
padding: 0;
border: none;
background: transparent;
}
}
</style>
<style>
/* ===== Mobile Overflow Guard v1 ===== */
@media (max-width: 768px) {
body.enterprise-netdisk .card,
body.enterprise-netdisk .files-view-card,
body.enterprise-netdisk .files-content-shell,
body.enterprise-netdisk .files-content-head,
body.enterprise-netdisk .files-content-head-meta,
body.enterprise-netdisk .files-content-head-actions,
body.enterprise-netdisk .settings-section,
body.enterprise-netdisk .settings-panel,
body.enterprise-netdisk .settings-subpanel,
body.enterprise-netdisk .settings-inline-tip,
body.enterprise-netdisk .settings-storage-switch,
body.enterprise-netdisk .settings-storage-grid,
body.enterprise-netdisk .settings-storage-option {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
body.enterprise-netdisk .settings-storage-grid {
grid-template-columns: 1fr !important;
gap: 10px !important;
}
body.enterprise-netdisk .settings-storage-head {
align-items: flex-start !important;
}
body.enterprise-netdisk .settings-storage-head > div {
width: 100%;
min-width: 0;
}
body.enterprise-netdisk .settings-storage-option [style*="display: flex"][style*="justify-content: space-between"] {
flex-wrap: wrap;
gap: 6px;
align-items: flex-start !important;
}
body.enterprise-netdisk .settings-storage-option button,
body.enterprise-netdisk .settings-oss-panel button,
body.enterprise-netdisk .settings-local-panel button {
max-width: 100%;
}
body.enterprise-netdisk .settings-page-subtitle,
body.enterprise-netdisk .settings-inline-tip,
body.enterprise-netdisk .files-content-title,
body.enterprise-netdisk .files-head-usage-progress-text {
overflow-wrap: anywhere;
word-break: break-word;
}
body.enterprise-netdisk .file-list {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
body.enterprise-netdisk .file-list-table {
width: 100%;
min-width: 0;
}
body.enterprise-netdisk .modal-content {
width: calc(100vw - 16px);
max-width: calc(100vw - 16px);
}
}
@media (max-width: 480px) {
body.enterprise-netdisk .settings-panel,
body.enterprise-netdisk .settings-subpanel {
padding: 10px !important;
}
body.enterprise-netdisk .settings-inline-tip {
font-size: 12px !important;
}
}
</style>
<script src="app.js?v=20260217003"></script>
</body>
</html>

View File

@@ -271,7 +271,11 @@ createApp({
quota_unit: 'GB', // 本地配额单位MB 或 GB
oss_storage_quota_value: 1, // OSS配额数值默认1GB
oss_quota_unit: 'GB', // OSS配额单位MB / GB / TB
oss_quota_unlimited: false // 兼容旧数据字段(当前固定为有限配额)
oss_quota_unlimited: false, // 兼容旧数据字段(当前固定为有限配额)
download_traffic_quota_value: 1, // 下载流量配额数值
download_quota_unit: 'GB', // 下载流量单位MB / GB / TB
download_quota_unlimited: true, // 下载流量true=不限
download_traffic_used: 0 // 下载流量已使用(字节)
},
// 服务器存储统计
@@ -1454,36 +1458,13 @@ handleDragLeave(e) {
// 构建文件路径
const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`;
// OSS 模式:使用签名 URL 直连下载(不经过后端)
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') {
this.downloadFromOSS(filePath);
} else {
// 本地存储模式:通过后端下载
this.downloadFromLocal(filePath);
}
// 统一走后端下载接口,确保下载流量可精确计量
this.downloadFromLocal(filePath);
},
// OSS 直连下载使用签名URL不经过后端节省后端带宽
async downloadFromOSS(filePath) {
try {
// 获取签名 URL
const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, {
params: { path: filePath }
});
if (data.success) {
// 直连 OSS 下载不经过后端充分利用OSS带宽和CDN
window.open(data.downloadUrl, '_blank');
} else {
// 处理后端返回的错误
console.error('获取下载链接失败:', data.message);
this.showToast('error', '下载失败', data.message || '获取下载链接失败');
}
} catch (error) {
console.error('获取下载链接失败:', error);
const errorMsg = error.response?.data?.message || error.message || '获取下载链接失败';
this.showToast('error', '下载失败', errorMsg);
}
// 保留方法名兼容旧调用,内部统一转发到后端下载
downloadFromOSS(filePath) {
this.downloadFromLocal(filePath);
},
// 本地存储下载
@@ -1785,16 +1766,19 @@ handleDragLeave(e) {
? `/${file.name}`
: `${this.currentPath}/${file.name}`;
// OSS 模式:返回签名 URL用于媒体预览
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') {
const hasDownloadTrafficLimit = Number(this.user?.download_traffic_quota || 0) > 0;
// OSS 模式且未启用下载流量限制时,返回签名 URL用于媒体预览
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none' && !hasDownloadTrafficLimit) {
try {
const { data } = await axios.get(`${this.apiBase}/api/files/download-url`, {
params: { path: filePath }
});
return data.success ? data.downloadUrl : null;
if (data.success) {
return data.downloadUrl;
}
} catch (error) {
console.error('获取媒体URL失败:', error);
return null;
}
}
@@ -2785,6 +2769,13 @@ handleDragLeave(e) {
return;
}
const confirmMessage = type === 'local'
? '切换到本地存储不会自动迁移 OSS 文件。切换后只会显示本地文件,确认继续?'
: '切换到 OSS 存储不会自动迁移本地文件。切换后只会显示 OSS 文件,确认继续?';
if (!window.confirm(confirmMessage)) {
return;
}
// 不再弹出配置引导弹窗,直接尝试切换
// 如果后端检测到没有OSS配置会返回错误提示
@@ -2942,6 +2933,32 @@ handleDragLeave(e) {
this.editStorageForm.oss_quota_unit = 'MB';
}
// 下载流量配额0 表示不限)
const downloadQuotaBytes = Number(user.download_traffic_quota || 0);
const effectiveDownloadQuotaBytes = Number.isFinite(downloadQuotaBytes) && downloadQuotaBytes > 0
? downloadQuotaBytes
: 0;
const downloadUsedBytes = Number(user.download_traffic_used || 0);
this.editStorageForm.download_traffic_used = Number.isFinite(downloadUsedBytes) && downloadUsedBytes > 0
? Math.floor(downloadUsedBytes)
: 0;
this.editStorageForm.download_quota_unlimited = effectiveDownloadQuotaBytes <= 0;
if (effectiveDownloadQuotaBytes <= 0) {
this.editStorageForm.download_traffic_quota_value = 1;
this.editStorageForm.download_quota_unit = 'GB';
} else if (effectiveDownloadQuotaBytes >= tb && effectiveDownloadQuotaBytes % tb === 0) {
this.editStorageForm.download_traffic_quota_value = effectiveDownloadQuotaBytes / tb;
this.editStorageForm.download_quota_unit = 'TB';
} else if (effectiveDownloadQuotaBytes >= gb && effectiveDownloadQuotaBytes % gb === 0) {
this.editStorageForm.download_traffic_quota_value = effectiveDownloadQuotaBytes / gb;
this.editStorageForm.download_quota_unit = 'GB';
} else {
this.editStorageForm.download_traffic_quota_value = Math.max(1, Math.round(effectiveDownloadQuotaBytes / mb));
this.editStorageForm.download_quota_unit = 'MB';
}
this.showEditStorageModal = true;
},
@@ -2976,12 +2993,30 @@ handleDragLeave(e) {
ossQuotaBytes = this.editStorageForm.oss_storage_quota_value * 1024 * 1024;
}
// 计算下载流量配额字节0表示不限
let downloadQuotaBytes = 0;
if (!this.editStorageForm.download_quota_unlimited) {
if (!this.editStorageForm.download_traffic_quota_value || this.editStorageForm.download_traffic_quota_value < 1) {
this.showToast('error', '参数错误', '下载流量配额必须大于 0或选择不限流量');
return;
}
if (this.editStorageForm.download_quota_unit === 'TB') {
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024 * 1024 * 1024;
} else if (this.editStorageForm.download_quota_unit === 'GB') {
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024 * 1024;
} else {
downloadQuotaBytes = this.editStorageForm.download_traffic_quota_value * 1024 * 1024;
}
}
const response = await axios.post(
`${this.apiBase}/api/admin/users/${this.editStorageForm.userId}/storage-permission`,
{
storage_permission: this.editStorageForm.storage_permission,
local_storage_quota: localQuotaBytes,
oss_storage_quota: ossQuotaBytes
oss_storage_quota: ossQuotaBytes,
download_traffic_quota: downloadQuotaBytes
},
);
@@ -3022,6 +3057,14 @@ handleDragLeave(e) {
return Math.min(100, Math.round((used / quota) * 100));
},
getAdminUserDownloadQuotaPercentage(user) {
const quota = Number(user?.download_traffic_quota || 0);
const used = Number(user?.download_traffic_used || 0);
if (!Number.isFinite(quota) || quota <= 0) return 0;
if (!Number.isFinite(used) || used <= 0) return 0;
return Math.min(100, Math.round((used / quota) * 100));
},
formatDate(dateString) {
if (!dateString) return '-';