From 86f8d57874eba32e7b1d9c021701a341f4d1ca75 Mon Sep 17 00:00:00 2001 From: liuwei Date: Thu, 7 May 2026 11:10:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EDashboard=E6=9C=AA=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BA=8C=E7=BB=B4=E7=A0=81=E5=BC=95=E5=AF=BC=E4=B8=8E?= =?UTF-8?q?=E5=80=92=E8=AE=A1=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/dashboard/blueprints/robot.py | 45 ++ admin/dashboard/templates/index.html | 605 ++++++++++++++++++++ docs/wechat_ipad多版本Server适配路线图.md | 2 + robot.py | 170 ++++++ wechat_ipad/providers/legacy_855/runtime.py | 79 ++- 5 files changed, 900 insertions(+), 1 deletion(-) diff --git a/admin/dashboard/blueprints/robot.py b/admin/dashboard/blueprints/robot.py index f6a50fa..93a7131 100644 --- a/admin/dashboard/blueprints/robot.py +++ b/admin/dashboard/blueprints/robot.py @@ -12,6 +12,36 @@ robot_bp = Blueprint('robot', __name__, url_prefix='/robot') LOG = logger +def _serialize_login_qr_state(server) -> dict: + """把 Robot 中的二维码登录态整理成 Dashboard 接口输出。""" + robot = getattr(server, "robot", None) + if robot is None or not hasattr(robot, "get_ipad_login_qr_state"): + return { + "logged_in": False, + "active": False, + "status": "unavailable", + "status_text": "机器人运行态暂不可用", + "current": {}, + "history": [], + "server_now": datetime.now().timestamp(), + } + + state = robot.get_ipad_login_qr_state() or {} + return { + "logged_in": bool(state.get("logged_in", False)), + "active": bool(state.get("active", False)), + "status": str(state.get("status", "idle") or "idle"), + "status_text": str(state.get("status_text", "尚未进入扫码登录流程") or "尚未进入扫码登录流程"), + "runtime_running": bool(state.get("runtime_running", False)), + "wxid": str(state.get("wxid", "") or ""), + "nickname": str(state.get("nickname", "") or ""), + "updated_at": float(state.get("updated_at", 0) or 0), + "server_now": float(state.get("server_now", datetime.now().timestamp()) or datetime.now().timestamp()), + "current": dict(state.get("current", {}) or {}), + "history": [dict(item or {}) for item in (state.get("history", []) or [])], + } + + def _build_group_ops_profile(server, group_id: str, group_name: str, peak_hours: list, plugin_stats: list) -> dict: """构建群运营 2.0 所需的群画像摘要。 @@ -423,6 +453,21 @@ def robot_management(): return redirect('/contacts') +@robot_bp.route('/api/login_qr_status', methods=['GET']) +@login_required +def api_login_qr_status(): + """返回当前二维码登录状态,供 Dashboard 首页弹窗轮询。""" + try: + server = current_app.dashboard_server + return jsonify({ + "success": True, + "data": _serialize_login_qr_state(server), + }) + except Exception as e: + LOG.error(f"获取登录二维码状态失败: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + # API路由 @robot_bp.route('/api/groups') @login_required diff --git a/admin/dashboard/templates/index.html b/admin/dashboard/templates/index.html index ded72f7..2315d89 100644 --- a/admin/dashboard/templates/index.html +++ b/admin/dashboard/templates/index.html @@ -17,6 +17,27 @@ + + + + @@ -101,6 +122,86 @@ + + + + @@ -468,6 +569,19 @@ summary: '加载中...' } }, + loginQrDialog: { + visible: false, + loading: false, + logged_in: false, + active: false, + status: 'idle', + status_text: '尚未进入扫码登录流程', + current: {}, + history: [], + runtime_running: false, + server_now: 0 + }, + loginQrCountdownSeconds: 0, groups: [], selectedGroupForHourlyTrend: '', hourlyTrendDays: 1, @@ -501,6 +615,53 @@ return result; }, + loginQrCurrent() { + return this.loginQrDialog.current || {}; + }, + showLoginQrBanner() { + return !this.loginQrDialog.logged_in; + }, + loginQrStatusTone() { + return this.mapLoginQrTone(this.loginQrDialog.status); + }, + loginQrStatusText() { + const toneMap = { + waiting: '等待扫码', + expired: '二维码过期', + confirmed: '登录成功', + logged_in: '已复用登录态', + idle: '等待登录流程', + unavailable: '状态暂不可用' + }; + return toneMap[this.loginQrDialog.status] || '等待登录流程'; + }, + loginQrSourceText() { + const source = this.loginQrCurrent.login_source; + if (source === 'awaken') { + return '缓存唤醒登录'; + } + if (source === 'fresh_qr') { + return '新二维码登录'; + } + return '登录引导中'; + }, + loginQrCountdownText() { + if (this.loginQrDialog.logged_in) { + return '已登录'; + } + if (this.loginQrDialog.status === 'expired') { + return '已过期,等待刷新'; + } + if (!this.loginQrCurrent.uuid) { + return '等待生成'; + } + if (this.loginQrCountdownSeconds <= 0) { + return '剩余时间获取中'; + } + const minutes = Math.floor(this.loginQrCountdownSeconds / 60); + const seconds = this.loginQrCountdownSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + }, healthCards() { // 首页健康卡片统一在这里做展示层映射,模板只负责渲染,避免 HTML 中堆太多业务判断。 const robot = this.healthSummary.robot || {}; @@ -577,13 +738,22 @@ this.loadData(); this.refreshRuntimeSnapshot(); this.loadCurrentUserInfo(); + this.loadLoginQrStatus(); this.loadGroups(); this.systemInfoTimer = setInterval(this.refreshRuntimeSnapshot, 30000); + this.loginQrPollTimer = setInterval(() => this.loadLoginQrStatus(false), 5000); + this.loginQrCountdownTimer = setInterval(this.tickLoginQrCountdown, 1000); }, beforeDestroy() { if (this.systemInfoTimer) { clearInterval(this.systemInfoTimer); } + if (this.loginQrPollTimer) { + clearInterval(this.loginQrPollTimer); + } + if (this.loginQrCountdownTimer) { + clearInterval(this.loginQrCountdownTimer); + } }, methods: { loadData() { @@ -629,6 +799,117 @@ console.error('加载系统健康摘要出错:', error); }); }, + mapLoginQrTone(status) { + const toneMap = { + waiting: 'warning', + expired: 'danger', + confirmed: 'healthy', + logged_in: 'healthy', + idle: 'soft', + unavailable: 'soft' + }; + return toneMap[status] || 'soft'; + }, + openLoginQrDialog() { + this.loginQrDialog.visible = true; + }, + applyLoginQrState(state) { + const nextState = state || {}; + const current = nextState.current || {}; + const history = Array.isArray(nextState.history) ? nextState.history : []; + this.loginQrDialog = { + ...this.loginQrDialog, + ...nextState, + current, + history + }; + + if (nextState.logged_in) { + this.loginQrDialog.visible = false; + this.loginQrCountdownSeconds = 0; + return; + } + + const expiresAt = Number(current.expires_at || 0); + const serverNow = Number(nextState.server_now || 0); + if (expiresAt > 0 && serverNow > 0) { + this.loginQrCountdownSeconds = Math.max(0, Math.floor(expiresAt - serverNow)); + } else { + this.loginQrCountdownSeconds = Number(current.remaining_seconds || 0); + } + + // 首页只要还未登录,就主动弹出二维码弹窗: + // 1. 新部署环境通常是“打开后台就要扫码”,无需用户再点到别的页面; + // 2. 如果当前二维码正在刷新或刚过期,也保留弹窗,方便用户持续观察状态; + // 3. 同时顶部保留一条提示条,用户手动关闭弹窗后仍可重新打开。 + this.loginQrDialog.visible = true; + }, + loadLoginQrStatus(showLoading = false) { + if (showLoading) { + this.loginQrDialog.loading = true; + } + axios.get('/robot/api/login_qr_status') + .then(response => { + if (response.data.success) { + this.applyLoginQrState(response.data.data || {}); + } + }) + .catch(error => { + console.error('加载登录二维码状态出错:', error); + }) + .finally(() => { + this.loginQrDialog.loading = false; + }); + }, + tickLoginQrCountdown() { + if (this.loginQrDialog.logged_in) { + this.loginQrCountdownSeconds = 0; + return; + } + if (this.loginQrCountdownSeconds > 0) { + this.loginQrCountdownSeconds -= 1; + return; + } + if (this.loginQrDialog.status === 'waiting' && this.loginQrCurrent.uuid) { + this.loginQrDialog.status = 'expired'; + this.loginQrDialog.status_text = '二维码可能已过期,正在等待下一次状态刷新'; + } + }, + copyLoginQrScanUrl() { + const scanUrl = this.loginQrCurrent.scan_url || ''; + if (!scanUrl) { + this.$message.warning('当前暂无可复制的扫码链接'); + return; + } + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(scanUrl) + .then(() => { + this.$message.success('扫码链接已复制'); + }) + .catch(() => { + this.fallbackCopyLoginQrScanUrl(scanUrl); + }); + return; + } + this.fallbackCopyLoginQrScanUrl(scanUrl); + }, + fallbackCopyLoginQrScanUrl(scanUrl) { + const textarea = document.createElement('textarea'); + textarea.value = scanUrl; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + try { + document.execCommand('copy'); + this.$message.success('扫码链接已复制'); + } catch (error) { + this.$message.error('复制扫码链接失败'); + } finally { + document.body.removeChild(textarea); + } + }, renderSystemCharts() { this.renderPieChart('cpuChart', this.systemInfo.cpu_usage, 'CPU使用率'); this.renderPieChart('memoryChart', this.systemInfo.memory_usage, '内存使用率'); @@ -1463,6 +1744,313 @@ margin-bottom: 16px; } + .login-qr-banner { + margin-bottom: 16px; + border-radius: 18px !important; + } + + .login-qr-banner__content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + width: 100%; + } + + .login-qr-banner__title { + font-size: 14px; + font-weight: 700; + color: #92400e; + margin-bottom: 4px; + } + + .login-qr-banner__desc { + font-size: 13px; + color: #b45309; + line-height: 1.6; + } + + .login-qr-dialog .el-dialog { + border-radius: 26px; + overflow: hidden; + } + + .login-qr-dialog .el-dialog__body { + padding-top: 10px; + } + + .login-qr-dialog__body { + display: flex; + flex-direction: column; + gap: 22px; + } + + .login-qr-dialog__hero { + display: grid; + grid-template-columns: 280px minmax(0, 1fr); + gap: 22px; + align-items: stretch; + } + + .login-qr-dialog__preview, + .login-qr-dialog__summary { + border-radius: 22px; + border: 1px solid rgba(148, 163, 184, 0.14); + background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.92)); + } + + .login-qr-dialog__preview { + padding: 18px; + } + + .login-qr-dialog__summary { + padding: 20px; + } + + .login-qr-dialog__image-wrap { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 20px; + background: #fff; + display: flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(148, 163, 184, 0.12); + overflow: hidden; + } + + .login-qr-dialog__image-wrap--empty { + flex-direction: column; + gap: 10px; + color: #94a3b8; + font-size: 13px; + } + + .login-qr-dialog__image-wrap--empty i { + font-size: 24px; + } + + .login-qr-dialog__image { + width: 100%; + height: 100%; + object-fit: contain; + } + + .login-qr-dialog__badge-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 14px; + } + + .login-qr-dialog__badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + } + + .login-qr-dialog__badge--healthy { + color: #047857; + background: rgba(16, 185, 129, 0.12); + } + + .login-qr-dialog__badge--warning { + color: #b45309; + background: rgba(245, 158, 11, 0.14); + } + + .login-qr-dialog__badge--danger { + color: #b91c1c; + background: rgba(239, 68, 68, 0.14); + } + + .login-qr-dialog__badge--soft { + color: #475569; + background: rgba(226, 232, 240, 0.72); + } + + .login-qr-dialog__summary h3 { + font-size: 22px; + font-weight: 700; + color: #0f172a; + margin-bottom: 10px; + } + + .login-qr-dialog__summary p { + font-size: 14px; + line-height: 1.7; + color: #475569; + margin-bottom: 18px; + } + + .login-qr-dialog__countdown { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 14px 16px; + border-radius: 18px; + background: linear-gradient(135deg, rgba(251, 191, 36, 0.12), rgba(255,255,255,0.92)); + margin-bottom: 16px; + } + + .login-qr-dialog__countdown-label { + font-size: 12px; + color: #92400e; + } + + .login-qr-dialog__countdown-value { + font-size: 24px; + font-weight: 700; + color: #b45309; + letter-spacing: 0.06em; + } + + .login-qr-dialog__meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + } + + .login-qr-dialog__meta-item { + padding: 12px 14px; + border-radius: 16px; + background: rgba(248, 250, 252, 0.96); + border: 1px solid rgba(148, 163, 184, 0.12); + } + + .login-qr-dialog__meta-item span { + display: block; + font-size: 12px; + color: #94a3b8; + margin-bottom: 6px; + } + + .login-qr-dialog__meta-item strong { + display: block; + font-size: 13px; + color: #0f172a; + word-break: break-all; + } + + .login-qr-dialog__actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-top: 16px; + } + + .login-qr-history { + padding: 18px; + border-radius: 22px; + border: 1px solid rgba(148, 163, 184, 0.14); + background: rgba(248, 250, 252, 0.68); + } + + .login-qr-history__head { + margin-bottom: 14px; + } + + .login-qr-history__head h4 { + font-size: 16px; + color: #0f172a; + margin-bottom: 6px; + } + + .login-qr-history__head p { + font-size: 13px; + color: #64748b; + } + + .login-qr-history__grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + } + + .login-qr-history__card { + padding: 12px; + border-radius: 18px; + background: rgba(255,255,255,0.92); + border: 1px solid rgba(148, 163, 184, 0.12); + } + + .login-qr-history__card--active { + box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.22); + } + + .login-qr-history__thumb { + width: 100%; + aspect-ratio: 1 / 1; + border-radius: 14px; + overflow: hidden; + background: #fff; + border: 1px solid rgba(148, 163, 184, 0.10); + margin-bottom: 10px; + } + + .login-qr-history__thumb img { + width: 100%; + height: 100%; + object-fit: contain; + } + + .login-qr-history__thumb-empty { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #94a3b8; + } + + .login-qr-history__status { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: #334155; + margin-bottom: 8px; + } + + .login-qr-history__status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #cbd5e1; + flex-shrink: 0; + } + + .login-qr-history__status-dot--healthy { + background: #10b981; + } + + .login-qr-history__status-dot--warning { + background: #f59e0b; + } + + .login-qr-history__status-dot--danger { + background: #ef4444; + } + + .login-qr-history__status-dot--soft { + background: #94a3b8; + } + + .login-qr-history__text { + font-size: 12px; + color: #64748b; + line-height: 1.6; + word-break: break-all; + } + .health-overview-card .el-card__body { padding: 20px !important; } @@ -2197,6 +2785,14 @@ .health-service-grid { grid-template-columns: 1fr; } + + .login-qr-dialog__hero { + grid-template-columns: 1fr; + } + + .login-qr-history__grid { + grid-template-columns: 1fr; + } } @media (max-width: 768px) { @@ -2248,6 +2844,15 @@ font-size: 24px; } + .login-qr-banner__content { + flex-direction: column; + align-items: flex-start; + } + + .login-qr-dialog__meta { + grid-template-columns: 1fr; + } + .chart-container--large, .chart-container--panel { height: 260px; diff --git a/docs/wechat_ipad多版本Server适配路线图.md b/docs/wechat_ipad多版本Server适配路线图.md index b109efa..0a45e4a 100644 --- a/docs/wechat_ipad多版本Server适配路线图.md +++ b/docs/wechat_ipad多版本Server适配路线图.md @@ -25,6 +25,7 @@ - 已将 [robot.py](/d:/learn/abot/robot.py:1) 精简为“注册回调 + 业务处理”,不再直接维护 855 的运行时主循环 - 已补上 `Legacy855WechatClient` 的显式初始化入口,避免 provider 多继承构造链不稳定 - 已删除历史 `wechat_ipad/client/` 目录,避免后续误回退到旧实现 +- 已为 855 登录流程补充 Dashboard 首页二维码引导态,支持未登录时自动弹窗、倒计时与最近二维码记录展示 当前尚未完成的关键项: @@ -36,6 +37,7 @@ - “接入入口已收口” - “855 运行时主链路已迁入 provider” +- “未登录场景已有 Dashboard 可视化登录引导” - “尚未达到 855 可直接替换现网上线的最终状态” ## 2. 当前问题概览 diff --git a/robot.py b/robot.py index 8750ecb..0aa8496 100644 --- a/robot.py +++ b/robot.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import asyncio +import base64 +import io import os import threading import time @@ -8,6 +10,7 @@ import traceback import uuid from collections import deque from loguru import logger +import qrcode import wechat_ipad from base.plugin_common.message_plugin_interface import MessagePluginInterface @@ -65,6 +68,12 @@ class Robot: # 3. 这样主线程就不会再把“线程已启动”误判成“wechat 已成功就绪”。 self.ipad_startup_event = threading.Event() self.ipad_startup_error = None + # Dashboard 登录引导态: + # 1. 首次部署或登录失效时,后台首页需要知道当前二维码、剩余有效期和最近一次刷新时间; + # 2. 这类状态属于“运行时临时信息”,不应该写回配置文件,也不值得额外拉一层服务; + # 3. 因此直接挂在 Robot 上,用锁保护跨线程读写,保持实现足够轻。 + self._ipad_login_qr_lock = threading.Lock() + self.ipad_login_qr_state = self._build_empty_ipad_login_qr_state() self.wxid = None self.nickname = None self.alias = None @@ -317,6 +326,8 @@ class Robot: on_idle_payload=self._handle_runtime_idle_payload, on_logout=self._handle_ipad_logout, on_runtime_state_change=self._handle_runtime_state_change, + on_login_qr_update=self._handle_ipad_login_qr_update, + on_login_qr_cleared=self._handle_ipad_login_qr_cleared, ) except Exception as e: @@ -413,6 +424,159 @@ class Robot: self.LOG.warning(f"读取 TOML 配置失败,将按空配置继续: path={normalized_path}, error={e}") return {} + @staticmethod + def _build_empty_ipad_login_qr_state() -> dict: + """构造 Dashboard 可直接消费的默认二维码登录态。""" + return { + "logged_in": False, + "active": False, + "status": "idle", + "status_text": "尚未进入扫码登录流程", + "current": {}, + "history": [], + "updated_at": 0, + } + + @staticmethod + def _build_qr_image_data(scan_url: str) -> str: + """把扫码内容生成 base64 图片,供 Dashboard 直接展示。""" + normalized_scan_url = str(scan_url or "").strip() + if not normalized_scan_url: + return "" + + try: + # 这里直接在后端生成二维码图片: + # 1. 避免首页再额外引入前端二维码依赖,减少静态资源改动; + # 2. 即使 provider 没有返回可直接访问的图片 URL,只要有扫码内容也能展示; + # 3. 返回 data URI 后,前端只需要普通 `` 即可渲染。 + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=4, + ) + qr.add_data(normalized_scan_url) + qr.make(fit=True) + image = qr.make_image(fill_color="black", back_color="white") + buffer = io.BytesIO() + image.save(buffer, format="PNG") + encoded = base64.b64encode(buffer.getvalue()).decode("utf-8") + return f"data:image/png;base64,{encoded}" + except Exception: + return "" + + def get_ipad_login_qr_state(self) -> dict: + """返回当前 Dashboard 可读取的二维码登录态快照。""" + with self._ipad_login_qr_lock: + login_state_flag = bool(self.ipad_login_qr_state.get("logged_in", False)) + qr_status = str(self.ipad_login_qr_state.get("status", "idle") or "idle") + state = { + "logged_in": bool(self.wxid) or login_state_flag or qr_status in {"confirmed", "logged_in"}, + "active": bool(self.ipad_login_qr_state.get("active", False)), + "status": qr_status, + "status_text": str( + self.ipad_login_qr_state.get("status_text", "尚未进入扫码登录流程") or "尚未进入扫码登录流程" + ), + "updated_at": float(self.ipad_login_qr_state.get("updated_at", 0) or 0), + "current": dict(self.ipad_login_qr_state.get("current", {}) or {}), + "history": [dict(item or {}) for item in (self.ipad_login_qr_state.get("history", []) or [])], + "runtime_running": bool(self.ipad_running), + "wxid": str(self.wxid or ""), + "nickname": str(self.nickname or ""), + } + + now_ts = time.time() + current = state.get("current", {}) or {} + expires_at = float(current.get("expires_at", 0) or 0) + if expires_at > 0: + current["remaining_seconds"] = max(0, int(expires_at - now_ts)) + else: + current["remaining_seconds"] = int(current.get("remaining_seconds", 0) or 0) + state["current"] = current + state["server_now"] = now_ts + return state + + async def _handle_ipad_login_qr_update(self, payload: dict) -> None: + """同步 provider 扫码登录态到 Robot,供 Dashboard 轮询读取。""" + now_ts = time.time() + uuid_value = str((payload or {}).get("uuid", "") or "").strip() + scan_url = str((payload or {}).get("scan_url", "") or "").strip() + raw_url = str((payload or {}).get("url", "") or "").strip() + status = str((payload or {}).get("status", "waiting") or "waiting").strip() or "waiting" + status_text = str((payload or {}).get("status_text", "等待扫码登录") or "等待扫码登录").strip() + login_source = str((payload or {}).get("login_source", "fresh_qr") or "fresh_qr").strip() + expires_in = (payload or {}).get("expires_in") + expires_in = None if expires_in in (None, "") else max(0, int(expires_in)) + current_record = { + "uuid": uuid_value, + "scan_url": scan_url, + "raw_url": raw_url, + "image_data": self._build_qr_image_data(scan_url), + "status": status, + "status_text": status_text, + "login_source": login_source, + "updated_at": now_ts, + "updated_at_text": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_ts)), + } + if expires_in is not None: + current_record["remaining_seconds"] = expires_in + current_record["expires_at"] = now_ts + expires_in + + with self._ipad_login_qr_lock: + history_records = list(self.ipad_login_qr_state.get("history", []) or []) + # 二维码历史只在“uuid 发生变化”时追加一条: + # 1. 倒计时刷新会非常频繁,如果每次都入历史,前端会被大量重复记录淹没; + # 2. 这里把历史理解为“最近几次生成过哪些二维码”,而不是每一秒状态快照; + # 3. 这样首页既能展示多个二维码记录,也能保持列表简洁可读。 + if uuid_value: + existing_index = next( + (index for index, item in enumerate(history_records) if str(item.get("uuid", "") or "") == uuid_value), + -1, + ) + history_entry = dict(current_record) + if existing_index >= 0: + history_records[existing_index] = history_entry + else: + history_records.insert(0, history_entry) + history_records = history_records[:3] + + self.ipad_login_qr_state = { + "logged_in": False, + "active": status != "confirmed", + "status": status, + "status_text": status_text, + "current": current_record, + "history": history_records, + "updated_at": now_ts, + } + + async def _handle_ipad_login_qr_cleared(self, payload: dict | None = None) -> None: + """在登录完成或识别到已有登录态后关闭首页二维码引导。""" + now_ts = time.time() + status = str((payload or {}).get("status", "idle") or "idle").strip() or "idle" + status_text = str((payload or {}).get("status_text", "登录流程已结束") or "登录流程已结束").strip() + cleared_uuid = str((payload or {}).get("uuid", "") or "").strip() + + with self._ipad_login_qr_lock: + history_records = list(self.ipad_login_qr_state.get("history", []) or []) + if cleared_uuid: + for item in history_records: + if str(item.get("uuid", "") or "") == cleared_uuid: + item["status"] = status + item["status_text"] = status_text + item["updated_at"] = now_ts + item["updated_at_text"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_ts)) + + self.ipad_login_qr_state = { + "logged_in": status in {"confirmed", "logged_in"} or bool(self.wxid), + "active": False, + "status": status, + "status_text": status_text, + "current": {}, + "history": history_records[:3], + "updated_at": now_ts, + } + async def _on_ipad_login_ready(self, login_identity: dict) -> None: """处理 provider 登录成功后的项目侧初始化动作。 @@ -433,6 +597,12 @@ class Robot: self.ipad_bot.alias = self.alias self.ipad_bot.phone = self.phone self.ipad_bot.signature = self.signature + await self._handle_ipad_login_qr_cleared( + { + "status": "confirmed", + "status_text": "微信已登录,二维码弹窗已关闭", + } + ) self.LOG.info( f"wechat_ipad登录账号信息: wxid: {self.wxid} 昵称: {self.nickname} 微信号: {self.alias} 手机号: {self.phone}" ) diff --git a/wechat_ipad/providers/legacy_855/runtime.py b/wechat_ipad/providers/legacy_855/runtime.py index 803bf82..8a880dd 100644 --- a/wechat_ipad/providers/legacy_855/runtime.py +++ b/wechat_ipad/providers/legacy_855/runtime.py @@ -49,6 +49,8 @@ class Legacy855RuntimeMixin: on_idle_payload: AsyncCallback | None = None, on_logout: AsyncCallback | None = None, on_runtime_state_change: AsyncCallback | None = None, + on_login_qr_update: AsyncCallback | None = None, + on_login_qr_cleared: AsyncCallback | None = None, ) -> None: """启动 855 provider 的完整运行时。 @@ -73,6 +75,8 @@ class Legacy855RuntimeMixin: ipad_config=ipad_config, state_path=state_path, logger=logger, + on_login_qr_update=on_login_qr_update, + on_login_qr_cleared=on_login_qr_cleared, ) # 登录后的项目初始化若失败,应直接中断启动: @@ -163,6 +167,8 @@ class Legacy855RuntimeMixin: ipad_config: dict, state_path: str, logger, + on_login_qr_update: AsyncCallback | None = None, + on_login_qr_cleared: AsyncCallback | None = None, ) -> None: """保证当前 provider 已完成登录,并把登录结果写回配置。 @@ -178,6 +184,15 @@ class Legacy855RuntimeMixin: self.alias = profile.get("Alias", "") self.phone = profile.get("BindMobile", {}).get("string", "") self.signature = profile.get("Signature", "") + await self._safe_callback( + on_login_qr_cleared, + { + "status": "logged_in", + "status_text": "已检测到现有登录态", + }, + logger=logger, + callback_name="on_login_qr_cleared", + ) logger.info( f"wechat_ipad登录账号信息: wxid: {self.wxid} 昵称: {self.nickname} 微信号: {self.alias} 手机号: {self.phone}" ) @@ -186,9 +201,11 @@ class Legacy855RuntimeMixin: while not await self.is_logged_in(wxid): uuid = "" url = "" + login_source = "fresh_qr" try: if await self.get_cached_info(wxid): uuid = await self.awaken_login(wxid) + login_source = "awaken" logger.info(f"获取到登录uuid: {uuid}") else: uuid, url = await self.get_qr_code(device_id=device_id, device_name=device_name, print_qr=True) @@ -197,17 +214,77 @@ class Legacy855RuntimeMixin: except Exception as e: logger.error(f"登录过程出错: {e}") uuid, url = await self.get_qr_code(device_id=device_id, device_name=device_name, print_qr=True) + login_source = "fresh_qr" logger.info(f"获取到登录uuid: {uuid}") logger.info(f"获取到登录二维码: {url}") + # 每次拿到新的 uuid 都立刻把二维码状态推给上层: + # 1. 这样 Dashboard 无需等待下一次轮询结果,就能立刻弹出二维码; + # 2. 即使是 awaken 登录没有返回图片 URL,也可以先靠 uuid 生成扫码内容; + # 3. 后续倒计时再通过 check_login_uuid 的轮询结果持续刷新。 + scan_url = f"http://weixin.qq.com/x/{uuid}" if uuid else "" + await self._safe_callback( + on_login_qr_update, + { + "uuid": uuid, + "url": url, + "scan_url": scan_url, + "expires_in": None, + "status": "waiting", + "status_text": "等待扫码登录", + "login_source": login_source, + }, + logger=logger, + callback_name="on_login_qr_update", + ) + while True: logger.info(f"uuid: {uuid}, url: {url}") stat, data = await self.check_login_uuid(uuid, device_id=device_id) if stat: + await self._safe_callback( + on_login_qr_cleared, + { + "status": "confirmed", + "status_text": "扫码登录成功", + "uuid": uuid, + }, + logger=logger, + callback_name="on_login_qr_cleared", + ) + break + + # 855 的扫码登录会返回剩余有效期: + # 1. 这里把它直接同步给上层,Dashboard 就能展示实时倒计时; + # 2. 一旦倒计时归零,当前二维码已失效,应跳出内层循环重新申请新二维码; + # 3. 这样新环境登录时不会卡在一张已经过期的旧码上。 + expires_in = int(data or 0) + qr_status = "expired" if expires_in <= 0 else "waiting" + qr_status_text = "二维码已过期,准备刷新" if expires_in <= 0 else "等待扫码登录" + await self._safe_callback( + on_login_qr_update, + { + "uuid": uuid, + "url": url, + "scan_url": scan_url, + "expires_in": expires_in, + "status": qr_status, + "status_text": qr_status_text, + "login_source": login_source, + }, + logger=logger, + callback_name="on_login_qr_update", + ) + logger.info(f"等待登录中,过期倒计时:{expires_in}") + if expires_in <= 0: break - logger.info(f"等待登录中,过期倒计时:{data}") await asyncio.sleep(5) + if not stat: + # 当前二维码失效后回到外层 while 重新申请新二维码, + # 这样可以持续给 Dashboard 产出新的扫码入口。 + continue + self._apply_login_result(data=data, logger=logger) ipad_config["wxid"] = self.wxid ipad_config["device_name"] = device_name