新增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;