Files
abot/admin/dashboard/templates/base.html

1276 lines
43 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}机器人管理后台{% endblock %}</title>
<link rel="icon" href="/static/favicon.ico">
<style>
:root {
--bg: #f4f7fb;
--bg-soft: #eef3f8;
--surface: rgba(255, 255, 255, 0.86);
--surface-strong: #ffffff;
--surface-muted: #f8fafc;
--border: rgba(148, 163, 184, 0.18);
--border-strong: rgba(148, 163, 184, 0.26);
--text: #0f172a;
--text-soft: #475569;
--text-faint: #94a3b8;
--primary: #4f46e5;
--primary-soft: rgba(79, 70, 229, 0.10);
--primary-soft-2: rgba(99, 102, 241, 0.16);
--info: #3b82f6;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--shadow-sm: 0 8px 24px rgba(15, 23, 42, 0.06);
--shadow-md: 0 18px 48px rgba(15, 23, 42, 0.08);
--radius-xs: 10px;
--radius-sm: 14px;
--radius-md: 18px;
--radius-lg: 24px;
--topbar-height: 72px;
--subnav-height: 64px;
--content-padding: 24px;
}
* {
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
margin: 0;
min-height: 100vh;
overflow: hidden;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(99, 102, 241, 0.10), transparent 28%),
radial-gradient(circle at top right, rgba(56, 189, 248, 0.08), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #f3f6fb 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
}
h1, h2, h3, h4, h5, h6, p {
margin: 0;
}
.app-container {
min-height: 100vh;
opacity: 0;
transition: opacity .24s ease;
}
.app-container.loaded {
opacity: 1;
}
.topbar {
position: sticky;
top: 0;
z-index: 200;
height: var(--topbar-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 0 24px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.04);
}
.topbar-left,
.topbar-right {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.brand-logo {
width: 40px;
height: 40px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.16);
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.brand-title {
font-size: 17px;
font-weight: 700;
color: var(--text);
letter-spacing: 0.01em;
}
.brand-subtitle {
font-size: 12px;
color: var(--text-faint);
white-space: nowrap;
}
.main-nav {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow-x: auto;
padding: 4px;
border-radius: 999px;
background: rgba(255,255,255,0.58);
border: 1px solid rgba(148, 163, 184, 0.14);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.8);
}
.main-nav::-webkit-scrollbar,
.sub-nav::-webkit-scrollbar {
display: none;
}
.main-nav-link,
.sub-nav-link {
appearance: none;
border: none;
background: transparent;
color: var(--text-soft);
cursor: pointer;
font: inherit;
white-space: nowrap;
transition: all .18s ease;
}
.main-nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 38px;
padding: 0 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
}
.main-nav-link:hover {
background: rgba(255,255,255,0.92);
color: var(--text);
}
.main-nav-link.is-active {
color: var(--primary);
background: linear-gradient(135deg, rgba(79,70,229,0.12), rgba(99,102,241,0.08));
box-shadow: inset 0 0 0 1px rgba(99,102,241,0.10);
}
.status-pill,
.user-pill {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255,255,255,0.72);
border: 1px solid var(--border);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.9);
color: var(--text-soft);
font-size: 13px;
white-space: nowrap;
}
.status-dot,
.user-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot {
background: var(--success);
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.12);
}
.user-dot {
background: var(--info);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.10);
}
.logout-btn {
color: var(--text-soft) !important;
padding: 10px 14px !important;
border-radius: 999px !important;
transition: all .18s ease !important;
}
.account-btn {
color: var(--text-soft) !important;
padding: 10px 14px !important;
border-radius: 999px !important;
transition: all .18s ease !important;
}
.account-btn:hover {
color: var(--primary) !important;
background: var(--primary-soft) !important;
}
.logout-btn:hover {
color: var(--primary) !important;
background: var(--primary-soft) !important;
}
.subnav-bar {
position: sticky;
top: var(--topbar-height);
z-index: 190;
height: var(--subnav-height);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 0 24px;
background: rgba(248, 250, 252, 0.84);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
}
.subnav-title {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.subnav-title h2 {
font-size: 19px;
font-weight: 700;
color: var(--text);
}
.subnav-title p {
font-size: 13px;
color: var(--text-soft);
}
.sub-nav {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow-x: auto;
padding-bottom: 2px;
}
.subnav-right {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.sub-nav-link {
min-height: 36px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.12);
background: rgba(255,255,255,0.64);
font-size: 13px;
font-weight: 500;
}
.sub-nav-link:hover {
background: rgba(255,255,255,0.95);
color: var(--text);
border-color: rgba(148, 163, 184, 0.18);
}
.sub-nav-link.is-active {
background: linear-gradient(135deg, rgba(79,70,229,0.12), rgba(99,102,241,0.08));
color: var(--primary);
border-color: rgba(99, 102, 241, 0.12);
}
.layout-shell {
height: calc(100vh - var(--topbar-height) - var(--subnav-height));
overflow: hidden;
}
.content {
height: 100%;
overflow-y: auto;
padding: var(--content-padding);
}
.content-inner {
min-height: calc(100vh - var(--topbar-height) - var(--subnav-height) - (var(--content-padding) * 2));
}
.toolbar-card,
.el-card {
border-radius: var(--radius-md) !important;
border: 1px solid var(--border) !important;
background: rgba(255, 255, 255, 0.82) !important;
box-shadow: var(--shadow-sm) !important;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
overflow: hidden;
}
.el-card__header {
padding: 16px 18px !important;
background: linear-gradient(180deg, rgba(255,255,255,0.82), rgba(248,250,252,0.92)) !important;
border-bottom: 1px solid rgba(148, 163, 184, 0.12) !important;
color: var(--text) !important;
}
.el-card__body {
padding: 18px !important;
}
.el-button {
border-radius: 12px !important;
font-weight: 500 !important;
transition: all .18s ease !important;
}
.el-button--primary {
background: linear-gradient(135deg, #4f46e5, #6366f1) !important;
border-color: transparent !important;
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.18) !important;
}
.el-button--primary:hover,
.el-button--primary:focus {
transform: translateY(-1px);
box-shadow: 0 14px 24px rgba(79, 70, 229, 0.22) !important;
}
.el-button--primary.is-plain,
.el-button--success.is-plain,
.el-button--warning.is-plain,
.el-button--danger.is-plain,
.el-button--info.is-plain {
background: rgba(255, 255, 255, 0.96) !important;
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.06) !important;
}
.el-button--primary.is-plain {
color: #4338ca !important;
border-color: rgba(99, 102, 241, 0.34) !important;
}
.el-button--primary.is-plain:hover,
.el-button--primary.is-plain:focus {
color: #ffffff !important;
border-color: transparent !important;
background: linear-gradient(135deg, #4f46e5, #6366f1) !important;
box-shadow: 0 12px 22px rgba(79, 70, 229, 0.20) !important;
}
.el-button--success.is-plain {
color: #047857 !important;
border-color: rgba(16, 185, 129, 0.34) !important;
}
.el-button--success.is-plain:hover,
.el-button--success.is-plain:focus {
color: #ffffff !important;
border-color: transparent !important;
background: linear-gradient(135deg, #10b981, #34d399) !important;
box-shadow: 0 12px 22px rgba(16, 185, 129, 0.20) !important;
}
.el-button--warning.is-plain {
color: #b45309 !important;
border-color: rgba(245, 158, 11, 0.34) !important;
}
.el-button--warning.is-plain:hover,
.el-button--warning.is-plain:focus {
color: #ffffff !important;
border-color: transparent !important;
background: linear-gradient(135deg, #f59e0b, #fbbf24) !important;
box-shadow: 0 12px 22px rgba(245, 158, 11, 0.20) !important;
}
.el-button--danger.is-plain {
color: #b91c1c !important;
border-color: rgba(239, 68, 68, 0.34) !important;
}
.el-button--danger.is-plain:hover,
.el-button--danger.is-plain:focus {
color: #ffffff !important;
border-color: transparent !important;
background: linear-gradient(135deg, #ef4444, #f87171) !important;
box-shadow: 0 12px 22px rgba(239, 68, 68, 0.20) !important;
}
.el-button--info.is-plain {
color: #475569 !important;
border-color: rgba(100, 116, 139, 0.34) !important;
}
.el-button--info.is-plain:hover,
.el-button--info.is-plain:focus {
color: #ffffff !important;
border-color: transparent !important;
background: linear-gradient(135deg, #64748b, #94a3b8) !important;
box-shadow: 0 12px 22px rgba(100, 116, 139, 0.20) !important;
}
.el-button--default {
background: rgba(255,255,255,0.85) !important;
border-color: var(--border-strong) !important;
color: var(--text) !important;
}
.el-button--default:hover,
.el-button--default:focus {
border-color: rgba(99,102,241,0.3) !important;
color: var(--primary) !important;
background: rgba(255,255,255,0.96) !important;
}
.el-button--text,
.el-button--text:not(.is-disabled) {
color: #334155 !important;
font-weight: 600 !important;
padding: 6px 10px !important;
border-radius: 10px !important;
background: rgba(248, 250, 252, 0.9) !important;
border: 1px solid rgba(148, 163, 184, 0.14) !important;
}
.el-button--text:hover,
.el-button--text:focus,
.el-button--text:not(.is-disabled):hover,
.el-button--text:not(.is-disabled):focus {
color: var(--primary) !important;
background: rgba(99, 102, 241, 0.08) !important;
border-color: rgba(99, 102, 241, 0.18) !important;
}
.el-button--text [class*="el-icon-"],
.el-button--text span {
color: inherit !important;
}
.el-table .el-button--text,
.el-table .el-button--text:not(.is-disabled),
.el-card .el-button--text,
.el-card .el-button--text:not(.is-disabled) {
color: #1e293b !important;
background: rgba(255, 255, 255, 0.92) !important;
border-color: rgba(100, 116, 139, 0.18) !important;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.04) !important;
}
.el-table .el-button--text:hover,
.el-table .el-button--text:focus,
.el-card .el-button--text:hover,
.el-card .el-button--text:focus {
color: var(--primary) !important;
background: rgba(99, 102, 241, 0.10) !important;
border-color: rgba(99, 102, 241, 0.20) !important;
box-shadow: 0 8px 16px rgba(79, 70, 229, 0.10) !important;
}
.el-button--success,
.el-button--warning,
.el-button--danger,
.el-button--info {
color: #fff !important;
border-color: transparent !important;
}
.el-button--success { background: linear-gradient(135deg, #10b981, #34d399) !important; }
.el-button--warning { background: linear-gradient(135deg, #f59e0b, #fbbf24) !important; }
.el-button--danger { background: linear-gradient(135deg, #ef4444, #f87171) !important; }
.el-button--info { background: linear-gradient(135deg, #64748b, #94a3b8) !important; }
.el-input__inner,
.el-textarea__inner {
height: 40px;
border-radius: 12px !important;
background: rgba(248,250,252,0.9) !important;
border-color: rgba(148,163,184,0.22) !important;
color: var(--text) !important;
transition: all .18s ease !important;
}
.el-textarea__inner {
min-height: 110px;
}
.el-input__inner::placeholder,
.el-textarea__inner::placeholder {
color: var(--text-faint) !important;
}
.el-input__inner:focus,
.el-textarea__inner:focus {
border-color: rgba(99,102,241,0.42) !important;
box-shadow: 0 0 0 4px rgba(99,102,241,0.10) !important;
background: #fff !important;
}
.el-tag {
border-radius: 999px !important;
border: none !important;
padding: 0 10px !important;
font-weight: 600;
}
.el-table {
color: var(--text) !important;
background: transparent !important;
border-radius: 14px !important;
overflow: hidden;
}
.el-table::before,
.el-table--group::after,
.el-table--border::after,
.el-table__fixed-right::before,
.el-table__fixed::before {
display: none !important;
}
.el-table th {
background: rgba(248,250,252,0.95) !important;
color: var(--text-soft) !important;
font-weight: 600 !important;
border-bottom: 1px solid rgba(148,163,184,0.12) !important;
}
.el-table tr,
.el-table td,
.el-table__expanded-cell {
background: rgba(255,255,255,0.45) !important;
}
.el-table td,
.el-table th.is-leaf {
border-bottom: 1px solid rgba(148,163,184,0.10) !important;
}
.el-table--border,
.el-table--group {
border: 1px solid rgba(148,163,184,0.14) !important;
}
.el-table--border th,
.el-table--border td {
border-right: 1px solid rgba(148,163,184,0.08) !important;
}
.el-table__row:hover > td {
background: rgba(99,102,241,0.05) !important;
}
.el-dialog,
.el-message-box {
border-radius: 22px !important;
overflow: hidden !important;
border: 1px solid rgba(148,163,184,0.14) !important;
box-shadow: var(--shadow-md) !important;
background: rgba(255,255,255,0.94) !important;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.el-dialog__header,
.el-message-box__header {
padding: 18px 20px !important;
border-bottom: 1px solid rgba(148,163,184,0.10) !important;
background: rgba(248,250,252,0.86) !important;
}
.el-dialog__body,
.el-message-box__content {
color: var(--text) !important;
}
.el-pagination .el-pager li,
.el-pagination button {
border-radius: 10px !important;
border: 1px solid rgba(148,163,184,0.14) !important;
background: rgba(255,255,255,0.92) !important;
}
.el-pagination .el-pager li.active {
background: linear-gradient(135deg, #4f46e5, #6366f1) !important;
color: #fff !important;
}
.el-tabs__item {
color: var(--text-soft) !important;
}
.el-tabs__item.is-active,
.el-tabs__item:hover {
color: var(--primary) !important;
}
.el-tabs__active-bar {
background: var(--primary) !important;
height: 3px !important;
border-radius: 999px !important;
}
.toolbar-card {
margin-bottom: 18px;
}
.time-filter-form {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.time-range-inline {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 999px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.05);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.time-range-inline .el-select .el-input__inner {
height: 32px !important;
border-radius: 999px !important;
padding-left: 12px !important;
padding-right: 30px !important;
min-width: 110px;
font-size: 12px !important;
}
.time-range-inline .el-button {
min-height: 32px !important;
padding: 0 10px !important;
border-radius: 999px !important;
}
.v-modal {
background: rgba(15, 23, 42, 0.16) !important;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
a {
color: var(--primary);
}
a:hover {
color: #4338ca;
}
@media (max-width: 1320px) {
.topbar,
.subnav-bar,
.content {
padding-left: 18px;
padding-right: 18px;
}
.topbar {
gap: 12px;
}
.status-pill {
display: none;
}
}
.password-dialog-tip {
margin-top: 8px;
color: var(--text-faint);
font-size: 12px;
line-height: 1.7;
}
/* 修改密码弹窗:默认宽度受限,避免在中小屏超出视口。 */
.password-dialog {
width: min(460px, 92vw) !important;
}
@media (max-width: 1024px) {
.topbar {
gap: 10px;
padding-left: 12px;
padding-right: 12px;
}
.brand-subtitle {
display: none;
}
.main-nav-link {
padding: 0 10px;
min-height: 34px;
font-size: 13px;
}
.topbar-right {
gap: 8px;
}
.user-pill {
display: none;
}
.account-btn,
.logout-btn {
padding: 8px 10px !important;
font-size: 12px !important;
}
}
@media (max-width: 768px) {
.topbar {
height: auto;
flex-wrap: wrap;
align-items: stretch;
padding-top: 10px;
padding-bottom: 10px;
}
.topbar-left {
width: 100%;
flex-wrap: wrap;
gap: 8px;
}
.brand {
min-width: 0;
}
.brand-title {
font-size: 15px;
}
.main-nav {
width: 100%;
order: 3;
}
.topbar-right {
margin-left: auto;
width: auto;
}
.account-btn,
.logout-btn {
padding: 6px 8px !important;
min-width: 0;
font-size: 12px !important;
white-space: nowrap;
}
.subnav-bar {
top: auto;
position: static;
height: auto;
flex-wrap: wrap;
padding: 10px 12px;
gap: 10px;
}
.subnav-right {
width: 100%;
flex-wrap: wrap;
}
.sub-nav {
width: 100%;
}
.layout-shell {
height: calc(100vh - 150px);
}
}
@media (max-width: 640px) {
/* 小屏下仅对“修改密码”弹窗做堆叠标签,不影响其它弹窗。 */
.password-dialog .el-form-item__label {
float: none;
display: block;
text-align: left;
line-height: 20px;
padding: 0 0 6px;
}
.password-dialog .el-form-item__content {
margin-left: 0 !important;
}
}
</style>
<link rel="stylesheet" href="/static/css/element-ui/theme-chalk/index.min.css">
{% block styles %}{% endblock %}
<script src="/static/js/chart.js"></script>
<script src="/static/js/vue.js"></script>
<script src="/static/js/element-ui/index.min.js"></script>
<script src="/static/js/axios.min.js"></script>
{% block head %}{% endblock %}
</head>
<body>
<div id="app" class="app-container">
<header class="topbar">
<div class="topbar-left">
<div class="brand">
<img src="/static/logo.png" class="brand-logo" alt="logo">
<div class="brand-copy">
<div class="brand-title">机器人控制台</div>
<div class="brand-subtitle">更产品化的运营、消息与系统工作台</div>
</div>
</div>
<nav class="main-nav" aria-label="主导航">
<button
v-for="item in navGroups"
:key="item.key"
class="main-nav-link"
:class="{ 'is-active': activeGroup === item.key }"
@click="goToPrimary(item)">
<i :class="item.icon"></i>
<span>{% raw %}{{ item.label }}{% endraw %}</span>
</button>
</nav>
</div>
<div class="topbar-right">
<div class="status-pill">
<span class="status-dot"></span>
<span>控制台在线</span>
</div>
<div class="user-pill">
<span class="user-dot"></span>
<span>{{ session.get('username', '管理员') }} 已登录</span>
</div>
<el-button type="text" class="account-btn" @click="openPasswordDialog">
<i class="el-icon-lock"></i> 修改密码
</el-button>
<el-button type="text" class="logout-btn" @click="logout">
<i class="el-icon-switch-button"></i> 退出
</el-button>
</div>
</header>
<section class="subnav-bar">
<div class="subnav-title">
<h2>{% raw %}{{ activeGroupMeta.label }}{% endraw %}</h2>
<p>{% raw %}{{ activeGroupMeta.description }}{% endraw %}</p>
</div>
<div class="subnav-right">
<div v-if="showTimeRangeSelector" class="time-range-inline">
<el-select v-model="timeRange" size="mini" @change="loadData">
<el-option label="最近7天" value="7"></el-option>
<el-option label="最近30天" value="30"></el-option>
<el-option label="最近90天" value="90"></el-option>
</el-select>
<el-button size="mini" type="primary" plain @click="loadData">
<i class="el-icon-refresh"></i>
</el-button>
</div>
<nav class="sub-nav" aria-label="二级导航">
<button
v-for="item in currentSubNav"
:key="item.path"
class="sub-nav-link"
:class="{ 'is-active': isPathActive(item.path) }"
@click="go(item.path)">
{% raw %}{{ item.label }}{% endraw %}
</button>
</nav>
</div>
</section>
<div class="layout-shell">
<main class="content">
<div class="content-inner">
{% block content %}{% endblock %}
</div>
</main>
</div>
<el-dialog
title="修改后台登录密码"
:visible.sync="passwordDialogVisible"
width="460px"
:close-on-click-modal="false"
:close-on-press-escape="!passwordDialogLocked"
:show-close="!passwordDialogLocked"
:before-close="handlePasswordDialogBeforeClose"
custom-class="password-dialog">
<el-form
ref="passwordFormRef"
:model="passwordForm"
:rules="passwordRules"
label-width="96px">
<el-form-item label="旧密码" prop="old_password">
<el-input v-model="passwordForm.old_password" type="password" show-password autocomplete="off" placeholder="请输入当前密码"></el-input>
</el-form-item>
<el-form-item label="新密码" prop="new_password">
<el-input v-model="passwordForm.new_password" type="password" show-password autocomplete="off" placeholder="至少8位建议字母+数字/符号组合"></el-input>
</el-form-item>
<el-form-item label="确认新密码" prop="confirm_password">
<el-input v-model="passwordForm.confirm_password" type="password" show-password autocomplete="off" placeholder="请再次输入新密码"></el-input>
</el-form-item>
</el-form>
<div class="password-dialog-tip">
提示:修改成功后将立即生效,建议使用强密码(至少 8 位,且包含两类以上字符)。
</div>
<span slot="footer" class="dialog-footer">
<el-button v-if="!passwordDialogLocked" @click="passwordDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="passwordSubmitting" @click="submitPasswordChange">确认修改</el-button>
</span>
</el-dialog>
</div>
<script>
const NAV_GROUPS = [
{
key: 'overview',
label: '概览',
icon: 'el-icon-s-home',
description: '查看整体运行、趋势与核心经营指标',
defaultPath: '/',
items: [
{ label: '首页概览', path: '/' }
]
},
{
key: 'messages',
label: '消息',
icon: 'el-icon-chat-line-square',
description: '处理消息、日志与定时推送相关工作',
defaultPath: '/messages',
items: [
{ label: '消息列表', path: '/messages' },
{ label: '定时推送', path: '/message_push' }
]
},
{
key: 'friend-circle',
label: '朋友圈',
icon: 'el-icon-picture-outline-round',
description: '集中管理朋友圈查看、发布、点赞与评论流程',
defaultPath: '/friend_circle',
items: [
{ label: '朋友圈管理', path: '/friend_circle' }
]
},
{
key: 'groups',
label: '通讯录',
icon: 'el-icon-s-cooperation',
description: '管理群组、权限、通讯录与虚拟群组能力',
defaultPath: '/groups',
items: [
{ label: '群组统计', path: '/groups' },
{ label: '通讯录', path: '/contacts' },
{ label: '虚拟群组', path: '/virtual_group' }
]
},
{
key: 'plugins',
label: '插件',
icon: 'el-icon-s-grid',
description: '查看插件使用、管理插件与接口说明',
defaultPath: '/plugins',
items: [
{ label: '插件统计', path: '/plugins' },
{ label: '插件管理', path: '/plugins_manage' },
{ label: '命令索引', path: '/command_catalog' },
{ label: '插件定时任务', path: '/plugin_schedules' },
{ label: '群级插件配置', path: '/group_plugin_config' },
{ label: '响应指令管理', path: '/fun_command_rules' },
{ label: '接口文档', path: '/api_docs' }
]
},
{
key: 'system',
label: '系统',
icon: 'el-icon-cpu',
description: '关注用户、资源与文件等平台基础能力',
defaultPath: '/users',
items: [
{ label: '用户统计', path: '/users' },
{ label: '资源监控', path: '/system_status' },
{ label: '系统定时任务', path: '/system_jobs' },
{ label: '全局配置', path: '/system_llm' },
{ label: '文件浏览', path: '/file_browser' },
{ label: '运行日志', path: '/wx_logs' },
{ label: '错误日志', path: '/errors' }
]
}
];
const baseApp = {
data() {
const vm = this;
return {
currentView: '1',
timeRange: '7',
showTimeRangeSelector: false,
navGroups: NAV_GROUPS,
// 账号密码修改弹窗状态。
passwordDialogVisible: false,
passwordDialogLocked: false,
passwordSubmitting: false,
passwordForm: {
old_password: '',
new_password: '',
confirm_password: ''
},
passwordRules: {
old_password: [
{ required: true, message: '请输入旧密码', trigger: 'blur' }
],
new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 8, message: '新密码长度至少8位', trigger: 'blur' },
{
validator: function(rule, value, callback) {
const passwordText = String(value || '');
let score = 0;
if (/[A-Za-z]/.test(passwordText)) score += 1;
if (/\d/.test(passwordText)) score += 1;
if (/[^A-Za-z0-9]/.test(passwordText)) score += 1;
if (passwordText && score < 2) {
callback(new Error('新密码需至少包含字母、数字、符号中的两类'));
return;
}
callback();
},
trigger: 'blur'
}
],
confirm_password: [
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: function(rule, value, callback) {
if (value !== vm.passwordForm.new_password) {
callback(new Error('两次输入的新密码不一致'));
return;
}
callback();
},
trigger: 'blur'
}
]
}
}
},
computed: {
activeGroup() {
const path = this.normalizePath(window.location.pathname);
const matched = this.navGroups.find(group =>
group.items.some(item => this.normalizePath(item.path) === path)
);
return matched ? matched.key : 'overview';
},
activeGroupMeta() {
return this.navGroups.find(group => group.key === this.activeGroup) || this.navGroups[0];
},
currentSubNav() {
return this.activeGroupMeta.items || [];
}
},
created() {
const path = window.location.pathname;
this.showTimeRangeSelector = ['/', '/plugins', '/users', '/groups', '/errors'].includes(path);
},
mounted() {
document.querySelector('.app-container').classList.add('loaded');
this.loadSecurityStatus();
},
methods: {
normalizePath(path) {
if (!path) return '/';
if (path.length > 1 && path.endsWith('/')) {
return path.slice(0, -1);
}
return path;
},
isPathActive(path) {
return this.normalizePath(window.location.pathname) === this.normalizePath(path);
},
go(path) {
if (path && this.normalizePath(window.location.pathname) !== this.normalizePath(path)) {
window.location.href = path;
}
},
goToPrimary(group) {
if (!group) return;
const current = this.normalizePath(window.location.pathname);
const ownPaths = (group.items || []).map(item => this.normalizePath(item.path));
if (ownPaths.includes(current)) return;
this.go(group.defaultPath || (group.items && group.items[0] && group.items[0].path));
},
handleSelect(key) {
const routes = {
'1': '/',
'2': '/plugins',
'3': '/users',
'4': '/groups',
'5': '/errors',
'7': '/messages',
'9': '/wx_logs',
'10': '/contacts',
'11': '/plugins_manage',
'12': '/virtual_group',
'13': '/api_docs',
'14': '/system_status',
'18': '/command_catalog',
'17': '/system_llm',
'15': '/file_browser',
'16': '/message_push'
};
if (routes[key] && window.location.pathname !== routes[key]) {
window.location.href = routes[key];
}
},
logout() {
this.$confirm('确认退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
window.location.href = '/logout';
});
},
loadSecurityStatus() {
// 登录后主动拉一次账号安全状态:
// 1. 若仍在使用默认/弱口令,这里会强制打开改密弹窗;
// 2. 这样用户不需要额外摸索入口,首次进入后台就能完成风险收敛。
axios.get('/api/auth/security_status')
.then((response) => {
const data = (response.data || {}).data || {};
if (data.force_password_change) {
this.passwordDialogLocked = true;
this.openPasswordDialog();
this.$alert('当前后台账号仍在使用默认或弱密码,请先修改密码后再继续操作。', '安全提示', {
confirmButtonText: '去修改',
showClose: false,
closeOnClickModal: false,
closeOnPressEscape: false,
type: 'warning'
}).catch(() => {});
}
})
.catch(() => {
// 安全状态获取失败时不阻塞页面使用,避免偶发接口异常影响整体后台。
});
},
openPasswordDialog() {
// 打开弹窗前重置表单,避免上次输入残留。
this.passwordDialogVisible = true;
this.passwordSubmitting = false;
this.passwordForm = {
old_password: '',
new_password: '',
confirm_password: ''
};
this.$nextTick(() => {
if (this.$refs.passwordFormRef) {
this.$refs.passwordFormRef.clearValidate();
}
});
},
handlePasswordDialogBeforeClose(done) {
if (this.passwordDialogLocked) {
this.$message.warning('当前账号需要先完成密码修改,暂时不能关闭该弹窗。');
return;
}
done();
},
submitPasswordChange() {
if (!this.$refs.passwordFormRef) {
return;
}
this.$refs.passwordFormRef.validate((valid) => {
if (!valid) {
return;
}
this.passwordSubmitting = true;
axios.post('/api/auth/change_password', this.passwordForm)
.then((response) => {
const data = response.data || {};
if (!data.success) {
this.$message.error(data.error || '修改密码失败');
return;
}
this.$message.success(data.message || '密码修改成功');
this.passwordDialogVisible = false;
this.passwordDialogLocked = false;
})
.catch((error) => {
let errorMsg = '修改密码失败,请稍后重试';
try {
if (error && error.response && error.response.data && error.response.data.error) {
errorMsg = error.response.data.error;
}
} catch (e) {
// 读取错误信息失败时保持默认文案即可。
}
this.$message.error(errorMsg);
})
.finally(() => {
this.passwordSubmitting = false;
});
});
}
}
};
</script>
<script>
// 页面显示兜底逻辑:
// 1) 正常情况下由 Vue mounted 时给 .app-container 增加 loaded页面平滑显示
// 2) 若脚本中途报错导致 mounted 未执行,这里在 DOM 就绪后做一次延迟兜底,避免“整页空白”。
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
var app = document.querySelector('.app-container');
if (app && !app.classList.contains('loaded')) {
app.classList.add('loaded');
}
}, 1200);
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>