优化响应指令管理菜单与动作配置交互
1. 将插件导航菜单名称调整为响应指令管理。 2. 将响应动作编辑从手写JSON改为结构化表单,按文本图片语音视频卡片App分类型维护字段。 3. 新增动作类型切换默认值、字段校验与提交映射逻辑,降低维护成本与配置出错率。
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user