Files
abot/admin/dashboard/templates/index.html
2026-05-07 15:32:22 +08:00

2999 lines
115 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}首页概览 - 机器人管理后台{% endblock %}
{% block content %}
<div class="dashboard-page">
<div class="page-title dashboard-title">
<div class="page-title-main">
<h1>首页概览</h1>
<p>聚焦机器人运行状态、关键指标与近期趋势,先看重点,再看明细。</p>
</div>
<div class="dashboard-title-actions">
<div class="live-badge">
<span class="live-dot"></span>
<span>实时监控中</span>
</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">{% raw %}{{ loginQrBannerTitle }}{% endraw %}</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">
<div v-if="currentUser.success" class="hero-profile">
<div class="hero-profile-top">
<div class="hero-avatar-wrap">
<img :src="currentUser.data.avatar" alt="用户头像" class="hero-avatar" />
</div>
<div class="hero-profile-copy">
<div class="hero-eyebrow">当前账号</div>
<h2>{% raw %}{{ currentUser.data.nickname || '未知用户' }}{% endraw %}</h2>
<p>{% raw %}{{ currentUser.data.signature || '正在管理机器人后台与系统运行状态。' }}{% endraw %}</p>
</div>
</div>
<div class="hero-profile-meta">
<div class="meta-item">
<span class="meta-label">微信 ID</span>
<span class="meta-value">{% raw %}{{ currentUser.data.wx_id || '-' }}{% endraw %}</span>
</div>
<div class="meta-item" v-if="currentUser.data.mobile">
<span class="meta-label">手机号</span>
<span class="meta-value">{% raw %}{{ currentUser.data.mobile }}{% endraw %}</span>
</div>
<div class="meta-item">
<span class="meta-label">系统运行</span>
<span class="meta-value">{% raw %}{{ formattedUptime }}{% endraw %}</span>
</div>
</div>
</div>
<div v-else class="user-info-empty hero-empty">
<i class="el-icon-warning-outline"></i>
<div>{% raw %}{{ currentUser.message || '未获取到用户信息' }}{% endraw %}</div>
</div>
</el-card>
</el-col>
<el-col :xl="17" :lg="15" :md="24" :sm="24" :xs="24">
<el-row :gutter="16" class="metric-grid">
<el-col :span="8">
<el-card class="metric-card metric-card--primary" shadow="hover">
<div class="metric-label">总调用次数</div>
<div class="metric-value">{% raw %}{{ totalCalls }}{% endraw %}</div>
<div class="metric-footnote">统计周期内累计请求量</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">成功率</div>
<div class="metric-value">{% raw %}{{ successRate.toFixed(2) }}{% endraw %}%</div>
<div class="metric-footnote">请求成功占比</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">平均响应时间</div>
<div class="metric-value">{% raw %}{{ avgResponseTime.toFixed(2) }}{% endraw %}<span class="metric-unit">ms</span></div>
<div class="metric-footnote">接口平均响应耗时</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">活跃用户数</div>
<div class="metric-value">{% raw %}{{ activeUsers }}{% endraw %}</div>
<div class="metric-footnote">近期实际触发用户</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">活跃群组数</div>
<div class="metric-value">{% raw %}{{ activeGroups }}{% endraw %}</div>
<div class="metric-footnote">近期开启互动群组</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card metric-card--soft" shadow="hover">
<div class="metric-label">系统运行时间</div>
<div class="metric-value">{% raw %}{{ formattedUptime }}{% endraw %}</div>
<div class="metric-footnote">当前实例持续在线时长</div>
</el-card>
</el-col>
</el-row>
</el-col>
</el-row>
<el-dialog
title="微信登录二维码"
:visible="shouldShowLoginQrDialog"
width="760px"
class="login-qr-dialog"
:show-close="false"
:close-on-click-modal="false"
:close-on-press-escape="false"
@update:visible="handleLoginQrDialogVisibleChange">
<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 v-if="loginQrCurrent.nick_name || loginQrCurrent.head_img_url" class="login-qr-dialog__account">
<div class="login-qr-dialog__account-avatar">
<img v-if="loginQrCurrent.head_img_url" :src="loginQrCurrent.head_img_url" alt="扫码账号头像" />
<span v-else>{% raw %}{{ (loginQrCurrent.nick_name || '?').slice(0, 1) }}{% endraw %}</span>
</div>
<div class="login-qr-dialog__account-info">
<span class="login-qr-dialog__account-label">当前扫码账号</span>
<strong>{% raw %}{{ loginQrCurrent.nick_name || '已识别微信账号' }}{% endraw %}</strong>
</div>
</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 %}{{ loginQrWayText }}{% endraw %}</strong>
</div>
<div class="login-qr-dialog__meta-item">
<span>原始状态</span>
<strong>{% raw %}{{ formatLoginQrRawState(loginQrCurrent.raw_state) }}{% 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>
<el-button
v-if="loginQrCurrent.verification_url && loginQrDialog.provider_stage === 'verification_required'"
type="text"
@click="openLoginQrVerificationUrl">
打开安全验证链接
</el-button>
</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">
<div class="metric-label">新增用户数</div>
<div class="metric-value">{% raw %}{{ newUsers }}{% endraw %}</div>
<div class="metric-footnote">统计周期内首次触发用户</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card" shadow="hover">
<div class="metric-label">群渗透率</div>
<div class="metric-value">{% raw %}{{ avgGroupPenetration.toFixed(2) }}{% endraw %}%</div>
<div class="metric-footnote">活跃群触发人数占成员比例(均值)</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="metric-card metric-card--soft" shadow="hover">
<div class="metric-label">群健康分</div>
<div class="metric-value">{% raw %}{{ groupHealthScore.toFixed(2) }}{% endraw %}</div>
<div class="metric-footnote">综合成功率与响应时延评分</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="health-row">
<el-col :span="24">
<el-card class="health-overview-card" shadow="hover">
<div class="section-heading section-heading--stack">
<div>
<h3>系统健康快照</h3>
<p>把连接状态、插件运行、异常数量、LLM 运行态与任务调度集中到一个面板里。</p>
</div>
<div class="health-overview-meta">
<span class="health-overview-meta__label">最近刷新</span>
<span class="health-overview-meta__value">{% raw %}{{ healthSummary.timestamp || '-' }}{% endraw %}</span>
</div>
</div>
<div class="health-grid">
<div v-for="card in healthCards" :key="card.key" class="health-item" :class="`health-item--${card.status}`">
<div class="health-item__head">
<span class="health-item__title">{% raw %}{{ card.title }}{% endraw %}</span>
<span class="health-item__badge" :class="`health-item__badge--${card.status}`">
{% raw %}{{ getHealthStatusText(card.status) }}{% endraw %}
</span>
</div>
<button
v-if="card.actionPath"
type="button"
class="health-item__action"
@click="openHealthDetail(card.actionPath)">
{% raw %}{{ card.actionLabel || '查看详情' }}{% endraw %}
</button>
<div class="health-item__value">{% raw %}{{ card.value }}{% endraw %}</div>
<div class="health-item__summary">{% raw %}{{ card.summary }}{% endraw %}</div>
<div v-if="card.serviceBlocks && card.serviceBlocks.length" class="health-service-grid">
<div
v-for="service in card.serviceBlocks"
:key="service.key"
class="health-service-panel"
:class="`health-service-panel--${service.status}`">
<div class="health-service-panel__head">
<div>
<div class="health-service-panel__title">{% raw %}{{ service.title }}{% endraw %}</div>
<div class="health-service-panel__summary">{% raw %}{{ service.summary }}{% endraw %}</div>
</div>
<span class="health-service-panel__badge" :class="`health-service-panel__badge--${service.status}`">
{% raw %}{{ getHealthStatusText(service.status) }}{% endraw %}
</span>
</div>
<div v-if="service.highlights && service.highlights.length" class="health-service-highlights">
<div
v-for="highlight in service.highlights"
:key="highlight.label"
class="health-service-highlight"
:class="highlight.tone ? `health-service-highlight--${highlight.tone}` : ''">
<span class="health-service-highlight__label">{% raw %}{{ highlight.label }}{% endraw %}</span>
<span class="health-service-highlight__value">{% raw %}{{ highlight.value }}{% endraw %}</span>
</div>
</div>
<div v-if="service.meters && service.meters.length" class="health-service-meter-list">
<div v-for="meter in service.meters" :key="meter.label" class="health-service-meter">
<div class="health-service-meter__head">
<span class="health-service-meter__label">{% raw %}{{ meter.label }}{% endraw %}</span>
<div class="health-service-meter__meta">
<span class="health-service-meter__number">{% raw %}{{ meter.displayValue }}{% endraw %}</span>
<span
v-if="meter.levelText"
class="health-service-meter__badge"
:class="meter.levelClass ? `health-service-meter__badge--${meter.levelClass}` : ''">
{% raw %}{{ meter.levelText }}{% endraw %}
</span>
</div>
</div>
<div class="health-service-meter__track">
<div
class="health-service-meter__fill"
:class="meter.levelClass ? `health-service-meter__fill--${meter.levelClass}` : ''"
:style="{ width: `${meter.percent}%` }"></div>
</div>
</div>
</div>
<div class="health-service-metrics">
<div v-for="metric in service.metrics" :key="metric.label" class="health-service-metric">
<span class="health-service-metric__label">{% raw %}{{ metric.label }}{% endraw %}</span>
<span class="health-service-metric__value">{% raw %}{{ metric.value }}{% endraw %}</span>
</div>
</div>
</div>
</div>
<div v-if="card.extra" class="health-item__extra">{% raw %}{{ card.extra }}{% endraw %}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="stats-highlight-row">
<el-col :span="8">
<el-card class="insight-card" shadow="hover">
<div class="section-heading">
<div>
<h3>热门用户</h3>
<p>最近最活跃的高频调用用户</p>
</div>
</div>
<el-table :data="topUsers" style="width: 100%">
<el-table-column label="用户信息" min-width="210">
<template slot-scope="scope">
<div class="rank-cell">
<span class="rank-index">{% raw %}{{ scope.$index + 1 }}{% endraw %}</span>
<div class="rank-copy">
<div class="rank-title">{% raw %}{{ scope.row.user_name || scope.row.user_id }}{% endraw %}</div>
<div class="rank-subtitle">ID: {% raw %}{{ scope.row.user_id }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="total_calls" label="调用次数" width="100" align="right"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="insight-card" shadow="hover">
<div class="section-heading">
<div>
<h3>热门群组</h3>
<p>最近互动最活跃的群组</p>
</div>
</div>
<el-table :data="topGroups" style="width: 100%">
<el-table-column label="群组信息" min-width="210">
<template slot-scope="scope">
<div class="rank-cell">
<span class="rank-index">{% raw %}{{ scope.$index + 1 }}{% endraw %}</span>
<div class="rank-copy">
<div class="rank-title">{% raw %}{{ scope.row.group_name || scope.row.group_id }}{% endraw %}</div>
<div class="rank-subtitle">ID: {% raw %}{{ scope.row.group_id }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="total_calls" label="调用次数" width="100" align="right"></el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="insight-card" shadow="hover">
<div class="section-heading">
<div>
<h3>热门插件</h3>
<p>最受欢迎的能力模块</p>
</div>
</div>
<el-table :data="topPlugins" style="width: 100%">
<el-table-column label="插件名称" min-width="210">
<template slot-scope="scope">
<div class="rank-cell rank-cell--simple">
<span class="rank-index">{% raw %}{{ scope.$index + 1 }}{% endraw %}</span>
<div class="rank-copy">
<div class="rank-title">{% raw %}{{ scope.row.plugin_name }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="total_calls" label="调用次数" width="100" align="right"></el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :span="16">
<el-card class="chart-card chart-card--large" shadow="hover">
<div slot="header" class="chart-card-header">
<div>
<h3>使用趋势</h3>
<p>总调用、成功调用、失败调用随时间变化</p>
</div>
</div>
<div class="chart-container chart-container--large">
<canvas id="trendChart" width="800" height="180"></canvas>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card class="chart-card" shadow="hover">
<div slot="header" class="chart-card-header">
<div>
<h3>成功率分析</h3>
<p>插件维度稳定性表现</p>
</div>
</div>
<div class="chart-container chart-container--panel">
<canvas id="successRateChart"></canvas>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="chart-row">
<el-col :span="12">
<el-card class="chart-card" shadow="hover">
<div slot="header" class="chart-card-header">
<div>
<h3>插件使用排行</h3>
<p>按调用次数排序的前十插件</p>
</div>
</div>
<div class="chart-container chart-container--panel">
<canvas id="pluginChart"></canvas>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card class="chart-card" shadow="hover" v-loading="hourlyMessageTrendLoading">
<div slot="header" class="chart-card-header chart-card-header--split">
<div>
<h3>按小时聊天趋势</h3>
<p>观察群组消息在一天内的分布节奏</p>
</div>
<div class="chart-toolbar">
<el-select v-model="selectedGroupForHourlyTrend" placeholder="选择群组" style="width: 190px;" @change="loadHourlyMessageTrend">
<el-option v-for="group in groups" :key="group.group_id" :label="group.group_name" :value="group.group_id"></el-option>
</el-select>
<el-select v-model="hourlyTrendDays" placeholder="选择时间范围" style="width: 120px;" @change="loadHourlyMessageTrend">
<el-option :value="1" label="最近1天"></el-option>
<el-option :value="2" label="最近2天"></el-option>
<el-option :value="3" label="最近3天"></el-option>
</el-select>
</div>
</div>
<div class="chart-container chart-container--panel">
<canvas id="hourlyMessageTrendChart" width="800" height="180"></canvas>
</div>
</el-card>
</el-col>
</el-row>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
totalCalls: 0,
successRate: 0,
activeUsers: 0,
activeGroups: 0,
newUsers: 0,
avgResponseTime: 0,
avgGroupPenetration: 0,
groupHealthScore: 0,
topUsers: [],
topGroups: [],
topPlugins: [],
pluginStats: [],
charts: {},
currentUser: {
success: false,
message: '加载中...',
data: {}
},
systemInfo: {
os: '加载中...',
os_version: '',
python_version: '加载中...',
cpu_usage: 0,
memory_usage: 0,
disk_usage: 0,
uptime: 0,
timestamp: '-'
},
healthSummary: {
timestamp: '-',
robot: {
status: 'warning',
running: false,
nickname: '',
wxid: '',
summary: '加载中...'
},
plugins: {
status: 'warning',
total: 0,
running: 0,
error: 0,
summary: '加载中...'
},
errors: {
status: 'warning',
recent_24h_count: 0,
summary: '加载中...'
},
infrastructure: {
status: 'warning',
summary: '加载中...',
mysql: {
status: 'warning',
summary: '加载中...'
},
redis: {
status: 'warning',
summary: '加载中...'
}
},
ai_runtime: {
status: 'warning',
total_calls: 0,
failed_calls: 0,
success_rate: 0,
avg_latency_ms: 0,
summary: '加载中...',
last_call: {},
scene_count: 0,
target_count: 0,
provider_count: 0,
has_routing: false,
default_scene: '',
default_backend: '',
last_provider: '',
last_backend: '',
last_scene: '',
last_model: '',
last_timestamp: '',
last_latency_ms: 0,
last_error: ''
},
scheduler: {
status: 'warning',
total_jobs: 0,
enabled_jobs: 0,
running_jobs: 0,
failed_jobs: 0,
invalid_jobs: 0,
paused_jobs: 0,
never_run_jobs: 0,
system_job_count: 0,
plugin_job_count: 0,
next_run_at: '',
latest_failed_job_name: '',
latest_failed_error: '',
summary: '加载中...'
}
},
loginQrDialog: {
visible: false,
loading: false,
logged_in: false,
active: false,
status: 'idle',
provider_name: '',
provider_stage: 'bootstrap',
connection_ready: false,
login_required: false,
status_text: '尚未进入扫码登录流程',
current: {},
history: [],
runtime_running: false,
server_now: 0
},
loginQrCountdownSeconds: 0,
groups: [],
selectedGroupForHourlyTrend: '',
hourlyTrendDays: 1,
hourlyMessageTrendData: {
hours: [],
counts: []
},
hourlyMessageTrendLoading: true,
charts: {
pluginChart: null,
successRateChart: null,
trendChart: null,
cpuChart: null,
memoryChart: null,
diskChart: null,
hourlyMessageTrendChart: null
}
}
},
computed: {
formattedUptime() {
const seconds = this.systemInfo.uptime;
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
let result = '';
if (days > 0) result += days + 'D ';
if (hours > 0 || days > 0) result += hours + 'H ';
result += minutes + 'M';
return result;
},
loginQrCurrent() {
return this.loginQrDialog.current || {};
},
showLoginQrBanner() {
return !this.loginQrDialog.logged_in;
},
shouldShowLoginQrDialog() {
// 首页登录弹窗的展示规则尽量简单且稳定:
// 1. 只要当前还没登录成功,就必须强制显示二维码弹窗,避免前端局部状态抖动把弹窗意外关掉;
// 2. 登录成功后再退回到本地 visible 控制,兼容后续如果需要保留“手动再次查看”的空间;
// 3. 这样能直接满足“没有可用账号,就一直要求扫码登录”的目标,不再依赖多处代码同步开关。
if (!this.loginQrDialog.logged_in) {
return true;
}
return !!this.loginQrDialog.visible;
},
loginQrStatusTone() {
if (this.loginQrDialog.provider_stage === 'connection_pending') {
return 'soft';
}
if (this.loginQrDialog.provider_stage === 'login_required') {
return 'danger';
}
return this.mapLoginQrTone(this.loginQrDialog.status);
},
loginQrBannerTitle() {
if (this.loginQrDialog.provider_stage === 'connection_pending') {
return '当前 864 服务连接尚未建立,首页已进入登录准备模式';
}
if (this.loginQrDialog.provider_stage === 'login_required') {
return '当前微信登录态已失效,首页已进入重新登录模式';
}
return '当前微信未登录,首页已进入扫码引导模式';
},
loginQrStatusText() {
const toneMap = {
waiting: '等待扫码',
expired: '二维码过期',
confirmed: '登录成功',
logged_in: '已复用登录态',
idle: '等待登录流程',
unavailable: '状态暂不可用'
};
if (this.loginQrDialog.provider_stage === 'connection_pending') {
return '等待建立连接';
}
if (this.loginQrDialog.provider_stage === 'login_required') {
return '需要重新登录';
}
if (this.loginQrDialog.provider_stage === 'status_unavailable') {
return '登录异常';
}
if (this.loginQrDialog.provider_stage === 'login_finalizing') {
return '登录收口中';
}
if (this.loginQrDialog.provider_stage === 'scan_confirmed') {
return '已扫码待确认';
}
if (this.loginQrDialog.provider_stage === 'verification_required') {
return '等待安全验证';
}
return toneMap[this.loginQrDialog.status] || '等待登录流程';
},
loginQrSourceText() {
const providerName = String(this.loginQrDialog.provider_name || '').toLowerCase();
if (providerName === 'server_864' || providerName === '864') {
if (this.loginQrDialog.provider_stage === 'connection_pending') {
return '864 服务端准备中';
}
if (this.loginQrDialog.provider_stage === 'status_unavailable') {
return '864 登录异常';
}
if (this.loginQrDialog.provider_stage === 'login_finalizing') {
return '864 登录收口中';
}
if (this.loginQrDialog.provider_stage === 'scan_confirmed') {
return '864 扫码确认中';
}
if (this.loginQrDialog.provider_stage === 'verification_required') {
return '864 二次验证';
}
return '864 服务端登录';
}
const source = this.loginQrCurrent.login_source;
if (source === 'awaken') {
return '缓存唤醒登录';
}
if (source === 'fresh_qr') {
return '新二维码登录';
}
return '登录引导中';
},
loginQrWayText() {
const normalizedWay = String(this.loginQrCurrent.login_way || '').trim().toLowerCase();
const wayMap = {
mac: 'mac',
win: 'win',
harmony: 'harmony',
car: 'car',
watch: 'watch'
};
// 弹窗里直接显示当前实际使用的登录终端形态:
// 1. 864 的二维码接口会根据 `Way` 走不同的登录链路,联调时很容易忘记当前到底用的是哪一档;
// 2. 用户已经明确在 `.env` 中切到了 `WECHAT_LOGIN_WAY=harmony`,这里展示出来能立刻确认配置有没有真的生效;
// 3. 若后续 provider 回传了别的兼容值,也保留原文,避免前端把未知新值错误折叠掉。
return wayMap[normalizedWay] || normalizedWay || '-';
},
loginQrCountdownText() {
if (this.loginQrDialog.logged_in) {
return '已登录';
}
if (this.loginQrDialog.status === 'expired') {
return '已过期,等待刷新';
}
if (this.loginQrDialog.provider_stage === 'connection_pending') {
return '等待服务端准备';
}
if (this.loginQrDialog.provider_stage === 'status_unavailable') {
return '请查看当前错误提示';
}
if (this.loginQrDialog.provider_stage === 'login_finalizing') {
return '扫码成功,等待服务端完成登录';
}
if (this.loginQrDialog.provider_stage === 'scan_confirmed') {
return '已扫码,等待服务端确认';
}
if (this.loginQrDialog.provider_stage === 'verification_required') {
return '等待打开验证链接';
}
if (this.loginQrDialog.provider_stage === 'login_required' && !this.loginQrCurrent.uuid) {
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 || {};
const plugins = this.healthSummary.plugins || {};
const errors = this.healthSummary.errors || {};
const infrastructure = this.healthSummary.infrastructure || {};
const aiRuntime = this.healthSummary.ai_runtime || {};
const scheduler = this.healthSummary.scheduler || {};
return [
{
key: 'robot',
title: '机器人连接',
status: robot.status || 'warning',
value: robot.running ? '在线' : '离线',
summary: robot.summary || '暂无状态',
extra: robot.wxid ? `标识:${robot.wxid}` : ''
},
{
key: 'plugins',
title: '插件运行',
status: plugins.status || 'warning',
value: `${plugins.running || 0} / ${plugins.total || 0}`,
summary: plugins.summary || '暂无状态',
extra: `异常 ${plugins.error || 0}`
},
{
key: 'errors',
title: '最近异常',
status: errors.status || 'warning',
value: `${errors.recent_24h_count || 0}`,
summary: errors.summary || '暂无状态',
extra: '统计窗口:近 24 小时'
},
{
key: 'infrastructure',
title: '基础设施',
status: infrastructure.status || 'warning',
value: `${this.countHealthyInfrastructureServices(infrastructure)} / 2`,
summary: infrastructure.summary || '暂无状态',
serviceBlocks: this.buildInfrastructureServiceBlocks(infrastructure),
extra: '首页展示的是服务摘要;如果后续要做更深入的运维排查,再单独拆详细页会更合适。',
actionLabel: '系统状态',
actionPath: '/system_status'
},
{
key: 'ai_runtime',
title: 'LLM 运行态',
status: aiRuntime.status || 'warning',
value: (aiRuntime.total_calls || 0) > 0
? `${this.formatMetricNumber(aiRuntime.success_rate, 2)}%`
: `${aiRuntime.scene_count || 0} 个场景`,
summary: aiRuntime.summary || '暂无状态',
serviceBlocks: this.buildAiRuntimeServiceBlocks(aiRuntime),
extra: this.buildAiRuntimeExtra(aiRuntime),
actionLabel: 'LLM详情',
actionPath: '/system_llm'
},
{
key: 'scheduler',
title: '任务调度',
status: scheduler.status || 'warning',
value: `${scheduler.enabled_jobs || 0} / ${scheduler.total_jobs || 0}`,
summary: scheduler.summary || '暂无状态',
serviceBlocks: this.buildSchedulerServiceBlocks(scheduler),
extra: this.buildSchedulerExtra(scheduler),
actionLabel: '任务中心',
actionPath: '/system_jobs'
}
];
}
},
mounted() {
this.currentView = '1';
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() {
const days = parseInt(this.timeRange);
this.loadDashboardSummary(days);
this.loadPluginStats(days);
this.loadPluginTrend(days);
},
refreshRuntimeSnapshot() {
// 系统资源与健康聚合都属于运行态信息,统一刷新可以降低后续维护成本。
this.loadSystemInfo();
this.loadSystemHealth();
},
openHealthDetail(path) {
// 健康卡片统一走这里做详情跳转,避免模板层分散路由字符串,后续改版时维护成本更低。
if (!path) {
return;
}
window.location.href = path;
},
loadSystemInfo() {
axios.get('/api/system_info')
.then(response => {
if (response.data.success) {
this.systemInfo = response.data.data;
this.$nextTick(() => {
this.renderSystemCharts();
});
}
})
.catch(error => {
console.error('加载系统信息出错:', error);
});
},
loadSystemHealth() {
axios.get('/api/system_health_summary')
.then(response => {
if (response.data.success) {
this.healthSummary = response.data.data || this.healthSummary;
}
})
.catch(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;
},
handleLoginQrDialogVisibleChange(nextVisible) {
// 未登录时弹窗不允许被真正关闭:
// 1. Element UI 仍可能在内部触发 `update:visible` 事件;
// 2. 如果这里直接接受 false就会再次出现“业务要求必须常驻但组件自己收起”的问题
// 3. 因此只有在已登录成功后,才允许同步关闭状态。
if (!this.loginQrDialog.logged_in) {
this.loginQrDialog.visible = true;
return;
}
this.loginQrDialog.visible = !!nextVisible;
},
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.loginQrDialog.provider_stage === 'connection_pending') {
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);
},
openLoginQrVerificationUrl() {
const verificationUrl = this.loginQrCurrent.verification_url || '';
if (!verificationUrl) {
this.$message.warning('当前暂无可打开的验证链接');
return;
}
window.open(verificationUrl, '_blank', 'noopener');
},
formatLoginQrRawState(rawState) {
const normalized = Number(rawState || 0);
const labelMap = {
0: '0 等待扫码',
1: '1 已扫码待确认',
2: '2 登录完成',
4: '4 终态/已失效'
};
return labelMap[normalized] || `${normalized || 0} 未知状态`;
},
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, '内存使用率');
this.renderPieChart('diskChart', this.systemInfo.disk_usage, '磁盘使用率');
},
getHealthStatusText(status) {
const statusMap = {
healthy: '健康',
warning: '关注',
danger: '异常'
};
return statusMap[status] || '未知';
},
formatCompactDuration(seconds) {
const totalSeconds = parseInt(seconds) || 0;
if (totalSeconds <= 0) return '-';
const days = Math.floor(totalSeconds / 86400);
const hours = Math.floor((totalSeconds % 86400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
if (days > 0) return `${days}D ${hours}H`;
if (hours > 0) return `${hours}H ${minutes}M`;
return `${minutes}M`;
},
formatMetricNumber(value, fractionDigits = 0) {
if (value === null || value === undefined || value === '') return '-';
const numeric = Number(value);
if (Number.isNaN(numeric)) return String(value);
return numeric.toFixed(fractionDigits);
},
normalizePercent(value) {
const numeric = Number(value || 0);
if (Number.isNaN(numeric)) return 0;
return Math.max(0, Math.min(100, numeric));
},
buildRiskLevel(percent, warningThreshold = 50, dangerThreshold = 80) {
const normalized = this.normalizePercent(percent);
if (normalized >= dangerThreshold) {
return { text: '高压', className: 'danger' };
}
if (normalized >= warningThreshold) {
return { text: '偏高', className: 'warning' };
}
return { text: '平稳', className: 'healthy' };
},
buildPositiveLevel(percent, warningThreshold = 70, healthyThreshold = 90) {
const normalized = this.normalizePercent(percent);
if (normalized >= healthyThreshold) {
return { text: '优秀', className: 'healthy' };
}
if (normalized >= warningThreshold) {
return { text: '可接受', className: 'warning' };
}
return { text: '偏低', className: 'danger' };
},
buildMeter(label, percent, displayValue, levelBuilder) {
const normalized = this.normalizePercent(percent);
const level = typeof levelBuilder === 'function'
? levelBuilder(normalized)
: { text: '', className: '' };
return {
label,
percent: normalized,
displayValue,
levelText: level.text || '',
levelClass: level.className || ''
};
},
buildLatencyMeter(label, latencyMs, warningThreshold = 1200, dangerThreshold = 3000) {
// 耗时不是天然百分比,这里映射成一个“压力条”视图:
// 1. 低于 warningThreshold 视为较平稳;
// 2. 接近 dangerThreshold 时让条形接近满格;
// 3. 超过 dangerThreshold 直接按 100% 展示,首页一眼就能看出慢调用风险。
const latency = Number(latencyMs || 0);
const safeLatency = Number.isNaN(latency) ? 0 : Math.max(0, latency);
const percent = Math.max(0, Math.min(100, (safeLatency / dangerThreshold) * 100));
return this.buildMeter(
label,
percent,
`${this.formatMetricNumber(safeLatency, 2)} ms`,
() => this.buildRiskLevel(percent, (warningThreshold / dangerThreshold) * 100, 100)
);
},
buildRatioPercent(numerator, denominator) {
const num = Number(numerator || 0);
const den = Number(denominator || 0);
if (Number.isNaN(num) || Number.isNaN(den) || den <= 0) {
return 0;
}
return this.normalizePercent((num / den) * 100);
},
countHealthyInfrastructureServices(infrastructure) {
const mysql = infrastructure.mysql || {};
const redis = infrastructure.redis || {};
let count = 0;
if (mysql.status === 'healthy') count += 1;
if (redis.status === 'healthy') count += 1;
return count;
},
buildInfrastructureServiceBlocks(infrastructure) {
const mysql = infrastructure.mysql || {};
const redis = infrastructure.redis || {};
return [
{
key: 'mysql',
title: 'MySQL',
status: mysql.status || 'warning',
summary: mysql.summary || '暂无状态',
highlights: [
{
label: '数据库',
value: mysql.database || '-',
tone: 'neutral'
},
{
label: '版本',
value: mysql.version || '-',
tone: 'neutral'
},
{
label: '慢SQL阈值',
value: `${this.formatMetricNumber(mysql.slow_query_threshold_ms)} ms`,
tone: 'info'
}
],
meters: [
this.buildMeter(
'连接负载',
mysql.connection_usage_percent,
`${this.formatMetricNumber(mysql.connection_usage_percent, 1)}%`,
(percent) => this.buildRiskLevel(percent, 45, 80)
)
],
metrics: [
{ label: '连接数', value: `${this.formatMetricNumber(mysql.threads_connected)} / ${mysql.max_connections || '-'}` },
{ label: '运行线程', value: this.formatMetricNumber(mysql.threads_running) },
{ label: 'QPS', value: this.formatMetricNumber(mysql.questions_per_second, 2) },
{ label: '库体积', value: `${this.formatMetricNumber(mysql.schema_size_mb, 2)} MB` },
{ label: '表数量', value: this.formatMetricNumber(mysql.table_count) }
]
},
{
key: 'redis',
title: 'Redis',
status: redis.status || 'warning',
summary: redis.summary || '暂无状态',
highlights: [
{
label: 'DB',
value: redis.db_index ?? '-',
tone: 'neutral'
},
{
label: '峰值内存',
value: redis.used_memory_peak_human || '-',
tone: 'neutral'
},
{
label: '阻塞客户端',
value: this.formatMetricNumber(redis.blocked_clients),
tone: Number(redis.blocked_clients || 0) > 0 ? 'warning' : 'healthy'
}
],
meters: [
this.buildMeter(
'内存占用',
redis.memory_usage_percent,
`${this.formatMetricNumber(redis.memory_usage_percent, 1)}%`,
(percent) => this.buildRiskLevel(percent, 60, 80)
),
this.buildMeter(
'命中率',
redis.hit_rate_percent,
`${this.formatMetricNumber(redis.hit_rate_percent, 1)}%`,
(percent) => this.buildPositiveLevel(percent, 70, 90)
)
],
metrics: [
{ label: 'Key 数量', value: this.formatMetricNumber(redis.key_count) },
{ label: '客户端', value: this.formatMetricNumber(redis.connected_clients) },
{ label: 'OPS/s', value: this.formatMetricNumber(redis.ops_per_sec) },
{ label: '内存占用', value: redis.used_memory_human || '-' },
{ label: '运行时间', value: this.formatCompactDuration(redis.uptime_seconds) }
]
}
];
},
buildAiRuntimeServiceBlocks(aiRuntime) {
// LLM 运行态原先拆成 3 张子卡片,视觉高度会明显超过基础设施里的 MySQL / Redis 两张卡片。
// 这里改成 2 张:第一张保留整体质量指标,第二张合并“路由配置 + 最近调用”,
// 这样首页信息密度不变,但版面层级更统一。
return [
{
key: 'ai-overview',
title: '运行总览',
status: aiRuntime.status || 'warning',
summary: aiRuntime.summary || '暂无状态',
highlights: [
{
label: '最近调用',
value: this.formatMetricNumber(aiRuntime.total_calls),
tone: 'neutral'
},
{
label: '失败次数',
value: this.formatMetricNumber(aiRuntime.failed_calls),
tone: Number(aiRuntime.failed_calls || 0) > 0 ? 'warning' : 'healthy'
},
{
label: '最近记录',
value: aiRuntime.last_timestamp || '-',
tone: aiRuntime.last_timestamp ? 'info' : 'neutral'
}
],
meters: [
this.buildMeter(
'成功率',
aiRuntime.success_rate,
`${this.formatMetricNumber(aiRuntime.success_rate, 2)}%`,
(percent) => this.buildPositiveLevel(percent, 70, 95)
),
this.buildLatencyMeter(
'平均耗时',
aiRuntime.avg_latency_ms,
1200,
3000
)
],
metrics: [
{ label: '最近场景', value: aiRuntime.last_scene || '-' },
{ label: '最近后端', value: aiRuntime.last_backend || '-' },
{ label: '最近模型', value: aiRuntime.last_model || '-' },
{ label: '最近错误', value: aiRuntime.last_error || '无' }
]
},
{
key: 'ai-routing-last-call',
title: '路由与最近调用',
status: aiRuntime.has_routing
? ((aiRuntime.total_calls || 0) > 0 ? 'healthy' : 'warning')
: 'warning',
summary: aiRuntime.last_timestamp
? `最近调用时间:${aiRuntime.last_timestamp}`
: (aiRuntime.default_scene ? `默认场景:${aiRuntime.default_scene}` : '当前未设置默认场景'),
highlights: [
{
label: '默认场景',
value: aiRuntime.default_scene || '-',
tone: aiRuntime.default_scene ? 'healthy' : 'warning'
},
{
label: '默认后端',
value: aiRuntime.default_backend || '-',
tone: aiRuntime.default_backend ? 'healthy' : 'warning'
},
{
label: '最近场景',
value: aiRuntime.last_scene || '-',
tone: aiRuntime.last_scene ? 'info' : 'neutral'
}
],
meters: [
this.buildLatencyMeter(
'最近耗时',
aiRuntime.last_latency_ms,
1200,
3000
)
],
metrics: [
{ label: '场景数量', value: this.formatMetricNumber(aiRuntime.scene_count) },
{ label: '目标数量', value: this.formatMetricNumber(aiRuntime.target_count) },
{ label: 'Provider 模板', value: this.formatMetricNumber(aiRuntime.provider_count) },
{ label: '最近 Provider', value: aiRuntime.last_provider || '-' },
{ label: '最近后端', value: aiRuntime.last_backend || '-' },
{ label: '模型', value: aiRuntime.last_model || '-' },
{ label: '最近错误', value: aiRuntime.last_error || '无' }
]
}
];
},
buildAiRuntimeExtra(aiRuntime) {
return `最近调用 ${aiRuntime.total_calls || 0} 次,失败 ${aiRuntime.failed_calls || 0} 次,平均耗时 ${this.formatMetricNumber(aiRuntime.avg_latency_ms, 2)} ms`;
},
buildSchedulerServiceBlocks(scheduler) {
// 任务调度也从 3 张子卡片压缩成 2 张,避免它在首页右侧比基础设施区块显得“更重”。
// 处理方式是把“失败与恢复”合并进“执行状态”,把运维最关注的信息集中在一张卡片里。
return [
{
key: 'scheduler-overview',
title: '任务装载',
status: scheduler.enabled_jobs > 0 ? 'healthy' : 'warning',
summary: scheduler.next_run_at ? `下一次执行:${scheduler.next_run_at}` : '当前没有可计算的下一次执行时间',
highlights: [
{
label: '总任务',
value: this.formatMetricNumber(scheduler.total_jobs),
tone: 'neutral'
},
{
label: '启用任务',
value: this.formatMetricNumber(scheduler.enabled_jobs),
tone: Number(scheduler.enabled_jobs || 0) > 0 ? 'healthy' : 'warning'
},
{
label: '下次执行',
value: scheduler.next_run_at || '-',
tone: scheduler.next_run_at ? 'info' : 'warning'
}
],
meters: [
this.buildMeter(
'启用率',
this.buildRatioPercent(scheduler.enabled_jobs, scheduler.total_jobs),
`${this.formatMetricNumber(this.buildRatioPercent(scheduler.enabled_jobs, scheduler.total_jobs), 1)}%`,
(percent) => this.buildPositiveLevel(percent, 50, 80)
)
],
metrics: [
{ label: '启用任务', value: this.formatMetricNumber(scheduler.enabled_jobs) },
{ label: '暂停任务', value: this.formatMetricNumber(scheduler.paused_jobs) },
{ label: '系统任务', value: this.formatMetricNumber(scheduler.system_job_count) },
{ label: '插件任务', value: this.formatMetricNumber(scheduler.plugin_job_count) }
]
},
{
key: 'scheduler-runtime-alert',
title: '执行与告警',
status: scheduler.latest_failed_error
? 'warning'
: (scheduler.invalid_jobs > 0 ? 'danger' : (scheduler.status || 'warning')),
summary: scheduler.latest_failed_error
? `最近失败原因:${scheduler.latest_failed_error}`
: (scheduler.latest_failed_job_name ? `最近失败任务:${scheduler.latest_failed_job_name}` : '当前未发现最近失败任务'),
highlights: [
{
label: '执行中',
value: this.formatMetricNumber(scheduler.running_jobs),
tone: Number(scheduler.running_jobs || 0) > 0 ? 'info' : 'neutral'
},
{
label: '失败任务',
value: this.formatMetricNumber(scheduler.failed_jobs),
tone: Number(scheduler.failed_jobs || 0) > 0 ? 'warning' : 'healthy'
},
{
label: '非法调度',
value: this.formatMetricNumber(scheduler.invalid_jobs),
tone: Number(scheduler.invalid_jobs || 0) > 0 ? 'danger' : 'healthy'
},
{
label: '最近失败任务',
value: scheduler.latest_failed_job_name || '无',
tone: scheduler.latest_failed_job_name ? 'warning' : 'healthy'
}
],
meters: [
this.buildMeter(
'失败占比',
this.buildRatioPercent(scheduler.failed_jobs, scheduler.total_jobs),
`${this.formatMetricNumber(this.buildRatioPercent(scheduler.failed_jobs, scheduler.total_jobs), 1)}%`,
(percent) => this.buildRiskLevel(percent, 10, 30)
),
this.buildMeter(
'未运行占比',
this.buildRatioPercent(scheduler.never_run_jobs, scheduler.total_jobs),
`${this.formatMetricNumber(this.buildRatioPercent(scheduler.never_run_jobs, scheduler.total_jobs), 1)}%`,
(percent) => this.buildRiskLevel(percent, 20, 50)
)
],
metrics: [
{ label: '失败任务', value: this.formatMetricNumber(scheduler.failed_jobs) },
{ label: '未执行过', value: this.formatMetricNumber(scheduler.never_run_jobs) },
{ label: '下次执行', value: scheduler.next_run_at || '-' },
{ label: '系统任务', value: this.formatMetricNumber(scheduler.system_job_count) },
{ label: '插件任务', value: this.formatMetricNumber(scheduler.plugin_job_count) },
{ label: '最近失败原因', value: scheduler.latest_failed_error || '无' }
]
}
];
},
buildSchedulerExtra(scheduler) {
if (scheduler.latest_failed_error) {
return `最近失败原因:${scheduler.latest_failed_error}`;
}
return scheduler.next_run_at
? `下次执行时间:${scheduler.next_run_at}`
: '当前暂无可用的下一次执行时间';
},
renderPieChart(chartId, usageValue, label) {
const ctx = document.getElementById(chartId);
if (!ctx) return;
const context = ctx.getContext('2d');
if (this.charts[chartId]) {
this.charts[chartId].destroy();
}
let color = 'rgba(16, 185, 129, 1)';
if (usageValue > 70) {
color = 'rgba(239, 68, 68, 1)';
} else if (usageValue > 50) {
color = 'rgba(245, 158, 11, 1)';
}
this.charts[chartId] = new Chart(context, {
type: 'doughnut',
data: {
labels: ['已使用', '可用'],
datasets: [{
data: [usageValue, 100 - usageValue],
backgroundColor: [color, 'rgba(226, 232, 240, 0.85)'],
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
cutout: '72%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
return context.label + ': ' + context.raw + '%';
}
}
}
}
}
});
},
loadCurrentUserInfo() {
axios.get('/api/current_user_info')
.then(response => {
this.currentUser = response.data;
})
.catch(error => {
console.error('加载当前用户信息出错:', error);
this.currentUser = {
success: false,
message: '获取用户信息失败',
data: {}
};
});
},
getProgressStatus(value) {
if (value > 70) {
return 'exception';
} else if (value > 50) {
return 'warning';
} else {
return 'success';
}
},
loadDashboardSummary(days) {
axios.get(`/api/dashboard_summary?days=${days}`)
.then(response => {
if (response.data.success) {
const data = response.data.data;
this.totalCalls = parseInt(data.total_calls) || 0;
this.successRate = parseFloat(data.success_rate) || 0;
this.activeUsers = data.active_users || 0;
this.activeGroups = data.active_groups || 0;
this.newUsers = data.new_users || 0;
this.avgResponseTime = parseFloat(data.avg_response_time) || 0;
this.avgGroupPenetration = parseFloat(data.avg_group_penetration) || 0;
this.groupHealthScore = parseFloat(data.group_health_score) || 0;
this.topUsers = data.top_users || [];
this.topGroups = data.top_groups || [];
this.topPlugins = data.top_plugins || [];
}
})
.catch(error => {
console.error('加载仪表盘摘要数据出错:', error);
this.$message.error('加载仪表盘摘要数据出错');
});
},
loadPluginStats(days) {
axios.get(`/api/plugin_stats?days=${days}`)
.then(response => {
if (response.data.success) {
this.pluginStats = response.data.data || [];
this.$nextTick(() => {
this.renderPluginChart();
this.renderSuccessRateChart();
});
}
})
.catch(error => {
console.error('加载插件统计数据出错:', error);
this.$message.error('加载插件统计数据出错');
});
},
loadPluginTrend(days) {
axios.get(`/api/plugin_trend?days=${days}`)
.then(response => {
if (response.data.success) {
const trendData = response.data.data || [];
this.$nextTick(() => {
this.renderTrendChart(trendData);
});
}
})
.catch(error => {
console.error('加载插件趋势数据出错:', error);
this.$message.error('加载插件趋势数据出错');
});
},
renderPluginChart() {
const ctx = document.getElementById('pluginChart').getContext('2d');
if (this.charts.pluginChart) {
this.charts.pluginChart.destroy();
}
const sortedData = [...this.pluginStats].sort((a, b) => b.total_calls - a.total_calls).slice(0, 10);
const labels = sortedData.map(item => `${item.plugin_name}(${item.command})`);
const data = sortedData.map(item => item.total_calls);
this.charts.pluginChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '调用次数',
data: data,
backgroundColor: 'rgba(99, 102, 241, 0.72)',
borderColor: 'rgba(79, 70, 229, 1)',
borderRadius: 10,
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.12)'
}
},
x: {
grid: {
display: false
}
}
}
}
});
},
renderSuccessRateChart() {
const ctx = document.getElementById('successRateChart').getContext('2d');
if (this.charts.successRateChart) {
this.charts.successRateChart.destroy();
}
const sortedData = [...this.pluginStats]
.filter(item => item.total_calls > 0)
.sort((a, b) => (b.success_calls / b.total_calls) - (a.success_calls / a.total_calls))
.slice(0, 10);
const labels = sortedData.map(item => `${item.plugin_name}(${item.command})`);
const data = sortedData.map(item => (item.success_calls / item.total_calls) * 100);
this.charts.successRateChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: '成功率(%)',
data: data,
backgroundColor: 'rgba(16, 185, 129, 0.74)',
borderColor: 'rgba(5, 150, 105, 1)',
borderRadius: 10,
borderWidth: 0
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
max: 100,
grid: {
color: 'rgba(148, 163, 184, 0.12)'
}
},
x: {
grid: {
display: false
}
}
}
}
});
},
renderTrendChart(trendData) {
const ctx = document.getElementById('trendChart').getContext('2d');
if (this.charts && this.charts.trendChart) {
this.charts.trendChart.destroy();
}
if (!this.charts) {
this.charts = {};
}
const labels = trendData.map(item => item.date);
const totalData = trendData.map(item => parseInt(item.total_calls) || 0);
const successData = trendData.map(item => parseInt(item.success_calls) || 0);
const errorData = trendData.map(item => parseInt(item.failed_calls) || 0);
this.charts.trendChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: '总调用',
data: totalData,
fill: false,
backgroundColor: 'rgba(79, 70, 229, 0.2)',
borderColor: 'rgba(79, 70, 229, 1)',
tension: 0.32,
borderWidth: 3,
pointRadius: 2
},
{
label: '成功调用',
data: successData,
fill: false,
backgroundColor: 'rgba(16, 185, 129, 0.2)',
borderColor: 'rgba(16, 185, 129, 1)',
tension: 0.32,
borderWidth: 3,
pointRadius: 2
},
{
label: '失败调用',
data: errorData,
fill: false,
backgroundColor: 'rgba(239, 68, 68, 0.2)',
borderColor: 'rgba(239, 68, 68, 1)',
tension: 0.32,
borderWidth: 3,
pointRadius: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false
},
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.12)'
}
},
x: {
grid: {
display: false
}
}
}
}
});
},
loadGroups() {
axios.get('/api/groups')
.then(response => {
if (response.data && response.data.groups) {
this.groups = response.data.groups;
if (this.groups.length > 0) {
this.selectedGroupForHourlyTrend = this.groups[0].group_id;
this.loadHourlyMessageTrend();
}
}
})
.catch(error => {
console.error('加载群组列表失败:', error);
this.$message.error('加载群组列表失败');
});
},
loadHourlyMessageTrend() {
if (!this.selectedGroupForHourlyTrend) return;
this.hourlyMessageTrendLoading = true;
axios.get('/api/hourly_message_trend', {
params: {
group_id: this.selectedGroupForHourlyTrend,
days: this.hourlyTrendDays
}
})
.then(response => {
if (response.data && response.data.success) {
this.hourlyMessageTrendData = response.data.data;
this.$nextTick(() => {
this.renderHourlyMessageTrendChart();
});
} else {
this.$message.error(response.data.error || '加载按小时聊天趋势数据失败');
}
this.hourlyMessageTrendLoading = false;
})
.catch(error => {
console.error('加载按小时聊天趋势数据失败:', error);
this.$message.error('加载按小时聊天趋势数据失败');
this.hourlyMessageTrendLoading = false;
});
},
renderHourlyMessageTrendChart() {
const ctx = document.getElementById('hourlyMessageTrendChart').getContext('2d');
if (this.charts.hourlyMessageTrendChart) {
this.charts.hourlyMessageTrendChart.destroy();
}
this.charts.hourlyMessageTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: this.hourlyMessageTrendData.hours,
datasets: [{
label: '消息数量',
data: this.hourlyMessageTrendData.counts,
fill: true,
backgroundColor: 'rgba(59, 130, 246, 0.10)',
borderColor: 'rgba(59, 130, 246, 1)',
tension: 0.32,
borderWidth: 3,
pointRadius: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
grid: {
color: 'rgba(148, 163, 184, 0.12)'
}
},
x: {
ticks: {
maxRotation: 45,
minRotation: 45
},
grid: {
display: false
}
}
},
plugins: {
title: {
display: true,
text: `${this.hourlyMessageTrendData.group_name || '未知群组'} 的聊天趋势`
},
legend: {
display: false
}
}
}
});
}
}
});
</script>
{% endblock %}
{% block styles %}
<style>
.dashboard-title {
margin-bottom: 18px;
}
.dashboard-page .el-row {
margin-left: 0 !important;
margin-right: 0 !important;
}
.dashboard-page .el-row > .el-col {
padding-left: 8px !important;
padding-right: 8px !important;
}
.dashboard-title-actions {
display: flex;
align-items: center;
gap: 12px;
}
.live-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.18);
color: #475569;
font-size: 13px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
.live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
}
.hero-row,
.health-row,
.metric-extended-row,
.stats-highlight-row,
.chart-row {
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__account {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(148, 163, 184, 0.14);
margin-bottom: 16px;
}
.login-qr-dialog__account-avatar {
width: 48px;
height: 48px;
border-radius: 16px;
overflow: hidden;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.18), rgba(14, 165, 233, 0.12));
color: #1d4ed8;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
flex-shrink: 0;
}
.login-qr-dialog__account-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.login-qr-dialog__account-info {
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.login-qr-dialog__account-label {
font-size: 12px;
color: #64748b;
}
.login-qr-dialog__account-info strong {
font-size: 15px;
color: #0f172a;
word-break: break-all;
}
.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;
}
.health-overview-card .el-card__body {
padding: 20px !important;
}
.section-heading--stack {
align-items: flex-start;
}
.health-overview-meta {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 14px;
background: rgba(248, 250, 252, 0.92);
border: 1px solid rgba(148, 163, 184, 0.12);
color: #64748b;
font-size: 13px;
}
.health-overview-meta__label {
color: #94a3b8;
}
.health-overview-meta__value {
color: #0f172a;
font-weight: 600;
}
.health-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 18px;
}
.health-item {
padding: 18px;
border-radius: 18px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(248,250,252,0.88));
min-height: 156px;
display: flex;
flex-direction: column;
}
.health-item--healthy {
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.10);
}
.health-item--warning {
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.10);
}
.health-item--danger {
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.10);
}
.health-item__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.health-item__title {
font-size: 14px;
color: #475569;
font-weight: 600;
}
.health-item__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 50px;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.health-item__badge--healthy {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.health-item__badge--warning {
color: #b45309;
background: rgba(245, 158, 11, 0.14);
}
.health-item__badge--danger {
color: #b91c1c;
background: rgba(239, 68, 68, 0.14);
}
.health-item__value {
font-size: 28px;
line-height: 1.1;
font-weight: 700;
color: #0f172a;
margin-bottom: 12px;
}
.health-item__summary {
font-size: 13px;
line-height: 1.7;
color: #475569;
}
.health-item__action {
align-self: flex-start;
margin: -4px 0 12px;
padding: 6px 10px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.92);
color: #475569;
font-size: 11px;
font-weight: 700;
cursor: pointer;
transition: all .18s ease;
}
.health-item__action:hover {
color: #1d4ed8;
border-color: rgba(59, 130, 246, 0.22);
background: rgba(59, 130, 246, 0.08);
}
.health-service-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
/* 同一张卡片内的服务面板按等高网格排列,避免信息多少不同导致页面右侧出现明显错层。 */
grid-auto-rows: 1fr;
gap: 12px;
margin-top: 16px;
}
.health-service-panel {
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(148, 163, 184, 0.14);
background: rgba(248, 250, 252, 0.72);
/* 纵向弹性布局可以让摘要自然占位,指标区稳定贴底,三类健康卡片的视觉节奏会更统一。 */
display: flex;
flex-direction: column;
height: 100%;
}
.health-service-panel--healthy {
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.08);
}
.health-service-panel--warning {
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.10);
}
.health-service-panel--danger {
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.10);
}
.health-service-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.health-service-panel__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
margin-bottom: 4px;
}
.health-service-panel__summary {
font-size: 12px;
line-height: 1.6;
color: #64748b;
}
.health-service-highlights {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.health-service-highlight {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(148, 163, 184, 0.16);
color: #475569;
font-size: 11px;
}
.health-service-highlight--healthy {
color: #047857;
background: rgba(16, 185, 129, 0.10);
border-color: rgba(16, 185, 129, 0.16);
}
.health-service-highlight--warning {
color: #b45309;
background: rgba(245, 158, 11, 0.12);
border-color: rgba(245, 158, 11, 0.16);
}
.health-service-highlight--info {
color: #1d4ed8;
background: rgba(59, 130, 246, 0.10);
border-color: rgba(59, 130, 246, 0.16);
}
.health-service-highlight__label {
color: inherit;
opacity: 0.86;
}
.health-service-highlight__value {
color: #0f172a;
font-weight: 600;
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.health-service-meter-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 12px;
}
.health-service-meter__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.health-service-meter__label {
font-size: 11px;
color: #64748b;
}
.health-service-meter__meta {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.health-service-meter__number {
font-size: 12px;
color: #0f172a;
font-weight: 700;
}
.health-service-meter__badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
background: rgba(148, 163, 184, 0.12);
color: #64748b;
}
.health-service-meter__badge--healthy {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.health-service-meter__badge--warning {
color: #b45309;
background: rgba(245, 158, 11, 0.14);
}
.health-service-meter__badge--danger {
color: #b91c1c;
background: rgba(239, 68, 68, 0.14);
}
.health-service-meter__track {
width: 100%;
height: 8px;
border-radius: 999px;
background: rgba(226, 232, 240, 0.88);
overflow: hidden;
}
.health-service-meter__fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(16, 185, 129, 0.68), rgba(5, 150, 105, 0.92));
}
.health-service-meter__fill--healthy {
background: linear-gradient(90deg, rgba(16, 185, 129, 0.68), rgba(5, 150, 105, 0.92));
}
.health-service-meter__fill--warning {
background: linear-gradient(90deg, rgba(245, 158, 11, 0.70), rgba(217, 119, 6, 0.95));
}
.health-service-meter__fill--danger {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.72), rgba(220, 38, 38, 0.96));
}
.health-service-panel__badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
padding: 4px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
flex-shrink: 0;
}
.health-service-panel__badge--healthy {
color: #047857;
background: rgba(16, 185, 129, 0.12);
}
.health-service-panel__badge--warning {
color: #b45309;
background: rgba(245, 158, 11, 0.14);
}
.health-service-panel__badge--danger {
color: #b91c1c;
background: rgba(239, 68, 68, 0.14);
}
.health-service-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
/* 指标区放到底部,服务名称、状态说明与关键指标之间能自动拉开间距。 */
margin-top: auto;
}
.health-service-metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.health-service-metric__label {
font-size: 11px;
color: #94a3b8;
}
.health-service-metric__value {
font-size: 13px;
font-weight: 600;
color: #1e293b;
word-break: break-word;
}
.health-item__extra {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed rgba(148, 163, 184, 0.22);
font-size: 12px;
color: #94a3b8;
word-break: break-all;
}
.stats-highlight-row {
display: flex;
flex-wrap: wrap;
align-items: stretch;
}
.stats-highlight-row > .el-col {
display: flex;
}
.hero-card {
min-height: 228px;
height: 100%;
}
.hero-card--profile {
background: linear-gradient(145deg, rgba(79,70,229,0.94), rgba(59,130,246,0.82)) !important;
color: #fff !important;
border: none !important;
box-shadow: 0 24px 48px rgba(79, 70, 229, 0.18) !important;
}
.hero-card--profile .el-card__body {
padding: 22px !important;
}
.hero-profile {
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 20px;
}
.hero-profile-top {
display: flex;
align-items: flex-start;
gap: 18px;
}
.hero-avatar-wrap {
width: 76px;
height: 76px;
flex-shrink: 0;
padding: 3px;
border-radius: 24px;
background: rgba(255,255,255,0.16);
}
.hero-avatar {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 20px;
background: rgba(255,255,255,0.22);
}
.hero-profile-copy {
flex: 1;
min-width: 0;
}
.hero-eyebrow {
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.8;
margin-bottom: 8px;
}
.hero-profile-copy h2 {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
color: #fff;
}
.hero-profile-copy p {
color: rgba(255,255,255,0.80);
line-height: 1.6;
font-size: 14px;
}
.hero-profile-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.meta-item {
padding: 12px 14px;
border-radius: 16px;
background: rgba(255,255,255,0.14);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.meta-label {
display: block;
font-size: 12px;
color: rgba(255,255,255,0.72);
margin-bottom: 6px;
}
.meta-value {
display: block;
font-size: 14px;
font-weight: 600;
color: #fff;
word-break: break-all;
}
.metric-grid .el-col {
margin-bottom: 16px;
display: flex;
}
.metric-extended-row > .el-col {
display: flex;
}
.metric-card {
min-height: 106px;
width: 100%;
height: 100%;
}
.metric-card .el-card__body {
padding: 18px !important;
}
.metric-card--primary {
background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important;
}
.metric-card--soft {
background: linear-gradient(180deg, rgba(59,130,246,0.08), rgba(255,255,255,0.94)) !important;
}
.metric-label {
font-size: 13px;
color: #64748b;
margin-bottom: 14px;
}
.metric-value {
font-size: 30px;
line-height: 1;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.02em;
margin-bottom: 12px;
}
.metric-unit {
font-size: 14px;
color: #64748b;
margin-left: 6px;
}
.metric-footnote {
font-size: 12px;
color: #94a3b8;
}
.insight-card .el-card__body {
padding-top: 12px !important;
}
.insight-card {
width: 100%;
height: 100%;
}
.section-heading,
.chart-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.section-heading h3,
.chart-card-header h3 {
font-size: 17px;
font-weight: 700;
color: #0f172a;
margin-bottom: 4px;
}
.section-heading p,
.chart-card-header p {
font-size: 13px;
color: #64748b;
}
.chart-card-header--split {
align-items: flex-start;
}
.chart-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.chart-card--large {
min-height: 390px;
}
.chart-container {
position: relative;
background: linear-gradient(180deg, rgba(248,250,252,0.72), rgba(255,255,255,0.96));
border: 1px solid rgba(148,163,184,0.12);
border-radius: 18px;
padding: 14px;
}
.chart-container--large {
height: 300px;
}
.chart-container--panel {
height: 300px;
}
.rank-cell {
display: flex;
align-items: center;
gap: 12px;
}
.rank-index {
width: 28px;
height: 28px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(79,70,229,0.10);
color: #4f46e5;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
}
.rank-copy {
min-width: 0;
}
.rank-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rank-subtitle {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hero-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
flex-direction: column;
gap: 8px;
color: rgba(255,255,255,0.85);
}
.hero-empty i {
font-size: 24px;
}
@media (max-width: 1200px) {
.hero-row > .el-col,
.health-row > .el-col,
.metric-extended-row > .el-col,
.stats-highlight-row > .el-col,
.chart-row > .el-col {
width: 100% !important;
}
.metric-grid > .el-col {
width: 50% !important;
}
.health-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.dashboard-title {
flex-direction: column;
align-items: flex-start;
}
.hero-row,
.health-row,
.metric-extended-row,
.stats-highlight-row,
.chart-row {
margin-bottom: 12px;
}
.hero-profile-top {
flex-direction: column;
align-items: flex-start;
}
.hero-profile-meta {
grid-template-columns: 1fr;
}
.chart-card-header,
.chart-card-header--split,
.section-heading {
flex-direction: column;
align-items: flex-start;
}
.chart-toolbar {
width: 100%;
}
.chart-toolbar .el-select {
width: 100% !important;
}
.metric-grid > .el-col {
width: 100% !important;
}
.health-grid {
grid-template-columns: 1fr;
}
.health-service-grid {
grid-template-columns: 1fr;
}
.login-qr-dialog__hero {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.dashboard-page {
overflow-x: hidden;
}
.dashboard-title {
margin-bottom: 14px;
}
.page-title-main h1,
.page-title-main h2 {
font-size: 22px;
}
.page-title-main p {
font-size: 13px;
}
.live-badge {
width: 100%;
justify-content: center;
}
.hero-card,
.chart-card,
.insight-card,
.metric-card,
.health-item {
min-height: auto;
}
.hero-card--profile .el-card__body,
.metric-card .el-card__body {
padding: 16px !important;
}
.hero-avatar-wrap {
width: 64px;
height: 64px;
}
.hero-profile-copy h2 {
font-size: 20px;
}
.metric-value {
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;
}
.rank-cell {
align-items: flex-start;
}
.rank-title,
.rank-subtitle {
white-space: normal;
}
.el-table {
font-size: 12px;
}
}
@media (max-width: 480px) {
.dashboard-page .el-row > .el-col {
padding-left: 0 !important;
padding-right: 0 !important;
}
.hero-row,
.health-row,
.metric-extended-row,
.stats-highlight-row,
.chart-row {
margin-bottom: 10px;
}
.hero-card--profile .el-card__body,
.metric-card .el-card__body,
.insight-card .el-card__body {
padding: 14px !important;
}
.metric-label,
.metric-footnote,
.section-heading p,
.chart-card-header p {
font-size: 12px;
}
.health-overview-card .el-card__body {
padding: 14px !important;
}
.health-item {
padding: 14px;
}
.health-item__value {
font-size: 24px;
}
.health-service-metrics {
grid-template-columns: 1fr;
}
.chart-container--large,
.chart-container--panel {
height: 220px;
}
}
</style>
{% endblock %}