feat: add share security, resumable upload, global search and reservation ops panel
This commit is contained in:
@@ -223,6 +223,11 @@ function initDatabase() {
|
|||||||
share_path TEXT NOT NULL,
|
share_path TEXT NOT NULL,
|
||||||
share_type TEXT DEFAULT 'file',
|
share_type TEXT DEFAULT 'file',
|
||||||
share_password TEXT,
|
share_password TEXT,
|
||||||
|
max_downloads INTEGER DEFAULT NULL,
|
||||||
|
ip_whitelist TEXT,
|
||||||
|
device_limit TEXT DEFAULT 'all',
|
||||||
|
access_time_start TEXT,
|
||||||
|
access_time_end TEXT,
|
||||||
|
|
||||||
-- 分享统计
|
-- 分享统计
|
||||||
view_count INTEGER DEFAULT 0,
|
view_count INTEGER DEFAULT 0,
|
||||||
@@ -333,6 +338,39 @@ function initDatabase() {
|
|||||||
console.error('数据库迁移(share_type)失败:', error);
|
console.error('数据库迁移(share_type)失败:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 数据库迁移:分享高级安全策略字段
|
||||||
|
try {
|
||||||
|
const shareColumns = db.prepare("PRAGMA table_info(shares)").all();
|
||||||
|
const hasMaxDownloads = shareColumns.some(col => col.name === 'max_downloads');
|
||||||
|
const hasIpWhitelist = shareColumns.some(col => col.name === 'ip_whitelist');
|
||||||
|
const hasDeviceLimit = shareColumns.some(col => col.name === 'device_limit');
|
||||||
|
const hasAccessTimeStart = shareColumns.some(col => col.name === 'access_time_start');
|
||||||
|
const hasAccessTimeEnd = shareColumns.some(col => col.name === 'access_time_end');
|
||||||
|
|
||||||
|
if (!hasMaxDownloads) {
|
||||||
|
db.exec('ALTER TABLE shares ADD COLUMN max_downloads INTEGER DEFAULT NULL');
|
||||||
|
console.log('数据库迁移:添加 shares.max_downloads 字段完成');
|
||||||
|
}
|
||||||
|
if (!hasIpWhitelist) {
|
||||||
|
db.exec('ALTER TABLE shares ADD COLUMN ip_whitelist TEXT');
|
||||||
|
console.log('数据库迁移:添加 shares.ip_whitelist 字段完成');
|
||||||
|
}
|
||||||
|
if (!hasDeviceLimit) {
|
||||||
|
db.exec('ALTER TABLE shares ADD COLUMN device_limit TEXT DEFAULT "all"');
|
||||||
|
console.log('数据库迁移:添加 shares.device_limit 字段完成');
|
||||||
|
}
|
||||||
|
if (!hasAccessTimeStart) {
|
||||||
|
db.exec('ALTER TABLE shares ADD COLUMN access_time_start TEXT');
|
||||||
|
console.log('数据库迁移:添加 shares.access_time_start 字段完成');
|
||||||
|
}
|
||||||
|
if (!hasAccessTimeEnd) {
|
||||||
|
db.exec('ALTER TABLE shares ADD COLUMN access_time_end TEXT');
|
||||||
|
console.log('数据库迁移:添加 shares.access_time_end 字段完成');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('数据库迁移(分享安全策略)失败:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// 数据库迁移:邮箱验证字段
|
// 数据库迁移:邮箱验证字段
|
||||||
try {
|
try {
|
||||||
const columns = db.prepare("PRAGMA table_info(users)").all();
|
const columns = db.prepare("PRAGMA table_info(users)").all();
|
||||||
@@ -445,6 +483,47 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 本地分片上传会话(断点续传)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS upload_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
storage_type TEXT NOT NULL DEFAULT 'local',
|
||||||
|
target_path TEXT NOT NULL,
|
||||||
|
file_name TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
chunk_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
total_chunks INTEGER NOT NULL DEFAULT 0,
|
||||||
|
uploaded_chunks TEXT NOT NULL DEFAULT '',
|
||||||
|
uploaded_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
temp_file_path TEXT NOT NULL,
|
||||||
|
file_hash TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active', -- active/completed/expired/cancelled
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at DATETIME,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 文件哈希索引(用于秒传)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_file_hash_index (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
storage_type TEXT NOT NULL DEFAULT 'local',
|
||||||
|
file_hash TEXT NOT NULL,
|
||||||
|
file_size INTEGER NOT NULL DEFAULT 0,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
object_key TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(user_id, storage_type, file_hash, file_size, file_path),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// 日志表索引
|
// 日志表索引
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON system_logs(created_at);
|
CREATE INDEX IF NOT EXISTS idx_logs_created_at ON system_logs(created_at);
|
||||||
@@ -468,6 +547,18 @@ function initDatabase() {
|
|||||||
-- 已处理日志索引(按处理时间回溯)
|
-- 已处理日志索引(按处理时间回溯)
|
||||||
CREATE INDEX IF NOT EXISTS idx_ingested_logs_processed_at
|
CREATE INDEX IF NOT EXISTS idx_ingested_logs_processed_at
|
||||||
ON download_traffic_ingested_logs(processed_at);
|
ON download_traffic_ingested_logs(processed_at);
|
||||||
|
|
||||||
|
-- 断点上传会话索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_upload_sessions_user_status_expires
|
||||||
|
ON upload_sessions(user_id, status, expires_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
|
||||||
|
ON upload_sessions(user_id, file_hash, file_size);
|
||||||
|
|
||||||
|
-- 秒传索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_file_hash_index_lookup
|
||||||
|
ON user_file_hash_index(user_id, storage_type, file_hash, file_size);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_file_hash_index_path
|
||||||
|
ON user_file_hash_index(user_id, storage_type, file_path);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('[数据库性能优化] ✓ 日志表复合索引已创建');
|
console.log('[数据库性能优化] ✓ 日志表复合索引已创建');
|
||||||
@@ -1142,7 +1233,12 @@ const ShareDB = {
|
|||||||
file_path = '',
|
file_path = '',
|
||||||
file_name = '',
|
file_name = '',
|
||||||
password = null,
|
password = null,
|
||||||
expiry_days = null
|
expiry_days = null,
|
||||||
|
max_downloads = null,
|
||||||
|
ip_whitelist = null,
|
||||||
|
device_limit = 'all',
|
||||||
|
access_time_start = null,
|
||||||
|
access_time_end = null
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
let shareCode;
|
let shareCode;
|
||||||
@@ -1174,8 +1270,11 @@ const ShareDB = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stmt = db.prepare(`
|
const stmt = db.prepare(`
|
||||||
INSERT INTO shares (user_id, share_code, share_path, share_type, share_password, expires_at)
|
INSERT INTO shares (
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
user_id, share_code, share_path, share_type, share_password, expires_at,
|
||||||
|
max_downloads, ip_whitelist, device_limit, access_time_start, access_time_end
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const hashedPassword = password ? bcrypt.hashSync(password, 10) : null;
|
const hashedPassword = password ? bcrypt.hashSync(password, 10) : null;
|
||||||
@@ -1199,7 +1298,12 @@ const ShareDB = {
|
|||||||
sharePath,
|
sharePath,
|
||||||
share_type,
|
share_type,
|
||||||
hashedPassword,
|
hashedPassword,
|
||||||
expiresAt
|
expiresAt,
|
||||||
|
max_downloads,
|
||||||
|
ip_whitelist,
|
||||||
|
device_limit || 'all',
|
||||||
|
access_time_start,
|
||||||
|
access_time_end
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1207,6 +1311,11 @@ const ShareDB = {
|
|||||||
share_code: shareCode,
|
share_code: shareCode,
|
||||||
share_type: share_type,
|
share_type: share_type,
|
||||||
expires_at: expiresAt,
|
expires_at: expiresAt,
|
||||||
|
max_downloads: max_downloads ?? null,
|
||||||
|
ip_whitelist: ip_whitelist ?? null,
|
||||||
|
device_limit: device_limit || 'all',
|
||||||
|
access_time_start: access_time_start ?? null,
|
||||||
|
access_time_end: access_time_end ?? null
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -1221,6 +1330,7 @@ const ShareDB = {
|
|||||||
s.id, s.user_id, s.share_code, s.share_path, s.share_type,
|
s.id, s.user_id, s.share_code, s.share_path, s.share_type,
|
||||||
s.storage_type,
|
s.storage_type,
|
||||||
s.share_password,
|
s.share_password,
|
||||||
|
s.max_downloads, s.ip_whitelist, s.device_limit, s.access_time_start, s.access_time_end,
|
||||||
s.view_count, s.download_count, s.created_at, s.expires_at,
|
s.view_count, s.download_count, s.created_at, s.expires_at,
|
||||||
u.username,
|
u.username,
|
||||||
-- OSS 配置(访问分享文件所需)
|
-- OSS 配置(访问分享文件所需)
|
||||||
@@ -2029,6 +2139,481 @@ const DownloadTrafficReservationDB = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { consumed, finalizedCount };
|
return { consumed, finalizedCount };
|
||||||
|
},
|
||||||
|
|
||||||
|
getAdminList(options = {}) {
|
||||||
|
const where = ['1=1'];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
const status = typeof options.status === 'string' ? options.status.trim() : '';
|
||||||
|
const allowedStatus = new Set(['pending', 'confirmed', 'expired', 'cancelled']);
|
||||||
|
if (allowedStatus.has(status)) {
|
||||||
|
where.push('r.status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = Number(options.userId);
|
||||||
|
if (Number.isFinite(uid) && uid > 0) {
|
||||||
|
where.push('r.user_id = ?');
|
||||||
|
params.push(Math.floor(uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyword = typeof options.keyword === 'string' ? options.keyword.trim() : '';
|
||||||
|
if (keyword) {
|
||||||
|
const likeValue = `%${keyword}%`;
|
||||||
|
where.push('(u.username LIKE ? OR COALESCE(r.object_key, \'\') LIKE ? OR r.source LIKE ?)');
|
||||||
|
params.push(likeValue, likeValue, likeValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereSql = where.join(' AND ');
|
||||||
|
const page = Math.max(1, Math.floor(Number(options.page) || 1));
|
||||||
|
const pageSize = Math.min(200, Math.max(1, Math.floor(Number(options.pageSize) || 20)));
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
const totalRow = db.prepare(`
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM user_download_traffic_reservations r
|
||||||
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
|
WHERE ${whereSql}
|
||||||
|
`).get(...params);
|
||||||
|
const total = Math.max(0, Number(totalRow?.total || 0));
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const safeOffset = (safePage - 1) * pageSize;
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
r.*,
|
||||||
|
u.username
|
||||||
|
FROM user_download_traffic_reservations r
|
||||||
|
LEFT JOIN users u ON u.id = r.user_id
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY r.created_at DESC, r.id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(...params, pageSize, safeOffset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
pagination: {
|
||||||
|
page: safePage,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
getAdminSummary() {
|
||||||
|
const statusRows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
status,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
COALESCE(SUM(remaining_bytes), 0) AS remaining_bytes,
|
||||||
|
COALESCE(SUM(reserved_bytes), 0) AS reserved_bytes
|
||||||
|
FROM user_download_traffic_reservations
|
||||||
|
GROUP BY status
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
total: 0,
|
||||||
|
pending: 0,
|
||||||
|
confirmed: 0,
|
||||||
|
expired: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
pending_remaining_bytes: 0,
|
||||||
|
pending_reserved_bytes: 0,
|
||||||
|
pending_expiring_soon: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of statusRows) {
|
||||||
|
const status = String(row.status || '').trim();
|
||||||
|
const count = Number(row.count || 0);
|
||||||
|
const remaining = Number(row.remaining_bytes || 0);
|
||||||
|
const reserved = Number(row.reserved_bytes || 0);
|
||||||
|
|
||||||
|
summary.total += count;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(summary, status)) {
|
||||||
|
summary[status] = count;
|
||||||
|
}
|
||||||
|
if (status === 'pending') {
|
||||||
|
summary.pending_remaining_bytes = Math.max(0, remaining);
|
||||||
|
summary.pending_reserved_bytes = Math.max(0, reserved);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiringSoonRow = db.prepare(`
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM user_download_traffic_reservations
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND expires_at <= datetime('now', 'localtime', '+5 minutes')
|
||||||
|
`).get();
|
||||||
|
summary.pending_expiring_soon = Number(expiringSoonRow?.count || 0);
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelPendingById(id) {
|
||||||
|
const rid = Number(id);
|
||||||
|
if (!Number.isFinite(rid) || rid <= 0) {
|
||||||
|
return { changes: 0, row: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM user_download_traffic_reservations
|
||||||
|
WHERE id = ?
|
||||||
|
AND status = 'pending'
|
||||||
|
`).get(Math.floor(rid));
|
||||||
|
if (!row) {
|
||||||
|
return { changes: 0, row: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db.prepare(`
|
||||||
|
UPDATE user_download_traffic_reservations
|
||||||
|
SET status = 'cancelled',
|
||||||
|
remaining_bytes = 0,
|
||||||
|
finalized_at = datetime('now', 'localtime'),
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE id = ?
|
||||||
|
AND status = 'pending'
|
||||||
|
`).run(Math.floor(rid));
|
||||||
|
|
||||||
|
return {
|
||||||
|
changes: Number(result?.changes || 0),
|
||||||
|
row: db.prepare('SELECT * FROM user_download_traffic_reservations WHERE id = ?').get(Math.floor(rid))
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanupFinalizedHistory(keepDays = 7) {
|
||||||
|
const days = Math.min(365, Math.max(1, Math.floor(Number(keepDays) || 7)));
|
||||||
|
return db.prepare(`
|
||||||
|
DELETE FROM user_download_traffic_reservations
|
||||||
|
WHERE status IN ('confirmed', 'expired', 'cancelled')
|
||||||
|
AND updated_at < datetime('now', 'localtime', '-' || ? || ' days')
|
||||||
|
`).run(days);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const UploadSessionDB = {
|
||||||
|
_normalizeSessionId(sessionId) {
|
||||||
|
return typeof sessionId === 'string' ? sessionId.trim() : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
parseUploadedChunks(chunksText) {
|
||||||
|
const raw = typeof chunksText === 'string' ? chunksText.trim() : '';
|
||||||
|
if (!raw) return [];
|
||||||
|
const set = new Set();
|
||||||
|
for (const part of raw.split(',')) {
|
||||||
|
const idx = Number(part);
|
||||||
|
if (Number.isFinite(idx) && idx >= 0) {
|
||||||
|
set.add(Math.floor(idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set).sort((a, b) => a - b);
|
||||||
|
},
|
||||||
|
|
||||||
|
formatUploadedChunks(chunks = []) {
|
||||||
|
if (!Array.isArray(chunks) || chunks.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const set = new Set();
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const idx = Number(chunk);
|
||||||
|
if (Number.isFinite(idx) && idx >= 0) {
|
||||||
|
set.add(Math.floor(idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set).sort((a, b) => a - b).join(',');
|
||||||
|
},
|
||||||
|
|
||||||
|
create(data = {}) {
|
||||||
|
const sessionId = this._normalizeSessionId(data.sessionId);
|
||||||
|
const userId = Number(data.userId);
|
||||||
|
const storageType = typeof data.storageType === 'string' ? data.storageType : 'local';
|
||||||
|
const targetPath = typeof data.targetPath === 'string' ? data.targetPath : '';
|
||||||
|
const fileName = typeof data.fileName === 'string' ? data.fileName : '';
|
||||||
|
const fileSize = Math.max(0, Math.floor(Number(data.fileSize) || 0));
|
||||||
|
const chunkSize = Math.max(1, Math.floor(Number(data.chunkSize) || 1));
|
||||||
|
const totalChunks = Math.max(1, Math.floor(Number(data.totalChunks) || 1));
|
||||||
|
const uploadedChunks = this.formatUploadedChunks(data.uploadedChunks || []);
|
||||||
|
const uploadedBytes = Math.max(0, Math.floor(Number(data.uploadedBytes) || 0));
|
||||||
|
const tempFilePath = typeof data.tempFilePath === 'string' ? data.tempFilePath : '';
|
||||||
|
const fileHash = typeof data.fileHash === 'string' ? data.fileHash : null;
|
||||||
|
const expiresAt = typeof data.expiresAt === 'string' ? data.expiresAt : null;
|
||||||
|
|
||||||
|
if (!sessionId || !Number.isFinite(userId) || userId <= 0 || !targetPath || !fileName || !tempFilePath || !expiresAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO upload_sessions (
|
||||||
|
session_id, user_id, storage_type, target_path, file_name,
|
||||||
|
file_size, chunk_size, total_chunks, uploaded_chunks, uploaded_bytes,
|
||||||
|
temp_file_path, file_hash, status, expires_at, created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, 'active', ?, datetime('now', 'localtime'), datetime('now', 'localtime')
|
||||||
|
)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
storage_type = excluded.storage_type,
|
||||||
|
target_path = excluded.target_path,
|
||||||
|
file_name = excluded.file_name,
|
||||||
|
file_size = excluded.file_size,
|
||||||
|
chunk_size = excluded.chunk_size,
|
||||||
|
total_chunks = excluded.total_chunks,
|
||||||
|
uploaded_chunks = excluded.uploaded_chunks,
|
||||||
|
uploaded_bytes = excluded.uploaded_bytes,
|
||||||
|
temp_file_path = excluded.temp_file_path,
|
||||||
|
file_hash = excluded.file_hash,
|
||||||
|
status = 'active',
|
||||||
|
expires_at = excluded.expires_at,
|
||||||
|
completed_at = NULL,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
`).run(
|
||||||
|
sessionId,
|
||||||
|
Math.floor(userId),
|
||||||
|
storageType,
|
||||||
|
targetPath,
|
||||||
|
fileName,
|
||||||
|
fileSize,
|
||||||
|
chunkSize,
|
||||||
|
totalChunks,
|
||||||
|
uploadedChunks,
|
||||||
|
uploadedBytes,
|
||||||
|
tempFilePath,
|
||||||
|
fileHash,
|
||||||
|
expiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findBySessionId(sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
findBySessionId(sessionId) {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
if (!sid) return null;
|
||||||
|
return db.prepare('SELECT * FROM upload_sessions WHERE session_id = ?').get(sid);
|
||||||
|
},
|
||||||
|
|
||||||
|
findActiveForResume(userId, targetPath, fileSize, fileHash = null) {
|
||||||
|
const uid = Number(userId);
|
||||||
|
const safePath = typeof targetPath === 'string' ? targetPath : '';
|
||||||
|
const size = Math.floor(Number(fileSize) || 0);
|
||||||
|
const hash = typeof fileHash === 'string' ? fileHash.trim() : '';
|
||||||
|
if (!Number.isFinite(uid) || uid <= 0 || !safePath || size <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM upload_sessions
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND target_path = ?
|
||||||
|
AND file_size = ?
|
||||||
|
AND status = 'active'
|
||||||
|
AND expires_at > datetime('now', 'localtime')
|
||||||
|
AND COALESCE(file_hash, '') = ?
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(Math.floor(uid), safePath, size, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM upload_sessions
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND target_path = ?
|
||||||
|
AND file_size = ?
|
||||||
|
AND status = 'active'
|
||||||
|
AND expires_at > datetime('now', 'localtime')
|
||||||
|
ORDER BY updated_at DESC, created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(Math.floor(uid), safePath, size);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateProgress(sessionId, uploadedChunks = [], uploadedBytes = 0, expiresAt = null) {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
if (!sid) return { changes: 0 };
|
||||||
|
const chunksText = this.formatUploadedChunks(uploadedChunks);
|
||||||
|
const bytes = Math.max(0, Math.floor(Number(uploadedBytes) || 0));
|
||||||
|
|
||||||
|
if (typeof expiresAt === 'string' && expiresAt) {
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE upload_sessions
|
||||||
|
SET uploaded_chunks = ?,
|
||||||
|
uploaded_bytes = ?,
|
||||||
|
expires_at = ?,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
`).run(chunksText, bytes, expiresAt, sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE upload_sessions
|
||||||
|
SET uploaded_chunks = ?,
|
||||||
|
uploaded_bytes = ?,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
`).run(chunksText, bytes, sid);
|
||||||
|
},
|
||||||
|
|
||||||
|
setStatus(sessionId, status, { completed = false, expiresAt = null } = {}) {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
const safeStatus = typeof status === 'string' ? status : '';
|
||||||
|
if (!sid || !safeStatus) return { changes: 0 };
|
||||||
|
|
||||||
|
if (expiresAt && completed) {
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE upload_sessions
|
||||||
|
SET status = ?,
|
||||||
|
expires_at = ?,
|
||||||
|
completed_at = datetime('now', 'localtime'),
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
`).run(safeStatus, expiresAt, sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt) {
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE upload_sessions
|
||||||
|
SET status = ?,
|
||||||
|
expires_at = ?,
|
||||||
|
completed_at = CASE WHEN ? = 1 THEN datetime('now', 'localtime') ELSE completed_at END,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
`).run(safeStatus, expiresAt, completed ? 1 : 0, sid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE upload_sessions
|
||||||
|
SET status = ?,
|
||||||
|
completed_at = CASE WHEN ? = 1 THEN datetime('now', 'localtime') ELSE completed_at END,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
`).run(safeStatus, completed ? 1 : 0, sid);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete(sessionId) {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
if (!sid) return { changes: 0 };
|
||||||
|
return db.prepare('DELETE FROM upload_sessions WHERE session_id = ?').run(sid);
|
||||||
|
},
|
||||||
|
|
||||||
|
listExpiredActive(limit = 100) {
|
||||||
|
const safeLimit = Math.min(500, Math.max(1, Math.floor(Number(limit) || 100)));
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM upload_sessions
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND expires_at <= datetime('now', 'localtime')
|
||||||
|
ORDER BY expires_at ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(safeLimit);
|
||||||
|
},
|
||||||
|
|
||||||
|
expireSession(sessionId) {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
if (!sid) return { changes: 0 };
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE upload_sessions
|
||||||
|
SET status = 'expired',
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND status = 'active'
|
||||||
|
`).run(sid);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileHashIndexDB = {
|
||||||
|
normalizeStorageType(storageType) {
|
||||||
|
return storageType === 'oss' ? 'oss' : 'local';
|
||||||
|
},
|
||||||
|
|
||||||
|
upsert(entry = {}) {
|
||||||
|
const userId = Number(entry.userId);
|
||||||
|
const storageType = this.normalizeStorageType(entry.storageType);
|
||||||
|
const fileHash = typeof entry.fileHash === 'string' ? entry.fileHash.trim() : '';
|
||||||
|
const fileSize = Math.max(0, Math.floor(Number(entry.fileSize) || 0));
|
||||||
|
const filePath = typeof entry.filePath === 'string' ? entry.filePath : '';
|
||||||
|
const objectKey = typeof entry.objectKey === 'string' && entry.objectKey ? entry.objectKey : null;
|
||||||
|
|
||||||
|
if (!Number.isFinite(userId) || userId <= 0 || !fileHash || fileSize <= 0 || !filePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
INSERT INTO user_file_hash_index (
|
||||||
|
user_id, storage_type, file_hash, file_size, file_path, object_key, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'))
|
||||||
|
ON CONFLICT(user_id, storage_type, file_hash, file_size, file_path)
|
||||||
|
DO UPDATE SET
|
||||||
|
object_key = excluded.object_key,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
`).run(
|
||||||
|
Math.floor(userId),
|
||||||
|
storageType,
|
||||||
|
fileHash,
|
||||||
|
fileSize,
|
||||||
|
filePath,
|
||||||
|
objectKey
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
findLatestByHash(userId, storageType, fileHash, fileSize) {
|
||||||
|
const uid = Number(userId);
|
||||||
|
const normalizedStorage = this.normalizeStorageType(storageType);
|
||||||
|
const hash = typeof fileHash === 'string' ? fileHash.trim() : '';
|
||||||
|
const size = Math.max(0, Math.floor(Number(fileSize) || 0));
|
||||||
|
if (!Number.isFinite(uid) || uid <= 0 || !hash || size <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM user_file_hash_index
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND storage_type = ?
|
||||||
|
AND file_hash = ?
|
||||||
|
AND file_size = ?
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(Math.floor(uid), normalizedStorage, hash, size);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteByPath(userId, storageType, filePath) {
|
||||||
|
const uid = Number(userId);
|
||||||
|
const normalizedStorage = this.normalizeStorageType(storageType);
|
||||||
|
const safePath = typeof filePath === 'string' ? filePath : '';
|
||||||
|
if (!Number.isFinite(uid) || uid <= 0 || !safePath) {
|
||||||
|
return { changes: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
DELETE FROM user_file_hash_index
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND storage_type = ?
|
||||||
|
AND file_path = ?
|
||||||
|
`).run(Math.floor(uid), normalizedStorage, safePath);
|
||||||
|
},
|
||||||
|
|
||||||
|
getByPath(userId, storageType, filePath) {
|
||||||
|
const uid = Number(userId);
|
||||||
|
const normalizedStorage = this.normalizeStorageType(storageType);
|
||||||
|
const safePath = typeof filePath === 'string' ? filePath : '';
|
||||||
|
if (!Number.isFinite(uid) || uid <= 0 || !safePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM user_file_hash_index
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND storage_type = ?
|
||||||
|
AND file_path = ?
|
||||||
|
ORDER BY updated_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`).get(Math.floor(uid), normalizedStorage, safePath);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2284,6 +2869,8 @@ module.exports = {
|
|||||||
PasswordResetTokenDB,
|
PasswordResetTokenDB,
|
||||||
DownloadTrafficReportDB,
|
DownloadTrafficReportDB,
|
||||||
DownloadTrafficReservationDB,
|
DownloadTrafficReservationDB,
|
||||||
|
UploadSessionDB,
|
||||||
|
FileHashIndexDB,
|
||||||
DownloadTrafficIngestDB,
|
DownloadTrafficIngestDB,
|
||||||
SystemLogDB,
|
SystemLogDB,
|
||||||
TransactionDB,
|
TransactionDB,
|
||||||
|
|||||||
1492
backend/server.js
1492
backend/server.js
File diff suppressed because it is too large
Load Diff
@@ -1969,6 +1969,68 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="margin: 10px 0 14px 0; position: relative;">
|
||||||
|
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-input"
|
||||||
|
v-model="globalSearchKeyword"
|
||||||
|
@input="triggerGlobalSearch"
|
||||||
|
@focus="globalSearchVisible = !!globalSearchKeyword"
|
||||||
|
placeholder="全局搜索文件名(跨全部目录)"
|
||||||
|
style="flex: 1; min-width: 220px;">
|
||||||
|
<select class="form-input" v-model="globalSearchType" @change="runGlobalSearch" style="width: 120px;">
|
||||||
|
<option value="all">全部</option>
|
||||||
|
<option value="file">仅文件</option>
|
||||||
|
<option value="directory">仅文件夹</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-secondary" @click="runGlobalSearch" :disabled="globalSearchLoading" style="min-width: 86px;">
|
||||||
|
<i :class="globalSearchLoading ? 'fas fa-spinner fa-spin' : 'fas fa-search'"></i>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="clearGlobalSearch()" style="min-width: 72px;">
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="globalSearchVisible" style="position: absolute; left: 0; right: 0; top: calc(100% + 8px); z-index: 30; background: var(--bg-card); border: 1px solid var(--glass-border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,0.22); max-height: 320px; overflow: auto;">
|
||||||
|
<div v-if="globalSearchLoading" style="padding: 12px; color: var(--text-secondary);">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> 正在搜索...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="globalSearchError" style="padding: 12px; color: #ef4444;">
|
||||||
|
<i class="fas fa-circle-exclamation"></i> {{ globalSearchError }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="globalSearchResults.length === 0" style="padding: 12px; color: var(--text-secondary);">
|
||||||
|
暂无匹配结果
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<button
|
||||||
|
v-for="item in globalSearchResults"
|
||||||
|
:key="item.path"
|
||||||
|
class="btn"
|
||||||
|
@click="jumpToSearchResult(item)"
|
||||||
|
style="display: block; width: 100%; border: none; border-bottom: 1px solid var(--glass-border); border-radius: 0; text-align: left; background: transparent; padding: 10px 12px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; gap: 12px; align-items: center;">
|
||||||
|
<div style="min-width: 0;">
|
||||||
|
<div style="font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
<i class="fas" :class="item.isDirectory ? 'fa-folder' : 'fa-file'" style="margin-right: 6px; color: #667eea;"></i>
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||||
|
{{ item.path }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 12px; color: var(--text-muted); white-space: nowrap;">
|
||||||
|
{{ item.isDirectory ? '文件夹' : (item.sizeFormatted || '-') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<div v-if="globalSearchMeta?.truncated" style="padding: 10px 12px; font-size: 12px; color: #f59e0b;">
|
||||||
|
结果已截断,请缩小关键词范围
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div @dragenter="handleDragEnter" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop" class="files-container" :class="{ 'drag-over': isDragging }">
|
<div @dragenter="handleDragEnter" @dragover="handleDragOver" @dragleave="handleDragLeave" @drop="handleDrop" class="files-container" :class="{ 'drag-over': isDragging }">
|
||||||
<div v-if="files.length === 0" class="empty-hint files-empty-state">
|
<div v-if="files.length === 0" class="empty-hint files-empty-state">
|
||||||
<i class="fas fa-folder-open"></i>
|
<i class="fas fa-folder-open"></i>
|
||||||
@@ -2209,6 +2271,49 @@
|
|||||||
<label class="form-label">自定义天数</label>
|
<label class="form-label">自定义天数</label>
|
||||||
<input type="number" class="form-input" v-model.number="shareFileForm.customDays" min="1" max="365">
|
<input type="number" class="form-input" v-model.number="shareFileForm.customDays" min="1" max="365">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top: 12px;">
|
||||||
|
<label class="share-password-toggle">
|
||||||
|
<input type="checkbox" v-model="shareFileForm.enableAdvancedSecurity">
|
||||||
|
<span>{{ shareFileForm.enableAdvancedSecurity ? '已启用高级安全策略' : '高级安全策略(可选)' }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="shareFileForm.enableAdvancedSecurity" style="padding: 12px; border: 1px dashed var(--glass-border); border-radius: 10px; margin-top: 8px;">
|
||||||
|
<div class="form-group" style="margin-bottom: 10px;">
|
||||||
|
<label class="share-password-toggle">
|
||||||
|
<input type="checkbox" v-model="shareFileForm.maxDownloadsEnabled">
|
||||||
|
<span>限制下载次数</span>
|
||||||
|
</label>
|
||||||
|
<input v-if="shareFileForm.maxDownloadsEnabled" type="number" class="form-input" v-model.number="shareFileForm.maxDownloads" min="1" max="1000000" style="margin-top: 8px;" placeholder="例如 10 次">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 10px;">
|
||||||
|
<label class="form-label">IP 白名单(可选)</label>
|
||||||
|
<textarea class="form-input" v-model="shareFileForm.ipWhitelist" rows="2" placeholder="支持逗号/空格分隔,例如:1.2.3.4, 5.6.7.*"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 10px;">
|
||||||
|
<label class="form-label">设备限制</label>
|
||||||
|
<select class="form-input" v-model="shareFileForm.deviceLimit">
|
||||||
|
<option value="all">全部设备</option>
|
||||||
|
<option value="mobile">仅移动端</option>
|
||||||
|
<option value="desktop">仅桌面端</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 0;">
|
||||||
|
<label class="share-password-toggle">
|
||||||
|
<input type="checkbox" v-model="shareFileForm.accessTimeEnabled">
|
||||||
|
<span>限制访问时段</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="shareFileForm.accessTimeEnabled" style="display: flex; gap: 8px; margin-top: 8px;">
|
||||||
|
<input type="time" class="form-input" v-model="shareFileForm.accessTimeStart" style="flex: 1;">
|
||||||
|
<input type="time" class="form-input" v-model="shareFileForm.accessTimeEnd" style="flex: 1;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="shareResult" class="share-success-panel" style="margin-top: 15px;">
|
<div v-if="shareResult" class="share-success-panel" style="margin-top: 15px;">
|
||||||
<div class="share-success-head">
|
<div class="share-success-head">
|
||||||
<i class="fas fa-circle-check"></i>
|
<i class="fas fa-circle-check"></i>
|
||||||
@@ -3591,6 +3696,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 下载预扣运维面板 -->
|
||||||
|
<div class="card" style="margin-bottom: 30px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px;">
|
||||||
|
<h3 style="margin: 0;"><i class="fas fa-gauge-high"></i> 下载预扣运维面板</h3>
|
||||||
|
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button class="btn btn-secondary" @click="cleanupReservations" :disabled="reservationMonitor.cleaning">
|
||||||
|
<i :class="reservationMonitor.cleaning ? 'fas fa-spinner fa-spin' : 'fas fa-broom'"></i>
|
||||||
|
{{ reservationMonitor.cleaning ? '清理中...' : '清理历史' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" @click="loadDownloadReservationMonitor(reservationMonitor.page)" :disabled="reservationMonitor.loading">
|
||||||
|
<i :class="reservationMonitor.loading ? 'fas fa-spinner fa-spin' : 'fas fa-sync'"></i>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px;">
|
||||||
|
<select class="form-input" v-model="reservationMonitor.filters.status" @change="loadDownloadReservationMonitor(1)" style="width: 140px;">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="pending">待确认</option>
|
||||||
|
<option value="confirmed">已确认</option>
|
||||||
|
<option value="expired">已过期</option>
|
||||||
|
<option value="cancelled">已取消</option>
|
||||||
|
</select>
|
||||||
|
<input class="form-input" v-model="reservationMonitor.filters.userId" @keyup.enter="loadDownloadReservationMonitor(1)" placeholder="用户ID" style="width: 120px;">
|
||||||
|
<input class="form-input" v-model="reservationMonitor.filters.keyword" @input="triggerReservationKeywordSearch" placeholder="用户名 / 对象Key / 来源" style="flex: 1; min-width: 220px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; font-size: 12px;">
|
||||||
|
<span class="share-chip info">总数 {{ reservationMonitor.summary?.total || 0 }}</span>
|
||||||
|
<span class="share-chip warn">待确认 {{ reservationMonitor.summary?.pending || 0 }}</span>
|
||||||
|
<span class="share-chip success">已确认 {{ reservationMonitor.summary?.confirmed || 0 }}</span>
|
||||||
|
<span class="share-chip danger">已过期 {{ reservationMonitor.summary?.expired || 0 }}</span>
|
||||||
|
<span class="share-chip info">待确认剩余 {{ formatBytes(reservationMonitor.summary?.pending_remaining_bytes || 0) }}</span>
|
||||||
|
<span class="share-chip info">即将过期 {{ reservationMonitor.summary?.pending_expiring_soon || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reservationMonitor.loading" style="padding: 24px; text-align: center; color: var(--text-muted);">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> 正在加载预扣数据...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="reservationMonitor.rows.length === 0" style="padding: 24px; text-align: center; color: var(--text-muted);">
|
||||||
|
暂无预扣记录
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else style="overflow-x: auto;">
|
||||||
|
<table class="share-list-table" style="min-width: 920px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="padding: 8px;">ID</th>
|
||||||
|
<th style="padding: 8px;">用户</th>
|
||||||
|
<th style="padding: 8px;">来源</th>
|
||||||
|
<th style="padding: 8px;">已预扣</th>
|
||||||
|
<th style="padding: 8px;">剩余</th>
|
||||||
|
<th style="padding: 8px;">状态</th>
|
||||||
|
<th style="padding: 8px;">到期</th>
|
||||||
|
<th style="padding: 8px;">对象</th>
|
||||||
|
<th style="padding: 8px;">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in reservationMonitor.rows" :key="row.id">
|
||||||
|
<td style="padding: 8px; text-align: center;">{{ row.id }}</td>
|
||||||
|
<td style="padding: 8px; text-align: center;">
|
||||||
|
<div>#{{ row.user_id }}</div>
|
||||||
|
<div style="font-size: 12px; color: var(--text-secondary);">{{ row.username || '-' }}</div>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px; text-align: center;">{{ row.source || '-' }}</td>
|
||||||
|
<td style="padding: 8px; text-align: center;">{{ formatBytes(row.reserved_bytes || 0) }}</td>
|
||||||
|
<td style="padding: 8px; text-align: center;">{{ formatBytes(row.remaining_bytes || 0) }}</td>
|
||||||
|
<td style="padding: 8px; text-align: center; font-weight: 600;" :style="{ color: getReservationStatusColor(row.status) }">
|
||||||
|
{{ getReservationStatusText(row.status) }}
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px; text-align: center; white-space: nowrap;">{{ formatDate(row.expires_at) }}</td>
|
||||||
|
<td style="padding: 8px;">
|
||||||
|
<span style="display: inline-block; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="row.object_key || '-'">
|
||||||
|
{{ row.object_key || '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 8px; text-align: center;">
|
||||||
|
<button v-if="row.status === 'pending'" class="btn btn-secondary" @click="cancelReservation(row)" style="padding: 4px 10px; font-size: 12px;">
|
||||||
|
<i class="fas fa-ban"></i> 释放
|
||||||
|
</button>
|
||||||
|
<span v-else style="color: var(--text-muted); font-size: 12px;">-</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reservationMonitor.totalPages > 1" style="margin-top: 12px; display: flex; justify-content: center; gap: 8px;">
|
||||||
|
<button class="btn btn-secondary" @click="changeReservationPage(reservationMonitor.page - 1)" :disabled="reservationMonitor.page <= 1">
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span style="display: flex; align-items: center; color: var(--text-secondary);">
|
||||||
|
{{ reservationMonitor.page }} / {{ reservationMonitor.totalPages }}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-secondary" @click="changeReservationPage(reservationMonitor.page + 1)" :disabled="reservationMonitor.page >= reservationMonitor.totalPages">
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 系统日志 -->
|
<!-- 系统日志 -->
|
||||||
<div class="card" style="margin-bottom: 30px;">
|
<div class="card" style="margin-bottom: 30px;">
|
||||||
<div class="admin-log-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
<div class="admin-log-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||||
|
|||||||
472
frontend/app.js
472
frontend/app.js
@@ -103,7 +103,15 @@ createApp({
|
|||||||
enablePassword: false,
|
enablePassword: false,
|
||||||
password: "",
|
password: "",
|
||||||
expiryType: "never",
|
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,
|
shareResult: null,
|
||||||
showDirectLinkModal: false,
|
showDirectLinkModal: false,
|
||||||
@@ -150,6 +158,15 @@ createApp({
|
|||||||
isDragging: false,
|
isDragging: false,
|
||||||
modalMouseDownTarget: null, // 模态框鼠标按下的目标
|
modalMouseDownTarget: null, // 模态框鼠标按下的目标
|
||||||
|
|
||||||
|
// 全局搜索(文件页)
|
||||||
|
globalSearchKeyword: '',
|
||||||
|
globalSearchType: 'all', // all/file/directory
|
||||||
|
globalSearchLoading: false,
|
||||||
|
globalSearchError: '',
|
||||||
|
globalSearchResults: [],
|
||||||
|
globalSearchMeta: null,
|
||||||
|
globalSearchVisible: false,
|
||||||
|
|
||||||
// 管理员
|
// 管理员
|
||||||
adminUsers: [],
|
adminUsers: [],
|
||||||
adminUsersLoading: false,
|
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',
|
monitorTabLoading: initialAdminTab === 'monitor',
|
||||||
|
|
||||||
@@ -1542,6 +1576,7 @@ handleDragLeave(e) {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
// 确保路径不为undefined
|
// 确保路径不为undefined
|
||||||
this.currentPath = path || '/';
|
this.currentPath = path || '/';
|
||||||
|
this.globalSearchVisible = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${this.apiBase}/api/files`, {
|
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) {
|
async handleFileClick(file) {
|
||||||
// 修复:长按后会触发一次 click,需要忽略避免误打开文件/目录
|
// 修复:长按后会触发一次 click,需要忽略避免误打开文件/目录
|
||||||
if (this.longPressTriggered) {
|
if (this.longPressTriggered) {
|
||||||
@@ -2221,6 +2327,14 @@ handleDragLeave(e) {
|
|||||||
this.shareFileForm.password = '';
|
this.shareFileForm.password = '';
|
||||||
this.shareFileForm.expiryType = 'never';
|
this.shareFileForm.expiryType = 'never';
|
||||||
this.shareFileForm.customDays = 7;
|
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.shareResult = null; // 清空上次的分享结果
|
||||||
this.showShareFileModal = true;
|
this.showShareFileModal = true;
|
||||||
},
|
},
|
||||||
@@ -2267,6 +2381,45 @@ handleDragLeave(e) {
|
|||||||
return { valid: true, value: password };
|
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 = {}) {
|
buildShareResult(data, options = {}) {
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
@@ -2295,19 +2448,24 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
const expiryDays = expiryCheck.value;
|
const expiryDays = expiryCheck.value;
|
||||||
const password = passwordCheck.value;
|
const password = passwordCheck.value;
|
||||||
|
const securityCheck = this.buildShareSecurityPayload();
|
||||||
|
if (!securityCheck.valid) {
|
||||||
|
this.showToast('warning', '提示', securityCheck.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 根据是否为文件夹决定share_type
|
// 根据是否为文件夹决定share_type
|
||||||
const shareType = this.shareFileForm.isDirectory ? 'directory' : 'file';
|
const shareType = this.shareFileForm.isDirectory ? 'directory' : 'file';
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${this.apiBase}/api/share/create`,
|
`${this.apiBase}/api/share/create`,
|
||||||
{
|
Object.assign({
|
||||||
share_type: shareType, // 修复:文件夹使用directory类型
|
share_type: shareType, // 修复:文件夹使用directory类型
|
||||||
file_path: this.shareFileForm.filePath,
|
file_path: this.shareFileForm.filePath,
|
||||||
file_name: this.shareFileForm.fileName,
|
file_name: this.shareFileForm.fileName,
|
||||||
password,
|
password,
|
||||||
expiry_days: expiryDays
|
expiry_days: expiryDays
|
||||||
},
|
}, securityCheck.payload),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
@@ -2421,12 +2579,27 @@ handleDragLeave(e) {
|
|||||||
this.totalBytes = file.size;
|
this.totalBytes = file.size;
|
||||||
|
|
||||||
try {
|
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') {
|
if (this.storageType === 'oss' && this.user?.oss_config_source !== 'none') {
|
||||||
// ===== OSS 直连上传(不经过后端) =====
|
// ===== OSS 直连上传(不经过后端) =====
|
||||||
await this.uploadToOSSDirect(file);
|
await this.uploadToOSSDirect(file, fileHash);
|
||||||
} else {
|
} else {
|
||||||
// ===== 本地存储上传(经过后端) =====
|
// ===== 本地存储优先分片上传(断点续传) =====
|
||||||
await this.uploadToLocal(file);
|
const resumableOk = await this.uploadToLocalResumable(file, fileHash);
|
||||||
|
if (!resumableOk) {
|
||||||
|
await this.uploadToLocal(file, fileHash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上传失败:', 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 直连上传
|
// OSS 直连上传
|
||||||
async uploadToOSSDirect(file) {
|
async uploadToOSSDirect(file, fileHash = null) {
|
||||||
try {
|
try {
|
||||||
// 预检查 OSS 配额(后端也会做强校验,未配置默认 1GB)
|
// 预检查 OSS 配额(后端也会做强校验,未配置默认 1GB)
|
||||||
const ossQuota = Number(this.user?.oss_storage_quota || DEFAULT_OSS_STORAGE_QUOTA_BYTES);
|
const ossQuota = Number(this.user?.oss_storage_quota || DEFAULT_OSS_STORAGE_QUOTA_BYTES);
|
||||||
@@ -2458,7 +2708,8 @@ handleDragLeave(e) {
|
|||||||
filename: file.name,
|
filename: file.name,
|
||||||
path: this.currentPath,
|
path: this.currentPath,
|
||||||
contentType: file.type || 'application/octet-stream',
|
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;
|
const estimatedUsage = this.localUsed + file.size;
|
||||||
if (estimatedUsage > this.localQuota) {
|
if (estimatedUsage > this.localQuota) {
|
||||||
@@ -2527,12 +2871,15 @@ handleDragLeave(e) {
|
|||||||
this.uploadedBytes = 0;
|
this.uploadedBytes = 0;
|
||||||
this.totalBytes = 0;
|
this.totalBytes = 0;
|
||||||
this.uploadingFileName = '';
|
this.uploadingFileName = '';
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
formData.append('path', this.currentPath);
|
formData.append('path', this.currentPath);
|
||||||
|
if (fileHash) {
|
||||||
|
formData.append('file_hash', fileHash);
|
||||||
|
}
|
||||||
|
|
||||||
const response = await axios.post(`${this.apiBase}/api/upload`, formData, {
|
const response = await axios.post(`${this.apiBase}/api/upload`, formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
@@ -2553,6 +2900,7 @@ handleDragLeave(e) {
|
|||||||
await this.loadFiles(this.currentPath);
|
await this.loadFiles(this.currentPath);
|
||||||
await this.refreshStorageUsage();
|
await this.refreshStorageUsage();
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ===== 分享管理 =====
|
// ===== 分享管理 =====
|
||||||
@@ -3940,11 +4288,13 @@ handleDragLeave(e) {
|
|||||||
this.monitorTabLoading = true;
|
this.monitorTabLoading = true;
|
||||||
this.healthCheck.loading = true;
|
this.healthCheck.loading = true;
|
||||||
this.systemLogs.loading = true;
|
this.systemLogs.loading = true;
|
||||||
|
this.reservationMonitor.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.loadHealthCheck(),
|
this.loadHealthCheck(),
|
||||||
this.loadSystemLogs(1)
|
this.loadSystemLogs(1),
|
||||||
|
this.loadDownloadReservationMonitor(1)
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} 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