Files
abot/admin/dashboard/templates/plugins_manage.html
2026-05-01 12:45:41 +08:00

1074 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}插件管理 - 机器人管理后台{% endblock %}
{% block content %}
<div class="page-shell plugins-page">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">Plugins Workspace</div>
<h1>插件管理</h1>
<p>统一查看插件状态、版本、说明与配置,减少传统后台式碎片操作。</p>
</div>
<div class="page-hero-actions">
<el-button type="primary" @click="refreshPlugins">
<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 %}{{ plugins.length }}{% 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 %}{{ runningPluginsCount }}{% 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 %}{{ stoppedPluginsCount }}{% 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 %}{{ governanceRiskCount }}{% endraw %}</div>
<div class="overview-note">存在配置、依赖或加载风险的插件</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>
<!-- 桌面端继续保留表格视图,避免影响高密度信息浏览效率。 -->
<el-table v-if="!isMobileViewport" :data="plugins" style="width: 100%" v-loading="loading">
<el-table-column label="插件信息" min-width="260">
<template slot-scope="scope">
<div class="entity-cell">
<div class="entity-badge">{% raw %}{{ scope.$index + 1 }}{% endraw %}</div>
<div class="entity-copy">
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
<div class="entity-subtitle">模块:{% raw %}{{ scope.row.module_name }}{% endraw %}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="120"></el-table-column>
<el-table-column prop="author" label="作者" width="140"></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">
<template slot-scope="scope">
<el-tag :type="pluginStatusTagType(scope.row.status)">
{% raw %}{{ pluginStatusLabel(scope.row) }}{% endraw %}
</el-tag>
</template>
</el-table-column>
<el-table-column label="治理健康" width="170" align="center">
<template slot-scope="scope">
<div class="governance-cell">
<el-tag :type="governanceTagType(scope.row.governance_status)" size="small">
{% raw %}{{ governanceLabel(scope.row.governance_status) }}{% endraw %}
</el-tag>
<div class="governance-note">
{% raw %}{{ governanceIssueSummary(scope.row) }}{% endraw %}
</div>
</div>
</template>
</el-table-column>
<el-table-column label="能力类型" width="150" align="center">
<template slot-scope="scope">
<div class="command-tags command-tags--compact">
<el-tag v-for="pluginType in (scope.row.plugin_types || [])" :key="pluginType" size="mini" effect="plain">
{% raw %}{{ pluginType }}{% endraw %}
</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="命令 / 权限" min-width="180">
<template slot-scope="scope">
<div class="entity-subtitle">
{% raw %}{{ scope.row.command_count ? `命令 ${scope.row.command_count} 个` : '无命令声明' }}{% endraw %}
</div>
<div class="entity-subtitle">
{% raw %}{{ scope.row.feature_key ? `Feature: ${scope.row.feature_key}` : '未接入群级权限' }}{% endraw %}
</div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="290">
<template slot-scope="scope">
<div class="action-row">
<el-button
size="mini"
:type="scope.row.status === 'RUNNING' ? 'danger' : 'success'"
@click="togglePluginStatus(scope.row)">
{% raw %}{{ scope.row.status === 'RUNNING' ? '禁用' : '启用' }}{% endraw %}
</el-button>
<el-button size="mini" type="primary" plain @click="reloadPlugin(scope.row)">
重载
</el-button>
<el-button size="mini" type="info" plain @click="showPluginInfo(scope.row)">
详情
</el-button>
<el-button size="mini" type="warning" plain @click="showPluginGroupStatus(scope.row)">
群状态
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div v-else class="mobile-plugin-list" v-loading="loading">
<!-- 手机端改成卡片流式布局,避免表格固定列宽把页面撑出横向滚动。 -->
<div v-if="plugins.length === 0" class="mobile-empty-state">暂无插件数据</div>
<el-card
v-for="(plugin, index) in plugins"
:key="plugin.module_name || plugin.name || index"
class="mobile-plugin-card"
shadow="hover">
<div class="mobile-plugin-card__header">
<div class="entity-cell">
<div class="entity-badge">{% raw %}{{ index + 1 }}{% endraw %}</div>
<div class="entity-copy">
<div class="entity-title">{% raw %}{{ plugin.name }}{% endraw %}</div>
<div class="entity-subtitle">模块:{% raw %}{{ plugin.module_name }}{% endraw %}</div>
</div>
</div>
<el-tag :type="pluginStatusTagType(plugin.status)" size="small">
{% raw %}{{ pluginStatusLabel(plugin) }}{% endraw %}
</el-tag>
</div>
<div class="mobile-plugin-card__meta">
<span>版本:{% raw %}{{ plugin.version || '未知' }}{% endraw %}</span>
<span>治理:{% raw %}{{ governanceLabel(plugin.governance_status) }}{% endraw %}</span>
</div>
<div class="mobile-plugin-card__desc">
{% raw %}{{ plugin.description || '暂无描述' }}{% endraw %}
</div>
<div class="mobile-plugin-card__meta">
<span>{% raw %}{{ governanceIssueSummary(plugin) }}{% endraw %}</span>
<span>{% raw %}{{ plugin.feature_key ? `Feature: ${plugin.feature_key}` : '未接入群级权限' }}{% endraw %}</span>
</div>
<div class="mobile-plugin-card__actions">
<el-button
size="mini"
:type="plugin.status === 'RUNNING' ? 'danger' : 'success'"
@click="togglePluginStatus(plugin)">
{% raw %}{{ plugin.status === 'RUNNING' ? '禁用' : '启用' }}{% endraw %}
</el-button>
<el-button size="mini" type="primary" plain @click="reloadPlugin(plugin)">
重载
</el-button>
<el-button size="mini" type="info" plain @click="showPluginInfo(plugin)">
详情
</el-button>
<el-button size="mini" type="warning" plain @click="showPluginGroupStatus(plugin)">
群状态
</el-button>
</div>
</el-card>
</div>
</el-card>
<el-dialog title="插件详情" :visible.sync="pluginInfoVisible" :width="pluginInfoDialogWidth" top="5vh">
<div v-if="selectedPlugin" class="plugin-detail-container">
<div class="dialog-intro">查看插件基础信息、命令列表与配置内容,需要时可直接在这里编辑配置。</div>
<el-descriptions border direction="vertical" :column="pluginDescriptionsColumn" size="small" class="plugin-descriptions">
<el-descriptions-item label="插件名称" :span="1">{% raw %}{{ selectedPlugin.name }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="模块名称" :span="1">{% raw %}{{ selectedPlugin.module_name }}{% 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">
<el-tag :type="pluginStatusTagType(selectedPlugin.status)" size="small">
{% raw %}{{ pluginStatusLabel(selectedPlugin) }}{% endraw %}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="治理健康" :span="1">
<el-tag :type="governanceTagType(selectedPlugin.governance_status)" size="small">
{% raw %}{{ governanceLabel(selectedPlugin.governance_status) }}{% endraw %}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="命令前缀" :span="1" v-if="selectedPlugin.command_prefix !== undefined">
{% raw %}{{ selectedPlugin.command_prefix || '无' }}{% endraw %}
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">{% raw %}{{ selectedPlugin.description }}{% endraw %}</el-descriptions-item>
<el-descriptions-item label="能力类型" :span="2" v-if="selectedPlugin.plugin_types && selectedPlugin.plugin_types.length > 0">
<div class="command-tags">
<el-tag v-for="pluginType in selectedPlugin.plugin_types" :key="pluginType" size="mini" effect="plain">
{% raw %}{{ pluginType }}{% endraw %}
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item label="Feature Key" :span="1">
{% raw %}{{ selectedPlugin.feature_key || '未声明' }}{% endraw %}
</el-descriptions-item>
<el-descriptions-item label="群级开关" :span="1">
{% raw %}{{ selectedPlugin.supports_group_switch ? '支持' : '未接入' }}{% endraw %}
</el-descriptions-item>
<el-descriptions-item label="依赖插件" :span="2">
<div v-if="selectedPlugin.dependencies && selectedPlugin.dependencies.length > 0" class="command-tags">
<el-tag v-for="dependency in selectedPlugin.dependencies" :key="dependency" size="mini" effect="plain">
{% raw %}{{ dependency }}{% endraw %}
</el-tag>
</div>
<span v-else></span>
</el-descriptions-item>
<el-descriptions-item label="命令列表" :span="2" v-if="selectedPlugin.commands && selectedPlugin.commands.length > 0">
<div class="command-tags">
<el-tag v-for="cmd in selectedPlugin.commands" :key="cmd" size="mini" class="command-tag">
{% raw %}{{ cmd }}{% endraw %}
</el-tag>
</div>
</el-descriptions-item>
<el-descriptions-item label="配置概览" :span="2" v-if="selectedPlugin.config_overview">
<div class="config-overview-grid">
<div class="config-overview-item">
<span class="config-overview-label">配置文件</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.exists ? '存在' : '缺失' }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">解析状态</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.parse_ok ? '正常' : '失败' }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">配置分组</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.section_count || 0 }}{% endraw %}</span>
</div>
<div class="config-overview-item">
<span class="config-overview-label">敏感字段</span>
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.sensitive_field_count || 0 }}{% endraw %}</span>
</div>
</div>
<div class="entity-subtitle" style="margin-top: 8px;">
{% raw %}{{ selectedPlugin.config_path || '未声明配置路径' }}{% endraw %}
</div>
<div class="entity-subtitle" v-if="selectedPlugin.config_overview.parse_error">
{% raw %}{{ `解析错误:${selectedPlugin.config_overview.parse_error}` }}{% endraw %}
</div>
</el-descriptions-item>
<el-descriptions-item label="治理诊断" :span="2" v-if="selectedPlugin.governance_diagnostics">
<div v-if="selectedPlugin.governance_diagnostics.length > 0" class="diagnostic-list">
<div
v-for="(diagnostic, index) in selectedPlugin.governance_diagnostics"
:key="`${diagnostic.code}-${index}`"
class="diagnostic-item">
<el-tag :type="governanceTagType(diagnostic.level)" size="mini">
{% raw %}{{ governanceLabel(diagnostic.level) }}{% endraw %}
</el-tag>
<span class="diagnostic-text">{% raw %}{{ diagnostic.message }}{% endraw %}</span>
</div>
</div>
<span v-else>暂无治理诊断项</span>
</el-descriptions-item>
<el-descriptions-item label="配置信息" :span="2" v-if="selectedPlugin.config">
<div class="config-container">
<div class="config-actions">
<el-button type="primary" size="mini" @click="editConfig" :disabled="isEditingConfig">编辑配置</el-button>
<el-button type="success" size="mini" @click="saveConfig" v-if="isEditingConfig">保存配置</el-button>
<el-button type="info" size="mini" @click="cancelEditConfig" v-if="isEditingConfig">取消</el-button>
</div>
<div v-if="!isEditingConfig">
<pre>{% raw %}{{ selectedPlugin.configText }}{% endraw %}</pre>
</div>
<div v-else>
<el-input
type="textarea"
v-model="editedConfig"
:rows="12"
placeholder="请输入TOML格式的配置"
class="config-editor">
</el-input>
<div class="config-error" v-if="configError">{% raw %}{{ configError }}{% endraw %}</div>
</div>
</div>
</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
<el-dialog title="插件群状态" :visible.sync="pluginGroupStatusVisible" :width="groupStatusDialogWidth" top="5vh">
<div class="plugin-group-status-dialog" v-loading="groupStatusLoading">
<div v-if="pluginGroupStatusData" class="group-status-header">
<div class="group-status-title">
{% raw %}{{ pluginGroupStatusData.plugin_name || '未知插件' }}{% endraw %}
<span class="group-status-subtitle">
{% raw %}{{ `共 ${pluginGroupStatusData.total_group_count || 0} 个群` }}{% endraw %}
</span>
</div>
<div class="group-status-summary">
<el-tag size="small" type="success">已开启 {% raw %}{{ pluginGroupStatusData.enabled_count || 0 }}{% endraw %}</el-tag>
<el-tag size="small" type="info">未开启 {% raw %}{{ pluginGroupStatusData.disabled_count || 0 }}{% endraw %}</el-tag>
</div>
</div>
<el-alert
v-if="pluginGroupStatusData && !pluginGroupStatusData.supports_group_switch"
title="该插件未接入群级开关能力,当前仅展示群列表,状态默认为未开启。"
type="warning"
:closable="false"
show-icon
class="group-status-alert">
</el-alert>
<el-row :gutter="16" v-if="pluginGroupStatusData">
<el-col :xs="24" :sm="24" :md="12">
<el-card shadow="never" class="group-status-card">
<div slot="header" class="group-status-card-header">
<span>已开启群</span>
<el-tag size="mini" type="success">{% raw %}{{ pluginGroupStatusData.enabled_count || 0 }}{% endraw %}</el-tag>
</div>
<!-- 桌面端保留双列表格,移动端单独渲染群卡片,避免群 ID 列挤出屏幕。 -->
<el-table
v-if="!isMobileViewport"
:data="pluginGroupStatusData.enabled_groups || []"
size="mini"
max-height="420"
empty-text="暂无已开启群">
<el-table-column label="群名称" min-width="170">
<template slot-scope="scope">
{% raw %}{{ scope.row.group_name || scope.row.group_id }}{% endraw %}
</template>
</el-table-column>
<el-table-column prop="group_id" label="群ID" min-width="210" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" width="100" align="center">
<template slot-scope="scope">
<el-button
size="mini"
type="danger"
plain
:disabled="groupStatusLoading || !pluginGroupStatusData.supports_group_switch"
@click="togglePluginGroupSwitch(scope.row, false)">
关闭
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="mobile-group-list">
<div v-if="!(pluginGroupStatusData.enabled_groups || []).length" class="mobile-empty-state">暂无已开启群</div>
<el-card
v-for="group in (pluginGroupStatusData.enabled_groups || [])"
:key="`enabled-${group.group_id}`"
class="mobile-group-card"
shadow="never">
<div class="mobile-group-card__title">{% raw %}{{ group.group_name || group.group_id }}{% endraw %}</div>
<div class="mobile-group-card__id">群ID{% raw %}{{ group.group_id }}{% endraw %}</div>
<div class="mobile-group-card__actions">
<el-button
size="mini"
type="danger"
plain
:disabled="groupStatusLoading || !pluginGroupStatusData.supports_group_switch"
@click="togglePluginGroupSwitch(group, false)">
关闭
</el-button>
</div>
</el-card>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="12">
<el-card shadow="never" class="group-status-card">
<div slot="header" class="group-status-card-header">
<span>未开启群</span>
<el-tag size="mini" type="info">{% raw %}{{ pluginGroupStatusData.disabled_count || 0 }}{% endraw %}</el-tag>
</div>
<el-table
v-if="!isMobileViewport"
:data="pluginGroupStatusData.disabled_groups || []"
size="mini"
max-height="420"
empty-text="暂无未开启群">
<el-table-column label="群名称" min-width="170">
<template slot-scope="scope">
{% raw %}{{ scope.row.group_name || scope.row.group_id }}{% endraw %}
</template>
</el-table-column>
<el-table-column prop="group_id" label="群ID" min-width="210" show-overflow-tooltip></el-table-column>
<el-table-column label="操作" width="100" align="center">
<template slot-scope="scope">
<el-button
size="mini"
type="success"
plain
:disabled="groupStatusLoading || !pluginGroupStatusData.supports_group_switch"
@click="togglePluginGroupSwitch(scope.row, true)">
开启
</el-button>
</template>
</el-table-column>
</el-table>
<div v-else class="mobile-group-list">
<div v-if="!(pluginGroupStatusData.disabled_groups || []).length" class="mobile-empty-state">暂无未开启群</div>
<el-card
v-for="group in (pluginGroupStatusData.disabled_groups || [])"
:key="`disabled-${group.group_id}`"
class="mobile-group-card"
shadow="never">
<div class="mobile-group-card__title">{% raw %}{{ group.group_name || group.group_id }}{% endraw %}</div>
<div class="mobile-group-card__id">群ID{% raw %}{{ group.group_id }}{% endraw %}</div>
<div class="mobile-group-card__actions">
<el-button
size="mini"
type="success"
plain
:disabled="groupStatusLoading || !pluginGroupStatusData.supports_group_switch"
@click="togglePluginGroupSwitch(group, true)">
开启
</el-button>
</div>
</el-card>
</div>
</el-card>
</el-col>
</el-row>
</div>
</el-dialog>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
plugins: [],
selectedPlugin: null,
pluginInfoVisible: false,
loading: false,
isEditingConfig: false,
editedConfig: '',
configError: '',
configFormat: 'toml',
pluginGroupStatusVisible: false,
groupStatusLoading: false,
pluginGroupStatusData: null,
currentGroupStatusPlugin: null,
// 使用响应式视口状态切换移动端卡片布局,避免仅靠 CSS 隐藏后仍渲染宽表格。
isMobileViewport: false
}
},
computed: {
runningPluginsCount() {
return this.plugins.filter(plugin => plugin.status === 'RUNNING').length;
},
stoppedPluginsCount() {
return this.plugins.filter(plugin => plugin.status !== 'RUNNING').length;
},
governanceRiskCount() {
return (this.plugins || []).filter(plugin => ['warning', 'error'].includes((plugin.governance_status || '').toLowerCase())).length;
},
// 弹窗宽度按视口分级收缩,保证手机上弹窗内容不会贴边或继续触发横向溢出。
pluginInfoDialogWidth() {
return this.isMobileViewport ? '94%' : '64%';
},
groupStatusDialogWidth() {
return this.isMobileViewport ? '94%' : '72%';
},
pluginDescriptionsColumn() {
return this.isMobileViewport ? 1 : 2;
}
},
mounted() {
this.currentView = '11';
// 首次进入页面就同步一次屏幕宽度,确保移动端不会先闪出桌面表格。
this.updateViewportState();
window.addEventListener('resize', this.updateViewportState);
this.loadPlugins();
},
beforeDestroy() {
// 页面销毁时移除监听,避免重复绑定造成状态更新和内存占用问题。
window.removeEventListener('resize', this.updateViewportState);
},
methods: {
updateViewportState() {
// 这里统一以 768px 作为移动端断点,和常见后台管理布局断点保持一致。
this.isMobileViewport = window.innerWidth <= 768;
},
pluginStatusTagType(status) {
const normalizedStatus = String(status || '').toUpperCase();
if (normalizedStatus === 'RUNNING') return 'success';
if (normalizedStatus === 'ERROR') return 'danger';
if (normalizedStatus === 'LOADED') return 'warning';
return 'info';
},
pluginStatusLabel(plugin) {
if (plugin && plugin.status_label) return plugin.status_label;
const normalizedStatus = String(plugin?.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] || '提示';
},
governanceIssueSummary(plugin) {
const errorCount = Number(plugin?.governance_error_count || 0);
const warningCount = Number(plugin?.governance_warning_count || 0);
const infoCount = Number(plugin?.governance_info_count || 0);
if (errorCount > 0 || warningCount > 0) {
return `错误 ${errorCount} / 告警 ${warningCount}`;
}
if (infoCount > 0) {
return `提示 ${infoCount}`;
}
return '暂无治理问题';
},
loadPlugins() {
this.loading = true;
axios.get('/api/plugins')
.then(response => {
if (response.data.success) {
this.plugins = response.data.data || [];
} else {
this.$message.error(response.data.message || '加载插件列表失败');
}
})
.catch(error => {
console.error('加载插件列表出错:', error);
this.$message.error('加载插件列表出错');
})
.finally(() => {
this.loading = false;
});
},
refreshPlugins() {
this.loadPlugins();
this.$message.success('插件列表已刷新');
},
togglePluginStatus(plugin) {
const action = plugin.status === 'RUNNING' ? 'disable' : 'enable';
const actionText = plugin.status === 'RUNNING' ? '禁用' : '启用';
this.$confirm(`确定要${actionText}插件 "${plugin.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
axios.post(`/api/plugins/${action}`, {
plugin_name: plugin.module_name
})
.then(response => {
if (response.data.success) {
this.$message.success(`${actionText}插件成功`);
this.loadPlugins();
} else {
this.$message.error(response.data.message || `${actionText}插件失败`);
}
})
.catch(error => {
console.error(`${actionText}插件出错:`, error);
this.$message.error(`${actionText}插件出错`);
});
}).catch(() => {
this.$message.info('已取消操作');
});
},
reloadPlugin(plugin) {
this.$confirm(`确定要重载插件 "${plugin.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
axios.post('/api/plugins/reload', {
plugin_name: plugin.module_name
})
.then(response => {
if (response.data.success) {
this.$message.success('重载插件成功');
this.loadPlugins();
} else {
this.$message.error(response.data.message || '重载插件失败');
}
})
.catch(error => {
console.error('重载插件出错:', error);
this.$message.error('重载插件出错');
});
}).catch(() => {
this.$message.info('已取消操作');
});
},
editConfig() {
this.isEditingConfig = true;
this.editedConfig = this.selectedPlugin.configText || '';
this.configError = '';
},
cancelEditConfig() {
this.isEditingConfig = false;
this.editedConfig = '';
this.configError = '';
},
saveConfig() {
try {
axios.post('/api/plugins/config/update', {
plugin_name: this.selectedPlugin.module_name,
config_text: this.editedConfig,
format: 'toml'
})
.then(response => {
if (response.data.success) {
this.$message.success('配置保存成功');
this.isEditingConfig = false;
this.selectedPlugin.configText = this.editedConfig;
// 保存成功后立即重新拉取详情:
// 1. 同步刷新治理诊断、配置概览和内存中的插件配置快照;
// 2. 避免页面上继续停留在旧的健康状态;
// 3. 这样后续是否重载插件,用户都能先看到“配置文本已通过校验并落盘”。
this.showPluginInfo(this.selectedPlugin);
this.$confirm('配置已保存,是否要重载插件以应用新配置?', '提示', {
confirmButtonText: '重载插件',
cancelButtonText: '稍后手动重载',
type: 'info'
}).then(() => {
this.reloadPlugin(this.selectedPlugin);
}).catch(() => {
this.$message.info('您可以稍后手动重载插件以应用新配置');
});
} else {
this.configError = response.data.message || '保存配置失败';
}
})
.catch(error => {
console.error('保存配置出错:', error);
this.configError = '保存配置出错: ' + (error.response?.data?.message || error.message);
});
} catch (e) {
this.configError = '处理配置时出错: ' + e.message;
}
},
showPluginInfo(plugin) {
axios.get(`/api/plugins/info?plugin_name=${plugin.module_name}`)
.then(response => {
if (response.data.success) {
this.selectedPlugin = response.data.data;
if (this.selectedPlugin.configText) {
this.pluginInfoVisible = true;
this.isEditingConfig = false;
this.editedConfig = '';
this.configError = '';
return;
}
axios.get(`/api/plugins/config/raw?plugin_name=${plugin.module_name}`)
.then(configResponse => {
if (configResponse.data.success) {
this.selectedPlugin.configText = configResponse.data.data;
this.configFormat = configResponse.data.format || 'toml';
} else {
this.selectedPlugin.configText = '# 无法获取原始配置文件';
console.error('获取配置文件失败:', configResponse.data.message);
}
this.pluginInfoVisible = true;
this.isEditingConfig = false;
this.editedConfig = '';
this.configError = '';
})
.catch(error => {
console.error('获取配置文件出错:', error);
this.selectedPlugin.configText = '# 获取配置文件时出错';
this.pluginInfoVisible = true;
});
} else {
this.$message.error(response.data.message || '获取插件详情失败');
}
})
.catch(error => {
console.error('获取插件详情出错:', error);
this.$message.error('获取插件详情出错');
});
},
showPluginGroupStatus(plugin) {
// 打开弹窗前先进入加载态,避免用户在慢接口场景下看到旧数据。
this.pluginGroupStatusVisible = true;
this.groupStatusLoading = true;
this.pluginGroupStatusData = null;
// 记录当前正在查看群状态的插件,供“开启/关闭后刷新”复用。
this.currentGroupStatusPlugin = plugin;
// 统一使用插件模块名查询,和启用/禁用/重载接口参数保持一致。
return axios.get('/api/plugins/group_status', {
params: {
plugin_name: plugin.module_name
}
})
.then(response => {
if (response.data.success) {
// 接口返回已按“已开启/未开启”拆分好,前端仅做展示。
this.pluginGroupStatusData = response.data.data || {
enabled_groups: [],
disabled_groups: [],
enabled_count: 0,
disabled_count: 0,
total_group_count: 0,
supports_group_switch: false
};
} else {
this.$message.error(response.data.message || '获取插件群状态失败');
this.pluginGroupStatusVisible = false;
}
})
.catch(error => {
console.error('获取插件群状态出错:', error);
this.$message.error('获取插件群状态出错');
this.pluginGroupStatusVisible = false;
})
.finally(() => {
this.groupStatusLoading = false;
});
},
togglePluginGroupSwitch(group, enable) {
if (!this.currentGroupStatusPlugin || !group || !group.group_id) {
this.$message.error('缺少必要参数,无法切换');
return;
}
const actionText = enable ? '开启' : '关闭';
const pluginName = this.currentGroupStatusPlugin.name || this.currentGroupStatusPlugin.module_name;
const groupName = group.group_name || group.group_id;
this.$confirm(`确定要${actionText}群 "${groupName}" 的插件 "${pluginName}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.groupStatusLoading = true;
// 调用后端切换接口,后端内部复用 GroupBotManager 的原有缓存+Redis策略。
axios.post('/api/plugins/group_status/toggle', {
plugin_name: this.currentGroupStatusPlugin.module_name,
group_id: group.group_id,
status: enable ? 'enabled' : 'disabled'
})
.then(response => {
if (response.data.success) {
this.$message.success(`${actionText}成功`);
// 切换后重新拉取当前插件群状态,保证统计与列表一致。
return this.showPluginGroupStatus(this.currentGroupStatusPlugin);
} else {
this.$message.error(response.data.message || `${actionText}失败`);
}
})
.catch(error => {
console.error(`${actionText}插件群状态出错:`, error);
this.$message.error(`${actionText}插件群状态出错`);
})
.finally(() => {
this.groupStatusLoading = false;
});
}).catch(() => {
this.$message.info('已取消操作');
});
}
}
});
</script>
<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.9));
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;
}
.overview-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 h3 { font-size: 18px; margin-bottom: 4px; }
.workspace-header p { font-size: 13px; color: #64748b; }
.entity-cell { display: flex; align-items: center; gap: 12px; }
.entity-badge {
width: 30px; height: 30px; 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;
}
.entity-title { font-size: 14px; font-weight: 600; color: #0f172a; }
.entity-subtitle { margin-top: 4px; font-size: 12px; color: #94a3b8; }
.action-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.plugin-detail-container { max-height: 70vh; overflow-y: auto; }
.plugin-descriptions { width: 100%; }
.dialog-intro { margin-bottom: 14px; color: #64748b; font-size: 13px; }
.config-container {
max-height: 320px; overflow-y: auto; background: rgba(248,250,252,0.82); border: 1px solid rgba(148,163,184,0.12);
border-radius: 14px; padding: 12px; font-size: 12px; color: #334155;
}
.config-container pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
.command-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.command-tags--compact { justify-content: center; }
.command-tag { margin: 0 !important; }
.config-actions { margin-bottom: 10px; display: flex; gap: 10px; }
.config-editor { font-family: monospace; font-size: 12px; }
.config-error { color: #ef4444; font-size: 12px; margin-top: 5px; }
.governance-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.governance-note {
font-size: 11px;
color: #94a3b8;
line-height: 1.4;
}
.config-overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.config-overview-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(255,255,255,0.72);
border: 1px solid rgba(148,163,184,0.12);
}
.config-overview-label {
font-size: 12px;
color: #64748b;
}
.config-overview-value {
font-size: 14px;
font-weight: 600;
color: #0f172a;
}
.diagnostic-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.diagnostic-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
border-radius: 12px;
background: rgba(248,250,252,0.82);
border: 1px solid rgba(148,163,184,0.12);
}
.diagnostic-text {
flex: 1;
font-size: 13px;
color: #334155;
line-height: 1.6;
word-break: break-word;
}
.plugin-group-status-dialog { min-height: 240px; }
.mobile-plugin-list,
.mobile-group-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-plugin-card,
.mobile-group-card {
border-radius: 16px;
}
.mobile-plugin-card__header,
.mobile-group-card__actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.mobile-plugin-card__header {
margin-bottom: 12px;
}
.mobile-plugin-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin-bottom: 10px;
font-size: 12px;
color: #64748b;
}
.mobile-plugin-card__desc,
.mobile-group-card__id {
font-size: 13px;
line-height: 1.7;
color: #475569;
word-break: break-word;
}
.mobile-plugin-card__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.mobile-group-card__title {
font-size: 14px;
font-weight: 600;
color: #0f172a;
margin-bottom: 6px;
word-break: break-word;
}
.mobile-group-card__actions {
margin-top: 12px;
justify-content: flex-end;
}
.mobile-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;
}
.group-status-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
gap: 10px;
flex-wrap: wrap;
}
.group-status-title { font-size: 16px; font-weight: 600; color: #0f172a; }
.group-status-subtitle { margin-left: 8px; font-size: 12px; color: #64748b; font-weight: 500; }
.group-status-summary { display: flex; align-items: center; gap: 8px; }
.group-status-alert { margin-bottom: 12px; }
.group-status-card { border-radius: 12px; }
.group-status-card-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
color: #334155;
}
/* 小屏重点做三件事:
1. 让头部和操作区纵向排布,避免按钮把容器撑宽;
2. 让弹窗内容与卡片内边距缩小,提升可视面积;
3. 让配置编辑区和标签内容可换行,不再出现页面级横向滚动。 */
@media (max-width: 768px) {
.page-hero {
flex-direction: column;
align-items: stretch;
padding: 18px 16px;
border-radius: 20px;
}
.page-hero-copy h1 {
font-size: 24px;
}
.page-hero-actions .el-button {
width: 100%;
}
.workspace-header {
flex-direction: column;
align-items: flex-start;
}
.workspace-card .el-card__body,
.group-status-card .el-card__body {
padding: 14px;
}
.entity-cell {
align-items: flex-start;
}
.entity-copy,
.group-status-title {
min-width: 0;
}
.entity-title,
.entity-subtitle,
.group-status-title,
.group-status-subtitle {
word-break: break-word;
}
.plugin-detail-container {
max-height: 72vh;
}
.config-container {
padding: 10px;
}
.config-actions {
flex-wrap: wrap;
}
.mobile-plugin-card__header {
flex-direction: column;
}
.mobile-plugin-card__actions .el-button,
.mobile-group-card__actions .el-button {
flex: 1 1 calc(50% - 8px);
min-width: 0;
margin-left: 0 !important;
}
.group-status-summary {
flex-wrap: wrap;
}
}
</style>
{% endblock %}