Files
abot/admin/dashboard/templates/fun_command_rules.html
liuwei 3c7becd94f 响应指令管理支持媒体上传并自动回填路径
1. 新增响应指令管理专用媒体上传接口,按图片语音视频白名单校验并分目录存储。

2. 在动作配置UI中为图片语音视频增加上传按钮,上传成功后自动回填本地绝对路径。

3. 保留结构化动作表单,进一步减少手工维护路径和JSON的场景。
2026-04-23 13:30:17 +08:00

656 lines
32 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">Response Command Center</div>
<h1>响应指令管理</h1>
<p>把“文本关键词/拍一拍事件”映射成可编排的多媒体回应,用 UI 维护玩法,不再依赖手写 JSON。</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.scope_type" clearable placeholder="全部" style="width:160px" @change="loadRows">
<el-option label="全局" value="global"></el-option>
<el-option label="群聊" value="group"></el-option>
<el-option label="私聊" value="private"></el-option>
</el-select>
</el-form-item>
<el-form-item label="启用状态">
<el-select v-model="filters.enabled" clearable placeholder="全部" style="width:150px" @change="loadRows">
<el-option label="启用" value="1"></el-option>
<el-option label="停用" value="0"></el-option>
</el-select>
</el-form-item>
<el-form-item label="作用域ID">
<el-input v-model="filters.scope_id" placeholder="群ID/用户ID" style="width:260px" @keyup.enter.native="loadRows"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" plain @click="loadRows">查询</el-button>
</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="rule_name" label="规则名" min-width="150"></el-table-column>
<el-table-column label="作用域" width="130">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.scope_type === 'global' ? 'info' : 'success'">{% raw %}{{ scopeLabel(scope.row.scope_type) }}{% endraw %}</el-tag>
</template>
</el-table-column>
<el-table-column prop="scope_id" label="作用域ID" min-width="180" show-overflow-tooltip></el-table-column>
<el-table-column label="触发" min-width="210">
<template slot-scope="scope">
<div class="trigger-box">
<div class="trigger-type">{% raw %}{{ scope.row.trigger_type }}{% endraw %}</div>
<div class="trigger-text">{% raw %}{{ scope.row.trigger_type === 'event' ? scope.row.event_key : scope.row.trigger_text }}{% endraw %}</div>
</div>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="90"></el-table-column>
<el-table-column prop="cooldown_seconds" label="冷却(s)" width="90"></el-table-column>
<el-table-column label="启用" width="90">
<template slot-scope="scope">
<el-switch :value="!!scope.row.enabled" @change="toggleEnabled(scope.row, $event)"></el-switch>
</template>
</el-table-column>
<el-table-column label="响应数" width="90">
<template slot-scope="scope">
{% raw %}{{ (scope.row.responses_json || []).length }}{% endraw %}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间" width="180"></el-table-column>
<el-table-column label="操作" width="200">
<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-card shadow="hover" style="margin-top:14px;">
<div slot="header"><strong>规则测试</strong></div>
<el-form inline>
<el-form-item label="作用域">
<el-select v-model="tester.scope_type" style="width:120px">
<el-option label="群聊" value="group"></el-option>
<el-option label="私聊" value="private"></el-option>
<el-option label="全局" value="global"></el-option>
</el-select>
</el-form-item>
<el-form-item label="作用域ID">
<el-input v-model="tester.scope_id" placeholder="群ID/用户ID" style="width:220px"></el-input>
</el-form-item>
<el-form-item label="事件">
<el-select v-model="tester.event_key" clearable placeholder="无" style="width:140px">
<el-option label="PAT(拍一拍)" value="PAT"></el-option>
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="tester.content" placeholder="测试文案" style="width:260px"></el-input>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="testMatch">测试命中</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="testResult"
:type="testResult.matched ? 'success' : 'info'"
:title="testResult.matched ? ('命中规则:#' + testResult.data.id + ' ' + testResult.data.rule_name) : '未命中规则'"
:closable="false">
</el-alert>
</el-card>
<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-form-item>
<el-form-item label="作用域类型">
<el-select v-model="form.scope_type" style="width:180px">
<el-option label="全局" value="global"></el-option>
<el-option label="群聊" value="group"></el-option>
<el-option label="私聊" value="private"></el-option>
</el-select>
</el-form-item>
<el-form-item label="作用域ID" v-if="form.scope_type !== 'global'">
<el-select v-if="form.scope_type === 'group'" v-model="form.scope_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-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:220px">
<el-option label="精确匹配" value="exact"></el-option>
<el-option label="前缀匹配" value="prefix"></el-option>
<el-option label="包含匹配" value="contains"></el-option>
<el-option label="正则匹配" value="regex"></el-option>
<el-option label="事件触发" value="event"></el-option>
</el-select>
</el-form-item>
<el-form-item label="触发文本" v-if="form.trigger_type !== 'event'">
<el-input v-model="form.trigger_text" placeholder="例如:早安"></el-input>
</el-form-item>
<el-form-item label="事件键" v-else>
<el-select v-model="form.event_key" style="width:200px">
<el-option label="PAT(拍一拍)" value="PAT"></el-option>
</el-select>
</el-form-item>
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="优先级">
<el-input-number v-model="form.priority" :min="1" :max="10000"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="冷却秒数">
<el-input-number v-model="form.cooldown_seconds" :min="0" :max="86400"></el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="启用">
<el-switch v-model="form.enabled"></el-switch>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="响应动作">
<div class="action-toolbar">
<el-button size="mini" type="primary" plain @click="addAction">新增动作</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="150">
<template slot-scope="scope">
<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>
<el-option label="视频" value="video"></el-option>
<el-option label="卡片" value="link"></el-option>
<el-option label="App" value="app"></el-option>
</el-select>
</template>
</el-table-column>
<el-table-column label="延迟(ms)" width="120">
<template slot-scope="scope">
<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="参数配置" min-width="500">
<template slot-scope="scope">
<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>
<div class="upload-row">
<el-upload
:show-file-list="false"
:http-request="(options) => uploadActionFile(options, scope.row, 'image')"
accept=".png,.jpg,.jpeg,.gif,.webp">
<el-button size="mini" type="primary" plain style="margin-top:6px;">上传图片并回填</el-button>
</el-upload>
</div>
</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>
<div class="upload-row">
<el-upload
:show-file-list="false"
:http-request="(options) => uploadActionFile(options, scope.row, 'voice')"
accept=".mp3,.wav,.amr">
<el-button size="mini" type="primary" plain style="margin-top:6px;">上传语音并回填</el-button>
</el-upload>
</div>
</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>
<div class="upload-row">
<el-upload
:show-file-list="false"
:http-request="(options) => uploadActionFile(options, scope.row, 'video')"
accept=".mp4,.mov,.m4v">
<el-button size="mini" type="primary" plain style="margin-top:6px;">上传视频并回填</el-button>
</el-upload>
</div>
</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">
<template slot-scope="scope">
<el-button size="mini" type="danger" plain @click="removeAction(scope.$index)"></el-button>
</template>
</el-table-column>
</el-table>
<div class="payload-tip">
支持占位符:<code>{sender}</code> <code>{roomid}</code> <code>{event}</code>
示例:文本里可写 <code>你拍了拍我:{sender}</code>
</div>
</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: [],
filters: { scope_type: '', scope_id: '', enabled: '' },
dialogVisible: false,
editing: false,
editingRuleId: 0,
testResult: null,
tester: {
scope_type: 'group',
scope_id: '',
event_key: '',
content: ''
},
form: {
rule_name: '',
scope_type: 'global',
scope_id: '',
trigger_type: 'exact',
trigger_text: '',
event_key: '',
priority: 100,
cooldown_seconds: 0,
enabled: true,
responses_json: []
}
}
},
mounted() {
this.loadGroups()
this.loadRows()
},
methods: {
scopeLabel(scopeType) {
const map = { global: '全局', group: '群聊', private: '私聊' }
return map[scopeType] || scopeType
},
// 创建结构化动作对象:所有字段都明确成表单项,避免 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
}
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(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)
},
// 把结构化表单转换为后端动作数组。
normalizeActionsForSubmit() {
const actions = []
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)
}
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)
}
actions.push(action)
}
if (!actions.length) {
throw new Error('至少配置一条响应动作')
}
return actions
},
// 把后端动作数组反向映射到结构化表单。
mapActionsForEdit(actions) {
return (actions || []).map(action => {
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 uploadActionFile(uploadOptions, row, mediaType) {
// 统一处理媒体上传:
// 1. 使用后端专用接口保存到固定目录;
// 2. 上传成功后自动把绝对路径回填到当前动作;
// 3. 这样配置者不需要手动复制路径,维护成本更低。
const file = uploadOptions && uploadOptions.file
if (!file) {
this.$message.error('未获取到上传文件')
return
}
const formData = new FormData()
formData.append('file', file)
formData.append('media_type', mediaType)
try {
const resp = await axios.post('/fun_command_rules/api/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
if (resp.data && resp.data.success) {
const path = (((resp.data || {}).data || {}).path) || ''
if (path) {
row.path = path
}
this.$message.success(resp.data.message || '上传成功')
return
}
this.$message.error((resp.data && resp.data.message) || '上传失败')
} catch (error) {
const msg = (error.response && error.response.data && error.response.data.message) || '上传失败'
this.$message.error(msg)
}
},
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 loadRows() {
this.loading = true
try {
const resp = await axios.get('/fun_command_rules/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.editingRuleId = 0
this.form = {
rule_name: '',
scope_type: 'global',
scope_id: '',
trigger_type: 'exact',
trigger_text: '',
event_key: '',
priority: 100,
cooldown_seconds: 0,
enabled: true,
responses_json: []
}
this.addAction()
this.dialogVisible = true
},
openEdit(row) {
this.editing = true
this.editingRuleId = row.id
this.form = {
rule_name: row.rule_name || '',
scope_type: row.scope_type || 'global',
scope_id: row.scope_id || '',
trigger_type: row.trigger_type || 'exact',
trigger_text: row.trigger_text || '',
event_key: row.event_key || '',
priority: Number(row.priority || 100),
cooldown_seconds: Number(row.cooldown_seconds || 0),
enabled: !!row.enabled,
responses_json: this.mapActionsForEdit(row.responses_json || [])
}
if (!this.form.responses_json.length) {
this.addAction()
}
this.dialogVisible = true
},
async saveForm() {
let actions = []
try {
actions = this.normalizeActionsForSubmit()
} catch (e) {
this.$message.error(e.message || '响应动作格式错误')
return
}
const payload = {
...this.form,
responses_json: actions,
updated_by: 'dashboard'
}
if (payload.trigger_type === 'event') {
payload.trigger_text = ''
} else {
payload.event_key = ''
}
const url = this.editing
? `/fun_command_rules/api/update/${this.editingRuleId}`
: '/fun_command_rules/api/create'
try {
const resp = await axios.post(url, 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) || '保存失败')
} catch (error) {
const msg = (error.response && error.response.data && error.response.data.message) || '保存失败'
this.$message.error(msg)
}
},
async removeRow(row) {
try {
await this.$confirm('确认删除该规则吗?', '提示', { type: 'warning' })
} catch (e) {
return
}
try {
const resp = await axios.post(`/fun_command_rules/api/delete/${row.id}`)
if (resp.data && resp.data.success) {
this.$message.success(resp.data.message || '删除成功')
await this.loadRows()
return
}
this.$message.error((resp.data && resp.data.message) || '删除失败')
} catch (error) {
const msg = (error.response && error.response.data && error.response.data.message) || '删除失败'
this.$message.error(msg)
}
},
async toggleEnabled(row, enabled) {
try {
const resp = await axios.post(`/fun_command_rules/api/toggle/${row.id}`, {
enabled: !!enabled,
updated_by: 'dashboard'
})
if (resp.data && resp.data.success) {
this.$message.success('状态已更新')
row.enabled = !!enabled
return
}
this.$message.error((resp.data && resp.data.message) || '状态更新失败')
} catch (error) {
const msg = (error.response && error.response.data && error.response.data.message) || '状态更新失败'
this.$message.error(msg)
}
row.enabled = !enabled
},
async testMatch() {
try {
const resp = await axios.post('/fun_command_rules/api/test_match', this.tester)
if (resp.data && resp.data.success) {
this.testResult = {
matched: !!resp.data.matched,
data: resp.data.data || null
}
return
}
this.$message.error((resp.data && resp.data.message) || '测试失败')
} catch (error) {
const msg = (error.response && error.response.data && error.response.data.message) || '测试失败'
this.$message.error(msg)
}
}
}
})
</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(12,148,93,.10), rgba(45,212,191,.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:#0f766e;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}
.trigger-box{display:flex;flex-direction:column;gap:4px}
.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}
.upload-row{display:flex;align-items:center}
.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>
{% endblock %}