feat: add online device management and desktop settings integration

This commit is contained in:
2026-02-19 17:34:41 +08:00
parent 365ada1a4a
commit 19f53875c9
7 changed files with 1070 additions and 48 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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", {});
}
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(() => {
密码
<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;
}

View File

@@ -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;">

View File

@@ -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();
}
// 记住最后停留的视图(需合法且已登录)