Compare commits

..

2 Commits

4 changed files with 752 additions and 21 deletions

View File

@@ -365,6 +365,21 @@ function initDatabase() {
)
`);
// 下载流量日统计表(用于用户侧报表)
db.exec(`
CREATE TABLE IF NOT EXISTS user_download_traffic_daily (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
date_key TEXT NOT NULL, -- YYYY-MM-DD本地时区
bytes_used INTEGER NOT NULL DEFAULT 0,
download_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, date_key),
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
)
`);
// 日志表索引
db.exec(`
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON system_logs(created_at);
@@ -377,6 +392,9 @@ function initDatabase() {
-- 优势:快速查询用户最近的活动记录,支持时间范围过滤
-- 使用场景:用户活动历史、审计日志查询
CREATE INDEX IF NOT EXISTS idx_logs_user_created ON system_logs(user_id, created_at);
-- 下载流量报表索引(按用户+日期查询)
CREATE INDEX IF NOT EXISTS idx_download_traffic_user_date ON user_download_traffic_daily(user_id, date_key);
`);
console.log('[数据库性能优化] ✓ 日志表复合索引已创建');
@@ -1421,6 +1439,104 @@ function migrateDownloadTrafficFields() {
}
}
// 下载流量报表(按天聚合)
const DownloadTrafficReportDB = {
normalizeDays(days, defaultDays = 30, maxDays = 365) {
const parsed = Number(days);
if (!Number.isFinite(parsed) || parsed <= 0) {
return defaultDays;
}
return Math.min(maxDays, Math.max(1, Math.floor(parsed)));
},
formatDateKey(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
if (Number.isNaN(target.getTime())) {
return null;
}
const year = target.getFullYear();
const month = String(target.getMonth() + 1).padStart(2, '0');
const day = String(target.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
addUsage(userId, bytesUsed, downloadCount = 1, date = new Date()) {
const uid = Number(userId);
const bytes = Math.floor(Number(bytesUsed));
const count = Math.floor(Number(downloadCount));
const dateKey = this.formatDateKey(date);
if (!Number.isFinite(uid) || uid <= 0 || !Number.isFinite(bytes) || bytes <= 0 || !dateKey) {
return null;
}
const normalizedCount = Number.isFinite(count) && count > 0 ? count : 1;
return db.prepare(`
INSERT INTO user_download_traffic_daily (user_id, date_key, bytes_used, download_count, created_at, updated_at)
VALUES (?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
ON CONFLICT(user_id, date_key)
DO UPDATE SET
bytes_used = user_download_traffic_daily.bytes_used + excluded.bytes_used,
download_count = user_download_traffic_daily.download_count + excluded.download_count,
updated_at = datetime('now', 'localtime')
`).run(uid, dateKey, bytes, normalizedCount);
},
getDailyUsage(userId, days = 30) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid <= 0) {
return [];
}
const safeDays = this.normalizeDays(days);
const offset = `-${safeDays - 1} days`;
return db.prepare(`
SELECT
date_key,
bytes_used,
download_count
FROM user_download_traffic_daily
WHERE user_id = ?
AND date_key >= date('now', 'localtime', ?)
ORDER BY date_key ASC
`).all(uid, offset);
},
getPeriodSummary(userId, days = null) {
const uid = Number(userId);
if (!Number.isFinite(uid) || uid <= 0) {
return { bytes_used: 0, download_count: 0 };
}
if (days === null || days === undefined) {
const row = db.prepare(`
SELECT
COALESCE(SUM(bytes_used), 0) AS bytes_used,
COALESCE(SUM(download_count), 0) AS download_count
FROM user_download_traffic_daily
WHERE user_id = ?
`).get(uid);
return row || { bytes_used: 0, download_count: 0 };
}
const safeDays = this.normalizeDays(days);
const offset = `-${safeDays - 1} days`;
const row = db.prepare(`
SELECT
COALESCE(SUM(bytes_used), 0) AS bytes_used,
COALESCE(SUM(download_count), 0) AS download_count
FROM user_download_traffic_daily
WHERE user_id = ?
AND date_key >= date('now', 'localtime', ?)
`).get(uid, offset);
return row || { bytes_used: 0, download_count: 0 };
}
};
// 系统日志操作
const SystemLogDB = {
// 日志级别常量
@@ -1616,6 +1732,7 @@ module.exports = {
SettingsDB,
VerificationDB,
PasswordResetTokenDB,
DownloadTrafficReportDB,
SystemLogDB,
TransactionDB,
WalManager

View File

@@ -62,7 +62,7 @@ function clearOssUsageCache(userId) {
console.log(`[OSS缓存] 已清除: 用户 ${userId}`);
}
const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, SystemLogDB, TransactionDB, WalManager } = require('./database');
const { db, UserDB, ShareDB, SettingsDB, VerificationDB, PasswordResetTokenDB, DownloadTrafficReportDB, SystemLogDB, TransactionDB, WalManager } = require('./database');
const StorageUsageCache = require('./utils/storage-cache');
const { JWT_SECRET, generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth');
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
@@ -691,6 +691,31 @@ function formatDateTimeForSqlite(date = new Date()) {
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
function getDateKeyFromDate(date = new Date()) {
const target = date instanceof Date ? date : new Date(date);
if (Number.isNaN(target.getTime())) {
return null;
}
const year = target.getFullYear();
const month = String(target.getMonth() + 1).padStart(2, '0');
const day = String(target.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function getRecentDateKeys(days = 30, now = new Date()) {
const safeDays = Math.max(1, Math.floor(Number(days) || 30));
const keys = [];
for (let i = safeDays - 1; i >= 0; i -= 1) {
const date = new Date(now.getTime());
date.setDate(date.getDate() - i);
const key = getDateKeyFromDate(date);
if (key) {
keys.push(key);
}
}
return keys;
}
function getNextDownloadResetTime(lastResetAt, resetCycle) {
const baseDate = parseDateTimeValue(lastResetAt);
if (!baseDate) {
@@ -847,11 +872,16 @@ const applyDownloadTrafficUsageTransaction = db.transaction((userId, bytesToAdd)
UserDB.update(userId, { download_traffic_used: nextUsed });
const addedBytes = Math.max(0, nextUsed - trafficState.used);
if (addedBytes > 0) {
DownloadTrafficReportDB.addUsage(userId, addedBytes, 1, new Date());
}
return {
quota: trafficState.quota,
usedBefore: trafficState.used,
usedAfter: nextUsed,
added: Math.max(0, nextUsed - trafficState.used)
added: addedBytes
};
});
@@ -863,6 +893,63 @@ function applyDownloadTrafficUsage(userId, bytesToAdd) {
return applyDownloadTrafficUsageTransaction(userId, Math.floor(parsedBytes));
}
const reserveDirectDownloadTrafficTransaction = db.transaction((userId, bytesToReserve) => {
const policyState = enforceDownloadTrafficPolicyTransaction(userId, 'direct_download_reserve');
const user = policyState?.user || UserDB.findById(userId);
if (!user) {
return { ok: false, reason: 'user_not_found' };
}
const reserveBytes = Math.floor(Number(bytesToReserve));
if (!Number.isFinite(reserveBytes) || reserveBytes <= 0) {
return {
ok: true,
quota: normalizeDownloadTrafficQuota(user.download_traffic_quota),
usedBefore: normalizeDownloadTrafficUsed(user.download_traffic_used, normalizeDownloadTrafficQuota(user.download_traffic_quota)),
usedAfter: normalizeDownloadTrafficUsed(user.download_traffic_used, normalizeDownloadTrafficQuota(user.download_traffic_quota)),
reserved: 0
};
}
const trafficState = getDownloadTrafficState(user);
if (!trafficState.isUnlimited && reserveBytes > trafficState.remaining) {
return {
ok: false,
reason: 'insufficient',
quota: trafficState.quota,
usedBefore: trafficState.used,
remaining: trafficState.remaining
};
}
const nextUsed = trafficState.used + reserveBytes;
UserDB.update(userId, { download_traffic_used: nextUsed });
DownloadTrafficReportDB.addUsage(userId, reserveBytes, 1, new Date());
return {
ok: true,
quota: trafficState.quota,
usedBefore: trafficState.used,
usedAfter: nextUsed,
reserved: reserveBytes
};
});
function reserveDirectDownloadTraffic(userId, bytesToReserve) {
const parsedBytes = Number(bytesToReserve);
if (!Number.isFinite(parsedBytes) || parsedBytes <= 0) {
return {
ok: true,
quota: 0,
usedBefore: 0,
usedAfter: 0,
reserved: 0
};
}
return reserveDirectDownloadTrafficTransaction(userId, Math.floor(parsedBytes));
}
function runDownloadTrafficPolicySweep(trigger = 'scheduled') {
try {
const users = UserDB.getAll();
@@ -2526,6 +2613,112 @@ app.get('/api/user/profile', authMiddleware, (req, res) => {
});
});
// 获取用户下载流量额度与报表
app.get('/api/user/download-traffic-report', authMiddleware, (req, res) => {
try {
const days = DownloadTrafficReportDB.normalizeDays(req.query.days, 30, 365);
const policyState = enforceDownloadTrafficPolicy(req.user.id, 'traffic_report');
const latestUser = policyState?.user || UserDB.findById(req.user.id);
if (!latestUser) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
const trafficState = getDownloadTrafficState(latestUser);
const dailyRows = DownloadTrafficReportDB.getDailyUsage(latestUser.id, days);
const dailyRowMap = new Map(
dailyRows.map(row => [row.date_key, row])
);
const dateKeys = getRecentDateKeys(days, new Date());
const dailySeries = dateKeys.map(dateKey => {
const row = dailyRowMap.get(dateKey);
const bytesUsed = Number(row?.bytes_used || 0);
const downloadCount = Number(row?.download_count || 0);
return {
date: dateKey,
bytes_used: Number.isFinite(bytesUsed) && bytesUsed > 0 ? Math.floor(bytesUsed) : 0,
download_count: Number.isFinite(downloadCount) && downloadCount > 0 ? Math.floor(downloadCount) : 0
};
});
const selectedSummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, days);
const todaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 1);
const sevenDaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 7);
const thirtyDaySummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, 30);
const allSummary = DownloadTrafficReportDB.getPeriodSummary(latestUser.id, null);
const peakDay = dailySeries.reduce((peak, current) => {
if (!peak || current.bytes_used > peak.bytes_used) {
return current;
}
return peak;
}, null);
const safeQuota = trafficState.quota > 0 ? trafficState.quota : 0;
const remainingBytes = safeQuota > 0 ? Math.max(0, safeQuota - trafficState.used) : null;
const usagePercentage = safeQuota > 0
? Math.min(100, Math.round((trafficState.used / safeQuota) * 100))
: null;
res.json({
success: true,
quota: {
quota: safeQuota,
used: trafficState.used,
remaining: remainingBytes,
usage_percentage: usagePercentage,
is_unlimited: trafficState.isUnlimited,
reset_cycle: latestUser.download_traffic_reset_cycle || 'none',
expires_at: latestUser.download_traffic_quota_expires_at || null,
last_reset_at: latestUser.download_traffic_last_reset_at || null
},
report: {
days,
daily: dailySeries,
summary: {
today: {
bytes_used: Number(todaySummary?.bytes_used || 0),
download_count: Number(todaySummary?.download_count || 0)
},
last_7_days: {
bytes_used: Number(sevenDaySummary?.bytes_used || 0),
download_count: Number(sevenDaySummary?.download_count || 0)
},
last_30_days: {
bytes_used: Number(thirtyDaySummary?.bytes_used || 0),
download_count: Number(thirtyDaySummary?.download_count || 0)
},
selected_range: {
bytes_used: Number(selectedSummary?.bytes_used || 0),
download_count: Number(selectedSummary?.download_count || 0),
average_daily_bytes: Math.round(Number(selectedSummary?.bytes_used || 0) / Math.max(1, days))
},
all_time: {
bytes_used: Number(allSummary?.bytes_used || 0),
download_count: Number(allSummary?.download_count || 0)
},
peak_day: peakDay
? {
date: peakDay.date,
bytes_used: peakDay.bytes_used,
download_count: peakDay.download_count
}
: null
}
}
});
} catch (error) {
console.error('获取下载流量报表失败:', error);
res.status(500).json({
success: false,
message: '获取下载流量报表失败'
});
}
});
// 获取用户主题偏好(包含全局默认主题)
app.get('/api/user/theme', authMiddleware, (req, res) => {
try {
@@ -3932,7 +4125,6 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
});
}
// 开启下载流量配额后,禁止发放直连 URL避免绕过后端计量
const policyState = enforceDownloadTrafficPolicy(req.user.id, 'download_url');
const latestUser = policyState?.user || UserDB.findById(req.user.id);
if (!latestUser) {
@@ -3943,12 +4135,6 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
}
const trafficState = getDownloadTrafficState(latestUser);
if (!trafficState.isUnlimited) {
return res.status(403).json({
success: false,
message: '当前账号已启用下载流量限制,请使用系统下载入口'
});
}
// 检查用户是否配置了 OSS包括个人配置和系统级统一配置
const hasUnifiedConfig = SettingsDB.hasUnifiedOssConfig();
@@ -3960,11 +4146,44 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
}
try {
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { client, bucket, ossClient } = createS3ClientContextForUser(req.user);
const { client, bucket, ossClient } = createS3ClientContextForUser(latestUser);
const objectKey = ossClient.getObjectKey(normalizedPath);
let fileSize = 0;
// 启用下载流量限制时,签发前先校验文件大小与剩余额度
if (!trafficState.isUnlimited) {
let headResponse;
try {
headResponse = await client.send(new HeadObjectCommand({
Bucket: bucket,
Key: objectKey
}));
} catch (headError) {
const statusCode = headError?.$metadata?.httpStatusCode;
if (headError?.name === 'NotFound' || headError?.name === 'NoSuchKey' || statusCode === 404) {
return res.status(404).json({
success: false,
message: '文件不存在'
});
}
throw headError;
}
const contentLength = Number(headResponse?.ContentLength || 0);
fileSize = Number.isFinite(contentLength) && contentLength > 0
? Math.floor(contentLength)
: 0;
if (fileSize > trafficState.remaining) {
return res.status(403).json({
success: false,
message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(trafficState.remaining)}`
});
}
}
// 创建 GetObject 命令
const command = new GetObjectCommand({
@@ -3976,10 +4195,24 @@ app.get('/api/files/download-url', authMiddleware, async (req, res) => {
// 生成签名 URL1小时有效
const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 });
// 直连模式下无法精确获知真实下载字节;限流时在签发前预扣文件大小
if (!trafficState.isUnlimited && fileSize > 0) {
const reserveResult = reserveDirectDownloadTraffic(latestUser.id, fileSize);
if (!reserveResult?.ok) {
const remaining = Number(reserveResult?.remaining || 0);
return res.status(403).json({
success: false,
message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(remaining)}`
});
}
}
res.json({
success: true,
downloadUrl: signedUrl,
expiresIn: 3600
expiresIn: 3600,
direct: true,
quotaLimited: !trafficState.isUnlimited
});
} catch (error) {
console.error('[OSS签名] 生成下载签名失败:', error);
@@ -5235,8 +5468,8 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
const storageType = share.storage_type || 'oss';
const ownerTrafficState = getDownloadTrafficState(shareOwner);
// 本地存储,或分享者启用了下载流量上限:统一走后端下载(便于计量)
if (storageType !== 'oss' || !ownerTrafficState.isUnlimited) {
// 本地存储:继续走后端下载
if (storageType !== 'oss') {
let downloadUrl = `${getSecureBaseUrl(req)}/api/share/${code}/download-file?path=${encodeURIComponent(normalizedFilePath)}`;
if (share.share_password) {
@@ -5265,11 +5498,43 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
});
}
const { GetObjectCommand } = require('@aws-sdk/client-s3');
const { GetObjectCommand, HeadObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { client, bucket, ossClient } = createS3ClientContextForUser(shareOwner);
const objectKey = ossClient.getObjectKey(normalizedFilePath);
let fileSize = 0;
if (!ownerTrafficState.isUnlimited) {
let headResponse;
try {
headResponse = await client.send(new HeadObjectCommand({
Bucket: bucket,
Key: objectKey
}));
} catch (headError) {
const statusCode = headError?.$metadata?.httpStatusCode;
if (headError?.name === 'NotFound' || headError?.name === 'NoSuchKey' || statusCode === 404) {
return res.status(404).json({
success: false,
message: '文件不存在'
});
}
throw headError;
}
const contentLength = Number(headResponse?.ContentLength || 0);
fileSize = Number.isFinite(contentLength) && contentLength > 0
? Math.floor(contentLength)
: 0;
if (fileSize > ownerTrafficState.remaining) {
return res.status(403).json({
success: false,
message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(ownerTrafficState.remaining)}`
});
}
}
// 创建 GetObject 命令
const command = new GetObjectCommand({
@@ -5281,10 +5546,22 @@ app.post('/api/share/:code/download-url', shareRateLimitMiddleware, async (req,
// 生成签名 URL1小时有效
const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 });
if (!ownerTrafficState.isUnlimited && fileSize > 0) {
const reserveResult = reserveDirectDownloadTraffic(shareOwner.id, fileSize);
if (!reserveResult?.ok) {
const remaining = Number(reserveResult?.remaining || 0);
return res.status(403).json({
success: false,
message: `下载流量不足:文件 ${formatFileSize(fileSize)},剩余 ${formatFileSize(remaining)}`
});
}
}
res.json({
success: true,
downloadUrl: signedUrl,
direct: true,
quotaLimited: !ownerTrafficState.isUnlimited,
expiresIn: 3600
});

View File

@@ -2331,6 +2331,163 @@
</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>
<div class="settings-panel settings-theme-panel" style="background: var(--bg-card); padding: 20px; border-radius: 12px; border: 1px solid var(--glass-border); margin-bottom: 30px;">

View File

@@ -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,48 @@ handleDragLeave(e) {
this.loadFiles(newPath);
},
downloadFile(file) {
async downloadFile(file) {
// 构建文件路径
const filePath = this.currentPath === '/' ? `/${file.name}` : `${this.currentPath}/${file.name}`;
// 统一走后端下载接口,确保下载流量可精确计量
const canDirectOssDownload = this.storageType === 'oss'
&& this.user?.oss_config_source !== 'none';
// 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 +2847,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 +3071,9 @@ handleDragLeave(e) {
}
break;
case 'settings':
// 设置页面不需要额外加载数据
if (this.user && !this.user.is_admin) {
this.loadDownloadTrafficReport();
}
break;
}
},
@@ -3612,6 +3791,7 @@ handleDragLeave(e) {
} else if (newView === 'settings' && this.user && !this.user.is_admin) {
// 普通用户进入设置页面时加载OSS配置
this.loadOssConfig();
this.loadDownloadTrafficReport();
}
// 记住最后停留的视图(需合法且已登录)