Merge remote-tracking branch 'origin/feature-855' into feature-855

# Conflicts:
#	admin/dashboard/blueprints/system.py
This commit is contained in:
liuwei
2026-05-06 08:39:09 +08:00
22 changed files with 156 additions and 3822 deletions

View File

@@ -7,42 +7,11 @@ from flask import Blueprint, request, jsonify, render_template, current_app
from admin.dashboard.blueprints.auth import login_required
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
from plugins.robot_menu.menu_render_tool import RobotMenuRenderTool
# 创建蓝图
plugin_routes = Blueprint('plugin_routes', __name__)
LOG = logger
# 后台命令索引页只复用“命令目录生成”能力,不需要图片渲染,
# 因此这里固定使用轻量 text 配置创建一个工具实例即可。
_command_catalog_tool = RobotMenuRenderTool(
output_mode="text",
image_fallback_to_text=True,
image_render_timeout_seconds=30,
image_render_retries=1,
image_template_path="plugins/robot_menu/templates/menu_cards.html",
log=LOG,
)
def _build_group_options(server) -> list:
"""构建后台命令索引页的群组选项列表。"""
group_ids = sorted(set(GroupBotManager.get_group_list() or []))
options = []
for group_id in group_ids:
group_name = ""
try:
group_name = server.contact_manager.get_nickname(group_id) or ""
except Exception:
group_name = ""
options.append(
{
"group_id": group_id,
"group_name": str(group_name or group_id),
}
)
return options
# 机器人管理页面
@plugin_routes.route('/plugins_manage')
@@ -51,61 +20,40 @@ def robot_management():
return render_template('plugins_manage.html')
@plugin_routes.route('/command_catalog')
@login_required
def command_catalog_page():
"""后台命令索引页面。"""
return render_template('command_catalog.html')
@plugin_routes.route('/api/plugins', methods=['GET'])
@login_required
def get_plugins():
"""获取所有插件列表"""
try:
server = current_app.dashboard_server
# 统一改为消费 PluginManager 的标准治理快照:
# 1. 这样既能覆盖“已加载插件”,也能覆盖“发现但加载失败/配置禁用”的模块;
# 2. 后台不必重复拼装版本、命令、依赖、配置健康等字段;
# 3. 后续继续补错误统计、性能排名时,也只需要在快照层扩展。
plugin_list = server.plugin_manager.get_plugin_snapshots()
# 获取插件注册表
plugins = server.plugin_registry.get_all_plugins()
# 转换为前端需要的格式
plugin_list = []
for name, plugin in plugins.items():
# 获取插件模块名
try:
module_name = plugin.__class__.__module__.split('.')[-2]
except (IndexError, AttributeError):
module_name = "unknown"
plugin_info = {
"name": plugin.name,
"module_name": module_name,
"version": getattr(plugin, 'version', 'N/A'),
"author": getattr(plugin, 'author', 'N/A'),
"description": getattr(plugin, 'description', 'N/A'),
"status": plugin.status.name if hasattr(plugin, 'status') else 'UNKNOWN'
}
plugin_list.append(plugin_info)
return jsonify({"success": True, "data": plugin_list})
except Exception as e:
LOG.error(f"获取插件列表失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"获取插件列表失败: {str(e)}"})
@plugin_routes.route('/api/plugins/command_catalog', methods=['GET'])
@login_required
def get_command_catalog():
"""获取后台命令索引数据。"""
try:
server = current_app.dashboard_server
group_id = str(request.args.get('group_id') or '').strip()
# 后台命令索引默认站在“管理员”视角,
# 这样既能看到当前可用命令,也能看到未启用能力和管理指令。
catalog = _command_catalog_tool.build_command_catalog_data(
group_id=group_id,
requester_id="dashboard_admin",
force_admin=True,
)
data = {
**catalog,
"group_options": _build_group_options(server),
"summary": {
"available_manual_count": len(catalog.get("available_manual", []) or []),
"available_auto_count": len(catalog.get("available_auto", []) or []),
"unavailable_manual_count": len(catalog.get("unavailable_manual", []) or []),
"admin_command_count": len(catalog.get("admin_commands", []) or []),
},
}
return jsonify({"success": True, "data": data})
except Exception as e:
LOG.error(f"获取命令索引失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"获取命令索引失败: {str(e)}"})
@plugin_routes.route('/api/plugins/group_status', methods=['GET'])
@login_required
def get_plugin_group_status():
@@ -245,10 +193,31 @@ def get_plugin_info():
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
plugin_info = server.plugin_manager.get_plugin_snapshot(plugin_name)
if not plugin_info:
# 获取插件管理器
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
if not plugin:
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
# 获取插件模块名
try:
module_name = plugin.__class__.__module__.split('.')[-2]
except (IndexError, AttributeError):
module_name = "unknown"
# 构建详细信息
plugin_info = {
"name": plugin.name,
"module_name": module_name,
"version": getattr(plugin, 'version', 'N/A'),
"author": getattr(plugin, 'author', 'N/A'),
"description": getattr(plugin, 'description', 'N/A'),
"status": plugin.status.name if hasattr(plugin, 'status') else 'UNKNOWN',
"command_prefix": getattr(plugin, 'command_prefix', ''),
"commands": getattr(plugin, 'commands', []),
"config": getattr(plugin, '_config', {})
}
return jsonify({"success": True, "data": plugin_info})
except Exception as e:
LOG.error(f"获取插件详情失败: {str(e)}", exc_info=True)
@@ -266,14 +235,9 @@ def enable_plugin():
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
# 已加载插件直接启动;尚未加载的插件则先尝试加载,再进入启动流程。
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
if not plugin:
plugin = server.plugin_manager.load_plugin(plugin_name)
if plugin:
display_name = plugin.name
if plugin and server.plugin_manager.start_plugin(display_name or plugin_name):
# 获取插件管理器
# 启用插件
if server.plugin_manager.start_plugin(plugin_name):
return jsonify({"success": True, "message": f"插件 {plugin_name} 启用成功"})
else:
return jsonify({"success": False, "message": f"插件 {plugin_name} 启用失败"})
@@ -314,14 +278,8 @@ def reload_plugin():
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
# 已加载插件优先走重载;若当前未加载,则退化为“重新尝试加载并启动”。
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
if plugin:
reloaded_plugin = server.plugin_manager.reload_plugin(plugin_name)
else:
reloaded_plugin = server.plugin_manager.load_plugin(plugin_name)
if reloaded_plugin:
server.plugin_manager.start_plugin(reloaded_plugin.name)
# 载插件
reloaded_plugin = server.plugin_manager.reload_plugin(plugin_name)
if reloaded_plugin:
return jsonify({"success": True, "message": f"插件 {plugin_name} 重载成功"})
@@ -342,11 +300,16 @@ def get_raw_plugin_config():
if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"})
plugin_snapshot = server.plugin_manager.get_plugin_snapshot(plugin_name)
if not plugin_snapshot:
# 获取插件管理器
# 查找插件
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
if not plugin:
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
config_path = str(plugin_snapshot.get("config_path", "") or "").strip()
# 获取配置文件路径
config_path = plugin.get_config_path()
if not os.path.exists(config_path):
return jsonify({"success": False, "message": f"配置文件不存在: {config_path}"})
@@ -386,29 +349,15 @@ def update_plugin_config():
if not plugin_name or config_text is None:
return jsonify({"success": False, "message": "缺少必要参数"})
# 查找插件
# 获取插件管理器
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
plugin_snapshot = server.plugin_manager.get_plugin_snapshot(plugin_name)
if not plugin_snapshot:
if not plugin:
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
config_path = str(plugin_snapshot.get("config_path", "") or "").strip()
if not config_path:
return jsonify({"success": False, "message": "插件未声明配置路径,暂不支持在线编辑"})
# 保存前先做格式校验:
# 1. 避免把坏 TOML 先写回磁盘,再让插件进入“文件已坏但提示成功”的状态;
# 2. 校验通过后再真正落盘,失败则保留线上旧配置;
# 3. 这也是插件治理中心第一阶段的“配置校验底座”。
try:
if format_type == 'toml':
config_obj = toml.loads(config_text)
elif format_type == 'json':
config_obj = json.loads(config_text)
else:
return jsonify({"success": False, "message": f"不支持的配置格式: {format_type}"})
except Exception as parse_error:
LOG.error(f"解析配置失败: {str(parse_error)}", exc_info=True)
return jsonify({"success": False, "message": f"配置格式校验失败: {str(parse_error)}"})
# 获取配置文件路径
config_path = plugin.get_config_path()
# 确保配置目录存在
os.makedirs(os.path.dirname(config_path), exist_ok=True)
@@ -417,11 +366,22 @@ def update_plugin_config():
with open(config_path, 'w', encoding='utf-8') as f:
f.write(config_text)
# 若插件当前已加载,则同步刷新内存中的配置镜像,减少“保存后详情弹窗仍是旧配置”的困惑。
if plugin:
# 解析配置并更新插件内部配置
try:
if format_type == 'toml':
config_obj = toml.loads(config_text)
elif format_type == 'json':
config_obj = json.loads(config_text)
else:
return jsonify({"success": False, "message": f"不支持的配置格式: {format_type}"})
# 更新插件内部配置
plugin._config = config_obj
return jsonify({"success": True, "message": "配置已保存并通过格式校验"})
return jsonify({"success": True, "message": "配置已保存"})
except Exception as e:
LOG.error(f"解析配置失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"配置已保存,但解析失败: {str(e)}"})
except Exception as e:
LOG.error(f"更新插件配置失败: {str(e)}", exc_info=True)

View File

@@ -40,7 +40,7 @@ def api_list_schedules():
data = server.plugin_schedule_manager.list_schedules_with_runtime()
# 后端统一格式化时间字段,避免前端出现 Fri, 17 Apr 2026 ... 这类 RFC 时间串。
for row in data:
for key in ("next_run_at", "last_run_at", "latest_success_at", "latest_failed_at", "created_at", "updated_at"):
for key in ("next_run_at", "last_run_at", "created_at", "updated_at"):
if key in row:
row[key] = _normalize_datetime_text(row.get(key))
return jsonify({"success": True, "data": data})

View File

@@ -21,40 +21,6 @@ def _normalize_datetime_text(value):
return text
def _build_job_health_status(*, enabled: bool, running: bool, last_status: str, latest_success_at, latest_failure_summary: str) -> str:
"""根据任务启停、运行态和历史结果输出后台可读的健康状态。"""
# 状态设计尽量贴近运维判断顺序:
# 1. 停用态单独标记,避免和“从未执行”混淆;
# 2. 执行中的任务优先展示 running方便后台快速识别实时动作
# 3. 最近一次执行失败时直接标记 failed让异常任务在列表里一眼可见
# 4. 有成功历史且最近不是失败时视为 healthy否则落到 idle。
if not enabled:
return "disabled"
if running:
return "running"
if str(last_status or "").strip().lower() == "failed":
return "failed"
if latest_success_at or str(last_status or "").strip().lower() == "success":
return "healthy"
if str(latest_failure_summary or "").strip():
return "failed"
return "idle"
def _build_job_health_message(*, health_status: str, latest_success_at, latest_failure_summary: str) -> str:
"""为后台列表生成一句简短的任务健康提示。"""
if health_status == "disabled":
return "任务已停用"
if health_status == "running":
return "任务正在执行中"
if health_status == "failed":
return str(latest_failure_summary or "最近一次执行失败").strip()
if health_status == "healthy":
success_text = _normalize_datetime_text(latest_success_at)
return f"最近成功于 {success_text}" if success_text else "任务近期执行正常"
return "暂无执行记录"
@system_jobs_bp.route("/")
@login_required
def page_system_jobs():
@@ -68,31 +34,11 @@ def api_list_jobs():
db_rows = server.system_job_db.list_jobs()
runtime_rows = async_job.get_jobs_snapshot()
runtime_by_key = {row.get("job_key", ""): row for row in runtime_rows if row.get("job_key")}
job_keys = [str(row.get("job_key") or "").strip() for row in db_rows if str(row.get("job_key") or "").strip()]
latest_log_by_key = server.system_job_db.get_latest_logs_map(job_keys)
history_summary_by_key = server.system_job_db.get_job_history_summary_map(job_keys)
result = []
for row in db_rows:
job_key = row.get("job_key")
runtime = runtime_by_key.get(job_key, {})
latest_log = latest_log_by_key.get(job_key, {})
history_summary = history_summary_by_key.get(job_key, {})
last_status = runtime.get("last_status") or latest_log.get("status") or "never"
last_run_at = runtime.get("last_run_at") or latest_log.get("triggered_at")
last_error = runtime.get("last_error") or ""
if not last_error and str(last_status or "").strip().lower() == "failed":
last_error = (
str(latest_log.get("summary") or "").strip()
or str(history_summary.get("latest_failure_summary") or "").strip()
)
health_status = _build_job_health_status(
enabled=bool(row.get("enabled", 0)),
running=bool(runtime.get("running", False)),
last_status=str(last_status or ""),
latest_success_at=history_summary.get("latest_success_at"),
latest_failure_summary=str(history_summary.get("latest_failure_summary") or ""),
)
result.append(
{
"job_key": job_key,
@@ -105,26 +51,14 @@ def api_list_jobs():
"runtime_enabled": runtime.get("enabled"),
"running": runtime.get("running", False),
"trigger_text": runtime.get("trigger_text", ""),
"last_run_at": _normalize_datetime_text(last_run_at),
"last_status": last_status,
"last_error": last_error,
"last_duration_ms": runtime.get("last_duration_ms") or latest_log.get("duration_ms"),
"last_run_at": _normalize_datetime_text(runtime.get("last_run_at")),
"last_status": runtime.get("last_status"),
"last_error": runtime.get("last_error"),
"last_duration_ms": runtime.get("last_duration_ms"),
"next_run_at": _normalize_datetime_text(runtime.get("next_run_at")),
"run_count": runtime.get("run_count", 0),
"success_count": runtime.get("success_count", 0),
"fail_count": runtime.get("fail_count", 0),
"latest_success_at": _normalize_datetime_text(history_summary.get("latest_success_at")),
"latest_failed_at": _normalize_datetime_text(history_summary.get("latest_failed_at")),
"latest_failure_summary": str(history_summary.get("latest_failure_summary") or "").strip(),
"history_success_count": int(history_summary.get("history_success_count", 0) or 0),
"history_fail_count": int(history_summary.get("history_fail_count", 0) or 0),
"history_total_count": int(history_summary.get("history_total_count", 0) or 0),
"health_status": health_status,
"health_message": _build_job_health_message(
health_status=health_status,
latest_success_at=history_summary.get("latest_success_at"),
latest_failure_summary=str(history_summary.get("latest_failure_summary") or "").strip(),
),
}
)

View File

@@ -1012,7 +1012,6 @@
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' },
@@ -1153,7 +1152,6 @@
'12': '/virtual_group',
'13': '/api_docs',
'14': '/system_status',
'18': '/command_catalog',
'17': '/system_llm',
'15': '/file_browser',
'16': '/message_push'

View File

@@ -1,478 +0,0 @@
{% extends "base.html" %}
{% block title %}命令索引 - 机器人管理后台{% endblock %}
{% block content %}
<div class="page-shell command-catalog-page">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">Command Catalog</div>
<h1>命令索引</h1>
<p>集中查看当前插件命令、群可用状态、自动能力与管理员触发示例,减少靠记忆找功能的成本。</p>
</div>
<div class="page-hero-actions">
<el-select v-model="selectedGroupId" clearable filterable placeholder="选择群查看实际可用状态" @change="loadCatalog" class="hero-select">
<el-option
v-for="group in groupOptions"
:key="group.group_id"
:label="group.group_name"
:value="group.group_id">
</el-option>
</el-select>
<el-button type="primary" @click="loadCatalog">
<i class="el-icon-refresh"></i> 刷新索引
</el-button>
</div>
</div>
<el-row :gutter="16" class="overview-grid">
<el-col :xs="24" :sm="12" :md="6">
<el-card class="overview-card overview-card--primary" shadow="hover">
<div class="overview-label">可用手动命令</div>
<div class="overview-value">{% raw %}{{ summary.available_manual_count || 0 }}{% endraw %}</div>
<div class="overview-note">当前群可以直接触发的消息命令</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">自动能力</div>
<div class="overview-value">{% raw %}{{ summary.available_auto_count || 0 }}{% endraw %}</div>
<div class="overview-note">无需手动发送指令的自动/定时能力</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="overview-card overview-card--soft" shadow="hover">
<div class="overview-label">未启用命令</div>
<div class="overview-value">{% raw %}{{ summary.unavailable_manual_count || 0 }}{% endraw %}</div>
<div class="overview-note">管理员视角下可看到但当前群不可用的命令</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">管理命令</div>
<div class="overview-value">{% raw %}{{ summary.admin_command_count || 0 }}{% endraw %}</div>
<div class="overview-note">群开关、管理员维护等后台辅助命令</div>
</el-card>
</el-col>
</el-row>
<el-card class="workspace-card workspace-card--filters" shadow="hover">
<div class="workspace-header workspace-header--compact">
<div>
<h3>筛选条件</h3>
<p>支持按命令、插件名、描述关键词快速定位。</p>
</div>
<el-input
v-model.trim="searchKeyword"
clearable
placeholder="搜索命令 / 插件 / 描述"
class="search-input">
<i slot="prefix" class="el-input__icon el-icon-search"></i>
</el-input>
</div>
</el-card>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>当前可用命令</h3>
<p>这里展示的是当前群在管理员视角下“真实可触发”的命令入口。</p>
</div>
<div class="workspace-meta">
{% raw %}{{ activeGroupLabel }}{% endraw %}
</div>
</div>
<el-table :data="filteredAvailableManual" style="width: 100%" v-loading="loading" empty-text="当前没有可直接使用的命令">
<el-table-column label="插件" min-width="180">
<template slot-scope="scope">
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
<div class="entity-subtitle">{% raw %}{{ scope.row.description }}{% endraw %}</div>
</template>
</el-table-column>
<el-table-column label="主指令" min-width="180">
<template slot-scope="scope">
<span class="mono-text">{% raw %}{{ scope.row.primary_command || '-' }}{% endraw %}</span>
</template>
</el-table-column>
<el-table-column label="别名" min-width="220">
<template slot-scope="scope">
<div class="tag-row" v-if="scope.row.alias_commands && scope.row.alias_commands.length">
<el-tag v-for="alias in scope.row.alias_commands" :key="alias" size="mini" effect="plain">
{% raw %}{{ alias }}{% endraw %}
</el-tag>
</div>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="类别" width="110" align="center">
<template slot-scope="scope">
<el-tag size="small" type="success">{% raw %}{{ scope.row.category_label }}{% endraw %}</el-tag>
</template>
</el-table-column>
<el-table-column label="可用原因" min-width="150">
<template slot-scope="scope">
{% raw %}{{ scope.row.availability_reason || '-' }}{% endraw %}
</template>
</el-table-column>
<el-table-column label="Feature Key" min-width="150">
<template slot-scope="scope">
<span class="mono-text">{% raw %}{{ scope.row.feature_key || '-' }}{% endraw %}</span>
</template>
</el-table-column>
</el-table>
</el-card>
<el-row :gutter="16" class="insight-grid">
<el-col :xs="24" :lg="12">
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>未启用命令</h3>
<p>这部分只在后台管理员视角展示,便于你知道还有哪些能力没在当前群打开。</p>
</div>
</div>
<el-table :data="filteredUnavailableManual" style="width: 100%" v-loading="loading" empty-text="当前没有未启用命令">
<el-table-column label="插件" min-width="160">
<template slot-scope="scope">
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
<div class="entity-subtitle">{% raw %}{{ scope.row.description }}{% endraw %}</div>
</template>
</el-table-column>
<el-table-column label="命令" min-width="150">
<template slot-scope="scope">
<span class="mono-text">{% raw %}{{ scope.row.primary_command || '-' }}{% endraw %}</span>
</template>
</el-table-column>
<el-table-column label="原因" min-width="140">
<template slot-scope="scope">
<el-tag size="mini" type="warning">{% raw %}{{ scope.row.availability_reason || '-' }}{% endraw %}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>自动/定时能力</h3>
<p>用于提醒你哪些功能不是靠用户发命令触发,而是自动执行。</p>
</div>
</div>
<div class="info-list">
<div v-if="filteredAutoCommands.length === 0" class="empty-state">当前没有已启用的自动能力</div>
<div v-for="item in filteredAutoCommands" :key="item.module_name" class="info-item">
<div class="info-item__head">
<div class="info-item__title">{% raw %}{{ item.name }}{% endraw %}</div>
<el-tag size="mini" type="info">{% raw %}{{ item.category_label }}{% endraw %}</el-tag>
</div>
<div class="info-item__desc">{% raw %}{{ item.description }}{% endraw %}</div>
<div class="info-item__meta">{% raw %}{{ item.availability_reason || '自动执行' }}{% endraw %}</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>管理命令示例</h3>
<p>给管理员的常用操作命令,适合快速开关功能和维护群管理员。</p>
</div>
</div>
<div class="info-list info-list--grid">
<div v-if="!adminCommands.length" class="empty-state">当前没有管理命令示例</div>
<div v-for="item in adminCommands" :key="item.example" class="info-item">
<div class="info-item__head">
<div class="info-item__title">{% raw %}{{ item.title }}{% endraw %}</div>
</div>
<div class="info-item__command mono-text">{% raw %}{{ item.example }}{% endraw %}</div>
<div class="info-item__desc">{% raw %}{{ item.description }}{% endraw %}</div>
</div>
</div>
</el-card>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
currentView: '18',
loading: false,
searchKeyword: '',
selectedGroupId: '',
groupOptions: [],
summary: {
available_manual_count: 0,
available_auto_count: 0,
unavailable_manual_count: 0,
admin_command_count: 0
},
availableManual: [],
unavailableManual: [],
autoCommands: [],
adminCommands: [],
generatedAt: ''
};
},
computed: {
activeGroupLabel() {
if (!this.selectedGroupId) {
return '当前视角:全部运行中插件(未指定群)';
}
const matched = (this.groupOptions || []).find(item => item.group_id === this.selectedGroupId);
return `当前视角:${matched ? matched.group_name : this.selectedGroupId}`;
},
filteredAvailableManual() {
return this.filterCommandItems(this.availableManual);
},
filteredUnavailableManual() {
return this.filterCommandItems(this.unavailableManual);
},
filteredAutoCommands() {
return this.filterCommandItems(this.autoCommands);
}
},
mounted() {
this.currentView = '18';
this.loadCatalog();
},
methods: {
filterCommandItems(items) {
const keyword = String(this.searchKeyword || '').trim().toLowerCase();
if (!keyword) return items || [];
return (items || []).filter(item => {
const aliasText = ((item.alias_commands || []).join(' ') || '').toLowerCase();
const fullText = [
item.name,
item.description,
item.primary_command,
aliasText,
item.feature_key,
item.availability_reason
].join(' ').toLowerCase();
return fullText.includes(keyword);
});
},
loadCatalog() {
this.loading = true;
axios.get('/api/plugins/command_catalog', {
params: {
group_id: this.selectedGroupId
}
})
.then(response => {
if (response.data.success) {
const payload = response.data.data || {};
this.groupOptions = payload.group_options || [];
this.summary = payload.summary || this.summary;
this.availableManual = payload.available_manual || [];
this.unavailableManual = payload.unavailable_manual || [];
this.autoCommands = payload.available_auto || [];
this.adminCommands = payload.admin_commands || [];
this.generatedAt = payload.generated_at || '';
} else {
this.$message.error(response.data.message || '加载命令索引失败');
}
})
.catch(error => {
console.error('加载命令索引出错:', error);
this.$message.error('加载命令索引出错');
})
.finally(() => {
this.loading = false;
});
}
}
});
</script>
{% endblock %}
{% block styles %}
<style>
.page-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-hero {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
padding: 24px 26px;
border-radius: 24px;
background: linear-gradient(135deg, rgba(79,70,229,0.10), rgba(59,130,246,0.08), rgba(255,255,255,0.92));
border: 1px solid rgba(148, 163, 184, 0.16);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
}
.page-eyebrow {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
color: #6366f1;
font-weight: 700;
margin-bottom: 8px;
}
.page-hero-copy h1 {
font-size: 30px;
line-height: 1.1;
margin-bottom: 10px;
color: #0f172a;
}
.page-hero-copy p {
color: #64748b;
font-size: 14px;
}
.page-hero-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.hero-select {
width: 280px;
max-width: 100%;
}
.overview-grid .el-col,
.insight-grid .el-col {
margin-bottom: 16px;
}
.overview-card { min-height: 112px; }
.overview-card--primary {
background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important;
}
.overview-card--soft {
background: linear-gradient(180deg, rgba(59,130,246,0.08), rgba(255,255,255,0.94)) !important;
}
.overview-label { font-size: 13px; color: #64748b; margin-bottom: 14px; }
.overview-value { font-size: 30px; font-weight: 700; color: #0f172a; margin-bottom: 10px; }
.overview-note { font-size: 12px; color: #94a3b8; }
.workspace-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.workspace-header--compact {
align-items: flex-end;
}
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
.workspace-header p { font-size: 13px; color: #64748b; }
.workspace-meta {
font-size: 12px;
color: #94a3b8;
white-space: nowrap;
}
.search-input {
width: 280px;
max-width: 100%;
}
.entity-title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.entity-subtitle {
margin-top: 4px;
font-size: 12px;
color: #94a3b8;
line-height: 1.6;
}
.mono-text {
font-family: Consolas, "SFMono-Regular", Menlo, monospace;
color: #334155;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-list--grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.info-item {
padding: 14px;
border-radius: 16px;
background: rgba(248,250,252,0.82);
border: 1px solid rgba(148,163,184,0.12);
}
.info-item__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.info-item__title {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.info-item__command {
margin-bottom: 8px;
font-size: 13px;
}
.info-item__desc {
font-size: 13px;
line-height: 1.7;
color: #475569;
word-break: break-word;
}
.info-item__meta {
margin-top: 8px;
font-size: 12px;
color: #94a3b8;
}
.empty-state {
padding: 16px 12px;
text-align: center;
color: #94a3b8;
font-size: 13px;
background: rgba(248, 250, 252, 0.9);
border: 1px dashed rgba(148, 163, 184, 0.35);
border-radius: 14px;
}
@media (max-width: 900px) {
.page-hero,
.workspace-header,
.workspace-header--compact {
flex-direction: column;
align-items: flex-start;
}
.page-hero-actions,
.hero-select,
.search-input {
width: 100%;
}
.info-list--grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.page-hero {
padding: 18px 16px;
border-radius: 20px;
}
.page-hero-copy h1 {
font-size: 24px;
}
.workspace-card .el-card__body {
padding: 14px;
}
}
</style>
{% endblock %}

View File

@@ -131,7 +131,7 @@
<div class="section-heading section-heading--stack">
<div>
<h3>系统健康快照</h3>
<p>把连接状态、插件运行、异常数量、LLM 运行态与任务调度集中到一个面板里。</p>
<p>把连接状态、插件运行、异常数量与转图运行时集中到一个面板里。</p>
</div>
<div class="health-overview-meta">
<span class="health-overview-meta__label">最近刷新</span>
@@ -148,29 +148,6 @@
</div>
<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 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>
@@ -394,38 +371,15 @@
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: ''
last_call: {}
},
scheduler: {
md2img: {
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: '',
healthy: false,
runtime_ready: false,
browser_ready: false,
summary: '加载中...'
}
},
@@ -469,7 +423,7 @@
const errors = this.healthSummary.errors || {};
const infrastructure = this.healthSummary.infrastructure || {};
const aiRuntime = this.healthSummary.ai_runtime || {};
const scheduler = this.healthSummary.scheduler || {};
const md2img = this.healthSummary.md2img || {};
return [
{
key: 'robot',
@@ -499,30 +453,25 @@
key: 'infrastructure',
title: '基础设施',
status: infrastructure.status || 'warning',
value: `${this.countHealthyInfrastructureServices(infrastructure)} / 2`,
value: infrastructure.status === 'healthy' ? '正常' : '异常',
summary: infrastructure.summary || '暂无状态',
serviceBlocks: this.buildInfrastructureServiceBlocks(infrastructure),
extra: '首页展示的是服务摘要;如果后续要做更深入的运维排查,再单独拆详细页会更合适。'
extra: `MySQL${((infrastructure.mysql || {}).status === 'healthy') ? '正常' : '异常'} / Redis${((infrastructure.redis || {}).status === 'healthy') ? '正常' : '异常'}`
},
{
key: 'ai_runtime',
title: 'LLM 运行态',
title: 'AI 运行态',
status: aiRuntime.status || 'warning',
value: (aiRuntime.total_calls || 0) > 0
? `${this.formatMetricNumber(aiRuntime.success_rate, 2)}%`
: `${aiRuntime.scene_count || 0} 个场景`,
value: `${aiRuntime.avg_latency_ms || 0} ms`,
summary: aiRuntime.summary || '暂无状态',
serviceBlocks: this.buildAiRuntimeServiceBlocks(aiRuntime),
extra: this.buildAiRuntimeExtra(aiRuntime)
extra: `最近调用 ${aiRuntime.total_calls || 0} 次,失败 ${aiRuntime.failed_calls || 0}`
},
{
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)
key: 'md2img',
title: 'Markdown 转图',
status: md2img.status || 'warning',
value: md2img.healthy ? '就绪' : '待检查',
summary: md2img.summary || '暂无状态',
extra: `Runtime ${md2img.runtime_ready ? '已就绪' : '未就绪'} / Browser ${md2img.browser_ready ? '已就绪' : '未就绪'}`
}
];
}
@@ -590,235 +539,6 @@
};
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);
},
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 || '暂无状态',
metrics: [
{
label: '连接负载',
value: `${this.formatMetricNumber(mysql.connection_usage_percent, 1)}%`
},
{
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 || '暂无状态',
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.formatMetricNumber(redis.hit_rate_percent, 1)}%`
},
{
label: '运行时间',
value: this.formatCompactDuration(redis.uptime_seconds)
}
]
}
];
},
buildAiRuntimeServiceBlocks(aiRuntime) {
// AI 卡片拆成“路由配置”和“最近调用”两个子面板,
// 让首页既能判断配置是否完整,也能快速定位最近请求到底走了哪条链路。
return [
{
key: 'ai-routing',
title: '路由配置',
status: aiRuntime.has_routing ? 'healthy' : 'warning',
summary: aiRuntime.default_scene
? `默认场景:${aiRuntime.default_scene}`
: '当前未设置默认场景',
metrics: [
{
label: '场景数量',
value: this.formatMetricNumber(aiRuntime.scene_count)
},
{
label: '目标数量',
value: this.formatMetricNumber(aiRuntime.target_count)
},
{
label: 'Provider 模板',
value: this.formatMetricNumber(aiRuntime.provider_count)
},
{
label: '默认后端',
value: aiRuntime.default_backend || '-'
}
]
},
{
key: 'ai-last-call',
title: '最近调用',
status: (aiRuntime.failed_calls || 0) > 0 ? 'warning' : ((aiRuntime.total_calls || 0) > 0 ? 'healthy' : 'warning'),
summary: aiRuntime.last_timestamp
? `最近一次记录时间:${aiRuntime.last_timestamp}`
: '当前窗口内暂无调用记录',
metrics: [
{
label: 'Provider',
value: aiRuntime.last_provider || '-'
},
{
label: 'Backend',
value: aiRuntime.last_backend || '-'
},
{
label: 'Scene',
value: aiRuntime.last_scene || '-'
},
{
label: '模型',
value: aiRuntime.last_model || '-'
},
{
label: '最近耗时',
value: `${this.formatMetricNumber(aiRuntime.last_latency_ms, 2)} ms`
},
{
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) {
// 任务调度卡片只保留首页最需要的摘要:
// 任务装载量、执行态、失败数,以及系统任务/插件任务的大致构成。
return [
{
key: 'scheduler-overview',
title: '任务装载',
status: scheduler.enabled_jobs > 0 ? 'healthy' : 'warning',
summary: scheduler.next_run_at
? `下一次执行:${scheduler.next_run_at}`
: '当前没有可计算的下一次执行时间',
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',
title: '执行状态',
status: scheduler.status || 'warning',
summary: scheduler.latest_failed_job_name
? `最近失败任务:${scheduler.latest_failed_job_name}`
: '当前未发现最近失败任务',
metrics: [
{
label: '执行中',
value: this.formatMetricNumber(scheduler.running_jobs)
},
{
label: '失败任务',
value: this.formatMetricNumber(scheduler.failed_jobs)
},
{
label: '非法调度',
value: this.formatMetricNumber(scheduler.invalid_jobs)
},
{
label: '未执行过',
value: this.formatMetricNumber(scheduler.never_run_jobs)
}
]
}
];
},
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;
@@ -1375,104 +1095,6 @@
color: #475569;
}
.health-service-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 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);
}
.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-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;
}
.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;
@@ -1828,10 +1450,6 @@
.health-grid {
grid-template-columns: 1fr;
}
.health-service-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
@@ -1941,10 +1559,6 @@
font-size: 24px;
}
.health-service-metrics {
grid-template-columns: 1fr;
}
.chart-container--large,
.chart-container--panel {
height: 220px;

View File

@@ -42,44 +42,11 @@
<el-tag :type="statusTag(scope.row.last_status)">{% raw %}{{ scope.row.last_status || 'never' }}{% endraw %}</el-tag>
</template>
</el-table-column>
<el-table-column label="健康状态" width="120" align="center">
<template slot-scope="scope">
<el-tag :type="healthTag(scope.row.health_status)">{% raw %}{{ healthLabel(scope.row.health_status) }}{% endraw %}</el-tag>
</template>
</el-table-column>
<el-table-column label="最近成功" min-width="165">
<template slot-scope="scope">
{% raw %}{{ formatDateTime(scope.row.latest_success_at) }}{% endraw %}
</template>
</el-table-column>
<el-table-column label="最近失败原因" min-width="240">
<template slot-scope="scope">
<div class="cell-ellipsis" :title="scope.row.latest_failure_summary || scope.row.last_error || '-'">
{% raw %}{{ scope.row.latest_failure_summary || scope.row.last_error || '-' }}{% endraw %}
</div>
</template>
</el-table-column>
<el-table-column label="历史执行" width="150" align="center">
<template slot-scope="scope">
<div class="history-metrics">
<span class="metric-success">{% raw %}{{ `成 ${scope.row.history_success_count || 0}` }}{% endraw %}</span>
<span class="metric-fail">{% raw %}{{ `失 ${scope.row.history_fail_count || 0}` }}{% endraw %}</span>
</div>
<div class="history-total">{% raw %}{{ `共 ${scope.row.history_total_count || 0}` }}{% endraw %}</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="360">
<el-table-column label="操作" min-width="280">
<template slot-scope="scope">
<div class="action-row">
<el-button size="mini" type="primary" plain @click="openEdit(scope.row)">编辑</el-button>
<el-button size="mini" type="success" plain @click="triggerNow(scope.row)">立即触发</el-button>
<el-button
size="mini"
:type="scope.row.enabled ? 'warning' : 'success'"
plain
@click="toggleEnabled(scope.row)">
{% raw %}{{ scope.row.enabled ? '停用' : '启用' }}{% endraw %}
</el-button>
<el-button size="mini" type="text" @click="viewLogs(scope.row)">日志</el-button>
</div>
</template>
@@ -230,25 +197,6 @@ new Vue({
if (status === 'running') return 'warning'
return 'info'
},
healthTag(status) {
if (status === 'healthy') return 'success'
if (status === 'running') return 'warning'
if (status === 'failed') return 'danger'
if (status === 'degraded') return 'warning'
if (status === 'disabled') return 'info'
return ''
},
healthLabel(status) {
const mapping = {
healthy: '健康',
running: '执行中',
failed: '异常',
degraded: '有告警',
disabled: '停用',
idle: '待运行'
}
return mapping[status] || '待运行'
},
formatDateTime(value) {
// 统一清洗时间展示:去掉 ISO 'T',并兼容字符串与日期对象。
if (!value) return ''
@@ -382,25 +330,6 @@ new Vue({
}
await this.loadSchedules()
},
async toggleEnabled(row) {
const payload = {
action_name: row.action_name,
description: row.description,
enabled: !row.enabled,
trigger_type: row.trigger_type,
trigger_config: row.trigger_config,
target_scope: row.target_scope,
target_config: row.target_config,
payload: row.payload || {}
}
const resp = await axios.put(`/plugin_schedules/api/schedules/${row.id}`, payload)
if (resp.data.success) {
this.$message.success(row.enabled ? '已停用' : '已启用')
await this.loadSchedules()
} else {
this.$message.error(resp.data.message || '更新失败')
}
},
async viewLogs(row) {
const resp = await axios.get(`/plugin_schedules/api/schedules/${row.id}/logs`)
if (resp.data.success) {
@@ -423,10 +352,5 @@ new Vue({
.page-hero-copy p{color:#64748b;font-size:14px}
.action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.detail-pre{white-space:pre-wrap;word-break:break-word;background:rgba(248,250,252,.85);border:1px solid rgba(148,163,184,.12);border-radius:14px;padding:10px;color:#334155}
.cell-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#475569}
.history-metrics{display:flex;align-items:center;justify-content:center;gap:8px}
.metric-success{color:#16a34a;font-weight:600}
.metric-fail{color:#dc2626;font-weight:600}
.history-total{margin-top:4px;color:#64748b;font-size:12px}
</style>
{% endblock %}

View File

@@ -18,168 +18,32 @@
</div>
<el-row :gutter="16" class="overview-grid">
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-col :xs="24" :sm="12" :md="6">
<el-card class="overview-card overview-card--primary" shadow="hover">
<div class="overview-label">插件总数</div>
<div class="overview-value">{% raw %}{{ plugins.length }}{% endraw %}</div>
<div class="overview-note">当前已注册插件模块</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-col :xs="24" :sm="12" :md="6">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">运行中</div>
<div class="overview-value">{% raw %}{{ runningPluginsCount }}{% endraw %}</div>
<div class="overview-note">可正常提供能力的插件</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-col :xs="24" :sm="12" :md="6">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">已停用</div>
<div class="overview-value">{% raw %}{{ stoppedPluginsCount }}{% endraw %}</div>
<div class="overview-note">待启用或排查状态</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-col :xs="24" :sm="12" :md="6">
<el-card class="overview-card overview-card--soft" shadow="hover">
<div class="overview-label">治理告警</div>
<div class="overview-value">{% raw %}{{ governanceRiskCount }}{% endraw %}</div>
<div class="overview-note">存在配置、依赖或加载风险的插件</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">执行异常</div>
<div class="overview-value">{% raw %}{{ executionRiskCount }}{% endraw %}</div>
<div class="overview-note">最近执行失败、超时或进入熔断的插件</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="4">
<el-card class="overview-card" shadow="hover">
<div class="overview-label">熔断中</div>
<div class="overview-value">{% raw %}{{ openCircuitCount }}{% endraw %}</div>
<div class="overview-note">当前被保护机制隔离的高风险插件</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="insight-grid">
<el-col :xs="24" :md="12">
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>高风险插件</h3>
<p>优先排查熔断中、连续失败或最近错误较多的插件。</p>
</div>
</div>
<div class="rank-list">
<div v-if="topRiskPlugins.length === 0" class="mobile-empty-state">暂无高风险插件</div>
<div v-for="(plugin, index) in topRiskPlugins" :key="`risk-${plugin.module_name}`" class="rank-item">
<div class="rank-item__index">{% raw %}{{ index + 1 }}{% endraw %}</div>
<div class="rank-item__content">
<div class="rank-item__title-row">
<div class="rank-item__title">{% raw %}{{ plugin.name }}{% endraw %}</div>
<el-tag :type="executionTagType((plugin.execution_summary || {}).status)" size="mini">
{% raw %}{{ executionLabel((plugin.execution_summary || {}).status) }}{% endraw %}
</el-tag>
</div>
<div class="rank-item__summary">{% raw %}{{ (plugin.execution_summary || {}).summary || '暂无执行摘要' }}{% endraw %}</div>
<div class="rank-item__meta">
<span>最近错误:{% raw %}{{ (plugin.execution_summary || {}).last_error_message || '无' }}{% endraw %}</span>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>慢插件排行</h3>
<p>基于最近一次执行耗时,快速定位可能影响主链路响应的插件。</p>
</div>
</div>
<div class="rank-list">
<div v-if="slowestPlugins.length === 0" class="mobile-empty-state">暂无执行样本</div>
<div v-for="(plugin, index) in slowestPlugins" :key="`slow-${plugin.module_name}`" class="rank-item">
<div class="rank-item__index">{% raw %}{{ index + 1 }}{% endraw %}</div>
<div class="rank-item__content">
<div class="rank-item__title-row">
<div class="rank-item__title">{% raw %}{{ plugin.name }}{% endraw %}</div>
<div class="rank-item__value">{% raw %}{{ formatDurationMs((plugin.execution_summary || {}).last_process_time_ms) }}{% endraw %}</div>
</div>
<div class="rank-item__summary">{% raw %}{{ (plugin.execution_summary || {}).summary || '暂无执行摘要' }}{% endraw %}</div>
<div class="rank-item__meta">
<span>成功率:{% raw %}{{ formatPercent((plugin.execution_summary || {}).success_rate) }}{% endraw %}</span>
<span>累计执行:{% raw %}{{ (plugin.execution_summary || {}).total_executions || 0 }}{% endraw %}</span>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="insight-grid">
<el-col :xs="24" :md="12">
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>依赖核心插件</h3>
<p>优先保护被多个插件依赖的基础能力节点,避免单点异常扩散。</p>
</div>
</div>
<div class="rank-list">
<div v-if="topDependencyCorePlugins.length === 0" class="mobile-empty-state">暂无依赖关系数据</div>
<div v-for="(plugin, index) in topDependencyCorePlugins" :key="`core-${plugin.module_name}`" class="rank-item">
<div class="rank-item__index">{% raw %}{{ index + 1 }}{% endraw %}</div>
<div class="rank-item__content">
<div class="rank-item__title-row">
<div class="rank-item__title">{% raw %}{{ plugin.name }}{% endraw %}</div>
<el-tag :type="governanceTagType(plugin.governance_status)" size="mini">
{% raw %}{{ `${(plugin.dependency_summary || {}).dependent_count || 0} 个上游` }}{% endraw %}
</el-tag>
</div>
<div class="rank-item__summary">
{% raw %}{{ buildDependencyCoreSummary(plugin) }}{% endraw %}
</div>
<div class="rank-item__meta">
<span>执行:{% raw %}{{ executionLabel((plugin.execution_summary || {}).status) }}{% endraw %}</span>
<span>治理:{% raw %}{{ governanceLabel(plugin.governance_status) }}{% endraw %}</span>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card class="workspace-card" shadow="hover">
<div slot="header" class="workspace-header">
<div>
<h3>缺失依赖风险</h3>
<p>快速查看声明了依赖但当前目标未加载的插件,优先处理运行链断裂问题。</p>
</div>
</div>
<div class="rank-list">
<div v-if="pluginsWithMissingDependencies.length === 0" class="mobile-empty-state">当前没有缺失依赖风险</div>
<div v-for="(plugin, index) in pluginsWithMissingDependencies" :key="`missing-${plugin.module_name}`" class="rank-item">
<div class="rank-item__index">{% raw %}{{ index + 1 }}{% endraw %}</div>
<div class="rank-item__content">
<div class="rank-item__title-row">
<div class="rank-item__title">{% raw %}{{ plugin.name }}{% endraw %}</div>
<el-tag type="warning" size="mini">
{% raw %}{{ `${(plugin.dependency_summary || {}).missing_count || 0} 个缺失` }}{% endraw %}
</el-tag>
</div>
<div class="rank-item__summary">
{% raw %}{{ buildMissingDependencySummary(plugin) }}{% endraw %}
</div>
<div class="rank-item__meta">
<span>模块:{% raw %}{{ plugin.module_name }}{% endraw %}</span>
</div>
</div>
</div>
</div>
<div class="overview-label">作者数量</div>
<div class="overview-value">{% raw %}{{ authorsCount }}{% endraw %}</div>
<div class="overview-note">参与维护的作者规模</div>
</el-card>
</el-col>
</el-row>
@@ -188,7 +52,7 @@
<div slot="header" class="workspace-header">
<div>
<h3>插件列表</h3>
<p>优先关注状态、执行表现和说明,再进入单个插件详情与配置编辑。</p>
<p>优先关注状态和说明,再进入单个插件详情与配置编辑。</p>
</div>
</div>
@@ -210,59 +74,11 @@
<el-table-column prop="description" label="描述" min-width="280" show-overflow-tooltip></el-table-column>
<el-table-column label="状态" width="120" align="center">
<template slot-scope="scope">
<el-tag :type="pluginStatusTagType(scope.row.status)">
{% raw %}{{ pluginStatusLabel(scope.row) }}{% endraw %}
<el-tag :type="scope.row.status === 'RUNNING' ? 'success' : 'info'">
{% raw %}{{ scope.row.status === 'RUNNING' ? '已启用' : '已禁用' }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column label="治理健康" width="170" align="center">
<template slot-scope="scope">
<div class="governance-cell">
<el-tag :type="governanceTagType(scope.row.governance_status)" size="small">
{% raw %}{{ governanceLabel(scope.row.governance_status) }}{% endraw %}
</el-tag>
<div class="governance-note">
{% raw %}{{ governanceIssueSummary(scope.row) }}{% endraw %}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="执行表现" min-width="220">
<template slot-scope="scope">
<div class="execution-cell">
<div class="execution-cell__head">
<el-tag :type="executionTagType((scope.row.execution_summary || {}).status)" size="mini">
{% raw %}{{ executionLabel((scope.row.execution_summary || {}).status) }}{% endraw %}
</el-tag>
<span class="execution-cell__metric">
{% raw %}{{ `${formatPercent((scope.row.execution_summary || {}).success_rate)} / ${formatDurationMs((scope.row.execution_summary || {}).last_process_time_ms)}` }}{% endraw %}
</span>
</div>
<div class="execution-cell__summary">
{% raw %}{{ (scope.row.execution_summary || {}).summary || '暂无执行摘要' }}{% endraw %}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="能力类型" width="150" align="center">
<template slot-scope="scope">
<div class="command-tags command-tags--compact">
<el-tag v-for="pluginType in (scope.row.plugin_types || [])" :key="pluginType" size="mini" effect="plain">
{% raw %}{{ pluginType }}{% endraw %}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="命令 / 权限" min-width="180">
<template slot-scope="scope">
<div class="entity-subtitle">
{% raw %}{{ scope.row.command_count ? `命令 ${scope.row.command_count} 个` : '无命令声明' }}{% endraw %}
</div>
<div class="entity-subtitle">
{% raw %}{{ scope.row.feature_key ? `Feature: ${scope.row.feature_key}` : '未接入群级权限' }}{% endraw %}
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="290">
<template slot-scope="scope">
<div class="action-row">
@@ -302,29 +118,17 @@
<div class="entity-subtitle">模块:{% raw %}{{ plugin.module_name }}{% endraw %}</div>
</div>
</div>
<el-tag :type="pluginStatusTagType(plugin.status)" size="small">
{% raw %}{{ pluginStatusLabel(plugin) }}{% endraw %}
<el-tag :type="plugin.status === 'RUNNING' ? 'success' : 'info'" size="small">
{% raw %}{{ plugin.status === 'RUNNING' ? '已启用' : '已禁用' }}{% endraw %}
</el-tag>
</div>
<div class="mobile-plugin-card__meta">
<span>版本:{% raw %}{{ plugin.version || '未知' }}{% endraw %}</span>
<span>治理{% raw %}{{ governanceLabel(plugin.governance_status) }}{% endraw %}</span>
</div>
<div class="mobile-plugin-card__meta">
<span>执行:{% raw %}{{ executionLabel((plugin.execution_summary || {}).status) }}{% endraw %}</span>
<span>成功率:{% raw %}{{ formatPercent((plugin.execution_summary || {}).success_rate) }}{% endraw %}</span>
<span>耗时:{% raw %}{{ formatDurationMs((plugin.execution_summary || {}).last_process_time_ms) }}{% endraw %}</span>
<span>作者{% raw %}{{ plugin.author || '未知' }}{% endraw %}</span>
</div>
<div class="mobile-plugin-card__desc">
{% raw %}{{ plugin.description || '暂无描述' }}{% endraw %}
</div>
<div class="mobile-plugin-card__meta">
<span>{% raw %}{{ governanceIssueSummary(plugin) }}{% endraw %}</span>
<span>{% raw %}{{ plugin.feature_key ? `Feature: ${plugin.feature_key}` : '未接入群级权限' }}{% endraw %}</span>
</div>
<div class="mobile-plugin-card__meta">
<span>依赖:{% raw %}{{ buildDependencySummaryText(plugin) }}{% endraw %}</span>
</div>
<div class="mobile-plugin-card__actions">
<el-button
size="mini"
@@ -355,89 +159,14 @@
<el-descriptions-item label="版本" :span="1">{% raw %}{{ selectedPlugin.version }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="作者" :span="1">{% raw %}{{ selectedPlugin.author }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="状态" :span="1">
<el-tag :type="pluginStatusTagType(selectedPlugin.status)" size="small">
{% raw %}{{ pluginStatusLabel(selectedPlugin) }}{% endraw %}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="治理健康" :span="1">
<el-tag :type="governanceTagType(selectedPlugin.governance_status)" size="small">
{% raw %}{{ governanceLabel(selectedPlugin.governance_status) }}{% endraw %}
<el-tag :type="selectedPlugin.status === 'RUNNING' ? 'success' : 'info'" size="small">
{% raw %}{{ selectedPlugin.status === 'RUNNING' ? '已启用' : '已禁用' }}{% endraw %}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="命令前缀" :span="1" v-if="selectedPlugin.command_prefix !== undefined">
{% raw %}{{ selectedPlugin.command_prefix || '无' }}{% endraw %}
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{% raw %}{{ selectedPlugin.description }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="能力类型" :span="2" v-if="selectedPlugin.plugin_types && selectedPlugin.plugin_types.length > 0">
<div class="command-tags">
<el-tag v-for="pluginType in selectedPlugin.plugin_types" :key="pluginType" size="mini" effect="plain">
{% raw %}{{ pluginType }}{% endraw %}
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item label="Feature Key" :span="1">
{% raw %}{{ selectedPlugin.feature_key || '未声明' }}{% endraw %}
</el-descriptions-item>
<el-descriptions-item label="群级开关" :span="1">
{% raw %}{{ selectedPlugin.supports_group_switch ? '支持' : '未接入' }}{% endraw %}
</el-descriptions-item>
<el-descriptions-item label="依赖插件" :span="2">
<div v-if="selectedPlugin.dependencies && selectedPlugin.dependencies.length > 0" class="command-tags">
<el-tag v-for="dependency in selectedPlugin.dependencies" :key="dependency" size="mini" effect="plain">
{% raw %}{{ dependency }}{% endraw %}
</el-tag>
</div>
<span v-else></span>
</el-descriptions-item>
<el-descriptions-item label="依赖关系" :span="2" v-if="selectedPlugin.dependency_summary">
<div class="config-overview-grid">
<div class="config-overview-item">
<span class="config-overview-label">声明依赖</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.dependency_summary.declared_count || 0 }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">已解析依赖</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.dependency_summary.resolved_count || 0 }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">缺失依赖</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.dependency_summary.missing_count || 0 }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">下游依赖</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.dependency_summary.dependent_count || 0 }}{% endraw %}</span>
</div>
</div>
<div class="dependency-panels">
<div class="dependency-panel">
<div class="dependency-panel__title">已解析依赖</div>
<div v-if="selectedPlugin.resolved_dependencies && selectedPlugin.resolved_dependencies.length > 0" class="command-tags">
<el-tag v-for="dependency in selectedPlugin.resolved_dependencies" :key="`resolved-${dependency.module_name}`" size="mini" effect="plain">
{% raw %}{{ `${dependency.name} (${dependency.status_label || dependency.status || '未知'})` }}{% endraw %}
</el-tag>
</div>
<div v-else class="entity-subtitle"></div>
</div>
<div class="dependency-panel">
<div class="dependency-panel__title">缺失依赖</div>
<div v-if="selectedPlugin.missing_dependencies && selectedPlugin.missing_dependencies.length > 0" class="command-tags">
<el-tag v-for="dependency in selectedPlugin.missing_dependencies" :key="`missing-${dependency.name}`" size="mini" type="warning">
{% raw %}{{ dependency.name }}{% endraw %}
</el-tag>
</div>
<div v-else class="entity-subtitle"></div>
</div>
<div class="dependency-panel">
<div class="dependency-panel__title">下游依赖插件</div>
<div v-if="selectedPlugin.dependent_plugins && selectedPlugin.dependent_plugins.length > 0" class="command-tags">
<el-tag v-for="dependency in selectedPlugin.dependent_plugins" :key="`dependent-${dependency.module_name}`" size="mini" type="success" effect="plain">
{% raw %}{{ `${dependency.name} (${dependency.status_label || dependency.status || '未知'})` }}{% endraw %}
</el-tag>
</div>
<div v-else class="entity-subtitle"></div>
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="命令列表" :span="2" v-if="selectedPlugin.commands && selectedPlugin.commands.length > 0">
<div class="command-tags">
<el-tag v-for="cmd in selectedPlugin.commands" :key="cmd" size="mini" class="command-tag">
@@ -445,105 +174,6 @@
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item label="配置概览" :span="2" v-if="selectedPlugin.config_overview">
<div class="config-overview-grid">
<div class="config-overview-item">
<span class="config-overview-label">配置文件</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.exists ? '存在' : '缺失' }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">解析状态</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.parse_ok ? '正常' : '失败' }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">配置分组</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.section_count || 0 }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">敏感字段</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.sensitive_field_count || 0 }}{% endraw %}</span>
</div>
</div>
<div class="entity-subtitle" style="margin-top: 8px;">
{% raw %}{{ selectedPlugin.config_path || '未声明配置路径' }}{% endraw %}
</div>
<div class="entity-subtitle" v-if="selectedPlugin.config_overview.parse_error">
{% raw %}{{ `解析错误:${selectedPlugin.config_overview.parse_error}` }}{% endraw %}
</div>
</el-descriptions-item>
<el-descriptions-item label="执行保护" :span="2" v-if="selectedPlugin.execution_guard">
<div class="config-overview-grid">
<div class="config-overview-item">
<span class="config-overview-label">熔断状态</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_guard.circuit_state || 'closed' }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">连续失败</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_guard.consecutive_failures || 0 }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">连续超时</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_guard.consecutive_timeouts || 0 }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">恢复剩余</span>
<span class="config-overview-value">{% raw %}{{ `${selectedPlugin.execution_guard.open_remaining_seconds || 0}s` }}{% endraw %}</span>
</div>
</div>
</el-descriptions-item>
<el-descriptions-item label="执行表现" :span="2" v-if="selectedPlugin.execution_summary">
<div class="config-overview-grid">
<div class="config-overview-item">
<span class="config-overview-label">执行状态</span>
<span class="config-overview-value">{% raw %}{{ executionLabel(selectedPlugin.execution_summary.status) }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">累计执行</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_summary.total_executions || 0 }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">成功率</span>
<span class="config-overview-value">{% raw %}{{ formatPercent(selectedPlugin.execution_summary.success_rate) }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">超时率</span>
<span class="config-overview-value">{% raw %}{{ formatPercent(selectedPlugin.execution_summary.timeout_rate) }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">最近耗时</span>
<span class="config-overview-value">{% raw %}{{ formatDurationMs(selectedPlugin.execution_summary.last_process_time_ms) }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">最近成功</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_summary.last_success_at_text || '-' }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">最近失败</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_summary.last_failure_at_text || '-' }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">最近错误</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_summary.last_error_message || '无' }}{% endraw %}</span>
</div>
</div>
<div class="entity-subtitle" style="margin-top: 8px;">
{% raw %}{{ selectedPlugin.execution_summary.summary || '暂无执行摘要' }}{% endraw %}
</div>
</el-descriptions-item>
<el-descriptions-item label="治理诊断" :span="2" v-if="selectedPlugin.governance_diagnostics">
<div v-if="selectedPlugin.governance_diagnostics.length > 0" class="diagnostic-list">
<div
v-for="(diagnostic, index) in selectedPlugin.governance_diagnostics"
:key="`${diagnostic.code}-${index}`"
class="diagnostic-item">
<el-tag :type="governanceTagType(diagnostic.level)" size="mini">
{% raw %}{{ governanceLabel(diagnostic.level) }}{% endraw %}
</el-tag>
<span class="diagnostic-text">{% raw %}{{ diagnostic.message }}{% endraw %}</span>
</div>
</div>
<span v-else>暂无治理诊断项</span>
</el-descriptions-item>
<el-descriptions-item label="配置信息" :span="2" v-if="selectedPlugin.config">
<div class="config-container">
<div class="config-actions">
@@ -740,75 +370,8 @@
stoppedPluginsCount() {
return this.plugins.filter(plugin => plugin.status !== 'RUNNING').length;
},
governanceRiskCount() {
return (this.plugins || []).filter(plugin => ['warning', 'error'].includes((plugin.governance_status || '').toLowerCase())).length;
},
executionRiskCount() {
// 这里把执行风险单独统计出来,和治理告警区分开:
// 治理告警偏配置/依赖/加载问题,执行风险偏运行过程中的失败、超时与熔断。
return (this.plugins || []).filter(plugin => ['warning', 'error'].includes((((plugin.execution_summary || {}).status) || '').toLowerCase())).length;
},
openCircuitCount() {
return (this.plugins || []).filter(plugin => ((((plugin.execution_summary || {}).circuit_state) || '').toLowerCase() === 'open')).length;
},
topRiskPlugins() {
// 风险排行优先按熔断状态、执行状态和连续失败次数排序,
// 让页面顶部尽量把“最值得先排查”的插件顶上来。
const statusPriority = {
error: 0,
warning: 1,
info: 2,
healthy: 3
};
return (this.plugins || [])
.filter(plugin => ['warning', 'error'].includes((((plugin.execution_summary || {}).status) || '').toLowerCase()))
.slice()
.sort((left, right) => {
const leftSummary = left.execution_summary || {};
const rightSummary = right.execution_summary || {};
const leftPriority = statusPriority[(leftSummary.status || 'info').toLowerCase()];
const rightPriority = statusPriority[(rightSummary.status || 'info').toLowerCase()];
return (
(typeof leftPriority === 'number' ? leftPriority : 9) - (typeof rightPriority === 'number' ? rightPriority : 9)
|| Number(rightSummary.consecutive_failures || 0) - Number(leftSummary.consecutive_failures || 0)
|| Number(rightSummary.failure_count_total || 0) - Number(leftSummary.failure_count_total || 0)
|| Number(rightSummary.timeout_count_total || 0) - Number(leftSummary.timeout_count_total || 0)
);
})
.slice(0, 5);
},
topDependencyCorePlugins() {
// 核心依赖插件优先按“被多少插件依赖”排序,
// 这样最容易形成单点影响的基础插件会排在前面。
return (this.plugins || [])
.filter(plugin => Number(((plugin.dependency_summary || {}).dependent_count) || 0) > 0)
.slice()
.sort((left, right) => {
return (
Number(((right.dependency_summary || {}).dependent_count) || 0) - Number(((left.dependency_summary || {}).dependent_count) || 0)
|| Number(((right.dependency_summary || {}).declared_count) || 0) - Number(((left.dependency_summary || {}).declared_count) || 0)
);
})
.slice(0, 5);
},
pluginsWithMissingDependencies() {
return (this.plugins || [])
.filter(plugin => Number(((plugin.dependency_summary || {}).missing_count) || 0) > 0)
.slice()
.sort((left, right) => {
return Number(((right.dependency_summary || {}).missing_count) || 0) - Number(((left.dependency_summary || {}).missing_count) || 0);
})
.slice(0, 5);
},
slowestPlugins() {
// 慢插件排行只看有执行样本的插件,避免未执行插件把榜单冲掉。
return (this.plugins || [])
.filter(plugin => Number((plugin.execution_summary || {}).total_executions || 0) > 0)
.slice()
.sort((left, right) => {
return Number((right.execution_summary || {}).last_process_time_ms || 0) - Number((left.execution_summary || {}).last_process_time_ms || 0);
})
.slice(0, 5);
authorsCount() {
return new Set((this.plugins || []).map(plugin => plugin.author).filter(Boolean)).size;
},
// 弹窗宽度按视口分级收缩,保证手机上弹窗内容不会贴边或继续触发横向溢出。
pluginInfoDialogWidth() {
@@ -837,109 +400,6 @@
// 这里统一以 768px 作为移动端断点,和常见后台管理布局断点保持一致。
this.isMobileViewport = window.innerWidth <= 768;
},
pluginStatusTagType(status) {
const normalizedStatus = String(status || '').toUpperCase();
if (normalizedStatus === 'RUNNING') return 'success';
if (normalizedStatus === 'ERROR') return 'danger';
if (normalizedStatus === 'LOADED') return 'warning';
return 'info';
},
pluginStatusLabel(plugin) {
if (plugin && plugin.status_label) return plugin.status_label;
const normalizedStatus = String((plugin && plugin.status) || '').toUpperCase();
const mapping = {
RUNNING: '运行中',
STOPPED: '已停用',
LOADED: '已加载',
UNLOADED: '未加载',
ERROR: '异常',
DISCOVERED: '待处理'
};
return mapping[normalizedStatus] || '未知';
},
governanceTagType(level) {
const normalizedLevel = String(level || '').toLowerCase();
if (normalizedLevel === 'error') return 'danger';
if (normalizedLevel === 'warning') return 'warning';
if (normalizedLevel === 'healthy') return 'success';
return 'info';
},
governanceLabel(level) {
const normalizedLevel = String(level || '').toLowerCase();
const mapping = {
healthy: '健康',
warning: '告警',
error: '异常',
info: '提示'
};
return mapping[normalizedLevel] || '提示';
},
executionTagType(level) {
const normalizedLevel = String(level || '').toLowerCase();
if (normalizedLevel === 'error') return 'danger';
if (normalizedLevel === 'warning') return 'warning';
if (normalizedLevel === 'healthy') return 'success';
return 'info';
},
executionLabel(level) {
const normalizedLevel = String(level || '').toLowerCase();
const mapping = {
healthy: '稳定',
warning: '需关注',
error: '高风险',
info: '暂无样本'
};
return mapping[normalizedLevel] || '暂无样本';
},
governanceIssueSummary(plugin) {
const errorCount = Number((plugin && plugin.governance_error_count) || 0);
const warningCount = Number((plugin && plugin.governance_warning_count) || 0);
const infoCount = Number((plugin && plugin.governance_info_count) || 0);
if (errorCount > 0 || warningCount > 0) {
return `错误 ${errorCount} / 告警 ${warningCount}`;
}
if (infoCount > 0) {
return `提示 ${infoCount}`;
}
return '暂无治理问题';
},
formatPercent(value) {
const normalizedValue = Number(value || 0);
if (!Number.isFinite(normalizedValue)) return '0.00%';
return `${normalizedValue.toFixed(2)}%`;
},
formatDurationMs(value) {
const normalizedValue = Number(value || 0);
if (!Number.isFinite(normalizedValue) || normalizedValue <= 0) return '-';
return `${normalizedValue.toFixed(2)} ms`;
},
buildDependencySummaryText(plugin) {
const dependencySummary = (plugin && plugin.dependency_summary) || {};
const declaredCount = Number(dependencySummary.declared_count || 0);
const missingCount = Number(dependencySummary.missing_count || 0);
const dependentCount = Number(dependencySummary.dependent_count || 0);
if (declaredCount <= 0 && dependentCount <= 0) {
return '无依赖关系';
}
if (missingCount > 0) {
return `声明 ${declaredCount} 个,缺失 ${missingCount}`;
}
if (dependentCount > 0) {
return `${dependentCount} 个插件依赖`;
}
return `已解析 ${declaredCount} 个依赖`;
},
buildDependencyCoreSummary(plugin) {
const dependencySummary = (plugin && plugin.dependency_summary) || {};
return `当前被 ${(dependencySummary.dependent_count || 0)} 个插件依赖,自身声明 ${(dependencySummary.declared_count || 0)} 个依赖。`;
},
buildMissingDependencySummary(plugin) {
const missingDependencies = ((plugin && plugin.missing_dependencies) || []).map(item => item.name).filter(Boolean);
if (!missingDependencies.length) {
return '当前没有缺失依赖。';
}
return `缺失依赖:${missingDependencies.join('、')}`;
},
loadPlugins() {
this.loading = true;
axios.get('/api/plugins')
@@ -1027,6 +487,7 @@
},
saveConfig() {
try {
let configObj;
axios.post('/api/plugins/config/update', {
plugin_name: this.selectedPlugin.module_name,
config_text: this.editedConfig,
@@ -1037,11 +498,7 @@
this.$message.success('配置保存成功');
this.isEditingConfig = false;
this.selectedPlugin.configText = this.editedConfig;
// 保存成功后立即重新拉取详情:
// 1. 同步刷新治理诊断、配置概览和内存中的插件配置快照;
// 2. 避免页面上继续停留在旧的健康状态;
// 3. 这样后续是否重载插件,用户都能先看到“配置文本已通过校验并落盘”。
this.showPluginInfo(this.selectedPlugin);
this.selectedPlugin.config = configObj;
this.$confirm('配置已保存,是否要重载插件以应用新配置?', '提示', {
confirmButtonText: '重载插件',
cancelButtonText: '稍后手动重载',
@@ -1057,7 +514,7 @@
})
.catch(error => {
console.error('保存配置出错:', error);
this.configError = '保存配置出错: ' + (((error.response || {}).data || {}).message || error.message);
this.configError = '保存配置出错: ' + (error.response?.data?.message || error.message);
});
} catch (e) {
this.configError = '处理配置时出错: ' + e.message;
@@ -1224,7 +681,6 @@
font-size: 14px;
}
.overview-grid .el-col { margin-bottom: 16px; }
.insight-grid .el-col { margin-bottom: 16px; }
.overview-card { min-height: 112px; }
.overview-card--primary {
background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important;
@@ -1240,64 +696,6 @@
}
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
.workspace-header p { font-size: 13px; color: #64748b; }
.rank-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.rank-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: 16px;
background: rgba(248,250,252,0.82);
border: 1px solid rgba(148,163,184,0.12);
}
.rank-item__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-item__content {
flex: 1;
min-width: 0;
}
.rank-item__title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.rank-item__title,
.rank-item__value {
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.rank-item__summary {
font-size: 13px;
line-height: 1.7;
color: #475569;
word-break: break-word;
}
.rank-item__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin-top: 8px;
font-size: 12px;
color: #94a3b8;
}
.entity-cell { display: flex; align-items: center; gap: 12px; }
.entity-badge {
width: 30px; height: 30px; border-radius: 50%; display: inline-flex; align-items: center;
@@ -1316,106 +714,10 @@
}
.config-container pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
.command-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.command-tags--compact { justify-content: center; }
.command-tag { margin: 0 !important; }
.config-actions { margin-bottom: 10px; display: flex; gap: 10px; }
.config-editor { font-family: monospace; font-size: 12px; }
.config-error { color: #ef4444; font-size: 12px; margin-top: 5px; }
.governance-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.governance-note {
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
}
.execution-cell {
display: flex;
flex-direction: column;
gap: 6px;
}
.execution-cell__head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.execution-cell__metric {
font-size: 12px;
color: #64748b;
font-weight: 600;
}
.execution-cell__summary {
font-size: 12px;
color: #94a3b8;
line-height: 1.6;
word-break: break-word;
}
.config-overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.dependency-panels {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 12px;
}
.dependency-panel {
padding: 12px;
border-radius: 14px;
background: rgba(248,250,252,0.82);
border: 1px solid rgba(148,163,184,0.12);
}
.dependency-panel__title {
font-size: 13px;
font-weight: 700;
color: #334155;
margin-bottom: 8px;
}
.config-overview-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.72);
border: 1px solid rgba(148,163,184,0.12);
}
.config-overview-label {
font-size: 12px;
color: #64748b;
}
.config-overview-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.diagnostic-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.diagnostic-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(248,250,252,0.82);
border: 1px solid rgba(148,163,184,0.12);
}
.diagnostic-text {
flex: 1;
font-size: 13px;
color: #334155;
line-height: 1.6;
word-break: break-word;
}
.plugin-group-status-dialog { min-height: 240px; }
.mobile-plugin-list,
.mobile-group-list {
@@ -1548,10 +850,6 @@
.mobile-plugin-card__header {
flex-direction: column;
}
.rank-item__title-row {
flex-direction: column;
align-items: flex-start;
}
.mobile-plugin-card__actions .el-button,
.mobile-group-card__actions .el-button {
flex: 1 1 calc(50% - 8px);

View File

@@ -33,28 +33,6 @@
<el-tag :type="statusTag(scope.row.last_status)">{% raw %}{{ scope.row.last_status || 'never' }}{% endraw %}</el-tag>
</template>
</el-table-column>
<el-table-column label="健康状态" width="120" align="center">
<template slot-scope="scope">
<el-tag :type="healthTag(scope.row.health_status)">{% raw %}{{ healthLabel(scope.row.health_status) }}{% endraw %}</el-tag>
</template>
</el-table-column>
<el-table-column prop="latest_success_at" label="最近成功" min-width="170"></el-table-column>
<el-table-column label="最近失败原因" min-width="240">
<template slot-scope="scope">
<div class="cell-ellipsis" :title="scope.row.latest_failure_summary || scope.row.last_error || '-'">
{% raw %}{{ scope.row.latest_failure_summary || scope.row.last_error || '-' }}{% endraw %}
</div>
</template>
</el-table-column>
<el-table-column label="历史执行" width="150" align="center">
<template slot-scope="scope">
<div class="history-metrics">
<span class="metric-success">{% raw %}{{ `成 ${scope.row.history_success_count || 0}` }}{% endraw %}</span>
<span class="metric-fail">{% raw %}{{ `失 ${scope.row.history_fail_count || 0}` }}{% endraw %}</span>
</div>
<div class="history-total">{% raw %}{{ `共 ${scope.row.history_total_count || 0}` }}{% endraw %}</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="280">
<template slot-scope="scope">
<div class="action-row">
@@ -165,23 +143,6 @@ new Vue({
if (status === 'running') return 'warning';
return 'info';
},
healthTag(status) {
if (status === 'healthy') return 'success';
if (status === 'running') return 'warning';
if (status === 'failed') return 'danger';
if (status === 'disabled') return 'info';
return '';
},
healthLabel(status) {
const mapping = {
healthy: '健康',
running: '执行中',
failed: '异常',
disabled: '停用',
idle: '待运行'
};
return mapping[status] || '待运行';
},
async loadJobs() {
this.loading = true;
try {
@@ -308,10 +269,5 @@ new Vue({
.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}
.page-hero-copy p{color:#64748b;font-size:14px}
.action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.cell-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#475569}
.history-metrics{display:flex;align-items:center;justify-content:center;gap:8px}
.metric-success{color:#16a34a;font-weight:600}
.metric-fail{color:#dc2626;font-weight:600}
.history-total{margin-top:4px;color:#64748b;font-size:12px}
</style>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
import time
from loguru import logger
from typing import List, Dict, Any, Optional, Tuple, Union
@@ -14,62 +12,19 @@ class BaseDBOperator:
self.db_manager = db_manager
self.LOG = logger
@staticmethod
def _compact_sql(sql: str) -> str:
"""把 SQL 压成单行,便于日志里快速定位问题。"""
return " ".join(str(sql or "").split())
@classmethod
def _truncate_text(cls, value, max_length: int = 240) -> str:
"""截断长文本,避免日志被超长 SQL 或参数刷屏。"""
text = str(value or "")
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
def _log_sql_timing(self, operation: str, sql: str, params, elapsed_ms: float, affected_rows: Optional[int] = None) -> None:
"""记录慢 SQL 日志。
设计说明:
1. 只在超过阈值时输出 warning避免日常日志噪声过大
2. 统一输出压缩后的 SQL 与截断参数,便于线上排查具体慢点;
3. 查询/更新/批量/事务都走同一入口,后续如果要接后台审计也更容易扩展。
"""
if not self.db_manager.is_slow_query_log_enabled():
return
threshold_ms = self.db_manager.get_slow_query_threshold_ms()
if elapsed_ms < threshold_ms:
return
affected_text = ""
if affected_rows is not None:
affected_text = f" affected_rows={affected_rows}"
self.LOG.warning(
f"检测到慢SQL operation={operation} cost_ms={round(elapsed_ms, 2)} threshold_ms={threshold_ms}"
f"{affected_text} sql={self._truncate_text(self._compact_sql(sql), 400)} "
f"params={self._truncate_text(params, 240)}"
)
def execute_query(self, sql: str, params: Optional[tuple] = None, fetch_one: bool = False) -> Union[
List[Dict], Dict, None]:
"""执行查询SQL"""
conn = self.db_manager.get_mysql_connection()
started_at = time.perf_counter()
try:
with conn.cursor(dictionary=True) as cursor:
cursor.execute(sql, params or ())
elapsed_ms = (time.perf_counter() - started_at) * 1000
if fetch_one:
result = cursor.fetchone()
self._log_sql_timing("query_one", sql, params, elapsed_ms, 1 if result else 0)
return result
result = cursor.fetchall()
self._log_sql_timing("query", sql, params, elapsed_ms, len(result or []))
return result
return cursor.fetchone()
return cursor.fetchall()
except Exception as e:
self.LOG.error(
f"执行查询SQL出错: {e}, SQL: {sql}, 参数: {str(params)[:200] + '...' if len(str(params)) > 200 else params}"
f"执行更新SQL出错: {e}, SQL: {sql}, 参数: {str(params)[:200] + '...' if len(str(params)) > 200 else params}"
)
return None
finally:
@@ -78,13 +33,10 @@ class BaseDBOperator:
def execute_update(self, sql: str, params: Optional[tuple] = None) -> bool:
"""执行更新SQL"""
conn = self.db_manager.get_mysql_connection()
started_at = time.perf_counter()
try:
with conn.cursor() as cursor:
cursor.execute(sql, params or ())
affected_rows = cursor.rowcount
conn.commit()
self._log_sql_timing("update", sql, params, (time.perf_counter() - started_at) * 1000, affected_rows)
return True
except Exception as e:
self.LOG.error(
@@ -101,19 +53,10 @@ class BaseDBOperator:
return True
conn = self.db_manager.get_mysql_connection()
started_at = time.perf_counter()
try:
with conn.cursor() as cursor:
cursor.executemany(sql, params_list)
affected_rows = cursor.rowcount
conn.commit()
self._log_sql_timing(
"batch_update",
sql,
f"params_count={len(params_list)}",
(time.perf_counter() - started_at) * 1000,
affected_rows,
)
return True
except Exception as e:
self.LOG.error(f"批量执行SQL出错: {e}, SQL: {sql}, 参数数量: {len(params_list)}")
@@ -128,18 +71,11 @@ class BaseDBOperator:
return True
conn = self.db_manager.get_mysql_connection()
started_at = time.perf_counter()
try:
with conn.cursor() as cursor:
for sql, params in operations:
cursor.execute(sql, params)
conn.commit()
self._log_sql_timing(
"transaction",
f"{len(operations)} statements",
f"operations={len(operations)}",
(time.perf_counter() - started_at) * 1000,
)
return True
except Exception as e:
self.LOG.error(f"执行事务出错: {e}, 操作数量: {len(operations)}")

View File

@@ -39,13 +39,7 @@ class DBConnectionManager:
self.LOG = logger
self.mysql_pool = None
self.redis_pool = None
# 保存原始配置快照,供慢 SQL 阈值、库名探测等公共能力复用:
# 1. BaseDBOperator 需要读取数据库名,去 information_schema 中检查索引;
# 2. 慢 SQL 记录需要统一读取阈值配置,而不是每个 DB Operator 各自硬编码;
# 3. 这里做浅拷贝即可,避免后续外部修改传入 dict 时影响内部状态。
self.mysql_config = dict(mysql_config or {})
self.redis_config = dict(redis_config or {})
# 初始化MySQL连接池
if mysql_config:
self.init_mysql_pool(mysql_config)
@@ -64,8 +58,6 @@ class DBConnectionManager:
if not config:
self.LOG.warning("MySQL配置为空跳过初始化")
return
self.mysql_config = dict(config or {})
# 准备连接池配置
pool_config = {
@@ -98,8 +90,6 @@ class DBConnectionManager:
if not config:
self.LOG.warning("Redis配置为空跳过初始化")
return
self.redis_config = dict(config or {})
self.redis_pool = redis.ConnectionPool(
host=config.get('host', 'localhost'),
@@ -127,26 +117,6 @@ class DBConnectionManager:
raise Exception("MySQL连接池未初始化")
return self.mysql_pool.get_connection()
def get_mysql_database_name(self) -> str:
"""返回当前 MySQL 目标库名。"""
return str(self.mysql_config.get('database', '') or '').strip()
def get_slow_query_threshold_ms(self) -> int:
"""读取慢 SQL 阈值,默认 500ms。"""
try:
threshold = int(self.mysql_config.get('slow_query_threshold_ms', 500) or 500)
return threshold if threshold > 0 else 500
except (TypeError, ValueError):
return 500
def is_slow_query_log_enabled(self) -> bool:
"""是否启用慢 SQL 日志。"""
raw_value = self.mysql_config.get('enable_slow_query_log', True)
if isinstance(raw_value, str):
normalized = raw_value.strip().lower()
return normalized not in {'0', 'false', 'off', 'no'}
return bool(raw_value)
def get_redis_connection(self):
"""获取Redis连接
@@ -170,4 +140,4 @@ class DBConnectionManager:
# 关闭Redis连接池
if self.redis_pool:
self.redis_pool.disconnect()
self.redis_pool = None
self.redis_pool = None

View File

@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
from datetime import datetime, timedelta
from datetime import datetime
import json
from threading import Lock
from typing import Dict, List, Optional
from db.base import BaseDBOperator
@@ -13,103 +12,8 @@ from wechat_ipad.models.message import WxMessage
class MessageStorageDB(BaseDBOperator):
"""消息存储相关数据库操作"""
_performance_ready = False
_performance_lock = Lock()
def __init__(self, db_manager: DBConnectionManager):
super().__init__(db_manager)
self._ensure_performance_primitives()
@staticmethod
def _normalize_datetime_text(value) -> str:
"""把日期/时间对象统一转成数据库可比较的标准字符串。"""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value or "").strip()
@classmethod
def _build_day_time_range(cls, target_date: str) -> tuple[str, str]:
"""把 `YYYY-MM-DD` 日期转换成 `[00:00:00, 次日00:00:00)` 时间范围。"""
start_dt = datetime.strptime(str(target_date or "").strip(), "%Y-%m-%d")
end_dt = start_dt.replace(hour=0, minute=0, second=0, microsecond=0)
next_day_dt = end_dt + timedelta(days=1)
return (
end_dt.strftime("%Y-%m-%d 00:00:00"),
next_day_dt.strftime("%Y-%m-%d 00:00:00"),
)
@classmethod
def _build_day_bounds(cls, start_date: str, end_date: str) -> tuple[str, str]:
"""把日期区间转换成适合索引命中的时间范围。"""
start_dt = datetime.strptime(str(start_date or "").strip(), "%Y-%m-%d")
end_dt = datetime.strptime(str(end_date or "").strip(), "%Y-%m-%d")
if end_dt < start_dt:
start_dt, end_dt = end_dt, start_dt
next_day_dt = end_dt + timedelta(days=1)
return (
start_dt.strftime("%Y-%m-%d 00:00:00"),
next_day_dt.strftime("%Y-%m-%d 00:00:00"),
)
def _ensure_performance_primitives(self) -> None:
"""确保消息存储相关的关键索引存在。
设计说明:
1. 这一步只补“高频查询明确受益”的索引,不做激进表结构重写;
2. 使用 information_schema 做存在性检查,保证重复启动时仍然幂等;
3. 只在进程内执行一次,避免每次 new MessageStorageDB 都重复打元数据查询。
"""
if self.__class__._performance_ready:
return
with self.__class__._performance_lock:
if self.__class__._performance_ready:
return
self._ensure_index_exists(
table_name="messages",
index_name="idx_group_sender_timestamp",
create_sql="CREATE INDEX idx_group_sender_timestamp ON messages (group_id, sender, timestamp)",
)
self._ensure_index_exists(
table_name="messages",
index_name="idx_group_type_timestamp",
create_sql="CREATE INDEX idx_group_type_timestamp ON messages (group_id, message_type, timestamp)",
)
self._ensure_index_exists(
table_name="messages",
index_name="idx_media_pending_lookup",
create_sql="CREATE INDEX idx_media_pending_lookup ON messages (message_type, image_path, timestamp, group_id)",
)
self.__class__._performance_ready = True
def _ensure_index_exists(self, table_name: str, index_name: str, create_sql: str) -> None:
"""按需补建单个索引。"""
database_name = self.db_manager.get_mysql_database_name()
if not database_name:
return
existing = self.execute_query(
"""
SELECT 1
FROM information_schema.statistics
WHERE table_schema = %s
AND table_name = %s
AND index_name = %s
LIMIT 1
""",
(database_name, table_name, index_name),
fetch_one=True,
)
if existing:
return
# 索引补建属于“性能自愈”动作:
# 1. 不要求用户手工跑 migration服务启动时可自动补齐
# 2. 若线上库字段类型和预期不一致,失败后只记日志,不阻断主流程;
# 3. 这样先拿到可观测收益,再决定后续是否做更完整的 schema migration。
if not self.execute_update(create_sql):
self.LOG.warning(f"消息表索引补建失败,请人工检查: table={table_name}, index={index_name}")
def archive_message(self, msg: WxMessage) -> bool:
"""存档消息
@@ -348,12 +252,10 @@ class MessageStorageDB(BaseDBOperator):
def get_member_messages_on_date(self, group_id: str, wxid: str, target_date: str, limit: int = 120) -> List[Dict]:
"""获取成员在某一天的消息"""
start_time, end_time = self._build_day_time_range(target_date)
sql = """
SELECT timestamp, sender, content, message_type
FROM messages
WHERE timestamp >= %s
AND timestamp < %s
WHERE DATE(timestamp) = %s
AND group_id = %s
AND sender = %s
AND message_type IN (1, 49)
@@ -362,16 +264,14 @@ class MessageStorageDB(BaseDBOperator):
ORDER BY timestamp ASC
LIMIT %s
"""
return self.execute_query(sql, (start_time, end_time, group_id, wxid, limit)) or []
return self.execute_query(sql, (target_date, group_id, wxid, limit)) or []
def get_member_messages_for_group_date(self, group_id: str, target_date: str, limit: int = 5000) -> List[Dict]:
"""获取群在某一天的全部文本消息"""
start_time, end_time = self._build_day_time_range(target_date)
sql = """
SELECT timestamp, sender, content, message_type
FROM messages
WHERE timestamp >= %s
AND timestamp < %s
WHERE DATE(timestamp) = %s
AND group_id = %s
AND sender IS NOT NULL
AND sender <> ''
@@ -381,7 +281,7 @@ class MessageStorageDB(BaseDBOperator):
ORDER BY timestamp ASC
LIMIT %s
"""
return self.execute_query(sql, (start_time, end_time, group_id, limit)) or []
return self.execute_query(sql, (target_date, group_id, limit)) or []
def get_recent_group_chat_messages(self, group_id: str, limit: int = 20) -> List[Dict]:
"""获取群聊最近消息"""
@@ -415,15 +315,13 @@ class MessageStorageDB(BaseDBOperator):
def get_message_count_by_date(self, date: str) -> List[Dict]:
"""获取指定日期的消息统计"""
start_time, end_time = self._build_day_time_range(date)
sql = """
SELECT group_id, sender, COUNT(*) as count
FROM messages
WHERE timestamp >= %s
AND timestamp < %s
WHERE DATE(timestamp) = %s
GROUP BY group_id, sender
"""
return self.execute_query(sql, (start_time, end_time)) or []
return self.execute_query(sql, (date,)) or []
def get_speech_ranking(self, date: str, group_id: str, limit: int = 20) -> List[Dict]:
"""获取指定日期和群组的发言排名"""
@@ -582,19 +480,14 @@ class MessageStorageDB(BaseDBOperator):
params.append(group_id)
if start_date:
start_bound = f"{str(start_date).strip()} 00:00:00"
sql_count += " AND timestamp >= %s "
sql_data += " AND timestamp >= %s "
params.append(start_bound)
sql_count += " AND DATE(timestamp) >= %s "
sql_data += " AND DATE(timestamp) >= %s "
params.append(start_date)
if end_date:
_, end_bound = self._build_day_bounds(
start_date or str(end_date).strip(),
str(end_date).strip(),
)
sql_count += " AND timestamp < %s "
sql_data += " AND timestamp < %s "
params.append(end_bound)
sql_count += " AND DATE(timestamp) <= %s "
sql_data += " AND DATE(timestamp) <= %s "
params.append(end_date)
if search_text:
sql_count += " AND content LIKE %s "
@@ -772,8 +665,8 @@ class MessageStorageDB(BaseDBOperator):
"""
return self.execute_query(sql, (f'%md5="{md5}"%',), fetch_one=True)
def get_messages_by_calendar_range(self, group_id: str, start_date: str, end_date: str = None,
min_content_length: int = 6, max_results: int = 5000) -> List[Dict]:
def get_messages_by_date_range(self, group_id: str, start_date: str, end_date: str = None,
min_content_length: int = 6, max_results: int = 5000) -> List[Dict]:
"""按日期范围获取消息(支持按天总结)
Args:
@@ -789,13 +682,11 @@ class MessageStorageDB(BaseDBOperator):
if end_date is None:
end_date = start_date
start_time, end_time = self._build_day_bounds(start_date, end_date)
sql = """
SELECT timestamp, sender, content, message_type
FROM messages
WHERE timestamp >= %s
AND timestamp < %s
WHERE DATE(timestamp) >= %s
AND DATE(timestamp) <= %s
AND group_id = %s
AND message_type IN (1, 49)
AND LENGTH(content) > %s
@@ -804,7 +695,7 @@ class MessageStorageDB(BaseDBOperator):
ORDER BY timestamp ASC
LIMIT %s
"""
params = (start_time, end_time, group_id, min_content_length, max_results)
params = (start_date, end_date, group_id, min_content_length, max_results)
return self.execute_query(sql, params) or []
def get_messages_for_summary(self, group_id: str, hours_ago: int = 8,
@@ -858,8 +749,8 @@ class MessageStorageDB(BaseDBOperator):
AND content NOT LIKE '/%'
ORDER BY timestamp ASC
"""
params = (self._normalize_datetime_text(start_time),
self._normalize_datetime_text(end_time),
params = (start_time.strftime('%Y-%m-%d %H:%M:%S'),
end_time.strftime('%Y-%m-%d %H:%M:%S'),
group_id)
return self.execute_query(sql, params) or []
@@ -885,8 +776,8 @@ class MessageStorageDB(BaseDBOperator):
AND CHAR_LENGTH(content) < 300
AND content NOT LIKE '/%'
"""
params = (self._normalize_datetime_text(start_time),
self._normalize_datetime_text(end_time),
params = (start_time.strftime('%Y-%m-%d %H:%M:%S'),
end_time.strftime('%Y-%m-%d %H:%M:%S'),
group_id)
result = self.execute_query(sql, params)
return result[0]['count'] if result else 0
@@ -910,8 +801,8 @@ class MessageStorageDB(BaseDBOperator):
AND sender <> ''
"""
params = (
self._normalize_datetime_text(start_time),
self._normalize_datetime_text(end_time),
start_time.strftime('%Y-%m-%d %H:%M:%S'),
end_time.strftime('%Y-%m-%d %H:%M:%S'),
group_id,
)
result = self.execute_query(sql, params, fetch_one=True) or {}

View File

@@ -216,22 +216,6 @@ class PluginScheduleDBOperator(BaseDBOperator):
) or {}
return row.get("triggered_at")
@staticmethod
def _clean_schedule_ids(schedule_ids: List[int]) -> List[int]:
"""清洗批量查询用的调度 ID 列表。"""
clean_ids: List[int] = []
seen = set()
for item in schedule_ids or []:
text = str(item or "").strip()
if not text.isdigit():
continue
schedule_id = int(text)
if schedule_id in seen:
continue
clean_ids.append(schedule_id)
seen.add(schedule_id)
return clean_ids
def get_latest_logs_map(self, schedule_ids: List[int]) -> Dict[int, Dict[str, Any]]:
"""批量获取每个调度任务最新一条执行日志。
@@ -240,7 +224,7 @@ class PluginScheduleDBOperator(BaseDBOperator):
2. 进程重启后async_job 的运行时计数会重置,但数据库日志仍完整;
3. 这里提供批量查询接口,让上层可用日志数据兜底回填展示字段。
"""
clean_ids = self._clean_schedule_ids(schedule_ids)
clean_ids = [int(x) for x in schedule_ids if str(x).strip().isdigit()]
if not clean_ids:
return {}
@@ -263,83 +247,3 @@ class PluginScheduleDBOperator(BaseDBOperator):
if schedule_id > 0:
result[schedule_id] = row
return result
def get_schedule_history_summary_map(self, schedule_ids: List[int]) -> Dict[int, Dict[str, Any]]:
"""批量汇总调度任务的历史执行摘要。"""
clean_ids = self._clean_schedule_ids(schedule_ids)
if not clean_ids:
return {}
placeholders = ",".join(["%s"] * len(clean_ids))
summary_sql = f"""
SELECT
schedule_id,
MAX(CASE WHEN status = 'success' THEN triggered_at ELSE NULL END) AS latest_success_at,
MAX(CASE WHEN status = 'failed' THEN triggered_at ELSE NULL END) AS latest_failed_at,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS fail_count,
COUNT(*) AS total_count
FROM t_plugin_schedule_logs
WHERE schedule_id IN ({placeholders})
GROUP BY schedule_id
"""
latest_failed_sql = f"""
SELECT l.*
FROM t_plugin_schedule_logs l
INNER JOIN (
SELECT schedule_id, MAX(id) AS max_id
FROM t_plugin_schedule_logs
WHERE status = 'failed' AND schedule_id IN ({placeholders})
GROUP BY schedule_id
) t ON l.id = t.max_id
"""
summary_rows = self.execute_query(summary_sql, tuple(clean_ids)) or []
latest_failed_rows = self.execute_query(latest_failed_sql, tuple(clean_ids)) or []
result: Dict[int, Dict[str, Any]] = {}
for row in summary_rows:
schedule_id = int(row.get("schedule_id") or 0)
if schedule_id <= 0:
continue
result[schedule_id] = {
"latest_success_at": row.get("latest_success_at"),
"latest_failed_at": row.get("latest_failed_at"),
"latest_failure_summary": "",
"latest_failure_detail": {},
"history_success_count": int(row.get("success_count") or 0),
"history_fail_count": int(row.get("fail_count") or 0),
"history_total_count": int(row.get("total_count") or 0),
}
for row in latest_failed_rows:
schedule_id = int(row.get("schedule_id") or 0)
if schedule_id <= 0:
continue
detail = row.get("detail_json")
if isinstance(detail, str):
try:
detail = json.loads(detail)
except json.JSONDecodeError:
detail = {}
elif detail is None:
detail = {}
history = result.setdefault(
schedule_id,
{
"latest_success_at": None,
"latest_failed_at": row.get("triggered_at"),
"latest_failure_summary": "",
"latest_failure_detail": {},
"history_success_count": 0,
"history_fail_count": 0,
"history_total_count": 0,
},
)
history["latest_failed_at"] = row.get("triggered_at")
history["latest_failure_summary"] = str(row.get("summary") or "").strip()
history["latest_failure_detail"] = detail or {}
return result

View File

@@ -52,12 +52,6 @@ create or replace index idx_date_timestamp
create or replace index idx_group_timestamp
on message_archive.messages (group_id, timestamp);
create or replace index idx_group_sender_timestamp
on message_archive.messages (group_id, sender, timestamp);
create or replace index idx_group_type_timestamp
on message_archive.messages (group_id, message_type, timestamp);
create or replace index idx_message_sender
on message_archive.messages (sender);
@@ -67,9 +61,6 @@ create or replace index idx_message_type
create or replace index messages_message_id_index
on message_archive.messages (message_id);
create or replace index idx_media_pending_lookup
on message_archive.messages (message_type, image_path, timestamp, group_id);
create or replace table message_archive.t_emoji_assets
(
md5 varchar(64) not null comment '表情MD5'

View File

@@ -171,145 +171,6 @@ class SystemJobDBOperator(BaseDBOperator):
row["detail_json"] = {}
return rows
@staticmethod
def _clean_job_keys(job_keys: List[str]) -> List[str]:
"""清洗批量查询用的任务 key 列表。
设计说明:
1. 后台列表页会一次性请求多个任务的历史摘要,必须先去掉空值和重复值;
2. 统一在 DB Operator 层做清洗,避免上层每个调用方都重复写一遍;
3. 保持输入顺序,便于后续排查时能和原始列表一一对应。
"""
clean_keys: List[str] = []
seen = set()
for item in job_keys or []:
key = str(item or "").strip()
if not key or key in seen:
continue
clean_keys.append(key)
seen.add(key)
return clean_keys
def get_latest_logs_map(self, job_keys: List[str]) -> Dict[str, Dict[str, Any]]:
"""批量读取每个任务最新一条执行日志。"""
clean_keys = self._clean_job_keys(job_keys)
if not clean_keys:
return {}
placeholders = ",".join(["%s"] * len(clean_keys))
sql = f"""
SELECT l.*
FROM t_system_job_logs l
INNER JOIN (
SELECT job_key, MAX(id) AS max_id
FROM t_system_job_logs
WHERE job_key IN ({placeholders})
GROUP BY job_key
) t ON l.id = t.max_id
"""
rows = self.execute_query(sql, tuple(clean_keys)) or []
result: Dict[str, Dict[str, Any]] = {}
for row in rows:
detail = row.get("detail_json")
if isinstance(detail, str):
try:
row["detail_json"] = json.loads(detail)
except json.JSONDecodeError:
row["detail_json"] = {}
elif detail is None:
row["detail_json"] = {}
job_key = str(row.get("job_key") or "").strip()
if job_key:
result[job_key] = row
return result
def get_job_history_summary_map(self, job_keys: List[str]) -> Dict[str, Dict[str, Any]]:
"""批量汇总系统任务的执行历史摘要。
返回字段覆盖后台最常用的问题定位视角:
1. 最近成功时间,便于判断任务是否长期没有跑通;
2. 最近失败时间与失败摘要,便于列表页直接看到异常原因;
3. 累计成功/失败/总执行次数,便于粗看任务稳定性。
"""
clean_keys = self._clean_job_keys(job_keys)
if not clean_keys:
return {}
placeholders = ",".join(["%s"] * len(clean_keys))
summary_sql = f"""
SELECT
job_key,
MAX(CASE WHEN status = 'success' THEN triggered_at ELSE NULL END) AS latest_success_at,
MAX(CASE WHEN status = 'failed' THEN triggered_at ELSE NULL END) AS latest_failed_at,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS fail_count,
COUNT(*) AS total_count
FROM t_system_job_logs
WHERE job_key IN ({placeholders})
GROUP BY job_key
"""
latest_failed_sql = f"""
SELECT l.*
FROM t_system_job_logs l
INNER JOIN (
SELECT job_key, MAX(id) AS max_id
FROM t_system_job_logs
WHERE status = 'failed' AND job_key IN ({placeholders})
GROUP BY job_key
) t ON l.id = t.max_id
"""
summary_rows = self.execute_query(summary_sql, tuple(clean_keys)) or []
latest_failed_rows = self.execute_query(latest_failed_sql, tuple(clean_keys)) or []
result: Dict[str, Dict[str, Any]] = {}
for row in summary_rows:
job_key = str(row.get("job_key") or "").strip()
if not job_key:
continue
result[job_key] = {
"latest_success_at": row.get("latest_success_at"),
"latest_failed_at": row.get("latest_failed_at"),
"latest_failure_summary": "",
"latest_failure_detail": {},
"history_success_count": int(row.get("success_count") or 0),
"history_fail_count": int(row.get("fail_count") or 0),
"history_total_count": int(row.get("total_count") or 0),
}
for row in latest_failed_rows:
job_key = str(row.get("job_key") or "").strip()
if not job_key:
continue
detail = row.get("detail_json")
if isinstance(detail, str):
try:
detail = json.loads(detail)
except json.JSONDecodeError:
detail = {}
elif detail is None:
detail = {}
history = result.setdefault(
job_key,
{
"latest_success_at": None,
"latest_failed_at": row.get("triggered_at"),
"latest_failure_summary": "",
"latest_failure_detail": {},
"history_success_count": 0,
"history_fail_count": 0,
"history_total_count": 0,
},
)
history["latest_failed_at"] = row.get("triggered_at")
history["latest_failure_summary"] = str(row.get("summary") or "").strip()
history["latest_failure_detail"] = detail or {}
return result
def get_latest_log_time(self, job_key: str) -> Optional[datetime]:
"""获取任务最新一次执行日志时间。"""
row = self.execute_query(

View File

@@ -400,15 +400,6 @@
- 把插件系统从“可加载”升级为“可治理”
当前进展:
- 第一阶段已完成:`PluginManager` 已输出统一插件治理快照,后台不再只展示“加载成功的插件”
- 第一阶段已完成后台插件管理页已补充治理健康、能力类型、Feature Key、依赖与配置概览信息
- 第一阶段已完成:插件配置保存前已增加格式校验,避免坏配置直接写回线上文件
- 第二阶段已完成:插件管理页已补充执行表现摘要、最近错误信息与高风险/慢插件排行,便于快速定位运行异常插件
- 第二阶段已完成:插件快照已补充依赖拓扑摘要,后台可直接查看核心依赖插件、缺失依赖风险与上下游关系
- 后续可继续补充插件错误历史、性能排名、依赖图与熔断/隔离控制
建议内容:
- 插件元信息页面
@@ -435,13 +426,6 @@
- 防止单插件问题拖垮整体系统
当前进展:
- 第一阶段已完成:消息插件执行已增加统一超时保护,避免单插件长时间卡住主链路
- 第一阶段已完成:已补充连续失败熔断、冷却后半开探测与自动恢复逻辑
- 第一阶段已完成:插件治理快照与后台详情已可查看执行保护状态、连续失败与恢复剩余时间
- 后续可继续补充插件级并发配额、失败原因聚合、后台手动解除熔断与更细粒度的隔离策略
建议内容:
- 插件处理超时控制
@@ -465,13 +449,6 @@
- 让定时任务真正可管理、可追踪
当前进展:
- 第一阶段已完成:系统任务页与插件调度页已补充历史执行摘要,可直接查看最近成功时间、最近失败原因与累计成功/失败次数
- 第一阶段已完成:任务列表接口已合并内存运行态与数据库日志态,服务重启后后台仍可回看最近执行结果
- 第一阶段已完成:插件调度页已补充快捷启停入口,减少仅为切换启用状态而进入编辑弹窗的操作成本
- 后续可继续补充任务执行审计人、失败重试策略模板、筛选搜索与跨任务汇总看板
建议内容:
- 展示任务执行历史
@@ -499,14 +476,6 @@
- 提高高消息量场景下的吞吐与查询效率
当前进展:
- 第一阶段已完成:数据库公共层已增加慢 SQL 记录能力,可按 `db_config.slow_query_threshold_ms` 阈值输出慢查询日志
- 第一阶段已完成:消息存储层启动时会自动补齐关键查询索引,优先覆盖群消息范围查询、成员消息回溯与待处理媒体扫描场景
- 第一阶段已完成:多处按日期查询已改为时间范围查询,避免 `DATE(timestamp)` 直接作用在索引列上导致索引失效
- 第一阶段已完成:已修正消息存储层重复定义的日期范围方法,避免按天汇总查询误走错误实现
- 后续可继续补充统计报表快照表、Redis key 扫描替换方案、后台慢 SQL 看板与更多统计表索引治理
建议内容:
- 梳理消息表与统计表索引
@@ -587,13 +556,6 @@
- 降低普通用户与管理员的使用门槛
当前进展:
- 第一阶段已完成:`菜单 指令清单 / 功能清单 / 命令清单 / 帮助` 已改为基于运行中插件快照自动生成
- 第一阶段已完成:指令清单已按当前群真实可用状态过滤,管理员可额外看到未启用命令与管理命令
- 第二阶段已完成:后台已新增“命令索引”页面,可按群查看真实可用命令、未启用命令、自动能力与管理员触发示例
- 后续可继续补充插件触发示例模板、命令分类标签与更细粒度的使用说明
建议内容:
- 自动生成按插件分类的帮助菜单

View File

@@ -20,7 +20,7 @@ class RobotMenuPlugin(MessagePluginInterface):
# 功能权限常量
FEATURE_KEY = "ROBOT_MENU"
FEATURE_DESCRIPTION = "📋 功能菜单 [菜单 | 菜单 状态 | 菜单 指令清单]"
FEATURE_DESCRIPTION = "📋 功能菜单 [菜单 - 显示功能菜单 | 菜单 状态 - 显示功能状态]"
@property
def name(self) -> str:
@@ -263,31 +263,6 @@ class RobotMenuPlugin(MessagePluginInterface):
)
return True, "显示功能状态"
if cmd_name in {"指令清单", "功能清单", "命令清单", "帮助"}:
# 指令清单改为直接从插件快照自动生成:
# 1. 展示当前群“真实可用”的命令,而不是手工维护的固定文案;
# 2. 管理员额外看到未启用项与管理命令,普通用户只看到能直接用的内容;
# 3. 这样后续新增/删除插件后,菜单无需手动同步修改。
command_catalog_text = self.menu_renderer.build_command_catalog_text(
roomid if roomid else sender,
sender,
)
command_catalog_markdown = self.menu_renderer.build_command_catalog_markdown(
roomid if roomid else sender,
sender,
)
await self.menu_renderer.send_menu_content(
bot=bot,
target=target,
sender=sender,
revoke=revoke,
text_content=command_catalog_text,
markdown_content=command_catalog_markdown,
html_content="",
revoke_seconds=120,
)
return True, "显示指令清单"
# 处理群列表命令
if cmd_name.upper() == "群列表":
group_list_text = self.get_group_list()

View File

@@ -7,7 +7,6 @@ from typing import Any, Optional, Tuple
from loguru import logger as default_logger
from base.plugin_common.plugin_manager import PluginManager
from utils.markdown_to_image import convert_md_str_to_image, html_to_image
from utils.revoke.message_auto_revoke import MessageAutoRevoke
from utils.robot_cmd.robot_command import Feature, GroupBotManager, PermissionStatus
@@ -190,283 +189,6 @@ class RobotMenuRenderTool:
},
)
@staticmethod
def _get_plugin_manager() -> PluginManager:
"""获取当前运行中的插件管理器单例。"""
return PluginManager.get_instance()
@staticmethod
def _resolve_snapshot_group_status(snapshot: dict, group_id: str) -> dict:
"""解析插件在当前群里的可用状态。
规则说明:
1. 插件必须先处于 RUNNING才可能被认为“可用”
2. 若插件支持群级开关,则继续读取该群的 feature 权限;
3. 若插件没有群级开关,则视为“运行即全局可用”。
"""
normalized_snapshot = dict(snapshot or {})
status = str(normalized_snapshot.get("status") or "").strip().upper()
supports_group_switch = bool(normalized_snapshot.get("supports_group_switch"))
feature_key = str(normalized_snapshot.get("feature_key") or "").strip()
if status != "RUNNING":
return {
"available": False,
"reason": "插件未运行",
"reason_code": "plugin_not_running",
}
if not group_id or not supports_group_switch or not feature_key:
return {
"available": True,
"reason": "全局可用",
"reason_code": "global_available",
}
feature = Feature.get_feature(feature_key)
if feature is None:
return {
"available": True,
"reason": "未绑定群级开关,按运行中处理",
"reason_code": "feature_not_registered",
}
permission = GroupBotManager.get_group_permission(group_id, feature)
if permission == PermissionStatus.ENABLED:
return {
"available": True,
"reason": "本群已启用",
"reason_code": "group_enabled",
}
return {
"available": False,
"reason": "本群未启用",
"reason_code": "group_disabled",
}
@staticmethod
def _format_plugin_command(example_command: str, command_prefix: str) -> str:
"""把插件命令和前缀拼成最终展示文本。"""
prefix = str(command_prefix or "").strip()
command = str(example_command or "").strip()
if not prefix:
return command
return f"{prefix}{command}"
def _build_plugin_command_entry(self, snapshot: dict, group_id: str) -> Optional[dict]:
"""把插件快照转换为菜单可展示的命令项。"""
normalized_snapshot = dict(snapshot or {})
commands = list(normalized_snapshot.get("commands", []) or [])
plugin_types = list(normalized_snapshot.get("plugin_types", []) or [])
if not commands and "scheduled" not in plugin_types:
return None
availability = self._resolve_snapshot_group_status(normalized_snapshot, group_id)
command_prefix = str(normalized_snapshot.get("command_prefix") or "").strip()
primary_command = self._format_plugin_command(commands[0], command_prefix) if commands else ""
alias_commands = [
self._format_plugin_command(command_text, command_prefix)
for command_text in commands[1:4]
if str(command_text or "").strip()
]
if "message" in plugin_types:
category = "message"
category_label = "消息指令"
elif "scheduled" in plugin_types:
category = "scheduled"
category_label = "自动任务"
else:
category = "generic"
category_label = "通用能力"
return {
"name": str(normalized_snapshot.get("name") or "").strip(),
"module_name": str(normalized_snapshot.get("module_name") or "").strip(),
"description": str(normalized_snapshot.get("description") or "").strip() or "暂无描述",
"category": category,
"category_label": category_label,
"commands": commands,
"primary_command": primary_command,
"alias_commands": alias_commands,
"supports_group_switch": bool(normalized_snapshot.get("supports_group_switch")),
"feature_key": str(normalized_snapshot.get("feature_key") or "").strip(),
"available": bool(availability.get("available")),
"availability_reason": str(availability.get("reason") or "").strip(),
"availability_code": str(availability.get("reason_code") or "").strip(),
"status_label": str(normalized_snapshot.get("status_label") or "").strip(),
}
def _collect_command_catalog(self, group_id: str, requester_id: str, force_admin: Optional[bool] = None) -> dict:
"""采集当前群和当前身份视角下的命令清单。
输出结构分三层:
1. 普通用户可直接用的命令;
2. 自动/定时能力;
3. 管理员附加能力与未启用项。
"""
plugin_manager = self._get_plugin_manager()
snapshots = plugin_manager.get_plugin_snapshots()
if force_admin is None:
is_admin = bool(GroupBotManager.is_admin_for_group(requester_id, group_id)) if group_id else bool(GroupBotManager.is_admin(requester_id))
else:
is_admin = bool(force_admin)
available_manual = []
available_auto = []
unavailable_manual = []
for snapshot in snapshots:
entry = self._build_plugin_command_entry(snapshot, group_id)
if not entry:
continue
if entry["category"] == "scheduled":
if entry["available"]:
available_auto.append(entry)
continue
if entry["available"]:
available_manual.append(entry)
else:
unavailable_manual.append(entry)
available_manual.sort(key=lambda item: (item["category"], item["name"], item["primary_command"]))
available_auto.sort(key=lambda item: (item["name"], item["primary_command"]))
unavailable_manual.sort(key=lambda item: (item["availability_code"], item["name"]))
admin_commands = []
if is_admin:
admin_commands = [
{"title": "查看功能状态", "example": "菜单 状态", "description": "查看当前群所有功能开关状态"},
{"title": "启用某个功能", "example": "菜单 启用 功能序号", "description": "按菜单序号启用某项功能"},
{"title": "关闭某个功能", "example": "菜单 关闭 功能序号", "description": "按菜单序号关闭某项功能"},
{"title": "查看群管理员", "example": "菜单 管理员 列表", "description": "查看当前群管理员清单"},
{"title": "添加群管理员", "example": "菜单 管理员 添加 @某人", "description": "把某个群成员加入本群管理员"},
{"title": "删除群管理员", "example": "菜单 管理员 删除 @某人", "description": "移除某个群管理员"},
]
if GroupBotManager.is_admin(requester_id):
admin_commands.append(
{"title": "查看托管群列表", "example": "菜单 群列表", "description": "查看所有已启用机器人的群"}
)
return {
"group_id": str(group_id or "").strip(),
"requester_id": str(requester_id or "").strip(),
"is_admin": is_admin,
"available_manual": available_manual,
"available_auto": available_auto,
"unavailable_manual": unavailable_manual,
"admin_commands": admin_commands,
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
}
def build_command_catalog_data(self, group_id: str, requester_id: str, force_admin: Optional[bool] = None) -> dict:
"""对外暴露统一的命令目录结构,供机器人菜单和后台页面共同复用。"""
return self._collect_command_catalog(group_id, requester_id, force_admin=force_admin)
def build_command_catalog_text(self, group_id: str, requester_id: str) -> str:
"""构建适合直接发送给用户的文本版命令清单。"""
catalog = self.build_command_catalog_data(group_id, requester_id)
lines = [
"📚 当前群指令清单",
f"群ID{catalog['group_id'] or '私聊'}",
f"生成时间:{catalog['generated_at']}",
"",
"一、当前可直接使用的命令",
]
if catalog["available_manual"]:
for item in catalog["available_manual"]:
lines.append(f"{item['name']}{item['description']}")
if item["primary_command"]:
lines.append(f"主指令:{item['primary_command']}")
if item["alias_commands"]:
lines.append(f"别名:{' / '.join(item['alias_commands'])}")
lines.append("")
else:
lines.append("当前没有可直接使用的手动命令")
lines.append("")
lines.append("二、自动/定时能力")
if catalog["available_auto"]:
for item in catalog["available_auto"]:
lines.append(f"{item['name']}{item['description']}")
lines.append("触发方式:自动或定时运行")
lines.append("")
else:
lines.append("当前没有已启用的自动能力")
lines.append("")
if catalog["is_admin"]:
lines.append("三、管理员额外可见")
if catalog["unavailable_manual"]:
lines.append("未启用或暂不可用命令:")
for item in catalog["unavailable_manual"]:
primary = item["primary_command"] or "无手动指令"
lines.append(f"- {item['name']}{primary}{item['availability_reason']}")
lines.append("")
else:
lines.append("当前没有未启用的命令项")
lines.append("")
lines.append("管理命令:")
for item in catalog["admin_commands"]:
lines.append(f"- {item['example']}{item['description']}")
lines.append("")
lines.append("提示:发送“菜单”查看功能开关;发送“菜单 状态”查看本群功能状态。")
return "\n".join(lines).strip()
def build_command_catalog_markdown(self, group_id: str, requester_id: str) -> str:
"""构建适合图片渲染的 Markdown 版指令清单。"""
catalog = self.build_command_catalog_data(group_id, requester_id)
lines = [
"# 机器人指令清单",
"",
f"- 目标:`{catalog['group_id'] or '私聊'}`",
f"- 生成时间:`{catalog['generated_at']}`",
"",
"## 当前可直接使用的命令",
]
if catalog["available_manual"]:
for item in catalog["available_manual"]:
lines.append(f"### {item['name']}")
lines.append(f"- 说明:{item['description']}")
if item["primary_command"]:
lines.append(f"- 主指令:`{item['primary_command']}`")
if item["alias_commands"]:
alias_text = " / ".join(f"`{alias}`" for alias in item["alias_commands"])
lines.append(f"- 别名:{alias_text}")
lines.append("")
else:
lines.append("- 当前没有可直接使用的手动命令")
lines.append("")
lines.append("## 自动/定时能力")
if catalog["available_auto"]:
for item in catalog["available_auto"]:
lines.append(f"- **{item['name']}**{item['description']}")
else:
lines.append("- 当前没有已启用的自动能力")
lines.append("")
if catalog["is_admin"]:
lines.append("## 管理员额外可见")
if catalog["unavailable_manual"]:
lines.append("### 未启用或暂不可用命令")
for item in catalog["unavailable_manual"]:
primary = item["primary_command"] or "无手动指令"
lines.append(f"- **{item['name']}**`{primary}`{item['availability_reason']}")
lines.append("")
lines.append("### 管理命令")
for item in catalog["admin_commands"]:
lines.append(f"- `{item['example']}`{item['description']}")
lines.append("")
lines.append("> 提示:发送 `菜单` 查看功能开关;发送 `菜单 状态` 查看本群功能状态。")
return "\n".join(lines)
async def send_menu_content(
self,
bot: WechatAPIClient,

138
robot.py
View File

@@ -649,33 +649,7 @@ class Robot:
# 检查插件是否可以处理该消息
if plugin.can_process(plugin_msg):
protection_policy = self._build_message_plugin_protection_policy(plugin)
acquire_result = self.plugin_manager.try_acquire_plugin_execution(
plugin,
recovery_seconds=protection_policy["circuit_recovery_seconds"],
)
if not acquire_result.get("allowed", False):
# 熔断打开或半开探测占用时,这里只跳过当前插件:
# 1. 保护目标是避免单插件持续拖慢主链路,而不是直接关闭整个插件;
# 2. 后续插件仍然可以继续尝试处理当前消息,降低功能面损失;
# 3. 冷却结束后会自动进入半开恢复探测,无需人工介入恢复。
self.LOG.warning(
self._trace_message(
msg,
f"插件保护跳过 plugin={plugin.name} reason={acquire_result.get('reason')} "
f"remaining={acquire_result.get('open_remaining_seconds', 0)}s"
)
)
continue
processed, _ = await asyncio.wait_for(
plugin.process_message(plugin_msg),
timeout=protection_policy["process_timeout_seconds"],
)
self.plugin_manager.record_plugin_execution_success(
plugin,
process_time_ms=self._elapsed_ms(started_at),
)
processed, _ = await plugin.process_message(plugin_msg)
self._record_plugin_call_result(
plugin=plugin,
msg=msg,
@@ -696,58 +670,14 @@ class Robot:
)
)
return True
except asyncio.TimeoutError as timeout_error:
protection_policy = self._build_message_plugin_protection_policy(plugin)
failure_record = self.plugin_manager.record_plugin_execution_failure(
plugin,
failure_type="timeout",
error_message=(
f"插件执行超时,超过 {protection_policy['process_timeout_seconds']} 秒仍未完成。"
),
process_time_ms=self._elapsed_ms(started_at),
timeout_seconds=protection_policy["process_timeout_seconds"],
failure_threshold=protection_policy["failure_threshold"],
recovery_seconds=protection_policy["circuit_recovery_seconds"],
)
self._record_plugin_call_error(
plugin=plugin,
msg=msg,
command_name=command_name,
error=timeout_error,
)
self.LOG.error(
self._trace_message(
msg,
f"插件 {plugin.name} 执行超时timeout={protection_policy['process_timeout_seconds']}s "
f"circuit_state={failure_record.get('circuit_state')} "
f"consecutive_failures={failure_record.get('consecutive_failures')}"
)
)
except Exception as e:
protection_policy = self._build_message_plugin_protection_policy(plugin)
failure_record = self.plugin_manager.record_plugin_execution_failure(
plugin,
failure_type="error",
error_message=str(e),
process_time_ms=self._elapsed_ms(started_at),
timeout_seconds=0,
failure_threshold=protection_policy["failure_threshold"],
recovery_seconds=protection_policy["circuit_recovery_seconds"],
)
self._record_plugin_call_error(
plugin=plugin,
msg=msg,
command_name=command_name,
error=e,
)
self.LOG.error(
self._trace_message(
msg,
f"插件 {plugin.name} 处理消息失败: {e} "
f"circuit_state={failure_record.get('circuit_state')} "
f"consecutive_failures={failure_record.get('consecutive_failures')}"
)
)
self.LOG.error(self._trace_message(msg, f"插件 {plugin.name} 处理消息失败: {e}"))
return False
@@ -796,70 +726,6 @@ class Robot:
msg_type = getattr(getattr(msg, "msg_type", None), "name", "")
return f"[{msg_type or 'UNKNOWN'}]"
@staticmethod
def _safe_positive_int(value, default: int) -> int:
"""把配置中的数字安全转成正整数。"""
try:
parsed = int(value)
return parsed if parsed > 0 else default
except (TypeError, ValueError):
return default
def _build_message_plugin_protection_policy(self, plugin) -> dict:
"""构建消息插件执行保护策略。"""
plugin_config = getattr(plugin, "_config", {}) or {}
runtime_config = plugin_config.get("runtime", {}) if isinstance(plugin_config, dict) else {}
runtime_config = runtime_config if isinstance(runtime_config, dict) else {}
breaker_config = runtime_config.get("circuit_breaker", {}) if isinstance(runtime_config, dict) else {}
breaker_config = breaker_config if isinstance(breaker_config, dict) else {}
# 超时策略尽量遵循“显式配置优先,已有内部超时参数兜底”的思路:
# 1. 新插件如果有特殊需求,只需要在 runtime / circuit_breaker 下声明自己的超时;
# 2. 老插件不改代码也能自动复用现有的 request / llm / render 超时字段;
# 3. 最终统一加一个缓冲区,避免外层 wait_for 比插件内部自己的超时还更早打断。
explicit_timeout = (
runtime_config.get("plugin_process_timeout_seconds")
or runtime_config.get("message_timeout_seconds")
or breaker_config.get("timeout_seconds")
or getattr(plugin, "plugin_process_timeout_seconds", 0)
)
timeout_candidates = []
for attr_name in [
"llm_call_timeout_sec",
"_request_timeout_seconds",
"default_timeout",
"_image_render_timeout_seconds",
"image_render_timeout_seconds",
"_receive_timeout",
"_connect_timeout_seconds",
"_connect_timeout",
]:
attr_value = getattr(plugin, attr_name, 0)
if isinstance(attr_value, (int, float)) and attr_value > 0:
timeout_candidates.append(int(attr_value))
if explicit_timeout:
resolved_timeout = self._safe_positive_int(explicit_timeout, 30)
elif timeout_candidates:
resolved_timeout = max(timeout_candidates) + 10
else:
resolved_timeout = 30
failure_threshold = self._safe_positive_int(
breaker_config.get("failure_threshold") or runtime_config.get("circuit_breaker_failure_threshold") or 3,
3,
)
circuit_recovery_seconds = self._safe_positive_int(
breaker_config.get("recovery_seconds") or runtime_config.get("circuit_breaker_recovery_seconds") or 180,
180,
)
return {
"process_timeout_seconds": max(10, min(int(resolved_timeout), 180)),
"failure_threshold": max(2, min(int(failure_threshold), 10)),
"circuit_recovery_seconds": max(30, min(int(circuit_recovery_seconds), 900)),
}
def _get_stats_collector_plugin(self):
"""获取运行中的统计收集插件实例。"""
# 统计插件已经从“事件订阅”切到“主链路直接回调”,

View File

@@ -209,47 +209,6 @@ class PluginScheduleManager:
return False
return latest_log_at < (expected_at - timedelta(seconds=self._compensation_tolerance_seconds))
@staticmethod
def _build_schedule_health_status(
*,
enabled: bool,
running: bool,
last_status: str,
latest_success_at,
latest_failure_summary: str,
) -> str:
"""根据调度任务运行态和历史态生成后台健康标签。"""
if not enabled:
return "disabled"
if running:
return "running"
# 只有“最近一次执行仍是失败”时才把健康态打成 failed
# 避免历史上曾失败过、但后面已经恢复成功的任务一直显示异常。
if str(last_status or "").strip().lower() == "failed":
return "failed"
if latest_success_at or str(last_status or "").strip().lower() == "success":
return "healthy"
if str(latest_failure_summary or "").strip():
return "degraded"
return "idle"
@staticmethod
def _build_schedule_health_message(*, health_status: str, latest_success_at, latest_failure_summary: str) -> str:
"""生成调度任务列表里展示的简短健康说明。"""
if health_status == "disabled":
return "任务已停用"
if health_status == "running":
return "任务正在执行中"
if health_status in ("failed", "degraded"):
return str(latest_failure_summary or "最近存在失败记录").strip()
if health_status == "healthy":
if isinstance(latest_success_at, datetime):
return f"最近成功于 {latest_success_at.strftime('%Y-%m-%d %H:%M:%S')}"
if latest_success_at:
return f"最近成功于 {latest_success_at}"
return "任务近期执行正常"
return "暂无执行记录"
async def _run_one_schedule(self, schedule_row: Dict[str, Any]) -> Dict[str, Any]:
schedule_id = int(schedule_row["id"])
action_key = schedule_row.get("action_key")
@@ -338,7 +297,6 @@ class PluginScheduleManager:
# 日志兜底:进程重启后内存态 last_run_at 会丢失,任务页需要从数据库最新日志恢复显示。
schedule_ids = [int(row.get("id")) for row in db_rows if row.get("id") is not None]
latest_log_by_schedule = self.db.get_latest_logs_map(schedule_ids)
history_summary_by_schedule = self.db.get_schedule_history_summary_map(schedule_ids)
data = []
for row in db_rows:
@@ -346,7 +304,6 @@ class PluginScheduleManager:
key = f"plugin_schedule:{schedule_id}"
runtime = runtime_by_key.get(key, {})
latest_log = latest_log_by_schedule.get(schedule_id) or {}
history_summary = history_summary_by_schedule.get(schedule_id) or {}
merged = dict(row)
merged["runtime_job_id"] = runtime.get("id")
merged["running"] = runtime.get("running", False)
@@ -362,24 +319,6 @@ class PluginScheduleManager:
merged["run_count"] = runtime.get("run_count", 0)
merged["success_count"] = runtime.get("success_count", 0)
merged["fail_count"] = runtime.get("fail_count", 0)
merged["latest_success_at"] = history_summary.get("latest_success_at")
merged["latest_failed_at"] = history_summary.get("latest_failed_at")
merged["latest_failure_summary"] = str(history_summary.get("latest_failure_summary") or "").strip()
merged["history_success_count"] = int(history_summary.get("history_success_count", 0) or 0)
merged["history_fail_count"] = int(history_summary.get("history_fail_count", 0) or 0)
merged["history_total_count"] = int(history_summary.get("history_total_count", 0) or 0)
merged["health_status"] = self._build_schedule_health_status(
enabled=bool(row.get("enabled", 0)),
running=bool(runtime.get("running", False)),
last_status=str(merged.get("last_status") or ""),
latest_success_at=history_summary.get("latest_success_at"),
latest_failure_summary=str(history_summary.get("latest_failure_summary") or ""),
)
merged["health_message"] = self._build_schedule_health_message(
health_status=merged["health_status"],
latest_success_at=history_summary.get("latest_success_at"),
latest_failure_summary=str(history_summary.get("latest_failure_summary") or ""),
)
data.append(merged)
return data

View File

@@ -883,7 +883,7 @@ class MessageStorage:
end_date = current_time.strftime('%Y-%m-%d')
# 使用新的按日期查询方法
messages = self.message_db.get_messages_by_calendar_range(
messages = self.message_db.get_messages_by_date_range(
group_id,
start_date=start_date,
end_date=end_date,