feat(群级配置): 新增MySQL+Redis持久缓存并接入进群欢迎差异化配置

新增群级插件配置表与服务层,采用MySQL持久化+Redis长期缓存(TTL=-1);后台新增群级插件配置管理页面与API,支持按群按插件维护JSON配置并在修改后同步回填MySQL和刷新Redis;已将群成员变更监控插件接入该配置,支持欢迎文案与卡片URL等按群差异化。
This commit is contained in:
liuwei
2026-04-20 10:42:46 +08:00
parent 6cf90c02e5
commit d4b7cb32f6
8 changed files with 650 additions and 6 deletions

View File

@@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}群级插件配置 - 机器人管理后台{% endblock %}
{% block content %}
<div class="page-shell">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">Group Plugin Config</div>
<h1>群级插件配置</h1>
<p>后台维护按群差异化配置文案、URL、发送内容。保存时写入 MySQL 并刷新 Redis 缓存TTL 永久)。</p>
</div>
<div class="page-hero-actions">
<el-button type="success" @click="loadRows">刷新</el-button>
<el-button type="primary" @click="openCreate">新增配置</el-button>
</div>
</div>
<el-card shadow="hover" style="margin-bottom: 14px;">
<el-form inline>
<el-form-item label="群">
<el-select v-model="filters.group_id" filterable clearable placeholder="全部群" style="width:280px" @change="loadRows">
<el-option v-for="item in groupOptions" :key="item.wxid" :label="item.name" :value="item.wxid"></el-option>
</el-select>
</el-form-item>
<el-form-item label="插件">
<el-select v-model="filters.plugin_name" filterable clearable placeholder="全部插件" style="width:220px" @change="loadRows">
<el-option v-for="name in pluginOptions" :key="name" :label="name" :value="name"></el-option>
</el-select>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="hover">
<el-table :data="rows" style="width:100%" v-loading="loading">
<el-table-column prop="id" label="ID" width="70"></el-table-column>
<el-table-column prop="group_id" label="群ID" min-width="190"></el-table-column>
<el-table-column prop="plugin_name" label="插件" min-width="140"></el-table-column>
<el-table-column prop="config_key" label="配置键" width="120"></el-table-column>
<el-table-column label="启用" width="90">
<template slot-scope="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'info'">{% raw %}{{ scope.row.enabled ? '是' : '否' }}{% endraw %}</el-tag>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="90"></el-table-column>
<el-table-column prop="updated_by" label="更新人" width="120"></el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180"></el-table-column>
<el-table-column label="配置预览" min-width="260">
<template slot-scope="scope">
<pre class="detail-pre">{% raw %}{{ JSON.stringify(scope.row.config_json || {}, null, 2) }}{% endraw %}</pre>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template slot-scope="scope">
<el-button size="mini" type="primary" plain @click="openEdit(scope.row)">编辑</el-button>
<el-button size="mini" type="danger" plain @click="removeRow(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog :title="editing ? '编辑配置' : '新增配置'" :visible.sync="dialogVisible" width="720px">
<el-form label-width="95px">
<el-form-item label="群ID">
<el-select v-model="form.group_id" filterable placeholder="请选择群" style="width:100%">
<el-option v-for="item in groupOptions" :key="item.wxid" :label="item.name" :value="item.wxid"></el-option>
</el-select>
</el-form-item>
<el-form-item label="插件">
<el-select v-model="form.plugin_name" filterable placeholder="请选择插件" style="width:100%">
<el-option v-for="name in pluginOptions" :key="name" :label="name" :value="name"></el-option>
</el-select>
</el-form-item>
<el-form-item label="配置键">
<el-input v-model="form.config_key" placeholder="default"></el-input>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled"></el-switch>
</el-form-item>
<el-form-item label="JSON配置">
<el-input type="textarea" :rows="12" v-model="form.config_json_text"></el-input>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="dialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveForm">保存</el-button>
</div>
</el-dialog>
</div>
{% endblock %}
{% block scripts %}
<script>
new Vue({
el: '#app',
mixins: [baseApp],
data() {
return {
loading: false,
rows: [],
groupOptions: [],
pluginOptions: [],
filters: { group_id: '', plugin_name: '' },
dialogVisible: false,
editing: false,
form: {
group_id: '',
plugin_name: '',
config_key: 'default',
enabled: true,
config_json_text: '{}'
}
}
},
mounted() {
this.loadGroups()
this.loadPlugins()
this.loadRows()
},
methods: {
async loadGroups() {
const resp = await axios.get('/contacts/api/groups')
const groups = (resp.data && resp.data.data && resp.data.data.groups) || {}
this.groupOptions = Object.entries(groups).map(([wxid, name]) => ({ wxid, name: String(name || wxid) }))
},
async loadPlugins() {
const resp = await axios.get('/group_plugin_config/api/plugins')
if (resp.data && resp.data.success) {
this.pluginOptions = resp.data.data || []
}
},
async loadRows() {
this.loading = true
try {
const resp = await axios.get('/group_plugin_config/api/list', { params: this.filters })
if (resp.data && resp.data.success) {
this.rows = resp.data.data || []
}
} finally {
this.loading = false
}
},
openCreate() {
this.editing = false
this.form = {
group_id: '',
plugin_name: '',
config_key: 'default',
enabled: true,
config_json_text: '{}'
}
this.dialogVisible = true
},
openEdit(row) {
this.editing = true
this.form = {
group_id: row.group_id || '',
plugin_name: row.plugin_name || '',
config_key: row.config_key || 'default',
enabled: !!row.enabled,
config_json_text: JSON.stringify(row.config_json || {}, null, 2)
}
this.dialogVisible = true
},
async saveForm() {
let parsed = {}
try {
parsed = JSON.parse(this.form.config_json_text || '{}')
} catch (e) {
this.$message.error('JSON 配置格式错误')
return
}
const payload = {
group_id: this.form.group_id,
plugin_name: this.form.plugin_name,
config_key: this.form.config_key || 'default',
enabled: !!this.form.enabled,
config_json: parsed,
updated_by: 'dashboard'
}
const resp = await axios.post('/group_plugin_config/api/upsert', payload)
if (resp.data && resp.data.success) {
this.$message.success(resp.data.message || '保存成功')
this.dialogVisible = false
await this.loadRows()
return
}
this.$message.error((resp.data && resp.data.message) || '保存失败')
},
async removeRow(row) {
try {
await this.$confirm('确认删除该配置吗?', '提示', { type: 'warning' })
} catch (e) {
return
}
const resp = await axios.post('/group_plugin_config/api/delete', {
group_id: row.group_id,
plugin_name: row.plugin_name,
config_key: row.config_key
})
if (resp.data && resp.data.success) {
this.$message.success(resp.data.message || '删除成功')
await this.loadRows()
return
}
this.$message.error((resp.data && resp.data.message) || '删除失败')
}
}
})
</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,.10), rgba(59,130,246,.08), rgba(255,255,255,.9));border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 40px rgba(15,23,42,.06)}
.page-hero-actions{display:flex;align-items:center;gap:12px}
.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}
.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;max-height:180px;overflow:auto}
</style>
{% endblock %}