新增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;
|
||||
|
||||
Reference in New Issue
Block a user