feat: 远程浏览器功能 - Web面板内嵌操作滑块验证
- 新增 remote_browser.py: CDP screencast截图流 + 鼠标/键盘事件转发 - Flask-SocketIO 实时通信 - 短信登录时弹出远程浏览器窗口,用户直接在Web页面拖滑块 - 自动检测登录成功并保存auth状态
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
flask>=3.0
|
flask>=3.0
|
||||||
|
flask-socketio>=5.3
|
||||||
playwright==1.52.0
|
playwright==1.52.0
|
||||||
playwright-stealth>=1.0
|
playwright-stealth>=1.0
|
||||||
pyyaml>=6.0
|
pyyaml>=6.0
|
||||||
|
|||||||
7
run.py
7
run.py
@@ -1,13 +1,14 @@
|
|||||||
"""启动入口:python run.py"""
|
"""启动入口:python run.py"""
|
||||||
from server.app import create_app
|
from server.app import create_app, socketio
|
||||||
|
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
import os
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
print(" 微店抢购管理系统已启动")
|
print(" 微店抢购管理系统已启动")
|
||||||
print(" 访问 http://localhost:9000")
|
print(" 访问 http://localhost:9000")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
import os
|
|
||||||
debug = os.environ.get('FLASK_DEBUG', '1') == '1'
|
debug = os.environ.get('FLASK_DEBUG', '1') == '1'
|
||||||
app.run(host='0.0.0.0', port=9000, debug=debug)
|
socketio.run(app, host='0.0.0.0', port=9000, debug=debug,
|
||||||
|
allow_unsafe_werkzeug=True)
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# 确保项目根目录在 sys.path 中
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
from flask import Flask, redirect, url_for
|
from flask import Flask, redirect, url_for
|
||||||
|
from flask_socketio import SocketIO
|
||||||
from server.database import init_db
|
from server.database import init_db
|
||||||
from server.routers import accounts, tasks, orders
|
from server.routers import accounts, tasks, orders
|
||||||
|
|
||||||
|
socketio = SocketIO(cors_allowed_origins="*")
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
app = Flask(
|
app = Flask(
|
||||||
@@ -27,9 +29,50 @@ def create_app():
|
|||||||
def index():
|
def index():
|
||||||
return redirect(url_for('tasks.list_tasks'))
|
return redirect(url_for('tasks.list_tasks'))
|
||||||
|
|
||||||
|
socketio.init_app(app)
|
||||||
|
_register_socketio_events()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _register_socketio_events():
|
||||||
|
import asyncio
|
||||||
|
from server.services.remote_browser import get_session
|
||||||
|
|
||||||
|
@socketio.on('rb_mouse', namespace='/rb')
|
||||||
|
def handle_mouse(data):
|
||||||
|
s = get_session(data.get('session_id', ''))
|
||||||
|
if s and s.loop and s.loop.is_running():
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
s.handle_mouse(
|
||||||
|
data.get('action', 'click'),
|
||||||
|
data.get('x', 0), data.get('y', 0)),
|
||||||
|
s.loop)
|
||||||
|
|
||||||
|
@socketio.on('rb_keyboard', namespace='/rb')
|
||||||
|
def handle_keyboard(data):
|
||||||
|
s = get_session(data.get('session_id', ''))
|
||||||
|
if s and s.loop and s.loop.is_running():
|
||||||
|
text = data.get('text', '')
|
||||||
|
if text:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
s.handle_keyboard(text), s.loop)
|
||||||
|
|
||||||
|
@socketio.on('rb_key', namespace='/rb')
|
||||||
|
def handle_key(data):
|
||||||
|
s = get_session(data.get('session_id', ''))
|
||||||
|
if s and s.loop and s.loop.is_running():
|
||||||
|
key = data.get('key', '')
|
||||||
|
if key:
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
s.handle_key(key), s.loop)
|
||||||
|
|
||||||
|
@socketio.on('rb_close', namespace='/rb')
|
||||||
|
def handle_close(data):
|
||||||
|
from server.services.remote_browser import close_session
|
||||||
|
close_session(data.get('session_id', ''))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app = create_app()
|
app = create_app()
|
||||||
app.run(host='0.0.0.0', port=9000, debug=True)
|
socketio.run(app, host='0.0.0.0', port=9000, debug=True)
|
||||||
|
|||||||
@@ -89,10 +89,7 @@ def do_login(account_id):
|
|||||||
|
|
||||||
@bp.route('/login_sms/<int:account_id>', methods=['POST'])
|
@bp.route('/login_sms/<int:account_id>', methods=['POST'])
|
||||||
def do_sms_login(account_id):
|
def do_sms_login(account_id):
|
||||||
"""
|
"""短信验证码登录 — 远程浏览器模式,在 Web 面板中操作"""
|
||||||
短信验证码登录(需要人机交互)
|
|
||||||
会弹出浏览器窗口,需要人拖滑块 + 输入验证码。
|
|
||||||
"""
|
|
||||||
db = get_db()
|
db = get_db()
|
||||||
account = db.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone()
|
account = db.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone()
|
||||||
db.close()
|
db.close()
|
||||||
@@ -114,10 +111,13 @@ def do_sms_login(account_id):
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# 后台启动短信登录(会弹出浏览器窗口)
|
# 启动远程浏览器会话
|
||||||
_start_bg_sms_login(account_id, phone)
|
from server.app import socketio
|
||||||
|
from server.services.remote_browser import start_session
|
||||||
|
session_id = start_session(account_id, phone, socketio)
|
||||||
|
|
||||||
return jsonify(success=True, msg='已启动短信登录,请在弹出的浏览器中完成滑块验证,并在终端输入验证码')
|
return jsonify(success=True, session_id=session_id,
|
||||||
|
msg='远程浏览器已启动,请在弹出窗口中操作')
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/status/<int:account_id>')
|
@bp.route('/status/<int:account_id>')
|
||||||
|
|||||||
292
server/services/remote_browser.py
Normal file
292
server/services/remote_browser.py
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
"""
|
||||||
|
远程浏览器服务 — 通过 WebSocket 将 headless 浏览器画面串流到前端,
|
||||||
|
前端用户可以直接在 Web 页面上操作浏览器(拖滑块、输入验证码等)。
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
from utils.stealth import stealth_async
|
||||||
|
from server.services.auth_service import (
|
||||||
|
SSO_LOGIN_URL, get_auth_path, _save_cookies_file_async
|
||||||
|
)
|
||||||
|
from server.database import get_db
|
||||||
|
|
||||||
|
# 全局会话存储: {session_id: RemoteBrowserSession}
|
||||||
|
_sessions = {}
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteBrowserSession:
|
||||||
|
def __init__(self, session_id, account_id, phone):
|
||||||
|
self.session_id = session_id
|
||||||
|
self.account_id = account_id
|
||||||
|
self.phone = phone
|
||||||
|
self.page = None
|
||||||
|
self.browser = None
|
||||||
|
self.context = None
|
||||||
|
self.pw = None
|
||||||
|
self.cdp = None
|
||||||
|
self.loop = None
|
||||||
|
self.thread = None
|
||||||
|
self.status = 'init' # init, running, success, failed, closed
|
||||||
|
self.message = ''
|
||||||
|
self.login_success = False
|
||||||
|
self._socketio = None
|
||||||
|
self._frame_ack_id = 0
|
||||||
|
|
||||||
|
def start(self, socketio):
|
||||||
|
self._socketio = socketio
|
||||||
|
self.status = 'running'
|
||||||
|
self.thread = threading.Thread(target=self._run, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
self.loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(self.loop)
|
||||||
|
try:
|
||||||
|
self.loop.run_until_complete(self._async_run())
|
||||||
|
except Exception as e:
|
||||||
|
self.status = 'failed'
|
||||||
|
self.message = str(e)
|
||||||
|
finally:
|
||||||
|
self.loop.close()
|
||||||
|
|
||||||
|
async def _async_run(self):
|
||||||
|
self.pw = await async_playwright().start()
|
||||||
|
self.browser = await self.pw.chromium.launch(
|
||||||
|
headless=True,
|
||||||
|
args=['--no-sandbox', '--disable-gpu',
|
||||||
|
'--disable-blink-features=AutomationControlled']
|
||||||
|
)
|
||||||
|
self.context = await self.browser.new_context(
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) "
|
||||||
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) "
|
||||||
|
"Version/18.0 Mobile/15E148 Safari/604.1"
|
||||||
|
),
|
||||||
|
viewport={"width": 390, "height": 844},
|
||||||
|
device_scale_factor=2,
|
||||||
|
is_mobile=True,
|
||||||
|
has_touch=True,
|
||||||
|
)
|
||||||
|
await self.context.add_init_script(
|
||||||
|
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined});"
|
||||||
|
)
|
||||||
|
self.page = await self.context.new_page()
|
||||||
|
await stealth_async(self.page)
|
||||||
|
|
||||||
|
# 监听登录相关响应
|
||||||
|
self.page.on("response", self._on_response)
|
||||||
|
|
||||||
|
# 开启 CDP screencast
|
||||||
|
self.cdp = await self.page.context.new_cdp_session(self.page)
|
||||||
|
self.cdp.on("Page.screencastFrame", self._on_frame)
|
||||||
|
await self.cdp.send("Page.startScreencast", {
|
||||||
|
"format": "jpeg", "quality": 50, "maxWidth": 390, "maxHeight": 844
|
||||||
|
})
|
||||||
|
|
||||||
|
# 打开登录页
|
||||||
|
self.message = '正在打开登录页...'
|
||||||
|
await self.page.goto(SSO_LOGIN_URL, wait_until="load", timeout=20000)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
# 点击"登录"
|
||||||
|
try:
|
||||||
|
btn = self.page.locator("#login_init_by_login")
|
||||||
|
if await btn.count() > 0 and await btn.is_visible():
|
||||||
|
await btn.click()
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 填手机号
|
||||||
|
self.message = '正在填写手机号...'
|
||||||
|
try:
|
||||||
|
tele = self.page.locator('input[placeholder*="手机号"]').first
|
||||||
|
await tele.click()
|
||||||
|
await tele.fill("")
|
||||||
|
await self.page.keyboard.type(self.phone, delay=80)
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 点击获取验证码
|
||||||
|
self.message = '请完成滑块验证'
|
||||||
|
try:
|
||||||
|
code_btn = self.page.locator(
|
||||||
|
"#login_quickCode_right, text=获取验证码").first
|
||||||
|
await code_btn.click()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 等待登录完成或超时
|
||||||
|
for _ in range(300): # 5分钟超时
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
if self.login_success:
|
||||||
|
break
|
||||||
|
if self.status == 'closed':
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.login_success:
|
||||||
|
auth_path = get_auth_path(self.account_id)
|
||||||
|
await self.context.storage_state(path=auth_path)
|
||||||
|
await _save_cookies_file_async(self.context, self.account_id)
|
||||||
|
self.status = 'success'
|
||||||
|
self.message = '登录成功'
|
||||||
|
self._update_db(True, '登录成功')
|
||||||
|
self._emit('rb_status', {'status': 'success', 'msg': '登录成功'})
|
||||||
|
elif self.status != 'closed':
|
||||||
|
self.status = 'failed'
|
||||||
|
self.message = self.message or '登录超时'
|
||||||
|
self._update_db(False, self.message)
|
||||||
|
self._emit('rb_status', {'status': 'failed', 'msg': self.message})
|
||||||
|
|
||||||
|
await self._cleanup()
|
||||||
|
|
||||||
|
def _on_frame(self, params):
|
||||||
|
"""CDP screencast 帧回调"""
|
||||||
|
session_id = params.get("sessionId", 0)
|
||||||
|
data = params.get("data", "")
|
||||||
|
if data and self._socketio:
|
||||||
|
self._emit('rb_frame', {
|
||||||
|
'session_id': self.session_id,
|
||||||
|
'data': data # base64 jpeg
|
||||||
|
})
|
||||||
|
# 必须 ack 才能收到下一帧
|
||||||
|
if self.cdp and self.loop and self.loop.is_running():
|
||||||
|
asyncio.run_coroutine_threadsafe(
|
||||||
|
self.cdp.send("Page.screencastFrameAck",
|
||||||
|
{"sessionId": session_id}),
|
||||||
|
self.loop
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _on_response(self, response):
|
||||||
|
url = response.url
|
||||||
|
try:
|
||||||
|
if "cap_union_new_verify" in url:
|
||||||
|
data = await response.json()
|
||||||
|
if data.get("errorCode") == "0":
|
||||||
|
self.message = '滑块验证通过,请输入验证码'
|
||||||
|
self._emit('rb_status', {
|
||||||
|
'status': 'running', 'msg': self.message})
|
||||||
|
elif "get.vcode" in url:
|
||||||
|
data = await response.json()
|
||||||
|
code = str(data.get("status", {}).get("code", ""))
|
||||||
|
if code == "0":
|
||||||
|
self.message = '短信已发送,请输入验证码并点击登录'
|
||||||
|
self._emit('rb_status', {
|
||||||
|
'status': 'running', 'msg': self.message})
|
||||||
|
elif "user/loginbyvcode" in url or "user/login" in url:
|
||||||
|
data = await response.json()
|
||||||
|
sc = str(data.get("status", {}).get("status_code",
|
||||||
|
data.get("status", {}).get("code", "")))
|
||||||
|
if sc == "0":
|
||||||
|
self.login_success = True
|
||||||
|
elif "synclogin" in url:
|
||||||
|
# SSO 同步完成也算登录成功
|
||||||
|
cookies = await self.context.cookies()
|
||||||
|
cookie_map = {c["name"]: c["value"] for c in cookies}
|
||||||
|
if cookie_map.get("wduss") or cookie_map.get("uid"):
|
||||||
|
self.login_success = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _emit(self, event, data):
|
||||||
|
if self._socketio:
|
||||||
|
data['session_id'] = self.session_id
|
||||||
|
self._socketio.emit(event, data, namespace='/rb')
|
||||||
|
|
||||||
|
def _update_db(self, success, msg):
|
||||||
|
try:
|
||||||
|
db = get_db()
|
||||||
|
if success:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE accounts SET is_logged_in=1, login_msg=?, "
|
||||||
|
"updated_at=datetime('now','localtime') WHERE id=?",
|
||||||
|
(msg, self.account_id))
|
||||||
|
else:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE accounts SET is_logged_in=0, login_msg=?, "
|
||||||
|
"updated_at=datetime('now','localtime') WHERE id=?",
|
||||||
|
(msg, self.account_id))
|
||||||
|
db.commit()
|
||||||
|
db.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def handle_mouse(self, action, x, y, **kwargs):
|
||||||
|
if not self.page:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if action == 'click':
|
||||||
|
await self.page.mouse.click(x, y)
|
||||||
|
elif action == 'down':
|
||||||
|
await self.page.mouse.move(x, y)
|
||||||
|
await self.page.mouse.down()
|
||||||
|
elif action == 'move':
|
||||||
|
await self.page.mouse.move(x, y)
|
||||||
|
elif action == 'up':
|
||||||
|
await self.page.mouse.move(x, y)
|
||||||
|
await self.page.mouse.up()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def handle_keyboard(self, text):
|
||||||
|
if not self.page:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.page.keyboard.type(text, delay=50)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def handle_key(self, key):
|
||||||
|
if not self.page:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self.page.keyboard.press(key)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _cleanup(self):
|
||||||
|
try:
|
||||||
|
if self.cdp:
|
||||||
|
await self.cdp.send("Page.stopScreencast")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if self.browser:
|
||||||
|
await self.browser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
if self.pw:
|
||||||
|
await self.pw.stop()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.status = 'closed'
|
||||||
|
if self.loop and self.loop.is_running():
|
||||||
|
asyncio.run_coroutine_threadsafe(self._cleanup(), self.loop)
|
||||||
|
|
||||||
|
|
||||||
|
def start_session(account_id, phone, socketio):
|
||||||
|
sid = f"rb_{account_id}_{int(time.time())}"
|
||||||
|
session = RemoteBrowserSession(sid, account_id, phone)
|
||||||
|
_sessions[sid] = session
|
||||||
|
session.start(socketio)
|
||||||
|
return sid
|
||||||
|
|
||||||
|
|
||||||
|
def get_session(session_id):
|
||||||
|
return _sessions.get(session_id)
|
||||||
|
|
||||||
|
|
||||||
|
def close_session(session_id):
|
||||||
|
s = _sessions.pop(session_id, None)
|
||||||
|
if s:
|
||||||
|
s.close()
|
||||||
@@ -21,9 +21,10 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if a.is_logged_in %}
|
{% if a.is_logged_in %}
|
||||||
<span class="badge bg-success">已登录</span>
|
<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="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>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary" title="{{ a.login_msg or '' }}">未登录</span>
|
<span class="badge bg-secondary" title="{{ a.login_msg or '' }}">未登录</span>
|
||||||
@@ -50,6 +51,8 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加账号弹窗 -->
|
||||||
<div class="modal fade" id="addModal" tabindex="-1">
|
<div class="modal fade" id="addModal" tabindex="-1">
|
||||||
<div class="modal-dialog"><div class="modal-content">
|
<div class="modal-dialog"><div class="modal-content">
|
||||||
<div class="modal-header"><h5 class="modal-title">添加微店账号</h5></div>
|
<div class="modal-header"><h5 class="modal-title">添加微店账号</h5></div>
|
||||||
@@ -75,60 +78,190 @@
|
|||||||
</div>
|
</div>
|
||||||
</div></div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
var rbSocket = null, rbSessionId = null, rbDragging = false;
|
||||||
|
var rbCanvas = document.getElementById('rbCanvas');
|
||||||
|
var rbCtx = rbCanvas.getContext('2d');
|
||||||
|
|
||||||
function addAccount() {
|
function addAccount() {
|
||||||
var form = document.getElementById('addForm');
|
var form = document.getElementById('addForm');
|
||||||
var data = new FormData(form);
|
var data = new FormData(form);
|
||||||
var btn = event.target;
|
var btn = event.target;
|
||||||
btn.disabled = true;
|
btn.disabled = true; btn.textContent = '添加中...';
|
||||||
btn.textContent = '添加中...';
|
|
||||||
fetch('/accounts/add', { method: 'POST', body: data })
|
fetch('/accounts/add', { method: 'POST', body: data })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (d.success) {
|
if (d.success) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('addModal')).hide();
|
||||||
form.reset();
|
form.reset(); location.reload();
|
||||||
location.reload();
|
} else { alert(d.msg); btn.disabled = false; btn.textContent = '添加'; }
|
||||||
} else {
|
}).catch(function() { btn.disabled = false; btn.textContent = '添加'; });
|
||||||
alert(d.msg);
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = '添加';
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(function() { btn.disabled = false; btn.textContent = '添加'; });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function doLogin(id, btn) {
|
function doLogin(id, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 登录中...';
|
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' })
|
fetch('/accounts/login/' + id, { method: 'POST' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function() { pollStatus(id, btn); })
|
.then(function() { pollStatus(id, btn); })
|
||||||
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-key"></i> 密码登录'; });
|
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-key"></i> 密码登录'; });
|
||||||
}
|
}
|
||||||
|
|
||||||
function doSmsLogin(id, btn) {
|
function doSmsLogin(id, btn) {
|
||||||
if (!confirm('短信登录需要您在弹出的浏览器中拖动滑块,并在服务器终端输入验证码。确定继续?')) return;
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> 等待交互...';
|
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 = '等待人机交互...'; }
|
|
||||||
fetch('/accounts/login_sms/' + id, { method: 'POST' })
|
fetch('/accounts/login_sms/' + id, { method: 'POST' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(d) {
|
.then(function(d) {
|
||||||
if (d.success) {
|
if (d.success && d.session_id) {
|
||||||
alert(d.msg);
|
openRB(d.session_id, id, btn);
|
||||||
pollStatus(id, btn);
|
|
||||||
} else {
|
} else {
|
||||||
alert(d.msg);
|
alert(d.msg || '启动失败');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
|
btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录';
|
||||||
}
|
}
|
||||||
})
|
}).catch(function() {
|
||||||
.catch(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-chat-dots"></i> 短信登录'; });
|
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) {
|
function pollStatus(id, btn) {
|
||||||
var interval = setInterval(function() {
|
var interval = setInterval(function() {
|
||||||
fetch('/accounts/status/' + id)
|
fetch('/accounts/status/' + id)
|
||||||
@@ -140,24 +273,21 @@ function pollStatus(id, btn) {
|
|||||||
if (row) {
|
if (row) {
|
||||||
var badge = row.querySelector('.badge');
|
var badge = row.querySelector('.badge');
|
||||||
if (d.is_logged_in) {
|
if (d.is_logged_in) {
|
||||||
badge.className = 'badge bg-success';
|
badge.className = 'badge bg-success'; badge.textContent = '已登录';
|
||||||
badge.textContent = '已登录';
|
|
||||||
} else {
|
} else {
|
||||||
badge.className = 'badge bg-danger';
|
badge.className = 'badge bg-danger'; badge.textContent = '失败';
|
||||||
badge.textContent = '失败';
|
|
||||||
badge.title = d.login_msg;
|
badge.title = d.login_msg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = btn.textContent.includes('短信') ?
|
btn.innerHTML = '<i class="bi bi-key"></i> 密码登录';
|
||||||
'<i class="bi bi-chat-dots"></i> 短信登录' :
|
|
||||||
'<i class="bi bi-key"></i> 密码登录';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteAccount(id) {
|
function deleteAccount(id) {
|
||||||
if (!confirm('确定删除此账号?相关任务也会被删除。')) return;
|
if (!confirm('确定删除此账号?相关任务也会被删除。')) return;
|
||||||
fetch('/accounts/delete/' + id, { method: 'POST' })
|
fetch('/accounts/delete/' + id, { method: 'POST' })
|
||||||
|
|||||||
Reference in New Issue
Block a user