feat: add online device management and desktop settings integration
This commit is contained in:
@@ -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