优化响应指令管理菜单与动作配置交互

1. 将插件导航菜单名称调整为响应指令管理。

2. 将响应动作编辑从手写JSON改为结构化表单,按文本图片语音视频卡片App分类型维护字段。

3. 新增动作类型切换默认值、字段校验与提交映射逻辑,降低维护成本与配置出错率。
This commit is contained in:
liuwei
2026-04-23 13:09:11 +08:00
parent d61fb8bc8a
commit 23b9d76b06
2 changed files with 159 additions and 65 deletions

View File

@@ -1011,7 +1011,7 @@
{ label: '插件管理', path: '/plugins_manage' },
{ label: '插件定时任务', path: '/plugin_schedules' },
{ label: '群级插件配置', path: '/group_plugin_config' },
{ label: '趣味指令剧本', path: '/fun_command_rules' },
{ label: '响应指令管理', path: '/fun_command_rules' },
{ label: '接口文档', path: '/api_docs' }
]
},

View File

@@ -1,14 +1,14 @@
{% extends "base.html" %}
{% block title %}趣味指令剧本 - 机器人管理后台{% endblock %}
{% block title %}响应指令管理 - 机器人管理后台{% endblock %}
{% block content %}
<div class="page-shell">
<div class="page-hero">
<div class="page-hero-copy">
<div class="page-eyebrow">Fun Script Rules</div>
<h1>趣味指令剧本</h1>
<p>把“文本关键词/拍一拍事件”映射成可编排的多媒体回应,持续沉淀你的机器人玩法库</p>
<div class="page-eyebrow">Response Command Center</div>
<h1>响应指令管理</h1>
<p>把“文本关键词/拍一拍事件”映射成可编排的多媒体回应,用 UI 维护玩法,不再依赖手写 JSON</p>
</div>
<div class="page-hero-actions">
<el-button type="success" @click="loadRows">刷新</el-button>
@@ -113,10 +113,10 @@
</el-alert>
</el-card>
<el-dialog :title="editing ? '编辑规则' : '新增规则'" :visible.sync="dialogVisible" width="860px">
<el-dialog :title="editing ? '编辑规则' : '新增规则'" :visible.sync="dialogVisible" width="980px">
<el-form label-width="110px">
<el-form-item label="规则名称">
<el-input v-model="form.rule_name" placeholder="例如:开场梗回复"></el-input>
<el-input v-model="form.rule_name" placeholder="例如:拍一拍抖机灵"></el-input>
</el-form-item>
<el-form-item label="作用域类型">
<el-select v-model="form.scope_type" style="width:180px">
@@ -132,7 +132,7 @@
<el-input v-else v-model="form.scope_id" placeholder="私聊用户 wxid"></el-input>
</el-form-item>
<el-form-item label="触发类型">
<el-select v-model="form.trigger_type" style="width:200px">
<el-select v-model="form.trigger_type" style="width:220px">
<el-option label="精确匹配" value="exact"></el-option>
<el-option label="前缀匹配" value="prefix"></el-option>
<el-option label="包含匹配" value="contains"></el-option>
@@ -169,15 +169,14 @@
<el-form-item label="响应动作">
<div class="action-toolbar">
<el-button size="mini" type="primary" plain @click="addAction">新增动作</el-button>
<el-button size="mini" @click="formatActionsJson">格式化JSON</el-button>
</div>
<el-table :data="form.responses_json" size="mini" border style="width:100%">
<el-table-column label="#" width="60">
<template slot-scope="scope">{% raw %}{{ scope.$index + 1 }}{% endraw %}</template>
</el-table-column>
<el-table-column label="类型" width="140">
<el-table-column label="类型" width="150">
<template slot-scope="scope">
<el-select v-model="scope.row.type" style="width:120px">
<el-select v-model="scope.row.type" style="width:130px" @change="onActionTypeChange(scope.row)">
<el-option label="文本" value="text"></el-option>
<el-option label="图片" value="image"></el-option>
<el-option label="语音" value="voice"></el-option>
@@ -192,9 +191,43 @@
<el-input-number v-model="scope.row.delay_ms" :min="0" :max="60000" :step="100"></el-input-number>
</template>
</el-table-column>
<el-table-column label="动作内容(JSON)" min-width="360">
<el-table-column label="参数配置" min-width="500">
<template slot-scope="scope">
<el-input type="textarea" :rows="3" v-model="scope.row.payload_text" placeholder='示例: {"text":"你好"}'></el-input>
<div class="action-config">
<template v-if="scope.row.type === 'text'">
<el-input v-model="scope.row.text" placeholder="发送文本内容"></el-input>
</template>
<template v-else-if="scope.row.type === 'image'">
<el-input v-model="scope.row.path" placeholder="图片文件路径,例如 D:/learn/abot/static/uploads/a.jpg"></el-input>
</template>
<template v-else-if="scope.row.type === 'voice'">
<el-input v-model="scope.row.path" placeholder="语音文件路径"></el-input>
<el-select v-model="scope.row.format" style="width:120px;margin-top:6px;">
<el-option label="mp3" value="mp3"></el-option>
<el-option label="wav" value="wav"></el-option>
<el-option label="amr" value="amr"></el-option>
</el-select>
</template>
<template v-else-if="scope.row.type === 'video'">
<el-input v-model="scope.row.path" placeholder="视频文件路径"></el-input>
<el-input v-model="scope.row.cover_path" style="margin-top:6px;" placeholder="封面路径(可选)"></el-input>
</template>
<template v-else-if="scope.row.type === 'link'">
<el-input v-model="scope.row.title" placeholder="卡片标题"></el-input>
<el-input v-model="scope.row.desc" style="margin-top:6px;" placeholder="卡片描述"></el-input>
<el-input v-model="scope.row.url" style="margin-top:6px;" placeholder="卡片跳转URL必填"></el-input>
<el-input v-model="scope.row.thumb_url" style="margin-top:6px;" placeholder="卡片缩略图URL可选"></el-input>
</template>
<template v-else-if="scope.row.type === 'app'">
<el-input type="textarea" :rows="3" v-model="scope.row.xml" placeholder="App XML 内容"></el-input>
<el-input-number v-model="scope.row.app_type" :min="0" :max="1000" style="margin-top:6px;"></el-input-number>
</template>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="90">
@@ -205,9 +238,7 @@
</el-table>
<div class="payload-tip">
支持占位符:<code>{sender}</code> <code>{roomid}</code> <code>{event}</code>
常见示例:
<code>text -> {"text":"你拍了拍我,我就拍回去~"}</code>
<code>link -> {"title":"今日梗图","desc":"点开看","url":"https://example.com","thumb_url":""}</code>
示例:文本里可写 <code>你拍了拍我:{sender}</code>
</div>
</el-form-item>
</el-form>
@@ -263,77 +294,139 @@ new Vue({
const map = { global: '全局', group: '群聊', private: '私聊' }
return map[scopeType] || scopeType
},
defaultPayloadTextByType(type) {
const table = {
text: '{"text":""}',
image: '{"path":"D:/learn/abot/static/uploads/demo.jpg"}',
voice: '{"path":"D:/learn/abot/static/uploads/demo.mp3","format":"mp3"}',
video: '{"path":"D:/learn/abot/static/uploads/demo.mp4"}',
link: '{"title":"","desc":"","url":"https://example.com","thumb_url":""}',
app: '{"xml":"<appmsg></appmsg>","app_type":0}'
// 创建结构化动作对象:所有字段都明确成表单项,避免 JSON 字符串编辑。
createActionRow(type = 'text') {
const base = {
type,
delay_ms: 0,
text: '',
path: '',
format: 'mp3',
cover_path: '',
title: '',
desc: '',
url: '',
thumb_url: '',
xml: '',
app_type: 0
}
return table[type] || '{"text":""}'
if (type === 'text') {
base.text = ''
} else if (type === 'image') {
base.path = 'D:/learn/abot/static/uploads/demo.jpg'
} else if (type === 'voice') {
base.path = 'D:/learn/abot/static/uploads/demo.mp3'
base.format = 'mp3'
} else if (type === 'video') {
base.path = 'D:/learn/abot/static/uploads/demo.mp4'
} else if (type === 'link') {
base.url = 'https://example.com'
} else if (type === 'app') {
base.xml = '<appmsg></appmsg>'
base.app_type = 0
}
return base
},
addAction() {
this.form.responses_json.push({
type: 'text',
delay_ms: 0,
payload_text: this.defaultPayloadTextByType('text')
})
this.form.responses_json.push(this.createActionRow('text'))
},
onActionTypeChange(row) {
// 切换类型时重置为该类型默认结构,保留延迟值,减少“字段残留”导致的误配置。
const delayMs = Number((row && row.delay_ms) || 0)
const next = this.createActionRow(String((row && row.type) || 'text'))
next.delay_ms = delayMs
Object.assign(row, next)
},
removeAction(index) {
this.form.responses_json.splice(index, 1)
},
formatActionsJson() {
this.form.responses_json = (this.form.responses_json || []).map(item => {
let payloadObj = {}
try {
payloadObj = JSON.parse(item.payload_text || '{}')
} catch (e) {
payloadObj = {}
}
return {
...item,
payload_text: JSON.stringify(payloadObj, null, 2)
}
})
this.$message.success('动作 JSON 已格式化')
},
// 把结构化表单转换为后端动作数组。
normalizeActionsForSubmit() {
const actions = []
for (const item of (this.form.responses_json || [])) {
if (!item || !item.type) continue
let payloadObj = {}
try {
payloadObj = JSON.parse(item.payload_text || '{}')
} catch (e) {
throw new Error(`动作 payload JSON 格式错误: ${item.payload_text || ''}`)
for (const row of (this.form.responses_json || [])) {
if (!row || !row.type) continue
const type = String(row.type || '').toLowerCase()
const action = {
type,
delay_ms: Number(row.delay_ms || 0)
}
const action = {
type: String(item.type || '').toLowerCase(),
delay_ms: Number(item.delay_ms || 0)
if (type === 'text') {
if (!String(row.text || '').trim()) {
throw new Error('文本动作的内容不能为空')
}
action.text = String(row.text || '').trim()
} else if (type === 'image') {
if (!String(row.path || '').trim()) {
throw new Error('图片动作的路径不能为空')
}
action.path = String(row.path || '').trim()
} else if (type === 'voice') {
if (!String(row.path || '').trim()) {
throw new Error('语音动作的路径不能为空')
}
action.path = String(row.path || '').trim()
action.format = String(row.format || 'mp3').trim().toLowerCase() || 'mp3'
} else if (type === 'video') {
if (!String(row.path || '').trim()) {
throw new Error('视频动作的视频路径不能为空')
}
action.path = String(row.path || '').trim()
if (String(row.cover_path || '').trim()) {
action.cover_path = String(row.cover_path || '').trim()
}
} else if (type === 'link') {
if (!String(row.url || '').trim()) {
throw new Error('卡片动作的 URL 不能为空')
}
action.title = String(row.title || '').trim()
action.desc = String(row.desc || '').trim()
action.url = String(row.url || '').trim()
action.thumb_url = String(row.thumb_url || '').trim()
} else if (type === 'app') {
if (!String(row.xml || '').trim()) {
throw new Error('App 动作的 XML 不能为空')
}
action.xml = String(row.xml || '').trim()
action.app_type = Number(row.app_type || 0)
}
Object.assign(action, payloadObj)
actions.push(action)
}
if (!actions.length) {
throw new Error('至少配置一条响应动作')
}
return actions
},
// 把后端动作数组反向映射到结构化表单。
mapActionsForEdit(actions) {
return (actions || []).map(action => {
const item = { ...action }
const type = String(item.type || 'text').toLowerCase()
const delayMs = Number(item.delay_ms || 0)
delete item.type
delete item.delay_ms
return {
type,
delay_ms: delayMs,
payload_text: JSON.stringify(item, null, 2)
const type = String((action && action.type) || 'text').toLowerCase()
const row = this.createActionRow(type)
row.delay_ms = Number((action && action.delay_ms) || 0)
if (type === 'text') {
row.text = String((action && action.text) || '')
} else if (type === 'image') {
row.path = String((action && action.path) || '')
} else if (type === 'voice') {
row.path = String((action && action.path) || '')
row.format = String((action && action.format) || 'mp3').toLowerCase()
} else if (type === 'video') {
row.path = String((action && action.path) || '')
row.cover_path = String((action && action.cover_path) || '')
} else if (type === 'link') {
row.title = String((action && action.title) || '')
row.desc = String((action && action.desc) || '')
row.url = String((action && action.url) || '')
row.thumb_url = String((action && action.thumb_url) || '')
} else if (type === 'app') {
row.xml = String((action && action.xml) || '')
row.app_type = Number((action && action.app_type) || 0)
}
return row
})
},
async loadGroups() {
@@ -498,6 +591,7 @@ new Vue({
.trigger-type{font-size:12px;color:#0f766e;font-weight:600}
.trigger-text{font-size:13px;color:#334155;word-break:break-all}
.action-toolbar{display:flex;gap:8px;margin-bottom:8px}
.action-config{display:flex;flex-direction:column;gap:0}
.payload-tip{margin-top:8px;color:#64748b;font-size:12px;line-height:1.7}
.payload-tip code{background:#f1f5f9;border:1px solid #dbe3ee;padding:1px 6px;border-radius:6px;margin-right:6px}
</style>