新增Dashboard未登录二维码引导与倒计时
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
170
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 后,前端只需要普通 `<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}"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user