变更项: - 查询区作用域ID改为下拉选择:群聊与私聊均支持按名称搜索 - 新增私聊联系人数据加载逻辑,接入 /contacts/api/personal - 新增作用域切换自动清空ID逻辑,避免群ID/用户ID串用 - 新增加载失败兜底提示,并统一展示 名称(wxid) 便于识别
679 lines
33 KiB
HTML
679 lines
33 KiB
HTML
{% 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 %}
|