feat: 远程浏览器功能 - Web面板内嵌操作滑块验证
- 新增 remote_browser.py: CDP screencast截图流 + 鼠标/键盘事件转发 - Flask-SocketIO 实时通信 - 短信登录时弹出远程浏览器窗口,用户直接在Web页面拖滑块 - 自动检测登录成功并保存auth状态
This commit is contained in:
@@ -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' })
|
||||
|
||||
Reference in New Issue
Block a user