新增Dashboard未登录二维码引导与倒计时
This commit is contained in:
@@ -12,6 +12,36 @@ robot_bp = Blueprint('robot', __name__, url_prefix='/robot')
|
|||||||
LOG = logger
|
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:
|
def _build_group_ops_profile(server, group_id: str, group_name: str, peak_hours: list, plugin_stats: list) -> dict:
|
||||||
"""构建群运营 2.0 所需的群画像摘要。
|
"""构建群运营 2.0 所需的群画像摘要。
|
||||||
|
|
||||||
@@ -423,6 +453,21 @@ def robot_management():
|
|||||||
return redirect('/contacts')
|
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路由
|
# API路由
|
||||||
@robot_bp.route('/api/groups')
|
@robot_bp.route('/api/groups')
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -17,6 +17,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-row :gutter="16" class="hero-row">
|
||||||
<el-col :xl="7" :lg="9" :md="24" :sm="24" :xs="24">
|
<el-col :xl="7" :lg="9" :md="24" :sm="24" :xs="24">
|
||||||
<el-card class="hero-card hero-card--profile" shadow="hover">
|
<el-card class="hero-card hero-card--profile" shadow="hover">
|
||||||
@@ -101,6 +122,86 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</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-row :gutter="16" class="metric-extended-row">
|
||||||
<el-col :span="8">
|
<el-col :span="8">
|
||||||
<el-card class="metric-card metric-card--soft" shadow="hover">
|
<el-card class="metric-card metric-card--soft" shadow="hover">
|
||||||
@@ -468,6 +569,19 @@
|
|||||||
summary: '加载中...'
|
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: [],
|
groups: [],
|
||||||
selectedGroupForHourlyTrend: '',
|
selectedGroupForHourlyTrend: '',
|
||||||
hourlyTrendDays: 1,
|
hourlyTrendDays: 1,
|
||||||
@@ -501,6 +615,53 @@
|
|||||||
|
|
||||||
return result;
|
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() {
|
healthCards() {
|
||||||
// 首页健康卡片统一在这里做展示层映射,模板只负责渲染,避免 HTML 中堆太多业务判断。
|
// 首页健康卡片统一在这里做展示层映射,模板只负责渲染,避免 HTML 中堆太多业务判断。
|
||||||
const robot = this.healthSummary.robot || {};
|
const robot = this.healthSummary.robot || {};
|
||||||
@@ -577,13 +738,22 @@
|
|||||||
this.loadData();
|
this.loadData();
|
||||||
this.refreshRuntimeSnapshot();
|
this.refreshRuntimeSnapshot();
|
||||||
this.loadCurrentUserInfo();
|
this.loadCurrentUserInfo();
|
||||||
|
this.loadLoginQrStatus();
|
||||||
this.loadGroups();
|
this.loadGroups();
|
||||||
this.systemInfoTimer = setInterval(this.refreshRuntimeSnapshot, 30000);
|
this.systemInfoTimer = setInterval(this.refreshRuntimeSnapshot, 30000);
|
||||||
|
this.loginQrPollTimer = setInterval(() => this.loadLoginQrStatus(false), 5000);
|
||||||
|
this.loginQrCountdownTimer = setInterval(this.tickLoginQrCountdown, 1000);
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.systemInfoTimer) {
|
if (this.systemInfoTimer) {
|
||||||
clearInterval(this.systemInfoTimer);
|
clearInterval(this.systemInfoTimer);
|
||||||
}
|
}
|
||||||
|
if (this.loginQrPollTimer) {
|
||||||
|
clearInterval(this.loginQrPollTimer);
|
||||||
|
}
|
||||||
|
if (this.loginQrCountdownTimer) {
|
||||||
|
clearInterval(this.loginQrCountdownTimer);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
loadData() {
|
loadData() {
|
||||||
@@ -629,6 +799,117 @@
|
|||||||
console.error('加载系统健康摘要出错:', error);
|
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() {
|
renderSystemCharts() {
|
||||||
this.renderPieChart('cpuChart', this.systemInfo.cpu_usage, 'CPU使用率');
|
this.renderPieChart('cpuChart', this.systemInfo.cpu_usage, 'CPU使用率');
|
||||||
this.renderPieChart('memoryChart', this.systemInfo.memory_usage, '内存使用率');
|
this.renderPieChart('memoryChart', this.systemInfo.memory_usage, '内存使用率');
|
||||||
@@ -1463,6 +1744,313 @@
|
|||||||
margin-bottom: 16px;
|
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 {
|
.health-overview-card .el-card__body {
|
||||||
padding: 20px !important;
|
padding: 20px !important;
|
||||||
}
|
}
|
||||||
@@ -2197,6 +2785,14 @@
|
|||||||
.health-service-grid {
|
.health-service-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-qr-dialog__hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-qr-history__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
@@ -2248,6 +2844,15 @@
|
|||||||
font-size: 24px;
|
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--large,
|
||||||
.chart-container--panel {
|
.chart-container--panel {
|
||||||
height: 260px;
|
height: 260px;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
- 已将 [robot.py](/d:/learn/abot/robot.py:1) 精简为“注册回调 + 业务处理”,不再直接维护 855 的运行时主循环
|
- 已将 [robot.py](/d:/learn/abot/robot.py:1) 精简为“注册回调 + 业务处理”,不再直接维护 855 的运行时主循环
|
||||||
- 已补上 `Legacy855WechatClient` 的显式初始化入口,避免 provider 多继承构造链不稳定
|
- 已补上 `Legacy855WechatClient` 的显式初始化入口,避免 provider 多继承构造链不稳定
|
||||||
- 已删除历史 `wechat_ipad/client/` 目录,避免后续误回退到旧实现
|
- 已删除历史 `wechat_ipad/client/` 目录,避免后续误回退到旧实现
|
||||||
|
- 已为 855 登录流程补充 Dashboard 首页二维码引导态,支持未登录时自动弹窗、倒计时与最近二维码记录展示
|
||||||
|
|
||||||
当前尚未完成的关键项:
|
当前尚未完成的关键项:
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
|
|
||||||
- “接入入口已收口”
|
- “接入入口已收口”
|
||||||
- “855 运行时主链路已迁入 provider”
|
- “855 运行时主链路已迁入 provider”
|
||||||
|
- “未登录场景已有 Dashboard 可视化登录引导”
|
||||||
- “尚未达到 855 可直接替换现网上线的最终状态”
|
- “尚未达到 855 可直接替换现网上线的最终状态”
|
||||||
|
|
||||||
## 2. 当前问题概览
|
## 2. 当前问题概览
|
||||||
|
|||||||
170
robot.py
170
robot.py
@@ -1,5 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -8,6 +10,7 @@ import traceback
|
|||||||
import uuid
|
import uuid
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
import qrcode
|
||||||
|
|
||||||
import wechat_ipad
|
import wechat_ipad
|
||||||
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
||||||
@@ -65,6 +68,12 @@ class Robot:
|
|||||||
# 3. 这样主线程就不会再把“线程已启动”误判成“wechat 已成功就绪”。
|
# 3. 这样主线程就不会再把“线程已启动”误判成“wechat 已成功就绪”。
|
||||||
self.ipad_startup_event = threading.Event()
|
self.ipad_startup_event = threading.Event()
|
||||||
self.ipad_startup_error = None
|
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.wxid = None
|
||||||
self.nickname = None
|
self.nickname = None
|
||||||
self.alias = None
|
self.alias = None
|
||||||
@@ -317,6 +326,8 @@ class Robot:
|
|||||||
on_idle_payload=self._handle_runtime_idle_payload,
|
on_idle_payload=self._handle_runtime_idle_payload,
|
||||||
on_logout=self._handle_ipad_logout,
|
on_logout=self._handle_ipad_logout,
|
||||||
on_runtime_state_change=self._handle_runtime_state_change,
|
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:
|
except Exception as e:
|
||||||
@@ -413,6 +424,159 @@ class Robot:
|
|||||||
self.LOG.warning(f"读取 TOML 配置失败,将按空配置继续: path={normalized_path}, error={e}")
|
self.LOG.warning(f"读取 TOML 配置失败,将按空配置继续: path={normalized_path}, error={e}")
|
||||||
return {}
|
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:
|
async def _on_ipad_login_ready(self, login_identity: dict) -> None:
|
||||||
"""处理 provider 登录成功后的项目侧初始化动作。
|
"""处理 provider 登录成功后的项目侧初始化动作。
|
||||||
|
|
||||||
@@ -433,6 +597,12 @@ class Robot:
|
|||||||
self.ipad_bot.alias = self.alias
|
self.ipad_bot.alias = self.alias
|
||||||
self.ipad_bot.phone = self.phone
|
self.ipad_bot.phone = self.phone
|
||||||
self.ipad_bot.signature = self.signature
|
self.ipad_bot.signature = self.signature
|
||||||
|
await self._handle_ipad_login_qr_cleared(
|
||||||
|
{
|
||||||
|
"status": "confirmed",
|
||||||
|
"status_text": "微信已登录,二维码弹窗已关闭",
|
||||||
|
}
|
||||||
|
)
|
||||||
self.LOG.info(
|
self.LOG.info(
|
||||||
f"wechat_ipad登录账号信息: wxid: {self.wxid} 昵称: {self.nickname} 微信号: {self.alias} 手机号: {self.phone}"
|
f"wechat_ipad登录账号信息: wxid: {self.wxid} 昵称: {self.nickname} 微信号: {self.alias} 手机号: {self.phone}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class Legacy855RuntimeMixin:
|
|||||||
on_idle_payload: AsyncCallback | None = None,
|
on_idle_payload: AsyncCallback | None = None,
|
||||||
on_logout: AsyncCallback | None = None,
|
on_logout: AsyncCallback | None = None,
|
||||||
on_runtime_state_change: 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:
|
) -> None:
|
||||||
"""启动 855 provider 的完整运行时。
|
"""启动 855 provider 的完整运行时。
|
||||||
|
|
||||||
@@ -73,6 +75,8 @@ class Legacy855RuntimeMixin:
|
|||||||
ipad_config=ipad_config,
|
ipad_config=ipad_config,
|
||||||
state_path=state_path,
|
state_path=state_path,
|
||||||
logger=logger,
|
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,
|
ipad_config: dict,
|
||||||
state_path: str,
|
state_path: str,
|
||||||
logger,
|
logger,
|
||||||
|
on_login_qr_update: AsyncCallback | None = None,
|
||||||
|
on_login_qr_cleared: AsyncCallback | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""保证当前 provider 已完成登录,并把登录结果写回配置。
|
"""保证当前 provider 已完成登录,并把登录结果写回配置。
|
||||||
|
|
||||||
@@ -178,6 +184,15 @@ class Legacy855RuntimeMixin:
|
|||||||
self.alias = profile.get("Alias", "")
|
self.alias = profile.get("Alias", "")
|
||||||
self.phone = profile.get("BindMobile", {}).get("string", "")
|
self.phone = profile.get("BindMobile", {}).get("string", "")
|
||||||
self.signature = profile.get("Signature", "")
|
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(
|
logger.info(
|
||||||
f"wechat_ipad登录账号信息: wxid: {self.wxid} 昵称: {self.nickname} 微信号: {self.alias} 手机号: {self.phone}"
|
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):
|
while not await self.is_logged_in(wxid):
|
||||||
uuid = ""
|
uuid = ""
|
||||||
url = ""
|
url = ""
|
||||||
|
login_source = "fresh_qr"
|
||||||
try:
|
try:
|
||||||
if await self.get_cached_info(wxid):
|
if await self.get_cached_info(wxid):
|
||||||
uuid = await self.awaken_login(wxid)
|
uuid = await self.awaken_login(wxid)
|
||||||
|
login_source = "awaken"
|
||||||
logger.info(f"获取到登录uuid: {uuid}")
|
logger.info(f"获取到登录uuid: {uuid}")
|
||||||
else:
|
else:
|
||||||
uuid, url = await self.get_qr_code(device_id=device_id, device_name=device_name, print_qr=True)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"登录过程出错: {e}")
|
logger.error(f"登录过程出错: {e}")
|
||||||
uuid, url = await self.get_qr_code(device_id=device_id, device_name=device_name, print_qr=True)
|
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"获取到登录uuid: {uuid}")
|
||||||
logger.info(f"获取到登录二维码: {url}")
|
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:
|
while True:
|
||||||
logger.info(f"uuid: {uuid}, url: {url}")
|
logger.info(f"uuid: {uuid}, url: {url}")
|
||||||
stat, data = await self.check_login_uuid(uuid, device_id=device_id)
|
stat, data = await self.check_login_uuid(uuid, device_id=device_id)
|
||||||
if stat:
|
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
|
break
|
||||||
logger.info(f"等待登录中,过期倒计时:{data}")
|
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
if not stat:
|
||||||
|
# 当前二维码失效后回到外层 while 重新申请新二维码,
|
||||||
|
# 这样可以持续给 Dashboard 产出新的扫码入口。
|
||||||
|
continue
|
||||||
|
|
||||||
self._apply_login_result(data=data, logger=logger)
|
self._apply_login_result(data=data, logger=logger)
|
||||||
ipad_config["wxid"] = self.wxid
|
ipad_config["wxid"] = self.wxid
|
||||||
ipad_config["device_name"] = device_name
|
ipad_config["device_name"] = device_name
|
||||||
|
|||||||
Reference in New Issue
Block a user