AutoReplyRuleView.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. <template>
  2. <div class="app-page">
  3. <section class="glass-card section-card">
  4. <el-form :model="filters" inline class="filter-form">
  5. <el-form-item label="规则名称">
  6. <el-input v-model="filters.name" placeholder="搜索规则名称" clearable style="width:160px" @keyup.enter="loadData" />
  7. </el-form-item>
  8. <el-form-item label="触发类型">
  9. <el-select v-model="filters.triggerType" placeholder="全部" clearable style="width:130px">
  10. <el-option label="关键词" value="keyword" />
  11. <el-option label="意图识别" value="intent" />
  12. <el-option label="寒暄" value="greeting" />
  13. <el-option label="兜底回复" value="fallback" />
  14. </el-select>
  15. </el-form-item>
  16. <el-form-item label="状态">
  17. <el-select v-model="filters.status" placeholder="全部" clearable style="width:120px">
  18. <el-option label="已启用" value="enabled" />
  19. <el-option label="已禁用" value="disabled" />
  20. </el-select>
  21. </el-form-item>
  22. <el-form-item>
  23. <el-button type="primary" @click="loadData">查询</el-button>
  24. <el-button @click="resetFilters">重置</el-button>
  25. </el-form-item>
  26. </el-form>
  27. </section>
  28. <section class="glass-card section-card" style="padding:12px 24px">
  29. <div class="table-toolbar" style="margin-bottom:0">
  30. <div class="chip-list">
  31. <el-button type="primary" @click="openCreate">新建规则</el-button>
  32. <el-button type="success" @click="saveFlow">保存流程</el-button>
  33. </div>
  34. <el-button @click="loadData">刷新</el-button>
  35. </div>
  36. </section>
  37. <section class="glass-card section-card">
  38. <div class="rule-layout">
  39. <div class="rule-list">
  40. <div class="rule-list__header">
  41. <h3>规则列表</h3>
  42. <span class="rule-list__hint">拖拽调整优先级</span>
  43. </div>
  44. <div class="rule-items">
  45. <div
  46. v-for="(rule, index) in filteredRules"
  47. :key="rule.id"
  48. class="rule-item"
  49. :class="{ 'rule-item--active': currentRule?.id === rule.id, 'rule-item--disabled': rule.status === 'disabled' }"
  50. @click="selectRule(rule)"
  51. >
  52. <div class="rule-item__priority">
  53. <el-tag size="small" :type="index === 0 ? 'danger' : index < 3 ? 'warning' : 'info'">
  54. {{ index === 0 ? '最高' : `优先${index + 1}` }}
  55. </el-tag>
  56. </div>
  57. <div class="rule-item__info">
  58. <div class="rule-item__name">{{ rule.name }}</div>
  59. <div class="rule-item__meta">
  60. <el-tag size="small" :type="triggerTypeTag(rule.triggerType)">{{ triggerTypeLabel(rule.triggerType) }}</el-tag>
  61. <span class="rule-item__hit">命中 {{ rule.hitCount }} 次</span>
  62. </div>
  63. </div>
  64. <div class="rule-item__status">
  65. <el-switch
  66. :model-value="rule.status === 'enabled'"
  67. @change="toggleStatus(rule, $event)"
  68. @click.stop
  69. />
  70. </div>
  71. </div>
  72. </div>
  73. </div>
  74. <div class="rule-detail">
  75. <template v-if="currentRule">
  76. <div class="rule-detail__header">
  77. <h3>{{ isEdit ? '编辑规则' : '规则详情' }}</h3>
  78. <el-button v-if="!isEdit" type="primary" size="small" @click="isEdit = true">编辑</el-button>
  79. <el-button v-if="isEdit" size="small" @click="cancelEdit">取消</el-button>
  80. <el-button v-if="isEdit" type="success" size="small" @click="saveRule">保存</el-button>
  81. </div>
  82. <el-form :model="editForm" label-width="100px" class="rule-form">
  83. <el-form-item label="规则名称" required>
  84. <el-input v-model="editForm.name" :disabled="!isEdit" placeholder="请输入规则名称" />
  85. </el-form-item>
  86. <el-form-item label="触发类型" required>
  87. <el-select v-model="editForm.triggerType" :disabled="!isEdit" style="width:100%">
  88. <el-option label="关键词匹配" value="keyword" />
  89. <el-option label="意图识别" value="intent" />
  90. <el-option label="寒暄回复" value="greeting" />
  91. <el-option label="兜底回复" value="fallback" />
  92. </el-select>
  93. </el-form-item>
  94. <el-form-item label="匹配模式">
  95. <el-radio-group v-model="editForm.matchMode" :disabled="!isEdit">
  96. <el-radio label="contain">包含任意关键词</el-radio>
  97. <el-radio label="exact">完全匹配</el-radio>
  98. <el-radio label="regex">正则表达式</el-radio>
  99. </el-radio-group>
  100. </el-form-item>
  101. <el-form-item label="关键词" v-if="editForm.triggerType === 'keyword'" required>
  102. <el-select v-model="editForm.keywords" multiple filterable allow-create :disabled="!isEdit" placeholder="输入关键词后回车" style="width:100%">
  103. <el-option v-for="kw in editForm.keywords" :key="kw" :label="kw" :value="kw" />
  104. </el-select>
  105. </el-form-item>
  106. <el-form-item label="回复内容" required>
  107. <el-input v-model="editForm.responses[0]" type="textarea" :rows="4" :disabled="!isEdit" placeholder="请输入回复内容" />
  108. </el-form-item>
  109. <el-form-item label="多轮回复" v-if="editForm.responses.length > 1">
  110. <div class="multi-response">
  111. <div v-for="(resp, idx) in editForm.responses.slice(1)" :key="idx" class="multi-response__item">
  112. <span class="multi-response__label">追问{{ idx + 1 }}:</span>
  113. <el-input v-model="editForm.responses[idx + 1]" :disabled="!isEdit" />
  114. </div>
  115. </div>
  116. <el-button v-if="isEdit" size="small" @click="addResponse" style="margin-top:8px">添加追问</el-button>
  117. </el-form-item>
  118. <el-form-item label="统计信息">
  119. <div class="rule-stats">
  120. <div class="rule-stats__item">
  121. <span class="rule-stats__label">命中次数</span>
  122. <span class="rule-stats__value">{{ currentRule.hitCount }}</span>
  123. </div>
  124. <div class="rule-stats__item">
  125. <span class="rule-stats__label">准确率</span>
  126. <span class="rule-stats__value">{{ currentRule.accuracy }}%</span>
  127. </div>
  128. </div>
  129. </el-form-item>
  130. <el-form-item label="测试">
  131. <div class="test-section">
  132. <el-input v-model="testMessage" placeholder="输入测试消息" style="width:200px" />
  133. <el-button @click="testRule" :loading="testing">测试</el-button>
  134. </div>
  135. <div v-if="testResult" class="test-result">
  136. <span class="test-result__label">回复:</span>
  137. <span class="test-result__content">{{ testResult }}</span>
  138. </div>
  139. </el-form-item>
  140. </el-form>
  141. </template>
  142. <el-empty v-else description="请选择一条规则查看详情" />
  143. </div>
  144. </div>
  145. </section>
  146. <el-dialog v-model="dialogVisible" title="新建规则" width="600px">
  147. <el-form :model="form" label-width="100px">
  148. <el-form-item label="规则名称" required>
  149. <el-input v-model="form.name" placeholder="请输入规则名称" />
  150. </el-form-item>
  151. <el-form-item label="触发类型" required>
  152. <el-select v-model="form.triggerType" style="width:100%">
  153. <el-option label="关键词匹配" value="keyword" />
  154. <el-option label="意图识别" value="intent" />
  155. <el-option label="寒暄回复" value="greeting" />
  156. <el-option label="兜底回复" value="fallback" />
  157. </el-select>
  158. </el-form-item>
  159. <el-form-item label="匹配模式">
  160. <el-radio-group v-model="form.matchMode">
  161. <el-radio label="contain">包含任意关键词</el-radio>
  162. <el-radio label="exact">完全匹配</el-radio>
  163. <el-radio label="regex">正则表达式</el-radio>
  164. </el-radio-group>
  165. </el-form-item>
  166. <el-form-item label="关键词" v-if="form.triggerType === 'keyword'" required>
  167. <el-select v-model="form.keywords" multiple filterable allow-create placeholder="输入关键词后回车" style="width:100%">
  168. <el-option v-for="kw in form.keywords" :key="kw" :label="kw" :value="kw" />
  169. </el-select>
  170. </el-form-item>
  171. <el-form-item label="回复内容" required>
  172. <el-input v-model="form.responses[0]" type="textarea" :rows="3" placeholder="请输入回复内容" />
  173. </el-form-item>
  174. </el-form>
  175. <template #footer>
  176. <el-button @click="dialogVisible = false">取消</el-button>
  177. <el-button type="primary" @click="createRule">创建</el-button>
  178. </template>
  179. </el-dialog>
  180. </div>
  181. </template>
  182. <script setup lang="ts">
  183. import { ref, computed, onMounted } from 'vue';
  184. import { ElMessage } from 'element-plus';
  185. import type { AutoReplyRule } from '@/types/page';
  186. const loading = ref(false);
  187. const dialogVisible = ref(false);
  188. const isEdit = ref(false);
  189. const currentRule = ref<AutoReplyRule | null>(null);
  190. const testMessage = ref('');
  191. const testResult = ref('');
  192. const testing = ref(false);
  193. const filters = ref({ name: '', triggerType: '', status: '' });
  194. const rules = ref<AutoReplyRule[]>([
  195. { id: 'R001', name: '物流查询', priority: 1, triggerType: 'keyword', keywords: ['物流', '快递', '发货', '到哪了', '什么时候到'], matchMode: 'contain', responses: ['您好,您的订单已在运输中,预计2-3天送达。'], status: 'enabled', hitCount: 1567, accuracy: 94, createdAt: '2024-01-01', updatedAt: '2024-01-15' },
  196. { id: 'R002', name: '退换货咨询', priority: 2, triggerType: 'keyword', keywords: ['退货', '换货', '退款', '售后'], matchMode: 'contain', responses: ['本店支持7天无理由退货(定制商品除外)。请登录账号申请退货退款。'], status: 'enabled', hitCount: 892, accuracy: 91, createdAt: '2024-01-02', updatedAt: '2024-01-14' },
  197. { id: 'R003', name: '支付问题', priority: 3, triggerType: 'keyword', keywords: ['支付', '付款', '银行卡', '支付宝', '微信'], matchMode: 'contain', responses: ['我们支持支付宝、微信支付、银行卡等多种支付方式。'], status: 'enabled', hitCount: 654, accuracy: 88, createdAt: '2024-01-03', updatedAt: '2024-01-13' },
  198. { id: 'R004', name: '产品推荐', priority: 4, triggerType: 'intent', keywords: [], matchMode: 'contain', responses: ['根据您的需求,为您推荐以下热销商品...'], status: 'enabled', hitCount: 432, accuracy: 82, createdAt: '2024-01-05', updatedAt: '2024-01-12' },
  199. { id: 'R005', name: '问候寒暄', priority: 5, triggerType: 'greeting', keywords: ['你好', '您好', 'hi', 'hello', '在吗'], matchMode: 'contain', responses: ['您好!很高兴为您服务。请问有什么可以帮助您的?'], status: 'enabled', hitCount: 2341, accuracy: 96, createdAt: '2024-01-01', updatedAt: '2024-01-10' },
  200. { id: 'R006', name: '感谢回复', priority: 6, triggerType: 'greeting', keywords: ['谢谢', '感谢', '辛苦了'], matchMode: 'contain', responses: ['不客气!很高兴能帮到您,祝您购物愉快!'], status: 'enabled', hitCount: 1234, accuracy: 98, createdAt: '2024-01-01', updatedAt: '2024-01-08' },
  201. { id: 'R007', name: '未知问题兜底', priority: 99, triggerType: 'fallback', keywords: [], matchMode: 'contain', responses: ['抱歉,您的问题我未能理解。请问您可以详细描述一下吗?您也可以联系人工客服获得更专业的帮助。'], status: 'enabled', hitCount: 567, accuracy: 75, createdAt: '2024-01-01', updatedAt: '2024-01-05' },
  202. { id: 'R008', name: '优惠券咨询', priority: 7, triggerType: 'keyword', keywords: ['优惠', '优惠券', '打折', '促销'], matchMode: 'contain', responses: ['当前有满199减20的优惠券可领取,是否需要我帮您跳转领取?'], status: 'disabled', hitCount: 234, accuracy: 85, createdAt: '2024-01-06', updatedAt: '2024-01-07' }
  203. ]);
  204. const filteredRules = computed(() => {
  205. return rules.value.filter(rule => {
  206. if (filters.value.name && !rule.name.includes(filters.value.name)) return false;
  207. if (filters.value.triggerType && rule.triggerType !== filters.value.triggerType) return false;
  208. if (filters.value.status && rule.status !== filters.value.status) return false;
  209. return true;
  210. }).sort((a, b) => a.priority - b.priority);
  211. });
  212. const editForm = ref<Partial<AutoReplyRule>>({});
  213. const form = ref<Partial<AutoReplyRule>>({ keywords: [], responses: [''], matchMode: 'contain', triggerType: 'keyword' });
  214. const loadData = () => { loading.value = true; setTimeout(() => { loading.value = false; }, 300); };
  215. const resetFilters = () => { filters.value = { name: '', triggerType: '', status: '' }; };
  216. const triggerTypeLabel = (type: string) => {
  217. const map: Record<string, string> = { keyword: '关键词', intent: '意图', greeting: '寒暄', fallback: '兜底' };
  218. return map[type] || type;
  219. };
  220. const triggerTypeTag = (type: string) => {
  221. const map: Record<string, string> = { keyword: 'primary', intent: 'success', greeting: 'warning', fallback: 'info' };
  222. return map[type] || '';
  223. };
  224. const selectRule = (rule: AutoReplyRule) => {
  225. currentRule.value = rule;
  226. editForm.value = { ...rule, responses: [...rule.responses] };
  227. isEdit.value = false;
  228. testResult.value = '';
  229. };
  230. const toggleStatus = (rule: AutoReplyRule, val: boolean) => {
  231. rule.status = val ? 'enabled' : 'disabled';
  232. ElMessage.success(`已${val ? '启用' : '禁用'}`);
  233. };
  234. const openCreate = () => {
  235. form.value = { keywords: [], responses: [''], matchMode: 'contain', triggerType: 'keyword' };
  236. dialogVisible.value = true;
  237. };
  238. const createRule = () => {
  239. if (!form.value.name || !form.value.responses?.[0]) {
  240. ElMessage.warning('请填写必填项');
  241. return;
  242. }
  243. rules.value.push({
  244. id: 'R' + Date.now(),
  245. priority: rules.value.length + 1,
  246. hitCount: 0,
  247. accuracy: 0,
  248. status: 'enabled',
  249. createdAt: new Date().toISOString().split('T')[0],
  250. updatedAt: new Date().toISOString().split('T')[0],
  251. ...form.value
  252. } as AutoReplyRule);
  253. ElMessage.success('创建成功');
  254. dialogVisible.value = false;
  255. };
  256. const saveRule = () => {
  257. if (!editForm.value.name || !editForm.value.responses?.[0]) {
  258. ElMessage.warning('请填写必填项');
  259. return;
  260. }
  261. const idx = rules.value.findIndex(r => r.id === currentRule.value?.id);
  262. if (idx !== -1) {
  263. rules.value[idx] = { ...rules.value[idx], ...editForm.value, updatedAt: new Date().toISOString().split('T')[0] } as AutoReplyRule;
  264. currentRule.value = rules.value[idx];
  265. }
  266. isEdit.value = false;
  267. ElMessage.success('保存成功');
  268. };
  269. const cancelEdit = () => {
  270. if (currentRule.value) {
  271. editForm.value = { ...currentRule.value, responses: [...currentRule.value.responses] };
  272. }
  273. isEdit.value = false;
  274. };
  275. const addResponse = () => {
  276. if (!editForm.value.responses) editForm.value.responses = [];
  277. editForm.value.responses.push('');
  278. };
  279. const testRule = () => {
  280. if (!testMessage.value) {
  281. ElMessage.warning('请输入测试消息');
  282. return;
  283. }
  284. testing.value = true;
  285. setTimeout(() => {
  286. if (currentRule.value?.triggerType === 'fallback') {
  287. testResult.value = currentRule.value.responses[0];
  288. } else if (testMessage.value.includes('物流') || testMessage.value.includes('快递')) {
  289. testResult.value = rules.value[0].responses[0];
  290. } else if (testMessage.value.includes('你好') || testMessage.value.includes('您好')) {
  291. testResult.value = rules.value[4].responses[0];
  292. } else {
  293. testResult.value = rules.value.find(r => r.triggerType === 'fallback')?.responses[0] || '抱歉,未匹配到任何规则';
  294. }
  295. testing.value = false;
  296. }, 500);
  297. };
  298. const saveFlow = () => { ElMessage.success('流程保存成功'); };
  299. onMounted(() => {
  300. if (filteredRules.value.length > 0) {
  301. selectRule(filteredRules.value[0]);
  302. }
  303. });
  304. </script>
  305. <style scoped>
  306. .filter-form :deep(.el-form-item) { margin-bottom: 0; }
  307. .rule-layout {
  308. display: grid;
  309. grid-template-columns: 320px 1fr;
  310. gap: 16px;
  311. min-height: 600px;
  312. }
  313. .rule-list {
  314. background: #fafafa;
  315. border-radius: 12px;
  316. padding: 16px;
  317. }
  318. .rule-list__header {
  319. display: flex;
  320. justify-content: space-between;
  321. align-items: center;
  322. margin-bottom: 16px;
  323. }
  324. .rule-list__header h3 {
  325. margin: 0;
  326. font-size: 15px;
  327. font-weight: 600;
  328. }
  329. .rule-list__hint {
  330. font-size: 12px;
  331. color: #999;
  332. }
  333. .rule-items {
  334. display: flex;
  335. flex-direction: column;
  336. gap: 8px;
  337. }
  338. .rule-item {
  339. display: flex;
  340. align-items: center;
  341. gap: 12px;
  342. padding: 12px;
  343. background: #fff;
  344. border-radius: 10px;
  345. cursor: pointer;
  346. transition: all 0.2s;
  347. border: 2px solid transparent;
  348. }
  349. .rule-item:hover {
  350. box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  351. }
  352. .rule-item--active {
  353. border-color: var(--cb-primary);
  354. background: rgba(15, 118, 110, 0.05);
  355. }
  356. .rule-item--disabled {
  357. opacity: 0.6;
  358. }
  359. .rule-item__info {
  360. flex: 1;
  361. min-width: 0;
  362. }
  363. .rule-item__name {
  364. font-weight: 600;
  365. font-size: 14px;
  366. margin-bottom: 4px;
  367. }
  368. .rule-item__meta {
  369. display: flex;
  370. align-items: center;
  371. gap: 8px;
  372. }
  373. .rule-item__hit {
  374. font-size: 12px;
  375. color: #999;
  376. }
  377. .rule-detail {
  378. background: #fafafa;
  379. border-radius: 12px;
  380. padding: 16px;
  381. }
  382. .rule-detail__header {
  383. display: flex;
  384. justify-content: space-between;
  385. align-items: center;
  386. margin-bottom: 20px;
  387. }
  388. .rule-detail__header h3 {
  389. margin: 0;
  390. font-size: 15px;
  391. font-weight: 600;
  392. }
  393. .rule-form {
  394. max-width: 600px;
  395. }
  396. .multi-response__item {
  397. display: flex;
  398. align-items: center;
  399. gap: 8px;
  400. margin-bottom: 8px;
  401. }
  402. .multi-response__label {
  403. font-size: 13px;
  404. color: #666;
  405. white-space: nowrap;
  406. }
  407. .rule-stats {
  408. display: flex;
  409. gap: 24px;
  410. }
  411. .rule-stats__item {
  412. display: flex;
  413. flex-direction: column;
  414. gap: 4px;
  415. }
  416. .rule-stats__label {
  417. font-size: 12px;
  418. color: #999;
  419. }
  420. .rule-stats__value {
  421. font-size: 18px;
  422. font-weight: 600;
  423. color: var(--cb-primary);
  424. }
  425. .test-section {
  426. display: flex;
  427. gap: 8px;
  428. }
  429. .test-result {
  430. margin-top: 12px;
  431. padding: 12px;
  432. background: #fff;
  433. border-radius: 8px;
  434. }
  435. .test-result__label {
  436. font-size: 13px;
  437. color: #666;
  438. }
  439. .test-result__content {
  440. font-size: 14px;
  441. color: #333;
  442. }
  443. </style>