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 admin.dashboard.blueprints.auth import login_required
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
from plugins.robot_menu.menu_render_tool import RobotMenuRenderTool
# 创建蓝图 # 创建蓝图
plugin_routes = Blueprint('plugin_routes', __name__) plugin_routes = Blueprint('plugin_routes', __name__)
LOG = logger 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') @plugin_routes.route('/plugins_manage')
@@ -51,61 +20,40 @@ def robot_management():
return render_template('plugins_manage.html') 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']) @plugin_routes.route('/api/plugins', methods=['GET'])
@login_required @login_required
def get_plugins(): def get_plugins():
"""获取所有插件列表""" """获取所有插件列表"""
try: try:
server = current_app.dashboard_server server = current_app.dashboard_server
# 统一改为消费 PluginManager 的标准治理快照: # 获取插件注册表
# 1. 这样既能覆盖“已加载插件”,也能覆盖“发现但加载失败/配置禁用”的模块; plugins = server.plugin_registry.get_all_plugins()
# 2. 后台不必重复拼装版本、命令、依赖、配置健康等字段;
# 3. 后续继续补错误统计、性能排名时,也只需要在快照层扩展。 # 转换为前端需要的格式
plugin_list = server.plugin_manager.get_plugin_snapshots() 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}) return jsonify({"success": True, "data": plugin_list})
except Exception as e: except Exception as e:
LOG.error(f"获取插件列表失败: {str(e)}", exc_info=True) LOG.error(f"获取插件列表失败: {str(e)}", exc_info=True)
return jsonify({"success": False, "message": f"获取插件列表失败: {str(e)}"}) 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']) @plugin_routes.route('/api/plugins/group_status', methods=['GET'])
@login_required @login_required
def get_plugin_group_status(): def get_plugin_group_status():
@@ -245,10 +193,31 @@ def get_plugin_info():
if not plugin_name: if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"}) 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}"}) 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}) return jsonify({"success": True, "data": plugin_info})
except Exception as e: except Exception as e:
LOG.error(f"获取插件详情失败: {str(e)}", exc_info=True) LOG.error(f"获取插件详情失败: {str(e)}", exc_info=True)
@@ -266,14 +235,9 @@ def enable_plugin():
if not plugin_name: if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"}) return jsonify({"success": False, "message": "缺少插件名称参数"})
# 已加载插件直接启动;尚未加载的插件则先尝试加载,再进入启动流程。 # 获取插件管理器
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name) # 启用插件
if not plugin: if server.plugin_manager.start_plugin(plugin_name):
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):
return jsonify({"success": True, "message": f"插件 {plugin_name} 启用成功"}) return jsonify({"success": True, "message": f"插件 {plugin_name} 启用成功"})
else: else:
return jsonify({"success": False, "message": f"插件 {plugin_name} 启用失败"}) return jsonify({"success": False, "message": f"插件 {plugin_name} 启用失败"})
@@ -314,14 +278,8 @@ def reload_plugin():
if not plugin_name: if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"}) return jsonify({"success": False, "message": "缺少插件名称参数"})
# 已加载插件优先走重载;若当前未加载,则退化为“重新尝试加载并启动”。 # 载插件
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name) reloaded_plugin = server.plugin_manager.reload_plugin(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)
if reloaded_plugin: if reloaded_plugin:
return jsonify({"success": True, "message": f"插件 {plugin_name} 重载成功"}) return jsonify({"success": True, "message": f"插件 {plugin_name} 重载成功"})
@@ -342,11 +300,16 @@ def get_raw_plugin_config():
if not plugin_name: if not plugin_name:
return jsonify({"success": False, "message": "缺少插件名称参数"}) 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}"}) 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): if not os.path.exists(config_path):
return jsonify({"success": False, "message": f"配置文件不存在: {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: if not plugin_name or config_text is None:
return jsonify({"success": False, "message": "缺少必要参数"}) return jsonify({"success": False, "message": "缺少必要参数"})
# 查找插件
# 获取插件管理器
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name) 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}"}) return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
config_path = str(plugin_snapshot.get("config_path", "") or "").strip() # 获取配置文件路径
if not config_path: config_path = plugin.get_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)}"})
# 确保配置目录存在 # 确保配置目录存在
os.makedirs(os.path.dirname(config_path), exist_ok=True) 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: with open(config_path, 'w', encoding='utf-8') as f:
f.write(config_text) 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 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: except Exception as e:
LOG.error(f"更新插件配置失败: {str(e)}", exc_info=True) 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() data = server.plugin_schedule_manager.list_schedules_with_runtime()
# 后端统一格式化时间字段,避免前端出现 Fri, 17 Apr 2026 ... 这类 RFC 时间串。 # 后端统一格式化时间字段,避免前端出现 Fri, 17 Apr 2026 ... 这类 RFC 时间串。
for row in data: 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: if key in row:
row[key] = _normalize_datetime_text(row.get(key)) row[key] = _normalize_datetime_text(row.get(key))
return jsonify({"success": True, "data": data}) return jsonify({"success": True, "data": data})

View File

