Files
abot/admin/dashboard/templates/fun_command_rules.html
liuwei c22b4cf055 优化响应指令管理作用域选择交互,支持群/用户下拉搜索
变更项:

- 查询区作用域ID改为下拉选择:群聊与私聊均支持按名称搜索

- 新增私聊联系人数据加载逻辑,接入 /contacts/api/personal

- 新增作用域切换自动清空ID逻辑,避免群ID/用户ID串用

- 新增加载失败兜底提示,并统一展示 名称(wxid) 便于识别
2026-04-23 14:26:46 +08:00

679 lines
33 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-select
v-if="filters.scope_type === 'group'"
v-model="filters.scope_id"
filterable
clearable
placeholder="请选择群(支持名称搜索)"
style="width:320px"
@change="loadRows">
<el-option
v-for="item in groupOptions"
:key="item.wxid"
:label="item.name + ' (' + item.wxid + ')'"
:value="item.wxid">
</el-option>
</el-select>
<el-select
v-else-if="filters.scope_type === 'private'"
v-model="filters.scope_id"
filterable
clearable
placeholder="请选择用户(支持名称搜索)"
style="width:320px"
@change="loadRows">
<el-option
v-for="item in userOptions"
:key="item.wxid"
:label="item.name + ' (' + item.wxid + ')'"
:value="item.wxid">
</el-option>
</el-select>
<el-input
v-else
value=""
disabled
placeholder="全局作用域无需选择ID"
style="width:320px">
</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-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'">
<!-- 群聊作用域通过可搜索下拉选择群避免手填ID带来的维护成本。 -->
<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 + ' (' + item.wxid + ')'" :value="item.wxid"></el-option>
</el-select>
<!-- 私聊作用域:支持按备注/昵称检索,降低大规模规则库维护难度。 -->
<el-select
v-else
v-model="form.scope_id"
filterable
placeholder="请选择私聊用户(支持名称搜索)"
style="width:100%">
<el-option
v-for="item in userOptions"
:key="item.wxid"
:label="item.name + ' (' + item.wxid + ')'"
:value="item.wxid">
</el-option>
</el-select>
</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: [],
userOptions: [],
filters: { scope_type: '', scope_id: '', enabled: '' },
dialogVisible: false,
editing: false,
editingRuleId: 0,
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.loadUsers()
this.loadRows()
},
watch: {
'filters.scope_type'() {
// 切换筛选作用域后清空旧ID避免群ID/用户ID串用导致查询结果异常。
this.filters.scope_id = ''
},
'form.scope_type'() {
// 编辑表单中切换作用域类型时同步清空scope_id强制重新选择目标对象。
this.form.scope_id = ''
}
},
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() {
// 群聊数据用于“作用域”下拉选择,统一做名称兜底,确保没有昵称时仍可选。
try {
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) }))
} catch (error) {
this.groupOptions = []
this.$message.error('加载群列表失败,请稍后重试')
}
},
async loadUsers() {
// 私聊联系人数据用于“作用域”下拉选择,支持按名称搜索定位目标用户。
try {
const resp = await axios.get('/contacts/api/personal')
const users = (resp.data && resp.data.data && resp.data.data.personal) || {}
this.userOptions = Object.entries(users).map(([wxid, name]) => ({ wxid, name: String(name || wxid) }))
} catch (error) {
this.userOptions = []
this.$message.error('加载私聊联系人失败,请稍后重试')
}
},
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
}
}
})
</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 %}