feat: add online device management and desktop settings integration
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const { UserDB } = require('./database');
|
||||
const { UserDB, DeviceSessionDB } = require('./database');
|
||||
const { decryptSecret } = require('./utils/encryption');
|
||||
const DEFAULT_LOCAL_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密钥验证通过');
|
||||
|
||||
// 生成Access Token(短期)
|
||||
function generateToken(user) {
|
||||
function generateToken(user, sessionId = null) {
|
||||
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
type: 'access'
|
||||
type: 'access',
|
||||
sid: safeSessionId,
|
||||
jti: crypto.randomBytes(12).toString('hex')
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: ACCESS_TOKEN_EXPIRES }
|
||||
@@ -78,11 +81,13 @@ function generateToken(user) {
|
||||
}
|
||||
|
||||
// 生成Refresh Token(长期)
|
||||
function generateRefreshToken(user) {
|
||||
function generateRefreshToken(user, sessionId = null) {
|
||||
const safeSessionId = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : null;
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
type: 'refresh',
|
||||
sid: safeSessionId,
|
||||
// 添加随机标识,使每次生成的refresh token不同
|
||||
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
|
||||
function refreshAccessToken(refreshToken) {
|
||||
function refreshAccessToken(refreshToken, context = {}) {
|
||||
try {
|
||||
const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
|
||||
|
||||
@@ -100,6 +123,18 @@ function refreshAccessToken(refreshToken) {
|
||||
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);
|
||||
|
||||
if (!user) {
|
||||
@@ -115,11 +150,12 @@ function refreshAccessToken(refreshToken) {
|
||||
}
|
||||
|
||||
// 生成新的access token
|
||||
const newAccessToken = generateToken(user);
|
||||
const newAccessToken = generateToken(user, sessionId || null);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
token: newAccessToken,
|
||||
sessionId: sessionId || null,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -148,6 +184,29 @@ function authMiddleware(req, res, next) {
|
||||
|
||||
try {
|
||||
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);
|
||||
|
||||
if (!user) {
|
||||
@@ -217,6 +276,8 @@ function authMiddleware(req, res, next) {
|
||||
// 主题偏好
|
||||
theme_preference: user.theme_preference || null
|
||||
};
|
||||
req.authSessionId = sessionId || null;
|
||||
req.authTokenJti = typeof decoded.jti === 'string' ? decoded.jti : null;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
@@ -329,6 +390,8 @@ module.exports = {
|
||||
JWT_SECRET,
|
||||
generateToken,
|
||||
generateRefreshToken,
|
||||
decodeAccessToken,
|
||||
decodeRefreshToken,
|
||||
refreshAccessToken,
|
||||
authMiddleware,
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS user_file_hash_index (
|
||||
@@ -554,6 +575,12 @@ function initDatabase() {
|
||||
CREATE INDEX IF NOT EXISTS idx_upload_sessions_hash
|
||||
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
|
||||
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 = {
|
||||
normalizeStorageType(storageType) {
|
||||
return storageType === 'oss' ? 'oss' : 'local';
|
||||
@@ -2960,6 +3150,7 @@ module.exports = {
|
||||
DownloadTrafficReportDB,
|
||||
DownloadTrafficReservationDB,
|
||||
UploadSessionDB,
|
||||
DeviceSessionDB,
|
||||
FileHashIndexDB,
|
||||
DownloadTrafficIngestDB,
|
||||
SystemLogDB,
|
||||
|
||||
@@ -74,6 +74,7 @@ const {
|
||||
DownloadTrafficReportDB,
|
||||
DownloadTrafficReservationDB,
|
||||
UploadSessionDB,
|
||||
DeviceSessionDB,
|
||||
FileHashIndexDB,
|
||||
DownloadTrafficIngestDB,
|
||||
SystemLogDB,
|
||||
@@ -81,7 +82,17 @@ const {
|
||||
WalManager
|
||||
} = require('./database');
|
||||
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 { encryptSecret, decryptSecret } = require('./utils/encryption');
|
||||
|
||||
@@ -2083,6 +2094,34 @@ setTimeout(() => {
|
||||
cleanupExpiredUploadSessions('startup');
|
||||
}, 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)
|
||||
function buildStorageUserContext(user, overrides = {}) {
|
||||
if (!user) {
|
||||
@@ -2852,6 +2891,85 @@ function detectDeviceTypeFromUserAgent(userAgent = '') {
|
||||
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) {
|
||||
if (typeof value !== 'string') return null;
|
||||
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 {
|
||||
// 检查是否需要验证码
|
||||
@@ -4085,8 +4203,32 @@ app.post('/api/login',
|
||||
user = loginPolicyState.user;
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
const refreshToken = generateRefreshToken(user);
|
||||
const deviceContext = buildDeviceSessionContext(req, {
|
||||
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) {
|
||||
@@ -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) {
|
||||
return res.status(401).json({
|
||||
@@ -4200,6 +4345,20 @@ app.post('/api/refresh-token', (req, res) => {
|
||||
|
||||
// 登出(清除Cookie)
|
||||
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
|
||||
res.clearCookie('token', { 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) => {
|
||||
try {
|
||||
|
||||
@@ -113,6 +113,53 @@ fn join_api_url(base_url: &str, path: &str) -> String {
|
||||
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 {
|
||||
let mut data = Map::new();
|
||||
data.insert("success".to_string(), Value::Bool(status.is_success()));
|
||||
@@ -390,9 +437,14 @@ async fn api_login(
|
||||
password: String,
|
||||
captcha: Option<String>,
|
||||
) -> Result<BridgeResponse, String> {
|
||||
let (platform, device_name, device_id) = build_desktop_client_meta();
|
||||
let mut body = Map::new();
|
||||
body.insert("username".to_string(), Value::String(username));
|
||||
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 !value.trim().is_empty() {
|
||||
body.insert("captcha".to_string(), Value::String(value));
|
||||
@@ -426,6 +478,50 @@ async fn api_get_profile(
|
||||
.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]
|
||||
fn api_save_login_state(
|
||||
base_url: String,
|
||||
@@ -1483,6 +1579,8 @@ pub fn run() {
|
||||
api_load_login_state,
|
||||
api_clear_login_state,
|
||||
api_get_profile,
|
||||
api_list_online_devices,
|
||||
api_kick_online_device,
|
||||
api_list_files,
|
||||
api_logout,
|
||||
api_search_files,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
|
||||
type NavKey = "files" | "transfers" | "shares" | "sync" | "updates";
|
||||
type NavKey = "files" | "transfers" | "shares" | "sync" | "settings";
|
||||
|
||||
type FileItem = {
|
||||
name: string;
|
||||
@@ -35,6 +35,19 @@ type ShareItem = {
|
||||
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 = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
@@ -93,7 +106,6 @@ const loginForm = reactive({
|
||||
username: "",
|
||||
password: "",
|
||||
captcha: "",
|
||||
remember: true,
|
||||
});
|
||||
|
||||
const loginState = reactive({
|
||||
@@ -151,6 +163,13 @@ const updateState = reactive({
|
||||
lastCheckedAt: "",
|
||||
message: "",
|
||||
});
|
||||
const onlineDevices = reactive({
|
||||
loading: false,
|
||||
kickingSessionId: "",
|
||||
items: [] as OnlineDeviceItem[],
|
||||
message: "",
|
||||
lastLoadedAt: "",
|
||||
});
|
||||
const updateRuntime = reactive({
|
||||
downloading: false,
|
||||
installing: false,
|
||||
@@ -178,6 +197,11 @@ const shareDeleteDialog = reactive({
|
||||
loading: false,
|
||||
share: null as ShareItem | null,
|
||||
});
|
||||
const fileDeleteDialog = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
file: null as FileItem | null,
|
||||
});
|
||||
const dropState = reactive({
|
||||
active: false,
|
||||
uploading: false,
|
||||
@@ -214,7 +238,7 @@ const navItems = computed(() => [
|
||||
{ key: "transfers" as const, label: "传输列表", hint: `${transferTasks.value.length} 个任务` },
|
||||
{ key: "shares" as const, label: "我的分享", hint: `${shares.value.length} 条` },
|
||||
{ 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(() => {
|
||||
@@ -305,7 +329,7 @@ const toolbarCrumbs = computed(() => {
|
||||
transfers: "传输列表",
|
||||
shares: "我的分享",
|
||||
sync: "同步盘",
|
||||
updates: "版本更新",
|
||||
settings: "设置",
|
||||
};
|
||||
return [{ label: "工作台", path: "/" }, { label: map[nav.value], path: "" }];
|
||||
});
|
||||
@@ -858,7 +882,7 @@ async function confirmUpdateFromPrompt() {
|
||||
updatePrompt.loading = true;
|
||||
updatePrompt.visible = false;
|
||||
try {
|
||||
nav.value = "updates";
|
||||
nav.value = "settings";
|
||||
await installLatestUpdate();
|
||||
} finally {
|
||||
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() {
|
||||
try {
|
||||
const result = await openDialog({
|
||||
@@ -1254,6 +1333,24 @@ function closeDeleteShareDialog(force = false) {
|
||||
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) {
|
||||
const response = await invokeBridge("api_delete_share", {
|
||||
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) {
|
||||
const url = String(share.share_url || "").trim();
|
||||
if (!url) {
|
||||
@@ -1311,6 +1427,7 @@ async function restoreSession() {
|
||||
rebuildSyncScheduler();
|
||||
await loadFiles("/");
|
||||
await loadShares(true);
|
||||
void loadOnlineDevices(true);
|
||||
await checkUpdateAfterLogin();
|
||||
return true;
|
||||
}
|
||||
@@ -1348,13 +1465,13 @@ async function tryAutoLoginFromSavedState() {
|
||||
user.value = loginResponse.data.user || null;
|
||||
nav.value = "files";
|
||||
loginForm.password = savedPassword;
|
||||
loginForm.remember = true;
|
||||
loadSyncConfig();
|
||||
rebuildSyncScheduler();
|
||||
await loadFiles("/");
|
||||
if (!user.value) {
|
||||
await loadProfile();
|
||||
}
|
||||
void loadOnlineDevices(true);
|
||||
await checkUpdateAfterLogin();
|
||||
showToast("已恢复登录状态", "success");
|
||||
return true;
|
||||
@@ -1427,15 +1544,12 @@ async function handleLogin() {
|
||||
if (!user.value) {
|
||||
await loadProfile();
|
||||
}
|
||||
if (loginForm.remember) {
|
||||
await invokeBridge("api_save_login_state", {
|
||||
baseUrl: appConfig.baseUrl,
|
||||
username: loginForm.username.trim(),
|
||||
password: loginForm.password,
|
||||
});
|
||||
} else {
|
||||
await invokeBridge("api_clear_login_state", {});
|
||||
}
|
||||
void loadOnlineDevices(true);
|
||||
await checkUpdateAfterLogin();
|
||||
return;
|
||||
}
|
||||
@@ -1472,6 +1586,14 @@ async function handleLogout() {
|
||||
updateRuntime.downloading = false;
|
||||
updateRuntime.installing = 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;
|
||||
showToast("已退出客户端", "info");
|
||||
}
|
||||
@@ -1519,11 +1641,11 @@ async function deleteSelected(target?: FileItem | null, silent = false) {
|
||||
const current = target || selectedFile.value;
|
||||
if (!current) {
|
||||
if (!silent) showToast("请先选中文件或文件夹", "info");
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (!silent) {
|
||||
const confirmed = window.confirm(`确认删除「${current.displayName || current.name}」吗?`);
|
||||
if (!confirmed) return;
|
||||
requestDeleteFile(current);
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await invokeBridge("api_delete_file", {
|
||||
@@ -1532,11 +1654,7 @@ async function deleteSelected(target?: FileItem | null, silent = false) {
|
||||
fileName: current.name,
|
||||
});
|
||||
if (response.ok && response.data?.success) {
|
||||
if (!silent) {
|
||||
showToast("删除成功", "success");
|
||||
await loadFiles(pathState.currentPath);
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (!silent) {
|
||||
showToast(response.data?.message || "删除失败", "error");
|
||||
@@ -1965,8 +2083,9 @@ watch(nav, async (next) => {
|
||||
await loadShares();
|
||||
return;
|
||||
}
|
||||
if (next === "updates" && authenticated.value) {
|
||||
if (next === "settings" && authenticated.value) {
|
||||
await checkClientUpdate(false);
|
||||
await loadOnlineDevices(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2054,10 +2173,6 @@ onBeforeUnmount(() => {
|
||||
密码
|
||||
<input v-model="loginForm.password" type="password" placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||
</label>
|
||||
<label class="check-line">
|
||||
<input v-model="loginForm.remember" type="checkbox" />
|
||||
<span>记住登录状态(本机 SQLite)</span>
|
||||
</label>
|
||||
<label v-if="loginState.needCaptcha">
|
||||
验证码
|
||||
<input v-model="loginForm.captcha" type="text" placeholder="当前服务要求验证码" />
|
||||
@@ -2155,13 +2270,16 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
<button class="action-btn" @click="clearSyncSnapshot">重建索引</button>
|
||||
</template>
|
||||
<template v-else-if="nav === 'updates'">
|
||||
<template v-else-if="nav === 'settings'">
|
||||
<button class="action-btn" :disabled="updateState.checking || updateRuntime.downloading" @click="checkClientUpdate()">
|
||||
{{ updateState.checking ? "检查中..." : "检查更新" }}
|
||||
</button>
|
||||
<button class="action-btn" :disabled="updateRuntime.downloading || !updateState.available || !updateState.downloadUrl" @click="installLatestUpdate()">
|
||||
{{ updateRuntime.downloading ? "下载中..." : "立即更新" }}
|
||||
</button>
|
||||
<button class="action-btn" :disabled="onlineDevices.loading || !!onlineDevices.kickingSessionId" @click="loadOnlineDevices()">
|
||||
{{ onlineDevices.loading ? "刷新中..." : "刷新设备" }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
@@ -2331,10 +2449,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="nav === 'updates'">
|
||||
<template v-else-if="nav === 'settings'">
|
||||
<div class="panel-head">
|
||||
<h3>版本更新</h3>
|
||||
<span>支持检查新版本并一键跳转下载升级包</span>
|
||||
<h3>设置</h3>
|
||||
<span>版本更新与在线设备管理</span>
|
||||
</div>
|
||||
|
||||
<div class="update-layout">
|
||||
@@ -2367,6 +2485,41 @@ onBeforeUnmount(() => {
|
||||
<span>提示:{{ updateState.message || "可手动点击“检查更新”获取最新信息" }}</span>
|
||||
</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>
|
||||
</template>
|
||||
</section>
|
||||
@@ -2435,7 +2588,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="nav === 'updates'">
|
||||
<template v-else-if="nav === 'settings'">
|
||||
<div class="stat-grid">
|
||||
<div>
|
||||
<strong>v{{ updateState.currentVersion }}</strong>
|
||||
@@ -2455,9 +2608,9 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="selected-info">
|
||||
<h4>升级说明</h4>
|
||||
<p>点击“立即更新”会下载并尝试启动安装包。</p>
|
||||
<p>升级后建议重启客户端,确保版本信息刷新。</p>
|
||||
<h4>设备与升级</h4>
|
||||
<p>当前在线设备:{{ onlineDevices.items.length }} 台。</p>
|
||||
<p>更新下载和静默安装状态会显示在右下角状态卡。</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2520,6 +2673,22 @@ onBeforeUnmount(() => {
|
||||
</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 class="confirm-card" @click.stop>
|
||||
<h4>发现新版本 v{{ updateState.latestVersion || "-" }}</h4>
|
||||
@@ -3341,6 +3510,97 @@ select:focus {
|
||||
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,
|
||||
.update-layout {
|
||||
display: flex;
|
||||
@@ -3795,6 +4055,10 @@ select:focus {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-device-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.share-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@@ -2913,6 +2913,67 @@
|
||||
</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>
|
||||
<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
|
||||
}
|
||||
},
|
||||
onlineDevices: {
|
||||
loading: false,
|
||||
kickingSessionId: '',
|
||||
items: [],
|
||||
error: '',
|
||||
lastLoadedAt: ''
|
||||
},
|
||||
|
||||
// 健康检测
|
||||
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() {
|
||||
this.errorMessage = '';
|
||||
this.loginLoading = true;
|
||||
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) {
|
||||
// token 和 refreshToken 都通过 HttpOnly Cookie 自动管理
|
||||
@@ -1444,6 +1535,11 @@ handleDragLeave(e) {
|
||||
localStorage.removeItem('adminTab');
|
||||
this.showResendVerify = false;
|
||||
this.resendVerifyEmail = '';
|
||||
this.onlineDevices.items = [];
|
||||
this.onlineDevices.kickingSessionId = '';
|
||||
this.onlineDevices.loading = false;
|
||||
this.onlineDevices.error = '';
|
||||
this.onlineDevices.lastLoadedAt = '';
|
||||
|
||||
// 停止定期检查
|
||||
this.stopProfileSync();
|
||||
@@ -1545,6 +1641,11 @@ handleDragLeave(e) {
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('lastView');
|
||||
this.stopProfileSync();
|
||||
this.onlineDevices.items = [];
|
||||
this.onlineDevices.kickingSessionId = '';
|
||||
this.onlineDevices.loading = false;
|
||||
this.onlineDevices.error = '';
|
||||
this.onlineDevices.lastLoadedAt = '';
|
||||
},
|
||||
|
||||
// 启动token自动刷新定时器
|
||||
@@ -3918,6 +4019,7 @@ handleDragLeave(e) {
|
||||
}
|
||||
break;
|
||||
case 'settings':
|
||||
this.loadOnlineDevices();
|
||||
if (this.user && !this.user.is_admin) {
|
||||
this.loadDownloadTrafficReport();
|
||||
}
|
||||
@@ -4891,6 +4993,9 @@ handleDragLeave(e) {
|
||||
// 普通用户进入设置页面时加载OSS配置
|
||||
this.loadOssConfig();
|
||||
this.loadDownloadTrafficReport();
|
||||
this.loadOnlineDevices();
|
||||
} else if (newView === 'settings') {
|
||||
this.loadOnlineDevices();
|
||||
}
|
||||
|
||||
// 记住最后停留的视图(需合法且已登录)
|
||||
|
||||
Reference in New Issue
Block a user