@@ -21,40 +21,6 @@ def _normalize_datetime_text(value):
return text 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("/") @system_jobs_bp.route("/")
@login_required @login_required
def page_system_jobs(): def page_system_jobs():
@@ -68,31 +34,11 @@ def api_list_jobs():
db_rows = server.system_job_db.list_jobs() db_rows = server.system_job_db.list_jobs()
runtime_rows = async_job.get_jobs_snapshot() 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")} 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 = [] result = []
for row in db_rows: for row in db_rows:
job_key = row.get("job_key") job_key = row.get("job_key")
runtime = runtime_by_key.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( result.append(
{ {
"job_key": job_key, "job_key": job_key,
@@ -105,26 +51,14 @@ def api_list_jobs():
"runtime_enabled": runtime.get("enabled"), "runtime_enabled": runtime.get("enabled"),
"running": runtime.get("running", False), "running": runtime.get("running", False),
"trigger_text": runtime.get("trigger_text", ""), "trigger_text": runtime.get("trigger_text", ""),
"last_run_at": _normalize_datetime_text(last_run_at), "last_run_at": _normalize_datetime_text(runtime.get("last_run_at")),
"last_status": last_status, "last_status": runtime.get("last_status"),
"last_error": last_error, "last_error": runtime.get("last_error"),
"last_duration_ms": runtime.get("last_duration_ms") or latest_log.get("duration_ms"), "last_duration_ms": runtime.get("last_duration_ms"),
"next_run_at": _normalize_datetime_text(runtime.get("next_run_at")), "next_run_at": _normalize_datetime_text(runtime.get("next_run_at")),
"run_count": runtime.get("run_count", 0), "run_count": runtime.get("run_count", 0),
"success_count": runtime.get("success_count", 0), "success_count": runtime.get("success_count", 0),
"fail_count": runtime.get("fail_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: [ items: [
{ label: '插件统计', path: '/plugins' }, { label: '插件统计', path: '/plugins' },
{ label: '插件管理', path: '/plugins_manage' }, { label: '插件管理', path: '/plugins_manage' },
{ label: '命令索引', path: '/command_catalog' },
{ label: '插件定时任务', path: '/plugin_schedules' }, { label: '插件定时任务', path: '/plugin_schedules' },
{ label: '群级插件配置', path: '/group_plugin_config' }, { label: '群级插件配置', path: '/group_plugin_config' },
{ label: '响应指令管理', path: '/fun_command_rules' }, { label: '响应指令管理', path: '/fun_command_rules' },
@@ -1153,7 +1152,6 @@
'12': '/virtual_group', '12': '/virtual_group',
'13': '/api_docs', '13': '/api_docs',
'14': '/system_status', '14': '/system_status',
'18': '/command_catalog',
'17': '/system_llm', '17': '/system_llm',
'15': '/file_browser', '15': '/file_browser',
'16': '/message_push' '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 class="section-heading section-heading--stack">
<div> <div>
<h3>系统健康快照</h3> <h3>系统健康快照</h3>
<p>把连接状态、插件运行、异常数量、LLM 运行态与任务调度集中到一个面板里。</p> <p>把连接状态、插件运行、异常数量与转图运行时集中到一个面板里。</p>
</div> </div>
<div class="health-overview-meta"> <div class="health-overview-meta">
<span class="health-overview-meta__label">最近刷新</span> <span class="health-overview-meta__label">最近刷新</span>
@@ -148,29 +148,6 @@
</div> </div>
<div class="health-item__value">{% raw %}{{ card.value }}{% endraw %}</div> <div class="health-item__value">{% raw %}{{ card.value }}{% endraw %}</div>
<div class="health-item__summary">{% raw %}{{ card.summary }}{% 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 v-if="card.extra" class="health-item__extra">{% raw %}{{ card.extra }}{% endraw %}</div>
</div> </div>
</div> </div>
@@ -394,38 +371,15 @@
status: 'warning', status: 'warning',
total_calls: 0, total_calls: 0,
failed_calls: 0, failed_calls: 0,
success_rate: 0,
avg_latency_ms: 0, avg_latency_ms: 0,
summary: '加载中...', summary: '加载中...',
last_call: {}, last_call: {}
scene_count: 0,
target_count: 0,
provider_count: 0,
has_routing: false,
default_scene: '',
default_backend: '',
last_provider: '',
last_backend: '',
last_scene: '',
last_model: '',
last_timestamp: '',
last_latency_ms: 0,
last_error: ''
}, },
scheduler: { md2img: {
status: 'warning', status: 'warning',
total_jobs: 0, healthy: false,
enabled_jobs: 0, runtime_ready: false,
running_jobs: 0, browser_ready: false,
failed_jobs: 0,
invalid_jobs: 0,
paused_jobs: 0,
never_run_jobs: 0,
system_job_count: 0,
plugin_job_count: 0,
next_run_at: '',
latest_failed_job_name: '',
latest_failed_error: '',
summary: '加载中...' summary: '加载中...'
} }
}, },
@@ -469,7 +423,7 @@
const errors = this.healthSummary.errors || {}; const errors = this.healthSummary.errors || {};
const infrastructure = this.healthSummary.infrastructure || {}; const infrastructure = this.healthSummary.infrastructure || {};
const aiRuntime = this.healthSummary.ai_runtime || {}; const aiRuntime = this.healthSummary.ai_runtime || {};
const scheduler = this.healthSummary.scheduler || {}; const md2img = this.healthSummary.md2img || {};
return [ return [
{ {
key: 'robot', key: 'robot',
@@ -499,30 +453,25 @@
key: 'infrastructure', key: 'infrastructure',
title: '基础设施', title: '基础设施',
status: infrastructure.status || 'warning', status: infrastructure.status || 'warning',
value: `${this.countHealthyInfrastructureServices(infrastructure)} / 2`, value: infrastructure.status === 'healthy' ? '正常' : '异常',
summary: infrastructure.summary || '暂无状态', summary: infrastructure.summary || '暂无状态',
serviceBlocks: this.buildInfrastructureServiceBlocks(infrastructure), extra: `MySQL${((infrastructure.mysql || {}).status === 'healthy') ? '正常' : '异常'} / Redis${((infrastructure.redis || {}).status === 'healthy') ? '正常' : '异常'}`
extra: '首页展示的是服务摘要;如果后续要做更深入的运维排查,再单独拆详细页会更合适。'
}, },
{ {
key: 'ai_runtime', key: 'ai_runtime',
title: 'LLM 运行态', title: 'AI 运行态',
status: aiRuntime.status || 'warning', status: aiRuntime.status || 'warning',
value: (aiRuntime.total_calls || 0) > 0 value: `${aiRuntime.avg_latency_ms || 0} ms`,
? `${this.formatMetricNumber(aiRuntime.success_rate, 2)}%`
: `${aiRuntime.scene_count || 0} 个场景`,
summary: aiRuntime.summary || '暂无状态', summary: aiRuntime.summary || '暂无状态',
serviceBlocks: this.buildAiRuntimeServiceBlocks(aiRuntime), extra: `最近调用 ${aiRuntime.total_calls || 0} 次,失败 ${aiRuntime.failed_calls || 0}`
extra: this.buildAiRuntimeExtra(aiRuntime)
}, },
{ {
key: 'scheduler', key: 'md2img',
title: '任务调度', title: 'Markdown 转图',
status: scheduler.status || 'warning', status: md2img.status || 'warning',
value: `${scheduler.enabled_jobs || 0} / ${scheduler.total_jobs || 0}`, value: md2img.healthy ? '就绪' : '待检查',
summary: scheduler.summary || '暂无状态', summary: md2img.summary || '暂无状态',
serviceBlocks: this.buildSchedulerServiceBlocks(scheduler), extra: `Runtime ${md2img.runtime_ready ? '已就绪' : '未就绪'} / Browser ${md2img.browser_ready ? '已就绪' : '未就绪'}`
extra: this.buildSchedulerExtra(scheduler)
} }
]; ];
} }
@@ -590,235 +539,6 @@
}; };
return statusMap[status] || '未知'; 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) { renderPieChart(chartId, usageValue, label) {
const ctx = document.getElementById(chartId); const ctx = document.getElementById(chartId);
if (!ctx) return; if (!ctx) return;
@@ -1375,104 +1095,6 @@
color: #475569; 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 { .health-item__extra {
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;
@@ -1828,10 +1450,6 @@
.health-grid { .health-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.health-service-grid {
grid-template-columns: 1fr;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -1941,10 +1559,6 @@
font-size: 24px; font-size: 24px;
} }
.health-service-metrics {
grid-template-columns: 1fr;
}
.chart-container--large, .chart-container--large,
.chart-container--panel { .chart-container--panel {
height: 220px; 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> <el-tag :type="statusTag(scope.row.last_status)">{% raw %}{{ scope.row.last_status || 'never' }}{% endraw %}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="健康状态" width="120" align="center"> <el-table-column label="操作" min-width="280">
<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">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="action-row"> <div class="action-row">
<el-button size="mini" type="primary" plain @click="openEdit(scope.row)">编辑</el-button> <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="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> <el-button size="mini" type="text" @click="viewLogs(scope.row)">日志</el-button>
</div> </div>
</template> </template>
@@ -230,25 +197,6 @@ new Vue({
if (status === 'running') return 'warning' if (status === 'running') return 'warning'
return 'info' 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) { formatDateTime(value) {
// 统一清洗时间展示:去掉 ISO 'T',并兼容字符串与日期对象。 // 统一清洗时间展示:去掉 ISO 'T',并兼容字符串与日期对象。
if (!value) return '' if (!value) return ''
@@ -382,25 +330,6 @@ new Vue({
} }
await this.loadSchedules() 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) { async viewLogs(row) {
const resp = await axios.get(`/plugin_schedules/api/schedules/${row.id}/logs`) const resp = await axios.get(`/plugin_schedules/api/schedules/${row.id}/logs`)
if (resp.data.success) { if (resp.data.success) {
@@ -423,10 +352,5 @@ new Vue({
.page-hero-copy p{color:#64748b;font-size:14px} .page-hero-copy p{color:#64748b;font-size:14px}
.action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .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} .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> </style>
{% endblock %} {% endblock %}

View File

@@ -18,168 +18,32 @@
</div> </div>
<el-row :gutter="16" class="overview-grid"> <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"> <el-card class="overview-card overview-card--primary" shadow="hover">
<div class="overview-label">插件总数</div> <div class="overview-label">插件总数</div>
<div class="overview-value">{% raw %}{{ plugins.length }}{% endraw %}</div> <div class="overview-value">{% raw %}{{ plugins.length }}{% endraw %}</div>
<div class="overview-note">当前已注册插件模块</div> <div class="overview-note">当前已注册插件模块</div>
</el-card> </el-card>
</el-col> </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"> <el-card class="overview-card" shadow="hover">
<div class="overview-label">运行中</div> <div class="overview-label">运行中</div>
<div class="overview-value">{% raw %}{{ runningPluginsCount }}{% endraw %}</div> <div class="overview-value">{% raw %}{{ runningPluginsCount }}{% endraw %}</div>
<div class="overview-note">可正常提供能力的插件</div> <div class="overview-note">可正常提供能力的插件</div>
</el-card> </el-card>
</el-col> </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"> <el-card class="overview-card" shadow="hover">
<div class="overview-label">已停用</div> <div class="overview-label">已停用</div>
<div class="overview-value">{% raw %}{{ stoppedPluginsCount }}{% endraw %}</div> <div class="overview-value">{% raw %}{{ stoppedPluginsCount }}{% endraw %}</div>
<div class="overview-note">待启用或排查状态</div> <div class="overview-note">待启用或排查状态</div>
</el-card> </el-card>
</el-col> </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"> <el-card class="overview-card overview-card--soft" shadow="hover">
<div class="overview-label">治理告警</div> <div class="overview-label">作者数量</div>
<div class="overview-value">{% raw %}{{ governanceRiskCount }}{% endraw %}</div> <div class="overview-value">{% raw %}{{ authorsCount }}{% endraw %}</div>
<div class="overview-note">存在配置、依赖或加载风险的插件</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>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
@@ -188,7 +52,7 @@
<div slot="header" class="workspace-header"> <div slot="header" class="workspace-header">
<div> <div>
<h3>插件列表</h3> <h3>插件列表</h3>
<p>优先关注状态、执行表现和说明,再进入单个插件详情与配置编辑。</p> <p>优先关注状态和说明,再进入单个插件详情与配置编辑。</p>
</div> </div>
</div> </div>
@@ -210,59 +74,11 @@
<el-table-column prop="description" label="描述" min-width="280" show-overflow-tooltip></el-table-column> <el-table-column prop="description" label="描述" min-width="280" show-overflow-tooltip></el-table-column>
<el-table-column label="状态" width="120" align="center"> <el-table-column label="状态" width="120" align="center">
<template slot-scope="scope"> <template slot-scope="scope">
<el-tag :type="pluginStatusTagType(scope.row.status)"> <el-tag :type="scope.row.status === 'RUNNING' ? 'success' : 'info'">
{% raw %}{{ pluginStatusLabel(scope.row) }}{% endraw %} {% raw %}{{ scope.row.status === 'RUNNING' ? '已启用' : '已禁用' }}{% endraw %}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </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"> <el-table-column label="操作" min-width="290">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="action-row"> <div class="action-row">
@@ -302,29 +118,17 @@
<div class="entity-subtitle">模块:{% raw %}{{ plugin.module_name }}{% endraw %}</div> <div class="entity-subtitle">模块:{% raw %}{{ plugin.module_name }}{% endraw %}</div>
</div> </div>
</div> </div>
<el-tag :type="pluginStatusTagType(plugin.status)" size="small"> <el-tag :type="plugin.status === 'RUNNING' ? 'success' : 'info'" size="small">
{% raw %}{{ pluginStatusLabel(plugin) }}{% endraw %} {% raw %}{{ plugin.status === 'RUNNING' ? '已启用' : '已禁用' }}{% endraw %}
</el-tag> </el-tag>
</div> </div>
<div class="mobile-plugin-card__meta"> <div class="mobile-plugin-card__meta">
<span>版本:{% raw %}{{ plugin.version || '未知' }}{% endraw %}</span> <span>版本:{% raw %}{{ plugin.version || '未知' }}{% endraw %}</span>
<span>治理{% raw %}{{ governanceLabel(plugin.governance_status) }}{% endraw %}</span> <span>作者{% raw %}{{ plugin.author || '未知' }}{% 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>
</div> </div>
<div class="mobile-plugin-card__desc"> <div class="mobile-plugin-card__desc">
{% raw %}{{ plugin.description || '暂无描述' }}{% endraw %} {% raw %}{{ plugin.description || '暂无描述' }}{% endraw %}
</div> </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"> <div class="mobile-plugin-card__actions">
<el-button <el-button
size="mini" 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.version }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="作者" :span="1">{% raw %}{{ selectedPlugin.author }}{% endraw %}</el-descriptions-item> <el-descriptions-item label="作者" :span="1">{% raw %}{{ selectedPlugin.author }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="状态" :span="1"> <el-descriptions-item label="状态" :span="1">
<el-tag :type="pluginStatusTagType(selectedPlugin.status)" size="small"> <el-tag :type="selectedPlugin.status === 'RUNNING' ? 'success' : 'info'" size="small">
{% raw %}{{ pluginStatusLabel(selectedPlugin) }}{% endraw %} {% raw %}{{ selectedPlugin.status === 'RUNNING' ? '已启用' : '已禁用' }}{% 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> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="命令前缀" :span="1" v-if="selectedPlugin.command_prefix !== undefined"> <el-descriptions-item label="命令前缀" :span="1" v-if="selectedPlugin.command_prefix !== undefined">
{% raw %}{{ selectedPlugin.command_prefix || '无' }}{% endraw %} {% raw %}{{ selectedPlugin.command_prefix || '无' }}{% endraw %}
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{% raw %}{{ selectedPlugin.description }}{% 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"> <el-descriptions-item label="命令列表" :span="2" v-if="selectedPlugin.commands && selectedPlugin.commands.length > 0">
<div class="command-tags"> <div class="command-tags">
<el-tag v-for="cmd in selectedPlugin.commands" :key="cmd" size="mini" class="command-tag"> <el-tag v-for="cmd in selectedPlugin.commands" :key="cmd" size="mini" class="command-tag">
@@ -445,105 +174,6 @@
</el-tag> </el-tag>
</div> </div>
</el-descriptions-item> </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"> <el-descriptions-item label="配置信息" :span="2" v-if="selectedPlugin.config">
<div class="config-container"> <div class="config-container">
<div class="config-actions"> <div class="config-actions">
@@ -740,75 +370,8 @@
stoppedPluginsCount() { stoppedPluginsCount() {
return this.plugins.filter(plugin => plugin.status !== 'RUNNING').length; return this.plugins.filter(plugin => plugin.status !== 'RUNNING').length;
}, },
governanceRiskCount() { authorsCount() {
return (this.plugins || []).filter(plugin => ['warning', 'error'].includes((plugin.governance_status || '').toLowerCase())).length; return new Set((this.plugins || []).map(plugin => plugin.author).filter(Boolean)).size;
},
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);
}, },
// 弹窗宽度按视口分级收缩,保证手机上弹窗内容不会贴边或继续触发横向溢出。 // 弹窗宽度按视口分级收缩,保证手机上弹窗内容不会贴边或继续触发横向溢出。
pluginInfoDialogWidth() { pluginInfoDialogWidth() {
@@ -837,109 +400,6 @@
// 这里统一以 768px 作为移动端断点,和常见后台管理布局断点保持一致。 // 这里统一以 768px 作为移动端断点,和常见后台管理布局断点保持一致。
this.isMobileViewport = window.innerWidth <= 768; 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() { loadPlugins() {
this.loading = true; this.loading = true;
axios.get('/api/plugins') axios.get('/api/plugins')
@@ -1027,6 +487,7 @@
}, },
saveConfig() { saveConfig() {
try { try {
let configObj;
axios.post('/api/plugins/config/update', { axios.post('/api/plugins/config/update', {
plugin_name: this.selectedPlugin.module_name, plugin_name: this.selectedPlugin.module_name,
config_text: this.editedConfig, config_text: this.editedConfig,
@@ -1037,11 +498,7 @@
this.$message.success('配置保存成功'); this.$message.success('配置保存成功');
this.isEditingConfig = false; this.isEditingConfig = false;
this.selectedPlugin.configText = this.editedConfig; this.selectedPlugin.configText = this.editedConfig;
// 保存成功后立即重新拉取详情: this.selectedPlugin.config = configObj;
// 1. 同步刷新治理诊断、配置概览和内存中的插件配置快照;
// 2. 避免页面上继续停留在旧的健康状态;
// 3. 这样后续是否重载插件,用户都能先看到“配置文本已通过校验并落盘”。
this.showPluginInfo(this.selectedPlugin);
this.$confirm('配置已保存,是否要重载插件以应用新配置?', '提示', { this.$confirm('配置已保存,是否要重载插件以应用新配置?', '提示', {
confirmButtonText: '重载插件', confirmButtonText: '重载插件',
cancelButtonText: '稍后手动重载', cancelButtonText: '稍后手动重载',
@@ -1057,7 +514,7 @@
}) })
.catch(error => { .catch(error => {
console.error('保存配置出错:', error); console.error('保存配置出错:', error);
this.configError = '保存配置出错: ' + (((error.response || {}).data || {}).message || error.message); this.configError = '保存配置出错: ' + (error.response?.data?.message || error.message);
}); });
} catch (e) { } catch (e) {
this.configError = '处理配置时出错: ' + e.message; this.configError = '处理配置时出错: ' + e.message;
@@ -1224,7 +681,6 @@
font-size: 14px; font-size: 14px;
} }
.overview-grid .el-col { margin-bottom: 16px; } .overview-grid .el-col { margin-bottom: 16px; }
.insight-grid .el-col { margin-bottom: 16px; }
.overview-card { min-height: 112px; } .overview-card { min-height: 112px; }
.overview-card--primary { .overview-card--primary {
background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important; 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 h3 { font-size: 18px; margin-bottom: 4px; }
.workspace-header p { font-size: 13px; color: #64748b; } .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-cell { display: flex; align-items: center; gap: 12px; }
.entity-badge { .entity-badge {
width: 30px; height: 30px; border-radius: 50%; display: inline-flex; align-items: center; 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; } .config-container pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
.command-tags { display: flex; flex-wrap: wrap; gap: 6px; } .command-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.command-tags--compact { justify-content: center; }
.command-tag { margin: 0 !important; } .command-tag { margin: 0 !important; }
.config-actions { margin-bottom: 10px; display: flex; gap: 10px; } .config-actions { margin-bottom: 10px; display: flex; gap: 10px; }
.config-editor { font-family: monospace; font-size: 12px; } .config-editor { font-family: monospace; font-size: 12px; }
.config-error { color: #ef4444; font-size: 12px; margin-top: 5px; } .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; } .plugin-group-status-dialog { min-height: 240px; }
.mobile-plugin-list, .mobile-plugin-list,
.mobile-group-list { .mobile-group-list {
@@ -1548,10 +850,6 @@
.mobile-plugin-card__header { .mobile-plugin-card__header {
flex-direction: column; flex-direction: column;
} }
.rank-item__title-row {
flex-direction: column;
align-items: flex-start;
}
.mobile-plugin-card__actions .el-button, .mobile-plugin-card__actions .el-button,
.mobile-group-card__actions .el-button { .mobile-group-card__actions .el-button {
flex: 1 1 calc(50% - 8px); 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> <el-tag :type="statusTag(scope.row.last_status)">{% raw %}{{ scope.row.last_status || 'never' }}{% endraw %}</el-tag>
</template> </template>
</el-table-column> </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"> <el-table-column label="操作" min-width="280">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="action-row"> <div class="action-row">
@@ -165,23 +143,6 @@ new Vue({
if (status === 'running') return 'warning'; if (status === 'running') return 'warning';
return 'info'; 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() { async loadJobs() {
this.loading = true; this.loading = true;
try { 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 h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}
.page-hero-copy p{color:#64748b;font-size:14px} .page-hero-copy p{color:#64748b;font-size:14px}
.action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .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> </style>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time
from loguru import logger from loguru import logger
from typing import List, Dict, Any, Optional, Tuple, Union from typing import List, Dict, Any, Optional, Tuple, Union
@@ -14,62 +12,19 @@ class BaseDBOperator:
self.db_manager = db_manager self.db_manager = db_manager
self.LOG = logger 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[ def execute_query(self, sql: str, params: Optional[tuple] = None, fetch_one: bool = False) -> Union[
List[Dict], Dict, None]: List[Dict], Dict, None]:
"""执行查询SQL""" """执行查询SQL"""
conn = self.db_manager.get_mysql_connection() conn = self.db_manager.get_mysql_connection()
started_at = time.perf_counter()
try: try:
with conn.cursor(dictionary=True) as cursor: with conn.cursor(dictionary=True) as cursor:
cursor.execute(sql, params or ()) cursor.execute(sql, params or ())
elapsed_ms = (time.perf_counter() - started_at) * 1000
if fetch_one: if fetch_one:
result = cursor.fetchone() return cursor.fetchone()
self._log_sql_timing("query_one", sql, params, elapsed_ms, 1 if result else 0) return cursor.fetchall()
return result
result = cursor.fetchall()
self._log_sql_timing("query", sql, params, elapsed_ms, len(result or []))
return result
except Exception as e: except Exception as e:
self.LOG.error( 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 return None
finally: finally:
@@ -78,13 +33,10 @@ class BaseDBOperator:
def execute_update(self, sql: str, params: Optional[tuple] = None) -> bool: def execute_update(self, sql: str, params: Optional[tuple] = None) -> bool:
"""执行更新SQL""" """执行更新SQL"""
conn = self.db_manager.get_mysql_connection() conn = self.db_manager.get_mysql_connection()
started_at = time.perf_counter()
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(sql, params or ()) cursor.execute(sql, params or ())
affected_rows = cursor.rowcount
conn.commit() conn.commit()
self._log_sql_timing("update", sql, params, (time.perf_counter() - started_at) * 1000, affected_rows)
return True return True
except Exception as e: except Exception as e:
self.LOG.error( self.LOG.error(
@@ -101,19 +53,10 @@ class BaseDBOperator:
return True return True
conn = self.db_manager.get_mysql_connection() conn = self.db_manager.get_mysql_connection()
started_at = time.perf_counter()
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.executemany(sql, params_list) cursor.executemany(sql, params_list)
affected_rows = cursor.rowcount
conn.commit() 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 return True
except Exception as e: except Exception as e:
self.LOG.error(f"批量执行SQL出错: {e}, SQL: {sql}, 参数数量: {len(params_list)}") self.LOG.error(f"批量执行SQL出错: {e}, SQL: {sql}, 参数数量: {len(params_list)}")
@@ -128,18 +71,11 @@ class BaseDBOperator:
return True return True
conn = self.db_manager.get_mysql_connection() conn = self.db_manager.get_mysql_connection()
started_at = time.perf_counter()
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
for sql, params in operations: for sql, params in operations:
cursor.execute(sql, params) cursor.execute(sql, params)
conn.commit() conn.commit()
self._log_sql_timing(
"transaction",
f"{len(operations)} statements",
f"operations={len(operations)}",
(time.perf_counter() - started_at) * 1000,
)
return True return True
except Exception as e: except Exception as e:
self.LOG.error(f"执行事务出错: {e}, 操作数量: {len(operations)}") self.LOG.error(f"执行事务出错: {e}, 操作数量: {len(operations)}")

View File

@@ -39,13 +39,7 @@ class DBConnectionManager:
self.LOG = logger self.LOG = logger
self.mysql_pool = None self.mysql_pool = None
self.redis_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连接池 # 初始化MySQL连接池
if mysql_config: if mysql_config:
self.init_mysql_pool(mysql_config) self.init_mysql_pool(mysql_config)
@@ -64,8 +58,6 @@ class DBConnectionManager:
if not config: if not config:
self.LOG.warning("MySQL配置为空跳过初始化") self.LOG.warning("MySQL配置为空跳过初始化")
return return
self.mysql_config = dict(config or {})
# 准备连接池配置 # 准备连接池配置
pool_config = { pool_config = {
@@ -98,8 +90,6 @@ class DBConnectionManager:
if not config: if not config:
self.LOG.warning("Redis配置为空跳过初始化") self.LOG.warning("Redis配置为空跳过初始化")
return return
self.redis_config = dict(config or {})
self.redis_pool = redis.ConnectionPool( self.redis_pool = redis.ConnectionPool(
host=config.get('host', 'localhost'), host=config.get('host', 'localhost'),
@@ -127,26 +117,6 @@ class DBConnectionManager:
raise Exception("MySQL连接池未初始化") raise Exception("MySQL连接池未初始化")
return self.mysql_pool.get_connection() 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): def get_redis_connection(self):
"""获取Redis连接 """获取Redis连接
@@ -170,4 +140,4 @@ class DBConnectionManager:
# 关闭Redis连接池 # 关闭Redis连接池
if self.redis_pool: if self.redis_pool:
self.redis_pool.disconnect() self.redis_pool.disconnect()
self.redis_pool = None self.redis_pool = None

View File

@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from datetime import datetime, timedelta from datetime import datetime
import json import json
from threading import Lock
from typing import Dict, List, Optional from typing import Dict, List, Optional
from db.base import BaseDBOperator from db.base import BaseDBOperator
@@ -13,103 +12,8 @@ from wechat_ipad.models.message import WxMessage
class MessageStorageDB(BaseDBOperator): class MessageStorageDB(BaseDBOperator):
"""消息存储相关数据库操作""" """消息存储相关数据库操作"""
_performance_ready = False
_performance_lock = Lock()
def __init__(self, db_manager: DBConnectionManager): def __init__(self, db_manager: DBConnectionManager):
super().__init__(db_manager) 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: 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]: 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 = """ sql = """
SELECT timestamp, sender, content, message_type SELECT timestamp, sender, content, message_type
FROM messages FROM messages
WHERE timestamp >= %s WHERE DATE(timestamp) = %s
AND timestamp < %s
AND group_id = %s AND group_id = %s
AND sender = %s AND sender = %s
AND message_type IN (1, 49) AND message_type IN (1, 49)
@@ -362,16 +264,14 @@ class MessageStorageDB(BaseDBOperator):
ORDER BY timestamp ASC ORDER BY timestamp ASC
LIMIT %s 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]: 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 = """ sql = """
SELECT timestamp, sender, content, message_type SELECT timestamp, sender, content, message_type
FROM messages FROM messages
WHERE timestamp >= %s WHERE DATE(timestamp) = %s
AND timestamp < %s
AND group_id = %s AND group_id = %s
AND sender IS NOT NULL AND sender IS NOT NULL
AND sender <> '' AND sender <> ''
@@ -381,7 +281,7 @@ class MessageStorageDB(BaseDBOperator):
ORDER BY timestamp ASC ORDER BY timestamp ASC
LIMIT %s 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]: 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]: def get_message_count_by_date(self, date: str) -> List[Dict]:
"""获取指定日期的消息统计""" """获取指定日期的消息统计"""
start_time, end_time = self._build_day_time_range(date)
sql = """ sql = """
SELECT group_id, sender, COUNT(*) as count SELECT group_id, sender, COUNT(*) as count
FROM messages FROM messages
WHERE timestamp >= %s WHERE DATE(timestamp) = %s
AND timestamp < %s
GROUP BY group_id, sender 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]: 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) params.append(group_id)
if start_date: if start_date:
start_bound = f"{str(start_date).strip()} 00:00:00" sql_count += " AND DATE(timestamp) >= %s "
sql_count += " AND timestamp >= %s " sql_data += " AND DATE(timestamp) >= %s "
sql_data += " AND timestamp >= %s " params.append(start_date)
params.append(start_bound)
if end_date: if end_date:
_, end_bound = self._build_day_bounds( sql_count += " AND DATE(timestamp) <= %s "
start_date or str(end_date).strip(), sql_data += " AND DATE(timestamp) <= %s "
str(end_date).strip(), params.append(end_date)
)
sql_count += " AND timestamp < %s "
sql_data += " AND timestamp < %s "
params.append(end_bound)
if search_text: if search_text:
sql_count += " AND content LIKE %s " sql_count += " AND content LIKE %s "
@@ -772,8 +665,8 @@ class MessageStorageDB(BaseDBOperator):
""" """
return self.execute_query(sql, (f'%md5="{md5}"%',), fetch_one=True) 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, 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]: min_content_length: int = 6, max_results: int = 5000) -> List[Dict]:
"""按日期范围获取消息(支持按天总结) """按日期范围获取消息(支持按天总结)
Args: Args:
@@ -789,13 +682,11 @@ class MessageStorageDB(BaseDBOperator):
if end_date is None: if end_date is None:
end_date = start_date end_date = start_date
start_time, end_time = self._build_day_bounds(start_date, end_date)
sql = """ sql = """
SELECT timestamp, sender, content, message_type SELECT timestamp, sender, content, message_type
FROM messages FROM messages
WHERE timestamp >= %s WHERE DATE(timestamp) >= %s
AND timestamp < %s AND DATE(timestamp) <= %s
AND group_id = %s AND group_id = %s
AND message_type IN (1, 49) AND message_type IN (1, 49)
AND LENGTH(content) > %s AND LENGTH(content) > %s
@@ -804,7 +695,7 @@ class MessageStorageDB(BaseDBOperator):
ORDER BY timestamp ASC ORDER BY timestamp ASC
LIMIT %s 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 [] return self.execute_query(sql, params) or []
def get_messages_for_summary(self, group_id: str, hours_ago: int = 8, def get_messages_for_summary(self, group_id: str, hours_ago: int = 8,
@@ -858,8 +749,8 @@ class MessageStorageDB(BaseDBOperator):
AND content NOT LIKE '/%' AND content NOT LIKE '/%'
ORDER BY timestamp ASC ORDER BY timestamp ASC
""" """
params = (self._normalize_datetime_text(start_time), params = (start_time.strftime('%Y-%m-%d %H:%M:%S'),
self._normalize_datetime_text(end_time), end_time.strftime('%Y-%m-%d %H:%M:%S'),
group_id) group_id)
return self.execute_query(sql, params) or [] return self.execute_query(sql, params) or []
@@ -885,8 +776,8 @@ class MessageStorageDB(BaseDBOperator):
AND CHAR_LENGTH(content) < 300 AND CHAR_LENGTH(content) < 300
AND content NOT LIKE '/%' AND content NOT LIKE '/%'
""" """
params = (self._normalize_datetime_text(start_time), params = (start_time.strftime('%Y-%m-%d %H:%M:%S'),
self._normalize_datetime_text(end_time), end_time.strftime('%Y-%m-%d %H:%M:%S'),
group_id) group_id)
result = self.execute_query(sql, params) result = self.execute_query(sql, params)
return result[0]['count'] if result else 0 return result[0]['count'] if result else 0
@@ -910,8 +801,8 @@ class MessageStorageDB(BaseDBOperator):
AND sender <> '' AND sender <> ''
""" """
params = ( params = (
self._normalize_datetime_text(start_time), start_time.strftime('%Y-%m-%d %H:%M:%S'),
self._normalize_datetime_text(end_time), end_time.strftime('%Y-%m-%d %H:%M:%S'),
group_id, group_id,
) )
result = self.execute_query(sql, params, fetch_one=True) or {} result = self.execute_query(sql, params, fetch_one=True) or {}

View File

@@ -216,22 +216,6 @@ class PluginScheduleDBOperator(BaseDBOperator):
) or {} ) or {}
return row.get("triggered_at") 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]]: 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 的运行时计数会重置,但数据库日志仍完整; 2. 进程重启后async_job 的运行时计数会重置,但数据库日志仍完整;
3. 这里提供批量查询接口,让上层可用日志数据兜底回填展示字段。 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: if not clean_ids:
return {} return {}
@@ -263,83 +247,3 @@ class PluginScheduleDBOperator(BaseDBOperator):
if schedule_id > 0: if schedule_id > 0:
result[schedule_id] = row result[schedule_id] = row
return result 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 create or replace index idx_group_timestamp
on message_archive.messages (group_id, 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 create or replace index idx_message_sender
on message_archive.messages (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 create or replace index messages_message_id_index
on message_archive.messages (message_id); 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 create or replace table message_archive.t_emoji_assets
( (
md5 varchar(64) not null comment '表情MD5' md5 varchar(64) not null comment '表情MD5'

View File

@@ -171,145 +171,6 @@ class SystemJobDBOperator(BaseDBOperator):
row["detail_json"] = {} row["detail_json"] = {}
return rows 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]: def get_latest_log_time(self, job_key: str) -> Optional[datetime]:
"""获取任务最新一次执行日志时间。""" """获取任务最新一次执行日志时间。"""
row = self.execute_query( 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_KEY = "ROBOT_MENU"
FEATURE_DESCRIPTION = "📋 功能菜单 [菜单 | 菜单 状态 | 菜单 指令清单]" FEATURE_DESCRIPTION = "📋 功能菜单 [菜单 - 显示功能菜单 | 菜单 状态 - 显示功能状态]"
@property @property
def name(self) -> str: def name(self) -> str:
@@ -263,31 +263,6 @@ class RobotMenuPlugin(MessagePluginInterface):
) )
return True, "显示功能状态" 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() == "群列表": if cmd_name.upper() == "群列表":
group_list_text = self.get_group_list() 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 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.markdown_to_image import convert_md_str_to_image, html_to_image
from utils.revoke.message_auto_revoke import MessageAutoRevoke from utils.revoke.message_auto_revoke import MessageAutoRevoke
from utils.robot_cmd.robot_command import Feature, GroupBotManager, PermissionStatus 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( async def send_menu_content(
self, self,
bot: WechatAPIClient, bot: WechatAPIClient,

138
robot.py
View File

@@ -649,33 +649,7 @@ class Robot:
# 检查插件是否可以处理该消息 # 检查插件是否可以处理该消息
if plugin.can_process(plugin_msg): if plugin.can_process(plugin_msg):
protection_policy = self._build_message_plugin_protection_policy(plugin) processed, _ = await plugin.process_message(plugin_msg)
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),
)
self._record_plugin_call_result( self._record_plugin_call_result(
plugin=plugin, plugin=plugin,
msg=msg, msg=msg,
@@ -696,58 +670,14 @@ class Robot:
) )
) )
return True 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: 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( self._record_plugin_call_error(
plugin=plugin, plugin=plugin,
msg=msg, msg=msg,
command_name=command_name, command_name=command_name,
error=e, error=e,
) )
self.LOG.error( self.LOG.error(self._trace_message(msg, f"插件 {plugin.name} 处理消息失败: {e}"))
self._trace_message(
msg,
f"插件 {plugin.name} 处理消息失败: {e} "
f"circuit_state={failure_record.get('circuit_state')} "
f"consecutive_failures={failure_record.get('consecutive_failures')}"
)
)
return False return False
@@ -796,70 +726,6 @@ class Robot:
msg_type = getattr(getattr(msg, "msg_type", None), "name", "") msg_type = getattr(getattr(msg, "msg_type", None), "name", "")
return f"[{msg_type or 'UNKNOWN'}]" 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): def _get_stats_collector_plugin(self):
"""获取运行中的统计收集插件实例。""" """获取运行中的统计收集插件实例。"""
# 统计插件已经从“事件订阅”切到“主链路直接回调”, # 统计插件已经从“事件订阅”切到“主链路直接回调”,

View File

@@ -209,47 +209,6 @@ class PluginScheduleManager:
return False return False
return latest_log_at < (expected_at - timedelta(seconds=self._compensation_tolerance_seconds)) 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]: async def _run_one_schedule(self, schedule_row: Dict[str, Any]) -> Dict[str, Any]:
schedule_id = int(schedule_row["id"]) schedule_id = int(schedule_row["id"])
action_key = schedule_row.get("action_key") action_key = schedule_row.get("action_key")
@@ -338,7 +297,6 @@ class PluginScheduleManager:
# 日志兜底:进程重启后内存态 last_run_at 会丢失,任务页需要从数据库最新日志恢复显示。 # 日志兜底:进程重启后内存态 last_run_at 会丢失,任务页需要从数据库最新日志恢复显示。
schedule_ids = [int(row.get("id")) for row in db_rows if row.get("id") is not None] 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) 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 = [] data = []
for row in db_rows: for row in db_rows:
@@ -346,7 +304,6 @@ class PluginScheduleManager:
key = f"plugin_schedule:{schedule_id}" key = f"plugin_schedule:{schedule_id}"
runtime = runtime_by_key.get(key, {}) runtime = runtime_by_key.get(key, {})
latest_log = latest_log_by_schedule.get(schedule_id) or {} latest_log = latest_log_by_schedule.get(schedule_id) or {}
history_summary = history_summary_by_schedule.get(schedule_id) or {}
merged = dict(row) merged = dict(row)
merged["runtime_job_id"] = runtime.get("id") merged["runtime_job_id"] = runtime.get("id")
merged["running"] = runtime.get("running", False) merged["running"] = runtime.get("running", False)
@@ -362,24 +319,6 @@ class PluginScheduleManager:
merged["run_count"] = runtime.get("run_count", 0) merged["run_count"] = runtime.get("run_count", 0)
merged["success_count"] = runtime.get("success_count", 0) merged["success_count"] = runtime.get("success_count", 0)
merged["fail_count"] = runtime.get("fail_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) data.append(merged)
return data return data

View File

@@ -883,7 +883,7 @@ class MessageStorage:
end_date = current_time.strftime('%Y-%m-%d') 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, group_id,
start_date=start_date, start_date=start_date,
end_date=end_date, end_date=end_date,