feat: add online device management and desktop settings integration
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { UserDB } = require('./database');
|
const { UserDB, DeviceSessionDB } = require('./database');
|
||||||
const { decryptSecret } = require('./utils/encryption');
|
const { decryptSecret } = require('./utils/encryption');
|
||||||
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
const DEFAULT_LOCAL_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||||
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
const DEFAULT_OSS_STORAGE_QUOTA_BYTES = 1024 * 1024 * 1024; // 1GB
|
||||||
@@ -64,13 +64,16 @@ if (JWT_SECRET.length < 32) {
|
|||||||
console.log('[安全] ✓ JWT密钥验证通过');
|
console.log('[安全] ✓ JWT密钥验证通过');
|
||||||
|
|
||||||
// 生成Access Token(短期)
|
// 生成Access Token(短期)
|
||||||
function generateToken(user) {
|
function generateToken(user, sessionId = null) {
|
||||||
|
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{
|
{
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
is_admin: user.is_admin,
|
is_admin: user.is_admin,
|
||||||
type: 'access'
|
type: 'access',
|
||||||
|
sid: safeSessionId,
|
||||||
|
jti: crypto.randomBytes(12).toString('hex')
|
||||||
},
|
},
|
||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
{ expiresIn: ACCESS_TOKEN_EXPIRES }
|
{ expiresIn: ACCESS_TOKEN_EXPIRES }
|
||||||
@@ -78,11 +81,13 @@ function generateToken(user) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成Refresh Token(长期)
|
// 生成Refresh Token(长期)
|
||||||
function generateRefreshToken(user) {
|
function generateRefreshToken(user, sessionId = null) {
|
||||||
|
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
||||||
return jwt.sign(
|
return jwt.sign(
|
||||||
{
|
{
|
||||||
id: user.id,
|
id: user.id,
|
||||||
type: 'refresh',
|
type: 'refresh',
|
||||||
|
sid: safeSessionId,
|
||||||
// 添加随机标识,使每次生成的refresh token不同
|
// 添加随机标识,使每次生成的refresh token不同
|
||||||
jti: crypto.randomBytes(16).toString('hex')
|
jti: crypto.randomBytes(16).toString('hex')
|
||||||
},
|
},
|
||||||
@@ -91,8 +96,26 @@ function generateRefreshToken(user) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decodeAccessToken(token) {
|
||||||
|
if (!token || typeof token !== 'string') return null;
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, JWT_SECRET);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeRefreshToken(token) {
|
||||||
|
if (!token || typeof token !== 'string') return null;
|
||||||
|
try {
|
||||||
|
return jwt.verify(token, REFRESH_SECRET);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证Refresh Token并返回新的Access Token
|
// 验证Refresh Token并返回新的Access Token
|
||||||
function refreshAccessToken(refreshToken) {
|
function refreshAccessToken(refreshToken, context = {}) {
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
|
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
|
||||||
|
|
||||||
@@ -100,6 +123,18 @@ function refreshAccessToken(refreshToken) {
|
|||||||
return { success: false, message: '无效的刷新令牌类型' };
|
return { success: false, message: '无效的刷新令牌类型' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
|
||||||
|
if (sessionId) {
|
||||||
|
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
|
||||||
|
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
|
||||||
|
return { success: false, message: '当前设备会话已失效,请重新登录' };
|
||||||
|
}
|
||||||
|
DeviceSessionDB.touch(sessionId, {
|
||||||
|
ipAddress: context.ipAddress,
|
||||||
|
userAgent: context.userAgent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const user = UserDB.findById(decoded.id);
|
const user = UserDB.findById(decoded.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -115,11 +150,12 @@ function refreshAccessToken(refreshToken) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 生成新的access token
|
// 生成新的access token
|
||||||
const newAccessToken = generateToken(user);
|
const newAccessToken = generateToken(user, sessionId || null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
token: newAccessToken,
|
token: newAccessToken,
|
||||||
|
sessionId: sessionId || null,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@@ -148,6 +184,29 @@ function authMiddleware(req, res, next) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, JWT_SECRET);
|
const decoded = jwt.verify(token, JWT_SECRET);
|
||||||
|
|
||||||
|
if (decoded.type !== 'access') {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '无效的令牌类型'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = typeof decoded.sid === 'string' ? decoded.sid.trim() : '';
|
||||||
|
if (sessionId) {
|
||||||
|
const activeSession = DeviceSessionDB.findActiveBySessionId(sessionId);
|
||||||
|
if (!activeSession || Number(activeSession.user_id) !== Number(decoded.id)) {
|
||||||
|
return res.status(401).json({
|
||||||
|
success: false,
|
||||||
|
message: '当前设备已下线,请重新登录'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
DeviceSessionDB.touch(sessionId, {
|
||||||
|
ipAddress: req.ip || req.socket?.remoteAddress || '',
|
||||||
|
userAgent: req.get('user-agent') || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const user = UserDB.findById(decoded.id);
|
const user = UserDB.findById(decoded.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -217,6 +276,8 @@ function authMiddleware(req, res, next) {
|
|||||||
// 主题偏好
|
// 主题偏好
|
||||||
theme_preference: user.theme_preference || null
|
theme_preference: user.theme_preference || null
|
||||||
};
|
};
|
||||||
|
req.authSessionId = sessionId || null;
|
||||||
|
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -329,6 +390,8 @@ module.exports = {
|
|||||||
JWT_SECRET,
|
JWT_SECRET,
|
||||||
generateToken,
|
generateToken,
|
||||||
generateRefreshToken,
|
generateRefreshToken,
|
||||||
|
decodeAccessToken,
|
||||||
|
decodeRefreshToken,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
adminMiddleware,
|
adminMiddleware,
|
||||||
|
|||||||
@@ -507,6 +507,27 @@ function initDatabase() {
|
|||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// 在线设备会话(用于设备管理和强制下线)
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_device_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
client_type TEXT NOT NULL DEFAULT 'web',
|
||||||
|
device_id TEXT,
|
||||||
|
device_name TEXT,
|
||||||
|
platform TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_active_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
revoked_at DATETIME,
|
||||||
|
revoked_reason TEXT,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
// 文件哈希索引(用于秒传)
|
// 文件哈希索引(用于秒传)
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS user_file_hash_index (
|
CREATE TABLE IF NOT EXISTS user_file_hash_index (
|
||||||
@@ -554,6 +575,12 @@ function initDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
|
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
|
||||||
ON upload_sessions(user_id, file_hash, file_size);
|
ON upload_sessions(user_id, file_hash, file_size);
|
||||||
|
|
||||||
|
-- 在线设备会话索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_device_sessions_user_active
|
||||||
|
ON user_device_sessions(user_id, revoked_at, expires_at, last_active_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_device_sessions_expires
|
||||||
|
ON user_device_sessions(expires_at);
|
||||||
|
|
||||||
-- 秒传索引
|
-- 秒传索引
|
||||||
CREATE INDEX IF NOT EXISTS idx_file_hash_index_lookup
|
CREATE INDEX IF NOT EXISTS idx_file_hash_index_lookup
|
||||||
ON user_file_hash_index(user_id, storage_type, file_hash, file_size);
|
ON user_file_hash_index(user_id, storage_type, file_hash, file_size);
|
||||||
@@ -2615,6 +2642,169 @@ const UploadSessionDB = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DeviceSessionDB = {
|
||||||
|
_normalizeSessionId(sessionId) {
|
||||||
|
return typeof sessionId === 'string' ? sessionId.trim() : '';
|
||||||
|
},
|
||||||
|
|
||||||
|
_normalizeText(value, maxLength = 255) {
|
||||||
|
if (typeof value !== 'string') return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
return trimmed.slice(0, maxLength);
|
||||||
|
},
|
||||||
|
|
||||||
|
_normalizeClientType(value) {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return 'web';
|
||||||
|
},
|
||||||
|
|
||||||
|
create(data = {}) {
|
||||||
|
const sessionId = this._normalizeSessionId(data.sessionId);
|
||||||
|
const userId = Number(data.userId);
|
||||||
|
const clientType = this._normalizeClientType(data.clientType);
|
||||||
|
const deviceId = this._normalizeText(data.deviceId, 128);
|
||||||
|
const deviceName = this._normalizeText(data.deviceName, 120);
|
||||||
|
const platform = this._normalizeText(data.platform, 80);
|
||||||
|
const ipAddress = this._normalizeText(data.ipAddress, 80);
|
||||||
|
const userAgent = this._normalizeText(data.userAgent, 1024);
|
||||||
|
const ttlDays = Math.max(1, Math.min(30, Number(data.ttlDays || 7)));
|
||||||
|
|
||||||
|
if (!sessionId || !Number.isFinite(userId) || userId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO user_device_sessions (
|
||||||
|
session_id, user_id, client_type, device_id, device_name,
|
||||||
|
platform, ip_address, user_agent, created_at, last_active_at,
|
||||||
|
expires_at, revoked_at, revoked_reason, updated_at
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, datetime('now', 'localtime'), datetime('now', 'localtime'),
|
||||||
|
datetime('now', 'localtime', '+' || ? || ' days'), NULL, NULL, datetime('now', 'localtime')
|
||||||
|
)
|
||||||
|
ON CONFLICT(session_id) DO UPDATE SET
|
||||||
|
user_id = excluded.user_id,
|
||||||
|
client_type = excluded.client_type,
|
||||||
|
device_id = excluded.device_id,
|
||||||
|
device_name = excluded.device_name,
|
||||||
|
platform = excluded.platform,
|
||||||
|
ip_address = excluded.ip_address,
|
||||||
|
user_agent = excluded.user_agent,
|
||||||
|
last_active_at = datetime('now', 'localtime'),
|
||||||
|
expires_at = datetime('now', 'localtime', '+' || ? || ' days'),
|
||||||
|
revoked_at = NULL,
|
||||||
|
revoked_reason = NULL,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
`).run(
|
||||||
|
sessionId,
|
||||||
|
Math.floor(userId),
|
||||||
|
clientType,
|
||||||
|
deviceId,
|
||||||
|
deviceName,
|
||||||
|
platform,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
ttlDays,
|
||||||
|
ttlDays
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.findBySessionId(sessionId);
|
||||||
|
},
|
||||||
|
|
||||||
|
findBySessionId(sessionId) {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
if (!sid) return null;
|
||||||
|
return db.prepare('SELECT * FROM user_device_sessions WHERE session_id = ?').get(sid);
|
||||||
|
},
|
||||||
|
|
||||||
|
findActiveBySessionId(sessionId) {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
if (!sid) return null;
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM user_device_sessions
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND expires_at > datetime('now', 'localtime')
|
||||||
|
LIMIT 1
|
||||||
|
`).get(sid);
|
||||||
|
},
|
||||||
|
|
||||||
|
listActiveByUser(userId, limit = 60) {
|
||||||
|
const uid = Number(userId);
|
||||||
|
if (!Number.isFinite(uid) || uid <= 0) return [];
|
||||||
|
const safeLimit = Math.max(1, Math.min(200, Math.floor(Number(limit) || 60)));
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM user_device_sessions
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND expires_at > datetime('now', 'localtime')
|
||||||
|
ORDER BY datetime(COALESCE(last_active_at, created_at)) DESC, created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(Math.floor(uid), safeLimit);
|
||||||
|
},
|
||||||
|
|
||||||
|
touch(sessionId, options = {}) {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
if (!sid) return { changes: 0 };
|
||||||
|
const ipAddress = this._normalizeText(options.ipAddress, 80);
|
||||||
|
const userAgent = this._normalizeText(options.userAgent, 1024);
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE user_device_sessions
|
||||||
|
SET ip_address = COALESCE(?, ip_address),
|
||||||
|
user_agent = COALESCE(?, user_agent),
|
||||||
|
last_active_at = datetime('now', 'localtime'),
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND expires_at > datetime('now', 'localtime')
|
||||||
|
`).run(ipAddress, userAgent, sid);
|
||||||
|
},
|
||||||
|
|
||||||
|
revoke(sessionId, userId = null, reason = 'manual') {
|
||||||
|
const sid = this._normalizeSessionId(sessionId);
|
||||||
|
if (!sid) return { changes: 0 };
|
||||||
|
const revokeReason = this._normalizeText(reason, 120) || 'manual';
|
||||||
|
if (userId !== null && userId !== undefined) {
|
||||||
|
const uid = Number(userId);
|
||||||
|
if (!Number.isFinite(uid) || uid <= 0) return { changes: 0 };
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE user_device_sessions
|
||||||
|
SET revoked_at = datetime('now', 'localtime'),
|
||||||
|
revoked_reason = ?,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND user_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
`).run(revokeReason, sid, Math.floor(uid));
|
||||||
|
}
|
||||||
|
return db.prepare(`
|
||||||
|
UPDATE user_device_sessions
|
||||||
|
SET revoked_at = datetime('now', 'localtime'),
|
||||||
|
revoked_reason = ?,
|
||||||
|
updated_at = datetime('now', 'localtime')
|
||||||
|
WHERE session_id = ?
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
`).run(revokeReason, sid);
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanupExpired(keepDays = 30) {
|
||||||
|
const safeDays = Math.max(1, Math.min(365, Math.floor(Number(keepDays) || 30)));
|
||||||
|
return db.prepare(`
|
||||||
|
DELETE FROM user_device_sessions
|
||||||
|
WHERE (expires_at <= datetime('now', 'localtime', '-' || ? || ' days'))
|
||||||
|
OR (revoked_at IS NOT NULL AND revoked_at <= datetime('now', 'localtime', '-' || ? || ' days'))
|
||||||
|
`).run(safeDays, safeDays);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const FileHashIndexDB = {
|
const FileHashIndexDB = {
|
||||||
normalizeStorageType(storageType) {
|
normalizeStorageType(storageType) {
|
||||||
return storageType === 'oss' ? 'oss' : 'local';
|
return storageType === 'oss' ? 'oss' : 'local';
|
||||||
@@ -2960,6 +3150,7 @@ module.exports = {
|
|||||||
DownloadTrafficReportDB,
|
DownloadTrafficReportDB,
|
||||||
DownloadTrafficReservationDB,
|
DownloadTrafficReservationDB,
|
||||||
UploadSessionDB,
|
UploadSessionDB,
|
||||||
|
DeviceSessionDB,
|
||||||
FileHashIndexDB,
|
FileHashIndexDB,
|
||||||
DownloadTrafficIngestDB,
|
DownloadTrafficIngestDB,
|
||||||
SystemLogDB,
|
SystemLogDB,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ const {
|
|||||||
DownloadTrafficReportDB,
|
DownloadTrafficReportDB,
|
||||||
DownloadTrafficReservationDB,
|
DownloadTrafficReservationDB,
|
||||||
UploadSessionDB,
|
UploadSessionDB,
|
||||||
|
DeviceSessionDB,
|
||||||
FileHashIndexDB,
|
FileHashIndexDB,
|
||||||
DownloadTrafficIngestDB,
|
DownloadTrafficIngestDB,
|
||||||
SystemLogDB,
|
SystemLogDB,
|
||||||
@@ -81,7 +82,17 @@ const {
|
|||||||
WalManager
|
WalManager
|
||||||
} = require('./database');
|
} = require('./database');
|
||||||
const StorageUsageCache = require('./utils/storage-cache');
|
const StorageUsageCache = require('./utils/storage-cache');
|
||||||
const { JWT_SECRET, generateToken, generateRefreshToken, refreshAccessToken, authMiddleware, adminMiddleware, isJwtSecretSecure } = require('./auth');
|
const {
|
||||||
|
JWT_SECRET,
|
||||||
|
generateToken,
|
||||||
|
generateRefreshToken,
|
||||||
|
decodeAccessToken,
|
||||||
|
decodeRefreshToken,
|
||||||
|
refreshAccessToken,
|
||||||
|
authMiddleware,
|
||||||
|
adminMiddleware,
|
||||||
|
isJwtSecretSecure
|
||||||
|
} = require('./auth');
|
||||||
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
|
const { StorageInterface, LocalStorageClient, OssStorageClient, formatFileSize, formatOssError } = require('./storage');
|
||||||
const { encryptSecret, decryptSecret } = require('./utils/encryption');
|
const { encryptSecret, decryptSecret } = require('./utils/encryption');
|
||||||
|
|
||||||
@@ -2083,6 +2094,34 @@ setTimeout(() => {
|
|||||||
cleanupExpiredUploadSessions('startup');
|
cleanupExpiredUploadSessions('startup');
|
||||||
}, 20 * 1000);
|
}, 20 * 1000);
|
||||||
|
|
||||||
|
const deviceSessionSweepTimer = setInterval(() => {
|
||||||
|
try {
|
||||||
|
const result = DeviceSessionDB.cleanupExpired(30);
|
||||||
|
const cleaned = Number(result?.changes || 0);
|
||||||
|
if (cleaned > 0) {
|
||||||
|
console.log(`[在线设备] 已清理过期会话 ${cleaned} 条`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[在线设备] 清理过期会话失败:', error);
|
||||||
|
}
|
||||||
|
}, 6 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
if (deviceSessionSweepTimer && typeof deviceSessionSweepTimer.unref === 'function') {
|
||||||
|
deviceSessionSweepTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const result = DeviceSessionDB.cleanupExpired(30);
|
||||||
|
const cleaned = Number(result?.changes || 0);
|
||||||
|
if (cleaned > 0) {
|
||||||
|
console.log(`[在线设备] 启动清理过期会话 ${cleaned} 条`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[在线设备] 启动清理过期会话失败:', error);
|
||||||
|
}
|
||||||
|
}, 25 * 1000);
|
||||||
|
|
||||||
// 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret)
|
// 构建用于存储客户端的用户对象(自动尝试解密 OSS Secret)
|
||||||
function buildStorageUserContext(user, overrides = {}) {
|
function buildStorageUserContext(user, overrides = {}) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -2852,6 +2891,85 @@ function detectDeviceTypeFromUserAgent(userAgent = '') {
|
|||||||
return mobilePattern.test(ua) ? 'mobile' : 'desktop';
|
return mobilePattern.test(ua) ? 'mobile' : 'desktop';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function inferPlatformFromUserAgent(userAgent = '') {
|
||||||
|
const ua = String(userAgent || '');
|
||||||
|
if (!ua) return '未知平台';
|
||||||
|
if (/windows/i.test(ua)) return 'Windows';
|
||||||
|
if (/macintosh|mac os x/i.test(ua)) return 'macOS';
|
||||||
|
if (/android/i.test(ua)) return 'Android';
|
||||||
|
if (/iphone|ipad|ios/i.test(ua)) return 'iOS';
|
||||||
|
if (/linux/i.test(ua)) return 'Linux';
|
||||||
|
return '未知平台';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeClientType(value = '') {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase();
|
||||||
|
if (['web', 'desktop', 'mobile', 'api'].includes(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveClientType(clientType, userAgent = '') {
|
||||||
|
const normalized = normalizeClientType(clientType);
|
||||||
|
if (normalized) return normalized;
|
||||||
|
const ua = String(userAgent || '').toLowerCase();
|
||||||
|
if (ua.includes('tauri') || ua.includes('electron') || ua.includes('wanwan-cloud-desktop')) {
|
||||||
|
return 'desktop';
|
||||||
|
}
|
||||||
|
return detectDeviceTypeFromUserAgent(ua) === 'mobile' ? 'mobile' : 'web';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeDeviceText(value, maxLength = 120) {
|
||||||
|
if (typeof value !== 'string') return '';
|
||||||
|
return value.trim().slice(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeviceName({ clientType, deviceName, platform }) {
|
||||||
|
const preferred = sanitizeDeviceText(deviceName, 120);
|
||||||
|
if (preferred) return preferred;
|
||||||
|
const platformText = sanitizeDeviceText(platform, 80) || '未知平台';
|
||||||
|
if (clientType === 'desktop') return `桌面客户端 · ${platformText}`;
|
||||||
|
if (clientType === 'mobile') return `移动端浏览器 · ${platformText}`;
|
||||||
|
if (clientType === 'api') return `API 客户端 · ${platformText}`;
|
||||||
|
return `网页端浏览器 · ${platformText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeviceSessionContext(req, payload = {}) {
|
||||||
|
const userAgent = sanitizeDeviceText(req.get('user-agent') || req.headers?.['user-agent'] || '', 1024);
|
||||||
|
const clientType = resolveClientType(payload.client_type || req.headers?.['x-client-type'], userAgent);
|
||||||
|
const platform = sanitizeDeviceText(payload.platform, 80) || inferPlatformFromUserAgent(userAgent);
|
||||||
|
return {
|
||||||
|
clientType,
|
||||||
|
deviceId: sanitizeDeviceText(payload.device_id, 128) || null,
|
||||||
|
deviceName: buildDeviceName({
|
||||||
|
clientType,
|
||||||
|
deviceName: payload.device_name,
|
||||||
|
platform
|
||||||
|
}),
|
||||||
|
platform,
|
||||||
|
ipAddress: sanitizeDeviceText(getClientIp(req), 80) || null,
|
||||||
|
userAgent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatOnlineDeviceRecord(row, currentSessionId = '') {
|
||||||
|
const sid = String(row?.session_id || '');
|
||||||
|
const isCurrent = !!currentSessionId && sid === currentSessionId;
|
||||||
|
return {
|
||||||
|
session_id: sid,
|
||||||
|
client_type: String(row?.client_type || 'web'),
|
||||||
|
device_name: String(row?.device_name || '未知设备'),
|
||||||
|
platform: String(row?.platform || ''),
|
||||||
|
ip_address: String(row?.ip_address || ''),
|
||||||
|
last_active_at: row?.last_active_at || row?.updated_at || row?.created_at || null,
|
||||||
|
created_at: row?.created_at || null,
|
||||||
|
expires_at: row?.expires_at || null,
|
||||||
|
is_current: isCurrent,
|
||||||
|
is_local: isCurrent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeTimeHHmm(value) {
|
function normalizeTimeHHmm(value) {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -3952,7 +4070,7 @@ app.post('/api/login',
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, password, captcha } = req.body;
|
const { username, password, captcha, client_type, device_id, device_name, platform } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查是否需要验证码
|
// 检查是否需要验证码
|
||||||
@@ -4085,8 +4203,32 @@ app.post('/api/login',
|
|||||||
user = loginPolicyState.user;
|
user = loginPolicyState.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = generateToken(user);
|
const deviceContext = buildDeviceSessionContext(req, {
|
||||||
const refreshToken = generateRefreshToken(user);
|
client_type,
|
||||||
|
device_id,
|
||||||
|
device_name,
|
||||||
|
platform
|
||||||
|
});
|
||||||
|
let sessionId = null;
|
||||||
|
try {
|
||||||
|
const createdSession = DeviceSessionDB.create({
|
||||||
|
sessionId: crypto.randomBytes(24).toString('hex'),
|
||||||
|
userId: user.id,
|
||||||
|
clientType: deviceContext.clientType,
|
||||||
|
deviceId: deviceContext.deviceId,
|
||||||
|
deviceName: deviceContext.deviceName,
|
||||||
|
platform: deviceContext.platform,
|
||||||
|
ipAddress: deviceContext.ipAddress,
|
||||||
|
userAgent: deviceContext.userAgent,
|
||||||
|
ttlDays: 7
|
||||||
|
});
|
||||||
|
sessionId = createdSession?.session_id || null;
|
||||||
|
} catch (sessionError) {
|
||||||
|
console.error('[在线设备] 创建登录会话失败:', sessionError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = generateToken(user, sessionId);
|
||||||
|
const refreshToken = generateRefreshToken(user, sessionId);
|
||||||
|
|
||||||
// 清除失败记录
|
// 清除失败记录
|
||||||
if (req.rateLimitKeys) {
|
if (req.rateLimitKeys) {
|
||||||
@@ -4173,7 +4315,10 @@ app.post('/api/refresh-token', (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = refreshAccessToken(refreshToken);
|
const result = refreshAccessToken(refreshToken, {
|
||||||
|
ipAddress: getClientIp(req),
|
||||||
|
userAgent: req.get('user-agent') || ''
|
||||||
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -4200,6 +4345,20 @@ app.post('/api/refresh-token', (req, res) => {
|
|||||||
|
|
||||||
// 登出(清除Cookie)
|
// 登出(清除Cookie)
|
||||||
app.post('/api/logout', (req, res) => {
|
app.post('/api/logout', (req, res) => {
|
||||||
|
const accessToken = req.headers.authorization?.replace('Bearer ', '') || req.cookies?.token || '';
|
||||||
|
const refreshToken = req.cookies?.refreshToken || '';
|
||||||
|
const accessPayload = decodeAccessToken(accessToken);
|
||||||
|
const refreshPayload = decodeRefreshToken(refreshToken);
|
||||||
|
const sessionId = String(accessPayload?.sid || refreshPayload?.sid || '').trim();
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
try {
|
||||||
|
DeviceSessionDB.revoke(sessionId, null, 'logout');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[在线设备] 登出时撤销会话失败:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 清除所有认证Cookie
|
// 清除所有认证Cookie
|
||||||
res.clearCookie('token', { path: '/' });
|
res.clearCookie('token', { path: '/' });
|
||||||
res.clearCookie('refreshToken', { path: '/' });
|
res.clearCookie('refreshToken', { path: '/' });
|
||||||
@@ -4246,6 +4405,87 @@ app.get('/api/user/profile', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 在线设备列表
|
||||||
|
app.get('/api/user/online-devices', authMiddleware, (req, res) => {
|
||||||
|
try {
|
||||||
|
const currentSessionId = String(req.authSessionId || '').trim();
|
||||||
|
const devices = DeviceSessionDB
|
||||||
|
.listActiveByUser(req.user.id, 80)
|
||||||
|
.map((row) => formatOnlineDeviceRecord(row, currentSessionId));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
devices,
|
||||||
|
current_session_id: currentSessionId || null
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取在线设备失败:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '获取在线设备失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 强制踢下线(可踢其它设备,也可踢当前设备)
|
||||||
|
app.post('/api/user/online-devices/:sessionId/kick', authMiddleware, (req, res) => {
|
||||||
|
try {
|
||||||
|
const sessionId = String(req.params.sessionId || '').trim();
|
||||||
|
if (!sessionId || sessionId.length < 16 || sessionId.length > 128) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: '会话标识无效'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = DeviceSessionDB.findBySessionId(sessionId);
|
||||||
|
if (!target || Number(target.user_id) !== Number(req.user.id)) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: '目标设备不存在'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.revoked_at) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: '设备已离线'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const revokeResult = DeviceSessionDB.revoke(sessionId, req.user.id, 'kicked_by_user');
|
||||||
|
if (!(Number(revokeResult?.changes || 0) > 0)) {
|
||||||
|
return res.status(409).json({
|
||||||
|
success: false,
|
||||||
|
message: '设备已离线'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const kickedCurrent = String(req.authSessionId || '') === sessionId;
|
||||||
|
if (kickedCurrent) {
|
||||||
|
res.clearCookie('token', { path: '/' });
|
||||||
|
res.clearCookie('refreshToken', { path: '/' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAuth(req, 'device_kick', `用户踢下线设备: ${sessionId}`, {
|
||||||
|
userId: req.user.id,
|
||||||
|
kickedCurrent
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
kicked_current: kickedCurrent,
|
||||||
|
message: kickedCurrent ? '当前设备已下线' : '设备已下线'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('踢设备下线失败:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: '踢设备下线失败'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 获取用户下载流量额度与报表
|
// 获取用户下载流量额度与报表
|
||||||
app.get('/api/user/download-traffic-report', authMiddleware, (req, res) => {
|
app.get('/api/user/download-traffic-report', authMiddleware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -113,6 +113,53 @@ fn join_api_url(base_url: &str, path: &str) -> String {
|
|||||||
format!("{}{}", normalize_base_url(base_url), path)
|
format!("{}{}", normalize_base_url(base_url), path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sanitize_device_id_component(raw: &str) -> String {
|
||||||
|
let mut output = String::new();
|
||||||
|
let mut last_is_dash = false;
|
||||||
|
for ch in raw.chars() {
|
||||||
|
let normalized = ch.to_ascii_lowercase();
|
||||||
|
if normalized.is_ascii_alphanumeric() {
|
||||||
|
output.push(normalized);
|
||||||
|
last_is_dash = false;
|
||||||
|
} else if !last_is_dash {
|
||||||
|
output.push('-');
|
||||||
|
last_is_dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output.trim_matches('-').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_desktop_client_meta() -> (String, String, String) {
|
||||||
|
let os = match env::consts::OS {
|
||||||
|
"windows" => "Windows",
|
||||||
|
"macos" => "macOS",
|
||||||
|
"linux" => "Linux",
|
||||||
|
other => other,
|
||||||
|
};
|
||||||
|
let platform = format!("{}-{}", os, env::consts::ARCH);
|
||||||
|
let host_name = env::var("COMPUTERNAME")
|
||||||
|
.or_else(|_| env::var("HOSTNAME"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let host_trimmed = host_name.trim();
|
||||||
|
let device_name = if host_trimmed.is_empty() {
|
||||||
|
format!("桌面客户端 · {}", platform)
|
||||||
|
} else {
|
||||||
|
format!("{} · {}", host_trimmed, platform)
|
||||||
|
};
|
||||||
|
let id_seed = if host_trimmed.is_empty() {
|
||||||
|
platform.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}-{}", host_trimmed, platform)
|
||||||
|
};
|
||||||
|
let normalized = sanitize_device_id_component(&id_seed);
|
||||||
|
let device_id = if normalized.is_empty() {
|
||||||
|
"desktop-client".to_string()
|
||||||
|
} else {
|
||||||
|
format!("desktop-{}", normalized)
|
||||||
|
};
|
||||||
|
(platform, device_name, device_id)
|
||||||
|
}
|
||||||
|
|
||||||
fn fallback_json(status: StatusCode, text: &str) -> Value {
|
fn fallback_json(status: StatusCode, text: &str) -> Value {
|
||||||
let mut data = Map::new();
|
let mut data = Map::new();
|
||||||
data.insert("success".to_string(), Value::Bool(status.is_success()));
|
data.insert("success".to_string(), Value::Bool(status.is_success()));
|
||||||
@@ -390,9 +437,14 @@ async fn api_login(
|
|||||||
password: String,
|
password: String,
|
||||||
captcha: Option<String>,
|
captcha: Option<String>,
|
||||||
) -> Result<BridgeResponse, String> {
|
) -> Result<BridgeResponse, String> {
|
||||||
|
let (platform, device_name, device_id) = build_desktop_client_meta();
|
||||||
let mut body = Map::new();
|
let mut body = Map::new();
|
||||||
body.insert("username".to_string(), Value::String(username));
|
body.insert("username".to_string(), Value::String(username));
|
||||||
body.insert("password".to_string(), Value::String(password));
|
body.insert("password".to_string(), Value::String(password));
|
||||||
|
body.insert("client_type".to_string(), Value::String("desktop".to_string()));
|
||||||
|
body.insert("platform".to_string(), Value::String(platform));
|
||||||
|
body.insert("device_name".to_string(), Value::String(device_name));
|
||||||
|
body.insert("device_id".to_string(), Value::String(device_id));
|
||||||
if let Some(value) = captcha {
|
if let Some(value) = captcha {
|
||||||
if !value.trim().is_empty() {
|
if !value.trim().is_empty() {
|
||||||
body.insert("captcha".to_string(), Value::String(value));
|
body.insert("captcha".to_string(), Value::String(value));
|
||||||
@@ -426,6 +478,50 @@ async fn api_get_profile(
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn api_list_online_devices(
|
||||||
|
state: tauri::State<'_, ApiState>,
|
||||||
|
base_url: String,
|
||||||
|
) -> Result<BridgeResponse, String> {
|
||||||
|
request_with_optional_csrf(
|
||||||
|
&state.client,
|
||||||
|
Method::GET,
|
||||||
|
&base_url,
|
||||||
|
"/api/user/online-devices",
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn api_kick_online_device(
|
||||||
|
state: tauri::State<'_, ApiState>,
|
||||||
|
base_url: String,
|
||||||
|
session_id: String,
|
||||||
|
) -> Result<BridgeResponse, String> {
|
||||||
|
let session = session_id.trim().to_string();
|
||||||
|
if session.is_empty() {
|
||||||
|
return Err("会话标识不能为空".to_string());
|
||||||
|
}
|
||||||
|
if session.len() > 128 {
|
||||||
|
return Err("会话标识长度无效".to_string());
|
||||||
|
}
|
||||||
|
let api_path = format!(
|
||||||
|
"/api/user/online-devices/{}/kick",
|
||||||
|
urlencoding::encode(&session)
|
||||||
|
);
|
||||||
|
request_with_optional_csrf(
|
||||||
|
&state.client,
|
||||||
|
Method::POST,
|
||||||
|
&base_url,
|
||||||
|
&api_path,
|
||||||
|
Some(Value::Object(Map::new())),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn api_save_login_state(
|
fn api_save_login_state(
|
||||||
base_url: String,
|
base_url: String,
|
||||||
@@ -1483,6 +1579,8 @@ pub fn run() {
|
|||||||
api_load_login_state,
|
api_load_login_state,
|
||||||
api_clear_login_state,
|
api_clear_login_state,
|
||||||
api_get_profile,
|
api_get_profile,
|
||||||
|
api_list_online_devices,
|
||||||
|
api_kick_online_device,
|
||||||
api_list_files,
|
api_list_files,
|
||||||
api_logout,
|
api_logout,
|
||||||
api_search_files,
|
api_search_files,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||||
|
|
||||||
type NavKey = "files" | "transfers" | "shares" | "sync" | "updates";
|
type NavKey = "files" | "transfers" | "shares" | "sync" | "settings";
|
||||||
|
|
||||||
type FileItem = {
|
type FileItem = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,6 +35,19 @@ type ShareItem = {
|
|||||||
storage_type?: string;
|
storage_type?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OnlineDeviceItem = {
|
||||||
|
session_id: string;
|
||||||
|
client_type?: string;
|
||||||
|
device_name?: string;
|
||||||
|
platform?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
last_active_at?: string;
|
||||||
|
created_at?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
is_current?: boolean;
|
||||||
|
is_local?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type BridgeResponse = {
|
type BridgeResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status: number;
|
status: number;
|
||||||
@@ -93,7 +106,6 @@ const loginForm = reactive({
|
|||||||
username: "",
|
username: "",
|
||||||
password: "",
|
password: "",
|
||||||
captcha: "",
|
captcha: "",
|
||||||
remember: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const loginState = reactive({
|
const loginState = reactive({
|
||||||
@@ -151,6 +163,13 @@ const updateState = reactive({
|
|||||||
lastCheckedAt: "",
|
lastCheckedAt: "",
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
|
const onlineDevices = reactive({
|
||||||
|
loading: false,
|
||||||
|
kickingSessionId: "",
|
||||||
|
items: [] as OnlineDeviceItem[],
|
||||||
|
message: "",
|
||||||
|
lastLoadedAt: "",
|
||||||
|
});
|
||||||
const updateRuntime = reactive({
|
const updateRuntime = reactive({
|
||||||
downloading: false,
|
downloading: false,
|
||||||
installing: false,
|
installing: false,
|
||||||
@@ -178,6 +197,11 @@ const shareDeleteDialog = reactive({
|
|||||||
loading: false,
|
loading: false,
|
||||||
share: null as ShareItem | null,
|
share: null as ShareItem | null,
|
||||||
});
|
});
|
||||||
|
const fileDeleteDialog = reactive({
|
||||||
|
visible: false,
|
||||||
|
loading: false,
|
||||||
|
file: null as FileItem | null,
|
||||||
|
});
|
||||||
const dropState = reactive({
|
const dropState = reactive({
|
||||||
active: false,
|
active: false,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
@@ -214,7 +238,7 @@ const navItems = computed(() => [
|
|||||||
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` },
|
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` },
|
||||||
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length} 条` },
|
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length} 条` },
|
||||||
{ key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置" },
|
{ key: "sync" as const, label: "同步盘", hint: syncState.localDir ? "已配置" : "未配置" },
|
||||||
{ key: "updates" as const, label: "版本更新", hint: updateState.available ? "有新版本" : "最新" },
|
{ key: "settings" as const, label: "设置", hint: updateState.available ? "发现新版本" : "系统与更新" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const sortedShares = computed(() => {
|
const sortedShares = computed(() => {
|
||||||
@@ -305,7 +329,7 @@ const toolbarCrumbs = computed(() => {
|
|||||||
transfers: "传输列表",
|
transfers: "传输列表",
|
||||||
shares: "我的分享",
|
shares: "我的分享",
|
||||||
sync: "同步盘",
|
sync: "同步盘",
|
||||||
updates: "版本更新",
|
settings: "设置",
|
||||||
};
|
};
|
||||||
return [{ label: "工作台", path: "/" }, { label: map[nav.value], path: "" }];
|
return [{ label: "工作台", path: "/" }, { label: map[nav.value], path: "" }];
|
||||||
});
|
});
|
||||||
@@ -858,7 +882,7 @@ async function confirmUpdateFromPrompt() {
|
|||||||
updatePrompt.loading = true;
|
updatePrompt.loading = true;
|
||||||
updatePrompt.visible = false;
|
updatePrompt.visible = false;
|
||||||
try {
|
try {
|
||||||
nav.value = "updates";
|
nav.value = "settings";
|
||||||
await installLatestUpdate();
|
await installLatestUpdate();
|
||||||
} finally {
|
} finally {
|
||||||
updatePrompt.loading = false;
|
updatePrompt.loading = false;
|
||||||
@@ -934,6 +958,61 @@ async function installLatestUpdate(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatOnlineDeviceType(value: string | undefined) {
|
||||||
|
const kind = String(value || "").trim().toLowerCase();
|
||||||
|
if (kind === "desktop") return "桌面端";
|
||||||
|
if (kind === "mobile") return "移动端";
|
||||||
|
if (kind === "api") return "API";
|
||||||
|
return "网页端";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOnlineDevices(silent = false) {
|
||||||
|
if (!silent) {
|
||||||
|
onlineDevices.loading = true;
|
||||||
|
}
|
||||||
|
onlineDevices.message = "";
|
||||||
|
const response = await invokeBridge("api_list_online_devices", {
|
||||||
|
baseUrl: appConfig.baseUrl,
|
||||||
|
});
|
||||||
|
if (response.ok && response.data?.success) {
|
||||||
|
onlineDevices.items = Array.isArray(response.data?.devices) ? response.data.devices : [];
|
||||||
|
onlineDevices.lastLoadedAt = new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
onlineDevices.message = String(response.data?.message || "加载在线设备失败");
|
||||||
|
if (!silent) {
|
||||||
|
showToast(onlineDevices.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onlineDevices.loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kickOnlineDevice(item: OnlineDeviceItem) {
|
||||||
|
const sessionId = String(item?.session_id || "").trim();
|
||||||
|
if (!sessionId || onlineDevices.kickingSessionId) return;
|
||||||
|
const tip = item?.is_current ? "确定要下线当前设备吗?下线后需要重新登录。" : "确定要强制该设备下线吗?";
|
||||||
|
const confirmed = window.confirm(tip);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
onlineDevices.kickingSessionId = sessionId;
|
||||||
|
const response = await invokeBridge("api_kick_online_device", {
|
||||||
|
baseUrl: appConfig.baseUrl,
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
onlineDevices.kickingSessionId = "";
|
||||||
|
|
||||||
|
if (response.ok && response.data?.success) {
|
||||||
|
showToast(String(response.data?.message || "设备已下线"), "success");
|
||||||
|
if (response.data?.kicked_current) {
|
||||||
|
await handleLogout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadOnlineDevices(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast(String(response.data?.message || "踢下线失败"), "error");
|
||||||
|
}
|
||||||
|
|
||||||
async function chooseSyncDirectory() {
|
async function chooseSyncDirectory() {
|
||||||
try {
|
try {
|
||||||
const result = await openDialog({
|
const result = await openDialog({
|
||||||
@@ -1254,6 +1333,24 @@ function closeDeleteShareDialog(force = false) {
|
|||||||
shareDeleteDialog.share = null;
|
shareDeleteDialog.share = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestDeleteFile(target?: FileItem | null) {
|
||||||
|
if (fileDeleteDialog.loading) return;
|
||||||
|
const current = target || selectedFile.value;
|
||||||
|
if (!current) {
|
||||||
|
showToast("请先选中文件或文件夹", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileDeleteDialog.file = current;
|
||||||
|
fileDeleteDialog.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDeleteFileDialog(force = false) {
|
||||||
|
if (fileDeleteDialog.loading && !force) return;
|
||||||
|
fileDeleteDialog.visible = false;
|
||||||
|
fileDeleteDialog.loading = false;
|
||||||
|
fileDeleteDialog.file = null;
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteShare(share: ShareItem) {
|
async function deleteShare(share: ShareItem) {
|
||||||
const response = await invokeBridge("api_delete_share", {
|
const response = await invokeBridge("api_delete_share", {
|
||||||
baseUrl: appConfig.baseUrl,
|
baseUrl: appConfig.baseUrl,
|
||||||
@@ -1285,6 +1382,25 @@ async function confirmDeleteShare() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function confirmDeleteFile() {
|
||||||
|
const file = fileDeleteDialog.file;
|
||||||
|
if (!file || fileDeleteDialog.loading) return;
|
||||||
|
|
||||||
|
fileDeleteDialog.loading = true;
|
||||||
|
try {
|
||||||
|
const ok = await deleteSelected(file, true);
|
||||||
|
if (ok) {
|
||||||
|
closeDeleteFileDialog(true);
|
||||||
|
showToast("删除成功", "success");
|
||||||
|
await loadFiles(pathState.currentPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fileDeleteDialog.loading = false;
|
||||||
|
} catch {
|
||||||
|
fileDeleteDialog.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyShareLink(share: ShareItem) {
|
async function copyShareLink(share: ShareItem) {
|
||||||
const url = String(share.share_url || "").trim();
|
const url = String(share.share_url || "").trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
@@ -1311,6 +1427,7 @@ async function restoreSession() {
|
|||||||
rebuildSyncScheduler();
|
rebuildSyncScheduler();
|
||||||
await loadFiles("/");
|
await loadFiles("/");
|
||||||
await loadShares(true);
|
await loadShares(true);
|
||||||
|
void loadOnlineDevices(true);
|
||||||
await checkUpdateAfterLogin();
|
await checkUpdateAfterLogin();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1348,13 +1465,13 @@ async function tryAutoLoginFromSavedState() {
|
|||||||
user.value = loginResponse.data.user || null;
|
user.value = loginResponse.data.user || null;
|
||||||
nav.value = "files";
|
nav.value = "files";
|
||||||
loginForm.password = savedPassword;
|
loginForm.password = savedPassword;
|
||||||
loginForm.remember = true;
|
|
||||||
loadSyncConfig();
|
loadSyncConfig();
|
||||||
rebuildSyncScheduler();
|
rebuildSyncScheduler();
|
||||||
await loadFiles("/");
|
await loadFiles("/");
|
||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
}
|
}
|
||||||
|
void loadOnlineDevices(true);
|
||||||
await checkUpdateAfterLogin();
|
await checkUpdateAfterLogin();
|
||||||
showToast("已恢复登录状态", "success");
|
showToast("已恢复登录状态", "success");
|
||||||
return true;
|
return true;
|
||||||
@@ -1427,15 +1544,12 @@ async function handleLogin() {
|
|||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
await loadProfile();
|
await loadProfile();
|
||||||
}
|
}
|
||||||
if (loginForm.remember) {
|
await invokeBridge("api_save_login_state", {
|
||||||
await invokeBridge("api_save_login_state", {
|
baseUrl: appConfig.baseUrl,
|
||||||
baseUrl: appConfig.baseUrl,
|
username: loginForm.username.trim(),
|
||||||
username: loginForm.username.trim(),
|
password: loginForm.password,
|
||||||
password: loginForm.password,
|
});
|
||||||
});
|
void loadOnlineDevices(true);
|
||||||
} else {
|
|
||||||
await invokeBridge("api_clear_login_state", {});
|
|
||||||
}
|
|
||||||
await checkUpdateAfterLogin();
|
await checkUpdateAfterLogin();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1472,6 +1586,14 @@ async function handleLogout() {
|
|||||||
updateRuntime.downloading = false;
|
updateRuntime.downloading = false;
|
||||||
updateRuntime.installing = false;
|
updateRuntime.installing = false;
|
||||||
updatePrompt.visible = false;
|
updatePrompt.visible = false;
|
||||||
|
fileDeleteDialog.visible = false;
|
||||||
|
fileDeleteDialog.loading = false;
|
||||||
|
fileDeleteDialog.file = null;
|
||||||
|
onlineDevices.loading = false;
|
||||||
|
onlineDevices.kickingSessionId = "";
|
||||||
|
onlineDevices.items = [];
|
||||||
|
onlineDevices.message = "";
|
||||||
|
onlineDevices.lastLoadedAt = "";
|
||||||
hasCheckedUpdateAfterAuth = false;
|
hasCheckedUpdateAfterAuth = false;
|
||||||
showToast("已退出客户端", "info");
|
showToast("已退出客户端", "info");
|
||||||
}
|
}
|
||||||
@@ -1519,11 +1641,11 @@ async function deleteSelected(target?: FileItem | null, silent = false) {
|
|||||||
const current = target || selectedFile.value;
|
const current = target || selectedFile.value;
|
||||||
if (!current) {
|
if (!current) {
|
||||||
if (!silent) showToast("请先选中文件或文件夹", "info");
|
if (!silent) showToast("请先选中文件或文件夹", "info");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
const confirmed = window.confirm(`确认删除「${current.displayName || current.name}」吗?`);
|
requestDeleteFile(current);
|
||||||
if (!confirmed) return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await invokeBridge("api_delete_file", {
|
const response = await invokeBridge("api_delete_file", {
|
||||||
@@ -1532,11 +1654,7 @@ async function deleteSelected(target?: FileItem | null, silent = false) {
|
|||||||
fileName: current.name,
|
fileName: current.name,
|
||||||
});
|
});
|
||||||
if (response.ok && response.data?.success) {
|
if (response.ok && response.data?.success) {
|
||||||
if (!silent) {
|
return true;
|
||||||
showToast("删除成功", "success");
|
|
||||||
await loadFiles(pathState.currentPath);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
showToast(response.data?.message || "删除失败", "error");
|
showToast(response.data?.message || "删除失败", "error");
|
||||||
@@ -1965,8 +2083,9 @@ watch(nav, async (next) => {
|
|||||||
await loadShares();
|
await loadShares();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (next === "updates" && authenticated.value) {
|
if (next === "settings" && authenticated.value) {
|
||||||
await checkClientUpdate(false);
|
await checkClientUpdate(false);
|
||||||
|
await loadOnlineDevices(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2054,10 +2173,6 @@ onBeforeUnmount(() => {
|
|||||||
密码
|
密码
|
||||||
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||||
</label>
|
</label>
|
||||||
<label class="check-line">
|
|
||||||
<input v-model="loginForm.remember" type="checkbox" />
|
|
||||||
<span>记住登录状态(本机 SQLite)</span>
|
|
||||||
</label>
|
|
||||||
<label v-if="loginState.needCaptcha">
|
<label v-if="loginState.needCaptcha">
|
||||||
验证码
|
验证码
|
||||||
<input v-model="loginForm.captcha" type="text" placeholder="当前服务要求验证码" />
|
<input v-model="loginForm.captcha" type="text" placeholder="当前服务要求验证码" />
|
||||||
@@ -2155,13 +2270,16 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
<button class="action-btn" @click="clearSyncSnapshot">重建索引</button>
|
<button class="action-btn" @click="clearSyncSnapshot">重建索引</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="nav === 'updates'">
|
<template v-else-if="nav === 'settings'">
|
||||||
<button class="action-btn" :disabled="updateState.checking || updateRuntime.downloading" @click="checkClientUpdate()">
|
<button class="action-btn" :disabled="updateState.checking || updateRuntime.downloading" @click="checkClientUpdate()">
|
||||||
{{ updateState.checking ? "检查中..." : "检查更新" }}
|
{{ updateState.checking ? "检查中..." : "检查更新" }}
|
||||||
</button>
|
</button>
|
||||||
<button class="action-btn" :disabled="updateRuntime.downloading || !updateState.available || !updateState.downloadUrl" @click="installLatestUpdate()">
|
<button class="action-btn" :disabled="updateRuntime.downloading || !updateState.available || !updateState.downloadUrl" @click="installLatestUpdate()">
|
||||||
{{ updateRuntime.downloading ? "下载中..." : "立即更新" }}
|
{{ updateRuntime.downloading ? "下载中..." : "立即更新" }}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="action-btn" :disabled="onlineDevices.loading || !!onlineDevices.kickingSessionId" @click="loadOnlineDevices()">
|
||||||
|
{{ onlineDevices.loading ? "刷新中..." : "刷新设备" }}
|
||||||
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -2331,10 +2449,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="nav === 'updates'">
|
<template v-else-if="nav === 'settings'">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h3>版本更新</h3>
|
<h3>设置</h3>
|
||||||
<span>支持检查新版本并一键跳转下载升级包</span>
|
<span>版本更新与在线设备管理</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="update-layout">
|
<div class="update-layout">
|
||||||
@@ -2367,6 +2485,41 @@ onBeforeUnmount(() => {
|
|||||||
<span>提示:{{ updateState.message || "可手动点击“检查更新”获取最新信息" }}</span>
|
<span>提示:{{ updateState.message || "可手动点击“检查更新”获取最新信息" }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-device-card">
|
||||||
|
<div class="settings-device-head">
|
||||||
|
<strong>在线设备</strong>
|
||||||
|
<span>{{ onlineDevices.items.length }} 台</span>
|
||||||
|
</div>
|
||||||
|
<p class="settings-device-tip">可强制下线异常设备,标记“本机”的为当前客户端。</p>
|
||||||
|
<p v-if="onlineDevices.message" class="settings-device-error">{{ onlineDevices.message }}</p>
|
||||||
|
<div v-if="onlineDevices.loading && onlineDevices.items.length === 0" class="empty-tip">正在加载在线设备...</div>
|
||||||
|
<div v-else-if="onlineDevices.items.length === 0" class="empty-tip">暂无在线设备</div>
|
||||||
|
<div v-else class="settings-device-list">
|
||||||
|
<div v-for="item in onlineDevices.items" :key="item.session_id" class="settings-device-item">
|
||||||
|
<div class="settings-device-main">
|
||||||
|
<div class="settings-device-name-row">
|
||||||
|
<strong>{{ item.device_name || "未知设备" }}</strong>
|
||||||
|
<span class="share-badge">{{ formatOnlineDeviceType(item.client_type) }}</span>
|
||||||
|
<span v-if="item.is_current || item.is_local" class="share-badge local">本机</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-device-meta">
|
||||||
|
<span>平台 {{ item.platform || "-" }}</span>
|
||||||
|
<span>IP {{ item.ip_address || "-" }}</span>
|
||||||
|
<span>活跃 {{ formatDate(item.last_active_at) }}</span>
|
||||||
|
<span>登录 {{ formatDate(item.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="action-btn danger"
|
||||||
|
:disabled="onlineDevices.kickingSessionId === item.session_id"
|
||||||
|
@click="kickOnlineDevice(item)"
|
||||||
|
>
|
||||||
|
{{ onlineDevices.kickingSessionId === item.session_id ? "处理中..." : (item.is_current || item.is_local ? "下线本机" : "踢下线") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
@@ -2435,7 +2588,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="nav === 'updates'">
|
<template v-else-if="nav === 'settings'">
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
<div>
|
<div>
|
||||||
<strong>v{{ updateState.currentVersion }}</strong>
|
<strong>v{{ updateState.currentVersion }}</strong>
|
||||||
@@ -2455,9 +2608,9 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="selected-info">
|
<div class="selected-info">
|
||||||
<h4>升级说明</h4>
|
<h4>设备与升级</h4>
|
||||||
<p>点击“立即更新”会下载并尝试启动安装包。</p>
|
<p>当前在线设备:{{ onlineDevices.items.length }} 台。</p>
|
||||||
<p>升级后建议重启客户端,确保版本信息刷新。</p>
|
<p>更新下载和静默安装状态会显示在右下角状态卡。</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -2520,6 +2673,22 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="fileDeleteDialog.visible" class="confirm-mask" @click="closeDeleteFileDialog()">
|
||||||
|
<div class="confirm-card" @click.stop>
|
||||||
|
<h4>确认删除文件</h4>
|
||||||
|
<p>
|
||||||
|
确认删除 <strong>{{ fileDeleteDialog.file?.displayName || fileDeleteDialog.file?.name || "-" }}</strong> 吗?
|
||||||
|
删除后将无法恢复。
|
||||||
|
</p>
|
||||||
|
<div class="confirm-actions">
|
||||||
|
<button class="action-btn" :disabled="fileDeleteDialog.loading" @click="closeDeleteFileDialog()">取消</button>
|
||||||
|
<button class="action-btn danger" :disabled="fileDeleteDialog.loading" @click="confirmDeleteFile()">
|
||||||
|
{{ fileDeleteDialog.loading ? "删除中..." : "确定删除" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="updatePrompt.visible" class="confirm-mask" @click="dismissUpdatePrompt()">
|
<div v-if="updatePrompt.visible" class="confirm-mask" @click="dismissUpdatePrompt()">
|
||||||
<div class="confirm-card" @click.stop>
|
<div class="confirm-card" @click.stop>
|
||||||
<h4>发现新版本 v{{ updateState.latestVersion || "-" }}</h4>
|
<h4>发现新版本 v{{ updateState.latestVersion || "-" }}</h4>
|
||||||
@@ -3341,6 +3510,97 @@ select:focus {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-badge.local {
|
||||||
|
background: #e8f8ee;
|
||||||
|
color: #1f8f4f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-card {
|
||||||
|
border: 1px solid #d8e1ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-head strong {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #203754;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-head span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #5d7898;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-tip {
|
||||||
|
margin: 0;
|
||||||
|
color: #5a718c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-error {
|
||||||
|
margin: 0;
|
||||||
|
color: #c24747;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 310px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-item {
|
||||||
|
border: 1px solid #d8e1ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fbff;
|
||||||
|
padding: 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-main {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-name-row strong {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #203043;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-device-meta span {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #60768f;
|
||||||
|
}
|
||||||
|
|
||||||
.sync-layout,
|
.sync-layout,
|
||||||
.update-layout {
|
.update-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3795,6 +4055,10 @@ select:focus {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-device-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.share-actions {
|
.share-actions {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2913,6 +2913,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 在线设备 -->
|
||||||
|
<h3 class="settings-section-title settings-section-gap" style="margin: 40px 0 20px 0;">
|
||||||
|
<i class="fas fa-laptop-house"></i> 在线设备
|
||||||
|
</h3>
|
||||||
|
<div class="settings-panel" style="background: var(--bg-card); padding: 20px; border-radius: 12px; border: 1px solid var(--glass-border); margin-bottom: 30px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px;">
|
||||||
|
<div style="color: var(--text-secondary); font-size: 13px;">
|
||||||
|
可查看当前账号已登录设备,并支持远程强制下线
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary" @click="loadOnlineDevices()" :disabled="onlineDevices.loading || !!onlineDevices.kickingSessionId">
|
||||||
|
<i :class="onlineDevices.loading ? 'fas fa-sync-alt fa-spin' : 'fas fa-sync-alt'"></i>
|
||||||
|
{{ onlineDevices.loading ? '刷新中...' : '刷新设备列表' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="onlineDevices.error" style="margin-bottom: 12px; 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> {{ onlineDevices.error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="onlineDevices.loading && onlineDevices.items.length === 0" style="text-align: center; padding: 20px; color: var(--text-muted);">
|
||||||
|
<i class="fas fa-spinner fa-spin"></i> 正在加载设备列表...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="onlineDevices.items.length === 0" style="text-align: center; padding: 20px; color: var(--text-muted);">
|
||||||
|
暂无在线设备记录
|
||||||
|
</div>
|
||||||
|
<div v-else style="display: grid; gap: 10px;">
|
||||||
|
<div
|
||||||
|
v-for="device in onlineDevices.items"
|
||||||
|
:key="device.session_id"
|
||||||
|
style="border: 1px solid var(--glass-border); border-radius: 10px; background: var(--bg-secondary); padding: 12px; display: grid; gap: 8px;"
|
||||||
|
>
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||||
|
<div style="display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<strong style="color: var(--text-primary);">{{ device.device_name || '未知设备' }}</strong>
|
||||||
|
<span style="font-size: 12px; padding: 3px 8px; border-radius: 999px; background: rgba(99,102,241,0.16); color: #6366f1;">
|
||||||
|
{{ formatOnlineDeviceType(device.client_type) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="device.is_current" style="font-size: 12px; padding: 3px 8px; border-radius: 999px; background: rgba(34,197,94,0.16); color: #16a34a;">
|
||||||
|
本机
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
style="padding: 6px 12px; border-radius: 8px;"
|
||||||
|
:disabled="onlineDevices.kickingSessionId === device.session_id"
|
||||||
|
@click="kickOnlineDevice(device)"
|
||||||
|
>
|
||||||
|
<i :class="onlineDevices.kickingSessionId === device.session_id ? 'fas fa-spinner fa-spin' : 'fas fa-power-off'"></i>
|
||||||
|
{{ device.is_current ? '下线本机' : '踢下线' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 8px; font-size: 12px; color: var(--text-secondary);">
|
||||||
|
<div>平台:{{ device.platform || '-' }}</div>
|
||||||
|
<div>IP:{{ device.ip_address || '-' }}</div>
|
||||||
|
<div>最近活跃:{{ formatDate(device.last_active_at) }}</div>
|
||||||
|
<div>登录时间:{{ formatDate(device.created_at) }}</div>
|
||||||
|
</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>
|
<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;">
|
<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;">
|
||||||
|
|||||||
107
frontend/app.js
107
frontend/app.js
@@ -264,6 +264,13 @@ createApp({
|
|||||||
has_password: false
|
has_password: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onlineDevices: {
|
||||||
|
loading: false,
|
||||||
|
kickingSessionId: '',
|
||||||
|
items: [],
|
||||||
|
error: '',
|
||||||
|
lastLoadedAt: ''
|
||||||
|
},
|
||||||
|
|
||||||
// 健康检测
|
// 健康检测
|
||||||
healthCheck: {
|
healthCheck: {
|
||||||
@@ -945,11 +952,95 @@ handleDragLeave(e) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getOrCreateWebDeviceId() {
|
||||||
|
const storageKey = 'wanwan_web_device_id_v1';
|
||||||
|
const existing = localStorage.getItem(storageKey);
|
||||||
|
if (existing && existing.trim()) {
|
||||||
|
return existing.trim();
|
||||||
|
}
|
||||||
|
const generated = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||||
|
localStorage.setItem(storageKey, generated);
|
||||||
|
return generated;
|
||||||
|
},
|
||||||
|
|
||||||
|
buildLoginClientMeta() {
|
||||||
|
const platform = navigator.platform || '未知平台';
|
||||||
|
const name = `${navigator.userAgent?.includes('Mobile') ? '移动端网页' : '网页端'} · ${platform || '未知平台'}`;
|
||||||
|
return {
|
||||||
|
client_type: navigator.userAgent?.includes('Mobile') ? 'mobile' : 'web',
|
||||||
|
device_id: this.getOrCreateWebDeviceId(),
|
||||||
|
device_name: name.slice(0, 120),
|
||||||
|
platform: String(platform || '').slice(0, 80)
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
formatOnlineDeviceType(clientType) {
|
||||||
|
if (clientType === 'desktop') return '桌面端';
|
||||||
|
if (clientType === 'mobile') return '移动端';
|
||||||
|
if (clientType === 'api') return 'API';
|
||||||
|
return '网页端';
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadOnlineDevices(silent = false) {
|
||||||
|
if (!silent) {
|
||||||
|
this.onlineDevices.loading = true;
|
||||||
|
}
|
||||||
|
this.onlineDevices.error = '';
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.apiBase}/api/user/online-devices`);
|
||||||
|
if (response.data?.success) {
|
||||||
|
this.onlineDevices.items = Array.isArray(response.data.devices) ? response.data.devices : [];
|
||||||
|
this.onlineDevices.lastLoadedAt = new Date().toISOString();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.onlineDevices.error = response.data?.message || '加载在线设备失败';
|
||||||
|
} catch (error) {
|
||||||
|
this.onlineDevices.error = error.response?.data?.message || '加载在线设备失败';
|
||||||
|
} finally {
|
||||||
|
this.onlineDevices.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async kickOnlineDevice(device) {
|
||||||
|
const sessionId = String(device?.session_id || '').trim();
|
||||||
|
if (!sessionId || this.onlineDevices.kickingSessionId) return;
|
||||||
|
|
||||||
|
const isCurrent = !!device?.is_current;
|
||||||
|
const tip = isCurrent
|
||||||
|
? '确定要下线当前设备吗?下线后需要重新登录。'
|
||||||
|
: '确定要强制该设备下线吗?';
|
||||||
|
if (!confirm(tip)) return;
|
||||||
|
|
||||||
|
this.onlineDevices.kickingSessionId = sessionId;
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${this.apiBase}/api/user/online-devices/${encodeURIComponent(sessionId)}/kick`);
|
||||||
|
if (response.data?.success) {
|
||||||
|
this.showToast('success', '成功', response.data?.message || '设备已下线');
|
||||||
|
if (response.data?.kicked_current) {
|
||||||
|
this.handleTokenExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.loadOnlineDevices(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showToast('error', '失败', response.data?.message || '踢下线失败');
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast('error', '失败', error.response?.data?.message || '踢下线失败');
|
||||||
|
} finally {
|
||||||
|
this.onlineDevices.kickingSessionId = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async handleLogin() {
|
async handleLogin() {
|
||||||
this.errorMessage = '';
|
this.errorMessage = '';
|
||||||
this.loginLoading = true;
|
this.loginLoading = true;
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(`${this.apiBase}/api/login`, this.loginForm);
|
const response = await axios.post(`${this.apiBase}/api/login`, {
|
||||||
|
...this.loginForm,
|
||||||
|
...this.buildLoginClientMeta()
|
||||||
|
});
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
// token 和 refreshToken 都通过 HttpOnly Cookie 自动管理
|
// token 和 refreshToken 都通过 HttpOnly Cookie 自动管理
|
||||||
@@ -1444,6 +1535,11 @@ handleDragLeave(e) {
|
|||||||
localStorage.removeItem('adminTab');
|
localStorage.removeItem('adminTab');
|
||||||
this.showResendVerify = false;
|
this.showResendVerify = false;
|
||||||
this.resendVerifyEmail = '';
|
this.resendVerifyEmail = '';
|
||||||
|
this.onlineDevices.items = [];
|
||||||
|
this.onlineDevices.kickingSessionId = '';
|
||||||
|
this.onlineDevices.loading = false;
|
||||||
|
this.onlineDevices.error = '';
|
||||||
|
this.onlineDevices.lastLoadedAt = '';
|
||||||
|
|
||||||
// 停止定期检查
|
// 停止定期检查
|
||||||
this.stopProfileSync();
|
this.stopProfileSync();
|
||||||
@@ -1545,6 +1641,11 @@ handleDragLeave(e) {
|
|||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
localStorage.removeItem('lastView');
|
localStorage.removeItem('lastView');
|
||||||
this.stopProfileSync();
|
this.stopProfileSync();
|
||||||
|
this.onlineDevices.items = [];
|
||||||
|
this.onlineDevices.kickingSessionId = '';
|
||||||
|
this.onlineDevices.loading = false;
|
||||||
|
this.onlineDevices.error = '';
|
||||||
|
this.onlineDevices.lastLoadedAt = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
// 启动token自动刷新定时器
|
// 启动token自动刷新定时器
|
||||||
@@ -3918,6 +4019,7 @@ handleDragLeave(e) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
|
this.loadOnlineDevices();
|
||||||
if (this.user && !this.user.is_admin) {
|
if (this.user && !this.user.is_admin) {
|
||||||
this.loadDownloadTrafficReport();
|
this.loadDownloadTrafficReport();
|
||||||
}
|
}
|
||||||
@@ -4891,6 +4993,9 @@ handleDragLeave(e) {
|
|||||||
// 普通用户进入设置页面时加载OSS配置
|
// 普通用户进入设置页面时加载OSS配置
|
||||||
this.loadOssConfig();
|
this.loadOssConfig();
|
||||||
this.loadDownloadTrafficReport();
|
this.loadDownloadTrafficReport();
|
||||||
|
this.loadOnlineDevices();
|
||||||
|
} else if (newView === 'settings') {
|
||||||
|
this.loadOnlineDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 记住最后停留的视图(需合法且已登录)
|
// 记住最后停留的视图(需合法且已登录)
|
||||||
|
|||||||
Reference in New Issue
Block a user