新增Dashboard未登录二维码引导与倒计时

This commit is contained in:
liuwei
2026-05-07 11:10:00 +08:00
parent c628afc530
commit 86f8d57874
5 changed files with 900 additions and 1 deletions

View File

@@ -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

View File

@@ -17,6 +17,27 @@
</div>
</div>
<el-alert
v-if="showLoginQrBanner"
class="login-qr-banner"
:closable="false"
type="warning"
show-icon>
<template slot="title">
<div class="login-qr-banner__content">
<div>
<div class="login-qr-banner__title">当前微信未登录,首页已进入扫码引导模式</div>
<div class="login-qr-banner__desc">
{% raw %}{{ loginQrDialog.status_text || '请使用手机微信扫码登录当前环境。' }}{% endraw %}
</div>
</div>
<el-button type="primary" size="mini" @click="openLoginQrDialog">
打开二维码
</el-button>
</div>
</template>
</el-alert>
<el-row :gutter="16" class="hero-row">
<el-col :xl="7" :lg="9" :md="24" :sm="24" :xs="24">
<el-card class="hero-card hero-card--profile" shadow="hover">
@@ -101,6 +122,86 @@
</el-col>
</el-row>
<el-dialog
title="微信登录二维码"
:visible.sync="loginQrDialog.visible"
width="760px"
class="login-qr-dialog"
:close-on-click-modal="false">
<div class="login-qr-dialog__body" v-loading="loginQrDialog.loading">
<div class="login-qr-dialog__hero">
<div class="login-qr-dialog__preview">
<div v-if="loginQrCurrent.image_data" class="login-qr-dialog__image-wrap">
<img :src="loginQrCurrent.image_data" alt="微信登录二维码" class="login-qr-dialog__image" />
</div>
<div v-else class="login-qr-dialog__image-wrap login-qr-dialog__image-wrap--empty">
<i class="el-icon-loading"></i>
<span>二维码生成中</span>
</div>
</div>
<div class="login-qr-dialog__summary">
<div class="login-qr-dialog__badge-row">
<span class="login-qr-dialog__badge" :class="`login-qr-dialog__badge--${loginQrStatusTone}`">
{% raw %}{{ loginQrStatusText }}{% endraw %}
</span>
<span class="login-qr-dialog__badge login-qr-dialog__badge--soft">
{% raw %}{{ loginQrSourceText }}{% endraw %}
</span>
</div>
<h3>新环境登录引导</h3>
<p>{% raw %}{{ loginQrDialog.status_text || '请使用微信扫码完成登录。' }}{% endraw %}</p>
<div class="login-qr-dialog__countdown">
<span class="login-qr-dialog__countdown-label">二维码有效期</span>
<span class="login-qr-dialog__countdown-value">{% raw %}{{ loginQrCountdownText }}{% endraw %}</span>
</div>
<div class="login-qr-dialog__meta">
<div class="login-qr-dialog__meta-item">
<span>UUID</span>
<strong>{% raw %}{{ loginQrCurrent.uuid || '-' }}{% endraw %}</strong>
</div>
<div class="login-qr-dialog__meta-item">
<span>最近刷新</span>
<strong>{% raw %}{{ loginQrCurrent.updated_at_text || '-' }}{% endraw %}</strong>
</div>
</div>
<div class="login-qr-dialog__actions">
<el-button size="mini" @click="loadLoginQrStatus(true)">立即刷新状态</el-button>
<el-button v-if="loginQrCurrent.scan_url" type="text" @click="copyLoginQrScanUrl">
复制扫码链接
</el-button>
</div>
</div>
</div>
<div v-if="loginQrDialog.history.length" class="login-qr-history">
<div class="login-qr-history__head">
<h4>最近二维码记录</h4>
<p>保留最近几次二维码,方便确认是否已经刷新到新码。</p>
</div>
<div class="login-qr-history__grid">
<div
v-for="item in loginQrDialog.history"
:key="item.uuid || item.updated_at"
class="login-qr-history__card"
:class="item.uuid === loginQrCurrent.uuid ? 'login-qr-history__card--active' : ''">
<div class="login-qr-history__thumb">
<img v-if="item.image_data" :src="item.image_data" alt="历史二维码" />
<div v-else class="login-qr-history__thumb-empty">暂无预览</div>
</div>
<div class="login-qr-history__info">
<div class="login-qr-history__status">
<span class="login-qr-history__status-dot" :class="`login-qr-history__status-dot--${mapLoginQrTone(item.status)}`"></span>
<span>{% raw %}{{ item.status_text || '等待扫码登录' }}{% endraw %}</span>
</div>
<div class="login-qr-history__text">UUID: {% raw %}{{ item.uuid || '-' }}{% endraw %}</div>
<div class="login-qr-history__text">时间: {% raw %}{{ item.updated_at_text || '-' }}{% endraw %}</div>
</div>
</div>
</div>
</div>
</div>
</el-dialog>
<el-row :gutter="16" class="metric-extended-row">
<el-col :span="8">
<el-card class="metric-card metric-card--soft" shadow="hover">
@@ -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;

View File

@@ -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. 当前问题概览

170
robot.py
View File

@@ -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 后,前端只需要普通 `<img>` 即可渲染。
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}"
)

View File

@@ -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