feat: 远程浏览器功能 - Web面板内嵌操作滑块验证

- 新增 remote_browser.py: CDP screencast截图流 + 鼠标/键盘事件转发
- Flask-SocketIO 实时通信
- 短信登录时弹出远程浏览器窗口,用户直接在Web页面拖滑块
- 自动检测登录成功并保存auth状态
This commit is contained in:
2026-04-01 13:56:27 +08:00
parent 2ebdaec965
commit 8b64d7e69e
6 changed files with 511 additions and 44 deletions

View File

@@ -21,9 +21,10 @@
<td>
{% if a.is_logged_in %}
<span class="badge bg-success">已登录</span>
{% elif a.login_msg == '登录中...' %}
{% elif a.login_msg == '登录中...' or a.login_msg == '等待人机交互...' %}
<span class="badge bg-warning">
<span class="spinner-border spinner-border-sm" style="width:.7rem;height:.7rem"></span> 登录中...
<span class="spinner-border spinner-border-sm" style="width:.7rem;height:.7rem"></span>
{{ a.login_msg }}
</span>
{% else %}
<span class="badge bg-secondary" title="{{ a.login_msg or '' }}">未登录</span>
@@ -50,6 +51,8 @@
</table>
</div>
</div>
<!-- 添加账号弹窗 -->
<div class="modal fade" id="addModal" tabindex="-1">
<div class="modal-dialog"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title">添加微店账号</h5></div>
@@ -75,60 +78,190 @@
</div>
</div></div>
</div>
<!-- 远程浏览器弹窗 -->
<div class="modal fade" id="rbModal" tabindex="-1" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered" style="max-width:430px">
<div class="modal-content" style="border-radius:16px;overflow:hidden">
<div class="modal-header py-2" style="background:#f0f4ff;border:none">
<span class="fw-bold" style="font-size:.9rem">
<i class="bi bi-phone"></i> 远程浏览器 — 短信登录
</span>
<button type="button" class="btn-close" onclick="closeRB()"></button>
</div>
<div class="modal-body p-0 text-center" style="background:#000">
<div id="rbStatus" style="color:#fff;font-size:.8rem;padding:6px 12px;background:#333">
正在启动浏览器...
</div>
<canvas id="rbCanvas" width="390" height="844"
style="width:390px;height:844px;cursor:pointer;display:block;margin:0 auto"></canvas>
</div>
<div class="modal-footer py-2 justify-content-center" style="background:#f0f4ff;border:none">
<span style="font-size:.75rem;color:#888">
直接在上方画面中点击、拖动操作浏览器
</span>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script>
var rbSocket = null, rbSessionId = null, rbDragging = false;
var rbCanvas = document.getElementById('rbCanvas');
var rbCtx = rbCanvas.getContext('2d');
function addAccount() {
var form = document.getElementById('addForm');
var data = new FormData(form);
var btn = event.target;
btn.disabled = true;
btn.textContent = '添加中...';
btn.disabled = true; btn.textContent = '添加中...';
fetch('/accounts/add', { method: 'POST', body: data })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
form.reset();
location.reload();
} else {
alert(d.msg);
btn.disabled = false;
btn.textContent = '添加';
}
})
.catch(function() { btn.disabled = false; btn.textContent = '添加'; });
form.reset(); location.reload();
} else { alert(d.msg); btn.disabled = false; btn.textContent = '添加'; }
}).catch(function() { btn.disabled = false; btn.textContent = '添加'; });
}
function doLogin(id, btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 登录中...';
var badge = btn.closest('tr').querySelector('.badge');
if (badge) { badge.className = 'badge bg-warning'; badge.textContent = '登录中...'; }
fetch('/accounts/login/' + id, { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function() { pollStatus(id, btn); })
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-key"></i> 密码登录'; });
}
function doSmsLogin(id, btn) {
if (!confirm('短信登录需要您在弹出的浏览器中拖动滑块,并在服务器终端输入验证码。确定继续?')) return;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 等待交互...';
var badge = btn.closest('tr').querySelector('.badge');
if (badge) { badge.className = 'badge bg-info'; badge.textContent = '等待人机交互...'; }
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 启动中...';
fetch('/accounts/login_sms/' + id, { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
alert(d.msg);
pollStatus(id, btn);
if (d.success && d.session_id) {
openRB(d.session_id, id, btn);
} else {
alert(d.msg);
alert(d.msg || '启动失败');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
}
})
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录'; });
}).catch(function() {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
});
}
function openRB(sessionId, accountId, btn) {
rbSessionId = sessionId;
document.getElementById('rbStatus').textContent = '正在启动浏览器...';
rbCtx.fillStyle = '#111';
rbCtx.fillRect(0, 0, 390, 844);
rbCtx.fillStyle = '#666';
rbCtx.font = '14px sans-serif';
rbCtx.textAlign = 'center';
rbCtx.fillText('正在加载...', 195, 422);
var modal = new bootstrap.Modal(document.getElementById('rbModal'));
modal.show();
// 连接 WebSocket
if (rbSocket) { rbSocket.disconnect(); }
rbSocket = io('/rb', { transports: ['websocket', 'polling'] });
rbSocket.on('connect', function() {
console.log('RB WebSocket connected');
});
rbSocket.on('rb_frame', function(data) {
if (data.session_id !== rbSessionId) return;
var img = new Image();
img.onload = function() {
rbCtx.drawImage(img, 0, 0, 390, 844);
};
img.src = 'data:image/jpeg;base64,' + data.data;
});
rbSocket.on('rb_status', function(data) {
if (data.session_id !== rbSessionId) return;
document.getElementById('rbStatus').textContent = data.msg || '';
if (data.status === 'success') {
document.getElementById('rbStatus').style.background = '#198754';
document.getElementById('rbStatus').textContent = '✅ 登录成功3秒后关闭...';
setTimeout(function() { closeRB(); location.reload(); }, 3000);
} else if (data.status === 'failed') {
document.getElementById('rbStatus').style.background = '#dc3545';
}
});
// Canvas 鼠标/触摸事件
rbCanvas.onmousedown = function(e) {
rbDragging = true;
var r = rbCanvas.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
rbSocket.emit('rb_mouse', { session_id: rbSessionId, action: 'down', x: x, y: y });
};
rbCanvas.onmousemove = function(e) {
if (!rbDragging) return;
var r = rbCanvas.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
rbSocket.emit('rb_mouse', { session_id: rbSessionId, action: 'move', x: x, y: y });
};
rbCanvas.onmouseup = function(e) {
if (!rbDragging) return;
rbDragging = false;
var r = rbCanvas.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
rbSocket.emit('rb_mouse', { session_id: rbSessionId, action: 'up', x: x, y: y });
};
rbCanvas.onclick = function(e) {
if (rbDragging) return;
var r = rbCanvas.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
rbSocket.emit('rb_mouse', { session_id: rbSessionId, action: 'click', x: x, y: y });
};
// 键盘事件(当 modal 打开时捕获)
document.addEventListener('keydown', rbKeyHandler);
// 存储 btn 引用用于关闭时恢复
window._rbBtn = btn;
window._rbAccountId = accountId;
}
function rbKeyHandler(e) {
if (!rbSocket || !rbSessionId) return;
// 不拦截 Escape
if (e.key === 'Escape') return;
e.preventDefault();
if (e.key.length === 1) {
rbSocket.emit('rb_keyboard', { session_id: rbSessionId, text: e.key });
} else {
rbSocket.emit('rb_key', { session_id: rbSessionId, key: e.key });
}
}
function closeRB() {
document.removeEventListener('keydown', rbKeyHandler);
if (rbSocket && rbSessionId) {
rbSocket.emit('rb_close', { session_id: rbSessionId });
rbSocket.disconnect();
rbSocket = null;
}
rbSessionId = null;
try {
bootstrap.Modal.getInstance(document.getElementById('rbModal')).hide();
} catch(e) {}
if (window._rbBtn) {
window._rbBtn.disabled = false;
window._rbBtn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
}
}
function pollStatus(id, btn) {
var interval = setInterval(function() {
fetch('/accounts/status/' + id)
@@ -140,24 +273,21 @@ function pollStatus(id, btn) {
if (row) {
var badge = row.querySelector('.badge');
if (d.is_logged_in) {
badge.className = 'badge bg-success';
badge.textContent = '已登录';
badge.className = 'badge bg-success'; badge.textContent = '已登录';
} else {
badge.className = 'badge bg-danger';
badge.textContent = '失败';
badge.className = 'badge bg-danger'; badge.textContent = '失败';
badge.title = d.login_msg;
}
}
if (btn) {
btn.disabled = false;
btn.innerHTML = btn.textContent.includes('短信') ?
'<i class="bi bi-chat-dots"></i> 短信登录' :
'<i class="bi bi-key"></i> 密码登录';
btn.innerHTML = '<i class="bi bi-key"></i> 密码登录';
}
}
});
}, 2000);
}
function deleteAccount(id) {
if (!confirm('确定删除此账号?相关任务也会被删除。')) return;
fetch('/accounts/delete/' + id, { method: 'POST' })