Files
abot/admin/dashboard/templates/group_plugin_config.html
liuwei d4732d79ee feat(群级配置): 新增结构化表单模式降低JSON维护门槛
- 为群成员变更监控/welcome 配置新增标准表单编辑模式,覆盖欢迎文本与卡片关键字段
- 保留高级JSON模式,支持标准表单与JSON双模式切换
- 新增变量提示与实时预览,便于运营同学所见即所得配置文案
- 增加URL与必填项校验,保存前拦截常见配置错误
- 标准表单字段变更实时同步JSON文本,确保两种模式数据一致
2026-04-20 10:48:31 +08:00

400 lines
19 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">
<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="编辑模式" v-if="isWelcomeTemplateForm">
<el-radio-group v-model="editorMode">
<el-radio-button label="simple">标准表单</el-radio-button>
<el-radio-button label="advanced">高级JSON</el-radio-button>
</el-radio-group>
</el-form-item>
<template v-if="isWelcomeTemplateForm && editorMode === 'simple'">
<div class="form-tip">
变量支持:<code>{nickname}</code> <code>{wxid}</code> <code>{group_id}</code> <code>{now}</code> <code>{head_url}</code>
</div>
<el-form-item label="文本欢迎">
<el-switch v-model="simpleWelcome.welcome_text_enabled"></el-switch>
</el-form-item>
<el-form-item label="文本模板">
<el-input v-model="simpleWelcome.welcome_text_template" placeholder="👏欢迎 {nickname} 加入群聊!🎉"></el-input>
</el-form-item>
<el-form-item label="欢迎卡片">
<el-switch v-model="simpleWelcome.welcome_card_enabled"></el-switch>
</el-form-item>
<el-form-item label="卡片标题">
<el-input v-model="simpleWelcome.card_title_template" placeholder="👏欢迎 {nickname} 加入群聊!🎉"></el-input>
</el-form-item>
<el-form-item label="卡片描述">
<el-input v-model="simpleWelcome.card_desc_template" placeholder="⌚时间:{now}"></el-input>
</el-form-item>
<el-form-item label="卡片URL">
<el-input v-model="simpleWelcome.card_url" placeholder="https://example.com/welcome?gid={group_id}"></el-input>
</el-form-item>
<el-form-item label="缩略图URL">
<el-input v-model="simpleWelcome.card_thumb_url" placeholder="{head_url}"></el-input>
</el-form-item>
<el-form-item label="效果预览">
<div class="preview-box">
<p><strong>文本:</strong>{% raw %}{{ previewWelcomeText }}{% endraw %}</p>
<p><strong>标题:</strong>{% raw %}{{ previewCardTitle }}{% endraw %}</p>
<p><strong>描述:</strong>{% raw %}{{ previewCardDesc }}{% endraw %}</p>
<p><strong>URL</strong>{% raw %}{{ previewCardUrl }}{% endraw %}</p>
</div>
</el-form-item>
</template>
<el-form-item label="JSON配置" v-if="!isWelcomeTemplateForm || editorMode === 'advanced'">
<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,
editorMode: 'simple',
// 标准表单模型:用于“群成员变更监控 + welcome”的结构化配置编辑。
simpleWelcome: {
welcome_text_enabled: true,
welcome_text_template: '👏欢迎 {nickname} 加入群聊!🎉',
welcome_card_enabled: true,
card_title_template: '👏欢迎 {nickname} 加入群聊!🎉',
card_desc_template: '⌚时间:{now}',
card_url: 'https://newsnow.busiyi.world/',
card_thumb_url: '{head_url}'
},
form: {
group_id: '',
plugin_name: '',
config_key: 'default',
enabled: true,
config_json_text: '{}'
}
}
},
mounted() {
this.loadGroups()
this.loadPlugins()
this.loadRows()
},
computed: {
// 仅当命中当前已接入模板时启用标准表单其它插件继续使用JSON高级模式。
isWelcomeTemplateForm() {
return this.form.plugin_name === '群成员变更监控' && this.form.config_key === 'welcome'
},
previewVariables() {
return {
nickname: '张三',
wxid: 'wxid_demo_123',
group_id: this.form.group_id || '123456@chatroom',
now: '2026-04-20 12:00:00',
head_url: 'https://example.com/avatar.png'
}
},
previewWelcomeText() {
return this.renderTemplate(this.simpleWelcome.welcome_text_template)
},
previewCardTitle() {
return this.renderTemplate(this.simpleWelcome.card_title_template)
},
previewCardDesc() {
return this.renderTemplate(this.simpleWelcome.card_desc_template)
},
previewCardUrl() {
return this.renderTemplate(this.simpleWelcome.card_url)
}
},
watch: {
// 当插件/配置键切换到结构化模板时自动从JSON回填表单减少手工搬运。
'form.plugin_name'() {
this.onTemplateTypeChanged()
},
'form.config_key'() {
this.onTemplateTypeChanged()
},
// 在标准表单模式下任何字段变化都实时同步到JSON文本保证两种模式数据一致。
simpleWelcome: {
handler() {
if (this.isWelcomeTemplateForm && this.editorMode === 'simple') {
this.form.config_json_text = JSON.stringify(this.simpleWelcome, null, 2)
}
},
deep: true
}
},
methods: {
renderTemplate(template) {
let result = String(template || '')
Object.entries(this.previewVariables).forEach(([k, v]) => {
result = result.replace(new RegExp(`\\{${k}\\}`, 'g'), String(v || ''))
})
return result
},
applyWelcomeDefaults() {
this.simpleWelcome = {
welcome_text_enabled: true,
welcome_text_template: '👏欢迎 {nickname} 加入群聊!🎉',
welcome_card_enabled: true,
card_title_template: '👏欢迎 {nickname} 加入群聊!🎉',
card_desc_template: '⌚时间:{now}',
card_url: 'https://newsnow.busiyi.world/',
card_thumb_url: '{head_url}'
}
this.form.config_json_text = JSON.stringify(this.simpleWelcome, null, 2)
},
onTemplateTypeChanged() {
if (!this.isWelcomeTemplateForm) {
this.editorMode = 'advanced'
return
}
this.editorMode = 'simple'
let parsed = {}
try {
parsed = JSON.parse(this.form.config_json_text || '{}')
} catch (e) {
parsed = {}
}
this.simpleWelcome = {
welcome_text_enabled: parsed.welcome_text_enabled !== undefined ? !!parsed.welcome_text_enabled : true,
welcome_text_template: String(parsed.welcome_text_template || '👏欢迎 {nickname} 加入群聊!🎉'),
welcome_card_enabled: parsed.welcome_card_enabled !== undefined ? !!parsed.welcome_card_enabled : true,
card_title_template: String(parsed.card_title_template || '👏欢迎 {nickname} 加入群聊!🎉'),
card_desc_template: String(parsed.card_desc_template || '⌚时间:{now}'),
card_url: String(parsed.card_url || 'https://newsnow.busiyi.world/'),
card_thumb_url: String(parsed.card_thumb_url || '{head_url}')
}
this.form.config_json_text = JSON.stringify(this.simpleWelcome, null, 2)
},
validateSimpleWelcome() {
// 结构化模式下做强校验避免无效URL或空模板入库。
if (!this.simpleWelcome.welcome_text_template.trim()) {
this.$message.error('文本模板不能为空')
return false
}
if (!this.simpleWelcome.card_title_template.trim()) {
this.$message.error('卡片标题不能为空')
return false
}
if (!this.simpleWelcome.card_url.trim()) {
this.$message.error('卡片URL不能为空')
return false
}
const renderedUrl = this.renderTemplate(this.simpleWelcome.card_url)
if (!/^https?:\/\//i.test(renderedUrl)) {
this.$message.error('卡片URL必须是 http 或 https 开头')
return false
}
return true
},
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.applyWelcomeDefaults()
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.onTemplateTypeChanged()
this.dialogVisible = true
},
async saveForm() {
let parsed = {}
if (this.isWelcomeTemplateForm && this.editorMode === 'simple') {
if (!this.validateSimpleWelcome()) {
return
}
parsed = { ...this.simpleWelcome }
this.form.config_json_text = JSON.stringify(parsed, null, 2)
}
try {
parsed = this.isWelcomeTemplateForm && this.editorMode === 'simple'
? 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}
.form-tip{padding:10px 12px;border-radius:10px;background:#f8fbff;border:1px solid #d9e8f8;color:#4a6179;margin:0 0 12px 0}
.form-tip code{display:inline-block;margin-right:6px;background:#eef6ff;border:1px solid #d2e6ff;color:#12539a;padding:1px 6px;border-radius:6px;font-size:12px}
.preview-box{padding:12px;border:1px dashed #c7d8ea;background:#f8fbff;border-radius:10px;color:#3f5c77;line-height:1.7}
.preview-box p{margin:0 0 4px 0}
</style>
{% endblock %}