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

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