From 19f53875c937704d5d1054684de76638b146b991 Mon Sep 17 00:00:00 2001 From: yuyx <237899745@qq.com> Date: Thu, 19 Feb 2026 17:34:41 +0800 Subject: [PATCH] feat: add online device management and desktop settings integration --- backend/auth.js | 75 ++++++- backend/database.js | 191 ++++++++++++++++ backend/server.js | 250 ++++++++++++++++++++- desktop-client/src-tauri/src/lib.rs | 98 ++++++++ desktop-client/src/App.vue | 336 +++++++++++++++++++++++++--- frontend/app.html | 61 +++++ frontend/app.js | 107 ++++++++- 7 files changed, 1070 insertions(+), 48 deletions(-) diff --git a/backend/auth.js b/backend/auth.js index f726ef3..ec06570 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -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, diff --git a/backend/database.js b/backend/database.js index d88bea1..7456706 100644 --- a/backend/database.js +++ b/backend/database.js @@ -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, diff --git a/backend/server.js b/backend/server.js index 9ccc1ea..e571d69 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 { diff --git a/desktop-client/src-tauri/src/lib.rs b/desktop-client/src-tauri/src/lib.rs index 455ee77..5bf5616 100644 --- a/desktop-client/src-tauri/src/lib.rs +++ b/desktop-client/src-tauri/src/lib.rs @@ -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, ) -> Result { + 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 { + 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 { + 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, diff --git a/desktop-client/src/App.vue b/desktop-client/src/App.vue index 56d7954..c131909 100644 --- a/desktop-client/src/App.vue +++ b/desktop-client/src/App.vue @@ -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 { } } +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", {}); - } + await invokeBridge("api_save_login_state", { + baseUrl: appConfig.baseUrl, + username: loginForm.username.trim(), + password: loginForm.password, + }); + 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(() => { 密码 -