feat(quota): add downloadable traffic quota with local/OSS/share metering
This commit is contained in:
@@ -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>
|
||||
|
||||
111
frontend/app.js
111
frontend/app.js
@@ -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 '-';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user